From cc55962d522df6667eb531bbb0a186ec84ba2d12 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 ++ doc/source/configuration/configuration.rst | 61 ++++++--- fenix/api/__init__.py | 52 ------- fenix/api/context.py | 40 ++++++ 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/maintenance.py | 127 +++++++++++++++++ fenix/api/v1/hooks.py | 68 +++++++++ fenix/api/v1/maintenance.py | 103 +------------- fenix/api/v1/middleware/__init__.py | 137 +++++++++++++++++++ fenix/cmd/api.py | 12 +- 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 ++- 24 files changed, 982 insertions(+), 283 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/maintenance.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/doc/source/configuration/configuration.rst b/doc/source/configuration/configuration.rst index e0bda77..8d22aab 100644 --- a/doc/source/configuration/configuration.rst +++ b/doc/source/configuration/configuration.rst @@ -11,59 +11,78 @@ to the current system. [DEFAULT] - #Mandatory configuration options + # Mandatory configuration options - #Host where API is running. default="127.0.0.1" + # Host where API is running. default="127.0.0.1" host = - #API Port. default=5000 + # API Port. default=5000 port = - #An URL representing the messaging driver to use and its full configuration. + # An URL representing the messaging driver to use and its full configuration. transport_url = + [keystone_authtoken] + # OpenStack Identity service URL. + auth_url = http://127.0.0.1/identity + # Authentication type + auth_type = password + # PEM encoded Certificate Authority to use when verifying HTTPs connections. + cafile = /opt/stack/data/ca-bundle.pem + # The Fenix admin project domain. + project_domain_name = Default + # The Fenix admin project. + project_name = admin + # A domain name the os_username belongs to. + user_domain_name = Default + # Fenix admin user password. + password = admin + # Fenix user. Must have admin role. + username = admin + + * Edit the ``/etc/fenix/fenix.conf`` file the configure fenix-engine .. code-block:: ini [DEFAULT] - #Mandatory configuration options + # Mandatory configuration options - #Host where engine is running. default="127.0.0.1" + # Host where engine is running. default="127.0.0.1" host = - #API Port. default=5000 + # API Port. default=5000 port = - #An URL representing the messaging driver to use and its full configuration. + # An URL representing the messaging driver to use and its full configuration. transport_url = - #Optional configuration options + # Optional configuration options - #Wait for project reply after message sent to project. default 120 + # Wait for project reply after message sent to project. default 120 wait_project_reply = 120 - #Project maintenance reply confirmation time in seconds. default 40 + # Project maintenance reply confirmation time in seconds. default 40 project_maintenance_reply = 40 - #Project scale in reply confirmation time in seconds. default 60 + # Project scale in reply confirmation time in seconds. default 60 project_scale_in_reply = 60 - #Number of live migration retries. default 5 + # Number of live migration retries. default 5 live_migration_retries = 5 - #How long to wait live migration to be done. default 600 + # How long to wait live migration to be done. default 600 live_migration_wait_time = 600 [database] - #database connection URL + # database connection URL connection = mysql+pymysql://fenix:FENIX_DBPASS@controller/fenix [service_user] - #OpenStack Identity service URL. Default to environmental variable OS_AUTH_URL + # OpenStack Identity service URL. Default to environmental variable OS_AUTH_URL os_auth_url = http://127.0.0.1/identity - #Fenix user. Must have admin role. Default to environmental variable OS_USERNAME + # Fenix user. Must have admin role. Default to environmental variable OS_USERNAME os_username = admin - #Fenix admin user password. Default to environmental variable OS_PASSWORD + # Fenix admin user password. Default to environmental variable OS_PASSWORD os_password = admin - #A domain name the os_username belongs to. Default to environmental variable OS_USER_DOMAIN_NAME + # A domain name the os_username belongs to. Default to environmental variable OS_USER_DOMAIN_NAME os_user_domain_name = default - #The Fenix admin project. Default to environmental variable OS_PROJECT_NAME + # The Fenix admin project. Default to environmental variable OS_PROJECT_NAME os_project_name = admin - #The Fenix admin project domain. Default to environmental variable OS_PROJECT_DOMAIN_NAME + # The Fenix admin project domain. Default to environmental variable OS_PROJECT_DOMAIN_NAME os_project_domain_name = default 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..4dd0690 --- /dev/null +++ b/fenix/api/context.py @@ -0,0 +1,40 @@ +# 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() + + return context.FenixContext( + user_id=headers['X-User-Id'], + project_id=headers['X-Project-Id'], + auth_token=headers['X-Auth-Token'], + service_catalog=service_catalog, + user_name=headers['X-User-Name'], + project_name=headers['X-Project-Name'], + roles=list(map(six.text_type.strip, headers['X-Roles'].split(','))), + ) 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/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/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..abfb6d1 100644 --- a/fenix/cmd/api.py +++ b/fenix/cmd/api.py @@ -13,22 +13,24 @@ # License for the specific language governing permissions and limitations # under the License. +import gettext from wsgiref import simple_server 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) + logging.setup(cfg.CONF, 'fenix') + app = v1_app.make_app() + srv = simple_server.make_server(cfg.CONF.host, cfg.CONF.port, app) LOG.info("fenix-api started") srv.serve_forever() 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)