diff --git a/etc/glance-image-import.conf.sample b/etc/glance-image-import.conf.sample new file mode 100644 index 0000000000..fc84cca508 --- /dev/null +++ b/etc/glance-image-import.conf.sample @@ -0,0 +1,62 @@ +[DEFAULT] + + +[image_import_opts] + +# +# From glance +# + +# +# Image import plugins to be enabled for task processing. +# +# Provide list of strings reflecting to the task Objects +# that should be included to the Image Import flow. The +# task objects needs to be defined in the 'glance/async/ +# flows/plugins/*' and may be implemented by OpenStack +# Glance project team, deployer or 3rd party. +# +# By default no plugins are enabled and to take advantage +# of the plugin model the list of plugins must be set +# explicitly in the glance-image-import.conf file. +# +# The allowed values for this option is comma separated +# list of object names in between ``[`` and ``]``. +# +# Possible values: +# * no_op (only logs debug level message that the +# plugin has been executed) +# * Any provided Task object name to be included +# in to the flow. +# (list value) +#image_import_plugins = [no_op] + + +[inject_metadata_properties] + +# +# From glance +# + +# +# Specify name of user roles to be ignored for injecting metadata +# properties in the image. +# +# Specify name of the user roles +# +# Possible values: +# * List containing user roles. For example: [admin,member] +# +# (list value) +#ignore_user_roles = admin + +# +# Dictionary contains metadata properties to be injected in image. +# +# Possible values: +# * Dictionary containing key/value pairs. Key characters +# length should be <= 255. For example: k1:v1,k2:v2 +# +# +# (dict value) +#inject = diff --git a/etc/oslo-config-generator/glance-image-import.conf b/etc/oslo-config-generator/glance-image-import.conf new file mode 100644 index 0000000000..36b0dfdf57 --- /dev/null +++ b/etc/oslo-config-generator/glance-image-import.conf @@ -0,0 +1,4 @@ +[DEFAULT] +wrap_width = 80 +output_file = etc/glance-image-import.conf.sample +namespace = glance \ No newline at end of file diff --git a/glance/async/flows/plugins/inject_image_metadata.py b/glance/async/flows/plugins/inject_image_metadata.py new file mode 100644 index 0000000000..5843fc6df5 --- /dev/null +++ b/glance/async/flows/plugins/inject_image_metadata.py @@ -0,0 +1,101 @@ +# Copyright 2018 NTT DATA, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +from oslo_config import cfg +from taskflow.patterns import linear_flow as lf +from taskflow import task + +from glance.i18n import _ + + +CONF = cfg.CONF + + +inject_metadata_opts = [ + + cfg.ListOpt('ignore_user_roles', + default='admin', + help=_(""" +Specify name of user roles to be ignored for injecting metadata +properties in the image. + +Possible values: + * List containing user roles. For example: [admin,member] + +""")), + cfg.DictOpt('inject', + default={}, + help=_(""" +Dictionary contains metadata properties to be injected in image. + +Possible values: + * Dictionary containing key/value pairs. Key characters + length should be <= 255. For example: k1:v1,k2:v2 + + +""")), +] + +CONF.register_opts(inject_metadata_opts, group='inject_metadata_properties') + + +class _InjectMetadataProperties(task.Task): + + def __init__(self, context, task_id, task_type, image_repo, image_id): + self.context = context + self.task_id = task_id + self.task_type = task_type + self.image_repo = image_repo + self.image_id = image_id + super(_InjectMetadataProperties, self).__init__( + name='%s-InjectMetadataProperties-%s' % (task_type, task_id)) + + def execute(self): + """Inject custom metadata properties to image + + :param image_id: Glance Image ID + """ + user_roles = self.context.roles + ignore_user_roles = CONF.inject_metadata_properties.ignore_user_roles + + if not [role for role in user_roles if role in ignore_user_roles]: + properties = CONF.inject_metadata_properties.inject + + if properties: + image = self.image_repo.get(self.image_id) + image.extra_properties.update(properties) + self.image_repo.save(image) + + +def get_flow(**kwargs): + """Return task flow for inject_image_metadata. + + :param task_id: Task ID. + :param task_type: Type of the task. + :param image_repo: Image repository used. + :param image_id: Image_ID used. + :param context: Context used. + """ + task_id = kwargs.get('task_id') + task_type = kwargs.get('task_type') + image_repo = kwargs.get('image_repo') + image_id = kwargs.get('image_id') + context = kwargs.get('context') + + return lf.Flow(task_type).add( + _InjectMetadataProperties(context, task_id, task_type, image_repo, + image_id), + ) diff --git a/glance/common/property_utils.py b/glance/common/property_utils.py index e0ccf61722..718d464832 100644 --- a/glance/common/property_utils.py +++ b/glance/common/property_utils.py @@ -216,6 +216,11 @@ class PropertyRules(object): def check_property_rules(self, property_name, action, context): roles = context.roles + + # Include service roles to check if an action can be + # performed on the property or not + if context.service_roles: + roles.extend(context.service_roles) if not self.rules: return True diff --git a/glance/common/wsgi_app.py b/glance/common/wsgi_app.py index 7b621c5102..3c7c6d67ae 100644 --- a/glance/common/wsgi_app.py +++ b/glance/common/wsgi_app.py @@ -24,14 +24,26 @@ CONF = cfg.CONF CONF.import_group("profiler", "glance.common.wsgi") logging.register_options(CONF) -CONFIG_FILES = ['glance-api-paste.ini', 'glance-api.conf'] +CONFIG_FILES = ['glance-api-paste.ini', + 'glance-image-import.conf', + 'glance-api.conf'] def _get_config_files(env=None): if env is None: env = os.environ dirname = env.get('OS_GLANCE_CONFIG_DIR', '/etc/glance').strip() - return [os.path.join(dirname, config_file) for config_file in CONFIG_FILES] + config_files = [] + for config_file in CONFIG_FILES: + cfg_file = os.path.join(dirname, config_file) + # As 'glance-image-import.conf' is optional conf file + # so include it only if it's existing. + if config_file == 'glance-image-import.conf' and ( + not os.path.exists(cfg_file)): + continue + config_files.append(cfg_file) + + return config_files def _setup_os_profiler(): diff --git a/glance/opts.py b/glance/opts.py index ec9f25bf92..196e81be0e 100644 --- a/glance/opts.py +++ b/glance/opts.py @@ -30,6 +30,7 @@ import glance.api.middleware.context import glance.api.versions import glance.async.flows.api_image_import import glance.async.flows.convert +import glance.async.flows.plugins.inject_image_metadata import glance.async.taskflow_executor import glance.common.config import glance.common.location_strategy @@ -107,7 +108,9 @@ _manage_opts = [ (None, []) ] _image_import_opts = [ - ('image_import_opts', glance.async.flows.api_image_import.api_import_opts) + ('image_import_opts', glance.async.flows.api_image_import.api_import_opts), + ('inject_metadata_properties', + glance.async.flows.plugins.inject_image_metadata.inject_metadata_opts) ] diff --git a/glance/tests/unit/async/flows/plugins/__init__.py b/glance/tests/unit/async/flows/plugins/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/glance/tests/unit/async/flows/plugins/test_inject_image_metadata.py b/glance/tests/unit/async/flows/plugins/test_inject_image_metadata.py new file mode 100644 index 0000000000..ed1d8cf605 --- /dev/null +++ b/glance/tests/unit/async/flows/plugins/test_inject_image_metadata.py @@ -0,0 +1,128 @@ +# Copyright 2018 NTT DATA, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import mock +import os + +import glance_store +from oslo_config import cfg + +import glance.async.flows.plugins.inject_image_metadata as inject_metadata +from glance.common import utils +from glance import domain +from glance import gateway +from glance.tests.unit import utils as test_unit_utils +import glance.tests.utils as test_utils + +CONF = cfg.CONF + + +UUID1 = 'c80a1a6c-bd1f-41c5-90ee-81afedb1d58d' +TENANT1 = '6838eb7b-6ded-434a-882c-b344c77fe8df' + + +class TestInjectImageMetadataTask(test_utils.BaseTestCase): + + def setUp(self): + super(TestInjectImageMetadataTask, self).setUp() + + glance_store.register_opts(CONF) + self.config(default_store='file', + stores=['file', 'http'], + filesystem_store_datadir=self.test_dir, + group="glance_store") + glance_store.create_stores(CONF) + + self.work_dir = os.path.join(self.test_dir, 'work_dir') + utils.safe_mkdirs(self.work_dir) + self.config(work_dir=self.work_dir, group='task') + + self.context = mock.MagicMock() + self.img_repo = mock.MagicMock() + self.task_repo = mock.MagicMock() + self.image_id = mock.MagicMock() + + self.gateway = gateway.Gateway() + self.task_factory = domain.TaskFactory() + self.img_factory = self.gateway.get_image_factory(self.context) + self.image = self.img_factory.new_image(image_id=UUID1, + disk_format='qcow2', + container_format='bare') + + task_input = { + "import_from": "http://cloud.foo/image.qcow2", + "import_from_format": "qcow2", + "image_properties": {'disk_format': 'qcow2', + 'container_format': 'bare'} + } + task_ttl = CONF.task.task_time_to_live + + self.task_type = 'import' + self.task = self.task_factory.new_task(self.task_type, TENANT1, + task_time_to_live=task_ttl, + task_input=task_input) + + def test_inject_image_metadata_using_non_admin_user(self): + context = test_unit_utils.get_fake_context(roles='member') + inject_image_metadata = inject_metadata._InjectMetadataProperties( + context, self.task.task_id, self.task_type, self.img_repo, + self.image_id) + + self.config(inject={"test": "abc"}, + group='inject_metadata_properties') + + with mock.patch.object(self.img_repo, 'get') as get_mock: + image = mock.MagicMock(image_id=self.image_id, + extra_properties={"test": "abc"}) + get_mock.return_value = image + + with mock.patch.object(self.img_repo, 'save') as save_mock: + inject_image_metadata.execute() + get_mock.assert_called_once_with(self.image_id) + save_mock.assert_called_once_with(image) + self.assertEqual({"test": "abc"}, image.extra_properties) + + def test_inject_image_metadata_using_admin_user(self): + context = test_unit_utils.get_fake_context(roles='admin') + inject_image_metadata = inject_metadata._InjectMetadataProperties( + context, self.task.task_id, self.task_type, self.img_repo, + self.image_id) + + self.config(inject={"test": "abc"}, + group='inject_metadata_properties') + + inject_image_metadata.execute() + + with mock.patch.object(self.img_repo, 'get') as get_mock: + get_mock.assert_not_called() + + with mock.patch.object(self.img_repo, 'save') as save_mock: + save_mock.assert_not_called() + + def test_inject_image_metadata_empty(self): + context = test_unit_utils.get_fake_context(roles='member') + inject_image_metadata = inject_metadata._InjectMetadataProperties( + context, self.task.task_id, self.task_type, self.img_repo, + self.image_id) + + self.config(inject={}, group='inject_metadata_properties') + + inject_image_metadata.execute() + + with mock.patch.object(self.img_repo, 'get') as get_mock: + get_mock.assert_not_called() + + with mock.patch.object(self.img_repo, 'save') as save_mock: + save_mock.assert_not_called() diff --git a/glance/tests/unit/utils.py b/glance/tests/unit/utils.py index 067851cd5c..2b5aa13317 100644 --- a/glance/tests/unit/utils.py +++ b/glance/tests/unit/utils.py @@ -97,6 +97,21 @@ def fake_get_verifier(context, img_signature_certificate_uuid, return verifier +def get_fake_context(user=USER1, tenant=TENANT1, roles=None, is_admin=False): + if roles is None: + roles = ['member'] + + kwargs = { + 'user': user, + 'tenant': tenant, + 'roles': roles, + 'is_admin': is_admin, + } + + context = glance.context.RequestContext(**kwargs) + return context + + class FakeDB(object): def __init__(self, initialize=True): diff --git a/releasenotes/notes/bp-inject-image-metadata-0a08af539bcce7f2.yaml b/releasenotes/notes/bp-inject-image-metadata-0a08af539bcce7f2.yaml new file mode 100644 index 0000000000..59750bdf40 --- /dev/null +++ b/releasenotes/notes/bp-inject-image-metadata-0a08af539bcce7f2.yaml @@ -0,0 +1,72 @@ +--- +features: + - | + Made provision to inject image metadata properties to non-admin + images during creation of image using 'image-import' API. + +upgrade: + - | + - There are two methods to create images: + + - Method A: + + .. code-block:: none + + POST /v2/images + PUT /v2/images/{image_id}/file + + - Method B: + + .. code-block:: none + + POST /v2/images + PUT /v2/images/{image_id}/stage + POST /v2/images/{image_id}/import + + The long term goal is to make end-users use Method B to create images + and cross-services like Nova to use Method A until changes are made to + use Method B. To restrict end-users from using Method A to create + images, you will need to allow only admin or service users to call + "upload_image" API as shown below. + + .. code-block:: none + + upload_image": "role:admin or (service_user_id:) or + (service_roles:)" + + "service_role" is the role which is created for the service user + and assigned to the trusted services. + + - To use this feature below configurations are required: + + You will need to configure 'glance-image-import.conf' file as shown + below: + + .. code-block:: none + + [image_import_opts] + image_import_plugins = [inject_image_metadata] + + [inject_metadata_properties] + ignore_user_roles = admin,... + inject = "property1":"value",... + + The first section "image_import_opts" is used to enable/plug the task + using `image_import_plugins` parameter by giving plugin name. + Plugin name is nothing but the module name under + glance/async/flows/plugins/ + + You don't want to allow end-users to create metadata properties + you want to be injected automatically during creation of images. + So, you will need to protect such metadata properties using + property protection configuration file as shown below. + Only admin or service user will be able to create metadata + property 'property1'. + + .. code-block:: none + + [property1] + create = admin,service_role + read = admin,service_role,member,_member_ + update = admin + delete = admin diff --git a/setup.cfg b/setup.cfg index 3d9767fddc..d8b93c5e6c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -24,6 +24,7 @@ data_files = etc/glance-manage.conf etc/glance-registry.conf etc/glance-scrubber.conf + etc/glance-image-import.conf etc/glance-api-paste.ini etc/glance-registry-paste.ini etc/policy.json @@ -55,6 +56,7 @@ oslo.config.opts = glance.scrubber = glance.opts:list_scrubber_opts glance.cache= glance.opts:list_cache_opts glance.manage = glance.opts:list_manage_opts + glance = glance.opts:list_image_import_opts oslo.config.opts.defaults = glance.api = glance.common.config:set_cors_middleware_defaults glance.database.migration_backend = @@ -73,6 +75,7 @@ glance.flows.import = glance.image_import.plugins = no_op = glance.async.flows.plugins.no_op:get_flow + inject_image_metadata=glance.async.flows.plugins.inject_image_metadata:get_flow [build_sphinx] builder = html man diff --git a/tox.ini b/tox.ini index b79797ab1d..2bf8c44a1f 100644 --- a/tox.ini +++ b/tox.ini @@ -68,6 +68,7 @@ commands = oslo-config-generator --config-file etc/oslo-config-generator/glance-scrubber.conf oslo-config-generator --config-file etc/oslo-config-generator/glance-cache.conf oslo-config-generator --config-file etc/oslo-config-generator/glance-manage.conf + oslo-config-generator --config-file etc/oslo-config-generator/glance-image-import.conf [testenv:api-ref] # This environment is called from CI scripts to test and publish