diff --git a/armada/api/armada_controller.py b/armada/api/armada_controller.py new file mode 100644 index 00000000..e37d6ff5 --- /dev/null +++ b/armada/api/armada_controller.py @@ -0,0 +1,47 @@ +# Copyright 2017 The Armada Authors. +# +# 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 armada.handlers.armada import Armada as Handler +from falcon import HTTP_200 + +import json + +class Apply(object): + ''' + apply armada endpoint service + ''' + + def on_post(self, req, resp): + + # Load data from request and get options + data = json.load(req.stream) + opts = data['options'] + + # Encode filename + data['file'] = data['file'].encode('utf-8') + + armada = Handler(open('../../' + data['file']), + disable_update_pre=opts['disable_update_pre'], + disable_update_post=opts['disable_update_post'], + enable_chart_cleanup=opts['enable_chart_cleanup'], + dry_run=opts['dry_run'], + wait=opts['wait'], + timeout=opts['timeout']) + + armada.sync() + + resp.data = json.dumps({'message': 'Success'}) + resp.content_type = 'application/json' + resp.status = HTTP_200 diff --git a/armada/api/middleware.py b/armada/api/middleware.py new file mode 100644 index 00000000..037f77fe --- /dev/null +++ b/armada/api/middleware.py @@ -0,0 +1,107 @@ +# Copyright 2017 The Armada Authors. +# +# 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 armada.conf import default + +# Required Oslo configuration setup +default.register_opts() + +from keystoneauth1 import session +from keystoneauth1.identity import v3 + +from oslo_config import cfg +from oslo_log import log as logging + +import falcon + +LOG = logging.getLogger(__name__) +CONF = cfg.CONF +DOMAIN = "armada" + +logging.setup(CONF, DOMAIN) + +class AuthMiddleware(object): + + def process_request(self, req, resp): + + # Validate token and get user session + token = req.get_header('X-Auth-Token') + req.context['session'] = self._get_user_session(token) + + # Add token roles to request context + req.context['roles'] = self._get_roles(req.context['session']) + + def _get_roles(self, session): + + # Get roles IDs associated with user + request_url = CONF.auth_url + '/role_assignments' + resp = self._session_request(session=session, request_url=request_url) + + json_resp = resp.json()['role_assignments'] + role_ids = [r['role']['id'].encode('utf-8') for r in json_resp] + + # Get role names associated with role IDs + roles = [] + for role_id in role_ids: + request_url = CONF.auth_url + '/roles/' + role_id + resp = self._session_request(session=session, + request_url=request_url) + + role = resp.json()['role']['name'].encode('utf-8') + roles.append(role) + + return roles + + def _get_user_session(self, token): + + # Get user session from token + auth = v3.Token(auth_url=CONF.auth_url, + project_name=CONF.project_name, + project_domain_name=CONF.project_domain_name, + token=token) + + return session.Session(auth=auth) + + def _session_request(self, session, request_url): + try: + return session.get(request_url) + except: + raise falcon.HTTPUnauthorized('Authentication required', + ('Authentication token is invalid.')) + +class RoleMiddleware(object): + + def process_request(self, req, resp): + endpoint = req.path + roles = req.context['roles'] + + # Verify roles have sufficient permissions for request endpoint + if not (self._verify_roles(endpoint, roles)): + raise falcon.HTTPUnauthorized('Insufficient permissions', + ('Token role insufficient.')) + + def _verify_roles(self, endpoint, roles): + + # Compare the verified roles listed in the config with the user's + # associated roles + if endpoint == '/armada/apply': + approved_roles = CONF.armada_apply_roles + elif endpoint == '/tiller/releases': + approved_roles = CONF.tiller_release_roles + elif endpoint == '/tiller/status': + approved_roles = CONF.tiller_status_roles + + verified_roles = set(roles).intersection(approved_roles) + + return bool(verified_roles) diff --git a/armada/api/server.py b/armada/api/server.py new file mode 100644 index 00000000..2c42b51b --- /dev/null +++ b/armada/api/server.py @@ -0,0 +1,57 @@ +# Copyright 2017 The Armada Authors. +# +# 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 armada.conf import default + +# Required Oslo configuration setup +default.register_opts() + +from armada_controller import Apply +from tiller_controller import Release, Status + +from middleware import AuthMiddleware, RoleMiddleware + +import falcon + +from oslo_config import cfg +from oslo_log import log as logging + +LOG = logging.getLogger(__name__) +CONF = cfg.CONF +DOMAIN = "armada" + +logging.setup(CONF, DOMAIN) + +# Build API +def create(middleware=CONF.middleware): + if middleware: + api = falcon.API(middleware=[AuthMiddleware(), RoleMiddleware()]) + else: + api = falcon.API() + + # Configure API routing + url_routes = ( + ('/tiller/status', Status()), + ('/tiller/releases', Release()), + ('/armada/apply/', Apply()) + ) + + for route, service in url_routes: + api.add_route(route, service) + + return api + + +api = create() diff --git a/server.py b/armada/api/tiller_controller.py similarity index 73% rename from server.py rename to armada/api/tiller_controller.py index ace2fd97..638c8f67 100644 --- a/server.py +++ b/armada/api/tiller_controller.py @@ -15,7 +15,6 @@ from armada.conf import default -import falcon import json from falcon import HTTP_200 @@ -26,7 +25,6 @@ from oslo_log import log as logging default.register_opts() from armada.handlers.tiller import Tiller as tillerHandler -from armada.handlers.armada import Armada as armadaHandler LOG = logging.getLogger(__name__) CONF = cfg.CONF @@ -34,11 +32,7 @@ DOMAIN = "armada" logging.setup(CONF, DOMAIN) -class Tiller(object): - ''' - tiller service endpoints - ''' - +class Status(object): def on_get(self, req, resp): ''' get tiller status @@ -54,28 +48,18 @@ class Tiller(object): resp.content_type = 'application/json' resp.status = HTTP_200 -class Armada(object): - ''' - apply armada endpoint service - ''' +class Release(object): + def on_get(self, req, resp): + ''' + get tiller releases + ''' + # Get tiller releases + handler = tillerHandler() - def on_post(self, req, resp): - armada = armadaHandler(req.stream.read()) - armada.sync() + releases = {} + for release in handler.list_releases(): + releases[release.name] = release.namespace - resp.data = json.dumps({'message': 'Success'}) + resp.data = json.dumps({'releases': releases}) resp.content_type = 'application/json' resp.status = HTTP_200 - - -wsgi_app = api = falcon.API() - -# Routing - -url_routes = ( - ('/tiller/status', Tiller()), - ('/apply', Armada()), -) - -for route, service in url_routes: - api.add_route(route, service) diff --git a/armada/conf/default.py b/armada/conf/default.py index 950ae0fb..c660836d 100644 --- a/armada/conf/default.py +++ b/armada/conf/default.py @@ -17,6 +17,17 @@ import os from oslo_config import cfg default_options = [ + + cfg.ListOpt( + 'armada_apply_roles', + default=['admin'], + help='IDs of approved API access roles.'), + + cfg.StrOpt( + 'auth_url', + default='http://0.0.0.0/v3', + help='The default Keystone authentication url.'), + cfg.BoolOpt( 'debug', default='false', @@ -105,6 +116,21 @@ default_options = [ help='Defines the format string for \ %(user_identity)s that is used in logging_context_format_string.'), + cfg.BoolOpt( + 'middleware', + default='true', + help='Enables or disables Keystone authentication middleware.'), + + cfg.StrOpt( + 'project_domain_name', + default='default', + help='The Keystone project domain name used for authentication.'), + + cfg.StrOpt( + 'project_name', + default='admin', + help='The Keystone project name used for authentication.'), + cfg.BoolOpt( 'publish_errors', default='true', @@ -152,6 +178,16 @@ default_options = [ default='true', help='Log output to syslog.'), + cfg.ListOpt( + 'tiller_release_roles', + default=['admin'], + help='IDs of approved API access roles.'), + + cfg.ListOpt( + 'tiller_status_roles', + default=['admin'], + help='IDs of approved API access roles.'), + cfg.BoolOpt( 'watch_log_file', default='false', diff --git a/armada/tests/unit/api/test_api.py b/armada/tests/unit/api/test_api.py new file mode 100644 index 00000000..f7c0b797 --- /dev/null +++ b/armada/tests/unit/api/test_api.py @@ -0,0 +1,76 @@ +# Copyright 2017 The Armada Authors. +# +# 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 armada.api import server +from falcon import testing + +import json +import mock + +class APITestCase(testing.TestCase): + def setUp(self): + super(APITestCase, self).setUp() + + self.app = server.create(middleware=False) + +class TestAPI(APITestCase): + @mock.patch('armada.api.armada_controller.Handler') + def test_armada_apply(self, mock_armada): + ''' + Test /armada/apply endpoint + ''' + mock_armada.sync.return_value = None + + body = json.dumps({'file': '../examples/openstack-helm.yaml', + 'options': {'debug': 'true', + 'disable_update_pre': 'false', + 'disable_update_post': 'false', + 'enable_chart_cleanup': 'false', + 'skip_pre_flight': 'false', + 'dry_run': 'false', + 'wait': 'false', + 'timeout': '100'}}) + + doc = {u'message': u'Success'} + + result = self.simulate_post(path='/armada/apply', body=body) + self.assertEqual(result.json, doc) + + @mock.patch('armada.api.tiller_controller.tillerHandler') + def test_tiller_status(self, mock_tiller): + ''' + Test /tiller/status endpoint + ''' + + # Mock tiller status value + mock_tiller.tiller_status.return_value = 'Active' + + doc = {u'message': u'Tiller Server is Active'} + + result = self.simulate_get('/tiller/status') + self.assertEqual(result.json, doc) + + @mock.patch('armada.api.tiller_controller.tillerHandler') + def test_tiller_releases(self, mock_tiller): + ''' + Test /tiller/releases endpoint + ''' + + # Mock tiller status value + mock_tiller.list_releases.return_value = None + + doc = {u'releases': {}} + + result = self.simulate_get('/tiller/releases') + self.assertEqual(result.json, doc) diff --git a/docs/source/operations/guide-api.rst b/docs/source/operations/guide-api.rst new file mode 100644 index 00000000..f7ba4ebf --- /dev/null +++ b/docs/source/operations/guide-api.rst @@ -0,0 +1,76 @@ +Armada RESTful API +=================== + +Armada Endpoints +----------------- + +.. http:post:: /armada/apply + + :string file The yaml file to apply + :>json boolean debug Enable debug logging + :>json boolean disable_update_pre + :>json boolean disable_update_post + :>json boolean enable_chart_cleanup + :>json boolean skip_pre_flight + :>json boolean dry_run + :>json boolean wait + :>json float timeout + + Request: + + .. sourcecode:: js + + { + "file": "examples/openstack-helm.yaml", + "options": { + "debug": true, + "disable_update_pre": false, + "disable_update_post": false, + "enable_chart_cleanup": false, + "skip_pre_flight": false, + "dry_run": false, + "wait": false, + "timeout": false + } + } + + Results: + + .. sourcecode:: js + + { + "message": "success" + } + +Tiller Endpoints +----------------- + +.. http:get:: /tiller/releases + + Retrieves tiller releases. + + Results: + + .. sourcecode:: js + + { + "releases": { + "armada-memcached": "openstack", + "armada-etcd": "openstack", + "armada-keystone": "openstack", + "armada-rabbitmq": "openstack", + "armada-horizon": "openstack" + } + } + +.. http:get:: /tiller/status + + Retrieves the status of the Tiller server. + + Results: + + .. sourcecode:: js + + { + "message": Tiller Server is Active + } diff --git a/requirements.txt b/requirements.txt index ff58be99..acd4cc20 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ grpc==0.3.post19 grpcio==1.1.3 grpcio-tools==1.1.3 +keystoneauth1==2.21.0 kubernetes>=1.0.0 oslo.log==3.28.0 oslo.messaging==5.28.0