Skip to content

Commit 6a5a0e3

Browse files
nberleesmira
authored andcommitted
feat: support pattern link aliases
Allow LinkAliasConfig names like net%d to match multiple links and assign sequential aliases in alphabetical order, skipping links already claimed by earlier alias configs. Add validation for the format verb and controller tests covering ordering and reconciliation on link changes. Signed-off-by: Nico Berlee <nico.berlee@on2it.net> Signed-off-by: Andrey Smirnov <andrey.smirnov@siderolabs.com>
1 parent 9758bd4 commit 6a5a0e3

File tree

11 files changed

+321
-58
lines changed

11 files changed

+321
-58
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,6 @@ require (
190190
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af
191191
gopkg.in/typ.v4 v4.4.0
192192
k8s.io/klog/v2 v2.130.1
193-
k8s.io/utils v0.0.0-20260108192941-914a6e750570
194193
kernel.org/pub/linux/libs/security/libcap/cap v1.2.77
195194
sigs.k8s.io/cli-utils v0.37.3-0.20250918194211-77c836a69463
196195
sigs.k8s.io/hydrophone v0.7.0
@@ -369,6 +368,7 @@ require (
369368
k8s.io/cli-runtime v0.35.0 // indirect
370369
k8s.io/component-helpers v0.35.0 // indirect
371370
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect
371+
k8s.io/utils v0.0.0-20260108192941-914a6e750570 // indirect
372372
kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 // indirect
373373
rsc.io/qr v0.2.0 // indirect
374374
sigs.k8s.io/controller-runtime v0.22.2 // indirect

hack/release.toml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,19 @@ A new `KubeSpanConfig` document has been introduced to configure KubeSpan settin
129129
It replaces and deprecates the previous method of configuring KubeSpan via the `.machine.network.kubespan` field.
130130
131131
The old configuration field will continue to work for backward compatibility.
132+
"""
133+
134+
[notes.link_alias_config]
135+
title = "LinkAliasConfig Pattern-Based Multi-Alias"
136+
description = """\
137+
`LinkAliasConfig` now supports pattern-based alias names using `%d` format verb (e.g. `net%d`).
138+
139+
When the alias name contains a `%d` format verb, the selector is allowed to match multiple links.
140+
Each matched link receives a sequential alias (e.g. `net0`, `net1`, ...) based on hardware address order
141+
of the links. Links already aliased by a previous config are automatically skipped.
142+
143+
This enables creating stable aliases from any N links using a single config document,
144+
useful for `BondConfig` and `BridgeConfig` member interfaces on varying hardware.
132145
"""
133146

134147
[notes.extraArgs]

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

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@
55
package network
66

77
import (
8+
"bytes"
89
"context"
910
"fmt"
1011
"slices"
12+
"strconv"
13+
"strings"
1114

1215
"github.com/cosi-project/runtime/pkg/controller"
1316
"github.com/cosi-project/runtime/pkg/safe"
@@ -89,6 +92,21 @@ func (ctrl *LinkAliasConfigController) Run(ctx context.Context, r controller.Run
8992
return item.TypedSpec().Physical()
9093
})
9194

95+
// sort the links by MAC address to ensure consistent alias assignment for pattern-based configs
96+
slices.SortFunc(physicalLinks, func(a, b *network.LinkStatus) int {
97+
addrA := a.TypedSpec().PermanentAddr
98+
if len(addrA) == 0 {
99+
addrA = a.TypedSpec().HardwareAddr
100+
}
101+
102+
addrB := b.TypedSpec().PermanentAddr
103+
if len(addrB) == 0 {
104+
addrB = b.TypedSpec().HardwareAddr
105+
}
106+
107+
return bytes.Compare(addrA, addrB)
108+
})
109+
92110
physicalLinkSpecs := make([]*networkpb.LinkStatusSpec, 0, len(physicalLinks))
93111

94112
for _, link := range physicalLinks {
@@ -107,6 +125,11 @@ func (ctrl *LinkAliasConfigController) Run(ctx context.Context, r controller.Run
107125
linkAliasConfigs = cfg.Config().NetworkLinkAliasConfigs()
108126
}
109127

128+
// sort the link alias configs by name to ensure deterministic processing order
129+
slices.SortFunc(linkAliasConfigs, func(a, b configconfig.NetworkLinkAliasConfig) int {
130+
return strings.Compare(a.Name(), b.Name())
131+
})
132+
110133
linkAliases := map[string]string{}
111134

112135
for _, lac := range linkAliasConfigs {
@@ -125,11 +148,8 @@ func (ctrl *LinkAliasConfigController) Run(ctx context.Context, r controller.Run
125148
}
126149
}
127150

128-
if len(matchedLinks) == 0 {
129-
continue
130-
}
131-
132-
if len(matchedLinks) > 1 {
151+
// Fixed name: require exactly one match
152+
if len(matchedLinks) > 1 && !lac.IsPatternAlias() {
133153
logger.Warn("link selector matched multiple links, skipping",
134154
zap.String("selector", lac.LinkSelector().String()),
135155
zap.String("alias", lac.Name()),
@@ -141,19 +161,33 @@ func (ctrl *LinkAliasConfigController) Run(ctx context.Context, r controller.Run
141161
continue
142162
}
143163

144-
matchedLink := matchedLinks[0]
164+
matchedLinks = xslices.Filter(matchedLinks, func(matchedLink *network.LinkStatus) bool {
165+
_, alreadyAliased := linkAliases[matchedLink.Metadata().ID()]
166+
if alreadyAliased {
167+
logger.Warn("link already has an alias, skipping",
168+
zap.String("link", matchedLink.Metadata().ID()),
169+
zap.String("existing_alias", linkAliases[matchedLink.Metadata().ID()]),
170+
zap.String("new_alias", lac.Name()),
171+
)
172+
}
145173

146-
if _, ok := linkAliases[matchedLink.Metadata().ID()]; ok {
147-
logger.Warn("link already has an alias, skipping",
148-
zap.String("link", matchedLink.Metadata().ID()),
149-
zap.String("existing_alias", linkAliases[matchedLink.Metadata().ID()]),
150-
zap.String("new_alias", lac.Name()),
151-
)
174+
return !alreadyAliased
175+
})
152176

177+
if len(matchedLinks) == 0 {
153178
continue
154179
}
155180

156-
linkAliases[matchedLink.Metadata().ID()] = lac.Name()
181+
if lac.IsPatternAlias() {
182+
// Pattern-based name: create sequential aliases for each matched link in name order
183+
for counter, matchedLink := range matchedLinks {
184+
linkAliases[matchedLink.Metadata().ID()] = strings.Replace(lac.Name(), "%d", strconv.Itoa(counter), 1)
185+
}
186+
} else {
187+
matchedLink := matchedLinks[0]
188+
189+
linkAliases[matchedLink.Metadata().ID()] = lac.Name()
190+
}
157191
}
158192

159193
for linkID, alias := range linkAliases {

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

Lines changed: 157 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,25 @@ type LinkAliasConfigSuite struct {
2929
ctest.DefaultSuite
3030
}
3131

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

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-
}
64+
suite.createLinks([]testLink{
65+
{name: "enp0s2", permanentAddr: "00:1a:2b:33:44:55"},
66+
{name: "enp1s3", permanentAddr: "33:44:55:66:77:88"},
67+
{name: "enp1s4", permanentAddr: "33:44:55:66:77:89"},
68+
})
7269

7370
rtestutils.AssertResource(suite.Ctx(), suite.T(), suite.State(), "enp0s2", func(spec *network.LinkAliasSpec, asrt *assert.Assertions) {
7471
asrt.Equal("net0", spec.TypedSpec().Alias)
@@ -81,6 +78,139 @@ func (suite *LinkAliasConfigSuite) TestMachineConfigurationNewStyle() {
8178
rtestutils.AssertNoResource[*network.LinkAliasSpec](suite.Ctx(), suite.T(), suite.State(), "enp0s2")
8279
}
8380

81+
func (suite *LinkAliasConfigSuite) TestMachineConfigurationTwoAliasesSameLink() {
82+
lc1 := networkcfg.NewLinkAliasConfigV1Alpha1("net1")
83+
lc1.Selector.Match = cel.MustExpression(cel.ParseBooleanExpression(`glob("00:1a:2b:*", mac(link.permanent_addr))`, celenv.LinkLocator()))
84+
85+
lc2 := networkcfg.NewLinkAliasConfigV1Alpha1("net0")
86+
lc2.Selector.Match = cel.MustExpression(cel.ParseBooleanExpression(`glob("00:1a:2b:33:*", mac(link.permanent_addr))`, celenv.LinkLocator()))
87+
88+
ctr, err := container.New(lc1, lc2)
89+
suite.Require().NoError(err)
90+
91+
cfg := config.NewMachineConfig(ctr)
92+
suite.Create(cfg)
93+
94+
suite.createLinks([]testLink{
95+
{name: "enp0s2", permanentAddr: "00:1a:2b:33:44:55"},
96+
})
97+
98+
// the "smallest" alias (net0) should win, net1 should be ignored since it conflicts with net0
99+
rtestutils.AssertResource(suite.Ctx(), suite.T(), suite.State(), "enp0s2", func(spec *network.LinkAliasSpec, asrt *assert.Assertions) {
100+
asrt.Equal("net0", spec.TypedSpec().Alias)
101+
})
102+
}
103+
104+
func (suite *LinkAliasConfigSuite) TestPatternAliasSortsByMAC() {
105+
// Test that pattern aliases are assigned in alphabetical order, regardless of creation order
106+
lc1 := networkcfg.NewLinkAliasConfigV1Alpha1("net%d")
107+
lc1.Selector.Match = cel.MustExpression(cel.ParseBooleanExpression(`link.type == 1`, celenv.LinkLocator()))
108+
109+
ctr, err := container.New(lc1)
110+
suite.Require().NoError(err)
111+
112+
cfg := config.NewMachineConfig(ctr)
113+
suite.Create(cfg)
114+
115+
// Create links out of order
116+
suite.createLinks([]testLink{
117+
{name: "enp1s4", permanentAddr: "33:44:55:66:77:88"},
118+
{name: "enp0s2", permanentAddr: "00:1a:2b:33:44:55"},
119+
{name: "enp1s3", permanentAddr: "33:44:55:66:77:89"},
120+
})
121+
122+
// Aliases should follow alphabetical order of link name
123+
rtestutils.AssertResource(suite.Ctx(), suite.T(), suite.State(), "enp0s2", func(spec *network.LinkAliasSpec, asrt *assert.Assertions) {
124+
asrt.Equal("net0", spec.TypedSpec().Alias)
125+
})
126+
rtestutils.AssertResource(suite.Ctx(), suite.T(), suite.State(), "enp1s3", func(spec *network.LinkAliasSpec, asrt *assert.Assertions) {
127+
asrt.Equal("net2", spec.TypedSpec().Alias)
128+
})
129+
rtestutils.AssertResource(suite.Ctx(), suite.T(), suite.State(), "enp1s4", func(spec *network.LinkAliasSpec, asrt *assert.Assertions) {
130+
asrt.Equal("net1", spec.TypedSpec().Alias)
131+
})
132+
133+
suite.Destroy(cfg)
134+
}
135+
136+
func (suite *LinkAliasConfigSuite) TestPatternSkipsAlreadyAliased() {
137+
// Test that a fixed-name config claims a link, and a subsequent pattern config skips it
138+
lc1 := networkcfg.NewLinkAliasConfigV1Alpha1("mgmt0")
139+
lc1.Selector.Match = cel.MustExpression(cel.ParseBooleanExpression(`mac(link.permanent_addr) == "00:1a:2b:33:44:55"`, celenv.LinkLocator()))
140+
141+
lc2 := networkcfg.NewLinkAliasConfigV1Alpha1("net%d")
142+
lc2.Selector.Match = cel.MustExpression(cel.ParseBooleanExpression(`link.type == 1`, celenv.LinkLocator()))
143+
144+
ctr, err := container.New(lc1, lc2)
145+
suite.Require().NoError(err)
146+
147+
cfg := config.NewMachineConfig(ctr)
148+
suite.Create(cfg)
149+
150+
suite.createLinks([]testLink{
151+
{name: "enp0s2", permanentAddr: "00:1a:2b:33:44:55"},
152+
{name: "enp1s3", permanentAddr: "33:44:55:66:77:88"},
153+
{name: "enp1s4", permanentAddr: "33:44:55:66:77:89"},
154+
})
155+
156+
// enp0s2 gets mgmt0 from the fixed-name config
157+
rtestutils.AssertResource(suite.Ctx(), suite.T(), suite.State(), "enp0s2", func(spec *network.LinkAliasSpec, asrt *assert.Assertions) {
158+
asrt.Equal("mgmt0", spec.TypedSpec().Alias)
159+
})
160+
// enp1s3 and enp1s4 get net0 and net1 from the pattern config (enp0s2 skipped)
161+
rtestutils.AssertResource(suite.Ctx(), suite.T(), suite.State(), "enp1s3", func(spec *network.LinkAliasSpec, asrt *assert.Assertions) {
162+
asrt.Equal("net0", spec.TypedSpec().Alias)
163+
})
164+
rtestutils.AssertResource(suite.Ctx(), suite.T(), suite.State(), "enp1s4", func(spec *network.LinkAliasSpec, asrt *assert.Assertions) {
165+
asrt.Equal("net1", spec.TypedSpec().Alias)
166+
})
167+
168+
suite.Destroy(cfg)
169+
}
170+
171+
func (suite *LinkAliasConfigSuite) TestPatternReconcileOnLinkChange() {
172+
// Test that when links change, pattern aliases are reconciled (re-numbered)
173+
lc1 := networkcfg.NewLinkAliasConfigV1Alpha1("net%d")
174+
lc1.Selector.Match = cel.MustExpression(cel.ParseBooleanExpression(`link.type == 1`, celenv.LinkLocator()))
175+
176+
ctr, err := container.New(lc1)
177+
suite.Require().NoError(err)
178+
179+
cfg := config.NewMachineConfig(ctr)
180+
suite.Create(cfg)
181+
182+
suite.createLinks([]testLink{
183+
{name: "enp0s2", permanentAddr: "00:1a:2b:33:44:55"},
184+
{name: "enp1s3", permanentAddr: "33:44:55:66:77:88"},
185+
{name: "enp1s4", permanentAddr: "33:44:55:66:77:89"},
186+
})
187+
188+
// Initial state: net0, net1, net2
189+
rtestutils.AssertResource(suite.Ctx(), suite.T(), suite.State(), "enp0s2", func(spec *network.LinkAliasSpec, asrt *assert.Assertions) {
190+
asrt.Equal("net0", spec.TypedSpec().Alias)
191+
})
192+
rtestutils.AssertResource(suite.Ctx(), suite.T(), suite.State(), "enp1s3", func(spec *network.LinkAliasSpec, asrt *assert.Assertions) {
193+
asrt.Equal("net1", spec.TypedSpec().Alias)
194+
})
195+
rtestutils.AssertResource(suite.Ctx(), suite.T(), suite.State(), "enp1s4", func(spec *network.LinkAliasSpec, asrt *assert.Assertions) {
196+
asrt.Equal("net2", spec.TypedSpec().Alias)
197+
})
198+
199+
// Remove the middle link — aliases should be re-numbered
200+
suite.Destroy(network.NewLinkStatus(network.NamespaceName, "enp1s3"))
201+
202+
// enp1s3 alias should be cleaned up, enp1s4 re-numbered to net1
203+
rtestutils.AssertNoResource[*network.LinkAliasSpec](suite.Ctx(), suite.T(), suite.State(), "enp1s3")
204+
rtestutils.AssertResource(suite.Ctx(), suite.T(), suite.State(), "enp0s2", func(spec *network.LinkAliasSpec, asrt *assert.Assertions) {
205+
asrt.Equal("net0", spec.TypedSpec().Alias)
206+
})
207+
rtestutils.AssertResource(suite.Ctx(), suite.T(), suite.State(), "enp1s4", func(spec *network.LinkAliasSpec, asrt *assert.Assertions) {
208+
asrt.Equal("net1", spec.TypedSpec().Alias)
209+
})
210+
211+
suite.Destroy(cfg)
212+
}
213+
84214
func TestLinkAliasConfigSuite(t *testing.T) {
85215
t.Parallel()
86216

pkg/machinery/config/config/network.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ type NetworkRouteConfig interface {
175175
type NetworkLinkAliasConfig interface {
176176
NamedDocument
177177
LinkSelector() cel.Expression
178+
IsPatternAlias() bool
178179
}
179180

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

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2457,16 +2457,16 @@
24572457
"name": {
24582458
"type": "string",
24592459
"title": "name",
2460-
"description": "Alias for the link.\n\nDon’t use system interface names like “eth0”, “ens3”, “enp0s2”, etc. as those may conflict\nwith existing physical interfaces.\n",
2461-
"markdownDescription": "Alias for the link.\n\nDon't use system interface names like \"eth0\", \"ens3\", \"enp0s2\", etc. as those may conflict\nwith existing physical interfaces.",
2462-
"x-intellij-html-description": "\u003cp\u003eAlias for the link.\u003c/p\u003e\n\n\u003cp\u003eDon\u0026rsquo;t use system interface names like \u0026ldquo;eth0\u0026rdquo;, \u0026ldquo;ens3\u0026rdquo;, \u0026ldquo;enp0s2\u0026rdquo;, etc. as those may conflict\nwith existing physical interfaces.\u003c/p\u003e\n"
2460+
"description": "Alias for the link.\n\nDon’t use system interface names like “eth0”, “ens3”, “enp0s2”, etc. as those may conflict\nwith existing physical interfaces.\n\nThe name can contain a single integer format verb (%d) to create multiple aliases\nfrom a single config document. When a format verb is detected, each matched link receives a sequential\nalias (e.g. net0, net1, …) based on hardware address order of the links.\nLinks already aliased by a previous config are automatically skipped.\n",
2461+
"markdownDescription": "Alias for the link.\n\nDon't use system interface names like \"eth0\", \"ens3\", \"enp0s2\", etc. as those may conflict\nwith existing physical interfaces.\n\nThe name can contain a single integer format verb (`%d`) to create multiple aliases\nfrom a single config document. When a format verb is detected, each matched link receives a sequential\nalias (e.g. `net0`, `net1`, ...) based on hardware address order of the links.\nLinks already aliased by a previous config are automatically skipped.",
2462+
"x-intellij-html-description": "\u003cp\u003eAlias for the link.\u003c/p\u003e\n\n\u003cp\u003eDon\u0026rsquo;t use system interface names like \u0026ldquo;eth0\u0026rdquo;, \u0026ldquo;ens3\u0026rdquo;, \u0026ldquo;enp0s2\u0026rdquo;, etc. as those may conflict\nwith existing physical interfaces.\u003c/p\u003e\n\n\u003cp\u003eThe name can contain a single integer format verb (\u003ccode\u003e%d\u003c/code\u003e) to create multiple aliases\nfrom a single config document. When a format verb is detected, each matched link receives a sequential\nalias (e.g. \u003ccode\u003enet0\u003c/code\u003e, \u003ccode\u003enet1\u003c/code\u003e, \u0026hellip;) based on hardware address order of the links.\nLinks already aliased by a previous config are automatically skipped.\u003c/p\u003e\n"
24632463
},
24642464
"selector": {
24652465
"$ref": "#/$defs/network.LinkSelector",
24662466
"title": "selector",
2467-
"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",
2468-
"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.",
2469-
"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"
2467+
"description": "Selector to match the link to alias.\n\nWhen the alias name is a fixed string, the selector must match exactly one link.\nWhen the alias name contains a format verb (e.g. net%d), the selector may match multiple links\nand each match receives a sequential alias.\nIf multiple selectors match the same link, the first one is used.\n",
2468+
"markdownDescription": "Selector to match the link to alias.\n\nWhen the alias name is a fixed string, the selector must match exactly one link.\nWhen the alias name contains a format verb (e.g. `net%d`), the selector may match multiple links\nand each match receives a sequential alias.\nIf multiple selectors match the same link, the first one is used.",
2469+
"x-intellij-html-description": "\u003cp\u003eSelector to match the link to alias.\u003c/p\u003e\n\n\u003cp\u003eWhen the alias name is a fixed string, the selector must match exactly one link.\nWhen the alias name contains a format verb (e.g. \u003ccode\u003enet%d\u003c/code\u003e), the selector may match multiple links\nand each match receives a sequential alias.\nIf multiple selectors match the same link, the first one is used.\u003c/p\u003e\n"
24702470
}
24712471
},
24722472
"additionalProperties": false,

0 commit comments

Comments
 (0)