Skip to content

fix(charts): filter default-route discovery helpers to IPv4 family - #145

Merged
Aleksei Sviridkin (lexfrei) merged 2 commits into
mainfrom
fix/default-gateway-ipv4-only
May 7, 2026
Merged

fix(charts): filter default-route discovery helpers to IPv4 family#145
Aleksei Sviridkin (lexfrei) merged 2 commits into
mainfrom
fix/default-gateway-ipv4-only

Conversation

@lexfrei

@lexfrei Aleksei Sviridkin (lexfrei) commented May 7, 2026

Copy link
Copy Markdown
Contributor

What changed

All six default_*_by_gateway helpers in charts/talm/templates/_helpers.tpl now filter routes to family == inet4. Previously they iterated lookup "routes" and broke at the first default route, so on a dual-stack node where IPv6 was discovered first the chart would emit:

  • gateway: <ipv6 link-local> paired with an IPv4 destination (network: 0.0.0.0/0 on the legacy schema, or no network: field on the typed RouteConfig schema where Talos derives family from the literal) — Talos rejects the malformed entry, Layer2 VIP and other routing features that depend on the chart-emitted route silently break.
  • addresses: empty in the rendered LinkConfig because the helper inherited the IPv6 family from the first-iterated default route and the address filter then dropped every IPv4 entry on the link.
  • On multi-NIC setups (Hetzner-style with management NIC on IPv4 and public NIC on IPv6 default), LinkConfig name: would attach to the IPv6-default link while addresses/gateway described the IPv4-default link — neither NIC ended up correctly configured.

Why

The IPv4-only filter is symmetric with the pre-existing gateway_by_link helper, which already documents "IPv4-only by convention to avoid family/address mismatch on dual-stack nodes". The chart consumers all pair these helpers with IPv4 destinations, so widening the existing helpers to non-IPv4 routes was always inconsistent with how they're used.

For IPv6-only setups: the helper now returns empty for default_gateway (documented), and operators can pin advertisedSubnets explicitly via values.yaml (the comment was extended to call this out).

Tests

  • TestDefaultGatewayIsIPv4OnDualStack — helper-level pin on default_gateway with IPv6-first ordering.
  • TestDefaultLinkHelpersFollowIPv4OnTwoNicDualStack — multi-NIC scenario: IPv4 and IPv6 default routes on different links.
  • TestCozystackChartRendersIPv4GatewayOnDualStack — end-to-end through cozystack and generic charts on the v1.12 multi-doc schema, including a multi-NIC subtest.

Closes #78.

Summary by CodeRabbit

  • Documentation

    • Clarified VIP and subnet configuration options with enhanced explanations for multi-homed and IPv6 scenarios.
  • New Features

    • Added support for discovering and enumerating non-primary network interfaces.
  • Improvements

    • Enhanced IPv4 preference in dual-stack network environments when multiple default routes exist.
  • Tests

    • Added comprehensive dual-stack regression test coverage for network configuration handling.

Add two failing tests covering the dual-stack regression:

- TestDefaultGatewayIsIPv4OnDualStack: helper-level assertion that
  talm.discovered.default_gateway returns the IPv4 default-route gateway
  on a node with both IPv4 and IPv6 default routes (IPv6 ordered first
  in discovery, mirroring real Hetzner-style nodes).

- TestCozystackChartRendersIPv4GatewayOnDualStack: end-to-end assertion
  on the cozystack v1.12 multi-doc chart output. Both LinkConfig.routes
  gateway and LinkConfig.addresses must use the IPv4 default-route data
  even when an IPv6 default route also exists.

Both fail today: default_gateway and default_addresses_by_gateway both
iterate routes and break at the first default route without filtering
by family, so an IPv6-first discovery yields:

  - gateway: fe80::1 paired with an implicit IPv4 destination, which
    Talos rejects as a malformed route (the headline VIP-not-addressable
    symptom users hit).
  - addresses: empty, because the IPv6 family selected by the first
    default route filters out the node's IPv4 addresses, leaving the
    primary NIC unconfigured.

Add a dualStackNicLookup() fixture next to simpleNicLookup() so the
chart-rendering test path can exercise dual-stack discovery without
disturbing the existing IPv4-only fixtures the rest of the suite
depends on.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
talm.discovered.default_gateway and talm.discovered.default_addresses_by_gateway
now skip routes whose family is not inet4. The chart consumers (cozystack
and generic) pair the returned gateway with a hardcoded IPv4 destination
(network: 0.0.0.0/0 on the legacy schema, or no network field on the typed
RouteConfig schema where Talos defaults to IPv4), and the addresses helper
filters per-link addresses by the route's family.

Without the filter, a node with both IPv4 and IPv6 default routes (the
typical Hetzner / dual-stack setup) ends up with:

  - gateway: <ipv6 link-local> in a route block whose destination is
    IPv4 — Talos rejects the malformed entry and dependent features
    (Layer2 VIP, default routing through the rendered chart config)
    silently break.
  - addresses: empty in the rendered LinkConfig because the helper
    inherits the IPv6 family from the first iterated default route and
    the address filter then drops every IPv4 entry on the link.

The IPv4-only filter is symmetric with gateway_by_link, which already
documents "IPv4-only by convention to avoid family/address mismatch on
dual-stack nodes". Doc comments updated to spell out the chain so a
future maintainer who adds an IPv6-aware variant for new typed routes
does not silently widen the existing helpers and re-introduce the
regression.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
@coderabbitai

coderabbitai Bot commented May 7, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR updates Helm template helpers to explicitly prefer IPv4 default routes in dual-stack network environments, supported by comprehensive regression tests and clarified configuration documentation for VIP and subnet selection behavior.

Changes

IPv4-Prioritized Dual-Stack Network Discovery

Layer / File(s) Summary
Configuration Documentation
charts/cozystack/values.yaml, charts/generic/values.yaml
Comments for vipLink and advertisedSubnets are reworded to clarify default-gateway-bearing link selection when overrides are empty, operator responsibility for VIP link provisioning, and when subnet pinning is needed for multi-homed or IPv6-only nodes.
Helm Discovery Helpers - IPv4 Default Route Selection
charts/talm/templates/_helpers.tpl
Template helpers default_addresses_by_gateway, default_link_name_by_gateway, default_link_address_by_gateway, default_link_bus_by_gateway, default_link_selector_by_gateway, and default_gateway now explicitly filter routes by family=inet4 to select the IPv4 default route/link, ensuring consistent IPv4 preference in dual-stack scenarios.
Test Fixtures - Dual-Stack Network Scenarios
pkg/engine/render_test.go
New fixtures dualStackNicLookup and dualStackTwoNicsLookup model dual-stack topologies: single-link with both IPv4 and IPv6 defaults, and multi-link with IPv4 default on one interface and IPv6 on another.
Regression Tests - IPv4 Default Route Preference
pkg/engine/render_test.go
New test cases validate that helpers return IPv4 defaults when both IPv4 and IPv6 exist, that all default_*_by_gateway helpers follow the correct IPv4 link in multi-NIC setups, and that end-to-end chart rendering produces correct IPv4 gateway, addresses, and LinkConfig naming.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

Poem

🐰 Dual-stack routes need sorting, swift,
IPv4 default takes the lift,
When six and four both claim the way,
Four wins the race—let's celebrate the day! 🎉

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title clearly and specifically summarizes the primary change: filtering default-route discovery helpers to IPv4 family, which directly addresses the linked issue.
Linked Issues check ✅ Passed All code changes directly address issue #78 by ensuring default-route discovery returns IPv4 addresses/gateways on dual-stack nodes, fixing broken VIP templating and empty gateway fields.
Out of Scope Changes check ✅ Passed All changes are in-scope: documentation updates clarify IPv4-only behavior and handling of IPv6-only setups, helpers add multi-NIC discovery support, and tests validate the fix without introducing unrelated modifications.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/default-gateway-ipv4-only

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@lexfrei
Aleksei Sviridkin (lexfrei) marked this pull request as ready for review May 7, 2026 17:22

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request ensures that network discovery helpers in the talm chart specifically target IPv4 routes by adding an 'inet4' family filter. This change prevents configuration issues on dual-stack nodes where IPv6 routes might be incorrectly selected. The PR also updates documentation in values.yaml and adds extensive unit tests with dual-stack fixtures. Feedback suggests refactoring duplicated test setup logic into a shared helper function to improve maintainability.

Comment thread pkg/engine/render_test.go
Comment on lines +1470 to +1476
helpersSrc, err := os.ReadFile("../../charts/talm/templates/_helpers.tpl")
if err != nil {
t.Fatalf("read helpers: %v", err)
}
if err := os.WriteFile(filepath.Join(chartRoot, "templates", "_helpers.tpl"), helpersSrc, 0o644); err != nil {
t.Fatalf("write vendored helpers: %v", err)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The code to create a test chart and vendor the _helpers.tpl file is duplicated in TestDefaultGatewayIsIPv4OnDualStack and TestDefaultLinkHelpersFollowIPv4OnTwoNicDualStack. To avoid repetition and improve test maintainability, you could extract this logic into a new test helper function.

For example, you could create a function like createTestChartWithTalmHelpers that encapsulates this setup, and then call it from both tests.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
pkg/engine/render_test.go (1)

3581-3788: 💤 Low value

Fixtures look clean, but consider extracting a small builder to cut duplication.

dualStackNicLookup and dualStackTwoNicsLookup each repeat the same boilerplate (linksList, addressesList, nodeDefault, resolvers, the switch resource dispatcher) that already appears in simpleNicLookup and secondaryNicLookup. As more fixtures land (multi-link, IPv6-only, etc.) this will keep growing. Optional: introduce a small builder (e.g. lookupBuilder{links, addresses, routes, nodeAddress, resolvers}.Build()) so each fixture only declares the data it cares about. Not a blocker — purely a maintainability nit.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/engine/render_test.go` around lines 3581 - 3788, dualStackNicLookup and
dualStackTwoNicsLookup duplicate boilerplate that appears in simpleNicLookup and
secondaryNicLookup; refactor by introducing a small fixture builder (e.g. a
lookupBuilder type with fields links, addresses, routes, nodeAddress, resolvers
and a Build() method) and update dualStackNicLookup, dualStackTwoNicsLookup (and
optionally simpleNicLookup/secondaryNicLookup) to call lookupBuilder.Build() and
only supply the differing pieces (routes, eth0/eth1 definitions) so the shared
linksList/addressesList/nodeDefault/resolvers and the switch resource dispatcher
are centralized in the builder.
charts/talm/templates/_helpers.tpl (2)

102-121: 💤 Low value

$family is now effectively constant — consider hardcoding for clarity.

After the route filter narrows to family=inet4, $family can only be "inet4" (or "" when no IPv4 default exists, in which case $linkName is also "" and the address-filter loop produces an empty list either way). The two-stage capture-and-filter pattern made sense when the route filter was family-agnostic; now it just adds indirection. Either inline "inet4" or drop a comment explaining why the variable is kept (e.g. "future-proofing for an IPv6-aware sibling helper").

♻️ Optional simplification
 {{- define "talm.discovered.default_addresses_by_gateway" }}
 {{- $linkName := "" }}
-{{- $family := "" }}
 {{- range (lookup "routes" "" "").items }}
 {{- if and (eq .spec.dst "") (not (eq .spec.gateway "")) (eq .spec.table "main") (eq (.spec.family | toString) "inet4") }}
 {{- $linkName = .spec.outLinkName }}
-{{- $family = .spec.family }}
 {{- break }}
 {{- end }}
 {{- end }}
 {{- $addresses := list }}
 {{- range (lookup "addresses" "" "").items }}
-{{- if and (eq .spec.linkName $linkName) (eq .spec.family $family) (not (eq .spec.scope "host")) }}
+{{- if and (ne $linkName "") (eq .spec.linkName $linkName) (eq (.spec.family | toString) "inet4") (not (eq .spec.scope "host")) }}
 {{- if not (hasPrefix (printf "%s/" $.Values.floatingIP) .spec.address) }}
 {{- $addresses = append $addresses .spec.address }}
 {{- end }}
 {{- end }}
 {{- end }}
 {{- toJson $addresses }}
 {{- end }}

The added (ne $linkName "") guard also closes a small defensive gap: when no IPv4 default route exists, the previous code matched any address with empty linkName/family fields (rare in practice, but possible with malformed mocks).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@charts/talm/templates/_helpers.tpl` around lines 102 - 121, The helper
talm.discovered.default_addresses_by_gateway currently captures $family from a
route match but that value is effectively always "inet4"; simplify by hardcoding
the family check to "inet4" (remove the $family capture/assignment and use the
literal "inet4" when filtering addresses) or, if you intentionally want to keep
the indirection, add a brief comment above $family explaining it's retained for
future IPv6 support; update the address-filtering condition (which currently
compares .spec.family to $family) to compare to the literal "inet4" when
hardcoding, and keep the existing (ne $linkName "") defensive check to avoid
matching empty linkName cases.

156-216: 💤 Low value

Five helpers repeat the same default-route guard — consider extracting.

The clause (eq .spec.dst "") (not (eq .spec.gateway "")) (eq .spec.table "main") (eq (.spec.family | toString) "inet4") is duplicated across default_link_name_by_gateway, default_link_address_by_gateway, default_link_bus_by_gateway, default_link_selector_by_gateway, and default_gateway. A future change (e.g. honoring route priority, supporting an alternate table) has to land in five places. Since all five helpers also fundamentally need the same matching route, one option is to compute the IPv4 default route's outLinkName and gateway once into a tiny helper and have the link-attribute helpers dispatch off that.

Not blocking — Helm template factoring is awkward and the duplication is shallow — but worth flagging while the surface is fresh.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@charts/talm/templates/_helpers.tpl` around lines 156 - 216, Extract the
repeated default-route guard into a single helper (e.g.
"talm.discovered.ipv4_default_route") that runs the range over (lookup "routes"
"" "").items and returns the matching route object (the one satisfying (eq
.spec.dst "") (not (eq .spec.gateway "")) (eq .spec.table "main") (eq
(.spec.family | toString) "inet4") ) or empty; then change the five helpers
talm.discovered.default_link_name_by_gateway,
talm.discovered.default_link_address_by_gateway,
talm.discovered.default_link_bus_by_gateway,
talm.discovered.default_link_selector_by_gateway and
talm.discovered.default_gateway to call that new helper (use with/if to guard
empty) and derive .spec.outLinkName/.spec.gateway or lookup "links" ""
<outLinkName>.spec.hardwareAddr / .spec.busPath from the single returned route
instead of repeating the guard logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@charts/talm/templates/_helpers.tpl`:
- Around line 102-121: The helper talm.discovered.default_addresses_by_gateway
currently captures $family from a route match but that value is effectively
always "inet4"; simplify by hardcoding the family check to "inet4" (remove the
$family capture/assignment and use the literal "inet4" when filtering addresses)
or, if you intentionally want to keep the indirection, add a brief comment above
$family explaining it's retained for future IPv6 support; update the
address-filtering condition (which currently compares .spec.family to $family)
to compare to the literal "inet4" when hardcoding, and keep the existing (ne
$linkName "") defensive check to avoid matching empty linkName cases.
- Around line 156-216: Extract the repeated default-route guard into a single
helper (e.g. "talm.discovered.ipv4_default_route") that runs the range over
(lookup "routes" "" "").items and returns the matching route object (the one
satisfying (eq .spec.dst "") (not (eq .spec.gateway "")) (eq .spec.table "main")
(eq (.spec.family | toString) "inet4") ) or empty; then change the five helpers
talm.discovered.default_link_name_by_gateway,
talm.discovered.default_link_address_by_gateway,
talm.discovered.default_link_bus_by_gateway,
talm.discovered.default_link_selector_by_gateway and
talm.discovered.default_gateway to call that new helper (use with/if to guard
empty) and derive .spec.outLinkName/.spec.gateway or lookup "links" ""
<outLinkName>.spec.hardwareAddr / .spec.busPath from the single returned route
instead of repeating the guard logic.

In `@pkg/engine/render_test.go`:
- Around line 3581-3788: dualStackNicLookup and dualStackTwoNicsLookup duplicate
boilerplate that appears in simpleNicLookup and secondaryNicLookup; refactor by
introducing a small fixture builder (e.g. a lookupBuilder type with fields
links, addresses, routes, nodeAddress, resolvers and a Build() method) and
update dualStackNicLookup, dualStackTwoNicsLookup (and optionally
simpleNicLookup/secondaryNicLookup) to call lookupBuilder.Build() and only
supply the differing pieces (routes, eth0/eth1 definitions) so the shared
linksList/addressesList/nodeDefault/resolvers and the switch resource dispatcher
are centralized in the builder.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 023f224c-0b70-48c6-81dc-08e0318f5244

📥 Commits

Reviewing files that changed from the base of the PR and between 892c543 and 5d2ef46.

📒 Files selected for processing (4)
  • charts/cozystack/values.yaml
  • charts/generic/values.yaml
  • charts/talm/templates/_helpers.tpl
  • pkg/engine/render_test.go

@lexfrei
Aleksei Sviridkin (lexfrei) merged commit 741f895 into main May 7, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Interface default configuration to enable VIP when templating with talm

2 participants