Skip to content

Commit

Permalink
Merge pull request #261 from RDFLib/function_app_patches
Browse files Browse the repository at this point in the history
Update Azure Function app files
  • Loading branch information
ashleysommer committed Aug 28, 2024
2 parents c5d3931 + 2857b44 commit 9424742
Show file tree
Hide file tree
Showing 11 changed files with 202 additions and 11 deletions.
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ venv/
.vscode/
.idea/
.git/
build/
test_*.py
.github/
Dockerfile
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ __pycache__/
.pytest_cache/
.env*
dist/
build/
!.env-template
rdf/
http/
Expand Down
7 changes: 7 additions & 0 deletions azure/.funcignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.vscode/
.venv/
.idea/
__pycache__/
__pycache__
local.settings.json

39 changes: 39 additions & 0 deletions azure/README.md
Original file line number Diff line number Diff line change
@@ -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 <function-app-name> --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 <image-name> -f azure/azure_functions.Dockerfile .
```
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"]
29 changes: 22 additions & 7 deletions function_app.py → azure/function_app.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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)

File renamed without changes.
File renamed without changes.
63 changes: 63 additions & 0 deletions azure/patched_asgi_function_wrapper.py
Original file line number Diff line number Diff line change
@@ -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)
67 changes: 67 additions & 0 deletions azure/publish_or_start.sh
Original file line number Diff line number Diff line change
@@ -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 <start|publish> [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."
2 changes: 0 additions & 2 deletions requirements.txt

This file was deleted.

0 comments on commit 9424742

Please sign in to comment.