Skip to content

Commit 5df180b

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 f018fbe commit 5df180b

File tree

10 files changed

+292
-57
lines changed

10 files changed

+292
-57
lines changed

hack/release.toml

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

133146
[notes.extraArgs]

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

Lines changed: 42 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 {
@@ -125,11 +143,8 @@ func (ctrl *LinkAliasConfigController) Run(ctx context.Context, r controller.Run
125143
}
126144
}
127145

128-
if len(matchedLinks) == 0 {
129-
continue
130-
}
131-
132-
if len(matchedLinks) > 1 {
146+
// Fixed name: require exactly one match
147+
if len(matchedLinks) > 1 && !lac.IsPatternAlias() {
133148
logger.Warn("link selector matched multiple links, skipping",
134149
zap.String("selector", lac.LinkSelector().String()),
135150
zap.String("alias", lac.Name()),
@@ -141,19 +156,33 @@ func (ctrl *LinkAliasConfigController) Run(ctx context.Context, r controller.Run
141156
continue
142157
}
143158

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

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-
)
169+
return !alreadyAliased
170+
})
152171

172+
if len(matchedLinks) == 0 {
153173
continue
154174
}
155175

156-
linkAliases[matchedLink.Metadata().ID()] = lac.Name()
176+
if lac.IsPatternAlias() {
177+
// Pattern-based name: create sequential aliases for each matched link in name order
178+
for counter, matchedLink := range matchedLinks {
179+
linkAliases[matchedLink.Metadata().ID()] = strings.Replace(lac.Name(), "%d", strconv.Itoa(counter), 1)
180+
}
181+
} else {
182+
matchedLink := matchedLinks[0]
183+
184+
linkAliases[matchedLink.Metadata().ID()] = lac.Name()
185+
}
157186
}
158187

159188
for linkID, alias := range linkAliases {

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

Lines changed: 134 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,116 @@ func (suite *LinkAliasConfigSuite) TestMachineConfigurationNewStyle() {
8178
rtestutils.AssertNoResource[*network.LinkAliasSpec](suite.Ctx(), suite.T(), suite.State(), "enp0s2")
8279
}
8380

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

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)