diff --git a/README.md b/README.md index 3d85bb1..05582f2 100644 --- a/README.md +++ b/README.md @@ -61,10 +61,6 @@ Install dependencies: $ pip install -r requirements.txt -r test-requirements.txt -Install `functions_python` lib: - - $ pip install -e git+ssh://git@github.com/iron-io/functions_python.git#egg=functions-python - Install LaOS itself: $ pip install -e . @@ -191,8 +187,24 @@ Cons: Testing: Integration -------------------- -TBD +Integration tests are dependent on following env variables: +* TEST_DB_URI - similar to functional tests, database endpoint +* FUNCTIONS_API_URL - IronFunctions API URL (default value - `http://localhost:8080/v1`) +* OS_AUTH_URL - OpenStack Identity endpoint +* OS_PROJECT_NAME - OpenStack user-specific project name +* OS_USERNAME - OpenStack user name +* OS_PASSWORD - OpenStack user user password + +To run tests use following command: + + export TEST_DB_URI=mysql://:@:/ + export FUNCTIONS_API_URL=://:/ + export OS_AUTH_URL=://:/ + export OS_PROJECT_NAME= + export OS_USERNAME= + export OS_PASSWORD= + tox -epy35-integration Testing: Coverage regression ---------------------------- @@ -213,10 +225,6 @@ IronFunctions: * https://github.com/iron-io/functions/issues/275 * https://github.com/iron-io/functions/issues/274 -aiohttp_swagger: - -* https://github.com/cr0hn/aiohttp-swagger/issues/12 ([fix proposed](https://github.com/cr0hn/aiohttp-swagger/pull/13)) - TODOs ----- diff --git a/examples/hello-lambda.sh b/examples/hello-lambda.sh index f21831e..88ee696 100755 --- a/examples/hello-lambda.sh +++ b/examples/hello-lambda.sh @@ -70,16 +70,16 @@ echo -e "Show app public route\n" curl ${LAOS_API_URL}/v1/${OS_PROJECT_ID}/apps/${app_name}/routes/hello-sync-public -H "X-Auth-Token:${OS_TOKEN}" -H "Content-Type: application/json" | python3 -mjson.tool echo -e "Running app sync private route\n" -curl -X POST -d '{"name": "Johnny"}' ${LAOS_API_URL}/private/${OS_PROJECT_ID}/${app_name}/hello-sync-private -H "X-Auth-Token:${OS_TOKEN}" -H "Content-Type: application/json" | python3 -mjson.tool +curl -X POST -d '{"name": "Johnny"}' ${LAOS_API_URL}/v1/r/${OS_PROJECT_ID}/${app_name}/hello-sync-private -H "X-Auth-Token:${OS_TOKEN}" -H "Content-Type: application/json" | python3 -mjson.tool echo -e "Running app sync public route\n" -curl -X POST -d '{"name": "Johnny"}' ${LAOS_API_URL}/public/${app_name}/hello-sync-public -H "Content-Type: application/json" | python3 -mjson.tool +curl -X POST -d '{"name": "Johnny"}' ${LAOS_API_URL}/r/${app_name}/hello-sync-public -H "Content-Type: application/json" | python3 -mjson.tool echo -e "Creating app async route\n" curl -X POST -d '{"route":{"type": "async", "path": "/hello-async-private", "image": "iron/hello", "is_public": "false"}}' ${LAOS_API_URL}/v1/${OS_PROJECT_ID}/apps/${app_name}/routes -H "X-Auth-Token:${OS_TOKEN}" -H "Content-Type: application/json" | python3 -mjson.tool echo -e "Running app async route\n" -curl -X POST -d '{"name": "Johnny"}' ${LAOS_API_URL}/private/${OS_PROJECT_ID}/${app_name}/hello-async-private -H "X-Auth-Token:${OS_TOKEN}" -H "Content-Type: application/json" | python3 -mjson.tool +curl -X POST -d '{"name": "Johnny"}' ${LAOS_API_URL}/v1/r/${OS_PROJECT_ID}/${app_name}/hello-async-private -H "X-Auth-Token:${OS_TOKEN}" -H "Content-Type: application/json" | python3 -mjson.tool echo -e "Deleting app route\n" curl -X DELETE ${LAOS_API_URL}/v1/${OS_PROJECT_ID}/apps/${app_name}/routes/hello-sync-public -H "X-Auth-Token:${OS_TOKEN}" -H "Content-Type: application/json" | python3 -mjson.tool diff --git a/laos/api/controllers/apps.py b/laos/api/controllers/apps.py index 81495aa..bde58cc 100644 --- a/laos/api/controllers/apps.py +++ b/laos/api/controllers/apps.py @@ -14,20 +14,20 @@ from aiohttp import web +from aioservice.http import controller +from aioservice.http import requests + from laos.api.views import app as app_view - -from laos.common.base import controllers from laos.common import config - from laos.models import app as app_model -class AppV1Controller(controllers.ServiceControllerBase): +class AppV1Controller(controller.ServiceController): controller_name = "apps" version = "v1" - @controllers.api_action(method='GET', route='{project_id}/apps') + @requests.api_action(method='GET', route='{project_id}/apps') async def list(self, request, **kwargs): """ --- @@ -59,7 +59,7 @@ class AppV1Controller(controllers.ServiceControllerBase): status=200 ) - @controllers.api_action(method='POST', route='{project_id}/apps') + @requests.api_action(method='POST', route='{project_id}/apps') async def create(self, request, **kwargs): """ --- @@ -118,7 +118,7 @@ class AppV1Controller(controllers.ServiceControllerBase): }, status=200 ) - @controllers.api_action(method='GET', route='{project_id}/apps/{app}') + @requests.api_action(method='GET', route='{project_id}/apps/{app}') async def get(self, request, **kwargs): """ --- @@ -161,7 +161,7 @@ class AppV1Controller(controllers.ServiceControllerBase): ) # TODO(denismakogon): disabled until iron-io/functions/pull/259 # - # @controllers.api_action(method='PUT', route='{project_id}/apps/{app}') + # @requests.api_action(method='PUT', route='{project_id}/apps/{app}') # async def update(self, request, **kwargs): # log = config.Config.config_instance().logger # project_id = request.match_info.get('project_id') @@ -176,7 +176,7 @@ class AppV1Controller(controllers.ServiceControllerBase): # status=200 # ) - @controllers.api_action(method='DELETE', route='{project_id}/apps/{app}') + @requests.api_action(method='DELETE', route='{project_id}/apps/{app}') async def delete(self, request, **kwargs): """ --- @@ -210,7 +210,8 @@ class AppV1Controller(controllers.ServiceControllerBase): if fn_app_routes: return web.json_response(data={ "error": { - "message": ("Unable to delete app {} with routes".format(app)) + "message": ("Unable to delete app {} " + "with routes".format(app)) } }, status=403) diff --git a/laos/api/controllers/routes.py b/laos/api/controllers/routes.py index e3ffa73..886f55a 100644 --- a/laos/api/controllers/routes.py +++ b/laos/api/controllers/routes.py @@ -16,20 +16,20 @@ import json from aiohttp import web +from aioservice.http import controller +from aioservice.http import requests + from laos.api.views import app as app_view - -from laos.common.base import controllers from laos.common import config - from laos.models import app as app_model -class AppRouteV1Controller(controllers.ServiceControllerBase): +class AppRouteV1Controller(controller.ServiceController): controller_name = "routes" version = "v1" - @controllers.api_action( + @requests.api_action( method='GET', route='{project_id}/apps/{app}/routes') async def list(self, request, **kwargs): """ @@ -82,7 +82,7 @@ class AppRouteV1Controller(controllers.ServiceControllerBase): "message": "Successfully loaded app routes", }, status=200) - @controllers.api_action( + @requests.api_action( method='POST', route='{project_id}/apps/{app}/routes') async def create(self, request, **kwargs): """ @@ -185,7 +185,7 @@ class AppRouteV1Controller(controllers.ServiceControllerBase): "message": "App route successfully created" }, status=200) - @controllers.api_action( + @requests.api_action( method='GET', route='{project_id}/apps/{app}/routes/{route}') async def get(self, request, **kwargs): """ @@ -248,7 +248,7 @@ class AppRouteV1Controller(controllers.ServiceControllerBase): "message": "App route successfully loaded" }, status=200) - @controllers.api_action( + @requests.api_action( method='DELETE', route='{project_id}/apps/{app}/routes/{route}') async def delete(self, request, **kwargs): """ diff --git a/laos/api/controllers/runnable.py b/laos/api/controllers/runnable.py index a442441..7a83a20 100644 --- a/laos/api/controllers/runnable.py +++ b/laos/api/controllers/runnable.py @@ -14,7 +14,9 @@ from aiohttp import web -from laos.common.base import controllers +from aioservice.http import controller +from aioservice.http import requests + from laos.common import config @@ -64,14 +66,14 @@ class RunnableMixin(object): return web.json_response(status=200, data=process_result(result)) -class PublicRunnableV1Controller(controllers.ServiceControllerBase, +class PublicRunnableV1Controller(controller.ServiceController, RunnableMixin): controller_name = "public_runnable" # IronFunction uses `r` as runnable instead API version version = "r" - @controllers.api_action( + @requests.api_action( method='POST', route='{app}/{route}') async def run(self, request, **kwargs): """ @@ -93,15 +95,15 @@ class PublicRunnableV1Controller(controllers.ServiceControllerBase, self).run(request, **kwargs) -class RunnableV1Controller(controllers.ServiceControllerBase, +class RunnableV1Controller(controller.ServiceController, RunnableMixin): controller_name = "runnable" # IronFunction uses `r` as runnable instead API version - version = "r" + version = "v1" - @controllers.api_action( - method='POST', route='{project_id}/{app}/{route}') + @requests.api_action( + method='POST', route='r/{project_id}/{app}/{route}') async def run(self, request, **kwargs): """ --- diff --git a/laos/api/controllers/tasks.py b/laos/api/controllers/tasks.py index 27dce9b..dc6adda 100644 --- a/laos/api/controllers/tasks.py +++ b/laos/api/controllers/tasks.py @@ -16,13 +16,15 @@ from aiohttp import web # from laos.models import app as app_model -from laos.common.base import controllers +from aioservice.http import controller +from aioservice.http import requests + # from laos.common import config # TODO(denismakogon): disabled until # https://github.com/iron-io/functions/issues/275 -class TasksV1Controller(controllers.ServiceControllerBase): +class TasksV1Controller(controller.ServiceController): controller_name = "tasks" version = "v1" @@ -33,7 +35,7 @@ class TasksV1Controller(controllers.ServiceControllerBase): # - on each request check if route is public our private # * reject with 401 if route is private # * accept with 200 if route is public - @controllers.api_action( + @requests.api_action( method='GET', route='{project_id}/tasks') async def list(self, request, **kwargs): """ @@ -63,7 +65,7 @@ class TasksV1Controller(controllers.ServiceControllerBase): } }, status=405) - @controllers.api_action( + @requests.api_action( method='GET', route='{project_id}/tasks/{task}') async def show(self, request, **kwargs): """ diff --git a/laos/api/views/app.py b/laos/api/views/app.py index 3641aea..8a61e98 100644 --- a/laos/api/views/app.py +++ b/laos/api/views/app.py @@ -45,11 +45,11 @@ class AppRouteView(object): view = [] for route in self.routes: if not route.is_public: - path = ("{}/private/{}/{}{}".format( + path = ("{}/v1/r/{}/{}{}".format( self.api_url, self.project_id, route.appname, route.path)) else: - path = ("{}/public/{}{}".format( + path = ("{}/r/{}{}".format( self.api_url, route.appname, route.path)) view.append({ "path": path, diff --git a/laos/common/base/controllers.py b/laos/common/base/controllers.py deleted file mode 100644 index 4ec4ed6..0000000 --- a/laos/common/base/controllers.py +++ /dev/null @@ -1,77 +0,0 @@ -# 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 functools -import inspect - -from aiohttp import web - - -class ServiceControllerBase(object): - - controller_name = 'abstract' - version = "" - - def __get_handlers(self): - # when this method gets executed by child classes - # method list includes a method of parent class, - # so this code ignores it because it doesn't belong to controllers - methods = [getattr(self, _m) - for _m in dir(self) if inspect.ismethod( - getattr(self, _m)) and "__" not in _m] - - return [[method, - method.arg_method, - method.arg_route] for method in methods] - - def __init__(self, sub_service: web.Application): - for fn, http_method, route in self.__get_handlers(): - proxy_fn = '_'.join([fn.__name__, self.controller_name]) - setattr(self, proxy_fn, fn) - sub_service.router.add_route( - http_method, "/{}".format(route), - getattr(self, proxy_fn), name=proxy_fn) - - -def api_action(**outter_kwargs): - """ - Wrapps API controller action actions handler - :param outter_kwargs: API instance action key-value args - :return: _api_handler - - Example: - - class Controller(ControllerBase): - - @api_action(method='GET') - async def index(self, request, **kwargs): - return web.Response( - text=str(request), - reason="dumb API", - status=201) - - """ - - def _api_handler(func): - - @functools.wraps(func) - async def wrapper(self, *args, **kwargs): - return await func(self, *args, *kwargs) - - for key, value in outter_kwargs.items(): - setattr(wrapper, 'arg_{}'.format(key), value) - setattr(wrapper, 'is_module_function', True) - return wrapper - - return _api_handler diff --git a/laos/common/base/service.py b/laos/common/base/service.py deleted file mode 100644 index 0fa96ce..0000000 --- a/laos/common/base/service.py +++ /dev/null @@ -1,86 +0,0 @@ -# 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 asyncio - -import aiohttp_swagger - -from aiohttp import web - -from laos.common import logger as log - - -class AbstractWebServer(object): - - def __init__(self, host: str='127.0.0.1', - port: int= '10001', - private_controllers: dict=None, - private_middlewares: list=None, - public_middlewares: list=None, - public_controllers: dict=None, - event_loop: asyncio.AbstractEventLoop=None, - logger=log.UnifiedLogger( - log_to_console=True, - level="INFO").setup_logger(__name__), - debug=False): - """ - HTTP server abstraction class - :param host: Bind host - :param port: Bind port - :param private_controllers: private API controllers mapping - :param private_middlewares: list of private API middleware - :param public_middlewares: - list of public API middleware - :param public_controllers: - public API controllers mapping - :param event_loop: asyncio eventloop - :param logger: logging.Logger - """ - self.host = host - self.port = port - self.event_loop = event_loop - self.logger = logger - - self.root_service = web.Application( - logger=self.logger, - loop=self.event_loop, - debug=debug - ) - - self.register_subapps(private_controllers, private_middlewares) - self.register_subapps(public_controllers, public_middlewares) - - def _apply_routers(self, service, controllers): - for controller in controllers: - controller(service) - return service - - def register_subapps(self, controllers_mapping: dict, middlewares: list): - if controllers_mapping: - for sub_route, controllers in controllers_mapping.items(): - service = self._apply_routers( - web.Application( - logger=self.logger, - loop=self.event_loop, - middlewares=middlewares - if middlewares else []), - controllers) - self.root_service.router.add_subapp( - "/{}/".format(sub_route), service) - - def initialize(self): - aiohttp_swagger.setup_swagger( - self.root_service, swagger_url="/api") - web.run_app(self.root_service, host=self.host, port=self.port, - shutdown_timeout=10, access_log=self.logger) diff --git a/laos/common/logger.py b/laos/common/logger.py index ec997b4..1a399cb 100644 --- a/laos/common/logger.py +++ b/laos/common/logger.py @@ -21,7 +21,7 @@ from laos.common import utils def common_logger_setup( level=logging.DEBUG, - filename='/tmp/aiorchestra.log', + filename='/tmp/laos-api.log', log_formatter='[%(asctime)s] - ' '%(name)s - ' '%(levelname)s - ' diff --git a/laos/service/laos_api.py b/laos/service/laos_api.py index effa89b..c17ddd5 100644 --- a/laos/service/laos_api.py +++ b/laos/service/laos_api.py @@ -17,6 +17,8 @@ import asyncio import click import uvloop +from aioservice.http import service + from laos.api.controllers import apps from laos.api.controllers import routes from laos.api.controllers import runnable @@ -25,46 +27,46 @@ from laos.api.controllers import tasks from laos.api.middleware import content_type from laos.api.middleware import keystone -from laos.common.base import service from laos.common import config from laos.common import logger as log -class API(service.AbstractWebServer): +class API(service.HTTPService): def __init__(self, host: str='0.0.0.0', port: int=10001, loop: asyncio.AbstractEventLoop=asyncio.get_event_loop(), logger=None, debug=False): + + v1_service = service.VersionedService( + [ + apps.AppV1Controller, + routes.AppRouteV1Controller, + runnable.RunnableV1Controller, + tasks.TasksV1Controller + ], middleware=[ + keystone.auth_through_token, + content_type.content_type_validator + ]) + + public_runnable_service = service.VersionedService( + [ + runnable.PublicRunnableV1Controller + ], middleware=[ + content_type.content_type_validator, + ] + ) + super(API, self).__init__( host=host, port=port, - private_controllers={ - "v1": [ - apps.AppV1Controller, - routes.AppRouteV1Controller, - tasks.TasksV1Controller, - ], - "private": [ - runnable.RunnableV1Controller, - ] - }, - public_controllers={ - "public": [ - runnable.PublicRunnableV1Controller, - ], - }, - private_middlewares=[ - keystone.auth_through_token, - content_type.content_type_validator, - ], - public_middlewares=[ - content_type.content_type_validator, - ], event_loop=loop, logger=logger, debug=debug, + subservice_definitions=[ + v1_service, public_runnable_service + ] ) @@ -126,8 +128,15 @@ def server(host, port, db_uri, event_loop=loop, ) - API(host=host, port=port, loop=loop, - logger=logger, debug=debug).initialize() + API( + host=host, port=port, loop=loop, + logger=logger, debug=debug + ).apply_swagger( + swagger_url="/api", + description="Laos API service docs", + api_version="v1.0.0", + title="Laos API", + ).initialize() if __name__ == "__main__": diff --git a/laos/common/base/__init__.py b/laos/tests/common/__init__.py similarity index 100% rename from laos/common/base/__init__.py rename to laos/tests/common/__init__.py diff --git a/laos/tests/common/apps.py b/laos/tests/common/apps.py new file mode 100644 index 0000000..3f5f048 --- /dev/null +++ b/laos/tests/common/apps.py @@ -0,0 +1,84 @@ +# 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. + + +class AppsTestSuite(object): + + def list_apps(self): + json, http_status = self.testloop.run_until_complete( + self.test_client.apps.list()) + self.assertIn("message", json) + self.assertIn("apps", json) + self.assertEqual(200, http_status) + + def get_unknown(self): + json, http_status = self.testloop.run_until_complete( + self.test_client.apps.show("unknown")) + self.assertEqual(404, http_status) + self.assertIn("error", json) + + def create_and_delete(self): + create_json, create_status = self.testloop.run_until_complete( + self.test_client.apps.create("testapp")) + delete_json, delete_status = self.testloop.run_until_complete( + self.test_client.apps.delete(create_json["app"]["name"])) + + self.assertIn("message", create_json) + self.assertIn("app", create_json) + self.assertEqual(200, create_status) + + self.assertIn("message", delete_json) + self.assertEqual(200, delete_status) + + def attempt_to_double_create(self): + app = "testapp" + create_json, _ = self.testloop.run_until_complete( + self.test_client.apps.create(app)) + err, status = self.testloop.run_until_complete( + self.test_client.apps.create(app)) + self.testloop.run_until_complete( + self.test_client.apps.delete(create_json["app"]["name"])) + self.assertEqual(409, status) + self.assertIn("error", err) + self.assertIn("message", err["error"]) + + def attempt_delete_unknonw(self): + json, http_status = self.testloop.run_until_complete( + self.test_client.apps.delete("unknown")) + self.assertEqual(404, http_status) + self.assertIn("error", json) + + def delete_with_routes(self): + app_name = "testapp" + app, _ = self.testloop.run_until_complete( + self.test_client.apps.create(app_name)) + self.testloop.run_until_complete( + self.test_client.routes.create( + app["app"]["name"], **self.route_data) + ) + attempt, status = self.testloop.run_until_complete( + self.test_client.apps.delete(app["app"]["name"]) + ) + self.testloop.run_until_complete( + self.test_client.routes.delete( + app["app"]["name"], self.route_data["path"]) + ) + _, status_2 = self.testloop.run_until_complete( + self.test_client.apps.delete(app["app"]["name"]) + ) + self.assertEqual(403, status) + self.assertIn("error", attempt) + self.assertIn("message", attempt["error"]) + self.assertIn("with routes", attempt["error"]["message"]) + self.assertEqual(200, status_2) diff --git a/laos/tests/common/base.py b/laos/tests/common/base.py new file mode 100644 index 0000000..924adab --- /dev/null +++ b/laos/tests/common/base.py @@ -0,0 +1,42 @@ +# 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 asyncio +import datetime +import uvloop + +from laos.common import logger as log + + +class LaosTestsBase(object): + + def get_loop_and_logger(self, test_type): + self.route_data = { + "type": "sync", + "path": "/hello-sync-private", + "image": "iron/hello", + "is_public": "false" + } + try: + testloop = asyncio.get_event_loop() + except Exception: + asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) + testloop = asyncio.get_event_loop() + + logger = log.UnifiedLogger( + log_to_console=False, + filename=("/tmp/laos-{}-tests-run-{}.log" + .format(test_type, datetime.datetime.now())), + level="DEBUG").setup_logger(__package__) + return testloop, logger diff --git a/laos/tests/functional/client.py b/laos/tests/common/client.py similarity index 95% rename from laos/tests/functional/client.py rename to laos/tests/common/client.py index 5639dfa..a9009b4 100644 --- a/laos/tests/functional/client.py +++ b/laos/tests/common/client.py @@ -74,12 +74,15 @@ class RoutesV1(object): class ProjectBoundLaosTestClient(test_utils.TestClient): - def __init__(self, app_or_server, project_id): + def __init__(self, app_or_server, project_id, **kwargs): super(ProjectBoundLaosTestClient, self).__init__(app_or_server) self.project_id = project_id self.headers = { "Content-Type": "application/json" } + if kwargs.get("headers"): + self.headers.update(kwargs.get("headers")) + self.apps = AppsV1(self) self.routes = RoutesV1(self) diff --git a/laos/tests/common/routes.py b/laos/tests/common/routes.py new file mode 100644 index 0000000..34197da --- /dev/null +++ b/laos/tests/common/routes.py @@ -0,0 +1,118 @@ +# 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 as jsonlib + + +class AppRoutesTestSuite(object): + + def list_routes_from_unknown_app(self): + json, status = self.testloop.run_until_complete( + self.test_client.routes.list("uknown_app") + ) + self.assertEqual(status, 404) + self.assertIn("error", json) + self.assertIn("not found", json["error"]["message"]) + + def list_routes_from_existing_app(self): + create_json, _ = self.testloop.run_until_complete( + self.test_client.apps.create("testapp")) + json, status = self.testloop.run_until_complete( + self.test_client.routes.list(create_json["app"]["name"]) + ) + self.testloop.run_until_complete( + self.test_client.apps.delete(create_json["app"]["name"])) + self.assertEqual(status, 200) + self.assertIn("routes", json) + self.assertIn("message", json) + + def show_unknown_route_from_existing_app(self): + path = "/unknown_path" + create_json, _ = self.testloop.run_until_complete( + self.test_client.apps.create("testapp")) + json, status = self.testloop.run_until_complete( + self.test_client.routes.show( + create_json["app"]["name"], path) + ) + self.testloop.run_until_complete( + self.test_client.apps.delete(create_json["app"]["name"])) + + self.assertEqual(404, status) + self.assertIn("error", json) + self.assertIn("not found", json["error"]["message"]) + + def delete_unknown_route_from_existing_app(self): + create_json, _ = self.testloop.run_until_complete( + self.test_client.apps.create("testapp")) + json, status = self.testloop.run_until_complete( + self.test_client.routes.delete( + create_json["app"]["name"], "/unknown_path") + ) + self.testloop.run_until_complete( + self.test_client.apps.delete(create_json["app"]["name"])) + + self.assertEqual(404, status) + self.assertIn("error", json) + self.assertIn("not found", json["error"]["message"]) + + def create_and_delete_route(self): + app, _ = self.testloop.run_until_complete( + self.test_client.apps.create("testapp")) + route, create_status = self.testloop.run_until_complete( + self.test_client.routes.create( + app["app"]["name"], **self.route_data) + ) + route_deleted, delete_status = self.testloop.run_until_complete( + self.test_client.routes.delete( + app["app"]["name"], self.route_data["path"]) + ) + self.testloop.run_until_complete( + self.test_client.apps.delete(app["app"]["name"])) + after_post = route["route"] + for k in self.route_data: + if k == "path": + self.assertIn(self.route_data["path"], after_post[k]) + continue + if k == "is_public": + is_public = jsonlib.loads(self.route_data[k]) + self.assertEqual(is_public, after_post[k]) + continue + self.assertEqual(self.route_data[k], after_post[k]) + self.assertEqual(200, delete_status) + self.assertEqual(200, create_status) + self.assertIn("message", route_deleted) + + def double_create_route(self): + app, _ = self.testloop.run_until_complete( + self.test_client.apps.create("testapp")) + self.testloop.run_until_complete( + self.test_client.routes.create( + app["app"]["name"], **self.route_data) + ) + + json, double_create_status = self.testloop.run_until_complete( + self.test_client.routes.create( + app["app"]["name"], **self.route_data) + ) + self.testloop.run_until_complete( + self.test_client.routes.delete( + app["app"]["name"], self.route_data["path"]) + ) + self.testloop.run_until_complete( + self.test_client.apps.delete(app["app"]["name"])) + + self.assertEqual(409, double_create_status) + self.assertIn("error", json) + self.assertIn("message", json["error"]) + self.assertIn("already exist", json["error"]["message"]) diff --git a/laos/tests/functional/base.py b/laos/tests/functional/base.py index 58e71c2..9bb28aa 100644 --- a/laos/tests/functional/base.py +++ b/laos/tests/functional/base.py @@ -12,13 +12,12 @@ # License for the specific language governing permissions and limitations # under the License. -import asyncio import collections -import datetime import os import testtools import uuid -import uvloop + +from aioservice.http import service from laos.api.controllers import apps from laos.api.controllers import routes @@ -26,58 +25,42 @@ from laos.api.controllers import runnable from laos.api.controllers import tasks from laos.api.middleware import content_type -from laos.common.base import service from laos.common import config -from laos.common import logger as log - +from laos.tests.common import base +from laos.tests.common import client from laos.tests.fakes import functions_api -from laos.tests.functional import client -class LaosFunctionalTestsBase(testtools.TestCase): +class LaosFunctionalTestsBase(base.LaosTestsBase, testtools.TestCase): def setUp(self): - try: - self.testloop = asyncio.get_event_loop() - except Exception: - asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) - self.testloop = asyncio.get_event_loop() + self.testloop, logger = self.get_loop_and_logger("functional") - logger = log.UnifiedLogger( - log_to_console=False, - filename=("/tmp/laos-integration-tests-run-{}.log" - .format(datetime.datetime.now())), - level="DEBUG").setup_logger(__package__) - - self.testapp = service.AbstractWebServer( - host="localhost", + v1_service = service.VersionedService( + [ + apps.AppV1Controller, + routes.AppRouteV1Controller, + tasks.TasksV1Controller, + runnable.RunnableV1Controller, + ], middleware=[ + content_type.content_type_validator + ] + ) + public_runnable = service.VersionedService( + [ + runnable.PublicRunnableV1Controller, + ], middleware=[ + content_type.content_type_validator, + ] + ) + self.testapp = service.HTTPService( + [v1_service, public_runnable], port=10001, - private_controllers={ - "v1": [ - apps.AppV1Controller, - routes.AppRouteV1Controller, - tasks.TasksV1Controller, - ], - "private": [ - runnable.RunnableV1Controller, - ] - }, - public_controllers={ - "public": [ - runnable.PublicRunnableV1Controller, - ], - }, - private_middlewares=[ - content_type.content_type_validator, - ], - public_middlewares=[ - content_type.content_type_validator, - ], event_loop=self.testloop, logger=logger, debug=True, - ).root_service + ).root connection_pool = config.Connection( os.getenv("TEST_DB_URI"), loop=self.testloop) diff --git a/laos/tests/functional/test_apps.py b/laos/tests/functional/test_apps.py index 02c5802..adc5038 100644 --- a/laos/tests/functional/test_apps.py +++ b/laos/tests/functional/test_apps.py @@ -12,71 +12,26 @@ # License for the specific language governing permissions and limitations # under the License. +from laos.tests.common import apps as apps_suite from laos.tests.functional import base -class TestApps(base.LaosFunctionalTestsBase): +class TestApps(base.LaosFunctionalTestsBase, apps_suite.AppsTestSuite): def test_list_apps(self): - json, http_status = self.testloop.run_until_complete( - self.test_client.apps.list()) - self.assertIn("message", json) - self.assertIn("apps", json) - self.assertEqual(200, http_status) + super(TestApps, self).list_apps() def test_get_unknown(self): - json, http_status = self.testloop.run_until_complete( - self.test_client.apps.show("unknown")) - self.assertEqual(404, http_status) - self.assertIn("error", json) + super(TestApps, self).get_unknown() def test_create_and_delete(self): - create_json, create_status = self.testloop.run_until_complete( - self.test_client.apps.create("testapp")) - delete_json, delete_status = self.testloop.run_until_complete( - self.test_client.apps.delete(create_json["app"]["name"])) - - self.assertIn("message", create_json) - self.assertIn("app", create_json) - self.assertEqual(200, create_status) - - self.assertIn("message", delete_json) - self.assertEqual(200, delete_status) + super(TestApps, self).create_and_delete() def test_attempt_to_double_create(self): - app = "testapp" - create_json, _ = self.testloop.run_until_complete( - self.test_client.apps.create(app)) - err, status = self.testloop.run_until_complete( - self.test_client.apps.create(app)) - self.testloop.run_until_complete( - self.test_client.apps.delete(create_json["app"]["name"])) - self.assertEqual(409, status) - self.assertIn("error", err) - self.assertIn("message", err["error"]) + super(TestApps, self).attempt_to_double_create() def test_attempt_delete_unknonw(self): - json, http_status = self.testloop.run_until_complete( - self.test_client.apps.delete("unknown")) - self.assertEqual(404, http_status) - self.assertIn("error", json) + super(TestApps, self).attempt_delete_unknonw() def test_delete_with_routes(self): - app_name = "testapp" - app, _ = self.testloop.run_until_complete( - self.test_client.apps.create(app_name)) - self.testloop.run_until_complete( - self.test_client.routes.create( - app["app"]["name"], **self.route_data) - ) - attempt, status = self.testloop.run_until_complete( - self.test_client.apps.delete(app["app"]["name"]) - ) - self.testloop.run_until_complete( - self.test_client.routes.delete( - app["app"]["name"], self.route_data["path"]) - ) - self.assertEqual(403, status) - self.assertIn("error", attempt) - self.assertIn("message", attempt["error"]) - self.assertIn("has routes", attempt["error"]["message"]) + super(TestApps, self).delete_with_routes() diff --git a/laos/tests/functional/test_routes.py b/laos/tests/functional/test_routes.py index 9ecd6b7..2544d93 100644 --- a/laos/tests/functional/test_routes.py +++ b/laos/tests/functional/test_routes.py @@ -12,110 +12,31 @@ # License for the specific language governing permissions and limitations # under the License. -import json as jsonlib - +from laos.tests.common import routes as routes_suite from laos.tests.functional import base -class TestAppRoutes(base.LaosFunctionalTestsBase): +class TestAppRoutes(base.LaosFunctionalTestsBase, + routes_suite.AppRoutesTestSuite): def test_list_routes_from_unknown_app(self): - json, status = self.testloop.run_until_complete( - self.test_client.routes.list("uknown_app") - ) - self.assertEqual(status, 404) - self.assertIn("error", json) - self.assertIn("not found", json["error"]["message"]) + super(TestAppRoutes, self).list_routes_from_unknown_app() def test_list_routes_from_existing_app(self): - create_json, _ = self.testloop.run_until_complete( - self.test_client.apps.create("testapp")) - json, status = self.testloop.run_until_complete( - self.test_client.routes.list(create_json["app"]["name"]) - ) - self.testloop.run_until_complete( - self.test_client.apps.delete(create_json["app"]["name"])) - self.assertEqual(status, 200) - self.assertIn("routes", json) - self.assertIn("message", json) + super(TestAppRoutes, self).list_routes_from_existing_app() def test_show_unknown_route_from_existing_app(self): - path = "/unknown_path" - create_json, _ = self.testloop.run_until_complete( - self.test_client.apps.create("testapp")) - json, status = self.testloop.run_until_complete( - self.test_client.routes.show( - create_json["app"]["name"], path) - ) - self.testloop.run_until_complete( - self.test_client.apps.delete(create_json["app"]["name"])) - - self.assertEqual(404, status) - self.assertIn("error", json) - self.assertIn("not found", json["error"]["message"]) - self.assertIn(path[1:], json["error"]["message"]) + super( + TestAppRoutes, self + ).show_unknown_route_from_existing_app() def test_delete_unknown_route_from_existing_app(self): - create_json, _ = self.testloop.run_until_complete( - self.test_client.apps.create("testapp")) - json, status = self.testloop.run_until_complete( - self.test_client.routes.delete( - create_json["app"]["name"], "/unknown_path") - ) - self.testloop.run_until_complete( - self.test_client.apps.delete(create_json["app"]["name"])) - - self.assertEqual(404, status) - self.assertIn("error", json) - self.assertIn("not found", json["error"]["message"]) + super( + TestAppRoutes, self + ).delete_unknown_route_from_existing_app() def test_create_and_delete_route(self): - app, _ = self.testloop.run_until_complete( - self.test_client.apps.create("testapp")) - route, create_status = self.testloop.run_until_complete( - self.test_client.routes.create( - app["app"]["name"], **self.route_data) - ) - route_deleted, delete_status = self.testloop.run_until_complete( - self.test_client.routes.delete( - app["app"]["name"], self.route_data["path"]) - ) - self.testloop.run_until_complete( - self.test_client.apps.delete(app["app"]["name"])) - after_post = route["route"] - for k in self.route_data: - if k == "path": - self.assertIn(self.route_data["path"], after_post[k]) - continue - if k == "is_public": - is_public = jsonlib.loads(self.route_data[k]) - self.assertEqual(is_public, after_post[k]) - continue - self.assertEqual(self.route_data[k], after_post[k]) - self.assertEqual(200, delete_status) - self.assertEqual(200, create_status) - self.assertIn("message", route_deleted) + super(TestAppRoutes, self).create_and_delete_route() def test_double_create_route(self): - app, _ = self.testloop.run_until_complete( - self.test_client.apps.create("testapp")) - self.testloop.run_until_complete( - self.test_client.routes.create( - app["app"]["name"], **self.route_data) - ) - - json, double_create_status = self.testloop.run_until_complete( - self.test_client.routes.create( - app["app"]["name"], **self.route_data) - ) - self.testloop.run_until_complete( - self.test_client.routes.delete( - app["app"]["name"], self.route_data["path"]) - ) - self.testloop.run_until_complete( - self.test_client.apps.delete(app["app"]["name"])) - - self.assertEqual(409, double_create_status) - self.assertIn("error", json) - self.assertIn("message", json["error"]) - self.assertIn("already exist", json["error"]["message"]) + super(TestAppRoutes, self).double_create_route() diff --git a/laos/tests/integration/__init__.py b/laos/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/laos/tests/integration/base.py b/laos/tests/integration/base.py new file mode 100644 index 0000000..9c2cf66 --- /dev/null +++ b/laos/tests/integration/base.py @@ -0,0 +1,119 @@ +# 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 aiohttp +import json +import os +import testtools + +from laos.common import config +from laos.service import laos_api + +from laos.tests.common import base +from laos.tests.common import client + +from urllib import parse + + +class LaosIntegrationTestsBase(base.LaosTestsBase, testtools.TestCase): + + async def authenticate(self, os_user, os_pass, os_project, url): + auth_request_data = { + "auth": { + "identity": { + "methods": ["password"], + "password": { + "user": { + "name": os_user, + "domain": {"id": "default"}, + "password": os_pass + } + } + }, + "scope": { + "project": { + "name": os_project, + "domain": {"id": "default"} + } + } + } + } + with aiohttp.ClientSession(loop=self.testloop) as session: + response = await session.post( + "{}/auth/tokens".format(url), + data=json.dumps(auth_request_data), + headers={ + "Content-Type": "application/json" + }, + timeout=20) + response.raise_for_status() + result = await response.json() + return (response.headers["X-Subject-Token"], + result["token"]["project"]["id"]) + + def setUp(self): + + self.testloop, logger = self.get_loop_and_logger("integration") + + (functions_url, db_uri, + os_auth_url, os_user, os_pass, os_project) = ( + os.getenv("FUNCTIONS_API_URL", "http://localhost:8080/v1"), + os.getenv("TEST_DB_URI"), + os.getenv("OS_AUTH_URL"), + os.getenv("OS_USERNAME"), + os.getenv("OS_PASSWORD"), + os.getenv("OS_PROJECT_NAME"), + ) + + if not all([db_uri, os_auth_url, os_user, os_pass, os_project]): + raise self.skipTest("Not all test env variables were set.") + + parts = parse.urlparse(functions_url) + + fnclient = config.FunctionsClient( + parts.hostname, + api_port=parts.port, + api_protocol=parts.scheme, + api_version=parts.path[1:] + ) + self.testloop.run_until_complete(fnclient.ping(loop=self.testloop)) + connection_pool = config.Connection(db_uri, loop=self.testloop) + + config.Config( + auth_url=os_auth_url, + functions_client=fnclient, + logger=logger, + connection=connection_pool, + event_loop=self.testloop, + ) + + self.test_app = laos_api.API( + loop=self.testloop, logger=logger, debug=True) + + os_token, project_id = self.testloop.run_until_complete( + self.authenticate( + os_user, os_pass, + os_project, os_auth_url)) + + self.test_client = client.ProjectBoundLaosTestClient( + self.test_app.root, project_id, headers={ + "X-Auth-Token": os_token + }) + self.testloop.run_until_complete( + self.test_client.start_server()) + super(LaosIntegrationTestsBase, self).setUp() + + def tearDown(self): + self.testloop.run_until_complete(self.test_client.close()) + super(LaosIntegrationTestsBase, self).tearDown() diff --git a/laos/tests/integration/test_apps.py b/laos/tests/integration/test_apps.py new file mode 100644 index 0000000..09ee9ac --- /dev/null +++ b/laos/tests/integration/test_apps.py @@ -0,0 +1,38 @@ +# 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 laos.tests.common import apps as apps_suite +from laos.tests.integration import base + + +class TestIntegrationApps(base.LaosIntegrationTestsBase, + apps_suite.AppsTestSuite): + + def test_list_apps(self): + super(TestIntegrationApps, self).list_apps() + + def test_get_unknown(self): + super(TestIntegrationApps, self).get_unknown() + + def test_create_and_delete(self): + super(TestIntegrationApps, self).create_and_delete() + + def test_attempt_to_double_create(self): + super(TestIntegrationApps, self).attempt_to_double_create() + + def test_attempt_delete_unknonw(self): + super(TestIntegrationApps, self).attempt_delete_unknonw() + + def test_delete_with_routes(self): + super(TestIntegrationApps, self).delete_with_routes() diff --git a/laos/tests/integration/test_routes.py b/laos/tests/integration/test_routes.py new file mode 100644 index 0000000..d14e2ed --- /dev/null +++ b/laos/tests/integration/test_routes.py @@ -0,0 +1,50 @@ +# 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 laos.tests.common import routes as routes_suite +from laos.tests.functional import base + + +class TestIntegrationAppRoutes(base.LaosFunctionalTestsBase, + routes_suite.AppRoutesTestSuite): + + def test_list_routes_from_unknown_app(self): + super( + TestIntegrationAppRoutes, self + ).list_routes_from_unknown_app() + + def test_list_routes_from_existing_app(self): + super( + TestIntegrationAppRoutes, self + ).list_routes_from_existing_app() + + def test_show_unknown_route_from_existing_app(self): + super( + TestIntegrationAppRoutes, self + ).show_unknown_route_from_existing_app() + + def test_delete_unknown_route_from_existing_app(self): + super( + TestIntegrationAppRoutes, self + ).delete_unknown_route_from_existing_app() + + def test_create_and_delete_route(self): + super( + TestIntegrationAppRoutes, self + ).create_and_delete_route() + + def test_double_create_route(self): + super( + TestIntegrationAppRoutes, self + ).double_create_route() diff --git a/requirements.txt b/requirements.txt index a6ecafa..865e4aa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,11 +3,15 @@ # process, which may cause wedges in the gate later. uvloop==0.6.0 # Apache-2.0 -aiohttp==1.1.5 # Apache-2.0 +aioservice==0.0.1 # Apache-2.0 aiomysql==0.0.9 # Apache-2.0 alembic==0.8.8 # MIT click==6.6 # Apache-2.0 + +# IronFunctions python-functionsclient==0.0.1 + +# OpenStack keystoneauth1==2.15.0 # Apache-2.0 python-keystoneclient==3.6.0 # Apache-2.0 diff --git a/scripts/test_regression.sh b/scripts/test_regression.sh index 2daca8e..b743f6d 100755 --- a/scripts/test_regression.sh +++ b/scripts/test_regression.sh @@ -4,17 +4,17 @@ set +x set +e function get_current_coverage { - local prev_stat_raw=`TEST_DB_URI=${TEST_DB_URI} pytest --tb=long --capture=sys --cov=laos --capture=fd laos/tests/functional | grep TOTAL | awk '{print $4}'` + local prev_stat_raw=`pytest --tb=long --capture=sys --cov=laos --capture=fd laos/tests/${1:-functional} | grep TOTAL | awk '{print $4}'` echo ${prev_stat_raw:0:2} } function get_coverage_delta { - local current_coverage=$(get_current_coverage) + local current_coverage=$(get_current_coverage $1) local current_branch=`git branch | awk '{print $2}'` - echo -e "Falling back to ${1} commits." - local commits=`seq -f "^" -s '' ${1:-1}` + echo -e "Falling back to ${2} commits." + local commits=`seq -f "^" -s '' ${2}` git checkout `git rev-parse --verify HEAD${commits}` &> /dev/null - local prev_coverage=$(get_current_coverage) + local prev_coverage=$(get_current_coverage $1) echo -e "Current coverage: ${current_coverage}%" echo -e "Previous coverage: ${prev_coverage}%" if [ "${prev_coverage}" -gt "${current_coverage}" ]; then @@ -27,4 +27,4 @@ function get_coverage_delta { } -get_coverage_delta ${1:-1} +get_coverage_delta ${1:-functional} ${2:-1} diff --git a/setup.py b/setup.py index 68aa679..6a27e37 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ setuptools.setup( packages=setuptools.find_packages(), install_requires=[ "uvloop==0.6.0", - "aiohttp==1.1.5", + "aioservice==0.0.1", "aiomysql==0.0.9", "alembic==0.8.8", "click==6.6", diff --git a/tox.ini b/tox.ini index 76881f2..ef3ef62 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ # Project LaOS [tox] -envlist = py35-functional,pep8 +envlist = py35-functional,py35-functional-regression,py35-integration,py35-integration-regression,pep8 minversion = 1.6 skipsdist = True @@ -9,6 +9,11 @@ skipsdist = True passenv = PYTHONASYNCIODEBUG TEST_DB_URI + FUNCTIONS_API_URL + OS_AUTH_URL + OS_PASSWORD + OS_USERNAME + OS_PROJECT_NAME setenv = VIRTUAL_ENV={envdir} usedevelop = True install_command = pip install -U {opts} {packages} @@ -26,13 +31,17 @@ commands = flake8 [testenv:venv] commands = {posargs} +[testenv:py35-integration] +commands = pytest --tb=long --capture=sys --cov=laos --capture=fd {toxinidir}/laos/tests/integration [testenv:py35-functional] commands = pytest --tb=long --capture=sys --cov=laos --capture=fd {toxinidir}/laos/tests/functional [testenv:py35-functional-regression] -commands = {toxinidir}/scripts/test_regression.sh {posargs} +commands = {toxinidir}/scripts/test_regression.sh functional {posargs} +[testenv:py35-integration-regression] +commands = {toxinidir}/scripts/test_regression.sh integration {posargs} [testenv:docs] commands =