Merge lp:~daniel-thewatkins/cloud-init/walinux-wip into lp:~cloud-init-dev/cloud-init/trunk

Proposed by Dan Watkins on 2015-05-08
Status: Merged
Merged at revision: 1104
Proposed branch: lp:~daniel-thewatkins/cloud-init/walinux-wip
Merge into: lp:~cloud-init-dev/cloud-init/trunk
Diff against target: 1021 lines (+821/-91)
4 files modified
cloudinit/sources/DataSourceAzure.py (+65/-76)
cloudinit/sources/helpers/azure.py (+293/-0)
tests/unittests/test_datasource/test_azure.py (+24/-15)
tests/unittests/test_datasource/test_azure_helper.py (+439/-0)
To merge this branch: bzr merge lp:~daniel-thewatkins/cloud-init/walinux-wip
Reviewer Review Type Date Requested Status
cloud-init commiters 2015-05-08 Pending
Review via email: mp+258644@code.launchpad.net
To post a comment you must log in.
1112. By Dan Watkins on 2015-05-08

Default to old code path.

1113. By Dan Watkins on 2015-05-08

Fix retrying.

1114. By Dan Watkins on 2015-05-08

Python 2.6 fixes.

Scott Moser (smoser) wrote :

Dan, this looks really nice. thank you.
Can you put together a commit message and i'll pull.

sm

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'cloudinit/sources/DataSourceAzure.py'
2--- cloudinit/sources/DataSourceAzure.py 2015-04-15 11:13:17 +0000
3+++ cloudinit/sources/DataSourceAzure.py 2015-05-08 15:53:08 +0000
4@@ -29,6 +29,8 @@
5 from cloudinit.settings import PER_ALWAYS
6 from cloudinit import sources
7 from cloudinit import util
8+from cloudinit.sources.helpers.azure import (
9+ get_metadata_from_fabric, iid_from_shared_config_content)
10
11 LOG = logging.getLogger(__name__)
12
13@@ -111,6 +113,56 @@
14 root = sources.DataSource.__str__(self)
15 return "%s [seed=%s]" % (root, self.seed)
16
17+ def get_metadata_from_agent(self):
18+ temp_hostname = self.metadata.get('local-hostname')
19+ hostname_command = self.ds_cfg['hostname_bounce']['hostname_command']
20+ with temporary_hostname(temp_hostname, self.ds_cfg,
21+ hostname_command=hostname_command) \
22+ as previous_hostname:
23+ if (previous_hostname is not None
24+ and util.is_true(self.ds_cfg.get('set_hostname'))):
25+ cfg = self.ds_cfg['hostname_bounce']
26+ try:
27+ perform_hostname_bounce(hostname=temp_hostname,
28+ cfg=cfg,
29+ prev_hostname=previous_hostname)
30+ except Exception as e:
31+ LOG.warn("Failed publishing hostname: %s", e)
32+ util.logexc(LOG, "handling set_hostname failed")
33+
34+ try:
35+ invoke_agent(self.ds_cfg['agent_command'])
36+ except util.ProcessExecutionError:
37+ # claim the datasource even if the command failed
38+ util.logexc(LOG, "agent command '%s' failed.",
39+ self.ds_cfg['agent_command'])
40+
41+ ddir = self.ds_cfg['data_dir']
42+ shcfgxml = os.path.join(ddir, "SharedConfig.xml")
43+ wait_for = [shcfgxml]
44+
45+ fp_files = []
46+ for pk in self.cfg.get('_pubkeys', []):
47+ bname = str(pk['fingerprint'] + ".crt")
48+ fp_files += [os.path.join(ddir, bname)]
49+
50+ missing = util.log_time(logfunc=LOG.debug, msg="waiting for files",
51+ func=wait_for_files,
52+ args=(wait_for + fp_files,))
53+ if len(missing):
54+ LOG.warn("Did not find files, but going on: %s", missing)
55+
56+ metadata = {}
57+ if shcfgxml in missing:
58+ LOG.warn("SharedConfig.xml missing, using static instance-id")
59+ else:
60+ try:
61+ metadata['instance-id'] = iid_from_shared_config(shcfgxml)
62+ except ValueError as e:
63+ LOG.warn("failed to get instance id in %s: %s", shcfgxml, e)
64+ metadata['public-keys'] = pubkeys_from_crt_files(fp_files)
65+ return metadata
66+
67 def get_data(self):
68 # azure removes/ejects the cdrom containing the ovf-env.xml
69 # file on reboot. So, in order to successfully reboot we
70@@ -163,8 +215,6 @@
71 # now update ds_cfg to reflect contents pass in config
72 user_ds_cfg = util.get_cfg_by_path(self.cfg, DS_CFG_PATH, {})
73 self.ds_cfg = util.mergemanydict([user_ds_cfg, self.ds_cfg])
74- mycfg = self.ds_cfg
75- ddir = mycfg['data_dir']
76
77 if found != ddir:
78 cached_ovfenv = util.load_file(
79@@ -185,53 +235,18 @@
80 # the directory to be protected.
81 write_files(ddir, files, dirmode=0o700)
82
83- temp_hostname = self.metadata.get('local-hostname')
84- hostname_command = mycfg['hostname_bounce']['hostname_command']
85- with temporary_hostname(temp_hostname, mycfg,
86- hostname_command=hostname_command) \
87- as previous_hostname:
88- if (previous_hostname is not None
89- and util.is_true(mycfg.get('set_hostname'))):
90- cfg = mycfg['hostname_bounce']
91- try:
92- perform_hostname_bounce(hostname=temp_hostname,
93- cfg=cfg,
94- prev_hostname=previous_hostname)
95- except Exception as e:
96- LOG.warn("Failed publishing hostname: %s", e)
97- util.logexc(LOG, "handling set_hostname failed")
98-
99- try:
100- invoke_agent(mycfg['agent_command'])
101- except util.ProcessExecutionError:
102- # claim the datasource even if the command failed
103- util.logexc(LOG, "agent command '%s' failed.",
104- mycfg['agent_command'])
105-
106- shcfgxml = os.path.join(ddir, "SharedConfig.xml")
107- wait_for = [shcfgxml]
108-
109- fp_files = []
110- for pk in self.cfg.get('_pubkeys', []):
111- bname = str(pk['fingerprint'] + ".crt")
112- fp_files += [os.path.join(ddir, bname)]
113-
114- missing = util.log_time(logfunc=LOG.debug, msg="waiting for files",
115- func=wait_for_files,
116- args=(wait_for + fp_files,))
117- if len(missing):
118- LOG.warn("Did not find files, but going on: %s", missing)
119-
120- if shcfgxml in missing:
121- LOG.warn("SharedConfig.xml missing, using static instance-id")
122+ if self.ds_cfg['agent_command'] == '__builtin__':
123+ metadata_func = get_metadata_from_fabric
124 else:
125- try:
126- self.metadata['instance-id'] = iid_from_shared_config(shcfgxml)
127- except ValueError as e:
128- LOG.warn("failed to get instance id in %s: %s", shcfgxml, e)
129+ metadata_func = self.get_metadata_from_agent
130+ try:
131+ fabric_data = metadata_func()
132+ except Exception as exc:
133+ LOG.info("Error communicating with Azure fabric; assume we aren't"
134+ " on Azure.", exc_info=True)
135+ return False
136
137- pubkeys = pubkeys_from_crt_files(fp_files)
138- self.metadata['public-keys'] = pubkeys
139+ self.metadata.update(fabric_data)
140
141 found_ephemeral = find_ephemeral_disk()
142 if found_ephemeral:
143@@ -363,10 +378,11 @@
144 'env': env})
145
146
147-def crtfile_to_pubkey(fname):
148+def crtfile_to_pubkey(fname, data=None):
149 pipeline = ('openssl x509 -noout -pubkey < "$0" |'
150 'ssh-keygen -i -m PKCS8 -f /dev/stdin')
151- (out, _err) = util.subp(['sh', '-c', pipeline, fname], capture=True)
152+ (out, _err) = util.subp(['sh', '-c', pipeline, fname],
153+ capture=True, data=data)
154 return out.rstrip()
155
156
157@@ -476,20 +492,6 @@
158 return found
159
160
161-def single_node_at_path(node, pathlist):
162- curnode = node
163- for tok in pathlist:
164- results = find_child(curnode, lambda n: n.localName == tok)
165- if len(results) == 0:
166- raise ValueError("missing %s token in %s" % (tok, str(pathlist)))
167- if len(results) > 1:
168- raise ValueError("found %s nodes of type %s looking for %s" %
169- (len(results), tok, str(pathlist)))
170- curnode = results[0]
171-
172- return curnode
173-
174-
175 def read_azure_ovf(contents):
176 try:
177 dom = minidom.parseString(contents)
178@@ -620,19 +622,6 @@
179 return iid_from_shared_config_content(content)
180
181
182-def iid_from_shared_config_content(content):
183- """
184- find INSTANCE_ID in:
185- <?xml version="1.0" encoding="utf-8"?>
186- <SharedConfig version="1.0.0.0" goalStateIncarnation="1">
187- <Deployment name="INSTANCE_ID" guid="{...}" incarnation="0">
188- <Service name="..." guid="{00000000-0000-0000-0000-000000000000}" />
189- """
190- dom = minidom.parseString(content)
191- depnode = single_node_at_path(dom, ["SharedConfig", "Deployment"])
192- return depnode.attributes.get('name').value
193-
194-
195 class BrokenAzureDataSource(Exception):
196 pass
197
198
199=== added file 'cloudinit/sources/helpers/azure.py'
200--- cloudinit/sources/helpers/azure.py 1970-01-01 00:00:00 +0000
201+++ cloudinit/sources/helpers/azure.py 2015-05-08 15:53:08 +0000
202@@ -0,0 +1,293 @@
203+import logging
204+import os
205+import re
206+import socket
207+import struct
208+import tempfile
209+import time
210+from contextlib import contextmanager
211+from xml.etree import ElementTree
212+
213+from cloudinit import util
214+
215+
216+LOG = logging.getLogger(__name__)
217+
218+
219+@contextmanager
220+def cd(newdir):
221+ prevdir = os.getcwd()
222+ os.chdir(os.path.expanduser(newdir))
223+ try:
224+ yield
225+ finally:
226+ os.chdir(prevdir)
227+
228+
229+class AzureEndpointHttpClient(object):
230+
231+ headers = {
232+ 'x-ms-agent-name': 'WALinuxAgent',
233+ 'x-ms-version': '2012-11-30',
234+ }
235+
236+ def __init__(self, certificate):
237+ self.extra_secure_headers = {
238+ "x-ms-cipher-name": "DES_EDE3_CBC",
239+ "x-ms-guest-agent-public-x509-cert": certificate,
240+ }
241+
242+ def get(self, url, secure=False):
243+ headers = self.headers
244+ if secure:
245+ headers = self.headers.copy()
246+ headers.update(self.extra_secure_headers)
247+ return util.read_file_or_url(url, headers=headers)
248+
249+ def post(self, url, data=None, extra_headers=None):
250+ headers = self.headers
251+ if extra_headers is not None:
252+ headers = self.headers.copy()
253+ headers.update(extra_headers)
254+ return util.read_file_or_url(url, data=data, headers=headers)
255+
256+
257+class GoalState(object):
258+
259+ def __init__(self, xml, http_client):
260+ self.http_client = http_client
261+ self.root = ElementTree.fromstring(xml)
262+ self._certificates_xml = None
263+
264+ def _text_from_xpath(self, xpath):
265+ element = self.root.find(xpath)
266+ if element is not None:
267+ return element.text
268+ return None
269+
270+ @property
271+ def container_id(self):
272+ return self._text_from_xpath('./Container/ContainerId')
273+
274+ @property
275+ def incarnation(self):
276+ return self._text_from_xpath('./Incarnation')
277+
278+ @property
279+ def instance_id(self):
280+ return self._text_from_xpath(
281+ './Container/RoleInstanceList/RoleInstance/InstanceId')
282+
283+ @property
284+ def shared_config_xml(self):
285+ url = self._text_from_xpath('./Container/RoleInstanceList/RoleInstance'
286+ '/Configuration/SharedConfig')
287+ return self.http_client.get(url).contents
288+
289+ @property
290+ def certificates_xml(self):
291+ if self._certificates_xml is None:
292+ url = self._text_from_xpath(
293+ './Container/RoleInstanceList/RoleInstance'
294+ '/Configuration/Certificates')
295+ if url is not None:
296+ self._certificates_xml = self.http_client.get(
297+ url, secure=True).contents
298+ return self._certificates_xml
299+
300+
301+class OpenSSLManager(object):
302+
303+ certificate_names = {
304+ 'private_key': 'TransportPrivate.pem',
305+ 'certificate': 'TransportCert.pem',
306+ }
307+
308+ def __init__(self):
309+ self.tmpdir = tempfile.mkdtemp()
310+ self.certificate = None
311+ self.generate_certificate()
312+
313+ def clean_up(self):
314+ util.del_dir(self.tmpdir)
315+
316+ def generate_certificate(self):
317+ LOG.debug('Generating certificate for communication with fabric...')
318+ if self.certificate is not None:
319+ LOG.debug('Certificate already generated.')
320+ return
321+ with cd(self.tmpdir):
322+ util.subp([
323+ 'openssl', 'req', '-x509', '-nodes', '-subj',
324+ '/CN=LinuxTransport', '-days', '32768', '-newkey', 'rsa:2048',
325+ '-keyout', self.certificate_names['private_key'],
326+ '-out', self.certificate_names['certificate'],
327+ ])
328+ certificate = ''
329+ for line in open(self.certificate_names['certificate']):
330+ if "CERTIFICATE" not in line:
331+ certificate += line.rstrip()
332+ self.certificate = certificate
333+ LOG.debug('New certificate generated.')
334+
335+ def parse_certificates(self, certificates_xml):
336+ tag = ElementTree.fromstring(certificates_xml).find(
337+ './/Data')
338+ certificates_content = tag.text
339+ lines = [
340+ b'MIME-Version: 1.0',
341+ b'Content-Disposition: attachment; filename="Certificates.p7m"',
342+ b'Content-Type: application/x-pkcs7-mime; name="Certificates.p7m"',
343+ b'Content-Transfer-Encoding: base64',
344+ b'',
345+ certificates_content.encode('utf-8'),
346+ ]
347+ with cd(self.tmpdir):
348+ with open('Certificates.p7m', 'wb') as f:
349+ f.write(b'\n'.join(lines))
350+ out, _ = util.subp(
351+ 'openssl cms -decrypt -in Certificates.p7m -inkey'
352+ ' {private_key} -recip {certificate} | openssl pkcs12 -nodes'
353+ ' -password pass:'.format(**self.certificate_names),
354+ shell=True)
355+ private_keys, certificates = [], []
356+ current = []
357+ for line in out.splitlines():
358+ current.append(line)
359+ if re.match(r'[-]+END .*?KEY[-]+$', line):
360+ private_keys.append('\n'.join(current))
361+ current = []
362+ elif re.match(r'[-]+END .*?CERTIFICATE[-]+$', line):
363+ certificates.append('\n'.join(current))
364+ current = []
365+ keys = []
366+ for certificate in certificates:
367+ with cd(self.tmpdir):
368+ public_key, _ = util.subp(
369+ 'openssl x509 -noout -pubkey |'
370+ 'ssh-keygen -i -m PKCS8 -f /dev/stdin',
371+ data=certificate,
372+ shell=True)
373+ keys.append(public_key)
374+ return keys
375+
376+
377+def iid_from_shared_config_content(content):
378+ """
379+ find INSTANCE_ID in:
380+ <?xml version="1.0" encoding="utf-8"?>
381+ <SharedConfig version="1.0.0.0" goalStateIncarnation="1">
382+ <Deployment name="INSTANCE_ID" guid="{...}" incarnation="0">
383+ <Service name="..." guid="{00000000-0000-0000-0000-000000000000}"/>
384+ """
385+ root = ElementTree.fromstring(content)
386+ depnode = root.find('Deployment')
387+ return depnode.get('name')
388+
389+
390+class WALinuxAgentShim(object):
391+
392+ REPORT_READY_XML_TEMPLATE = '\n'.join([
393+ '<?xml version="1.0" encoding="utf-8"?>',
394+ '<Health xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
395+ ' xmlns:xsd="http://www.w3.org/2001/XMLSchema">',
396+ ' <GoalStateIncarnation>{incarnation}</GoalStateIncarnation>',
397+ ' <Container>',
398+ ' <ContainerId>{container_id}</ContainerId>',
399+ ' <RoleInstanceList>',
400+ ' <Role>',
401+ ' <InstanceId>{instance_id}</InstanceId>',
402+ ' <Health>',
403+ ' <State>Ready</State>',
404+ ' </Health>',
405+ ' </Role>',
406+ ' </RoleInstanceList>',
407+ ' </Container>',
408+ '</Health>'])
409+
410+ def __init__(self):
411+ LOG.debug('WALinuxAgentShim instantiated...')
412+ self.endpoint = self.find_endpoint()
413+ self.openssl_manager = None
414+ self.values = {}
415+
416+ def clean_up(self):
417+ if self.openssl_manager is not None:
418+ self.openssl_manager.clean_up()
419+
420+ @staticmethod
421+ def find_endpoint():
422+ LOG.debug('Finding Azure endpoint...')
423+ content = util.load_file('/var/lib/dhcp/dhclient.eth0.leases')
424+ value = None
425+ for line in content.splitlines():
426+ if 'unknown-245' in line:
427+ value = line.strip(' ').split(' ', 2)[-1].strip(';\n"')
428+ if value is None:
429+ raise Exception('No endpoint found in DHCP config.')
430+ if ':' in value:
431+ hex_string = ''
432+ for hex_pair in value.split(':'):
433+ if len(hex_pair) == 1:
434+ hex_pair = '0' + hex_pair
435+ hex_string += hex_pair
436+ value = struct.pack('>L', int(hex_string.replace(':', ''), 16))
437+ else:
438+ value = value.encode('utf-8')
439+ endpoint_ip_address = socket.inet_ntoa(value)
440+ LOG.debug('Azure endpoint found at %s', endpoint_ip_address)
441+ return endpoint_ip_address
442+
443+ def register_with_azure_and_fetch_data(self):
444+ self.openssl_manager = OpenSSLManager()
445+ http_client = AzureEndpointHttpClient(self.openssl_manager.certificate)
446+ LOG.info('Registering with Azure...')
447+ attempts = 0
448+ while True:
449+ try:
450+ response = http_client.get(
451+ 'http://{0}/machine/?comp=goalstate'.format(self.endpoint))
452+ except Exception:
453+ if attempts < 10:
454+ time.sleep(attempts + 1)
455+ else:
456+ raise
457+ else:
458+ break
459+ attempts += 1
460+ LOG.debug('Successfully fetched GoalState XML.')
461+ goal_state = GoalState(response.contents, http_client)
462+ public_keys = []
463+ if goal_state.certificates_xml is not None:
464+ LOG.debug('Certificate XML found; parsing out public keys.')
465+ public_keys = self.openssl_manager.parse_certificates(
466+ goal_state.certificates_xml)
467+ data = {
468+ 'instance-id': iid_from_shared_config_content(
469+ goal_state.shared_config_xml),
470+ 'public-keys': public_keys,
471+ }
472+ self._report_ready(goal_state, http_client)
473+ return data
474+
475+ def _report_ready(self, goal_state, http_client):
476+ LOG.debug('Reporting ready to Azure fabric.')
477+ document = self.REPORT_READY_XML_TEMPLATE.format(
478+ incarnation=goal_state.incarnation,
479+ container_id=goal_state.container_id,
480+ instance_id=goal_state.instance_id,
481+ )
482+ http_client.post(
483+ "http://{0}/machine?comp=health".format(self.endpoint),
484+ data=document,
485+ extra_headers={'Content-Type': 'text/xml; charset=utf-8'},
486+ )
487+ LOG.info('Reported ready to Azure fabric.')
488+
489+
490+def get_metadata_from_fabric():
491+ shim = WALinuxAgentShim()
492+ try:
493+ return shim.register_with_azure_and_fetch_data()
494+ finally:
495+ shim.clean_up()
496
497=== modified file 'tests/unittests/test_datasource/test_azure.py'
498--- tests/unittests/test_datasource/test_azure.py 2015-04-15 11:13:17 +0000
499+++ tests/unittests/test_datasource/test_azure.py 2015-05-08 15:53:08 +0000
500@@ -18,7 +18,6 @@
501 import yaml
502 import shutil
503 import tempfile
504-import unittest
505
506
507 def construct_valid_ovf_env(data=None, pubkeys=None, userdata=None):
508@@ -123,6 +122,11 @@
509 mod = DataSourceAzure
510 mod.BUILTIN_DS_CONFIG['data_dir'] = self.waagent_d
511
512+ self.get_metadata_from_fabric = mock.MagicMock(return_value={
513+ 'instance-id': 'i-my-azure-id',
514+ 'public-keys': [],
515+ })
516+
517 self.apply_patches([
518 (mod, 'list_possible_azure_ds_devs', dsdevs),
519 (mod, 'invoke_agent', _invoke_agent),
520@@ -132,7 +136,8 @@
521 (mod, 'perform_hostname_bounce', mock.MagicMock()),
522 (mod, 'get_hostname', mock.MagicMock()),
523 (mod, 'set_hostname', mock.MagicMock()),
524- ])
525+ (mod, 'get_metadata_from_fabric', self.get_metadata_from_fabric),
526+ ])
527
528 dsrc = mod.DataSourceAzureNet(
529 data.get('sys_cfg', {}), distro=None, paths=self.paths)
530@@ -382,6 +387,20 @@
531 self.assertEqual(new_ovfenv,
532 load_file(os.path.join(self.waagent_d, 'ovf-env.xml')))
533
534+ def test_exception_fetching_fabric_data_doesnt_propagate(self):
535+ ds = self._get_ds({'ovfcontent': construct_valid_ovf_env()})
536+ ds.ds_cfg['agent_command'] = '__builtin__'
537+ self.get_metadata_from_fabric.side_effect = Exception
538+ self.assertFalse(ds.get_data())
539+
540+ def test_fabric_data_included_in_metadata(self):
541+ ds = self._get_ds({'ovfcontent': construct_valid_ovf_env()})
542+ ds.ds_cfg['agent_command'] = '__builtin__'
543+ self.get_metadata_from_fabric.return_value = {'test': 'value'}
544+ ret = ds.get_data()
545+ self.assertTrue(ret)
546+ self.assertEqual('value', ds.metadata['test'])
547+
548
549 class TestAzureBounce(TestCase):
550
551@@ -402,6 +421,9 @@
552 self.patches.enter_context(
553 mock.patch.object(DataSourceAzure, 'find_ephemeral_part',
554 mock.MagicMock(return_value=None)))
555+ self.patches.enter_context(
556+ mock.patch.object(DataSourceAzure, 'get_metadata_from_fabric',
557+ mock.MagicMock(return_value={})))
558
559 def setUp(self):
560 super(TestAzureBounce, self).setUp()
561@@ -566,16 +588,3 @@
562 for mypk in mypklist:
563 self.assertIn(mypk, cfg['_pubkeys'])
564
565-
566-class TestReadAzureSharedConfig(unittest.TestCase):
567- def test_valid_content(self):
568- xml = """<?xml version="1.0" encoding="utf-8"?>
569- <SharedConfig>
570- <Deployment name="MY_INSTANCE_ID">
571- <Service name="myservice"/>
572- <ServiceInstance name="INSTANCE_ID.0" guid="{abcd-uuid}" />
573- </Deployment>
574- <Incarnation number="1"/>
575- </SharedConfig>"""
576- ret = DataSourceAzure.iid_from_shared_config_content(xml)
577- self.assertEqual("MY_INSTANCE_ID", ret)
578
579=== added file 'tests/unittests/test_datasource/test_azure_helper.py'
580--- tests/unittests/test_datasource/test_azure_helper.py 1970-01-01 00:00:00 +0000
581+++ tests/unittests/test_datasource/test_azure_helper.py 2015-05-08 15:53:08 +0000
582@@ -0,0 +1,439 @@
583+import os
584+import struct
585+import unittest
586+
587+from cloudinit.sources.helpers import azure as azure_helper
588+from ..helpers import TestCase
589+
590+try:
591+ from unittest import mock
592+except ImportError:
593+ import mock
594+
595+try:
596+ from contextlib import ExitStack
597+except ImportError:
598+ from contextlib2 import ExitStack
599+
600+
601+GOAL_STATE_TEMPLATE = """\
602+<?xml version="1.0" encoding="utf-8"?>
603+<GoalState xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="goalstate10.xsd">
604+ <Version>2012-11-30</Version>
605+ <Incarnation>{incarnation}</Incarnation>
606+ <Machine>
607+ <ExpectedState>Started</ExpectedState>
608+ <StopRolesDeadlineHint>300000</StopRolesDeadlineHint>
609+ <LBProbePorts>
610+ <Port>16001</Port>
611+ </LBProbePorts>
612+ <ExpectHealthReport>FALSE</ExpectHealthReport>
613+ </Machine>
614+ <Container>
615+ <ContainerId>{container_id}</ContainerId>
616+ <RoleInstanceList>
617+ <RoleInstance>
618+ <InstanceId>{instance_id}</InstanceId>
619+ <State>Started</State>
620+ <Configuration>
621+ <HostingEnvironmentConfig>http://100.86.192.70:80/machine/46504ebc-f968-4f23-b9aa-cd2b3e4d470c/68ce47b32ea94952be7b20951c383628.utl%2Dtrusty%2D%2D292258?comp=config&amp;type=hostingEnvironmentConfig&amp;incarnation=1</HostingEnvironmentConfig>
622+ <SharedConfig>{shared_config_url}</SharedConfig>
623+ <ExtensionsConfig>http://100.86.192.70:80/machine/46504ebc-f968-4f23-b9aa-cd2b3e4d470c/68ce47b32ea94952be7b20951c383628.utl%2Dtrusty%2D%2D292258?comp=config&amp;type=extensionsConfig&amp;incarnation=1</ExtensionsConfig>
624+ <FullConfig>http://100.86.192.70:80/machine/46504ebc-f968-4f23-b9aa-cd2b3e4d470c/68ce47b32ea94952be7b20951c383628.utl%2Dtrusty%2D%2D292258?comp=config&amp;type=fullConfig&amp;incarnation=1</FullConfig>
625+ <Certificates>{certificates_url}</Certificates>
626+ <ConfigName>68ce47b32ea94952be7b20951c383628.0.68ce47b32ea94952be7b20951c383628.0.utl-trusty--292258.1.xml</ConfigName>
627+ </Configuration>
628+ </RoleInstance>
629+ </RoleInstanceList>
630+ </Container>
631+</GoalState>
632+"""
633+
634+
635+class TestReadAzureSharedConfig(unittest.TestCase):
636+
637+ def test_valid_content(self):
638+ xml = """<?xml version="1.0" encoding="utf-8"?>
639+ <SharedConfig>
640+ <Deployment name="MY_INSTANCE_ID">
641+ <Service name="myservice"/>
642+ <ServiceInstance name="INSTANCE_ID.0" guid="{abcd-uuid}" />
643+ </Deployment>
644+ <Incarnation number="1"/>
645+ </SharedConfig>"""
646+ ret = azure_helper.iid_from_shared_config_content(xml)
647+ self.assertEqual("MY_INSTANCE_ID", ret)
648+
649+
650+class TestFindEndpoint(TestCase):
651+
652+ def setUp(self):
653+ super(TestFindEndpoint, self).setUp()
654+ patches = ExitStack()
655+ self.addCleanup(patches.close)
656+
657+ self.load_file = patches.enter_context(
658+ mock.patch.object(azure_helper.util, 'load_file'))
659+
660+ def test_missing_file(self):
661+ self.load_file.side_effect = IOError
662+ self.assertRaises(IOError,
663+ azure_helper.WALinuxAgentShim.find_endpoint)
664+
665+ def test_missing_special_azure_line(self):
666+ self.load_file.return_value = ''
667+ self.assertRaises(Exception,
668+ azure_helper.WALinuxAgentShim.find_endpoint)
669+
670+ def _build_lease_content(self, ip_address, use_hex=True):
671+ ip_address_repr = ':'.join(
672+ [hex(int(part)).replace('0x', '')
673+ for part in ip_address.split('.')])
674+ if not use_hex:
675+ ip_address_repr = struct.pack(
676+ '>L', int(ip_address_repr.replace(':', ''), 16))
677+ ip_address_repr = '"{0}"'.format(ip_address_repr.decode('utf-8'))
678+ return '\n'.join([
679+ 'lease {',
680+ ' interface "eth0";',
681+ ' option unknown-245 {0};'.format(ip_address_repr),
682+ '}'])
683+
684+ def test_hex_string(self):
685+ ip_address = '98.76.54.32'
686+ file_content = self._build_lease_content(ip_address)
687+ self.load_file.return_value = file_content
688+ self.assertEqual(ip_address,
689+ azure_helper.WALinuxAgentShim.find_endpoint())
690+
691+ def test_hex_string_with_single_character_part(self):
692+ ip_address = '4.3.2.1'
693+ file_content = self._build_lease_content(ip_address)
694+ self.load_file.return_value = file_content
695+ self.assertEqual(ip_address,
696+ azure_helper.WALinuxAgentShim.find_endpoint())
697+
698+ def test_packed_string(self):
699+ ip_address = '98.76.54.32'
700+ file_content = self._build_lease_content(ip_address, use_hex=False)
701+ self.load_file.return_value = file_content
702+ self.assertEqual(ip_address,
703+ azure_helper.WALinuxAgentShim.find_endpoint())
704+
705+ def test_latest_lease_used(self):
706+ ip_addresses = ['4.3.2.1', '98.76.54.32']
707+ file_content = '\n'.join([self._build_lease_content(ip_address)
708+ for ip_address in ip_addresses])
709+ self.load_file.return_value = file_content
710+ self.assertEqual(ip_addresses[-1],
711+ azure_helper.WALinuxAgentShim.find_endpoint())
712+
713+
714+class TestGoalStateParsing(TestCase):
715+
716+ default_parameters = {
717+ 'incarnation': 1,
718+ 'container_id': 'MyContainerId',
719+ 'instance_id': 'MyInstanceId',
720+ 'shared_config_url': 'MySharedConfigUrl',
721+ 'certificates_url': 'MyCertificatesUrl',
722+ }
723+
724+ def _get_goal_state(self, http_client=None, **kwargs):
725+ if http_client is None:
726+ http_client = mock.MagicMock()
727+ parameters = self.default_parameters.copy()
728+ parameters.update(kwargs)
729+ xml = GOAL_STATE_TEMPLATE.format(**parameters)
730+ if parameters['certificates_url'] is None:
731+ new_xml_lines = []
732+ for line in xml.splitlines():
733+ if 'Certificates' in line:
734+ continue
735+ new_xml_lines.append(line)
736+ xml = '\n'.join(new_xml_lines)
737+ return azure_helper.GoalState(xml, http_client)
738+
739+ def test_incarnation_parsed_correctly(self):
740+ incarnation = '123'
741+ goal_state = self._get_goal_state(incarnation=incarnation)
742+ self.assertEqual(incarnation, goal_state.incarnation)
743+
744+ def test_container_id_parsed_correctly(self):
745+ container_id = 'TestContainerId'
746+ goal_state = self._get_goal_state(container_id=container_id)
747+ self.assertEqual(container_id, goal_state.container_id)
748+
749+ def test_instance_id_parsed_correctly(self):
750+ instance_id = 'TestInstanceId'
751+ goal_state = self._get_goal_state(instance_id=instance_id)
752+ self.assertEqual(instance_id, goal_state.instance_id)
753+
754+ def test_shared_config_xml_parsed_and_fetched_correctly(self):
755+ http_client = mock.MagicMock()
756+ shared_config_url = 'TestSharedConfigUrl'
757+ goal_state = self._get_goal_state(
758+ http_client=http_client, shared_config_url=shared_config_url)
759+ shared_config_xml = goal_state.shared_config_xml
760+ self.assertEqual(1, http_client.get.call_count)
761+ self.assertEqual(shared_config_url, http_client.get.call_args[0][0])
762+ self.assertEqual(http_client.get.return_value.contents,
763+ shared_config_xml)
764+
765+ def test_certificates_xml_parsed_and_fetched_correctly(self):
766+ http_client = mock.MagicMock()
767+ certificates_url = 'TestSharedConfigUrl'
768+ goal_state = self._get_goal_state(
769+ http_client=http_client, certificates_url=certificates_url)
770+ certificates_xml = goal_state.certificates_xml
771+ self.assertEqual(1, http_client.get.call_count)
772+ self.assertEqual(certificates_url, http_client.get.call_args[0][0])
773+ self.assertTrue(http_client.get.call_args[1].get('secure', False))
774+ self.assertEqual(http_client.get.return_value.contents,
775+ certificates_xml)
776+
777+ def test_missing_certificates_skips_http_get(self):
778+ http_client = mock.MagicMock()
779+ goal_state = self._get_goal_state(
780+ http_client=http_client, certificates_url=None)
781+ certificates_xml = goal_state.certificates_xml
782+ self.assertEqual(0, http_client.get.call_count)
783+ self.assertIsNone(certificates_xml)
784+
785+
786+class TestAzureEndpointHttpClient(TestCase):
787+
788+ regular_headers = {
789+ 'x-ms-agent-name': 'WALinuxAgent',
790+ 'x-ms-version': '2012-11-30',
791+ }
792+
793+ def setUp(self):
794+ super(TestAzureEndpointHttpClient, self).setUp()
795+ patches = ExitStack()
796+ self.addCleanup(patches.close)
797+
798+ self.read_file_or_url = patches.enter_context(
799+ mock.patch.object(azure_helper.util, 'read_file_or_url'))
800+
801+ def test_non_secure_get(self):
802+ client = azure_helper.AzureEndpointHttpClient(mock.MagicMock())
803+ url = 'MyTestUrl'
804+ response = client.get(url, secure=False)
805+ self.assertEqual(1, self.read_file_or_url.call_count)
806+ self.assertEqual(self.read_file_or_url.return_value, response)
807+ self.assertEqual(mock.call(url, headers=self.regular_headers),
808+ self.read_file_or_url.call_args)
809+
810+ def test_secure_get(self):
811+ url = 'MyTestUrl'
812+ certificate = mock.MagicMock()
813+ expected_headers = self.regular_headers.copy()
814+ expected_headers.update({
815+ "x-ms-cipher-name": "DES_EDE3_CBC",
816+ "x-ms-guest-agent-public-x509-cert": certificate,
817+ })
818+ client = azure_helper.AzureEndpointHttpClient(certificate)
819+ response = client.get(url, secure=True)
820+ self.assertEqual(1, self.read_file_or_url.call_count)
821+ self.assertEqual(self.read_file_or_url.return_value, response)
822+ self.assertEqual(mock.call(url, headers=expected_headers),
823+ self.read_file_or_url.call_args)
824+
825+ def test_post(self):
826+ data = mock.MagicMock()
827+ url = 'MyTestUrl'
828+ client = azure_helper.AzureEndpointHttpClient(mock.MagicMock())
829+ response = client.post(url, data=data)
830+ self.assertEqual(1, self.read_file_or_url.call_count)
831+ self.assertEqual(self.read_file_or_url.return_value, response)
832+ self.assertEqual(
833+ mock.call(url, data=data, headers=self.regular_headers),
834+ self.read_file_or_url.call_args)
835+
836+ def test_post_with_extra_headers(self):
837+ url = 'MyTestUrl'
838+ client = azure_helper.AzureEndpointHttpClient(mock.MagicMock())
839+ extra_headers = {'test': 'header'}
840+ client.post(url, extra_headers=extra_headers)
841+ self.assertEqual(1, self.read_file_or_url.call_count)
842+ expected_headers = self.regular_headers.copy()
843+ expected_headers.update(extra_headers)
844+ self.assertEqual(
845+ mock.call(mock.ANY, data=mock.ANY, headers=expected_headers),
846+ self.read_file_or_url.call_args)
847+
848+
849+class TestOpenSSLManager(TestCase):
850+
851+ def setUp(self):
852+ super(TestOpenSSLManager, self).setUp()
853+ patches = ExitStack()
854+ self.addCleanup(patches.close)
855+
856+ self.subp = patches.enter_context(
857+ mock.patch.object(azure_helper.util, 'subp'))
858+ try:
859+ self.open = patches.enter_context(
860+ mock.patch('__builtin__.open'))
861+ except ImportError:
862+ self.open = patches.enter_context(
863+ mock.patch('builtins.open'))
864+
865+ @mock.patch.object(azure_helper, 'cd', mock.MagicMock())
866+ @mock.patch.object(azure_helper.tempfile, 'mkdtemp')
867+ def test_openssl_manager_creates_a_tmpdir(self, mkdtemp):
868+ manager = azure_helper.OpenSSLManager()
869+ self.assertEqual(mkdtemp.return_value, manager.tmpdir)
870+
871+ def test_generate_certificate_uses_tmpdir(self):
872+ subp_directory = {}
873+
874+ def capture_directory(*args, **kwargs):
875+ subp_directory['path'] = os.getcwd()
876+
877+ self.subp.side_effect = capture_directory
878+ manager = azure_helper.OpenSSLManager()
879+ self.assertEqual(manager.tmpdir, subp_directory['path'])
880+
881+ @mock.patch.object(azure_helper, 'cd', mock.MagicMock())
882+ @mock.patch.object(azure_helper.tempfile, 'mkdtemp', mock.MagicMock())
883+ @mock.patch.object(azure_helper.util, 'del_dir')
884+ def test_clean_up(self, del_dir):
885+ manager = azure_helper.OpenSSLManager()
886+ manager.clean_up()
887+ self.assertEqual([mock.call(manager.tmpdir)], del_dir.call_args_list)
888+
889+
890+class TestWALinuxAgentShim(TestCase):
891+
892+ def setUp(self):
893+ super(TestWALinuxAgentShim, self).setUp()
894+ patches = ExitStack()
895+ self.addCleanup(patches.close)
896+
897+ self.AzureEndpointHttpClient = patches.enter_context(
898+ mock.patch.object(azure_helper, 'AzureEndpointHttpClient'))
899+ self.find_endpoint = patches.enter_context(
900+ mock.patch.object(
901+ azure_helper.WALinuxAgentShim, 'find_endpoint'))
902+ self.GoalState = patches.enter_context(
903+ mock.patch.object(azure_helper, 'GoalState'))
904+ self.iid_from_shared_config_content = patches.enter_context(
905+ mock.patch.object(azure_helper, 'iid_from_shared_config_content'))
906+ self.OpenSSLManager = patches.enter_context(
907+ mock.patch.object(azure_helper, 'OpenSSLManager'))
908+ patches.enter_context(
909+ mock.patch.object(azure_helper.time, 'sleep', mock.MagicMock()))
910+
911+ def test_http_client_uses_certificate(self):
912+ shim = azure_helper.WALinuxAgentShim()
913+ shim.register_with_azure_and_fetch_data()
914+ self.assertEqual(
915+ [mock.call(self.OpenSSLManager.return_value.certificate)],
916+ self.AzureEndpointHttpClient.call_args_list)
917+
918+ def test_correct_url_used_for_goalstate(self):
919+ self.find_endpoint.return_value = 'test_endpoint'
920+ shim = azure_helper.WALinuxAgentShim()
921+ shim.register_with_azure_and_fetch_data()
922+ get = self.AzureEndpointHttpClient.return_value.get
923+ self.assertEqual(
924+ [mock.call('http://test_endpoint/machine/?comp=goalstate')],
925+ get.call_args_list)
926+ self.assertEqual(
927+ [mock.call(get.return_value.contents,
928+ self.AzureEndpointHttpClient.return_value)],
929+ self.GoalState.call_args_list)
930+
931+ def test_certificates_used_to_determine_public_keys(self):
932+ shim = azure_helper.WALinuxAgentShim()
933+ data = shim.register_with_azure_and_fetch_data()
934+ self.assertEqual(
935+ [mock.call(self.GoalState.return_value.certificates_xml)],
936+ self.OpenSSLManager.return_value.parse_certificates.call_args_list)
937+ self.assertEqual(
938+ self.OpenSSLManager.return_value.parse_certificates.return_value,
939+ data['public-keys'])
940+
941+ def test_absent_certificates_produces_empty_public_keys(self):
942+ self.GoalState.return_value.certificates_xml = None
943+ shim = azure_helper.WALinuxAgentShim()
944+ data = shim.register_with_azure_and_fetch_data()
945+ self.assertEqual([], data['public-keys'])
946+
947+ def test_instance_id_returned_in_data(self):
948+ shim = azure_helper.WALinuxAgentShim()
949+ data = shim.register_with_azure_and_fetch_data()
950+ self.assertEqual(
951+ [mock.call(self.GoalState.return_value.shared_config_xml)],
952+ self.iid_from_shared_config_content.call_args_list)
953+ self.assertEqual(self.iid_from_shared_config_content.return_value,
954+ data['instance-id'])
955+
956+ def test_correct_url_used_for_report_ready(self):
957+ self.find_endpoint.return_value = 'test_endpoint'
958+ shim = azure_helper.WALinuxAgentShim()
959+ shim.register_with_azure_and_fetch_data()
960+ expected_url = 'http://test_endpoint/machine?comp=health'
961+ self.assertEqual(
962+ [mock.call(expected_url, data=mock.ANY, extra_headers=mock.ANY)],
963+ self.AzureEndpointHttpClient.return_value.post.call_args_list)
964+
965+ def test_goal_state_values_used_for_report_ready(self):
966+ self.GoalState.return_value.incarnation = 'TestIncarnation'
967+ self.GoalState.return_value.container_id = 'TestContainerId'
968+ self.GoalState.return_value.instance_id = 'TestInstanceId'
969+ shim = azure_helper.WALinuxAgentShim()
970+ shim.register_with_azure_and_fetch_data()
971+ posted_document = (
972+ self.AzureEndpointHttpClient.return_value.post.call_args[1]['data']
973+ )
974+ self.assertIn('TestIncarnation', posted_document)
975+ self.assertIn('TestContainerId', posted_document)
976+ self.assertIn('TestInstanceId', posted_document)
977+
978+ def test_clean_up_can_be_called_at_any_time(self):
979+ shim = azure_helper.WALinuxAgentShim()
980+ shim.clean_up()
981+
982+ def test_clean_up_will_clean_up_openssl_manager_if_instantiated(self):
983+ shim = azure_helper.WALinuxAgentShim()
984+ shim.register_with_azure_and_fetch_data()
985+ shim.clean_up()
986+ self.assertEqual(
987+ 1, self.OpenSSLManager.return_value.clean_up.call_count)
988+
989+ def test_failure_to_fetch_goalstate_bubbles_up(self):
990+ class SentinelException(Exception):
991+ pass
992+ self.AzureEndpointHttpClient.return_value.get.side_effect = (
993+ SentinelException)
994+ shim = azure_helper.WALinuxAgentShim()
995+ self.assertRaises(SentinelException,
996+ shim.register_with_azure_and_fetch_data)
997+
998+
999+class TestGetMetadataFromFabric(TestCase):
1000+
1001+ @mock.patch.object(azure_helper, 'WALinuxAgentShim')
1002+ def test_data_from_shim_returned(self, shim):
1003+ ret = azure_helper.get_metadata_from_fabric()
1004+ self.assertEqual(
1005+ shim.return_value.register_with_azure_and_fetch_data.return_value,
1006+ ret)
1007+
1008+ @mock.patch.object(azure_helper, 'WALinuxAgentShim')
1009+ def test_success_calls_clean_up(self, shim):
1010+ azure_helper.get_metadata_from_fabric()
1011+ self.assertEqual(1, shim.return_value.clean_up.call_count)
1012+
1013+ @mock.patch.object(azure_helper, 'WALinuxAgentShim')
1014+ def test_failure_in_registration_calls_clean_up(self, shim):
1015+ class SentinelException(Exception):
1016+ pass
1017+ shim.return_value.register_with_azure_and_fetch_data.side_effect = (
1018+ SentinelException)
1019+ self.assertRaises(SentinelException,
1020+ azure_helper.get_metadata_from_fabric)
1021+ self.assertEqual(1, shim.return_value.clean_up.call_count)