Skip to content

Commit 86cee70

Browse files
committed
feat: add requireUniqueMatch and skipAliasedLinks to LinkAliasConfig
This adds two new selector options to `LinkAliasConfig`: - `requireUniqueMatch` (default: true): When false, allows the selector to match multiple links and uses the first matching link. - `skipAliasedLinks` (default: false): When true, skips links that already have an alias from a previous config. These options enable creating sequential aliases like `net0` and `net1` from any N random links, which is useful for bonding/bridging setups where consistent interface naming is needed regardless of physical hardware. Example: ```yaml apiVersion: v1alpha1 kind: LinkAliasConfig name: net0 selector: match: link.type == 1 requireUniqueMatch: false --- apiVersion: v1alpha1 kind: LinkAliasConfig name: net1 selector: match: link.type == 1 requireUniqueMatch: false skipAliasedLinks: true ``` Closes siderolabs#12718 Signed-off-by: Nico Berlee <nico.berlee@on2it.net>
1 parent 1bf95ee commit 86cee70

File tree

12 files changed

+302
-39
lines changed

12 files changed

+302
-39
lines changed

hack/release.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,17 @@ A new `KubeSpanConfig` document has been introduced to configure KubeSpan settin
127127
It replaces and deprecates the previous method of configuring KubeSpan via the `.machine.network.kubespan` field.
128128
129129
The old configuration field will continue to work for backward compatibility.
130+
"""
131+
132+
[notes.link_alias_config]
133+
title = "LinkAliasConfig Selector Enhancements"
134+
description = """\
135+
`LinkAliasConfig` selector now supports two new options:
136+
137+
- `requireUniqueMatch` (default: `true`): When `false`, uses the first matching link instead of requiring exactly one match.
138+
- `skipAliasedLinks` (default: `false`): When `true`, skips links already aliased by a previous `LinkAliasConfig`.
139+
140+
This enables creating stable aliases like `net0`, `net1` from any N links, useful for `BondConfig` and `BridgeConfig` member interfaces on varying hardware.
130141
"""
131142

132143
[notes.extraArgs]

internal/app/machined/pkg/controllers/network/link_alias_config.go

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,13 @@ func (ctrl *LinkAliasConfigController) Run(ctx context.Context, r controller.Run
113113
var matchedLinks []*network.LinkStatus
114114

115115
for idx, link := range physicalLinkSpecs {
116+
// Skip links that already have an alias if skipAliasedLinks is enabled
117+
if lac.SkipAliasedLinks() {
118+
if _, ok := linkAliases[physicalLinks[idx].Metadata().ID()]; ok {
119+
continue
120+
}
121+
}
122+
116123
matches, err := lac.LinkSelector().EvalBool(celenv.LinkLocator(), map[string]any{
117124
"link": link,
118125
})
@@ -130,15 +137,26 @@ func (ctrl *LinkAliasConfigController) Run(ctx context.Context, r controller.Run
130137
}
131138

132139
if len(matchedLinks) > 1 {
133-
logger.Warn("link selector matched multiple links, skipping",
140+
if lac.RequireUniqueMatch() {
141+
logger.Warn("link selector matched multiple links, skipping",
142+
zap.String("selector", lac.LinkSelector().String()),
143+
zap.String("alias", lac.Name()),
144+
zap.Strings("links", xslices.Map(matchedLinks, func(item *network.LinkStatus) string {
145+
return item.Metadata().ID()
146+
})),
147+
)
148+
149+
continue
150+
}
151+
152+
logger.Info("link selector matched multiple links, using first match",
134153
zap.String("selector", lac.LinkSelector().String()),
135154
zap.String("alias", lac.Name()),
155+
zap.String("selected_link", matchedLinks[0].Metadata().ID()),
136156
zap.Strings("links", xslices.Map(matchedLinks, func(item *network.LinkStatus) string {
137157
return item.Metadata().ID()
138158
})),
139159
)
140-
141-
continue
142160
}
143161

144162
matchedLink := matchedLinks[0]

internal/app/machined/pkg/controllers/network/link_alias_config_test.go

Lines changed: 123 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"time"
1212

1313
"github.com/cosi-project/runtime/pkg/resource/rtestutils"
14+
"github.com/siderolabs/go-pointer"
1415
"github.com/stretchr/testify/assert"
1516
"github.com/stretchr/testify/suite"
1617

@@ -29,6 +30,25 @@ type LinkAliasConfigSuite struct {
2930
ctest.DefaultSuite
3031
}
3132

33+
type testLink struct {
34+
name string
35+
permanentAddr string
36+
}
37+
38+
func (suite *LinkAliasConfigSuite) createLinks(links []testLink) {
39+
for _, link := range links {
40+
pAddr, err := net.ParseMAC(link.permanentAddr)
41+
suite.Require().NoError(err)
42+
43+
status := network.NewLinkStatus(network.NamespaceName, link.name)
44+
status.TypedSpec().PermanentAddr = nethelpers.HardwareAddr(pAddr)
45+
status.TypedSpec().HardwareAddr = nethelpers.HardwareAddr(pAddr)
46+
status.TypedSpec().Type = nethelpers.LinkEther
47+
48+
suite.Create(status)
49+
}
50+
}
51+
3252
func (suite *LinkAliasConfigSuite) TestMachineConfigurationNewStyle() {
3353
lc1 := networkcfg.NewLinkAliasConfigV1Alpha1("net0")
3454
lc1.Selector.Match = cel.MustExpression(cel.ParseBooleanExpression(`glob("00:1a:2b:*", mac(link.permanent_addr))`, celenv.LinkLocator()))
@@ -42,33 +62,11 @@ func (suite *LinkAliasConfigSuite) TestMachineConfigurationNewStyle() {
4262
cfg := config.NewMachineConfig(ctr)
4363
suite.Create(cfg)
4464

45-
for _, link := range []struct {
46-
name string
47-
permanentAddr string
48-
}{
49-
{
50-
name: "enp0s2",
51-
permanentAddr: "00:1a:2b:33:44:55",
52-
},
53-
{
54-
name: "enp1s3",
55-
permanentAddr: "33:44:55:66:77:88",
56-
},
57-
{
58-
name: "enp1s4",
59-
permanentAddr: "33:44:55:66:77:89",
60-
},
61-
} {
62-
pAddr, err := net.ParseMAC(link.permanentAddr)
63-
suite.Require().NoError(err)
64-
65-
status := network.NewLinkStatus(network.NamespaceName, link.name)
66-
status.TypedSpec().PermanentAddr = nethelpers.HardwareAddr(pAddr)
67-
status.TypedSpec().HardwareAddr = nethelpers.HardwareAddr(pAddr)
68-
status.TypedSpec().Type = nethelpers.LinkEther
69-
70-
suite.Create(status)
71-
}
65+
suite.createLinks([]testLink{
66+
{name: "enp0s2", permanentAddr: "00:1a:2b:33:44:55"},
67+
{name: "enp1s3", permanentAddr: "33:44:55:66:77:88"},
68+
{name: "enp1s4", permanentAddr: "33:44:55:66:77:89"},
69+
})
7270

7371
rtestutils.AssertResource(suite.Ctx(), suite.T(), suite.State(), "enp0s2", func(spec *network.LinkAliasSpec, asrt *assert.Assertions) {
7472
asrt.Equal("net0", spec.TypedSpec().Alias)
@@ -81,6 +79,104 @@ func (suite *LinkAliasConfigSuite) TestMachineConfigurationNewStyle() {
8179
rtestutils.AssertNoResource[*network.LinkAliasSpec](suite.Ctx(), suite.T(), suite.State(), "enp0s2")
8280
}
8381

82+
func (suite *LinkAliasConfigSuite) TestRequireUniqueMatchFalse() {
83+
// Test that when requireUniqueMatch is false, the first matching link is used
84+
lc1 := networkcfg.NewLinkAliasConfigV1Alpha1("net0")
85+
lc1.Selector.Match = cel.MustExpression(cel.ParseBooleanExpression(`glob("33:44:55:*", mac(link.permanent_addr))`, celenv.LinkLocator()))
86+
lc1.Selector.RequireUniqueMatch = pointer.To(false) // Allow multiple matches, use first
87+
88+
ctr, err := container.New(lc1)
89+
suite.Require().NoError(err)
90+
91+
cfg := config.NewMachineConfig(ctr)
92+
suite.Create(cfg)
93+
94+
suite.createLinks([]testLink{
95+
{name: "enp1s3", permanentAddr: "33:44:55:66:77:88"},
96+
{name: "enp1s4", permanentAddr: "33:44:55:66:77:89"},
97+
})
98+
99+
// First link (enp1s3) should get the alias since it's first in iteration order
100+
rtestutils.AssertResource(suite.Ctx(), suite.T(), suite.State(), "enp1s3", func(spec *network.LinkAliasSpec, asrt *assert.Assertions) {
101+
asrt.Equal("net0", spec.TypedSpec().Alias)
102+
})
103+
rtestutils.AssertNoResource[*network.LinkAliasSpec](suite.Ctx(), suite.T(), suite.State(), "enp1s4")
104+
105+
suite.Destroy(cfg)
106+
}
107+
108+
func (suite *LinkAliasConfigSuite) TestSkipAliasedLinks() {
109+
// Test that skipAliasedLinks allows creating net0 and net1 from any two links
110+
lc1 := networkcfg.NewLinkAliasConfigV1Alpha1("net0")
111+
lc1.Selector.Match = cel.MustExpression(cel.ParseBooleanExpression(`link.type == 1`, celenv.LinkLocator())) // Match all ethernet links
112+
lc1.Selector.RequireUniqueMatch = pointer.To(false)
113+
lc1.Selector.SkipAliasedLinks = pointer.To(false) // First config doesn't need to skip
114+
115+
lc2 := networkcfg.NewLinkAliasConfigV1Alpha1("net1")
116+
lc2.Selector.Match = cel.MustExpression(cel.ParseBooleanExpression(`link.type == 1`, celenv.LinkLocator())) // Same selector
117+
lc2.Selector.RequireUniqueMatch = pointer.To(false)
118+
lc2.Selector.SkipAliasedLinks = pointer.To(true) // Skip links already aliased
119+
120+
ctr, err := container.New(lc1, lc2)
121+
suite.Require().NoError(err)
122+
123+
cfg := config.NewMachineConfig(ctr)
124+
suite.Create(cfg)
125+
126+
suite.createLinks([]testLink{
127+
{name: "enp0s2", permanentAddr: "00:1a:2b:33:44:55"},
128+
{name: "enp1s3", permanentAddr: "33:44:55:66:77:88"},
129+
{name: "enp1s4", permanentAddr: "33:44:55:66:77:89"},
130+
})
131+
132+
// First link gets net0, second link gets net1
133+
rtestutils.AssertResource(suite.Ctx(), suite.T(), suite.State(), "enp0s2", func(spec *network.LinkAliasSpec, asrt *assert.Assertions) {
134+
asrt.Equal("net0", spec.TypedSpec().Alias)
135+
})
136+
rtestutils.AssertResource(suite.Ctx(), suite.T(), suite.State(), "enp1s3", func(spec *network.LinkAliasSpec, asrt *assert.Assertions) {
137+
asrt.Equal("net1", spec.TypedSpec().Alias)
138+
})
139+
// Third link doesn't get an alias
140+
rtestutils.AssertNoResource[*network.LinkAliasSpec](suite.Ctx(), suite.T(), suite.State(), "enp1s4")
141+
142+
suite.Destroy(cfg)
143+
}
144+
145+
func (suite *LinkAliasConfigSuite) TestSkipAliasedLinksWithUniqueMatch() {
146+
// Test requireUniqueMatch=true (default) + skipAliasedLinks=true
147+
// First config matches exactly one link, second config skips the aliased link and matches exactly one remaining link
148+
lc1 := networkcfg.NewLinkAliasConfigV1Alpha1("net0")
149+
lc1.Selector.Match = cel.MustExpression(cel.ParseBooleanExpression(`mac(link.permanent_addr) == "00:1a:2b:33:44:55"`, celenv.LinkLocator()))
150+
// requireUniqueMatch defaults to true
151+
152+
lc2 := networkcfg.NewLinkAliasConfigV1Alpha1("net1")
153+
lc2.Selector.Match = cel.MustExpression(cel.ParseBooleanExpression(`glob("*", mac(link.permanent_addr))`, celenv.LinkLocator())) // Matches all links
154+
// requireUniqueMatch defaults to true
155+
lc2.Selector.SkipAliasedLinks = pointer.To(true) // Skip enp0s2 which got net0
156+
157+
ctr, err := container.New(lc1, lc2)
158+
suite.Require().NoError(err)
159+
160+
cfg := config.NewMachineConfig(ctr)
161+
suite.Create(cfg)
162+
163+
suite.createLinks([]testLink{
164+
{name: "enp0s2", permanentAddr: "00:1a:2b:33:44:55"},
165+
{name: "enp1s3", permanentAddr: "33:44:55:66:77:88"},
166+
})
167+
168+
// First link gets net0 (exact match)
169+
rtestutils.AssertResource(suite.Ctx(), suite.T(), suite.State(), "enp0s2", func(spec *network.LinkAliasSpec, asrt *assert.Assertions) {
170+
asrt.Equal("net0", spec.TypedSpec().Alias)
171+
})
172+
// Second link gets net1 (enp0s2 skipped due to skipAliasedLinks, leaving only enp1s3 as unique match)
173+
rtestutils.AssertResource(suite.Ctx(), suite.T(), suite.State(), "enp1s3", func(spec *network.LinkAliasSpec, asrt *assert.Assertions) {
174+
asrt.Equal("net1", spec.TypedSpec().Alias)
175+
})
176+
177+
suite.Destroy(cfg)
178+
}
179+
84180
func TestLinkAliasConfigSuite(t *testing.T) {
85181
t.Parallel()
86182

pkg/machinery/config/config/network.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,11 @@ type NetworkRouteConfig interface {
175175
type NetworkLinkAliasConfig interface {
176176
NamedDocument
177177
LinkSelector() cel.Expression
178+
// RequireUniqueMatch returns true if the selector must match exactly one link.
179+
// When false, if multiple links match, the first matching link is used.
180+
RequireUniqueMatch() bool
181+
// SkipAliasedLinks returns true if links that already have an alias should be skipped.
182+
SkipAliasedLinks() bool
178183
}
179184

180185
// NetworkDHCPConfig defines a DHCP configuration for a network link.

pkg/machinery/config/schemas/config.schema.json

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2376,9 +2376,9 @@
23762376
"selector": {
23772377
"$ref": "#/$defs/network.LinkSelector",
23782378
"title": "selector",
2379-
"description": "Selector to match the link to alias.\n\nSelector must match exactly one link, otherwise an error is returned.\nIf multiple selectors match the same link, the first one is used.\n",
2380-
"markdownDescription": "Selector to match the link to alias.\n\nSelector must match exactly one link, otherwise an error is returned.\nIf multiple selectors match the same link, the first one is used.",
2381-
"x-intellij-html-description": "\u003cp\u003eSelector to match the link to alias.\u003c/p\u003e\n\n\u003cp\u003eSelector must match exactly one link, otherwise an error is returned.\nIf multiple selectors match the same link, the first one is used.\u003c/p\u003e\n"
2379+
"description": "Selector to match the link to alias.\n\nBy default, the selector must match exactly one link, otherwise the alias is not applied.\nSet requireUniqueMatch to false to allow multiple matches and use the first matching link.\nIf multiple selectors match the same link, the first one is used.\n",
2380+
"markdownDescription": "Selector to match the link to alias.\n\nBy default, the selector must match exactly one link, otherwise the alias is not applied.\nSet `requireUniqueMatch` to `false` to allow multiple matches and use the first matching link.\nIf multiple selectors match the same link, the first one is used.",
2381+
"x-intellij-html-description": "\u003cp\u003eSelector to match the link to alias.\u003c/p\u003e\n\n\u003cp\u003eBy default, the selector must match exactly one link, otherwise the alias is not applied.\nSet \u003ccode\u003erequireUniqueMatch\u003c/code\u003e to \u003ccode\u003efalse\u003c/code\u003e to allow multiple matches and use the first matching link.\nIf multiple selectors match the same link, the first one is used.\u003c/p\u003e\n"
23822382
}
23832383
},
23842384
"additionalProperties": false,
@@ -2476,6 +2476,20 @@
24762476
"description": "The Common Expression Language (CEL) expression to match the link.\n",
24772477
"markdownDescription": "The Common Expression Language (CEL) expression to match the link.",
24782478
"x-intellij-html-description": "\u003cp\u003eThe Common Expression Language (CEL) expression to match the link.\u003c/p\u003e\n"
2479+
},
2480+
"requireUniqueMatch": {
2481+
"type": "boolean",
2482+
"title": "requireUniqueMatch",
2483+
"description": "Require the selector to match exactly one link.\n\nWhen set to false, if multiple links match the selector, the first matching link is used.\nWhen set to true (default), if multiple links match, the alias is not applied.\n",
2484+
"markdownDescription": "Require the selector to match exactly one link.\n\nWhen set to `false`, if multiple links match the selector, the first matching link is used.\nWhen set to `true` (default), if multiple links match, the alias is not applied.",
2485+
"x-intellij-html-description": "\u003cp\u003eRequire the selector to match exactly one link.\u003c/p\u003e\n\n\u003cp\u003eWhen set to \u003ccode\u003efalse\u003c/code\u003e, if multiple links match the selector, the first matching link is used.\nWhen set to \u003ccode\u003etrue\u003c/code\u003e (default), if multiple links match, the alias is not applied.\u003c/p\u003e\n"
2486+
},
2487+
"skipAliasedLinks": {
2488+
"type": "boolean",
2489+
"title": "skipAliasedLinks",
2490+
"description": "Skip links that already have an alias assigned by a previous LinkAliasConfig.\n\nThis allows creating sequential aliases like net0 and net1 from any N links\nby using the same broad selector and relying on processing order.\n",
2491+
"markdownDescription": "Skip links that already have an alias assigned by a previous LinkAliasConfig.\n\nThis allows creating sequential aliases like `net0` and `net1` from any N links\nby using the same broad selector and relying on processing order.",
2492+
"x-intellij-html-description": "\u003cp\u003eSkip links that already have an alias assigned by a previous LinkAliasConfig.\u003c/p\u003e\n\n\u003cp\u003eThis allows creating sequential aliases like \u003ccode\u003enet0\u003c/code\u003e and \u003ccode\u003enet1\u003c/code\u003e from any N links\nby using the same broad selector and relying on processing order.\u003c/p\u003e\n"
24792493
}
24802494
},
24812495
"additionalProperties": false,

pkg/machinery/config/types/network/deep_copy.generated.go

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/machinery/config/types/network/link_alias.go

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ type LinkAliasConfigV1Alpha1 struct {
6565
// description: |
6666
// Selector to match the link to alias.
6767
//
68-
// Selector must match exactly one link, otherwise an error is returned.
68+
// By default, the selector must match exactly one link, otherwise the alias is not applied.
69+
// Set `requireUniqueMatch` to `false` to allow multiple matches and use the first matching link.
6970
// If multiple selectors match the same link, the first one is used.
7071
Selector LinkSelector `yaml:"selector,omitempty"`
7172
}
@@ -87,6 +88,22 @@ type LinkSelector struct {
8788
// exampleLinkSelector3()
8889
// name: match links by driver name
8990
Match cel.Expression `yaml:"match,omitempty"`
91+
// description: |
92+
// Require the selector to match exactly one link.
93+
//
94+
// When set to `false`, if multiple links match the selector, the first matching link is used.
95+
// When set to `true` (default), if multiple links match, the alias is not applied.
96+
// schema:
97+
// type: boolean
98+
RequireUniqueMatch *bool `yaml:"requireUniqueMatch,omitempty"`
99+
// description: |
100+
// Skip links that already have an alias assigned by a previous LinkAliasConfig.
101+
//
102+
// This allows creating sequential aliases like `net0` and `net1` from any N links
103+
// by using the same broad selector and relying on processing order.
104+
// schema:
105+
// type: boolean
106+
SkipAliasedLinks *bool `yaml:"skipAliasedLinks,omitempty"`
90107
}
91108

92109
// NewLinkAliasConfigV1Alpha1 creates a new LinkAliasConfig config document.
@@ -155,3 +172,21 @@ func (s *LinkAliasConfigV1Alpha1) Validate(validation.RuntimeMode, ...validation
155172
func (s *LinkAliasConfigV1Alpha1) LinkSelector() cel.Expression {
156173
return s.Selector.Match
157174
}
175+
176+
// RequireUniqueMatch implements config.NetworkLinkAliasConfig interface.
177+
func (s *LinkAliasConfigV1Alpha1) RequireUniqueMatch() bool {
178+
if s.Selector.RequireUniqueMatch == nil {
179+
return true
180+
}
181+
182+
return *s.Selector.RequireUniqueMatch
183+
}
184+
185+
// SkipAliasedLinks implements config.NetworkLinkAliasConfig interface.
186+
func (s *LinkAliasConfigV1Alpha1) SkipAliasedLinks() bool {
187+
if s.Selector.SkipAliasedLinks == nil {
188+
return false
189+
}
190+
191+
return *s.Selector.SkipAliasedLinks
192+
}

0 commit comments

Comments
 (0)