From 9ac5eb8fd923896c291bb33a2f1b0b66c7b88940 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Thu, 7 May 2026 21:28:49 +0300 Subject: [PATCH 1/2] test(charts): pin multi-doc renderer coverage for multi-NIC, VLAN, MTU, bond, bridge, and legacy guardrail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add MultiDoc subtests covering the headline coverage gaps in the v1.12 multi-doc renderer: - TestMultiDocRendersAllConfigurableLinks: fixture with eth0 (routed) + eth1 (storage). Renderer must emit a LinkConfig per configurable link instead of stopping at the gateway-bearing one. - TestMultiDocLinkConfigCarriesMTU: same fixture with non-default MTU on eth1 (jumbo). LinkConfig must surface mtu so a re-render does not silently drop the value. - TestMultiDocEmitsVLANConfigForDiscoveredVLAN: fixture with eth0.4000 carrying the default route via a VLAN over eth0. The renderer must emit LinkConfig (parent eth0) + VLANConfig (eth0.4000 with parent + vlanID) and place the gateway on the VLAN. - TestMultiDocBondSlavesNotEmittedAsLinkConfig: fixture with two physical NICs enrolled into a bond master. Slaves must not appear as standalone LinkConfig documents alongside the master's BondConfig — Talos rejects the conflicting declarations. - TestMultiDocBridgeSkipsLinkConfigBranch: fixture with a discovered bridge link. Bridges must not fall through to LinkConfig (wrong document kind) — they are skipped until BridgeConfig support lands. - TestMultiDocBondAndVLANCarryMTU: pin that MTU surfaces on BondConfig and VLANConfig, not just LinkConfig. - TestMultiDocLayer2VIPLinkOnVLANDefaultGateway: pin that the discovery-derived Layer2VIPConfig.link points at the VLAN sub-interface when a VLAN carries the default route. - TestMultiDocBondConfigOmitsMissingBondMasterFields: pin graceful degradation when discovery returns a bond with partial bondMaster — the renderer must omit unset fields rather than emit bondMode: and break YAML. - TestMultiDocFailsOnLegacyInterfacesInRunningConfig: fixture with non-empty machine.network.interfaces[] in the running MachineConfig. The renderer must fail with a clear migration message rather than silently dropping the legacy block on first re-render after upgrade from a chart that emitted the legacy schema. Also update TestCozystackChartRendersIPv4GatewayOnDualStack's two-NIC subtest: with the multi-link renderer landing in the same change, eth1 must now appear as its own LinkConfig (gateway-less, IPv6-only addresses), not be silently dropped. The single-NIC subtest contract is unchanged. Six new fixtures support the new tests: multiNicLookup, multiNicWithVLANLookup, bondWithSlavesLookup, bondWithoutBondMasterLookup, bridgeLookup, legacyInterfacesInRunningConfigLookup. All fixtures return the resource map for per-id lookups (mirroring real discovery shape) so the slave-filter and bridge-skip branches hit production-like inputs. All new tests fail on the previous chart; the renderer rewrite in the follow-up commit makes them green. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- pkg/engine/render_test.go | 1363 ++++++++++++++++++++++++++++++++++++- 1 file changed, 1354 insertions(+), 9 deletions(-) diff --git a/pkg/engine/render_test.go b/pkg/engine/render_test.go index 615d901c..5a7a1803 100644 --- a/pkg/engine/render_test.go +++ b/pkg/engine/render_test.go @@ -1587,20 +1587,21 @@ func TestCozystackChartRendersIPv4GatewayOnDualStack(t *testing.T) { t.Run("two NIC dual-stack with IPv4 and IPv6 default routes on different links", func(t *testing.T) { // On Hetzner-like nodes the IPv4 default route and the IPv6 - // default route may terminate on different links. The chart - // must attach LinkConfig.name to the IPv4-default link (eth0) - // so the addresses/gateway it renders on the same document - // describe the same NIC. A missing family filter in any - // default_link_*_by_gateway helper would point name at eth1 - // (the IPv6-default link) while addresses/gateway describe - // eth0 — neither NIC ends up correctly configured. + // default route may terminate on different links. Both links + // must end up in the rendered config — eth0 (the IPv4-default + // link) carries the gateway since the chart's route-emit is + // IPv4-only, eth1 (the IPv6-default link) renders as a + // gateway-less LinkConfig. A missing family filter would route + // the gateway through eth1 instead of eth0; a single-link + // renderer would drop eth1 entirely (regression that + // TestMultiDocRendersAllConfigurableLinks pins separately). output := renderChartTemplateWithLookup(t, "../../charts/cozystack", "templates/controlplane.yaml", dualStackTwoNicsLookup(), "v1.12") if !strings.Contains(output, "name: eth0") { t.Errorf("expected LinkConfig name: eth0 (the IPv4-default link), got:\n%s", output) } - if strings.Contains(output, "name: eth1") { - t.Errorf("LinkConfig was attached to eth1 (the IPv6-default link), the rendered LinkConfig.addresses/gateway describe eth0:\n%s", output) + if !strings.Contains(output, "name: eth1") { + t.Errorf("expected LinkConfig name: eth1 (the IPv6-default link) — multi-doc renderer must emit a doc per configurable link:\n%s", output) } if !strings.Contains(output, "gateway: 192.168.201.1") { t.Errorf("expected rendered gateway 192.168.201.1, got:\n%s", output) @@ -1631,6 +1632,407 @@ func TestNetworkMultidoc_NoDiscovery(t *testing.T) { assertContains(t, output, "kind: ResolverConfig") } +// TestMultiDocRendersAllConfigurableLinks pins the contract that the +// v1.12 multi-doc renderer emits a LinkConfig for every configurable +// link on the node, not just the one that carries the default route. +// Today the renderer resolves a single $defaultLinkName via +// default_link_name_by_gateway and stops, so a node with a routed NIC +// plus a storage NIC ends up with the storage NIC silently +// unconfigured — discovery sees it but the rendered config does not +// describe it. +func TestMultiDocRendersAllConfigurableLinks(t *testing.T) { + output := renderChartTemplateWithLookup(t, "../../charts/cozystack", "templates/controlplane.yaml", multiNicLookup(), "v1.12") + + if !strings.Contains(output, "name: eth0") { + t.Errorf("expected LinkConfig name: eth0 (routed uplink), got:\n%s", output) + } + if !strings.Contains(output, "name: eth1") { + t.Errorf("expected LinkConfig name: eth1 (storage NIC) — multi-doc renderer dropped a configurable link, leaving it unconfigured:\n%s", output) + } + // Gateway lives only on the routed link's LinkConfig. + if !strings.Contains(output, "gateway: 192.168.201.1") { + t.Errorf("expected default gateway on eth0 LinkConfig, got:\n%s", output) + } + // Each link's addresses must come from its own discovery, not from + // the gateway link's addresses. + if !strings.Contains(output, "192.168.201.10/24") { + t.Errorf("expected eth0 address 192.168.201.10/24 in merged output:\n%s", output) + } + if !strings.Contains(output, "10.0.0.5/24") { + t.Errorf("expected eth1 address 10.0.0.5/24 (storage NIC) in merged output — by-link addresses were dropped:\n%s", output) + } +} + +// TestMultiDocEmitsVLANConfigForDiscoveredVLAN pins that the v1.12 +// multi-doc renderer emits a VLANConfig document for any VLAN +// sub-interface present on the node, regardless of whether the VLAN +// itself or its parent carries the default route. The previous +// single-link renderer emitted VLANConfig only when the VLAN was the +// gateway link; a VLAN on a non-gateway parent (storage VLAN, mgmt +// VLAN) was silently dropped. +func TestMultiDocEmitsVLANConfigForDiscoveredVLAN(t *testing.T) { + output := renderChartTemplateWithLookup(t, "../../charts/cozystack", "templates/controlplane.yaml", multiNicWithVLANLookup(), "v1.12") + + // Both LinkConfig (parent eth0) and VLANConfig (eth0.4000) must be emitted. + if !strings.Contains(output, "name: eth0") { + t.Errorf("expected LinkConfig name: eth0 (parent of the VLAN), got:\n%s", output) + } + if !strings.Contains(output, "kind: VLANConfig") { + t.Errorf("expected a VLANConfig document for the VLAN sub-interface, got:\n%s", output) + } + if !strings.Contains(output, "name: eth0.4000") { + t.Errorf("expected VLANConfig name: eth0.4000, got:\n%s", output) + } + if !strings.Contains(output, "vlanID: 4000") { + t.Errorf("expected vlanID: 4000 on the VLANConfig, got:\n%s", output) + } + if !strings.Contains(output, "parent: eth0") { + t.Errorf("expected parent: eth0 on the VLANConfig, got:\n%s", output) + } + if !strings.Contains(output, "192.168.100.2/24") { + t.Errorf("expected VLAN address 192.168.100.2/24 on the VLANConfig, got:\n%s", output) + } +} + +// TestMultiDocLinkConfigStripsFloatingIPFromAddresses pins that the +// configured floatingIP does not leak into LinkConfig.addresses on +// the link where the VIP is currently active. Talos's VIP operator +// installs the floating IP as a regular global-scope address on the +// link, indistinguishable from a permanent address in the COSI +// addresses resource — without filtering, a re-render against the +// VIP-active node ends up declaring the same IP both as a permanent +// address on LinkConfig and as the Layer2VIPConfig.link target, +// causing a config tug-of-war between leader and follower nodes. +func TestMultiDocLinkConfigStripsFloatingIPFromAddresses(t *testing.T) { + output := renderCozystackWith(t, vipActiveOnLinkLookup(), map[string]any{ + "floatingIP": "192.168.201.5", + }) + + // Permanent address must remain. + if !strings.Contains(output, "192.168.201.10/24") { + t.Errorf("expected permanent address 192.168.201.10/24 on LinkConfig, got:\n%s", output) + } + // floatingIP/32 must NOT appear under any LinkConfig.addresses. + if strings.Contains(output, "192.168.201.5/32") { + t.Errorf("floatingIP 192.168.201.5/32 leaked into LinkConfig.addresses; the VIP-bearing address must be filtered out so the re-render does not declare the VIP both as a permanent address and as the Layer2VIPConfig target:\n%s", output) + } + // Layer2VIPConfig must still be emitted. + if !strings.Contains(output, "kind: Layer2VIPConfig") { + t.Errorf("expected Layer2VIPConfig to still emit, got:\n%s", output) + } +} + +// TestMultiDocFailsWhenBridgeCarriesDefaultRoute pins the guardrail +// for the case where a discovered bridge is the IPv4-default link. +// The bridge branch of the renderer skips emission (BridgeConfig is +// not yet implemented), so without an explicit fail the rendered +// config would carry no document for the gateway-bearing link at +// all — silent drop of the entire network configuration. The +// guardrail surfaces the missing branch as a clear error pointing +// the operator at the per-node body workaround. +func TestMultiDocFailsWhenBridgeCarriesDefaultRoute(t *testing.T) { + origLookup := helmEngine.LookupFunc + t.Cleanup(func() { helmEngine.LookupFunc = origLookup }) + helmEngine.LookupFunc = bridgeWithGatewayLookup() + + chrt, err := loader.LoadDir("../../charts/cozystack") + if err != nil { + t.Fatalf("load chart: %v", err) + } + values := cloneValues(chrt.Values) + if v, _ := values["endpoint"].(string); v == "" { + values["endpoint"] = testEndpoint + } + if arr, ok := values["advertisedSubnets"].([]any); !ok || len(arr) == 0 { + values["advertisedSubnets"] = []any{testAdvertisedSubnet} + } + + eng := helmEngine.Engine{} + _, err = eng.Render(chrt, chartutil.Values{ + "Values": values, + "TalosVersion": "v1.12", + }) + if err == nil { + t.Fatal("expected render to fail when a bridge carries the IPv4 default route — silent drop of every network document for the gateway link is the regression this guardrail prevents") + } + if !strings.Contains(err.Error(), "bridge") { + t.Errorf("fail message must name the bridge so the operator can locate the offending link, got: %v", err) + } +} + +// TestMultiDocLinkConfigEmitsIPv6Addresses pins the dual-stack +// contract for LinkConfig.addresses: both IPv4 and IPv6 global-scope +// addresses on a link reach the rendered config. The chart's +// gateway helper is IPv4-only by convention, but addresses are not +// — a node with an IPv6 address on a NIC must keep that address in +// the rendered LinkConfig so a re-render does not silently drop the +// user's IPv6 configuration. addresses_by_link returns both +// families; the renderer passes them through after the +// floatingIP-strip filter. +func TestMultiDocLinkConfigEmitsIPv6Addresses(t *testing.T) { + output := renderChartTemplateWithLookup(t, "../../charts/cozystack", "templates/controlplane.yaml", dualStackTwoNicsLookup(), "v1.12") + + // eth1 has only an IPv6 global-scope address in this fixture. + // The renderer must emit it as a LinkConfig address — silently + // dropping the IPv6 entry would erase the user's network state + // on the storage NIC. + if !strings.Contains(output, "2001:db8::a/64") { + t.Errorf("expected IPv6 address 2001:db8::a/64 on eth1's LinkConfig — addresses_by_link returns both families and the renderer must surface them, got:\n%s", output) + } +} + +// TestMultiDocFailsWhenVLANHasNoVlanID pins the symmetric guardrail +// for the missing-vlanID case: VLANConfig requires both parent and +// vlanID on the wire. A VLAN discovered without a resolvable +// vlanID (partial discovery state) must surface a fail at template +// time, not silently emit a VLANConfig that Talos rejects on apply. +func TestMultiDocFailsWhenVLANHasNoVlanID(t *testing.T) { + origLookup := helmEngine.LookupFunc + t.Cleanup(func() { helmEngine.LookupFunc = origLookup }) + helmEngine.LookupFunc = vlanWithoutVlanIDLookup() + + chrt, err := loader.LoadDir("../../charts/cozystack") + if err != nil { + t.Fatalf("load chart: %v", err) + } + values := cloneValues(chrt.Values) + if v, _ := values["endpoint"].(string); v == "" { + values["endpoint"] = testEndpoint + } + if arr, ok := values["advertisedSubnets"].([]any); !ok || len(arr) == 0 { + values["advertisedSubnets"] = []any{testAdvertisedSubnet} + } + + eng := helmEngine.Engine{} + _, err = eng.Render(chrt, chartutil.Values{ + "Values": values, + "TalosVersion": "v1.12", + }) + if err == nil { + t.Fatal("expected render to fail when a VLAN link is missing vlanID — the field is required by Talos and an emitted VLANConfig without it is rejected on apply") + } + if !strings.Contains(err.Error(), "vlanID") { + t.Errorf("fail message must name vlanID so the operator knows what is missing, got: %v", err) + } +} + +// TestMultiDocLayer2VIPLinkOnBondDefaultGateway pins that the +// discovery-derived Layer2VIPConfig.link points at the bond master +// when the bond is the IPv4-default link. Mirror of the VLAN-as- +// default-gateway test; physical-as-default-gateway is covered by +// existing TestMultiDocCozystack_Layer2VIPConfigWhenFloatingIPSet. +func TestMultiDocLayer2VIPLinkOnBondDefaultGateway(t *testing.T) { + output := renderCozystackWith(t, bondWithSlavesLookup(), map[string]any{ + "floatingIP": "192.168.201.5", + }) + + if !strings.Contains(output, "kind: Layer2VIPConfig") { + t.Errorf("expected Layer2VIPConfig in the rendered output:\n%s", output) + } + if !strings.Contains(output, "link: bond0") { + t.Errorf("expected Layer2VIPConfig.link: bond0 (the bond master carrying the default route), got:\n%s", output) + } +} + +// TestMultiDocFailsWhenVLANHasNoParent pins that the renderer +// surfaces a clear error when a VLAN link discovered without a +// resolvable parent reaches the VLANConfig branch — Talos rejects a +// VLANConfig without the required parent field, and silently +// emitting one would surface the rejection on the apply RPC instead +// of at template time. +func TestMultiDocFailsWhenVLANHasNoParent(t *testing.T) { + origLookup := helmEngine.LookupFunc + t.Cleanup(func() { helmEngine.LookupFunc = origLookup }) + helmEngine.LookupFunc = vlanWithoutParentLookup() + + chrt, err := loader.LoadDir("../../charts/cozystack") + if err != nil { + t.Fatalf("load chart: %v", err) + } + values := cloneValues(chrt.Values) + if v, _ := values["endpoint"].(string); v == "" { + values["endpoint"] = testEndpoint + } + if arr, ok := values["advertisedSubnets"].([]any); !ok || len(arr) == 0 { + values["advertisedSubnets"] = []any{testAdvertisedSubnet} + } + + eng := helmEngine.Engine{} + _, err = eng.Render(chrt, chartutil.Values{ + "Values": values, + "TalosVersion": "v1.12", + }) + if err == nil { + t.Fatal("expected render to fail when a VLAN link is missing a resolvable parent — emitting VLANConfig without the required parent field is rejected by Talos at apply time") + } + if !strings.Contains(err.Error(), "parent") { + t.Errorf("fail message must name the missing parent link so the operator knows what to fix, got: %v", err) + } +} + +// TestMultiDocBondSlavesNotEmittedAsLinkConfig pins that bond slaves +// (physical NICs enrolled into a bond) do NOT get their own +// LinkConfig document alongside the master's BondConfig. A slave +// LinkConfig conflicts with the bond's claim on the link and Talos +// rejects the resulting configuration on controller convergence. +// configurable_link_names filters on spec.slaveKind to drop slaves +// from the iteration. +func TestMultiDocBondSlavesNotEmittedAsLinkConfig(t *testing.T) { + output := renderChartTemplateWithLookup(t, "../../charts/cozystack", "templates/controlplane.yaml", bondWithSlavesLookup(), "v1.12") + + if !strings.Contains(output, "kind: BondConfig") { + t.Errorf("expected BondConfig for the bond master, got:\n%s", output) + } + if !strings.Contains(output, "name: bond0") { + t.Errorf("expected BondConfig name: bond0, got:\n%s", output) + } + // Slaves must NOT appear as standalone LinkConfig — emitting them + // alongside the master's BondConfig produces conflicting + // declarations Talos rejects. + if strings.Contains(output, "name: eth0\n") || strings.Contains(output, "name: eth1\n") { + t.Errorf("bond slave eth0/eth1 emitted as standalone LinkConfig, conflicts with master's BondConfig:\n%s", output) + } +} + +// TestMultiDocBridgeSkipsLinkConfigBranch pins that a discovered +// bridge link does NOT fall through to the LinkConfig branch. The +// chart does not yet emit BridgeConfig, so the bridge must be +// skipped (rather than rendered as a wrong-kind LinkConfig that +// Talos would attach to the wrong interface semantics). Once a +// future change adds a BridgeConfig branch, the test gets updated +// to assert the new emission. +func TestMultiDocBridgeSkipsLinkConfigBranch(t *testing.T) { + output := renderChartTemplateWithLookup(t, "../../charts/cozystack", "templates/controlplane.yaml", bridgeLookup(), "v1.12") + + if strings.Contains(output, "kind: LinkConfig\nname: br0") { + t.Errorf("bridge br0 emitted as a LinkConfig — wrong document kind. Should be skipped until BridgeConfig support lands:\n%s", output) + } + // Routed physical NIC (eth0) still emits its own LinkConfig. + if !strings.Contains(output, "name: eth0") { + t.Errorf("expected LinkConfig for the routed physical eth0, got:\n%s", output) + } +} + +// TestMultiDocBondAndVLANCarryMTU pins that BondConfig and VLANConfig +// surface discovered MTU just like LinkConfig does. The renderer +// emits mtu on all three document kinds; without the test, a future +// change that drops the field on bond or VLAN would silently +// regress on jumbo-frame setups for those link kinds. +func TestMultiDocBondAndVLANCarryMTU(t *testing.T) { + bond := renderChartTemplateWithLookup(t, "../../charts/cozystack", "templates/controlplane.yaml", bondWithSlavesLookup(), "v1.12") + if !strings.Contains(bond, "mtu: 9000") { + t.Errorf("expected BondConfig mtu: 9000 on the jumbo-MTU bond fixture, got:\n%s", bond) + } + + vlan := renderChartTemplateWithLookup(t, "../../charts/cozystack", "templates/controlplane.yaml", multiNicWithVLANLookup(), "v1.12") + // The fixture sets parent eth0 mtu=1500 and the VLAN eth0.4000 + // mtu=1450 so the assertion targets the VLAN document, not the + // parent LinkConfig that also carries mtu. + if !strings.Contains(vlan, "mtu: 1450") { + t.Errorf("expected VLANConfig mtu: 1450 on the VLAN fixture (parent LinkConfig has mtu: 1500), got:\n%s", vlan) + } +} + +// TestMultiDocLayer2VIPLinkOnVLANDefaultGateway pins that the +// discovery-derived Layer2VIPConfig.link points at the VLAN +// sub-interface when the VLAN is the link carrying the default +// route. The single-link renderer set $vipLinkName = $defaultLinkName +// on the VLAN branch; the rewrite preserves that semantic by +// pointing the discovery VIP directly at $defaultLinkName, but no +// existing test pinned the VLAN-default-gateway case with floatingIP +// set. +func TestMultiDocLayer2VIPLinkOnVLANDefaultGateway(t *testing.T) { + output := renderCozystackWith(t, multiNicWithVLANLookup(), map[string]any{ + "floatingIP": "192.168.100.10", + }) + + if !strings.Contains(output, "kind: Layer2VIPConfig") { + t.Errorf("expected Layer2VIPConfig in the rendered output:\n%s", output) + } + if !strings.Contains(output, "link: eth0.4000") { + t.Errorf("expected Layer2VIPConfig.link: eth0.4000 (the VLAN sub-interface carrying the default route), got:\n%s", output) + } +} + +// TestMultiDocBondConfigOmitsMissingBondMasterFields pins the +// gracefully-degrading BondConfig render: when discovery returns a +// bond link with a partial or absent bondMaster, the renderer must +// omit fields that aren't set rather than emitting `bondMode: ` +// or similar broken YAML. The previous renderer unconditionally +// emitted bondMode and the surrounding fields, breaking on partial +// discovery state. +func TestMultiDocBondConfigOmitsMissingBondMasterFields(t *testing.T) { + output := renderChartTemplateWithLookup(t, "../../charts/cozystack", "templates/controlplane.yaml", bondWithoutBondMasterLookup(), "v1.12") + + if !strings.Contains(output, "kind: BondConfig") { + t.Errorf("expected BondConfig to still render despite missing bondMaster, got:\n%s", output) + } + if strings.Contains(output, "bondMode: ") || strings.Contains(output, "bondMode: \n") { + t.Errorf("BondConfig rendered bondMode with a nil/empty value, breaking YAML:\n%s", output) + } +} + +// TestMultiDocFailsOnLegacyInterfacesInRunningConfig pins the +// guardrail for clusters upgrading from a chart that emitted the +// legacy machine.network.interfaces[] schema to one that emits the +// v1.12 multi-doc form: the multi-doc renderer must surface a clear +// failure when the live MachineConfig still carries legacy interface +// declarations, because the renderer reconstructs network state from +// discovery resources and would otherwise silently drop the user's +// declarations. +// +// The fail directive points at the concrete migration path (move the +// legacy interfaces into the per-node body as multi-doc documents) +// instead of letting the user discover the silent drop in production. +func TestMultiDocFailsOnLegacyInterfacesInRunningConfig(t *testing.T) { + origLookup := helmEngine.LookupFunc + t.Cleanup(func() { helmEngine.LookupFunc = origLookup }) + helmEngine.LookupFunc = legacyInterfacesInRunningConfigLookup() + + chrt, err := loader.LoadDir("../../charts/cozystack") + if err != nil { + t.Fatalf("load chart: %v", err) + } + values := cloneValues(chrt.Values) + if v, _ := values["endpoint"].(string); v == "" { + values["endpoint"] = testEndpoint + } + if arr, ok := values["advertisedSubnets"].([]any); !ok || len(arr) == 0 { + values["advertisedSubnets"] = []any{testAdvertisedSubnet} + } + + eng := helmEngine.Engine{} + _, err = eng.Render(chrt, chartutil.Values{ + "Values": values, + "TalosVersion": "v1.12", + }) + if err == nil { + t.Fatal("expected render to fail when running config carries legacy machine.network.interfaces[] — silent drop of user's interfaces is the regression this guardrail is for") + } + if !strings.Contains(err.Error(), "machine.network.interfaces") { + t.Errorf("fail message must name the offending field so the user knows what to migrate; got: %v", err) + } +} + +// TestMultiDocLinkConfigCarriesMTU pins that LinkConfig (and +// VLANConfig / BondConfig where applicable) carries the link's MTU +// when discovery reports one. Today the multi-doc LinkConfig template +// emits only name / addresses / routes, so a node with a non-default +// MTU (jumbo frames, GRE tunnels, etc.) cannot be managed via the +// chart on v1.12 — the discovered MTU is silently dropped, and a user +// override in legacy `machine.network.interfaces[].mtu` does not +// translate either. +func TestMultiDocLinkConfigCarriesMTU(t *testing.T) { + output := renderChartTemplateWithLookup(t, "../../charts/cozystack", "templates/controlplane.yaml", multiNicLookup(), "v1.12") + + // eth0 has mtu: 1500 (default), eth1 has mtu: 9000 (jumbo). + // The renderer must surface eth1's non-default MTU on its + // LinkConfig. Default MTU rendering is allowed but not required. + if !strings.Contains(output, "mtu: 9000") { + t.Errorf("expected eth1 LinkConfig to carry mtu: 9000 (jumbo frames discovered on the storage NIC), got:\n%s", output) + } +} + // TestNetworkLegacy_NoDiscovery is a regression test for #58 covering the // legacy (Talos < v1.12) path. The `interfaces:` key was unconditionally // emitted, producing either an empty list or a `- interface:` block with an @@ -3814,6 +4216,949 @@ func dualStackNicLookup() func(string, string, string) (map[string]any, error) { } } +// multiNicLookup returns a lookup fixture for a node with two +// physical NICs configured for IPv4 only: eth0 carries the default +// route (the routed uplink) and eth1 has a static address but no +// default route (a storage / management network NIC). The shape +// mirrors the typical control-plane node with a dedicated cluster- +// internal network. Used to pin the multi-doc renderer's contract +// that EVERY configurable link gets a LinkConfig document, not just +// the gateway-bearing one. +func multiNicLookup() func(string, string, string) (map[string]any, error) { + eth0 := map[string]any{ + "metadata": map[string]any{"id": "eth0"}, + "spec": map[string]any{ + "kind": "physical", + "index": 1, + "hardwareAddr": "aa:bb:cc:00:00:01", + "busPath": "pci-0000:00:1f.0", + "mtu": 1500, + }, + } + eth1 := map[string]any{ + "metadata": map[string]any{"id": "eth1"}, + "spec": map[string]any{ + "kind": "physical", + "index": 2, + "hardwareAddr": "aa:bb:cc:00:00:02", + "busPath": "pci-0000:00:1f.1", + "mtu": 9000, + }, + } + routesList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{ + map[string]any{ + "spec": map[string]any{ + "dst": "", + "gateway": "192.168.201.1", + "outLinkName": "eth0", + "family": "inet4", + "table": "main", + "priority": 100, + }, + }, + map[string]any{ + "spec": map[string]any{ + "dst": "10.0.0.0/24", + "gateway": "10.0.0.1", + "outLinkName": "eth1", + "family": "inet4", + "table": "main", + "priority": 200, + }, + }, + }, + } + linksList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{eth0, eth1}, + } + addressesList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{ + map[string]any{"spec": map[string]any{"linkName": "eth0", "address": "192.168.201.10/24", "family": "inet4", "scope": "global"}}, + map[string]any{"spec": map[string]any{"linkName": "eth1", "address": "10.0.0.5/24", "family": "inet4", "scope": "global"}}, + }, + } + nodeDefault := map[string]any{ + "spec": map[string]any{ + "addresses": []any{"192.168.201.10/24"}, + }, + } + resolvers := map[string]any{ + "spec": map[string]any{ + "dnsServers": []any{"8.8.8.8"}, + }, + } + return func(resource, namespace, id string) (map[string]any, error) { + switch resource { + case "routes": + return routesList, nil + case "links": + switch id { + case "eth0": + return eth0, nil + case "eth1": + return eth1, nil + case "": + 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 + } +} + +// multiNicWithVLANLookup returns a lookup fixture for a node with a +// physical NIC eth0 and a VLAN sub-interface eth0.4000. The VLAN is +// the link that actually carries the IPv4 default route — common +// shape on bare-metal hosters where the management/cluster network +// rides a VLAN over the public NIC. The renderer must emit +// LinkConfig for eth0 (the parent), VLANConfig for eth0.4000 (the +// VLAN) with parent reference, and place the gateway on the VLAN. +func multiNicWithVLANLookup() func(string, string, string) (map[string]any, error) { + eth0 := map[string]any{ + "metadata": map[string]any{"id": "eth0"}, + "spec": map[string]any{ + "kind": "physical", + "index": 1, + "hardwareAddr": "aa:bb:cc:00:00:01", + "busPath": "pci-0000:00:1f.0", + "mtu": 1500, + }, + } + vlan := map[string]any{ + "metadata": map[string]any{"id": "eth0.4000"}, + "spec": map[string]any{ + "kind": "vlan", + "index": 2, + "linkIndex": 1, + "vlan": map[string]any{"vlanID": 4000}, + "mtu": 1450, + }, + } + routesList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{ + map[string]any{ + "spec": map[string]any{ + "dst": "", + "gateway": "192.168.100.1", + "outLinkName": "eth0.4000", + "family": "inet4", + "table": "main", + "priority": 100, + }, + }, + }, + } + linksList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{eth0, vlan}, + } + addressesList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{ + map[string]any{"spec": map[string]any{"linkName": "eth0", "address": "88.99.249.47/26", "family": "inet4", "scope": "global"}}, + map[string]any{"spec": map[string]any{"linkName": "eth0.4000", "address": "192.168.100.2/24", "family": "inet4", "scope": "global"}}, + }, + } + nodeDefault := map[string]any{ + "spec": map[string]any{ + "addresses": []any{"192.168.100.2/24"}, + }, + } + resolvers := map[string]any{ + "spec": map[string]any{ + "dnsServers": []any{"8.8.8.8"}, + }, + } + return func(resource, namespace, id string) (map[string]any, error) { + switch resource { + case "routes": + return routesList, nil + case "links": + switch id { + case "eth0": + return eth0, nil + case "eth0.4000": + return vlan, nil + case "": + 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 + } +} + +// legacyInterfacesInRunningConfigLookup returns a lookup fixture +// shaped like a node that was originally bootstrapped on a legacy +// chart (talosVersion v1.11) and carries non-empty +// machine.network.interfaces[] in its running MachineConfig. The +// multi-doc renderer must detect this and refuse to render rather +// than silently dropping the legacy interface block — otherwise an +// upgrade from chart v0.23 to v0.24+ would silently lose every +// user-declared address, route, and VLAN that lived under the +// legacy schema. +func legacyInterfacesInRunningConfigLookup() func(string, string, string) (map[string]any, error) { + eth0 := map[string]any{ + "metadata": map[string]any{"id": "eth0"}, + "spec": map[string]any{ + "kind": "physical", + "index": 1, + "hardwareAddr": "aa:bb:cc:00:00:01", + "busPath": "pci-0000:00:1f.0", + }, + } + routesList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{ + map[string]any{ + "spec": map[string]any{ + "dst": "", + "gateway": "192.168.201.1", + "outLinkName": "eth0", + "family": "inet4", + "table": "main", + }, + }, + }, + } + linksList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{eth0}, + } + addressesList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{ + map[string]any{"spec": map[string]any{"linkName": "eth0", "address": "192.168.201.10/24", "family": "inet4", "scope": "global"}}, + }, + } + machineConfig := map[string]any{ + "spec": map[string]any{ + "machine": map[string]any{ + "network": map[string]any{ + "interfaces": []any{ + map[string]any{ + "interface": "eth0", + "mtu": 9000, + "vlans": []any{ + map[string]any{"vlanId": 4000, "addresses": []any{"192.168.100.2/24"}}, + }, + }, + }, + }, + }, + }, + } + nodeDefault := map[string]any{ + "spec": map[string]any{ + "addresses": []any{"192.168.201.10/24"}, + }, + } + resolvers := map[string]any{ + "spec": map[string]any{ + "dnsServers": []any{"8.8.8.8"}, + }, + } + return func(resource, namespace, id string) (map[string]any, error) { + switch resource { + case "routes": + return routesList, nil + case "links": + if id == "eth0" { + return eth0, 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 + } + case "machineconfig": + if id == "v1alpha1" { + return machineConfig, nil + } + } + return map[string]any{}, nil + } +} + +// bondWithSlavesLookup returns a lookup fixture for a node where two +// physical NICs (eth0 + eth1) are enrolled into a bond master bond0. +// Mirrors the Talos representation: the slaves expose their busPath +// (so the regex matches them as "physical") AND have spec.slaveKind +// set ("bond"), which configurable_link_names uses to filter them +// out of the iteration. Without that filter the renderer would emit +// LinkConfig for each slave alongside the master's BondConfig and +// Talos would reject the conflicting declarations. +func bondWithSlavesLookup() func(string, string, string) (map[string]any, error) { + eth0 := map[string]any{ + "metadata": map[string]any{"id": "eth0"}, + "spec": map[string]any{ + "kind": "physical", + "index": 1, + "hardwareAddr": "aa:bb:cc:00:00:01", + "busPath": "pci-0000:00:1f.0", + "slaveKind": "bond", + "masterIndex": 3, + "mtu": 9000, + }, + } + eth1 := map[string]any{ + "metadata": map[string]any{"id": "eth1"}, + "spec": map[string]any{ + "kind": "physical", + "index": 2, + "hardwareAddr": "aa:bb:cc:00:00:02", + "busPath": "pci-0000:00:1f.1", + "slaveKind": "bond", + "masterIndex": 3, + "mtu": 9000, + }, + } + bond0 := map[string]any{ + "metadata": map[string]any{"id": "bond0"}, + "spec": map[string]any{ + "kind": "bond", + "index": 3, + "hardwareAddr": "aa:bb:cc:00:00:01", + "mtu": 9000, + "bondMaster": map[string]any{ + "mode": "802.3ad", + "xmitHashPolicy": "layer2+3", + "miimon": 100, + }, + }, + } + routesList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{ + map[string]any{ + "spec": map[string]any{ + "dst": "", + "gateway": "192.168.201.1", + "outLinkName": "bond0", + "family": "inet4", + "table": "main", + "priority": 100, + }, + }, + }, + } + linksList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{eth0, eth1, bond0}, + } + addressesList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{ + map[string]any{"spec": map[string]any{"linkName": "bond0", "address": "192.168.201.10/24", "family": "inet4", "scope": "global"}}, + }, + } + nodeDefault := map[string]any{ + "spec": map[string]any{ + "addresses": []any{"192.168.201.10/24"}, + }, + } + resolvers := map[string]any{ + "spec": map[string]any{ + "dnsServers": []any{"8.8.8.8"}, + }, + } + return func(resource, namespace, id string) (map[string]any, error) { + switch resource { + case "routes": + return routesList, nil + case "links": + switch id { + case "eth0": + return eth0, nil + case "eth1": + return eth1, nil + case "bond0": + return bond0, nil + case "": + 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 + } +} + +// bondWithoutBondMasterLookup returns a lookup fixture for a bond +// link where the bondMaster sub-resource is missing or partial +// (real Talos sometimes returns this on freshly-created bonds where +// the master controller hasn't filled the spec yet). The renderer +// must gate every BondMaster field on its presence so the rendered +// BondConfig stays valid YAML — without the gate, missing fields +// surfaced as `bondMode: ` and broke the parse. +func bondWithoutBondMasterLookup() func(string, string, string) (map[string]any, error) { + eth0 := map[string]any{ + "metadata": map[string]any{"id": "eth0"}, + "spec": map[string]any{ + "kind": "physical", + "index": 1, + "hardwareAddr": "aa:bb:cc:00:00:01", + "busPath": "pci-0000:00:1f.0", + "slaveKind": "bond", + "masterIndex": 2, + }, + } + bond0 := map[string]any{ + "metadata": map[string]any{"id": "bond0"}, + "spec": map[string]any{ + "kind": "bond", + "index": 2, + "hardwareAddr": "aa:bb:cc:00:00:01", + }, + } + routesList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{ + map[string]any{ + "spec": map[string]any{ + "dst": "", + "gateway": "192.168.201.1", + "outLinkName": "bond0", + "family": "inet4", + "table": "main", + }, + }, + }, + } + linksList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{eth0, bond0}, + } + addressesList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{ + map[string]any{"spec": map[string]any{"linkName": "bond0", "address": "192.168.201.10/24", "family": "inet4", "scope": "global"}}, + }, + } + nodeDefault := map[string]any{ + "spec": map[string]any{ + "addresses": []any{"192.168.201.10/24"}, + }, + } + resolvers := map[string]any{ + "spec": map[string]any{ + "dnsServers": []any{"8.8.8.8"}, + }, + } + return func(resource, namespace, id string) (map[string]any, error) { + switch resource { + case "routes": + return routesList, nil + case "links": + switch id { + case "eth0": + return eth0, nil + case "bond0": + return bond0, nil + case "": + 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 + } +} + +// bridgeLookup returns a lookup fixture for a node with a routed +// physical NIC eth0 plus a bridge br0. The renderer must emit +// LinkConfig for eth0 and SKIP the bridge entirely (until +// BridgeConfig support lands) rather than emit a wrong-kind +// LinkConfig name: br0. +func bridgeLookup() func(string, string, string) (map[string]any, error) { + eth0 := map[string]any{ + "metadata": map[string]any{"id": "eth0"}, + "spec": map[string]any{ + "kind": "physical", + "index": 1, + "hardwareAddr": "aa:bb:cc:00:00:01", + "busPath": "pci-0000:00:1f.0", + }, + } + br0 := map[string]any{ + "metadata": map[string]any{"id": "br0"}, + "spec": map[string]any{ + "kind": "bridge", + "index": 2, + }, + } + routesList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{ + map[string]any{ + "spec": map[string]any{ + "dst": "", + "gateway": "192.168.201.1", + "outLinkName": "eth0", + "family": "inet4", + "table": "main", + }, + }, + }, + } + linksList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{eth0, br0}, + } + addressesList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{ + map[string]any{"spec": map[string]any{"linkName": "eth0", "address": "192.168.201.10/24", "family": "inet4", "scope": "global"}}, + }, + } + nodeDefault := map[string]any{ + "spec": map[string]any{ + "addresses": []any{"192.168.201.10/24"}, + }, + } + resolvers := map[string]any{ + "spec": map[string]any{ + "dnsServers": []any{"8.8.8.8"}, + }, + } + return func(resource, namespace, id string) (map[string]any, error) { + switch resource { + case "routes": + return routesList, nil + case "links": + switch id { + case "eth0": + return eth0, nil + case "br0": + return br0, nil + case "": + 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 + } +} + +// vipActiveOnLinkLookup returns a lookup fixture for a node where +// the configured floatingIP is currently active on eth0 — discovery +// reports two global-scope addresses on the link: the permanent +// address and the VIP. The Talos VIP operator does not mark the VIP +// address with any distinguishing field, so the chart must filter +// it out by matching against the operator-declared floatingIP. +func vipActiveOnLinkLookup() func(string, string, string) (map[string]any, error) { + eth0 := map[string]any{ + "metadata": map[string]any{"id": "eth0"}, + "spec": map[string]any{ + "kind": "physical", + "index": 1, + "hardwareAddr": "aa:bb:cc:00:00:01", + "busPath": "pci-0000:00:1f.0", + }, + } + routesList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{ + map[string]any{ + "spec": map[string]any{ + "dst": "", + "gateway": "192.168.201.1", + "outLinkName": "eth0", + "family": "inet4", + "table": "main", + }, + }, + }, + } + linksList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{eth0}, + } + addressesList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{ + map[string]any{"spec": map[string]any{"linkName": "eth0", "address": "192.168.201.10/24", "family": "inet4", "scope": "global"}}, + map[string]any{"spec": map[string]any{"linkName": "eth0", "address": "192.168.201.5/32", "family": "inet4", "scope": "global"}}, + }, + } + nodeDefault := map[string]any{ + "spec": map[string]any{ + "addresses": []any{"192.168.201.10/24"}, + }, + } + resolvers := map[string]any{ + "spec": map[string]any{ + "dnsServers": []any{"8.8.8.8"}, + }, + } + return func(resource, namespace, id string) (map[string]any, error) { + switch resource { + case "routes": + return routesList, nil + case "links": + if id == "eth0" { + return eth0, 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 + } +} + +// bridgeWithGatewayLookup returns a lookup fixture where a discovered +// bridge br0 carries the IPv4 default route (typical shape: VMs sit +// behind br0, the bridge gets the host's address). The renderer +// cannot emit BridgeConfig today, so it must surface a fail rather +// than silently drop every network document for the gateway-bearing +// link. +func bridgeWithGatewayLookup() func(string, string, string) (map[string]any, error) { + br0 := map[string]any{ + "metadata": map[string]any{"id": "br0"}, + "spec": map[string]any{ + "kind": "bridge", + "index": 1, + }, + } + routesList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{ + map[string]any{ + "spec": map[string]any{ + "dst": "", + "gateway": "192.168.201.1", + "outLinkName": "br0", + "family": "inet4", + "table": "main", + }, + }, + }, + } + linksList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{br0}, + } + addressesList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{ + map[string]any{"spec": map[string]any{"linkName": "br0", "address": "192.168.201.10/24", "family": "inet4", "scope": "global"}}, + }, + } + nodeDefault := map[string]any{ + "spec": map[string]any{ + "addresses": []any{"192.168.201.10/24"}, + }, + } + resolvers := map[string]any{ + "spec": map[string]any{ + "dnsServers": []any{"8.8.8.8"}, + }, + } + return func(resource, namespace, id string) (map[string]any, error) { + switch resource { + case "routes": + return routesList, nil + case "links": + if id == "br0" { + return br0, 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 + } +} + +// vlanWithoutVlanIDLookup returns a lookup fixture where a VLAN +// link's spec.vlan.vlanID is unset (partial discovery state). The +// renderer must fail rather than emit a VLANConfig missing the +// required vlanID field. +func vlanWithoutVlanIDLookup() func(string, string, string) (map[string]any, error) { + eth0 := map[string]any{ + "metadata": map[string]any{"id": "eth0"}, + "spec": map[string]any{ + "kind": "physical", + "index": 1, + "hardwareAddr": "aa:bb:cc:00:00:01", + "busPath": "pci-0000:00:1f.0", + }, + } + vlan := map[string]any{ + "metadata": map[string]any{"id": "eth0.4000"}, + "spec": map[string]any{ + "kind": "vlan", + "index": 2, + "linkIndex": 1, + }, + } + routesList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{ + map[string]any{ + "spec": map[string]any{ + "dst": "", + "gateway": "192.168.201.1", + "outLinkName": "eth0", + "family": "inet4", + "table": "main", + }, + }, + }, + } + linksList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{eth0, vlan}, + } + addressesList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{ + map[string]any{"spec": map[string]any{"linkName": "eth0", "address": "192.168.201.10/24", "family": "inet4", "scope": "global"}}, + }, + } + nodeDefault := map[string]any{ + "spec": map[string]any{ + "addresses": []any{"192.168.201.10/24"}, + }, + } + resolvers := map[string]any{ + "spec": map[string]any{ + "dnsServers": []any{"8.8.8.8"}, + }, + } + return func(resource, namespace, id string) (map[string]any, error) { + switch resource { + case "routes": + return routesList, nil + case "links": + switch id { + case "eth0": + return eth0, nil + case "eth0.4000": + return vlan, nil + case "": + 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 + } +} + +// vlanWithoutParentLookup returns a lookup fixture where a VLAN +// link is discovered but its parent link cannot be resolved +// (linkIndex points at a non-existent index). VLANConfig requires +// the parent field on the wire — emitting one without it would be +// rejected by Talos at apply time. The renderer must surface a +// clear template-time error instead. +func vlanWithoutParentLookup() func(string, string, string) (map[string]any, error) { + eth0 := map[string]any{ + "metadata": map[string]any{"id": "eth0"}, + "spec": map[string]any{ + "kind": "physical", + "index": 1, + "hardwareAddr": "aa:bb:cc:00:00:01", + "busPath": "pci-0000:00:1f.0", + }, + } + // VLAN with linkIndex pointing at a non-existent parent. + vlan := map[string]any{ + "metadata": map[string]any{"id": "eth0.4000"}, + "spec": map[string]any{ + "kind": "vlan", + "index": 2, + "linkIndex": 99, + "vlan": map[string]any{"vlanID": 4000}, + }, + } + routesList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{ + map[string]any{ + "spec": map[string]any{ + "dst": "", + "gateway": "192.168.201.1", + "outLinkName": "eth0", + "family": "inet4", + "table": "main", + }, + }, + }, + } + linksList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{eth0, vlan}, + } + addressesList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{ + map[string]any{"spec": map[string]any{"linkName": "eth0", "address": "192.168.201.10/24", "family": "inet4", "scope": "global"}}, + }, + } + nodeDefault := map[string]any{ + "spec": map[string]any{ + "addresses": []any{"192.168.201.10/24"}, + }, + } + resolvers := map[string]any{ + "spec": map[string]any{ + "dnsServers": []any{"8.8.8.8"}, + }, + } + return func(resource, namespace, id string) (map[string]any, error) { + switch resource { + case "routes": + return routesList, nil + case "links": + switch id { + case "eth0": + return eth0, nil + case "eth0.4000": + return vlan, nil + case "": + 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 + } +} + // dualStackTwoNicsLookup returns a lookup fixture for a node with the // IPv4 and IPv6 default routes on DIFFERENT links — the multi-NIC // shape where the IPv4-only filter actually matters: a missing filter From e64dfd275c68b7cef8cc6faed402095c461e3a46 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Thu, 7 May 2026 21:29:11 +0300 Subject: [PATCH 2/2] fix(charts): rewrite v1.12 multi-doc network renderer for full link coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous multi-doc renderer resolved a single $defaultLinkName via talm.discovered.default_link_name_by_gateway and emitted exactly one network document for it: BondConfig, VLANConfig, or LinkConfig depending on the gateway link kind. Every other link on the node — secondary physical NIC, VLAN sub-interface on a non-gateway parent, additional bond — was silently dropped from the rendered config. Replace the single-link block with a range over talm.discovered.configurable_link_names, emitting the appropriate document per link kind (bond / vlan / physical). The IPv4 default-route gateway is placed only on the link that carries it; every other link gets addresses without a default route. MTU is surfaced on each document when discovery reports a value, so non-default-MTU links (jumbo frames, GRE) survive a re-render. Also harden three coverage edges that the simple range exposes: - Filter bond slaves out of configurable_link_names. Slaves match the physical-NIC regex but are members of a bond master via spec.slaveKind; emitting a standalone LinkConfig for a slave alongside the master's BondConfig produces conflicting declarations Talos rejects on controller convergence. - Skip kind=bridge in the renderer. The chart does not yet emit BridgeConfig, so falling through to LinkConfig would land a wrong-kind document. The branch is reserved for a future PR that adds bridge support. - Gate every BondMaster sub-field on its presence. A bond returned with a partial or absent bondMaster (real Talos shape on freshly- created bonds before the master controller fills the spec) used to render bondMode: and break the parse. Surface a hard-fail in the multi-doc renderer when the running MachineConfig still carries non-empty machine.network.interfaces[]. The renderer cannot translate those entries today, and silently dropping them on the first re-render after a chart upgrade from the legacy schema would erase user-declared interfaces, addresses, and VLANs. The fail message points the operator at the concrete migration path: move the interfaces into per-node body overlays as v1.12 typed documents, or pin templateOptions.talosVersion to v1.11 until the translator lands. README updated to describe the per-link emission, the bond-slave filter, and the upgrade-from-legacy guardrail with the fail message text and migration path. Together this closes the multi-doc renderer coverage gaps: single-link-only emission, dropped non-gateway VLAN sub-interfaces, silent loss of legacy machine.network.interfaces during upgrade, and missing MTU on every multi-doc document kind. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- README.md | 120 +++---------------- charts/cozystack/templates/_helpers.tpl | 151 ++++++++++++++++++------ charts/generic/templates/_helpers.tpl | 151 ++++++++++++++++++------ charts/talm/templates/_helpers.tpl | 29 ++++- 4 files changed, 265 insertions(+), 186 deletions(-) diff --git a/README.md b/README.md index bf2a5f3b..58d58a55 100644 --- a/README.md +++ b/README.md @@ -8,27 +8,15 @@ Talm is just like Helm, but for Talos Linux While developing Talm, we aimed to achieve the following goals: -- **Automatic Discovery**: In a bare-metal environment, each server may vary -slightly in aspects such as disks and network interfaces. -Talm enables discovery of node information, which is then used to generate patches. +- **Automatic Discovery**: In a bare-metal environment, each server may vary slightly in aspects such as disks and network interfaces. Talm enables discovery of node information, which is then used to generate patches. -- **Ease of Customization**: You can customize templates to create your unique -configuration based on your environment. The templates use the standard -Go templates syntax, enhanced with widely-known Helm templating logic. +- **Ease of Customization**: You can customize templates to create your unique configuration based on your environment. The templates use the standard Go templates syntax, enhanced with widely-known Helm templating logic. -- **GitOps Friendly**: The patches generated do not contain sensitive data, -allowing them to be stored in Git in an unencrypted, open format. For scenarios -requiring complete configurations, the `--full` option allows the obtain -a complete config that can be used for matchbox and other solutions. +- **GitOps Friendly**: The patches generated do not contain sensitive data, allowing them to be stored in Git in an unencrypted, open format. For scenarios requiring complete configurations, the `--full` option allows the obtain a complete config that can be used for matchbox and other solutions. -- **Simplicity of Use**: You no longer need to pass connection options for each -specific server; they are saved along with the templating results into -a separate file. This allows you to easily apply one or multiple files in batch -using a syntax similar to `kubectl apply -f node1.yaml -f node2.yaml`. +- **Simplicity of Use**: You no longer need to pass connection options for each specific server; they are saved along with the templating results into a separate file. This allows you to easily apply one or multiple files in batch using a syntax similar to `kubectl apply -f node1.yaml -f node2.yaml`. -- **Compatibility with talosctl**: We strive to maintain compatibility with the upstream -project in patches and configurations. The configurations you obtain can be used -with the official tools like talosctl and Omni. +- **Compatibility with talosctl**: We strive to maintain compatibility with the upstream project in patches and configurations. The configurations you obtain can be used with the official tools like talosctl and Omni. ## Installation @@ -52,14 +40,7 @@ curl -sSL https://github.com/cozystack/talm/raw/refs/heads/main/hack/install.sh ### Windows -Windows is supported. Download the `talm-windows-*.zip` archive from the -[releases page](https://github.com/cozystack/talm/releases/latest) and -extract `talm.exe`. On Windows, template paths passed to the `-t` / -`--template` flag accept either `\` or `/` separators, so -`-t templates\controlplane.yaml` and `-t templates/controlplane.yaml` -are equivalent. Other path flags (`--talosconfig`, `-f` / `--file`) -are delegated to the underlying OS file loader and follow standard -Windows path rules. +Windows is supported. Download the `talm-windows-*.zip` archive from the [releases page](https://github.com/cozystack/talm/releases/latest) and extract `talm.exe`. On Windows, template paths passed to the `-t` / `--template` flag accept either `\` or `/` separators, so `-t templates\controlplane.yaml` and `-t templates/controlplane.yaml` are equivalent. Other path flags (`--talosconfig`, `-f` / `--file`) are delegated to the underlying OS file loader and follow standard Windows path rules. ## Getting Started @@ -70,28 +51,7 @@ cd newcluster talm init -p cozystack -N myawesomecluster ``` -Edit `values.yaml` to set your cluster's control-plane endpoint. This -is the URL every node's kubelet and kube-proxy will dial. The chart -leaves it empty on purpose so a missed override fails loudly instead -of silently embedding a placeholder. For cozystack VIP setups set -`endpoint` and `floatingIP` together (same IP, single shared VIP); -for single-node clusters use that node's routable IP and leave -`floatingIP` blank; for multi-node with an external load balancer -use the LB URL and leave `floatingIP` blank. When the VIP must sit -on a link that does not yet exist on the live system at first apply -(typically a VLAN sub-interface), set `vipLink` to that link name — -the chart pins `Layer2VIPConfig.link` to it instead of the default- -gateway link that discovery would otherwise pick, and emits the -document even on a totally fresh node where no default-gateway link -has been discovered yet. The chart does not auto-emit a `LinkConfig` -or `VLANConfig` for the override link; the operator is responsible -for ensuring the link comes up, typically by adding a `LinkConfig` -or `VLANConfig` for that link to the per-node body overlay alongside -`vipLink`. Subnet-selector fields -(`kubelet.validSubnets`, `etcd.advertisedSubnets`) are derived -automatically from the node's default-gateway-bearing link, so no -override is needed unless you have a multi-homed node that requires -a specific subnet pinned. +Edit `values.yaml` to set your cluster's control-plane endpoint. This is the URL every node's kubelet and kube-proxy will dial. The chart leaves it empty on purpose so a missed override fails loudly instead of silently embedding a placeholder. For cozystack VIP setups set `endpoint` and `floatingIP` together (same IP, single shared VIP); for single-node clusters use that node's routable IP and leave `floatingIP` blank; for multi-node with an external load balancer use the LB URL and leave `floatingIP` blank. When the VIP must sit on a link that does not yet exist on the live system at first apply (typically a VLAN sub-interface), set `vipLink` to that link name — the chart pins `Layer2VIPConfig.link` to it instead of the default-gateway link that discovery would otherwise pick, and emits the document even on a totally fresh node where no default-gateway link has been discovered yet. The chart does not auto-emit a `LinkConfig` or `VLANConfig` for the override link; the operator is responsible for ensuring the link comes up, typically by adding a `LinkConfig` or `VLANConfig` for that link to the per-node body overlay alongside `vipLink`. Subnet-selector fields (`kubelet.validSubnets`, `etcd.advertisedSubnets`) are derived automatically from the node's default-gateway-bearing link, so no override is needed unless you have a multi-homed node that requires a specific subnet pinned. Boot Talos Linux node, let's say it has address `192.0.2.4`. Then: @@ -156,9 +116,7 @@ cluster: endpoint: https://192.0.2.4:6443 ``` -> **Note:** The output format depends on the Talos version configured in `Chart.yaml` (`templateOptions.talosVersion`) or via the `--talos-version` CLI flag. -> For Talos < v1.12, the output is a single YAML document with `machine.network` and `machine.registries` sections (as shown above). -> For Talos >= v1.12, the output uses the multi-document format with separate typed documents instead of the deprecated monolithic fields. `HostnameConfig`, `ResolverConfig` and a network interface document (`LinkConfig`, `BondConfig`, or `VLANConfig` — depending on topology) are always emitted; `Layer2VIPConfig` appears on controlplane nodes when `floatingIP` is set; `RegistryMirrorConfig` is emitted only by the cozystack chart. +> **Note:** The output format depends on the Talos version configured in `Chart.yaml` (`templateOptions.talosVersion`) or via the `--talos-version` CLI flag. For Talos < v1.12, the output is a single YAML document with `machine.network` and `machine.registries` sections (as shown above). For Talos >= v1.12, the output uses the multi-document format with separate typed documents instead of the deprecated monolithic fields. `HostnameConfig` and `ResolverConfig` are always emitted; one network interface document is emitted per configurable link on the node (`LinkConfig` for physical NICs, `BondConfig` for bond masters, `VLANConfig` for VLAN sub-interfaces) — multi-NIC nodes therefore produce one document per NIC, not one document total. The link carrying the IPv4 default route gets the gateway entry on its document; every other link is emitted gateway-less. Both IPv4 and IPv6 global-scope addresses on a link are surfaced in its document. Bond slaves are filtered out so they do not collide with the master's `BondConfig`. Bridges are deliberately not auto-emitted as `BridgeConfig` yet — a non-gateway bridge is skipped (declare it via a per-node body overlay if needed); a bridge that carries the default route fails the render with a clear migration hint. The operator-declared `floatingIP` is stripped from per-link addresses so the VIP currently held by a leader does not leak into the static `LinkConfig`. `Layer2VIPConfig` appears on controlplane nodes when `floatingIP` is set; `RegistryMirrorConfig` is emitted only by the cozystack chart. > **Version compatibility (`templateOptions.talosVersion` / `--talos-version`).** This setting must match the **Talos version actually running on the target node** — i.e. the maintenance ISO/PXE the node booted from for `apply -i`, or the installed Talos for an authenticated apply. It is **not** the same as `install.image`, which only controls what gets written to disk after a successful apply. When the configured contract is newer than the running binary, machinery injects fields (e.g. `machine.install.grubUseUKICmdline` from v1.12) that the running parser does not know, and the apply fails on the node side with `failed to parse config: unknown keys found during decoding: ...`. `talm apply` runs a best-effort pre-flight check against the running version and prints a `warning: pre-flight: ...` line with a hint when it detects this mismatch; if the warning is missed, the same hint is appended to the apply error. Either reboot the node into a maintenance image that matches the configured contract, or lower `templateOptions.talosVersion` / `--talos-version` to match what is running. @@ -182,67 +140,21 @@ Re-template and update generated file in place (this will overwrite it): talm template -f nodes/node1.yaml -I ``` -> **Per-node patches inside node files.** A node file can carry Talos config -> below its modeline (for example, a custom `hostname`, secondary -> interfaces with `deviceSelector`, VIP placement, or extra etcd args). -> When `talm apply -f node.yaml` runs the template-rendering branch, that -> body is applied as a strategic merge patch on top of the rendered -> template before the result is sent to the node — so per-node fields -> survive even when the template auto-generates conflicting values -> (e.g. `hostname: talos-XXXXX`). +> **Per-node patches inside node files.** A node file can carry Talos config below its modeline (for example, a custom `hostname`, secondary interfaces with `deviceSelector`, VIP placement, or extra etcd args). When `talm apply -f node.yaml` runs the template-rendering branch, that body is applied as a strategic merge patch on top of the rendered template before the result is sent to the node — so per-node fields survive even when the template auto-generates conflicting values (e.g. `hostname: talos-XXXXX`). > -> **Talos v1.12+ caveat.** The multi-document output format introduced -> in v1.12 splits network configuration into typed documents -> (`LinkConfig`, `BondConfig`, `VLANConfig`, `Layer2VIPConfig`, -> `HostnameConfig`, `ResolverConfig`). Legacy node-body fields under -> `machine.network.interfaces` have no safe 1:1 mapping to those types, -> so the multi-doc path does not translate them — if you target a -> v1.12+ Talos node, pin per-node network settings by patching the -> typed resources (e.g. a `LinkConfig` document below the modeline) -> rather than legacy `machine.network.interfaces`. Fields outside the -> network area (`machine.network.hostname` via `HostnameConfig`, -> `machine.install.disk`, extra etcd args, etc.) still merge as -> expected. +> **Talos v1.12+ caveat.** The multi-document output format introduced in v1.12 splits network configuration into typed documents (`LinkConfig`, `BondConfig`, `VLANConfig`, `Layer2VIPConfig`, `HostnameConfig`, `ResolverConfig`). Legacy node-body fields under `machine.network.interfaces` have no safe 1:1 mapping to those types and the chart cannot translate them yet — pin per-node network settings by patching the typed resources (e.g. a `LinkConfig` document below the modeline) rather than legacy `machine.network.interfaces`. Fields outside the network area (`machine.network.hostname` via `HostnameConfig`, `machine.install.disk`, extra etcd args, etc.) still merge as expected. > -> **One body, one node.** A non-empty body is a per-node pin, so the -> modeline for that file must target exactly one node. `talm apply` -> refuses a multi-node modeline when the body is non-empty; modeline- -> only files (no body) are still allowed and drive the same rendered -> template on every listed target. +> **Upgrade-from-legacy guardrail.** Nodes originally bootstrapped on a chart that emitted the legacy schema still carry `machine.network.interfaces[]` in their running `MachineConfig`. On v1.12 multi-doc rendering the chart cannot reconstruct equivalent typed documents from those entries automatically, and silently dropping them on the next apply would erase the user's network declarations. The renderer therefore fails the render with `talm: the multi-doc renderer cannot translate legacy machine.network.interfaces[] from the running MachineConfig...`, spelling out the migration path: move the interfaces, vlans, and addresses into per-node body overlays as v1.12 typed documents (`LinkConfig`, `VLANConfig`, `BondConfig`, `RouteConfig`) before re-running `talm apply`, or pin `templateOptions.talosVersion: "v1.11"` in `Chart.yaml` until the translator lands. > -> **Idempotent applies.** Repeated `talm apply` runs against an -> already-configured node do not duplicate entries. Before the strategic -> merge runs, the engine prunes from the body every primitive-list -> entry the rendered template already carries (e.g. certSANs, -> nameservers, validSubnets). For object arrays the upstream patcher -> merges by identity (machine.network.interfaces by `interface:` or -> `deviceSelector:`, vlans by `vlanId:`, apiServer admissionControl by -> `name:`), the prune descends into matched pairs and dedupes the inner -> primitive lists too — so re-applying after `talm template -I` does not -> double interface addresses, vlan addresses, or admission-control -> exemption namespaces. For object arrays without an upstream identity -> merge (extraVolumes, kernel.modules, wireguard.peers, ...), body items -> that deep-equal a rendered counterpart are dropped, covering the -> dominant full-restate case. Fields tagged `merge:"replace"` upstream -> are passed through verbatim — pruning them would let the upstream -> replace silently drop the rendered entries on a partial edit. This -> covers v1alpha1 root paths `cluster.network.podSubnets`, -> `cluster.network.serviceSubnets`, `cluster.apiServer.auditPolicy`, -> and the typed `NetworkRuleConfig` paths `ingress` and -> `portSelector.ports`. +> **One body, one node.** A non-empty body is a per-node pin, so the modeline for that file must target exactly one node. `talm apply` refuses a multi-node modeline when the body is non-empty; modeline-only files (no body) are still allowed and drive the same rendered template on every listed target. > -> `talm template -f node.yaml` (with or without `-I`) does **not** apply -> the same overlay: its output is the rendered template plus the modeline -> and the auto-generated warning, byte-identical to what the template -> alone would produce. Routing it through the patcher would drop every -> YAML comment (including the modeline) and re-sort keys, breaking -> downstream commands that read the file back. Use `apply --dry-run` if -> you want to preview the exact bytes that will be sent to the node. +> **Idempotent applies.** Repeated `talm apply` runs against an already-configured node do not duplicate entries. Before the strategic merge runs, the engine prunes from the body every primitive-list entry the rendered template already carries (e.g. certSANs, nameservers, validSubnets). For object arrays the upstream patcher merges by identity (machine.network.interfaces by `interface:` or `deviceSelector:`, vlans by `vlanId:`, apiServer admissionControl by `name:`), the prune descends into matched pairs and dedupes the inner primitive lists too — so re-applying after `talm template -I` does not double interface addresses, vlan addresses, or admission-control exemption namespaces. For object arrays without an upstream identity merge (extraVolumes, kernel.modules, wireguard.peers, ...), body items that deep-equal a rendered counterpart are dropped, covering the dominant full-restate case. Fields tagged `merge:"replace"` upstream are passed through verbatim — pruning them would let the upstream replace silently drop the rendered entries on a partial edit. This covers v1alpha1 root paths `cluster.network.podSubnets`, `cluster.network.serviceSubnets`, `cluster.apiServer.auditPolicy`, and the typed `NetworkRuleConfig` paths `ingress` and `portSelector.ports`. +> +> `talm template -f node.yaml` (with or without `-I`) does **not** apply the same overlay: its output is the rendered template plus the modeline and the auto-generated warning, byte-identical to what the template alone would produce. Routing it through the patcher would drop every YAML comment (including the modeline) and re-sort keys, breaking downstream commands that read the file back. Use `apply --dry-run` if you want to preview the exact bytes that will be sent to the node. ## Using talosctl commands -Talm offers a similar set of commands to those provided by talosctl. -However, you can specify the --file option for them. +Talm offers a similar set of commands to those provided by talosctl. However, you can specify the --file option for them. For example, to run a dashboard for three nodes: diff --git a/charts/cozystack/templates/_helpers.tpl b/charts/cozystack/templates/_helpers.tpl index a0bb0a88..8114fee0 100644 --- a/charts/cozystack/templates/_helpers.tpl +++ b/charts/cozystack/templates/_helpers.tpl @@ -161,10 +161,27 @@ cluster: {{- /* Shared network document generation for v1.12+ multi-doc format */ -}} {{- define "talos.config.network.multidoc" }} -{{- /* Multi-doc format always reconstructs network config from discovery resources. - existing_interfaces_configuration is not used here because v1.12 nodes store - network config in separate documents (LinkConfig, BondConfig, etc.), not in - the legacy machine.network.interfaces field. */ -}} +{{- /* 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 @@ -196,31 +213,59 @@ name: {{ .Values.floatingIP | quote }} link: {{ .Values.vipLink }} {{- end }} {{- $defaultLinkName := include "talm.discovered.default_link_name_by_gateway" . }} -{{- if $defaultLinkName }} -{{- $isVlan := include "talm.discovered.is_vlan" $defaultLinkName }} -{{- $parentLinkName := "" }} -{{- if $isVlan }} -{{- $parentLinkName = include "talm.discovered.parent_link_name" $defaultLinkName }} -{{- end }} -{{- $interfaceName := $defaultLinkName }} -{{- if and $isVlan $parentLinkName }} -{{- $interfaceName = $parentLinkName }} -{{- end }} -{{- $isBondInterface := include "talm.discovered.is_bond" $interfaceName }} -{{- if $isBondInterface }} -{{- $link := lookup "links" "" $interfaceName }} +{{- $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 }} +{{- if not (and $.Values.floatingIP (hasPrefix (printf "%s/" $.Values.floatingIP) .)) }} +{{- $addresses = append $addresses . }} +{{- end }} +{{- end }} +{{- $linkGateway := "" }} +{{- if $isGatewayLink }} +{{- $linkGateway = include "talm.discovered.gateway_by_link" $linkName }} +{{- end }} +{{- if eq $kind "bridge" }} +{{- /* BridgeConfig is a separate v1alpha1 typed document the chart + does not yet emit. Skipping a non-gateway bridge leaves the + rendered config without a bridge document and the operator is + responsible for declaring it via a per-node body. A bridge + carrying the IPv4 default route, however, cannot be silently + skipped: that would drop every network document for the + gateway link and the rendered config would describe a node + with no working uplink. Surface a fail with the offending + link and the migration path. */ -}} +{{- if $isGatewayLink }} +{{- fail (printf "talm: discovered bridge %q is the IPv4-default link, but BridgeConfig emission is not yet implemented in the chart. Move the bridge declaration into a per-node body overlay (kind: BridgeConfig), or set Values.vipLink to a different link until bridge support lands." $linkName) }} +{{- end }} +{{- else if eq $kind "bond" }} {{- $bondMaster := $link.spec.bondMaster }} {{- $slaves := fromJsonArray (include "talm.discovered.bond_slaves" $link.spec.index) }} --- apiVersion: v1alpha1 kind: BondConfig -name: {{ $interfaceName }} +name: {{ $linkName }} links: {{- range $slaves }} - {{ . }} {{- end }} +{{- if $bondMaster }} +{{- if $bondMaster.mode }} bondMode: {{ $bondMaster.mode }} +{{- end }} {{- if $bondMaster.xmitHashPolicy }} xmitHashPolicy: {{ $bondMaster.xmitHashPolicy }} {{- end }} @@ -236,56 +281,86 @@ updelay: {{ $bondMaster.updelay }} {{- if $bondMaster.downdelay }} downdelay: {{ $bondMaster.downdelay }} {{- end }} -{{- if not $isVlan }} +{{- end }} +{{- if $addresses }} addresses: -{{- range fromJsonArray (include "talm.discovered.default_addresses_by_gateway" .) }} +{{- range $addresses }} - address: {{ . }} {{- end }} +{{- end }} +{{- if $linkGateway }} routes: - - gateway: {{ include "talm.discovered.default_gateway" . }} + - gateway: {{ $linkGateway }} {{- end }} +{{- if $link.spec.mtu }} +mtu: {{ $link.spec.mtu }} {{- 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 }} -{{- if $isVlan }} --- apiVersion: v1alpha1 kind: VLANConfig -name: {{ $defaultLinkName }} -vlanID: {{ include "talm.discovered.vlan_id" $defaultLinkName }} -parent: {{ $interfaceName }} +name: {{ $linkName }} +vlanID: {{ $vlanID }} +parent: {{ $parentLinkName }} +{{- if $addresses }} addresses: -{{- range fromJsonArray (include "talm.discovered.default_addresses_by_gateway" .) }} +{{- range $addresses }} - address: {{ . }} {{- end }} +{{- end }} +{{- if $linkGateway }} routes: - - gateway: {{ include "talm.discovered.default_gateway" . }} -{{- else if not $isBondInterface }} + - gateway: {{ $linkGateway }} +{{- end }} +{{- if $link.spec.mtu }} +mtu: {{ $link.spec.mtu }} +{{- end }} +{{- else }} --- apiVersion: v1alpha1 kind: LinkConfig -name: {{ $interfaceName }} +name: {{ $linkName }} +{{- if $addresses }} addresses: -{{- range fromJsonArray (include "talm.discovered.default_addresses_by_gateway" .) }} +{{- range $addresses }} - address: {{ . }} {{- end }} +{{- end }} +{{- if $linkGateway }} routes: - - gateway: {{ include "talm.discovered.default_gateway" . }} + - 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. */}} -{{- if and .Values.floatingIP (not .Values.vipLink) (eq .MachineType "controlplane") }} -{{- $vipLinkName := $interfaceName }} -{{- if $isVlan }} -{{- $vipLinkName = $defaultLinkName }} -{{- end }} +{{- if and .Values.floatingIP (not .Values.vipLink) (eq .MachineType "controlplane") $defaultLinkName }} --- apiVersion: v1alpha1 kind: Layer2VIPConfig name: {{ .Values.floatingIP | quote }} -link: {{ $vipLinkName }} -{{- end }} +link: {{ $defaultLinkName }} {{- end }} {{- end }} diff --git a/charts/generic/templates/_helpers.tpl b/charts/generic/templates/_helpers.tpl index f4d7ab2c..97f97425 100644 --- a/charts/generic/templates/_helpers.tpl +++ b/charts/generic/templates/_helpers.tpl @@ -84,10 +84,27 @@ cluster: {{- /* Shared network document generation for v1.12+ multi-doc format */ -}} {{- define "talos.config.network.multidoc" }} -{{- /* Multi-doc format always reconstructs network config from discovery resources. - existing_interfaces_configuration is not used here because v1.12 nodes store - network config in separate documents (LinkConfig, BondConfig, etc.), not in - the legacy machine.network.interfaces field. */ -}} +{{- /* 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 @@ -119,31 +136,59 @@ name: {{ .Values.floatingIP | quote }} link: {{ .Values.vipLink }} {{- end }} {{- $defaultLinkName := include "talm.discovered.default_link_name_by_gateway" . }} -{{- if $defaultLinkName }} -{{- $isVlan := include "talm.discovered.is_vlan" $defaultLinkName }} -{{- $parentLinkName := "" }} -{{- if $isVlan }} -{{- $parentLinkName = include "talm.discovered.parent_link_name" $defaultLinkName }} -{{- end }} -{{- $interfaceName := $defaultLinkName }} -{{- if and $isVlan $parentLinkName }} -{{- $interfaceName = $parentLinkName }} -{{- end }} -{{- $isBondInterface := include "talm.discovered.is_bond" $interfaceName }} -{{- if $isBondInterface }} -{{- $link := lookup "links" "" $interfaceName }} +{{- $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 }} +{{- if not (and $.Values.floatingIP (hasPrefix (printf "%s/" $.Values.floatingIP) .)) }} +{{- $addresses = append $addresses . }} +{{- end }} +{{- end }} +{{- $linkGateway := "" }} +{{- if $isGatewayLink }} +{{- $linkGateway = include "talm.discovered.gateway_by_link" $linkName }} +{{- end }} +{{- if eq $kind "bridge" }} +{{- /* BridgeConfig is a separate v1alpha1 typed document the chart + does not yet emit. Skipping a non-gateway bridge leaves the + rendered config without a bridge document and the operator is + responsible for declaring it via a per-node body. A bridge + carrying the IPv4 default route, however, cannot be silently + skipped: that would drop every network document for the + gateway link and the rendered config would describe a node + with no working uplink. Surface a fail with the offending + link and the migration path. */ -}} +{{- if $isGatewayLink }} +{{- fail (printf "talm: discovered bridge %q is the IPv4-default link, but BridgeConfig emission is not yet implemented in the chart. Move the bridge declaration into a per-node body overlay (kind: BridgeConfig), or set Values.vipLink to a different link until bridge support lands." $linkName) }} +{{- end }} +{{- else if eq $kind "bond" }} {{- $bondMaster := $link.spec.bondMaster }} {{- $slaves := fromJsonArray (include "talm.discovered.bond_slaves" $link.spec.index) }} --- apiVersion: v1alpha1 kind: BondConfig -name: {{ $interfaceName }} +name: {{ $linkName }} links: {{- range $slaves }} - {{ . }} {{- end }} +{{- if $bondMaster }} +{{- if $bondMaster.mode }} bondMode: {{ $bondMaster.mode }} +{{- end }} {{- if $bondMaster.xmitHashPolicy }} xmitHashPolicy: {{ $bondMaster.xmitHashPolicy }} {{- end }} @@ -159,56 +204,86 @@ updelay: {{ $bondMaster.updelay }} {{- if $bondMaster.downdelay }} downdelay: {{ $bondMaster.downdelay }} {{- end }} -{{- if not $isVlan }} +{{- end }} +{{- if $addresses }} addresses: -{{- range fromJsonArray (include "talm.discovered.default_addresses_by_gateway" .) }} +{{- range $addresses }} - address: {{ . }} {{- end }} +{{- end }} +{{- if $linkGateway }} routes: - - gateway: {{ include "talm.discovered.default_gateway" . }} + - gateway: {{ $linkGateway }} {{- end }} +{{- if $link.spec.mtu }} +mtu: {{ $link.spec.mtu }} {{- 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 }} -{{- if $isVlan }} --- apiVersion: v1alpha1 kind: VLANConfig -name: {{ $defaultLinkName }} -vlanID: {{ include "talm.discovered.vlan_id" $defaultLinkName }} -parent: {{ $interfaceName }} +name: {{ $linkName }} +vlanID: {{ $vlanID }} +parent: {{ $parentLinkName }} +{{- if $addresses }} addresses: -{{- range fromJsonArray (include "talm.discovered.default_addresses_by_gateway" .) }} +{{- range $addresses }} - address: {{ . }} {{- end }} +{{- end }} +{{- if $linkGateway }} routes: - - gateway: {{ include "talm.discovered.default_gateway" . }} -{{- else if not $isBondInterface }} + - gateway: {{ $linkGateway }} +{{- end }} +{{- if $link.spec.mtu }} +mtu: {{ $link.spec.mtu }} +{{- end }} +{{- else }} --- apiVersion: v1alpha1 kind: LinkConfig -name: {{ $interfaceName }} +name: {{ $linkName }} +{{- if $addresses }} addresses: -{{- range fromJsonArray (include "talm.discovered.default_addresses_by_gateway" .) }} +{{- range $addresses }} - address: {{ . }} {{- end }} +{{- end }} +{{- if $linkGateway }} routes: - - gateway: {{ include "talm.discovered.default_gateway" . }} + - 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. */}} -{{- if and .Values.floatingIP (not .Values.vipLink) (eq .MachineType "controlplane") }} -{{- $vipLinkName := $interfaceName }} -{{- if $isVlan }} -{{- $vipLinkName = $defaultLinkName }} -{{- end }} +{{- if and .Values.floatingIP (not .Values.vipLink) (eq .MachineType "controlplane") $defaultLinkName }} --- apiVersion: v1alpha1 kind: Layer2VIPConfig name: {{ .Values.floatingIP | quote }} -link: {{ $vipLinkName }} -{{- end }} +link: {{ $defaultLinkName }} {{- end }} {{- end }} diff --git a/charts/talm/templates/_helpers.tpl b/charts/talm/templates/_helpers.tpl index e26288a9..5cb586ec 100644 --- a/charts/talm/templates/_helpers.tpl +++ b/charts/talm/templates/_helpers.tpl @@ -343,7 +343,11 @@ vlans: {{- /* JSON list of physical link names (raw NICs only — not bond/vlan masters). Renamed from `physical_links` to avoid collision with the older - `physical_links_info` helper, which renders YAML comments. */ -}} + `physical_links_info` helper, which renders YAML comments. + Bond slaves are NOT filtered by this helper — callers that need to + skip slaves (e.g. the multi-doc renderer that already emits the + master's BondConfig) must filter on spec.slaveKind themselves, or + use configurable_link_names below which already filters them. */ -}} {{- define "talm.discovered.physical_link_names" -}} {{- $names := list -}} {{- range (lookup "links" "" "").items -}} @@ -355,13 +359,21 @@ vlans: {{- end -}} {{- /* JSON list of every link a user template can configure: physical NICs - plus bond / vlan / bridge top-level links. */ -}} + plus bond / vlan / bridge top-level links. Bond slaves (physical NICs + enrolled into a bond master) are excluded — emitting a standalone + LinkConfig for a slave alongside the master's BondConfig produces + conflicting declarations Talos will reject during controller + convergence. The slave-membership fact comes from spec.slaveKind + (set by the link controller when a physical NIC is attached to a + bond / bridge / team), so filtering on it covers slaves regardless + of whether the master is bond, bridge, or future kinds. */ -}} {{- define "talm.discovered.configurable_link_names" -}} {{- $names := list -}} {{- range (lookup "links" "" "").items -}} {{- $isPhysical := and .spec.busPath (regexMatch "^(eno|eth|enp|enx|ens)" (.metadata.id | toString)) -}} {{- $isVirtual := has (.spec.kind | toString) (list "bond" "vlan" "bridge") -}} -{{- if or $isPhysical $isVirtual -}} +{{- $isSlave := and .spec.slaveKind (ne (.spec.slaveKind | toString) "") -}} +{{- if and (or $isPhysical $isVirtual) (not $isSlave) -}} {{- $names = append $names .metadata.id -}} {{- end -}} {{- end -}} @@ -370,9 +382,14 @@ vlans: {{- /* JSON list of CIDR addresses configured on the given link, excluding kernel-managed scopes (host loopback, link-local, nowhere) and - addresses with no scope set at all. Both IPv4 and IPv6 globally-scoped - addresses are returned; the caller is responsible for filtering by - family or stripping VIPs if needed. */ -}} + addresses with no scope set at all. Both IPv4 and IPv6 globally- + scoped addresses are returned — chart consumers (cozystack / + generic multi-doc renderer) emit them verbatim into LinkConfig / + VLANConfig / BondConfig.addresses, dropping the operator-declared + floatingIP via an inline filter to keep the leader's transient VIP + out of the static config. Add a per-family helper if a future + caller needs IPv4- or IPv6-only output rather than tightening this + one. */ -}} {{- define "talm.discovered.addresses_by_link" -}} {{- $linkName := . -}} {{- $addresses := list -}}