Add a proper client object

Introduce BaseClient and ClientV1 objects, make existing functions
shortcuts for ClientV1 methods.

Reorganize README to be ClientV1-centric. Add new functional tests.
Switch CLI to the ClientV1 object.

Change-Id: I64604657bf552aab942f2aad34a7362b931f6fcf
This commit is contained in:
Dmitry Tantsur
2015-09-15 11:09:22 +02:00
parent 7ac591e471
commit 1008937683
10 changed files with 747 additions and 373 deletions

View File

@@ -11,46 +11,25 @@ This is a client library and tool for `Ironic Inspector`_.
Please follow usual OpenStack `Gerrit Workflow`_ to submit a patch, see
`Inspector contributing guide`_ for more detail.
Usage
-----
Python API
----------
CLI tool is based on OpenStackClient_ with prefix
``openstack baremetal introspection``. Accepts optional argument
``--inspector-url`` with the **Ironic Inspector** API endpoint.
To use Python API first create a ``ClientV1`` object::
* **Start introspection on a node**:
import ironic_inspector_client
``ironic_inspector_client.introspect(uuid, new_ipmi_username=None,
new_ipmi_password=None)``
url = 'http://HOST:5050'
client = ironic_inspector_client.ClientV1(auth_token=token, inspector_url=url)
::
This code creates a client with API version *1.0* and an authentication token.
If ``inspector_url`` is missing, local host is assumed for now. Service
catalog will be used in the future.
$ openstack baremetal introspection start UUID [--new-ipmi-password=PWD [--new-ipmi-username=USER]]
Optional ``api_version`` argument is a minimum API version that a server must
support. It can be a tuple (MAJ, MIN), string "MAJ.MIN" or integer
(only major, minimum supported minor version is assumed).
* ``uuid`` - Ironic node UUID;
* ``new_ipmi_username`` and ``new_ipmi_password`` - if these are set,
**Ironic Inspector** will switch to manual power on and assigning IPMI
credentials on introspection. See `Setting IPMI Credentials`_ for details.
* **Query introspection status**:
``ironic_inspector_client.get_status(uuid)``
::
$ openstack baremetal introspection status UUID
* ``uuid`` - Ironic node UUID.
Every call accepts additional optional arguments:
* ``base_url`` **Ironic Inspector** API endpoint, defaults to
``127.0.0.1:5050``,
* ``auth_token`` Keystone authentication token.
* ``api_version`` requested API version; can be a tuple (MAJ, MIN), string
"MAJ.MIN" or integer (only major). Defaults to ``DEFAULT_API_VERSION``.
Refer to HTTP-API.rst_ for information on the **Ironic Inspector** HTTP API.
See `Usage`_ for the list of available calls.
API Versioning
~~~~~~~~~~~~~~
@@ -61,18 +40,7 @@ versioning. Version is a tuple (X, Y), where X is always 1 for now.
The server has maximum and minimum supported versions. If no version is
requested, the server assumes (1, 0).
* There is a helper function to figure out the current server API versions
range:
``ironic_inspector_client.server_api_versions()``
Returns a tuple (minimum version, maximum version).
Supports optional argument:
* ``base_url`` **Ironic Inspector** API endpoint, defaults to
``127.0.0.1:5050``,
Two constants are exposed by the client:
Two constants are exposed for convenience:
* ``DEFAULT_API_VERSION`` server API version used by default, always (1, 0)
for now.
@@ -81,6 +49,74 @@ Two constants are exposed by the client:
with. This does not mean that other versions won't work at all - the server
might still support them.
Usage
-----
CLI tool is based on OpenStackClient_ with prefix
``openstack baremetal introspection``. Accepts optional argument
``--inspector-url`` with the **Ironic Inspector** API endpoint.
Refer to HTTP-API.rst_ for information on the **Ironic Inspector** HTTP API.
Detect server API versions
~~~~~~~~~~~~~~~~~~~~~~~~~~
``client.server_api_versions()``
Returns a tuple (minimum version, maximum version). See `API Versioning`_ for
details.
Start introspection on a node
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
``client.introspect(uuid, new_ipmi_username=None, new_ipmi_password=None)``
* ``uuid`` - Ironic node UUID;
* ``new_ipmi_username`` and ``new_ipmi_password`` - if these are set,
**Ironic Inspector** will switch to manual power on and assigning IPMI
credentials on introspection. See `Setting IPMI Credentials`_ for details.
CLI::
$ openstack baremetal introspection start UUID [--new-ipmi-password=PWD [--new-ipmi-username=USER]]
Query introspection status
~~~~~~~~~~~~~~~~~~~~~~~~~~
``client.get_status(uuid)``
* ``uuid`` - Ironic node UUID.
Returns a dict with keys:
* ``finished`` - whether introspection has finished for this node;
* ``error`` - last error, ``None`` if introspection ended without an error.
CLI::
$ openstack baremetal introspection status UUID
Shortcut Functions
~~~~~~~~~~~~~~~~~~
The following functions are available for simplified access to the most common
functionality:
* Starting introspection::
ironic_inspector_client.introspect(uuid[, new_ipmi_password[, new_ipmi_username]][, auth_token][, base_url][, api_version])
* Getting introspection status::
ironic_inspector_client.get_status(uuid[, auth_token][, base_url][, api_version])
* Getting API versions supported by a server::
ironic_inspector_client.server_api_versions([base_url])
Here ``base_url`` argument is the same as ``inspector_url`` argument to
``ClientV1`` constructor.
.. _Gerrit Workflow: http://docs.openstack.org/infra/manual/developers.html#development-workflow
.. _Ironic Inspector: https://pypi.python.org/pypi/ironic-inspector

View File

@@ -12,3 +12,4 @@
# limitations under the License.
from .client import * # noqa
from .v1 import ClientV1 # noqa

View File

@@ -11,98 +11,19 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import json
import logging
"""Shorthand client functions using V1 API."""
from oslo_utils import netutils
import requests
import six
from ironic_inspector_client.common.i18n import _
_DEFAULT_URL = 'http://' + netutils.get_my_ipv4() + ':5050/v1'
_ERROR_ENCODING = 'utf-8'
LOG = logging.getLogger('ironic_inspector_client')
from ironic_inspector_client.common import http
from ironic_inspector_client import v1
DEFAULT_API_VERSION = (1, 0)
MAX_API_VERSION = (1, 0)
_MIN_VERSION_HEADER = 'X-OpenStack-Ironic-Inspector-API-Minimum-Version'
_MAX_VERSION_HEADER = 'X-OpenStack-Ironic-Inspector-API-Maximum-Version'
_VERSION_HEADER = 'X-OpenStack-Ironic-Inspector-API-Version'
def _prepare(base_url, auth_token, api_version=None):
base_url = (base_url or _DEFAULT_URL).rstrip('/')
if not base_url.endswith('v1'):
base_url += '/v1'
headers = {'X-Auth-Token': auth_token} if auth_token else {}
if api_version:
api_version = _check_api_version(api_version, base_url)
headers[_VERSION_HEADER] = '%d.%d' % api_version
return base_url, headers
def _parse_version(api_version):
try:
return tuple(int(x) for x in api_version.split('.'))
except (ValueError, TypeError):
raise ValueError(_("Malformed API version: expect tuple, string "
"in form of X.Y or integer"))
def _check_api_version(api_version, base_url=None):
if isinstance(api_version, int):
api_version = (api_version, 0)
if isinstance(api_version, six.string_types):
api_version = _parse_version(api_version)
api_version = tuple(api_version)
if not all(isinstance(x, int) for x in api_version):
raise TypeError(_("All API version components should be integers"))
if len(api_version) == 1:
api_version += (0,)
elif len(api_version) > 2:
raise ValueError(_("API version should be of length 1 or 2"))
minv, maxv = server_api_versions(base_url=base_url)
if api_version < minv or api_version > maxv:
raise VersionNotSupported(api_version, (minv, maxv))
return api_version
class ClientError(requests.HTTPError):
"""Error returned from a server."""
def __init__(self, response):
# inspector returns error message in body
msg = response.content.decode(_ERROR_ENCODING)
try:
msg = json.loads(msg)['error']['message']
except ValueError:
LOG.debug('Old style error response returned, assuming '
'ironic-discoverd')
except (KeyError, TypeError):
LOG.exception('Bad error response from Ironic Inspector')
super(ClientError, self).__init__(msg, response=response)
@classmethod
def raise_if_needed(cls, response):
"""Raise exception if response contains error."""
if response.status_code >= 400:
raise cls(response)
class VersionNotSupported(Exception):
"""Denotes that requested API versions is not supported by the server."""
def __init__(self, expected, supported):
msg = (_('Version %(expected)s is not supported by the server, '
'supported range is %(supported)s') %
{'expected': expected,
'supported': ' to '.join(str(x) for x in supported)})
self.expected_version = expected
self.supported_versions = supported
super(Exception, self).__init__(msg)
# Reimport for backward compatibility
ClientError = http.ClientError
VersionNotSupported = http.VersionNotSupported
def introspect(uuid, base_url=None, auth_token=None,
@@ -125,18 +46,10 @@ def introspect(uuid, base_url=None, auth_token=None,
:raises: VersionNotSupported if requested api_version is not supported
:raises: *requests* library exception on connection problems.
"""
if not isinstance(uuid, six.string_types):
raise TypeError(_("Expected string for uuid argument, got %r") % uuid)
if new_ipmi_username and not new_ipmi_password:
raise ValueError(_("Setting IPMI user name requires a new password"))
base_url, headers = _prepare(base_url, auth_token, api_version=api_version)
params = {'new_ipmi_username': new_ipmi_username,
'new_ipmi_password': new_ipmi_password}
res = requests.post("%s/introspection/%s" % (base_url, uuid),
headers=headers, params=params)
ClientError.raise_if_needed(res)
c = v1.ClientV1(api_version=api_version, auth_token=auth_token,
inspector_url=base_url)
return c.introspect(uuid, new_ipmi_username=new_ipmi_username,
new_ipmi_password=new_ipmi_password)
def get_status(uuid, base_url=None, auth_token=None,
@@ -154,15 +67,9 @@ def get_status(uuid, base_url=None, auth_token=None,
:raises: VersionNotSupported if requested api_version is not supported
:raises: *requests* library exception on connection problems.
"""
if not isinstance(uuid, six.string_types):
raise TypeError(_("Expected string for uuid argument, got %r") % uuid)
base_url, headers = _prepare(base_url, auth_token, api_version=api_version)
res = requests.get("%s/introspection/%s" % (base_url, uuid),
headers=headers)
ClientError.raise_if_needed(res)
return res.json()
c = v1.ClientV1(api_version=api_version, auth_token=auth_token,
inspector_url=base_url)
return c.get_status(uuid)
def server_api_versions(base_url=None):
@@ -175,13 +82,8 @@ def server_api_versions(base_url=None):
:raises: *requests* library exception on connection problems.
:raises: ValueError if returned version cannot be parsed
"""
base_url, _headers = _prepare(base_url, auth_token=None)
res = requests.get(base_url)
# HTTP Not Found is a valid response for older (2.0.0) servers
if res.status_code >= 400 and res.status_code != 404:
ClientError.raise_if_needed(res)
return (_parse_version(res.headers.get(_MIN_VERSION_HEADER, '1.0')),
_parse_version(res.headers.get(_MAX_VERSION_HEADER, '1.0')))
c = http.BaseClient(1, inspector_url=base_url)
return c.server_api_versions()
__all__ = ['DEFAULT_API_VERSION', 'MAX_API_VERSION', 'ClientError',

View File

@@ -0,0 +1,168 @@
# 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.
"""Generic code for inspector client."""
import json
import logging
from oslo_utils import netutils
import requests
import six
from ironic_inspector_client.common.i18n import _
_DEFAULT_URL = 'http://' + netutils.get_my_ipv4() + ':5050'
_ERROR_ENCODING = 'utf-8'
LOG = logging.getLogger('ironic_inspector_client')
_MIN_VERSION_HEADER = 'X-OpenStack-Ironic-Inspector-API-Minimum-Version'
_MAX_VERSION_HEADER = 'X-OpenStack-Ironic-Inspector-API-Maximum-Version'
_VERSION_HEADER = 'X-OpenStack-Ironic-Inspector-API-Version'
_AUTH_TOKEN_HEADER = 'X-Auth-Token'
def _parse_version(api_version):
try:
return tuple(int(x) for x in api_version.split('.'))
except (ValueError, TypeError):
raise ValueError(_("Malformed API version: expect tuple, string "
"in form of X.Y or integer"))
class ClientError(requests.HTTPError):
"""Error returned from a server."""
def __init__(self, response):
# inspector returns error message in body
msg = response.content.decode(_ERROR_ENCODING)
try:
msg = json.loads(msg)['error']['message']
except ValueError:
LOG.debug('Old style error response returned, assuming '
'ironic-discoverd')
except (KeyError, TypeError):
LOG.exception('Bad error response from Ironic Inspector')
LOG.debug('Inspector returned error "%(msg)s" (HTTP %(code)s)',
{'msg': msg, 'code': response.status_code})
super(ClientError, self).__init__(msg, response=response)
@classmethod
def raise_if_needed(cls, response):
"""Raise exception if response contains error."""
if response.status_code >= 400:
raise cls(response)
class VersionNotSupported(Exception):
"""Denotes that requested API versions is not supported by the server."""
def __init__(self, expected, supported):
msg = (_('Version %(expected)s is not supported by the server, '
'supported range is %(supported)s') %
{'expected': expected,
'supported': ' to '.join(str(x) for x in supported)})
self.expected_version = expected
self.supported_versions = supported
super(Exception, self).__init__(msg)
class BaseClient(object):
"""Base class for clients, provides common HTTP code."""
def __init__(self, api_version, inspector_url=None, auth_token=None):
"""Create a client.
:param api_version: minimum API version that must be supported by
the server
:param inspector_url: *Ironic Inspector* URL in form:
http://host:port[/ver],
defaults to ``http://<current host>:5050/v<MAJOR>``.
:param auth_token: authentication token
"""
self._base_url = (inspector_url or _DEFAULT_URL).rstrip('/')
self._auth_token = auth_token
self._api_version = self._check_api_version(api_version)
self._version_str = '%d.%d' % self._api_version
ver_postfix = '/v%d' % self._api_version[0]
if not self._base_url.endswith(ver_postfix):
self._base_url += ver_postfix
def _make_headers(self, **kwargs):
kwargs[_VERSION_HEADER] = self._version_str
if self._auth_token:
kwargs[_AUTH_TOKEN_HEADER] = self._auth_token
return kwargs
def _check_api_version(self, api_version):
if isinstance(api_version, int):
api_version = (api_version, 0)
if isinstance(api_version, six.string_types):
api_version = _parse_version(api_version)
api_version = tuple(api_version)
if not all(isinstance(x, int) for x in api_version):
raise TypeError(_("All API version components should be integers"))
if len(api_version) == 1:
api_version += (0,)
elif len(api_version) > 2:
raise ValueError(_("API version should be of length 1 or 2"))
minv, maxv = self.server_api_versions()
if api_version < minv or api_version > maxv:
raise VersionNotSupported(api_version, (minv, maxv))
return api_version
def request(self, method, url, **kwargs):
"""Make an HTTP request.
:param method: HTTP method
:param endpoint: relative endpoint
:param kwargs: arguments to pass to 'requests' library
"""
headers = self._make_headers()
url = self._base_url + '/' + url.lstrip('/')
LOG.debug('Requesting %(method)s %(url)s (API version %(ver)s) '
'with %(args)s',
{'method': method.upper(), 'url': url,
'ver': self._version_str, 'args': kwargs})
res = getattr(requests, method)(url, headers=headers, **kwargs)
LOG.debug('Got response for %(method)s %(url)s with status code '
'%(code)s', {'url': url, 'method': method.upper(),
'code': res.status_code})
ClientError.raise_if_needed(res)
return res
def server_api_versions(self):
"""Get minimum and maximum supported API versions from a server.
:return: tuple (minimum version, maximum version) each version
is returned as a tuple (X, Y)
:raises: *requests* library exception on connection problems.
:raises: ValueError if returned version cannot be parsed
"""
res = requests.get(self._base_url)
# HTTP Not Found is a valid response for older (2.0.0) servers
if res.status_code >= 400 and res.status_code != 404:
ClientError.raise_if_needed(res)
min_ver = res.headers.get(_MIN_VERSION_HEADER, '1.0')
max_ver = res.headers.get(_MAX_VERSION_HEADER, '1.0')
res = (_parse_version(min_ver), _parse_version(max_ver))
LOG.debug('Supported API version range for %(url)s is '
'[%(min)s, %(max)s]',
{'url': self._base_url, 'min': min_ver, 'max': max_ver})
return res

View File

@@ -21,27 +21,38 @@ from cliff import command
from cliff import show
from openstackclient.common import utils
from ironic_inspector_client import client
import ironic_inspector_client
LOG = logging.getLogger('ironic_inspector.shell')
API_NAME = 'baremetal-introspection'
API_NAME = 'baremetal_introspection'
API_VERSION_OPTION = 'inspector_api_version'
DEFAULT_API_VERSION = '1'
API_VERSIONS = {
"1": "ironic_inspector.shell",
}
for mversion in range(client.MAX_API_VERSION[1] + 1):
for mversion in range(ironic_inspector_client.MAX_API_VERSION[1] + 1):
API_VERSIONS["1.%d" % mversion] = API_VERSIONS["1"]
def make_client(instance):
return ironic_inspector_client.ClientV1(
inspector_url=instance.get_configuration().get('inspector_url'),
auth_token=instance.auth_ref.auth_token,
api_version=instance._api_version[API_NAME])
def build_option_parser(parser):
parser.add_argument('--inspector-api-version',
default=utils.env('INSPECTOR_VERSION',
default=DEFAULT_API_VERSION),
help='inspector API version, only 1 is supported now '
'(env: INSPECTOR_VERSION).')
parser.add_argument('--inspector-url',
default=utils.env('INSPECTOR_URL', default=None),
help='inspector URL, defaults to localhost '
'(env: INSPECTOR_URL).')
return parser
@@ -50,7 +61,7 @@ class StartCommand(command.Command):
def get_parser(self, prog_name):
parser = super(StartCommand, self).get_parser(prog_name)
_add_common_arguments(parser)
parser.add_argument('uuid', help='baremetal node UUID')
parser.add_argument('--new-ipmi-username',
default=None,
help='if set, *Ironic Inspector* will update IPMI '
@@ -62,13 +73,10 @@ class StartCommand(command.Command):
return parser
def take_action(self, parsed_args):
auth_token = self.app.client_manager.auth_ref.auth_token
api_version = self.app.client_manager._api_version[API_NAME]
client.introspect(parsed_args.uuid, base_url=parsed_args.inspector_url,
auth_token=auth_token,
client = self.app.client_manager.baremetal_introspection
client.introspect(parsed_args.uuid,
new_ipmi_username=parsed_args.new_ipmi_username,
new_ipmi_password=parsed_args.new_ipmi_password,
api_version=api_version)
new_ipmi_password=parsed_args.new_ipmi_password)
if parsed_args.new_ipmi_password:
print('Setting IPMI credentials requested, please power on '
'the machine manually')
@@ -79,26 +87,10 @@ class StatusCommand(show.ShowOne):
def get_parser(self, prog_name):
parser = super(StatusCommand, self).get_parser(prog_name)
_add_common_arguments(parser)
parser.add_argument('uuid', help='baremetal node UUID')
return parser
def take_action(self, parsed_args):
auth_token = self.app.client_manager.auth_ref.auth_token
api_version = self.app.client_manager._api_version[API_NAME]
status = client.get_status(
parsed_args.uuid,
base_url=parsed_args.inspector_url,
auth_token=auth_token,
api_version=api_version)
client = self.app.client_manager.baremetal_introspection
status = client.get_status(parsed_args.uuid)
return zip(*sorted(status.items()))
def _add_common_arguments(parser):
"""Add commonly used arguments to a parser."""
parser.add_argument('uuid', help='baremetal node UUID')
# FIXME(dtantsur): this should be in build_option_parser, but then it won't
# be available in commands
parser.add_argument('--inspector-url',
default=utils.env('INSPECTOR_URL', default=None),
help='inspector URL, defaults to localhost '
'(env: INSPECTOR_URL).')

View File

@@ -18,10 +18,76 @@ import unittest
from ironic_inspector.test import functional
from ironic_inspector_client import client
import ironic_inspector_client as client
class TestPythonAPI(functional.Base):
class TestV1PythonAPI(functional.Base):
def setUp(self):
super(TestV1PythonAPI, self).setUp()
self.client = client.ClientV1()
def test_introspect_get_status(self):
self.client.introspect(self.uuid)
eventlet.greenthread.sleep(functional.DEFAULT_SLEEP)
self.cli.node.set_power_state.assert_called_once_with(self.uuid,
'reboot')
status = self.client.get_status(self.uuid)
self.assertEqual({'finished': False, 'error': None}, status)
res = self.call_continue(self.data)
self.assertEqual({'uuid': self.uuid}, res)
eventlet.greenthread.sleep(functional.DEFAULT_SLEEP)
self.cli.node.update.assert_any_call(self.uuid, self.patch)
self.cli.port.create.assert_called_once_with(
node_uuid=self.uuid, address='11:22:33:44:55:66')
status = self.client.get_status(self.uuid)
self.assertEqual({'finished': True, 'error': None}, status)
def test_setup_ipmi(self):
self.node.maintenance = True
self.client.introspect(self.uuid, new_ipmi_username='admin',
new_ipmi_password='pwd')
eventlet.greenthread.sleep(functional.DEFAULT_SLEEP)
self.assertFalse(self.cli.node.set_power_state.called)
res = self.call_continue(self.data)
self.assertEqual('admin', res['ipmi_username'])
self.assertEqual('pwd', res['ipmi_password'])
self.assertTrue(res['ipmi_setup_credentials'])
def test_api_versions(self):
minv, maxv = self.client.server_api_versions()
self.assertEqual((1, 0), minv)
self.assertGreaterEqual(maxv, (1, 0))
self.assertLess(maxv, (2, 0))
def test_client_init(self):
self.assertRaises(client.VersionNotSupported,
client.ClientV1, api_version=(1, 999))
self.assertRaises(client.VersionNotSupported,
client.ClientV1, api_version=2)
self.assertTrue(client.ClientV1(api_version=1).server_api_versions())
self.assertTrue(client.ClientV1(api_version='1.0')
.server_api_versions())
self.assertTrue(client.ClientV1(api_version=(1, 0))
.server_api_versions())
self.assertTrue(
client.ClientV1(inspector_url='http://127.0.0.1:5050')
.server_api_versions())
self.assertTrue(
client.ClientV1(inspector_url='http://127.0.0.1:5050/v1')
.server_api_versions())
self.assertTrue(client.ClientV1(auth_token='some token')
.server_api_versions())
class TestSimplePythonAPI(functional.Base):
def test_introspect_get_status(self):
client.introspect(self.uuid)
eventlet.greenthread.sleep(functional.DEFAULT_SLEEP)
@@ -42,18 +108,6 @@ class TestPythonAPI(functional.Base):
status = client.get_status(self.uuid)
self.assertEqual({'finished': True, 'error': None}, status)
def test_setup_ipmi(self):
self.node.maintenance = True
client.introspect(self.uuid, new_ipmi_username='admin',
new_ipmi_password='pwd')
eventlet.greenthread.sleep(functional.DEFAULT_SLEEP)
self.assertFalse(self.cli.node.set_power_state.called)
res = self.call_continue(self.data)
self.assertEqual('admin', res['ipmi_username'])
self.assertEqual('pwd', res['ipmi_password'])
self.assertTrue(res['ipmi_setup_credentials'])
def test_api_versions(self):
minv, maxv = client.server_api_versions()
self.assertEqual((1, 0), minv)

View File

@@ -15,207 +15,73 @@ import types
import unittest
import mock
from oslo_utils import netutils
from oslo_utils import uuidutils
import ironic_inspector_client
from ironic_inspector_client import client
from ironic_inspector_client.common import http
from ironic_inspector_client import v1
class BaseTest(unittest.TestCase):
def setUp(self):
super(BaseTest, self).setUp()
self.uuid = uuidutils.generate_uuid()
self.my_ip = 'http://' + netutils.get_my_ipv4() + ':5050/v1'
self.token = "token"
self.headers = {'X-OpenStack-Ironic-Inspector-API-Version': '1.0',
'X-Auth-Token': self.token}
uuid = uuidutils.generate_uuid()
token = "token"
@mock.patch.object(client, 'server_api_versions',
lambda *args, **kwargs: ((1, 0), (1, 99)))
@mock.patch.object(client.requests, 'post', autospec=True,
**{'return_value.status_code': 200})
@mock.patch.object(v1, 'ClientV1', autospec=True)
class TestIntrospect(BaseTest):
def test(self, mock_post):
def test(self, mock_v1):
client.introspect(self.uuid, base_url="http://host:port",
auth_token=self.token)
mock_post.assert_called_once_with(
"http://host:port/v1/introspection/%s" % self.uuid,
headers=self.headers,
params={'new_ipmi_username': None, 'new_ipmi_password': None}
)
mock_v1.assert_called_once_with(auth_token=self.token,
inspector_url="http://host:port",
api_version=client.DEFAULT_API_VERSION)
mock_v1.return_value.introspect.assert_called_once_with(
self.uuid, new_ipmi_username=None, new_ipmi_password=None)
def test_invalid_input(self, _):
self.assertRaises(TypeError, client.introspect, 42)
self.assertRaises(ValueError, client.introspect, 'uuid',
new_ipmi_username='user')
def test_full_url(self, mock_post):
def test_full_url(self, mock_v1):
client.introspect(self.uuid, base_url="http://host:port/v1/",
auth_token=self.token)
mock_post.assert_called_once_with(
"http://host:port/v1/introspection/%s" % self.uuid,
headers=self.headers,
params={'new_ipmi_username': None, 'new_ipmi_password': None}
)
mock_v1.assert_called_once_with(auth_token=self.token,
inspector_url="http://host:port/v1/",
api_version=client.DEFAULT_API_VERSION)
mock_v1.return_value.introspect.assert_called_once_with(
self.uuid, new_ipmi_username=None, new_ipmi_password=None)
def test_default_url(self, mock_post):
client.introspect(self.uuid, auth_token=self.token)
mock_post.assert_called_once_with(
"%(my_ip)s/introspection/%(uuid)s" %
{'my_ip': self.my_ip, 'uuid': self.uuid},
headers=self.headers,
params={'new_ipmi_username': None, 'new_ipmi_password': None}
)
def test_set_ipmi_credentials(self, mock_post):
def test_set_ipmi_credentials(self, mock_v1):
client.introspect(self.uuid, base_url="http://host:port",
auth_token=self.token, new_ipmi_password='p',
new_ipmi_username='u')
mock_post.assert_called_once_with(
"http://host:port/v1/introspection/%s" % self.uuid,
headers=self.headers,
params={'new_ipmi_username': 'u', 'new_ipmi_password': 'p'}
)
def test_none_ok(self, mock_post):
client.introspect(self.uuid)
del self.headers['X-Auth-Token']
mock_post.assert_called_once_with(
"%(my_ip)s/introspection/%(uuid)s" %
{'my_ip': self.my_ip, 'uuid': self.uuid},
headers=self.headers,
params={'new_ipmi_username': None, 'new_ipmi_password': None}
)
def test_failed(self, mock_post):
mock_post.return_value.status_code = 404
mock_post.return_value.content = b'{"error":{"message":"boom"}}'
self.assertRaisesRegexp(client.ClientError, "boom",
client.introspect, self.uuid)
def test_failed_discoverd_style(self, mock_post):
mock_post.return_value.status_code = 404
mock_post.return_value.content = b"boom"
self.assertRaisesRegexp(client.ClientError, "boom",
client.introspect, self.uuid)
def test_failed_bad_json(self, mock_post):
mock_post.return_value.status_code = 404
mock_post.return_value.content = b'42'
self.assertRaisesRegexp(client.ClientError, "42",
client.introspect, self.uuid)
mock_v1.assert_called_once_with(auth_token=self.token,
inspector_url="http://host:port",
api_version=client.DEFAULT_API_VERSION)
mock_v1.return_value.introspect.assert_called_once_with(
self.uuid, new_ipmi_username='u', new_ipmi_password='p')
@mock.patch.object(client, 'server_api_versions',
lambda *args, **kwargs: ((1, 0), (1, 99)))
@mock.patch.object(client.requests, 'get', autospec=True,
**{'return_value.status_code': 200})
@mock.patch.object(v1, 'ClientV1', autospec=True)
class TestGetStatus(BaseTest):
def test(self, mock_get):
mock_get.return_value.json.return_value = 'json'
def test(self, mock_v1):
mock_v1.return_value.get_status.return_value = 'json'
client.get_status(self.uuid, auth_token=self.token)
self.assertEqual('json',
client.get_status(self.uuid, auth_token=self.token))
mock_get.assert_called_once_with(
"%(my_ip)s/introspection/%(uuid)s" %
{'my_ip': self.my_ip, 'uuid': self.uuid},
headers=self.headers
)
def test_invalid_input(self, _):
self.assertRaises(TypeError, client.get_status, 42)
def test_failed(self, mock_post):
mock_post.return_value.status_code = 404
mock_post.return_value.content = b'{"error":{"message":"boom"}}'
self.assertRaisesRegexp(client.ClientError, "boom",
client.get_status, self.uuid)
def test_failed_discoverd_style(self, mock_post):
mock_post.return_value.status_code = 404
mock_post.return_value.content = b"boom"
self.assertRaisesRegexp(client.ClientError, "boom",
client.get_status, self.uuid)
def test_failed_bad_json(self, mock_post):
mock_post.return_value.status_code = 404
mock_post.return_value.content = b'42'
self.assertRaisesRegexp(client.ClientError, "42",
client.get_status, self.uuid)
mock_v1.assert_called_once_with(auth_token=self.token,
inspector_url=None,
api_version=client.DEFAULT_API_VERSION)
mock_v1.return_value.get_status.assert_called_once_with(self.uuid)
@mock.patch.object(client, 'server_api_versions',
lambda *args, **kwargs: ((1, 0), (1, 99)))
class TestCheckVesion(unittest.TestCase):
def test_tuple(self):
self.assertEqual((1, 0), client._check_api_version((1, 0)))
def test_small_tuple(self):
self.assertEqual((1, 0), client._check_api_version((1,)))
def test_int(self):
self.assertEqual((1, 0), client._check_api_version(1))
def test_str(self):
self.assertEqual((1, 0), client._check_api_version("1.0"))
def test_invalid_tuple(self):
self.assertRaises(TypeError, client._check_api_version, (1, "x"))
self.assertRaises(ValueError, client._check_api_version, (1, 2, 3))
def test_invalid_str(self):
self.assertRaises(ValueError, client._check_api_version, "a.b")
self.assertRaises(ValueError, client._check_api_version, "1.2.3")
self.assertRaises(ValueError, client._check_api_version, "foo")
def test_unsupported(self):
self.assertRaises(client.VersionNotSupported,
client._check_api_version, (99, 42))
@mock.patch.object(client.requests, 'get', autospec=True,
**{'return_value.status_code': 200})
@mock.patch.object(http, 'BaseClient', autospec=True)
class TestServerApiVersions(BaseTest):
def test_no_headers(self, mock_get):
mock_get.return_value.headers = {}
def test(self, mock_cli):
mock_cli.return_value.server_api_versions.return_value = (1, 0), (1, 1)
minv, maxv = client.server_api_versions()
self.assertEqual((1, 0), minv)
self.assertEqual((1, 0), maxv)
mock_get.assert_called_once_with(self.my_ip)
def test_with_headers(self, mock_get):
mock_get.return_value.headers = {
'X-OpenStack-Ironic-Inspector-API-Minimum-Version': '1.1',
'X-OpenStack-Ironic-Inspector-API-Maximum-Version': '1.42',
}
minv, maxv = client.server_api_versions()
self.assertEqual((1, 1), minv)
self.assertEqual((1, 42), maxv)
mock_get.assert_called_once_with(self.my_ip)
def test_with_404(self, mock_get):
mock_get.return_value.status_code = 404
mock_get.return_value.headers = {}
minv, maxv = client.server_api_versions()
self.assertEqual((1, 0), minv)
self.assertEqual((1, 0), maxv)
mock_get.assert_called_once_with(self.my_ip)
def test_with_other_error(self, mock_get):
mock_get.return_value.status_code = 500
mock_get.return_value.headers = {}
self.assertRaises(client.ClientError, client.server_api_versions)
mock_get.assert_called_once_with(self.my_ip)
self.assertEqual((1, 1), maxv)
class TestExposedAPI(unittest.TestCase):
@@ -224,7 +90,7 @@ class TestExposedAPI(unittest.TestCase):
if not x.startswith('__') and
not isinstance(getattr(ironic_inspector_client, x),
types.ModuleType)}
self.assertEqual(set(client.__all__), exposed)
self.assertEqual(set(client.__all__) | {'ClientV1'}, exposed)
def test_client_exposes_everything(self):
actual = {x for x in dir(client)

View File

@@ -0,0 +1,171 @@
# 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 unittest
import mock
from ironic_inspector_client.common import http
class TestCheckVersion(unittest.TestCase):
@mock.patch.object(http.BaseClient, 'server_api_versions',
lambda *args, **kwargs: ((1, 0), (1, 99)))
def _check(self, version):
cli = http.BaseClient(1)
return cli._check_api_version(version)
def test_tuple(self):
self.assertEqual((1, 0), self._check((1, 0)))
def test_small_tuple(self):
self.assertEqual((1, 0), self._check((1,)))
def test_int(self):
self.assertEqual((1, 0), self._check(1))
def test_str(self):
self.assertEqual((1, 0), self._check("1.0"))
def test_invalid_tuple(self):
self.assertRaises(TypeError, self._check, (1, "x"))
self.assertRaises(ValueError, self._check, (1, 2, 3))
def test_invalid_str(self):
self.assertRaises(ValueError, self._check, "a.b")
self.assertRaises(ValueError, self._check, "1.2.3")
self.assertRaises(ValueError, self._check, "foo")
def test_unsupported(self):
self.assertRaises(http.VersionNotSupported, self._check, (99, 42))
FAKE_HEADERS = {
http._MIN_VERSION_HEADER: '1.0',
http._MAX_VERSION_HEADER: '1.9'
}
@mock.patch.object(http.requests, 'get', autospec=True,
**{'return_value.status_code': 200,
'return_value.headers': FAKE_HEADERS})
class TestServerApiVersions(unittest.TestCase):
def _check(self, current=1):
return http.BaseClient(api_version=current).server_api_versions()
def test_no_headers(self, mock_get):
mock_get.return_value.headers = {}
minv, maxv = self._check()
self.assertEqual((1, 0), minv)
self.assertEqual((1, 0), maxv)
def test_with_headers(self, mock_get):
mock_get.return_value.headers = {
'X-OpenStack-Ironic-Inspector-API-Minimum-Version': '1.1',
'X-OpenStack-Ironic-Inspector-API-Maximum-Version': '1.42',
}
minv, maxv = self._check(current=(1, 2))
self.assertEqual((1, 1), minv)
self.assertEqual((1, 42), maxv)
def test_with_404(self, mock_get):
mock_get.return_value.status_code = 404
mock_get.return_value.headers = {}
minv, maxv = self._check()
self.assertEqual((1, 0), minv)
self.assertEqual((1, 0), maxv)
def test_with_other_error(self, mock_get):
mock_get.return_value.status_code = 500
mock_get.return_value.headers = {}
self.assertRaises(http.ClientError, self._check)
@mock.patch.object(http.requests, 'get', autospec=True,
**{'return_value.status_code': 200})
class TestRequest(unittest.TestCase):
base_url = http._DEFAULT_URL + '/v1'
token = 'token'
def setUp(self):
super(TestRequest, self).setUp()
self.headers = {http._VERSION_HEADER: '1.0',
'X-Auth-Token': self.token}
@mock.patch.object(http.BaseClient, 'server_api_versions',
lambda self: ((1, 0), (1, 42)))
def get_client(self, version=1, auth_token='token', inspector_url=None):
return http.BaseClient(version, auth_token=auth_token,
inspector_url=inspector_url)
def test_ok(self, mock_req):
res = self.get_client().request('get', '/foo/bar')
self.assertIs(mock_req.return_value, res)
mock_req.assert_called_once_with(self.base_url + '/foo/bar',
headers=self.headers)
def test_no_auth(self, mock_req):
res = self.get_client(auth_token=None).request('get', '/foo/bar')
self.assertIs(mock_req.return_value, res)
del self.headers['X-Auth-Token']
mock_req.assert_called_once_with(self.base_url + '/foo/bar',
headers=self.headers)
def test_explicit_version(self, mock_req):
res = self.get_client(version='1.2').request('get', '/foo/bar')
self.assertIs(mock_req.return_value, res)
self.headers[http._VERSION_HEADER] = '1.2'
mock_req.assert_called_once_with(self.base_url + '/foo/bar',
headers=self.headers)
def test_explicit_url(self, mock_req):
res = self.get_client(inspector_url='http://host').request(
'get', '/foo/bar')
self.assertIs(mock_req.return_value, res)
mock_req.assert_called_once_with('http://host/v1/foo/bar',
headers=self.headers)
def test_explicit_url_with_version(self, mock_req):
res = self.get_client(inspector_url='http://host/v1').request(
'get', '/foo/bar')
self.assertIs(mock_req.return_value, res)
mock_req.assert_called_once_with('http://host/v1/foo/bar',
headers=self.headers)
def test_error(self, mock_req):
mock_req.return_value.status_code = 400
mock_req.return_value.content = json.dumps(
{'error': {'message': 'boom'}}).encode('utf-8')
self.assertRaisesRegexp(http.ClientError, 'boom',
self.get_client().request, 'get', 'url')
def test_error_discoverd_style(self, mock_req):
mock_req.return_value.status_code = 400
mock_req.return_value.content = b'boom'
self.assertRaisesRegexp(http.ClientError, 'boom',
self.get_client().request, 'get', 'url')

View File

@@ -0,0 +1,110 @@
# 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 unittest
import mock
from oslo_utils import netutils
from oslo_utils import uuidutils
import ironic_inspector_client
from ironic_inspector_client.common import http
FAKE_HEADERS = {
http._MIN_VERSION_HEADER: '1.0',
http._MAX_VERSION_HEADER: '1.9'
}
@mock.patch.object(http.requests, 'get',
return_value=mock.Mock(headers=FAKE_HEADERS,
status_code=200))
class TestInit(unittest.TestCase):
my_ip = 'http://' + netutils.get_my_ipv4() + ':5050'
token = "token"
def get_client(self, **kwargs):
return ironic_inspector_client.ClientV1(auth_token=self.token,
**kwargs)
def test_ok(self, mock_get):
self.get_client()
mock_get.assert_called_once_with(self.my_ip)
def test_explicit_version(self, mock_get):
self.get_client(api_version=(1, 2))
self.get_client(api_version=1)
self.get_client(api_version='1.3')
def test_unsupported_version(self, mock_get):
self.assertRaises(ironic_inspector_client.VersionNotSupported,
self.get_client, api_version=(1, 99))
self.assertRaises(ironic_inspector_client.VersionNotSupported,
self.get_client, api_version=2)
self.assertRaises(ironic_inspector_client.VersionNotSupported,
self.get_client, api_version='1.42')
def test_explicit_url(self, mock_get):
self.get_client(inspector_url='http://host:port')
mock_get.assert_called_once_with('http://host:port')
class BaseTest(unittest.TestCase):
def setUp(self):
super(BaseTest, self).setUp()
self.uuid = uuidutils.generate_uuid()
self.my_ip = 'http://' + netutils.get_my_ipv4() + ':5050'
self.token = "token"
@mock.patch.object(http.BaseClient, 'server_api_versions',
lambda self: ((1, 0), (1, 99)))
def get_client(self, **kwargs):
return ironic_inspector_client.ClientV1(auth_token=self.token,
**kwargs)
@mock.patch.object(http.BaseClient, 'request')
class TestIntrospect(BaseTest):
def test(self, mock_req):
self.get_client().introspect(self.uuid)
mock_req.assert_called_once_with(
'post', '/introspection/%s' % self.uuid,
params={'new_ipmi_username': None, 'new_ipmi_password': None})
def test_invalid_input(self, mock_req):
self.assertRaises(TypeError, self.get_client().introspect, 42)
self.assertRaises(ValueError, self.get_client().introspect, 'uuid',
new_ipmi_username='user')
def test_set_ipmi_credentials(self, mock_req):
self.get_client().introspect(self.uuid,
new_ipmi_password='p',
new_ipmi_username='u')
mock_req.assert_called_once_with(
'post', '/introspection/%s' % self.uuid,
params={'new_ipmi_username': 'u', 'new_ipmi_password': 'p'})
@mock.patch.object(http.BaseClient, 'request')
class TestGetStatus(BaseTest):
def test(self, mock_req):
mock_req.return_value.json.return_value = 'json'
self.get_client().get_status(self.uuid)
mock_req.assert_called_once_with(
'get', '/introspection/%s' % self.uuid)
def test_invalid_input(self, _):
self.assertRaises(TypeError, self.get_client().get_status, 42)

View File

@@ -0,0 +1,74 @@
# 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.
"""Client for V1 API."""
import six
from ironic_inspector_client.common import http
from ironic_inspector_client.common.i18n import _
DEFAULT_API_VERSION = (1, 0)
MAX_API_VERSION = (1, 0)
class ClientV1(http.BaseClient):
"""Client for API v1."""
def __init__(self, **kwargs):
"""Create a client.
:param kwargs: arguments to pass to the BaseClient constructor.
api_version is set to DEFAULT_API_VERSION by default.
"""
kwargs.setdefault('api_version', DEFAULT_API_VERSION)
super(ClientV1, self).__init__(**kwargs)
def introspect(self, uuid, new_ipmi_password=None, new_ipmi_username=None):
"""Start introspection for a node.
:param uuid: node uuid
:param new_ipmi_password: if set, *Ironic Inspector* will update IPMI
password to this value.
:param new_ipmi_username: if new_ipmi_password is set, this values sets
new IPMI user name. Defaults to one in
driver_info.
:raises: ClientError on error reported from a server
:raises: VersionNotSupported if requested api_version is not supported
:raises: *requests* library exception on connection problems.
"""
if not isinstance(uuid, six.string_types):
raise TypeError(
_("Expected string for uuid argument, got %r") % uuid)
if new_ipmi_username and not new_ipmi_password:
raise ValueError(
_("Setting IPMI user name requires a new password"))
params = {'new_ipmi_username': new_ipmi_username,
'new_ipmi_password': new_ipmi_password}
self.request('post', '/introspection/%s' % uuid, params=params)
def get_status(self, uuid):
"""Get introspection status for a node.
:param uuid: node uuid.
:raises: ClientError on error reported from a server
:raises: VersionNotSupported if requested api_version is not supported
:raises: *requests* library exception on connection problems.
"""
if not isinstance(uuid, six.string_types):
raise TypeError(
_("Expected string for uuid argument, got %r") % uuid)
return self.request('get', '/introspection/%s' % uuid).json()