CLI for resource providers

Co-Authored-by: Andrey Volkov <avolkov@mirantis.com>
Blueprint: placement-osc-plugin
Change-Id: Ifa82bc0ae5c067fd39a932e9e0ec02d5987ead46
This commit is contained in:
Roman Podoliaka 2017-04-12 19:02:55 +03:00 committed by Andrey Volkov
parent 5769a511e9
commit 1b1ae94ed8
11 changed files with 584 additions and 23 deletions

60
osc_placement/http.py Normal file
View File

@ -0,0 +1,60 @@
# 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 contextlib
import json
import keystoneauth1.exceptions.http as ks_exceptions
import osc_lib.exceptions as exceptions
import six
_http_error_to_exc = {
cls.http_status: cls
for cls in exceptions.ClientException.__subclasses__()
}
@contextlib.contextmanager
def _wrap_http_exceptions():
"""Reraise osc-lib exceptions with detailed messages."""
try:
yield
except ks_exceptions.HttpError as exc:
detail = json.loads(exc.response.content)['errors'][0]['detail']
msg = detail.split('\n')[-1].strip()
exc_class = _http_error_to_exc.get(exc.http_status,
exceptions.CommandError)
six.raise_from(exc_class(exc.http_status, msg), exc)
class SessionClient(object):
def __init__(self, session, ks_filter, api_version='1.0'):
self.session = session
self.ks_filter = ks_filter
self.api_version = api_version
def request(self, method, url, **kwargs):
version = kwargs.pop('version', None)
api_version = (self.ks_filter['service_type'] + ' ' +
(version or self.api_version))
headers = kwargs.pop('headers', {})
headers.setdefault('OpenStack-API-Version', api_version)
headers.setdefault('Accept', 'application/json')
with _wrap_http_exceptions():
return self.session.request(url, method,
headers=headers,
endpoint_filter=self.ks_filter,
**kwargs)

View File

@ -21,7 +21,7 @@ LOG = logging.getLogger(__name__)
API_NAME = 'placement'
API_VERSION_OPTION = 'os_placement_api_version'
API_VERSIONS = {'1.0': 'osc_placement.plugin.Client'}
API_VERSIONS = {'1.0': 'osc_placement.http.SessionClient'}
def make_client(instance):
@ -31,10 +31,14 @@ def make_client(instance):
API_VERSIONS
)
ks_filter = {'service_type': API_NAME,
'region_name': instance._region_name,
'interface': instance.interface}
LOG.debug('Instantiating placement client: %s', client_class)
# TODO(rpodolyaka): add version negotiation
return client_class(session=instance.session,
region_name=instance._region_name,
interface=instance.interface,
ks_filter=ks_filter,
api_version=instance._api_version[API_NAME])
@ -49,14 +53,3 @@ def build_option_parser(parser):
help='Placement API version, default=1.0'
)
return parser
class Client(object):
def __init__(self, session, region_name, interface, api_version='1.0'):
self.session = session
self.api_version = api_version
self.ks_filter = {
'service_type': 'placement',
'region_name': region_name,
'interface': interface,
}

View File

View File

@ -0,0 +1,39 @@
# 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 six
from six.moves.urllib import parse as urlparse
def encode(value, encoding='utf-8'):
"""Return a byte repr of a string for a given encoding.
Byte strings and values of other types are returned as is.
"""
if isinstance(value, six.text_type):
return value.encode(encoding)
else:
return value
def url_with_filters(url, filters=None):
"""Add a percent-encoded string of filters (a dict) to a base url."""
if filters:
filters = [(encode(k), encode(v)) for k, v in filters.items()]
urlencoded_filters = urlparse.urlencode(filters)
url = urlparse.urljoin(url, '?' + urlencoded_filters)
return url

View File

@ -0,0 +1,159 @@
# 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_lib import utils
from osc_placement.resources import common
BASE_URL = '/resource_providers'
FIELDS = ('uuid', 'name', 'generation')
class CreateResourceProvider(command.ShowOne):
"""Create a new resource provider"""
def get_parser(self, prog_name):
parser = super(CreateResourceProvider, self).get_parser(prog_name)
parser.add_argument(
'--uuid',
metavar='<uuid>',
help='UUID of the resource provider'
)
parser.add_argument(
'name',
metavar='<name>',
help='Name of the resource provider'
)
return parser
def take_action(self, parsed_args):
http = self.app.client_manager.placement
data = {'name': parsed_args.name}
if 'uuid' in parsed_args and parsed_args.uuid:
data['uuid'] = parsed_args.uuid
resp = http.request('POST', BASE_URL, json=data)
resource = http.request('GET', resp.headers['Location']).json()
return FIELDS, utils.get_dict_properties(resource, FIELDS)
class ListResourceProvider(command.Lister):
"""List resource providers"""
def get_parser(self, prog_name):
parser = super(ListResourceProvider, self).get_parser(prog_name)
parser.add_argument(
'--uuid',
metavar='<uuid>',
help='UUID of the resource provider'
)
parser.add_argument(
'--name',
metavar='<name>',
help='Name of the resource provider'
)
return parser
def take_action(self, parsed_args):
http = self.app.client_manager.placement
filters = {}
if 'name' in parsed_args and parsed_args.name:
filters['name'] = parsed_args.name
if 'uuid' in parsed_args and parsed_args.uuid:
filters['uuid'] = parsed_args.uuid
url = common.url_with_filters(BASE_URL, filters)
resources = http.request('GET', url).json()['resource_providers']
rows = (utils.get_dict_properties(r, FIELDS) for r in resources)
return FIELDS, rows
class ShowResourceProvider(command.ShowOne):
"""Show resource provider details"""
def get_parser(self, prog_name):
parser = super(ShowResourceProvider, self).get_parser(prog_name)
# TODO(avolkov): show by uuid or name
parser.add_argument(
'uuid',
metavar='<uuid>',
help='UUID of the resource provider'
)
return parser
def take_action(self, parsed_args):
http = self.app.client_manager.placement
url = BASE_URL + '/' + parsed_args.uuid
resource = http.request('GET', url).json()
return FIELDS, utils.get_dict_properties(resource, FIELDS)
class SetResourceProvider(command.ShowOne):
"""Update an existing resource provider"""
def get_parser(self, prog_name):
parser = super(SetResourceProvider, self).get_parser(prog_name)
parser.add_argument(
'uuid',
metavar='<uuid>',
help='UUID of the resource provider'
)
parser.add_argument(
'--name',
metavar='<name>',
help='A new name of the resource provider',
required=True
)
return parser
def take_action(self, parsed_args):
http = self.app.client_manager.placement
url = BASE_URL + '/' + parsed_args.uuid
resource = http.request('PUT', url,
json={'name': parsed_args.name}).json()
return FIELDS, utils.get_dict_properties(resource, FIELDS)
class DeleteResourceProvider(command.Command):
"""Delete a resource provider"""
def get_parser(self, prog_name):
parser = super(DeleteResourceProvider, self).get_parser(prog_name)
# TODO(avolkov): delete by uuid or name
parser.add_argument(
'uuid',
metavar='<uuid>',
help='UUID of the resource provider'
)
return parser
def take_action(self, parsed_args):
http = self.app.client_manager.placement
url = BASE_URL + '/' + parsed_args.uuid
http.request('DELETE', url)

View File

@ -0,0 +1,81 @@
# 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 json
import random
import string
import subprocess
from oslotest import base
RP_PREFIX = 'osc-placement-functional-tests-'
class BaseTestCase(base.BaseTestCase):
@staticmethod
def openstack(cmd, may_fail=False, use_json=False):
try:
to_exec = ['openstack'] + cmd.split()
if use_json:
to_exec += ['-f', 'json']
output = subprocess.check_output(to_exec, stderr=subprocess.STDOUT)
result = (output or b'').decode('utf-8')
except subprocess.CalledProcessError:
if not may_fail:
raise
if use_json:
return json.loads(result)
else:
return result
def resource_provider_create(self, name=''):
if not name:
random_part = ''.join(random.choice(string.ascii_letters)
for i in range(10))
name = RP_PREFIX + random_part
res = self.openstack('resource provider create ' + name,
use_json=True)
def cleanup():
try:
self.resource_provider_delete(res['uuid'])
except subprocess.CalledProcessError as exc:
# may have already been deleted by a test case
err_message = exc.output.decode('utf-8').lower()
if 'no resource provider' not in err_message:
raise
self.addCleanup(cleanup)
return res
def resource_provider_set(self, uuid, name):
to_exec = 'resource provider set ' + uuid + ' --name ' + name
return self.openstack(to_exec, use_json=True)
def resource_provider_show(self, uuid):
return self.openstack('resource provider show ' + uuid, use_json=True)
def resource_provider_list(self, uuid=None, name=None):
to_exec = 'resource provider list'
if uuid:
to_exec += ' --uuid ' + uuid
if name:
to_exec += ' --name ' + name
return self.openstack(to_exec, use_json=True)
def resource_provider_delete(self, uuid):
return self.openstack('resource provider delete ' + uuid)

View File

@ -0,0 +1,120 @@
# 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 operator
import subprocess
import uuid
import six
from osc_placement.tests.functional import base
class TestResourceProvider(base.BaseTestCase):
def test_resource_provider_create(self):
created = self.resource_provider_create('test_rp_creation')
self.assertEqual('test_rp_creation', created['name'])
retrieved = self.resource_provider_show(created['uuid'])
self.assertEqual(created, retrieved)
def test_resource_provider_delete(self):
created = self.resource_provider_create()
before_delete = self.resource_provider_list(uuid=created['uuid'])
self.assertEqual([created['uuid']],
[rp['uuid'] for rp in before_delete])
self.resource_provider_delete(created['uuid'])
after_delete = self.resource_provider_list(uuid=created['uuid'])
self.assertEqual([], after_delete)
def test_resource_provider_delete_not_found(self):
rp_uuid = six.text_type(uuid.uuid4())
msg = 'No resource provider with uuid ' + rp_uuid + ' found'
exc = self.assertRaises(subprocess.CalledProcessError,
self.resource_provider_delete, rp_uuid)
self.assertIn(msg, exc.output.decode('utf-8'))
def test_resource_provider_set(self):
created = self.resource_provider_create(name='test_rp_orig_name')
before_update = self.resource_provider_show(created['uuid'])
self.assertEqual('test_rp_orig_name', before_update['name'])
self.assertEqual(0, before_update['generation'])
self.resource_provider_set(created['uuid'], name='test_rp_new_name')
after_update = self.resource_provider_show(created['uuid'])
self.assertEqual('test_rp_new_name', after_update['name'])
self.assertEqual(0, after_update['generation'])
def test_resource_provider_set_not_found(self):
rp_uuid = six.text_type(uuid.uuid4())
msg = 'No resource provider with uuid ' + rp_uuid + ' found'
exc = self.assertRaises(subprocess.CalledProcessError,
self.resource_provider_set, rp_uuid, 'test')
self.assertIn(msg, exc.output.decode('utf-8'))
def test_resource_provider_show(self):
created = self.resource_provider_create()
retrieved = self.resource_provider_show(created['uuid'])
self.assertEqual(created, retrieved)
def test_resource_provider_show_not_found(self):
rp_uuid = six.text_type(uuid.uuid4())
msg = 'No resource provider with uuid ' + rp_uuid + ' found'
exc = self.assertRaises(subprocess.CalledProcessError,
self.resource_provider_show, rp_uuid)
self.assertIn(msg, exc.output.decode('utf-8'))
def test_resource_provider_list(self):
rp1 = self.resource_provider_create()
rp2 = self.resource_provider_create()
expected_full = sorted([rp1, rp2], key=operator.itemgetter('uuid'))
self.assertEqual(
expected_full,
sorted([rp for rp in self.resource_provider_list()
if rp['name'] in (rp1['name'], rp2['name'])],
key=operator.itemgetter('uuid'))
)
def test_resource_provider_list_by_name(self):
rp1 = self.resource_provider_create()
self.resource_provider_create()
expected_filtered_by_name = [rp1]
self.assertEqual(
expected_filtered_by_name,
[rp for rp in self.resource_provider_list(name=rp1['name'])]
)
def test_resource_provider_list_by_uuid(self):
rp1 = self.resource_provider_create()
self.resource_provider_create()
expected_filtered_by_uuid = [rp1]
self.assertEqual(
expected_filtered_by_uuid,
[rp for rp in self.resource_provider_list(uuid=rp1['uuid'])]
)
def test_resource_provider_list_empty(self):
by_name = self.resource_provider_list(name='some_non_existing_name')
self.assertEqual([], by_name)
by_uuid = self.resource_provider_list(uuid=str(uuid.uuid4()))
self.assertEqual([], by_uuid)

View File

@ -0,0 +1,56 @@
# coding: utf-8
#
# 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 collections
import oslotest.base as base
import osc_placement.resources.common as common
class TestCommon(base.BaseTestCase):
def test_encode(self):
self.assertEqual(u'привет'.encode('utf-8'),
common.encode(u'привет'))
def test_encode_custom_encoding(self):
self.assertEqual(u'привет'.encode('utf-16'),
common.encode(u'привет', 'utf-16'))
def test_encode_non_string(self):
self.assertEqual(b'bytesvalue',
common.encode(b'bytesvalue'))
def test_url_with_filters(self):
base_url = '/resource_providers'
expected = '/resource_providers?name=test&uuid=123456'
filters = collections.OrderedDict([('name', 'test'), ('uuid', 123456)])
actual = common.url_with_filters(base_url, filters)
self.assertEqual(expected, actual)
def test_url_with_filters_empty(self):
base_url = '/resource_providers'
self.assertEqual(base_url, common.url_with_filters(base_url))
self.assertEqual(base_url, common.url_with_filters(base_url, {}))
def test_url_with_filters_unicode_string(self):
base_url = '/resource_providers'
expected = ('/resource_providers?'
'name=%D0%BF%D1%80%D0%B8%D0%B2%D0%B5%D1%82')
actual = common.url_with_filters(base_url, {'name': u'привет'})
self.assertEqual(expected, actual)

View File

@ -0,0 +1,43 @@
# 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 json
import mock
import six
import keystoneauth1.exceptions.http as ks_exceptions
import osc_lib.exceptions as exceptions
import oslotest.base as base
import osc_placement.http as http
class TestSessionClient(base.BaseTestCase):
def test_wrap_http_exceptions(self):
def go():
with http._wrap_http_exceptions():
error = {
"errors": [
{"status": 404,
"detail": ("The resource could not be found.\n\n"
"No resource provider with uuid 123 "
"found for delete")}
]
}
response = mock.Mock(content=json.dumps(error))
raise ks_exceptions.NotFound(response=response)
exc = self.assertRaises(exceptions.NotFound, go)
self.assertEqual(404, exc.http_status)
self.assertIn('No resource provider with uuid 123 found',
six.text_type(exc))

View File

@ -27,14 +27,17 @@ class TestPlugin(base.BaseTestCase):
args = parser.parse_args(['--os-placement-api-version', '1.0'])
self.assertEqual('1.0', args.os_placement_api_version)
def test_make_client(self):
@mock.patch('osc_placement.http.SessionClient')
def test_make_client(self, mock_session_client):
instance = mock.Mock(_api_version={'placement': '1.0'})
client = plugin.make_client(instance)
self.assertIsInstance(client, plugin.Client)
self.assertIs(client.session, instance.session)
self.assertEqual('1.0', client.api_version)
self.assertEqual({'service_type': 'placement',
'region_name': instance._region_name,
'interface': instance.interface},
client.ks_filter)
plugin.make_client(instance)
mock_session_client.assert_called_with(
session=instance.session,
ks_filter={
'service_type': 'placement',
'region_name': instance._region_name,
'interface': instance.interface
},
api_version='1.0'
)

View File

@ -26,6 +26,13 @@ packages =
openstack.cli.extension =
placement = osc_placement.plugin
openstack.placement.v1 =
resource_provider_create = osc_placement.resources.resource_provider:CreateResourceProvider
resource_provider_list = osc_placement.resources.resource_provider:ListResourceProvider
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
[build_sphinx]
source-dir = doc/source
build-dir = doc/build