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/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..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)