~smoser/ubuntu/quantal/cloud-init/sru

« back to all changes in this revision

Viewing changes to .pc/lp-1100545-allow-config-drive-from-cdrom.patch/cloudinit/sources/DataSourceConfigDrive.py

  • Committer: Scott Moser
  • Date: 2013-01-18 15:28:08 UTC
  • Revision ID: smoser@ubuntu.com-20130118152808-jy5uq9pc79t82r85
Tags: 0.7.0-0ubuntu2.3~ppa0
debian/patches/lp-1100545-allow-config-drive-from-cdrom.patch:
in config-drive data to be provided from a CD-ROM (LP: #1100545)

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
# vi: ts=4 expandtab
 
2
#
 
3
#    Copyright (C) 2012 Canonical Ltd.
 
4
#    Copyright (C) 2012 Yahoo! Inc.
 
5
#
 
6
#    Author: Scott Moser <scott.moser@canonical.com>
 
7
#    Author: Joshua Harlow <harlowja@yahoo-inc.com>
 
8
#
 
9
#    This program is free software: you can redistribute it and/or modify
 
10
#    it under the terms of the GNU General Public License version 3, as
 
11
#    published by the Free Software Foundation.
 
12
#
 
13
#    This program is distributed in the hope that it will be useful,
 
14
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
 
15
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 
16
#    GNU General Public License for more details.
 
17
#
 
18
#    You should have received a copy of the GNU General Public License
 
19
#    along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
20
 
 
21
import json
 
22
import os
 
23
 
 
24
from cloudinit import log as logging
 
25
from cloudinit import sources
 
26
from cloudinit import util
 
27
 
 
28
LOG = logging.getLogger(__name__)
 
29
 
 
30
# Various defaults/constants...
 
31
DEFAULT_IID = "iid-dsconfigdrive"
 
32
DEFAULT_MODE = 'pass'
 
33
CFG_DRIVE_FILES_V1 = [
 
34
    "etc/network/interfaces",
 
35
    "root/.ssh/authorized_keys",
 
36
    "meta.js",
 
37
]
 
38
DEFAULT_METADATA = {
 
39
    "instance-id": DEFAULT_IID,
 
40
}
 
41
VALID_DSMODES = ("local", "net", "pass", "disabled")
 
42
 
 
43
 
 
44
class DataSourceConfigDrive(sources.DataSource):
 
45
    def __init__(self, sys_cfg, distro, paths):
 
46
        sources.DataSource.__init__(self, sys_cfg, distro, paths)
 
47
        self.source = None
 
48
        self.dsmode = 'local'
 
49
        self.seed_dir = os.path.join(paths.seed_dir, 'config_drive')
 
50
        self.version = None
 
51
 
 
52
    def __str__(self):
 
53
        mstr = "%s [%s,ver=%s]" % (util.obj_name(self), self.dsmode,
 
54
                                   self.version)
 
55
        mstr += "[source=%s]" % (self.source)
 
56
        return mstr
 
57
 
 
58
    def get_data(self):
 
59
        found = None
 
60
        md = {}
 
61
 
 
62
        results = {}
 
63
        if os.path.isdir(self.seed_dir):
 
64
            try:
 
65
                results = read_config_drive_dir(self.seed_dir)
 
66
                found = self.seed_dir
 
67
            except NonConfigDriveDir:
 
68
                util.logexc(LOG, "Failed reading config drive from %s",
 
69
                            self.seed_dir)
 
70
        if not found:
 
71
            devlist = find_candidate_devs()
 
72
            for dev in devlist:
 
73
                try:
 
74
                    results = util.mount_cb(dev, read_config_drive_dir)
 
75
                    found = dev
 
76
                    break
 
77
                except (NonConfigDriveDir, util.MountFailedError):
 
78
                    pass
 
79
                except BrokenConfigDriveDir:
 
80
                    util.logexc(LOG, "broken config drive: %s", dev)
 
81
 
 
82
        if not found:
 
83
            return False
 
84
 
 
85
        md = results['metadata']
 
86
        md = util.mergedict(md, DEFAULT_METADATA)
 
87
 
 
88
        # Perform some metadata 'fixups'
 
89
        #
 
90
        # OpenStack uses the 'hostname' key
 
91
        # while most of cloud-init uses the metadata
 
92
        # 'local-hostname' key instead so if it doesn't
 
93
        # exist we need to make sure its copied over.
 
94
        for (tgt, src) in [('local-hostname', 'hostname')]:
 
95
            if tgt not in md and src in md:
 
96
                md[tgt] = md[src]
 
97
 
 
98
        user_dsmode = results.get('dsmode', None)
 
99
        if user_dsmode not in VALID_DSMODES + (None,):
 
100
            LOG.warn("user specified invalid mode: %s" % user_dsmode)
 
101
            user_dsmode = None
 
102
 
 
103
        dsmode = get_ds_mode(cfgdrv_ver=results['cfgdrive_ver'],
 
104
                             ds_cfg=self.ds_cfg.get('dsmode'),
 
105
                             user=user_dsmode)
 
106
 
 
107
        if dsmode == "disabled":
 
108
            # most likely user specified
 
109
            return False
 
110
 
 
111
        # TODO(smoser): fix this, its dirty.
 
112
        # we want to do some things (writing files and network config)
 
113
        # only on first boot, and even then, we want to do so in the
 
114
        # local datasource (so they happen earlier) even if the configured
 
115
        # dsmode is 'net' or 'pass'. To do this, we check the previous
 
116
        # instance-id
 
117
        prev_iid = get_previous_iid(self.paths)
 
118
        cur_iid = md['instance-id']
 
119
 
 
120
        if ('network_config' in results and self.dsmode == "local" and
 
121
            prev_iid != cur_iid):
 
122
            LOG.debug("Updating network interfaces from config drive (%s)",
 
123
                      dsmode)
 
124
            self.distro.apply_network(results['network_config'])
 
125
 
 
126
        # file writing occurs in local mode (to be as early as possible)
 
127
        if self.dsmode == "local" and prev_iid != cur_iid and results['files']:
 
128
            LOG.debug("writing injected files")
 
129
            try:
 
130
                write_files(results['files'])
 
131
            except:
 
132
                util.logexc(LOG, "Failed writing files")
 
133
 
 
134
        # dsmode != self.dsmode here if:
 
135
        #  * dsmode = "pass",  pass means it should only copy files and then
 
136
        #    pass to another datasource
 
137
        #  * dsmode = "net" and self.dsmode = "local"
 
138
        #    so that user boothooks would be applied with network, the
 
139
        #    local datasource just gets out of the way, and lets the net claim
 
140
        if dsmode != self.dsmode:
 
141
            LOG.debug("%s: not claiming datasource, dsmode=%s", self, dsmode)
 
142
            return False
 
143
 
 
144
        self.source = found
 
145
        self.metadata = md
 
146
        self.userdata_raw = results.get('userdata')
 
147
        self.version = results['cfgdrive_ver']
 
148
 
 
149
        return True
 
150
 
 
151
    def get_public_ssh_keys(self):
 
152
        name = "public_keys"
 
153
        if self.version == 1:
 
154
            name = "public-keys"
 
155
        return sources.normalize_pubkey_data(self.metadata.get(name))
 
156
 
 
157
 
 
158
class DataSourceConfigDriveNet(DataSourceConfigDrive):
 
159
    def __init__(self, sys_cfg, distro, paths):
 
160
        DataSourceConfigDrive.__init__(self, sys_cfg, distro, paths)
 
161
        self.dsmode = 'net'
 
162
 
 
163
 
 
164
class NonConfigDriveDir(Exception):
 
165
    pass
 
166
 
 
167
 
 
168
class BrokenConfigDriveDir(Exception):
 
169
    pass
 
170
 
 
171
 
 
172
def find_candidate_devs():
 
173
    """Return a list of devices that may contain the config drive.
 
174
 
 
175
    The returned list is sorted by search order where the first item has
 
176
    should be searched first (highest priority)
 
177
 
 
178
    config drive v1:
 
179
       Per documentation, this is "associated as the last available disk on the
 
180
       instance", and should be VFAT.
 
181
       Currently, we do not restrict search list to "last available disk"
 
182
 
 
183
    config drive v2:
 
184
       Disk should be:
 
185
        * either vfat or iso9660 formated
 
186
        * labeled with 'config-2'
 
187
    """
 
188
 
 
189
    by_fstype = (util.find_devs_with("TYPE=vfat") +
 
190
                 util.find_devs_with("TYPE=iso9660"))
 
191
    by_label = util.find_devs_with("LABEL=config-2")
 
192
 
 
193
    # give preference to "last available disk" (vdb over vda)
 
194
    # note, this is not a perfect rendition of that.
 
195
    by_fstype.sort(reverse=True)
 
196
    by_label.sort(reverse=True)
 
197
 
 
198
    # combine list of items by putting by-label items first
 
199
    # followed by fstype items, but with dupes removed
 
200
    combined = (by_label + [d for d in by_fstype if d not in by_label])
 
201
 
 
202
    # We are looking for block device (sda, not sda1), ignore partitions
 
203
    combined = [d for d in combined if d[-1] not in "0123456789"]
 
204
 
 
205
    return combined
 
206
 
 
207
 
 
208
def read_config_drive_dir(source_dir):
 
209
    last_e = NonConfigDriveDir("Not found")
 
210
    for finder in (read_config_drive_dir_v2, read_config_drive_dir_v1):
 
211
        try:
 
212
            data = finder(source_dir)
 
213
            return data
 
214
        except NonConfigDriveDir as exc:
 
215
            last_e = exc
 
216
    raise last_e
 
217
 
 
218
 
 
219
def read_config_drive_dir_v2(source_dir, version="2012-08-10"):
 
220
 
 
221
    if (not os.path.isdir(os.path.join(source_dir, "openstack", version)) and
 
222
        os.path.isdir(os.path.join(source_dir, "openstack", "latest"))):
 
223
        LOG.warn("version '%s' not available, attempting to use 'latest'" %
 
224
                 version)
 
225
        version = "latest"
 
226
 
 
227
    datafiles = (
 
228
        ('metadata',
 
229
         "openstack/%s/meta_data.json" % version, True, json.loads),
 
230
        ('userdata', "openstack/%s/user_data" % version, False, None),
 
231
        ('ec2-metadata', "ec2/latest/metadata.json", False, json.loads),
 
232
    )
 
233
 
 
234
    results = {'userdata': None}
 
235
    for (name, path, required, process) in datafiles:
 
236
        fpath = os.path.join(source_dir, path)
 
237
        data = None
 
238
        found = False
 
239
        if os.path.isfile(fpath):
 
240
            try:
 
241
                with open(fpath) as fp:
 
242
                    data = fp.read()
 
243
            except Exception as exc:
 
244
                raise BrokenConfigDriveDir("failed to read: %s" % fpath)
 
245
            found = True
 
246
        elif required:
 
247
            raise NonConfigDriveDir("missing mandatory %s" % fpath)
 
248
 
 
249
        if found and process:
 
250
            try:
 
251
                data = process(data)
 
252
            except Exception as exc:
 
253
                raise BrokenConfigDriveDir("failed to process: %s" % fpath)
 
254
 
 
255
        if found:
 
256
            results[name] = data
 
257
 
 
258
    # instance-id is 'uuid' for openstack. just copy it to instance-id.
 
259
    if 'instance-id' not in results['metadata']:
 
260
        try:
 
261
            results['metadata']['instance-id'] = results['metadata']['uuid']
 
262
        except KeyError:
 
263
            raise BrokenConfigDriveDir("No uuid entry in metadata")
 
264
 
 
265
    def read_content_path(item):
 
266
        # do not use os.path.join here, as content_path starts with /
 
267
        cpath = os.path.sep.join((source_dir, "openstack",
 
268
                                  "./%s" % item['content_path']))
 
269
        with open(cpath) as fp:
 
270
            return(fp.read())
 
271
 
 
272
    files = {}
 
273
    try:
 
274
        for item in results['metadata'].get('files', {}):
 
275
            files[item['path']] = read_content_path(item)
 
276
 
 
277
        # the 'network_config' item in metadata is a content pointer
 
278
        # to the network config that should be applied.
 
279
        # in folsom, it is just a '/etc/network/interfaces' file.
 
280
        item = results['metadata'].get("network_config", None)
 
281
        if item:
 
282
            results['network_config'] = read_content_path(item)
 
283
    except Exception as exc:
 
284
        raise BrokenConfigDriveDir("failed to read file %s: %s" % (item, exc))
 
285
 
 
286
    # to openstack, user can specify meta ('nova boot --meta=key=value') and
 
287
    # those will appear under metadata['meta'].
 
288
    # if they specify 'dsmode' they're indicating the mode that they intend
 
289
    # for this datasource to operate in.
 
290
    try:
 
291
        results['dsmode'] = results['metadata']['meta']['dsmode']
 
292
    except KeyError:
 
293
        pass
 
294
 
 
295
    results['files'] = files
 
296
    results['cfgdrive_ver'] = 2
 
297
    return results
 
298
 
 
299
 
 
300
def read_config_drive_dir_v1(source_dir):
 
301
    """
 
302
    read source_dir, and return a tuple with metadata dict, user-data,
 
303
    files and version (1).  If not a valid dir, raise a NonConfigDriveDir
 
304
    """
 
305
 
 
306
    found = {}
 
307
    for af in CFG_DRIVE_FILES_V1:
 
308
        fn = os.path.join(source_dir, af)
 
309
        if os.path.isfile(fn):
 
310
            found[af] = fn
 
311
 
 
312
    if len(found) == 0:
 
313
        raise NonConfigDriveDir("%s: %s" % (source_dir, "no files found"))
 
314
 
 
315
    md = {}
 
316
    keydata = ""
 
317
    if "etc/network/interfaces" in found:
 
318
        fn = found["etc/network/interfaces"]
 
319
        md['network_config'] = util.load_file(fn)
 
320
 
 
321
    if "root/.ssh/authorized_keys" in found:
 
322
        fn = found["root/.ssh/authorized_keys"]
 
323
        keydata = util.load_file(fn)
 
324
 
 
325
    meta_js = {}
 
326
    if "meta.js" in found:
 
327
        fn = found['meta.js']
 
328
        content = util.load_file(fn)
 
329
        try:
 
330
            # Just check if its really json...
 
331
            meta_js = json.loads(content)
 
332
            if not isinstance(meta_js, (dict)):
 
333
                raise TypeError("Dict expected for meta.js root node")
 
334
        except (ValueError, TypeError) as e:
 
335
            raise NonConfigDriveDir("%s: %s, %s" %
 
336
                (source_dir, "invalid json in meta.js", e))
 
337
        md['meta_js'] = content
 
338
 
 
339
    # keydata in meta_js is preferred over "injected"
 
340
    keydata = meta_js.get('public-keys', keydata)
 
341
    if keydata:
 
342
        lines = keydata.splitlines()
 
343
        md['public-keys'] = [l for l in lines
 
344
            if len(l) and not l.startswith("#")]
 
345
 
 
346
    # config-drive-v1 has no way for openstack to provide the instance-id
 
347
    # so we copy that into metadata from the user input
 
348
    if 'instance-id' in meta_js:
 
349
        md['instance-id'] = meta_js['instance-id']
 
350
 
 
351
    results = {'cfgdrive_ver': 1, 'metadata': md}
 
352
 
 
353
    # allow the user to specify 'dsmode' in a meta tag
 
354
    if 'dsmode' in meta_js:
 
355
        results['dsmode'] = meta_js['dsmode']
 
356
 
 
357
    # config-drive-v1 has no way of specifying user-data, so the user has
 
358
    # to cheat and stuff it in a meta tag also.
 
359
    results['userdata'] = meta_js.get('user-data')
 
360
 
 
361
    # this implementation does not support files
 
362
    # (other than network/interfaces and authorized_keys)
 
363
    results['files'] = []
 
364
 
 
365
    return results
 
366
 
 
367
 
 
368
def get_ds_mode(cfgdrv_ver, ds_cfg=None, user=None):
 
369
    """Determine what mode should be used.
 
370
    valid values are 'pass', 'disabled', 'local', 'net'
 
371
    """
 
372
    # user passed data trumps everything
 
373
    if user is not None:
 
374
        return user
 
375
 
 
376
    if ds_cfg is not None:
 
377
        return ds_cfg
 
378
 
 
379
    # at config-drive version 1, the default behavior was pass.  That
 
380
    # meant to not use use it as primary data source, but expect a ec2 metadata
 
381
    # source. for version 2, we default to 'net', which means
 
382
    # the DataSourceConfigDriveNet, would be used.
 
383
    #
 
384
    # this could change in the future.  If there was definitive metadata
 
385
    # that indicated presense of an openstack metadata service, then
 
386
    # we could change to 'pass' by default also. The motivation for that
 
387
    # would be 'cloud-init query' as the web service could be more dynamic
 
388
    if cfgdrv_ver == 1:
 
389
        return "pass"
 
390
    return "net"
 
391
 
 
392
 
 
393
def get_previous_iid(paths):
 
394
    # interestingly, for this purpose the "previous" instance-id is the current
 
395
    # instance-id.  cloud-init hasn't moved them over yet as this datasource
 
396
    # hasn't declared itself found.
 
397
    fname = os.path.join(paths.get_cpath('data'), 'instance-id')
 
398
    try:
 
399
        with open(fname) as fp:
 
400
            return fp.read()
 
401
    except IOError:
 
402
        return None
 
403
 
 
404
 
 
405
def write_files(files):
 
406
    for (name, content) in files.iteritems():
 
407
        if name[0] != os.sep:
 
408
            name = os.sep + name
 
409
        util.write_file(name, content, mode=0660)
 
410
 
 
411
 
 
412
# Used to match classes to dependencies
 
413
datasources = [
 
414
  (DataSourceConfigDrive, (sources.DEP_FILESYSTEM, )),
 
415
  (DataSourceConfigDriveNet, (sources.DEP_FILESYSTEM, sources.DEP_NETWORK)),
 
416
]
 
417
 
 
418
 
 
419
# Return a list of data sources that match this set of dependencies
 
420
def get_datasource_list(depends):
 
421
    return sources.list_from_depends(depends, datasources)