Skip to content

Commit abd2da5

Browse files
Fix DNS in NetworkState (SC-133) (canonical#923)
v1 network config currently has no concept of interface-specific DNS, which is required for certain renderers. To fix this, added an optional 'interface' key on the v1 nameserver definition. If specified, it makes the DNS settings specific to the interface. Otherwise, it will be defined as global DNS as it always has. Additionally, DNS for v2 wasn't being recognized correctly. For DNS defined on a particular interface, these settings now also go into the global DNS settings as they were intended.
1 parent fbcb224 commit abd2da5

File tree

3 files changed

+159
-15
lines changed

3 files changed

+159
-15
lines changed

cloudinit/net/network_state.py

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ def __init__(self, version=NETWORK_STATE_VERSION, config=None):
237237
self._network_state = copy.deepcopy(self.initial_network_state)
238238
self._network_state['config'] = config
239239
self._parsed = False
240+
self._interface_dns_map = {}
240241

241242
@property
242243
def network_state(self):
@@ -310,6 +311,21 @@ def parse_config_v1(self, skip_broken=True):
310311
LOG.warning("Skipping invalid command: %s", command,
311312
exc_info=True)
312313
LOG.debug(self.dump_network_state())
314+
for interface, dns in self._interface_dns_map.items():
315+
iface = None
316+
try:
317+
iface = self._network_state['interfaces'][interface]
318+
except KeyError as e:
319+
raise ValueError(
320+
'Nameserver specified for interface {0}, '
321+
'but interface {0} does not exist!'.format(interface)
322+
) from e
323+
if iface:
324+
nameservers, search = dns
325+
iface['dns'] = {
326+
'addresses': nameservers,
327+
'search': search,
328+
}
313329

314330
def parse_config_v2(self, skip_broken=True):
315331
for command_type, command in self._config.items():
@@ -526,21 +542,40 @@ def handle_bridge(self, command):
526542
def handle_infiniband(self, command):
527543
self.handle_physical(command)
528544

529-
@ensure_command_keys(['address'])
530-
def handle_nameserver(self, command):
531-
dns = self._network_state.get('dns')
545+
def _parse_dns(self, command):
546+
nameservers = []
547+
search = []
532548
if 'address' in command:
533549
addrs = command['address']
534550
if not type(addrs) == list:
535551
addrs = [addrs]
536552
for addr in addrs:
537-
dns['nameservers'].append(addr)
553+
nameservers.append(addr)
538554
if 'search' in command:
539555
paths = command['search']
540556
if not isinstance(paths, list):
541557
paths = [paths]
542558
for path in paths:
543-
dns['search'].append(path)
559+
search.append(path)
560+
return nameservers, search
561+
562+
@ensure_command_keys(['address'])
563+
def handle_nameserver(self, command):
564+
dns = self._network_state.get('dns')
565+
nameservers, search = self._parse_dns(command)
566+
if 'interface' in command:
567+
self._interface_dns_map[command['interface']] = (
568+
nameservers, search
569+
)
570+
else:
571+
dns['nameservers'].extend(nameservers)
572+
dns['search'].extend(search)
573+
574+
@ensure_command_keys(['address'])
575+
def _handle_individual_nameserver(self, command, iface):
576+
_iface = self._network_state.get('interfaces')
577+
nameservers, search = self._parse_dns(command)
578+
_iface[iface]['dns'] = {'nameservers': nameservers, 'search': search}
544579

545580
@ensure_command_keys(['destination'])
546581
def handle_route(self, command):
@@ -706,16 +741,17 @@ def handle_wifis(self, command):
706741

707742
def _v2_common(self, cfg):
708743
LOG.debug('v2_common: handling config:\n%s', cfg)
709-
if 'nameservers' in cfg:
710-
search = cfg.get('nameservers').get('search', [])
711-
dns = cfg.get('nameservers').get('addresses', [])
712-
name_cmd = {'type': 'nameserver'}
713-
if len(search) > 0:
714-
name_cmd.update({'search': search})
715-
if len(dns) > 0:
716-
name_cmd.update({'addresses': dns})
717-
LOG.debug('v2(nameserver) -> v1(nameserver):\n%s', name_cmd)
718-
self.handle_nameserver(name_cmd)
744+
for iface, dev_cfg in cfg.items():
745+
if 'nameservers' in dev_cfg:
746+
search = dev_cfg.get('nameservers').get('search', [])
747+
dns = dev_cfg.get('nameservers').get('addresses', [])
748+
name_cmd = {'type': 'nameserver'}
749+
if len(search) > 0:
750+
name_cmd.update({'search': search})
751+
if len(dns) > 0:
752+
name_cmd.update({'address': dns})
753+
self.handle_nameserver(name_cmd)
754+
self._handle_individual_nameserver(name_cmd, iface)
719755

720756
def _handle_bond_bridge(self, command, cmd_type=None):
721757
"""Common handler for bond and bridge types"""

cloudinit/net/tests/test_network_state.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,62 @@
22

33
from unittest import mock
44

5+
import pytest
6+
7+
from cloudinit import safeyaml
58
from cloudinit.net import network_state
69
from cloudinit.tests.helpers import CiTestCase
710

811
netstate_path = 'cloudinit.net.network_state'
912

1013

14+
_V1_CONFIG_NAMESERVERS = """\
15+
network:
16+
version: 1
17+
config:
18+
- type: nameserver
19+
interface: {iface}
20+
address:
21+
- 192.168.1.1
22+
- 8.8.8.8
23+
search:
24+
- spam.local
25+
- type: nameserver
26+
address:
27+
- 192.168.1.0
28+
- 4.4.4.4
29+
search:
30+
- eggs.local
31+
- type: physical
32+
name: eth0
33+
mac_address: '00:11:22:33:44:55'
34+
- type: physical
35+
name: eth1
36+
mac_address: '66:77:88:99:00:11'
37+
"""
38+
39+
V1_CONFIG_NAMESERVERS_VALID = _V1_CONFIG_NAMESERVERS.format(iface='eth1')
40+
V1_CONFIG_NAMESERVERS_INVALID = _V1_CONFIG_NAMESERVERS.format(iface='eth90')
41+
42+
V2_CONFIG_NAMESERVERS = """\
43+
network:
44+
version: 2
45+
ethernets:
46+
eth0:
47+
match:
48+
macaddress: '00:11:22:33:44:55'
49+
nameservers:
50+
search: [spam.local, eggs.local]
51+
addresses: [8.8.8.8]
52+
eth1:
53+
match:
54+
macaddress: '66:77:88:99:00:11'
55+
nameservers:
56+
search: [foo.local, bar.local]
57+
addresses: [4.4.4.4]
58+
"""
59+
60+
1161
class TestNetworkStateParseConfig(CiTestCase):
1262

1363
def setUp(self):
@@ -55,4 +105,57 @@ def test_version_2_ignores_renderer_key(self):
55105
self.assertEqual(ncfg, nsi.as_dict()['config'])
56106

57107

108+
class TestNetworkStateParseNameservers:
109+
def _parse_network_state_from_config(self, config):
110+
yaml = safeyaml.load(config)
111+
return network_state.parse_net_config_data(yaml['network'])
112+
113+
def test_v1_nameservers_valid(self):
114+
config = self._parse_network_state_from_config(
115+
V1_CONFIG_NAMESERVERS_VALID)
116+
117+
# If an interface was specified, DNS shouldn't be in the global list
118+
assert ['192.168.1.0', '4.4.4.4'] == sorted(
119+
config.dns_nameservers)
120+
assert ['eggs.local'] == config.dns_searchdomains
121+
122+
# If an interface was specified, DNS should be part of the interface
123+
for iface in config.iter_interfaces():
124+
if iface['name'] == 'eth1':
125+
assert iface['dns']['addresses'] == ['192.168.1.1', '8.8.8.8']
126+
assert iface['dns']['search'] == ['spam.local']
127+
else:
128+
assert 'dns' not in iface
129+
130+
def test_v1_nameservers_invalid(self):
131+
with pytest.raises(ValueError):
132+
self._parse_network_state_from_config(
133+
V1_CONFIG_NAMESERVERS_INVALID)
134+
135+
def test_v2_nameservers(self):
136+
config = self._parse_network_state_from_config(V2_CONFIG_NAMESERVERS)
137+
138+
# Ensure DNS defined on interface exists on interface
139+
for iface in config.iter_interfaces():
140+
if iface['name'] == 'eth0':
141+
assert iface['dns'] == {
142+
'nameservers': ['8.8.8.8'],
143+
'search': ['spam.local', 'eggs.local'],
144+
}
145+
else:
146+
assert iface['dns'] == {
147+
'nameservers': ['4.4.4.4'],
148+
'search': ['foo.local', 'bar.local']
149+
}
150+
151+
# Ensure DNS defined on interface also exists globally (since there
152+
# is no global DNS definitions in v2)
153+
assert ['4.4.4.4', '8.8.8.8'] == sorted(config.dns_nameservers)
154+
assert [
155+
'bar.local',
156+
'eggs.local',
157+
'foo.local',
158+
'spam.local',
159+
] == sorted(config.dns_searchdomains)
160+
58161
# vi: ts=4 expandtab

doc/rtd/topics/network-config-format-v1.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,10 @@ the following keys:
335335

336336
- ``address``: List of IPv4 or IPv6 address of nameservers.
337337
- ``search``: List of of hostnames to include in the resolv.conf search path.
338+
- ``interface``: Optional. Ties the nameserver definition to the specified
339+
interface. The value specified here must match the `name` of an interface
340+
defined in this config. If unspecified, this nameserver will be considered
341+
a global nameserver.
338342

339343
**Nameserver Example**::
340344

@@ -349,6 +353,7 @@ the following keys:
349353
address: 192.168.23.14/27
350354
gateway: 192.168.23.1
351355
- type: nameserver
356+
interface: interface0 # Ties nameserver to interface0 only
352357
address:
353358
- 192.168.23.2
354359
- 8.8.8.8

0 commit comments

Comments
 (0)