From 0c4786a297117154ebf968c7cb4aba94ed4f73eb Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Thu, 14 May 2026 16:49:33 +0300 Subject: [PATCH 1/3] chore(chart): drop redundant int() wrapper around cidrPrefixLen calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cidrPrefixLen (the engine-provided helper in pkg/engine/helm) already returns Go int. Two call sites in charts/talm/templates/_helpers.tpl wrapped it in Sprig's int(): - addresses_by_link, line 151 - default_addresses_by_gateway, line 564 The third caller (link_name_for_address, line 429) uses the bare form. Dropping the no-op wrappers brings all three sites into one style and removes the implicit suggestion that cidrPrefixLen might return a non-int. Existing engine contract tests round-trip the render byte-for- byte, so no new test is needed — a regression would surface as diff in the existing render-output fixtures. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- charts/talm/templates/_helpers.tpl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/charts/talm/templates/_helpers.tpl b/charts/talm/templates/_helpers.tpl index 57178a2d..b2ab6d54 100644 --- a/charts/talm/templates/_helpers.tpl +++ b/charts/talm/templates/_helpers.tpl @@ -148,7 +148,7 @@ in the COSI addresses table must not leak verbatim into the legacy v1.11 machine.network.interfaces[].addresses block. */ -}} {{- $address := .spec.address | toString }} -{{- $validCidr := ge (int (cidrPrefixLen $address)) 0 }} +{{- $validCidr := ge (cidrPrefixLen $address) 0 }} {{- if and (eq .spec.linkName $linkName) (eq .spec.family $family) (not (eq .spec.scope "host")) $validCidr }} {{- if not (hasPrefix (printf "%s/" $fipStr) $address) }} {{- $addresses = append $addresses $address }} @@ -561,7 +561,7 @@ vlans: apply with a less-informative error than the chart could give. */ -}} {{- $address := .spec.address | toString -}} -{{- $validCidr := ge (int (cidrPrefixLen $address)) 0 -}} +{{- $validCidr := ge (cidrPrefixLen $address) 0 -}} {{- if and (eq .spec.linkName $linkName) $hasScope (not $skip) $validCidr -}} {{- $addresses = append $addresses $address -}} {{- end -}} From c9a42124592ad2d578c193cd17a1e334f242f7c9 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Thu, 14 May 2026 16:53:58 +0300 Subject: [PATCH 2/3] fix(chart): legacy default_addresses_by_gateway drops scope=link / nowhere too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy v1.11 helper `talm.discovered.default_addresses_by_gateway` filtered only the `host` scope, while the v1.12 multi-doc sibling `talm.discovered.addresses_by_link` rejects the full `host` / `link` / `nowhere` triple (the kernel-managed scopes that should never land in node config). Real Talos COSI always sets scope=link for the 169.254/16 link-local range, so the divergence was latent — but a future COSI schema bump that produced link-local entries on the default-gateway-bearing link would have leaked them verbatim into the legacy `machine.network.interfaces[].addresses` block while the v1.12 path correctly dropped them. Hoist the same `$skipScopes := list "host" "link" "nowhere"` the v1.12 helper uses and apply `has (.spec.scope | toString) $skipScopes` symmetrically. The two helpers now share one scope-filter rule. Pinned by TestContract_NetworkLegacy_DefaultAddressesFilterLinkScope with a new linkScopedAddressOnDefaultGatewayLookup fixture — a realistic Hetzner topology with a link-scoped 169.254/16 sandwiched between two global-scope siblings on the default- route-bearing link. Asserts the link-scoped entry is absent from the rendered output AND a global-scope sibling on the same link is still present (the filter must be scope-selective, not "drop everything on this link"). Verified regression-pin: stashed the chart fix, test failed at the expected assertion; restored, test passed. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- charts/talm/templates/_helpers.tpl | 13 ++- docs/manual-test-plan.md | 18 ++++ pkg/engine/contract_network_multidoc_test.go | 28 ++++++ pkg/engine/render_test.go | 96 ++++++++++++++++++++ 4 files changed, 154 insertions(+), 1 deletion(-) diff --git a/charts/talm/templates/_helpers.tpl b/charts/talm/templates/_helpers.tpl index b2ab6d54..bf69e088 100644 --- a/charts/talm/templates/_helpers.tpl +++ b/charts/talm/templates/_helpers.tpl @@ -142,6 +142,17 @@ it cannot match a real CIDR prefix. */ -}} {{- $fipStr := $.Values.floatingIP | toString }} {{- $addresses := list }} +{{- /* Drop the same kernel-managed scopes addresses_by_link + rejects (host loopback, link-local, "nowhere"). Real Talos + COSI always sets scope=link for the 169.254/16 link-local + range, so a legacy v1.11 render that filtered only the + "host" scope was at risk of leaking link-local addresses + verbatim into machine.network.interfaces[].addresses on + the next COSI schema bump. Mirror the two helpers so a + future bump that produces new link-local entries on the + default-gateway-bearing link can't slip through one path + while being correctly rejected by the other. */ -}} +{{- $skipScopes := list "host" "link" "nowhere" }} {{- range (lookup "addresses" "" "").items }} {{- /* Filter malformed or future-format entries the same way addresses_by_link does (cidrPrefixLen >= 0). A corrupt entry @@ -149,7 +160,7 @@ legacy v1.11 machine.network.interfaces[].addresses block. */ -}} {{- $address := .spec.address | toString }} {{- $validCidr := ge (cidrPrefixLen $address) 0 }} -{{- if and (eq .spec.linkName $linkName) (eq .spec.family $family) (not (eq .spec.scope "host")) $validCidr }} +{{- if and (eq .spec.linkName $linkName) (eq .spec.family $family) (not (has (.spec.scope | toString) $skipScopes)) $validCidr }} {{- if not (hasPrefix (printf "%s/" $fipStr) $address) }} {{- $addresses = append $addresses $address }} {{- end }} diff --git a/docs/manual-test-plan.md b/docs/manual-test-plan.md index a266ae86..d4ef4788 100644 --- a/docs/manual-test-plan.md +++ b/docs/manual-test-plan.md @@ -228,6 +228,24 @@ When the local `charts/talm/` is older than the talm binary's embedded preset, ` **Regression anchor**: `template -I` is rewrite, not merge — verify by adding a `# my comment` line above the modeline in `nodes/node0.yaml`, running B4, and confirming the comment is GONE in the new body. If the comment survives, a behaviour change shipped (could be either an intentional new `--preserve-comments` flag or an undocumented merge mode — neither should appear silently). +### B6. Scope-filter symmetry across v1.11 and v1.12 renders + +Pin both schema renders dropping kernel-managed scopes (`host` / `link` / `nowhere`) from the COSI addresses table. On a node where 169.254/16 link-local addresses live on the default-gateway-bearing interface (Talos always sets `scope=link` for that range), assert: + +```bash +# Render the legacy v1.11 path first — explicit talosVersion pin: +sed -i.bak 's/^talosVersion: "v1.12"/talosVersion: "v1.11"/' Chart.yaml +talm template -f nodes/node0.yaml | grep -E "address: 169\.254" && echo "FAIL: link-scoped leaked into v1.11" || echo "OK v1.11" +mv Chart.yaml.bak Chart.yaml + +# Then render the v1.12 path (default): +talm template -f nodes/node0.yaml | grep -E "address: 169\.254" && echo "FAIL: link-scoped leaked into v1.12" || echo "OK v1.12" +``` + +Expected: both renders print "OK" — no `address: 169.254.…` lines anywhere in the rendered output. The two helpers (`talm.discovered.default_addresses_by_gateway` for v1.11 and `talm.discovered.addresses_by_link` for v1.12) share the same `$skipScopes := list "host" "link" "nowhere"` filter so a link-local address on the default-gateway link is dropped from both paths. + +Regression anchor: a regression that re-introduces the v1.11-only `host`-scope filter would let link-local addresses leak into the legacy `machine.network.interfaces[].addresses` block on a future COSI schema bump that emitted them on the default-gateway-bearing link. Pinned by `TestContract_NetworkLegacy_DefaultAddressesFilterLinkScope` with a `linkScopedAddressOnDefaultGatewayLookup` fixture, but the live render is still useful as a sanity check against real cluster discovery output. + ## C. Apply (auth path) This section is the smoke-test for the apply pipe itself; the per-gate matrix lives in **Section C-safety** below. diff --git a/pkg/engine/contract_network_multidoc_test.go b/pkg/engine/contract_network_multidoc_test.go index def51d9b..91902f14 100644 --- a/pkg/engine/contract_network_multidoc_test.go +++ b/pkg/engine/contract_network_multidoc_test.go @@ -1005,6 +1005,34 @@ func TestContract_NetworkLegacy_DefaultAddressesFilterMalformedCidr(t *testing.T } } +// Contract: legacy v1.11 default_addresses_by_gateway drops the +// same kernel-managed scopes addresses_by_link drops — host, +// link, nowhere. Without this symmetry a link-scoped 169.254/16 +// entry on the default-gateway-bearing link could land verbatim +// in machine.network.interfaces[].addresses on a future COSI +// schema bump that produced link-local entries the legacy path +// hadn't yet seen. Real Talos COSI always uses scope=link for +// 169.254/16, so this case is currently latent — but the two +// helpers must apply the same filter for symmetry and to stay +// reasonable to maintain together. +// +// Fixture: a link-scoped 169.254.1.5/16 entry on the default- +// route-bearing link sandwiched between two global-scope +// siblings. The render asserts the link-scoped address is +// absent from the rendered output while the global-scope +// sibling on the same link is present. +func TestContract_NetworkLegacy_DefaultAddressesFilterLinkScope(t *testing.T) { + out := renderChartTemplateWithLookup(t, cozystackChartPath, controlplaneTpl, linkScopedAddressOnDefaultGatewayLookup(), "v1.11") + + if strings.Contains(out, "169.254.1.5") { + t.Errorf("legacy v1.11: link-scoped 169.254/16 leaked into machine.network.interfaces[].addresses; default_addresses_by_gateway scope filter must drop scope=link (mirror addresses_by_link). got:\n%s", out) + } + + if !strings.Contains(out, "88.99.210.37") { + t.Errorf("legacy v1.11: global-scope sibling on the default-gateway link missing from rendered output — scope filter dropped valid addresses too. got:\n%s", out) + } +} + // Generic-chart mirrors of the four legacy fail-fast contracts above. func TestContract_NetworkLegacy_Generic_VIPFailsOnInvalidFloatingIP(t *testing.T) { diff --git a/pkg/engine/render_test.go b/pkg/engine/render_test.go index 4a2d615a..fba584f8 100644 --- a/pkg/engine/render_test.go +++ b/pkg/engine/render_test.go @@ -5749,6 +5749,102 @@ func malformedAddressEntryLookup() func(string, string, string) (map[string]any, } } +// linkScopedAddressOnDefaultGatewayLookup mirrors +// malformedAddressEntryLookup's IPv4 Hetzner topology but +// sandwiches a link-scoped 169.254/16 entry on the +// default-gateway-bearing link between two global-scope +// siblings. Used by +// TestContract_NetworkLegacy_DefaultAddressesFilterLinkScope to +// pin that the legacy v1.11 default_addresses_by_gateway helper +// drops scope=link the same way addresses_by_link does. Real +// Talos COSI always emits scope=link for 169.254/16, so the +// fixture reflects realistic on-the-wire shape. +func linkScopedAddressOnDefaultGatewayLookup() func(string, string, string) (map[string]any, error) { + publicNIC := map[string]any{ + "metadata": map[string]any{"id": "enp0s31f6"}, + "spec": map[string]any{ + "kind": "physical", + "index": 1, + "hardwareAddr": "aa:bb:cc:00:01:01", + "busPath": "pci-0000:00:1f.6", + "mtu": 1500, + }, + } + routesList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{ + map[string]any{ + "spec": map[string]any{ + "dst": "", + "gateway": "88.99.210.1", + "outLinkName": "enp0s31f6", + "family": "inet4", + "table": "main", + "priority": 100, + }, + }, + }, + } + linksList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{publicNIC}, + } + // Three entries on the default-gateway link: a global-scope + // global IP, a link-scoped 169.254 link-local that the new + // scope filter MUST drop, and another global-scope sibling + // so the test can assert the filter is scope-selective (it + // doesn't drop everything on the link). + addressesList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{ + map[string]any{"spec": map[string]any{"linkName": "enp0s31f6", "address": "88.99.210.37/26", "family": "inet4", "scope": "global"}}, + map[string]any{"spec": map[string]any{"linkName": "enp0s31f6", "address": "169.254.1.5/16", "family": "inet4", "scope": "link"}}, + map[string]any{"spec": map[string]any{"linkName": "enp0s31f6", "address": "88.99.210.38/26", "family": "inet4", "scope": "global"}}, + }, + } + nodeDefault := map[string]any{ + "spec": map[string]any{ + "addresses": []any{"88.99.210.37/26"}, + }, + } + resolvers := map[string]any{ + "spec": map[string]any{ + "dnsServers": []any{"8.8.8.8"}, + }, + } + + return func(resource, _, id string) (map[string]any, error) { + switch resource { + case "routes": + return routesList, nil + case "links": + if id == "enp0s31f6" { + return publicNIC, nil + } + if id == "" { + return linksList, nil + } + + return map[string]any{}, nil + case "addresses": + return addressesList, nil + case "nodeaddress": + if id == "default" { + return nodeDefault, nil + } + case "resolvers": + if id == "resolvers" { + return resolvers, nil + } + } + + return map[string]any{}, nil + } +} + // hetznerPublicNICWithPrivateIPv6VLANLookup is the IPv6-equivalent of // hetznerPublicNICWithPrivateVLANLookup. The same physical / VLAN // topology, but the private subnet is a /64 ULA and the VIP is an From c4a07a68f1a15463ab273de11f6471e8a3222e99 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Thu, 14 May 2026 16:59:18 +0300 Subject: [PATCH 3/3] refactor(chart): factor v1.12 multi-doc per-link body into shared talm helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit charts/cozystack/templates/_helpers.tpl and charts/generic/templates/_helpers.tpl each carried a ~309-line talos.config.network.multidoc body that was byte-identical modulo six lines of prose drift in one comment. Every change to the multi-doc renderer (BondConfig / VLANConfig / BridgeConfig emission, address scope filtering, longest-prefix VIP-link selection, malformed-CIDR guard, floatingIP type coercion) had to be applied twice. The mirror tests under TestContract_NetworkMultidoc_Generic_* and TestContract_NetworkLegacy_Generic_* caught drift heuristically but never structurally. Extracts the body into talm.config.network.multidoc on the shared talm library chart (already a subchart of both cozystack and generic via charts//charts/talm symlink). Both chart-specific talos.config.network.multidoc defines shrink to a one-line forward: {{- define "talos.config.network.multidoc" }} {{- include "talm.config.network.multidoc" . }} {{- end }} cozystack-specific RegistryMirrorConfig and the chart-specific talos.config.cluster / talos.config.machine.common defines stay where they are — only the per-link body moves. The Generic mirror tests stay as defensive coverage but become structural rather than load-bearing: a single source of truth for the per-link emission logic guarantees byte-identical behavior across both chart presets by construction. Net change: charts/cozystack/templates/_helpers.tpl: 571 -> 265 lines charts/generic/templates/_helpers.tpl: 482 -> 176 lines charts/talm/templates/_helpers.tpl: 702 -> 1019 lines Full engine test suite stays green; both cozystack and generic mirror tests pass byte-for-byte against the new shared define. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- charts/cozystack/templates/_helpers.tpl | 308 +---------------------- charts/generic/templates/_helpers.tpl | 308 +---------------------- charts/talm/templates/_helpers.tpl | 317 ++++++++++++++++++++++++ 3 files changed, 319 insertions(+), 614 deletions(-) diff --git a/charts/cozystack/templates/_helpers.tpl b/charts/cozystack/templates/_helpers.tpl index a51e3821..a744a5ee 100644 --- a/charts/cozystack/templates/_helpers.tpl +++ b/charts/cozystack/templates/_helpers.tpl @@ -161,313 +161,7 @@ cluster: {{- /* Shared network document generation for v1.12+ multi-doc format */ -}} {{- define "talos.config.network.multidoc" }} -{{- /* Multi-doc format reconstructs network config from discovery resources. - Every configurable link on the node (physical NIC, bond, VLAN, bridge) - gets its own document so a multi-NIC node ends up with all NICs - configured rather than only the gateway-bearing one. The gateway- - link's IPv4 default-route gateway is emitted only on that link's - document; every other link gets its addresses without a default route. - MTU is surfaced when discovery reports a value so non-default-MTU - links (jumbo frames, GRE) survive a re-render. - - existing_interfaces_configuration is not consulted here: v1.12 nodes - store network config in separate documents (LinkConfig, BondConfig, - VLANConfig), not in the legacy machine.network.interfaces field. The - guardrail below catches the upgrade case where a node was originally - bootstrapped on a chart that emitted the legacy schema and still - carries non-empty machine.network.interfaces[] in its running - MachineConfig — the renderer cannot translate those entries today - and would otherwise silently drop them on the next apply. */ -}} -{{- $legacyInterfaces := include "talm.discovered.existing_interfaces_configuration" . }} -{{- if $legacyInterfaces }} -{{- fail (printf "talm: the multi-doc renderer cannot translate legacy machine.network.interfaces[] from the running MachineConfig. Move the interfaces, vlans, and addresses below into per-node body overlays as v1.12 typed documents (LinkConfig, VLANConfig, BondConfig, RouteConfig) before re-running talm apply, or pin templateOptions.talosVersion to v1.11 in Chart.yaml until the translator lands.\n\nDetected legacy block:\n%s" $legacyInterfaces) }} -{{- end }} -{{- (include "talm.discovered.physical_links_info" .) }} ---- -apiVersion: v1alpha1 -kind: HostnameConfig -hostname: {{ include "talm.discovered.hostname" . | quote }} ---- -apiVersion: v1alpha1 -kind: ResolverConfig -nameservers: -{{- $resolvers := include "talm.discovered.default_resolvers" . }} -{{- if $resolvers }} -{{- range fromJsonArray $resolvers }} - - address: {{ . | quote }} -{{- end }} -{{- else }} - [] -{{- end }} -{{- /* Coerce .Values.floatingIP to its string form once at the - top of the multi-doc body and reuse the result everywhere - a downstream lookup or formatter needs it. The per-link - addresses_by_link strip on every link emission below - depends on the same stringified value — a worker render - with `floatingIP: 192168` would otherwise feed printf - "%s/" an int, producing `%!s(int=192168)/` that never - matches a CIDR. The coercion isolates that trap to one - place and lets the rest of the template treat the value - uniformly. - - "" is Sprig's serialisation of nil and "" is the - unset string; both mean "operator did not supply a - value". The shared talm.validate_floatingIP partial below - handles the actual fail-fast — invoke it here AND in the - legacy define so a malformed value fails at render time - regardless of the rendered Talos version. */}} -{{- $fipStr := .Values.floatingIP | toString }} -{{- $fipIsSet := and (ne $fipStr "") (ne $fipStr "") }} -{{- include "talm.validate_floatingIP" . }} -{{- /* Operator-declared vipLink override: emit Layer2VIPConfig - regardless of discovery state. Useful when the target link - does not yet exist on the live system at first apply (typical - case: a VLAN sub-interface this template is about to bring up). - The discovery-derived block below skips its own Layer2VIPConfig - when this branch fires, so we never emit duplicates. */}} -{{- if and $fipIsSet .Values.vipLink (eq .MachineType "controlplane") }} ---- -apiVersion: v1alpha1 -kind: Layer2VIPConfig -name: {{ $fipStr | quote }} -link: {{ .Values.vipLink }} -{{- end }} -{{- $defaultLinkName := include "talm.discovered.default_link_name_by_gateway" . }} -{{- $configurableLinks := fromJsonArray (include "talm.discovered.configurable_link_names" .) }} -{{- range $linkName := $configurableLinks }} -{{- $link := lookup "links" "" $linkName }} -{{- if $link }} -{{- $kind := $link.spec.kind | toString }} -{{- $isGatewayLink := eq $linkName $defaultLinkName }} -{{- $rawAddresses := fromJsonArray (include "talm.discovered.addresses_by_link" $linkName) }} -{{- /* Strip the operator-declared floatingIP from per-link addresses - so the VIP currently held by this leader does not leak into - LinkConfig.addresses. Talos's VIP operator installs the VIP - as a regular global-scope address indistinguishable from a - permanent one in COSI; without the filter, a re-render against - the VIP-active node would declare the VIP both as a permanent - address and as the Layer2VIPConfig target, putting the leader - and follower configs out of sync. */}} -{{- $addresses := list }} -{{- range $rawAddresses }} -{{- /* Use the hoisted $fipStr/$fipIsSet from the top of the - define so the strip honours the same coerced value the - validation block above used. Going through `printf "%s/" - $.Values.floatingIP` directly would emit - `%!s(int=192168)/` for a numeric YAML scalar on a worker - render (controlplane was caught by the fail-fast). */ -}} -{{- if not (and $fipIsSet (hasPrefix (printf "%s/" $fipStr) .)) }} -{{- $addresses = append $addresses . }} -{{- end }} -{{- end }} -{{- $linkGateway := "" }} -{{- if $isGatewayLink }} -{{- $linkGateway = include "talm.discovered.gateway_by_link" $linkName }} -{{- end }} -{{- if eq $kind "bridge" }} -{{- /* BridgeConfig emission. Discovers bridge ports (members) via - talm.discovered.bridge_slaves and emits a typed v1.12+ - BridgeConfig document with the same address / route / mtu - shape as the other branches. STP and VLAN filtering are - opt-in: they are emitted only when the bridge controller - reported a non-nil spec.bridgeMaster.stp / spec.bridgeMaster - value, so a default-state bridge stays minimal. */ -}} -{{- $bridgeMaster := $link.spec.bridgeMaster }} -{{- $bridgePorts := fromJsonArray (include "talm.discovered.bridge_slaves" $link.spec.index) }} ---- -apiVersion: v1alpha1 -kind: BridgeConfig -name: {{ $linkName }} -{{- if $bridgePorts }} -links: -{{- range $bridgePorts }} - - {{ . }} -{{- end }} -{{- end }} -{{- if $bridgeMaster }} -{{- if $bridgeMaster.stp }} -{{- if hasKey $bridgeMaster.stp "enabled" }} -stp: - enabled: {{ $bridgeMaster.stp.enabled }} -{{- end }} -{{- end }} -{{- /* COSI's BridgeVLANSpec serialises FilteringEnabled as - yaml:"filteringEnabled" (verified against - siderolabs/talos pkg/machinery/resources/network/link.go). - The output-side BridgeConfig schema uses the shorter - yaml:"filtering,omitempty" key — so we read the long form - from discovery and emit the short form into the rendered - document. */ -}} -{{- if $bridgeMaster.vlan }} -{{- if hasKey $bridgeMaster.vlan "filteringEnabled" }} -vlan: - filtering: {{ $bridgeMaster.vlan.filteringEnabled }} -{{- end }} -{{- end }} -{{- end }} -{{- if $addresses }} -addresses: -{{- range $addresses }} - - address: {{ . }} -{{- end }} -{{- end }} -{{- if $linkGateway }} -routes: - - gateway: {{ $linkGateway }} -{{- end }} -{{- if $link.spec.mtu }} -mtu: {{ $link.spec.mtu }} -{{- end }} -{{- else if eq $kind "bond" }} -{{- $bondMaster := $link.spec.bondMaster }} -{{- $slaves := fromJsonArray (include "talm.discovered.bond_slaves" $link.spec.index) }} -{{- if not $slaves }} -{{- /* Talos's link controller can auto-create a bond stub in COSI - link state before any operator configuration enslaves - physical NICs to it: the master link carries a bondMaster - spec but no other link has slaveKind: bond + masterIndex - pointing at it. Emitting BondConfig with empty `links:` - produces a document Talos rejects on apply ("at least one - link must be specified"). Treat empty-slaves as not-a- - user-bond and skip the document. An operator who intends - a bond must declare its slaves via a per-node body overlay; - until then the discovered stub is not promoted to config. */ -}} -{{- else }} ---- -apiVersion: v1alpha1 -kind: BondConfig -name: {{ $linkName }} -links: -{{- range $slaves }} - - {{ . }} -{{- end }} -{{- if $bondMaster }} -{{- if $bondMaster.mode }} -bondMode: {{ $bondMaster.mode }} -{{- end }} -{{- if $bondMaster.xmitHashPolicy }} -xmitHashPolicy: {{ $bondMaster.xmitHashPolicy }} -{{- end }} -{{- if $bondMaster.lacpRate }} -lacpRate: {{ $bondMaster.lacpRate }} -{{- end }} -{{- if $bondMaster.miimon }} -miimon: {{ $bondMaster.miimon }} -{{- end }} -{{- if $bondMaster.updelay }} -updelay: {{ $bondMaster.updelay }} -{{- end }} -{{- if $bondMaster.downdelay }} -downdelay: {{ $bondMaster.downdelay }} -{{- end }} -{{- end }} -{{- if $addresses }} -addresses: -{{- range $addresses }} - - address: {{ . }} -{{- end }} -{{- end }} -{{- if $linkGateway }} -routes: - - gateway: {{ $linkGateway }} -{{- end }} -{{- if $link.spec.mtu }} -mtu: {{ $link.spec.mtu }} -{{- end }} -{{- end }} -{{- else if eq $kind "vlan" }} -{{- $parentLinkName := include "talm.discovered.parent_link_name" $linkName }} -{{- $vlanID := include "talm.discovered.vlan_id" $linkName }} -{{- if not $parentLinkName }} -{{- /* VLANConfig requires the parent field on the wire. Emitting one - without it produces a document Talos rejects on apply. Treat the - partial-discovery case as fail-fast — a VLAN with an unresolvable - linkIndex is a discovery bug, not a config we can render. */ -}} -{{- fail (printf "talm: discovered VLAN %q has no resolvable parent link (spec.linkIndex points at a non-existent link). VLANConfig requires the parent field; refusing to emit an invalid document. Fix the discovery state or declare the VLAN explicitly via a per-node body overlay." $linkName) }} -{{- end }} -{{- if not $vlanID }} -{{- /* VLANConfig also requires vlanID. Symmetric guardrail to the - missing-parent case above — discovery without spec.vlan.vlanID - cannot produce a valid VLANConfig. */ -}} -{{- fail (printf "talm: discovered VLAN %q has no resolvable vlanID (spec.vlan.vlanID is unset). VLANConfig requires vlanID; refusing to emit an invalid document. Fix the discovery state or declare the VLAN explicitly via a per-node body overlay." $linkName) }} -{{- end }} ---- -apiVersion: v1alpha1 -kind: VLANConfig -name: {{ $linkName }} -vlanID: {{ $vlanID }} -parent: {{ $parentLinkName }} -{{- if $addresses }} -addresses: -{{- range $addresses }} - - address: {{ . }} -{{- end }} -{{- end }} -{{- if $linkGateway }} -routes: - - gateway: {{ $linkGateway }} -{{- end }} -{{- if $link.spec.mtu }} -mtu: {{ $link.spec.mtu }} -{{- end }} -{{- else }} ---- -apiVersion: v1alpha1 -kind: LinkConfig -name: {{ $linkName }} -{{- if $addresses }} -addresses: -{{- range $addresses }} - - address: {{ . }} -{{- end }} -{{- end }} -{{- if $linkGateway }} -routes: - - gateway: {{ $linkGateway }} -{{- end }} -{{- if $link.spec.mtu }} -mtu: {{ $link.spec.mtu }} -{{- end }} -{{- end }} -{{- end }} -{{- end }} -{{- /* Discovery-derived Layer2VIPConfig: skipped when the operator - has set .Values.vipLink, since the override-path block above - has already emitted the document with the operator's chosen - link. - - Link selection prefers the link whose discovered addresses - contain the floatingIP (talm.discovered.link_name_for_address), - so a VIP in a private subnet hosted on a VLAN child lands on - that VLAN — not on the IPv4-default-route NIC. The - default-gateway link stays as the fallback for topologies - where the VIP isn't on any discovered subnet (typical for - upstream-routable VIPs that arrive via the default-route - link). When neither resolves a link, no Layer2VIPConfig is - emitted, matching the prior behaviour. */}} -{{- if and $fipIsSet (not .Values.vipLink) (eq .MachineType "controlplane") }} -{{- $vipLink := include "talm.discovered.link_name_for_address" $fipStr }} -{{- /* Default-gateway fallback must also point at a configurable - link — otherwise an unmanaged default-route NIC (Wireguard, - a slave NIC of a bond, anything outside the configurable - set) would silently win selection and the rendered - Layer2VIPConfig would dangle on a link the chart never - emits a per-link document for. Mirror the same - configurable-link gate link_name_for_address applies inside - its own iteration. */ -}} -{{- if not $vipLink }} -{{- if has $defaultLinkName $configurableLinks }} -{{- $vipLink = $defaultLinkName }} -{{- end }} -{{- end }} -{{- if $vipLink }} ---- -apiVersion: v1alpha1 -kind: Layer2VIPConfig -name: {{ $fipStr | quote }} -link: {{ $vipLink }} -{{- end }} -{{- end }} +{{- include "talm.config.network.multidoc" . }} {{- end }} {{- /* Shared legacy network section for machine.network */ -}} diff --git a/charts/generic/templates/_helpers.tpl b/charts/generic/templates/_helpers.tpl index 9b7c29bf..008bc68d 100644 --- a/charts/generic/templates/_helpers.tpl +++ b/charts/generic/templates/_helpers.tpl @@ -85,314 +85,8 @@ cluster: {{- /* Shared network document generation for v1.12+ multi-doc format */ -}} {{- define "talos.config.network.multidoc" }} -{{- /* Multi-doc format reconstructs network config from discovery resources. - Every configurable link on the node (physical NIC, bond, VLAN, bridge) - gets its own document so a multi-NIC node ends up with all NICs - configured rather than only the gateway-bearing one. The gateway- - link's IPv4 default-route gateway is emitted only on that link's - document; every other link gets its addresses without a default route. - MTU is surfaced when discovery reports a value so non-default-MTU - links (jumbo frames, GRE) survive a re-render. - - existing_interfaces_configuration is not consulted here: v1.12 nodes - store network config in separate documents (LinkConfig, BondConfig, - VLANConfig), not in the legacy machine.network.interfaces field. The - guardrail below catches the upgrade case where a node was originally - bootstrapped on a chart that emitted the legacy schema and still - carries non-empty machine.network.interfaces[] in its running - MachineConfig — the renderer cannot translate those entries today - and would otherwise silently drop them on the next apply. */ -}} -{{- $legacyInterfaces := include "talm.discovered.existing_interfaces_configuration" . }} -{{- if $legacyInterfaces }} -{{- fail (printf "talm: the multi-doc renderer cannot translate legacy machine.network.interfaces[] from the running MachineConfig. Move the interfaces, vlans, and addresses below into per-node body overlays as v1.12 typed documents (LinkConfig, VLANConfig, BondConfig, RouteConfig) before re-running talm apply, or pin templateOptions.talosVersion to v1.11 in Chart.yaml until the translator lands.\n\nDetected legacy block:\n%s" $legacyInterfaces) }} -{{- end }} -{{- (include "talm.discovered.physical_links_info" .) }} ---- -apiVersion: v1alpha1 -kind: HostnameConfig -hostname: {{ include "talm.discovered.hostname" . | quote }} ---- -apiVersion: v1alpha1 -kind: ResolverConfig -nameservers: -{{- $resolvers := include "talm.discovered.default_resolvers" . }} -{{- if $resolvers }} -{{- range fromJsonArray $resolvers }} - - address: {{ . | quote }} -{{- end }} -{{- else }} - [] -{{- end }} -{{- /* Coerce .Values.floatingIP to its string form once at the - top of the multi-doc body and reuse the result everywhere - a downstream lookup or formatter needs it. The per-link - addresses_by_link strip on every link emission below - depends on the same stringified value — a worker render - with `floatingIP: 192168` would otherwise feed printf - "%s/" an int, producing `%!s(int=192168)/` that never - matches a CIDR. The coercion isolates that trap to one - place and lets the rest of the template treat the value - uniformly. - - "" is Sprig's serialisation of nil and "" is the - unset string; both mean "operator did not supply a - value". The shared talm.validate_floatingIP partial below - handles the actual fail-fast — invoke it here AND in the - legacy define so a malformed value fails at render time - regardless of the rendered Talos version. */}} -{{- $fipStr := .Values.floatingIP | toString }} -{{- $fipIsSet := and (ne $fipStr "") (ne $fipStr "") }} -{{- include "talm.validate_floatingIP" . }} -{{- /* Operator-declared vipLink override: emit Layer2VIPConfig - regardless of discovery state. Useful when the target link - does not yet exist on the live system at first apply (typical - case: a VLAN sub-interface this template is about to bring up). - The discovery-derived block below skips its own Layer2VIPConfig - when this branch fires, so we never emit duplicates. */}} -{{- if and $fipIsSet .Values.vipLink (eq .MachineType "controlplane") }} ---- -apiVersion: v1alpha1 -kind: Layer2VIPConfig -name: {{ $fipStr | quote }} -link: {{ .Values.vipLink }} -{{- end }} -{{- $defaultLinkName := include "talm.discovered.default_link_name_by_gateway" . }} -{{- $configurableLinks := fromJsonArray (include "talm.discovered.configurable_link_names" .) }} -{{- range $linkName := $configurableLinks }} -{{- $link := lookup "links" "" $linkName }} -{{- if $link }} -{{- $kind := $link.spec.kind | toString }} -{{- $isGatewayLink := eq $linkName $defaultLinkName }} -{{- $rawAddresses := fromJsonArray (include "talm.discovered.addresses_by_link" $linkName) }} -{{- /* Strip the operator-declared floatingIP from per-link addresses - so the VIP currently held by this leader does not leak into - LinkConfig.addresses. Talos's VIP operator installs the VIP - as a regular global-scope address indistinguishable from a - permanent one in COSI; without the filter, a re-render against - the VIP-active node would declare the VIP both as a permanent - address and as the Layer2VIPConfig target, putting the leader - and follower configs out of sync. */}} -{{- $addresses := list }} -{{- range $rawAddresses }} -{{- /* Use the hoisted $fipStr/$fipIsSet from the top of the - define so the strip honours the same coerced value the - validation block above used. Going through `printf "%s/" - $.Values.floatingIP` directly would emit - `%!s(int=192168)/` for a numeric YAML scalar on a worker - render (controlplane was caught by the fail-fast). */ -}} -{{- if not (and $fipIsSet (hasPrefix (printf "%s/" $fipStr) .)) }} -{{- $addresses = append $addresses . }} -{{- end }} -{{- end }} -{{- $linkGateway := "" }} -{{- if $isGatewayLink }} -{{- $linkGateway = include "talm.discovered.gateway_by_link" $linkName }} -{{- end }} -{{- if eq $kind "bridge" }} -{{- /* BridgeConfig emission. Discovers bridge ports (members) via - talm.discovered.bridge_slaves and emits a typed v1.12+ - BridgeConfig document with the same address / route / mtu - shape as the other branches. STP and VLAN filtering are - opt-in: they are emitted only when the bridge controller - reported a non-nil spec.bridgeMaster.stp / spec.bridgeMaster - value, so a default-state bridge stays minimal. */ -}} -{{- $bridgeMaster := $link.spec.bridgeMaster }} -{{- $bridgePorts := fromJsonArray (include "talm.discovered.bridge_slaves" $link.spec.index) }} ---- -apiVersion: v1alpha1 -kind: BridgeConfig -name: {{ $linkName }} -{{- if $bridgePorts }} -links: -{{- range $bridgePorts }} - - {{ . }} -{{- end }} -{{- end }} -{{- if $bridgeMaster }} -{{- if $bridgeMaster.stp }} -{{- if hasKey $bridgeMaster.stp "enabled" }} -stp: - enabled: {{ $bridgeMaster.stp.enabled }} -{{- end }} -{{- end }} -{{- /* COSI's BridgeVLANSpec serialises FilteringEnabled as - yaml:"filteringEnabled" (verified against - siderolabs/talos pkg/machinery/resources/network/link.go). - The output-side BridgeConfig schema uses the shorter - yaml:"filtering,omitempty" key — so we read the long form - from discovery and emit the short form into the rendered - document. */ -}} -{{- if $bridgeMaster.vlan }} -{{- if hasKey $bridgeMaster.vlan "filteringEnabled" }} -vlan: - filtering: {{ $bridgeMaster.vlan.filteringEnabled }} -{{- end }} -{{- end }} -{{- end }} -{{- if $addresses }} -addresses: -{{- range $addresses }} - - address: {{ . }} -{{- end }} -{{- end }} -{{- if $linkGateway }} -routes: - - gateway: {{ $linkGateway }} -{{- end }} -{{- if $link.spec.mtu }} -mtu: {{ $link.spec.mtu }} -{{- end }} -{{- else if eq $kind "bond" }} -{{- $bondMaster := $link.spec.bondMaster }} -{{- $slaves := fromJsonArray (include "talm.discovered.bond_slaves" $link.spec.index) }} -{{- if not $slaves }} -{{- /* Talos's link controller can auto-create a bond stub in COSI - link state before any operator configuration enslaves - physical NICs to it: the master link carries a bondMaster - spec but no other link has slaveKind: bond + masterIndex - pointing at it. Emitting BondConfig with empty `links:` - produces a document Talos rejects on apply ("at least one - link must be specified"). Treat empty-slaves as not-a- - user-bond and skip the document. An operator who intends - a bond must declare its slaves via a per-node body overlay; - until then the discovered stub is not promoted to config. */ -}} -{{- else }} ---- -apiVersion: v1alpha1 -kind: BondConfig -name: {{ $linkName }} -links: -{{- range $slaves }} - - {{ . }} -{{- end }} -{{- if $bondMaster }} -{{- if $bondMaster.mode }} -bondMode: {{ $bondMaster.mode }} -{{- end }} -{{- if $bondMaster.xmitHashPolicy }} -xmitHashPolicy: {{ $bondMaster.xmitHashPolicy }} -{{- end }} -{{- if $bondMaster.lacpRate }} -lacpRate: {{ $bondMaster.lacpRate }} -{{- end }} -{{- if $bondMaster.miimon }} -miimon: {{ $bondMaster.miimon }} +{{- include "talm.config.network.multidoc" . }} {{- end }} -{{- if $bondMaster.updelay }} -updelay: {{ $bondMaster.updelay }} -{{- end }} -{{- if $bondMaster.downdelay }} -downdelay: {{ $bondMaster.downdelay }} -{{- end }} -{{- end }} -{{- if $addresses }} -addresses: -{{- range $addresses }} - - address: {{ . }} -{{- end }} -{{- end }} -{{- if $linkGateway }} -routes: - - gateway: {{ $linkGateway }} -{{- end }} -{{- if $link.spec.mtu }} -mtu: {{ $link.spec.mtu }} -{{- end }} -{{- end }} -{{- else if eq $kind "vlan" }} -{{- $parentLinkName := include "talm.discovered.parent_link_name" $linkName }} -{{- $vlanID := include "talm.discovered.vlan_id" $linkName }} -{{- if not $parentLinkName }} -{{- /* VLANConfig requires the parent field on the wire. Emitting one - without it produces a document Talos rejects on apply. Treat the - partial-discovery case as fail-fast — a VLAN with an unresolvable - linkIndex is a discovery bug, not a config we can render. */ -}} -{{- fail (printf "talm: discovered VLAN %q has no resolvable parent link (spec.linkIndex points at a non-existent link). VLANConfig requires the parent field; refusing to emit an invalid document. Fix the discovery state or declare the VLAN explicitly via a per-node body overlay." $linkName) }} -{{- end }} -{{- if not $vlanID }} -{{- /* VLANConfig also requires vlanID. Symmetric guardrail to the - missing-parent case above — discovery without spec.vlan.vlanID - cannot produce a valid VLANConfig. */ -}} -{{- fail (printf "talm: discovered VLAN %q has no resolvable vlanID (spec.vlan.vlanID is unset). VLANConfig requires vlanID; refusing to emit an invalid document. Fix the discovery state or declare the VLAN explicitly via a per-node body overlay." $linkName) }} -{{- end }} ---- -apiVersion: v1alpha1 -kind: VLANConfig -name: {{ $linkName }} -vlanID: {{ $vlanID }} -parent: {{ $parentLinkName }} -{{- if $addresses }} -addresses: -{{- range $addresses }} - - address: {{ . }} -{{- end }} -{{- end }} -{{- if $linkGateway }} -routes: - - gateway: {{ $linkGateway }} -{{- end }} -{{- if $link.spec.mtu }} -mtu: {{ $link.spec.mtu }} -{{- end }} -{{- else }} ---- -apiVersion: v1alpha1 -kind: LinkConfig -name: {{ $linkName }} -{{- if $addresses }} -addresses: -{{- range $addresses }} - - address: {{ . }} -{{- end }} -{{- end }} -{{- if $linkGateway }} -routes: - - gateway: {{ $linkGateway }} -{{- end }} -{{- if $link.spec.mtu }} -mtu: {{ $link.spec.mtu }} -{{- end }} -{{- end }} -{{- end }} -{{- end }} -{{- /* Discovery-derived Layer2VIPConfig: skipped when the operator - has set .Values.vipLink, since the override-path block above - has already emitted the document with the operator's chosen - link. - - Link selection prefers the link whose discovered addresses - contain the floatingIP (talm.discovered.link_name_for_address), - so a VIP in a private subnet hosted on a VLAN child lands on - that VLAN — not on the IPv4-default-route NIC. The - default-gateway link stays as the fallback for topologies - where the VIP isn't on any discovered subnet (typical for - upstream-routable VIPs that arrive via the default-route - link). When neither resolves a link, no Layer2VIPConfig is - emitted, matching the prior behaviour. */}} -{{- if and $fipIsSet (not .Values.vipLink) (eq .MachineType "controlplane") }} -{{- $vipLink := include "talm.discovered.link_name_for_address" $fipStr }} -{{- /* Default-gateway fallback must also point at a configurable - link — otherwise an unmanaged default-route NIC (Wireguard, - a slave link) would silently win selection and the rendered - Layer2VIPConfig would dangle on a link the chart never emits - a per-link document for. Mirror the same configurable-link - gate link_name_for_address applies inside its own iteration. */ -}} -{{- if not $vipLink }} -{{- if has $defaultLinkName $configurableLinks }} -{{- $vipLink = $defaultLinkName }} -{{- end }} -{{- end }} -{{- if $vipLink }} ---- -apiVersion: v1alpha1 -kind: Layer2VIPConfig -name: {{ $fipStr | quote }} -link: {{ $vipLink }} -{{- end }} -{{- end }} -{{- end }} - -{{- /* Shared legacy network section for machine.network */ -}} {{- define "talos.config.network.legacy" }} {{- /* Coerce floatingIP through toString and call the shared talm.validate_floatingIP partial so legacy renders fail at diff --git a/charts/talm/templates/_helpers.tpl b/charts/talm/templates/_helpers.tpl index bf69e088..fa420154 100644 --- a/charts/talm/templates/_helpers.tpl +++ b/charts/talm/templates/_helpers.tpl @@ -700,3 +700,320 @@ busPath: {{ $link.spec.busPath }} {{- end -}} {{- $value -}} {{- end -}} + +{{- /* talm.config.network.multidoc reconstructs the v1.12+ multi-doc + network config from discovery resources. Single source of truth + used by both the cozystack and generic chart presets — each + chart's talos.config.network.multidoc forwards here via include. + Lives in the talm library chart because both charts include + talm as a subchart for discovery helpers, so the define is in + scope under one canonical name. */ -}} +{{- define "talm.config.network.multidoc" }} +{{- /* Multi-doc format reconstructs network config from discovery resources. + Every configurable link on the node (physical NIC, bond, VLAN, bridge) + gets its own document so a multi-NIC node ends up with all NICs + configured rather than only the gateway-bearing one. The gateway- + link's IPv4 default-route gateway is emitted only on that link's + document; every other link gets its addresses without a default route. + MTU is surfaced when discovery reports a value so non-default-MTU + links (jumbo frames, GRE) survive a re-render. + + existing_interfaces_configuration is not consulted here: v1.12 nodes + store network config in separate documents (LinkConfig, BondConfig, + VLANConfig), not in the legacy machine.network.interfaces field. The + guardrail below catches the upgrade case where a node was originally + bootstrapped on a chart that emitted the legacy schema and still + carries non-empty machine.network.interfaces[] in its running + MachineConfig — the renderer cannot translate those entries today + and would otherwise silently drop them on the next apply. */ -}} +{{- $legacyInterfaces := include "talm.discovered.existing_interfaces_configuration" . }} +{{- if $legacyInterfaces }} +{{- fail (printf "talm: the multi-doc renderer cannot translate legacy machine.network.interfaces[] from the running MachineConfig. Move the interfaces, vlans, and addresses below into per-node body overlays as v1.12 typed documents (LinkConfig, VLANConfig, BondConfig, RouteConfig) before re-running talm apply, or pin templateOptions.talosVersion to v1.11 in Chart.yaml until the translator lands.\n\nDetected legacy block:\n%s" $legacyInterfaces) }} +{{- end }} +{{- (include "talm.discovered.physical_links_info" .) }} +--- +apiVersion: v1alpha1 +kind: HostnameConfig +hostname: {{ include "talm.discovered.hostname" . | quote }} +--- +apiVersion: v1alpha1 +kind: ResolverConfig +nameservers: +{{- $resolvers := include "talm.discovered.default_resolvers" . }} +{{- if $resolvers }} +{{- range fromJsonArray $resolvers }} + - address: {{ . | quote }} +{{- end }} +{{- else }} + [] +{{- end }} +{{- /* Coerce .Values.floatingIP to its string form once at the + top of the multi-doc body and reuse the result everywhere + a downstream lookup or formatter needs it. The per-link + addresses_by_link strip on every link emission below + depends on the same stringified value — a worker render + with `floatingIP: 192168` would otherwise feed printf + "%s/" an int, producing `%!s(int=192168)/` that never + matches a CIDR. The coercion isolates that trap to one + place and lets the rest of the template treat the value + uniformly. + + "" is Sprig's serialisation of nil and "" is the + unset string; both mean "operator did not supply a + value". The shared talm.validate_floatingIP partial below + handles the actual fail-fast — invoke it here AND in the + legacy define so a malformed value fails at render time + regardless of the rendered Talos version. */}} +{{- $fipStr := .Values.floatingIP | toString }} +{{- $fipIsSet := and (ne $fipStr "") (ne $fipStr "") }} +{{- include "talm.validate_floatingIP" . }} +{{- /* Operator-declared vipLink override: emit Layer2VIPConfig + regardless of discovery state. Useful when the target link + does not yet exist on the live system at first apply (typical + case: a VLAN sub-interface this template is about to bring up). + The discovery-derived block below skips its own Layer2VIPConfig + when this branch fires, so we never emit duplicates. */}} +{{- if and $fipIsSet .Values.vipLink (eq .MachineType "controlplane") }} +--- +apiVersion: v1alpha1 +kind: Layer2VIPConfig +name: {{ $fipStr | quote }} +link: {{ .Values.vipLink }} +{{- end }} +{{- $defaultLinkName := include "talm.discovered.default_link_name_by_gateway" . }} +{{- $configurableLinks := fromJsonArray (include "talm.discovered.configurable_link_names" .) }} +{{- range $linkName := $configurableLinks }} +{{- $link := lookup "links" "" $linkName }} +{{- if $link }} +{{- $kind := $link.spec.kind | toString }} +{{- $isGatewayLink := eq $linkName $defaultLinkName }} +{{- $rawAddresses := fromJsonArray (include "talm.discovered.addresses_by_link" $linkName) }} +{{- /* Strip the operator-declared floatingIP from per-link addresses + so the VIP currently held by this leader does not leak into + LinkConfig.addresses. Talos's VIP operator installs the VIP + as a regular global-scope address indistinguishable from a + permanent one in COSI; without the filter, a re-render against + the VIP-active node would declare the VIP both as a permanent + address and as the Layer2VIPConfig target, putting the leader + and follower configs out of sync. */}} +{{- $addresses := list }} +{{- range $rawAddresses }} +{{- /* Use the hoisted $fipStr/$fipIsSet from the top of the + define so the strip honours the same coerced value the + validation block above used. Going through `printf "%s/" + $.Values.floatingIP` directly would emit + `%!s(int=192168)/` for a numeric YAML scalar on a worker + render (controlplane was caught by the fail-fast). */ -}} +{{- if not (and $fipIsSet (hasPrefix (printf "%s/" $fipStr) .)) }} +{{- $addresses = append $addresses . }} +{{- end }} +{{- end }} +{{- $linkGateway := "" }} +{{- if $isGatewayLink }} +{{- $linkGateway = include "talm.discovered.gateway_by_link" $linkName }} +{{- end }} +{{- if eq $kind "bridge" }} +{{- /* BridgeConfig emission. Discovers bridge ports (members) via + talm.discovered.bridge_slaves and emits a typed v1.12+ + BridgeConfig document with the same address / route / mtu + shape as the other branches. STP and VLAN filtering are + opt-in: they are emitted only when the bridge controller + reported a non-nil spec.bridgeMaster.stp / spec.bridgeMaster + value, so a default-state bridge stays minimal. */ -}} +{{- $bridgeMaster := $link.spec.bridgeMaster }} +{{- $bridgePorts := fromJsonArray (include "talm.discovered.bridge_slaves" $link.spec.index) }} +--- +apiVersion: v1alpha1 +kind: BridgeConfig +name: {{ $linkName }} +{{- if $bridgePorts }} +links: +{{- range $bridgePorts }} + - {{ . }} +{{- end }} +{{- end }} +{{- if $bridgeMaster }} +{{- if $bridgeMaster.stp }} +{{- if hasKey $bridgeMaster.stp "enabled" }} +stp: + enabled: {{ $bridgeMaster.stp.enabled }} +{{- end }} +{{- end }} +{{- /* COSI's BridgeVLANSpec serialises FilteringEnabled as + yaml:"filteringEnabled" (verified against + siderolabs/talos pkg/machinery/resources/network/link.go). + The output-side BridgeConfig schema uses the shorter + yaml:"filtering,omitempty" key — so we read the long form + from discovery and emit the short form into the rendered + document. */ -}} +{{- if $bridgeMaster.vlan }} +{{- if hasKey $bridgeMaster.vlan "filteringEnabled" }} +vlan: + filtering: {{ $bridgeMaster.vlan.filteringEnabled }} +{{- end }} +{{- end }} +{{- end }} +{{- if $addresses }} +addresses: +{{- range $addresses }} + - address: {{ . }} +{{- end }} +{{- end }} +{{- if $linkGateway }} +routes: + - gateway: {{ $linkGateway }} +{{- end }} +{{- if $link.spec.mtu }} +mtu: {{ $link.spec.mtu }} +{{- end }} +{{- else if eq $kind "bond" }} +{{- $bondMaster := $link.spec.bondMaster }} +{{- $slaves := fromJsonArray (include "talm.discovered.bond_slaves" $link.spec.index) }} +{{- if not $slaves }} +{{- /* Talos's link controller can auto-create a bond stub in COSI + link state before any operator configuration enslaves + physical NICs to it: the master link carries a bondMaster + spec but no other link has slaveKind: bond + masterIndex + pointing at it. Emitting BondConfig with empty `links:` + produces a document Talos rejects on apply ("at least one + link must be specified"). Treat empty-slaves as not-a- + user-bond and skip the document. An operator who intends + a bond must declare its slaves via a per-node body overlay; + until then the discovered stub is not promoted to config. */ -}} +{{- else }} +--- +apiVersion: v1alpha1 +kind: BondConfig +name: {{ $linkName }} +links: +{{- range $slaves }} + - {{ . }} +{{- end }} +{{- if $bondMaster }} +{{- if $bondMaster.mode }} +bondMode: {{ $bondMaster.mode }} +{{- end }} +{{- if $bondMaster.xmitHashPolicy }} +xmitHashPolicy: {{ $bondMaster.xmitHashPolicy }} +{{- end }} +{{- if $bondMaster.lacpRate }} +lacpRate: {{ $bondMaster.lacpRate }} +{{- end }} +{{- if $bondMaster.miimon }} +miimon: {{ $bondMaster.miimon }} +{{- end }} +{{- if $bondMaster.updelay }} +updelay: {{ $bondMaster.updelay }} +{{- end }} +{{- if $bondMaster.downdelay }} +downdelay: {{ $bondMaster.downdelay }} +{{- end }} +{{- end }} +{{- if $addresses }} +addresses: +{{- range $addresses }} + - address: {{ . }} +{{- end }} +{{- end }} +{{- if $linkGateway }} +routes: + - gateway: {{ $linkGateway }} +{{- end }} +{{- if $link.spec.mtu }} +mtu: {{ $link.spec.mtu }} +{{- end }} +{{- end }} +{{- else if eq $kind "vlan" }} +{{- $parentLinkName := include "talm.discovered.parent_link_name" $linkName }} +{{- $vlanID := include "talm.discovered.vlan_id" $linkName }} +{{- if not $parentLinkName }} +{{- /* VLANConfig requires the parent field on the wire. Emitting one + without it produces a document Talos rejects on apply. Treat the + partial-discovery case as fail-fast — a VLAN with an unresolvable + linkIndex is a discovery bug, not a config we can render. */ -}} +{{- fail (printf "talm: discovered VLAN %q has no resolvable parent link (spec.linkIndex points at a non-existent link). VLANConfig requires the parent field; refusing to emit an invalid document. Fix the discovery state or declare the VLAN explicitly via a per-node body overlay." $linkName) }} +{{- end }} +{{- if not $vlanID }} +{{- /* VLANConfig also requires vlanID. Symmetric guardrail to the + missing-parent case above — discovery without spec.vlan.vlanID + cannot produce a valid VLANConfig. */ -}} +{{- fail (printf "talm: discovered VLAN %q has no resolvable vlanID (spec.vlan.vlanID is unset). VLANConfig requires vlanID; refusing to emit an invalid document. Fix the discovery state or declare the VLAN explicitly via a per-node body overlay." $linkName) }} +{{- end }} +--- +apiVersion: v1alpha1 +kind: VLANConfig +name: {{ $linkName }} +vlanID: {{ $vlanID }} +parent: {{ $parentLinkName }} +{{- if $addresses }} +addresses: +{{- range $addresses }} + - address: {{ . }} +{{- end }} +{{- end }} +{{- if $linkGateway }} +routes: + - gateway: {{ $linkGateway }} +{{- end }} +{{- if $link.spec.mtu }} +mtu: {{ $link.spec.mtu }} +{{- end }} +{{- else }} +--- +apiVersion: v1alpha1 +kind: LinkConfig +name: {{ $linkName }} +{{- if $addresses }} +addresses: +{{- range $addresses }} + - address: {{ . }} +{{- end }} +{{- end }} +{{- if $linkGateway }} +routes: + - gateway: {{ $linkGateway }} +{{- end }} +{{- if $link.spec.mtu }} +mtu: {{ $link.spec.mtu }} +{{- end }} +{{- end }} +{{- end }} +{{- end }} +{{- /* Discovery-derived Layer2VIPConfig: skipped when the operator + has set .Values.vipLink, since the override-path block above + has already emitted the document with the operator's chosen + link. + + Link selection prefers the link whose discovered addresses + contain the floatingIP (talm.discovered.link_name_for_address), + so a VIP in a private subnet hosted on a VLAN child lands on + that VLAN — not on the IPv4-default-route NIC. The + default-gateway link stays as the fallback for topologies + where the VIP isn't on any discovered subnet (typical for + upstream-routable VIPs that arrive via the default-route + link). When neither resolves a link, no Layer2VIPConfig is + emitted, matching the prior behaviour. */}} +{{- if and $fipIsSet (not .Values.vipLink) (eq .MachineType "controlplane") }} +{{- $vipLink := include "talm.discovered.link_name_for_address" $fipStr }} +{{- /* Default-gateway fallback must also point at a configurable + link — otherwise an unmanaged default-route NIC (Wireguard, + a slave NIC of a bond, anything outside the configurable + set) would silently win selection and the rendered + Layer2VIPConfig would dangle on a link the chart never + emits a per-link document for. Mirror the same + configurable-link gate link_name_for_address applies inside + its own iteration. */ -}} +{{- if not $vipLink }} +{{- if has $defaultLinkName $configurableLinks }} +{{- $vipLink = $defaultLinkName }} +{{- end }} +{{- end }} +{{- if $vipLink }} +--- +apiVersion: v1alpha1 +kind: Layer2VIPConfig +name: {{ $fipStr | quote }} +link: {{ $vipLink }} +{{- end }} +{{- end }} +{{- end }}