Validate metadata JSON file

If metadata is not valid dictionary then glance-api
throws 'internal server' error.

Validating metadata JSON file using jsonschema library
at configuration stage and caching metadata and using it
directly when new image being added.
Metadata JSON file requires 'id' and 'mountpoint' keys
to be 'string'. If value of 'id' and 'mountpoint' are not
string then it will raise ValidationError error.
After getting exception during json data validation,
'BadStoreConfiguration' exception will be raised.

If metadata file is list of dicts containing multiple mountpoints
then returned image metadata as dict containing exact mountpoint
where image is stored.

If image path do not starts with any of the 'mountpoint' provided
in metadata JSON file then error is logged and empty
dictionary is returned to glance api.

DocImpact:
Metadata JSON schema file should be valid object. This JSON object
should contain keys 'id', 'mountpoint' and value of both keys
should be string.

Example of valid metadata JSON:

1. If there is only one mountpoint-
{
  "id": "f0781415-cf81-47cd-8860-b83f9c2a415c",
  "mountpoint": "/var/lib/glance/images/"
}

2. If there are more than one mountpoints-
[
  {
    "id": "f0781415-cf81-47cd-8860-b83f9c2a415c",
    "mountpoint": "/var/lib/glance/images1/"
  },
  {
    "id": "asd81415-cf81-47cd-8860-b83f9c2a415c",
    "mountpoint": "/var/lib/glance/images2/"
  }
]

Change-Id: I76b0a0de54e909fb0ec90bd7f9972da76a2501eb
Closes-Bug: 1401778
This commit is contained in:
Abhijeet Malawade 2014-12-08 05:50:33 -08:00
parent 3e2d712ecf
commit 6b9cfa2951
3 changed files with 207 additions and 65 deletions

View File

@ -25,6 +25,7 @@ import os
import stat import stat
import urlparse import urlparse
import jsonschema
from oslo.serialization import jsonutils from oslo.serialization import jsonutils
from oslo_config import cfg from oslo_config import cfg
from oslo_utils import excutils from oslo_utils import excutils
@ -54,7 +55,9 @@ _FILESYSTEM_CONFIGS = [
help=_("The path to a file which contains the " help=_("The path to a file which contains the "
"metadata to be returned with any location " "metadata to be returned with any location "
"associated with this store. The file must " "associated with this store. The file must "
"contain a valid JSON dict.")), "contain a valid JSON object. The object should contain "
"the keys 'id' and 'mountpoint'. The value for both "
"keys should be 'string'.")),
cfg.IntOpt('filesystem_store_file_perm', cfg.IntOpt('filesystem_store_file_perm',
default=0, default=0,
help=_("The required permission for created image file. " help=_("The required permission for created image file. "
@ -65,6 +68,18 @@ _FILESYSTEM_CONFIGS = [
"default permission of the file. This value will be " "default permission of the file. This value will be "
"decoded as an octal digit."))] "decoded as an octal digit."))]
MULTI_FILESYSTEM_METADATA_SCHEMA = {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {"type": "string"},
"mountpoint": {"type": "string"}
},
"required": ["id", "mountpoint"],
}
}
class StoreLocation(glance_store.location.StoreLocation): class StoreLocation(glance_store.location.StoreLocation):
"""Class describing a Filesystem URI.""" """Class describing a Filesystem URI."""
@ -136,6 +151,7 @@ class Store(glance_store.driver.Store):
OPTIONS = _FILESYSTEM_CONFIGS OPTIONS = _FILESYSTEM_CONFIGS
READ_CHUNKSIZE = 64 * units.Ki READ_CHUNKSIZE = 64 * units.Ki
WRITE_CHUNKSIZE = READ_CHUNKSIZE WRITE_CHUNKSIZE = READ_CHUNKSIZE
FILESYSTEM_STORE_METADATA = None
def get_schemes(self): def get_schemes(self):
return ('file', 'filesystem') return ('file', 'filesystem')
@ -217,6 +233,47 @@ class Store(glance_store.driver.Store):
raise exceptions.BadStoreConfiguration( raise exceptions.BadStoreConfiguration(
store_name="filesystem", reason=reason) store_name="filesystem", reason=reason)
def _validate_metadata(self, metadata_file):
"""Validate metadata against json schema.
If metadata is valid then cache metadata and use it when
creating new image.
:param metadata_file: JSON metadata file path
:raises: BadStoreConfiguration exception if metadata is not valid.
"""
try:
with open(metadata_file, 'r') as fptr:
metadata = jsonutils.load(fptr)
if isinstance(metadata, dict):
# If metadata is of type dictionary
# i.e. - it contains only one mountpoint
# then convert it to list of dictionary.
metadata = [metadata]
# Validate metadata against json schema
jsonschema.validate(metadata, MULTI_FILESYSTEM_METADATA_SCHEMA)
glance_store.check_location_metadata(metadata)
self.FILESYSTEM_STORE_METADATA = metadata
except (jsonschema.exceptions.ValidationError,
exceptions.BackendException, ValueError) as vee:
reason = _('The JSON in the metadata file %(file)s is '
'not valid and it can not be used: '
'%(vee)s.') % dict(file=metadata_file,
vee=utils.exception_to_str(vee))
LOG.error(reason)
raise exceptions.BadStoreConfiguration(
store_name="filesystem", reason=reason)
except IOError as ioe:
reason = _('The path for the metadata file %(file)s could '
'not be accessed: '
'%(ioe)s.') % dict(file=metadata_file,
ioe=utils.exception_to_str(ioe))
LOG.error(reason)
raise exceptions.BadStoreConfiguration(
store_name="filesystem", reason=reason)
def configure_add(self): def configure_add(self):
""" """
Configure the Store to use the stored configuration options Configure the Store to use the stored configuration options
@ -275,6 +332,10 @@ class Store(glance_store.driver.Store):
self._create_image_directories(directory_paths) self._create_image_directories(directory_paths)
metadata_file = self.conf.glance_store.filesystem_store_metadata_file
if metadata_file:
self._validate_metadata(metadata_file)
def _check_directory_paths(self, datadir_path, directory_paths): def _check_directory_paths(self, datadir_path, directory_paths):
""" """
Checks if directory_path is already present in directory_paths. Checks if directory_path is already present in directory_paths.
@ -334,35 +395,41 @@ class Store(glance_store.driver.Store):
filesize = os.path.getsize(filepath) filesize = os.path.getsize(filepath)
return filepath, filesize return filepath, filesize
def _get_metadata(self): def _get_metadata(self, filepath):
metadata_file = self.conf.glance_store.filesystem_store_metadata_file """Return metadata dictionary.
if metadata_file is None: If metadata is provided as list of dictionaries then return
return {} metadata as dictionary containing 'id' and 'mountpoint'.
try: If there are multiple nfs directories (mountpoints) configured
with open(metadata_file, 'r') as fptr: for glance, then we need to create metadata JSON file as list
metadata = jsonutils.load(fptr) of dictionaries containing all mountpoints with unique id.
But Nova will not be able to find in which directory (mountpoint)
image is present if we store list of dictionary(containing mountpoints)
in glance image metadata. So if there are multiple mountpoints then
we will return dict containing exact mountpoint where image is stored.
glance_store.check_location_metadata(metadata) If image path does not start with any of the 'mountpoint' provided
return metadata in metadata JSON file then error is logged and empty
except exceptions.BackendException as bee: dictionary is returned.
LOG.error(_('The JSON in the metadata file %(file)s could not '
'be used: %(bee)s An empty dictionary will be ' :param filepath: Path of image on store
'returned to the client.') :returns: metadata dictionary
% dict(file=metadata_file, bee=str(bee))) """
return {} if self.FILESYSTEM_STORE_METADATA:
except IOError as ioe: for image_meta in self.FILESYSTEM_STORE_METADATA:
LOG.error(_('The path for the metadata file %(file)s could not be ' if filepath.startswith(image_meta['mountpoint']):
'opened: %(io)s An empty dictionary will be returned ' return image_meta
'to the client.')
% dict(file=metadata_file, io=ioe)) reason = (_LE("The image path %(path)s does not match with "
return {} "any of the mountpoint defined in "
except Exception as ex: "metadata: %(metadata)s. An empty dictionary "
LOG.exception(_('An error occurred processing the storage systems ' "will be returned to the client.")
'meta data file: %s. An empty dictionary will be ' % dict(path=filepath,
'returned to the client.') % str(ex)) metadata=self.FILESYSTEM_STORE_METADATA))
return {} LOG.error(reason)
return {}
def get(self, location, offset=0, chunk_size=None, context=None): def get(self, location, offset=0, chunk_size=None, context=None):
""" """
@ -516,7 +583,7 @@ class Store(glance_store.driver.Store):
self._delete_partial(filepath, image_id) self._delete_partial(filepath, image_id)
checksum_hex = checksum.hexdigest() checksum_hex = checksum.hexdigest()
metadata = self._get_metadata() metadata = self._get_metadata(filepath)
LOG.debug(_("Wrote %(bytes_written)d bytes to %(filepath)s with " LOG.debug(_("Wrote %(bytes_written)d bytes to %(filepath)s with "
"checksum %(checksum_hex)s"), "checksum %(checksum_hex)s"),

View File

@ -12,5 +12,7 @@ eventlet>=0.13.0
iso8601>=0.1.8 iso8601>=0.1.8
six>=1.4.1 six>=1.4.1
jsonschema>=2.0.0,<3.0.0
# py2.6 compat # py2.6 compat
ordereddict ordereddict

View File

@ -56,6 +56,25 @@ class TestStore(base.StoreBaseTest):
super(TestStore, self).tearDown() super(TestStore, self).tearDown()
ChunkedFile.CHUNKSIZE = self.orig_chunksize ChunkedFile.CHUNKSIZE = self.orig_chunksize
def _create_metadata_json_file(self, metadata):
expected_image_id = str(uuid.uuid4())
jsonfilename = os.path.join(self.test_dir,
"storage_metadata.%s" % expected_image_id)
self.config(filesystem_store_metadata_file=jsonfilename,
group="glance_store")
with open(jsonfilename, 'w') as fptr:
json.dump(metadata, fptr)
def _store_image(self, in_metadata):
expected_image_id = str(uuid.uuid4())
expected_file_size = 10
expected_file_contents = "*" * expected_file_size
image_file = StringIO.StringIO(expected_file_contents)
self.store.FILESYSTEM_STORE_METADATA = in_metadata
return self.store.add(expected_image_id, image_file,
expected_file_size)
def test_get(self): def test_get(self):
"""Test a "normal" retrieval of an image in chunks.""" """Test a "normal" retrieval of an image in chunks."""
# First add an image... # First add an image...
@ -162,45 +181,23 @@ class TestStore(base.StoreBaseTest):
self.assertEqual(expected_file_contents, new_image_contents) self.assertEqual(expected_file_contents, new_image_contents)
self.assertEqual(expected_file_size, new_image_file_size) self.assertEqual(expected_file_size, new_image_file_size)
def test_add_check_metadata_success(self): def test_add_check_metadata_with_invalid_mountpoint_location(self):
expected_image_id = str(uuid.uuid4()) in_metadata = [{'id': 'abcdefg',
in_metadata = {'akey': u'some value', 'list': [u'1', u'2', u'3']} 'mountpoint': '/xyz/images'}]
jsonfilename = os.path.join(self.test_dir, location, size, checksum, metadata = self._store_image(in_metadata)
"storage_metadata.%s" % expected_image_id) self.assertEqual({}, metadata)
self.config(filesystem_store_metadata_file=jsonfilename, def test_add_check_metadata_list_with_invalid_mountpoint_locations(self):
group="glance_store") in_metadata = [{'id': 'abcdefg', 'mountpoint': '/xyz/images'},
with open(jsonfilename, 'w') as fptr: {'id': 'xyz1234', 'mountpoint': '/pqr/images'}]
json.dump(in_metadata, fptr) location, size, checksum, metadata = self._store_image(in_metadata)
expected_file_size = 10 self.assertEqual({}, metadata)
expected_file_contents = "*" * expected_file_size
image_file = StringIO.StringIO(expected_file_contents)
location, size, checksum, metadata = self.store.add(expected_image_id, def test_add_check_metadata_list_with_valid_mountpoint_locations(self):
image_file, in_metadata = [{'id': 'abcdefg', 'mountpoint': '/tmp'},
expected_file_size) {'id': 'xyz1234', 'mountpoint': '/xyz'}]
location, size, checksum, metadata = self._store_image(in_metadata)
self.assertEqual(metadata, in_metadata) self.assertEqual(in_metadata[0], metadata)
def test_add_check_metadata_bad_data(self):
expected_image_id = str(uuid.uuid4())
in_metadata = {'akey': 10} # only unicode is allowed
jsonfilename = os.path.join(self.test_dir,
"storage_metadata.%s" % expected_image_id)
self.config(filesystem_store_metadata_file=jsonfilename,
group="glance_store")
with open(jsonfilename, 'w') as fptr:
json.dump(in_metadata, fptr)
expected_file_size = 10
expected_file_contents = "*" * expected_file_size
image_file = StringIO.StringIO(expected_file_contents)
location, size, checksum, metadata = self.store.add(expected_image_id,
image_file,
expected_file_size)
self.assertEqual(metadata, {})
def test_add_check_metadata_bad_nosuch_file(self): def test_add_check_metadata_bad_nosuch_file(self):
expected_image_id = str(uuid.uuid4()) expected_image_id = str(uuid.uuid4())
@ -360,6 +357,82 @@ class TestStore(base.StoreBaseTest):
self.assertEqual(self.store.priority_data_map, expected_priority_map) self.assertEqual(self.store.priority_data_map, expected_priority_map)
self.assertEqual(self.store.priority_list, expected_priority_list) self.assertEqual(self.store.priority_list, expected_priority_list)
def test_configure_add_with_metadata_file_success(self):
metadata = {'id': 'asdf1234',
'mountpoint': '/tmp'}
self._create_metadata_json_file(metadata)
self.store.configure_add()
self.assertEqual([metadata], self.store.FILESYSTEM_STORE_METADATA)
def test_configure_add_check_metadata_list_of_dicts_success(self):
metadata = [{'id': 'abcdefg', 'mountpoint': '/xyz/images'},
{'id': 'xyz1234', 'mountpoint': '/tmp/'}]
self._create_metadata_json_file(metadata)
self.store.configure_add()
self.assertEqual(metadata, self.store.FILESYSTEM_STORE_METADATA)
def test_configure_add_check_metadata_success_list_val_for_some_key(self):
metadata = {'akey': ['value1', 'value2'], 'id': 'asdf1234',
'mountpoint': '/tmp'}
self._create_metadata_json_file(metadata)
self.store.configure_add()
self.assertEqual([metadata], self.store.FILESYSTEM_STORE_METADATA)
def test_configure_add_check_metadata_bad_data(self):
metadata = {'akey': 10, 'id': 'asdf1234',
'mountpoint': '/tmp'} # only unicode is allowed
self._create_metadata_json_file(metadata)
self.assertRaises(exceptions.BadStoreConfiguration,
self.store.configure_add)
def test_configure_add_check_metadata_with_no_id_or_mountpoint(self):
metadata = {'mountpoint': '/tmp'}
self._create_metadata_json_file(metadata)
self.assertRaises(exceptions.BadStoreConfiguration,
self.store.configure_add)
metadata = {'id': 'asdfg1234'}
self._create_metadata_json_file(metadata)
self.assertRaises(exceptions.BadStoreConfiguration,
self.store.configure_add)
def test_configure_add_check_metadata_id_or_mountpoint_is_not_string(self):
metadata = {'id': 10, 'mountpoint': '/tmp'}
self._create_metadata_json_file(metadata)
self.assertRaises(exceptions.BadStoreConfiguration,
self.store.configure_add)
metadata = {'id': 'asdf1234', 'mountpoint': 12345}
self._create_metadata_json_file(metadata)
self.assertRaises(exceptions.BadStoreConfiguration,
self.store.configure_add)
def test_configure_add_check_metadata_list_with_no_id_or_mountpoint(self):
metadata = [{'id': 'abcdefg', 'mountpoint': '/xyz/images'},
{'mountpoint': '/pqr/images'}]
self._create_metadata_json_file(metadata)
self.assertRaises(exceptions.BadStoreConfiguration,
self.store.configure_add)
metadata = [{'id': 'abcdefg'},
{'id': 'xyz1234', 'mountpoint': '/pqr/images'}]
self._create_metadata_json_file(metadata)
self.assertRaises(exceptions.BadStoreConfiguration,
self.store.configure_add)
def test_add_check_metadata_list_id_or_mountpoint_is_not_string(self):
metadata = [{'id': 'abcdefg', 'mountpoint': '/xyz/images'},
{'id': 1234, 'mountpoint': '/pqr/images'}]
self._create_metadata_json_file(metadata)
self.assertRaises(exceptions.BadStoreConfiguration,
self.store.configure_add)
metadata = [{'id': 'abcdefg', 'mountpoint': 1234},
{'id': 'xyz1234', 'mountpoint': '/pqr/images'}]
self._create_metadata_json_file(metadata)
self.assertRaises(exceptions.BadStoreConfiguration,
self.store.configure_add)
def test_configure_add_same_dir_multiple_times(self): def test_configure_add_same_dir_multiple_times(self):
""" """
Tests BadStoreConfiguration exception is raised if same directory Tests BadStoreConfiguration exception is raised if same directory