From f1b2df908a0f13b81ecd36a1376e55ce14503b06 Mon Sep 17 00:00:00 2001 From: Dmitry Tantsur Date: Tue, 19 Nov 2019 18:19:31 +0100 Subject: [PATCH] Replace WSME and Pecan with Werkzeug WSME is no longer maintained and Pecan is an overkill for our (purely internal) API. This change rewrites the API in Werkzeug (the library underneath Flask). I don't use Flask here since it's also an overkill for API with 4 meaningful endpoints. Change-Id: Ifed45f70869adf00e795202a53a2a53c9c57ef30 --- doc/source/conf.py | 10 - ironic_python_agent/agent.py | 30 +- ironic_python_agent/api/app.py | 258 ++++++++++++++---- ironic_python_agent/api/config.py | 39 --- .../api/controllers/__init__.py | 0 ironic_python_agent/api/controllers/root.py | 97 ------- .../api/controllers/v1/__init__.py | 118 -------- .../api/controllers/v1/base.py | 73 ----- .../api/controllers/v1/command.py | 126 --------- .../api/controllers/v1/link.py | 43 --- .../api/controllers/v1/status.py | 55 ---- ironic_python_agent/extensions/clean.py | 2 +- ironic_python_agent/tests/unit/test_agent.py | 151 +++++----- ironic_python_agent/tests/unit/test_api.py | 124 +++------ lower-constraints.txt | 4 +- requirements.txt | 3 +- test-requirements.txt | 1 - 17 files changed, 319 insertions(+), 815 deletions(-) delete mode 100644 ironic_python_agent/api/config.py delete mode 100644 ironic_python_agent/api/controllers/__init__.py delete mode 100644 ironic_python_agent/api/controllers/root.py delete mode 100644 ironic_python_agent/api/controllers/v1/__init__.py delete mode 100644 ironic_python_agent/api/controllers/v1/base.py delete mode 100644 ironic_python_agent/api/controllers/v1/command.py delete mode 100644 ironic_python_agent/api/controllers/v1/link.py delete mode 100644 ironic_python_agent/api/controllers/v1/status.py diff --git a/doc/source/conf.py b/doc/source/conf.py index 735142083..ac6c2ec0d 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -4,14 +4,9 @@ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode', - 'sphinxcontrib.httpdomain', - 'sphinxcontrib.pecanwsme.rest', - 'wsmeext.sphinxext', 'openstackdocstheme', ] -wsme_protocols = ['restjson'] - # autodoc generation is a bit aggressive and a nuisance when doing heavy # text edit cycles. # execute "export SPHINX_DEBUG=1" in your terminal to disable @@ -41,11 +36,6 @@ add_module_names = True # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' -# Ignore the following warning: WARNING: while setting up extension -# wsmeext.sphinxext: directive 'autoattribute' is already registered, -# it will be overridden. -suppress_warnings = ['app.add_directive'] - # -- Options for HTML output -------------------------------------------------- diff --git a/ironic_python_agent/agent.py b/ironic_python_agent/agent.py index 13e787304..8adb96ead 100644 --- a/ironic_python_agent/agent.py +++ b/ironic_python_agent/agent.py @@ -21,14 +21,13 @@ import socket import threading import time from urllib import parse as urlparse -from wsgiref import simple_server +import eventlet from ironic_lib import exception as lib_exc from ironic_lib import mdns from oslo_concurrency import processutils from oslo_config import cfg from oslo_log import log -from oslo_utils import netutils import pkg_resources from stevedore import extension @@ -201,7 +200,7 @@ class IronicPythonAgent(base.ExecuteCommandMixin): self.advertise_address = advertise_address self.version = pkg_resources.get_distribution('ironic-python-agent')\ .version - self.api = app.VersionSelectorApplication(self) + self.api = app.Application(self, cfg.CONF) self.heartbeat_timeout = None self.started_at = None self.node = None @@ -354,27 +353,16 @@ class IronicPythonAgent(base.ExecuteCommandMixin): def serve_ipa_api(self): """Serve the API until an extension terminates it.""" - if netutils.is_ipv6_enabled(): - # Listens to both IP versions, assuming IPV6_V6ONLY isn't enabled, - # (the default behaviour in linux) - simple_server.WSGIServer.address_family = socket.AF_INET6 - server = simple_server.WSGIServer((self.listen_address.hostname, - self.listen_address.port), - simple_server.WSGIRequestHandler) - server.set_app(self.api) - + self.api.start() if not self.standalone and self.api_url: # Don't start heartbeating until the server is listening self.heartbeater.start() - - while self.serve_api: - try: - server.handle_request() - except BaseException as e: - msg = "Failed due to an unknown exception. Error %s" % e - LOG.exception(msg) - raise errors.IronicAPIError(msg) - LOG.info('shutting down') + try: + while self.serve_api: + eventlet.sleep(0) + except KeyboardInterrupt: + LOG.info('Caught keyboard interrupt, exiting') + self.api.stop() def run(self): """Run the Ironic Python Agent.""" diff --git a/ironic_python_agent/api/app.py b/ironic_python_agent/api/app.py index f9003e66d..9924b655d 100644 --- a/ironic_python_agent/api/app.py +++ b/ironic_python_agent/api/app.py @@ -12,64 +12,212 @@ # See the License for the specific language governing permissions and # limitations under the License. -import pecan -from pecan import hooks +import json -from ironic_python_agent.api import config +from ironic_lib import metrics_utils +from oslo_log import log +from oslo_service import wsgi +import werkzeug +from werkzeug import exceptions as http_exc +from werkzeug import routing +from werkzeug.wrappers import json as http_json + +from ironic_python_agent import encoding +from ironic_python_agent import netutils -class AgentHook(hooks.PecanHook): - """Hook to attach agent instance to API requests.""" - def __init__(self, agent, *args, **kwargs): - super(AgentHook, self).__init__(*args, **kwargs) +LOG = log.getLogger(__name__) +_CUSTOM_MEDIA_TYPE = 'application/vnd.openstack.ironic-python-agent.v1+json' +_DOCS_URL = 'https://docs.openstack.org/ironic-python-agent' + + +class Request(werkzeug.Request, http_json.JSONMixin): + """Custom request class with JSON support.""" + + +def jsonify(value, status=200): + """Convert value to a JSON response using the custom encoder.""" + encoder = encoding.RESTJSONEncoder() + data = encoder.encode(value) + return werkzeug.Response(data, status=status, mimetype='application/json') + + +def make_link(url, rel_name, resource='', resource_args='', + bookmark=False, type_=None): + if rel_name == 'describedby': + url = _DOCS_URL + type_ = 'text/html' + elif rel_name == 'bookmark': + bookmark = True + + template = ('%(root)s/%(resource)s' if bookmark + else '%(root)s/v1/%(resource)s') + template += ('%(args)s' + if resource_args.startswith('?') or not resource_args + else '/%(args)s') + + result = {'href': template % {'root': url, + 'resource': resource, + 'args': resource_args}, + 'rel': rel_name} + if type_: + result['type'] = type_ + return result + + +def version(url): + return { + 'id': 'v1', + 'links': [ + make_link(url, 'self', 'v1', bookmark=True), + make_link(url, 'describedby', bookmark=True), + ], + } + + +# Emulate WSME format +def format_exception(value): + code = getattr(value, 'status_code', None) or getattr(value, 'code', 500) + return { + 'faultcode': 'Server' if code >= 500 else 'Client', + 'faultstring': str(value), + } + + +class Application(object): + + PORT = 9999 + + def __init__(self, agent, conf): + """Set up the API app. + + :param agent: an :class:`ironic_python_agent.agent.IronicPythonAgent` + instance. + :param conf: configuration object. + """ self.agent = agent - - def before(self, state): - state.request.agent = self.agent - - -def get_pecan_config(): - """Set up the pecan configuration. - - :returns: pecan configuration object. - """ - filename = config.__file__.replace('.pyc', '.py') - filename = filename.replace('.pyo', '.py') - return pecan.configuration.conf_from_file(filename) - - -def setup_app(pecan_config=None, agent=None): - """Set up the API app. - - :param pecan_config: a pecan configuration object. - :param agent: an :class:`ironic_python_agent.agent.IronicPythonAgent` - instance. - :returns: wsgi app object. - """ - app_hooks = [AgentHook(agent)] - - if not pecan_config: - pecan_config = get_pecan_config() - - pecan.configuration.set_config(dict(pecan_config), overwrite=True) - - app = pecan.make_app( - pecan_config.app.root, - static_root=pecan_config.app.static_root, - debug=pecan_config.app.debug, - force_canonical=getattr(pecan_config.app, 'force_canonical', True), - hooks=app_hooks, - ) - - return app - - -class VersionSelectorApplication(object): - """WSGI application that handles multiple API versions.""" - - def __init__(self, agent): - pc = get_pecan_config() - self.v1 = setup_app(pecan_config=pc, agent=agent) + self.service = None + self._conf = conf + self.url_map = routing.Map([ + routing.Rule('/', endpoint='root', methods=['GET']), + routing.Rule('/v1/', endpoint='v1', methods=['GET']), + routing.Rule('/v1/status', endpoint='status', methods=['GET']), + routing.Rule('/v1/commands/', endpoint='list_commands', + methods=['GET']), + routing.Rule('/v1/commands/', endpoint='get_command', + methods=['GET']), + routing.Rule('/v1/commands/', endpoint='run_command', + methods=['POST']), + # Use the default version (i.e. v1) when the version is missing + routing.Rule('/status', endpoint='status', methods=['GET']), + routing.Rule('/commands/', endpoint='list_commands', + methods=['GET']), + routing.Rule('/commands/', endpoint='get_command', + methods=['GET']), + routing.Rule('/commands/', endpoint='run_command', + methods=['POST']), + ]) def __call__(self, environ, start_response): - return self.v1(environ, start_response) + """WSGI entry point.""" + try: + request = Request(environ) + adapter = self.url_map.bind_to_environ(request.environ) + endpoint, values = adapter.match() + response = getattr(self, "api_" + endpoint)(request, **values) + except Exception as exc: + response = self.handle_exception(environ, exc) + return response(environ, start_response) + + def start(self): + """Start the API service in the background.""" + self.service = wsgi.Server(self._conf, 'ironic-python-agent', app=self, + host=netutils.get_wildcard_address(), + port=self.PORT) + self.service.start() + LOG.info('Started API service on port %s', self.PORT) + + def stop(self): + """Stop the API service.""" + if self.service is None: + return + self.service.wait() + self.service = None + LOG.info('Stopped API service on port %s', self.PORT) + + def handle_exception(self, environ, exc): + """Handle an exception during request processing.""" + if isinstance(exc, http_exc.HTTPException): + if exc.code and exc.code < 400: + return exc # redirect + resp = exc.get_response(environ) + resp.data = json.dumps(format_exception(exc)) + resp.content_type = 'application/json' + return resp + else: + formatted = format_exception(exc) + if formatted['faultcode'] == 'Server': + LOG.exception('Internal server error: %s', exc) + return jsonify(formatted, status=getattr(exc, 'status_code', 500)) + + def api_root(self, request): + url = request.url_root.rstrip('/') + return jsonify({ + 'name': 'OpenStack Ironic Python Agent API', + 'description': ('Ironic Python Agent is a provisioning agent for ' + 'OpenStack Ironic'), + 'versions': [version(url)], + 'default_version': version(url), + }) + + def api_v1(self, request): + url = request.url_root.rstrip('/') + return jsonify(dict({ + 'commands': [ + make_link(url, 'self', 'commands'), + make_link(url, 'bookmark', 'commands'), + ], + 'status': [ + make_link(url, 'self', 'status'), + make_link(url, 'bookmark', 'status'), + ], + 'media_types': [ + {'base': 'application/json', + 'type': _CUSTOM_MEDIA_TYPE}, + ], + }, **version(url))) + + def api_status(self, request): + with metrics_utils.get_metrics_logger(__name__).timer('get_status'): + status = self.agent.get_status() + return jsonify(status) + + def api_list_commands(self, request): + with metrics_utils.get_metrics_logger(__name__).timer('list_commands'): + results = self.agent.list_command_results() + return jsonify({'commands': results}) + + def api_get_command(self, request, cmd): + with metrics_utils.get_metrics_logger(__name__).timer('get_command'): + result = self.agent.get_command_result(cmd) + wait = request.args.get('wait') + + if wait and wait.lower() == 'true': + result.join() + + return jsonify(result) + + def api_run_command(self, request): + body = request.get_json(force=True) + if ('name' not in body or 'params' not in body + or not isinstance(body['params'], dict)): + raise http_exc.BadRequest('Missing or invalid name or params') + + with metrics_utils.get_metrics_logger(__name__).timer('run_command'): + result = self.agent.execute_command(body['name'], **body['params']) + wait = request.args.get('wait') + + if wait and wait.lower() == 'true': + result.join() + + return jsonify(result) diff --git a/ironic_python_agent/api/config.py b/ironic_python_agent/api/config.py deleted file mode 100644 index 8dc062471..000000000 --- a/ironic_python_agent/api/config.py +++ /dev/null @@ -1,39 +0,0 @@ -# Copyright 2013 Rackspace, Inc. -# -# 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 ironic_python_agent import netutils - -# Server Specific Configurations -# See https://pecan.readthedocs.org/en/latest/configuration.html#server-configuration # noqa -server = { - 'port': '9999', - 'host': netutils.get_wildcard_address() -} - -# Pecan Application Configurations -# See https://pecan.readthedocs.org/en/latest/configuration.html#application-configuration # noqa -app = { - 'root': 'ironic_python_agent.api.controllers.root.RootController', - 'modules': ['ironic_python_agent.api'], - 'static_root': '%(confdir)s/public', - 'debug': False, - 'enable_acl': True, - 'acl_public_routes': ['/', '/v1'], -} - -# WSME Configurations -# See https://wsme.readthedocs.org/en/latest/integrate.html#configuration -wsme = { - 'debug': False, -} diff --git a/ironic_python_agent/api/controllers/__init__.py b/ironic_python_agent/api/controllers/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/ironic_python_agent/api/controllers/root.py b/ironic_python_agent/api/controllers/root.py deleted file mode 100644 index c95e2186d..000000000 --- a/ironic_python_agent/api/controllers/root.py +++ /dev/null @@ -1,97 +0,0 @@ -# Copyright 2014 Rackspace, Inc. -# -# 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 ironic_lib import metrics_utils -import pecan -from pecan import rest -from wsme import types as wtypes -import wsmeext.pecan as wsme_pecan - -from ironic_python_agent.api.controllers import v1 -from ironic_python_agent.api.controllers.v1 import base -from ironic_python_agent.api.controllers.v1 import link - - -class Version(base.APIBase): - """An API version representation.""" - - id = wtypes.text - "The ID of the version, also acts as the release number" - - links = [link.Link] - "A Link that point to a specific version of the API" - - @classmethod - def convert(self, id): - version = Version() - version.id = id - version.links = [link.Link.make_link('self', pecan.request.host_url, - id, '', bookmark=True)] - return version - - -class Root(base.APIBase): - - name = wtypes.text - "The name of the API" - - description = wtypes.text - "Some information about this API" - - versions = [Version] - "Links to all the versions available in this API" - - default_version = Version - "A link to the default version of the API" - - @classmethod - def convert(self): - root = Root() - root.name = 'OpenStack Ironic Python Agent API' - root.description = ('Ironic Python Agent is a provisioning agent for ' - 'OpenStack Ironic') - root.versions = [Version.convert('v1')] - root.default_version = Version.convert('v1') - return root - - -class RootController(rest.RestController): - - _versions = ['v1'] - "All supported API versions" - - _default_version = 'v1' - "The default API version" - - v1 = v1.Controller() - - @wsme_pecan.wsexpose(Root) - def get(self): - # NOTE: The reason why convert() it's being called for every - # request is because we need to get the host url from - # the request object to make the links. - with metrics_utils.get_metrics_logger(__name__).timer('get'): - return Root.convert() - - @pecan.expose() - def _route(self, args): - """Overrides the default routing behavior. - - It redirects the request to the default version of the ironic API - if the version number is not specified in the url. - """ - - if args[0] and args[0] not in self._versions: - args = [self._default_version] + args - return super(RootController, self)._route(args) diff --git a/ironic_python_agent/api/controllers/v1/__init__.py b/ironic_python_agent/api/controllers/v1/__init__.py deleted file mode 100644 index 7b4fae9ff..000000000 --- a/ironic_python_agent/api/controllers/v1/__init__.py +++ /dev/null @@ -1,118 +0,0 @@ -# All Rights Reserved. -# -# 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. - -""" -Version 1 of the Ironic Python Agent API -""" - -import pecan -from pecan import rest -from wsme import types as wtypes -import wsmeext.pecan as wsme_pecan - -from ironic_python_agent.api.controllers.v1 import base -from ironic_python_agent.api.controllers.v1 import command -from ironic_python_agent.api.controllers.v1 import link -from ironic_python_agent.api.controllers.v1 import status - - -class MediaType(base.APIBase): - """A media type representation.""" - - base = wtypes.text - type = wtypes.text - - def __init__(self, base, type): - self.base = base - self.type = type - - -class V1(base.APIBase): - """The representation of the version 1 of the API.""" - - id = wtypes.text - "The ID of the version, also acts as the release number" - - media_types = [MediaType] - "An array of supported media types for this version" - - links = [link.Link] - "Links that point to a specific URL for this version and documentation" - - commands = [link.Link] - "Links to the command resource" - - status = [link.Link] - "Links to the status resource" - - @classmethod - def convert(self): - v1 = V1() - v1.id = "v1" - v1.links = [ - link.Link.make_link('self', - pecan.request.host_url, - 'v1', - '', - bookmark=True), - link.Link.make_link('describedby', - 'https://docs.openstack.org', - 'developer', - 'ironic-python-agent', - bookmark=True, - type='text/html') - ] - v1.commands = [ - link.Link.make_link('self', - pecan.request.host_url, - 'commands', - ''), - link.Link.make_link('bookmark', - pecan.request.host_url, - 'commands', - '', - bookmark=True) - ] - v1.status = [ - link.Link.make_link('self', - pecan.request.host_url, - 'status', - ''), - link.Link.make_link('bookmark', - pecan.request.host_url, - 'status', - '', - bookmark=True) - ] - v1.media_types = [MediaType('application/json', - ('application/vnd.openstack.' - 'ironic-python-agent.v1+json'))] - return v1 - - -class Controller(rest.RestController): - """Version 1 API controller root.""" - - commands = command.CommandController() - status = status.StatusController() - - @wsme_pecan.wsexpose(V1) - def get(self): - # NOTE: The reason why convert() it's being called for every - # request is because we need to get the host url from - # the request object to make the links. - return V1.convert() - - -__all__ = (Controller) diff --git a/ironic_python_agent/api/controllers/v1/base.py b/ironic_python_agent/api/controllers/v1/base.py deleted file mode 100644 index c3644efaf..000000000 --- a/ironic_python_agent/api/controllers/v1/base.py +++ /dev/null @@ -1,73 +0,0 @@ -# All Rights Reserved. -# -# 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 wsme import types as wtypes - - -class ExceptionType(wtypes.UserType): - basetype = wtypes.DictType - name = 'exception' - - def validate(self, value): - if not isinstance(value, BaseException): - raise ValueError('Value is not an exception') - return value - - def tobasetype(self, value): - """Turn an Exception into a dict.""" - return { - 'type': value.__class__.__name__, - 'code': getattr(value, 'status_code', 500), - 'message': str(value), - 'details': getattr(value, 'details', ''), - } - - frombasetype = tobasetype - - -exception_type = ExceptionType() - - -class MultiType(wtypes.UserType): - """A complex type that represents one or more types. - - Used for validating that a value is an instance of one of the types. - - :param types: Variable-length list of types. - - """ - - def __init__(self, *types): - self.types = types - - def __str__(self): - return ' | '.join(map(str, self.types)) - - def validate(self, value): - for t in self.types: - if t is wtypes.text and isinstance(value, wtypes.bytes): - value = value.decode() - if isinstance(value, t): - return value - else: - raise ValueError( - "Wrong type. Expected '{type}', got '{value}'".format( - type=self.types, value=type(value))) - - -json_type = MultiType(list, dict, int, wtypes.text) - - -class APIBase(wtypes.Base): - pass diff --git a/ironic_python_agent/api/controllers/v1/command.py b/ironic_python_agent/api/controllers/v1/command.py deleted file mode 100644 index b6971fa76..000000000 --- a/ironic_python_agent/api/controllers/v1/command.py +++ /dev/null @@ -1,126 +0,0 @@ -# Copyright 2014 Rackspace, Inc. -# All Rights Reserved. -# -# 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 ironic_lib import metrics_utils -import pecan -from pecan import rest -from wsme import types -from wsmeext import pecan as wsme_pecan - -from ironic_python_agent.api.controllers.v1 import base - - -class CommandResult(base.APIBase): - """Object representing the result of a given command.""" - - id = types.text - command_name = types.text - command_params = types.DictType(types.text, base.json_type) - command_status = types.text - command_error = base.exception_type - command_result = types.DictType(types.text, base.json_type) - - @classmethod - def from_result(cls, result): - """Convert a BaseCommandResult object to a CommandResult object. - - :param result: a :class:`ironic_python_agent.extensions.base. - BaseCommandResult` object. - :returns: a :class:`ironic_python_agent.api.controllers.v1.command. - CommandResult` object. - """ - instance = cls() - for field in ('id', 'command_name', 'command_params', 'command_status', - 'command_error', 'command_result'): - setattr(instance, field, getattr(result, field)) - return instance - - -class CommandResultList(base.APIBase): - """An object representing a list of CommandResult objects.""" - commands = [CommandResult] - - @classmethod - def from_results(cls, results): - """Convert a list of BaseCommandResult objects to a CommandResultList. - - :param results: a list of :class:`ironic_python_agent.extensions.base. - BaseCommandResult` objects. - :returns: a :class:`ironic_python_agent.api.controllers.v1.command. - CommandResultList` object. - """ - instance = cls() - instance.commands = [CommandResult.from_result(result) - for result in results] - return instance - - -class Command(base.APIBase): - """A representation of a command.""" - name = types.wsattr(types.text, mandatory=True) - params = types.wsattr(base.MultiType(dict), mandatory=True) - - -class CommandController(rest.RestController): - """Controller for issuing commands and polling for command status.""" - - @wsme_pecan.wsexpose(CommandResultList) - def get_all(self): - """Get all command results.""" - with metrics_utils.get_metrics_logger(__name__).timer('get_all'): - agent = pecan.request.agent - results = agent.list_command_results() - return CommandResultList.from_results(results) - - @wsme_pecan.wsexpose(CommandResult, types.text, types.text) - def get_one(self, result_id, wait=None): - """Get a command result by ID. - - :param result_id: the ID of the result to get. - :param wait: if 'true', block until the command completes. - :returns: a :class:`ironic_python_agent.api.controller.v1.command. - CommandResult` object. - """ - with metrics_utils.get_metrics_logger(__name__).timer('get_one'): - agent = pecan.request.agent - result = agent.get_command_result(result_id) - - if wait and wait.lower() == 'true': - result.join() - - return CommandResult.from_result(result) - - @wsme_pecan.wsexpose(CommandResult, types.text, body=Command) - def post(self, wait=None, command=None): - """Post a command for the agent to run. - - :param wait: if 'true', block until the command completes. - :param command: the command to execute. If None, an InvalidCommandError - will be returned. - :returns: a :class:`ironic_python_agent.api.controller.v1.command. - CommandResult` object. - """ - with metrics_utils.get_metrics_logger(__name__).timer('post'): - # the POST body is always the last arg, - # so command must be a kwarg here - if command is None: - command = Command() - agent = pecan.request.agent - result = agent.execute_command(command.name, **command.params) - - if wait and wait.lower() == 'true': - result.join() - - return result diff --git a/ironic_python_agent/api/controllers/v1/link.py b/ironic_python_agent/api/controllers/v1/link.py deleted file mode 100644 index 7fa7f9823..000000000 --- a/ironic_python_agent/api/controllers/v1/link.py +++ /dev/null @@ -1,43 +0,0 @@ -# Copyright 2014 Rackspace, Inc. -# All Rights Reserved. -# -# 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 wsme import types as wtypes - -from ironic_python_agent.api.controllers.v1 import base - - -class Link(base.APIBase): - """A link representation.""" - - href = wtypes.text - "The url of a link." - - rel = wtypes.text - "The name of a link." - - type = wtypes.text - "Indicates the type of document/link." - - @classmethod - def make_link(cls, rel_name, url, resource, resource_args, - bookmark=False, type=wtypes.Unset): - template = '%s/%s' if bookmark else '%s/v1/%s' - # FIXME(lucasagomes): I'm getting a 404 when doing a GET on - # a nested resource that the URL ends with a '/'. - # https://groups.google.com/forum/#!topic/pecan-dev/QfSeviLg5qs - template += '%s' if resource_args.startswith('?') else '/%s' - - return Link(href=(template) % (url, resource, resource_args), - rel=rel_name, type=type) diff --git a/ironic_python_agent/api/controllers/v1/status.py b/ironic_python_agent/api/controllers/v1/status.py deleted file mode 100644 index b1ed83b8a..000000000 --- a/ironic_python_agent/api/controllers/v1/status.py +++ /dev/null @@ -1,55 +0,0 @@ -# Copyright 2014 Rackspace, Inc. -# All Rights Reserved. -# -# 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 ironic_lib import metrics_utils -import pecan -from pecan import rest -from wsme import types -from wsmeext import pecan as wsme_pecan - -from ironic_python_agent.api.controllers.v1 import base - - -class AgentStatus(base.APIBase): - """An object representing an agent instance's status.""" - - started_at = base.MultiType(float) - version = types.text - - @classmethod - def from_agent_status(cls, status): - """Convert an object representing agent status to an AgentStatus. - - :param status: An :class:`ironic_python_agent.agent. - IronicPythonAgentStatus` object. - :returns: An :class:`ironic_python_agent.api.controllers.v1.status. - AgentStatus` object. - """ - instance = cls() - for field in ('started_at', 'version'): - setattr(instance, field, getattr(status, field)) - return instance - - -class StatusController(rest.RestController): - """Controller for getting agent status.""" - - @wsme_pecan.wsexpose(AgentStatus) - def get_all(self): - """Get current status of the running agent.""" - with metrics_utils.get_metrics_logger(__name__).timer('get_all'): - agent = pecan.request.agent - status = agent.get_status() - return AgentStatus.from_agent_status(status) diff --git a/ironic_python_agent/extensions/clean.py b/ironic_python_agent/extensions/clean.py index 74047dd26..3bdf5a1e5 100644 --- a/ironic_python_agent/extensions/clean.py +++ b/ironic_python_agent/extensions/clean.py @@ -85,7 +85,7 @@ class CleanExtension(base.BaseAgentExtension): {'step': step, 'result': result}) # Cast result tuples (like output of utils.execute) as lists, or - # WSME throws errors + # API throws errors if isinstance(result, tuple): result = list(result) diff --git a/ironic_python_agent/tests/unit/test_agent.py b/ironic_python_agent/tests/unit/test_agent.py index 869b7b887..8a3a7c552 100644 --- a/ironic_python_agent/tests/unit/test_agent.py +++ b/ironic_python_agent/tests/unit/test_agent.py @@ -14,7 +14,6 @@ import socket import time -from wsgiref import simple_server from ironic_lib import exception as lib_exc import mock @@ -181,7 +180,7 @@ class TestBaseAgent(ironic_agent_base.IronicAgentTest): @mock.patch.object(hardware, 'dispatch_to_managers', autospec=True) @mock.patch.object(agent.IronicPythonAgent, '_wait_for_interface', autospec=True) - @mock.patch('wsgiref.simple_server.WSGIServer', autospec=True) + @mock.patch('oslo_service.wsgi.Server', autospec=True) @mock.patch.object(hardware, 'load_managers', autospec=True) def test_run(self, mock_load_managers, mock_wsgi, mock_wait, mock_dispatch): @@ -192,7 +191,7 @@ class TestBaseAgent(ironic_agent_base.IronicAgentTest): def set_serve_api(): self.agent.serve_api = False - wsgi_server.handle_request.side_effect = set_serve_api + wsgi_server.start.side_effect = set_serve_api self.agent.heartbeater = mock.Mock() self.agent.api_client.lookup_node = mock.Mock() self.agent.api_client.lookup_node.return_value = { @@ -206,13 +205,10 @@ class TestBaseAgent(ironic_agent_base.IronicAgentTest): self.agent.run() - listen_addr = agent.Host('192.0.2.1', 9999) - mock_wsgi.assert_called_once_with( - (listen_addr.hostname, - listen_addr.port), - simple_server.WSGIRequestHandler) - wsgi_server.set_app.assert_called_once_with(self.agent.api) - self.assertTrue(wsgi_server.handle_request.called) + mock_wsgi.assert_called_once_with(CONF, 'ironic-python-agent', + app=self.agent.api, + host=mock.ANY, port=9999) + wsgi_server.start.assert_called_once_with() mock_wait.assert_called_once_with(mock.ANY) self.assertEqual([mock.call('list_hardware_info'), mock.call('wait_for_disks')], @@ -226,7 +222,7 @@ class TestBaseAgent(ironic_agent_base.IronicAgentTest): @mock.patch.object(hardware, 'dispatch_to_managers', autospec=True) @mock.patch.object(agent.IronicPythonAgent, '_wait_for_interface', autospec=True) - @mock.patch('wsgiref.simple_server.WSGIServer', autospec=True) + @mock.patch('oslo_service.wsgi.Server', autospec=True) @mock.patch.object(hardware, 'load_managers', autospec=True) def test_url_from_mdns_by_default(self, mock_load_managers, mock_wsgi, mock_wait, mock_dispatch, mock_mdns): @@ -248,7 +244,7 @@ class TestBaseAgent(ironic_agent_base.IronicAgentTest): def set_serve_api(): self.agent.serve_api = False - wsgi_server.handle_request.side_effect = set_serve_api + wsgi_server.start.side_effect = set_serve_api self.agent.heartbeater = mock.Mock() self.agent.api_client.lookup_node = mock.Mock() self.agent.api_client.lookup_node.return_value = { @@ -262,13 +258,10 @@ class TestBaseAgent(ironic_agent_base.IronicAgentTest): self.agent.run() - listen_addr = agent.Host('192.0.2.1', 9999) - mock_wsgi.assert_called_once_with( - (listen_addr.hostname, - listen_addr.port), - simple_server.WSGIRequestHandler) - wsgi_server.set_app.assert_called_once_with(self.agent.api) - self.assertTrue(wsgi_server.handle_request.called) + mock_wsgi.assert_called_once_with(CONF, 'ironic-python-agent', + app=self.agent.api, + host=mock.ANY, port=9999) + wsgi_server.start.assert_called_once_with() mock_wait.assert_called_once_with(mock.ANY) self.assertEqual([mock.call('list_hardware_info'), mock.call('wait_for_disks')], @@ -282,7 +275,7 @@ class TestBaseAgent(ironic_agent_base.IronicAgentTest): @mock.patch.object(hardware, 'dispatch_to_managers', autospec=True) @mock.patch.object(agent.IronicPythonAgent, '_wait_for_interface', autospec=True) - @mock.patch('wsgiref.simple_server.WSGIServer', autospec=True) + @mock.patch('oslo_service.wsgi.Server', autospec=True) @mock.patch.object(hardware, 'load_managers', autospec=True) def test_url_from_mdns_explicitly(self, mock_load_managers, mock_wsgi, mock_wait, mock_dispatch, mock_mdns): @@ -308,7 +301,7 @@ class TestBaseAgent(ironic_agent_base.IronicAgentTest): def set_serve_api(): self.agent.serve_api = False - wsgi_server.handle_request.side_effect = set_serve_api + wsgi_server.start.side_effect = set_serve_api self.agent.heartbeater = mock.Mock() self.agent.api_client.lookup_node = mock.Mock() self.agent.api_client.lookup_node.return_value = { @@ -322,13 +315,10 @@ class TestBaseAgent(ironic_agent_base.IronicAgentTest): self.agent.run() - listen_addr = agent.Host('192.0.2.1', 9999) - mock_wsgi.assert_called_once_with( - (listen_addr.hostname, - listen_addr.port), - simple_server.WSGIRequestHandler) - wsgi_server.set_app.assert_called_once_with(self.agent.api) - self.assertTrue(wsgi_server.handle_request.called) + mock_wsgi.assert_called_once_with(CONF, 'ironic-python-agent', + app=self.agent.api, + host=mock.ANY, port=9999) + wsgi_server.start.assert_called_once_with() mock_wait.assert_called_once_with(mock.ANY) self.assertEqual([mock.call('list_hardware_info'), mock.call('wait_for_disks')], @@ -337,20 +327,22 @@ class TestBaseAgent(ironic_agent_base.IronicAgentTest): # changed via mdns self.assertEqual(42, CONF.disk_wait_attempts) + @mock.patch('eventlet.sleep', autospec=True) @mock.patch( 'ironic_python_agent.hardware_managers.cna._detect_cna_card', mock.Mock()) @mock.patch.object(agent.IronicPythonAgent, '_wait_for_interface', autospec=True) @mock.patch.object(hardware, 'dispatch_to_managers', autospec=True) - @mock.patch('wsgiref.simple_server.WSGIServer', autospec=True) + @mock.patch('oslo_service.wsgi.Server', autospec=True) @mock.patch.object(hardware, 'load_managers', autospec=True) - def test_run_raise_exception(self, mock_load_managers, mock_wsgi, - mock_dispatch, mock_wait): + def test_run_raise_keyboard_interrupt(self, mock_load_managers, mock_wsgi, + mock_dispatch, mock_wait, + mock_sleep): CONF.set_override('inspection_callback_url', '') wsgi_server = mock_wsgi.return_value - wsgi_server.handle_request.side_effect = KeyboardInterrupt() + mock_sleep.side_effect = KeyboardInterrupt() self.agent.heartbeater = mock.Mock() self.agent.api_client.lookup_node = mock.Mock() self.agent.api_client.lookup_node.return_value = { @@ -362,23 +354,17 @@ class TestBaseAgent(ironic_agent_base.IronicAgentTest): } } - self.assertRaisesRegex(errors.IronicAPIError, - 'Failed due to an unknown exception.', - self.agent.run) + self.agent.run() self.assertTrue(mock_wait.called) self.assertEqual([mock.call('list_hardware_info'), mock.call('wait_for_disks')], mock_dispatch.call_args_list) - listen_addr = agent.Host('192.0.2.1', 9999) - mock_wsgi.assert_called_once_with( - (listen_addr.hostname, - listen_addr.port), - simple_server.WSGIRequestHandler) - wsgi_server.set_app.assert_called_once_with(self.agent.api) - self.assertTrue(wsgi_server.handle_request.called) + mock_wsgi.assert_called_once_with(CONF, 'ironic-python-agent', + app=self.agent.api, + host=mock.ANY, port=9999) + wsgi_server.start.assert_called_once_with() self.agent.heartbeater.start.assert_called_once_with() - self.assertTrue(wsgi_server.handle_request.called) @mock.patch('ironic_python_agent.hardware_managers.cna._detect_cna_card', mock.Mock()) @@ -386,7 +372,7 @@ class TestBaseAgent(ironic_agent_base.IronicAgentTest): '_wait_for_interface', autospec=True) @mock.patch.object(inspector, 'inspect', autospec=True) @mock.patch.object(hardware, 'dispatch_to_managers', autospec=True) - @mock.patch('wsgiref.simple_server.WSGIServer', autospec=True) + @mock.patch('oslo_service.wsgi.Server', autospec=True) @mock.patch.object(hardware.HardwareManager, 'list_hardware_info', autospec=True) def test_run_with_inspection(self, mock_list_hardware, mock_wsgi, @@ -397,7 +383,7 @@ class TestBaseAgent(ironic_agent_base.IronicAgentTest): self.agent.serve_api = False wsgi_server = mock_wsgi.return_value - wsgi_server.handle_request.side_effect = set_serve_api + wsgi_server.start.side_effect = set_serve_api mock_inspector.return_value = 'uuid' @@ -413,12 +399,10 @@ class TestBaseAgent(ironic_agent_base.IronicAgentTest): } self.agent.run() - listen_addr = agent.Host('192.0.2.1', 9999) - mock_wsgi.assert_called_once_with( - (listen_addr.hostname, - listen_addr.port), - simple_server.WSGIRequestHandler) - self.assertTrue(mock_wsgi.called) + mock_wsgi.assert_called_once_with(CONF, 'ironic-python-agent', + app=self.agent.api, + host=mock.ANY, port=9999) + wsgi_server.start.assert_called_once_with() mock_inspector.assert_called_once_with() self.assertEqual(1, self.agent.api_client.lookup_node.call_count) @@ -440,7 +424,7 @@ class TestBaseAgent(ironic_agent_base.IronicAgentTest): '_wait_for_interface', autospec=True) @mock.patch.object(inspector, 'inspect', autospec=True) @mock.patch.object(hardware, 'dispatch_to_managers', autospec=True) - @mock.patch('wsgiref.simple_server.WSGIServer', autospec=True) + @mock.patch('oslo_service.wsgi.Server', autospec=True) @mock.patch.object(hardware.HardwareManager, 'list_hardware_info', autospec=True) def test_run_with_inspection_without_apiurl(self, @@ -473,16 +457,14 @@ class TestBaseAgent(ironic_agent_base.IronicAgentTest): self.agent.serve_api = False wsgi_server = mock_wsgi.return_value - wsgi_server.handle_request.side_effect = set_serve_api + wsgi_server.start.side_effect = set_serve_api self.agent.run() - listen_addr = agent.Host('192.0.2.1', 9999) - mock_wsgi.assert_called_once_with( - (listen_addr.hostname, - listen_addr.port), - simple_server.WSGIRequestHandler) - self.assertTrue(wsgi_server.handle_request.called) + mock_wsgi.assert_called_once_with(CONF, 'ironic-python-agent', + app=self.agent.api, + host=mock.ANY, port=9999) + wsgi_server.start.assert_called_once_with() mock_inspector.assert_called_once_with() @@ -497,7 +479,7 @@ class TestBaseAgent(ironic_agent_base.IronicAgentTest): '_wait_for_interface', autospec=True) @mock.patch.object(inspector, 'inspect', autospec=True) @mock.patch.object(hardware, 'dispatch_to_managers', autospec=True) - @mock.patch('wsgiref.simple_server.WSGIServer', autospec=True) + @mock.patch('oslo_service.wsgi.Server', autospec=True) @mock.patch.object(hardware.HardwareManager, 'list_hardware_info', autospec=True) def test_run_without_inspection_and_apiurl(self, @@ -530,16 +512,14 @@ class TestBaseAgent(ironic_agent_base.IronicAgentTest): self.agent.serve_api = False wsgi_server = mock_wsgi.return_value - wsgi_server.handle_request.side_effect = set_serve_api + wsgi_server.start.side_effect = set_serve_api self.agent.run() - listen_addr = agent.Host('192.0.2.1', 9999) - mock_wsgi.assert_called_once_with( - (listen_addr.hostname, - listen_addr.port), - simple_server.WSGIRequestHandler) - self.assertTrue(wsgi_server.handle_request.called) + mock_wsgi.assert_called_once_with(CONF, 'ironic-python-agent', + app=self.agent.api, + host=mock.ANY, port=9999) + wsgi_server.start.assert_called_once_with() self.assertFalse(mock_inspector.called) self.assertFalse(mock_wait.called) @@ -573,16 +553,16 @@ class TestBaseAgent(ironic_agent_base.IronicAgentTest): @mock.patch.object(agent.IronicPythonAgent, '_wait_for_interface', autospec=True) @mock.patch.object(hardware, 'dispatch_to_managers', autospec=True) - @mock.patch('wsgiref.simple_server.WSGIServer', autospec=True) - def test_run_with_sleep(self, mock_make_server, mock_dispatch, + @mock.patch('oslo_service.wsgi.Server', autospec=True) + def test_run_with_sleep(self, mock_wsgi, mock_dispatch, mock_wait, mock_sleep, mock_load_managers): CONF.set_override('inspection_callback_url', '') def set_serve_api(): self.agent.serve_api = False - wsgi_server = mock_make_server.return_value - wsgi_server.handle_request.side_effect = set_serve_api + wsgi_server = mock_wsgi.return_value + wsgi_server.start.side_effect = set_serve_api self.agent.hardware_initialization_delay = 10 self.agent.heartbeater = mock.Mock() @@ -597,11 +577,10 @@ class TestBaseAgent(ironic_agent_base.IronicAgentTest): } self.agent.run() - listen_addr = agent.Host('192.0.2.1', 9999) - mock_make_server.assert_called_once_with( - (listen_addr.hostname, - listen_addr.port), - simple_server.WSGIRequestHandler) + mock_wsgi.assert_called_once_with(CONF, 'ironic-python-agent', + app=self.agent.api, + host=mock.ANY, port=9999) + wsgi_server.start.assert_called_once_with() self.agent.heartbeater.start.assert_called_once_with() mock_sleep.assert_called_once_with(10) @@ -730,21 +709,18 @@ class TestAgentStandalone(ironic_agent_base.IronicAgentTest): @mock.patch( 'ironic_python_agent.hardware_managers.cna._detect_cna_card', mock.Mock()) - @mock.patch('wsgiref.simple_server.make_server', autospec=True) - @mock.patch('wsgiref.simple_server.WSGIServer', autospec=True) + @mock.patch('oslo_service.wsgi.Server', autospec=True) @mock.patch.object(hardware.HardwareManager, 'list_hardware_info', autospec=True) @mock.patch.object(hardware, 'load_managers', autospec=True) def test_run(self, mock_load_managers, mock_list_hardware, - mock_wsgi, mock_make_server): - wsgi_server = mock_make_server.return_value - wsgi_server.start.side_effect = KeyboardInterrupt() + mock_wsgi): wsgi_server_request = mock_wsgi.return_value def set_serve_api(): self.agent.serve_api = False - wsgi_server_request.handle_request.side_effect = set_serve_api + wsgi_server_request.start.side_effect = set_serve_api self.agent.heartbeater = mock.Mock() self.agent.api_client.lookup_node = mock.Mock() @@ -752,14 +728,11 @@ class TestAgentStandalone(ironic_agent_base.IronicAgentTest): self.agent.run() self.assertTrue(mock_load_managers.called) - listen_addr = agent.Host('192.0.2.1', 9999) - mock_wsgi.assert_called_once_with( - (listen_addr.hostname, - listen_addr.port), - simple_server.WSGIRequestHandler) - wsgi_server_request.set_app.assert_called_once_with(self.agent.api) + mock_wsgi.assert_called_once_with(CONF, 'ironic-python-agent', + app=self.agent.api, + host=mock.ANY, port=9999) + wsgi_server_request.start.assert_called_once_with() - self.assertTrue(wsgi_server_request.handle_request.called) self.assertFalse(self.agent.heartbeater.called) self.assertFalse(self.agent.api_client.lookup_node.called) diff --git a/ironic_python_agent/tests/unit/test_api.py b/ironic_python_agent/tests/unit/test_api.py index ce8def0da..76e0c9777 100644 --- a/ironic_python_agent/tests/unit/test_api.py +++ b/ironic_python_agent/tests/unit/test_api.py @@ -15,10 +15,13 @@ import time import mock -import pecan -import pecan.testing +from oslo_config import cfg +from werkzeug import test as http_test +from werkzeug import wrappers +from werkzeug.wrappers import json as http_json from ironic_python_agent import agent +from ironic_python_agent.api import app from ironic_python_agent.extensions import base from ironic_python_agent.tests.unit import base as ironic_agent_base @@ -26,35 +29,21 @@ from ironic_python_agent.tests.unit import base as ironic_agent_base PATH_PREFIX = '/v1' +class Response(wrappers.Response, http_json.JSONMixin): + pass + + class TestIronicAPI(ironic_agent_base.IronicAgentTest): def setUp(self): super(TestIronicAPI, self).setUp() self.mock_agent = mock.MagicMock() - self.app = self._make_app() + self.app = app.Application(self.mock_agent, cfg.CONF) + self.client = http_test.Client(self.app, Response) - def tearDown(self): - super(TestIronicAPI, self).tearDown() - pecan.set_config({}, overwrite=True) - - def _make_app(self): - self.config = { - 'app': { - 'root': 'ironic_python_agent.api.controllers.root.' - 'RootController', - 'modules': ['ironic_python_agent.api'], - 'static_root': '', - 'debug': True, - }, - } - - return pecan.testing.load_test_app(config=self.config, - agent=self.mock_agent) - - def _request_json(self, path, params, expect_errors=False, headers=None, - method="post", extra_environ=None, status=None, - path_prefix=PATH_PREFIX): - """Sends simulated HTTP request to Pecan test app. + def _request_json(self, path, params=None, expect_errors=False, + headers=None, method="post", path_prefix=PATH_PREFIX): + """Sends simulated HTTP request to the test app. :param path: url path of target service :param params: content for wsgi.input of request @@ -63,97 +52,61 @@ class TestIronicAPI(ironic_agent_base.IronicAgentTest): :param headers: a dictionary of headers to send along with the request :param method: Request method type. Appropriate method function call should be used rather than passing attribute in. - :param extra_environ: a dictionary of environ variables to send along - with the request - :param status: expected status code of response :param path_prefix: prefix of the url path """ full_path = path_prefix + path print('%s: %s %s' % (method.upper(), full_path, params)) - response = getattr(self.app, "%s_json" % method)( + response = self.client.open( str(full_path), - params=params, + method=method.upper(), + json=params, headers=headers, - status=status, - extra_environ=extra_environ, - expect_errors=expect_errors + follow_redirects=True, ) print('GOT:%s' % response) + if not expect_errors: + self.assertLess(response.status_code, 400) return response - def put_json(self, path, params, expect_errors=False, headers=None, - extra_environ=None, status=None): - """Sends simulated HTTP PUT request to Pecan test app. + def put_json(self, path, params, expect_errors=False, headers=None): + """Sends simulated HTTP PUT request to the test app. :param path: url path of target service :param params: content for wsgi.input of request :param expect_errors: Boolean value; whether an error is expected based on request :param headers: a dictionary of headers to send along with the request - :param extra_environ: a dictionary of environ variables to send along - with the request - :param status: expected status code of response """ return self._request_json(path=path, params=params, expect_errors=expect_errors, - headers=headers, extra_environ=extra_environ, - status=status, method="put") + headers=headers, method="put") - def post_json(self, path, params, expect_errors=False, headers=None, - extra_environ=None, status=None): - """Sends simulated HTTP POST request to Pecan test app. + def post_json(self, path, params, expect_errors=False, headers=None): + """Sends simulated HTTP POST request to the test app. :param path: url path of target service :param params: content for wsgi.input of request :param expect_errors: Boolean value; whether an error is expected based on request :param headers: a dictionary of headers to send along with the request - :param extra_environ: a dictionary of environ variables to send along - with the request - :param status: expected status code of response """ return self._request_json(path=path, params=params, expect_errors=expect_errors, - headers=headers, extra_environ=extra_environ, - status=status, method="post") + headers=headers, method="post") def get_json(self, path, expect_errors=False, headers=None, - extra_environ=None, q=None, path_prefix=PATH_PREFIX, - **params): - """Sends simulated HTTP GET request to Pecan test app. + path_prefix=PATH_PREFIX): + """Sends simulated HTTP GET request to the test app. :param path: url path of target service :param expect_errors: Boolean value;whether an error is expected based on request :param headers: a dictionary of headers to send along with the request - :param extra_environ: a dictionary of environ variables to send along - with the request - :param q: list of queries consisting of: field, value, op, and type - keys :param path_prefix: prefix of the url path - :param params: content for wsgi.input of request """ - full_path = path_prefix + path - query_params = {'q.field': [], - 'q.value': [], - 'q.op': [], - } - q = [] if q is None else q - for query in q: - for name in ['field', 'op', 'value']: - query_params['q.%s' % name].append(query.get(name, '')) - all_params = {} - all_params.update(params) - if q: - all_params.update(query_params) - print('GET: %s %r' % (full_path, all_params)) - response = self.app.get(full_path, - params=all_params, - headers=headers, - extra_environ=extra_environ, - expect_errors=expect_errors) - print('GOT:%s' % response) - return response + return self._request_json(path=path, expect_errors=expect_errors, + headers=headers, method="get", + path_prefix=path_prefix) def test_root(self): response = self.get_json('/', path_prefix='') @@ -166,6 +119,13 @@ class TestIronicAPI(ironic_agent_base.IronicAgentTest): self.assertIn('status', data) self.assertIn('commands', data) + def test_not_found(self): + response = self.get_json('/v1/foo', path_prefix='', + expect_errors=True) + self.assertEqual(404, response.status_code) + data = response.json + self.assertEqual('Client', data['faultcode']) + def test_get_agent_status(self): status = agent.IronicPythonAgentStatus(time.time(), 'v72ac9') @@ -265,9 +225,8 @@ class TestIronicAPI(ironic_agent_base.IronicAgentTest): expect_errors=True) self.assertEqual(400, response.status_code) data = response.json - msg = 'Invalid input for field/attribute name.' - self.assertIn(msg, data['faultstring']) - msg = 'Mandatory field missing' + self.assertEqual('Client', data['faultcode']) + msg = 'Missing or invalid name or params' self.assertIn(msg, data['faultstring']) def test_execute_agent_command_params_validation(self): @@ -277,8 +236,9 @@ class TestIronicAPI(ironic_agent_base.IronicAgentTest): expect_errors=True) self.assertEqual(400, response.status_code) data = response.json + self.assertEqual('Client', data['faultcode']) # this message is actually much longer, but I'm ok with this - msg = 'Invalid input for field/attribute params.' + msg = 'Missing or invalid name or params' self.assertIn(msg, data['faultstring']) def test_list_command_results(self): diff --git a/lower-constraints.txt b/lower-constraints.txt index 1d7ad8124..530867035 100644 --- a/lower-constraints.txt +++ b/lower-constraints.txt @@ -52,7 +52,6 @@ oslotest==3.2.0 Paste==2.0.3 PasteDeploy==1.5.2 pbr==2.0.0 -pecan==1.0.0 pep8==1.5.7 Pint==0.5 prettytable==0.7.2 @@ -81,7 +80,6 @@ simplegeneric==0.8.1 snowballstemmer==1.2.1 Sphinx==1.6.2 sphinxcontrib-httpdomain==1.6.1 -sphinxcontrib-pecanwsme==0.8.0 sphinxcontrib-websupport==1.0.1 stestr==1.0.0 stevedore==1.20.0 @@ -93,5 +91,5 @@ voluptuous==0.11.1 waitress==1.1.0 WebOb==1.7.4 WebTest==2.0.29 +Werkzeug==0.15.0 wrapt==1.10.11 -WSME==0.8.0 diff --git a/requirements.txt b/requirements.txt index e9d55752a..49052372d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,12 +10,11 @@ oslo.log>=3.36.0 # Apache-2.0 oslo.serialization!=2.19.1,>=2.18.0 # Apache-2.0 oslo.service!=1.28.1,>=1.24.0 # Apache-2.0 oslo.utils>=3.33.0 # Apache-2.0 -pecan!=1.0.2,!=1.0.3,!=1.0.4,!=1.2,>=1.0.0 # BSD Pint>=0.5 # BSD psutil>=3.2.2 # BSD pyudev>=0.18 # LGPLv2.1+ requests>=2.14.2 # Apache-2.0 rtslib-fb>=2.1.65 # Apache-2.0 stevedore>=1.20.0 # Apache-2.0 -WSME>=0.8.0 # MIT ironic-lib>=2.17.0 # Apache-2.0 +Werkzeug>=0.15.0 # BSD License diff --git a/test-requirements.txt b/test-requirements.txt index 2d9741663..ad691711c 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -14,6 +14,5 @@ bandit!=1.6.0,>=1.1.0,<2.0.0 # Apache-2.0 # Doc requirements doc8>=0.6.0 # Apache-2.0 sphinx!=1.6.6,!=1.6.7,>=1.6.2;python_version>='3.4' # BSD -sphinxcontrib-pecanwsme>=0.8.0 # Apache-2.0 openstackdocstheme>=1.20.0 # Apache-2.0 reno>=2.5.0 # Apache-2.0