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
This commit is contained in:
parent
6032643a04
commit
f1b2df908a
@ -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 --------------------------------------------------
|
||||
|
||||
|
@ -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."""
|
||||
|
@ -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/<cmd>', 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/<cmd>', 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)
|
||||
|
@ -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,
|
||||
}
|
@ -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)
|
@ -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)
|
@ -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
|
@ -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
|
@ -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)
|
@ -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)
|
@ -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)
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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))
|
||||