~smoser/cloud-init/trunk.lp1031065

« back to all changes in this revision

Viewing changes to cloudinit/sources/DataSourceConfigDrive.py

  • Committer: Scott Moser
  • Date: 2012-08-24 21:22:01 UTC
  • mfrom: (633.1.9 trunk.cfg-drive-2)
  • Revision ID: smoser@ubuntu.com-20120824212201-mkxn98gio4vikvks
add support for the config-drive-v2 datasource

config-drive-v2 was implemented in openstack at 
https://review.openstack.org/#/c/11184/ .  This adds support to
cloud-init for reading that.

Show diffs side-by-side

added added

removed removed

Lines of Context:
30
30
# Various defaults/constants...
31
31
DEFAULT_IID = "iid-dsconfigdrive"
32
32
DEFAULT_MODE = 'pass'
33
 
CFG_DRIVE_FILES = [
 
33
CFG_DRIVE_FILES_V1 = [
34
34
    "etc/network/interfaces",
35
35
    "root/.ssh/authorized_keys",
36
36
    "meta.js",
37
37
]
38
38
DEFAULT_METADATA = {
39
39
    "instance-id": DEFAULT_IID,
40
 
    "dsmode": DEFAULT_MODE,
41
40
}
42
 
CFG_DRIVE_DEV_ENV = 'CLOUD_INIT_CONFIG_DRIVE_DEVICE'
 
41
VALID_DSMODES = ("local", "net", "pass", "disabled")
43
42
 
44
43
 
45
44
class DataSourceConfigDrive(sources.DataSource):
46
45
    def __init__(self, sys_cfg, distro, paths):
47
46
        sources.DataSource.__init__(self, sys_cfg, distro, paths)
48
 
        self.seed = None
49
 
        self.cfg = {}
 
47
        self.source = None
50
48
        self.dsmode = 'local'
51
49
        self.seed_dir = os.path.join(paths.seed_dir, 'config_drive')
 
50
        self.version = None
52
51
 
53
52
    def __str__(self):
54
 
        mstr = "%s [%s]" % (util.obj_name(self), self.dsmode)
55
 
        mstr += "[seed=%s]" % (self.seed)
 
53
        mstr = "%s [%s,ver=%s]" % (util.obj_name(self), self.dsmode,
 
54
                                   self.version)
 
55
        mstr += "[source=%s]" % (self.source)
56
56
        return mstr
57
57
 
58
58
    def get_data(self):
59
59
        found = None
60
60
        md = {}
61
 
        ud = ""
62
61
 
 
62
        results = {}
63
63
        if os.path.isdir(self.seed_dir):
64
64
            try:
65
 
                (md, ud) = read_config_drive_dir(self.seed_dir)
 
65
                results = read_config_drive_dir(self.seed_dir)
66
66
                found = self.seed_dir
67
67
            except NonConfigDriveDir:
68
68
                util.logexc(LOG, "Failed reading config drive from %s",
69
69
                            self.seed_dir)
70
70
        if not found:
71
 
            dev = find_cfg_drive_device()
72
 
            if dev:
 
71
            devlist = find_candidate_devs()
 
72
            for dev in devlist:
73
73
                try:
74
 
                    (md, ud) = util.mount_cb(dev, read_config_drive_dir)
 
74
                    results = util.mount_cb(dev, read_config_drive_dir)
75
75
                    found = dev
 
76
                    break
76
77
                except (NonConfigDriveDir, util.MountFailedError):
77
78
                    pass
 
79
                except BrokenConfigDriveDir:
 
80
                    util.logexc(LOG, "broken config drive: %s", dev)
78
81
 
79
82
        if not found:
80
83
            return False
81
84
 
82
 
        if 'dsconfig' in md:
83
 
            self.cfg = md['dscfg']
84
 
 
 
85
        md = results['metadata']
85
86
        md = util.mergedict(md, DEFAULT_METADATA)
86
87
 
87
 
        # Update interfaces and ifup only on the local datasource
88
 
        # this way the DataSourceConfigDriveNet doesn't do it also.
89
 
        if 'network-interfaces' in md and self.dsmode == "local":
 
88
        user_dsmode = results.get('dsmode', None)
 
89
        if user_dsmode not in VALID_DSMODES + (None,):
 
90
            LOG.warn("user specified invalid mode: %s" % user_dsmode)
 
91
            user_dsmode = None
 
92
 
 
93
        dsmode = get_ds_mode(cfgdrv_ver=results['cfgdrive_ver'],
 
94
                             ds_cfg=self.ds_cfg.get('dsmode'),
 
95
                             user=user_dsmode)
 
96
 
 
97
        if dsmode == "disabled":
 
98
            # most likely user specified
 
99
            return False
 
100
 
 
101
        # TODO(smoser): fix this, its dirty.
 
102
        # we want to do some things (writing files and network config)
 
103
        # only on first boot, and even then, we want to do so in the
 
104
        # local datasource (so they happen earlier) even if the configured
 
105
        # dsmode is 'net' or 'pass'. To do this, we check the previous
 
106
        # instance-id
 
107
        prev_iid = get_previous_iid(self.paths)
 
108
        cur_iid = md['instance-id']
 
109
 
 
110
        if ('network_config' in results and self.dsmode == "local" and
 
111
            prev_iid != cur_iid):
90
112
            LOG.debug("Updating network interfaces from config drive (%s)",
91
 
                     md['dsmode'])
92
 
            self.distro.apply_network(md['network-interfaces'])
93
 
 
94
 
        self.seed = found
 
113
                      dsmode)
 
114
            self.distro.apply_network(results['network_config'])
 
115
 
 
116
        # file writing occurs in local mode (to be as early as possible)
 
117
        if self.dsmode == "local" and prev_iid != cur_iid and results['files']:
 
118
            LOG.debug("writing injected files")
 
119
            try:
 
120
                write_files(results['files'])
 
121
            except:
 
122
                util.logexc(LOG, "Failed writing files")
 
123
 
 
124
        # dsmode != self.dsmode here if:
 
125
        #  * dsmode = "pass",  pass means it should only copy files and then
 
126
        #    pass to another datasource
 
127
        #  * dsmode = "net" and self.dsmode = "local"
 
128
        #    so that user boothooks would be applied with network, the
 
129
        #    local datasource just gets out of the way, and lets the net claim
 
130
        if dsmode != self.dsmode:
 
131
            LOG.debug("%s: not claiming datasource, dsmode=%s", self, dsmode)
 
132
            return False
 
133
 
 
134
        self.source = found
95
135
        self.metadata = md
96
 
        self.userdata_raw = ud
97
 
 
98
 
        if md['dsmode'] == self.dsmode:
99
 
            return True
100
 
 
101
 
        LOG.debug("%s: not claiming datasource, dsmode=%s", self, md['dsmode'])
102
 
        return False
 
136
        self.userdata_raw = results.get('userdata')
 
137
        self.version = results['cfgdrive_ver']
 
138
 
 
139
        return True
103
140
 
104
141
    def get_public_ssh_keys(self):
105
142
        if not 'public-keys' in self.metadata:
106
143
            return []
107
144
        return self.metadata['public-keys']
108
145
 
109
 
    # The data sources' config_obj is a cloud-config formated
110
 
    # object that came to it from ways other than cloud-config
111
 
    # because cloud-config content would be handled elsewhere
112
 
    def get_config_obj(self):
113
 
        return self.cfg
114
 
 
115
146
 
116
147
class DataSourceConfigDriveNet(DataSourceConfigDrive):
117
148
    def __init__(self, sys_cfg, distro, paths):
123
154
    pass
124
155
 
125
156
 
126
 
def find_cfg_drive_device():
127
 
    """Get the config drive device.  Return a string like '/dev/vdb'
128
 
       or None (if there is no non-root device attached). This does not
129
 
       check the contents, only reports that if there *were* a config_drive
130
 
       attached, it would be this device.
131
 
       Note: per config_drive documentation, this is
132
 
       "associated as the last available disk on the instance"
 
157
class BrokenConfigDriveDir(Exception):
 
158
    pass
 
159
 
 
160
 
 
161
def find_candidate_devs():
 
162
    """Return a list of devices that may contain the config drive.
 
163
 
 
164
    The returned list is sorted by search order where the first item has
 
165
    should be searched first (highest priority)
 
166
 
 
167
    config drive v1:
 
168
       Per documentation, this is "associated as the last available disk on the
 
169
       instance", and should be VFAT.
 
170
       Currently, we do not restrict search list to "last available disk"
 
171
 
 
172
    config drive v2:
 
173
       Disk should be:
 
174
        * either vfat or iso9660 formated
 
175
        * labeled with 'config-2'
133
176
    """
134
177
 
135
 
    # This seems to be for debugging??
136
 
    if CFG_DRIVE_DEV_ENV in os.environ:
137
 
        return os.environ[CFG_DRIVE_DEV_ENV]
138
 
 
139
 
    # We are looking for a raw block device (sda, not sda1) with a vfat
140
 
    # filesystem on it....
141
 
    letters = "abcdefghijklmnopqrstuvwxyz"
142
 
    devs = util.find_devs_with("TYPE=vfat")
143
 
 
144
 
    # Filter out anything not ending in a letter (ignore partitions)
145
 
    devs = [f for f in devs if f[-1] in letters]
146
 
 
147
 
    # Sort them in reverse so "last" device is first
148
 
    devs.sort(reverse=True)
149
 
 
150
 
    if devs:
151
 
        return devs[0]
152
 
 
153
 
    return None
 
178
    by_fstype = (util.find_devs_with("TYPE=vfat") +
 
179
                 util.find_devs_with("TYPE=iso9660"))
 
180
    by_label = util.find_devs_with("LABEL=config-2")
 
181
 
 
182
    # give preference to "last available disk" (vdb over vda)
 
183
    # note, this is not a perfect rendition of that.
 
184
    by_fstype.sort(reverse=True)
 
185
    by_label.sort(reverse=True)
 
186
 
 
187
    # combine list of items by putting by-label items first
 
188
    # followed by fstype items, but with dupes removed
 
189
    combined = (by_label + [d for d in by_fstype if d not in by_label])
 
190
 
 
191
    # We are looking for block device (sda, not sda1), ignore partitions
 
192
    combined = [d for d in combined if d[-1] not in "0123456789"]
 
193
 
 
194
    return combined
154
195
 
155
196
 
156
197
def read_config_drive_dir(source_dir):
157
 
    """
158
 
    read_config_drive_dir(source_dir):
159
 
       read source_dir, and return a tuple with metadata dict and user-data
160
 
       string populated.  If not a valid dir, raise a NonConfigDriveDir
161
 
    """
162
 
 
163
 
    # TODO(harlowja): fix this for other operating systems...
164
 
    # Ie: this is where https://fedorahosted.org/netcf/ or similar should
165
 
    # be hooked in... (or could be)
 
198
    last_e = NonConfigDriveDir("Not found")
 
199
    for finder in (read_config_drive_dir_v2, read_config_drive_dir_v1):
 
200
        try:
 
201
            data = finder(source_dir)
 
202
            return data
 
203
        except NonConfigDriveDir as exc:
 
204
            last_e = exc
 
205
    raise last_e
 
206
 
 
207
 
 
208
def read_config_drive_dir_v2(source_dir, version="2012-08-10"):
 
209
 
 
210
    if (not os.path.isdir(os.path.join(source_dir, "openstack", version)) and
 
211
        os.path.isdir(os.path.join(source_dir, "openstack", "latest"))):
 
212
        LOG.warn("version '%s' not available, attempting to use 'latest'" %
 
213
                 version)
 
214
        version = "latest"
 
215
 
 
216
    datafiles = (
 
217
        ('metadata',
 
218
         "openstack/%s/meta_data.json" % version, True, json.loads),
 
219
        ('userdata', "openstack/%s/user_data" % version, False, None),
 
220
        ('ec2-metadata', "ec2/latest/metadata.json", False, json.loads),
 
221
    )
 
222
 
 
223
    results = {'userdata': None}
 
224
    for (name, path, required, process) in datafiles:
 
225
        fpath = os.path.join(source_dir, path)
 
226
        data = None
 
227
        found = False
 
228
        if os.path.isfile(fpath):
 
229
            try:
 
230
                with open(fpath) as fp:
 
231
                    data = fp.read()
 
232
            except Exception as exc:
 
233
                raise BrokenConfigDriveDir("failed to read: %s" % fpath)
 
234
            found = True
 
235
        elif required:
 
236
            raise NonConfigDriveDir("missing mandatory %s" % fpath)
 
237
 
 
238
        if found and process:
 
239
            try:
 
240
                data = process(data)
 
241
            except Exception as exc:
 
242
                raise BrokenConfigDriveDir("failed to process: %s" % fpath)
 
243
 
 
244
        if found:
 
245
            results[name] = data
 
246
 
 
247
    # instance-id is 'uuid' for openstack. just copy it to instance-id.
 
248
    if 'instance-id' not in results['metadata']:
 
249
        try:
 
250
            results['metadata']['instance-id'] = results['metadata']['uuid']
 
251
        except KeyError:
 
252
            raise BrokenConfigDriveDir("No uuid entry in metadata")
 
253
 
 
254
    def read_content_path(item):
 
255
        # do not use os.path.join here, as content_path starts with /
 
256
        cpath = os.path.sep.join((source_dir, "openstack",
 
257
                                  "./%s" % item['content_path']))
 
258
        with open(cpath) as fp:
 
259
            return(fp.read())
 
260
 
 
261
    files = {}
 
262
    try:
 
263
        for item in results['metadata'].get('files', {}):
 
264
            files[item['path']] = read_content_path(item)
 
265
 
 
266
        # the 'network_config' item in metadata is a content pointer
 
267
        # to the network config that should be applied.
 
268
        # in folsom, it is just a '/etc/network/interfaces' file.
 
269
        item = results['metadata'].get("network_config", None)
 
270
        if item:
 
271
            results['network_config'] = read_content_path(item)
 
272
    except Exception as exc:
 
273
        raise BrokenConfigDriveDir("failed to read file %s: %s" % (item, exc))
 
274
 
 
275
    # to openstack, user can specify meta ('nova boot --meta=key=value') and
 
276
    # those will appear under metadata['meta'].
 
277
    # if they specify 'dsmode' they're indicating the mode that they intend
 
278
    # for this datasource to operate in.
 
279
    try:
 
280
        results['dsmode'] = results['metadata']['meta']['dsmode']
 
281
    except KeyError:
 
282
        pass
 
283
 
 
284
    results['files'] = files
 
285
    results['cfgdrive_ver'] = 2
 
286
    return results
 
287
 
 
288
 
 
289
def read_config_drive_dir_v1(source_dir):
 
290
    """
 
291
    read source_dir, and return a tuple with metadata dict, user-data,
 
292
    files and version (1).  If not a valid dir, raise a NonConfigDriveDir
 
293
    """
 
294
 
166
295
    found = {}
167
 
    for af in CFG_DRIVE_FILES:
 
296
    for af in CFG_DRIVE_FILES_V1:
168
297
        fn = os.path.join(source_dir, af)
169
298
        if os.path.isfile(fn):
170
299
            found[af] = fn
173
302
        raise NonConfigDriveDir("%s: %s" % (source_dir, "no files found"))
174
303
 
175
304
    md = {}
176
 
    ud = ""
177
305
    keydata = ""
178
306
    if "etc/network/interfaces" in found:
179
307
        fn = found["etc/network/interfaces"]
180
 
        md['network-interfaces'] = util.load_file(fn)
 
308
        md['network_config'] = util.load_file(fn)
181
309
 
182
310
    if "root/.ssh/authorized_keys" in found:
183
311
        fn = found["root/.ssh/authorized_keys"]
197
325
                (source_dir, "invalid json in meta.js", e))
198
326
        md['meta_js'] = content
199
327
 
200
 
    # Key data override??
 
328
    # keydata in meta_js is preferred over "injected"
201
329
    keydata = meta_js.get('public-keys', keydata)
202
330
    if keydata:
203
331
        lines = keydata.splitlines()
204
332
        md['public-keys'] = [l for l in lines
205
333
            if len(l) and not l.startswith("#")]
206
334
 
207
 
    for copy in ('dsmode', 'instance-id', 'dscfg'):
208
 
        if copy in meta_js:
209
 
            md[copy] = meta_js[copy]
210
 
 
211
 
    if 'user-data' in meta_js:
212
 
        ud = meta_js['user-data']
213
 
 
214
 
    return (md, ud)
 
335
    # config-drive-v1 has no way for openstack to provide the instance-id
 
336
    # so we copy that into metadata from the user input
 
337
    if 'instance-id' in meta_js:
 
338
        md['instance-id'] = meta_js['instance-id']
 
339
 
 
340
    results = {'cfgdrive_ver': 1, 'metadata': md}
 
341
 
 
342
    # allow the user to specify 'dsmode' in a meta tag
 
343
    if 'dsmode' in meta_js:
 
344
        results['dsmode'] = meta_js['dsmode']
 
345
 
 
346
    # config-drive-v1 has no way of specifying user-data, so the user has
 
347
    # to cheat and stuff it in a meta tag also.
 
348
    results['userdata'] = meta_js.get('user-data')
 
349
 
 
350
    # this implementation does not support files
 
351
    # (other than network/interfaces and authorized_keys)
 
352
    results['files'] = []
 
353
 
 
354
    return results
 
355
 
 
356
 
 
357
def get_ds_mode(cfgdrv_ver, ds_cfg=None, user=None):
 
358
    """Determine what mode should be used.
 
359
    valid values are 'pass', 'disabled', 'local', 'net'
 
360
    """
 
361
    # user passed data trumps everything
 
362
    if user is not None:
 
363
        return user
 
364
 
 
365
    if ds_cfg is not None:
 
366
        return ds_cfg
 
367
 
 
368
    # at config-drive version 1, the default behavior was pass.  That
 
369
    # meant to not use use it as primary data source, but expect a ec2 metadata
 
370
    # source. for version 2, we default to 'net', which means
 
371
    # the DataSourceConfigDriveNet, would be used.
 
372
    #
 
373
    # this could change in the future.  If there was definitive metadata
 
374
    # that indicated presense of an openstack metadata service, then
 
375
    # we could change to 'pass' by default also. The motivation for that
 
376
    # would be 'cloud-init query' as the web service could be more dynamic
 
377
    if cfgdrv_ver == 1:
 
378
        return "pass"
 
379
    return "net"
 
380
 
 
381
 
 
382
def get_previous_iid(paths):
 
383
    # interestingly, for this purpose the "previous" instance-id is the current
 
384
    # instance-id.  cloud-init hasn't moved them over yet as this datasource
 
385
    # hasn't declared itself found.
 
386
    fname = os.path.join(paths.get_cpath('data'), 'instance-id')
 
387
    try:
 
388
        with open(fname) as fp:
 
389
            return fp.read()
 
390
    except IOError:
 
391
        return None
 
392
 
 
393
 
 
394
def write_files(files):
 
395
    for (name, content) in files.iteritems():
 
396
        if name[0] != os.sep:
 
397
            name = os.sep + name
 
398
        util.write_file(name, content, mode=0660)
215
399
 
216
400
 
217
401
# Used to match classes to dependencies