3
# Copyright (C) 2013 Canonical Ltd.
5
# Author: Scott Moser <scott.moser@canonical.com>
7
# This program is free software: you can redistribute it and/or modify
8
# it under the terms of the GNU General Public License version 3, as
9
# published by the Free Software Foundation.
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
# GNU General Public License for more details.
16
# You should have received a copy of the GNU General Public License
17
# along with this program. If not, see <http://www.gnu.org/licenses/>.
23
from xml.dom import minidom
25
from cloudinit import log as logging
26
from cloudinit import sources
27
from cloudinit import util
29
LOG = logging.getLogger(__name__)
32
DEFAULT_METADATA = {"instance-id": "iid-AZURE-NODE"}
33
AGENT_START = ['service', 'walinuxagent', 'start']
34
BOUNCE_COMMAND = ['sh', '-xc',
35
"i=$interface; x=0; ifdown $i || x=$?; ifup $i || x=$?; exit $x"]
38
'agent_command': AGENT_START,
39
'data_dir': "/var/lib/waagent",
44
'command': BOUNCE_COMMAND,
45
'hostname_command': 'hostname',
48
DS_CFG_PATH = ['datasource', DS_NAME]
51
class DataSourceAzureNet(sources.DataSource):
52
def __init__(self, sys_cfg, distro, paths):
53
sources.DataSource.__init__(self, sys_cfg, distro, paths)
54
self.seed_dir = os.path.join(paths.seed_dir, 'azure')
57
self.ds_cfg = util.mergemanydict([
58
util.get_cfg_by_path(sys_cfg, DS_CFG_PATH, {}),
62
root = sources.DataSource.__str__(self)
63
return "%s [seed=%s]" % (root, self.seed)
66
# azure removes/ejects the cdrom containing the ovf-env.xml
67
# file on reboot. So, in order to successfully reboot we
68
# need to look in the datadir and consider that valid
69
ddir = self.ds_cfg['data_dir']
71
candidates = [self.seed_dir]
72
candidates.extend(list_possible_azure_ds_devs())
74
candidates.append(ddir)
78
for cdev in candidates:
80
if cdev.startswith("/dev/"):
81
ret = util.mount_cb(cdev, load_azure_ds_dir)
83
ret = load_azure_ds_dir(cdev)
85
except NonAzureDataSource:
87
except BrokenAzureDataSource as exc:
89
except util.MountFailedError:
90
LOG.warn("%s was not mountable" % cdev)
93
(md, self.userdata_raw, cfg, files) = ret
95
self.metadata = util.mergemanydict([md, DEFAULT_METADATA])
99
LOG.debug("found datasource in %s", cdev)
106
LOG.debug("using files cached in %s", ddir)
108
# now update ds_cfg to reflect contents pass in config
109
usercfg = util.get_cfg_by_path(self.cfg, DS_CFG_PATH, {})
110
self.ds_cfg = util.mergemanydict([usercfg, self.ds_cfg])
113
# walinux agent writes files world readable, but expects
114
# the directory to be protected.
115
write_files(mycfg['data_dir'], files, dirmode=0700)
117
# handle the hostname 'publishing'
119
handle_set_hostname(mycfg.get('set_hostname'),
120
self.metadata.get('local-hostname'),
121
mycfg['hostname_bounce'])
122
except Exception as e:
123
LOG.warn("Failed publishing hostname: %s" % e)
124
util.logexc(LOG, "handling set_hostname failed")
127
invoke_agent(mycfg['agent_command'])
128
except util.ProcessExecutionError:
129
# claim the datasource even if the command failed
130
util.logexc(LOG, "agent command '%s' failed.",
131
mycfg['agent_command'])
133
shcfgxml = os.path.join(mycfg['data_dir'], "SharedConfig.xml")
134
wait_for = [shcfgxml]
137
for pk in self.cfg.get('_pubkeys', []):
138
bname = pk['fingerprint'] + ".crt"
139
fp_files += [os.path.join(mycfg['data_dir'], bname)]
142
missing = wait_for_files(wait_for + fp_files)
144
LOG.warn("Did not find files, but going on: %s", missing)
146
LOG.debug("waited %.3f seconds for %d files to appear",
147
time.time() - start, len(wait_for))
149
if shcfgxml in missing:
150
LOG.warn("SharedConfig.xml missing, using static instance-id")
153
self.metadata['instance-id'] = iid_from_shared_config(shcfgxml)
154
except ValueError as e:
155
LOG.warn("failed to get instance id in %s: %s" % (shcfgxml, e))
157
pubkeys = pubkeys_from_crt_files(fp_files)
159
self.metadata['public-keys'] = pubkeys
163
def get_config_obj(self):
167
def handle_set_hostname(enabled, hostname, cfg):
168
if not util.is_true(enabled):
172
LOG.warn("set_hostname was true but no local-hostname")
175
apply_hostname_bounce(hostname=hostname, policy=cfg['policy'],
176
interface=cfg['interface'],
177
command=cfg['command'],
178
hostname_command=cfg['hostname_command'])
181
def apply_hostname_bounce(hostname, policy, interface, command,
182
hostname_command="hostname"):
183
# set the hostname to 'hostname' if it is not already set to that.
184
# then, if policy is not off, bounce the interface using command
185
prev_hostname = util.subp(hostname_command, capture=True)[0].strip()
187
util.subp([hostname_command, hostname])
189
msg = ("phostname=%s hostname=%s policy=%s interface=%s" %
190
(prev_hostname, hostname, policy, interface))
192
if util.is_false(policy):
193
LOG.debug("pubhname: policy false, skipping [%s]", msg)
196
if prev_hostname == hostname and policy != "force":
197
LOG.debug("pubhname: no change, policy != force. skipping. [%s]", msg)
200
env = os.environ.copy()
201
env['interface'] = interface
202
env['hostname'] = hostname
203
env['old_hostname'] = prev_hostname
205
if command == "builtin":
206
command = BOUNCE_COMMAND
208
LOG.debug("pubhname: publishing hostname [%s]", msg)
210
shell = not isinstance(command, (list, tuple))
211
# capture=False, see comments in bug 1202758 and bug 1206164.
212
(output, err) = util.subp(command, shell=shell, capture=False, env=env)
213
LOG.debug("publishing hostname took %.3f seconds", time.time() - start)
216
def crtfile_to_pubkey(fname):
217
pipeline = ('openssl x509 -noout -pubkey < "$0" |'
218
'ssh-keygen -i -m PKCS8 -f /dev/stdin')
219
(out, _err) = util.subp(['sh', '-c', pipeline, fname], capture=True)
223
def pubkeys_from_crt_files(flist):
228
pubkeys.append(crtfile_to_pubkey(fname))
229
except util.ProcessExecutionError:
233
LOG.warn("failed to convert the crt files to pubkey: %s" % errors)
238
def wait_for_files(flist, maxwait=60, naplen=.5):
241
while waited < maxwait:
242
need -= set([f for f in need if os.path.exists(f)])
250
def write_files(datadir, files, dirmode=None):
255
util.ensure_dir(datadir, dirmode)
256
for (name, content) in files.items():
257
util.write_file(filename=os.path.join(datadir, name),
258
content=content, mode=0600)
261
def invoke_agent(cmd):
262
# this is a function itself to simplify patching it for test
264
LOG.debug("invoking agent: %s" % cmd)
265
util.subp(cmd, shell=(not isinstance(cmd, list)))
267
LOG.debug("not invoking agent")
270
def find_child(node, filter_func):
272
if not node.hasChildNodes():
274
for child in node.childNodes:
275
if filter_func(child):
280
def load_azure_ovf_pubkeys(sshnode):
281
# This parses a 'SSH' node formatted like below, and returns
283
# [{'fp': '6BE7A7C3C8A8F4B123CCA5D0C2F1BE4CA7B63ED7',
284
# 'path': 'where/to/go'}]
287
# <PublicKey><Fingerprint>ABC</FingerPrint><Path>/ABC</Path>
289
# </PublicKeys></SSH>
290
results = find_child(sshnode, lambda n: n.localName == "PublicKeys")
291
if len(results) == 0:
294
raise BrokenAzureDataSource("Multiple 'PublicKeys'(%s) in SSH node" %
297
pubkeys_node = results[0]
298
pubkeys = find_child(pubkeys_node, lambda n: n.localName == "PublicKey")
300
if len(pubkeys) == 0:
304
text_node = minidom.Document.TEXT_NODE
306
for pk_node in pubkeys:
307
if not pk_node.hasChildNodes():
309
cur = {'fingerprint': "", 'path': ""}
310
for child in pk_node.childNodes:
311
if (child.nodeType == text_node or not child.localName):
314
name = child.localName.lower()
316
if name not in cur.keys():
319
if (len(child.childNodes) != 1 or
320
child.childNodes[0].nodeType != text_node):
323
cur[name] = child.childNodes[0].wholeText.strip()
329
def single_node_at_path(node, pathlist):
332
results = find_child(curnode, lambda n: n.localName == tok)
333
if len(results) == 0:
334
raise ValueError("missing %s token in %s" % (tok, str(pathlist)))
336
raise ValueError("found %s nodes of type %s looking for %s" %
337
(len(results), tok, str(pathlist)))
343
def read_azure_ovf(contents):
345
dom = minidom.parseString(contents)
346
except Exception as e:
347
raise NonAzureDataSource("invalid xml: %s" % e)
349
results = find_child(dom.documentElement,
350
lambda n: n.localName == "ProvisioningSection")
352
if len(results) == 0:
353
raise NonAzureDataSource("No ProvisioningSection")
355
raise BrokenAzureDataSource("found '%d' ProvisioningSection items" %
357
provSection = results[0]
359
lpcs_nodes = find_child(provSection,
360
lambda n: n.localName == "LinuxProvisioningConfigurationSet")
362
if len(results) == 0:
363
raise NonAzureDataSource("No LinuxProvisioningConfigurationSet")
365
raise BrokenAzureDataSource("found '%d' %ss" %
366
("LinuxProvisioningConfigurationSet",
370
if not lpcs.hasChildNodes():
371
raise BrokenAzureDataSource("no child nodes of configuration set")
373
md_props = 'seedfrom'
374
md = {'azure_data': {}}
380
for child in lpcs.childNodes:
381
if child.nodeType == dom.TEXT_NODE or not child.localName:
384
name = child.localName.lower()
388
if (len(child.childNodes) == 1 and
389
child.childNodes[0].nodeType == dom.TEXT_NODE):
391
value = child.childNodes[0].wholeText
393
attrs = {k: v for k, v in child.attributes.items()}
395
# we accept either UserData or CustomData. If both are present
396
# then behavior is undefined.
397
if (name == "userdata" or name == "customdata"):
398
if attrs.get('encoding') in (None, "base64"):
399
ud = base64.b64decode(''.join(value.split()))
402
elif name == "username":
404
elif name == "userpassword":
406
elif name == "hostname":
407
md['local-hostname'] = value
408
elif name == "dscfg":
409
if attrs.get('encoding') in (None, "base64"):
410
dscfg = base64.b64decode(''.join(value.split()))
413
cfg['datasource'] = {DS_NAME: util.load_yaml(dscfg, default={})}
415
cfg['_pubkeys'] = load_azure_ovf_pubkeys(child)
416
elif name == "disablesshpasswordauthentication":
417
cfg['ssh_pwauth'] = util.is_false(value)
422
md['azure_data'][name] = value
426
defuser['name'] = username
428
defuser['password'] = password
429
defuser['lock_passwd'] = False
432
cfg['system_info'] = {'default_user': defuser}
434
if 'ssh_pwauth' not in cfg and password:
435
cfg['ssh_pwauth'] = True
440
def list_possible_azure_ds_devs():
441
# return a sorted list of devices that might have a azure datasource
443
for fstype in ("iso9660", "udf"):
444
devlist.extend(util.find_devs_with("TYPE=%s" % fstype))
446
devlist.sort(reverse=True)
450
def load_azure_ds_dir(source_dir):
451
ovf_file = os.path.join(source_dir, "ovf-env.xml")
453
if not os.path.isfile(ovf_file):
454
raise NonAzureDataSource("No ovf-env file found")
456
with open(ovf_file, "r") as fp:
459
md, ud, cfg = read_azure_ovf(contents)
460
return (md, ud, cfg, {'ovf-env.xml': contents})
463
def iid_from_shared_config(path):
464
with open(path, "rb") as fp:
466
return iid_from_shared_config_content(content)
469
def iid_from_shared_config_content(content):
472
<?xml version="1.0" encoding="utf-8"?>
473
<SharedConfig version="1.0.0.0" goalStateIncarnation="1">
474
<Deployment name="INSTANCE_ID" guid="{...}" incarnation="0">
475
<Service name="..." guid="{00000000-0000-0000-0000-000000000000}" />
477
dom = minidom.parseString(content)
478
depnode = single_node_at_path(dom, ["SharedConfig", "Deployment"])
479
return depnode.attributes.get('name').value
482
class BrokenAzureDataSource(Exception):
486
class NonAzureDataSource(Exception):
490
# Used to match classes to dependencies
492
(DataSourceAzureNet, (sources.DEP_FILESYSTEM, sources.DEP_NETWORK)),
496
# Return a list of data sources that match this set of dependencies
497
def get_datasource_list(depends):
498
return sources.list_from_depends(depends, datasources)