diff -Nru cloud-init-0.7.6~bzr1022/debian/changelog cloud-init-0.7.6~bzr1022/debian/changelog --- cloud-init-0.7.6~bzr1022/debian/changelog 2015-03-11 16:57:57.000000000 +0000 +++ cloud-init-0.7.6~bzr1022/debian/changelog 2015-05-25 15:43:30.000000000 +0000 @@ -1,3 +1,12 @@ +cloud-init (0.7.6~bzr1022-0ubuntu4) utopic; urgency=medium + + * d/patches/lp-1375252-1458052-Azure-hostname_password.patch: + Backport of 15.10 Azure Datasource to fix various issues: + - Azure Datasource writes user password in plain text (LP: #1458052). + - Hostname not preserved across Azure reboots (LP: #1375252). + + -- Ben Howard Mon, 25 May 2015 08:53:25 -0600 + cloud-init (0.7.6~bzr1022-0ubuntu3) utopic; urgency=medium * Backport support for fetching passwords in CloudStack (LP: #1422388). diff -Nru cloud-init-0.7.6~bzr1022/debian/patches/lp-1375252-1458052-Azure-hostname_password.patch cloud-init-0.7.6~bzr1022/debian/patches/lp-1375252-1458052-Azure-hostname_password.patch --- cloud-init-0.7.6~bzr1022/debian/patches/lp-1375252-1458052-Azure-hostname_password.patch 1970-01-01 00:00:00.000000000 +0000 +++ cloud-init-0.7.6~bzr1022/debian/patches/lp-1375252-1458052-Azure-hostname_password.patch 2015-05-25 15:26:30.000000000 +0000 @@ -0,0 +1,845 @@ +Description: Backport the 15.10 Azure Datasource + Backport of 15.10 Azure Datasource to fix various issues: + - Azure Datasource writes user password in plain text (LP: #1458052). + - Hostname not preserved across Azure reboots (LP: #1375252). +Author: Ben Howard +Bug-Ubuntu: https://bugs.launchpad.net/bugs/1375252 +Bug-Ubuntu: https://bugs.launchpad.net/bugs/1458052 +Forwarded: yes + +--- cloud-init-0.7.6~bzr1022.orig/cloudinit/sources/DataSourceAzure.py ++++ cloud-init-0.7.6~bzr1022/cloudinit/sources/DataSourceAzure.py +@@ -17,17 +17,22 @@ + # along with this program. If not, see . + + import base64 ++import contextlib + import crypt + import fnmatch + import os + import os.path + import time ++import xml.etree.ElementTree as ET ++ + from xml.dom import minidom + + from cloudinit import log as logging + from cloudinit.settings import PER_ALWAYS + from cloudinit import sources + from cloudinit import util ++from cloudinit.sources.helpers.azure import ( ++ get_metadata_from_fabric, iid_from_shared_config_content) + + LOG = logging.getLogger(__name__) + +@@ -65,6 +70,40 @@ BUILTIN_CLOUD_CONFIG = { + DS_CFG_PATH = ['datasource', DS_NAME] + DEF_EPHEMERAL_LABEL = 'Temporary Storage' + ++# The redacted password fails to meet password complexity requirements ++# so we can safely use this to mask/redact the password in the ovf-env.xml ++DEF_PASSWD_REDACTION = 'REDACTED' ++ ++ ++def get_hostname(hostname_command='hostname'): ++ return util.subp(hostname_command, capture=True)[0].strip() ++ ++ ++def set_hostname(hostname, hostname_command='hostname'): ++ util.subp([hostname_command, hostname]) ++ ++ ++@contextlib.contextmanager ++def temporary_hostname(temp_hostname, cfg, hostname_command='hostname'): ++ """ ++ Set a temporary hostname, restoring the previous hostname on exit. ++ ++ Will have the value of the previous hostname when used as a context ++ manager, or None if the hostname was not changed. ++ """ ++ policy = cfg['hostname_bounce']['policy'] ++ previous_hostname = get_hostname(hostname_command) ++ if (not util.is_true(cfg.get('set_hostname')) ++ or util.is_false(policy) ++ or (previous_hostname == temp_hostname and policy != 'force')): ++ yield None ++ return ++ set_hostname(temp_hostname, hostname_command) ++ try: ++ yield previous_hostname ++ finally: ++ set_hostname(previous_hostname, hostname_command) ++ + + class DataSourceAzureNet(sources.DataSource): + def __init__(self, sys_cfg, distro, paths): +@@ -80,6 +119,56 @@ class DataSourceAzureNet(sources.DataSou + root = sources.DataSource.__str__(self) + return "%s [seed=%s]" % (root, self.seed) + ++ def get_metadata_from_agent(self): ++ temp_hostname = self.metadata.get('local-hostname') ++ hostname_command = self.ds_cfg['hostname_bounce']['hostname_command'] ++ with temporary_hostname(temp_hostname, self.ds_cfg, ++ hostname_command=hostname_command) \ ++ as previous_hostname: ++ if (previous_hostname is not None ++ and util.is_true(self.ds_cfg.get('set_hostname'))): ++ cfg = self.ds_cfg['hostname_bounce'] ++ try: ++ perform_hostname_bounce(hostname=temp_hostname, ++ cfg=cfg, ++ prev_hostname=previous_hostname) ++ except Exception as e: ++ LOG.warn("Failed publishing hostname: %s", e) ++ util.logexc(LOG, "handling set_hostname failed") ++ ++ try: ++ invoke_agent(self.ds_cfg['agent_command']) ++ except util.ProcessExecutionError: ++ # claim the datasource even if the command failed ++ util.logexc(LOG, "agent command '%s' failed.", ++ self.ds_cfg['agent_command']) ++ ++ ddir = self.ds_cfg['data_dir'] ++ shcfgxml = os.path.join(ddir, "SharedConfig.xml") ++ wait_for = [shcfgxml] ++ ++ fp_files = [] ++ for pk in self.cfg.get('_pubkeys', []): ++ bname = str(pk['fingerprint'] + ".crt") ++ fp_files += [os.path.join(ddir, bname)] ++ ++ missing = util.log_time(logfunc=LOG.debug, msg="waiting for files", ++ func=wait_for_files, ++ args=(wait_for + fp_files,)) ++ if len(missing): ++ LOG.warn("Did not find files, but going on: %s", missing) ++ ++ metadata = {} ++ if shcfgxml in missing: ++ LOG.warn("SharedConfig.xml missing, using static instance-id") ++ else: ++ try: ++ metadata['instance-id'] = iid_from_shared_config(shcfgxml) ++ except ValueError as e: ++ LOG.warn("failed to get instance id in %s: %s", shcfgxml, e) ++ metadata['public-keys'] = pubkeys_from_crt_files(fp_files) ++ return metadata ++ + def get_data(self): + # azure removes/ejects the cdrom containing the ovf-env.xml + # file on reboot. So, in order to successfully reboot we +@@ -131,8 +220,6 @@ class DataSourceAzureNet(sources.DataSou + # now update ds_cfg to reflect contents pass in config + user_ds_cfg = util.get_cfg_by_path(self.cfg, DS_CFG_PATH, {}) + self.ds_cfg = util.mergemanydict([user_ds_cfg, self.ds_cfg]) +- mycfg = self.ds_cfg +- ddir = mycfg['data_dir'] + + if found != ddir: + cached_ovfenv = util.load_file( +@@ -151,48 +238,20 @@ class DataSourceAzureNet(sources.DataSou + + # walinux agent writes files world readable, but expects + # the directory to be protected. +- write_files(ddir, files, dirmode=0700) +- +- # handle the hostname 'publishing' +- try: +- handle_set_hostname(mycfg.get('set_hostname'), +- self.metadata.get('local-hostname'), +- mycfg['hostname_bounce']) +- except Exception as e: +- LOG.warn("Failed publishing hostname: %s", e) +- util.logexc(LOG, "handling set_hostname failed") ++ write_files(ddir, files, dirmode=0o700) + +- try: +- invoke_agent(mycfg['agent_command']) +- except util.ProcessExecutionError: +- # claim the datasource even if the command failed +- util.logexc(LOG, "agent command '%s' failed.", +- mycfg['agent_command']) +- +- shcfgxml = os.path.join(ddir, "SharedConfig.xml") +- wait_for = [shcfgxml] +- +- fp_files = [] +- for pk in self.cfg.get('_pubkeys', []): +- bname = str(pk['fingerprint'] + ".crt") +- fp_files += [os.path.join(ddir, bname)] +- +- missing = util.log_time(logfunc=LOG.debug, msg="waiting for files", +- func=wait_for_files, +- args=(wait_for + fp_files,)) +- if len(missing): +- LOG.warn("Did not find files, but going on: %s", missing) +- +- if shcfgxml in missing: +- LOG.warn("SharedConfig.xml missing, using static instance-id") ++ if self.ds_cfg['agent_command'] == '__builtin__': ++ metadata_func = get_metadata_from_fabric + else: +- try: +- self.metadata['instance-id'] = iid_from_shared_config(shcfgxml) +- except ValueError as e: +- LOG.warn("failed to get instance id in %s: %s", shcfgxml, e) ++ metadata_func = self.get_metadata_from_agent ++ try: ++ fabric_data = metadata_func() ++ except Exception as exc: ++ LOG.info("Error communicating with Azure fabric; assume we aren't" ++ " on Azure.", exc_info=True) ++ return False + +- pubkeys = pubkeys_from_crt_files(fp_files) +- self.metadata['public-keys'] = pubkeys ++ self.metadata.update(fabric_data) + + found_ephemeral = find_ephemeral_disk() + if found_ephemeral: +@@ -298,39 +357,15 @@ def support_new_ephemeral(cfg): + return mod_list + + +-def handle_set_hostname(enabled, hostname, cfg): +- if not util.is_true(enabled): +- return +- +- if not hostname: +- LOG.warn("set_hostname was true but no local-hostname") +- return +- +- apply_hostname_bounce(hostname=hostname, policy=cfg['policy'], +- interface=cfg['interface'], +- command=cfg['command'], +- hostname_command=cfg['hostname_command']) +- +- +-def apply_hostname_bounce(hostname, policy, interface, command, +- hostname_command="hostname"): ++def perform_hostname_bounce(hostname, cfg, prev_hostname): + # set the hostname to 'hostname' if it is not already set to that. + # then, if policy is not off, bounce the interface using command +- prev_hostname = util.subp(hostname_command, capture=True)[0].strip() +- +- util.subp([hostname_command, hostname]) +- +- msg = ("phostname=%s hostname=%s policy=%s interface=%s" % +- (prev_hostname, hostname, policy, interface)) +- +- if util.is_false(policy): +- LOG.debug("pubhname: policy false, skipping [%s]", msg) +- return +- +- if prev_hostname == hostname and policy != "force": +- LOG.debug("pubhname: no change, policy != force. skipping. [%s]", msg) +- return ++ command = cfg['command'] ++ interface = cfg['interface'] ++ policy = cfg['policy'] + ++ msg = ("hostname=%s policy=%s interface=%s" % ++ (hostname, policy, interface)) + env = os.environ.copy() + env['interface'] = interface + env['hostname'] = hostname +@@ -343,15 +378,16 @@ def apply_hostname_bounce(hostname, poli + shell = not isinstance(command, (list, tuple)) + # capture=False, see comments in bug 1202758 and bug 1206164. + util.log_time(logfunc=LOG.debug, msg="publishing hostname", +- get_uptime=True, func=util.subp, +- kwargs={'args': command, 'shell': shell, 'capture': False, +- 'env': env}) ++ get_uptime=True, func=util.subp, ++ kwargs={'args': command, 'shell': shell, 'capture': False, ++ 'env': env}) + + +-def crtfile_to_pubkey(fname): ++def crtfile_to_pubkey(fname, data=None): + pipeline = ('openssl x509 -noout -pubkey < "$0" |' + 'ssh-keygen -i -m PKCS8 -f /dev/stdin') +- (out, _err) = util.subp(['sh', '-c', pipeline, fname], capture=True) ++ (out, _err) = util.subp(['sh', '-c', pipeline, fname], ++ capture=True, data=data) + return out.rstrip() + + +@@ -383,14 +419,30 @@ def wait_for_files(flist, maxwait=60, na + + + def write_files(datadir, files, dirmode=None): ++ ++ def _redact_password(cnt, fname): ++ """Azure provides the UserPassword in plain text. So we redact it""" ++ try: ++ root = ET.fromstring(cnt) ++ for elem in root.iter(): ++ if ('UserPassword' in elem.tag and ++ elem.text != DEF_PASSWD_REDACTION): ++ elem.text = DEF_PASSWD_REDACTION ++ return ET.tostring(root) ++ except Exception as e: ++ LOG.critical("failed to redact userpassword in {}".format(fname)) ++ return cnt ++ + if not datadir: + return + if not files: + files = {} + util.ensure_dir(datadir, dirmode) + for (name, content) in files.items(): +- util.write_file(filename=os.path.join(datadir, name), +- content=content, mode=0600) ++ fname = os.path.join(datadir, name) ++ if 'ovf-env.xml' in name: ++ content = _redact_password(content, fname) ++ util.write_file(filename=fname, content=content, mode=0o600) + + + def invoke_agent(cmd): +@@ -461,20 +513,6 @@ def load_azure_ovf_pubkeys(sshnode): + return found + + +-def single_node_at_path(node, pathlist): +- curnode = node +- for tok in pathlist: +- results = find_child(curnode, lambda n: n.localName == tok) +- if len(results) == 0: +- raise ValueError("missing %s token in %s" % (tok, str(pathlist))) +- if len(results) > 1: +- raise ValueError("found %s nodes of type %s looking for %s" % +- (len(results), tok, str(pathlist))) +- curnode = results[0] +- +- return curnode +- +- + def read_azure_ovf(contents): + try: + dom = minidom.parseString(contents) +@@ -559,7 +597,7 @@ def read_azure_ovf(contents): + defuser = {} + if username: + defuser['name'] = username +- if password: ++ if password and DEF_PASSWD_REDACTION != password: + defuser['passwd'] = encrypt_pass(password) + defuser['lock_passwd'] = False + +@@ -592,7 +630,7 @@ def load_azure_ds_dir(source_dir): + if not os.path.isfile(ovf_file): + raise NonAzureDataSource("No ovf-env file found") + +- with open(ovf_file, "r") as fp: ++ with open(ovf_file, "rb") as fp: + contents = fp.read() + + md, ud, cfg = read_azure_ovf(contents) +@@ -605,19 +643,6 @@ def iid_from_shared_config(path): + return iid_from_shared_config_content(content) + + +-def iid_from_shared_config_content(content): +- """ +- find INSTANCE_ID in: +- +- +- +- +- """ +- dom = minidom.parseString(content) +- depnode = single_node_at_path(dom, ["SharedConfig", "Deployment"]) +- return depnode.attributes.get('name').value +- +- + class BrokenAzureDataSource(Exception): + pass + +--- /dev/null ++++ cloud-init-0.7.6~bzr1022/cloudinit/sources/helpers/azure.py +@@ -0,0 +1,293 @@ ++import logging ++import os ++import re ++import socket ++import struct ++import tempfile ++import time ++from contextlib import contextmanager ++from xml.etree import ElementTree ++ ++from cloudinit import util ++ ++ ++LOG = logging.getLogger(__name__) ++ ++ ++@contextmanager ++def cd(newdir): ++ prevdir = os.getcwd() ++ os.chdir(os.path.expanduser(newdir)) ++ try: ++ yield ++ finally: ++ os.chdir(prevdir) ++ ++ ++class AzureEndpointHttpClient(object): ++ ++ headers = { ++ 'x-ms-agent-name': 'WALinuxAgent', ++ 'x-ms-version': '2012-11-30', ++ } ++ ++ def __init__(self, certificate): ++ self.extra_secure_headers = { ++ "x-ms-cipher-name": "DES_EDE3_CBC", ++ "x-ms-guest-agent-public-x509-cert": certificate, ++ } ++ ++ def get(self, url, secure=False): ++ headers = self.headers ++ if secure: ++ headers = self.headers.copy() ++ headers.update(self.extra_secure_headers) ++ return util.read_file_or_url(url, headers=headers) ++ ++ def post(self, url, data=None, extra_headers=None): ++ headers = self.headers ++ if extra_headers is not None: ++ headers = self.headers.copy() ++ headers.update(extra_headers) ++ return util.read_file_or_url(url, data=data, headers=headers) ++ ++ ++class GoalState(object): ++ ++ def __init__(self, xml, http_client): ++ self.http_client = http_client ++ self.root = ElementTree.fromstring(xml) ++ self._certificates_xml = None ++ ++ def _text_from_xpath(self, xpath): ++ element = self.root.find(xpath) ++ if element is not None: ++ return element.text ++ return None ++ ++ @property ++ def container_id(self): ++ return self._text_from_xpath('./Container/ContainerId') ++ ++ @property ++ def incarnation(self): ++ return self._text_from_xpath('./Incarnation') ++ ++ @property ++ def instance_id(self): ++ return self._text_from_xpath( ++ './Container/RoleInstanceList/RoleInstance/InstanceId') ++ ++ @property ++ def shared_config_xml(self): ++ url = self._text_from_xpath('./Container/RoleInstanceList/RoleInstance' ++ '/Configuration/SharedConfig') ++ return self.http_client.get(url).contents ++ ++ @property ++ def certificates_xml(self): ++ if self._certificates_xml is None: ++ url = self._text_from_xpath( ++ './Container/RoleInstanceList/RoleInstance' ++ '/Configuration/Certificates') ++ if url is not None: ++ self._certificates_xml = self.http_client.get( ++ url, secure=True).contents ++ return self._certificates_xml ++ ++ ++class OpenSSLManager(object): ++ ++ certificate_names = { ++ 'private_key': 'TransportPrivate.pem', ++ 'certificate': 'TransportCert.pem', ++ } ++ ++ def __init__(self): ++ self.tmpdir = tempfile.mkdtemp() ++ self.certificate = None ++ self.generate_certificate() ++ ++ def clean_up(self): ++ util.del_dir(self.tmpdir) ++ ++ def generate_certificate(self): ++ LOG.debug('Generating certificate for communication with fabric...') ++ if self.certificate is not None: ++ LOG.debug('Certificate already generated.') ++ return ++ with cd(self.tmpdir): ++ util.subp([ ++ 'openssl', 'req', '-x509', '-nodes', '-subj', ++ '/CN=LinuxTransport', '-days', '32768', '-newkey', 'rsa:2048', ++ '-keyout', self.certificate_names['private_key'], ++ '-out', self.certificate_names['certificate'], ++ ]) ++ certificate = '' ++ for line in open(self.certificate_names['certificate']): ++ if "CERTIFICATE" not in line: ++ certificate += line.rstrip() ++ self.certificate = certificate ++ LOG.debug('New certificate generated.') ++ ++ def parse_certificates(self, certificates_xml): ++ tag = ElementTree.fromstring(certificates_xml).find( ++ './/Data') ++ certificates_content = tag.text ++ lines = [ ++ b'MIME-Version: 1.0', ++ b'Content-Disposition: attachment; filename="Certificates.p7m"', ++ b'Content-Type: application/x-pkcs7-mime; name="Certificates.p7m"', ++ b'Content-Transfer-Encoding: base64', ++ b'', ++ certificates_content.encode('utf-8'), ++ ] ++ with cd(self.tmpdir): ++ with open('Certificates.p7m', 'wb') as f: ++ f.write(b'\n'.join(lines)) ++ out, _ = util.subp( ++ 'openssl cms -decrypt -in Certificates.p7m -inkey' ++ ' {private_key} -recip {certificate} | openssl pkcs12 -nodes' ++ ' -password pass:'.format(**self.certificate_names), ++ shell=True) ++ private_keys, certificates = [], [] ++ current = [] ++ for line in out.splitlines(): ++ current.append(line) ++ if re.match(r'[-]+END .*?KEY[-]+$', line): ++ private_keys.append('\n'.join(current)) ++ current = [] ++ elif re.match(r'[-]+END .*?CERTIFICATE[-]+$', line): ++ certificates.append('\n'.join(current)) ++ current = [] ++ keys = [] ++ for certificate in certificates: ++ with cd(self.tmpdir): ++ public_key, _ = util.subp( ++ 'openssl x509 -noout -pubkey |' ++ 'ssh-keygen -i -m PKCS8 -f /dev/stdin', ++ data=certificate, ++ shell=True) ++ keys.append(public_key) ++ return keys ++ ++ ++def iid_from_shared_config_content(content): ++ """ ++ find INSTANCE_ID in: ++ ++ ++ ++ ++ """ ++ root = ElementTree.fromstring(content) ++ depnode = root.find('Deployment') ++ return depnode.get('name') ++ ++ ++class WALinuxAgentShim(object): ++ ++ REPORT_READY_XML_TEMPLATE = '\n'.join([ ++ '', ++ '', ++ ' {incarnation}', ++ ' ', ++ ' {container_id}', ++ ' ', ++ ' ', ++ ' {instance_id}', ++ ' ', ++ ' Ready', ++ ' ', ++ ' ', ++ ' ', ++ ' ', ++ '']) ++ ++ def __init__(self): ++ LOG.debug('WALinuxAgentShim instantiated...') ++ self.endpoint = self.find_endpoint() ++ self.openssl_manager = None ++ self.values = {} ++ ++ def clean_up(self): ++ if self.openssl_manager is not None: ++ self.openssl_manager.clean_up() ++ ++ @staticmethod ++ def find_endpoint(): ++ LOG.debug('Finding Azure endpoint...') ++ content = util.load_file('/var/lib/dhcp/dhclient.eth0.leases') ++ value = None ++ for line in content.splitlines(): ++ if 'unknown-245' in line: ++ value = line.strip(' ').split(' ', 2)[-1].strip(';\n"') ++ if value is None: ++ raise Exception('No endpoint found in DHCP config.') ++ if ':' in value: ++ hex_string = '' ++ for hex_pair in value.split(':'): ++ if len(hex_pair) == 1: ++ hex_pair = '0' + hex_pair ++ hex_string += hex_pair ++ value = struct.pack('>L', int(hex_string.replace(':', ''), 16)) ++ else: ++ value = value.encode('utf-8') ++ endpoint_ip_address = socket.inet_ntoa(value) ++ LOG.debug('Azure endpoint found at %s', endpoint_ip_address) ++ return endpoint_ip_address ++ ++ def register_with_azure_and_fetch_data(self): ++ self.openssl_manager = OpenSSLManager() ++ http_client = AzureEndpointHttpClient(self.openssl_manager.certificate) ++ LOG.info('Registering with Azure...') ++ attempts = 0 ++ while True: ++ try: ++ response = http_client.get( ++ 'http://{0}/machine/?comp=goalstate'.format(self.endpoint)) ++ except Exception: ++ if attempts < 10: ++ time.sleep(attempts + 1) ++ else: ++ raise ++ else: ++ break ++ attempts += 1 ++ LOG.debug('Successfully fetched GoalState XML.') ++ goal_state = GoalState(response.contents, http_client) ++ public_keys = [] ++ if goal_state.certificates_xml is not None: ++ LOG.debug('Certificate XML found; parsing out public keys.') ++ public_keys = self.openssl_manager.parse_certificates( ++ goal_state.certificates_xml) ++ data = { ++ 'instance-id': iid_from_shared_config_content( ++ goal_state.shared_config_xml), ++ 'public-keys': public_keys, ++ } ++ self._report_ready(goal_state, http_client) ++ return data ++ ++ def _report_ready(self, goal_state, http_client): ++ LOG.debug('Reporting ready to Azure fabric.') ++ document = self.REPORT_READY_XML_TEMPLATE.format( ++ incarnation=goal_state.incarnation, ++ container_id=goal_state.container_id, ++ instance_id=goal_state.instance_id, ++ ) ++ http_client.post( ++ "http://{0}/machine?comp=health".format(self.endpoint), ++ data=document, ++ extra_headers={'Content-Type': 'text/xml; charset=utf-8'}, ++ ) ++ LOG.info('Reported ready to Azure fabric.') ++ ++ ++def get_metadata_from_fabric(): ++ shim = WALinuxAgentShim() ++ try: ++ return shim.register_with_azure_and_fetch_data() ++ finally: ++ shim.clean_up() +--- cloud-init-0.7.6~bzr1022.orig/tests/unittests/test_datasource/test_azure.py ++++ cloud-init-0.7.6~bzr1022/tests/unittests/test_datasource/test_azure.py +@@ -9,6 +9,20 @@ from mocker import MockerTestCase + import os + import stat + import yaml ++import xml.etree.ElementTree as ET ++ ++OVERRIDE_BUILTIN_DS_CONFIG = { ++ 'agent_command': ['bin/true'], ++ 'data_dir': "/var/lib/waagent", ++ 'set_hostname': True, ++ 'hostname_bounce': { ++ 'interface': 'eth0', ++ 'policy': False, ++ 'command': '/bin/true', ++ 'hostname_command': '/bin/true', ++ }, ++ 'disk_aliases': {'ephemeral0': '/dev/sdb'}, ++} + + + def construct_valid_ovf_env(data=None, pubkeys=None, userdata=None): +@@ -107,14 +121,13 @@ class TestAzureDataSource(MockerTestCase + data['iid_from_shared_cfg'] = path + return 'i-my-azure-id' + +- def _apply_hostname_bounce(**kwargs): +- data['apply_hostname_bounce'] = kwargs +- + if data.get('ovfcontent') is not None: + populate_dir(os.path.join(self.paths.seed_dir, "azure"), + {'ovf-env.xml': data['ovfcontent']}) + ++ + mod = DataSourceAzure ++ mod.BUILTIN_DS_CONFIG = OVERRIDE_BUILTIN_DS_CONFIG + mod.BUILTIN_DS_CONFIG['data_dir'] = self.waagent_d + + self.apply_patches([(mod, 'list_possible_azure_ds_devs', dsdevs)]) +@@ -124,15 +137,46 @@ class TestAzureDataSource(MockerTestCase + (mod, 'pubkeys_from_crt_files', + _pubkeys_from_crt_files), + (mod, 'iid_from_shared_config', +- _iid_from_shared_config), +- (mod, 'apply_hostname_bounce', +- _apply_hostname_bounce), ]) ++ _iid_from_shared_config)]) + + dsrc = mod.DataSourceAzureNet( + data.get('sys_cfg', {}), distro=None, paths=self.paths) + + return dsrc + ++ def xml_equals(self, oxml, nxml): ++ """Compare two sets of XML to make sure they are equal""" ++ ++ def create_tag_index(xml): ++ et = ET.fromstring(xml) ++ ret = {} ++ for x in et.iter(): ++ ret[x.tag] = x ++ return ret ++ ++ def tags_exists(x, y): ++ for tag in x.keys(): ++ self.assertIn(tag, y) ++ for tag in y.keys(): ++ self.assertIn(tag, x) ++ ++ def tags_equal(x, y): ++ for x_tag, x_val in x.items(): ++ y_val = y.get(x_val.tag) ++ self.assertEquals(x_val.text, y_val.text) ++ ++ old_cnt = create_tag_index(oxml) ++ new_cnt = create_tag_index(nxml) ++ tags_exists(old_cnt, new_cnt) ++ tags_equal(old_cnt, new_cnt) ++ ++ def xml_notequals(self, oxml, nxml): ++ try: ++ self.xml_equals(oxml, nxml) ++ except AssertionError as e: ++ return ++ raise AssertionError("XML is the same") ++ + def test_basic_seed_dir(self): + odata = {'HostName': "myhost", 'UserName': "myuser"} + data = {'ovfcontent': construct_valid_ovf_env(data=odata), +@@ -258,44 +302,6 @@ class TestAzureDataSource(MockerTestCase + def test_disabled_bounce(self): + pass + +- def test_apply_bounce_call_1(self): +- # hostname needs to get through to apply_hostname_bounce +- odata = {'HostName': 'my-random-hostname'} +- data = {'ovfcontent': construct_valid_ovf_env(data=odata)} +- +- self._get_ds(data).get_data() +- self.assertIn('hostname', data['apply_hostname_bounce']) +- self.assertEqual(data['apply_hostname_bounce']['hostname'], +- odata['HostName']) +- +- def test_apply_bounce_call_configurable(self): +- # hostname_bounce should be configurable in datasource cfg +- cfg = {'hostname_bounce': {'interface': 'eth1', 'policy': 'off', +- 'command': 'my-bounce-command', +- 'hostname_command': 'my-hostname-command'}} +- odata = {'HostName': "xhost", +- 'dscfg': {'text': base64.b64encode(yaml.dump(cfg)), +- 'encoding': 'base64'}} +- data = {'ovfcontent': construct_valid_ovf_env(data=odata)} +- self._get_ds(data).get_data() +- +- for k in cfg['hostname_bounce']: +- self.assertIn(k, data['apply_hostname_bounce']) +- +- for k, v in cfg['hostname_bounce'].items(): +- self.assertEqual(data['apply_hostname_bounce'][k], v) +- +- def test_set_hostname_disabled(self): +- # config specifying set_hostname off should not bounce +- cfg = {'set_hostname': False} +- odata = {'HostName': "xhost", +- 'dscfg': {'text': base64.b64encode(yaml.dump(cfg)), +- 'encoding': 'base64'}} +- data = {'ovfcontent': construct_valid_ovf_env(data=odata)} +- self._get_ds(data).get_data() +- +- self.assertEqual(data.get('apply_hostname_bounce', "N/A"), "N/A") +- + def test_default_ephemeral(self): + # make sure the ephemeral device works + odata = {} +@@ -342,6 +348,31 @@ class TestAzureDataSource(MockerTestCase + + self.assertEqual(userdata, dsrc.userdata_raw) + ++ def test_password_redacted_in_ovf(self): ++ odata = {'HostName': "myhost", 'UserName': "myuser", ++ 'UserPassword': "mypass"} ++ data = {'ovfcontent': construct_valid_ovf_env(data=odata)} ++ dsrc = self._get_ds(data) ++ ret = dsrc.get_data() ++ ++ self.assertTrue(ret) ++ ovf_env_path = os.path.join(self.waagent_d, 'ovf-env.xml') ++ ++ # The XML should not be same since the user password is redacted ++ on_disk_ovf = load_file(ovf_env_path) ++ self.xml_notequals(data['ovfcontent'], on_disk_ovf) ++ ++ # Make sure that the redacted password on disk is not used by CI ++ self.assertNotEquals(dsrc.cfg.get('password'), ++ DataSourceAzure.DEF_PASSWD_REDACTION) ++ ++ # Make sure that the password was really encrypted ++ et = ET.fromstring(on_disk_ovf) ++ for elem in et.iter(): ++ if 'UserPassword' in elem.tag: ++ self.assertEquals(DataSourceAzure.DEF_PASSWD_REDACTION, ++ elem.text) ++ + def test_ovf_env_arrives_in_waagent_dir(self): + xml = construct_valid_ovf_env(data={}, userdata="FOODATA") + dsrc = self._get_ds({'ovfcontent': xml}) +@@ -351,7 +382,7 @@ class TestAzureDataSource(MockerTestCase + # we expect that the ovf-env.xml file is copied there. + ovf_env_path = os.path.join(self.waagent_d, 'ovf-env.xml') + self.assertTrue(os.path.exists(ovf_env_path)) +- self.assertEqual(xml, load_file(ovf_env_path)) ++ self.xml_equals(xml, load_file(ovf_env_path)) + + def test_existing_ovf_same(self): + # waagent/SharedConfig left alone if found ovf-env.xml same as cached +@@ -398,9 +429,8 @@ class TestAzureDataSource(MockerTestCase + os.path.exists(os.path.join(self.waagent_d, 'SharedConfig.xml'))) + self.assertTrue( + os.path.exists(os.path.join(self.waagent_d, 'ovf-env.xml'))) +- self.assertEqual(new_ovfenv, +- load_file(os.path.join(self.waagent_d, 'ovf-env.xml'))) +- ++ new_xml = load_file(os.path.join(self.waagent_d, 'ovf-env.xml')) ++ self.xml_equals(new_ovfenv, new_xml) + + class TestReadAzureOvf(MockerTestCase): + def test_invalid_xml_raises_non_azure_ds(self): diff -Nru cloud-init-0.7.6~bzr1022/debian/patches/series cloud-init-0.7.6~bzr1022/debian/patches/series --- cloud-init-0.7.6~bzr1022/debian/patches/series 2015-03-11 16:57:54.000000000 +0000 +++ cloud-init-0.7.6~bzr1022/debian/patches/series 2015-05-25 14:56:35.000000000 +0000 @@ -2,3 +2,4 @@ lp-1404311-gce-data_encoding.patch lp-1422919-azure-g5_ephemeral.patch lp-1422388-cloudstack-passwords.patch +lp-1375252-1458052-Azure-hostname_password.patch