Skip to content

Commit 6e78afb

Browse files
mcanevetsmira
authored andcommitted
feat(machined): add network alias support for OpenNebula (ETH*_ALIAS*)
Parse ETHn_ALIASm_* context variables and add secondary IPv4 addresses to the parent interface as additional AddressSpecSpec entries. Aliases are skipped when DETACH is non-empty or EXTERNAL=YES, matching the reference netcfg-networkd behavior. Also guard the ETHn_MAC interface loop to only process top-level interface keys (ETH<digits>_MAC), preventing alias MAC keys such as ETH0_ALIAS0_MAC from being mistakenly treated as interfaces. Signed-off-by: Mickaël Canévet <mickael.canevet@proton.ch> Signed-off-by: Andrey Smirnov <andrey.smirnov@siderolabs.com> (cherry picked from commit 196658c)
1 parent 9f648b4 commit 6e78afb

File tree

6 files changed

+292
-0
lines changed

6 files changed

+292
-0
lines changed
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
// This Source Code Form is subject to the terms of the Mozilla Public
2+
// License, v. 2.0. If a copy of the MPL was not distributed with this
3+
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
4+
5+
package opennebula_test
6+
7+
import (
8+
"net/netip"
9+
"testing"
10+
11+
"github.com/cosi-project/runtime/pkg/state"
12+
"github.com/cosi-project/runtime/pkg/state/impl/inmem"
13+
"github.com/cosi-project/runtime/pkg/state/impl/namespaced"
14+
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/require"
16+
17+
"github.com/siderolabs/talos/internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula"
18+
"github.com/siderolabs/talos/pkg/machinery/nethelpers"
19+
"github.com/siderolabs/talos/pkg/machinery/resources/network"
20+
)
21+
22+
const aliasContextBase = `ETH0_MAC = "02:00:c0:a8:01:5c"
23+
ETH0_IP = "192.168.1.92"
24+
ETH0_MASK = "255.255.255.0"
25+
NAME = "test"
26+
`
27+
28+
// aliasContext builds a minimal context string for alias testing.
29+
func aliasContext(extra string) []byte {
30+
return []byte(aliasContextBase + extra)
31+
}
32+
33+
func TestParseAliases(t *testing.T) {
34+
t.Parallel()
35+
36+
o := &opennebula.OpenNebula{}
37+
st := state.WrapCore(namespaced.NewState(inmem.Build))
38+
39+
alias0IPv4 := network.AddressSpecSpec{
40+
Address: netip.MustParsePrefix("192.168.1.100/24"),
41+
LinkName: "eth0",
42+
Family: nethelpers.FamilyInet4,
43+
Scope: nethelpers.ScopeGlobal,
44+
Flags: nethelpers.AddressFlags(nethelpers.AddressPermanent),
45+
ConfigLayer: network.ConfigPlatform,
46+
}
47+
48+
alias1IPv4 := network.AddressSpecSpec{
49+
Address: netip.MustParsePrefix("192.168.1.101/24"),
50+
LinkName: "eth0",
51+
Family: nethelpers.FamilyInet4,
52+
Scope: nethelpers.ScopeGlobal,
53+
Flags: nethelpers.AddressFlags(nethelpers.AddressPermanent),
54+
ConfigLayer: network.ConfigPlatform,
55+
}
56+
57+
for _, tc := range []struct {
58+
name string
59+
extra string
60+
wantAliasAddr []network.AddressSpecSpec
61+
}{
62+
{
63+
name: "IPv4 alias included",
64+
extra: `ETH0_ALIAS0_MAC = "02:00:c0:a8:01:64"
65+
ETH0_ALIAS0_IP = "192.168.1.100"
66+
ETH0_ALIAS0_MASK = "255.255.255.0"
67+
ETH0_ALIAS0_EXTERNAL = "NO"
68+
ETH0_ALIAS0_DETACH = ""`,
69+
wantAliasAddr: []network.AddressSpecSpec{alias0IPv4},
70+
},
71+
{
72+
name: "EXTERNAL=YES skips alias",
73+
extra: `ETH0_ALIAS0_MAC = "02:00:c0:a8:01:64"
74+
ETH0_ALIAS0_IP = "192.168.1.100"
75+
ETH0_ALIAS0_MASK = "255.255.255.0"
76+
ETH0_ALIAS0_EXTERNAL = "YES"
77+
ETH0_ALIAS0_DETACH = ""`,
78+
wantAliasAddr: nil,
79+
},
80+
{
81+
name: "EXTERNAL=NO includes alias",
82+
extra: `ETH0_ALIAS0_MAC = "02:00:c0:a8:01:64"
83+
ETH0_ALIAS0_IP = "192.168.1.100"
84+
ETH0_ALIAS0_MASK = "255.255.255.0"
85+
ETH0_ALIAS0_EXTERNAL = "NO"
86+
ETH0_ALIAS0_DETACH = ""`,
87+
wantAliasAddr: []network.AddressSpecSpec{alias0IPv4},
88+
},
89+
{
90+
name: "DETACH non-empty skips alias",
91+
extra: `ETH0_ALIAS0_MAC = "02:00:c0:a8:01:64"
92+
ETH0_ALIAS0_IP = "192.168.1.100"
93+
ETH0_ALIAS0_MASK = "255.255.255.0"
94+
ETH0_ALIAS0_EXTERNAL = "NO"
95+
ETH0_ALIAS0_DETACH = "yes"`,
96+
wantAliasAddr: nil,
97+
},
98+
{
99+
name: "DETACH empty includes alias",
100+
extra: `ETH0_ALIAS0_MAC = "02:00:c0:a8:01:64"
101+
ETH0_ALIAS0_IP = "192.168.1.100"
102+
ETH0_ALIAS0_MASK = "255.255.255.0"
103+
ETH0_ALIAS0_EXTERNAL = "NO"
104+
ETH0_ALIAS0_DETACH = ""`,
105+
wantAliasAddr: []network.AddressSpecSpec{alias0IPv4},
106+
},
107+
{
108+
name: "both DETACH non-empty and EXTERNAL=YES skips alias",
109+
extra: `ETH0_ALIAS0_MAC = "02:00:c0:a8:01:64"
110+
ETH0_ALIAS0_IP = "192.168.1.100"
111+
ETH0_ALIAS0_MASK = "255.255.255.0"
112+
ETH0_ALIAS0_EXTERNAL = "YES"
113+
ETH0_ALIAS0_DETACH = "yes"`,
114+
wantAliasAddr: nil,
115+
},
116+
{
117+
name: "multiple aliases sorted deterministically",
118+
extra: `ETH0_ALIAS1_MAC = "02:00:c0:a8:01:65"
119+
ETH0_ALIAS1_IP = "192.168.1.101"
120+
ETH0_ALIAS1_MASK = "255.255.255.0"
121+
ETH0_ALIAS1_EXTERNAL = "NO"
122+
ETH0_ALIAS1_DETACH = ""
123+
ETH0_ALIAS0_MAC = "02:00:c0:a8:01:64"
124+
ETH0_ALIAS0_IP = "192.168.1.100"
125+
ETH0_ALIAS0_MASK = "255.255.255.0"
126+
ETH0_ALIAS0_EXTERNAL = "NO"
127+
ETH0_ALIAS0_DETACH = ""`,
128+
// ALIAS0 must appear before ALIAS1 regardless of map iteration order
129+
wantAliasAddr: []network.AddressSpecSpec{alias0IPv4, alias1IPv4},
130+
},
131+
{
132+
name: "no alias keys — no extra addresses",
133+
extra: "",
134+
wantAliasAddr: nil,
135+
},
136+
} {
137+
t.Run(tc.name, func(t *testing.T) {
138+
t.Parallel()
139+
140+
networkConfig, err := o.ParseMetadata(st, aliasContext(tc.extra))
141+
require.NoError(t, err)
142+
143+
// The first address is always the primary ETH0 address; aliases follow.
144+
var aliasAddrs []network.AddressSpecSpec
145+
if len(networkConfig.Addresses) > 1 {
146+
aliasAddrs = networkConfig.Addresses[1:]
147+
}
148+
149+
assert.Equal(t, tc.wantAliasAddr, aliasAddrs)
150+
})
151+
}
152+
}
153+
154+
func TestParseAliasErrors(t *testing.T) {
155+
t.Parallel()
156+
157+
o := &opennebula.OpenNebula{}
158+
st := state.WrapCore(namespaced.NewState(inmem.Build))
159+
160+
t.Run("malformed IPv4 returns descriptive error", func(t *testing.T) {
161+
t.Parallel()
162+
163+
ctx := aliasContext(`ETH0_ALIAS0_MAC = "02:00:c0:a8:01:64"
164+
ETH0_ALIAS0_IP = "notanip"
165+
ETH0_ALIAS0_MASK = "255.255.255.0"
166+
ETH0_ALIAS0_EXTERNAL = "NO"
167+
ETH0_ALIAS0_DETACH = ""`)
168+
169+
_, err := o.ParseMetadata(st, ctx)
170+
require.ErrorContains(t, err, "ETH0_ALIAS0")
171+
require.ErrorContains(t, err, "IPv4")
172+
})
173+
}

internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula/opennebula.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
stderrors "errors"
1212
"fmt"
1313
"net/netip"
14+
"slices"
1415
"strconv"
1516
"strings"
1617

@@ -36,6 +37,86 @@ func (o *OpenNebula) Name() string {
3637
return "opennebula"
3738
}
3839

40+
// isDigitsOnly returns true if s is non-empty and contains only ASCII digits.
41+
func isDigitsOnly(s string) bool {
42+
for _, c := range s {
43+
if c < '0' || c > '9' {
44+
return false
45+
}
46+
}
47+
48+
return s != ""
49+
}
50+
51+
// collectAliasNames scans oneContext for keys of the form
52+
// <aliasPrefix><digits>_MAC and returns the sorted list of alias base names
53+
// (e.g. "ETH0_ALIAS0", "ETH0_ALIAS1").
54+
func collectAliasNames(oneContext map[string]string, aliasPrefix string) []string {
55+
seen := map[string]bool{}
56+
57+
var aliasNames []string
58+
59+
for key := range oneContext {
60+
if !strings.HasPrefix(key, aliasPrefix) || !strings.HasSuffix(key, "_MAC") {
61+
continue
62+
}
63+
64+
middle := strings.TrimPrefix(strings.TrimSuffix(key, "_MAC"), aliasPrefix)
65+
if !isDigitsOnly(middle) {
66+
continue
67+
}
68+
69+
aliasName := aliasPrefix + middle
70+
if !seen[aliasName] {
71+
seen[aliasName] = true
72+
aliasNames = append(aliasNames, aliasName)
73+
}
74+
}
75+
76+
slices.Sort(aliasNames)
77+
78+
return aliasNames
79+
}
80+
81+
// parseAliases collects ETHn_ALIASm_* address entries for a given interface.
82+
// An alias is skipped when DETACH is non-empty OR EXTERNAL=YES, matching the
83+
// reference netcfg-networkd behavior (lines 395-400).
84+
func parseAliases(oneContext map[string]string, ifaceName, ifaceNameLower string) ([]network.AddressSpecSpec, error) {
85+
aliasNames := collectAliasNames(oneContext, ifaceName+"_ALIAS")
86+
87+
var addrs []network.AddressSpecSpec
88+
89+
for _, aliasName := range aliasNames {
90+
// Skip detached aliases — reference: [ -z "${detach}" ]
91+
if oneContext[aliasName+"_DETACH"] != "" {
92+
continue
93+
}
94+
95+
// Skip externally managed aliases — reference: ! is_true "${external}"
96+
if strings.EqualFold(oneContext[aliasName+"_EXTERNAL"], "yes") {
97+
continue
98+
}
99+
100+
if ipStr := oneContext[aliasName+"_IP"]; ipStr != "" {
101+
ipPrefix, err := address.IPPrefixFrom(ipStr, oneContext[aliasName+"_MASK"])
102+
if err != nil {
103+
return nil, fmt.Errorf("alias %s: failed to parse IPv4: %w", aliasName, err)
104+
}
105+
106+
addrs = append(addrs, network.AddressSpecSpec{
107+
Address: ipPrefix,
108+
LinkName: ifaceNameLower,
109+
Family: nethelpers.FamilyInet4,
110+
Scope: nethelpers.ScopeGlobal,
111+
Flags: nethelpers.AddressFlags(nethelpers.AddressPermanent),
112+
ConfigLayer: network.ConfigPlatform,
113+
})
114+
}
115+
}
116+
117+
return addrs, nil
118+
}
119+
39120
// parseRouteFields extracts the destination prefix, gateway string, and optional
40121
// metric string from the fields of a single route entry.
41122
//
@@ -204,6 +285,13 @@ func (o *OpenNebula) ParseMetadata(st state.State, oneContextPlain []byte) (*run
204285
for key := range oneContext {
205286
if strings.HasPrefix(key, "ETH") && strings.HasSuffix(key, "_MAC") {
206287
ifaceName := strings.TrimSuffix(key, "_MAC")
288+
// Skip alias MAC keys (e.g. ETH0_ALIAS0_MAC); only process
289+
// top-level interface keys of the form ETH<digits>_MAC,
290+
// matching the reference get_context_interfaces() regex ETH[0-9]+.
291+
if !isDigitsOnly(strings.TrimPrefix(ifaceName, "ETH")) {
292+
continue
293+
}
294+
207295
ifaceNameLower := strings.ToLower(ifaceName)
208296

209297
if oneContext[ifaceName+"_METHOD"] == "dhcp" {
@@ -314,6 +402,15 @@ func (o *OpenNebula) ParseMetadata(st state.State, oneContextPlain []byte) (*run
314402

315403
allSearchDomains = append(allSearchDomains, strings.Fields(oneContext[ifaceName+"_SEARCH_DOMAIN"])...)
316404
}
405+
406+
// Process alias addresses for this interface (applies to both
407+
// static and DHCP interfaces, matching the reference behavior).
408+
aliasAddrs, err := parseAliases(oneContext, ifaceName, ifaceNameLower)
409+
if err != nil {
410+
return nil, err
411+
}
412+
413+
networkConfig.Addresses = append(networkConfig.Addresses, aliasAddrs...)
317414
}
318415
}
319416
// Emit a single merged ResolverSpecSpec combining global and per-interface

internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula/testdata/expected.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ addresses:
55
scope: global
66
flags: permanent
77
layer: platform
8+
- address: 192.168.1.100/24
9+
linkName: eth0
10+
family: inet4
11+
scope: global
12+
flags: permanent
13+
layer: platform
814
links:
915
- name: eth0
1016
logical: false

internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula/testdata/expected_no_network_flag.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ addresses:
55
scope: global
66
flags: permanent
77
layer: platform
8+
- address: 192.168.1.100/24
9+
linkName: eth0
10+
family: inet4
11+
scope: global
12+
flags: permanent
13+
layer: platform
814
links:
915
- name: eth0
1016
logical: false

internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula/testdata/metadata.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22
DISK_ID = "1"
33
DNS = "9.9.9.9"
44
ETH0_DNS = "192.168.1.1 8.8.8.8 1.1.1.1"
5+
ETH0_ALIAS0_DETACH = ""
6+
ETH0_ALIAS0_EXTERNAL = "NO"
7+
ETH0_ALIAS0_IP = "192.168.1.100"
8+
ETH0_ALIAS0_MAC = "02:00:c0:a8:01:64"
9+
ETH0_ALIAS0_MASK = "255.255.255.0"
510
ETH0_EXTERNAL = ""
611
ETH0_GATEWAY = "192.168.1.1"
712
ETH0_IP = "192.168.1.92"

internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula/testdata/metadata_no_network_flag.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44
DISK_ID = "1"
55
DNS = "9.9.9.9"
66
ETH0_DNS = "192.168.1.1 8.8.8.8 1.1.1.1"
7+
ETH0_ALIAS0_DETACH = ""
8+
ETH0_ALIAS0_EXTERNAL = "NO"
9+
ETH0_ALIAS0_IP = "192.168.1.100"
10+
ETH0_ALIAS0_MAC = "02:00:c0:a8:01:64"
11+
ETH0_ALIAS0_MASK = "255.255.255.0"
712
ETH0_EXTERNAL = ""
813
ETH0_GATEWAY = "192.168.1.1"
914
ETH0_IP = "192.168.1.92"

0 commit comments

Comments
 (0)