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:
Dmitry Tantsur 2019-11-19 18:19:31 +01:00
parent 6032643a04
commit f1b2df908a
17 changed files with 319 additions and 815 deletions

View File

@ -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 --------------------------------------------------

View File

@ -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."""

View File

@ -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)

View File

@ -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,
}

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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):

View File

@ -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

View File

@ -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

View File

@ -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