Skip to content

Commit 473b847

Browse files
authored
fix(chart): discovery-based defaults for subnets; required endpoint and VIP (#130)
* fix(chart): default kubelet.validSubnets from discovered primary subnet When .Values.advertisedSubnets is unset in values.yaml, fall back to the CIDR of the node's default-gateway-bearing link (as returned by talm.discovered.default_addresses_by_gateway). Previously the chart emitted whatever was in values.yaml verbatim, so a stale placeholder like 192.168.100.0/24 silently landed in machine configs for nodes on completely different networks. Talos' validSubnets matching uses net.ParseCIDR + IPNet.Contains, so emitting the node's IP-with-prefix (e.g. 192.168.201.10/24) is semantically equivalent to the network form (192.168.201.0/24) — no network-alignment math needed. Applied symmetrically to both cozystack and generic presets. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> * fix(chart): default etcd.advertisedSubnets from discovered primary subnet Same shape as the kubelet.validSubnets fallback: when .Values.advertisedSubnets is unset, pick the CIDR of the node's default-gateway-bearing link via talm.discovered.default_addresses_by_gateway instead of silently emitting the stale 192.168.100.0/24 placeholder baked into values.yaml. etcd matches advertisedSubnets against local addresses using the same CIDR semantics as kubelet, so the discovered IP/prefix form is safe to emit directly. Applied symmetrically to both cozystack and generic presets. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> * fix(chart): require cluster endpoint in values.yaml Replace the bare "{{ .Values.endpoint }}" interpolation with Helm's required() so an unset or empty endpoint produces a clear error at render time instead of silently embedding a stale 192.168.100.10:6443 placeholder in machine configs. Unlike kubelet validSubnets and etcd advertisedSubnets, endpoint has no per-node fallback: it is the cluster-wide URL every node dials for the API server, so it must come from operator input (VIP, LB, single-node IP) — there is nothing to discover locally. Applied symmetrically to both cozystack and generic presets. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> * chore(values): drop misleading placeholder subnet + clarify endpoint Two related changes to the chart's default values file: - advertisedSubnets: was pre-populated with 192.168.100.0/24. Every user not running on that subnet got a stale value silently baked into their machine config. Now defaults to an empty list so the chart's new fallback path (default_addresses_by_gateway) kicks in. The in-file comment explains when to override. - endpoint: the placeholder https://192.168.100.10:6443 is kept as an example but the comment is rewritten to mark the field REQUIRED and explain why no auto-discovery is possible (cluster-wide value, not per-node). The chart now uses required() so an unset or empty value fails loudly rather than silently embedding this placeholder. Applied symmetrically to cozystack and generic presets. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> * test(engine): cover discovery-based subnet fallbacks and required endpoint Five new tests pin the behavior introduced by the chart changes in this branch: - TestMultiDocCozystack_ValidSubnetsFallsBackToDiscovery — empty advertisedSubnets in values, lookup yields 192.168.201.10/24 on the gateway-bearing link; rendered kubelet.validSubnets must contain the discovered CIDR and must NOT contain the historical 192.168.100.0/24 placeholder. - TestMultiDocCozystack_AdvertisedSubnetsFallsBackToDiscovery — same setup, assertion on etcd.advertisedSubnets. - TestMultiDocCozystack_ValuesAdvertisedSubnetsOverridesDiscovery — explicit [10.0.0.0/8] in values; rendered config must use the operator's value in both subnet-selector fields, discovered CIDR must not leak into either. Counts occurrences of the override (expect ≥2 — once per consumer) and rejects the discovered CIDR in a subnet-list context (excluding LinkConfig address lines). - TestMultiDocCozystack_EndpointRequired — empty endpoint in values; Render must fail with Helm's required() error mentioning endpoint, instead of silently embedding the stale placeholder. - TestMultiDocGeneric_ValidSubnetsFallsBackToDiscovery — equivalent smoke check for the generic preset. Adds simpleNicLookup() — a minimal single-physical-interface lookup fixture with a deliberately non-100.* subnet so the tests can distinguish 'discovered' from 'historical default' in the output. Adds renderCozystackWith / renderGenericWith — thin wrappers over the existing chart-render pattern used by TestMultiDoc* suites, keeping the new tests readable. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> * fix(values): blank default endpoint so required fires on fresh install Previously 'endpoint' kept its https://192.168.100.10:6443 placeholder even after the chore(values) commit dropped advertisedSubnets — meaning Helm's required() guard never fired for a fresh user who kept defaults; the chart silently embedded the wrong endpoint. Set endpoint to the empty string so required() surfaces the missing value loudly. Comment block above the key explains the motivation and points at an example for operators to copy. Same change applied symmetrically to cozystack and generic presets. Also adds a commented format-example under advertisedSubnets so operators overriding discovery have a syntax reference. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> * test(engine): adapt existing tests to empty-endpoint default; add fresh-default + worker coverage Two mechanical changes + two new tests motivated by the fix(values) commit that blanks the shipped endpoint: 1. Existing TestMultiDoc*, TestLegacyCozystack_NrHugepages and the renderChartTemplate helper previously relied on chrt.Values carrying a non-empty endpoint default. With the default blank they all hit the required() guard and fail. Inject a testEndpoint constant (https://talm-test.invalid:6443) in tests that are not specifically exercising the guard, so the rest of the chart is still reachable. 2. renderCozystackWith / renderGenericWith helpers gain an auto-inject so all new tests going through them don't have to spell out the endpoint. Two new tests: - TestMultiDocCozystack_ShippedDefaultsFailFresh — renders with chrt.Values exactly as shipped, expects a required() error. This test would fail if a future commit reintroduced a placeholder endpoint into values.yaml. Pins the fix(values) commit's intent at the test level. - TestMultiDocCozystack_WorkerValidSubnetsFallsBackToDiscovery — kubelet.validSubnets lives in the shared talos.config.machine.common block, so it is emitted for workers too. This test guards against a regression that would only break the worker path. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> * fix(chart): canonical subnet form + required guard on empty discovery Two correctness fixes to the discovery fallback: 1. Emit the network form of the CIDR (192.168.201.0/24), not the host form (192.168.201.10/24). Talos matches both equivalently via netip.Prefix.Contains, but the ticket reporter explicitly asked for the canonical network form, and every Talos doc and third-party example uses it. Host-form output in a subnet-selector field is a trap for anyone comparing rendered configs against upstream references. Adds a small cidrNetwork template function to pkg/engine/helm that wraps net/netip.ParsePrefix+Masked and handles IPv4/IPv6 uniformly. Sprig ships no equivalent. The chart helpers pipe the discovered address through it in the fallback branches. 2. When the operator leaves advertisedSubnets empty AND discovery yields no default-gateway-bearing link (offline render, pre-boot node, no default route), the previous fallback produced an empty validSubnets list silently. A silent empty field is worse than the old broken default because nothing surfaces the problem. Wrap the fallback in a required() guard that fires on empty discovery with a clear remediation message pointing at both options (set values.yaml explicitly, or fix the node's default route). The etcd fallback reuses the validSubnets guard's fire path — same chart render, same .Values state — so a second required() there would just add noise without extra signal. Test updates: - All existing assertions on 192.168.201.10/24 in the subnet- selector fields switched to 192.168.201.0/24 (the masked form). LinkConfig's 'address: 192.168.201.10/24' stays host-form, which is correct for interface addresses. - New TestMultiDocCozystack_EmptyDiscoveryErrors exercises the new required() guard: advertisedSubnets empty + empty lookup → assert render fails with advertisedSubnets in the error message. - The override-precedence leak check updated to scan for the fallback's canonical form rather than the host form. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> * docs: couple endpoint+floatingIP in values.yaml and README getting-started Two adjacent docs fixes: - README getting-started previously said to run talm init then talm template -t ... > nodes/node1.yaml. With the new required-endpoint guard, that second command fails with a required() error because values.yaml ships endpoint empty. Add an explicit 'edit values.yaml' step between init and template, covering the endpoint + floatingIP coupling for cozystack deployments and the single-node / external-LB alternatives. Subnet fields are now covered in the same paragraph since they discover automatically. - charts/cozystack/values.yaml gained a long endpoint comment earlier in this branch but still left a silent trap: users who set 'endpoint: https://192.168.100.10:6443' by copying the example without also editing floatingIP ended up with an endpoint pointing at an IP no node VIP-claims. Comment now makes the coupling explicit and tells single-node / external-LB users to blank floatingIP. The floatingIP key itself gained a one-line comment pointing back at endpoint. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> * fix(values): blank default floatingIP so VIP never ships a placeholder Same reasoning as the earlier endpoint-blanking commit: floatingIP shipped as 192.168.100.10, which for any user whose network was not 192.168.100.0/24 silently embedded a Layer2VIPConfig pointing at an IP no node claims. Even worse than the endpoint case because Talos actively advertises the bogus VIP on the primary link, visibly broken without any kube-proxy round trip. Blank the default so operators opt into VIP explicitly. Single-node clusters and external-LB topologies leave it blank and get no VIP document; cozystack multi-node setups set it to match endpoint. Regression guards: TestMultiDocCozystack_NoVIPOnFreshDefaults pins the shipped-default behavior (no VIP); existing TestMultiDocCozystack_ControlPlane loses its pre-PR 'expects 192.168.100.10' assertion; TestMultiDocCozystack_ Layer2VIPConfigWhenFloatingIPSet covers the opt-in path. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> * fix(chart): dedupe subnet fallback; reword required messages Two small-but-meaningful improvements to the subnet fallback and error wording: 1. Dedupe subnets derived from multiple addresses on the same link. validSubnets is a set semantically (Talos uses it as a CIDR match list), so a link with a secondary address in the same subnet previously emitted two identical list entries. The new pass collects cidrNetwork results and runs them through sprig's uniq before emitting. TestMultiDocCozystack_DedupesDuplicateSubnets- FromMultipleAddresses pins this. 2. Reword required() messages to explain why auto-discovery is not possible ('talm template runs once per node and cannot reconcile per-node IPs into one cluster value') instead of the dismissive 'no auto-discovery is possible'. The original ticket author explicitly asked for auto-derivation, so the message should explain the constraint instead of just refusing. Swap em-dashes for -- since Helm's error wrapping is known to mangle em-dashes in some terminals. 3. advertisedSubnets docstring in charts/generic/values.yaml cleaned up to match the wording of charts/cozystack/values.yaml for symmetry. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> * docs+test: align README example; add VIP + dedupe + floatingIP tests README getting-started block previously showed endpoint=192.168.0.1 followed by a tutorial node at 1.2.3.4 -- inconsistent for a reader walking the example. Rewrite the example to use 1.2.3.4 throughout as a single-node scenario (endpoint=node IP, floatingIP blank), which also demonstrates the 'blank floatingIP for single-node' path that the new default enables. Test additions and updates on the engine side: - TestMultiDocCozystack_Layer2VIPConfigWhenFloatingIPSet covers the opt-in VIP path (user sets floatingIP, chart emits VIP doc). - TestMultiDocCozystack_NoVIPOnFreshDefaults pins the shipped-empty default behavior: no VIP on a vanilla 'talm init' render. - TestMultiDocCozystack_DedupesDuplicateSubnetsFromMultipleAddresses covers the new uniq-pass in the subnet fallback: two addresses in the same subnet collapse to one list entry per consumer (validSubnets + advertisedSubnets = 2 total occurrences). - TestMultiDocCozystack_ControlPlane (pre-existing) retired its 'expect 192.168.100.10 VIP' assertion in favor of an explicit 'no VIP on fresh defaults' assertion, reflecting the new shipped behavior. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> * test: unit-test cidrNetwork + strengthen empty-discovery assertion Two test-quality polish items from review: - TestCidrNetworkTemplateFunc covers cidrNetwork via a trivial chart that just renders {{ cidrNetwork INPUT }}: happy-path IPv4/IPv6 host-form masking, already-canonical round-trip, narrow prefix round-trip, and malformed inputs. Runs in ~0ms each. Catches a regression in the function without needing a full chart render. - TestMultiDocCozystack_EmptyDiscoveryErrors now asserts both the advertisedSubnets field name AND the 'default route' diagnostic phrase. Two independent signals pin the error's guidance shape, so a future reword cannot silently drop half the message. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> * fix(chart): use fail instead of required for unconditional discovery error Address review feedback from gemini-code-assist on charts/cozystack/templates/_helpers.tpl:32: The empty-discovery branch used `required "msg" ""` to fail — the literal empty string as value makes the check unconditionally-true, which is semantically confusing since required() is for optional-vs- required checks. `fail "msg"` expresses the same intent in one function call and matches the engine's existing helpers (the render harness already registers both required and fail). No behavior change; same error message, same trigger condition. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> * fix(chart): use fail instead of required for unconditional discovery error (generic) Address review feedback from gemini-code-assist on charts/generic/templates/_helpers.tpl:27: Mirror the cozystack-preset change — swap `required "msg" ""` for `fail "msg"` in the empty-discovery branch. Keeps the two presets symmetric. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> * test(engine): deep-copy chart values in render helpers Address review feedback from gemini-code-assist on pkg/engine/render_test.go:71: maps.Copy does a shallow copy of chrt.Values. Today's tests only mutate top-level keys, but a future contributor writing something like `values["podSubnets"] = append(values["podSubnets"].([]any), x)` could corrupt chrt.Values via the shared slice's backing array, and downstream tests loading the same chart would pick up the mutation. Introduce cloneValues — a small recursive deep-copy handling maps, slices, and primitives — and use it in the renderCozystackWith, renderGenericWith, and renderChartTemplate helpers where chrt.Values is the starting point. The existing maps.Copy for merging in caller- supplied overrides is kept; overrides maps are built fresh per test so they are already safe. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> * docs(readme): use RFC 5737 documentation IPs in getting-started walkthrough Address review feedback from coderabbitai on README.md:135: The rendered config sample showed the old placeholder https://192.168.0.1:6443, which contradicted the values.yaml example. Swap the walkthrough to the RFC 5737 TEST-NET-1 range (192.0.2.0/24) for both node and gateway so the example uses IPs reserved for documentation and cannot accidentally collide with a real network. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> --------- Signed-off-by: Aleksei Sviridkin <f@lex.la>
1 parent 00358fd commit 473b847

8 files changed

Lines changed: 806 additions & 24 deletions

File tree

README.md

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,16 +59,35 @@ cd newcluster
5959
talm init -p cozystack -N myawesomecluster
6060
```
6161

62-
Boot Talos Linux node, let's say it has address `1.2.3.4`
62+
Edit `values.yaml` to set your cluster's control-plane endpoint. This
63+
is the URL every node's kubelet and kube-proxy will dial. The chart
64+
leaves it empty on purpose so a missed override fails loudly instead
65+
of silently embedding a placeholder. For cozystack VIP setups set
66+
`endpoint` and `floatingIP` together (same IP, single shared VIP);
67+
for single-node clusters use that node's routable IP and leave
68+
`floatingIP` blank; for multi-node with an external load balancer
69+
use the LB URL and leave `floatingIP` blank. Subnet-selector fields
70+
(`kubelet.validSubnets`, `etcd.advertisedSubnets`) are derived
71+
automatically from the node's default-gateway-bearing link, so no
72+
override is needed unless you have a multi-homed node that requires
73+
a specific subnet pinned.
74+
75+
Boot Talos Linux node, let's say it has address `192.0.2.4`. Then:
76+
77+
```yaml
78+
# values.yaml (single-node example matching the 192.0.2.4 node below)
79+
endpoint: "https://192.0.2.4:6443"
80+
floatingIP: ""
81+
```
6382
6483
Gather node information:
6584
```bash
66-
talm -n 1.2.3.4 -e 1.2.3.4 template -t templates/controlplane.yaml -i > nodes/node1.yaml
85+
talm -n 192.0.2.4 -e 192.0.2.4 template -t templates/controlplane.yaml -i > nodes/node1.yaml
6786
```
6887

6988
Edit `nodes/node1.yaml` file:
7089
```yaml
71-
# talm: nodes=["1.2.3.4"], endpoints=["1.2.3.4"], templates=["templates/controlplane.yaml"]
90+
# talm: nodes=["192.0.2.4"], endpoints=["192.0.2.4"], templates=["templates/controlplane.yaml"]
7291
machine:
7392
network:
7493
# -- Discovered interfaces:
@@ -89,10 +108,10 @@ machine:
89108
interfaces:
90109
- interface: enx9c6b0047066c
91110
addresses:
92-
- 1.2.3.4/26
111+
- 192.0.2.4/26
93112
routes:
94113
- network: 0.0.0.0/0
95-
gateway: 1.2.3.1
114+
gateway: 192.0.2.1
96115
nameservers:
97116
- 8.8.8.8
98117
- 8.8.4.4
@@ -113,7 +132,7 @@ machine:
113132
cluster:
114133
clusterName: talm
115134
controlPlane:
116-
endpoint: https://192.168.0.1:6443
135+
endpoint: https://192.0.2.4:6443
117136
```
118137
119138
> **Note:** The output format depends on the Talos version configured in `Chart.yaml` (`templateOptions.talosVersion`) or via the `--talos-version` CLI flag.

charts/cozystack/templates/_helpers.tpl

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,27 @@ machine:
1818
kubelet:
1919
nodeIP:
2020
validSubnets:
21+
{{- if .Values.advertisedSubnets }}
2122
{{- toYaml .Values.advertisedSubnets | nindent 8 }}
23+
{{- else }}
24+
{{- /* Fall back to the subnet of the node's default-gateway-bearing
25+
link. cidrNetwork masks host bits so the emitted YAML is the
26+
canonical network form (192.168.201.0/24) rather than the
27+
host form (192.168.201.10/24). Dedupe after masking because
28+
a link with a secondary address in the same subnet would
29+
otherwise produce duplicate list entries. */ -}}
30+
{{- $addrs := fromJsonArray (include "talm.discovered.default_addresses_by_gateway" .) }}
31+
{{- if not $addrs }}
32+
{{- fail "values.yaml: `advertisedSubnets` was left empty and talm could not derive a default from discovery. No default-gateway-bearing link was found on the node. This field is a cluster-wide subnet selector fed to kubelet and etcd; `talm template` is invoked once per node and cannot merge per-node values into one cluster value. Either set advertisedSubnets explicitly in values.yaml, or ensure the node has a default route before running `talm template`." }}
33+
{{- end }}
34+
{{- $subnets := list }}
35+
{{- range $addrs }}
36+
{{- $subnets = append $subnets (. | cidrNetwork) }}
37+
{{- end }}
38+
{{- range uniq $subnets }}
39+
- {{ . }}
40+
{{- end }}
41+
{{- end }}
2242
extraConfig:
2343
cpuManagerPolicy: static
2444
maxPods: 512
@@ -85,7 +105,7 @@ cluster:
85105
{{- toYaml .Values.serviceSubnets | nindent 6 }}
86106
clusterName: "{{ .Chart.Name }}"
87107
controlPlane:
88-
endpoint: "{{ .Values.endpoint }}"
108+
endpoint: {{ required "values.yaml: `endpoint` must be set to the cluster control-plane URL (e.g. https://<vip>:6443). This field is cluster-wide: every node's kubelet and kube-proxy dials it, so it cannot be auto-derived from the current node's IP -- `talm template` runs once per node and has no way to reconcile per-node IPs into a single shared endpoint. For multi-node setups use a VIP (cozystack floatingIP) or an external load balancer; for single-node clusters the node's routable IP works." .Values.endpoint | quote }}
89109
{{- if eq .MachineType "controlplane" }}
90110
allowSchedulingOnControlPlanes: true
91111
controllerManager:
@@ -119,7 +139,23 @@ cluster:
119139
enabled: false
120140
etcd:
121141
advertisedSubnets:
142+
{{- if .Values.advertisedSubnets }}
122143
{{- toYaml .Values.advertisedSubnets | nindent 6 }}
144+
{{- else }}
145+
{{- /* Fall back to the subnet of the node's default-gateway-bearing
146+
link; cidrNetwork masks host bits to emit canonical network
147+
form. Dedupe handled the same way as validSubnets above.
148+
Empty discovery already errored via validSubnets' required()
149+
guard, so we reach this block only when at least one address
150+
was resolved. */ -}}
151+
{{- $subnets := list }}
152+
{{- range fromJsonArray (include "talm.discovered.default_addresses_by_gateway" .) }}
153+
{{- $subnets = append $subnets (. | cidrNetwork) }}
154+
{{- end }}
155+
{{- range uniq $subnets }}
156+
- {{ . }}
157+
{{- end }}
158+
{{- end }}
123159
{{- end }}
124160
{{- end }}
125161

charts/cozystack/values.yaml

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,53 @@
1-
endpoint: "https://192.168.100.10:6443"
1+
# REQUIRED. Cluster control-plane endpoint. Left empty intentionally
2+
# so the chart's `required` check fires loudly on a fresh install.
3+
# A placeholder default would silently embed a broken endpoint in
4+
# every rendered machine config. No auto-discovery is possible: this
5+
# is a cluster-wide value that every node's kubelet and kube-proxy
6+
# dials, not a per-node one.
7+
#
8+
# For cozystack VIP setups, set `endpoint` AND `floatingIP` below to
9+
# the SAME IP. floatingIP drives the per-node VIP (Layer2VIPConfig)
10+
# that endpoint points at; leaving them mismatched produces a cluster
11+
# that talks to an IP no node actually claims.
12+
#
13+
# For single-node clusters without a VIP, set `endpoint` to that
14+
# node's routable IP and leave `floatingIP` blank.
15+
#
16+
# For multi-node with an external load balancer, set `endpoint` to
17+
# the LB URL and leave `floatingIP` blank.
18+
#
19+
# Example: endpoint: "https://192.168.0.1:6443"
20+
endpoint: ""
21+
222
clusterDomain: cozy.local
3-
floatingIP: 192.168.100.10
23+
# Layer-2 VIP for cozystack multi-node setups. When set, the chart
24+
# emits a Layer2VIPConfig document pinning this IP as a floating
25+
# address on the node's primary link. MUST equal the host portion
26+
# of `endpoint` above, otherwise the cluster dials an IP that no
27+
# node actually claims. Blank by default so the shipped value never
28+
# silently embeds a wrong VIP -- fill in only if you want a VIP.
29+
# Single-node clusters and external-LB topologies leave it blank.
30+
# Example: floatingIP: 192.168.0.1
31+
floatingIP: ""
432
image: "ghcr.io/cozystack/cozystack/talos:v1.12.6"
533
podSubnets:
634
- 10.244.0.0/16
735
serviceSubnets:
836
- 10.96.0.0/16
9-
advertisedSubnets:
10-
- 192.168.100.0/24
37+
38+
# Optional override for machine.kubelet.nodeIP.validSubnets and
39+
# cluster.etcd.advertisedSubnets. When left empty the chart derives
40+
# the value from the node's default-gateway-bearing link at render
41+
# time (via talm.discovered.default_addresses_by_gateway), so the
42+
# generated machine config matches the node's actual network without
43+
# any values.yaml edit. Set this only when you want to pin a specific
44+
# subnet — typically for multi-homed nodes where the default-gateway
45+
# link is not the subnet you want kubelet/etcd to use.
46+
# Example:
47+
# advertisedSubnets:
48+
# - "10.0.0.0/8"
49+
advertisedSubnets: []
50+
1151
oidcIssuerUrl: ""
1252
certSANs: []
1353
nr_hugepages: 0

charts/generic/templates/_helpers.tpl

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,27 @@ machine:
1313
kubelet:
1414
nodeIP:
1515
validSubnets:
16+
{{- if .Values.advertisedSubnets }}
1617
{{- toYaml .Values.advertisedSubnets | nindent 8 }}
18+
{{- else }}
19+
{{- /* Fall back to the subnet of the node's default-gateway-bearing
20+
link. cidrNetwork masks host bits so the emitted YAML is the
21+
canonical network form (192.168.201.0/24) rather than the
22+
host form (192.168.201.10/24). Dedupe after masking because
23+
a link with a secondary address in the same subnet would
24+
otherwise produce duplicate list entries. */ -}}
25+
{{- $addrs := fromJsonArray (include "talm.discovered.default_addresses_by_gateway" .) }}
26+
{{- if not $addrs }}
27+
{{- fail "values.yaml: `advertisedSubnets` was left empty and talm could not derive a default from discovery. No default-gateway-bearing link was found on the node. This field is a cluster-wide subnet selector fed to kubelet and etcd; `talm template` is invoked once per node and cannot merge per-node values into one cluster value. Either set advertisedSubnets explicitly in values.yaml, or ensure the node has a default route before running `talm template`." }}
28+
{{- end }}
29+
{{- $subnets := list }}
30+
{{- range $addrs }}
31+
{{- $subnets = append $subnets (. | cidrNetwork) }}
32+
{{- end }}
33+
{{- range uniq $subnets }}
34+
- {{ . }}
35+
{{- end }}
36+
{{- end }}
1737
{{- with .Values.certSANs }}
1838
certSANs:
1939
{{- toYaml . | nindent 2 }}
@@ -33,7 +53,7 @@ cluster:
3353
{{- toYaml .Values.serviceSubnets | nindent 6 }}
3454
clusterName: "{{ .Chart.Name }}"
3555
controlPlane:
36-
endpoint: "{{ .Values.endpoint }}"
56+
endpoint: {{ required "values.yaml: `endpoint` must be set to the cluster control-plane URL (e.g. https://<vip>:6443). This field is cluster-wide: every node's kubelet and kube-proxy dials it, so it cannot be auto-derived from the current node's IP -- `talm template` runs once per node and has no way to reconcile per-node IPs into a single shared endpoint. For multi-node setups use a VIP or an external load balancer; for single-node clusters the node's routable IP works." .Values.endpoint | quote }}
3757
{{- if eq .MachineType "controlplane" }}
3858
apiServer:
3959
{{- with .Values.certSANs }}
@@ -42,7 +62,23 @@ cluster:
4262
{{- end }}
4363
etcd:
4464
advertisedSubnets:
65+
{{- if .Values.advertisedSubnets }}
4566
{{- toYaml .Values.advertisedSubnets | nindent 6 }}
67+
{{- else }}
68+
{{- /* Fall back to the subnet of the node's default-gateway-bearing
69+
link; cidrNetwork masks host bits to emit canonical network
70+
form. Dedupe handled the same way as validSubnets above.
71+
Empty discovery already errored via validSubnets' required()
72+
guard, so we reach this block only when at least one address
73+
was resolved. */ -}}
74+
{{- $subnets := list }}
75+
{{- range fromJsonArray (include "talm.discovered.default_addresses_by_gateway" .) }}
76+
{{- $subnets = append $subnets (. | cidrNetwork) }}
77+
{{- end }}
78+
{{- range uniq $subnets }}
79+
- {{ . }}
80+
{{- end }}
81+
{{- end }}
4682
{{- end }}
4783
{{- end }}
4884

charts/generic/values.yaml

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,29 @@
1-
endpoint: "https://192.168.100.10:6443"
1+
# REQUIRED. Cluster control-plane endpoint. Left empty intentionally
2+
# so the chart's `required` check fires loudly on a fresh install.
3+
# A placeholder default would silently embed a broken endpoint in
4+
# every rendered machine config. No auto-discovery is possible: this
5+
# is a cluster-wide value that every node's kubelet and kube-proxy
6+
# dials, not a per-node one. For single-node clusters set it to that
7+
# node's routable IP; for multi-node set it to a VIP or external LB.
8+
# Example: endpoint: "https://192.168.0.1:6443"
9+
endpoint: ""
10+
211
podSubnets:
312
- 10.244.0.0/16
413
serviceSubnets:
514
- 10.96.0.0/16
6-
advertisedSubnets:
7-
- 192.168.100.0/24
15+
16+
# Optional override for machine.kubelet.nodeIP.validSubnets and
17+
# cluster.etcd.advertisedSubnets. When left empty the chart derives
18+
# the value from the node's default-gateway-bearing link at render
19+
# time (via talm.discovered.default_addresses_by_gateway), so the
20+
# generated machine config matches the node's actual network without
21+
# any values.yaml edit. Set this only when you want to pin a specific
22+
# subnet — typically for multi-homed nodes where the default-gateway
23+
# link is not the subnet you want kubelet/etcd to use.
24+
# Example:
25+
# advertisedSubnets:
26+
# - "10.0.0.0/8"
27+
advertisedSubnets: []
28+
829
certSANs: []

pkg/engine/helm/engine.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package engine
1919
import (
2020
"fmt"
2121
"log"
22+
"net/netip"
2223
"path"
2324
"path/filepath"
2425
"regexp"
@@ -218,6 +219,19 @@ func (e Engine) initFunMap(t *template.Template) {
218219
}
219220
}
220221

222+
// cidrNetwork canonicalizes a CIDR string to its network form
223+
// ("192.168.201.10/24" -> "192.168.201.0/24"), matching what
224+
// operators see in Talos docs and upstream examples. Sprig ships
225+
// no equivalent; net/netip's ParsePrefix + Masked handles both
226+
// IPv4 and IPv6 without any host-bit arithmetic in the template.
227+
funcMap["cidrNetwork"] = func(cidr string) (string, error) {
228+
p, err := netip.ParsePrefix(cidr)
229+
if err != nil {
230+
return "", fmt.Errorf("cidrNetwork: %w", err)
231+
}
232+
return p.Masked().String(), nil
233+
}
234+
221235
t.Funcs(funcMap)
222236
}
223237

pkg/engine/helm/engine_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1218,3 +1218,56 @@ func TestTalosVersionConcurrentRender(t *testing.T) {
12181218
}
12191219
wg.Wait()
12201220
}
1221+
1222+
// TestCidrNetworkTemplateFunc exercises the cidrNetwork template
1223+
// function directly (bypassing chart rendering) so a future refactor
1224+
// that breaks parsing or masking — for either IPv4 or IPv6 inputs —
1225+
// is caught without needing to boot the whole helm engine.
1226+
func TestCidrNetworkTemplateFunc(t *testing.T) {
1227+
renderExpr := func(expr string) (string, error) {
1228+
chrt := &chart.Chart{
1229+
Metadata: &chart.Metadata{Name: "cidrtest"},
1230+
Templates: []*chart.File{{Name: "templates/out.yaml", Data: []byte(expr)}},
1231+
Values: map[string]any{},
1232+
}
1233+
var eng Engine
1234+
out, err := eng.Render(chrt, chartutil.Values{"Values": map[string]any{}})
1235+
if err != nil {
1236+
return "", err
1237+
}
1238+
return out["cidrtest/templates/out.yaml"], nil
1239+
}
1240+
1241+
tests := []struct {
1242+
name string
1243+
input string
1244+
want string
1245+
wantErr bool
1246+
}{
1247+
{"ipv4 host form", "192.168.201.10/24", "192.168.201.0/24", false},
1248+
{"ipv4 already canonical", "10.0.0.0/8", "10.0.0.0/8", false},
1249+
{"ipv4 narrow prefix", "192.168.201.10/31", "192.168.201.10/31", false},
1250+
{"ipv6 host form", "2001:db8::1/64", "2001:db8::/64", false},
1251+
{"ipv6 already canonical", "fd00::/8", "fd00::/8", false},
1252+
{"malformed missing prefix", "192.168.201.10", "", true},
1253+
{"malformed garbage", "not-a-cidr", "", true},
1254+
}
1255+
1256+
for _, tt := range tests {
1257+
t.Run(tt.name, func(t *testing.T) {
1258+
got, err := renderExpr(fmt.Sprintf(`{{ cidrNetwork %q }}`, tt.input))
1259+
if tt.wantErr {
1260+
if err == nil {
1261+
t.Errorf("expected error for input %q, got output %q", tt.input, got)
1262+
}
1263+
return
1264+
}
1265+
if err != nil {
1266+
t.Fatalf("unexpected error for %q: %v", tt.input, err)
1267+
}
1268+
if got != tt.want {
1269+
t.Errorf("cidrNetwork(%q) = %q, want %q", tt.input, got, tt.want)
1270+
}
1271+
})
1272+
}
1273+
}

0 commit comments

Comments
 (0)