~ubuntu-branches/ubuntu/saucy/cloud-init/saucy

« back to all changes in this revision

Viewing changes to cloudinit/sources/DataSourceAzure.py

  • Committer: Package Import Robot
  • Author(s): Scott Moser
  • Date: 2013-04-11 12:55:51 UTC
  • mfrom: (245.3.9 raring-proposed)
  • Revision ID: package-import@ubuntu.com-20130411125551-8k60jsoot7t21z4b
* New upstream snapshot.
  * make apt-get invoke 'dist-upgrade' rather than 'upgrade' for
    package_upgrade. (LP: #1164147)
  * workaround 2.6 kernel issue that stopped blkid from showing /dev/sr0

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
 
# vi: ts=4 expandtab
2
 
#
3
 
#    Copyright (C) 2013 Canonical Ltd.
4
 
#
5
 
#    Author: Scott Moser <scott.moser@canonical.com>
6
 
#
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.
10
 
#
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.
15
 
#
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/>.
18
 
 
19
 
import base64
20
 
import os
21
 
import os.path
22
 
import time
23
 
from xml.dom import minidom
24
 
 
25
 
from cloudinit import log as logging
26
 
from cloudinit import sources
27
 
from cloudinit import util
28
 
 
29
 
LOG = logging.getLogger(__name__)
30
 
 
31
 
DS_NAME = 'Azure'
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"]
36
 
 
37
 
BUILTIN_DS_CONFIG = {
38
 
    'agent_command': AGENT_START,
39
 
    'data_dir': "/var/lib/waagent",
40
 
    'set_hostname': True,
41
 
    'hostname_bounce': {
42
 
        'interface': 'eth0',
43
 
        'policy': True,
44
 
        'command': BOUNCE_COMMAND,
45
 
        'hostname_command': 'hostname',
46
 
    }
47
 
}
48
 
DS_CFG_PATH = ['datasource', DS_NAME]
49
 
 
50
 
 
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')
55
 
        self.cfg = {}
56
 
        self.seed = None
57
 
        self.ds_cfg = util.mergemanydict([
58
 
            util.get_cfg_by_path(sys_cfg, DS_CFG_PATH, {}),
59
 
            BUILTIN_DS_CONFIG])
60
 
 
61
 
    def __str__(self):
62
 
        root = sources.DataSource.__str__(self)
63
 
        return "%s [seed=%s]" % (root, self.seed)
64
 
 
65
 
    def get_data(self):
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']
70
 
 
71
 
        candidates = [self.seed_dir]
72
 
        candidates.extend(list_possible_azure_ds_devs())
73
 
        if ddir:
74
 
            candidates.append(ddir)
75
 
 
76
 
        found = None
77
 
 
78
 
        for cdev in candidates:
79
 
            try:
80
 
                if cdev.startswith("/dev/"):
81
 
                    ret = util.mount_cb(cdev, load_azure_ds_dir)
82
 
                else:
83
 
                    ret = load_azure_ds_dir(cdev)
84
 
 
85
 
            except NonAzureDataSource:
86
 
                continue
87
 
            except BrokenAzureDataSource as exc:
88
 
                raise exc
89
 
            except util.MountFailedError:
90
 
                LOG.warn("%s was not mountable" % cdev)
91
 
                continue
92
 
 
93
 
            (md, self.userdata_raw, cfg, files) = ret
94
 
            self.seed = cdev
95
 
            self.metadata = util.mergemanydict([md, DEFAULT_METADATA])
96
 
            self.cfg = cfg
97
 
            found = cdev
98
 
 
99
 
            LOG.debug("found datasource in %s", cdev)
100
 
            break
101
 
 
102
 
        if not found:
103
 
            return False
104
 
 
105
 
        if found == ddir:
106
 
            LOG.debug("using files cached in %s", ddir)
107
 
 
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])
111
 
        mycfg = self.ds_cfg
112
 
 
113
 
        # walinux agent writes files world readable, but expects
114
 
        # the directory to be protected.
115
 
        write_files(mycfg['data_dir'], files, dirmode=0700)
116
 
 
117
 
        # handle the hostname 'publishing'
118
 
        try:
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")
125
 
 
126
 
        try:
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'])
132
 
 
133
 
        shcfgxml = os.path.join(mycfg['data_dir'], "SharedConfig.xml")
134
 
        wait_for = [shcfgxml]
135
 
 
136
 
        fp_files = []
137
 
        for pk in self.cfg.get('_pubkeys', []):
138
 
            bname = pk['fingerprint'] + ".crt"
139
 
            fp_files += [os.path.join(mycfg['data_dir'], bname)]
140
 
 
141
 
        start = time.time()
142
 
        missing = wait_for_files(wait_for + fp_files)
143
 
        if len(missing):
144
 
            LOG.warn("Did not find files, but going on: %s", missing)
145
 
        else:
146
 
            LOG.debug("waited %.3f seconds for %d files to appear",
147
 
                      time.time() - start, len(wait_for))
148
 
 
149
 
        if shcfgxml in missing:
150
 
            LOG.warn("SharedConfig.xml missing, using static instance-id")
151
 
        else:
152
 
            try:
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))
156
 
 
157
 
        pubkeys = pubkeys_from_crt_files(fp_files)
158
 
 
159
 
        self.metadata['public-keys'] = pubkeys
160
 
 
161
 
        return True
162
 
 
163
 
    def get_config_obj(self):
164
 
        return self.cfg
165
 
 
166
 
 
167
 
def handle_set_hostname(enabled, hostname, cfg):
168
 
    if not util.is_true(enabled):
169
 
        return
170
 
 
171
 
    if not hostname:
172
 
        LOG.warn("set_hostname was true but no local-hostname")
173
 
        return
174
 
 
175
 
    apply_hostname_bounce(hostname=hostname, policy=cfg['policy'],
176
 
                          interface=cfg['interface'],
177
 
                          command=cfg['command'],
178
 
                          hostname_command=cfg['hostname_command'])
179
 
 
180
 
 
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()
186
 
 
187
 
    util.subp([hostname_command, hostname])
188
 
 
189
 
    msg = ("phostname=%s hostname=%s policy=%s interface=%s" %
190
 
           (prev_hostname, hostname, policy, interface))
191
 
 
192
 
    if util.is_false(policy):
193
 
        LOG.debug("pubhname: policy false, skipping [%s]", msg)
194
 
        return
195
 
 
196
 
    if prev_hostname == hostname and policy != "force":
197
 
        LOG.debug("pubhname: no change, policy != force. skipping. [%s]", msg)
198
 
        return
199
 
 
200
 
    env = os.environ.copy()
201
 
    env['interface'] = interface
202
 
    env['hostname'] = hostname
203
 
    env['old_hostname'] = prev_hostname
204
 
 
205
 
    if command == "builtin":
206
 
        command = BOUNCE_COMMAND
207
 
 
208
 
    LOG.debug("pubhname: publishing hostname [%s]", msg)
209
 
    start = time.time()
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)
214
 
 
215
 
 
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)
220
 
    return out.rstrip()
221
 
 
222
 
 
223
 
def pubkeys_from_crt_files(flist):
224
 
    pubkeys = []
225
 
    errors = []
226
 
    for fname in flist:
227
 
        try:
228
 
            pubkeys.append(crtfile_to_pubkey(fname))
229
 
        except util.ProcessExecutionError:
230
 
            errors.extend(fname)
231
 
 
232
 
    if errors:
233
 
        LOG.warn("failed to convert the crt files to pubkey: %s" % errors)
234
 
 
235
 
    return pubkeys
236
 
 
237
 
 
238
 
def wait_for_files(flist, maxwait=60, naplen=.5):
239
 
    need = set(flist)
240
 
    waited = 0
241
 
    while waited < maxwait:
242
 
        need -= set([f for f in need if os.path.exists(f)])
243
 
        if len(need) == 0:
244
 
            return []
245
 
        time.sleep(naplen)
246
 
        waited += naplen
247
 
    return need
248
 
 
249
 
 
250
 
def write_files(datadir, files, dirmode=None):
251
 
    if not datadir:
252
 
        return
253
 
    if not files:
254
 
        files = {}
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)
259
 
 
260
 
 
261
 
def invoke_agent(cmd):
262
 
    # this is a function itself to simplify patching it for test
263
 
    if cmd:
264
 
        LOG.debug("invoking agent: %s" % cmd)
265
 
        util.subp(cmd, shell=(not isinstance(cmd, list)))
266
 
    else:
267
 
        LOG.debug("not invoking agent")
268
 
 
269
 
 
270
 
def find_child(node, filter_func):
271
 
    ret = []
272
 
    if not node.hasChildNodes():
273
 
        return ret
274
 
    for child in node.childNodes:
275
 
        if filter_func(child):
276
 
            ret.append(child)
277
 
    return ret
278
 
 
279
 
 
280
 
def load_azure_ovf_pubkeys(sshnode):
281
 
    # This parses a 'SSH' node formatted like below, and returns
282
 
    # an array of dicts.
283
 
    #  [{'fp': '6BE7A7C3C8A8F4B123CCA5D0C2F1BE4CA7B63ED7',
284
 
    #    'path': 'where/to/go'}]
285
 
    #
286
 
    # <SSH><PublicKeys>
287
 
    #   <PublicKey><Fingerprint>ABC</FingerPrint><Path>/ABC</Path>
288
 
    #   ...
289
 
    # </PublicKeys></SSH>
290
 
    results = find_child(sshnode, lambda n: n.localName == "PublicKeys")
291
 
    if len(results) == 0:
292
 
        return []
293
 
    if len(results) > 1:
294
 
        raise BrokenAzureDataSource("Multiple 'PublicKeys'(%s) in SSH node" %
295
 
                                    len(results))
296
 
 
297
 
    pubkeys_node = results[0]
298
 
    pubkeys = find_child(pubkeys_node, lambda n: n.localName == "PublicKey")
299
 
 
300
 
    if len(pubkeys) == 0:
301
 
        return []
302
 
 
303
 
    found = []
304
 
    text_node = minidom.Document.TEXT_NODE
305
 
 
306
 
    for pk_node in pubkeys:
307
 
        if not pk_node.hasChildNodes():
308
 
            continue
309
 
        cur = {'fingerprint': "", 'path': ""}
310
 
        for child in pk_node.childNodes:
311
 
            if (child.nodeType == text_node or not child.localName):
312
 
                continue
313
 
 
314
 
            name = child.localName.lower()
315
 
 
316
 
            if name not in cur.keys():
317
 
                continue
318
 
 
319
 
            if (len(child.childNodes) != 1 or
320
 
                child.childNodes[0].nodeType != text_node):
321
 
                continue
322
 
 
323
 
            cur[name] = child.childNodes[0].wholeText.strip()
324
 
        found.append(cur)
325
 
 
326
 
    return found
327
 
 
328
 
 
329
 
def single_node_at_path(node, pathlist):
330
 
    curnode = node
331
 
    for tok in 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)))
335
 
        if len(results) > 1:
336
 
            raise ValueError("found %s nodes of type %s looking for %s" %
337
 
                             (len(results), tok, str(pathlist)))
338
 
        curnode = results[0]
339
 
 
340
 
    return curnode
341
 
 
342
 
 
343
 
def read_azure_ovf(contents):
344
 
    try:
345
 
        dom = minidom.parseString(contents)
346
 
    except Exception as e:
347
 
        raise NonAzureDataSource("invalid xml: %s" % e)
348
 
 
349
 
    results = find_child(dom.documentElement,
350
 
        lambda n: n.localName == "ProvisioningSection")
351
 
 
352
 
    if len(results) == 0:
353
 
        raise NonAzureDataSource("No ProvisioningSection")
354
 
    if len(results) > 1:
355
 
        raise BrokenAzureDataSource("found '%d' ProvisioningSection items" %
356
 
                                    len(results))
357
 
    provSection = results[0]
358
 
 
359
 
    lpcs_nodes = find_child(provSection,
360
 
        lambda n: n.localName == "LinuxProvisioningConfigurationSet")
361
 
 
362
 
    if len(results) == 0:
363
 
        raise NonAzureDataSource("No LinuxProvisioningConfigurationSet")
364
 
    if len(results) > 1:
365
 
        raise BrokenAzureDataSource("found '%d' %ss" %
366
 
                                    ("LinuxProvisioningConfigurationSet",
367
 
                                     len(results)))
368
 
    lpcs = lpcs_nodes[0]
369
 
 
370
 
    if not lpcs.hasChildNodes():
371
 
        raise BrokenAzureDataSource("no child nodes of configuration set")
372
 
 
373
 
    md_props = 'seedfrom'
374
 
    md = {'azure_data': {}}
375
 
    cfg = {}
376
 
    ud = ""
377
 
    password = None
378
 
    username = None
379
 
 
380
 
    for child in lpcs.childNodes:
381
 
        if child.nodeType == dom.TEXT_NODE or not child.localName:
382
 
            continue
383
 
 
384
 
        name = child.localName.lower()
385
 
 
386
 
        simple = False
387
 
        value = ""
388
 
        if (len(child.childNodes) == 1 and
389
 
            child.childNodes[0].nodeType == dom.TEXT_NODE):
390
 
            simple = True
391
 
            value = child.childNodes[0].wholeText
392
 
 
393
 
        attrs = {k: v for k, v in child.attributes.items()}
394
 
 
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()))
400
 
            else:
401
 
                ud = value
402
 
        elif name == "username":
403
 
            username = value
404
 
        elif name == "userpassword":
405
 
            password = value
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()))
411
 
            else:
412
 
                dscfg = value
413
 
            cfg['datasource'] = {DS_NAME: util.load_yaml(dscfg, default={})}
414
 
        elif name == "ssh":
415
 
            cfg['_pubkeys'] = load_azure_ovf_pubkeys(child)
416
 
        elif name == "disablesshpasswordauthentication":
417
 
            cfg['ssh_pwauth'] = util.is_false(value)
418
 
        elif simple:
419
 
            if name in md_props:
420
 
                md[name] = value
421
 
            else:
422
 
                md['azure_data'][name] = value
423
 
 
424
 
    defuser = {}
425
 
    if username:
426
 
        defuser['name'] = username
427
 
    if password:
428
 
        defuser['password'] = password
429
 
        defuser['lock_passwd'] = False
430
 
 
431
 
    if defuser:
432
 
        cfg['system_info'] = {'default_user': defuser}
433
 
 
434
 
    if 'ssh_pwauth' not in cfg and password:
435
 
        cfg['ssh_pwauth'] = True
436
 
 
437
 
    return (md, ud, cfg)
438
 
 
439
 
 
440
 
def list_possible_azure_ds_devs():
441
 
    # return a sorted list of devices that might have a azure datasource
442
 
    devlist = []
443
 
    for fstype in ("iso9660", "udf"):
444
 
        devlist.extend(util.find_devs_with("TYPE=%s" % fstype))
445
 
 
446
 
    devlist.sort(reverse=True)
447
 
    return devlist
448
 
 
449
 
 
450
 
def load_azure_ds_dir(source_dir):
451
 
    ovf_file = os.path.join(source_dir, "ovf-env.xml")
452
 
 
453
 
    if not os.path.isfile(ovf_file):
454
 
        raise NonAzureDataSource("No ovf-env file found")
455
 
 
456
 
    with open(ovf_file, "r") as fp:
457
 
        contents = fp.read()
458
 
 
459
 
    md, ud, cfg = read_azure_ovf(contents)
460
 
    return (md, ud, cfg, {'ovf-env.xml': contents})
461
 
 
462
 
 
463
 
def iid_from_shared_config(path):
464
 
    with open(path, "rb") as fp:
465
 
        content = fp.read()
466
 
    return iid_from_shared_config_content(content)
467
 
 
468
 
 
469
 
def iid_from_shared_config_content(content):
470
 
    """
471
 
    find INSTANCE_ID in:
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}" />
476
 
    """
477
 
    dom = minidom.parseString(content)
478
 
    depnode = single_node_at_path(dom, ["SharedConfig", "Deployment"])
479
 
    return depnode.attributes.get('name').value
480
 
 
481
 
 
482
 
class BrokenAzureDataSource(Exception):
483
 
    pass
484
 
 
485
 
 
486
 
class NonAzureDataSource(Exception):
487
 
    pass
488
 
 
489
 
 
490
 
# Used to match classes to dependencies
491
 
datasources = [
492
 
  (DataSourceAzureNet, (sources.DEP_FILESYSTEM, sources.DEP_NETWORK)),
493
 
]
494
 
 
495
 
 
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)