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 <tomi.juvonen@nokia.com>
This commit is contained in:
Tomi Juvonen 2019-06-25 12:16:08 +03:00
parent a890fbeeeb
commit 2ce52104c1
24 changed files with 1013 additions and 286 deletions

View File

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

View File

@ -11,14 +11,33 @@ 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 = <hostname>
#API Port. default=5000
# API Port. default=5000
port = <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 = <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
@ -26,44 +45,44 @@ to the current system.
[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 = <hostname>
#API Port. default=5000
# API Port. default=5000
port = <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 = <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

View File

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

67
fenix/api/context.py Normal file
View File

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

44
fenix/api/root.py Normal file
View File

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

View File

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

81
fenix/api/v1/app.py Normal file
View File

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

View File

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

View File

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

View File

@ -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/<session_id>/<project_id>
@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/<session_id>/<project_id>
@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/<session_id>
@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/<session_id>
@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/<session_id>
@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)

68
fenix/api/v1/hooks.py Normal file
View File

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

View File

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

View File

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

View File

@ -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__":

View File

@ -100,3 +100,7 @@ class FenixContext(BaseContext):
def current():
return FenixContext.current()
def elevated():
return FenixContext.elevated()

View File

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

View File

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

View File

@ -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/<session_id>'],
endpoint='maintenance/<session_id>'),
dict(resource=maintenance.MaintenanceSessionProject,
urls=['/maintenance/<session_id>/<project_id>'],
endpoint='maintenance/<session_id>/<project_id>'),
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

View File

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

50
fenix/policies/project.py Normal file
View File

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

57
fenix/policies/session.py Normal file
View File

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

110
fenix/policy.py Normal file
View File

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

View File

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

View File

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