diff --git a/zun/api/controllers/v1/containers.py b/zun/api/controllers/v1/containers.py index 904076a96..6b1b1c492 100644 --- a/zun/api/controllers/v1/containers.py +++ b/zun/api/controllers/v1/containers.py @@ -289,6 +289,8 @@ class ContainersController(base.Controller): 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') 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 48bb57991..a435c6de1 100644 --- a/zun/api/controllers/v1/schemas/containers.py +++ b/zun/api/controllers/v1/schemas/containers.py @@ -35,6 +35,7 @@ _container_properties = { 'runtime': parameter_types.runtime, 'hostname': parameter_types.hostname, 'disk': parameter_types.positive_integer, + 'availability_zone': parameter_types.availability_zone, } container_create = { diff --git a/zun/common/validation/parameter_types.py b/zun/common/validation/parameter_types.py index 88ef8d4d5..dea43cd34 100644 --- a/zun/common/validation/parameter_types.py +++ b/zun/common/validation/parameter_types.py @@ -113,6 +113,12 @@ nets = { 'type': ['array', 'null'] } +availability_zone = { + 'type': ['string', 'null'], + 'minLength': 1, + 'maxLength': 255, +} + mounts = { 'type': ['array', 'null'], 'items': { diff --git a/zun/conf/availability_zone.py b/zun/conf/availability_zone.py index 614aa55b2..75108c1c3 100644 --- a/zun/conf/availability_zone.py +++ b/zun/conf/availability_zone.py @@ -24,6 +24,20 @@ services. Possible values: * Any string representing an existing availability zone name. +"""), + cfg.StrOpt('default_schedule_zone', + help=""" +Default availability zone for containers. + +This option determines the default availability zone for containers, which will +be used when a user does not specify one when creating a container. The +container(s) will be bound to this availability zone for their lifetime. + +Possible values: + +* Any string representing an existing availability zone name. +* None, which means that the container can move from one availability zone to + another during its lifetime if it is moved from one compute node to another. """), ] diff --git a/zun/conf/scheduler.py b/zun/conf/scheduler.py index 48bfc3b93..c807576f0 100644 --- a/zun/conf/scheduler.py +++ b/zun/conf/scheduler.py @@ -63,6 +63,7 @@ Related options: """), cfg.ListOpt("enabled_filters", default=[ + "AvailabilityZoneFilter", "CPUFilter", "RamFilter", "ComputeFilter" diff --git a/zun/scheduler/filters/availability_zone_filter.py b/zun/scheduler/filters/availability_zone_filter.py new file mode 100644 index 000000000..7796ec78f --- /dev/null +++ b/zun/scheduler/filters/availability_zone_filter.py @@ -0,0 +1,47 @@ +# 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_log import log as logging + +import zun.conf +from zun.scheduler import filters + + +LOG = logging.getLogger(__name__) +CONF = zun.conf.CONF + + +class AvailabilityZoneFilter(filters.BaseHostFilter): + """Filters Hosts by availability zone.""" + + # Availability zones do not change within a request + run_filter_once_per_request = True + + def host_passes(self, host_state, container, extra_spec): + availability_zone = extra_spec.get('availability_zone') or \ + CONF.default_schedule_zone + if not availability_zone: + return True + + host_az = host_state.service.availability_zone + if not host_az: + host_az = CONF.default_availability_zone + + hosts_passes = availability_zone == host_az + if not hosts_passes: + LOG.debug("Availability Zone '%(az)s' requested. " + "%(host_state)s has AZs: %(host_az)s", + {'host_state': host_state, + 'az': availability_zone, + 'host_az': host_az}) + + return hosts_passes diff --git a/zun/tests/unit/api/controllers/v1/test_containers.py b/zun/tests/unit/api/controllers/v1/test_containers.py index d63e27830..650b50920 100644 --- a/zun/tests/unit/api/controllers/v1/test_containers.py +++ b/zun/tests/unit/api/controllers/v1/test_containers.py @@ -300,6 +300,37 @@ class TestContainerController(api_base.FunctionalTest): self.assertEqual(1, len(requested_networks)) self.assertEqual(fake_network['id'], requested_networks[0]['network']) + @patch('zun.network.neutron.NeutronAPI.get_available_network') + @patch('zun.compute.api.API.container_create') + @patch('zun.compute.api.API.image_search') + def test_create_container_with_availability_zone( + self, mock_search, mock_container_create, + mock_neutron_get_network): + mock_container_create.side_effect = lambda x, y, **z: y + fake_network = {'id': 'foo'} + mock_neutron_get_network.return_value = fake_network + # Create a container with a command + params = ('{"name": "MyDocker", "image": "ubuntu",' + '"command": "env",' + '"availability_zone": "test-az"}') + response = self.post('/v1/containers/', + params=params, + content_type='application/json') + self.assertEqual(202, response.status_int) + response = self.get('/v1/containers/') + self.assertEqual(200, response.status_int) + self.assertEqual(2, len(response.json)) + c = response.json['containers'][0] + self.assertIsNotNone(c.get('uuid')) + self.assertEqual('MyDocker', c.get('name')) + self.assertEqual('env', c.get('command')) + self.assertIsNone(c.get('memory')) + self.assertEqual({}, c.get('environment')) + mock_neutron_get_network.assert_called_once() + extra_spec = \ + mock_container_create.call_args[1]['extra_spec'] + self.assertEqual('test-az', extra_spec['availability_zone']) + @patch('zun.network.neutron.NeutronAPI.get_available_network') @patch('zun.compute.api.API.container_create') @patch('zun.compute.api.API.image_search') diff --git a/zun/tests/unit/scheduler/filters/test_availability_zone_filter.py b/zun/tests/unit/scheduler/filters/test_availability_zone_filter.py new file mode 100644 index 000000000..dd750824a --- /dev/null +++ b/zun/tests/unit/scheduler/filters/test_availability_zone_filter.py @@ -0,0 +1,72 @@ +# 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 zun.common import context +from zun import objects +from zun.scheduler.filters import availability_zone_filter as az_filter +from zun.tests import base +from zun.tests.unit.scheduler import fakes + + +class TestAvailabilityZoneFilter(base.TestCase): + + def setUp(self): + super(TestAvailabilityZoneFilter, self).setUp() + self.context = context.RequestContext('fake_user', 'fake_project') + + def test_az_filter(self): + self.assertIs(True, + self._test_az_filter(request_az='test-az', + node_az='test-az')) + self.assertIs(False, + self._test_az_filter(request_az='test-az', + node_az='another-az')) + + def test_az_filter_default_az(self): + cfg.CONF.set_override("default_availability_zone", "default-az") + self.assertIs(True, + self._test_az_filter(request_az='default-az', + node_az=None)) + self.assertIs(False, + self._test_az_filter(request_az='another-az', + node_az=None)) + + def test_az_filter_default_schedule_az(self): + cfg.CONF.set_override("default_schedule_zone", "schedule-az") + self.assertIs(True, + self._test_az_filter(request_az=None, + node_az='schedule-az')) + self.assertIs(False, + self._test_az_filter(request_az=None, + node_az='another-az')) + + def test_az_filter_no_az_requested(self): + self.assertIs(True, + self._test_az_filter(request_az=None, + node_az=None)) + self.assertIs(True, + self._test_az_filter(request_az=None, + node_az='any-az')) + + def _test_az_filter(self, request_az, node_az): + filt_cls = az_filter.AvailabilityZoneFilter() + container = objects.Container(self.context) + service = objects.ZunService(self.context) + service.availability_zone = node_az + extra_spec = {} + if request_az: + extra_spec = {'availability_zone': request_az} + host = fakes.FakeHostState('fake-host', + {'service': service}) + return filt_cls.host_passes(host, container, extra_spec)