From 085ce6b6fcc86101b3c5cc6fe40f66451034f453 Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Thu, 23 Aug 2012 17:04:46 -0400 Subject: initial functional unit test --- cloudinit/sources/DataSourceConfigDrive.py | 102 +++++++++-- .../unittests/test_datasource/test_configdrive.py | 194 +++++++++++++++++++++ 2 files changed, 286 insertions(+), 10 deletions(-) create mode 100644 tests/unittests/test_datasource/test_configdrive.py diff --git a/cloudinit/sources/DataSourceConfigDrive.py b/cloudinit/sources/DataSourceConfigDrive.py index 850b281c..677483d0 100644 --- a/cloudinit/sources/DataSourceConfigDrive.py +++ b/cloudinit/sources/DataSourceConfigDrive.py @@ -30,7 +30,7 @@ LOG = logging.getLogger(__name__) # Various defaults/constants... DEFAULT_IID = "iid-dsconfigdrive" DEFAULT_MODE = 'pass' -CFG_DRIVE_FILES = [ +CFG_DRIVE_FILES_V1 = [ "etc/network/interfaces", "root/.ssh/authorized_keys", "meta.js", @@ -49,9 +49,11 @@ class DataSourceConfigDrive(sources.DataSource): self.cfg = {} self.dsmode = 'local' self.seed_dir = os.path.join(paths.seed_dir, 'config_drive') + self.version = None def __str__(self): - mstr = "%s [%s]" % (util.obj_name(self), self.dsmode) + mstr = "%s [%s,ver=%s]" % (util.obj_name(self), self.dsmode, + self.version) mstr += "[seed=%s]" % (self.seed) return mstr @@ -62,17 +64,29 @@ class DataSourceConfigDrive(sources.DataSource): if os.path.isdir(self.seed_dir): try: - (md, ud) = read_config_drive_dir(self.seed_dir) + (md, ud, files, ver) = read_config_drive_dir(self.seed_dir) found = self.seed_dir except NonConfigDriveDir: util.logexc(LOG, "Failed reading config drive from %s", self.seed_dir) if not found: + fslist = util.find_devs_with("TYPE=vfat") + fslist.extend(util.find_devs_with("TYPE=iso9660")) + + label_list = util.find_devs_with("LABEL=config-2") + devlist = list(set(fslist) & set(label_list)) + dev = find_cfg_drive_device() - if dev: + if dev not in devlist: + devlist.append(dev) + + devlist.sort(reverse=True) + + for dev in devlist: try: - (md, ud) = util.mount_cb(dev, read_config_drive_dir) + (md, ud, files, ver) = util.mount_cb(dev, read_config_drive_dir) found = dev + break except (NonConfigDriveDir, util.MountFailedError): pass @@ -94,6 +108,7 @@ class DataSourceConfigDrive(sources.DataSource): self.seed = found self.metadata = md self.userdata_raw = ud + self.version = ver if md['dsmode'] == self.dsmode: return True @@ -122,6 +137,9 @@ class DataSourceConfigDriveNet(DataSourceConfigDrive): class NonConfigDriveDir(Exception): pass +class BrokenConfigDriveDir(Exception): + pass + def find_cfg_drive_device(): """Get the config drive device. Return a string like '/dev/vdb' @@ -154,17 +172,80 @@ def find_cfg_drive_device(): def read_config_drive_dir(source_dir): + last_e = NonConfigDriveDir("Not found") + for finder in (read_config_drive_dir_v2, read_config_drive_dir_v1): + try: + data = finder(source_dir) + return data + except NonConfigDriveDir as exc: + last_e = exc + raise last_e + +def read_config_drive_dir_v2(source_dir, version="latest"): + datafiles = ( + ('metadata', + "openstack/%s/meta_data.json" % version, True, json.loads), + ('userdata', "openstack/%s/user_data" % version, False, None), + ('ec2-metadata', "ec2/latest/metadata.json", False, json.loads), + ) + + results = {} + for (name, path, required, process) in datafiles: + fpath = os.path.join(source_dir, path) + data = None + found = False + if os.path.isfile(fpath): + try: + with open(fpath) as fp: + data = fp.read() + except Exception as exc: + raise BrokenConfigDriveDir("failed to read: %s" % fpath) + found = True + elif required: + raise BrokenConfigDriveDir("missing mandatory %s" % fpath) + + if found and process: + try: + data = process(data) + except Exception as exc: + raise BrokenConfigDriveDir("failed to process: %s" % fpath) + + if found: + results[name] = data + + def read_content_path(item): + # do not use os.path.join here, as content_path starts with / + cpath = os.path.sep.join((source_dir, "openstack", + "./%s" % item['content_path'])) + with open(cpath) as fp: + return(fp.read()) + + files = {} + try: + for item in results['metadata'].get('files',{}): + files[item['path']] = read_content_path(item) + + item = results['metadata'].get("network_config", None) + if item: + results['network_config'] = read_content_path(item) + except Exception as exc: + raise BrokenConfigDriveDir("failed to read file %s: %s" % (item, exc)) + + results['files'] = files + results['cfgdrive_ver'] = 2 + return results + +def read_config_drive_dir_v1(source_dir): """ - read_config_drive_dir(source_dir): - read source_dir, and return a tuple with metadata dict and user-data - string populated. If not a valid dir, raise a NonConfigDriveDir + read source_dir, and return a tuple with metadata dict, user-data, + files and version (1). If not a valid dir, raise a NonConfigDriveDir """ # TODO(harlowja): fix this for other operating systems... # Ie: this is where https://fedorahosted.org/netcf/ or similar should # be hooked in... (or could be) found = {} - for af in CFG_DRIVE_FILES: + for af in CFG_DRIVE_FILES_V1: fn = os.path.join(source_dir, af) if os.path.isfile(fn): found[af] = fn @@ -211,7 +292,8 @@ def read_config_drive_dir(source_dir): if 'user-data' in meta_js: ud = meta_js['user-data'] - return (md, ud) + # metadata, user-data, 'files', 1 + return {'metadata': md, 'userdata': ud, 'files': [], 'cfgdrive_ver': 1} # Used to match classes to dependencies diff --git a/tests/unittests/test_datasource/test_configdrive.py b/tests/unittests/test_datasource/test_configdrive.py new file mode 100644 index 00000000..f73200e5 --- /dev/null +++ b/tests/unittests/test_datasource/test_configdrive.py @@ -0,0 +1,194 @@ +from copy import copy +import json +import os +import os.path + +from cloudinit.sources import DataSourceConfigDrive +from cloudinit import url_helper +from mocker import MockerTestCase + + +PUBKEY = u'ssh-rsa AAAAB3NzaC1....sIkJhq8wdX+4I3A4cYbYP ubuntu@server-460\n' +EC2_META = { + 'ami-id': 'ami-00000001', + 'ami-launch-index': 0, + 'ami-manifest-path': 'FIXME', + 'block-device-mapping': { + 'ami': 'sda1', + 'ephemeral0': 'sda2', + 'root': '/dev/sda1', + 'swap': 'sda3'}, + 'hostname': 'sm-foo-test.novalocal', + 'instance-action': 'none', + 'instance-id': 'i-00000001', + 'instance-type': 'm1.tiny', + 'local-hostname': 'sm-foo-test.novalocal', + 'local-ipv4': None, + 'placement': {'availability-zone': 'nova'}, + 'public-hostname': 'sm-foo-test.novalocal', + 'public-ipv4': '', + 'public-keys': {'0': {'openssh-key': PUBKEY}}, + 'reservation-id': 'r-iru5qm4m', + 'security-groups': ['default'] +} +USER_DATA = '#!/bin/sh\necho This is user data\n' +OSTACK_META = { + 'availability_zone': 'nova', + 'files': [{'content_path': '/content/0000', 'path': '/etc/foo.cfg'}, + {'content_path': '/content/0001', 'path': '/etc/bar/bar.cfg'}], + 'hostname': 'sm-foo-test.novalocal', + 'meta': {'dsmode': 'local', 'my-meta': 'my-value'}, + 'name': 'sm-foo-test', + 'public_keys': {'mykey': PUBKEY}, + 'uuid': 'b0fa911b-69d4-4476-bbe2-1c92bff6535c'} + +CONTENT_0 = 'This is contents of /etc/foo.cfg\n' +CONTENT_1 = '# this is /etc/bar/bar.cfg\n' + +CFG_DRIVE_FILES_V2 = { + 'ec2/2009-04-04/meta-data.json': json.dumps(EC2_META), + 'ec2/2009-04-04/user-data': USER_DATA, + 'ec2/latest/meta-data.json': json.dumps(EC2_META), + 'ec2/latest/user-data': USER_DATA, + 'openstack/2012-08-10/meta_data.json': json.dumps(OSTACK_META), + 'openstack/2012-08-10/user_data': USER_DATA, + 'openstack/content/0000': CONTENT_0, + 'openstack/content/0001': CONTENT_1, + 'openstack/latest/meta_data.json': json.dumps(OSTACK_META), + 'openstack/latest/user_data': USER_DATA} + + +class TestConfigDriveDataSource(MockerTestCase): + + def setUp(self): + super(TestConfigDriveDataSource, self).setUp() + # Make a temp directoy for tests to use. + self.tmp = self.makeDir() + + def test_dir_valid(self): + """Verify a dir is read as such.""" + + my_d = os.path.join(self.tmp, "valid") + populate_dir(my_d, CFG_DRIVE_FILES_V2) + + found = DataSourceConfigDrive.read_config_drive_dir(my_d) + + self.assertEqual(USER_DATA, found['userdata']) + self.assertEqual(OSTACK_META, found['metadata']) + self.assertEqual(found['files']['/etc/foo.cfg'], CONTENT_0) + self.assertEqual(found['files']['/etc/bar/bar.cfg'], CONTENT_1) + + def test_seed_dir_valid_extra(self): + """Verify extra files do not affect datasource validity.""" + + my_d = os.path.join(self.tmp, "valid_extra") + data = copy(CFG_DRIVE_FILES_V2) + data["myfoofile"] = "myfoocontent" + + populate_dir(my_d, data) + +# (userdata, metadata) = DataSourceMAAS.read_maas_seed_dir(my_d) +# +# self.assertEqual(userdata, data['user-data']) +# for key in ('instance-id', 'local-hostname'): +# self.assertEqual(data[key], metadata[key]) +# +# # additional files should not just appear as keys in metadata atm +# self.assertFalse(('foo' in metadata)) +# +# def test_seed_dir_invalid(self): +# """Verify that invalid seed_dir raises MAASSeedDirMalformed.""" +# +# valid = {'instance-id': 'i-instanceid', +# 'local-hostname': 'test-hostname', 'user-data': ''} +# +# my_based = os.path.join(self.tmp, "valid_extra") +# +# # missing 'userdata' file +# my_d = "%s-01" % my_based +# invalid_data = copy(valid) +# del invalid_data['local-hostname'] +# populate_dir(my_d, invalid_data) +# self.assertRaises(DataSourceMAAS.MAASSeedDirMalformed, +# DataSourceMAAS.read_maas_seed_dir, my_d) +# +# # missing 'instance-id' +# my_d = "%s-02" % my_based +# invalid_data = copy(valid) +# del invalid_data['instance-id'] +# populate_dir(my_d, invalid_data) +# self.assertRaises(DataSourceMAAS.MAASSeedDirMalformed, +# DataSourceMAAS.read_maas_seed_dir, my_d) +# +# def test_seed_dir_none(self): +# """Verify that empty seed_dir raises MAASSeedDirNone.""" +# +# my_d = os.path.join(self.tmp, "valid_empty") +# self.assertRaises(DataSourceMAAS.MAASSeedDirNone, +# DataSourceMAAS.read_maas_seed_dir, my_d) +# +# def test_seed_dir_missing(self): +# """Verify that missing seed_dir raises MAASSeedDirNone.""" +# self.assertRaises(DataSourceMAAS.MAASSeedDirNone, +# DataSourceMAAS.read_maas_seed_dir, +# os.path.join(self.tmp, "nonexistantdirectory")) +# +# def test_seed_url_valid(self): +# """Verify that valid seed_url is read as such.""" +# valid = {'meta-data/instance-id': 'i-instanceid', +# 'meta-data/local-hostname': 'test-hostname', +# 'meta-data/public-keys': 'test-hostname', +# 'user-data': 'foodata'} +# valid_order = [ +# 'meta-data/local-hostname', +# 'meta-data/instance-id', +# 'meta-data/public-keys', +# 'user-data', +# ] +# my_seed = "http://example.com/xmeta" +# my_ver = "1999-99-99" +# my_headers = {'header1': 'value1', 'header2': 'value2'} +# +# def my_headers_cb(url): +# return my_headers +# +# mock_request = self.mocker.replace(url_helper.readurl, +# passthrough=False) +# +# for key in valid_order: +# url = "%s/%s/%s" % (my_seed, my_ver, key) +# mock_request(url, headers=my_headers, timeout=None) +# resp = valid.get(key) +# self.mocker.result(url_helper.UrlResponse(200, resp)) +# self.mocker.replay() +# +# (userdata, metadata) = DataSourceMAAS.read_maas_seed_url(my_seed, +# header_cb=my_headers_cb, version=my_ver) +# +# self.assertEqual("foodata", userdata) +# self.assertEqual(metadata['instance-id'], +# valid['meta-data/instance-id']) +# self.assertEqual(metadata['local-hostname'], +# valid['meta-data/local-hostname']) +# +# def test_seed_url_invalid(self): +# """Verify that invalid seed_url raises MAASSeedDirMalformed.""" +# pass +# +# def test_seed_url_missing(self): +# """Verify seed_url with no found entries raises MAASSeedDirNone.""" +# pass + + +def populate_dir(seed_dir, files): + os.mkdir(seed_dir) + for (name, content) in files.iteritems(): + path = os.path.join(seed_dir, name) + dirname = os.path.dirname(path) + if not os.path.isdir(dirname): + os.makedirs(dirname) + with open(path, "w") as fp: + fp.write(content) + fp.close() + +# vi: ts=4 expandtab -- cgit v1.2.3 From 5d57d93ff5e505e081abf2ff98569aa00fe679da Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Thu, 23 Aug 2012 17:13:50 -0400 Subject: fix pep8 and pylint --- cloudinit/sources/DataSourceConfigDrive.py | 16 +++++++++------- tests/unittests/test_datasource/test_configdrive.py | 3 +-- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/cloudinit/sources/DataSourceConfigDrive.py b/cloudinit/sources/DataSourceConfigDrive.py index 677483d0..cba499e4 100644 --- a/cloudinit/sources/DataSourceConfigDrive.py +++ b/cloudinit/sources/DataSourceConfigDrive.py @@ -60,11 +60,11 @@ class DataSourceConfigDrive(sources.DataSource): def get_data(self): found = None md = {} - ud = "" + results = {} if os.path.isdir(self.seed_dir): try: - (md, ud, files, ver) = read_config_drive_dir(self.seed_dir) + results = read_config_drive_dir(self.seed_dir) found = self.seed_dir except NonConfigDriveDir: util.logexc(LOG, "Failed reading config drive from %s", @@ -84,7 +84,7 @@ class DataSourceConfigDrive(sources.DataSource): for dev in devlist: try: - (md, ud, files, ver) = util.mount_cb(dev, read_config_drive_dir) + results = util.mount_cb(dev, read_config_drive_dir) found = dev break except (NonConfigDriveDir, util.MountFailedError): @@ -106,9 +106,8 @@ class DataSourceConfigDrive(sources.DataSource): self.distro.apply_network(md['network-interfaces']) self.seed = found - self.metadata = md - self.userdata_raw = ud - self.version = ver + self.metadata = results['metadata'] + self.userdata_raw = results.get('userdata') if md['dsmode'] == self.dsmode: return True @@ -137,6 +136,7 @@ class DataSourceConfigDriveNet(DataSourceConfigDrive): class NonConfigDriveDir(Exception): pass + class BrokenConfigDriveDir(Exception): pass @@ -181,6 +181,7 @@ def read_config_drive_dir(source_dir): last_e = exc raise last_e + def read_config_drive_dir_v2(source_dir, version="latest"): datafiles = ( ('metadata', @@ -222,7 +223,7 @@ def read_config_drive_dir_v2(source_dir, version="latest"): files = {} try: - for item in results['metadata'].get('files',{}): + for item in results['metadata'].get('files', {}): files[item['path']] = read_content_path(item) item = results['metadata'].get("network_config", None) @@ -235,6 +236,7 @@ def read_config_drive_dir_v2(source_dir, version="latest"): results['cfgdrive_ver'] = 2 return results + def read_config_drive_dir_v1(source_dir): """ read source_dir, and return a tuple with metadata dict, user-data, diff --git a/tests/unittests/test_datasource/test_configdrive.py b/tests/unittests/test_datasource/test_configdrive.py index f73200e5..a801826b 100644 --- a/tests/unittests/test_datasource/test_configdrive.py +++ b/tests/unittests/test_datasource/test_configdrive.py @@ -4,7 +4,6 @@ import os import os.path from cloudinit.sources import DataSourceConfigDrive -from cloudinit import url_helper from mocker import MockerTestCase @@ -54,7 +53,7 @@ CFG_DRIVE_FILES_V2 = { 'openstack/2012-08-10/user_data': USER_DATA, 'openstack/content/0000': CONTENT_0, 'openstack/content/0001': CONTENT_1, - 'openstack/latest/meta_data.json': json.dumps(OSTACK_META), + 'openstack/latest/meta_data.json': json.dumps(OSTACK_META), 'openstack/latest/user_data': USER_DATA} -- cgit v1.2.3 From aab21187a193b541ff009003b7337042a1d9d5c9 Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Thu, 23 Aug 2012 21:34:36 -0400 Subject: some more tests. --- cloudinit/sources/DataSourceConfigDrive.py | 2 +- .../unittests/test_datasource/test_configdrive.py | 127 ++++++--------------- 2 files changed, 36 insertions(+), 93 deletions(-) diff --git a/cloudinit/sources/DataSourceConfigDrive.py b/cloudinit/sources/DataSourceConfigDrive.py index cba499e4..1b10bc1e 100644 --- a/cloudinit/sources/DataSourceConfigDrive.py +++ b/cloudinit/sources/DataSourceConfigDrive.py @@ -203,7 +203,7 @@ def read_config_drive_dir_v2(source_dir, version="latest"): raise BrokenConfigDriveDir("failed to read: %s" % fpath) found = True elif required: - raise BrokenConfigDriveDir("missing mandatory %s" % fpath) + raise NonConfigDriveDir("missing mandatory %s" % fpath) if found and process: try: diff --git a/tests/unittests/test_datasource/test_configdrive.py b/tests/unittests/test_datasource/test_configdrive.py index a801826b..c5d678f9 100644 --- a/tests/unittests/test_datasource/test_configdrive.py +++ b/tests/unittests/test_datasource/test_configdrive.py @@ -82,101 +82,44 @@ class TestConfigDriveDataSource(MockerTestCase): my_d = os.path.join(self.tmp, "valid_extra") data = copy(CFG_DRIVE_FILES_V2) - data["myfoofile"] = "myfoocontent" + data["myfoofile.txt"] = "myfoocontent" + data["openstack/latest/random-file.txt"] = "random-content" populate_dir(my_d, data) -# (userdata, metadata) = DataSourceMAAS.read_maas_seed_dir(my_d) -# -# self.assertEqual(userdata, data['user-data']) -# for key in ('instance-id', 'local-hostname'): -# self.assertEqual(data[key], metadata[key]) -# -# # additional files should not just appear as keys in metadata atm -# self.assertFalse(('foo' in metadata)) -# -# def test_seed_dir_invalid(self): -# """Verify that invalid seed_dir raises MAASSeedDirMalformed.""" -# -# valid = {'instance-id': 'i-instanceid', -# 'local-hostname': 'test-hostname', 'user-data': ''} -# -# my_based = os.path.join(self.tmp, "valid_extra") -# -# # missing 'userdata' file -# my_d = "%s-01" % my_based -# invalid_data = copy(valid) -# del invalid_data['local-hostname'] -# populate_dir(my_d, invalid_data) -# self.assertRaises(DataSourceMAAS.MAASSeedDirMalformed, -# DataSourceMAAS.read_maas_seed_dir, my_d) -# -# # missing 'instance-id' -# my_d = "%s-02" % my_based -# invalid_data = copy(valid) -# del invalid_data['instance-id'] -# populate_dir(my_d, invalid_data) -# self.assertRaises(DataSourceMAAS.MAASSeedDirMalformed, -# DataSourceMAAS.read_maas_seed_dir, my_d) -# -# def test_seed_dir_none(self): -# """Verify that empty seed_dir raises MAASSeedDirNone.""" -# -# my_d = os.path.join(self.tmp, "valid_empty") -# self.assertRaises(DataSourceMAAS.MAASSeedDirNone, -# DataSourceMAAS.read_maas_seed_dir, my_d) -# -# def test_seed_dir_missing(self): -# """Verify that missing seed_dir raises MAASSeedDirNone.""" -# self.assertRaises(DataSourceMAAS.MAASSeedDirNone, -# DataSourceMAAS.read_maas_seed_dir, -# os.path.join(self.tmp, "nonexistantdirectory")) -# -# def test_seed_url_valid(self): -# """Verify that valid seed_url is read as such.""" -# valid = {'meta-data/instance-id': 'i-instanceid', -# 'meta-data/local-hostname': 'test-hostname', -# 'meta-data/public-keys': 'test-hostname', -# 'user-data': 'foodata'} -# valid_order = [ -# 'meta-data/local-hostname', -# 'meta-data/instance-id', -# 'meta-data/public-keys', -# 'user-data', -# ] -# my_seed = "http://example.com/xmeta" -# my_ver = "1999-99-99" -# my_headers = {'header1': 'value1', 'header2': 'value2'} -# -# def my_headers_cb(url): -# return my_headers -# -# mock_request = self.mocker.replace(url_helper.readurl, -# passthrough=False) -# -# for key in valid_order: -# url = "%s/%s/%s" % (my_seed, my_ver, key) -# mock_request(url, headers=my_headers, timeout=None) -# resp = valid.get(key) -# self.mocker.result(url_helper.UrlResponse(200, resp)) -# self.mocker.replay() -# -# (userdata, metadata) = DataSourceMAAS.read_maas_seed_url(my_seed, -# header_cb=my_headers_cb, version=my_ver) -# -# self.assertEqual("foodata", userdata) -# self.assertEqual(metadata['instance-id'], -# valid['meta-data/instance-id']) -# self.assertEqual(metadata['local-hostname'], -# valid['meta-data/local-hostname']) -# -# def test_seed_url_invalid(self): -# """Verify that invalid seed_url raises MAASSeedDirMalformed.""" -# pass -# -# def test_seed_url_missing(self): -# """Verify seed_url with no found entries raises MAASSeedDirNone.""" -# pass + found = DataSourceConfigDrive.read_config_drive_dir(my_d) + self.assertEqual(OSTACK_META, found['metadata']) + + def test_seed_dir_bad_json_metadata(self): + """Verify that bad json in metadata raises BrokenConfigDriveDir.""" + my_d = os.path.join(self.tmp, "bad-json-metadata") + data = copy(CFG_DRIVE_FILES_V2) + + data["openstack/2012-08-10/meta_data.json"] = "non-json garbage {}" + data["openstack/latest/meta_data.json"] = "non-json garbage {}" + + populate_dir(my_d, data) + + self.assertRaises(DataSourceConfigDrive.BrokenConfigDriveDir, + DataSourceConfigDrive.read_config_drive_dir, my_d) + + def test_seed_dir_no_configdrive(self): + """Verify that no metadata raises NonConfigDriveDir.""" + + my_d = os.path.join(self.tmp, "non-configdrive") + data = copy(CFG_DRIVE_FILES_V2) + data["myfoofile.txt"] = "myfoocontent" + data["openstack/latest/random-file.txt"] = "random-content" + data["content/foo"] = "foocontent" + + self.assertRaises(DataSourceConfigDrive.NonConfigDriveDir, + DataSourceConfigDrive.read_config_drive_dir, my_d) + + def test_seed_dir_missing(self): + """Verify that missing seed_dir raises NonConfigDriveDir.""" + my_d = os.path.join(self.tmp, "nonexistantdirectory") + self.assertRaises(DataSourceConfigDrive.NonConfigDriveDir, + DataSourceConfigDrive.read_config_drive_dir, my_d) def populate_dir(seed_dir, files): -- cgit v1.2.3 From 7a6543aebc06157c1ec2ee78dc086a84490e2a25 Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Fri, 24 Aug 2012 16:10:22 -0400 Subject: committing in preparation for actual test --- cloudinit/sources/DataSourceConfigDrive.py | 232 ++++++++++++++------- .../unittests/test_datasource/test_configdrive.py | 74 +++++-- 2 files changed, 212 insertions(+), 94 deletions(-) diff --git a/cloudinit/sources/DataSourceConfigDrive.py b/cloudinit/sources/DataSourceConfigDrive.py index 1b10bc1e..b68a97bb 100644 --- a/cloudinit/sources/DataSourceConfigDrive.py +++ b/cloudinit/sources/DataSourceConfigDrive.py @@ -37,16 +37,14 @@ CFG_DRIVE_FILES_V1 = [ ] DEFAULT_METADATA = { "instance-id": DEFAULT_IID, - "dsmode": DEFAULT_MODE, } -CFG_DRIVE_DEV_ENV = 'CLOUD_INIT_CONFIG_DRIVE_DEVICE' +VALID_DSMODES = ("local", "net", "pass", "disabled") class DataSourceConfigDrive(sources.DataSource): def __init__(self, sys_cfg, distro, paths): sources.DataSource.__init__(self, sys_cfg, distro, paths) - self.seed = None - self.cfg = {} + self.source = None self.dsmode = 'local' self.seed_dir = os.path.join(paths.seed_dir, 'config_drive') self.version = None @@ -54,7 +52,7 @@ class DataSourceConfigDrive(sources.DataSource): def __str__(self): mstr = "%s [%s,ver=%s]" % (util.obj_name(self), self.dsmode, self.version) - mstr += "[seed=%s]" % (self.seed) + mstr += "[source=%s]" % (self.source) return mstr def get_data(self): @@ -70,18 +68,7 @@ class DataSourceConfigDrive(sources.DataSource): util.logexc(LOG, "Failed reading config drive from %s", self.seed_dir) if not found: - fslist = util.find_devs_with("TYPE=vfat") - fslist.extend(util.find_devs_with("TYPE=iso9660")) - - label_list = util.find_devs_with("LABEL=config-2") - devlist = list(set(fslist) & set(label_list)) - - dev = find_cfg_drive_device() - if dev not in devlist: - devlist.append(dev) - - devlist.sort(reverse=True) - + devlist = find_candidate_devs() for dev in devlist: try: results = util.mount_cb(dev, read_config_drive_dir) @@ -89,43 +76,73 @@ class DataSourceConfigDrive(sources.DataSource): break except (NonConfigDriveDir, util.MountFailedError): pass + except BrokenConfigDriveDir: + util.logexc(LOG, "broken config drive: %s", dev) if not found: return False - if 'dsconfig' in md: - self.cfg = md['dscfg'] - + md = results['metadata'] md = util.mergedict(md, DEFAULT_METADATA) - # Update interfaces and ifup only on the local datasource - # this way the DataSourceConfigDriveNet doesn't do it also. - if 'network-interfaces' in md and self.dsmode == "local": + user_dsmode = results.get('dsmode', None) + if user_dsmode not in VALID_DSMODES + (None,): + LOG.warn("user specified invalid mode: %s" % user_dsmode) + user_dsmode = None + + dsmode = get_ds_mode(cfgdrv_ver=results['cfgdrive_ver'], + ds_cfg=self.ds_cfg.get('dsmode'), + user=user_dsmode) + + if dsmode == "disabled": + # most likely user specified + return False + + # TODO(smoser): fix this, its dirty. + # we want to do some things (writing files and network config) + # only on first boot, and even then, we want to do so in the + # local datasource (so they happen earlier) even if the configured + # dsmode is 'net' or 'pass'. To do this, we check the previous + # instance-id + prev_iid = get_previous_iid(self.paths) + cur_iid = md['instance-id'] + + if ('network_config' in results and self.dsmode == "local" and + prev_iid != cur_iid): LOG.debug("Updating network interfaces from config drive (%s)", - md['dsmode']) - self.distro.apply_network(md['network-interfaces']) + dsmode) + self.distro.apply_network(results['network_config']) - self.seed = found - self.metadata = results['metadata'] - self.userdata_raw = results.get('userdata') + # file writing occurs in local mode (to be as early as possible) + if self.dsmode == "local" and prev_iid != cur_iid and results['files']: + LOG.debug("writing injected files") + try: + write_files(results['files']) + except: + util.logexc(LOG, "Failed writing files") + + # dsmode != self.dsmode here if: + # * dsmode = "pass", pass means it should only copy files and then + # pass to another datasource + # * dsmode = "net" and self.dsmode = "local" + # so that user boothooks would be applied with network, the + # local datasource just gets out of the way, and lets the net claim + if dsmode != self.dsmode: + LOG.debug("%s: not claiming datasource, dsmode=%s", self, dsmode) + return False - if md['dsmode'] == self.dsmode: - return True + self.source = found + self.metadata = md + self.userdata_raw = results.get('userdata') + self.version = results['cfgdrive_ver'] - LOG.debug("%s: not claiming datasource, dsmode=%s", self, md['dsmode']) - return False + return True def get_public_ssh_keys(self): if not 'public-keys' in self.metadata: return [] return self.metadata['public-keys'] - # The data sources' config_obj is a cloud-config formated - # object that came to it from ways other than cloud-config - # because cloud-config content would be handled elsewhere - def get_config_obj(self): - return self.cfg - class DataSourceConfigDriveNet(DataSourceConfigDrive): def __init__(self, sys_cfg, distro, paths): @@ -141,34 +158,40 @@ class BrokenConfigDriveDir(Exception): pass -def find_cfg_drive_device(): - """Get the config drive device. Return a string like '/dev/vdb' - or None (if there is no non-root device attached). This does not - check the contents, only reports that if there *were* a config_drive - attached, it would be this device. - Note: per config_drive documentation, this is - "associated as the last available disk on the instance" - """ +def find_candidate_devs(): + """Return a list of devices that may contain the config drive. - # This seems to be for debugging?? - if CFG_DRIVE_DEV_ENV in os.environ: - return os.environ[CFG_DRIVE_DEV_ENV] + The returned list is sorted by search order where the first item has + should be searched first (highest priority) - # We are looking for a raw block device (sda, not sda1) with a vfat - # filesystem on it.... - letters = "abcdefghijklmnopqrstuvwxyz" - devs = util.find_devs_with("TYPE=vfat") + config drive v1: + Per documentation, this is "associated as the last available disk on the + instance", and should be VFAT. + Currently, we do not restrict search list to "last available disk" - # Filter out anything not ending in a letter (ignore partitions) - devs = [f for f in devs if f[-1] in letters] + config drive v2: + Disk should be: + * either vfat or iso9660 formated + * labeled with 'config-2' + """ + + by_fstype = (util.find_devs_with("TYPE=vfat") + + util.find_devs_with("TYPE=iso9660")) + by_label = util.find_devs_with("LABEL=config-2") - # Sort them in reverse so "last" device is first - devs.sort(reverse=True) + # give preference to "last available disk" (vdb over vda) + # note, this is not a perfect rendition of that. + by_fstype.sort(reverse=True) + by_label.sort(reverse=True) - if devs: - return devs[0] + # combine list of items by putting by-label items first + # followed by fstype items, but with dupes removed + combined = (by_label + [d for d in by_fstype if d not in by_label]) - return None + # We are looking for block device (sda, not sda1), ignore partitions + combined = [d for d in combined if d[-1] not in "0123456789"] + + return combined def read_config_drive_dir(source_dir): @@ -190,7 +213,7 @@ def read_config_drive_dir_v2(source_dir, version="latest"): ('ec2-metadata', "ec2/latest/metadata.json", False, json.loads), ) - results = {} + results = {'userdata': None} for (name, path, required, process) in datafiles: fpath = os.path.join(source_dir, path) data = None @@ -226,12 +249,24 @@ def read_config_drive_dir_v2(source_dir, version="latest"): for item in results['metadata'].get('files', {}): files[item['path']] = read_content_path(item) + # the 'network_config' item in metadata is a content pointer + # to the network config that should be applied. + # in folsom, it is just a '/etc/network/interfaces' file. item = results['metadata'].get("network_config", None) if item: results['network_config'] = read_content_path(item) except Exception as exc: raise BrokenConfigDriveDir("failed to read file %s: %s" % (item, exc)) + # to openstack, user can specify meta ('nova boot --meta=key=value') and + # those will appear under metadata['meta']. + # if they specify 'dsmode' they're indicating the mode that they intend + # for this datasource to operate in. + try: + results['dsmode'] = results['metadata']['meta']['dsmode'] + except KeyError: + pass + results['files'] = files results['cfgdrive_ver'] = 2 return results @@ -243,9 +278,6 @@ def read_config_drive_dir_v1(source_dir): files and version (1). If not a valid dir, raise a NonConfigDriveDir """ - # TODO(harlowja): fix this for other operating systems... - # Ie: this is where https://fedorahosted.org/netcf/ or similar should - # be hooked in... (or could be) found = {} for af in CFG_DRIVE_FILES_V1: fn = os.path.join(source_dir, af) @@ -256,11 +288,10 @@ def read_config_drive_dir_v1(source_dir): raise NonConfigDriveDir("%s: %s" % (source_dir, "no files found")) md = {} - ud = "" keydata = "" if "etc/network/interfaces" in found: fn = found["etc/network/interfaces"] - md['network-interfaces'] = util.load_file(fn) + md['network_config'] = util.load_file(fn) if "root/.ssh/authorized_keys" in found: fn = found["root/.ssh/authorized_keys"] @@ -280,22 +311,75 @@ def read_config_drive_dir_v1(source_dir): (source_dir, "invalid json in meta.js", e)) md['meta_js'] = content - # Key data override?? + # keydata in meta_js is preferred over "injected" keydata = meta_js.get('public-keys', keydata) if keydata: lines = keydata.splitlines() md['public-keys'] = [l for l in lines if len(l) and not l.startswith("#")] - for copy in ('dsmode', 'instance-id', 'dscfg'): - if copy in meta_js: - md[copy] = meta_js[copy] + # config-drive-v1 has no way for openstack to provide the instance-id + # so we copy that into metadata from the user input + if 'instance-id' in meta_js: + md['instance-id'] = meta_js['instance-id'] + + results = {'cfgdrive_ver': 1, 'metadata': md} + + # allow the user to specify 'dsmode' in a meta tag + if 'dsmode' in meta_js: + results['dsmode'] = meta_js['dsmode'] + + # config-drive-v1 has no way of specifying user-data, so the user has + # to cheat and stuff it in a meta tag also. + results['userdata'] = meta_js.get('user-data') + + # this implementation does not support files + # (other than network/interfaces and authorized_keys) + results['files'] = [] + + return results + + +def get_ds_mode(cfgdrv_ver, ds_cfg=None, user=None): + """Determine what mode should be used. + valid values are 'pass', 'disabled', 'local', 'net' + """ + # user passed data trumps everything + if user is not None: + return user + + if ds_cfg is not None: + return ds_cfg + + # at config-drive version 1, the default behavior was pass. That + # meant to not use use it as primary data source, but expect a ec2 metadata + # source. for version 2, we default to 'net', which means + # the DataSourceConfigDriveNet, would be used. + # + # this could change in the future. If there was definitive metadata + # that indicated presense of an openstack metadata service, then + # we could change to 'pass' by default also. The motivation for that + # would be 'cloud-init query' as the web service could be more dynamic + if cfgdrv_ver == 1: + return "pass" + return "net" + + +def get_previous_iid(paths): + fname = os.path.join(paths.get_cpath('data'), + 'previous-instance-id') + try: + with open(fname) as fp: + return fp.read() + except IOError: + return None - if 'user-data' in meta_js: - ud = meta_js['user-data'] - # metadata, user-data, 'files', 1 - return {'metadata': md, 'userdata': ud, 'files': [], 'cfgdrive_ver': 1} +def write_files(files): + for (name, content) in files.iteritems(): + if name[0] != os.sep: + name = os.sep + name + util.write_file(name, content, mode=0660) # Used to match classes to dependencies diff --git a/tests/unittests/test_datasource/test_configdrive.py b/tests/unittests/test_datasource/test_configdrive.py index c5d678f9..50b97ef8 100644 --- a/tests/unittests/test_datasource/test_configdrive.py +++ b/tests/unittests/test_datasource/test_configdrive.py @@ -2,9 +2,12 @@ from copy import copy import json import os import os.path +import shutil +import tempfile +from unittest import TestCase -from cloudinit.sources import DataSourceConfigDrive -from mocker import MockerTestCase +from cloudinit.sources import DataSourceConfigDrive as ds +from cloudinit import util PUBKEY = u'ssh-rsa AAAAB3NzaC1....sIkJhq8wdX+4I3A4cYbYP ubuntu@server-460\n' @@ -57,20 +60,24 @@ CFG_DRIVE_FILES_V2 = { 'openstack/latest/user_data': USER_DATA} -class TestConfigDriveDataSource(MockerTestCase): +class TestConfigDriveDataSource(TestCase): def setUp(self): super(TestConfigDriveDataSource, self).setUp() - # Make a temp directoy for tests to use. - self.tmp = self.makeDir() + self.tmp = tempfile.mkdtemp() + + def tearDown(self): + try: + shutil.rmtree(self.tmp) + except OSError: + pass def test_dir_valid(self): """Verify a dir is read as such.""" - my_d = os.path.join(self.tmp, "valid") - populate_dir(my_d, CFG_DRIVE_FILES_V2) + populate_dir(self.tmp, CFG_DRIVE_FILES_V2) - found = DataSourceConfigDrive.read_config_drive_dir(my_d) + found = ds.read_config_drive_dir(self.tmp) self.assertEqual(USER_DATA, found['userdata']) self.assertEqual(OSTACK_META, found['metadata']) @@ -80,28 +87,26 @@ class TestConfigDriveDataSource(MockerTestCase): def test_seed_dir_valid_extra(self): """Verify extra files do not affect datasource validity.""" - my_d = os.path.join(self.tmp, "valid_extra") data = copy(CFG_DRIVE_FILES_V2) data["myfoofile.txt"] = "myfoocontent" data["openstack/latest/random-file.txt"] = "random-content" - populate_dir(my_d, data) + populate_dir(self.tmp, data) - found = DataSourceConfigDrive.read_config_drive_dir(my_d) + found = ds.read_config_drive_dir(self.tmp) self.assertEqual(OSTACK_META, found['metadata']) def test_seed_dir_bad_json_metadata(self): """Verify that bad json in metadata raises BrokenConfigDriveDir.""" - my_d = os.path.join(self.tmp, "bad-json-metadata") data = copy(CFG_DRIVE_FILES_V2) data["openstack/2012-08-10/meta_data.json"] = "non-json garbage {}" data["openstack/latest/meta_data.json"] = "non-json garbage {}" - populate_dir(my_d, data) + populate_dir(self.tmp, data) - self.assertRaises(DataSourceConfigDrive.BrokenConfigDriveDir, - DataSourceConfigDrive.read_config_drive_dir, my_d) + self.assertRaises(ds.BrokenConfigDriveDir, + ds.read_config_drive_dir, self.tmp) def test_seed_dir_no_configdrive(self): """Verify that no metadata raises NonConfigDriveDir.""" @@ -112,18 +117,47 @@ class TestConfigDriveDataSource(MockerTestCase): data["openstack/latest/random-file.txt"] = "random-content" data["content/foo"] = "foocontent" - self.assertRaises(DataSourceConfigDrive.NonConfigDriveDir, - DataSourceConfigDrive.read_config_drive_dir, my_d) + self.assertRaises(ds.NonConfigDriveDir, + ds.read_config_drive_dir, my_d) def test_seed_dir_missing(self): """Verify that missing seed_dir raises NonConfigDriveDir.""" my_d = os.path.join(self.tmp, "nonexistantdirectory") - self.assertRaises(DataSourceConfigDrive.NonConfigDriveDir, - DataSourceConfigDrive.read_config_drive_dir, my_d) + self.assertRaises(ds.NonConfigDriveDir, + ds.read_config_drive_dir, my_d) + + def test_find_candidates(self): + devs_with_answers = { + "TYPE=vfat": [], + "TYPE=iso9660": ["/dev/vdb"], + "LABEL=config-2": ["/dev/vdb"], + } + + def my_devs_with(criteria): + return devs_with_answers[criteria] + + try: + orig_find_devs_with = util.find_devs_with + util.find_devs_with = my_devs_with + + self.assertEqual(["/dev/vdb"], ds.find_candidate_devs()) + + # add a vfat item + # zdd reverse sorts after vdb, but config-2 label is preferred + devs_with_answers['TYPE=vfat'] = ["/dev/zdd"] + self.assertEqual(["/dev/vdb", "/dev/zdd"], + ds.find_candidate_devs()) + + # verify that partitions are not considered + devs_with_answers = {"TYPE=vfat": ["/dev/sda1"], + "TYPE=iso9660": [], "LABEL=config-2": ["/dev/vdb3"]} + self.assertEqual([], ds.find_candidate_devs()) + + finally: + util.find_devs_with = orig_find_devs_with def populate_dir(seed_dir, files): - os.mkdir(seed_dir) for (name, content) in files.iteritems(): path = os.path.join(seed_dir, name) dirname = os.path.dirname(path) -- cgit v1.2.3 From cd47f4a6ae1249c3b516259edaa79fcc80a5990b Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Fri, 24 Aug 2012 16:51:16 -0400 Subject: use 'uuid' as 'instance-id' openstack metadata uses 'uuid' as an instances 'instance-id'. just copy that to the metadata['instance-id'] --- cloudinit/sources/DataSourceConfigDrive.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cloudinit/sources/DataSourceConfigDrive.py b/cloudinit/sources/DataSourceConfigDrive.py index b68a97bb..585dbb58 100644 --- a/cloudinit/sources/DataSourceConfigDrive.py +++ b/cloudinit/sources/DataSourceConfigDrive.py @@ -237,6 +237,13 @@ def read_config_drive_dir_v2(source_dir, version="latest"): if found: results[name] = data + # instance-id is 'uuid' for openstack. just copy it to instance-id. + if 'instance-id' not in results['metadata']: + try: + results['metadata']['instance-id'] = results['metadata']['uuid'] + except KeyError: + raise BrokenConfigDriveDir("No uuid entry in metadata") + def read_content_path(item): # do not use os.path.join here, as content_path starts with / cpath = os.path.sep.join((source_dir, "openstack", -- cgit v1.2.3 From 0a84d4e669913fe221a61a34641252d6b172299f Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Fri, 24 Aug 2012 16:53:41 -0400 Subject: use instance-id, not previous-instance-id. at the point where we are getting the previous instance id, there cloud-init hasn't performed the move yet. Therefore, the "previous" is the one that /var/lib/cloud/data/ says is the current. --- cloudinit/sources/DataSourceConfigDrive.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cloudinit/sources/DataSourceConfigDrive.py b/cloudinit/sources/DataSourceConfigDrive.py index 585dbb58..558e773c 100644 --- a/cloudinit/sources/DataSourceConfigDrive.py +++ b/cloudinit/sources/DataSourceConfigDrive.py @@ -373,8 +373,10 @@ def get_ds_mode(cfgdrv_ver, ds_cfg=None, user=None): def get_previous_iid(paths): - fname = os.path.join(paths.get_cpath('data'), - 'previous-instance-id') + # interestingly, for this purpose the "previous" instance-id is the current + # instance-id. cloud-init hasn't moved them over yet as this datasource + # hasn't declared itself found. + fname = os.path.join(paths.get_cpath('data'), 'instance-id') try: with open(fname) as fp: return fp.read() -- cgit v1.2.3 From c883c28c8abd00e9681ddcee97ac2f1ab5b33883 Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Fri, 24 Aug 2012 17:06:18 -0400 Subject: use openstack metadata version 2012-08-10 unless not available If 'latest' is found, but '2012-08-10' is not, we will log a warning but attempt to use it. --- cloudinit/sources/DataSourceConfigDrive.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cloudinit/sources/DataSourceConfigDrive.py b/cloudinit/sources/DataSourceConfigDrive.py index 558e773c..b8154367 100644 --- a/cloudinit/sources/DataSourceConfigDrive.py +++ b/cloudinit/sources/DataSourceConfigDrive.py @@ -205,7 +205,14 @@ def read_config_drive_dir(source_dir): raise last_e -def read_config_drive_dir_v2(source_dir, version="latest"): +def read_config_drive_dir_v2(source_dir, version="2012-08-10"): + + if (not os.path.isdir(os.path.join(source_dir, "openstack", version)) and + os.path.isdir(os.path.join(source_dir, "openstack", "latest"))): + LOG.warn("version '%s' not available, attempting to use 'latest'" % + version) + version = "latest" + datafiles = ( ('metadata', "openstack/%s/meta_data.json" % version, True, json.loads), -- cgit v1.2.3 From f19d5701fe34374f674d7fd334184d51aae061c4 Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Fri, 24 Aug 2012 17:16:32 -0400 Subject: fix test case for copy of uuid -> instance-id --- tests/unittests/test_datasource/test_configdrive.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/unittests/test_datasource/test_configdrive.py b/tests/unittests/test_datasource/test_configdrive.py index 50b97ef8..55573114 100644 --- a/tests/unittests/test_datasource/test_configdrive.py +++ b/tests/unittests/test_datasource/test_configdrive.py @@ -79,8 +79,11 @@ class TestConfigDriveDataSource(TestCase): found = ds.read_config_drive_dir(self.tmp) + expected_md = copy(OSTACK_META) + expected_md['instance-id'] = expected_md['uuid'] + self.assertEqual(USER_DATA, found['userdata']) - self.assertEqual(OSTACK_META, found['metadata']) + self.assertEqual(expected_md, found['metadata']) self.assertEqual(found['files']['/etc/foo.cfg'], CONTENT_0) self.assertEqual(found['files']['/etc/bar/bar.cfg'], CONTENT_1) @@ -94,7 +97,11 @@ class TestConfigDriveDataSource(TestCase): populate_dir(self.tmp, data) found = ds.read_config_drive_dir(self.tmp) - self.assertEqual(OSTACK_META, found['metadata']) + + expected_md = copy(OSTACK_META) + expected_md['instance-id'] = expected_md['uuid'] + + self.assertEqual(expected_md, found['metadata']) def test_seed_dir_bad_json_metadata(self): """Verify that bad json in metadata raises BrokenConfigDriveDir.""" -- cgit v1.2.3 From ed96b524a4c3b3985931d30b254a29466486d2c3 Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Fri, 24 Aug 2012 17:21:06 -0400 Subject: add changelog entry --- ChangeLog | 1 + 1 file changed, 1 insertion(+) diff --git a/ChangeLog b/ChangeLog index 0dae0ca5..b28566e5 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,4 +1,5 @@ 0.7.0: + - add support for config-drive-v2 (LP:#1037567) - support creating users, including the default user. [Ben Howard] (LP: #1028503) - add apt_reboot_if_required to reboot if an upgrade or package installation -- cgit v1.2.3