diff --git a/.dockerignore b/.dockerignore index bb381ce4..bcda5942 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,6 +6,7 @@ venv/ .vscode/ .idea/ .git/ +build/ test_*.py .github/ Dockerfile diff --git a/.gitignore b/.gitignore index 1b5d7dd8..639a36d9 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ __pycache__/ .pytest_cache/ .env* dist/ +build/ !.env-template rdf/ http/ diff --git a/azure/.funcignore b/azure/.funcignore new file mode 100644 index 00000000..fc0c36ff --- /dev/null +++ b/azure/.funcignore @@ -0,0 +1,7 @@ +.vscode/ +.venv/ +.idea/ +__pycache__/ +__pycache__ +local.settings.json + diff --git a/azure/README.md b/azure/README.md new file mode 100644 index 00000000..c37c48d1 --- /dev/null +++ b/azure/README.md @@ -0,0 +1,39 @@ +# Prez Azure Function-App deployment files + +This directory contains the files required to build and start or publish Prez as an Azure Function-App, as well as a Dockerfile that +can be used to build a container image for deploying the app as an Azure Container App. + +## Publishing +There is a publish_or_start.sh script that can be used to either build and run the function app locally, or publish the app to Azure. +To call it, make sure you are not in the "azure" directory, instead run the script from the root of the project. + +```bash +./azure/publish_or_start.sh start|publish --extra-options +``` +The FunctionAppName is required for publishing only, and is the name of the Azure Function-App that you want to publish to. +Note, the FunctionAppName must be the second argument to the script, after any optional arguments. + +This script will perform the following steps: +1. Create a ./build directory +2. Copy the required azure function files from the ./azure directory into the ./build directory + * ./azure/function_app.py + * ./azure/patched_asgi_function_wrapper.py + * ./azure/host.json + * ./azure/.funcignore +3. Copy the local prez module source code into the ./build directory +4. Copy the .env file into the ./build directory if it exists +5. Copy the pyproject.toml and poetry.lock files into the ./build directory +6. Generate the requirements.txt file using poetry +7. Start the app locally, or publish the app to the Azure Function-App (using remote build) + +**extra-options** can be used to pass additional arguments to the azure publish command. (Eg, the `--subscription` argument) + +_Note:_ the script automatically adds the `--build remote` argument to the publish command, you don't need to specify it. + +## Building the Docker container image + +To build the Docker container image, run the following command from the root of the project: + +```bash +docker build -t -f azure/azure_functions.Dockerfile . +``` diff --git a/azure_functions.Dockerfile b/azure/azure_functions.Dockerfile similarity index 93% rename from azure_functions.Dockerfile rename to azure/azure_functions.Dockerfile index 903c4059..0e8668b7 100644 --- a/azure_functions.Dockerfile +++ b/azure/azure_functions.Dockerfile @@ -19,7 +19,7 @@ RUN pip3 install poetry==${POETRY_VERSION} RUN mkdir -p /build WORKDIR /build -COPY . . +COPY .. . RUN poetry build RUN mkdir -p /home/site/wwwroot @@ -52,7 +52,7 @@ RUN DEBIAN_FRONTEND=noninteractive apt-get -qq update && \ bash WORKDIR /home/site/wwwroot -COPY requirements.txt pyproject.toml host.json function_app.py ./ +COPY pyproject.toml poetry.lock azure/host.json azure/function_app.py azure/patched_asgi_function_wrapper.py ./ ENTRYPOINT [] CMD ["/opt/startup/start_nonappservice.sh"] diff --git a/function_app.py b/azure/function_app.py similarity index 65% rename from function_app.py rename to azure/function_app.py index 0d599a80..98adcb98 100644 --- a/function_app.py +++ b/azure/function_app.py @@ -1,18 +1,29 @@ import os -import azure.functions as func +import sys +import pathlib +import logging + +cwd = pathlib.Path(__file__).parent +if cwd.name == "azure": + # We are running from the repo source directory + # assume running locally, we need to add the parent + # directory to the Python Path + sys.path.append(str(cwd.parent)) +import azure.functions as func try: from prez.app import assemble_app -except ImportError: +except ImportError as e: + logging.exception("Cannot import prez") assemble_app = None if assemble_app is None: raise RuntimeError( - "Cannot import prez in the Azure function app. Check requirements.py and pyproject.toml." + "Cannot import prez in the Azure function app. Check requirements.txt and pyproject.toml." ) - +from patched_asgi_function_wrapper import AsgiFunctionApp # This is the base URL path that Prez routes will stem from # must _start_ in a slash, but _not end_ in slash, eg: /prez @@ -32,7 +43,7 @@ prez_app = assemble_app(root_path=ROOT_PATH) -app = func.AsgiFunctionApp(app=prez_app, http_auth_level=auth_level) +app = AsgiFunctionApp(app=prez_app, http_auth_level=auth_level) if __name__ == "__main__": from azure.functions import HttpRequest, Context @@ -41,7 +52,11 @@ req = HttpRequest("GET", "/catalogs", headers={}, body=b"") context = dict() loop = asyncio.get_event_loop() - - task = app.middleware.handle_async(req, context) + fns = app.get_functions() + assert len(fns) == 1 + fn_def = fns[0] + fn = fn_def.get_user_function() + task = fn(req, context) resp = loop.run_until_complete(task) print(resp) + diff --git a/host.json b/azure/host.json similarity index 100% rename from host.json rename to azure/host.json diff --git a/local.settings.json b/azure/local.settings.json similarity index 100% rename from local.settings.json rename to azure/local.settings.json diff --git a/azure/patched_asgi_function_wrapper.py b/azure/patched_asgi_function_wrapper.py new file mode 100644 index 00000000..3bfd2383 --- /dev/null +++ b/azure/patched_asgi_function_wrapper.py @@ -0,0 +1,63 @@ +from typing import Union, TYPE_CHECKING +from copy import copy +import azure.functions as func +from azure.functions.decorators.http import HttpMethod +from azure.functions._http_asgi import AsgiMiddleware, AsgiRequest, AsgiResponse +from azure.functions._http_wsgi import WsgiMiddleware +from azure.functions._abc import Context +from azure.functions import HttpRequest + +# ------------------- +# Create a patched AsgiFunctionApp to fix the ASGI scope state issue +# ------------------- +# See https://github.com/Azure/azure-functions-python-worker/issues/1566 +class MyAsgiMiddleware(AsgiMiddleware): + async def _handle_async(self, req, context): + asgi_request = AsgiRequest(req, context) + scope = asgi_request.to_asgi_http_scope() + # shallow copy the state as-per the ASGI spec + scope["state"] = copy(self.state) # <-- this is the patch, add the state to the scope + asgi_response = await AsgiResponse.from_app(self._app, + scope, + req.get_body()) + return asgi_response.to_func_response() + +# ------------------- +# Create a patched AsgiFunctionApp to fix the double-slash route issue +# ------------------- +# See https://github.com/Azure/azure-functions-python-worker/issues/1310 +class AsgiFunctionApp(func.AsgiFunctionApp): + def __init__(self, app, http_auth_level): + super(AsgiFunctionApp, self).__init__(None, http_auth_level=http_auth_level) + self._function_builders.clear() + self.middleware = MyAsgiMiddleware(app) + self._add_http_app(self.middleware) + self.startup_task_done = False + + def _add_http_app( + self, http_middleware: Union[AsgiMiddleware, WsgiMiddleware] + ) -> None: + """Add an Asgi app integrated http function. + + :param http_middleware: :class:`WsgiMiddleware` + or class:`AsgiMiddleware` instance. + + :return: None + """ + + asgi_middleware: AsgiMiddleware = http_middleware + + @self.http_type(http_type="asgi") + @self.route( + methods=(method for method in HttpMethod), + auth_level=self.auth_level, + route="{*route}", # <-- this is the patch, removed the leading slash from the route + ) + async def http_app_func(req: HttpRequest, context: Context): + if not self.startup_task_done: + success = await asgi_middleware.notify_startup() + if not success: + raise RuntimeError("ASGI middleware startup failed.") + self.startup_task_done = True + + return await asgi_middleware.handle_async(req, context) diff --git a/azure/publish_or_start.sh b/azure/publish_or_start.sh new file mode 100755 index 00000000..c770edb5 --- /dev/null +++ b/azure/publish_or_start.sh @@ -0,0 +1,67 @@ +#!/bin/bash +DEFAULT_FUNC=$(which func) +DEFAULT_POETRY=$(which poetry) +FUNC_CLI=${FUNC_CLI:-"$DEFAULT_FUNC"} +POETRY=${POETRY:-"$DEFAULT_POETRY"} + +if [[ "$#" -lt 1 ]] ; then + echo "Usage: $0 [optional arguments] [FunctionAppName]" + echo " start: Run the function app locally (FunctionAppName not required)" + echo " publish: Publish the function app to Azure (FunctionAppName required)" + exit 1 +fi + +# Extract the first argument as the ACTION +ACTION="$1" +shift + +CWD="$(pwd)" +BASE_CWD="${CWD##*/}" +if [[ "$BASE_CWD" = "azure" ]] ; then + echo "Do not run this script from within the azure directory" + echo "Run from the root of the repo" + echo "eg: ./azure/publish_or_start.sh start" + exit 1 +fi + +if [[ -z "$FUNC_CLI" ]] ; then + echo "func cli not found, specify the location using env FUNC_CLI" + exit 1 +fi + +if [[ -z "$POETRY" ]] ; then + echo "poetry not found. Local poetry>=1.8.2 is required to generate the requirements.txt file" + echo "specify the location using env POETRY" + exit 1 +fi + +mkdir -p build +rm -rf build/* +cp ./azure/function_app.py ./azure/patched_asgi_function_wrapper.py ./azure/.funcignore ./azure/host.json ./azure/local.settings.json build/ +cp ./pyproject.toml ./poetry.lock ./build +cp -r ./prez ./build +if [[ -f "./.env" ]] ; then + cp ./.env ./build +fi +cd ./build +"$POETRY" export --without-hashes --format=requirements.txt > requirements.txt +echo "generated requirements.txt" +cat ./requirements.txt + +if [[ "$ACTION" == "publish" ]] ; then + if [[ "$#" -lt 1 ]] ; then + echo "Error: FunctionAppName is required for publish action" + exit 1 + fi + FUNC_APP_NAME="$1" + shift + "$FUNC_CLI" azure functionapp publish "$FUNC_APP_NAME" --build remote "$@" +elif [[ "$ACTION" == "start" ]] ; then + "$FUNC_CLI" start "$@" +else + echo "Invalid action. Use 'start' for local testing or 'publish' for publishing to Azure." + exit 1 +fi + +cd .. +echo "You can now delete the build directory if you wish." \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 831e2582..00000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -azure-functions -prez