Skip to content

Commit 9f648b4

Browse files
mcanevetsmira
authored andcommitted
feat(machined): merge global and per-interface DNS for OpenNebula
Accumulate DNS servers and search domains from both global context variables (DNS, SEARCH_DOMAIN) and per-interface variables (ETH*_DNS, ETH*_SEARCH_DOMAIN) into a single merged ResolverSpecSpec, matching the reference one-apps context-linux get_nameservers() / get_searchdomains() behavior that writes one /etc/resolv.conf. Signed-off-by: Mickaël Canévet <mickael.canevet@proton.ch> Signed-off-by: Andrey Smirnov <andrey.smirnov@siderolabs.com> (cherry picked from commit e96766e)
1 parent 04fba03 commit 9f648b4

File tree

6 files changed

+193
-18
lines changed

6 files changed

+193
-18
lines changed
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
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+
"testing"
9+
10+
"github.com/cosi-project/runtime/pkg/state"
11+
"github.com/cosi-project/runtime/pkg/state/impl/inmem"
12+
"github.com/cosi-project/runtime/pkg/state/impl/namespaced"
13+
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
15+
16+
"github.com/siderolabs/talos/internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula"
17+
)
18+
19+
func TestDNSMerge(t *testing.T) {
20+
t.Parallel()
21+
22+
o := &opennebula.OpenNebula{}
23+
st := state.WrapCore(namespaced.NewState(inmem.Build))
24+
25+
mac := `ETH0_MAC = "02:00:c0:a8:01:5c"
26+
ETH0_IP = "192.168.1.92"
27+
ETH0_MASK = "255.255.255.0"
28+
NAME = "test"
29+
`
30+
31+
for _, tc := range []struct {
32+
name string
33+
extra string
34+
wantDNS []string
35+
wantSearch []string
36+
wantNoResolver bool
37+
}{
38+
{
39+
name: "global DNS only",
40+
extra: `DNS = "9.9.9.9 1.1.1.1"`,
41+
wantDNS: []string{"9.9.9.9", "1.1.1.1"},
42+
wantSearch: nil,
43+
wantNoResolver: false,
44+
},
45+
{
46+
name: "per-interface DNS only",
47+
extra: `ETH0_DNS = "192.168.1.1 8.8.8.8"`,
48+
wantDNS: []string{"192.168.1.1", "8.8.8.8"},
49+
wantSearch: nil,
50+
wantNoResolver: false,
51+
},
52+
{
53+
name: "global and per-interface DNS merged, global first",
54+
extra: "DNS = \"9.9.9.9\"\nETH0_DNS = \"192.168.1.1\"",
55+
wantDNS: []string{"9.9.9.9", "192.168.1.1"},
56+
wantSearch: nil,
57+
wantNoResolver: false,
58+
},
59+
{
60+
name: "global SEARCH_DOMAIN only",
61+
extra: `SEARCH_DOMAIN = "global.example.com"`,
62+
wantDNS: nil,
63+
wantSearch: []string{"global.example.com"},
64+
wantNoResolver: false,
65+
},
66+
{
67+
name: "per-interface search domain only",
68+
extra: `ETH0_SEARCH_DOMAIN = "example.com"`,
69+
wantDNS: nil,
70+
wantSearch: []string{"example.com"},
71+
wantNoResolver: false,
72+
},
73+
{
74+
name: "global and per-interface search domains merged, global first",
75+
extra: "SEARCH_DOMAIN = \"global.example.com\"\nETH0_SEARCH_DOMAIN = \"example.com\"",
76+
wantDNS: nil,
77+
wantSearch: []string{"global.example.com", "example.com"},
78+
wantNoResolver: false,
79+
},
80+
{
81+
name: "neither global nor per-interface set — no resolver emitted",
82+
extra: "",
83+
wantNoResolver: true,
84+
},
85+
} {
86+
t.Run(tc.name, func(t *testing.T) {
87+
t.Parallel()
88+
89+
input := []byte(mac + tc.extra)
90+
91+
networkConfig, err := o.ParseMetadata(st, input)
92+
require.NoError(t, err)
93+
94+
if tc.wantNoResolver {
95+
assert.Empty(t, networkConfig.Resolvers)
96+
97+
return
98+
}
99+
100+
require.Len(t, networkConfig.Resolvers, 1)
101+
102+
resolver := networkConfig.Resolvers[0]
103+
104+
var dnsStrs []string
105+
106+
for _, ip := range resolver.DNSServers {
107+
dnsStrs = append(dnsStrs, ip.String())
108+
}
109+
110+
assert.Equal(t, tc.wantDNS, dnsStrs)
111+
assert.Equal(t, tc.wantSearch, resolver.SearchDomains)
112+
})
113+
}
114+
}
115+
116+
func TestDNSMergeError(t *testing.T) {
117+
t.Parallel()
118+
119+
o := &opennebula.OpenNebula{}
120+
st := state.WrapCore(namespaced.NewState(inmem.Build))
121+
122+
base := `ETH0_MAC = "02:00:c0:a8:01:5c"
123+
ETH0_IP = "192.168.1.92"
124+
ETH0_MASK = "255.255.255.0"
125+
NAME = "test"
126+
`
127+
128+
t.Run("malformed global DNS returns error", func(t *testing.T) {
129+
t.Parallel()
130+
131+
_, err := o.ParseMetadata(st, []byte(base+`DNS = "notanip"`))
132+
require.ErrorContains(t, err, "failed to parse global DNS server")
133+
require.ErrorContains(t, err, "notanip")
134+
})
135+
136+
t.Run("malformed per-interface DNS returns error with interface name", func(t *testing.T) {
137+
t.Parallel()
138+
139+
_, err := o.ParseMetadata(st, []byte(base+`ETH0_DNS = "notanip"`))
140+
require.ErrorContains(t, err, "ETH0")
141+
require.ErrorContains(t, err, "notanip")
142+
})
143+
}

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

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,25 @@ func (o *OpenNebula) ParseMetadata(st state.State, oneContextPlain []byte) (*run
174174
}
175175
}
176176

177+
// Seed the merged DNS/search-domain slices with global variables (DNS,
178+
// SEARCH_DOMAIN). These are applied regardless of interface, matching the
179+
// reference get_nameservers()/get_searchdomains() which processes global
180+
// variables before per-interface ones.
181+
var allDNSIPs []netip.Addr
182+
183+
var allSearchDomains []string
184+
185+
for s := range strings.FieldsSeq(oneContext["DNS"]) {
186+
ip, err := netip.ParseAddr(s)
187+
if err != nil {
188+
return nil, fmt.Errorf("failed to parse global DNS server %q: %w", s, err)
189+
}
190+
191+
allDNSIPs = append(allDNSIPs, ip)
192+
}
193+
194+
allSearchDomains = append(allSearchDomains, strings.Fields(oneContext["SEARCH_DOMAIN"])...)
195+
177196
// Iterate through parsed environment variables looking for ETHn_MAC keys.
178197
// The presence of ETHn_MAC is the sole trigger for interface configuration,
179198
// matching the behavior of the official OpenNebula guest contextualization
@@ -282,30 +301,31 @@ func (o *OpenNebula) ParseMetadata(st state.State, oneContextPlain []byte) (*run
282301
networkConfig.Routes = append(networkConfig.Routes, staticRoutes...)
283302
}
284303

285-
// Parse DNS servers
286-
dnsServers := strings.Fields(oneContext[ifaceName+"_DNS"])
287-
288-
var dnsIPs []netip.Addr
289-
290-
for _, dnsServer := range dnsServers {
291-
ip, err := netip.ParseAddr(dnsServer)
304+
// Accumulate per-interface DNS servers and search domains into
305+
// the shared slices (global values were seeded before the loop).
306+
for s := range strings.FieldsSeq(oneContext[ifaceName+"_DNS"]) {
307+
ip, err := netip.ParseAddr(s)
292308
if err != nil {
293-
return nil, fmt.Errorf("failed to parse DNS server IP: %w", err)
309+
return nil, fmt.Errorf("interface %s: failed to parse DNS server %q: %w", ifaceName, s, err)
294310
}
295311

296-
dnsIPs = append(dnsIPs, ip)
312+
allDNSIPs = append(allDNSIPs, ip)
297313
}
298314

299-
// Create ResolverSpecSpec entry with multiple DNS servers
300-
networkConfig.Resolvers = append(networkConfig.Resolvers,
301-
network.ResolverSpecSpec{
302-
DNSServers: dnsIPs,
303-
ConfigLayer: network.ConfigPlatform,
304-
},
305-
)
315+
allSearchDomains = append(allSearchDomains, strings.Fields(oneContext[ifaceName+"_SEARCH_DOMAIN"])...)
306316
}
307317
}
308318
}
319+
// Emit a single merged ResolverSpecSpec combining global and per-interface
320+
// values, matching the reference single /etc/resolv.conf output.
321+
if len(allDNSIPs) > 0 || len(allSearchDomains) > 0 {
322+
networkConfig.Resolvers = append(networkConfig.Resolvers, network.ResolverSpecSpec{
323+
DNSServers: allDNSIPs,
324+
SearchDomains: allSearchDomains,
325+
ConfigLayer: network.ConfigPlatform,
326+
})
327+
}
328+
309329
// Create HostnameSpecSpec entry
310330
networkConfig.Hostnames = append(networkConfig.Hostnames,
311331
network.HostnameSpecSpec{

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,14 @@ hostnames:
5656
layer: platform
5757
resolvers:
5858
- dnsServers:
59+
- 9.9.9.9
5960
- 192.168.1.1
6061
- 8.8.8.8
6162
- 1.1.1.1
6263
layer: platform
64+
searchDomains:
65+
- global.example.com
66+
- example.com
6367
timeServers: []
6468
operators: []
6569
externalIPs: []

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,14 @@ hostnames:
5656
layer: platform
5757
resolvers:
5858
- dnsServers:
59+
- 9.9.9.9
5960
- 192.168.1.1
6061
- 8.8.8.8
6162
- 1.1.1.1
6263
layer: platform
64+
searchDomains:
65+
- global.example.com
66+
- example.com
6367
timeServers: []
6468
operators: []
6569
externalIPs: []

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Context variables generated by OpenNebula
22
DISK_ID = "1"
3+
DNS = "9.9.9.9"
34
ETH0_DNS = "192.168.1.1 8.8.8.8 1.1.1.1"
45
ETH0_EXTERNAL = ""
56
ETH0_GATEWAY = "192.168.1.1"
@@ -17,11 +18,12 @@ ETH0_METRIC = ""
1718
ETH0_MTU = ""
1819
ETH0_NETWORK = "192.168.1.0"
1920
ETH0_ROUTES = "10.0.0.0 255.0.0.0 192.168.1.1, 172.16.0.0 255.255.0.0 192.168.1.1 500"
20-
ETH0_SEARCH_DOMAIN = ""
21+
ETH0_SEARCH_DOMAIN = "example.com"
2122
ETH0_VLAN_ID = "3"
2223
ETH0_VROUTER_IP = ""
2324
ETH0_VROUTER_IP6 = ""
2425
ETH0_VROUTER_MANAGEMENT = ""
26+
SEARCH_DOMAIN = "global.example.com"
2527
NETWORK = "YES"
2628
SSH_PUBLIC_KEY = ""
2729
TARGET = "hda"

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# ETH*_ variables are manually specified (e.g. for ETHER-type address ranges
33
# where NETWORK=YES would cause the server to overwrite them with empty values).
44
DISK_ID = "1"
5+
DNS = "9.9.9.9"
56
ETH0_DNS = "192.168.1.1 8.8.8.8 1.1.1.1"
67
ETH0_EXTERNAL = ""
78
ETH0_GATEWAY = "192.168.1.1"
@@ -19,11 +20,12 @@ ETH0_METRIC = ""
1920
ETH0_MTU = ""
2021
ETH0_NETWORK = "192.168.1.0"
2122
ETH0_ROUTES = "10.0.0.0 255.0.0.0 192.168.1.1, 172.16.0.0 255.255.0.0 192.168.1.1 500"
22-
ETH0_SEARCH_DOMAIN = ""
23+
ETH0_SEARCH_DOMAIN = "example.com"
2324
ETH0_VLAN_ID = "3"
2425
ETH0_VROUTER_IP = ""
2526
ETH0_VROUTER_IP6 = ""
2627
ETH0_VROUTER_MANAGEMENT = ""
28+
SEARCH_DOMAIN = "global.example.com"
2729
NETWORK = "NO"
2830
SSH_PUBLIC_KEY = ""
2931
TARGET = "hda"

0 commit comments

Comments
 (0)