From 719218ecc3eabab91162f60c7f9c61bedabfb70f Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Fri, 10 Apr 2026 12:40:15 +0300 Subject: [PATCH 1/6] feat(config): migrate to Talos v1.12 multi-document config format Add support for the Talos v1.12 multi-document configuration format. When TalosVersion is set to v1.12 or later (via Chart.yaml or --talos-version CLI flag), templates generate separate YAML documents instead of the deprecated monolithic machine.network and machine.registries fields. Closes #100 Engine: - Pass TalosVersion through the Helm values map into the template rendering context (available as .TalosVersion), avoiding global mutable state - Remove unused ctx and chrt parameters from applyPatchesAndRenderConfig Chart templates (cozystack + generic): - Split talos.config into shared sub-templates to eliminate duplication: talos.config.machine.common, talos.config.cluster, talos.config.network.legacy, talos.config.network.multidoc - Version dispatcher selects legacy or multi-doc format based on semverCompare - New v1.12 document types generated: HostnameConfig, ResolverConfig, LinkConfig, BondConfig, VLANConfig, RegistryMirrorConfig, Layer2VIPConfig Bug fix: - Fix nr_hugepages rendering inside with block (was producing empty string for non-zero values due to .Values being inaccessible when dot is rebound) Backward compatibility: - Legacy format is fully preserved when TalosVersion is empty or < v1.12 - All legacy regression tests pass unchanged Tests: - isTalosConfigPatch and extractExtraDocuments tests for all 7 new document types - Offline rendering tests for both charts x both roles x both formats - Version edge cases: pre-release, two-component version strings - Concurrent render test with race detector - nr_hugepages rendering test for both legacy and multi-doc paths - Regression test from #119 for offline lookup producing empty interface Code style: - Replace interface{} with any across engine package - Use maps.Copy instead of range+assign loops - Use range over int for simple counting loops - Use strings.Repeat instead of += concatenation Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- README.md | 4 + charts/cozystack/templates/_helpers.tpl | 249 +++++++++++---- charts/generic/templates/_helpers.tpl | 175 +++++++++-- pkg/engine/engine.go | 8 +- pkg/engine/engine_test.go | 53 ++++ pkg/engine/helm/engine.go | 1 + pkg/engine/helm/engine_test.go | 118 ++++++- pkg/engine/render_test.go | 391 ++++++++++++++++++++++++ 8 files changed, 912 insertions(+), 87 deletions(-) diff --git a/README.md b/README.md index af9f2988..5c36017c 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,10 @@ cluster: endpoint: https://192.168.0.1: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 (`HostnameConfig`, `ResolverConfig`, `LinkConfig`, `BondConfig`, `VLANConfig`, `RegistryMirrorConfig`, `Layer2VIPConfig`) instead of the deprecated monolithic fields. + Apply config: ```bash talm apply -f nodes/node1.yaml -i diff --git a/charts/cozystack/templates/_helpers.tpl b/charts/cozystack/templates/_helpers.tpl index 3f00f962..5e7412f4 100644 --- a/charts/cozystack/templates/_helpers.tpl +++ b/charts/cozystack/templates/_helpers.tpl @@ -1,4 +1,13 @@ {{- define "talos.config" }} +{{- if and .TalosVersion (not (semverCompare "<1.12.0-0" .TalosVersion)) }} +{{- include "talos.config.multidoc" . }} +{{- else }} +{{- include "talos.config.legacy" . }} +{{- end }} +{{- end }} + +{{- /* Shared machine section: type, kubelet, sysctls, kernel, certSANs, files, install */ -}} +{{- define "talos.config.machine.common" }} machine: {{- if eq .MachineType "controlplane" }} nodeLabels: @@ -14,8 +23,8 @@ machine: cpuManagerPolicy: static maxPods: 512 sysctls: - {{- with .Values.nr_hugepages }} - vm.nr_hugepages: {{ .Values.nr_hugepages | quote }} + {{- with $.Values.nr_hugepages }} + vm.nr_hugepages: {{ . | quote }} {{- end }} net.ipv4.neigh.default.gc_thresh1: "4096" net.ipv4.neigh.default.gc_thresh2: "8192" @@ -35,11 +44,6 @@ machine: {{- with .Values.certSANs }} {{- toYaml . | nindent 2 }} {{- end }} - registries: - mirrors: - docker.io: - endpoints: - - https://mirror.gcr.io files: - content: | [plugins] @@ -66,53 +70,10 @@ machine: {{- end }} {{- (include "talm.discovered.disks_info" .) | nindent 4 }} disk: {{ include "talm.discovered.system_disk_name" . | quote }} - network: - hostname: {{ include "talm.discovered.hostname" . | quote }} - nameservers: {{ include "talm.discovered.default_resolvers" . }} - {{- (include "talm.discovered.physical_links_info" .) | nindent 4 }} - interfaces: - {{- $existingInterfacesConfiguration := include "talm.discovered.existing_interfaces_configuration" . }} - {{- if $existingInterfacesConfiguration }} - {{- $existingInterfacesConfiguration | nindent 4 }} - {{- else }} - {{- $defaultLinkName := include "talm.discovered.default_link_name_by_gateway" . }} - {{- $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 }} - - interface: {{ $interfaceName }} - {{- $bondConfig := include "talm.discovered.bond_config" $interfaceName }} - {{- if $bondConfig }} - {{- $bondConfig | nindent 6 }} - {{- end }} - {{- if $isVlan }} - vlans: - - vlanId: {{ include "talm.discovered.vlan_id" $defaultLinkName }} - addresses: {{ include "talm.discovered.default_addresses_by_gateway" . }} - routes: - - network: 0.0.0.0/0 - gateway: {{ include "talm.discovered.default_gateway" . }} - {{- if and .Values.floatingIP (eq .MachineType "controlplane") }} - vip: - ip: {{ .Values.floatingIP }} - {{- end }} - {{- else }} - addresses: {{ include "talm.discovered.default_addresses_by_gateway" . }} - routes: - - network: 0.0.0.0/0 - gateway: {{ include "talm.discovered.default_gateway" . }} - {{- if and .Values.floatingIP (eq .MachineType "controlplane") }} - vip: - ip: {{ .Values.floatingIP }} - {{- end }} - {{- end }} - {{- end }} +{{- end }} +{{- /* Shared cluster section */ -}} +{{- define "talos.config.cluster" }} cluster: network: cni: @@ -161,3 +122,185 @@ cluster: {{- toYaml .Values.advertisedSubnets | nindent 6 }} {{- end }} {{- end }} + +{{- /* 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. */ -}} +{{- (include "talm.discovered.physical_links_info" .) }} +--- +apiVersion: v1alpha1 +kind: HostnameConfig +hostname: {{ include "talm.discovered.hostname" . | quote }} +--- +apiVersion: v1alpha1 +kind: ResolverConfig +nameservers: +{{- $resolvers := include "talm.discovered.default_resolvers" . }} +{{- if $resolvers }} +{{- range fromJsonArray $resolvers }} + - address: {{ . | quote }} +{{- end }} +{{- else }} + [] +{{- end }} +{{- $defaultLinkName := include "talm.discovered.default_link_name_by_gateway" . }} +{{- $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 }} +{{- if $link }} +{{- $bondMaster := $link.spec.bondMaster }} +{{- $slaves := fromJsonArray (include "talm.discovered.bond_slaves" $link.spec.index) }} +--- +apiVersion: v1alpha1 +kind: BondConfig +name: {{ $interfaceName }} +links: +{{- range $slaves }} + - {{ . }} +{{- end }} +bondMode: {{ $bondMaster.mode }} +{{- if $bondMaster.xmitHashPolicy }} +xmitHashPolicy: {{ $bondMaster.xmitHashPolicy }} +{{- end }} +{{- if $bondMaster.lacpRate }} +lacpRate: {{ $bondMaster.lacpRate }} +{{- end }} +{{- if $bondMaster.miimon }} +miimon: {{ $bondMaster.miimon }} +{{- end }} +{{- if $bondMaster.updelay }} +updelay: {{ $bondMaster.updelay }} +{{- end }} +{{- if $bondMaster.downdelay }} +downdelay: {{ $bondMaster.downdelay }} +{{- end }} +{{- if not $isVlan }} +addresses: +{{- range fromJsonArray (include "talm.discovered.default_addresses_by_gateway" .) }} + - address: {{ . }} +{{- end }} +routes: + - gateway: {{ include "talm.discovered.default_gateway" . }} +{{- end }} +{{- end }} +{{- end }} +{{- if $isVlan }} +--- +apiVersion: v1alpha1 +kind: VLANConfig +name: {{ $defaultLinkName }} +vlanID: {{ include "talm.discovered.vlan_id" $defaultLinkName }} +parent: {{ $interfaceName }} +addresses: +{{- range fromJsonArray (include "talm.discovered.default_addresses_by_gateway" .) }} + - address: {{ . }} +{{- end }} +routes: + - gateway: {{ include "talm.discovered.default_gateway" . }} +{{- else if not $isBondInterface }} +--- +apiVersion: v1alpha1 +kind: LinkConfig +name: {{ $interfaceName }} +addresses: +{{- range fromJsonArray (include "talm.discovered.default_addresses_by_gateway" .) }} + - address: {{ . }} +{{- end }} +routes: + - gateway: {{ include "talm.discovered.default_gateway" . }} +{{- end }} +{{- if and .Values.floatingIP (eq .MachineType "controlplane") }} +--- +apiVersion: v1alpha1 +kind: Layer2VIPConfig +name: {{ .Values.floatingIP | quote }} +link: {{ $interfaceName }} +{{- end }} +{{- end }} + +{{- /* Shared legacy network section for machine.network */ -}} +{{- define "talos.config.network.legacy" }} + network: + hostname: {{ include "talm.discovered.hostname" . | quote }} + nameservers: {{ include "talm.discovered.default_resolvers" . }} + {{- (include "talm.discovered.physical_links_info" .) | nindent 4 }} + interfaces: + {{- $existingInterfacesConfiguration := include "talm.discovered.existing_interfaces_configuration" . }} + {{- if $existingInterfacesConfiguration }} + {{- $existingInterfacesConfiguration | nindent 4 }} + {{- else }} + {{- $defaultLinkName := include "talm.discovered.default_link_name_by_gateway" . }} + {{- $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 }} + - interface: {{ $interfaceName }} + {{- $bondConfig := include "talm.discovered.bond_config" $interfaceName }} + {{- if $bondConfig }} + {{- $bondConfig | nindent 6 }} + {{- end }} + {{- if $isVlan }} + vlans: + - vlanId: {{ include "talm.discovered.vlan_id" $defaultLinkName }} + addresses: {{ include "talm.discovered.default_addresses_by_gateway" . }} + routes: + - network: 0.0.0.0/0 + gateway: {{ include "talm.discovered.default_gateway" . }} + {{- if and .Values.floatingIP (eq .MachineType "controlplane") }} + vip: + ip: {{ .Values.floatingIP }} + {{- end }} + {{- else }} + addresses: {{ include "talm.discovered.default_addresses_by_gateway" . }} + routes: + - network: 0.0.0.0/0 + gateway: {{ include "talm.discovered.default_gateway" . }} + {{- if and .Values.floatingIP (eq .MachineType "controlplane") }} + vip: + ip: {{ .Values.floatingIP }} + {{- end }} + {{- end }} + {{- end }} +{{- end }} + +{{- define "talos.config.legacy" }} +{{- include "talos.config.machine.common" . }} + registries: + mirrors: + docker.io: + endpoints: + - https://mirror.gcr.io +{{- include "talos.config.network.legacy" . }} + +{{- include "talos.config.cluster" . }} +{{- end }} + +{{- define "talos.config.multidoc" }} +{{- include "talos.config.machine.common" . }} + +{{- include "talos.config.cluster" . }} +--- +apiVersion: v1alpha1 +kind: RegistryMirrorConfig +name: docker.io +endpoints: + - url: https://mirror.gcr.io +{{- include "talos.config.network.multidoc" . }} +{{- end }} diff --git a/charts/generic/templates/_helpers.tpl b/charts/generic/templates/_helpers.tpl index 80c264d4..11b939a9 100644 --- a/charts/generic/templates/_helpers.tpl +++ b/charts/generic/templates/_helpers.tpl @@ -1,4 +1,13 @@ {{- define "talos.config" }} +{{- if and .TalosVersion (not (semverCompare "<1.12.0-0" .TalosVersion)) }} +{{- include "talos.config.multidoc" . }} +{{- else }} +{{- include "talos.config.legacy" . }} +{{- end }} +{{- end }} + +{{- /* Shared machine section: type, kubelet, certSANs, install */ -}} +{{- define "talos.config.machine.common" }} machine: type: {{ .MachineType }} kubelet: @@ -12,6 +21,140 @@ machine: install: {{- (include "talm.discovered.disks_info" .) | nindent 4 }} disk: {{ include "talm.discovered.system_disk_name" . | quote }} +{{- end }} + +{{- /* Shared cluster section */ -}} +{{- define "talos.config.cluster" }} +cluster: + network: + podSubnets: + {{- toYaml .Values.podSubnets | nindent 6 }} + serviceSubnets: + {{- toYaml .Values.serviceSubnets | nindent 6 }} + clusterName: "{{ .Chart.Name }}" + controlPlane: + endpoint: "{{ .Values.endpoint }}" + {{- if eq .MachineType "controlplane" }} + apiServer: + {{- with .Values.certSANs }} + certSANs: + {{- toYaml . | nindent 4 }} + {{- end }} + etcd: + advertisedSubnets: + {{- toYaml .Values.advertisedSubnets | nindent 6 }} + {{- end }} +{{- end }} + +{{- /* 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. */ -}} +{{- (include "talm.discovered.physical_links_info" .) }} +--- +apiVersion: v1alpha1 +kind: HostnameConfig +hostname: {{ include "talm.discovered.hostname" . | quote }} +--- +apiVersion: v1alpha1 +kind: ResolverConfig +nameservers: +{{- $resolvers := include "talm.discovered.default_resolvers" . }} +{{- if $resolvers }} +{{- range fromJsonArray $resolvers }} + - address: {{ . | quote }} +{{- end }} +{{- else }} + [] +{{- end }} +{{- $defaultLinkName := include "talm.discovered.default_link_name_by_gateway" . }} +{{- $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 }} +{{- if $link }} +{{- $bondMaster := $link.spec.bondMaster }} +{{- $slaves := fromJsonArray (include "talm.discovered.bond_slaves" $link.spec.index) }} +--- +apiVersion: v1alpha1 +kind: BondConfig +name: {{ $interfaceName }} +links: +{{- range $slaves }} + - {{ . }} +{{- end }} +bondMode: {{ $bondMaster.mode }} +{{- if $bondMaster.xmitHashPolicy }} +xmitHashPolicy: {{ $bondMaster.xmitHashPolicy }} +{{- end }} +{{- if $bondMaster.lacpRate }} +lacpRate: {{ $bondMaster.lacpRate }} +{{- end }} +{{- if $bondMaster.miimon }} +miimon: {{ $bondMaster.miimon }} +{{- end }} +{{- if $bondMaster.updelay }} +updelay: {{ $bondMaster.updelay }} +{{- end }} +{{- if $bondMaster.downdelay }} +downdelay: {{ $bondMaster.downdelay }} +{{- end }} +{{- if not $isVlan }} +addresses: +{{- range fromJsonArray (include "talm.discovered.default_addresses_by_gateway" .) }} + - address: {{ . }} +{{- end }} +routes: + - gateway: {{ include "talm.discovered.default_gateway" . }} +{{- end }} +{{- end }} +{{- end }} +{{- if $isVlan }} +--- +apiVersion: v1alpha1 +kind: VLANConfig +name: {{ $defaultLinkName }} +vlanID: {{ include "talm.discovered.vlan_id" $defaultLinkName }} +parent: {{ $interfaceName }} +addresses: +{{- range fromJsonArray (include "talm.discovered.default_addresses_by_gateway" .) }} + - address: {{ . }} +{{- end }} +routes: + - gateway: {{ include "talm.discovered.default_gateway" . }} +{{- else if not $isBondInterface }} +--- +apiVersion: v1alpha1 +kind: LinkConfig +name: {{ $interfaceName }} +addresses: +{{- range fromJsonArray (include "talm.discovered.default_addresses_by_gateway" .) }} + - address: {{ . }} +{{- end }} +routes: + - gateway: {{ include "talm.discovered.default_gateway" . }} +{{- end }} +{{- if and .Values.floatingIP (eq .MachineType "controlplane") }} +--- +apiVersion: v1alpha1 +kind: Layer2VIPConfig +name: {{ .Values.floatingIP | quote }} +link: {{ $interfaceName }} +{{- end }} +{{- end }} + +{{- /* Shared legacy network section for machine.network */ -}} +{{- define "talos.config.network.legacy" }} network: hostname: {{ include "talm.discovered.hostname" . | quote }} nameservers: {{ include "talm.discovered.default_resolvers" . }} @@ -58,24 +201,18 @@ machine: {{- end }} {{- end }} {{- end }} +{{- end }} -cluster: - network: - podSubnets: - {{- toYaml .Values.podSubnets | nindent 6 }} - serviceSubnets: - {{- toYaml .Values.serviceSubnets | nindent 6 }} - clusterName: "{{ .Chart.Name }}" - controlPlane: - endpoint: "{{ .Values.endpoint }}" - {{- if eq .MachineType "controlplane" }} - apiServer: - {{- with .Values.certSANs }} - certSANs: - {{- toYaml . | nindent 4 }} - {{- end }} - etcd: - advertisedSubnets: - {{- toYaml .Values.advertisedSubnets | nindent 6 }} - {{- end }} +{{- define "talos.config.legacy" }} +{{- include "talos.config.machine.common" . }} +{{- include "talos.config.network.legacy" . }} + +{{- include "talos.config.cluster" . }} +{{- end }} + +{{- define "talos.config.multidoc" }} +{{- include "talos.config.machine.common" . }} + +{{- include "talos.config.cluster" . }} +{{- include "talos.config.network.multidoc" . }} {{- end }} diff --git a/pkg/engine/engine.go b/pkg/engine/engine.go index 097c495d..a0da2889 100644 --- a/pkg/engine/engine.go +++ b/pkg/engine/engine.go @@ -23,7 +23,6 @@ import ( helmEngine "github.com/cozystack/talm/pkg/engine/helm" "github.com/cozystack/talm/pkg/yamltools" "github.com/hashicorp/go-multierror" - "helm.sh/helm/v3/pkg/chart" "helm.sh/helm/v3/pkg/chart/loader" "helm.sh/helm/v3/pkg/strvals" @@ -233,7 +232,8 @@ func Render(ctx context.Context, c *client.Client, opts Options) ([]byte, error) } rootValues := map[string]any{ - "Values": mergeMaps(chrt.Values, values), + "Values": mergeMaps(chrt.Values, values), + "TalosVersion": opts.TalosVersion, } eng := helmEngine.Engine{} @@ -257,7 +257,7 @@ func Render(ctx context.Context, c *client.Client, opts Options) ([]byte, error) configPatches = append(configPatches, configPatch) } - finalConfig, err := applyPatchesAndRenderConfig(ctx, opts, configPatches, chrt) + finalConfig, err := applyPatchesAndRenderConfig(opts, configPatches) if err != nil { // TODO return nil, err @@ -391,7 +391,7 @@ func extractExtraDocuments(patches []string) (talosPatches []string, extraDocs [ return talosPatches, extraDocs, nil } -func applyPatchesAndRenderConfig(ctx context.Context, opts Options, configPatches []string, chrt *chart.Chart) ([]byte, error) { +func applyPatchesAndRenderConfig(opts Options, configPatches []string) ([]byte, error) { // Separate Talos config patches from extra documents (like UserVolumeConfig) talosPatches, extraDocs, err := extractExtraDocuments(configPatches) if err != nil { diff --git a/pkg/engine/engine_test.go b/pkg/engine/engine_test.go index 59a65d8f..caa9016f 100644 --- a/pkg/engine/engine_test.go +++ b/pkg/engine/engine_test.go @@ -48,6 +48,41 @@ func TestIsTalosConfigPatch(t *testing.T) { doc: "apiVersion: v1alpha1\nkind: SideroLinkConfig\napiUrl: https://example.com", expected: false, }, + { + name: "HostnameConfig", + doc: "apiVersion: v1alpha1\nkind: HostnameConfig\nhostname: worker-1", + expected: false, + }, + { + name: "LinkConfig", + doc: "apiVersion: v1alpha1\nkind: LinkConfig\nname: enp0s3\naddresses:\n - address: 192.168.1.100/24", + expected: false, + }, + { + name: "BondConfig", + doc: "apiVersion: v1alpha1\nkind: BondConfig\nname: bond0\nlinks:\n - eth0\n - eth1\nbondMode: 802.3ad", + expected: false, + }, + { + name: "VLANConfig", + doc: "apiVersion: v1alpha1\nkind: VLANConfig\nname: bond0.100\nvlanID: 100\nparent: bond0", + expected: false, + }, + { + name: "ResolverConfig", + doc: "apiVersion: v1alpha1\nkind: ResolverConfig\nnameservers:\n - address: 8.8.8.8", + expected: false, + }, + { + name: "RegistryMirrorConfig", + doc: "apiVersion: v1alpha1\nkind: RegistryMirrorConfig\nname: docker.io\nendpoints:\n - url: https://mirror.gcr.io", + expected: false, + }, + { + name: "Layer2VIPConfig", + doc: "apiVersion: v1alpha1\nkind: Layer2VIPConfig\nname: 192.168.100.10\nlink: bond0", + expected: false, + }, { name: "empty document", doc: "", @@ -130,6 +165,24 @@ func TestExtractExtraDocuments(t *testing.T) { wantTalos: 1, wantExtra: 1, }, + { + name: "v1.12 multi-doc: talos patch + network and registry documents", + patches: []string{"machine:\n type: worker\ncluster:\n name: test\n---\napiVersion: v1alpha1\nkind: HostnameConfig\nhostname: worker-1\n---\napiVersion: v1alpha1\nkind: LinkConfig\nname: enp0s3\naddresses:\n - address: 192.168.1.100/24\n---\napiVersion: v1alpha1\nkind: RegistryMirrorConfig\nname: docker.io\nendpoints:\n - url: https://mirror.gcr.io"}, + wantTalos: 1, + wantExtra: 3, + }, + { + name: "v1.12 multi-doc: talos patch + bond, vlan, vip documents", + patches: []string{"machine:\n type: controlplane\ncluster:\n name: prod\n---\napiVersion: v1alpha1\nkind: BondConfig\nname: bond0\nlinks:\n - eth0\n - eth1\nbondMode: 802.3ad\n---\napiVersion: v1alpha1\nkind: VLANConfig\nname: bond0.100\nvlanID: 100\nparent: bond0\n---\napiVersion: v1alpha1\nkind: Layer2VIPConfig\nname: 192.168.100.10\nlink: bond0"}, + wantTalos: 1, + wantExtra: 3, + }, + { + name: "v1.12 multi-doc: talos patch + resolver config", + patches: []string{"machine:\n type: worker\n---\napiVersion: v1alpha1\nkind: ResolverConfig\nnameservers:\n - address: 8.8.8.8\n - address: 8.8.4.4"}, + wantTalos: 1, + wantExtra: 1, + }, { name: "invalid yaml should return error", patches: []string{"machine:\n network:\n interfaces:\n \n []"}, diff --git a/pkg/engine/helm/engine.go b/pkg/engine/helm/engine.go index a03a7d65..eb238d40 100644 --- a/pkg/engine/helm/engine.go +++ b/pkg/engine/helm/engine.go @@ -370,6 +370,7 @@ func recAllTpls(c *chart.Chart, templates map[string]renderable, vals chartutil. "Values": make(chartutil.Values), "Subcharts": subCharts, "Disks": Disks, + "TalosVersion": vals["TalosVersion"], } // If there is a {{.Values.ThisChart}} in the parent metadata, diff --git a/pkg/engine/helm/engine_test.go b/pkg/engine/helm/engine_test.go index 0d5b0be1..96dfee6f 100644 --- a/pkg/engine/helm/engine_test.go +++ b/pkg/engine/helm/engine_test.go @@ -843,15 +843,12 @@ func TestRenderRecursionLimit(t *testing.T) { times := 4000 phrase := "All work and no play makes Jack a dull boy" printFunc := `{{define "overlook"}}{{printf "` + phrase + `\n"}}{{end}}` - var repeatedIncl strings.Builder - for range times { - repeatedIncl.WriteString(`{{include "overlook" . }}`) - } + repeatedIncl := strings.Repeat(`{{include "overlook" . }}`, times) d := &chart.Chart{ Metadata: &chart.Metadata{Name: "overlook"}, Templates: []*chart.File{ - {Name: "templates/quote", Data: []byte(repeatedIncl.String())}, + {Name: "templates/quote", Data: []byte(repeatedIncl)}, {Name: "templates/_function", Data: []byte(printFunc)}, }, } @@ -861,12 +858,9 @@ func TestRenderRecursionLimit(t *testing.T) { t.Fatal(err) } - var expect strings.Builder - for range times { - expect.WriteString(phrase + "\n") - } - if got := out["overlook/templates/quote"]; got != expect.String() { - t.Errorf("Expected %q, got %q (%v)", expect.String(), got, out) + expect := strings.Repeat(phrase+"\n", times) + if got := out["overlook/templates/quote"]; got != expect { + t.Errorf("Expected %q, got %q (%v)", expect, got, out) } } @@ -1122,3 +1116,105 @@ func TestRenderTplMissingKeyString(t *testing.T) { t.Fatal(err) } } + +func TestTalosVersionInTemplateContext(t *testing.T) { + t.Parallel() + + c := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "testchart", + Version: "0.1.0", + }, + Templates: []*chart.File{ + {Name: "templates/test.yaml", Data: []byte("talosVersion: {{ .TalosVersion }}")}, + }, + } + + vals := chartutil.Values{ + "Values": chartutil.Values{}, + "TalosVersion": "v1.12", + } + + out, err := Render(c, vals) + if err != nil { + t.Fatalf("failed to render: %v", err) + } + + result := out["testchart/templates/test.yaml"] + expected := "talosVersion: v1.12" + if strings.TrimSpace(result) != expected { + t.Errorf("expected %q, got %q", expected, strings.TrimSpace(result)) + } +} + +func TestTalosVersionEmpty(t *testing.T) { + t.Parallel() + + c := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "testchart", + Version: "0.1.0", + }, + Templates: []*chart.File{ + {Name: "templates/test.yaml", Data: []byte("talosVersion: {{ .TalosVersion }}")}, + }, + } + + vals := chartutil.Values{ + "Values": chartutil.Values{}, + } + + out, err := Render(c, vals) + if err != nil { + t.Fatalf("failed to render: %v", err) + } + + result := out["testchart/templates/test.yaml"] + expected := "talosVersion:" + if strings.TrimSpace(result) != expected { + t.Errorf("expected %q, got %q", expected, strings.TrimSpace(result)) + } +} + +func TestTalosVersionConcurrentRender(t *testing.T) { + t.Parallel() + + renderWithVersion := func(version string, expected string) { + c := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "testchart", + Version: "0.1.0", + }, + Templates: []*chart.File{ + {Name: "templates/test.yaml", Data: []byte("talosVersion: {{ .TalosVersion }}")}, + }, + } + vals := chartutil.Values{ + "Values": chartutil.Values{}, + "TalosVersion": version, + } + out, err := Render(c, vals) + if err != nil { + t.Errorf("render with version %q failed: %v", version, err) + return + } + result := strings.TrimSpace(out["testchart/templates/test.yaml"]) + if result != expected { + t.Errorf("version %q: expected %q, got %q", version, expected, result) + } + } + + var wg sync.WaitGroup + for range 10 { + wg.Add(2) + go func() { + defer wg.Done() + renderWithVersion("v1.12", "talosVersion: v1.12") + }() + go func() { + defer wg.Done() + renderWithVersion("v1.11", "talosVersion: v1.11") + }() + } + wg.Wait() +} diff --git a/pkg/engine/render_test.go b/pkg/engine/render_test.go index dbb8f8e0..e863951c 100644 --- a/pkg/engine/render_test.go +++ b/pkg/engine/render_test.go @@ -15,15 +15,406 @@ package engine import ( + "maps" "os" + "path" "path/filepath" "strings" "testing" helmEngine "github.com/cozystack/talm/pkg/engine/helm" "helm.sh/helm/v3/pkg/chart/loader" + "helm.sh/helm/v3/pkg/chartutil" ) +// renderChartTemplate renders a chart template in offline mode and returns the output. +// talosVersion sets the TalosVersion in the Helm engine context (empty string for legacy). +func renderChartTemplate(t *testing.T, chartPath string, templateFile string, talosVersion ...string) string { + t.Helper() + + // Reset to offline mode + helmEngine.LookupFunc = func(string, string, string) (map[string]any, error) { + return map[string]any{}, nil + } + + chrt, err := loader.LoadDir(chartPath) + if err != nil { + t.Fatalf("failed to load chart from %s: %v", chartPath, err) + } + + tv := "" + if len(talosVersion) > 0 { + tv = talosVersion[0] + } + + rootValues := chartutil.Values{ + "Values": chrt.Values, + "TalosVersion": tv, + } + + eng := helmEngine.Engine{} + out, err := eng.Render(chrt, rootValues) + if err != nil { + t.Fatalf("failed to render chart: %v", err) + } + + key := path.Join(chrt.Name(), templateFile) + result, ok := out[key] + if !ok { + var keys []string + for k := range out { + keys = append(keys, k) + } + t.Fatalf("template %s not found in output, available keys: %v", key, keys) + } + + return result +} + +// assertContains checks that the output contains the expected substring. +func assertContains(t *testing.T, output string, substr string) { + t.Helper() + if !strings.Contains(output, substr) { + t.Errorf("expected output to contain %q, but it does not.\nOutput:\n%s", substr, output) + } +} + +// assertNotContains checks that the output does NOT contain the substring. +func assertNotContains(t *testing.T, output string, substr string) { + t.Helper() + if strings.Contains(output, substr) { + t.Errorf("expected output NOT to contain %q, but it does.\nOutput:\n%s", substr, output) + } +} + +func TestLegacyCozystack_ControlPlane(t *testing.T) { + output := renderChartTemplate(t, "../../charts/cozystack", "templates/controlplane.yaml") + + // Legacy format: machine.network section present + assertContains(t, output, "machine:") + assertContains(t, output, "network:") + assertContains(t, output, "hostname:") + assertContains(t, output, "nameservers:") + assertContains(t, output, "interfaces:") + + // Legacy format: machine.registries section present + assertContains(t, output, "registries:") + assertContains(t, output, "mirrors:") + assertContains(t, output, "docker.io:") + assertContains(t, output, "https://mirror.gcr.io") + + // Legacy format: cluster section present + assertContains(t, output, "cluster:") + assertContains(t, output, "clusterName:") + assertContains(t, output, "controlPlane:") + assertContains(t, output, "endpoint:") + + // Legacy format: cozystack-specific sections present + assertContains(t, output, "sysctls:") + assertContains(t, output, "kernel:") + assertContains(t, output, "kubelet:") + assertContains(t, output, "certSANs:") + assertContains(t, output, "install:") + + // Legacy format: controlplane-specific settings + assertContains(t, output, "allowSchedulingOnControlPlanes:") + assertContains(t, output, "etcd:") + assertContains(t, output, "proxy:") + + // Legacy format: no v1.12 multi-doc types + assertNotContains(t, output, "kind: HostnameConfig") + assertNotContains(t, output, "kind: ResolverConfig") + assertNotContains(t, output, "kind: LinkConfig") + assertNotContains(t, output, "kind: BondConfig") + assertNotContains(t, output, "kind: VLANConfig") + assertNotContains(t, output, "kind: RegistryMirrorConfig") + assertNotContains(t, output, "kind: Layer2VIPConfig") +} + +func TestLegacyCozystack_Worker(t *testing.T) { + output := renderChartTemplate(t, "../../charts/cozystack", "templates/worker.yaml") + + // Legacy format: machine section present + assertContains(t, output, "machine:") + assertContains(t, output, "type: worker") + assertContains(t, output, "network:") + assertContains(t, output, "hostname:") + assertContains(t, output, "interfaces:") + assertContains(t, output, "registries:") + + // Legacy format: cluster section present + assertContains(t, output, "cluster:") + + // Worker should NOT have controlplane-specific settings + assertNotContains(t, output, "allowSchedulingOnControlPlanes:") + + // No v1.12 multi-doc types + assertNotContains(t, output, "kind: HostnameConfig") + assertNotContains(t, output, "kind: RegistryMirrorConfig") + assertNotContains(t, output, "kind: Layer2VIPConfig") +} + +func TestLegacyGeneric_ControlPlane(t *testing.T) { + output := renderChartTemplate(t, "../../charts/generic", "templates/controlplane.yaml") + + // Legacy format: machine.network section present + assertContains(t, output, "machine:") + assertContains(t, output, "network:") + assertContains(t, output, "hostname:") + assertContains(t, output, "nameservers:") + assertContains(t, output, "interfaces:") + + // Legacy format: cluster section present + assertContains(t, output, "cluster:") + assertContains(t, output, "clusterName:") + assertContains(t, output, "controlPlane:") + assertContains(t, output, "endpoint:") + + // Generic does NOT have registries + assertNotContains(t, output, "registries:") + + // No v1.12 multi-doc types + assertNotContains(t, output, "kind: HostnameConfig") + assertNotContains(t, output, "kind: ResolverConfig") + assertNotContains(t, output, "kind: LinkConfig") + assertNotContains(t, output, "kind: RegistryMirrorConfig") +} + +func TestLegacyGeneric_Worker(t *testing.T) { + output := renderChartTemplate(t, "../../charts/generic", "templates/worker.yaml") + + // Legacy format: machine section present + assertContains(t, output, "machine:") + assertContains(t, output, "type: worker") + assertContains(t, output, "network:") + assertContains(t, output, "hostname:") + assertContains(t, output, "interfaces:") + + // Legacy format: cluster section present + assertContains(t, output, "cluster:") + + // No v1.12 multi-doc types + assertNotContains(t, output, "kind: HostnameConfig") + assertNotContains(t, output, "kind: LinkConfig") +} + +// --- Multi-doc format tests for cozystack (v1.12+) --- + +func TestMultiDocCozystack_ControlPlane(t *testing.T) { + output := renderChartTemplate(t, "../../charts/cozystack", "templates/controlplane.yaml", "v1.12") + + // Multi-doc: machine section retains non-deprecated fields + assertContains(t, output, "machine:") + assertContains(t, output, "type: controlplane") + assertContains(t, output, "kubelet:") + assertContains(t, output, "sysctls:") + assertContains(t, output, "kernel:") + assertContains(t, output, "certSANs:") + assertContains(t, output, "install:") + assertContains(t, output, "files:") + + // Multi-doc: deprecated machine.network and machine.registries REMOVED + assertNotContains(t, output, " interfaces:") + assertNotContains(t, output, " mirrors:") + + // Multi-doc: cluster section unchanged + assertContains(t, output, "cluster:") + assertContains(t, output, "clusterName:") + assertContains(t, output, "controlPlane:") + assertContains(t, output, "allowSchedulingOnControlPlanes:") + assertContains(t, output, "etcd:") + assertContains(t, output, "proxy:") + + // Multi-doc: new document types present + assertContains(t, output, "---") + assertContains(t, output, "kind: HostnameConfig") + assertContains(t, output, "kind: ResolverConfig") + assertContains(t, output, "kind: RegistryMirrorConfig") + assertContains(t, output, "https://mirror.gcr.io") + + // Multi-doc: network interface document present (LinkConfig or BondConfig) + hasLinkConfig := strings.Contains(output, "kind: LinkConfig") + hasBondConfig := strings.Contains(output, "kind: BondConfig") + if !hasLinkConfig && !hasBondConfig { + t.Errorf("expected output to contain either LinkConfig or BondConfig document") + } +} + +func TestMultiDocCozystack_Worker(t *testing.T) { + output := renderChartTemplate(t, "../../charts/cozystack", "templates/worker.yaml", "v1.12") + + // Multi-doc: machine section + assertContains(t, output, "machine:") + assertContains(t, output, "type: worker") + assertContains(t, output, "kubelet:") + assertContains(t, output, "install:") + + // Multi-doc: deprecated fields REMOVED + assertNotContains(t, output, " interfaces:") + assertNotContains(t, output, " mirrors:") + + // Multi-doc: new document types present + assertContains(t, output, "kind: HostnameConfig") + assertContains(t, output, "kind: ResolverConfig") + assertContains(t, output, "kind: RegistryMirrorConfig") + + // Worker should NOT have VIP or controlplane cluster settings + assertNotContains(t, output, "kind: Layer2VIPConfig") + assertNotContains(t, output, "allowSchedulingOnControlPlanes:") +} + +func TestMultiDocCozystack_LegacyFallback(t *testing.T) { + // v1.11 should produce legacy format even for cozystack chart + output := renderChartTemplate(t, "../../charts/cozystack", "templates/controlplane.yaml", "v1.11") + + // Legacy format present + assertContains(t, output, " interfaces:") + assertContains(t, output, " registries:") + assertContains(t, output, " mirrors:") + + // No multi-doc types + assertNotContains(t, output, "kind: HostnameConfig") + assertNotContains(t, output, "kind: RegistryMirrorConfig") +} + +func TestMultiDocCozystack_PreReleaseVersion(t *testing.T) { + // Pre-release v1.12 versions should still use multi-doc format + output := renderChartTemplate(t, "../../charts/cozystack", "templates/controlplane.yaml", "v1.12.0-alpha.1") + + assertContains(t, output, "kind: HostnameConfig") + assertContains(t, output, "kind: RegistryMirrorConfig") + assertNotContains(t, output, " interfaces:") +} + +func TestMultiDocCozystack_TwoComponentVersion(t *testing.T) { + // Two-component version string (v1.12 without patch) should work + output := renderChartTemplate(t, "../../charts/cozystack", "templates/controlplane.yaml", "v1.12") + + assertContains(t, output, "kind: HostnameConfig") + assertContains(t, output, "kind: RegistryMirrorConfig") + assertNotContains(t, output, " interfaces:") +} + +func TestLegacyCozystack_NrHugepages(t *testing.T) { + // Test nr_hugepages is rendered correctly in legacy format + helmEngine.LookupFunc = func(string, string, string) (map[string]any, error) { + return map[string]any{}, nil + } + + chrt, err := loader.LoadDir("../../charts/cozystack") + if err != nil { + t.Fatal(err) + } + + values := make(map[string]any) + maps.Copy(values, chrt.Values) + values["nr_hugepages"] = 1024 + + eng := helmEngine.Engine{} + out, err := eng.Render(chrt, chartutil.Values{ + "Values": values, + }) + if err != nil { + t.Fatal(err) + } + + result := out["cozystack/templates/controlplane.yaml"] + assertContains(t, result, `vm.nr_hugepages: "1024"`) +} + +func TestMultiDocCozystack_NrHugepages(t *testing.T) { + // Test nr_hugepages is rendered correctly (non-zero value) + helmEngine.LookupFunc = func(string, string, string) (map[string]any, error) { + return map[string]any{}, nil + } + + chrt, err := loader.LoadDir("../../charts/cozystack") + if err != nil { + t.Fatal(err) + } + + values := make(map[string]any) + maps.Copy(values, chrt.Values) + values["nr_hugepages"] = 1024 + + eng := helmEngine.Engine{} + out, err := eng.Render(chrt, chartutil.Values{ + "Values": values, + "TalosVersion": "v1.12", + }) + if err != nil { + t.Fatal(err) + } + + result := out["cozystack/templates/controlplane.yaml"] + assertContains(t, result, `vm.nr_hugepages: "1024"`) +} + +// --- Multi-doc format tests for generic (v1.12+) --- + +func TestMultiDocGeneric_ControlPlane(t *testing.T) { + output := renderChartTemplate(t, "../../charts/generic", "templates/controlplane.yaml", "v1.12") + + // Multi-doc: machine section still present but WITHOUT legacy network fields + assertContains(t, output, "machine:") + assertContains(t, output, "type: controlplane") + assertContains(t, output, "kubelet:") + assertContains(t, output, "install:") + + // Multi-doc: deprecated machine.network fields REMOVED (hostname, nameservers, interfaces) + assertNotContains(t, output, " interfaces:") + + // Multi-doc: cluster section still present + assertContains(t, output, "cluster:") + assertContains(t, output, "clusterName:") + assertContains(t, output, "controlPlane:") + assertContains(t, output, "endpoint:") + + // Multi-doc: new document types present + assertContains(t, output, "---") + assertContains(t, output, "kind: HostnameConfig") + assertContains(t, output, "kind: ResolverConfig") + assertContains(t, output, "kind: LinkConfig") + + // Generic does NOT have registries + assertNotContains(t, output, "kind: RegistryMirrorConfig") +} + +func TestMultiDocGeneric_Worker(t *testing.T) { + output := renderChartTemplate(t, "../../charts/generic", "templates/worker.yaml", "v1.12") + + // Multi-doc: machine section present + assertContains(t, output, "machine:") + assertContains(t, output, "type: worker") + assertContains(t, output, "kubelet:") + assertContains(t, output, "install:") + + // Multi-doc: deprecated machine.network fields REMOVED + assertNotContains(t, output, " interfaces:") + + // Multi-doc: new document types present + assertContains(t, output, "kind: HostnameConfig") + assertContains(t, output, "kind: ResolverConfig") + assertContains(t, output, "kind: LinkConfig") + + // Worker should NOT have VIP + assertNotContains(t, output, "kind: Layer2VIPConfig") +} + +func TestMultiDocGeneric_LegacyFallback(t *testing.T) { + // v1.11 should produce legacy format + output := renderChartTemplate(t, "../../charts/generic", "templates/controlplane.yaml", "v1.11") + + // Legacy format: machine.network present + assertContains(t, output, " network:") + assertContains(t, output, "hostname:") + assertContains(t, output, "interfaces:") + + // No multi-doc types + assertNotContains(t, output, "kind: HostnameConfig") + assertNotContains(t, output, "kind: LinkConfig") +} + // createTestChart creates a minimal Helm chart in a temp directory with the // given template content. Returns the chart root path. func createTestChart(t *testing.T, chartName, templateName, templateContent string) string { From dd1ede9c2d8957d844866f44dd8a8f5b229c4a91 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Tue, 14 Apr 2026 17:01:59 +0300 Subject: [PATCH 2/6] fix(engine): validate TalosVersion before template rendering Call ParseContractFromVersion early in Render() so malformed values (e.g. "latest", "foobar") surface a user-friendly "invalid talos-version" error instead of the opaque "semverCompare: invalid semantic version" that otherwise escapes from deep inside the Helm engine template evaluation. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- pkg/engine/engine.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pkg/engine/engine.go b/pkg/engine/engine.go index a0da2889..fcf87d51 100644 --- a/pkg/engine/engine.go +++ b/pkg/engine/engine.go @@ -205,6 +205,15 @@ func SerializeConfiguration(configBundle *bundle.Bundle, machineType machine.Typ // Render executes the rendering of templates based on the provided options. func Render(ctx context.Context, c *client.Client, opts Options) ([]byte, error) { + // Validate TalosVersion early so malformed values surface a user-friendly + // error instead of an opaque "semverCompare: invalid semantic version" from + // inside template rendering. + if opts.TalosVersion != "" { + if _, err := config.ParseContractFromVersion(opts.TalosVersion); err != nil { + return nil, fmt.Errorf("invalid talos-version: %w", err) + } + } + // Gather facts and enable lookup options if !opts.Offline { if err := helpers.FailIfMultiNodes(ctx, "talm template"); err != nil { From 08e93d3bdb5e9003dbd4ae64f675daf8fa503133 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Tue, 14 Apr 2026 17:02:09 +0300 Subject: [PATCH 3/6] test(engine): cover bond/vlan topologies, invalid version, Layer2VIPConfig - TestRenderInvalidTalosVersion: exercises the new early validation. - TestMultiDocCozystack_BondTopology / TestMultiDocGeneric_BondTopology: assert BondConfig emission with mock links/routes/addresses lookups. - TestMultiDocCozystack_VlanOnBondTopology / TestMultiDocGeneric_VlanOnBondTopology: assert BondConfig + VLANConfig combined emission when the default route goes through a VLAN. - TestMultiDocCozystack_ControlPlane: add positive assert for Layer2VIPConfig since cozystack defaults set floatingIP. - TestLegacy/MultiDocCozystack_NrHugepages: restore the package-level helmEngine.LookupFunc via t.Cleanup for test isolation. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- pkg/engine/render_test.go | 392 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 392 insertions(+) diff --git a/pkg/engine/render_test.go b/pkg/engine/render_test.go index e863951c..cb06f798 100644 --- a/pkg/engine/render_test.go +++ b/pkg/engine/render_test.go @@ -15,6 +15,7 @@ package engine import ( + "context" "maps" "os" "path" @@ -232,6 +233,11 @@ func TestMultiDocCozystack_ControlPlane(t *testing.T) { assertContains(t, output, "kind: RegistryMirrorConfig") assertContains(t, output, "https://mirror.gcr.io") + // Multi-doc: Layer2VIPConfig emitted for controlplane when floatingIP is + // set in values (cozystack default: 192.168.100.10). + assertContains(t, output, "kind: Layer2VIPConfig") + assertContains(t, output, "192.168.100.10") + // Multi-doc: network interface document present (LinkConfig or BondConfig) hasLinkConfig := strings.Contains(output, "kind: LinkConfig") hasBondConfig := strings.Contains(output, "kind: BondConfig") @@ -297,6 +303,8 @@ func TestMultiDocCozystack_TwoComponentVersion(t *testing.T) { func TestLegacyCozystack_NrHugepages(t *testing.T) { // Test nr_hugepages is rendered correctly in legacy format + origLookup := helmEngine.LookupFunc + t.Cleanup(func() { helmEngine.LookupFunc = origLookup }) helmEngine.LookupFunc = func(string, string, string) (map[string]any, error) { return map[string]any{}, nil } @@ -324,6 +332,8 @@ func TestLegacyCozystack_NrHugepages(t *testing.T) { func TestMultiDocCozystack_NrHugepages(t *testing.T) { // Test nr_hugepages is rendered correctly (non-zero value) + origLookup := helmEngine.LookupFunc + t.Cleanup(func() { helmEngine.LookupFunc = origLookup }) helmEngine.LookupFunc = func(string, string, string) (map[string]any, error) { return map[string]any{}, nil } @@ -572,3 +582,385 @@ func TestRenderOfflineSkipsLookupFunc(t *testing.T) { // if !opts.Offline { helmEngine.LookupFunc = newLookupFunction(ctx, c) } // This is tested implicitly by the online_lookup_populates_interface subtest. } + +// bondTopologyLookup returns a LookupFunc emulating a bonded interface with +// two physical slaves and a default route pointing through it. Used by +// BondConfig rendering tests. +func bondTopologyLookup() func(string, string, string) (map[string]any, error) { + bondLink := map[string]any{ + "metadata": map[string]any{"id": "bond0"}, + "spec": map[string]any{ + "kind": "bond", + "index": 10, + "bondMaster": map[string]any{ + "mode": "802.3ad", + "xmitHashPolicy": "layer3+4", + "lacpRate": "fast", + "miimon": 100, + }, + "hardwareAddr": "aa:bb:cc:dd:ee:ff", + "busPath": "pci-0000:00:1f.6", + }, + } + eth0 := map[string]any{ + "metadata": map[string]any{"id": "eth0"}, + "spec": map[string]any{ + "kind": "physical", + "slaveKind": "bond", + "masterIndex": 10, + "hardwareAddr": "aa:bb:cc:dd:ee:00", + "busPath": "pci-0000:00:1f.0", + }, + } + eth1 := map[string]any{ + "metadata": map[string]any{"id": "eth1"}, + "spec": map[string]any{ + "kind": "physical", + "slaveKind": "bond", + "masterIndex": 10, + "hardwareAddr": "aa:bb:cc:dd:ee:01", + "busPath": "pci-0000:00:1f.1", + }, + } + routesList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{ + map[string]any{ + "spec": map[string]any{ + "dst": "", + "gateway": "192.168.1.1", + "outLinkName": "bond0", + "family": "inet4", + "table": "main", + }, + }, + }, + } + linksList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{bondLink, eth0, eth1}, + } + addressesList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{ + map[string]any{ + "spec": map[string]any{ + "linkName": "bond0", + "address": "192.168.1.100/24", + "family": "inet4", + "scope": "global", + }, + }, + }, + } + nodeDefault := map[string]any{ + "spec": map[string]any{ + "addresses": []any{"192.168.1.100/24"}, + }, + } + resolvers := map[string]any{ + "spec": map[string]any{ + "dnsServers": []any{"8.8.8.8", "1.1.1.1"}, + }, + } + return func(resource, namespace, id string) (map[string]any, error) { + switch resource { + case "routes": + return routesList, nil + case "links": + if id == "bond0" { + return bondLink, 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 + } +} + +// vlanOnBondTopologyLookup returns a LookupFunc emulating a VLAN interface +// stacked on top of a bond. Used by VLANConfig rendering tests. +func vlanOnBondTopologyLookup() func(string, string, string) (map[string]any, error) { + bondLink := map[string]any{ + "metadata": map[string]any{"id": "bond0"}, + "spec": map[string]any{ + "kind": "bond", + "index": 10, + "bondMaster": map[string]any{ + "mode": "802.3ad", + }, + "hardwareAddr": "aa:bb:cc:dd:ee:ff", + "busPath": "pci-0000:00:1f.6", + }, + } + vlanLink := map[string]any{ + "metadata": map[string]any{"id": "bond0.100"}, + "spec": map[string]any{ + "kind": "vlan", + "index": 42, + "linkIndex": 10, + "vlan": map[string]any{"vlanID": 100}, + }, + } + eth0 := map[string]any{ + "metadata": map[string]any{"id": "eth0"}, + "spec": map[string]any{ + "kind": "physical", + "slaveKind": "bond", + "masterIndex": 10, + "hardwareAddr": "aa:bb:cc:dd:ee:00", + "busPath": "pci-0000:00:1f.0", + }, + } + eth1 := map[string]any{ + "metadata": map[string]any{"id": "eth1"}, + "spec": map[string]any{ + "kind": "physical", + "slaveKind": "bond", + "masterIndex": 10, + "hardwareAddr": "aa:bb:cc:dd:ee:01", + "busPath": "pci-0000:00:1f.1", + }, + } + routesList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{ + map[string]any{ + "spec": map[string]any{ + "dst": "", + "gateway": "10.0.0.1", + "outLinkName": "bond0.100", + "family": "inet4", + "table": "main", + }, + }, + }, + } + linksList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{bondLink, vlanLink, eth0, eth1}, + } + addressesList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{ + map[string]any{ + "spec": map[string]any{ + "linkName": "bond0.100", + "address": "10.0.0.50/24", + "family": "inet4", + "scope": "global", + }, + }, + }, + } + nodeDefault := map[string]any{ + "spec": map[string]any{ + "addresses": []any{"10.0.0.50/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 "bond0": + return bondLink, nil + case "bond0.100": + return vlanLink, 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 + } +} + +func TestMultiDocCozystack_BondTopology(t *testing.T) { + origLookup := helmEngine.LookupFunc + t.Cleanup(func() { helmEngine.LookupFunc = origLookup }) + helmEngine.LookupFunc = bondTopologyLookup() + + chrt, err := loader.LoadDir("../../charts/cozystack") + if err != nil { + t.Fatal(err) + } + + eng := helmEngine.Engine{} + out, err := eng.Render(chrt, chartutil.Values{ + "Values": chrt.Values, + "TalosVersion": "v1.12", + }) + if err != nil { + t.Fatal(err) + } + + result := out["cozystack/templates/controlplane.yaml"] + assertContains(t, result, "kind: BondConfig") + assertContains(t, result, "name: bond0") + assertContains(t, result, "- eth0") + assertContains(t, result, "- eth1") + assertContains(t, result, "bondMode: 802.3ad") + assertContains(t, result, "xmitHashPolicy: layer3+4") + assertContains(t, result, "lacpRate: fast") + assertContains(t, result, "address: 192.168.1.100/24") + assertContains(t, result, "gateway: 192.168.1.1") + assertNotContains(t, result, "kind: LinkConfig") + assertNotContains(t, result, "kind: VLANConfig") +} + +func TestMultiDocCozystack_VlanOnBondTopology(t *testing.T) { + origLookup := helmEngine.LookupFunc + t.Cleanup(func() { helmEngine.LookupFunc = origLookup }) + helmEngine.LookupFunc = vlanOnBondTopologyLookup() + + chrt, err := loader.LoadDir("../../charts/cozystack") + if err != nil { + t.Fatal(err) + } + + eng := helmEngine.Engine{} + out, err := eng.Render(chrt, chartutil.Values{ + "Values": chrt.Values, + "TalosVersion": "v1.12", + }) + if err != nil { + t.Fatal(err) + } + + result := out["cozystack/templates/controlplane.yaml"] + assertContains(t, result, "kind: BondConfig") + assertContains(t, result, "kind: VLANConfig") + assertContains(t, result, "name: bond0.100") + assertContains(t, result, "vlanID: 100") + assertContains(t, result, "parent: bond0") + assertContains(t, result, "address: 10.0.0.50/24") + assertContains(t, result, "gateway: 10.0.0.1") + assertNotContains(t, result, "kind: LinkConfig") +} + +func TestMultiDocGeneric_BondTopology(t *testing.T) { + origLookup := helmEngine.LookupFunc + t.Cleanup(func() { helmEngine.LookupFunc = origLookup }) + helmEngine.LookupFunc = bondTopologyLookup() + + chrt, err := loader.LoadDir("../../charts/generic") + if err != nil { + t.Fatal(err) + } + + eng := helmEngine.Engine{} + out, err := eng.Render(chrt, chartutil.Values{ + "Values": chrt.Values, + "TalosVersion": "v1.12", + }) + if err != nil { + t.Fatal(err) + } + + result := out["generic/templates/controlplane.yaml"] + assertContains(t, result, "kind: BondConfig") + assertContains(t, result, "name: bond0") + assertContains(t, result, "bondMode: 802.3ad") + assertContains(t, result, "- eth0") + assertContains(t, result, "- eth1") + assertNotContains(t, result, "kind: LinkConfig") + assertNotContains(t, result, "kind: VLANConfig") +} + +func TestMultiDocGeneric_VlanOnBondTopology(t *testing.T) { + origLookup := helmEngine.LookupFunc + t.Cleanup(func() { helmEngine.LookupFunc = origLookup }) + helmEngine.LookupFunc = vlanOnBondTopologyLookup() + + chrt, err := loader.LoadDir("../../charts/generic") + if err != nil { + t.Fatal(err) + } + + eng := helmEngine.Engine{} + out, err := eng.Render(chrt, chartutil.Values{ + "Values": chrt.Values, + "TalosVersion": "v1.12", + }) + if err != nil { + t.Fatal(err) + } + + result := out["generic/templates/controlplane.yaml"] + assertContains(t, result, "kind: BondConfig") + assertContains(t, result, "kind: VLANConfig") + assertContains(t, result, "vlanID: 100") + assertContains(t, result, "parent: bond0") + assertContains(t, result, "address: 10.0.0.50/24") + assertNotContains(t, result, "kind: LinkConfig") +} + +// TestRenderInvalidTalosVersion verifies that malformed TalosVersion values +// surface a user-friendly error before template rendering, instead of the +// opaque "error calling semverCompare: invalid semantic version" that escapes +// from deep inside the Helm engine. +func TestRenderInvalidTalosVersion(t *testing.T) { + chartRoot := createTestChart(t, "dummy", "config.yaml", "machine:\n type: worker\n") + + tests := []struct { + name string + version string + }{ + {"plain word", "latest"}, + {"garbage", "foobar"}, + {"v-prefixed garbage", "vlatest"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := Options{ + Offline: true, + Root: chartRoot, + TalosVersion: tt.version, + TemplateFiles: []string{"templates/config.yaml"}, + } + _, err := Render(context.Background(), nil, opts) + if err == nil { + t.Fatalf("Render(%q) expected error, got nil", tt.version) + } + if !strings.Contains(err.Error(), "invalid talos-version") { + t.Errorf("Render(%q) error = %q, want prefix 'invalid talos-version'", tt.version, err.Error()) + } + }) + } +} From 7f9957b06232e3ff111ff885dbfc73d1801f56aa Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Tue, 14 Apr 2026 17:02:17 +0300 Subject: [PATCH 4/6] docs: refine multi-doc README note and machine.common helper comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README: clarify that not all multi-doc types are always present — HostnameConfig/ResolverConfig and one network interface document are always emitted, Layer2VIPConfig depends on floatingIP on controlplane, and RegistryMirrorConfig is cozystack-only. - charts/cozystack _helpers.tpl: the talos.config.machine.common helper also renders nodeLabels for controlplane nodes — update the comment. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- README.md | 2 +- charts/cozystack/templates/_helpers.tpl | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5c36017c..750bb337 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ cluster: > **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 (`HostnameConfig`, `ResolverConfig`, `LinkConfig`, `BondConfig`, `VLANConfig`, `RegistryMirrorConfig`, `Layer2VIPConfig`) instead of the deprecated monolithic fields. +> 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. Apply config: ```bash diff --git a/charts/cozystack/templates/_helpers.tpl b/charts/cozystack/templates/_helpers.tpl index 5e7412f4..b856011a 100644 --- a/charts/cozystack/templates/_helpers.tpl +++ b/charts/cozystack/templates/_helpers.tpl @@ -6,7 +6,7 @@ {{- end }} {{- end }} -{{- /* Shared machine section: type, kubelet, sysctls, kernel, certSANs, files, install */ -}} +{{- /* Shared machine section: type, nodeLabels (controlplane), kubelet, sysctls, kernel, certSANs, files, install */ -}} {{- define "talos.config.machine.common" }} machine: {{- if eq .MachineType "controlplane" }} From cac8dd9560492722808fc5c549dab16e318f5dc2 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Tue, 14 Apr 2026 18:27:25 +0300 Subject: [PATCH 5/6] fix(helpers): attach Layer2VIP to VLAN interface in multi-doc path In the VLAN branch of talos.config.network.multidoc, $interfaceName is rewritten to the parent link, while addresses/routes are emitted on the VLAN link ($defaultLinkName). Using $interfaceName for Layer2VIPConfig.link moved the VIP off the configured VLAN. Select $defaultLinkName when the default link is a VLAN so the VIP stays attached to the VLAN interface, matching the legacy path behavior. Address review feedback from coderabbitai on charts/cozystack/templates/_helpers.tpl:229. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- charts/cozystack/templates/_helpers.tpl | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/charts/cozystack/templates/_helpers.tpl b/charts/cozystack/templates/_helpers.tpl index b856011a..9dae816b 100644 --- a/charts/cozystack/templates/_helpers.tpl +++ b/charts/cozystack/templates/_helpers.tpl @@ -221,12 +221,16 @@ addresses: routes: - gateway: {{ include "talm.discovered.default_gateway" . }} {{- end }} +{{- $vipLinkName := $interfaceName }} +{{- if $isVlan }} +{{- $vipLinkName = $defaultLinkName }} +{{- end }} {{- if and .Values.floatingIP (eq .MachineType "controlplane") }} --- apiVersion: v1alpha1 kind: Layer2VIPConfig name: {{ .Values.floatingIP | quote }} -link: {{ $interfaceName }} +link: {{ $vipLinkName }} {{- end }} {{- end }} From 463bfb7726786d8e8e208a818a5eaf68011d8691 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Tue, 14 Apr 2026 18:27:50 +0300 Subject: [PATCH 6/6] fix(helpers): attach Layer2VIP to VLAN interface in generic multi-doc path Mirror of the cozystack fix: when the default link is a VLAN, $interfaceName is rewritten to the parent link while the VLAN addresses/routes are emitted on $defaultLinkName. Use $defaultLinkName for Layer2VIPConfig.link in that branch so the VIP stays on the VLAN. Address review feedback from coderabbitai on charts/generic/templates/_helpers.tpl:152. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- charts/generic/templates/_helpers.tpl | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/charts/generic/templates/_helpers.tpl b/charts/generic/templates/_helpers.tpl index 11b939a9..78f1724f 100644 --- a/charts/generic/templates/_helpers.tpl +++ b/charts/generic/templates/_helpers.tpl @@ -144,12 +144,16 @@ addresses: routes: - gateway: {{ include "talm.discovered.default_gateway" . }} {{- end }} +{{- $vipLinkName := $interfaceName }} +{{- if $isVlan }} +{{- $vipLinkName = $defaultLinkName }} +{{- end }} {{- if and .Values.floatingIP (eq .MachineType "controlplane") }} --- apiVersion: v1alpha1 kind: Layer2VIPConfig name: {{ .Values.floatingIP | quote }} -link: {{ $interfaceName }} +link: {{ $vipLinkName }} {{- end }} {{- end }}