diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml new file mode 100644 index 0000000..fba1df9 --- /dev/null +++ b/.github/workflows/python.yml @@ -0,0 +1,44 @@ +name: Python - Test +on: + push: + branches: + - main + paths: + - "code/function/**" + - "tests/**" + - "requirements.txt" + - ".github/workflows/python.yml" + pull_request: + branches: + - main + paths: + - "code/function/**" + - "tests/**" + - "requirements.txt" + - ".github/workflows/python.yml" + +jobs: + lint: + name: Python Test + runs-on: ubuntu-latest + + steps: + # Setup Python 3.10 + - name: Setup Python 3.10 + id: python_setup + uses: actions/setup-python@v4 + with: + python-version: "3.10" + + # Checkout repository + - name: Check Out Repository + id: checkout_repository + uses: actions/checkout@v3 + + # Run Python Tests + - name: Run Python Tests + id: python_test + run: | + pip install -r ./code/function/requirements.txt -q + pip install -r requirements.txt -q + pytest diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 978da8d..35370ec 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,6 +13,15 @@ repos: - id: check-yaml - id: pretty-format-json args: ["--indent", "2", "--autofix", "--no-sort-keys"] + - repo: https://github.com/PyCQA/isort + rev: 5.12.0 + hooks: + - id: isort + args: ["--profile", "black", "--filter-files"] + - repo: https://github.com/psf/black + rev: 23.3.0 + hooks: + - id: black - repo: local hooks: - id: terraform-fmt diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..cbbad0f --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "ms-azuretools.vscode-azurefunctions", + "ms-python.python" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..0e43bd3 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,12 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Attach to Python Functions", + "type": "python", + "request": "attach", + "port": 9091, + "preLaunchTask": "func: host start" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..562fbbf --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,9 @@ +{ + "azureFunctions.deploySubpath": "code\\function", + "azureFunctions.scmDoBuildDuringDeployment": true, + "azureFunctions.pythonVenv": ".venv", + "azureFunctions.projectLanguage": "Python", + "azureFunctions.projectRuntime": "~4", + "debug.internalConsoleOptions": "neverOpen", + "azureFunctions.projectLanguageModel": 2 +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..0bbf25a --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,33 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "func", + "label": "func: host start", + "command": "host start", + "problemMatcher": "$func-python-watch", + "isBackground": true, + "dependsOn": "pip install (functions)", + "options": { + "cwd": "${workspaceFolder}/code\\function" + } + }, + { + "label": "pip install (functions)", + "type": "shell", + "osx": { + "command": "${config:azureFunctions.pythonVenv}/bin/python -m pip install -r requirements.txt" + }, + "windows": { + "command": "${config:azureFunctions.pythonVenv}\\Scripts\\python -m pip install -r requirements.txt" + }, + "linux": { + "command": "${config:azureFunctions.pythonVenv}/bin/python -m pip install -r requirements.txt" + }, + "problemMatcher": [], + "options": { + "cwd": "${workspaceFolder}/code\\function" + } + } + ] +} diff --git a/code/function/.funcignore b/code/function/.funcignore new file mode 100644 index 0000000..f1110d3 --- /dev/null +++ b/code/function/.funcignore @@ -0,0 +1,8 @@ +.git* +.vscode +__azurite_db*__.json +__blobstorage__ +__queuestorage__ +local.settings.json +test +.venv diff --git a/code/function/.gitignore b/code/function/.gitignore new file mode 100644 index 0000000..74fc765 --- /dev/null +++ b/code/function/.gitignore @@ -0,0 +1,135 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don’t work, or not +# install all needed dependencies. +#Pipfile.lock + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# Azure Functions artifacts +bin +obj +appsettings.json +local.settings.json + +# Azurite artifacts +__blobstorage__ +__queuestorage__ +__azurite_db*__.json +.python_packages diff --git a/code/function/.gitkeep b/code/function/api/__init__.py similarity index 100% rename from code/function/.gitkeep rename to code/function/api/__init__.py diff --git a/tests/.gitkeep b/code/function/api/v1/__init__.py similarity index 100% rename from tests/.gitkeep rename to code/function/api/v1/__init__.py diff --git a/code/function/api/v1/api_v1.py b/code/function/api/v1/api_v1.py new file mode 100644 index 0000000..329426e --- /dev/null +++ b/code/function/api/v1/api_v1.py @@ -0,0 +1,6 @@ +from fastapi import APIRouter +from function.api.v1.endpoints import heartbeat, sample + +api_v1_router = APIRouter() +api_v1_router.include_router(sample.router, prefix="/landingZone", tags=["sample"]) +api_v1_router.include_router(heartbeat.router, prefix="/health", tags=["health"]) diff --git a/code/function/api/v1/endpoints/heartbeat.py b/code/function/api/v1/endpoints/heartbeat.py new file mode 100644 index 0000000..dd03420 --- /dev/null +++ b/code/function/api/v1/endpoints/heartbeat.py @@ -0,0 +1,15 @@ +from typing import Any + +from fastapi import APIRouter +from function.models.heartbeat import HearbeatResult +from function.utils import setup_logging + +logger = setup_logging(__name__) + +router = APIRouter() + + +@router.get("/heartbeat", response_model=HearbeatResult, name="heartbeat") +async def get_hearbeat() -> Any: + logger.info("Received Heartbeat Request") + return HearbeatResult(isAlive=True) diff --git a/code/function/api/v1/endpoints/sample.py b/code/function/api/v1/endpoints/sample.py new file mode 100644 index 0000000..a04aa45 --- /dev/null +++ b/code/function/api/v1/endpoints/sample.py @@ -0,0 +1,17 @@ +from typing import Any + +from fastapi import APIRouter +from function.models.sample import SampleRequest, SampleResponse +from function.utils import setup_logging + +logger = setup_logging(__name__) + +router = APIRouter() + + +@router.post("/create", response_model=SampleResponse, name="create") +async def post_predict( + data: SampleRequest, +) -> SampleResponse: + logger.info(f"Received request: {data}") + return SampleResponse(output=f"Hello ${data.input}") diff --git a/code/function/core/__init__.py b/code/function/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/code/function/core/config.py b/code/function/core/config.py new file mode 100644 index 0000000..f6f33ac --- /dev/null +++ b/code/function/core/config.py @@ -0,0 +1,15 @@ +import logging + +from pydantic import BaseSettings + + +class Settings(BaseSettings): + PROJECT_NAME: str = "FunctionSample" + SERVER_NAME: str = "FunctionSample" + APP_VERSION: str = "v0.0.1" + API_V1_STR: str = "/v1" + LOGGING_LEVEL: int = logging.INFO + DEBUG: bool = False + + +settings = Settings() diff --git a/code/function/core/messages.py b/code/function/core/messages.py new file mode 100644 index 0000000..9741926 --- /dev/null +++ b/code/function/core/messages.py @@ -0,0 +1,14 @@ +from pydantic import BaseSettings + + +class Messages(BaseSettings): + # Base messages + NO_API_KEY = "No API key provided." + AUTH_REQ = "Authentication required." + HTTP_500_DETAIL = "Internal server error." + + # Templates + NO_VALID_PAYLOAD = "{} is not a valid payload." + + +messages = Messages() diff --git a/code/function/function_app.py b/code/function/function_app.py new file mode 100644 index 0000000..fe89a11 --- /dev/null +++ b/code/function/function_app.py @@ -0,0 +1,34 @@ +import azure.functions as func +from fastapi import FastAPI +from function.api.v1.api_v1 import api_v1_router +from function.core.config import settings + + +def get_app() -> FastAPI: + app = FastAPI( + title=settings.PROJECT_NAME, + version=settings.APP_VERSION, + openapi_url="/openapi.json", + debug=settings.DEBUG, + ) + app.include_router(api_v1_router, prefix=settings.API_V1_STR) + return app + + +fastapi_app = get_app() + + +@fastapi_app.on_event("startup") +async def startup_event(): + pass + + +@fastapi_app.on_event("shutdown") +async def shutdown_event(): + pass + + +app = func.AsgiFunctionApp( + app=fastapi_app, + http_auth_level=func.AuthLevel.ANONYMOUS, +) diff --git a/code/function/getting_started.md b/code/function/getting_started.md new file mode 100644 index 0000000..81aca9a --- /dev/null +++ b/code/function/getting_started.md @@ -0,0 +1,48 @@ +# Getting Started with Azure Functions in Python + + +## Python Programming Model V2 + +The new programming model in Azure Functions Python delivers an experience that aligns with Python development principles, and subsequently with commonly used Python frameworks. + +The improved programming model requires fewer files than the default model, and specifically eliminates the need for a configuration file (`function.json`). Instead, triggers and bindings are represented in the `function_app.py` file as decorators. Moreover, functions can be logically organized with support for multiple functions to be stored in the same file. Functions within the same function application can also be stored in different files, and be referenced as blueprints. + +In addition to the [documentation](https://docs.microsoft.com/azure/azure-functions/functions-reference-python?tabs=asgi%2Capplication-level), hints are available in code editors that support type checking with PYI files. + +To learn more about the new programming model for Azure Functions in Python, see [Programming Models in Azure Functions](https://aka.ms/functions-programming-models). + +## Notes + +- Mix and match of Functions written in the V1 programming model and the V2 programming model in the same Function App will not be supported. +- At this time, the main functions file must be named `function_app.py`. + +To learn more about the new programming model for Azure Functions in Python, see the [Azure Functions Python developer guide](https://docs.microsoft.com/en-us/azure/azure-functions/functions-reference-python?tabs=asgi%2Capplication-level). + +## Getting Started + +Project Structure + +The main project folder () can contain the following files: + +* *function_app.py*: Functions along with their triggers and bindings are defined here. +* *local.settings.json*: Used to store app settings and connection strings when running locally. This file doesn't get published to Azure. +* *requirements.txt*: Contains the list of Python packages the system installs when publishing to Azure. +* *host.json*: Contains configuration options that affect all functions in a function app instance. This file does get published to Azure. Not all options are supported when running locally. +* *blueprint.py*: (Optional) Functions that are defined in a separate file for logical organization and grouping, that can be referenced in `function_app.py`. +* *.vscode/*: (Optional) Contains store VSCode configuration. +* *.venv/*: (Optional) Contains a Python virtual environment used by local development. +* *Dockerfile*: (Optional) Used when publishing your project in a custom container. +* *tests/*: (Optional) Contains the test cases of your function app. +* *.funcignore*: (Optional) Declares files that shouldn't get published to Azure. Usually, this file contains `.vscode/` to ignore your editor setting, `.venv/` to ignore local Python virtual environment, `tests/` to ignore test cases, and `local.settings.json` to prevent local app settings being published. + +## Developing your first Python function using VS Code + +If you have not already, please checkout our [quickstart](https://aka.ms/fxpythonquickstart) to get you started with Azure Functions developments in Python. + +## Publishing your function app to Azure + +For more information on deployment options for Azure Functions, please visit this [guide](https://docs.microsoft.com/en-us/azure/azure-functions/create-first-function-vs-code-python#publish-the-project-to-azure). + +## Next Steps + +To learn more specific guidance on developing Azure Functions with Python, please visit [Azure Functions Developer Python Guide](https://docs.microsoft.com/en-us/azure/azure-functions/functions-reference-python?tabs=asgi%2Capplication-level). diff --git a/code/function/host.json b/code/function/host.json new file mode 100644 index 0000000..06d01bd --- /dev/null +++ b/code/function/host.json @@ -0,0 +1,15 @@ +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + } + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} diff --git a/code/function/models/__init__.py b/code/function/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/code/function/models/heartbeat.py b/code/function/models/heartbeat.py new file mode 100644 index 0000000..2fea18a --- /dev/null +++ b/code/function/models/heartbeat.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + + +class HearbeatResult(BaseModel): + isAlive: bool diff --git a/code/function/models/sample.py b/code/function/models/sample.py new file mode 100644 index 0000000..0ffa65f --- /dev/null +++ b/code/function/models/sample.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel + + +class SampleRequest(BaseModel): + input: str + + +class SampleResponse(BaseModel): + output: str diff --git a/code/function/requirements.txt b/code/function/requirements.txt new file mode 100644 index 0000000..6ba5be5 --- /dev/null +++ b/code/function/requirements.txt @@ -0,0 +1,7 @@ +# DO NOT include azure-functions-worker in this file +# The Python Worker is managed by Azure Functions platform +# Manually managing azure-functions-worker may cause unexpected issues + +azure-functions~=1.14.0 +fastapi~=0.96.0 +aiohttp~=3.8.4 diff --git a/code/function/utils.py b/code/function/utils.py new file mode 100644 index 0000000..a27ac01 --- /dev/null +++ b/code/function/utils.py @@ -0,0 +1,22 @@ +import logging +from logging import Logger + +from function.core.config import settings + + +def setup_logging(module) -> Logger: + """Setup logging and event handler. + RETURNS (Logger): The logger object to log activities. + """ + logger = logging.getLogger(module) + logger.setLevel(settings.LOGGING_LEVEL) + logger.propagate = False + + # Create stream handler + logger_stream_handler = logging.StreamHandler() + logger_stream_handler.setFormatter( + logging.Formatter("[%(asctime)s] [%(levelname)s] [%(module)-8.8s] %(message)s") + ) + + logger.addHandler(logger_stream_handler) + return logger diff --git a/code/infra/roleassignments.tf b/code/infra/roleassignments.tf index 30b92b7..83ee9e6 100644 --- a/code/infra/roleassignments.tf +++ b/code/infra/roleassignments.tf @@ -1,9 +1,3 @@ -resource "azurerm_role_assignment" "role_assignment_key_vault_uai" { - scope = azurerm_key_vault.key_vault.id - role_definition_name = "Key Vault Crypto Service Encryption User" - principal_id = azurerm_user_assigned_identity.user_assigned_identity.principal_id -} - resource "azurerm_role_assignment" "role_assignment_storage_function" { scope = azurerm_storage_account.storage.id role_definition_name = "Storage Blob Data Owner" diff --git a/code/infra/storage.tf b/code/infra/storage.tf index 84ee20b..a4e0437 100644 --- a/code/infra/storage.tf +++ b/code/infra/storage.tf @@ -3,12 +3,6 @@ resource "azurerm_storage_account" "storage" { location = var.location resource_group_name = azurerm_resource_group.app_rg.name tags = var.tags - identity { - type = "UserAssigned" - identity_ids = [ - azurerm_user_assigned_identity.user_assigned_identity.id - ] - } access_tier = "Hot" account_kind = "StorageV2" @@ -52,10 +46,6 @@ resource "azurerm_storage_account" "storage" { } sftp_enabled = false shared_access_key_enabled = false - - depends_on = [ - azurerm_role_assignment.role_assignment_key_vault_uai - ] } resource "azurerm_storage_management_policy" "storage_management_policy" { diff --git a/code/infra/terraform.tf b/code/infra/terraform.tf index ef727c6..9d58753 100644 --- a/code/infra/terraform.tf +++ b/code/infra/terraform.tf @@ -37,9 +37,6 @@ provider "azurerm" { recover_soft_deleted_keys = true recover_soft_deleted_secrets = true } - network { - relaxed_locking = true - } resource_group { prevent_deletion_if_contains_resources = true } diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..1d9b4af --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +pythonpath = code diff --git a/requirements.txt b/requirements.txt index d3e5ff0..16d3d4f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,3 @@ pre-commit~=3.3.2 +pytest~=7.3.1 +httpx~=0.24.1 diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..86e67e6 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,22 @@ +import pytest +from fastapi.testclient import TestClient +from function.core.config import settings +from function.function_app import fastapi_app + + +@pytest.fixture(scope="module") +def client() -> TestClient: + return TestClient(fastapi_app) + + +@pytest.mark.parametrize("version", ("v1",)) +def test_get_heartbeat(client, version): + # arrange + path = f"/{version}/health/heartbeat" + + # action + response = client.get(path) + + # assert + assert response.status_code == 200 + assert response.json() == {"isAlive": True}