diff --git a/.github/workflows/_functionAppDeployTemplate.yml b/.github/workflows/_functionAppDeployTemplate.yml new file mode 100644 index 0000000..285c432 --- /dev/null +++ b/.github/workflows/_functionAppDeployTemplate.yml @@ -0,0 +1,83 @@ +name: Function App Deploy Template + +on: + workflow_call: + inputs: + environment: + required: true + type: string + default: "dev" + description: "Specifies the environment of the deployment." + python_version: + required: true + type: string + default: "3.10" + description: "Specifies the python version." + function_directory: + required: true + type: string + description: "Specifies the directory of the Azure Function." + function_name: + required: true + type: string + description: "Specifies the name of the Azure Function." + secrets: + TENANT_ID: + required: true + description: "Specifies the tenant id of the deployment." + CLIENT_ID: + required: true + description: "Specifies the client id." + CLIENT_SECRET: + required: true + description: "Specifies the client secret." + SUBSCRIPTION_ID: + required: true + description: "Specifies the client id." + +jobs: + deployment: + name: Function App Deploy + runs-on: self-hosted + continue-on-error: false + environment: ${{ inputs.environment }} + + steps: + # Setup Python 3.10 + - name: Setup Python 3.10 + id: python_setup + uses: actions/setup-python@v4 + with: + python-version: ${{ inputs.python_version }} + + # Check Out Repository + - name: Check Out Repository + id: checkout_repository + uses: actions/checkout@v3 + + # Install Function Dependencies + - name: Resolve Function Dependencies + id: function_dependencies + shell: bash + run: | + pushd '${{ inputs.function_directory }}' + python -m pip install --upgrade pip + pip install -r requirements.txt --target=".python_packages/lib/site-packages" + popd + + # Login to Azure + - name: Azure Login + id: azure_login + uses: azure/login@v1 + with: + creds: '{"clientId":"${{ secrets.CLIENT_ID }}","clientSecret":"${{ secrets.CLIENT_SECRET }}","subscriptionId":"${{ secrets.SUBSCRIPTION_ID }}","tenantId":"${{ secrets.TENANT_ID }}"}' + + # Deploy Function + - name: Deploy Function + id: function_deploy + uses: Azure/functions-action@v1 + with: + app-name: ${{ inputs.function_name }} + package: ${{ inputs.function_directory }} + scm-do-build-during-deployment: true + enable-oryx-build: true diff --git a/.github/workflows/_functionAppTestTemplate.yml b/.github/workflows/_functionAppTestTemplate.yml new file mode 100644 index 0000000..ecf4713 --- /dev/null +++ b/.github/workflows/_functionAppTestTemplate.yml @@ -0,0 +1,41 @@ +name: Function App Test Template + +on: + workflow_call: + inputs: + python_version: + required: true + type: string + default: "3.10" + description: "Specifies the python version." + function_directory: + required: true + type: string + description: "Specifies the directory of the Azure Function." + +jobs: + deployment: + name: Function App Test + runs-on: ubuntu-latest + continue-on-error: false + + steps: + # Setup Python 3.10 + - name: Setup Python 3.10 + id: python_setup + uses: actions/setup-python@v4 + with: + python-version: ${{ inputs.python_version }} + + # Check Out 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 ${{ inputs.function_directory }}/requirements.txt -q + pip install -r requirements.txt -q + pytest diff --git a/.github/workflows/functionApp.yml b/.github/workflows/functionApp.yml new file mode 100644 index 0000000..ab7c056 --- /dev/null +++ b/.github/workflows/functionApp.yml @@ -0,0 +1,37 @@ +name: Function App Deployment +on: + push: + branches: + - main + paths: + - "**.py" + + pull_request: + branches: + - main + paths: + - "**.py" + +jobs: + function_test: + uses: ./.github/workflows/_functionAppTestTemplate.yml + name: "Function App Test" + with: + python_version: "3.10" + function_directory: "./code/function" + + function_deploy: + uses: ./.github/workflows/_functionAppDeployTemplate.yml + name: "Function App Deploy" + needs: [function_test] + if: github.event_name == 'push' || github.event_name == 'release' + with: + environment: "dev" + python_version: "3.10" + function_directory: "./code/function" + function_name: "myfunc-dev-fctn001" + secrets: + TENANT_ID: ${{ secrets.TENANT_ID }} + CLIENT_ID: ${{ secrets.CLIENT_ID }} + CLIENT_SECRET: ${{ secrets.CLIENT_SECRET }} + SUBSCRIPTION_ID: ${{ secrets.SUBSCRIPTION_ID }} diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml deleted file mode 100644 index fba1df9..0000000 --- a/.github/workflows/python.yml +++ /dev/null @@ -1,44 +0,0 @@ -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/code/function/api/v1/api_v1.py b/code/function/api/v1/api_v1.py index 329426e..4c9c462 100644 --- a/code/function/api/v1/api_v1.py +++ b/code/function/api/v1/api_v1.py @@ -2,5 +2,5 @@ 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(sample.router, prefix="/sample", tags=["sample"]) api_v1_router.include_router(heartbeat.router, prefix="/health", tags=["health"]) diff --git a/code/function/api/v1/endpoints/sample.py b/code/function/api/v1/endpoints/sample.py index a04aa45..5f4fc62 100644 --- a/code/function/api/v1/endpoints/sample.py +++ b/code/function/api/v1/endpoints/sample.py @@ -9,9 +9,9 @@ router = APIRouter() -@router.post("/create", response_model=SampleResponse, name="create") +@router.post("/sample", response_model=SampleResponse, name="sample") async def post_predict( data: SampleRequest, ) -> SampleResponse: logger.info(f"Received request: {data}") - return SampleResponse(output=f"Hello ${data.input}") + return SampleResponse(output=f"Hello {data.input}") diff --git a/code/infra/function.tf b/code/infra/function.tf index a0eeaa4..68dc919 100644 --- a/code/infra/function.tf +++ b/code/infra/function.tf @@ -40,7 +40,9 @@ resource "azapi_resource" "function" { scmSiteAlsoStopped = false serverFarmId = azurerm_service_plan.service_plan.id storageAccountRequired = false + vnetContentShareEnabled = true virtualNetworkSubnetId = azapi_resource.subnet_function.id + vnetRouteAllEnabled = true siteConfig = { autoHealEnabled = false acrUseManagedIdentityCreds = false @@ -66,6 +68,10 @@ resource "azapi_resource" "function" { name = "WEBSITE_CONTENTOVERVNET" value = "1" }, + { + name = "WEBSITE_RUN_FROM_PACKAGE" + value = "1" + }, { name = "AzureWebJobsStorage__accountName" value = azurerm_storage_account.storage.name @@ -75,10 +81,10 @@ resource "azapi_resource" "function" { detailedErrorLoggingEnabled = true functionAppScaleLimit = 0 functionsRuntimeScaleMonitoringEnabled = false - ftpsState = "FtpsOnly" + ftpsState = "Disabled" http20Enabled = false ipSecurityRestrictionsDefaultAction = "Deny" - linuxFxVersion = "Python|3.10" + linuxFxVersion = "Python|${var.python_version}" localMySqlEnabled = false loadBalancing = "LeastRequests" minTlsVersion = "1.2" @@ -89,7 +95,6 @@ resource "azapi_resource" "function" { scmIpSecurityRestrictionsUseMain = false scmIpSecurityRestrictionsDefaultAction = "Deny" use32BitWorkerProcess = true - vnetRouteAllEnabled = true vnetPrivatePortsCount = 0 webSocketsEnabled = false } @@ -131,3 +136,25 @@ resource "azurerm_monitor_diagnostic_setting" "diagnostic_setting_function" { } } } + +resource "azurerm_private_endpoint" "function_private_endpoint" { + name = "${azapi_resource.function.name}-pe" + location = var.location + resource_group_name = azurerm_resource_group.app_rg.name + tags = var.tags + + custom_network_interface_name = "${azapi_resource.function.name}-nic" + private_service_connection { + name = "${azapi_resource.function.name}-pe" + is_manual_connection = false + private_connection_resource_id = azapi_resource.function.id + subresource_names = ["sites"] + } + subnet_id = azapi_resource.subnet_services.id + private_dns_zone_group { + name = "${azapi_resource.function.name}-arecord" + private_dns_zone_ids = [ + var.private_dns_zone_id_sites + ] + } +} diff --git a/code/infra/roleassignments.tf b/code/infra/roleassignments.tf index 83ee9e6..86d553b 100644 --- a/code/infra/roleassignments.tf +++ b/code/infra/roleassignments.tf @@ -1,5 +1,11 @@ -resource "azurerm_role_assignment" "role_assignment_storage_function" { +resource "azurerm_role_assignment" "function_role_assignment_storage" { scope = azurerm_storage_account.storage.id role_definition_name = "Storage Blob Data Owner" principal_id = azapi_resource.function.identity[0].principal_id } + +resource "azurerm_role_assignment" "function_role_assignment_key_vault" { + scope = azurerm_key_vault.key_vault.id + role_definition_name = "Key Vault Secrets User" + principal_id = azapi_resource.function.identity[0].principal_id +} diff --git a/code/infra/variables.tf b/code/infra/variables.tf index 57c3fb7..cd90143 100644 --- a/code/infra/variables.tf +++ b/code/infra/variables.tf @@ -33,7 +33,7 @@ variable "tags" { } variable "vnet_id" { - description = "Specifies the resource ID of the Vnet used for the Data Landing Zone" + description = "Specifies the resource ID of the Vnet used for the Azure Function." type = string sensitive = false validation { @@ -43,7 +43,7 @@ variable "vnet_id" { } variable "nsg_id" { - description = "Specifies the resource ID of the default network security group for the Data Landing Zone" + description = "Specifies the resource ID of the default network security group for the Azure Function." type = string sensitive = false validation { @@ -53,7 +53,7 @@ variable "nsg_id" { } variable "route_table_id" { - description = "Specifies the resource ID of the default route table for the Data Landing Zone" + description = "Specifies the resource ID of the default route table for the Azure Function." type = string sensitive = false validation { @@ -62,6 +62,17 @@ variable "route_table_id" { } } +variable "python_version" { + description = "Specifies the python version of the Azure Function." + type = string + sensitive = false + default = "3.10" + validation { + condition = contains(["3.9", "3.10"], var.python_version) + error_message = "Please specify a valid Python version." + } +} + variable "private_dns_zone_id_blob" { description = "Specifies the resource ID of the private DNS zone for Azure Storage blob endpoints. Not required if DNS A-records get created via Azue Policy." type = string @@ -116,3 +127,14 @@ variable "private_dns_zone_id_key_vault" { error_message = "Please specify a valid resource ID for the private DNS Zone." } } + +variable "private_dns_zone_id_sites" { + description = "Specifies the resource ID of the private DNS zone for Azure Websites. Not required if DNS A-records get created via Azue Policy." + type = string + sensitive = false + default = "" + validation { + condition = var.private_dns_zone_id_sites == "" || (length(split("/", var.private_dns_zone_id_sites)) == 9 && endswith(var.private_dns_zone_id_sites, "privatelink.azurewebsites.net")) + error_message = "Please specify a valid resource ID for the private DNS Zone." + } +} diff --git a/code/infra/vars.dev.tfvars b/code/infra/vars.dev.tfvars index 89fce27..7af9c13 100644 --- a/code/infra/vars.dev.tfvars +++ b/code/infra/vars.dev.tfvars @@ -10,3 +10,4 @@ private_dns_zone_id_queue = "/subscriptions/8f171ff9-2b5b-4f0f-aed5-7fa360a1 private_dns_zone_id_table = "/subscriptions/8f171ff9-2b5b-4f0f-aed5-7fa360a1d094/resourceGroups/mycrp-prd-global-dns/providers/Microsoft.Network/privateDnsZones/privatelink.table.core.windows.net" private_dns_zone_id_file = "/subscriptions/8f171ff9-2b5b-4f0f-aed5-7fa360a1d094/resourceGroups/mycrp-prd-global-dns/providers/Microsoft.Network/privateDnsZones/privatelink.file.core.windows.net" private_dns_zone_id_key_vault = "/subscriptions/8f171ff9-2b5b-4f0f-aed5-7fa360a1d094/resourceGroups/mycrp-prd-global-dns/providers/Microsoft.Network/privateDnsZones/privatelink.vaultcore.azure.net" +private_dns_zone_id_sites = "/subscriptions/8f171ff9-2b5b-4f0f-aed5-7fa360a1d094/resourceGroups/mycrp-prd-global-dns/providers/Microsoft.Network/privateDnsZones/privatelink.azurewebsites.net" diff --git a/tests/test_main.py b/tests/test_main.py index 86e67e6..f1b1ffb 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -20,3 +20,16 @@ def test_get_heartbeat(client, version): # assert assert response.status_code == 200 assert response.json() == {"isAlive": True} + + +@pytest.mark.parametrize("version", ("v1",)) +def test_post_sample(client, version): + # arrange + path = f"/{version}/sample/sample" + + # action + response = client.post(path, json={"input": "Test"}) + + # assert + assert response.status_code == 200 + assert response.json() == {"output": "Hello Test"}