Skip to content

Commit ed3b820

Browse files
authored
Merge pull request #163 from cozystack/fix/v1.12-vip-on-vlan-child
fix(charts): pin Layer2VIPConfig to subnet-matching link, not default route
2 parents f5540f3 + 077b800 commit ed3b820

11 files changed

Lines changed: 3325 additions & 329 deletions

File tree

README.md

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,24 @@ talm init -p cozystack -N myawesomecluster --image factory.talos.dev/installer/<
6161

6262
`--image` rewrites the top-level `image:` field in the preset's `values.yaml` before write. The flag is honored on initial `init` only — for an existing project, edit `values.yaml` directly. The `cozystack` preset declares `image:`; the `generic` preset does not, so `--image --preset generic` is rejected up front.
6363

64-
Edit `values.yaml` to set your cluster's control-plane endpoint. This is the URL every node's kubelet and kube-proxy will dial. The chart leaves it empty on purpose so a missed override fails loudly instead of silently embedding a placeholder. For cozystack VIP setups set `endpoint` and `floatingIP` together (same IP, single shared VIP); for single-node clusters use that node's routable IP and leave `floatingIP` blank; for multi-node with an external load balancer use the LB URL and leave `floatingIP` blank. When the VIP must sit on a link that does not yet exist on the live system at first apply (typically a VLAN sub-interface), set `vipLink` to that link name — the chart pins `Layer2VIPConfig.link` to it instead of the default-gateway link that discovery would otherwise pick, and emits the document even on a totally fresh node where no default-gateway link has been discovered yet. The chart does not auto-emit a `LinkConfig` or `VLANConfig` for the override link; the operator is responsible for ensuring the link comes up, typically by adding a `LinkConfig` or `VLANConfig` for that link to the per-node body overlay alongside `vipLink`. Subnet-selector fields (`kubelet.validSubnets`, `etcd.advertisedSubnets`) are derived automatically from the node's default-gateway-bearing link, so no override is needed unless you have a multi-homed node that requires a specific subnet pinned.
64+
Edit `values.yaml` to set your cluster's control-plane endpoint. This is the URL every node's kubelet and kube-proxy will dial. The chart leaves it empty on purpose so a missed override fails loudly instead of silently embedding a placeholder.
65+
66+
Endpoint / floatingIP combinations:
67+
68+
- **cozystack VIP setup**: set `endpoint` and `floatingIP` together to the same IP — single shared VIP.
69+
- **single-node cluster**: set `endpoint` to the node's routable IP and leave `floatingIP` blank.
70+
- **multi-node with external load balancer**: set `endpoint` to the LB URL and leave `floatingIP` blank.
71+
72+
When `vipLink` is left empty the chart picks the link automatically using a two-step rule:
73+
74+
1. **Longest-prefix match across configurable links.** If `floatingIP` falls inside the CIDR of any address on a configurable link (physical NIC, bond, VLAN, bridge), the most specific subnet wins. This handles the Hetzner-style topology where a public NIC carries the default route and a VLAN child carries the private cluster subnet — the VIP lands on the VLAN child.
75+
2. **Fallback to the IPv4-default-gateway-bearing link.** Used when no configurable link's CIDR contains the `floatingIP` — typical for upstream-routable VIPs that arrive via the default route.
76+
77+
Addresses on links the chart does not emit a per-link document for (Wireguard, kernel-managed loopback, slave NICs of a bond, anything outside the configurable set) are skipped — a VIP pinned there would have no surrounding network document.
78+
79+
Set `vipLink` explicitly when the target link does not yet exist on the live system at first apply (typically a VLAN sub-interface). The chart pins `Layer2VIPConfig.link` to it directly and emits the document even on a fresh node where discovery has not yet populated the addresses table. The chart does not auto-emit a `LinkConfig` or `VLANConfig` for the override link; the operator is responsible for ensuring the link comes up, typically by adding a `LinkConfig` or `VLANConfig` for that link to the per-node body overlay alongside `vipLink`.
80+
81+
Subnet-selector fields (`kubelet.validSubnets`, `etcd.advertisedSubnets`) are derived automatically from the node's default-gateway-bearing link, so no override is needed unless you have a multi-homed node that requires a specific subnet pinned.
6582

6683
Boot Talos Linux node, let's say it has address `192.0.2.4`. Then:
6784

@@ -126,7 +143,30 @@ cluster:
126143
endpoint: https://192.0.2.4:6443
127144
```
128145
129-
> **Note:** The output format depends on the Talos version configured in `Chart.yaml` (`templateOptions.talosVersion`) or via the `--talos-version` CLI flag. For Talos < v1.12, the output is a single YAML document with `machine.network` and `machine.registries` sections (as shown above). For Talos >= v1.12, the output uses the multi-document format with separate typed documents instead of the deprecated monolithic fields. `HostnameConfig` and `ResolverConfig` are always emitted; one network interface document is emitted per configurable link on the node (`LinkConfig` for physical NICs, `BondConfig` for bond masters, `VLANConfig` for VLAN sub-interfaces) — multi-NIC nodes therefore produce one document per NIC, not one document total. The link carrying the IPv4 default route gets the gateway entry on its document; every other link is emitted gateway-less. Both IPv4 and IPv6 global-scope addresses on a link are surfaced in its document. Bond slaves are filtered out so they do not collide with the master's `BondConfig`. Bridges are deliberately not auto-emitted as `BridgeConfig` yet — a non-gateway bridge is skipped (declare it via a per-node body overlay if needed); a bridge that carries the default route fails the render with a clear migration hint. The operator-declared `floatingIP` is stripped from per-link addresses so the VIP currently held by a leader does not leak into the static `LinkConfig`. `Layer2VIPConfig` appears on controlplane nodes when `floatingIP` is set; `RegistryMirrorConfig` is emitted only by the cozystack chart.
146+
> **Note: output format depends on Talos version.**
147+
>
148+
> Selected via `Chart.yaml` (`templateOptions.talosVersion`) or `--talos-version`:
149+
>
150+
> - **Talos < v1.12** — single YAML document with `machine.network` and `machine.registries` sections (as shown above).
151+
> - **Talos >= v1.12** — multi-document format with separate typed documents instead of the deprecated monolithic fields.
152+
>
153+
> For v1.12+ multi-doc output, one document is emitted per configurable link on the node, plus a fixed pair on every render:
154+
>
155+
> - `HostnameConfig` and `ResolverConfig` — always emitted.
156+
> - `LinkConfig` — physical NICs.
157+
> - `BondConfig` — bond masters. Bond slaves are filtered out so they do not collide with the master's document.
158+
> - `VLANConfig` — VLAN sub-interfaces.
159+
> - `BridgeConfig` — bridges, symmetric to `BondConfig` for bonds. Ports discovered via `spec.slaveKind == "bridge"` + `spec.masterIndex`; STP / VLAN-filtering settings reach the output when the bridge controller reports them on `spec.bridgeMaster`.
160+
> - `Layer2VIPConfig` — controlplane nodes when `floatingIP` is set.
161+
> - `RegistryMirrorConfig` — cozystack chart only.
162+
>
163+
> Per-link emission rules:
164+
>
165+
> - The link carrying the IPv4 default route gets the `routes.gateway` entry on its document; every other link is emitted gateway-less. Applies uniformly to `LinkConfig`, `BondConfig`, `VLANConfig`, `BridgeConfig`.
166+
> - Both IPv4 and IPv6 global-scope addresses on a link are surfaced.
167+
> - The operator-declared `floatingIP` is stripped from per-link addresses so the VIP currently held by a leader does not leak into the static document.
168+
>
169+
> Multi-NIC nodes therefore produce one document per NIC, not one document total.
130170

131171
> **Version compatibility (`templateOptions.talosVersion` / `--talos-version`).** This setting must match the **Talos version actually running on the target node** — i.e. the maintenance ISO/PXE the node booted from for `apply -i`, or the installed Talos for an authenticated apply. It is **not** the same as `install.image`, which only controls what gets written to disk after a successful apply. When the configured contract is newer than the running binary, machinery injects fields (e.g. `machine.install.grubUseUKICmdline` from v1.12) that the running parser does not know, and the apply fails on the node side with `failed to parse config: unknown keys found during decoding: ...`. `talm apply` runs a best-effort pre-flight check against the running version and prints a `warning: pre-flight: ...` line with a hint when it detects this mismatch; if the warning is missed, the same hint is appended to the apply error. Either reboot the node into a maintenance image that matches the configured contract, or lower `templateOptions.talosVersion` / `--talos-version` to match what is running.
132172

charts/cozystack/templates/_helpers.tpl

Lines changed: 125 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -199,17 +199,37 @@ nameservers:
199199
{{- else }}
200200
[]
201201
{{- end }}
202+
{{- /* Coerce .Values.floatingIP to its string form once at the
203+
top of the multi-doc body and reuse the result everywhere
204+
a downstream lookup or formatter needs it. The per-link
205+
addresses_by_link strip on every link emission below
206+
depends on the same stringified value — a worker render
207+
with `floatingIP: 192168` would otherwise feed printf
208+
"%s/" an int, producing `%!s(int=192168)/` that never
209+
matches a CIDR. The coercion isolates that trap to one
210+
place and lets the rest of the template treat the value
211+
uniformly.
212+
213+
"<nil>" is Sprig's serialisation of nil and "" is the
214+
unset string; both mean "operator did not supply a
215+
value". The shared talm.validate_floatingIP partial below
216+
handles the actual fail-fast — invoke it here AND in the
217+
legacy define so a malformed value fails at render time
218+
regardless of the rendered Talos version. */}}
219+
{{- $fipStr := .Values.floatingIP | toString }}
220+
{{- $fipIsSet := and (ne $fipStr "") (ne $fipStr "<nil>") }}
221+
{{- include "talm.validate_floatingIP" . }}
202222
{{- /* Operator-declared vipLink override: emit Layer2VIPConfig
203223
regardless of discovery state. Useful when the target link
204224
does not yet exist on the live system at first apply (typical
205225
case: a VLAN sub-interface this template is about to bring up).
206226
The discovery-derived block below skips its own Layer2VIPConfig
207227
when this branch fires, so we never emit duplicates. */}}
208-
{{- if and .Values.floatingIP .Values.vipLink (eq .MachineType "controlplane") }}
228+
{{- if and $fipIsSet .Values.vipLink (eq .MachineType "controlplane") }}
209229
---
210230
apiVersion: v1alpha1
211231
kind: Layer2VIPConfig
212-
name: {{ .Values.floatingIP | quote }}
232+
name: {{ $fipStr | quote }}
213233
link: {{ .Values.vipLink }}
214234
{{- end }}
215235
{{- $defaultLinkName := include "talm.discovered.default_link_name_by_gateway" . }}
@@ -230,7 +250,13 @@ link: {{ .Values.vipLink }}
230250
and follower configs out of sync. */}}
231251
{{- $addresses := list }}
232252
{{- range $rawAddresses }}
233-
{{- if not (and $.Values.floatingIP (hasPrefix (printf "%s/" $.Values.floatingIP) .)) }}
253+
{{- /* Use the hoisted $fipStr/$fipIsSet from the top of the
254+
define so the strip honours the same coerced value the
255+
validation block above used. Going through `printf "%s/"
256+
$.Values.floatingIP` directly would emit
257+
`%!s(int=192168)/` for a numeric YAML scalar on a worker
258+
render (controlplane was caught by the fail-fast). */ -}}
259+
{{- if not (and $fipIsSet (hasPrefix (printf "%s/" $fipStr) .)) }}
234260
{{- $addresses = append $addresses . }}
235261
{{- end }}
236262
{{- end }}
@@ -239,17 +265,58 @@ link: {{ .Values.vipLink }}
239265
{{- $linkGateway = include "talm.discovered.gateway_by_link" $linkName }}
240266
{{- end }}
241267
{{- if eq $kind "bridge" }}
242-
{{- /* BridgeConfig is a separate v1alpha1 typed document the chart
243-
does not yet emit. Skipping a non-gateway bridge leaves the
244-
rendered config without a bridge document and the operator is
245-
responsible for declaring it via a per-node body. A bridge
246-
carrying the IPv4 default route, however, cannot be silently
247-
skipped: that would drop every network document for the
248-
gateway link and the rendered config would describe a node
249-
with no working uplink. Surface a fail with the offending
250-
link and the migration path. */ -}}
251-
{{- if $isGatewayLink }}
252-
{{- fail (printf "talm: discovered bridge %q is the IPv4-default link, but BridgeConfig emission is not yet implemented in the chart. Move the bridge declaration into a per-node body overlay (kind: BridgeConfig), or set Values.vipLink to a different link until bridge support lands." $linkName) }}
268+
{{- /* BridgeConfig emission. Discovers bridge ports (members) via
269+
talm.discovered.bridge_slaves and emits a typed v1.12+
270+
BridgeConfig document with the same address / route / mtu
271+
shape as the other branches. STP and VLAN filtering are
272+
opt-in: they are emitted only when the bridge controller
273+
reported a non-nil spec.bridgeMaster.stp / spec.bridgeMaster
274+
value, so a default-state bridge stays minimal. */ -}}
275+
{{- $bridgeMaster := $link.spec.bridgeMaster }}
276+
{{- $bridgePorts := fromJsonArray (include "talm.discovered.bridge_slaves" $link.spec.index) }}
277+
---
278+
apiVersion: v1alpha1
279+
kind: BridgeConfig
280+
name: {{ $linkName }}
281+
{{- if $bridgePorts }}
282+
links:
283+
{{- range $bridgePorts }}
284+
- {{ . }}
285+
{{- end }}
286+
{{- end }}
287+
{{- if $bridgeMaster }}
288+
{{- if $bridgeMaster.stp }}
289+
{{- if hasKey $bridgeMaster.stp "enabled" }}
290+
stp:
291+
enabled: {{ $bridgeMaster.stp.enabled }}
292+
{{- end }}
293+
{{- end }}
294+
{{- /* COSI's BridgeVLANSpec serialises FilteringEnabled as
295+
yaml:"filteringEnabled" (verified against
296+
siderolabs/talos pkg/machinery/resources/network/link.go).
297+
The output-side BridgeConfig schema uses the shorter
298+
yaml:"filtering,omitempty" key — so we read the long form
299+
from discovery and emit the short form into the rendered
300+
document. */ -}}
301+
{{- if $bridgeMaster.vlan }}
302+
{{- if hasKey $bridgeMaster.vlan "filteringEnabled" }}
303+
vlan:
304+
filtering: {{ $bridgeMaster.vlan.filteringEnabled }}
305+
{{- end }}
306+
{{- end }}
307+
{{- end }}
308+
{{- if $addresses }}
309+
addresses:
310+
{{- range $addresses }}
311+
- address: {{ . }}
312+
{{- end }}
313+
{{- end }}
314+
{{- if $linkGateway }}
315+
routes:
316+
- gateway: {{ $linkGateway }}
317+
{{- end }}
318+
{{- if $link.spec.mtu }}
319+
mtu: {{ $link.spec.mtu }}
253320
{{- end }}
254321
{{- else if eq $kind "bond" }}
255322
{{- $bondMaster := $link.spec.bondMaster }}
@@ -354,18 +421,52 @@ mtu: {{ $link.spec.mtu }}
354421
{{- /* Discovery-derived Layer2VIPConfig: skipped when the operator
355422
has set .Values.vipLink, since the override-path block above
356423
has already emitted the document with the operator's chosen
357-
link. */}}
358-
{{- if and .Values.floatingIP (not .Values.vipLink) (eq .MachineType "controlplane") $defaultLinkName }}
424+
link.
425+
426+
Link selection prefers the link whose discovered addresses
427+
contain the floatingIP (talm.discovered.link_name_for_address),
428+
so a VIP in a private subnet hosted on a VLAN child lands on
429+
that VLAN — not on the IPv4-default-route NIC. The
430+
default-gateway link stays as the fallback for topologies
431+
where the VIP isn't on any discovered subnet (typical for
432+
upstream-routable VIPs that arrive via the default-route
433+
link). When neither resolves a link, no Layer2VIPConfig is
434+
emitted, matching the prior behaviour. */}}
435+
{{- if and $fipIsSet (not .Values.vipLink) (eq .MachineType "controlplane") }}
436+
{{- $vipLink := include "talm.discovered.link_name_for_address" $fipStr }}
437+
{{- /* Default-gateway fallback must also point at a configurable
438+
link — otherwise an unmanaged default-route NIC (Wireguard,
439+
a slave NIC of a bond, anything outside the configurable
440+
set) would silently win selection and the rendered
441+
Layer2VIPConfig would dangle on a link the chart never
442+
emits a per-link document for. Mirror the same
443+
configurable-link gate link_name_for_address applies inside
444+
its own iteration. */ -}}
445+
{{- if not $vipLink }}
446+
{{- if has $defaultLinkName $configurableLinks }}
447+
{{- $vipLink = $defaultLinkName }}
448+
{{- end }}
449+
{{- end }}
450+
{{- if $vipLink }}
359451
---
360452
apiVersion: v1alpha1
361453
kind: Layer2VIPConfig
362-
name: {{ .Values.floatingIP | quote }}
363-
link: {{ $defaultLinkName }}
454+
name: {{ $fipStr | quote }}
455+
link: {{ $vipLink }}
456+
{{- end }}
364457
{{- end }}
365458
{{- end }}
366459

367460
{{- /* Shared legacy network section for machine.network */ -}}
368461
{{- define "talos.config.network.legacy" }}
462+
{{- /* Coerce floatingIP through toString and call the shared
463+
talm.validate_floatingIP partial so legacy renders fail at
464+
template time on a malformed value, same as the multi-doc
465+
path. $fipStr / $fipIsSet are reused below in place of every
466+
direct .Values.floatingIP reference. */ -}}
467+
{{- $fipStr := .Values.floatingIP | toString }}
468+
{{- $fipIsSet := and (ne $fipStr "") (ne $fipStr "<nil>") }}
469+
{{- include "talm.validate_floatingIP" . }}
369470
network:
370471
hostname: {{ include "talm.discovered.hostname" . | quote }}
371472
nameservers: {{ include "talm.discovered.default_resolvers" . }}
@@ -377,7 +478,7 @@ link: {{ $defaultLinkName }}
377478
top-level interfaces[] entry that carries only the vip block.
378479
When vipLink == $defaultLinkName the inline vip below already
379480
lands on the right link, so no override entry is needed. */}}
380-
{{- $vipOverride := and .Values.floatingIP .Values.vipLink (eq .MachineType "controlplane") (ne .Values.vipLink $defaultLinkName) }}
481+
{{- $vipOverride := and $fipIsSet .Values.vipLink (eq .MachineType "controlplane") (ne .Values.vipLink $defaultLinkName) }}
381482
{{- /* Suppress the inline (discovery-derived) vip when the operator
382483
has redirected it to a different link; otherwise the VIP would
383484
be pinned twice on different interfaces. */}}
@@ -408,25 +509,25 @@ link: {{ $defaultLinkName }}
408509
routes:
409510
- network: 0.0.0.0/0
410511
gateway: {{ include "talm.discovered.default_gateway" . }}
411-
{{- if and .Values.floatingIP (eq .MachineType "controlplane") (not $suppressInlineVip) }}
512+
{{- if and $fipIsSet (eq .MachineType "controlplane") (not $suppressInlineVip) }}
412513
vip:
413-
ip: {{ .Values.floatingIP }}
514+
ip: {{ $fipStr }}
414515
{{- end }}
415516
{{- else }}
416517
addresses: {{ include "talm.discovered.default_addresses_by_gateway" . }}
417518
routes:
418519
- network: 0.0.0.0/0
419520
gateway: {{ include "talm.discovered.default_gateway" . }}
420-
{{- if and .Values.floatingIP (eq .MachineType "controlplane") (not $suppressInlineVip) }}
521+
{{- if and $fipIsSet (eq .MachineType "controlplane") (not $suppressInlineVip) }}
421522
vip:
422-
ip: {{ .Values.floatingIP }}
523+
ip: {{ $fipStr }}
423524
{{- end }}
424525
{{- end }}
425526
{{- end }}
426527
{{- if $vipOverride }}
427528
- interface: {{ .Values.vipLink }}
428529
vip:
429-
ip: {{ .Values.floatingIP }}
530+
ip: {{ $fipStr }}
430531
{{- end }}
431532
{{- end }}
432533
{{- end }}

0 commit comments

Comments
 (0)