Merge lp:~oddbloke/cloud-init/lp1375252 into lp:~cloud-init-dev/cloud-init/trunk

Proposed by Dan Watkins
Status: Merged
Merged at revision: 1098
Proposed branch: lp:~oddbloke/cloud-init/lp1375252
Merge into: lp:~cloud-init-dev/cloud-init/trunk
Diff against target: 425 lines (+247/-106)
2 files modified
cloudinit/sources/DataSourceAzure.py (+75/-61)
tests/unittests/test_datasource/test_azure.py (+172/-45)
To merge this branch: bzr merge lp:~oddbloke/cloud-init/lp1375252
Reviewer Review Type Date Requested Status
cloud-init Commiters Pending
Review via email: mp+256291@code.launchpad.net
To post a comment you must log in.
Revision history for this message
Dan Watkins (oddbloke) wrote :

Review appreciated, but probably shouldn't be merged until vivid is out the door.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'cloudinit/sources/DataSourceAzure.py'
--- cloudinit/sources/DataSourceAzure.py 2015-02-18 13:30:51 +0000
+++ cloudinit/sources/DataSourceAzure.py 2015-04-15 11:26:23 +0000
@@ -17,6 +17,7 @@
17# along with this program. If not, see <http://www.gnu.org/licenses/>.17# along with this program. If not, see <http://www.gnu.org/licenses/>.
1818
19import base6419import base64
20import contextlib
20import crypt21import crypt
21import fnmatch22import fnmatch
22import os23import os
@@ -66,6 +67,36 @@
66DEF_EPHEMERAL_LABEL = 'Temporary Storage'67DEF_EPHEMERAL_LABEL = 'Temporary Storage'
6768
6869
70def get_hostname(hostname_command='hostname'):
71 return util.subp(hostname_command, capture=True)[0].strip()
72
73
74def set_hostname(hostname, hostname_command='hostname'):
75 util.subp([hostname_command, hostname])
76
77
78@contextlib.contextmanager
79def temporary_hostname(temp_hostname, cfg, hostname_command='hostname'):
80 """
81 Set a temporary hostname, restoring the previous hostname on exit.
82
83 Will have the value of the previous hostname when used as a context
84 manager, or None if the hostname was not changed.
85 """
86 policy = cfg['hostname_bounce']['policy']
87 previous_hostname = get_hostname(hostname_command)
88 if (not util.is_true(cfg.get('set_hostname'))
89 or util.is_false(policy)
90 or (previous_hostname == temp_hostname and policy != 'force')):
91 yield None
92 return
93 set_hostname(temp_hostname, hostname_command)
94 try:
95 yield previous_hostname
96 finally:
97 set_hostname(previous_hostname, hostname_command)
98
99
69class DataSourceAzureNet(sources.DataSource):100class DataSourceAzureNet(sources.DataSource):
70 def __init__(self, sys_cfg, distro, paths):101 def __init__(self, sys_cfg, distro, paths):
71 sources.DataSource.__init__(self, sys_cfg, distro, paths)102 sources.DataSource.__init__(self, sys_cfg, distro, paths)
@@ -154,33 +185,40 @@
154 # the directory to be protected.185 # the directory to be protected.
155 write_files(ddir, files, dirmode=0o700)186 write_files(ddir, files, dirmode=0o700)
156187
157 # handle the hostname 'publishing'188 temp_hostname = self.metadata.get('local-hostname')
158 try:189 hostname_command = mycfg['hostname_bounce']['hostname_command']
159 handle_set_hostname(mycfg.get('set_hostname'),190 with temporary_hostname(temp_hostname, mycfg,
160 self.metadata.get('local-hostname'),191 hostname_command=hostname_command) \
161 mycfg['hostname_bounce'])192 as previous_hostname:
162 except Exception as e:193 if (previous_hostname is not None
163 LOG.warn("Failed publishing hostname: %s", e)194 and util.is_true(mycfg.get('set_hostname'))):
164 util.logexc(LOG, "handling set_hostname failed")195 cfg = mycfg['hostname_bounce']
165196 try:
166 try:197 perform_hostname_bounce(hostname=temp_hostname,
167 invoke_agent(mycfg['agent_command'])198 cfg=cfg,
168 except util.ProcessExecutionError:199 prev_hostname=previous_hostname)
169 # claim the datasource even if the command failed200 except Exception as e:
170 util.logexc(LOG, "agent command '%s' failed.",201 LOG.warn("Failed publishing hostname: %s", e)
171 mycfg['agent_command'])202 util.logexc(LOG, "handling set_hostname failed")
172203
173 shcfgxml = os.path.join(ddir, "SharedConfig.xml")204 try:
174 wait_for = [shcfgxml]205 invoke_agent(mycfg['agent_command'])
175206 except util.ProcessExecutionError:
176 fp_files = []207 # claim the datasource even if the command failed
177 for pk in self.cfg.get('_pubkeys', []):208 util.logexc(LOG, "agent command '%s' failed.",
178 bname = str(pk['fingerprint'] + ".crt")209 mycfg['agent_command'])
179 fp_files += [os.path.join(ddir, bname)]210
180211 shcfgxml = os.path.join(ddir, "SharedConfig.xml")
181 missing = util.log_time(logfunc=LOG.debug, msg="waiting for files",212 wait_for = [shcfgxml]
182 func=wait_for_files,213
183 args=(wait_for + fp_files,))214 fp_files = []
215 for pk in self.cfg.get('_pubkeys', []):
216 bname = str(pk['fingerprint'] + ".crt")
217 fp_files += [os.path.join(ddir, bname)]
218
219 missing = util.log_time(logfunc=LOG.debug, msg="waiting for files",
220 func=wait_for_files,
221 args=(wait_for + fp_files,))
184 if len(missing):222 if len(missing):
185 LOG.warn("Did not find files, but going on: %s", missing)223 LOG.warn("Did not find files, but going on: %s", missing)
186224
@@ -299,39 +337,15 @@
299 return mod_list337 return mod_list
300338
301339
302def handle_set_hostname(enabled, hostname, cfg):340def perform_hostname_bounce(hostname, cfg, prev_hostname):
303 if not util.is_true(enabled):
304 return
305
306 if not hostname:
307 LOG.warn("set_hostname was true but no local-hostname")
308 return
309
310 apply_hostname_bounce(hostname=hostname, policy=cfg['policy'],
311 interface=cfg['interface'],
312 command=cfg['command'],
313 hostname_command=cfg['hostname_command'])
314
315
316def apply_hostname_bounce(hostname, policy, interface, command,
317 hostname_command="hostname"):
318 # set the hostname to 'hostname' if it is not already set to that.341 # set the hostname to 'hostname' if it is not already set to that.
319 # then, if policy is not off, bounce the interface using command342 # then, if policy is not off, bounce the interface using command
320 prev_hostname = util.subp(hostname_command, capture=True)[0].strip()343 command = cfg['command']
321344 interface = cfg['interface']
322 util.subp([hostname_command, hostname])345 policy = cfg['policy']
323346
324 msg = ("phostname=%s hostname=%s policy=%s interface=%s" %347 msg = ("hostname=%s policy=%s interface=%s" %
325 (prev_hostname, hostname, policy, interface))348 (hostname, policy, interface))
326
327 if util.is_false(policy):
328 LOG.debug("pubhname: policy false, skipping [%s]", msg)
329 return
330
331 if prev_hostname == hostname and policy != "force":
332 LOG.debug("pubhname: no change, policy != force. skipping. [%s]", msg)
333 return
334
335 env = os.environ.copy()349 env = os.environ.copy()
336 env['interface'] = interface350 env['interface'] = interface
337 env['hostname'] = hostname351 env['hostname'] = hostname
@@ -344,9 +358,9 @@
344 shell = not isinstance(command, (list, tuple))358 shell = not isinstance(command, (list, tuple))
345 # capture=False, see comments in bug 1202758 and bug 1206164.359 # capture=False, see comments in bug 1202758 and bug 1206164.
346 util.log_time(logfunc=LOG.debug, msg="publishing hostname",360 util.log_time(logfunc=LOG.debug, msg="publishing hostname",
347 get_uptime=True, func=util.subp,361 get_uptime=True, func=util.subp,
348 kwargs={'args': command, 'shell': shell, 'capture': False,362 kwargs={'args': command, 'shell': shell, 'capture': False,
349 'env': env})363 'env': env})
350364
351365
352def crtfile_to_pubkey(fname):366def crtfile_to_pubkey(fname):
353367
=== modified file 'tests/unittests/test_datasource/test_azure.py'
--- tests/unittests/test_datasource/test_azure.py 2015-02-24 16:58:22 +0000
+++ tests/unittests/test_datasource/test_azure.py 2015-04-15 11:26:23 +0000
@@ -116,9 +116,6 @@
116 data['iid_from_shared_cfg'] = path116 data['iid_from_shared_cfg'] = path
117 return 'i-my-azure-id'117 return 'i-my-azure-id'
118118
119 def _apply_hostname_bounce(**kwargs):
120 data['apply_hostname_bounce'] = kwargs
121
122 if data.get('ovfcontent') is not None:119 if data.get('ovfcontent') is not None:
123 populate_dir(os.path.join(self.paths.seed_dir, "azure"),120 populate_dir(os.path.join(self.paths.seed_dir, "azure"),
124 {'ovf-env.xml': data['ovfcontent']})121 {'ovf-env.xml': data['ovfcontent']})
@@ -132,7 +129,9 @@
132 (mod, 'wait_for_files', _wait_for_files),129 (mod, 'wait_for_files', _wait_for_files),
133 (mod, 'pubkeys_from_crt_files', _pubkeys_from_crt_files),130 (mod, 'pubkeys_from_crt_files', _pubkeys_from_crt_files),
134 (mod, 'iid_from_shared_config', _iid_from_shared_config),131 (mod, 'iid_from_shared_config', _iid_from_shared_config),
135 (mod, 'apply_hostname_bounce', _apply_hostname_bounce),132 (mod, 'perform_hostname_bounce', mock.MagicMock()),
133 (mod, 'get_hostname', mock.MagicMock()),
134 (mod, 'set_hostname', mock.MagicMock()),
136 ])135 ])
137136
138 dsrc = mod.DataSourceAzureNet(137 dsrc = mod.DataSourceAzureNet(
@@ -272,47 +271,6 @@
272 for mypk in mypklist:271 for mypk in mypklist:
273 self.assertIn(mypk, dsrc.cfg['_pubkeys'])272 self.assertIn(mypk, dsrc.cfg['_pubkeys'])
274273
275 def test_disabled_bounce(self):
276 pass
277
278 def test_apply_bounce_call_1(self):
279 # hostname needs to get through to apply_hostname_bounce
280 odata = {'HostName': 'my-random-hostname'}
281 data = {'ovfcontent': construct_valid_ovf_env(data=odata)}
282
283 self._get_ds(data).get_data()
284 self.assertIn('hostname', data['apply_hostname_bounce'])
285 self.assertEqual(data['apply_hostname_bounce']['hostname'],
286 odata['HostName'])
287
288 def test_apply_bounce_call_configurable(self):
289 # hostname_bounce should be configurable in datasource cfg
290 cfg = {'hostname_bounce': {'interface': 'eth1', 'policy': 'off',
291 'command': 'my-bounce-command',
292 'hostname_command': 'my-hostname-command'}}
293 odata = {'HostName': "xhost",
294 'dscfg': {'text': b64e(yaml.dump(cfg)),
295 'encoding': 'base64'}}
296 data = {'ovfcontent': construct_valid_ovf_env(data=odata)}
297 self._get_ds(data).get_data()
298
299 for k in cfg['hostname_bounce']:
300 self.assertIn(k, data['apply_hostname_bounce'])
301
302 for k, v in cfg['hostname_bounce'].items():
303 self.assertEqual(data['apply_hostname_bounce'][k], v)
304
305 def test_set_hostname_disabled(self):
306 # config specifying set_hostname off should not bounce
307 cfg = {'set_hostname': False}
308 odata = {'HostName': "xhost",
309 'dscfg': {'text': b64e(yaml.dump(cfg)),
310 'encoding': 'base64'}}
311 data = {'ovfcontent': construct_valid_ovf_env(data=odata)}
312 self._get_ds(data).get_data()
313
314 self.assertEqual(data.get('apply_hostname_bounce', "N/A"), "N/A")
315
316 def test_default_ephemeral(self):274 def test_default_ephemeral(self):
317 # make sure the ephemeral device works275 # make sure the ephemeral device works
318 odata = {}276 odata = {}
@@ -425,6 +383,175 @@
425 load_file(os.path.join(self.waagent_d, 'ovf-env.xml')))383 load_file(os.path.join(self.waagent_d, 'ovf-env.xml')))
426384
427385
386class TestAzureBounce(TestCase):
387
388 def mock_out_azure_moving_parts(self):
389 self.patches.enter_context(
390 mock.patch.object(DataSourceAzure, 'invoke_agent'))
391 self.patches.enter_context(
392 mock.patch.object(DataSourceAzure, 'wait_for_files'))
393 self.patches.enter_context(
394 mock.patch.object(DataSourceAzure, 'iid_from_shared_config',
395 mock.MagicMock(return_value='i-my-azure-id')))
396 self.patches.enter_context(
397 mock.patch.object(DataSourceAzure, 'list_possible_azure_ds_devs',
398 mock.MagicMock(return_value=[])))
399 self.patches.enter_context(
400 mock.patch.object(DataSourceAzure, 'find_ephemeral_disk',
401 mock.MagicMock(return_value=None)))
402 self.patches.enter_context(
403 mock.patch.object(DataSourceAzure, 'find_ephemeral_part',
404 mock.MagicMock(return_value=None)))
405
406 def setUp(self):
407 super(TestAzureBounce, self).setUp()
408 self.tmp = tempfile.mkdtemp()
409 self.waagent_d = os.path.join(self.tmp, 'var', 'lib', 'waagent')
410 self.paths = helpers.Paths({'cloud_dir': self.tmp})
411 self.addCleanup(shutil.rmtree, self.tmp)
412 DataSourceAzure.BUILTIN_DS_CONFIG['data_dir'] = self.waagent_d
413 self.patches = ExitStack()
414 self.mock_out_azure_moving_parts()
415 self.get_hostname = self.patches.enter_context(
416 mock.patch.object(DataSourceAzure, 'get_hostname'))
417 self.set_hostname = self.patches.enter_context(
418 mock.patch.object(DataSourceAzure, 'set_hostname'))
419 self.subp = self.patches.enter_context(
420 mock.patch('cloudinit.sources.DataSourceAzure.util.subp'))
421
422 def tearDown(self):
423 self.patches.close()
424
425 def _get_ds(self, ovfcontent=None):
426 if ovfcontent is not None:
427 populate_dir(os.path.join(self.paths.seed_dir, "azure"),
428 {'ovf-env.xml': ovfcontent})
429 return DataSourceAzure.DataSourceAzureNet(
430 {}, distro=None, paths=self.paths)
431
432 def get_ovf_env_with_dscfg(self, hostname, cfg):
433 odata = {
434 'HostName': hostname,
435 'dscfg': {
436 'text': b64e(yaml.dump(cfg)),
437 'encoding': 'base64'
438 }
439 }
440 return construct_valid_ovf_env(data=odata)
441
442 def test_disabled_bounce_does_not_change_hostname(self):
443 cfg = {'hostname_bounce': {'policy': 'off'}}
444 self._get_ds(self.get_ovf_env_with_dscfg('test-host', cfg)).get_data()
445 self.assertEqual(0, self.set_hostname.call_count)
446
447 @mock.patch('cloudinit.sources.DataSourceAzure.perform_hostname_bounce')
448 def test_disabled_bounce_does_not_perform_bounce(
449 self, perform_hostname_bounce):
450 cfg = {'hostname_bounce': {'policy': 'off'}}
451 self._get_ds(self.get_ovf_env_with_dscfg('test-host', cfg)).get_data()
452 self.assertEqual(0, perform_hostname_bounce.call_count)
453
454 def test_same_hostname_does_not_change_hostname(self):
455 host_name = 'unchanged-host-name'
456 self.get_hostname.return_value = host_name
457 cfg = {'hostname_bounce': {'policy': 'yes'}}
458 self._get_ds(self.get_ovf_env_with_dscfg(host_name, cfg)).get_data()
459 self.assertEqual(0, self.set_hostname.call_count)
460
461 @mock.patch('cloudinit.sources.DataSourceAzure.perform_hostname_bounce')
462 def test_unchanged_hostname_does_not_perform_bounce(
463 self, perform_hostname_bounce):
464 host_name = 'unchanged-host-name'
465 self.get_hostname.return_value = host_name
466 cfg = {'hostname_bounce': {'policy': 'yes'}}
467 self._get_ds(self.get_ovf_env_with_dscfg(host_name, cfg)).get_data()
468 self.assertEqual(0, perform_hostname_bounce.call_count)
469
470 @mock.patch('cloudinit.sources.DataSourceAzure.perform_hostname_bounce')
471 def test_force_performs_bounce_regardless(self, perform_hostname_bounce):
472 host_name = 'unchanged-host-name'
473 self.get_hostname.return_value = host_name
474 cfg = {'hostname_bounce': {'policy': 'force'}}
475 self._get_ds(self.get_ovf_env_with_dscfg(host_name, cfg)).get_data()
476 self.assertEqual(1, perform_hostname_bounce.call_count)
477
478 def test_different_hostnames_sets_hostname(self):
479 expected_hostname = 'azure-expected-host-name'
480 self.get_hostname.return_value = 'default-host-name'
481 self._get_ds(
482 self.get_ovf_env_with_dscfg(expected_hostname, {})).get_data()
483 self.assertEqual(expected_hostname,
484 self.set_hostname.call_args_list[0][0][0])
485
486 @mock.patch('cloudinit.sources.DataSourceAzure.perform_hostname_bounce')
487 def test_different_hostnames_performs_bounce(
488 self, perform_hostname_bounce):
489 expected_hostname = 'azure-expected-host-name'
490 self.get_hostname.return_value = 'default-host-name'
491 self._get_ds(
492 self.get_ovf_env_with_dscfg(expected_hostname, {})).get_data()
493 self.assertEqual(1, perform_hostname_bounce.call_count)
494
495 def test_different_hostnames_sets_hostname_back(self):
496 initial_host_name = 'default-host-name'
497 self.get_hostname.return_value = initial_host_name
498 self._get_ds(
499 self.get_ovf_env_with_dscfg('some-host-name', {})).get_data()
500 self.assertEqual(initial_host_name,
501 self.set_hostname.call_args_list[-1][0][0])
502
503 @mock.patch('cloudinit.sources.DataSourceAzure.perform_hostname_bounce')
504 def test_failure_in_bounce_still_resets_host_name(
505 self, perform_hostname_bounce):
506 perform_hostname_bounce.side_effect = Exception
507 initial_host_name = 'default-host-name'
508 self.get_hostname.return_value = initial_host_name
509 self._get_ds(
510 self.get_ovf_env_with_dscfg('some-host-name', {})).get_data()
511 self.assertEqual(initial_host_name,
512 self.set_hostname.call_args_list[-1][0][0])
513
514 def test_environment_correct_for_bounce_command(self):
515 interface = 'int0'
516 hostname = 'my-new-host'
517 old_hostname = 'my-old-host'
518 self.get_hostname.return_value = old_hostname
519 cfg = {'hostname_bounce': {'interface': interface, 'policy': 'force'}}
520 data = self.get_ovf_env_with_dscfg(hostname, cfg)
521 self._get_ds(data).get_data()
522 self.assertEqual(1, self.subp.call_count)
523 bounce_env = self.subp.call_args[1]['env']
524 self.assertEqual(interface, bounce_env['interface'])
525 self.assertEqual(hostname, bounce_env['hostname'])
526 self.assertEqual(old_hostname, bounce_env['old_hostname'])
527
528 def test_default_bounce_command_used_by_default(self):
529 cmd = 'default-bounce-command'
530 DataSourceAzure.BUILTIN_DS_CONFIG['hostname_bounce']['command'] = cmd
531 cfg = {'hostname_bounce': {'policy': 'force'}}
532 data = self.get_ovf_env_with_dscfg('some-hostname', cfg)
533 self._get_ds(data).get_data()
534 self.assertEqual(1, self.subp.call_count)
535 bounce_args = self.subp.call_args[1]['args']
536 self.assertEqual(cmd, bounce_args)
537
538 @mock.patch('cloudinit.sources.DataSourceAzure.perform_hostname_bounce')
539 def test_set_hostname_option_can_disable_bounce(
540 self, perform_hostname_bounce):
541 cfg = {'set_hostname': False, 'hostname_bounce': {'policy': 'force'}}
542 data = self.get_ovf_env_with_dscfg('some-hostname', cfg)
543 self._get_ds(data).get_data()
544
545 self.assertEqual(0, perform_hostname_bounce.call_count)
546
547 def test_set_hostname_option_can_disable_hostname_set(self):
548 cfg = {'set_hostname': False, 'hostname_bounce': {'policy': 'force'}}
549 data = self.get_ovf_env_with_dscfg('some-hostname', cfg)
550 self._get_ds(data).get_data()
551
552 self.assertEqual(0, self.set_hostname.call_count)
553
554
428class TestReadAzureOvf(TestCase):555class TestReadAzureOvf(TestCase):
429 def test_invalid_xml_raises_non_azure_ds(self):556 def test_invalid_xml_raises_non_azure_ds(self):
430 invalid_xml = "<foo>" + construct_valid_ovf_env(data={})557 invalid_xml = "<foo>" + construct_valid_ovf_env(data={})