Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11"]
python-version: ["3.13"]
steps:
- uses: actions/checkout@v7
- name: Set up Python ${{ matrix.python-version }}
Expand Down
10 changes: 4 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,6 @@ an Azure Function App to keep it up to date. For use with
- Azure CLI
- Installation instructions available at https://learn.microsoft.com/en-us/cli/azure/install-azure-cli

- Docker (when not using shared key access from the function app to the storage container)
- Installation instructions available at https://docs.docker.com/engine/install/

## Basic usage

To create a new Debian package repository with an Azure Function App, run
Expand Down Expand Up @@ -50,9 +47,10 @@ poetry run create-resources --location uksouth <resource_group_name>
## No shared-key access / Managed Identities

By default, the storage container that is created has shared-key access enabled.
You can instead create a deployment that uses Managed Identities, but this
requires Docker (as the function application and its dependencies must be
compiled and packed appropriately).
You can instead create a deployment that uses Managed Identities. In this mode
the function code is published with an Azure AD token via the "One Deploy"
endpoint (shared-key and SCM basic-auth publishing are both disabled), so only
the Azure CLI is required — no Docker or Azure Functions Core Tools.

To create a new Debian package repository which uses Managed Identities, run

Expand Down
452 changes: 99 additions & 353 deletions poetry.lock

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ license = "MIT"
version = "1.0.0"
readme = "README.md"
authors = [{name = "Max Dymond", email = "max.dymond@alianza.com"}]
requires-python = '>=3.9.2,<4.0.0'
requires-python = '>=3.13,<4.0.0'
dependencies = [
'azure-functions (>=1.21.3,<2.0.0)',
'azure-functions (>=2.2.0,<2.3.0)',
'azure-identity (>=1.19.0,<2.0.0)',
'azure-storage-blob (>=12.23.1,<13.0.0)',
'pydpkg (>=1.9.3,<2.0.0)',
Expand All @@ -29,7 +29,7 @@ requires-poetry = '>=2.0'
poetry-plugin-export = ">=1.8"

[tool.poetry.group.dev.dependencies]
mypy = "^1"
mypy = "^2.0.0"
ruff = "^0.15.0"

[build-system]
Expand Down
8 changes: 4 additions & 4 deletions rg.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ var common_storage_properties = {
var storage_properties = use_shared_keys ? common_storage_properties : union(common_storage_properties, {
allowSharedKeyAccess: false
})
resource storageAccount 'Microsoft.Storage/storageAccounts@2025-06-01' = {
resource storageAccount 'Microsoft.Storage/storageAccounts@2025-08-01' = {
name: storage_account_name
location: location
kind: 'StorageV2'
Expand All @@ -63,17 +63,17 @@ resource storageAccount 'Microsoft.Storage/storageAccounts@2025-06-01' = {
}

// Create a container for the packages
resource defBlobServices 'Microsoft.Storage/storageAccounts/blobServices@2025-06-01' = {
resource defBlobServices 'Microsoft.Storage/storageAccounts/blobServices@2025-08-01' = {
parent: storageAccount
name: 'default'
}
resource packageContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2025-06-01' = {
resource packageContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2025-08-01' = {
parent: defBlobServices
name: package_container_name
properties: {
}
}
resource pythonContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2025-06-01' = {
resource pythonContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2025-08-01' = {
parent: defBlobServices
name: python_container_name
properties: {
Expand Down
6 changes: 3 additions & 3 deletions rg_add_eventgrid.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,14 @@ param storage_account_name string = 'debianrepo${suffix}'
var package_container_name = 'packages'

// Define all existing resources
resource storageAccount 'Microsoft.Storage/storageAccounts@2025-06-01' existing = {
resource storageAccount 'Microsoft.Storage/storageAccounts@2025-08-01' existing = {
name: storage_account_name
}
resource defBlobServices 'Microsoft.Storage/storageAccounts/blobServices@2025-06-01' existing = {
resource defBlobServices 'Microsoft.Storage/storageAccounts/blobServices@2025-08-01' existing = {
parent: storageAccount
name: 'default'
}
resource packageContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2025-06-01' existing = {
resource packageContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2025-08-01' existing = {
parent: defBlobServices
name: package_container_name
}
Expand Down
10 changes: 5 additions & 5 deletions rg_funcapp.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ var python_container_name = 'python'
var storage_connection_string = 'DefaultEndpointsProtocol=https;AccountName=${storageAccount.name};EndpointSuffix=${environment().suffixes.storage};AccountKey=${storageAccount.listKeys().keys[0].value}'

// The version of Python to run with
var python_version = '3.11'
var python_version = '3.13'

// The name of the hosting plan, application insights, and function app
var functionAppName = appName
Expand All @@ -50,18 +50,18 @@ var applicationInsightsName = appName
resource uami 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' existing = {
name: 'uami${suffix}'
}
resource storageAccount 'Microsoft.Storage/storageAccounts@2025-06-01' existing = {
resource storageAccount 'Microsoft.Storage/storageAccounts@2025-08-01' existing = {
name: storage_account_name
}
resource defBlobServices 'Microsoft.Storage/storageAccounts/blobServices@2025-06-01' existing = {
resource defBlobServices 'Microsoft.Storage/storageAccounts/blobServices@2025-08-01' existing = {
parent: storageAccount
name: 'default'
}
resource packageContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2025-06-01' existing = {
resource packageContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2025-08-01' existing = {
parent: defBlobServices
name: package_container_name
}
resource pythonContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2025-06-01' existing = {
resource pythonContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2025-08-01' existing = {
parent: defBlobServices
name: python_container_name
}
Expand Down
4 changes: 2 additions & 2 deletions ruff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ exclude = [
line-length = 88
indent-width = 4

# Assume Python 3.9
target-version = "py39"
# Assume Python 3.13
target-version = "py313"

[lint]
extend-select = [
Expand Down
156 changes: 107 additions & 49 deletions src/apt_package_function/func_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@

import json
import logging
import os
import subprocess
import tempfile
import time
import urllib.request
from enum import IntEnum
from pathlib import Path
from subprocess import CalledProcessError
from types import TracebackType
Expand All @@ -19,6 +19,23 @@
log = logging.getLogger(__name__)
log.addHandler(logging.NullHandler())

# The files that make up the deployable function app package.
FUNCTION_APP_FILES = [
Path("host.json"),
Path("requirements.txt"),
Path("function_app.py"),
]


class DeployStatus(IntEnum):
"""Kudu deployment status codes (from /api/deployments/latest)."""

PENDING = 0
BUILDING = 1
DEPLOYING = 2
FAILED = 3
SUCCESS = 4


class FuncApp:
"""Basic class for managing function apps."""
Expand All @@ -36,6 +53,12 @@ def __init__(
self.output_path = output_path
self.subscription = subscription

def build_function_zip(self) -> None:
"""Write the function app package to the output path."""
with ZipFile(self.output_path, "w") as zipf:
for path in FUNCTION_APP_FILES:
zipf.write(path, path.name)

def wait_for_event_trigger(self) -> None:
"""Wait until the function app has an eventGridTrigger function."""
cmd = AzCmdJson(
Expand Down Expand Up @@ -103,16 +126,7 @@ def __init__(
name, resource_group, Path(self.tempfile.name), subscription=subscription
)

self.zip_paths = [
Path("host.json"),
Path("requirements.txt"),
Path("function_app.py"),
]

with ZipFile(self.tempfile, "w") as zipf:
for path in self.zip_paths:
zipf.write(path, path.name)

self.build_function_zip()
self.tempfile.close()

def deploy(self) -> None:
Expand Down Expand Up @@ -141,50 +155,94 @@ def deploy(self) -> None:


class FuncAppBundle(FuncApp):
"""Publishes the function app using the core-tools tooling."""
"""Publishes the function app via the Azure "One Deploy" endpoint.

Used when shared-key access is disabled. Both 'az functionapp deployment
source config-zip' and 'az functionapp deploy' insist on fetching SCM basic
publishing credentials, which are disabled on such apps, so they fail with
HTTP 403. Instead we POST the package straight to the One Deploy endpoint
with an AAD bearer token, which is accepted. This needs only 'az' (for the
token); no Docker image or Azure Functions Core Tools are required.
"""

# Poll the deployment for at most this long (remote build can be slow).
_DEPLOY_TIMEOUT_S = 600
_DEPLOY_POLL_INTERVAL_S = 15

def __init__(
self, name: str, resource_group: str, subscription: Optional[str] = None
) -> None:
"""Create a FuncAppBundle object."""
self.tempfile = tempfile.NamedTemporaryFile(suffix=".zip", delete=False)
super().__init__(
name, resource_group, Path("function_app.zip"), subscription=subscription
name, resource_group, Path(self.tempfile.name), subscription=subscription
)
self.build_function_zip()
self.tempfile.close()

def _access_token(self) -> str:
"""Get an AAD access token for the deployment endpoint."""
token: str = AzCmdJson(
["az", "account", "get-access-token", "--query", "accessToken"],
subscription=self.subscription,
).run()
return token

def deploy(self) -> None:
"""Deploy the function application."""
log.info("Deploying function app code")
cwd = Path.cwd()

# Mint an access token on the host: the host 'az' can read its own
# credential cache, whereas the (older) 'az' inside the core-tools image
# cannot deserialise a cache written by a newer host 'az'. So instead of
# mounting ~/.azure into the container, pass a token to 'func' directly.
token_cmd = ["az", "account", "get-access-token", "--query", "accessToken"]
token = AzCmdJson(token_cmd, subscription=self.subscription).run()

# Pass the token via the environment so it never appears in the logged
# command line or the container's process arguments.
func_cmd = f'func azure functionapp publish {self.name} --python --build remote --access-token "$FUNC_ACCESS_TOKEN"'
if self.subscription:
func_cmd += f" --subscription {self.subscription}"

# Publish the application using the core-tools tooling
cmd = [
"docker",
"run",
"--rm",
"-e",
"FUNC_ACCESS_TOKEN",
"-v",
f"{cwd}:/function_app",
"-w",
"/function_app",
"mcr.microsoft.com/azure-functions/python:4-python3.11-core-tools",
"bash",
"-c",
func_cmd,
]
log.debug("Running %s", cmd)
subprocess.run(cmd, check=True, env={**os.environ, "FUNC_ACCESS_TOKEN": token})
"""Deploy the function app via One Deploy with a bearer token."""
log.info("Deploying function app code to %s", self.name)
token = self._access_token()

data = self.output_path.read_bytes()
# RemoteBuild=true runs the build on Azure (Oryx) at the app's own
# runtime, so nothing local needs to match the target Python version.
url = (
f"https://{self.name}.scm.azurewebsites.net/api/publish"
"?type=zip&RemoteBuild=true"
)
request = urllib.request.Request( # noqa: S310
url,
data=data,
method="POST",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/zip",
},
)
with urllib.request.urlopen(request) as response: # noqa: S310
log.info("Deployment accepted (HTTP %s), awaiting build", response.status)

self._wait_for_deployment(token)
log.info("Function app code published to %s", self.name)

def _wait_for_deployment(self, token: str) -> None:
"""Poll the latest deployment until it succeeds, or raise on failure."""
url = f"https://{self.name}.scm.azurewebsites.net/api/deployments/latest"
deadline = time.monotonic() + self._DEPLOY_TIMEOUT_S
while time.monotonic() < deadline:
request = urllib.request.Request( # noqa: S310
url, headers={"Authorization": f"Bearer {token}"}
)
with urllib.request.urlopen(request) as response: # noqa: S310
info = json.loads(response.read())

status = info.get("status")
try:
status_name = DeployStatus(status).name
except ValueError:
status_name = f"Unknown({status})"
log.info(
"Deployment status: %s %s", status_name, info.get("status_text", "")
)
if status == DeployStatus.SUCCESS:
return
if status == DeployStatus.FAILED:
raise RuntimeError(
f"Deployment of {self.name} failed: {info.get('status_text', '')}"
)
time.sleep(self._DEPLOY_POLL_INTERVAL_S)

raise TimeoutError(
f"Deployment of {self.name} did not complete within "
f"{self._DEPLOY_TIMEOUT_S}s"
)