Skip to content

Commit bf5f7cc

Browse files
committed
feat(charts): emit BridgeConfig + gate default-route fallback on configurable set
Three CodeRabbit findings on the latest review, all of the same class: VIP-link selection paths can pick links the chart does not emit a per-link document for, leaving Layer2VIPConfig dangling. Resolved by implementing BridgeConfig emission properly rather than excluding bridges as a workaround: 1. bridge_slaves discovery helper: walks lookup "links" by spec.slaveKind=="bridge" + spec.masterIndex, symmetric to bond_slaves. 2. cozystack and generic charts: replace the prior "bridge as gateway = fail, non-gateway bridge = silently skipped" branch with a typed BridgeConfig emission carrying links (ports), optional stp / vlan blocks from spec.bridgeMaster, plus the shared addresses / routes / mtu shape. Bridges now behave like bonds at render time. 3. Both charts: guard the default-route-link fallback in the discovery-derived Layer2VIPConfig block on the same configurable_link_names set the matched-link path uses. Previously the fallback would happily promote a Wireguard / slave / other unmanaged link if that was where the IPv4 default route sat. Tests: - TestContract_NetworkMultidoc_BridgeConfigEmitted: non-gateway bridge yields a BridgeConfig document. - TestMultiDocEmitsBridgeConfigWhenBridgeCarriesDefaultRoute: gateway bridge now emits BridgeConfig + routes.gateway (was a hard fail before). - TestContract_Errors_MultidocBridgeAsGateway_NoLongerFails: pins the negation of the prior fail-fast. - TestContract_NetworkMultidoc_VIPOnBridge: VIP inside a bridge subnet pins to that bridge (BridgeConfig + Layer2VIPConfig co-emit, STP + ports verified). - TestContract_NetworkMultidoc_VIPSkipsNonConfigurableDefaultRouteLink plus generic mirror: default-route fallback honours the configurable gate, no Layer2VIPConfig when the only resolvable link is Wireguard. The unimplemented-BridgeConfig migration hint at engine.go:181 and its generic mirror are now obsolete — the gateway-bridge fail above them no longer fires, the typed branch handles both gateway and non-gateway cases uniformly. Signed-off-by: Aleksei Sviridkin <f@lex.la>
1 parent 66cc321 commit bf5f7cc

6 files changed

Lines changed: 460 additions & 90 deletions

File tree

charts/cozystack/templates/_helpers.tpl

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -249,17 +249,51 @@ link: {{ .Values.vipLink }}
249249
{{- $linkGateway = include "talm.discovered.gateway_by_link" $linkName }}
250250
{{- end }}
251251
{{- if eq $kind "bridge" }}
252-
{{- /* BridgeConfig is a separate v1alpha1 typed document the chart
253-
does not yet emit. Skipping a non-gateway bridge leaves the
254-
rendered config without a bridge document and the operator is
255-
responsible for declaring it via a per-node body. A bridge
256-
carrying the IPv4 default route, however, cannot be silently
257-
skipped: that would drop every network document for the
258-
gateway link and the rendered config would describe a node
259-
with no working uplink. Surface a fail with the offending
260-
link and the migration path. */ -}}
261-
{{- if $isGatewayLink }}
262-
{{- 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) }}
252+
{{- /* BridgeConfig emission. Discovers bridge ports (members) via
253+
talm.discovered.bridge_slaves and emits a typed v1.12+
254+
BridgeConfig document with the same address / route / mtu
255+
shape as the other branches. STP and VLAN filtering are
256+
opt-in: they are emitted only when the bridge controller
257+
reported a non-nil spec.bridgeMaster.stp / spec.bridgeMaster
258+
value, so a default-state bridge stays minimal. */ -}}
259+
{{- $bridgeMaster := $link.spec.bridgeMaster }}
260+
{{- $bridgePorts := fromJsonArray (include "talm.discovered.bridge_slaves" $link.spec.index) }}
261+
---
262+
apiVersion: v1alpha1
263+
kind: BridgeConfig
264+
name: {{ $linkName }}
265+
{{- if $bridgePorts }}
266+
links:
267+
{{- range $bridgePorts }}
268+
- {{ . }}
269+
{{- end }}
270+
{{- end }}
271+
{{- if $bridgeMaster }}
272+
{{- if $bridgeMaster.stp }}
273+
{{- if hasKey $bridgeMaster.stp "enabled" }}
274+
stp:
275+
enabled: {{ $bridgeMaster.stp.enabled }}
276+
{{- end }}
277+
{{- end }}
278+
{{- if $bridgeMaster.vlan }}
279+
{{- if hasKey $bridgeMaster.vlan "filtering" }}
280+
vlan:
281+
filtering: {{ $bridgeMaster.vlan.filtering }}
282+
{{- end }}
283+
{{- end }}
284+
{{- end }}
285+
{{- if $addresses }}
286+
addresses:
287+
{{- range $addresses }}
288+
- address: {{ . }}
289+
{{- end }}
290+
{{- end }}
291+
{{- if $linkGateway }}
292+
routes:
293+
- gateway: {{ $linkGateway }}
294+
{{- end }}
295+
{{- if $link.spec.mtu }}
296+
mtu: {{ $link.spec.mtu }}
263297
{{- end }}
264298
{{- else if eq $kind "bond" }}
265299
{{- $bondMaster := $link.spec.bondMaster }}
@@ -377,9 +411,18 @@ mtu: {{ $link.spec.mtu }}
377411
emitted, matching the prior behaviour. */}}
378412
{{- if and .Values.floatingIP (not .Values.vipLink) (eq .MachineType "controlplane") }}
379413
{{- $vipLink := include "talm.discovered.link_name_for_address" .Values.floatingIP }}
414+
{{- /* Default-gateway fallback must also point at a configurable
415+
link — otherwise an unmanaged default-route NIC (Wireguard,
416+
a CNI bridge before BridgeConfig support, a slave link) would
417+
silently win selection and the rendered Layer2VIPConfig would
418+
dangle on a link the chart never emits a per-link document
419+
for. Mirror the same configurable-link gate
420+
link_name_for_address applies inside its own iteration. */ -}}
380421
{{- if not $vipLink }}
422+
{{- if has $defaultLinkName $configurableLinks }}
381423
{{- $vipLink = $defaultLinkName }}
382424
{{- end }}
425+
{{- end }}
383426
{{- if $vipLink }}
384427
---
385428
apiVersion: v1alpha1

charts/generic/templates/_helpers.tpl

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -173,17 +173,51 @@ link: {{ .Values.vipLink }}
173173
{{- $linkGateway = include "talm.discovered.gateway_by_link" $linkName }}
174174
{{- end }}
175175
{{- if eq $kind "bridge" }}
176-
{{- /* BridgeConfig is a separate v1alpha1 typed document the chart
177-
does not yet emit. Skipping a non-gateway bridge leaves the
178-
rendered config without a bridge document and the operator is
179-
responsible for declaring it via a per-node body. A bridge
180-
carrying the IPv4 default route, however, cannot be silently
181-
skipped: that would drop every network document for the
182-
gateway link and the rendered config would describe a node
183-
with no working uplink. Surface a fail with the offending
184-
link and the migration path. */ -}}
185-
{{- if $isGatewayLink }}
186-
{{- 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) }}
176+
{{- /* BridgeConfig emission. Discovers bridge ports (members) via
177+
talm.discovered.bridge_slaves and emits a typed v1.12+
178+
BridgeConfig document with the same address / route / mtu
179+
shape as the other branches. STP and VLAN filtering are
180+
opt-in: they are emitted only when the bridge controller
181+
reported a non-nil spec.bridgeMaster.stp / spec.bridgeMaster
182+
value, so a default-state bridge stays minimal. */ -}}
183+
{{- $bridgeMaster := $link.spec.bridgeMaster }}
184+
{{- $bridgePorts := fromJsonArray (include "talm.discovered.bridge_slaves" $link.spec.index) }}
185+
---
186+
apiVersion: v1alpha1
187+
kind: BridgeConfig
188+
name: {{ $linkName }}
189+
{{- if $bridgePorts }}
190+
links:
191+
{{- range $bridgePorts }}
192+
- {{ . }}
193+
{{- end }}
194+
{{- end }}
195+
{{- if $bridgeMaster }}
196+
{{- if $bridgeMaster.stp }}
197+
{{- if hasKey $bridgeMaster.stp "enabled" }}
198+
stp:
199+
enabled: {{ $bridgeMaster.stp.enabled }}
200+
{{- end }}
201+
{{- end }}
202+
{{- if $bridgeMaster.vlan }}
203+
{{- if hasKey $bridgeMaster.vlan "filtering" }}
204+
vlan:
205+
filtering: {{ $bridgeMaster.vlan.filtering }}
206+
{{- end }}
207+
{{- end }}
208+
{{- end }}
209+
{{- if $addresses }}
210+
addresses:
211+
{{- range $addresses }}
212+
- address: {{ . }}
213+
{{- end }}
214+
{{- end }}
215+
{{- if $linkGateway }}
216+
routes:
217+
- gateway: {{ $linkGateway }}
218+
{{- end }}
219+
{{- if $link.spec.mtu }}
220+
mtu: {{ $link.spec.mtu }}
187221
{{- end }}
188222
{{- else if eq $kind "bond" }}
189223
{{- $bondMaster := $link.spec.bondMaster }}
@@ -301,9 +335,17 @@ mtu: {{ $link.spec.mtu }}
301335
emitted, matching the prior behaviour. */}}
302336
{{- if and .Values.floatingIP (not .Values.vipLink) (eq .MachineType "controlplane") }}
303337
{{- $vipLink := include "talm.discovered.link_name_for_address" .Values.floatingIP }}
338+
{{- /* Default-gateway fallback must also point at a configurable
339+
link — otherwise an unmanaged default-route NIC (Wireguard,
340+
a slave link) would silently win selection and the rendered
341+
Layer2VIPConfig would dangle on a link the chart never emits
342+
a per-link document for. Mirror the same configurable-link
343+
gate link_name_for_address applies inside its own iteration. */ -}}
304344
{{- if not $vipLink }}
345+
{{- if has $defaultLinkName $configurableLinks }}
305346
{{- $vipLink = $defaultLinkName }}
306347
{{- end }}
348+
{{- end }}
307349
{{- if $vipLink }}
308350
---
309351
apiVersion: v1alpha1

charts/talm/templates/_helpers.tpl

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,22 @@ busPath: {{ .spec.busPath }}
248248
{{- toJson $slaves -}}
249249
{{- end -}}
250250

251+
{{- /* Get bridge member interfaces (ports) for a given bridge index.
252+
Discovered via spec.slaveKind=="bridge" + spec.masterIndex
253+
matching — symmetric to bond_slaves above. Returns the JSON list
254+
so the multi-doc renderer can iterate without reaching back into
255+
the links collection. */ -}}
256+
{{- define "talm.discovered.bridge_slaves" -}}
257+
{{- $bridgeIndex := . -}}
258+
{{- $slaves := list -}}
259+
{{- range (lookup "links" "" "").items -}}
260+
{{- if and (eq .spec.slaveKind "bridge") (eq (int .spec.masterIndex) (int $bridgeIndex)) -}}
261+
{{- $slaves = append $slaves .metadata.id -}}
262+
{{- end -}}
263+
{{- end -}}
264+
{{- toJson $slaves -}}
265+
{{- end -}}
266+
251267
{{- /* Generate bond configuration from bondMaster spec */ -}}
252268
{{- define "talm.discovered.bond_config" -}}
253269
{{- $linkName := . -}}

pkg/engine/contract_errors_test.go

Lines changed: 12 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -272,35 +272,24 @@ func bridgeAsGatewayLookup() func(string, string, string) (map[string]any, error
272272
}
273273
}
274274

275-
// Contract: multi-doc renderer aborts when a bridge carries the IPv4
276-
// default route. BridgeConfig emission is not yet implemented in the
277-
// chart; the renderer must not silently skip the gateway link (which
278-
// would produce a config describing a node with no working uplink).
279-
// The fail names the offending link, the missing feature
280-
// (BridgeConfig), and the recourse (per-node body overlay, or move
281-
// the VIP via vipLink).
282-
func TestContract_Errors_MultidocBridgeAsGateway(t *testing.T) {
275+
// Contract: multi-doc renderer no longer aborts when a bridge
276+
// carries the IPv4 default route — it now emits a typed
277+
// BridgeConfig document with the gateway. The previous shape
278+
// hard-failed here on the premise that BridgeConfig emission was
279+
// unimplemented, but the typed-document branch handles bridges
280+
// symmetrically to bonds today. The per-chart cross-product is
281+
// pinned in TestContract_NetworkMultidoc_BridgeConfigEmitted and
282+
// TestMultiDocEmitsBridgeConfigWhenBridgeCarriesDefaultRoute; this
283+
// test stays as the negative pin against the previous fail-fast.
284+
func TestContract_Errors_MultidocBridgeAsGateway_NoLongerFails(t *testing.T) {
283285
for _, chartPath := range []string{cozystackChartPath, genericChartPath} {
284286
t.Run(chartPath, func(t *testing.T) {
285287
err := renderExpectingError(t, chartPath, multidocTalos, bridgeAsGatewayLookup(), map[string]any{
286288
"endpoint": testEndpoint,
287289
"advertisedSubnets": []any{testAdvertisedSubnet},
288290
})
289-
if err == nil {
290-
t.Fatalf("expected bridge-as-gateway fail, got nil")
291-
}
292-
msg := err.Error()
293-
if !strings.Contains(msg, "talm:") {
294-
t.Errorf("error must use 'talm:' prefix, got: %s", msg)
295-
}
296-
if !strings.Contains(msg, `"br0"`) {
297-
t.Errorf("error must name the offending link 'br0', got: %s", msg)
298-
}
299-
if !strings.Contains(msg, "BridgeConfig") {
300-
t.Errorf("error must mention BridgeConfig as the missing feature, got: %s", msg)
301-
}
302-
if !strings.Contains(msg, "vipLink") {
303-
t.Errorf("error must suggest vipLink as a workaround, got: %s", msg)
291+
if err != nil {
292+
t.Errorf("bridge-as-gateway must no longer fail (BridgeConfig is emitted now), got: %v", err)
304293
}
305294
})
306295
}

pkg/engine/contract_network_multidoc_test.go

Lines changed: 95 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -237,19 +237,62 @@ func TestContract_NetworkMultidoc_VLANOnBondRendersVLANConfig(t *testing.T) {
237237

238238
// === Bridge non-gateway: silent skip ===
239239

240-
// Contract: a bridge link that is NOT the IPv4 default route is
241-
// skipped silently — no BridgeConfig is emitted (chart does not yet
242-
// support BridgeConfig output), and no LinkConfig is emitted (it is
243-
// not a physical NIC). The expectation is that operators who run
244-
// bridges declare them via per-node body overlays. The non-gateway
245-
// case is the silent path; the gateway case is a hard fail (pinned
246-
// in contract_errors_test.go).
247-
func TestContract_NetworkMultidoc_NonGatewayBridgeSkipped(t *testing.T) {
240+
// Contract: a bridge link discovered on a node is rendered as a
241+
// typed BridgeConfig document, symmetric to BondConfig for bonds.
242+
// The non-gateway case lands BridgeConfig with addresses and mtu
243+
// but no routes entry (no default gateway to emit). The gateway
244+
// case adds the routes.gateway entry — pinned by
245+
// TestMultiDocEmitsBridgeConfigWhenBridgeCarriesDefaultRoute.
246+
//
247+
// Prior to BridgeConfig support landing, a non-gateway bridge was
248+
// silently skipped (no document emitted) on the premise that the
249+
// feature was unimplemented; this contract pins the current
250+
// "always emit" shape.
251+
func TestContract_NetworkMultidoc_BridgeConfigEmitted(t *testing.T) {
248252
out := renderCozystackWith(t, bridgeLookup(), map[string]any{
249253
"advertisedSubnets": []any{testAdvertisedSubnet},
250254
})
251-
// No BridgeConfig — feature unimplemented.
252-
assertNotContains(t, out, "kind: BridgeConfig")
255+
assertContains(t, out, "kind: BridgeConfig")
256+
assertContains(t, out, "name: br0")
257+
}
258+
259+
// Contract: a controlplane floatingIP that lives inside the subnet
260+
// configured on a bridge link now legitimately lands on the bridge —
261+
// the bridge is fully rendered as a typed BridgeConfig document, so
262+
// pinning the VIP there no longer leaves it dangling without a
263+
// surrounding network document. Symmetric to the VLAN-child case
264+
// pinned in HetznerTopology_VIPOnPrivateVLAN.
265+
//
266+
// Fixture: bridgeWithClusterSubnetLookup has br0 carrying
267+
// 10.5.0.10/24 (global scope) and the IPv4 default route. floatingIP
268+
// 10.5.0.99 is inside that subnet, so link_name_for_address resolves
269+
// to br0; the discovery-derived Layer2VIPConfig pin'ит link=br0.
270+
// Without BridgeConfig emission (the prior shape), this would have
271+
// been a "VIP on undocumented link" symptom; now BridgeConfig
272+
// documents the link explicitly and the chart also emits STP
273+
// settings carried in spec.bridgeMaster.
274+
func TestContract_NetworkMultidoc_VIPOnBridge(t *testing.T) {
275+
out := renderCozystackWith(t, bridgeWithClusterSubnetLookup(), map[string]any{
276+
"floatingIP": "10.5.0.99",
277+
"advertisedSubnets": []any{"10.5.0.0/24"},
278+
})
279+
280+
// BridgeConfig must be emitted alongside Layer2VIPConfig — that is
281+
// the whole reason landing the VIP on a bridge is now safe.
282+
assertContains(t, out, "kind: BridgeConfig")
283+
assertContains(t, out, "name: br0")
284+
assertContains(t, out, "- address: 10.5.0.10/24")
285+
assertContains(t, out, "gateway: 10.5.0.1")
286+
// STP setting from spec.bridgeMaster.stp.enabled must surface.
287+
assertContains(t, out, "stp:")
288+
assertContains(t, out, "enabled: true")
289+
// Bridge port discovered via spec.slaveKind=="bridge" must be
290+
// listed under the BridgeConfig.links.
291+
assertContains(t, out, "links:")
292+
assertContains(t, out, " - eth0")
293+
assertContains(t, out, "kind: Layer2VIPConfig")
294+
assertContains(t, out, `name: "10.5.0.99"`)
295+
assertContains(t, out, "link: br0")
253296
}
254297

255298
// === Layer2VIPConfig: discovery-derived ===
@@ -602,6 +645,48 @@ func TestContract_NetworkMultidoc_VIPFailsOnInvalidFloatingIP(t *testing.T) {
602645
}
603646
}
604647

648+
// Contract: when the default-route-link fallback resolves to a
649+
// non-configurable link (Wireguard, slave NIC, anything outside the
650+
// {physical, bond, vlan, bridge} set), the chart MUST NOT emit
651+
// Layer2VIPConfig — the chart does not emit a per-link document
652+
// for such links, so the VIP would dangle on a link the chart
653+
// never configures. The fallback path mirrors the configurable-
654+
// link gate that link_name_for_address applies inside its own
655+
// iteration; matched-link selection and fallback-link selection
656+
// have to honour the same renderable-link set.
657+
//
658+
// Fixture: IPv4 default route on wg0 (Wireguard, not configurable).
659+
// floatingIP 10.99.99.99 falls outside every discovered subnet, so
660+
// link_name_for_address returns empty. The fallback would have
661+
// picked wg0 before the guard landed; with the guard it skips and
662+
// no Layer2VIPConfig is emitted at all.
663+
func TestContract_NetworkMultidoc_VIPSkipsNonConfigurableDefaultRouteLink(t *testing.T) {
664+
out := renderCozystackWith(t, defaultRouteOnNonConfigurableLinkLookup(), map[string]any{
665+
"floatingIP": "10.99.99.99",
666+
"advertisedSubnets": []any{testAdvertisedSubnet},
667+
})
668+
if strings.Contains(out, "kind: Layer2VIPConfig") {
669+
t.Errorf("Layer2VIPConfig must not emit when the only resolvable link is non-configurable; got:\n%s", out)
670+
}
671+
if strings.Contains(out, "link: wg0") {
672+
t.Errorf("VIP pinned to non-configurable wg0 — fallback must honour the configurable-link gate; got:\n%s", out)
673+
}
674+
}
675+
676+
// Generic-chart mirror of TestContract_NetworkMultidoc_VIPSkipsNonConfigurableDefaultRouteLink.
677+
func TestContract_NetworkMultidoc_Generic_VIPSkipsNonConfigurableDefaultRouteLink(t *testing.T) {
678+
out := renderGenericWith(t, defaultRouteOnNonConfigurableLinkLookup(), map[string]any{
679+
"floatingIP": "10.99.99.99",
680+
"advertisedSubnets": []any{testAdvertisedSubnet},
681+
})
682+
if strings.Contains(out, "kind: Layer2VIPConfig") {
683+
t.Errorf("generic chart: Layer2VIPConfig must not emit when fallback target is non-configurable; got:\n%s", out)
684+
}
685+
if strings.Contains(out, "link: wg0") {
686+
t.Errorf("generic chart: VIP pinned to non-configurable wg0:\n%s", out)
687+
}
688+
}
689+
605690
// Contract: discovery has populated the addresses table with a
606691
// configurable link whose subnet contains the floatingIP, but the
607692
// routes table has no IPv4 default route yet (fresh-boot before

0 commit comments

Comments
 (0)