From dc49d557f0630d023e547f83504d7e7c996d7444 Mon Sep 17 00:00:00 2001 From: Hongbin Lu Date: Mon, 9 Mar 2020 00:07:04 +0000 Subject: [PATCH] Support 'host' on creating container Implements: blueprint specify-host-in-creation Change-Id: If83de304d8fa609c618030df92c2997fc0731956 --- .zuul.yaml | 2 +- api-ref/source/containers.inc | 1 + api-ref/source/parameters.yaml | 8 +++++ ...t-for-requested_host-0ea7e317234c3d0c.yaml | 5 +++ zun/api/controllers/v1/containers.py | 17 +++++++++- zun/api/controllers/v1/schemas/containers.py | 4 +++ zun/api/controllers/versions.py | 3 +- zun/api/rest_api_version_history.rst | 6 ++++ zun/common/exception.py | 4 +++ zun/common/policies/container.py | 11 +++++++ zun/compute/api.py | 25 ++++++++++++++ zun/scheduler/filter_scheduler.py | 33 ++++++++++++++++--- zun/scheduler/utils.py | 22 ++++++++++++- zun/tests/unit/api/base.py | 2 +- zun/tests/unit/api/controllers/test_root.py | 4 +-- zun/tests/unit/compute/test_compute_api.py | 8 ++--- 16 files changed, 140 insertions(+), 15 deletions(-) create mode 100644 releasenotes/notes/add-support-for-requested_host-0ea7e317234c3d0c.yaml diff --git a/.zuul.yaml b/.zuul.yaml index 764060a13..d59aa51c8 100644 --- a/.zuul.yaml +++ b/.zuul.yaml @@ -29,7 +29,7 @@ test-config: $TEMPEST_CONFIG: container_service: - min_microversion: 1.38 + min_microversion: 1.39 post-config: $ZUN_CONF: docker: diff --git a/api-ref/source/containers.inc b/api-ref/source/containers.inc index 032755adc..8dddecd90 100644 --- a/api-ref/source/containers.inc +++ b/api-ref/source/containers.inc @@ -61,6 +61,7 @@ Request - privileged: privileged-request - healthcheck: healthcheck-request - exposed_ports: exposed_ports + - host: requested_host Request Example ---------------- diff --git a/api-ref/source/parameters.yaml b/api-ref/source/parameters.yaml index eebcf3456..be9e667e7 100644 --- a/api-ref/source/parameters.yaml +++ b/api-ref/source/parameters.yaml @@ -1294,6 +1294,14 @@ request_id_body: in: body required: true type: string +requested_host: + description: | + The name of the host on which the container is to be created. + The API will return 400 if no host are found with the given host name. + By default, it can be specified by administrators only. + in: body + required: false + type: string restart_policy: description: | Restart policy to apply when a container exits. It must contain a diff --git a/releasenotes/notes/add-support-for-requested_host-0ea7e317234c3d0c.yaml b/releasenotes/notes/add-support-for-requested_host-0ea7e317234c3d0c.yaml new file mode 100644 index 000000000..3402d6805 --- /dev/null +++ b/releasenotes/notes/add-support-for-requested_host-0ea7e317234c3d0c.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + Add support for requesting specific host to run a container. + By default, host can be specified by administrators only. diff --git a/zun/api/controllers/v1/containers.py b/zun/api/controllers/v1/containers.py index 02aebf621..401780ea3 100644 --- a/zun/api/controllers/v1/containers.py +++ b/zun/api/controllers/v1/containers.py @@ -276,7 +276,7 @@ class ContainersController(base.Controller): container_dict['tty'] = interactive return self._do_post(run, **container_dict) - @base.Controller.api_version("1.36") # noqa + @base.Controller.api_version("1.36", "1.38") # noqa @pecan.expose('json') @api_utils.enforce_content_types(['application/json']) @exception.wrap_pecan_controller_exception @@ -285,6 +285,15 @@ class ContainersController(base.Controller): def post(self, run=False, **container_dict): return self._do_post(run, **container_dict) + @base.Controller.api_version("1.39") # noqa + @pecan.expose('json') + @api_utils.enforce_content_types(['application/json']) + @exception.wrap_pecan_controller_exception + @validation.validate_query_param(pecan.request, schema.query_param_create) + @validation.validated(schema.container_create_v139) + def post(self, run=False, **container_dict): + return self._do_post(run, **container_dict) + def _do_post(self, run=False, **container_dict): """Create or run a new container. @@ -403,12 +412,18 @@ class ContainersController(base.Controller): registry = utils.get_registry(registry) container_dict['registry_id'] = registry.id + requested_host = container_dict.pop('host', None) + if requested_host: + policy.enforce(context, "container:create:requested_destination", + action="container:create:requested_destination") + container_dict['status'] = consts.CREATING extra_spec = {} extra_spec['hints'] = container_dict.get('hints', None) extra_spec['pci_requests'] = pci_req extra_spec['availability_zone'] = container_dict.get( 'availability_zone') + extra_spec['requested_host'] = requested_host new_container = objects.Container(context, **container_dict) new_container.create(context) diff --git a/zun/api/controllers/v1/schemas/containers.py b/zun/api/controllers/v1/schemas/containers.py index 014d51b4d..523a0d13b 100644 --- a/zun/api/controllers/v1/schemas/containers.py +++ b/zun/api/controllers/v1/schemas/containers.py @@ -69,6 +69,10 @@ container_create = { 'additionalProperties': False } +# Add host in container +container_create_v139 = copy.deepcopy(container_create) +container_create_v139['properties']['host'] = parameter_types.hostname + query_param_rename = { 'type': 'object', 'properties': { diff --git a/zun/api/controllers/versions.py b/zun/api/controllers/versions.py index 7f43f3e4e..c8976ec67 100644 --- a/zun/api/controllers/versions.py +++ b/zun/api/controllers/versions.py @@ -71,10 +71,11 @@ REST_API_VERSION_HISTORY = """REST API Version History: * 1.36 - Add 'tty' to container * 1.37 - Add 'tty' and 'stdin' to capsule * 1.38 - Add 'annotations' to capsule + * 1.39 - Support requested host on container creation """ BASE_VER = '1.1' -CURRENT_MAX_VER = '1.38' +CURRENT_MAX_VER = '1.39' class Version(object): diff --git a/zun/api/rest_api_version_history.rst b/zun/api/rest_api_version_history.rst index 5f3660291..a535c25ce 100644 --- a/zun/api/rest_api_version_history.rst +++ b/zun/api/rest_api_version_history.rst @@ -293,3 +293,9 @@ user documentation. Add 'annotations' to capsule. This field stores metadata of the capsule in key-value format. + +1.39 +---- + + Add 'host' parameter on POST /v1/containers. + This field is used to request a host to run the container. diff --git a/zun/common/exception.py b/zun/common/exception.py index 19b124bcc..fe367ee14 100644 --- a/zun/common/exception.py +++ b/zun/common/exception.py @@ -889,6 +889,10 @@ class ComputeHostNotFound(NotFound): message = _("Compute host %(host)s could not be found.") +class RequestedHostNotFound(NotFound): + message = _("Requested host %(host)s could not be found.") + + class CNIError(ZunException): pass diff --git a/zun/common/policies/container.py b/zun/common/policies/container.py index 94b261433..2a55d9e03 100644 --- a/zun/common/policies/container.py +++ b/zun/common/policies/container.py @@ -52,6 +52,17 @@ rules = [ } ] ), + policy.DocumentedRuleDefault( + name=CONTAINER % 'create:requested_destination', + check_str=base.RULE_ADMIN_API, + description=('Create a container on the requested compute host.'), + operations=[ + { + 'path': '/v1/containers', + 'method': 'POST' + } + ] + ), policy.DocumentedRuleDefault( name=CONTAINER % 'create:image_pull_policy', check_str=base.RULE_ADMIN_API, diff --git a/zun/compute/api.py b/zun/compute/api.py index d44848d7a..c9caafd83 100644 --- a/zun/compute/api.py +++ b/zun/compute/api.py @@ -46,6 +46,10 @@ class API(object): def container_create(self, context, new_container, extra_spec, requested_networks, requested_volumes, run, pci_requests=None): + requested_host = extra_spec.get('requested_host') + if requested_host: + self._validate_host(context, new_container, requested_host) + try: host_state = self._schedule_container(context, new_container, extra_spec) @@ -96,6 +100,27 @@ class API(object): requested_networks, requested_volumes, run, pci_requests) + def _validate_host(self, context, container, host): + """Check whether compute nodes exist by validating the host. + If host is supplied, we can lookup the ComputeNode in + the API DB. + + :param context: The API request context. + :param host: Target host. + :raises: exception.RequestedHostNotFound if we find no compute nodes + with host and/or hypervisor_hostname. + """ + + if host: + # When host is specified. + try: + objects.ComputeNode.get_by_name(context, host) + except exception.ComputeNodeNotFound: + LOG.info('No compute node record found for host %(host)s.', + {'host': host}) + container.destroy(context) + raise exception.RequestedHostNotFound(host=host) + def _schedule_container(self, context, new_container, extra_spec): dests = self.scheduler_client.select_destinations(context, [new_container], diff --git a/zun/scheduler/filter_scheduler.py b/zun/scheduler/filter_scheduler.py index 08cb55ad9..5d25899e4 100644 --- a/zun/scheduler/filter_scheduler.py +++ b/zun/scheduler/filter_scheduler.py @@ -72,10 +72,7 @@ class FilterScheduler(driver.Scheduler): hosts = services.keys() nodes = [node for node in nodes if node.hostname in hosts] host_states = self.get_all_host_state(nodes, services) - hosts = self.filter_handler.get_filtered_objects(self.enabled_filters, - host_states, - container, - extra_specs) + hosts = self._get_filtered_hosts(host_states, container, extra_specs) if not hosts: msg = _("Is the appropriate service running?") raise exception.NoValidHost(reason=msg) @@ -122,6 +119,34 @@ class FilterScheduler(driver.Scheduler): return claimed_host + def _get_filtered_hosts(self, hosts, container, extra_specs): + """Filter hosts and return only ones passing all filters.""" + + def _get_hosts_matching_request(hosts, requested_host): + matched_hosts = [x for x in hosts + if x.hostname == requested_host] + if matched_hosts: + LOG.info('Host filter only checking host %(host)s', + {'host': requested_host}) + else: + # NOTE(hongbin): The API level should prevent the user from + # providing a wrong requested host but let's make sure a wrong + # destination doesn't trample the scheduler still. + LOG.info('No hosts matched due to not matching requested ' + 'destination (%(host)s)', {'host': requested_host}) + return iter(matched_hosts) + + requested_host = extra_specs.get('requested_host', []) + + if requested_host: + # NOTE(hongbin): Reduce a potentially long set of hosts as much as + # possible to any requested destination nodes before passing the + # list to the filters + hosts = _get_hosts_matching_request(hosts, requested_host) + + return self.filter_handler.get_filtered_objects( + self.enabled_filters, hosts, container, extra_specs) + def select_destinations(self, context, containers, extra_specs, alloc_reqs_by_rp_uuid, provider_summaries, allocation_request_version=None): diff --git a/zun/scheduler/utils.py b/zun/scheduler/utils.py index 44852cf69..1625b9fc4 100644 --- a/zun/scheduler/utils.py +++ b/zun/scheduler/utils.py @@ -22,6 +22,7 @@ import os_resource_classes as orc from oslo_log import log as logging from six.moves.urllib import parse +from zun.common import exception import zun.conf from zun import objects @@ -285,7 +286,7 @@ class ResourceRequest(object): def resources_from_request_spec(ctxt, container_obj, extra_specs): """Given a Container object, returns a ResourceRequest of the resources, traits, and aggregates it represents. - :param context: The request context. + :param ctxt: The request context. :param container_obj: A Container object. :return: A ResourceRequest object. :raises NoValidHost: If the specified host/node is not found in the DB. @@ -341,6 +342,25 @@ def resources_from_request_spec(ctxt, container_obj, extra_specs): for group in requested_resources: res_req.add_request_group(group) + target_host = extra_specs.get('requested_host') + if target_host: + nodes = objects.ComputeNode.list( + ctxt, filters={'hostname': target_host}) + if not nodes: + reason = (_('No such host - host: %(host)s ') % + {'host': target_host}) + raise exception.NoValidHost(reason=reason) + if len(nodes) == 1: + grp = res_req.get_request_group(None) + grp.in_tree = nodes[0].rp_uuid + else: + # Multiple nodes are found when a target host is specified + # without a specific node. Since placement doesn't support + # multiple uuids in the `in_tree` queryparam, what we can do here + # is to remove the limit from the `GET /a_c` query to prevent + # the found nodes from being filtered out in placement. + res_req._limit = None + # Don't limit allocation candidates when using affinity/anti-affinity. if (extra_specs.get('hints') and any( key in ['group', 'same_host', 'different_host'] diff --git a/zun/tests/unit/api/base.py b/zun/tests/unit/api/base.py index 154cf8cf2..2d91eca17 100644 --- a/zun/tests/unit/api/base.py +++ b/zun/tests/unit/api/base.py @@ -28,7 +28,7 @@ from zun.tests.unit.db import base PATH_PREFIX = '/v1' -CURRENT_VERSION = "container 1.38" +CURRENT_VERSION = "container 1.39" class FunctionalTest(base.DbTestCase): diff --git a/zun/tests/unit/api/controllers/test_root.py b/zun/tests/unit/api/controllers/test_root.py index 73c92bb2d..4de42692d 100644 --- a/zun/tests/unit/api/controllers/test_root.py +++ b/zun/tests/unit/api/controllers/test_root.py @@ -28,7 +28,7 @@ class TestRootController(api_base.FunctionalTest): 'default_version': {'id': 'v1', 'links': [{'href': 'http://localhost/v1/', 'rel': 'self'}], - 'max_version': '1.38', + 'max_version': '1.39', 'min_version': '1.1', 'status': 'CURRENT'}, 'description': 'Zun is an OpenStack project which ' @@ -37,7 +37,7 @@ class TestRootController(api_base.FunctionalTest): 'versions': [{'id': 'v1', 'links': [{'href': 'http://localhost/v1/', 'rel': 'self'}], - 'max_version': '1.38', + 'max_version': '1.39', 'min_version': '1.1', 'status': 'CURRENT'}]} diff --git a/zun/tests/unit/compute/test_compute_api.py b/zun/tests/unit/compute/test_compute_api.py index cee83f890..88666adc1 100644 --- a/zun/tests/unit/compute/test_compute_api.py +++ b/zun/tests/unit/compute/test_compute_api.py @@ -59,7 +59,7 @@ class TestAPI(base.TestCase): 'limits': {}} mock_image_search.return_value = [image_meta] self.compute_api.container_create(self.context, container, - None, None, None, False) + {}, None, None, False) self.assertTrue(mock_schedule_container.called) self.assertTrue(mock_image_search.called) self.assertTrue(mock_container_create.called) @@ -81,7 +81,7 @@ class TestAPI(base.TestCase): mock_image_search.side_effect = exception.OperationNotSupported self.compute_api.container_create(self.context, container, - None, None, None, False) + {}, None, None, False) self.assertTrue(mock_schedule_container.called) self.assertTrue(mock_image_search.called) self.assertTrue(mock_container_create.called) @@ -95,7 +95,7 @@ class TestAPI(base.TestCase): mock_schedule_container.side_effect = exception.NoValidHost( reason='not enough host') self.compute_api.container_create(self.context, container, - None, None, None, False) + {}, None, None, False) self.assertTrue(mock_schedule_container.called) self.assertTrue(mock_save.called) self.assertEqual(consts.ERROR, container.status) @@ -118,7 +118,7 @@ class TestAPI(base.TestCase): mock_image_search.side_effect = exception.ZunException self.compute_api.container_create( - self.context, container, None, None, None, False) + self.context, container, {}, None, None, False) self.assertTrue(mock_schedule_container.called) self.assertTrue(mock_image_search.called) self.assertFalse(mock_save.called)