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

« back to all changes in this revision

Viewing changes to .pc/lp-1292648-azure-format-ephemeral-new.patch/cloudinit/sources/DataSourceAzure.py

  • Committer: Scott Moser
  • Date: 2014-03-21 16:26:05 UTC
  • mfrom: (317.1.2 saucy-proposed.ben)
  • Revision ID: smoser@ubuntu.com-20140321162605-3u4kmqayg5k7agab
* debian/patches/lp-1269626-azure_new_instance.patch:
  fix handling of new instances on Windows Azure (LP: #1269626).
* debian/patches/lp-1292648-azure-format-ephemeral-new.patch:
  re-format ephemeral disk if necessary (LP: #1292648).

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 crypt
 
21
import os
 
22
import os.path
 
23
import time
 
24
from xml.dom import minidom
 
25
 
 
26
from cloudinit import log as logging
 
27
from cloudinit import sources
 
28
from cloudinit import util
 
29
 
 
30
LOG = logging.getLogger(__name__)
 
31
 
 
32
DS_NAME = 'Azure'
 
33
DEFAULT_METADATA = {"instance-id": "iid-AZURE-NODE"}
 
34
AGENT_START = ['service', 'walinuxagent', 'start']
 
35
BOUNCE_COMMAND = ['sh', '-xc',
 
36
    "i=$interface; x=0; ifdown $i || x=$?; ifup $i || x=$?; exit $x"]
 
37
DATA_DIR_CLEAN_LIST = ['SharedConfig.xml']
 
38
 
 
39
BUILTIN_DS_CONFIG = {
 
40
    'agent_command': AGENT_START,
 
41
    'data_dir': "/var/lib/waagent",
 
42
    'set_hostname': True,
 
43
    'hostname_bounce': {
 
44
        'interface': 'eth0',
 
45
        'policy': True,
 
46
        'command': BOUNCE_COMMAND,
 
47
        'hostname_command': 'hostname',
 
48
        },
 
49
    'disk_aliases': {'ephemeral0': '/dev/sdb'},
 
50
}
 
51
 
 
52
BUILTIN_CLOUD_CONFIG = {
 
53
    'disk_setup': {
 
54
        'ephemeral0': {'table_type': 'mbr',
 
55
                       'layout': True,
 
56
                       'overwrite': False}
 
57
         },
 
58
    'fs_setup': [{'filesystem': 'ext4',
 
59
                  'device': 'ephemeral0.1',
 
60
                  'replace_fs': 'ntfs'}]
 
61
}
 
62
 
 
63
DS_CFG_PATH = ['datasource', DS_NAME]
 
64
 
 
65
 
 
66
class DataSourceAzureNet(sources.DataSource):
 
67
    def __init__(self, sys_cfg, distro, paths):
 
68
        sources.DataSource.__init__(self, sys_cfg, distro, paths)
 
69
        self.seed_dir = os.path.join(paths.seed_dir, 'azure')
 
70
        self.cfg = {}
 
71
        self.seed = None
 
72
        self.ds_cfg = util.mergemanydict([
 
73
            util.get_cfg_by_path(sys_cfg, DS_CFG_PATH, {}),
 
74
            BUILTIN_DS_CONFIG])
 
75
 
 
76
    def __str__(self):
 
77
        root = sources.DataSource.__str__(self)
 
78
        return "%s [seed=%s]" % (root, self.seed)
 
79
 
 
80
    def get_data(self):
 
81
        # azure removes/ejects the cdrom containing the ovf-env.xml
 
82
        # file on reboot.  So, in order to successfully reboot we
 
83
        # need to look in the datadir and consider that valid
 
84
        ddir = self.ds_cfg['data_dir']
 
85
 
 
86
        candidates = [self.seed_dir]
 
87
        candidates.extend(list_possible_azure_ds_devs())
 
88
        if ddir:
 
89
            candidates.append(ddir)
 
90
 
 
91
        found = None
 
92
 
 
93
        for cdev in candidates:
 
94
            try:
 
95
                if cdev.startswith("/dev/"):
 
96
                    ret = util.mount_cb(cdev, load_azure_ds_dir)
 
97
                else:
 
98
                    ret = load_azure_ds_dir(cdev)
 
99
 
 
100
            except NonAzureDataSource:
 
101
                continue
 
102
            except BrokenAzureDataSource as exc:
 
103
                raise exc
 
104
            except util.MountFailedError:
 
105
                LOG.warn("%s was not mountable" % cdev)
 
106
                continue
 
107
 
 
108
            (md, self.userdata_raw, cfg, files) = ret
 
109
            self.seed = cdev
 
110
            self.metadata = util.mergemanydict([md, DEFAULT_METADATA])
 
111
            self.cfg = util.mergemanydict([cfg, BUILTIN_CLOUD_CONFIG])
 
112
            found = cdev
 
113
 
 
114
            LOG.debug("found datasource in %s", cdev)
 
115
            break
 
116
 
 
117
        if not found:
 
118
            return False
 
119
 
 
120
        if found == ddir:
 
121
            LOG.debug("using files cached in %s", ddir)
 
122
 
 
123
        # azure / hyper-v provides random data here
 
124
        seed = util.load_file("/sys/firmware/acpi/tables/OEM0", quiet=True)
 
125
        if seed:
 
126
            self.metadata['random_seed'] = seed
 
127
 
 
128
        # now update ds_cfg to reflect contents pass in config
 
129
        user_ds_cfg = util.get_cfg_by_path(self.cfg, DS_CFG_PATH, {})
 
130
        self.ds_cfg = util.mergemanydict([user_ds_cfg, self.ds_cfg])
 
131
        mycfg = self.ds_cfg
 
132
        ddir = mycfg['data_dir']
 
133
 
 
134
        if found != ddir:
 
135
            cached_ovfenv = util.load_file(
 
136
                os.path.join(ddir, 'ovf-env.xml'), quiet=True)
 
137
            if cached_ovfenv != files['ovf-env.xml']:
 
138
                # source was not walinux-agent's datadir, so we have to clean
 
139
                # up so 'wait_for_files' doesn't return early due to stale data
 
140
                cleaned = []
 
141
                for f in [os.path.join(ddir, f) for f in DATA_DIR_CLEAN_LIST]:
 
142
                    if os.path.exists(f):
 
143
                        util.del_file(f)
 
144
                        cleaned.append(f)
 
145
                if cleaned:
 
146
                    LOG.info("removed stale file(s) in '%s': %s",
 
147
                             ddir, str(cleaned))
 
148
 
 
149
        # walinux agent writes files world readable, but expects
 
150
        # the directory to be protected.
 
151
        write_files(ddir, files, dirmode=0700)
 
152
 
 
153
        # handle the hostname 'publishing'
 
154
        try:
 
155
            handle_set_hostname(mycfg.get('set_hostname'),
 
156
                                self.metadata.get('local-hostname'),
 
157
                                mycfg['hostname_bounce'])
 
158
        except Exception as e:
 
159
            LOG.warn("Failed publishing hostname: %s", e)
 
160
            util.logexc(LOG, "handling set_hostname failed")
 
161
 
 
162
        try:
 
163
            invoke_agent(mycfg['agent_command'])
 
164
        except util.ProcessExecutionError:
 
165
            # claim the datasource even if the command failed
 
166
            util.logexc(LOG, "agent command '%s' failed.",
 
167
                        mycfg['agent_command'])
 
168
 
 
169
        shcfgxml = os.path.join(ddir, "SharedConfig.xml")
 
170
        wait_for = [shcfgxml]
 
171
 
 
172
        fp_files = []
 
173
        for pk in self.cfg.get('_pubkeys', []):
 
174
            bname = str(pk['fingerprint'] + ".crt")
 
175
            fp_files += [os.path.join(ddir, bname)]
 
176
 
 
177
        missing = util.log_time(logfunc=LOG.debug, msg="waiting for files",
 
178
                                func=wait_for_files,
 
179
                                args=(wait_for + fp_files,))
 
180
        if len(missing):
 
181
            LOG.warn("Did not find files, but going on: %s", missing)
 
182
 
 
183
        if shcfgxml in missing:
 
184
            LOG.warn("SharedConfig.xml missing, using static instance-id")
 
185
        else:
 
186
            try:
 
187
                self.metadata['instance-id'] = iid_from_shared_config(shcfgxml)
 
188
            except ValueError as e:
 
189
                LOG.warn("failed to get instance id in %s: %s" % (shcfgxml, e))
 
190
 
 
191
        pubkeys = pubkeys_from_crt_files(fp_files)
 
192
 
 
193
        self.metadata['public-keys'] = pubkeys
 
194
        return True
 
195
 
 
196
    def device_name_to_device(self, name):
 
197
        return self.ds_cfg['disk_aliases'].get(name)
 
198
 
 
199
    def get_config_obj(self):
 
200
        return self.cfg
 
201
 
 
202
 
 
203
def handle_set_hostname(enabled, hostname, cfg):
 
204
    if not util.is_true(enabled):
 
205
        return
 
206
 
 
207
    if not hostname:
 
208
        LOG.warn("set_hostname was true but no local-hostname")
 
209
        return
 
210
 
 
211
    apply_hostname_bounce(hostname=hostname, policy=cfg['policy'],
 
212
                          interface=cfg['interface'],
 
213
                          command=cfg['command'],
 
214
                          hostname_command=cfg['hostname_command'])
 
215
 
 
216
 
 
217
def apply_hostname_bounce(hostname, policy, interface, command,
 
218
                          hostname_command="hostname"):
 
219
    # set the hostname to 'hostname' if it is not already set to that.
 
220
    # then, if policy is not off, bounce the interface using command
 
221
    prev_hostname = util.subp(hostname_command, capture=True)[0].strip()
 
222
 
 
223
    util.subp([hostname_command, hostname])
 
224
 
 
225
    msg = ("phostname=%s hostname=%s policy=%s interface=%s" %
 
226
           (prev_hostname, hostname, policy, interface))
 
227
 
 
228
    if util.is_false(policy):
 
229
        LOG.debug("pubhname: policy false, skipping [%s]", msg)
 
230
        return
 
231
 
 
232
    if prev_hostname == hostname and policy != "force":
 
233
        LOG.debug("pubhname: no change, policy != force. skipping. [%s]", msg)
 
234
        return
 
235
 
 
236
    env = os.environ.copy()
 
237
    env['interface'] = interface
 
238
    env['hostname'] = hostname
 
239
    env['old_hostname'] = prev_hostname
 
240
 
 
241
    if command == "builtin":
 
242
        command = BOUNCE_COMMAND
 
243
 
 
244
    LOG.debug("pubhname: publishing hostname [%s]", msg)
 
245
    shell = not isinstance(command, (list, tuple))
 
246
    # capture=False, see comments in bug 1202758 and bug 1206164.
 
247
    util.log_time(logfunc=LOG.debug, msg="publishing hostname",
 
248
        get_uptime=True, func=util.subp,
 
249
        kwargs={'args': command, 'shell': shell, 'capture': False,
 
250
                'env': env})
 
251
 
 
252
 
 
253
def crtfile_to_pubkey(fname):
 
254
    pipeline = ('openssl x509 -noout -pubkey < "$0" |'
 
255
                'ssh-keygen -i -m PKCS8 -f /dev/stdin')
 
256
    (out, _err) = util.subp(['sh', '-c', pipeline, fname], capture=True)
 
257
    return out.rstrip()
 
258
 
 
259
 
 
260
def pubkeys_from_crt_files(flist):
 
261
    pubkeys = []
 
262
    errors = []
 
263
    for fname in flist:
 
264
        try:
 
265
            pubkeys.append(crtfile_to_pubkey(fname))
 
266
        except util.ProcessExecutionError:
 
267
            errors.append(fname)
 
268
 
 
269
    if errors:
 
270
        LOG.warn("failed to convert the crt files to pubkey: %s", errors)
 
271
 
 
272
    return pubkeys
 
273
 
 
274
 
 
275
def wait_for_files(flist, maxwait=60, naplen=.5):
 
276
    need = set(flist)
 
277
    waited = 0
 
278
    while waited < maxwait:
 
279
        need -= set([f for f in need if os.path.exists(f)])
 
280
        if len(need) == 0:
 
281
            return []
 
282
        time.sleep(naplen)
 
283
        waited += naplen
 
284
    return need
 
285
 
 
286
 
 
287
def write_files(datadir, files, dirmode=None):
 
288
    if not datadir:
 
289
        return
 
290
    if not files:
 
291
        files = {}
 
292
    util.ensure_dir(datadir, dirmode)
 
293
    for (name, content) in files.items():
 
294
        util.write_file(filename=os.path.join(datadir, name),
 
295
                        content=content, mode=0600)
 
296
 
 
297
 
 
298
def invoke_agent(cmd):
 
299
    # this is a function itself to simplify patching it for test
 
300
    if cmd:
 
301
        LOG.debug("invoking agent: %s", cmd)
 
302
        util.subp(cmd, shell=(not isinstance(cmd, list)))
 
303
    else:
 
304
        LOG.debug("not invoking agent")
 
305
 
 
306
 
 
307
def find_child(node, filter_func):
 
308
    ret = []
 
309
    if not node.hasChildNodes():
 
310
        return ret
 
311
    for child in node.childNodes:
 
312
        if filter_func(child):
 
313
            ret.append(child)
 
314
    return ret
 
315
 
 
316
 
 
317
def load_azure_ovf_pubkeys(sshnode):
 
318
    # This parses a 'SSH' node formatted like below, and returns
 
319
    # an array of dicts.
 
320
    #  [{'fp': '6BE7A7C3C8A8F4B123CCA5D0C2F1BE4CA7B63ED7',
 
321
    #    'path': 'where/to/go'}]
 
322
    #
 
323
    # <SSH><PublicKeys>
 
324
    #   <PublicKey><Fingerprint>ABC</FingerPrint><Path>/ABC</Path>
 
325
    #   ...
 
326
    # </PublicKeys></SSH>
 
327
    results = find_child(sshnode, lambda n: n.localName == "PublicKeys")
 
328
    if len(results) == 0:
 
329
        return []
 
330
    if len(results) > 1:
 
331
        raise BrokenAzureDataSource("Multiple 'PublicKeys'(%s) in SSH node" %
 
332
                                    len(results))
 
333
 
 
334
    pubkeys_node = results[0]
 
335
    pubkeys = find_child(pubkeys_node, lambda n: n.localName == "PublicKey")
 
336
 
 
337
    if len(pubkeys) == 0:
 
338
        return []
 
339
 
 
340
    found = []
 
341
    text_node = minidom.Document.TEXT_NODE
 
342
 
 
343
    for pk_node in pubkeys:
 
344
        if not pk_node.hasChildNodes():
 
345
            continue
 
346
        cur = {'fingerprint': "", 'path': ""}
 
347
        for child in pk_node.childNodes:
 
348
            if child.nodeType == text_node or not child.localName:
 
349
                continue
 
350
 
 
351
            name = child.localName.lower()
 
352
 
 
353
            if name not in cur.keys():
 
354
                continue
 
355
 
 
356
            if (len(child.childNodes) != 1 or
 
357
                child.childNodes[0].nodeType != text_node):
 
358
                continue
 
359
 
 
360
            cur[name] = child.childNodes[0].wholeText.strip()
 
361
        found.append(cur)
 
362
 
 
363
    return found
 
364
 
 
365
 
 
366
def single_node_at_path(node, pathlist):
 
367
    curnode = node
 
368
    for tok in pathlist:
 
369
        results = find_child(curnode, lambda n: n.localName == tok)
 
370
        if len(results) == 0:
 
371
            raise ValueError("missing %s token in %s" % (tok, str(pathlist)))
 
372
        if len(results) > 1:
 
373
            raise ValueError("found %s nodes of type %s looking for %s" %
 
374
                             (len(results), tok, str(pathlist)))
 
375
        curnode = results[0]
 
376
 
 
377
    return curnode
 
378
 
 
379
 
 
380
def read_azure_ovf(contents):
 
381
    try:
 
382
        dom = minidom.parseString(contents)
 
383
    except Exception as e:
 
384
        raise BrokenAzureDataSource("invalid xml: %s" % e)
 
385
 
 
386
    results = find_child(dom.documentElement,
 
387
        lambda n: n.localName == "ProvisioningSection")
 
388
 
 
389
    if len(results) == 0:
 
390
        raise NonAzureDataSource("No ProvisioningSection")
 
391
    if len(results) > 1:
 
392
        raise BrokenAzureDataSource("found '%d' ProvisioningSection items" %
 
393
                                    len(results))
 
394
    provSection = results[0]
 
395
 
 
396
    lpcs_nodes = find_child(provSection,
 
397
        lambda n: n.localName == "LinuxProvisioningConfigurationSet")
 
398
 
 
399
    if len(results) == 0:
 
400
        raise NonAzureDataSource("No LinuxProvisioningConfigurationSet")
 
401
    if len(results) > 1:
 
402
        raise BrokenAzureDataSource("found '%d' %ss" %
 
403
                                    ("LinuxProvisioningConfigurationSet",
 
404
                                     len(results)))
 
405
    lpcs = lpcs_nodes[0]
 
406
 
 
407
    if not lpcs.hasChildNodes():
 
408
        raise BrokenAzureDataSource("no child nodes of configuration set")
 
409
 
 
410
    md_props = 'seedfrom'
 
411
    md = {'azure_data': {}}
 
412
    cfg = {}
 
413
    ud = ""
 
414
    password = None
 
415
    username = None
 
416
 
 
417
    for child in lpcs.childNodes:
 
418
        if child.nodeType == dom.TEXT_NODE or not child.localName:
 
419
            continue
 
420
 
 
421
        name = child.localName.lower()
 
422
 
 
423
        simple = False
 
424
        value = ""
 
425
        if (len(child.childNodes) == 1 and
 
426
            child.childNodes[0].nodeType == dom.TEXT_NODE):
 
427
            simple = True
 
428
            value = child.childNodes[0].wholeText
 
429
 
 
430
        attrs = dict([(k, v) for k, v in child.attributes.items()])
 
431
 
 
432
        # we accept either UserData or CustomData.  If both are present
 
433
        # then behavior is undefined.
 
434
        if name == "userdata" or name == "customdata":
 
435
            if attrs.get('encoding') in (None, "base64"):
 
436
                ud = base64.b64decode(''.join(value.split()))
 
437
            else:
 
438
                ud = value
 
439
        elif name == "username":
 
440
            username = value
 
441
        elif name == "userpassword":
 
442
            password = value
 
443
        elif name == "hostname":
 
444
            md['local-hostname'] = value
 
445
        elif name == "dscfg":
 
446
            if attrs.get('encoding') in (None, "base64"):
 
447
                dscfg = base64.b64decode(''.join(value.split()))
 
448
            else:
 
449
                dscfg = value
 
450
            cfg['datasource'] = {DS_NAME: util.load_yaml(dscfg, default={})}
 
451
        elif name == "ssh":
 
452
            cfg['_pubkeys'] = load_azure_ovf_pubkeys(child)
 
453
        elif name == "disablesshpasswordauthentication":
 
454
            cfg['ssh_pwauth'] = util.is_false(value)
 
455
        elif simple:
 
456
            if name in md_props:
 
457
                md[name] = value
 
458
            else:
 
459
                md['azure_data'][name] = value
 
460
 
 
461
    defuser = {}
 
462
    if username:
 
463
        defuser['name'] = username
 
464
    if password:
 
465
        defuser['passwd'] = encrypt_pass(password)
 
466
        defuser['lock_passwd'] = False
 
467
 
 
468
    if defuser:
 
469
        cfg['system_info'] = {'default_user': defuser}
 
470
 
 
471
    if 'ssh_pwauth' not in cfg and password:
 
472
        cfg['ssh_pwauth'] = True
 
473
 
 
474
    return (md, ud, cfg)
 
475
 
 
476
 
 
477
def encrypt_pass(password, salt_id="$6$"):
 
478
    return crypt.crypt(password, salt_id + util.rand_str(strlen=16))
 
479
 
 
480
 
 
481
def list_possible_azure_ds_devs():
 
482
    # return a sorted list of devices that might have a azure datasource
 
483
    devlist = []
 
484
    for fstype in ("iso9660", "udf"):
 
485
        devlist.extend(util.find_devs_with("TYPE=%s" % fstype))
 
486
 
 
487
    devlist.sort(reverse=True)
 
488
    return devlist
 
489
 
 
490
 
 
491
def load_azure_ds_dir(source_dir):
 
492
    ovf_file = os.path.join(source_dir, "ovf-env.xml")
 
493
 
 
494
    if not os.path.isfile(ovf_file):
 
495
        raise NonAzureDataSource("No ovf-env file found")
 
496
 
 
497
    with open(ovf_file, "r") as fp:
 
498
        contents = fp.read()
 
499
 
 
500
    md, ud, cfg = read_azure_ovf(contents)
 
501
    return (md, ud, cfg, {'ovf-env.xml': contents})
 
502
 
 
503
 
 
504
def iid_from_shared_config(path):
 
505
    with open(path, "rb") as fp:
 
506
        content = fp.read()
 
507
    return iid_from_shared_config_content(content)
 
508
 
 
509
 
 
510
def iid_from_shared_config_content(content):
 
511
    """
 
512
    find INSTANCE_ID in:
 
513
    <?xml version="1.0" encoding="utf-8"?>
 
514
    <SharedConfig version="1.0.0.0" goalStateIncarnation="1">
 
515
      <Deployment name="INSTANCE_ID" guid="{...}" incarnation="0">
 
516
        <Service name="..." guid="{00000000-0000-0000-0000-000000000000}" />
 
517
    """
 
518
    dom = minidom.parseString(content)
 
519
    depnode = single_node_at_path(dom, ["SharedConfig", "Deployment"])
 
520
    return depnode.attributes.get('name').value
 
521
 
 
522
 
 
523
class BrokenAzureDataSource(Exception):
 
524
    pass
 
525
 
 
526
 
 
527
class NonAzureDataSource(Exception):
 
528
    pass
 
529
 
 
530
 
 
531
# Used to match classes to dependencies
 
532
datasources = [
 
533
  (DataSourceAzureNet, (sources.DEP_FILESYSTEM, sources.DEP_NETWORK)),
 
534
]
 
535
 
 
536
 
 
537
# Return a list of data sources that match this set of dependencies
 
538
def get_datasource_list(depends):
 
539
    return sources.list_from_depends(depends, datasources)