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+ _DEPLOY_STATUS_NAMES = {
32+ 0 : "Pending" ,
33+ 1 : "Building" ,
34+ 2 : "Deploying" ,
35+ _DEPLOY_STATUS_FAILED : "Failed" ,
36+ _DEPLOY_STATUS_SUCCESS : "Success" ,
37+ }
38+
2239
2340class FuncApp :
2441 """Basic class for managing function apps."""
@@ -36,6 +53,12 @@ def __init__(
3653 self .output_path = output_path
3754 self .subscription = subscription
3855
56+ def build_function_zip (self ) -> None :
57+ """Write the function app package to the output path."""
58+ with ZipFile (self .output_path , "w" ) as zipf :
59+ for path in FUNCTION_APP_FILES :
60+ zipf .write (path , path .name )
61+
3962 def wait_for_event_trigger (self ) -> None :
4063 """Wait until the function app has an eventGridTrigger function."""
4164 cmd = AzCmdJson (
@@ -103,16 +126,7 @@ def __init__(
103126 name , resource_group , Path (self .tempfile .name ), subscription = subscription
104127 )
105128
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-
129+ self .build_function_zip ()
116130 self .tempfile .close ()
117131
118132 def deploy (self ) -> None :
@@ -141,56 +155,91 @@ def deploy(self) -> None:
141155
142156
143157class FuncAppBundle (FuncApp ):
144- """Publishes the function app using the core-tools tooling."""
158+ """Publishes the function app via the Azure "One Deploy" endpoint.
159+
160+ Used when shared-key access is disabled. Both 'az functionapp deployment
161+ source config-zip' and 'az functionapp deploy' insist on fetching SCM basic
162+ publishing credentials, which are disabled on such apps, so they fail with
163+ HTTP 403. Instead we POST the package straight to the One Deploy endpoint
164+ with an AAD bearer token, which is accepted. This needs only 'az' (for the
165+ token); no Docker image or Azure Functions Core Tools are required.
166+ """
167+
168+ # Poll the deployment for at most this long (remote build can be slow).
169+ _DEPLOY_TIMEOUT_S = 600
170+ _DEPLOY_POLL_INTERVAL_S = 15
145171
146172 def __init__ (
147173 self , name : str , resource_group : str , subscription : Optional [str ] = None
148174 ) -> None :
149175 """Create a FuncAppBundle object."""
176+ self .tempfile = tempfile .NamedTemporaryFile (suffix = ".zip" , delete = False )
150177 super ().__init__ (
151- name , resource_group , Path ("function_app.zip" ), subscription = subscription
178+ name , resource_group , Path (self . tempfile . name ), subscription = subscription
152179 )
180+ self .build_function_zip ()
181+ self .tempfile .close ()
182+
183+ def _access_token (self ) -> str :
184+ """Get an AAD access token for the deployment endpoint."""
185+ token : str = AzCmdJson (
186+ ["az" , "account" , "get-access-token" , "--query" , "accessToken" ],
187+ subscription = self .subscription ,
188+ ).run ()
189+ return token
153190
154191 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 })
192+ """Deploy the function app via One Deploy with a bearer token."""
193+ log .info ("Deploying function app code to %s" , self .name )
194+ token = self ._access_token ()
195+
196+ data = self .output_path .read_bytes ()
197+ # RemoteBuild=true runs the build on Azure (Oryx) at the app's own
198+ # runtime, so nothing local needs to match the target Python version.
199+ url = (
200+ f"https://{ self .name } .scm.azurewebsites.net/api/publish"
201+ "?type=zip&RemoteBuild=true"
202+ )
203+ request = urllib .request .Request ( # noqa: S310
204+ url ,
205+ data = data ,
206+ method = "POST" ,
207+ headers = {
208+ "Authorization" : f"Bearer { token } " ,
209+ "Content-Type" : "application/zip" ,
210+ },
211+ )
212+ with urllib .request .urlopen (request ) as response : # noqa: S310
213+ log .info ("Deployment accepted (HTTP %s), awaiting build" , response .status )
214+
215+ self ._wait_for_deployment (token )
196216 log .info ("Function app code published to %s" , self .name )
217+
218+ def _wait_for_deployment (self , token : str ) -> None :
219+ """Poll the latest deployment until it succeeds, or raise on failure."""
220+ url = f"https://{ self .name } .scm.azurewebsites.net/api/deployments/latest"
221+ deadline = time .monotonic () + self ._DEPLOY_TIMEOUT_S
222+ while time .monotonic () < deadline :
223+ request = urllib .request .Request ( # noqa: S310
224+ url , headers = {"Authorization" : f"Bearer { token } " }
225+ )
226+ with urllib .request .urlopen (request ) as response : # noqa: S310
227+ info = json .loads (response .read ())
228+
229+ status = info .get ("status" )
230+ status_name = _DEPLOY_STATUS_NAMES .get (status , f"Unknown({ status } )" )
231+ log .info (
232+ "Deployment status: %s %s" , status_name , info .get ("status_text" , "" )
233+ )
234+ if status == _DEPLOY_STATUS_SUCCESS :
235+ return
236+ if status == _DEPLOY_STATUS_FAILED :
237+ raise RuntimeError (
238+ f"Deployment of { self .name } failed: { info .get ('status_text' , '' )} "
239+ )
240+ time .sleep (self ._DEPLOY_POLL_INTERVAL_S )
241+
242+ raise TimeoutError (
243+ f"Deployment of { self .name } did not complete within "
244+ f"{ self ._DEPLOY_TIMEOUT_S } s"
245+ )
0 commit comments