Merge ~chad.smith/cloud-init:bug/azure-dhcp6-metric into cloud-init:master

Proposed by Chad Smith
Status: Merged
Approved by: Chad Smith
Approved revision: 9152f2dad5cb51f4d326fc5fcffb89f6bd9060b6
Merge reported by: Server Team CI bot
Merged at revision: not available
Proposed branch: ~chad.smith/cloud-init:bug/azure-dhcp6-metric
Merge into: cloud-init:master
Diff against target: 307 lines (+178/-10)
5 files modified
cloudinit/net/network_state.py (+13/-4)
cloudinit/net/sysconfig.py (+3/-3)
cloudinit/sources/DataSourceAzure.py (+7/-3)
tests/unittests/test_datasource/test_azure.py (+101/-0)
tests/unittests/test_net.py (+54/-0)
Reviewer Review Type Date Requested Status
Server Team CI bot continuous-integration Approve
Ryan Harper Approve
Review via email: mp+374994@code.launchpad.net

Commit message

azure: support matching dhcp route-metrics for dual-stack ipv4 ipv6

Network v2 configuration for Azure will set both dhcp4 and
dhcp6 to False by default.

When IPv6 privateIpAddresses are present for an interface in Azure's
Instance Metadata Service (IMDS), set dhcp6: True and provide a
route-metric value that will match the corresponding dhcp4 route-metric.
The route-metric value will increase by 100 for each additional
interface present to ensure the primary interface has a route to IMDS.

Also fix dhcp route-metric rendering for eni and sysconfig distros.

LP: #1850308

To post a comment you must log in.
Revision history for this message
Server Team CI bot (server-team-bot) wrote :

FAILED: Continuous integration, rev:897b07a874db1fa7ef5e44608e406c38fb3e62c7
https://jenkins.ubuntu.com/server/job/cloud-init-ci/1243/
Executed test runs:
    SUCCESS: Checkout
    FAILED: Unit & Style Tests

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/1243//rebuild

review: Needs Fixing (continuous-integration)
Revision history for this message
Ryan Harper (raharper) wrote :

Can we split this into two branches?

1) when we write dhcp4-overrides, write dhcp6-overrides as well

% git diff cloudinit/sources/DataSourceAzure.py
diff --git a/cloudinit/sources/DataSourceAzure.py b/cloudinit/sources/DataSourceAzure.py
index 4984fa8..2c46935 100755
--- a/cloudinit/sources/DataSourceAzure.py
+++ b/cloudinit/sources/DataSourceAzure.py
@@ -1341,6 +1341,10 @@ def parse_network_config(imds_metadata):
                             # primary nic due to lower route-metric value
                             dev_config['dhcp4-overrides'] = {
                                 'route-metric': (idx + 1) * 100}
+ # netplan requires v4 and v6 overrides to match
+ # it's harmless to include v6 override
+ dev_config['dhcp6-overrides'] = {
+ 'route-metric': (idx + 1) * 100}

2) in a separate branch, we can enhance ipv6 config to add secondary
ipv6 static ips if present.

Let me know if I'm missing something that prevents us from doing (1) and (2) separately.

review: Needs Fixing
Revision history for this message
Server Team CI bot (server-team-bot) wrote :

PASSED: Continuous integration, rev:86c621c28d99fc313753c9131b32d93f4894acec
https://jenkins.ubuntu.com/server/job/cloud-init-ci/1244/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    SUCCESS: Ubuntu LTS: Integration
    IN_PROGRESS: Declarative: Post Actions

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/1244//rebuild

review: Approve (continuous-integration)
Revision history for this message
Chad Smith (chad.smith) wrote :

An updated script I'm trying to adapt to setup a nic with ipv4 and ipv6 addrs. nearly there
http://paste.ubuntu.com/p/DHGj6Z8fzJ/

Revision history for this message
Ryan Harper (raharper) wrote :

I think we can simplify the logic if we have a common route-metric value and set the dhcpX-override key when we enable dhcp4 or dhcp6. See inline comment.

Revision history for this message
Chad Smith (chad.smith) :
Revision history for this message
Server Team CI bot (server-team-bot) wrote :

PASSED: Continuous integration, rev:443319eaa560bf7ed64e72b2f0aa59be99ef6b8f
https://jenkins.ubuntu.com/server/job/cloud-init-ci/1245/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    SUCCESS: Ubuntu LTS: Integration
    IN_PROGRESS: Declarative: Post Actions

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/1245//rebuild

review: Approve (continuous-integration)
Revision history for this message
Chad Smith (chad.smith) wrote :

netplan on a clean azure dual stack ipv4/ipv6 dhcp vm https://pastebin.canonical.com/p/GM8B9ZFQST/

Revision history for this message
Chad Smith (chad.smith) wrote :

cloud-init generated yaml on an Azure ipv4/ipv6 vm with this fix
 Azure generated netplan yaml for dual stack ipv4/ipv6 dhcp

# This file is generated from information provided by
# the datasource. Changes to it will not persist across an instance.
# To disable cloud-init's network configuration capabilities, write a file
# /etc/cloud/cloud.cfg.d/99-disable-network-config.cfg with the following:
# network: {config: disabled}
network:
    ethernets:
        eth0:
            dhcp4: true
            dhcp4-overrides: &id001
                route-metric: 100
            dhcp6: true
            dhcp6-overrides: *id001
            match:
                macaddress: 00:0d:3a:03:63:b7
            set-name: eth0
    version: 2

Revision history for this message
Ryan Harper (raharper) :
review: Needs Fixing
Revision history for this message
Chad Smith (chad.smith) :
Revision history for this message
Ryan Harper (raharper) wrote :

Hrm, the yaml references look odd. The netplan renderer uses safeyaml.dumps whith noalias=True.

Revision history for this message
Server Team CI bot (server-team-bot) wrote :

PASSED: Continuous integration, rev:185a0ea7d42cd7b83209bb54051ca5965d8c393a
https://jenkins.ubuntu.com/server/job/cloud-init-ci/1246/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    SUCCESS: Ubuntu LTS: Integration
    IN_PROGRESS: Declarative: Post Actions

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/1246//rebuild

review: Approve (continuous-integration)
Revision history for this message
Ryan Harper (raharper) wrote :

And we need v2 -> (eni, sysconfig) to accept the metric value from dhcpX-overrides

This should do it:

http://paste.ubuntu.com/p/QKfWVVrjXQ/

review: Needs Fixing
Revision history for this message
Server Team CI bot (server-team-bot) wrote :

PASSED: Continuous integration, rev:6382e974381e0e72a9825c9fd6fc150e2cade00d
https://jenkins.ubuntu.com/server/job/cloud-init-ci/1247/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    SUCCESS: Ubuntu LTS: Integration
    IN_PROGRESS: Declarative: Post Actions

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/1247//rebuild

review: Approve (continuous-integration)
Revision history for this message
Server Team CI bot (server-team-bot) wrote :

PASSED: Continuous integration, rev:9152f2dad5cb51f4d326fc5fcffb89f6bd9060b6
https://jenkins.ubuntu.com/server/job/cloud-init-ci/1248/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    SUCCESS: Ubuntu LTS: Integration
    IN_PROGRESS: Declarative: Post Actions

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/1248//rebuild

review: Approve (continuous-integration)
Revision history for this message
Ryan Harper (raharper) wrote :

Thanks. This looks good. Let's update the commit message to match what we're doing.

Revision history for this message
Ryan Harper (raharper) wrote :

Thanks Chad!

review: Approve
Revision history for this message
Server Team CI bot (server-team-bot) wrote :

Commit message lints:
- Line #7 has 62 too many characters. Line starts with: "route-metric value that"...

review: Needs Fixing
Revision history for this message
Server Team CI bot (server-team-bot) :
review: Approve (continuous-integration)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/cloudinit/net/network_state.py b/cloudinit/net/network_state.py
2index ba85c69..20b7716 100644
3--- a/cloudinit/net/network_state.py
4+++ b/cloudinit/net/network_state.py
5@@ -22,8 +22,9 @@ NETWORK_STATE_REQUIRED_KEYS = {
6 1: ['version', 'config', 'network_state'],
7 }
8 NETWORK_V2_KEY_FILTER = [
9- 'addresses', 'dhcp4', 'dhcp6', 'gateway4', 'gateway6', 'interfaces',
10- 'match', 'mtu', 'nameservers', 'renderer', 'set-name', 'wakeonlan'
11+ 'addresses', 'dhcp4', 'dhcp4-overrides', 'dhcp6', 'dhcp6-overrides',
12+ 'gateway4', 'gateway6', 'interfaces', 'match', 'mtu', 'nameservers',
13+ 'renderer', 'set-name', 'wakeonlan'
14 ]
15
16 NET_CONFIG_TO_V2 = {
17@@ -747,12 +748,20 @@ class NetworkStateInterpreter(object):
18 def _v2_to_v1_ipcfg(self, cfg):
19 """Common ipconfig extraction from v2 to v1 subnets array."""
20
21+ def _add_dhcp_overrides(overrides, subnet):
22+ if 'route-metric' in overrides:
23+ subnet['metric'] = overrides['route-metric']
24+
25 subnets = []
26 if cfg.get('dhcp4'):
27- subnets.append({'type': 'dhcp4'})
28+ subnet = {'type': 'dhcp4'}
29+ _add_dhcp_overrides(cfg.get('dhcp4-overrides', {}), subnet)
30+ subnets.append(subnet)
31 if cfg.get('dhcp6'):
32+ subnet = {'type': 'dhcp6'}
33 self.use_ipv6 = True
34- subnets.append({'type': 'dhcp6'})
35+ _add_dhcp_overrides(cfg.get('dhcp6-overrides', {}), subnet)
36+ subnets.append(subnet)
37
38 gateway4 = None
39 gateway6 = None
40diff --git a/cloudinit/net/sysconfig.py b/cloudinit/net/sysconfig.py
41index 6717d92..fe0c67c 100644
42--- a/cloudinit/net/sysconfig.py
43+++ b/cloudinit/net/sysconfig.py
44@@ -395,6 +395,9 @@ class Renderer(renderer.Renderer):
45 ipv6_index = -1
46 for i, subnet in enumerate(subnets, start=len(iface_cfg.children)):
47 subnet_type = subnet.get('type')
48+ # metric may apply to both dhcp and static config
49+ if 'metric' in subnet:
50+ iface_cfg['METRIC'] = subnet['metric']
51 if subnet_type in ['dhcp', 'dhcp4', 'dhcp6']:
52 if has_default_route and iface_cfg['BOOTPROTO'] != 'none':
53 iface_cfg['DHCLIENT_SET_DEFAULT_ROUTE'] = False
54@@ -426,9 +429,6 @@ class Renderer(renderer.Renderer):
55 else:
56 iface_cfg['GATEWAY'] = subnet['gateway']
57
58- if 'metric' in subnet:
59- iface_cfg['METRIC'] = subnet['metric']
60-
61 if 'dns_search' in subnet:
62 iface_cfg['DOMAIN'] = ' '.join(subnet['dns_search'])
63
64diff --git a/cloudinit/sources/DataSourceAzure.py b/cloudinit/sources/DataSourceAzure.py
65index cdf49d3..44cca21 100755
66--- a/cloudinit/sources/DataSourceAzure.py
67+++ b/cloudinit/sources/DataSourceAzure.py
68@@ -1322,7 +1322,8 @@ def parse_network_config(imds_metadata):
69 network_metadata = imds_metadata['network']
70 for idx, intf in enumerate(network_metadata['interface']):
71 nicname = 'eth{idx}'.format(idx=idx)
72- dev_config = {}
73+ dev_config = {'dhcp4': False, 'dhcp6': False}
74+ dhcp_override = {'route-metric': (idx + 1) * 100}
75 for addr4 in intf['ipv4']['ipAddress']:
76 privateIpv4 = addr4['privateIpAddress']
77 if privateIpv4:
78@@ -1340,12 +1341,15 @@ def parse_network_config(imds_metadata):
79 # non-primary interfaces should have a higher
80 # route-metric (cost) so default routes prefer
81 # primary nic due to lower route-metric value
82- dev_config['dhcp4-overrides'] = {
83- 'route-metric': (idx + 1) * 100}
84+ dev_config['dhcp4-overrides'] = dhcp_override
85 for addr6 in intf['ipv6']['ipAddress']:
86 privateIpv6 = addr6['privateIpAddress']
87 if privateIpv6:
88 dev_config['dhcp6'] = True
89+ # non-primary interfaces should have a higher
90+ # route-metric (cost) so default routes prefer
91+ # primary nic due to lower route-metric value
92+ dev_config['dhcp6-overrides'] = dhcp_override
93 break
94 if dev_config:
95 mac = ':'.join(re.findall(r'..', intf['macAddress']))
96diff --git a/tests/unittests/test_datasource/test_azure.py b/tests/unittests/test_datasource/test_azure.py
97index 80c6f01..d92d7b2 100644
98--- a/tests/unittests/test_datasource/test_azure.py
99+++ b/tests/unittests/test_datasource/test_azure.py
100@@ -153,6 +153,102 @@ SECONDARY_INTERFACE = {
101 MOCKPATH = 'cloudinit.sources.DataSourceAzure.'
102
103
104+class TestParseNetworkConfig(CiTestCase):
105+
106+ maxDiff = None
107+
108+ def test_single_ipv4_nic_configuration(self):
109+ """parse_network_config emits dhcp on single nic with ipv4"""
110+ expected = {'ethernets': {
111+ 'eth0': {'dhcp4': True,
112+ 'dhcp4-overrides': {'route-metric': 100},
113+ 'dhcp6': False,
114+ 'match': {'macaddress': '00:0d:3a:04:75:98'},
115+ 'set-name': 'eth0'}}, 'version': 2}
116+ self.assertEqual(expected, dsaz.parse_network_config(NETWORK_METADATA))
117+
118+ def test_increases_route_metric_for_non_primary_nics(self):
119+ """parse_network_config increases route-metric for each nic"""
120+ expected = {'ethernets': {
121+ 'eth0': {'dhcp4': True,
122+ 'dhcp4-overrides': {'route-metric': 100},
123+ 'dhcp6': False,
124+ 'match': {'macaddress': '00:0d:3a:04:75:98'},
125+ 'set-name': 'eth0'},
126+ 'eth1': {'set-name': 'eth1',
127+ 'match': {'macaddress': '22:0d:3a:04:75:98'},
128+ 'dhcp6': False,
129+ 'dhcp4': True,
130+ 'dhcp4-overrides': {'route-metric': 200}},
131+ 'eth2': {'set-name': 'eth2',
132+ 'match': {'macaddress': '33:0d:3a:04:75:98'},
133+ 'dhcp6': False,
134+ 'dhcp4': True,
135+ 'dhcp4-overrides': {'route-metric': 300}}}, 'version': 2}
136+ imds_data = copy.deepcopy(NETWORK_METADATA)
137+ imds_data['network']['interface'].append(SECONDARY_INTERFACE)
138+ third_intf = copy.deepcopy(SECONDARY_INTERFACE)
139+ third_intf['macAddress'] = third_intf['macAddress'].replace('22', '33')
140+ third_intf['ipv4']['subnet'][0]['address'] = '10.0.2.0'
141+ third_intf['ipv4']['ipAddress'][0]['privateIpAddress'] = '10.0.2.6'
142+ imds_data['network']['interface'].append(third_intf)
143+ self.assertEqual(expected, dsaz.parse_network_config(imds_data))
144+
145+ def test_ipv4_and_ipv6_route_metrics_match_for_nics(self):
146+ """parse_network_config emits matching ipv4 and ipv6 route-metrics."""
147+ expected = {'ethernets': {
148+ 'eth0': {'dhcp4': True,
149+ 'dhcp4-overrides': {'route-metric': 100},
150+ 'dhcp6': False,
151+ 'match': {'macaddress': '00:0d:3a:04:75:98'},
152+ 'set-name': 'eth0'},
153+ 'eth1': {'set-name': 'eth1',
154+ 'match': {'macaddress': '22:0d:3a:04:75:98'},
155+ 'dhcp4': True,
156+ 'dhcp6': False,
157+ 'dhcp4-overrides': {'route-metric': 200}},
158+ 'eth2': {'set-name': 'eth2',
159+ 'match': {'macaddress': '33:0d:3a:04:75:98'},
160+ 'dhcp4': True,
161+ 'dhcp4-overrides': {'route-metric': 300},
162+ 'dhcp6': True,
163+ 'dhcp6-overrides': {'route-metric': 300}}}, 'version': 2}
164+ imds_data = copy.deepcopy(NETWORK_METADATA)
165+ imds_data['network']['interface'].append(SECONDARY_INTERFACE)
166+ third_intf = copy.deepcopy(SECONDARY_INTERFACE)
167+ third_intf['macAddress'] = third_intf['macAddress'].replace('22', '33')
168+ third_intf['ipv4']['subnet'][0]['address'] = '10.0.2.0'
169+ third_intf['ipv4']['ipAddress'][0]['privateIpAddress'] = '10.0.2.6'
170+ third_intf['ipv6'] = {
171+ "subnet": [{"prefix": "64", "address": "2001:dead:beef::2"}],
172+ "ipAddress": [{"privateIpAddress": "2001:dead:beef::1"}]
173+ }
174+ imds_data['network']['interface'].append(third_intf)
175+ self.assertEqual(expected, dsaz.parse_network_config(imds_data))
176+
177+ def test_ipv4_secondary_ips_will_be_static_addrs(self):
178+ """parse_network_config emits primary ipv4 as dhcp others are static"""
179+ expected = {'ethernets': {
180+ 'eth0': {'addresses': ['10.0.0.5/24'],
181+ 'dhcp4': True,
182+ 'dhcp4-overrides': {'route-metric': 100},
183+ 'dhcp6': True,
184+ 'dhcp6-overrides': {'route-metric': 100},
185+ 'match': {'macaddress': '00:0d:3a:04:75:98'},
186+ 'set-name': 'eth0'}}, 'version': 2}
187+ imds_data = copy.deepcopy(NETWORK_METADATA)
188+ nic1 = imds_data['network']['interface'][0]
189+ nic1['ipv4']['ipAddress'].append({'privateIpAddress': '10.0.0.5'})
190+
191+ # Secondary ipv6 addresses currently ignored/unconfigured
192+ nic1['ipv6'] = {
193+ "subnet": [{"prefix": "10", "address": "2001:dead:beef::16"}],
194+ "ipAddress": [{"privateIpAddress": "2001:dead:beef::1"},
195+ {"privateIpAddress": "2001:dead:beef::2"}]
196+ }
197+ self.assertEqual(expected, dsaz.parse_network_config(imds_data))
198+
199+
200 class TestGetMetadataFromIMDS(HttprettyTestCase):
201
202 with_logs = True
203@@ -641,6 +737,7 @@ scbus-1 on xpt0 bus 0
204 'ethernets': {
205 'eth0': {'set-name': 'eth0',
206 'match': {'macaddress': '00:0d:3a:04:75:98'},
207+ 'dhcp6': False,
208 'dhcp4': True,
209 'dhcp4-overrides': {'route-metric': 100}}},
210 'version': 2}
211@@ -658,14 +755,17 @@ scbus-1 on xpt0 bus 0
212 'ethernets': {
213 'eth0': {'set-name': 'eth0',
214 'match': {'macaddress': '00:0d:3a:04:75:98'},
215+ 'dhcp6': False,
216 'dhcp4': True,
217 'dhcp4-overrides': {'route-metric': 100}},
218 'eth1': {'set-name': 'eth1',
219 'match': {'macaddress': '22:0d:3a:04:75:98'},
220+ 'dhcp6': False,
221 'dhcp4': True,
222 'dhcp4-overrides': {'route-metric': 200}},
223 'eth2': {'set-name': 'eth2',
224 'match': {'macaddress': '33:0d:3a:04:75:98'},
225+ 'dhcp6': False,
226 'dhcp4': True,
227 'dhcp4-overrides': {'route-metric': 300}}},
228 'version': 2}
229@@ -999,6 +1099,7 @@ scbus-1 on xpt0 bus 0
230 'ethernets': {
231 'eth0': {'dhcp4': True,
232 'dhcp4-overrides': {'route-metric': 100},
233+ 'dhcp6': False,
234 'match': {'macaddress': '00:0d:3a:04:75:98'},
235 'set-name': 'eth0'}},
236 'version': 2}
237diff --git a/tests/unittests/test_net.py b/tests/unittests/test_net.py
238index 6f83ad7..35ce55d 100644
239--- a/tests/unittests/test_net.py
240+++ b/tests/unittests/test_net.py
241@@ -3101,6 +3101,36 @@ USERCTL=no
242 self._compare_files_to_expected(
243 expected, self._render_and_read(network_config=v2data))
244
245+ def test_from_v2_route_metric(self):
246+ """verify route-metric gets rendered on nic when source is netplan."""
247+ overrides = {'route-metric': 100}
248+ v2base = {
249+ 'version': 2,
250+ 'ethernets': {
251+ 'eno1': {'dhcp4': True,
252+ 'match': {'macaddress': '07-1c-c6-75-a4-be'}}}}
253+ expected = {
254+ 'ifcfg-eno1': textwrap.dedent("""\
255+ BOOTPROTO=dhcp
256+ DEVICE=eno1
257+ HWADDR=07-1c-c6-75-a4-be
258+ METRIC=100
259+ NM_CONTROLLED=no
260+ ONBOOT=yes
261+ STARTMODE=auto
262+ TYPE=Ethernet
263+ USERCTL=no
264+ """),
265+ }
266+ for dhcp_ver in ('dhcp4', 'dhcp6'):
267+ v2data = copy.deepcopy(v2base)
268+ if dhcp_ver == 'dhcp6':
269+ expected['ifcfg-eno1'] += "IPV6INIT=yes\nDHCPV6C=yes\n"
270+ v2data['ethernets']['eno1'].update(
271+ {dhcp_ver: True, '{0}-overrides'.format(dhcp_ver): overrides})
272+ self._compare_files_to_expected(
273+ expected, self._render_and_read(network_config=v2data))
274+
275
276 class TestOpenSuseSysConfigRendering(CiTestCase):
277
278@@ -3466,6 +3496,30 @@ iface eth0 inet dhcp
279 self.assertEqual(
280 expected, dir2dict(tmp_dir)['/etc/network/interfaces'])
281
282+ def test_v2_route_metric_to_eni(self):
283+ """Network v2 route-metric overrides are preserved in eni output"""
284+ tmp_dir = self.tmp_dir()
285+ renderer = eni.Renderer()
286+ expected_tmpl = textwrap.dedent("""\
287+ auto lo
288+ iface lo inet loopback
289+
290+ auto eth0
291+ iface eth0 inet{suffix} dhcp
292+ metric 100
293+ """)
294+ for dhcp_ver in ('dhcp4', 'dhcp6'):
295+ suffix = '6' if dhcp_ver == 'dhcp6' else ''
296+ dhcp_cfg = {
297+ dhcp_ver: True,
298+ '{ver}-overrides'.format(ver=dhcp_ver): {'route-metric': 100}}
299+ v2_input = {'version': 2, 'ethernets': {'eth0': dhcp_cfg}}
300+ ns = network_state.parse_net_config_data(v2_input)
301+ renderer.render_network_state(ns, target=tmp_dir)
302+ self.assertEqual(
303+ expected_tmpl.format(suffix=suffix),
304+ dir2dict(tmp_dir)['/etc/network/interfaces'])
305+
306
307 class TestNetplanNetRendering(CiTestCase):
308

Subscribers

People subscribed via source and target branches