From 78692af86495206d0c0704c5fe1cda4374ffd7aa Mon Sep 17 00:00:00 2001 From: Tomi Juvonen Date: Tue, 25 Jun 2019 12:16:08 +0300 Subject: [PATCH] Flask to WSGI and security This adds a lot of stuff related changing from API from Flask to WSGI and adding security. This is mostly api side. There will more changes to add testing and engine side changes. API parameter validation can also be done later. story: 2004882 Task: #29163 story: 2003844 Task: #26635 Change-Id: Id373440affd2d625933da2a6dbeb6354acd75e1e Signed-off-by: Tomi Juvonen --- devstack/plugin.sh | 11 ++ fenix/api/__init__.py | 52 ------- fenix/api/context.py | 67 +++++++++ fenix/api/root.py | 44 ++++++ fenix/api/v1/__init__.py | 26 ---- fenix/api/v1/app.py | 81 +++++++++++ fenix/api/v1/base.py | 52 ------- fenix/api/v1/controllers/__init__.py | 64 +++++++++ fenix/api/v1/controllers/base.py | 52 +++++++ fenix/api/v1/controllers/maintenance.py | 127 +++++++++++++++++ fenix/api/v1/controllers/types.py | 132 ++++++++++++++++++ fenix/api/v1/hooks.py | 68 +++++++++ fenix/api/v1/maintenance.py | 103 +------------- fenix/api/v1/middleware/__init__.py | 137 +++++++++++++++++++ fenix/cmd/api.py | 19 +-- fenix/context.py | 4 + fenix/exceptions.py | 23 ++++ fenix/policies/__init__.py | 27 ++++ fenix/{api/v1/routes.py => policies/base.py} | 32 +++-- fenix/policies/maintenance.py | 49 +++++++ fenix/policies/project.py | 50 +++++++ fenix/policies/session.py | 57 ++++++++ fenix/policy.py | 110 +++++++++++++++ fenix/utils/service.py | 17 +-- fenix/workflow/workflows/default.py | 18 ++- 25 files changed, 1157 insertions(+), 265 deletions(-) create mode 100644 fenix/api/context.py create mode 100644 fenix/api/root.py create mode 100644 fenix/api/v1/app.py delete mode 100644 fenix/api/v1/base.py create mode 100644 fenix/api/v1/controllers/__init__.py create mode 100644 fenix/api/v1/controllers/base.py create mode 100644 fenix/api/v1/controllers/maintenance.py create mode 100644 fenix/api/v1/controllers/types.py create mode 100644 fenix/api/v1/hooks.py create mode 100644 fenix/api/v1/middleware/__init__.py create mode 100644 fenix/policies/__init__.py rename fenix/{api/v1/routes.py => policies/base.py} (51%) create mode 100644 fenix/policies/maintenance.py create mode 100644 fenix/policies/project.py create mode 100644 fenix/policies/session.py create mode 100644 fenix/policy.py diff --git a/devstack/plugin.sh b/devstack/plugin.sh index e9de8f8..414664a 100644 --- a/devstack/plugin.sh +++ b/devstack/plugin.sh @@ -19,6 +19,7 @@ function configure_fenix { create_service_user "$FENIX_USER_NAME" "admin" init_fenix_service_user_conf + init_fenix_keystone iniset $FENIX_CONF_FILE DEFAULT host "$FENIX_SERVICE_HOST" iniset $FENIX_API_CONF_FILE DEFAULT host "$FENIX_SERVICE_HOST" @@ -37,6 +38,16 @@ function init_fenix_service_user_conf { iniset $FENIX_CONF_FILE service_user os_user_domain_name "$SERVICE_DOMAIN_NAME" iniset $FENIX_CONF_FILE service_user os_project_name "$SERVICE_TENANT_NAME" iniset $FENIX_CONF_FILE service_user os_project_domain_name "$SERVICE_DOMAIN_NAME" + +function init_fenix_keystone { + iniset $FENIX_API_CONF keystone_authtoken cafile "$SSL_BUNDLE_FILE" + iniset $FENIX_API_CONF keystone_authtoken project_domain_name "$SERVICE_DOMAIN_NAME" + iniset $FENIX_API_CONF keystone_authtoken project_name "$SERVICE_TENANT_NAME" + iniset $FENIX_API_CONF keystone_authtoken user_domain_name "$SERVICE_DOMAIN_NAME" + iniset $FENIX_API_CONF keystone_authtoken password "$SERVICE_PASSWORD" + iniset $FENIX_API_CONF keystone_authtoken username "$FENIX_USER_NAME" + iniset $FENIX_API_CONF keystone_authtoken auth_url "$KEYSTONE_SERVICE_URI" + iniset $FENIX_API_CONF keystone_authtoken auth_type = password } function create_fenix_accounts { diff --git a/fenix/api/__init__.py b/fenix/api/__init__.py index 5996bfb..e69de29 100644 --- a/fenix/api/__init__.py +++ b/fenix/api/__init__.py @@ -1,52 +0,0 @@ -# Copyright (c) 2018 OpenStack Foundation. -# 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 flask import Flask -from oslo_config import cfg -from oslo_log import log as logging -import sys - -from fenix.api import v1 - - -LOG = logging.getLogger(__name__) - -api_opts = [ - cfg.StrOpt('api_config', - default="api.conf", - help="Configuration file for API service."), - cfg.StrOpt('host', - default="127.0.0.1", - help="API host IP"), - cfg.IntOpt('port', - default=5000, - help="API port to use."), -] - -CONF = cfg.CONF -CONF.register_opts(api_opts) -logging.register_options(cfg.CONF) -cfg.CONF(sys.argv[1:], project='fenix', prog='fenix-api') - - -def create_app(global_config, **local_config): - return setup_app() - - -def setup_app(config=None): - app = Flask(__name__, static_folder=None) - app.config.update(PROPAGATE_EXCEPTIONS=True) - app.register_blueprint(v1.bp, url_prefix='/v1') - return app diff --git a/fenix/api/context.py b/fenix/api/context.py new file mode 100644 index 0000000..c74ae4a --- /dev/null +++ b/fenix/api/context.py @@ -0,0 +1,67 @@ +# Copyright (c) 2019 OpenStack Foundation. +# 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 oslo_serialization import jsonutils + +import six + +from fenix import context +from fenix import exceptions + + +def ctx_from_headers(headers): + try: + service_catalog = jsonutils.loads(headers['X-Service-Catalog']) + except KeyError: + raise exceptions.ServiceCatalogNotFound() + except TypeError: + raise exceptions.WrongFormat() + if 'X-User-Id' in headers: + user_id = headers['X-User-Id'] + else: + user_id = None + if 'X-Project-Id' in headers: + project_id = headers['X-Project-Id'] + else: + project_id = None + if 'X-Auth-Token' in headers: + auth_token = headers['X-Auth-Token'] + else: + auth_token = None + if 'X-User-Name' in headers: + user_name = headers['X-User-Name'] + else: + user_name = None + if 'X-Project-Name' in headers: + project_name = headers['X-Project-Name'] + else: + project_name = None + if 'X-Project-Name' in headers: + project_name = headers['X-Project-Name'] + else: + project_name = None + if 'X-Roles' in headers: + roles = list(map(six.text_type.strip, headers['X-Roles'].split(','))) + else: + roles = None + return context.FenixContext( + user_id=user_id, + project_id=project_id, + auth_token=auth_token, + user_name=user_name, + project_name=project_name, + roles=roles, + service_catalog=service_catalog + ) diff --git a/fenix/api/root.py b/fenix/api/root.py new file mode 100644 index 0000000..fbbedec --- /dev/null +++ b/fenix/api/root.py @@ -0,0 +1,44 @@ +# Copyright (c) 2019 OpenStack Foundation. +# 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 oslo_serialization import jsonutils +import pecan + +from fenix.api.v1 import controllers + + +class RootController(object): + + v1 = controllers.V1Controller() + + def _append_versions_from_controller(self, versions, controller, path): + for version in getattr(controller, 'versions', None): + version['links'] = [{ + "href": "{0}/{1}".format(pecan.request.host_url, path), + "rel": "self"}] + versions.append(version) + + @pecan.expose(content_type='application/json') + def index(self): + pecan.response.status_code = 300 + pecan.response.content_type = 'application/json' + versions = {"versions": []} + self._append_versions_from_controller(versions['versions'], + self.v1, 'v1') + return jsonutils.dump_as_bytes(versions) + + @pecan.expose(content_type='application/json') + def versions(self): + return self.index() diff --git a/fenix/api/v1/__init__.py b/fenix/api/v1/__init__.py index 5879977..e69de29 100644 --- a/fenix/api/v1/__init__.py +++ b/fenix/api/v1/__init__.py @@ -1,26 +0,0 @@ -# Copyright (c) 2018 OpenStack Foundation. -# 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 flask import Blueprint -import flask_restful as restful - -from fenix.api.v1.routes import routes - - -bp = Blueprint('v1', __name__) -api = restful.Api(bp, catch_all_404s=True) - -for route in routes: - api.add_resource(route.pop('resource'), *route.pop('urls'), **route) diff --git a/fenix/api/v1/app.py b/fenix/api/v1/app.py new file mode 100644 index 0000000..7ede1c1 --- /dev/null +++ b/fenix/api/v1/app.py @@ -0,0 +1,81 @@ +# Copyright (c) 2018 OpenStack Foundation. +# 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. + +import sys + +from keystonemiddleware import auth_token +from oslo_config import cfg +from oslo_log import log as logging +import pecan + +from fenix.api.v1 import hooks +from fenix.api.v1 import middleware + +LOG = logging.getLogger(__name__) + +api_opts = [ + cfg.StrOpt('api_config', + default="api.conf", + help="Configuration file for API service."), + cfg.StrOpt('host', + default="127.0.0.1", + help="API host IP"), + cfg.IntOpt('port', + default=5000, + help="API port to use."), +] + +CONF = cfg.CONF +CONF.register_opts(api_opts) +logging.register_options(cfg.CONF) +cfg.CONF(sys.argv[1:], project='fenix', prog='fenix-api') + + +def setup_app(pecan_config=None, debug=False, argv=None): + + app_hooks = [hooks.ConfigHook(), + hooks.DBHook(), + hooks.ContextHook(), + hooks.RPCHook()] + + app = pecan.make_app(pecan_config.app.root, + debug=CONF.debug, + hooks=app_hooks, + wrap_app=middleware.ParsableErrorMiddleware, + guess_content_type_from_ext=False) + + # WSGI middleware for debugging + # if CONF.log_exchange: + # app = debug.Debug.factory(pecan_config)(app) + + # WSGI middleware for Keystone auth + # NOTE(sbauza): ACLs are always active unless for unittesting where + # enable_acl could be set to False + if pecan_config.app.enable_acl: + app = auth_token.AuthProtocol(app, {}) + + return app + + +def make_app(): + config = { + 'app': { + 'modules': ['fenix.api.v1'], + 'root': 'fenix.api.root.RootController', + 'enable_acl': True, + } + } + app = pecan.load_app(config) + return app diff --git a/fenix/api/v1/base.py b/fenix/api/v1/base.py deleted file mode 100644 index 9a6814e..0000000 --- a/fenix/api/v1/base.py +++ /dev/null @@ -1,52 +0,0 @@ -# Copyright (c) 2018 OpenStack Foundation. -# 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. - -import decorator -import flask -import flask_restful as restful -import inspect -import json -import re - -SORT_KEY_SPLITTER = re.compile('[ ,]') - - -class Resource(restful.Resource): - method_decorators = [] - - def error_response(self, status_code, message): - body = json.dumps( - { - 'status': status_code, - 'message': message - }, - ) - resp = flask.make_response("{body}\n".format(body=body)) - resp.status_code = status_code - return resp - - -@decorator.decorator -def http_codes(f, *args, **kwargs): - try: - return f(*args, **kwargs) - except Exception as err: - try: - inspect.getmodule(f).LOG.error( - 'Error during %s: %s' % (f.__qualname__, err)) - except AttributeError: - inspect.getmodule(f).LOG.error( - 'Error during %s: %s' % (f.__name__, err)) - return args[0].error_response(500, 'Unknown Error') diff --git a/fenix/api/v1/controllers/__init__.py b/fenix/api/v1/controllers/__init__.py new file mode 100644 index 0000000..bc9bbc4 --- /dev/null +++ b/fenix/api/v1/controllers/__init__.py @@ -0,0 +1,64 @@ +# Copyright (c) 2019 OpenStack Foundation. +# 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 API. +""" + +from oslo_log import log as logging +import pecan +from pecan import rest + +from fenix.api.v1.controllers import maintenance as controller + +LOG = logging.getLogger(__name__) + + +class V1Controller(rest.RestController): + + versions = [{"id": "v1", "status": "CURRENT"}] + _routes = {} + + # maps to v1/maintenance + maintenance = controller.MaintenanceController() + # maps to v1/maintenance/{session_id} + session = controller.SessionController() + # maps to v1/maintenance/{session_id}/{project_id} + project = controller.ProjectController() + + @pecan.expose() + def _route(self, args): + """Overrides the default routing behavior. + + It allows to map controller URL with correct controller instance. + By default, it maps with the same name. + """ + + try: + route = self._routes.get(args[0], args[0]) + depth = len(args) + if route is None: + # NOTE(sbauza): Route must map to a non-existing controller + args[0] = 'http404-nonexistingcontroller' + elif depth == 1: + args[0] = route + elif depth == 2 and route == "maintenance": + args[0] = "session" + elif depth == 3 and route == "maintenance": + args[0] = "project" + else: + args[0] = 'http404-nonexistingcontroller' + except IndexError: + LOG.error("No args found on V1 controller") + return super(V1Controller, self)._route(args) diff --git a/fenix/api/v1/controllers/base.py b/fenix/api/v1/controllers/base.py new file mode 100644 index 0000000..d271a20 --- /dev/null +++ b/fenix/api/v1/controllers/base.py @@ -0,0 +1,52 @@ +# Copyright (c) 2014 Bull. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import wsme +from wsme import types as wtypes + +from fenix.api.v1.controllers import types + + +class _Base(wtypes.DynamicBase): + + # NOTE(sbauza): That does respect ISO8601 but with a different sep (' ') + created_at = types.Datetime('%Y-%m-%d %H:%M:%S.%f') + "The time in UTC at which the object is created" + + updated_at = types.Datetime('%Y-%m-%d %H:%M:%S.%f') + "The time in UTC at which the object is updated" + + def as_dict(self): + cls = type(self) + valid_keys = [item for item in dir(cls) + if item not in dir(_Base) + and wtypes.iswsattr(getattr(cls, item))] + + if 'self' in valid_keys: + valid_keys.remove('self') + return self.as_dict_from_keys(valid_keys) + + def as_dict_from_keys(self, keys): + res = {} + for key in keys: + value = getattr(self, key, wsme.Unset) + if value != wsme.Unset: + res[key] = value + return res + + @classmethod + def convert(cls, rpc_obj): + obj = cls(**rpc_obj) + return obj diff --git a/fenix/api/v1/controllers/maintenance.py b/fenix/api/v1/controllers/maintenance.py new file mode 100644 index 0000000..c775d3b --- /dev/null +++ b/fenix/api/v1/controllers/maintenance.py @@ -0,0 +1,127 @@ +# Copyright (c) 2019 OpenStack Foundation. +# 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. + +import json +from pecan import abort +from pecan import expose +from pecan import request +from pecan import response +from pecan import rest + +from oslo_log import log +from oslo_serialization import jsonutils + +from fenix.api.v1 import maintenance +from fenix import policy + +LOG = log.getLogger(__name__) + + +class ProjectController(rest.RestController): + + name = 'project' + + def __init__(self): + self.engine_rpcapi = maintenance.EngineRPCAPI() + + # GET /v1/maintenance// + @policy.authorize('maintenance:session:project', 'get') + @expose(content_type='application/json') + def get(self, session_id, project_id): + if request.body: + LOG.error("Unexpected data") + abort(400) + engine_data = self.engine_rpcapi.project_get_session(session_id, + project_id) + response.body = jsonutils.dumps(engine_data) + + # PUT /v1/maintenance// + @policy.authorize('maintenance:session:project', 'put') + @expose(content_type='application/json') + def put(self, session_id, project_id): + data = json.loads(request.body.decode('utf8')) + engine_data = self.engine_rpcapi.project_update_session(session_id, + project_id, + data) + response.body = jsonutils.dumps(engine_data) + + +class SessionController(rest.RestController): + + name = 'session' + + def __init__(self): + self.engine_rpcapi = maintenance.EngineRPCAPI() + + # GET /v1/maintenance/ + @policy.authorize('maintenance:session', 'get') + @expose(content_type='application/json') + def get(self, session_id): + if request.body: + LOG.error("Unexpected data") + abort(400) + session = self.engine_rpcapi.admin_get_session(session_id) + if session is None: + response.status = 404 + return {"error": "Invalid session"} + response.body = jsonutils.dumps(session) + + # PUT /v1/maintenance/ + @policy.authorize('maintenance:session', 'put') + @expose(content_type='application/json') + def put(self, session_id): + data = json.loads(request.body.decode('utf8')) + engine_data = self.engine_rpcapi.admin_update_session(session_id, data) + response.body = jsonutils.dumps(engine_data) + + # DELETE /v1/maintenance/ + @policy.authorize('maintenance:session', 'delete') + @expose(content_type='application/json') + def delete(self, session_id): + if request.body: + LOG.error("Unexpected data") + abort(400) + engine_data = self.engine_rpcapi.admin_delete_session(session_id) + response.body = jsonutils.dumps(engine_data) + + +class MaintenanceController(rest.RestController): + + name = 'maintenance' + + def __init__(self): + self.engine_rpcapi = maintenance.EngineRPCAPI() + + # GET /v1/maintenance + @policy.authorize('maintenance', 'get') + @expose(content_type='application/json') + def get(self): + if request.body: + LOG.error("Unexpected data") + abort(400) + sessions = self.engine_rpcapi.admin_get() + response.body = jsonutils.dumps(sessions) + + # POST /v1/maintenance + @policy.authorize('maintenance', 'post') + @expose(content_type='application/json') + def post(self): + LOG.debug("POST /v1/maintenance") + data = json.loads(request.body.decode('utf8')) + session = self.engine_rpcapi.admin_create_session(data) + if session is None: + response.status = 509 + return {"error": "Too many sessions"} + response.body = jsonutils.dumps(session) diff --git a/fenix/api/v1/controllers/types.py b/fenix/api/v1/controllers/types.py new file mode 100644 index 0000000..efb491c --- /dev/null +++ b/fenix/api/v1/controllers/types.py @@ -0,0 +1,132 @@ +# Copyright (c) 2014 Bull. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime +import uuid + +from oslo_serialization import jsonutils +import six +from wsme import types as wtypes +from wsme import utils as wutils + +from fenix import exceptions + + +class UuidType(wtypes.UserType): + """A simple UUID type.""" + + basetype = wtypes.text + name = 'uuid' + # FIXME(sbauza): When used with wsexpose decorator WSME will try + # to get the name of the type by accessing it's __name__ attribute. + # Remove this __name__ attribute once it's fixed in WSME. + # https://bugs.launchpad.net/wsme/+bug/1265590 + __name__ = name + + def __init__(self, without_dashes=False): + self.without_dashes = without_dashes + + def validate(self, value): + try: + valid_uuid = six.text_type(uuid.UUID(value)) + if self.without_dashes: + valid_uuid = valid_uuid.replace('-', '') + return valid_uuid + except (TypeError, ValueError, AttributeError): + error = 'Value should be UUID format' + raise ValueError(error) + + +class IntegerType(wtypes.IntegerType): + """A simple integer type. Can validate a value range. + + :param minimum: Possible minimum value + :param maximum: Possible maximum value + + Example:: + + Price = IntegerType(minimum=1) + + """ + + name = 'integer' + # FIXME(sbauza): When used with wsexpose decorator WSME will try + # to get the name of the type by accessing it's __name__ attribute. + # Remove this __name__ attribute once it's fixed in WSME. + # https://bugs.launchpad.net/wsme/+bug/1265590 + __name__ = name + + +class CPUInfo(wtypes.UserType): + """A type for matching CPU info from hypervisors.""" + + basetype = wtypes.text + name = 'cpuinfo as JSON formated str' + + @staticmethod + def validate(value): + # NOTE(sbauza): cpu_info can be very different from one Nova driver to + # another. We need to keep this method as generic as + # possible, ie. we accept JSONified dict. + try: + cpu_info = jsonutils.loads(value) + except TypeError: + raise exceptions.InvalidInput(cls=CPUInfo.name, value=value) + if not isinstance(cpu_info, dict): + raise exceptions.InvalidInput(cls=CPUInfo.name, value=value) + return value + + +class TextOrInteger(wtypes.UserType): + """A type for matching either text or integer.""" + + basetype = wtypes.text + name = 'textorinteger' + + @staticmethod + def validate(value): + # NOTE(sbauza): We need to accept non-unicoded Python2 strings + if (isinstance(value, six.text_type) or isinstance(value, str) + or isinstance(value, int)): + return value + else: + raise exceptions.InvalidInput(cls=TextOrInteger.name, value=value) + + +class Datetime(wtypes.UserType): + """A type for matching unicoded datetime.""" + + basetype = wtypes.text + name = 'datetime' + + # Format must be ISO8601 as default + format = '%Y-%m-%dT%H:%M:%S.%f' + + def __init__(self, format=None): + if format: + self.format = format + + def validate(self, value): + try: + datetime.datetime.strptime(value, self.format) + except ValueError: + # FIXME(sbauza): Start_date and end_date are given using a specific + # format but are shown in default ISO8601, we must + # fail back to it for verification + try: + wutils.parse_isodatetime(value) + except ValueError: + raise exceptions.InvalidInput(cls=Datetime.name, value=value) + return value diff --git a/fenix/api/v1/hooks.py b/fenix/api/v1/hooks.py new file mode 100644 index 0000000..00c605a --- /dev/null +++ b/fenix/api/v1/hooks.py @@ -0,0 +1,68 @@ +# Copyright (c) 2019 OpenStack Foundation. +# 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 oslo_config import cfg +from oslo_log import log as logging +from pecan import hooks + +from fenix.api import context +from fenix.api.v1 import maintenance + +from fenix.db import api as dbapi + + +LOG = logging.getLogger(__name__) + + +class ConfigHook(hooks.PecanHook): + """ConfigHook + + Attach the configuration object to the request + so controllers can get to it. + """ + + def before(self, state): + state.request.cfg = cfg.CONF + + +class DBHook(hooks.PecanHook): + """Attach the dbapi object to the request so controllers can get to it.""" + + def before(self, state): + state.request.dbapi = dbapi.get_instance() + + +class ContextHook(hooks.PecanHook): + """Configures a request context and attaches it to the request.""" + + def before(self, state): + state.request.context = context.ctx_from_headers(state.request.headers) + state.request.context.__enter__() + + # NOTE(sbauza): on_error() can be fired before after() if the original + # exception is not catched by WSME. That's necessary to not + # handle context.__exit__() within on_error() as it could + # lead to pop the stack twice for the same request + def after(self, state): + # If no API extensions are loaded, context is empty + if state.request.context: + state.request.context.__exit__(None, None, None) + + +class RPCHook(hooks.PecanHook): + """Attach the rpcapi object to the request so controllers can get to it.""" + + def before(self, state): + state.request.rpcapi = maintenance.EngineRPCAPI() diff --git a/fenix/api/v1/maintenance.py b/fenix/api/v1/maintenance.py index afd8a33..f309a35 100644 --- a/fenix/api/v1/maintenance.py +++ b/fenix/api/v1/maintenance.py @@ -1,4 +1,4 @@ -# Copyright (c) 2018 OpenStack Foundation. +# Copyright (c) 2019 OpenStack Foundation. # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -13,12 +13,8 @@ # License for the specific language governing permissions and limitations # under the License. -from flask import request -import json from oslo_log import log -from oslo_serialization import jsonutils -from fenix.api.v1 import base from fenix import engine from fenix.utils import service @@ -48,9 +44,10 @@ class EngineRPCAPI(service.RPCClient): """Delete maintenance workflow session thread""" return self.call('admin_delete_session', session_id=session_id) - def admin_update_session(self, session_id): + def admin_update_session(self, session_id, data): """Update maintenance workflow session""" - return self.call('admin_update_session', session_id=session_id) + return self.call('admin_update_session', session_id=session_id, + data=data) def project_get_session(self, session_id, project_id): """Get maintenance workflow session project specific details""" @@ -61,95 +58,3 @@ class EngineRPCAPI(service.RPCClient): """Update maintenance workflow session project state""" return self.call('project_update_session', session_id=session_id, project_id=project_id, data=data) - - -class Maintenance(base.Resource): - - def __init__(self): - self.engine_rpcapi = EngineRPCAPI() - - @base.http_codes - def get(self): - if request.data: - LOG.error("Unexpected data") - return {}, 400, None - LOG.info("admin get") - LOG.info("self.engine_rpcapi.admin_get") - sessions = self.engine_rpcapi.admin_get() - LOG.info("return: %s" % sessions) - return jsonutils.to_primitive(sessions), 200, None - - @base.http_codes - def post(self): - LOG.info("admin post: first") - data = json.loads(request.data.decode('utf8')) - LOG.info("admin post: %s" % data) - session = self.engine_rpcapi.admin_create_session(data) - if session is None: - return {"error": "Too many sessions"}, 509, None - LOG.info("return: %s" % session) - return jsonutils.to_primitive(session), 200, None - - -class MaintenanceSession(base.Resource): - - def __init__(self): - self.engine_rpcapi = EngineRPCAPI() - - @base.http_codes - def get(self, session_id=None): - if request.data: - LOG.error("Unexpected data") - return {}, 400, None - LOG.info("admin session_id get") - session = self.engine_rpcapi.admin_get_session(session_id) - if session is None: - return {"error": "Invalid session"}, 404, None - return jsonutils.to_primitive(session), 200, None - - @base.http_codes - def put(self, session_id=None): - data = json.loads(request.data.decode('utf8')) - LOG.info("admin session_id put: %s" % data) - engine_data = self.engine_rpcapi.admin_update_session(session_id) - LOG.info("engine_data: %s" % engine_data) - response_body = {'maintenance': request.base_url, - 'session_id': session_id} - return jsonutils.to_primitive(response_body), 200, None - - @base.http_codes - def delete(self, session_id=None): - if request.data: - LOG.error("Unexpected data") - return {}, 400, None - LOG.info("admin session_id delete") - ret = self.engine_rpcapi.admin_delete_session(session_id) - LOG.info("return: %s" % ret) - return jsonutils.to_primitive(ret), 200, None - - -class MaintenanceSessionProject(base.Resource): - - def __init__(self): - self.engine_rpcapi = EngineRPCAPI() - - @base.http_codes - def get(self, session_id=None, project_id=None): - if request.data: - LOG.error("Unexpected data") - return {}, 400, None - LOG.info("%s_get" % project_id) - engine_data = self.engine_rpcapi.project_get_session(session_id, - project_id) - LOG.info("engine_data: %s" % engine_data) - return jsonutils.to_primitive(engine_data), 200, None - - @base.http_codes - def put(self, session_id=None, project_id=None): - data = json.loads(request.data.decode('utf8')) - LOG.info("%s_put: %s" % (project_id, data)) - engine_data = self.engine_rpcapi.project_update_session(session_id, - project_id, - data) - LOG.info("engine_data: %s" % engine_data) - return jsonutils.to_primitive(engine_data), 200, None diff --git a/fenix/api/v1/middleware/__init__.py b/fenix/api/v1/middleware/__init__.py new file mode 100644 index 0000000..4e88320 --- /dev/null +++ b/fenix/api/v1/middleware/__init__.py @@ -0,0 +1,137 @@ +# Copyright (c) 2014 Bull. +# +# 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 oslo_log import log as logging +from oslo_serialization import jsonutils +import webob + +from fenix.db import exceptions as db_exceptions +from fenix import exceptions + +LOG = logging.getLogger(__name__) + + +class ParsableErrorMiddleware(object): + """Middleware which prepared body to the client + + Middleware to replace the plain text message body of an error + response with one formatted so the client can parse it. + Based on pecan.middleware.errordocument + """ + + def __init__(self, app): + self.app = app + + def __call__(self, environ, start_response): + # Request for this state, modified by replace_start_response() + # and used when an error is being reported. + state = {} + faultstring = None + + def replacement_start_response(status, headers, exc_info=None): + """Overrides the default response to make errors parsable.""" + try: + status_code = int(status.split(' ')[0]) + except (ValueError, TypeError): # pragma: nocover + raise exceptions.FenixException(_( + 'Status {0} was unexpected').format(status)) + else: + if status_code >= 400: + # Remove some headers so we can replace them later + # when we have the full error message and can + # compute the length. + headers = [(h, v) + for (h, v) in headers + if h.lower() != 'content-length' + ] + # Save the headers as we need to modify them. + state['status_code'] = status_code + state['headers'] = headers + state['exc_info'] = exc_info + return start_response(status, headers, exc_info) + + # NOTE(sbauza): As agreed, XML is not supported with API v2, but can + # still work if no errors are raised + try: + app_iter = self.app(environ, replacement_start_response) + except exceptions.FenixException as e: + faultstring = "{0} {1}".format(e.__class__.__name__, str(e)) + replacement_start_response( + webob.response.Response(status=str(e.code)).status, + [('Content-Type', 'application/json; charset=UTF-8')] + ) + else: + if not state: + return app_iter + try: + res_dct = jsonutils.loads(app_iter[0]) + except ValueError: + return app_iter + else: + try: + faultstring = res_dct['faultstring'] + except KeyError: + return app_iter + + traceback_marker = 'Traceback (most recent call last):' + remote_marker = 'Remote error: ' + + if not faultstring: + return app_iter + + if traceback_marker in faultstring: + # Cut-off traceback. + faultstring = faultstring.split(traceback_marker, 1)[0] + faultstring = faultstring.split('[u\'', 1)[0] + if remote_marker in faultstring: + # RPC calls put that string on + try: + faultstring = faultstring.split( + remote_marker, 1)[1] + except IndexError: + pass + faultstring = faultstring.rstrip() + + try: + (exc_name, exc_value) = faultstring.split(' ', 1) + except (ValueError, AttributeError): + LOG.warning('Incorrect Remote error {0}'.format(faultstring)) + else: + cls = getattr(exceptions, exc_name, None) + if cls is not None: + faultstring = str(cls(exc_value)) + state['status_code'] = cls.code + else: + # Get the exception from db exceptions and hide + # the message because could contain table/column + # information + cls = getattr(db_exceptions, exc_name, None) + if cls is not None: + faultstring = '{0}: A database error occurred'.format( + cls.__name__) + state['status_code'] = cls.code + + # NOTE(sbauza): Client expects a JSON encoded dict + body = [jsonutils.dump_as_bytes( + {'error_code': state['status_code'], + 'error_message': faultstring, + 'error_name': state['status_code']} + )] + start_response( + webob.response.Response(status=state['status_code']).status, + state['headers'], + state['exc_info'] + ) + return body diff --git a/fenix/cmd/api.py b/fenix/cmd/api.py index 53918bd..6f62d23 100644 --- a/fenix/cmd/api.py +++ b/fenix/cmd/api.py @@ -13,24 +13,27 @@ # License for the specific language governing permissions and limitations # under the License. -from wsgiref import simple_server +import eventlet +eventlet.monkey_patch( + os=True, select=True, socket=True, thread=True, time=True) +from eventlet import wsgi +import gettext from oslo_config import cfg from oslo_log import log as logging -from fenix import api +gettext.install('fenix') + +from fenix.api.v1 import app as v1_app LOG = logging.getLogger(__name__) CONF = cfg.CONF def main(): - app = api.setup_app() - host, port = cfg.CONF.host, cfg.CONF.port - LOG.info("host %s port %s" % (host, port)) - srv = simple_server.make_server(host, port, app) - LOG.info("fenix-api started") - srv.serve_forever() + logging.setup(cfg.CONF, 'fenix') + app = v1_app.make_app() + wsgi.server(eventlet.listen((CONF.host, CONF.port), backlog=500), app) if __name__ == "__main__": diff --git a/fenix/context.py b/fenix/context.py index 18c5524..3afe389 100644 --- a/fenix/context.py +++ b/fenix/context.py @@ -100,3 +100,7 @@ class FenixContext(BaseContext): def current(): return FenixContext.current() + + +def elevated(): + return FenixContext.elevated() diff --git a/fenix/exceptions.py b/fenix/exceptions.py index 2ca4c04..21eb61e 100644 --- a/fenix/exceptions.py +++ b/fenix/exceptions.py @@ -47,3 +47,26 @@ class FenixException(Exception): message = self.msg_fmt super(FenixException, self).__init__(message) + + +class NotFound(FenixException): + """Object not found exception.""" + msg_fmt = "Object with %(object)s not found" + code = 404 + + +class NotAuthorized(FenixException): + msg_fmt = "Not authorized" + code = 403 + + +class PolicyNotAuthorized(NotAuthorized): + msg_fmt = "Policy doesn't allow %(action)s to be performed" + + +class ServiceCatalogNotFound(NotFound): + msg_fmt = "Could not find service catalog" + + +class WrongFormat(FenixException): + msg_fmt = "Unenxpectable object format" diff --git a/fenix/policies/__init__.py b/fenix/policies/__init__.py new file mode 100644 index 0000000..f046bb0 --- /dev/null +++ b/fenix/policies/__init__.py @@ -0,0 +1,27 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import itertools + +from fenix.policies import base +from fenix.policies import maintenance +from fenix.policies import project +from fenix.policies import session + + +def list_rules(): + return itertools.chain( + base.list_rules(), + maintenance.list_rules(), + session.list_rules(), + project.list_rules(), + ) diff --git a/fenix/api/v1/routes.py b/fenix/policies/base.py similarity index 51% rename from fenix/api/v1/routes.py rename to fenix/policies/base.py index fc22fbf..2bd7f64 100644 --- a/fenix/api/v1/routes.py +++ b/fenix/policies/base.py @@ -1,6 +1,3 @@ -# Copyright (c) 2018 OpenStack Foundation. -# 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 @@ -13,16 +10,23 @@ # License for the specific language governing permissions and limitations # under the License. -from fenix.api.v1 import maintenance +from oslo_policy import policy -routes = [ - dict(resource=maintenance.Maintenance, - urls=['/maintenance'], - endpoint='maintenance'), - dict(resource=maintenance.MaintenanceSession, - urls=['/maintenance/'], - endpoint='maintenance/'), - dict(resource=maintenance.MaintenanceSessionProject, - urls=['/maintenance//'], - endpoint='maintenance//'), +RULE_ADMIN = 'rule:admin' +RULE_ADMIN_OR_OWNER = 'rule:admin_or_owner' +RULE_ANY = '@' + +rules = [ + policy.RuleDefault( + name="admin", + check_str="is_admin:True or role:admin", + description="Default rule for most Admin APIs."), + policy.RuleDefault( + name="admin_or_owner", + check_str="rule:admin or project_id:%(project_id)s", + description="Default rule for most non-Admin APIs.") ] + + +def list_rules(): + return rules diff --git a/fenix/policies/maintenance.py b/fenix/policies/maintenance.py new file mode 100644 index 0000000..e474756 --- /dev/null +++ b/fenix/policies/maintenance.py @@ -0,0 +1,49 @@ +# Copyright (c) 2019 OpenStack Foundation. +# 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 oslo_policy import policy + +from fenix.policies import base + +POLICY_ROOT = 'fenix:maintenance:%s' + +maintenance_policies = [ + policy.DocumentedRuleDefault( + name=POLICY_ROOT % 'get', + check_str=base.RULE_ADMIN, + description='Policy rule for listing maintenance sessions API', + operations=[ + { + 'path': '/{api_version}/maintenance', + 'method': 'GET' + } + ] + ), + policy.DocumentedRuleDefault( + name=POLICY_ROOT % 'post', + check_str=base.RULE_ADMIN, + description='Policy rule for creating maintenance session API', + operations=[ + { + 'path': '/{api_version}/maintenance', + 'method': 'POST' + } + ] + ) +] + + +def list_rules(): + return maintenance_policies diff --git a/fenix/policies/project.py b/fenix/policies/project.py new file mode 100644 index 0000000..4131fd4 --- /dev/null +++ b/fenix/policies/project.py @@ -0,0 +1,50 @@ +# 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 oslo_policy import policy + +from fenix.policies import base + +POLICY_ROOT = 'fenix:maintenance:session:project:%s' + +project_policies = [ + policy.DocumentedRuleDefault( + name=POLICY_ROOT % 'get', + check_str=base.RULE_ADMIN_OR_OWNER, + description='Policy rule for showing project session affected ' + 'instances API.', + operations=[ + { + 'path': '/{api_version}/maintenance/{seission_id}/' + '{project_id}', + 'method': 'GET' + } + ] + ), + policy.DocumentedRuleDefault( + name=POLICY_ROOT % 'put', + check_str=base.RULE_ADMIN_OR_OWNER, + description='Policy rule for setting project session affected ' + 'instances actions API.', + operations=[ + { + 'path': '/{api_version}/maintenance/{seission_id}/' + '{project_id}', + 'method': 'PUT' + } + ] + ) +] + + +def list_rules(): + return project_policies diff --git a/fenix/policies/session.py b/fenix/policies/session.py new file mode 100644 index 0000000..58ed481 --- /dev/null +++ b/fenix/policies/session.py @@ -0,0 +1,57 @@ +# 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 oslo_policy import policy + +from fenix.policies import base + +POLICY_ROOT = 'fenix:maintenance:session:%s' + +session_policies = [ + policy.DocumentedRuleDefault( + name=POLICY_ROOT % 'get', + check_str=base.RULE_ADMIN, + description='Policy rule for showing maintenance session API', + operations=[ + { + 'path': '/{api_version}/maintenance/{seission_id}', + 'method': 'GET' + } + ] + ), + policy.DocumentedRuleDefault( + name=POLICY_ROOT % 'put', + check_str=base.RULE_ADMIN, + description='Policy rule for updating maintenance session API', + operations=[ + { + 'path': '/{api_version}/maintenance/{seission_id}', + 'method': 'PUT' + } + ] + ), + policy.DocumentedRuleDefault( + name=POLICY_ROOT % 'delete', + check_str=base.RULE_ADMIN, + description='Policy rule for deleting maintenance session API', + operations=[ + { + 'path': '/{api_version}/maintenance/{seission_id}', + 'method': 'DELETE' + } + ] + ) +] + + +def list_rules(): + return session_policies diff --git a/fenix/policy.py b/fenix/policy.py new file mode 100644 index 0000000..c2ea829 --- /dev/null +++ b/fenix/policy.py @@ -0,0 +1,110 @@ +# Copyright (c) 2013 Bull. +# +# 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. + +"""Policy Engine For Fenix.""" + +import functools + +from oslo_config import cfg +from oslo_log import log as logging +from oslo_policy import opts +from oslo_policy import policy + +from fenix import context +from fenix import exceptions +from fenix import policies + +CONF = cfg.CONF +opts.set_defaults(CONF) +LOG = logging.getLogger(__name__) + +_ENFORCER = None + + +def reset(): + global _ENFORCER + + if _ENFORCER: + _ENFORCER.clear() + _ENFORCER = None + + +def init(): + global _ENFORCER + if not _ENFORCER: + _ENFORCER = policy.Enforcer(CONF) + _ENFORCER.register_defaults(policies.list_rules()) + + +def set_rules(data, default_rule=None): + default_rule = default_rule or CONF.policy_default_rule + if not _ENFORCER: + init() + if default_rule: + _ENFORCER.default_rule = default_rule + _ENFORCER.set_rules(policy.Rules.load(data, default_rule)) + + +def enforce(context, action, target, do_raise=True): + """Verifies that the action is valid on the target in this context. + + :param context: fenix context + :param action: string representing the action to be checked + :param target: dictionary representing the object of the action + for object creation this should be a dictionary representing the + location of the object e.g. ``{'project_id': context.project_id}`` + :param do_raise: if True (the default), raises PolicyNotAuthorized; + if False, returns False + :raises fenix.exceptions.PolicyNotAuthorized: if verification fails + and do_raise is True. + :return: returns a non-False value (not necessarily "True") if + authorized, and the exact value False if not authorized and + do_raise is False. + """ + + init() + + credentials = context.to_dict() + LOG.debug("enforce %s" % credentials) + + # Add the exceptions arguments if asked to do a raise + extra = {} + if do_raise: + extra.update(exc=exceptions.PolicyNotAuthorized, action=action) + + return _ENFORCER.enforce(action, target, credentials, do_raise=do_raise, + **extra) + + +def authorize(extension, action=None, api='fenix', ctx=None, + target=None): + def decorator(func): + + @functools.wraps(func) + def wrapped(self, *args, **kwargs): + cur_ctx = ctx or context.current() + tgt = target or {'project_id': cur_ctx.project_id, + 'user_id': cur_ctx.user_id} + LOG.debug("authorize target: %s" % tgt) + if action is None: + act = '%s:%s' % (api, extension) + else: + act = '%s:%s:%s' % (api, extension, action) + LOG.debug("authorize policy: %s" % act) + enforce(cur_ctx, act, tgt) + return func(self, *args, **kwargs) + + return wrapped + return decorator diff --git a/fenix/utils/service.py b/fenix/utils/service.py index 9849946..2352e43 100644 --- a/fenix/utils/service.py +++ b/fenix/utils/service.py @@ -102,7 +102,7 @@ class EngineEndpoint(object): def admin_get(self, ctx): """Get maintenance workflow sessions""" LOG.info("EngineEndpoint: admin_get") - return {'sessions': self.workflow_sessions.keys()} + return {"sessions": self.workflow_sessions.keys()} def admin_create_session(self, ctx, data): """Create maintenance workflow session thread""" @@ -145,14 +145,14 @@ class EngineEndpoint(object): (self.session_id, data["workflow"])) self.workflow_sessions[session_id].start() - return {'session_id': session_id} + return {"session_id": session_id} def admin_get_session(self, ctx, session_id): """Get maintenance workflow session details""" if not self._validate_session(session_id): return None LOG.info("EngineEndpoint: admin_get_session") - return ({'session_id': session_id, 'state': + return ({"session_id": session_id, "state": self.workflow_sessions[session_id].session.state}) def admin_delete_session(self, ctx, session_id): @@ -165,10 +165,11 @@ class EngineEndpoint(object): rmtree(session_dir) return {} - def admin_update_session(self, ctx, session_id): + def admin_update_session(self, ctx, session_id, data): """Update maintenance workflow session""" LOG.info("EngineEndpoint: admin_update_session") - return {'session_id': session_id} + # TBD Update data to workflow and return updated data + return data def project_get_session(self, ctx, session_id, project_id): """Get maintenance workflow session project specific details""" @@ -177,7 +178,7 @@ class EngineEndpoint(object): LOG.info("EngineEndpoint: project_get_session") instance_ids = (self.workflow_sessions[session_id]. state_instance_ids(project_id)) - return {'instance_ids': instance_ids} + return {"instance_ids": instance_ids} def project_update_session(self, ctx, session_id, project_id, data): """Update maintenance workflow session project state""" @@ -185,9 +186,9 @@ class EngineEndpoint(object): session_obj = self.workflow_sessions[session_id] project = session_obj.project(project_id) project.state = data["state"] - if 'instance_actions' in data: + if "instance_actions" in data: session_obj.proj_instance_actions[project_id] = ( - data['instance_actions'].copy()) + data["instance_actions"].copy()) return data diff --git a/fenix/workflow/workflows/default.py b/fenix/workflow/workflows/default.py index 7c3126a..12e3e28 100644 --- a/fenix/workflow/workflows/default.py +++ b/fenix/workflow/workflows/default.py @@ -887,11 +887,13 @@ class Workflow(BaseWorkflow): self.disable_host_nova_compute(compute) for host in self.get_controller_hosts(): LOG.info('IN_MAINTENANCE controller %s' % host) - self._admin_notify(self.conf.workflow_project, host, + self._admin_notify(self.conf.service_user.os_project_name, + host, 'IN_MAINTENANCE', self.session_id) self.host_maintenance(host) - self._admin_notify(self.conf.workflow_project, host, + self._admin_notify(self.conf.service_user.os_project_name, + host, 'MAINTENANCE_COMPLETE', self.session_id) LOG.info('MAINTENANCE_COMPLETE controller %s' % host) @@ -904,11 +906,13 @@ class Workflow(BaseWorkflow): self._wait_host_empty(host) LOG.info('IN_MAINTENANCE compute %s' % host) - self._admin_notify(self.conf.workflow_project, host, + self._admin_notify(self.conf.service_user.os_project_name, + host, 'IN_MAINTENANCE', self.session_id) self.host_maintenance(host) - self._admin_notify(self.conf.workflow_project, host, + self._admin_notify(self.conf.service_user.os_project_name, + host, 'MAINTENANCE_COMPLETE', self.session_id) @@ -923,11 +927,13 @@ class Workflow(BaseWorkflow): self._wait_host_empty(host) LOG.info('IN_MAINTENANCE host %s' % host) - self._admin_notify(self.conf.workflow_project, host, + self._admin_notify(self.conf.service_user.os_project_name, + host, 'IN_MAINTENANCE', self.session_id) self.host_maintenance(host) - self._admin_notify(self.conf.workflow_project, host, + self._admin_notify(self.conf.service_user.os_project_name, + host, 'MAINTENANCE_COMPLETE', self.session_id)