diff --git a/osc_placement/plugin.py b/osc_placement/plugin.py index 2ba6731..bee841e 100644 --- a/osc_placement/plugin.py +++ b/osc_placement/plugin.py @@ -21,7 +21,12 @@ LOG = logging.getLogger(__name__) API_NAME = 'placement' API_VERSION_OPTION = 'os_placement_api_version' -API_VERSIONS = {'1.0': 'osc_placement.http.SessionClient'} +SUPPORTED_VERSIONS = [ + '1.0', + '1.1' +] +API_VERSIONS = {v: 'osc_placement.http.SessionClient' + for v in SUPPORTED_VERSIONS} def make_client(instance): diff --git a/osc_placement/resources/aggregate.py b/osc_placement/resources/aggregate.py new file mode 100644 index 0000000..0d83c84 --- /dev/null +++ b/osc_placement/resources/aggregate.py @@ -0,0 +1,86 @@ +# 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 osc_lib.command import command +from osc_placement import version + + +BASE_URL = '/resource_providers/{uuid}/aggregates' +FIELDS = ('uuid',) + + +class SetAggregate(command.Lister): + + """Associate a list of aggregates with the resource provider. + + Each request cleans up previously associated resource provider + aggregates entirely and sets the new ones. Passing empty aggregate + UUID list will remove all associations with aggregates for the + particular resource provider. + + This command requires at least --os-placement-api-version 1.1. + """ + + def get_parser(self, prog_name): + parser = super(SetAggregate, self).get_parser(prog_name) + + parser.add_argument( + 'uuid', + metavar='', + help='UUID of the resource provider' + ) + + parser.add_argument( + '--aggregate', + metavar='', + help='UUID of the aggregate. Specify multiple times to associate ' + 'a resource provider with multiple aggregates.', + action='append', + default=[] + ) + + return parser + + @version.check(version.ge('1.1')) + def take_action(self, parsed_args): + http = self.app.client_manager.placement + + url = BASE_URL.format(uuid=parsed_args.uuid) + resp = http.request('PUT', url, json=parsed_args.aggregate).json() + return FIELDS, [[r] for r in resp['aggregates']] + + +class ListAggregate(command.Lister): + + """List resource provider aggregates. + + This command requires at least --os-placement-api-version 1.1. + """ + + def get_parser(self, prog_name): + parser = super(ListAggregate, self).get_parser(prog_name) + + parser.add_argument( + 'uuid', + metavar='', + help='UUID of the resource provider' + ) + + return parser + + @version.check(version.ge('1.1')) + def take_action(self, parsed_args): + http = self.app.client_manager.placement + + url = BASE_URL.format(uuid=parsed_args.uuid) + resp = http.request('GET', url).json() + return FIELDS, [[r] for r in resp['aggregates']] diff --git a/osc_placement/tests/functional/base.py b/osc_placement/tests/functional/base.py index a33b0bd..9de0594 100644 --- a/osc_placement/tests/functional/base.py +++ b/osc_placement/tests/functional/base.py @@ -22,16 +22,22 @@ RP_PREFIX = 'osc-placement-functional-tests-' class BaseTestCase(base.BaseTestCase): - @staticmethod - def openstack(cmd, may_fail=False, use_json=False): + VERSION = None + + @classmethod + def openstack(cls, cmd, may_fail=False, use_json=False): try: to_exec = ['openstack'] + cmd.split() if use_json: to_exec += ['-f', 'json'] + if cls.VERSION is not None: + to_exec += ['--os-placement-api-version', cls.VERSION] output = subprocess.check_output(to_exec, stderr=subprocess.STDOUT) result = (output or b'').decode('utf-8') - except subprocess.CalledProcessError: + except subprocess.CalledProcessError as e: + msg = 'Command: "%s"\noutput: %s' % (' '.join(e.cmd), e.output) + e.cmd = msg if not may_fail: raise @@ -40,6 +46,18 @@ class BaseTestCase(base.BaseTestCase): else: return result + def assertCommandFailed(self, message, func, *args, **kwargs): + signature = [func] + signature.extend(args) + try: + func(*args, **kwargs) + self.fail('Command does not fail as required (%s)' % signature) + + except subprocess.CalledProcessError as e: + self.assertIn( + message, e.output, + 'Command "%s" fails with different message' % e.cmd) + def resource_provider_create(self, name=''): if not name: random_part = ''.join(random.choice(string.ascii_letters) @@ -143,3 +161,13 @@ class BaseTestCase(base.BaseTestCase): def resource_provider_show_usage(self, uuid): return self.openstack('resource provider usage show ' + uuid, use_json=True) + + def resource_provider_aggregate_list(self, uuid): + return self.openstack('resource provider aggregate list ' + uuid, + use_json=True) + + def resource_provider_aggregate_set(self, uuid, *aggregates): + cmd = 'resource provider aggregate set %s ' % uuid + cmd += ' '.join('--aggregate %s' % aggregate + for aggregate in aggregates) + return self.openstack(cmd, use_json=True) diff --git a/osc_placement/tests/functional/test_aggregate.py b/osc_placement/tests/functional/test_aggregate.py new file mode 100644 index 0000000..006b420 --- /dev/null +++ b/osc_placement/tests/functional/test_aggregate.py @@ -0,0 +1,88 @@ +# 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 uuid + +from osc_placement.tests.functional import base + + +class TestAggregate(base.BaseTestCase): + VERSION = '1.1' + + def test_fail_if_no_rp(self): + self.assertCommandFailed( + 'too few arguments', + self.openstack, + 'resource provider aggregate list') + + def test_fail_if_rp_not_found(self): + self.assertCommandFailed( + 'No resource provider', + self.resource_provider_aggregate_list, + 'fake-uuid') + + def test_return_empty_list_if_no_aggregates(self): + rp = self.resource_provider_create() + self.assertEqual( + [], self.resource_provider_aggregate_list(rp['uuid'])) + + def test_success_set_aggregate(self): + rp = self.resource_provider_create() + aggs = {str(uuid.uuid4()) for _ in range(2)} + rows = self.resource_provider_aggregate_set( + rp['uuid'], *aggs) + + self.assertEqual(aggs, {r['uuid'] for r in rows}) + rows = self.resource_provider_aggregate_list(rp['uuid']) + self.assertEqual(aggs, {r['uuid'] for r in rows}) + self.resource_provider_aggregate_set(rp['uuid']) + rows = self.resource_provider_aggregate_list(rp['uuid']) + self.assertEqual([], rows) + + def test_set_aggregate_fail_if_no_rp(self): + self.assertCommandFailed( + 'too few arguments', + self.openstack, + 'resource provider aggregate set') + + def test_success_set_multiple_aggregates(self): + # each rp is associated with two aggregates + rps = [self.resource_provider_create() for _ in range(2)] + aggs = {str(uuid.uuid4()) for _ in range(2)} + for rp in rps: + rows = self.resource_provider_aggregate_set(rp['uuid'], *aggs) + self.assertEqual(aggs, {r['uuid'] for r in rows}) + # remove association for the first aggregate + rows = self.resource_provider_aggregate_set(rps[0]['uuid']) + self.assertEqual([], rows) + # second rp should be in aggregates + rows = self.resource_provider_aggregate_list(rps[1]['uuid']) + self.assertEqual(aggs, {r['uuid'] for r in rows}) + # cleanup + rows = self.resource_provider_aggregate_set(rps[1]['uuid']) + self.assertEqual([], rows) + + def test_success_set_large_number_aggregates(self): + rp = self.resource_provider_create() + aggs = {str(uuid.uuid4()) for _ in range(100)} + rows = self.resource_provider_aggregate_set( + rp['uuid'], *aggs) + self.assertEqual(aggs, {r['uuid'] for r in rows}) + rows = self.resource_provider_aggregate_set(rp['uuid']) + self.assertEqual([], rows) + + def test_fail_if_incorrect_aggregate_uuid(self): + rp = self.resource_provider_create() + self.assertCommandFailed( + "is not a 'uuid'", + self.resource_provider_aggregate_set, + rp['uuid'], 'abc', 'efg') diff --git a/osc_placement/tests/unit/test_version.py b/osc_placement/tests/unit/test_version.py new file mode 100644 index 0000000..9918f9f --- /dev/null +++ b/osc_placement/tests/unit/test_version.py @@ -0,0 +1,82 @@ +# 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 oslotest.base as base + +from osc_placement import version + + +class TestVersion(base.BaseTestCase): + def test_compare(self): + self.assertTrue(version._compare('1.0', version.gt('0.9'))) + self.assertTrue(version._compare('1.0', version.ge('0.9'))) + self.assertTrue(version._compare('1.0', version.ge('1.0'))) + self.assertTrue(version._compare('1.0', version.eq('1.0'))) + self.assertTrue(version._compare('1.0', version.le('1.0'))) + self.assertTrue(version._compare('1.0', version.le('1.1'))) + self.assertTrue(version._compare('1.0', version.lt('1.1'))) + self.assertTrue( + version._compare('1.1', version.gt('1.0'), version.lt('1.2'))) + self.assertTrue( + version._compare( + '0.3', version.eq('0.2'), version.eq('0.3'), op=any)) + self.assertFalse(version._compare('1.0', version.gt('1.0'))) + self.assertFalse(version._compare('1.0', version.ge('1.1'))) + self.assertFalse(version._compare('1.0', version.eq('1.1'))) + self.assertFalse(version._compare('1.0', version.le('0.9'))) + self.assertFalse(version._compare('1.0', version.lt('0.9'))) + self.assertRaises( + ValueError, version._compare, 'abc', version.le('1.1')) + self.assertRaises( + ValueError, version._compare, '1.0', version.le('.0')) + self.assertRaises( + ValueError, version._compare, '1', version.le('2')) + + def test_compare_with_exc(self): + self.assertTrue(version.compare('1.05', version.gt('1.4'))) + self.assertFalse(version.compare('1.3', version.gt('1.4'), exc=False)) + self.assertRaisesRegex( + ValueError, + 'Operation or argument is not supported', + version.compare, '3.1.2', version.gt('3.1.3')) + + def test_check_decorator(self): + fake_api = mock.Mock() + fake_api_dec = version.check(version.gt('2.11'))(fake_api) + obj = mock.Mock() + obj.app.client_manager.placement.api_version = '2.12' + fake_api_dec(obj, 1, 2, 3) + fake_api.assert_called_once_with(obj, 1, 2, 3) + fake_api.reset_mock() + obj.app.client_manager.placement.api_version = '2.10' + self.assertRaisesRegex( + ValueError, + 'Operation or argument is not supported', + fake_api_dec, + obj, 1, 2, 3) + fake_api.assert_not_called() + + def test_check_mixin(self): + + class Test(version.CheckerMixin): + app = mock.Mock() + app.client_manager.placement.api_version = '1.2' + + t = Test() + self.assertTrue(t.compare_version(version.le('1.3'))) + self.assertTrue(t.check_version(version.ge('1.0'))) + self.assertRaisesRegex( + ValueError, + 'Operation or argument is not supported', + t.check_version, version.lt('1.2')) diff --git a/osc_placement/version.py b/osc_placement/version.py new file mode 100644 index 0000000..470c92e --- /dev/null +++ b/osc_placement/version.py @@ -0,0 +1,77 @@ +# 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 distutils.version import StrictVersion +import operator + + +def _op(func, b): + return lambda a: func(StrictVersion(a), StrictVersion(b)) + + +def lt(b): + return _op(operator.lt, b) + + +def le(b): + return _op(operator.le, b) + + +def eq(b): + return _op(operator.eq, b) + + +def ne(b): + return _op(operator.ne, b) + + +def ge(b): + return _op(operator.ge, b) + + +def gt(b): + return _op(operator.gt, b) + + +def _compare(ver, *predicates, **kwargs): + func = kwargs.get('op', all) + return func(p(ver) for p in predicates) + + +def compare(ver, *predicates, **kwargs): + exc = kwargs.get('exc', True) + if not _compare(ver, *predicates, **kwargs): + if exc: + raise ValueError( + 'Operation or argument is not supported with version %s' % ver) + return False + return True + + +def check(*predicates, **check_kwargs): + def wrapped(func): + def inner(self, *args, **kwargs): + version = self.app.client_manager.placement.api_version + compare(version, *predicates, **check_kwargs) + return func(self, *args, **kwargs) + return inner + return wrapped + + +class CheckerMixin(object): + def check_version(self, *predicates, **kwargs): + version = self.app.client_manager.placement.api_version + return compare(version, *predicates, **kwargs) + + def compare_version(self, *predicates, **kwargs): + version = self.app.client_manager.placement.api_version + return compare(version, *predicates, exc=False, **kwargs) diff --git a/setup.cfg b/setup.cfg index 23250be..b91e1e0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -35,13 +35,14 @@ openstack.placement.v1 = resource_provider_show = osc_placement.resources.resource_provider:ShowResourceProvider resource_provider_set = osc_placement.resources.resource_provider:SetResourceProvider resource_provider_delete = osc_placement.resources.resource_provider:DeleteResourceProvider + resource provider_usage_show = osc_placement.resources.usage:ShowUsage resource_provider_inventory_set = osc_placement.resources.inventory:SetInventory resource_provider_inventory_class_set = osc_placement.resources.inventory:SetClassInventory resource_provider_inventory_list = osc_placement.resources.inventory:ListInventory resource_provider_inventory_show = osc_placement.resources.inventory:ShowInventory resource_provider_inventory_delete = osc_placement.resources.inventory:DeleteInventory - resource provider_usage_show = osc_placement.resources.usage:ShowUsage - + resource_provider_aggregate_list = osc_placement.resources.aggregate:ListAggregate + resource_provider_aggregate_set = osc_placement.resources.aggregate:SetAggregate [build_sphinx] source-dir = doc/source