Skip to content

Commit acec0de

Browse files
committed
feat: deploy no-shared-keys apps via One Deploy, dropping Docker
The FuncAppBundle path (used when shared-key access is disabled) previously ran 'func azure functionapp publish' inside the core-tools Docker image. Both 'az functionapp deployment source config-zip' and 'az functionapp deploy' route through SCM basic auth, which is disabled on these apps (HTTP 403), which is why Docker + func was used. Instead, POST the zip straight to the One Deploy endpoint (/api/publish) with an AAD bearer token via urllib, then poll /api/deployments/latest. This needs only the Azure CLI (for the token) plus the standard library -- no Docker and no Azure Functions Core Tools, so an ordinary user can run it. Share the zip-building between FuncAppZip and FuncAppBundle via build_function_zip(). Update README to drop the Docker requirement. Verified end-to-end: fresh no-shared-keys + signing deploy succeeds, app runs 3.13, and the GPG Key Vault reference resolves.
1 parent acec0de commit acec0de

2 files changed

Lines changed: 98 additions & 61 deletions

File tree

README.md

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,6 @@ an Azure Function App to keep it up to date. For use with
1818
- Azure CLI
1919
- Installation instructions available at https://learn.microsoft.com/en-us/cli/azure/install-azure-cli
2020

21-
- Docker (when not using shared key access from the function app to the storage container)
22-
- Installation instructions available at https://docs.docker.com/engine/install/
23-
2421
## Basic usage
2522

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

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

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

src/apt_package_function/func_app.py

Lines changed: 94 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,9 @@
44

55
import json
66
import logging
7-
import os
8-
import subprocess
97
import tempfile
108
import time
9+
import urllib.request
1110
from pathlib import Path
1211
from subprocess import CalledProcessError
1312
from types import TracebackType
@@ -19,6 +18,17 @@
1918
log = logging.getLogger(__name__)
2019
log.addHandler(logging.NullHandler())
2120

21+
# The files that make up the deployable function app package.
22+
FUNCTION_APP_FILES = [
23+
Path("host.json"),
24+
Path("requirements.txt"),
25+
Path("function_app.py"),
26+
]
27+
28+
# Kudu deployment status codes (from /api/deployments/latest).
29+
_DEPLOY_STATUS_FAILED = 3
30+
_DEPLOY_STATUS_SUCCESS = 4
31+
2232

2333
class FuncApp:
2434
"""Basic class for managing function apps."""
@@ -36,6 +46,12 @@ def __init__(
3646
self.output_path = output_path
3747
self.subscription = subscription
3848

49+
def build_function_zip(self) -> None:
50+
"""Write the function app package to the output path."""
51+
with ZipFile(self.output_path, "w") as zipf:
52+
for path in FUNCTION_APP_FILES:
53+
zipf.write(path, path.name)
54+
3955
def wait_for_event_trigger(self) -> None:
4056
"""Wait until the function app has an eventGridTrigger function."""
4157
cmd = AzCmdJson(
@@ -103,16 +119,7 @@ def __init__(
103119
name, resource_group, Path(self.tempfile.name), subscription=subscription
104120
)
105121

106-
self.zip_paths = [
107-
Path("host.json"),
108-
Path("requirements.txt"),
109-
Path("function_app.py"),
110-
]
111-
112-
with ZipFile(self.tempfile, "w") as zipf:
113-
for path in self.zip_paths:
114-
zipf.write(path, path.name)
115-
122+
self.build_function_zip()
116123
self.tempfile.close()
117124

118125
def deploy(self) -> None:
@@ -141,56 +148,88 @@ def deploy(self) -> None:
141148

142149

143150
class FuncAppBundle(FuncApp):
144-
"""Publishes the function app using the core-tools tooling."""
151+
"""Publishes the function app via the Azure "One Deploy" endpoint.
152+
153+
Used when shared-key access is disabled. Both 'az functionapp deployment
154+
source config-zip' and 'az functionapp deploy' insist on fetching SCM basic
155+
publishing credentials, which are disabled on such apps, so they fail with
156+
HTTP 403. Instead we POST the package straight to the One Deploy endpoint
157+
with an AAD bearer token, which is accepted. This needs only 'az' (for the
158+
token); no Docker image or Azure Functions Core Tools are required.
159+
"""
160+
161+
# Poll the deployment for at most this long (remote build can be slow).
162+
_DEPLOY_TIMEOUT_S = 600
163+
_DEPLOY_POLL_INTERVAL_S = 15
145164

146165
def __init__(
147166
self, name: str, resource_group: str, subscription: Optional[str] = None
148167
) -> None:
149168
"""Create a FuncAppBundle object."""
169+
self.tempfile = tempfile.NamedTemporaryFile(suffix=".zip", delete=False)
150170
super().__init__(
151-
name, resource_group, Path("function_app.zip"), subscription=subscription
171+
name, resource_group, Path(self.tempfile.name), subscription=subscription
152172
)
173+
self.build_function_zip()
174+
self.tempfile.close()
175+
176+
def _access_token(self) -> str:
177+
"""Get an AAD access token for the deployment endpoint."""
178+
token: str = AzCmdJson(
179+
["az", "account", "get-access-token", "--query", "accessToken"],
180+
subscription=self.subscription,
181+
).run()
182+
return token
153183

154184
def deploy(self) -> None:
155-
"""Deploy the function application."""
156-
log.info("Deploying function app code")
157-
cwd = Path.cwd()
158-
159-
# Mint an access token on the host: the host 'az' can read its own
160-
# credential cache, whereas the (older) 'az' inside the core-tools image
161-
# cannot deserialise a cache written by a newer host 'az'. So instead of
162-
# mounting ~/.azure into the container, pass a token to 'func' directly.
163-
token_cmd = ["az", "account", "get-access-token", "--query", "accessToken"]
164-
token = AzCmdJson(token_cmd, subscription=self.subscription).run()
165-
166-
# Pass the token via the environment so it never appears in the logged
167-
# command line or the container's process arguments.
168-
func_cmd = f'func azure functionapp publish {self.name} --python --build remote --access-token "$FUNC_ACCESS_TOKEN"'
169-
if self.subscription:
170-
func_cmd += f" --subscription {self.subscription}"
171-
172-
# Publish the application using the core-tools tooling.
173-
#
174-
# The image's Python version need not match the app runtime: with
175-
# '--build remote' the build runs on Azure (Oryx) at the target
176-
# runtime, and this image only packages and uploads. 3.11 is the newest
177-
# published core-tools image (no 3.12/3.13 exists), and it deploys
178-
# 3.13 apps fine.
179-
cmd = [
180-
"docker",
181-
"run",
182-
"--rm",
183-
"-e",
184-
"FUNC_ACCESS_TOKEN",
185-
"-v",
186-
f"{cwd}:/function_app",
187-
"-w",
188-
"/function_app",
189-
"mcr.microsoft.com/azure-functions/python:4-python3.11-core-tools",
190-
"bash",
191-
"-c",
192-
func_cmd,
193-
]
194-
log.debug("Running %s", cmd)
195-
subprocess.run(cmd, check=True, env={**os.environ, "FUNC_ACCESS_TOKEN": token})
185+
"""Deploy the function app via One Deploy with a bearer token."""
186+
log.info("Deploying function app code to %s", self.name)
187+
token = self._access_token()
188+
189+
data = self.output_path.read_bytes()
190+
# RemoteBuild=true runs the build on Azure (Oryx) at the app's own
191+
# runtime, so nothing local needs to match the target Python version.
192+
url = (
193+
f"https://{self.name}.scm.azurewebsites.net/api/publish"
194+
"?type=zip&RemoteBuild=true"
195+
)
196+
request = urllib.request.Request( # noqa: S310
197+
url,
198+
data=data,
199+
method="POST",
200+
headers={
201+
"Authorization": f"Bearer {token}",
202+
"Content-Type": "application/zip",
203+
},
204+
)
205+
with urllib.request.urlopen(request) as response: # noqa: S310
206+
log.info("Deployment accepted (HTTP %s), awaiting build", response.status)
207+
208+
self._wait_for_deployment(token)
196209
log.info("Function app code published to %s", self.name)
210+
211+
def _wait_for_deployment(self, token: str) -> None:
212+
"""Poll the latest deployment until it succeeds, or raise on failure."""
213+
url = f"https://{self.name}.scm.azurewebsites.net/api/deployments/latest"
214+
deadline = time.monotonic() + self._DEPLOY_TIMEOUT_S
215+
while time.monotonic() < deadline:
216+
request = urllib.request.Request( # noqa: S310
217+
url, headers={"Authorization": f"Bearer {token}"}
218+
)
219+
with urllib.request.urlopen(request) as response: # noqa: S310
220+
info = json.loads(response.read())
221+
222+
status = info.get("status")
223+
log.info("Deployment status: %s (%s)", status, info.get("status_text", ""))
224+
if status == _DEPLOY_STATUS_SUCCESS:
225+
return
226+
if status == _DEPLOY_STATUS_FAILED:
227+
raise RuntimeError(
228+
f"Deployment of {self.name} failed: {info.get('status_text', '')}"
229+
)
230+
time.sleep(self._DEPLOY_POLL_INTERVAL_S)
231+
232+
raise TimeoutError(
233+
f"Deployment of {self.name} did not complete within "
234+
f"{self._DEPLOY_TIMEOUT_S}s"
235+
)

0 commit comments

Comments
 (0)