Merge ~chad.smith/cloud-init:feature/azure-network-on-boot into cloud-init:master

Proposed by Chad Smith
Status: Superseded
Proposed branch: ~chad.smith/cloud-init:feature/azure-network-on-boot
Merge into: cloud-init:master
Diff against target: 924 lines (+649/-25)
7 files modified
cloudinit/event.py (+17/-0)
cloudinit/sources/DataSourceAzure.py (+102/-9)
cloudinit/sources/__init__.py (+56/-1)
cloudinit/sources/tests/test_init.py (+67/-0)
cloudinit/stages.py (+10/-4)
cloudinit/tests/test_stages.py (+231/-0)
tests/unittests/test_datasource/test_azure.py (+166/-11)
Reviewer Review Type Date Requested Status
cloud-init Commiters Pending
Review via email: mp+348703@code.launchpad.net

Commit message

azure: allow azure to generate network configuration from IMDS on each boot

Azure datasource now queries IMDS metadata service for network configuration at
link local address http://169.254.169.254/metadata/instance?api-version=2017-12-01.
The azure metadata service presents a list of macs and allocated ip addresses
associated with this instance. Azure will now also regenerate network configuration
on every boot because it subscribes to EventType.BOOT maintenance events as well as
the 'first boot' EventType.BOOT_NEW_INSTANCE.

To post a comment you must log in.

There was an error fetching revisions from git servers. Please try again in a few minutes. If the problem persists, contact Launchpad support.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/cloudinit/event.py b/cloudinit/event.py
2new file mode 100644
3index 0000000..f7b311f
4--- /dev/null
5+++ b/cloudinit/event.py
6@@ -0,0 +1,17 @@
7+# This file is part of cloud-init. See LICENSE file for license information.
8+
9+"""Classes and functions related to event handling."""
10+
11+
12+# Event types which can generate maintenance requests for cloud-init.
13+class EventType(object):
14+ BOOT = "System boot"
15+ BOOT_NEW_INSTANCE = "New instance first boot"
16+
17+ # TODO: Cloud-init will grow support for the follow event types:
18+ # UDEV
19+ # METADATA_CHANGE
20+ # USER_REQUEST
21+
22+
23+# vi: ts=4 expandtab
24diff --git a/cloudinit/sources/DataSourceAzure.py b/cloudinit/sources/DataSourceAzure.py
25index 7007d9e..32d17f7 100644
26--- a/cloudinit/sources/DataSourceAzure.py
27+++ b/cloudinit/sources/DataSourceAzure.py
28@@ -8,6 +8,7 @@ import base64
29 import contextlib
30 import crypt
31 from functools import partial
32+import json
33 import os
34 import os.path
35 import re
36@@ -17,6 +18,7 @@ import xml.etree.ElementTree as ET
37
38 from cloudinit import log as logging
39 from cloudinit import net
40+from cloudinit.event import EventType
41 from cloudinit.net.dhcp import EphemeralDHCPv4
42 from cloudinit import sources
43 from cloudinit.sources.helpers.azure import get_metadata_from_fabric
44@@ -49,7 +51,7 @@ DEFAULT_FS = 'ext4'
45 AZURE_CHASSIS_ASSET_TAG = '7783-7084-3265-9085-8269-3286-77'
46 REPROVISION_MARKER_FILE = "/var/lib/cloud/data/poll_imds"
47 REPORTED_READY_MARKER_FILE = "/var/lib/cloud/data/reported_ready"
48-IMDS_URL = "http://169.254.169.254/metadata/reprovisiondata"
49+IMDS_URL = "http://169.254.169.254/metadata/"
50
51
52 def find_storvscid_from_sysctl_pnpinfo(sysctl_out, deviceid):
53@@ -252,6 +254,10 @@ class DataSourceAzure(sources.DataSource):
54
55 dsname = 'Azure'
56 _negotiated = False
57+ _metadata_imds = sources.UNSET
58+
59+ # Regenerate network config new_instance boot and every boot
60+ maintenance_events = [EventType.BOOT_NEW_INSTANCE, EventType.BOOT]
61
62 def __init__(self, sys_cfg, distro, paths):
63 sources.DataSource.__init__(self, sys_cfg, distro, paths)
64@@ -380,9 +386,13 @@ class DataSourceAzure(sources.DataSource):
65
66 if reprovision or self._should_reprovision(ret):
67 ret = self._reprovision()
68+ if self._metadata_imds == sources.UNSET:
69+ self._metadata_imds = get_metadata_from_imds(
70+ self.fallback_interface, retries=3)
71 (md, self.userdata_raw, cfg, files) = ret
72 self.seed = cdev
73- self.metadata = util.mergemanydict([md, DEFAULT_METADATA])
74+ self.metadata = util.mergemanydict(
75+ [md, {'imds': self._metadata_imds}, DEFAULT_METADATA])
76 self.cfg = util.mergemanydict([cfg, BUILTIN_CLOUD_CONFIG])
77 found = cdev
78
79@@ -436,7 +446,7 @@ class DataSourceAzure(sources.DataSource):
80 def _poll_imds(self):
81 """Poll IMDS for the new provisioning data until we get a valid
82 response. Then return the returned JSON object."""
83- url = IMDS_URL + "?api-version=2017-04-02"
84+ url = IMDS_URL + "reprovisiondata?api-version=2017-04-02"
85 headers = {"Metadata": "true"}
86 report_ready = bool(not os.path.isfile(REPORTED_READY_MARKER_FILE))
87 LOG.debug("Start polling IMDS")
88@@ -550,15 +560,47 @@ class DataSourceAzure(sources.DataSource):
89 2. Generate a fallback network config that does not include any of
90 the blacklisted devices.
91 """
92- blacklist = ['mlx4_core']
93 if not self._network_config:
94- LOG.debug('Azure: generating fallback configuration')
95- # generate a network config, blacklist picking any mlx4_core devs
96- netconfig = net.generate_fallback_config(
97- blacklist_drivers=blacklist, config_driver=True)
98+ if self._metadata_imds != sources.UNSET and self._metadata_imds:
99+ netconfig = {'version': 2, 'ethernets': {}}
100+ LOG.debug('Azure: generating network configuration from IMDS')
101+ network_metadata = self._metadata_imds['network']
102+ for idx, intf in enumerate(network_metadata['interface']):
103+ nicname = 'eth{idx}'.format(idx=idx)
104+ dev_config = {}
105+ for addr4 in intf['ipv4']['ipAddress']:
106+ privateIpv4 = addr4['privateIpAddress']
107+ if privateIpv4:
108+ if dev_config.get('dhcp4', False):
109+ # Append static address config for nic > 1
110+ netPrefix = intf['ipv4']['subnet'][0].get(
111+ 'prefix', '24')
112+ if not dev_config.get('addresses'):
113+ dev_config['addresses'] = []
114+ dev_config['addresses'].append(
115+ '{ip}/{prefix}'.format(
116+ ip=privateIpv4, prefix=netPrefix))
117+ else:
118+ dev_config['dhcp4'] = True
119+ for addr6 in intf['ipv6']['ipAddress']:
120+ privateIpv6 = addr6['privateIpAddress']
121+ if privateIpv6:
122+ dev_config['dhcp6'] = True
123+ break
124+ if dev_config:
125+ mac = ':'.join(re.findall(r'..', intf['macAddress']))
126+ dev_config.update(
127+ {'match': {'macaddress': mac.lower()},
128+ 'set-name': nicname})
129+ netconfig['ethernets'][nicname] = dev_config
130+ else:
131+ blacklist = ['mlx4_core']
132+ LOG.debug('Azure: generating fallback configuration')
133+ # generate a network config, blacklist picking mlx4_core devs
134+ netconfig = net.generate_fallback_config(
135+ blacklist_drivers=blacklist, config_driver=True)
136
137 self._network_config = netconfig
138-
139 return self._network_config
140
141
142@@ -1025,6 +1067,57 @@ def load_azure_ds_dir(source_dir):
143 return (md, ud, cfg, {'ovf-env.xml': contents})
144
145
146+def get_metadata_from_imds(fallback_nic, retries):
147+ """Query Azure's network metadata service, returning a dictionary.
148+
149+ If network is not up, setup ephemeral dhcp on fallback_nic to talk to the
150+ IMDS. For more info on IMDS:
151+ https://docs.microsoft.com/en-us/azure/virtual-machines/windows/instance-metadata-service
152+
153+ @param fallback_nic: String. The name of the nic which requires active
154+ networ in order to query IMDS.
155+ @param retries: The number of retries of the IMDS_URL.
156+
157+ @return: A dict of instance metadata containing compute and network
158+ info.
159+ """
160+ if net.is_up(fallback_nic):
161+ return util.log_time(
162+ logfunc=LOG.debug,
163+ msg='Crawl of Azure Instance Metadata Service (IMDS)',
164+ func=_get_metadata_from_imds, args=(retries,))
165+ else:
166+ with EphemeralDHCPv4(fallback_nic):
167+ return util.log_time(
168+ logfunc=LOG.debug,
169+ msg='Crawl of Azure Instance Metadata Service (IMDS)',
170+ func=_get_metadata_from_imds, args=(retries,))
171+
172+
173+def _get_metadata_from_imds(retries):
174+
175+ def retry_on_url_error(msg, exception):
176+ if isinstance(exception, UrlError) and exception.code == 404:
177+ return True # Continue retries
178+ return False # Stop retries on all other exceptions, including 404s
179+
180+ url = IMDS_URL + "instance?api-version=2017-12-01"
181+ headers = {"Metadata": "true"}
182+ try:
183+ response = readurl(
184+ url, timeout=1, headers=headers, retries=retries,
185+ exception_cb=retry_on_url_error)
186+ except Exception as e:
187+ LOG.debug('Ignoring IMDS instance metadata: %s', e)
188+ return {}
189+ try:
190+ return util.load_json(str(response))
191+ except json.decoder.JSONDecodeError:
192+ LOG.warning(
193+ 'Ignoring non-json IMDS instance metadata: %s', str(response))
194+ return {}
195+
196+
197 class BrokenAzureDataSource(Exception):
198 pass
199
200diff --git a/cloudinit/sources/__init__.py b/cloudinit/sources/__init__.py
201index 90d7457..02be572 100644
202--- a/cloudinit/sources/__init__.py
203+++ b/cloudinit/sources/__init__.py
204@@ -19,6 +19,7 @@ from cloudinit.atomic_helper import write_json
205 from cloudinit import importer
206 from cloudinit import log as logging
207 from cloudinit import net
208+from cloudinit.event import EventType
209 from cloudinit import type_utils
210 from cloudinit import user_data as ud
211 from cloudinit import util
212@@ -102,6 +103,26 @@ class DataSource(object):
213 url_timeout = 10 # timeout for each metadata url read attempt
214 url_retries = 5 # number of times to retry url upon 404
215
216+ # The datasource defines a list of supported EventTypes during which
217+ # the datasource can react to changes in metadata and regenerate
218+ # network configuration on metadata changes.
219+ # A datasource which supports writing network config on each system boot
220+ # would set maintenance_events = [EventType.BOOT].
221+
222+ # Default: generate network config on new instance id (first boot).
223+ maintenance_events = [EventType.BOOT_NEW_INSTANCE]
224+
225+ # N-tuple listing default values for any metadata-related class
226+ # attributes cached on an instance by a get_data runs. These attribute
227+ # values are reset via clear_cached_data during any of the supported
228+ # maintenance_events.
229+ cached_attr_defaults = (
230+ ('ec2_metadata', UNSET), ('network_json', UNSET),
231+ ('metadata', {}), ('userdata', None), ('userdata_raw', None),
232+ ('vendordata', None), ('vendordata_raw', None))
233+
234+ _dirty_cache = False
235+
236 def __init__(self, sys_cfg, distro, paths, ud_proc=None):
237 self.sys_cfg = sys_cfg
238 self.distro = distro
239@@ -134,11 +155,21 @@ class DataSource(object):
240 'region': self.region,
241 'availability-zone': self.availability_zone}}
242
243+ def clear_cached_data(self):
244+ """Reset any cached metadata attributes to datasource defaults."""
245+ if self._dirty_cache:
246+ for attribute, value in self.cached_attr_defaults:
247+ if hasattr(self, attribute):
248+ setattr(self, attribute, value)
249+ self._dirty_cache = False
250+
251 def get_data(self):
252 """Datasources implement _get_data to setup metadata and userdata_raw.
253
254 Minimally, the datasource should return a boolean True on success.
255 """
256+ self.clear_cached_data()
257+ self._dirty_cache = True
258 return_value = self._get_data()
259 json_file = os.path.join(self.paths.run_dir, INSTANCE_JSON_FILE)
260 if not return_value:
261@@ -416,6 +447,30 @@ class DataSource(object):
262 def get_package_mirror_info(self):
263 return self.distro.get_package_mirror_info(data_source=self)
264
265+ def update_metadata(self, source_event_types):
266+ """Refresh cached metadata if the datasource supports this event.
267+
268+ The datasource has a list of maintenance_events which
269+ trigger refreshing all cached metadata.
270+
271+ @param source_event_types: List of EventTypes which may trigger a
272+ metadata update.
273+
274+ @return True if the datasource did successfully update cached metadata
275+ due to source_event_type.
276+ """
277+ supported_events = [
278+ evt for evt in source_event_types
279+ if evt in self.maintenance_events]
280+ if supported_events:
281+ LOG.debug(
282+ "Update datasource metadata due to maintenance events: '%s'",
283+ ','.join(supported_events))
284+ result = self.get_data()
285+ if result:
286+ return True
287+ return False
288+
289 def check_instance_id(self, sys_cfg):
290 # quickly (local check only) if self.instance_id is still
291 return False
292@@ -520,7 +575,7 @@ def find_source(sys_cfg, distro, paths, ds_deps, cfg_list, pkg_list, reporter):
293 with myrep:
294 LOG.debug("Seeing if we can get any data from %s", cls)
295 s = cls(sys_cfg, distro, paths)
296- if s.get_data():
297+ if s.update_metadata([EventType.BOOT_NEW_INSTANCE]):
298 myrep.message = "found %s data from %s" % (mode, name)
299 return (s, type_utils.obj_name(cls))
300 except Exception:
301diff --git a/cloudinit/sources/tests/test_init.py b/cloudinit/sources/tests/test_init.py
302index d5bc98a..8da710f 100644
303--- a/cloudinit/sources/tests/test_init.py
304+++ b/cloudinit/sources/tests/test_init.py
305@@ -5,6 +5,7 @@ import os
306 import six
307 import stat
308
309+from cloudinit.event import EventType
310 from cloudinit.helpers import Paths
311 from cloudinit import importer
312 from cloudinit.sources import (
313@@ -381,3 +382,69 @@ class TestDataSource(CiTestCase):
314 get_args(grandchild.get_hostname), # pylint: disable=W1505
315 '%s does not implement DataSource.get_hostname params'
316 % grandchild)
317+
318+ def test_clear_cached_data_resets_cached_attr_class_attributes(self):
319+ """Class attributes listed in cached_attr_defaults are reset."""
320+ count = 0
321+ # Setup values for all cached class attributes
322+ for attr, value in self.datasource.cached_attr_defaults:
323+ setattr(self.datasource, attr, count)
324+ count += 1
325+ self.datasource._dirty_cache = True
326+ self.datasource.clear_cached_data()
327+ for attr, value in self.datasource.cached_attr_defaults:
328+ self.assertEqual(value, getattr(self.datasource, attr))
329+
330+ def test_clear_cached_data_noops_on_clean_cache(self):
331+ """Class attributes listed in cached_attr_defaults are reset."""
332+ count = 0
333+ # Setup values for all cached class attributes
334+ for attr, _ in self.datasource.cached_attr_defaults:
335+ setattr(self.datasource, attr, count)
336+ count += 1
337+ self.datasource._dirty_cache = False # Fake clean cache
338+ self.datasource.clear_cached_data()
339+ count = 0
340+ for attr, _ in self.datasource.cached_attr_defaults:
341+ self.assertEqual(count, getattr(self.datasource, attr))
342+ count += 1
343+
344+ def test_clear_cached_data_skips_non_attr_class_attributes(self):
345+ """Skip any cached_attr_defaults which aren't class attributes."""
346+ self.datasource._dirty_cache = True
347+ self.datasource.clear_cached_data()
348+ for attr in ('ec2_metadata', 'network_json'):
349+ self.assertFalse(hasattr(self.datasource, attr))
350+
351+ def test_update_metadata_only_acts_on_supported_maintenance_events(self):
352+ """update_metadata won't get_data on unsupported maintenance events."""
353+ self.assertEqual(
354+ [EventType.BOOT_NEW_INSTANCE],
355+ self.datasource.maintenance_events)
356+
357+ def fake_get_data():
358+ raise Exception('get_data should not be called')
359+
360+ self.datasource.get_data = fake_get_data
361+ self.assertFalse(
362+ self.datasource.update_metadata(
363+ source_event_types=[EventType.BOOT]))
364+
365+ def test_update_metadata_returns_true_on_supported_maintenance_event(self):
366+ """update_metadata returns get_data response on supported events."""
367+
368+ def fake_get_data():
369+ return True
370+
371+ self.datasource.get_data = fake_get_data
372+ self.assertTrue(
373+ self.datasource.update_metadata(
374+ source_event_types=[
375+ EventType.BOOT, EventType.BOOT_NEW_INSTANCE]))
376+ self.assertIn(
377+ "DEBUG: Update datasource metadata due to maintenance events:"
378+ " 'New instance first boot'",
379+ self.logs.getvalue())
380+
381+
382+# vi: ts=4 expandtab
383diff --git a/cloudinit/stages.py b/cloudinit/stages.py
384index 286607b..c132b57 100644
385--- a/cloudinit/stages.py
386+++ b/cloudinit/stages.py
387@@ -22,6 +22,8 @@ from cloudinit.handlers import cloud_config as cc_part
388 from cloudinit.handlers import shell_script as ss_part
389 from cloudinit.handlers import upstart_job as up_part
390
391+from cloudinit.event import EventType
392+
393 from cloudinit import cloud
394 from cloudinit import config
395 from cloudinit import distros
396@@ -648,10 +650,14 @@ class Init(object):
397 except Exception as e:
398 LOG.warning("Failed to rename devices: %s", e)
399
400- if (self.datasource is not NULL_DATA_SOURCE and
401- not self.is_new_instance()):
402- LOG.debug("not a new instance. network config is not applied.")
403- return
404+ if self.datasource is not NULL_DATA_SOURCE:
405+ if not self.is_new_instance():
406+ if not self.datasource.update_metadata([EventType.BOOT]):
407+ LOG.debug(
408+ "No network config applied. Neither a new instance"
409+ " nor datasource network update on '%s' event",
410+ EventType.BOOT)
411+ return
412
413 LOG.info("Applying network configuration from %s bringup=%s: %s",
414 src, bring_up, netcfg)
415diff --git a/cloudinit/tests/test_stages.py b/cloudinit/tests/test_stages.py
416new file mode 100644
417index 0000000..2c0b9fc
418--- /dev/null
419+++ b/cloudinit/tests/test_stages.py
420@@ -0,0 +1,231 @@
421+# This file is part of cloud-init. See LICENSE file for license information.
422+
423+"""Tests related to cloudinit.stages module."""
424+
425+import os
426+
427+from cloudinit import stages
428+from cloudinit import sources
429+
430+from cloudinit.event import EventType
431+from cloudinit.util import write_file
432+
433+from cloudinit.tests.helpers import CiTestCase, mock
434+
435+TEST_INSTANCE_ID = 'i-testing'
436+
437+
438+class FakeDataSource(sources.DataSource):
439+
440+ def __init__(self, paths=None, userdata=None, vendordata=None,
441+ network_config=''):
442+ super(FakeDataSource, self).__init__({}, None, paths=paths)
443+ self.metadata = {'instance-id': TEST_INSTANCE_ID}
444+ self.userdata_raw = userdata
445+ self.vendordata_raw = vendordata
446+ self._network_config = None
447+ if network_config: # Permit for None value to setup attribute
448+ self._network_config = network_config
449+
450+ @property
451+ def network_config(self):
452+ return self._network_config
453+
454+ def _get_data(self):
455+ return True
456+
457+
458+class TestInit(CiTestCase):
459+ with_logs = True
460+
461+ def setUp(self):
462+ super(TestInit, self).setUp()
463+ self.tmpdir = self.tmp_dir()
464+ self.init = stages.Init()
465+ # Setup fake Paths for Init to reference
466+ self.init._cfg = {'system_info': {
467+ 'distro': 'ubuntu', 'paths': {'cloud_dir': self.tmpdir,
468+ 'run_dir': self.tmpdir}}}
469+ self.init.datasource = FakeDataSource(paths=self.init.paths)
470+
471+ def test_wb__find_networking_config_disabled(self):
472+ """find_networking_config returns no config when disabled."""
473+ disable_file = os.path.join(
474+ self.init.paths.get_cpath('data'), 'upgraded-network')
475+ write_file(disable_file, '')
476+ self.assertEqual(
477+ (None, disable_file),
478+ self.init._find_networking_config())
479+
480+ @mock.patch('cloudinit.stages.cmdline.read_kernel_cmdline_config')
481+ def test_wb__find_networking_config_disabled_by_kernel(self, m_cmdline):
482+ """find_networking_config returns when disabled by kernel cmdline."""
483+ m_cmdline.return_value = {'config': 'disabled'}
484+ self.assertEqual(
485+ (None, 'cmdline'),
486+ self.init._find_networking_config())
487+ self.assertEqual('DEBUG: network config disabled by cmdline\n',
488+ self.logs.getvalue())
489+
490+ @mock.patch('cloudinit.stages.cmdline.read_kernel_cmdline_config')
491+ def test_wb__find_networking_config_disabled_by_datasrc(self, m_cmdline):
492+ """find_networking_config returns when disabled by datasource cfg."""
493+ m_cmdline.return_value = {} # Kernel doesn't disable networking
494+ self.init._cfg = {'system_info': {'paths': {'cloud_dir': self.tmpdir}},
495+ 'network': {}} # system config doesn't disable
496+
497+ self.init.datasource = FakeDataSource(
498+ network_config={'config': 'disabled'})
499+ self.assertEqual(
500+ (None, 'ds'),
501+ self.init._find_networking_config())
502+ self.assertEqual('DEBUG: network config disabled by ds\n',
503+ self.logs.getvalue())
504+
505+ @mock.patch('cloudinit.stages.cmdline.read_kernel_cmdline_config')
506+ def test_wb__find_networking_config_disabled_by_sysconfig(self, m_cmdline):
507+ """find_networking_config returns when disabled by system config."""
508+ m_cmdline.return_value = {} # Kernel doesn't disable networking
509+ self.init._cfg = {'system_info': {'paths': {'cloud_dir': self.tmpdir}},
510+ 'network': {'config': 'disabled'}}
511+ self.assertEqual(
512+ (None, 'system_cfg'),
513+ self.init._find_networking_config())
514+ self.assertEqual('DEBUG: network config disabled by system_cfg\n',
515+ self.logs.getvalue())
516+
517+ @mock.patch('cloudinit.stages.cmdline.read_kernel_cmdline_config')
518+ def test_wb__find_networking_config_returns_kernel(self, m_cmdline):
519+ """find_networking_config returns kernel cmdline config if present."""
520+ expected_cfg = {'config': ['fakekernel']}
521+ m_cmdline.return_value = expected_cfg
522+ self.init._cfg = {'system_info': {'paths': {'cloud_dir': self.tmpdir}},
523+ 'network': {'config': ['fakesys_config']}}
524+ self.init.datasource = FakeDataSource(
525+ network_config={'config': ['fakedatasource']})
526+ self.assertEqual(
527+ (expected_cfg, 'cmdline'),
528+ self.init._find_networking_config())
529+
530+ @mock.patch('cloudinit.stages.cmdline.read_kernel_cmdline_config')
531+ def test_wb__find_networking_config_returns_system_cfg(self, m_cmdline):
532+ """find_networking_config returns system config when present."""
533+ m_cmdline.return_value = {} # No kernel network config
534+ expected_cfg = {'config': ['fakesys_config']}
535+ self.init._cfg = {'system_info': {'paths': {'cloud_dir': self.tmpdir}},
536+ 'network': expected_cfg}
537+ self.init.datasource = FakeDataSource(
538+ network_config={'config': ['fakedatasource']})
539+ self.assertEqual(
540+ (expected_cfg, 'system_cfg'),
541+ self.init._find_networking_config())
542+
543+ @mock.patch('cloudinit.stages.cmdline.read_kernel_cmdline_config')
544+ def test_wb__find_networking_config_returns_datasrc_cfg(self, m_cmdline):
545+ """find_networking_config returns datasource net config if present."""
546+ m_cmdline.return_value = {} # No kernel network config
547+ # No system config for network in setUp
548+ expected_cfg = {'config': ['fakedatasource']}
549+ self.init.datasource = FakeDataSource(network_config=expected_cfg)
550+ self.assertEqual(
551+ (expected_cfg, 'ds'),
552+ self.init._find_networking_config())
553+
554+ @mock.patch('cloudinit.stages.cmdline.read_kernel_cmdline_config')
555+ def test_wb__find_networking_config_returns_fallback(self, m_cmdline):
556+ """find_networking_config returns fallback config if not defined."""
557+ m_cmdline.return_value = {} # Kernel doesn't disable networking
558+ # Neither datasource nor system_info disable or provide network
559+
560+ fake_cfg = {'config': [{'type': 'physical', 'name': 'eth9'}],
561+ 'version': 1}
562+
563+ def fake_generate_fallback():
564+ return fake_cfg
565+
566+ # Monkey patch distro which gets cached on self.init
567+ distro = self.init.distro
568+ distro.generate_fallback_config = fake_generate_fallback
569+ self.assertEqual(
570+ (fake_cfg, 'fallback'),
571+ self.init._find_networking_config())
572+ self.assertNotIn('network config disabled', self.logs.getvalue())
573+
574+ def test_apply_network_config_disabled(self):
575+ """Log when network is disabled by upgraded-network."""
576+ disable_file = os.path.join(
577+ self.init.paths.get_cpath('data'), 'upgraded-network')
578+
579+ def fake_network_config():
580+ return (None, disable_file)
581+
582+ self.init._find_networking_config = fake_network_config
583+
584+ self.init.apply_network_config(True)
585+ self.assertIn(
586+ 'INFO: network config is disabled by %s' % disable_file,
587+ self.logs.getvalue())
588+
589+ @mock.patch('cloudinit.distros.ubuntu.Distro')
590+ def test_apply_network_on_new_instance(self, m_ubuntu):
591+ """Call distro apply_network_config methods on is_new_instance."""
592+ net_cfg = {
593+ 'version': 1, 'config': [
594+ {'subnets': [{'type': 'dhcp'}], 'type': 'physical',
595+ 'name': 'eth9', 'mac_address': '42:42:42:42:42:42'}]}
596+
597+ def fake_network_config():
598+ return net_cfg, 'fallback'
599+
600+ self.init._find_networking_config = fake_network_config
601+ self.init.apply_network_config(True)
602+ self.init.distro.apply_network_config_names.assert_called_with(net_cfg)
603+ self.init.distro.apply_network_config.assert_called_with(
604+ net_cfg, bring_up=True)
605+
606+ @mock.patch('cloudinit.distros.ubuntu.Distro')
607+ def test_apply_network_on_same_instance_id(self, m_ubuntu):
608+ """Only call distro.apply_network_config_names on same instance id."""
609+ old_instance_id = os.path.join(
610+ self.init.paths.get_cpath('data'), 'instance-id')
611+ write_file(old_instance_id, TEST_INSTANCE_ID)
612+ net_cfg = {
613+ 'version': 1, 'config': [
614+ {'subnets': [{'type': 'dhcp'}], 'type': 'physical',
615+ 'name': 'eth9', 'mac_address': '42:42:42:42:42:42'}]}
616+
617+ def fake_network_config():
618+ return net_cfg, 'fallback'
619+
620+ self.init._find_networking_config = fake_network_config
621+ self.init.apply_network_config(True)
622+ self.init.distro.apply_network_config_names.assert_called_with(net_cfg)
623+ self.init.distro.apply_network_config.assert_not_called()
624+ self.assertIn(
625+ 'No network config applied. Neither a new instance'
626+ " nor datasource network update on '%s' event" % EventType.BOOT,
627+ self.logs.getvalue())
628+
629+ @mock.patch('cloudinit.distros.ubuntu.Distro')
630+ def test_apply_network_on_datasource_allowed_event(self, m_ubuntu):
631+ """Apply network if datasource.update_metadata permits BOOT event."""
632+ old_instance_id = os.path.join(
633+ self.init.paths.get_cpath('data'), 'instance-id')
634+ write_file(old_instance_id, TEST_INSTANCE_ID)
635+ net_cfg = {
636+ 'version': 1, 'config': [
637+ {'subnets': [{'type': 'dhcp'}], 'type': 'physical',
638+ 'name': 'eth9', 'mac_address': '42:42:42:42:42:42'}]}
639+
640+ def fake_network_config():
641+ return net_cfg, 'fallback'
642+
643+ self.init._find_networking_config = fake_network_config
644+ self.init.datasource = FakeDataSource(paths=self.init.paths)
645+ self.init.datasource.maintenance_events = [EventType.BOOT]
646+ self.init.apply_network_config(True)
647+ self.init.distro.apply_network_config_names.assert_called_with(net_cfg)
648+ self.init.distro.apply_network_config.assert_called_with(
649+ net_cfg, bring_up=True)
650+
651+# vi: ts=4 expandtab
652diff --git a/tests/unittests/test_datasource/test_azure.py b/tests/unittests/test_datasource/test_azure.py
653index e82716e..b2cafe6 100644
654--- a/tests/unittests/test_datasource/test_azure.py
655+++ b/tests/unittests/test_datasource/test_azure.py
656@@ -1,15 +1,19 @@
657 # This file is part of cloud-init. See LICENSE file for license information.
658
659 from cloudinit import helpers
660+from cloudinit import url_helper
661 from cloudinit.sources import DataSourceAzure as dsaz
662 from cloudinit.util import (b64e, decode_binary, load_file, write_file,
663 find_freebsd_part, get_path_dev_freebsd,
664 MountFailedError)
665 from cloudinit.version import version_string as vs
666-from cloudinit.tests.helpers import (CiTestCase, TestCase, populate_dir, mock,
667- ExitStack, PY26, SkipTest)
668+from cloudinit.tests.helpers import (
669+ HttprettyTestCase, CiTestCase, populate_dir, mock,
670+ ExitStack, PY26, SkipTest)
671
672 import crypt
673+import httpretty
674+import json
675 import os
676 import stat
677 import xml.etree.ElementTree as ET
678@@ -77,6 +81,106 @@ def construct_valid_ovf_env(data=None, pubkeys=None,
679 return content
680
681
682+NETWORK_METADATA = {
683+ "network": {
684+ "interface": [
685+ {
686+ "macAddress": "000D3A047598",
687+ "ipv6": {
688+ "ipAddress": []
689+ },
690+ "ipv4": {
691+ "subnet": [
692+ {
693+ "prefix": "24",
694+ "address": "10.0.0.0"
695+ }
696+ ],
697+ "ipAddress": [
698+ {
699+ "privateIpAddress": "10.0.0.4",
700+ "publicIpAddress": "104.46.124.81"
701+ }
702+ ]
703+ }
704+ }
705+ ]
706+ }
707+}
708+
709+
710+class TestGetMetadataFromIMDS(HttprettyTestCase):
711+
712+ with_logs = True
713+
714+ def setUp(self):
715+ super(TestGetMetadataFromIMDS, self).setUp()
716+ self.network_md_url = dsaz.IMDS_URL + "instance?api-version=2017-12-01"
717+
718+ @mock.patch('cloudinit.sources.DataSourceAzure.readurl')
719+ @mock.patch('cloudinit.sources.DataSourceAzure.EphemeralDHCPv4')
720+ @mock.patch('cloudinit.sources.DataSourceAzure.net.is_up')
721+ def test_get_metadata_does_not_dhcp_if_network_is_up(
722+ self, m_net_is_up, m_dhcp, m_readurl):
723+ """Do not perform DHCP setup when nic is already up."""
724+ m_net_is_up.return_value = True
725+ m_readurl.return_value = url_helper.StringResponse(
726+ json.dumps(NETWORK_METADATA).encode('utf-8'))
727+ self.assertEqual(
728+ NETWORK_METADATA,
729+ dsaz.get_metadata_from_imds('eth9', retries=3))
730+
731+ m_net_is_up.assert_called_with('eth9')
732+ m_dhcp.assert_not_called()
733+ self.assertIn(
734+ "Crawl of Azure Instance Metadata Service (IMDS) took", # log_time
735+ self.logs.getvalue())
736+
737+ @mock.patch('cloudinit.sources.DataSourceAzure.readurl')
738+ @mock.patch('cloudinit.sources.DataSourceAzure.EphemeralDHCPv4')
739+ @mock.patch('cloudinit.sources.DataSourceAzure.net.is_up')
740+ def test_get_metadata_performs_dhcp_when_network_is_down(
741+ self, m_net_is_up, m_dhcp, m_readurl):
742+ """Do not perform DHCP setup when nic is already up."""
743+ m_net_is_up.return_value = False
744+ m_readurl.return_value = url_helper.StringResponse(
745+ json.dumps(NETWORK_METADATA).encode('utf-8'))
746+
747+ self.assertEqual(
748+ NETWORK_METADATA,
749+ dsaz.get_metadata_from_imds('eth9', retries=2))
750+
751+ m_net_is_up.assert_called_with('eth9')
752+ m_dhcp.assert_called_with('eth9')
753+ self.assertIn(
754+ "Crawl of Azure Instance Metadata Service (IMDS) took", # log_time
755+ self.logs.getvalue())
756+
757+ m_readurl.assert_called_with(
758+ self.network_md_url, exception_cb=mock.ANY,
759+ headers={'Metadata': 'true'}, retries=2, timeout=1)
760+
761+ @mock.patch('cloudinit.url_helper.time.sleep')
762+ @mock.patch('cloudinit.sources.DataSourceAzure.net.is_up')
763+ def test_get_metadata_from_imds_empty_when_no_imds_present(
764+ self, m_net_is_up, m_sleep):
765+ """Return empty dict when IMDS network metadata is absent."""
766+ httpretty.register_uri(
767+ httpretty.GET,
768+ dsaz.IMDS_URL + 'instance?api-version=2017-12-01',
769+ body={}, status=404)
770+
771+ m_net_is_up.return_value = True # skips dhcp
772+
773+ self.assertEqual({}, dsaz.get_metadata_from_imds('eth9', retries=2))
774+
775+ m_net_is_up.assert_called_with('eth9')
776+ self.assertEqual([mock.call(1), mock.call(1)], m_sleep.call_args_list)
777+ self.assertIn(
778+ "Crawl of Azure Instance Metadata Service (IMDS) took", # log_time
779+ self.logs.getvalue())
780+
781+
782 class TestAzureDataSource(CiTestCase):
783
784 with_logs = True
785@@ -95,8 +199,12 @@ class TestAzureDataSource(CiTestCase):
786 self.patches = ExitStack()
787 self.addCleanup(self.patches.close)
788
789- self.patches.enter_context(mock.patch.object(dsaz, '_get_random_seed'))
790-
791+ self.patches.enter_context(mock.patch.object(
792+ dsaz, '_get_random_seed', return_value='wild'))
793+ self.m_get_metadata_from_imds = self.patches.enter_context(
794+ mock.patch.object(
795+ dsaz, 'get_metadata_from_imds',
796+ mock.MagicMock(return_value=NETWORK_METADATA)))
797 super(TestAzureDataSource, self).setUp()
798
799 def apply_patches(self, patches):
800@@ -314,6 +422,20 @@ fdescfs /dev/fd fdescfs rw 0 0
801 self.assertTrue(ret)
802 self.assertEqual(data['agent_invoked'], cfg['agent_command'])
803
804+ def test_network_config_set_from_imds(self):
805+ """Datasource.network_config returns IMDS network data."""
806+ odata = {}
807+ data = {'ovfcontent': construct_valid_ovf_env(data=odata)}
808+ expected_network_config = {
809+ 'ethernets': {
810+ 'eth0': {'set-name': 'eth0',
811+ 'match': {'macaddress': '00:0d:3a:04:75:98'},
812+ 'dhcp4': True}},
813+ 'version': 2}
814+ dsrc = self._get_ds(data)
815+ dsrc.get_data()
816+ self.assertEqual(expected_network_config, dsrc.network_config)
817+
818 def test_user_cfg_set_agent_command(self):
819 # set dscfg in via base64 encoded yaml
820 cfg = {'agent_command': "my_command"}
821@@ -579,12 +701,34 @@ fdescfs /dev/fd fdescfs rw 0 0
822 self.assertEqual(
823 [mock.call("/dev/cd0")], m_check_fbsd_cdrom.call_args_list)
824
825+ @mock.patch('cloudinit.net.generate_fallback_config')
826+ def test_imds_network_config(self, mock_fallback):
827+ """Network config is generated from IMDS network data when present."""
828+ odata = {'HostName': "myhost", 'UserName': "myuser"}
829+ data = {'ovfcontent': construct_valid_ovf_env(data=odata),
830+ 'sys_cfg': {}}
831+
832+ dsrc = self._get_ds(data)
833+ ret = dsrc.get_data()
834+ self.assertTrue(ret)
835+
836+ expected_cfg = {
837+ 'ethernets': {
838+ 'eth0': {'dhcp4': True,
839+ 'match': {'macaddress': '00:0d:3a:04:75:98'},
840+ 'set-name': 'eth0'}},
841+ 'version': 2}
842+
843+ self.assertEqual(expected_cfg, dsrc.network_config)
844+ mock_fallback.assert_not_called()
845+
846 @mock.patch('cloudinit.net.get_interface_mac')
847 @mock.patch('cloudinit.net.get_devicelist')
848 @mock.patch('cloudinit.net.device_driver')
849 @mock.patch('cloudinit.net.generate_fallback_config')
850- def test_network_config(self, mock_fallback, mock_dd,
851- mock_devlist, mock_get_mac):
852+ def test_fallback_network_config(self, mock_fallback, mock_dd,
853+ mock_devlist, mock_get_mac):
854+ """On absent IMDS network data, generate network fallback config."""
855 odata = {'HostName': "myhost", 'UserName': "myuser"}
856 data = {'ovfcontent': construct_valid_ovf_env(data=odata),
857 'sys_cfg': {}}
858@@ -605,6 +749,8 @@ fdescfs /dev/fd fdescfs rw 0 0
859 mock_get_mac.return_value = '00:11:22:33:44:55'
860
861 dsrc = self._get_ds(data)
862+ # Represent empty response from network imds
863+ self.m_get_metadata_from_imds.return_value = {}
864 ret = dsrc.get_data()
865 self.assertTrue(ret)
866
867@@ -617,8 +763,9 @@ fdescfs /dev/fd fdescfs rw 0 0
868 @mock.patch('cloudinit.net.get_devicelist')
869 @mock.patch('cloudinit.net.device_driver')
870 @mock.patch('cloudinit.net.generate_fallback_config')
871- def test_network_config_blacklist(self, mock_fallback, mock_dd,
872- mock_devlist, mock_get_mac):
873+ def test_fallback_network_config_blacklist(self, mock_fallback, mock_dd,
874+ mock_devlist, mock_get_mac):
875+ """On absent network metadata, blacklist mlx from fallback config."""
876 odata = {'HostName': "myhost", 'UserName': "myuser"}
877 data = {'ovfcontent': construct_valid_ovf_env(data=odata),
878 'sys_cfg': {}}
879@@ -649,6 +796,8 @@ fdescfs /dev/fd fdescfs rw 0 0
880 mock_get_mac.return_value = '00:11:22:33:44:55'
881
882 dsrc = self._get_ds(data)
883+ # Represent empty response from network imds
884+ self.m_get_metadata_from_imds.return_value = {}
885 ret = dsrc.get_data()
886 self.assertTrue(ret)
887
888@@ -689,9 +838,12 @@ class TestAzureBounce(CiTestCase):
889 mock.patch.object(dsaz, 'get_metadata_from_fabric',
890 mock.MagicMock(return_value={})))
891 self.patches.enter_context(
892- mock.patch.object(dsaz.util, 'which', lambda x: True))
893+ mock.patch.object(dsaz, 'get_metadata_from_imds',
894+ mock.MagicMock(return_value={})))
895 self.patches.enter_context(
896- mock.patch.object(dsaz, '_get_random_seed'))
897+ mock.patch.object(dsaz.util, 'which', lambda x: True))
898+ self.patches.enter_context(mock.patch.object(
899+ dsaz, '_get_random_seed', return_value='wild'))
900
901 def _dmi_mocks(key):
902 if key == 'system-uuid':
903@@ -719,9 +871,12 @@ class TestAzureBounce(CiTestCase):
904 mock.patch.object(dsaz, 'set_hostname'))
905 self.subp = self.patches.enter_context(
906 mock.patch('cloudinit.sources.DataSourceAzure.util.subp'))
907+ self.find_fallback_nic = self.patches.enter_context(
908+ mock.patch('cloudinit.net.find_fallback_nic', return_value='eth9'))
909
910 def tearDown(self):
911 self.patches.close()
912+ super(TestAzureBounce, self).tearDown()
913
914 def _get_ds(self, ovfcontent=None, agent_command=None):
915 if ovfcontent is not None:
916@@ -927,7 +1082,7 @@ class TestLoadAzureDsDir(CiTestCase):
917 str(context_manager.exception))
918
919
920-class TestReadAzureOvf(TestCase):
921+class TestReadAzureOvf(CiTestCase):
922
923 def test_invalid_xml_raises_non_azure_ds(self):
924 invalid_xml = "<foo>" + construct_valid_ovf_env(data={})

Subscribers

People subscribed via source and target branches