44
55import json
66import logging
7- import os
8- import subprocess
97import tempfile
108import time
9+ import urllib .request
1110from pathlib import Path
1211from subprocess import CalledProcessError
1312from types import TracebackType
1918log = logging .getLogger (__name__ )
2019log .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
2333class 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
143150class 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