fpu-barometer-admin 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fpu_barometer_admin/__init__.py +6 -0
- fpu_barometer_admin/cli/__init__.py +5 -0
- fpu_barometer_admin/cli/commands.py +199 -0
- fpu_barometer_admin/cli/deploy.py +719 -0
- fpu_barometer_admin/connectors/__init__.py +56 -0
- fpu_barometer_admin/connectors/acled_connector.py +77 -0
- fpu_barometer_admin/connectors/base_connector.py +60 -0
- fpu_barometer_admin/connectors/cpj_connector.py +92 -0
- fpu_barometer_admin/connectors/ert_connector.py +134 -0
- fpu_barometer_admin/connectors/gdelt_connector.py +403 -0
- fpu_barometer_admin/connectors/mfrr_connector.py +171 -0
- fpu_barometer_admin/connectors/rr_connector.py +84 -0
- fpu_barometer_admin/connectors/static_sources.py +41 -0
- fpu_barometer_admin/connectors/vdem_connector.py +165 -0
- fpu_barometer_admin/handlers/__init__.py +6 -0
- fpu_barometer_admin/handlers/function_app.py +543 -0
- fpu_barometer_admin/processors/__init__.py +46 -0
- fpu_barometer_admin/processors/acled_processor.py +263 -0
- fpu_barometer_admin/processors/base_processor.py +23 -0
- fpu_barometer_admin/processors/cpj_processor.py +147 -0
- fpu_barometer_admin/processors/ert_processor.py +72 -0
- fpu_barometer_admin/processors/gdelt_processor.py +260 -0
- fpu_barometer_admin/processors/mfrr_processor.py +327 -0
- fpu_barometer_admin/processors/rr_processor.py +208 -0
- fpu_barometer_admin/processors/vdem_processor.py +70 -0
- fpu_barometer_admin/runners/__init__.py +19 -0
- fpu_barometer_admin/runners/definitions.py +159 -0
- fpu_barometer_admin/runners/runners.py +291 -0
- fpu_barometer_admin/runners/scheduler.py +148 -0
- fpu_barometer_admin/runners/seed.py +399 -0
- fpu_barometer_admin/schemas/__init__.py +1 -0
- fpu_barometer_admin/schemas/event.py +362 -0
- fpu_barometer_admin/schemas/predictor.py +418 -0
- fpu_barometer_admin/storage/__init__.py +39 -0
- fpu_barometer_admin/storage/catalog.py +359 -0
- fpu_barometer_admin/storage/factory.py +165 -0
- fpu_barometer_admin/storage/objects.py +463 -0
- fpu_barometer_admin/storage/reader.py +410 -0
- fpu_barometer_admin-0.3.0.dist-info/METADATA +27 -0
- fpu_barometer_admin-0.3.0.dist-info/RECORD +43 -0
- fpu_barometer_admin-0.3.0.dist-info/WHEEL +4 -0
- fpu_barometer_admin-0.3.0.dist-info/entry_points.txt +2 -0
- fpu_barometer_admin-0.3.0.dist-info/licenses/LICENSE.md +7 -0
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
"""Azure deployment helpers for the FPU admin CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
import uuid
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Sequence
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
|
|
18
|
+
from fpu_barometer_admin.connectors.static_sources import STATIC_SOURCE_FILES
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class AzureDeployConfig:
|
|
23
|
+
resource_group: str = "fpu-research-rg"
|
|
24
|
+
location: str = "westeurope"
|
|
25
|
+
environment: str = "prod"
|
|
26
|
+
storage_container: str = "datasets"
|
|
27
|
+
project_root: Path | None = None
|
|
28
|
+
python_version: str = "3.10"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class AzureDeploymentResult:
|
|
33
|
+
resource_group: str
|
|
34
|
+
location: str
|
|
35
|
+
environment: str
|
|
36
|
+
storage_account: str
|
|
37
|
+
storage_container: str
|
|
38
|
+
function_app: str
|
|
39
|
+
function_app_url: str
|
|
40
|
+
config_path: Path
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class AzureCodeRedeployConfig:
|
|
45
|
+
resource_group: str | None = None
|
|
46
|
+
function_app: str | None = None
|
|
47
|
+
project_root: Path | None = None
|
|
48
|
+
scheduler_cron: str = "0 15 2 * * *"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class AzureCodeRedeployResult:
|
|
53
|
+
resource_group: str
|
|
54
|
+
function_app: str
|
|
55
|
+
function_app_url: str
|
|
56
|
+
scheduler_cron: str
|
|
57
|
+
config_path: Path
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def generate_unique_name(
|
|
61
|
+
base_name: str, max_length: int = 60, *, allow_dash: bool = True
|
|
62
|
+
) -> str:
|
|
63
|
+
"""Generate an Azure-safe unique resource name."""
|
|
64
|
+
|
|
65
|
+
suffix = uuid.uuid4().hex[:8]
|
|
66
|
+
normalized = base_name.lower()
|
|
67
|
+
if not allow_dash:
|
|
68
|
+
normalized = "".join(
|
|
69
|
+
character for character in normalized if character.isalnum()
|
|
70
|
+
)
|
|
71
|
+
separator = ""
|
|
72
|
+
else:
|
|
73
|
+
normalized = "".join(
|
|
74
|
+
character
|
|
75
|
+
for character in normalized
|
|
76
|
+
if character.isalnum() or character == "-"
|
|
77
|
+
).strip("-")
|
|
78
|
+
separator = "-"
|
|
79
|
+
available_length = max_length - len(separator) - len(suffix)
|
|
80
|
+
return f"{normalized[:available_length].rstrip('-')}{separator}{suffix}"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def check_prerequisites() -> dict[str, bool]:
|
|
84
|
+
"""Check local tools needed to deploy to Azure."""
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
"azure_cli": _command_available(["az", "--version"]),
|
|
88
|
+
"func_tools": _command_available(["func", "--version"]),
|
|
89
|
+
"python_build": _command_available(["python", "-m", "build", "--version"]),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def check_azure_login() -> bool:
|
|
94
|
+
"""Return whether Azure CLI has an active login."""
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
_run_command(["az", "account", "show"], "checking Azure CLI login", timeout=30)
|
|
98
|
+
except RuntimeError:
|
|
99
|
+
return False
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def wait_for_function_app(
|
|
104
|
+
function_app_name: str, resource_group: str, max_wait_seconds: int = 60
|
|
105
|
+
) -> bool:
|
|
106
|
+
"""Wait until Azure reports the Function App as running and reachable."""
|
|
107
|
+
|
|
108
|
+
print("Waiting for Function App to become ready...")
|
|
109
|
+
started = time.time()
|
|
110
|
+
while time.time() - started < max_wait_seconds:
|
|
111
|
+
try:
|
|
112
|
+
result = _run_command(
|
|
113
|
+
[
|
|
114
|
+
"az",
|
|
115
|
+
"functionapp",
|
|
116
|
+
"show",
|
|
117
|
+
"--name",
|
|
118
|
+
function_app_name,
|
|
119
|
+
"--resource-group",
|
|
120
|
+
resource_group,
|
|
121
|
+
"--query",
|
|
122
|
+
"state",
|
|
123
|
+
"--output",
|
|
124
|
+
"tsv",
|
|
125
|
+
],
|
|
126
|
+
"checking function app state",
|
|
127
|
+
timeout=30,
|
|
128
|
+
)
|
|
129
|
+
if result.stdout.strip() == "Running":
|
|
130
|
+
response = requests.head(
|
|
131
|
+
f"https://{function_app_name}.azurewebsites.net", timeout=5
|
|
132
|
+
)
|
|
133
|
+
if response.status_code in {200, 401, 403, 404}:
|
|
134
|
+
return True
|
|
135
|
+
except Exception:
|
|
136
|
+
pass
|
|
137
|
+
time.sleep(1)
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def stage_function_app(wheel_path: Path | Sequence[Path], staging_dir: Path) -> Path:
|
|
142
|
+
"""Create a deployable Azure Functions root around built package wheels."""
|
|
143
|
+
|
|
144
|
+
wheel_paths = [wheel_path] if isinstance(wheel_path, Path) else list(wheel_path)
|
|
145
|
+
if not wheel_paths:
|
|
146
|
+
raise RuntimeError("No wheels supplied for Function App staging")
|
|
147
|
+
if staging_dir.exists():
|
|
148
|
+
shutil.rmtree(staging_dir)
|
|
149
|
+
staging_dir.mkdir(parents=True)
|
|
150
|
+
|
|
151
|
+
for wheel in wheel_paths:
|
|
152
|
+
shutil.copy2(wheel, staging_dir / wheel.name)
|
|
153
|
+
(staging_dir / "function_app.py").write_text(
|
|
154
|
+
"from fpu_barometer_admin.handlers.function_app import app\n", encoding="utf-8"
|
|
155
|
+
)
|
|
156
|
+
(staging_dir / "host.json").write_text(
|
|
157
|
+
json.dumps(
|
|
158
|
+
{
|
|
159
|
+
"version": "2.0",
|
|
160
|
+
"extensionBundle": {
|
|
161
|
+
"id": "Microsoft.Azure.Functions.ExtensionBundle",
|
|
162
|
+
"version": "[4.*, 5.0.0)",
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
indent=2,
|
|
166
|
+
)
|
|
167
|
+
+ "\n",
|
|
168
|
+
encoding="utf-8",
|
|
169
|
+
)
|
|
170
|
+
requirement_lines = [f"./{wheel.name}" for wheel in wheel_paths]
|
|
171
|
+
requirement_lines[-1] = f"{requirement_lines[-1]}[admin]"
|
|
172
|
+
(staging_dir / "requirements.txt").write_text(
|
|
173
|
+
"\n".join(requirement_lines) + "\n", encoding="utf-8"
|
|
174
|
+
)
|
|
175
|
+
return staging_dir
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def deploy_azure(config: AzureDeployConfig) -> AzureDeploymentResult:
|
|
179
|
+
"""Deploy Barometer's Azure Functions API and Blob storage."""
|
|
180
|
+
|
|
181
|
+
_assert_prerequisites()
|
|
182
|
+
if not check_azure_login():
|
|
183
|
+
raise RuntimeError("Not logged into Azure CLI. Run 'az login' first.")
|
|
184
|
+
|
|
185
|
+
project_root = _resolve_project_root(config.project_root)
|
|
186
|
+
storage_account = generate_unique_name("barometerdata", 24, allow_dash=False)
|
|
187
|
+
function_app = generate_unique_name(f"barometer-api-{config.environment}", 60)
|
|
188
|
+
function_app_url = f"https://{function_app}.azurewebsites.net"
|
|
189
|
+
|
|
190
|
+
print("Starting Barometer Azure deployment")
|
|
191
|
+
print(f"Resource group: {config.resource_group}")
|
|
192
|
+
print(f"Location: {config.location}")
|
|
193
|
+
print(f"Storage account: {storage_account}")
|
|
194
|
+
print(f"Function app: {function_app}")
|
|
195
|
+
|
|
196
|
+
_run_command(
|
|
197
|
+
[
|
|
198
|
+
"az",
|
|
199
|
+
"group",
|
|
200
|
+
"create",
|
|
201
|
+
"--name",
|
|
202
|
+
config.resource_group,
|
|
203
|
+
"--location",
|
|
204
|
+
config.location,
|
|
205
|
+
"--output",
|
|
206
|
+
"table",
|
|
207
|
+
],
|
|
208
|
+
"creating resource group",
|
|
209
|
+
)
|
|
210
|
+
_run_command(
|
|
211
|
+
[
|
|
212
|
+
"az",
|
|
213
|
+
"storage",
|
|
214
|
+
"account",
|
|
215
|
+
"create",
|
|
216
|
+
"--name",
|
|
217
|
+
storage_account,
|
|
218
|
+
"--resource-group",
|
|
219
|
+
config.resource_group,
|
|
220
|
+
"--location",
|
|
221
|
+
config.location,
|
|
222
|
+
"--sku",
|
|
223
|
+
"Standard_LRS",
|
|
224
|
+
"--kind",
|
|
225
|
+
"StorageV2",
|
|
226
|
+
"--access-tier",
|
|
227
|
+
"Hot",
|
|
228
|
+
"--allow-blob-public-access",
|
|
229
|
+
"false",
|
|
230
|
+
"--enable-hierarchical-namespace",
|
|
231
|
+
"true",
|
|
232
|
+
"--output",
|
|
233
|
+
"table",
|
|
234
|
+
],
|
|
235
|
+
"creating storage account",
|
|
236
|
+
)
|
|
237
|
+
connection_string = _run_command(
|
|
238
|
+
[
|
|
239
|
+
"az",
|
|
240
|
+
"storage",
|
|
241
|
+
"account",
|
|
242
|
+
"show-connection-string",
|
|
243
|
+
"--resource-group",
|
|
244
|
+
config.resource_group,
|
|
245
|
+
"--name",
|
|
246
|
+
storage_account,
|
|
247
|
+
"--query",
|
|
248
|
+
"connectionString",
|
|
249
|
+
"--output",
|
|
250
|
+
"tsv",
|
|
251
|
+
],
|
|
252
|
+
"getting storage connection string",
|
|
253
|
+
).stdout.strip()
|
|
254
|
+
_run_command(
|
|
255
|
+
[
|
|
256
|
+
"az",
|
|
257
|
+
"storage",
|
|
258
|
+
"container",
|
|
259
|
+
"create",
|
|
260
|
+
"--name",
|
|
261
|
+
config.storage_container,
|
|
262
|
+
"--connection-string",
|
|
263
|
+
connection_string,
|
|
264
|
+
"--public-access",
|
|
265
|
+
"off",
|
|
266
|
+
"--output",
|
|
267
|
+
"none",
|
|
268
|
+
],
|
|
269
|
+
f"creating storage container {config.storage_container}",
|
|
270
|
+
)
|
|
271
|
+
upload_static_source_files(
|
|
272
|
+
project_root,
|
|
273
|
+
connection_string=connection_string,
|
|
274
|
+
container_name=config.storage_container,
|
|
275
|
+
)
|
|
276
|
+
_run_command(
|
|
277
|
+
[
|
|
278
|
+
"az",
|
|
279
|
+
"functionapp",
|
|
280
|
+
"create",
|
|
281
|
+
"--name",
|
|
282
|
+
function_app,
|
|
283
|
+
"--resource-group",
|
|
284
|
+
config.resource_group,
|
|
285
|
+
"--consumption-plan-location",
|
|
286
|
+
config.location,
|
|
287
|
+
"--storage-account",
|
|
288
|
+
storage_account,
|
|
289
|
+
"--runtime",
|
|
290
|
+
"python",
|
|
291
|
+
"--runtime-version",
|
|
292
|
+
config.python_version,
|
|
293
|
+
"--os-type",
|
|
294
|
+
"Linux",
|
|
295
|
+
"--functions-version",
|
|
296
|
+
"4",
|
|
297
|
+
],
|
|
298
|
+
"creating function app",
|
|
299
|
+
)
|
|
300
|
+
_run_command(
|
|
301
|
+
[
|
|
302
|
+
"az",
|
|
303
|
+
"functionapp",
|
|
304
|
+
"config",
|
|
305
|
+
"appsettings",
|
|
306
|
+
"set",
|
|
307
|
+
"--name",
|
|
308
|
+
function_app,
|
|
309
|
+
"--resource-group",
|
|
310
|
+
config.resource_group,
|
|
311
|
+
"--settings",
|
|
312
|
+
f"FPU_STORAGE_BACKEND=azure",
|
|
313
|
+
f"FPU_AZURE_STORAGE_CONNECTION_STRING={connection_string}",
|
|
314
|
+
f"FPU_AZURE_STORAGE_CONTAINER={config.storage_container}",
|
|
315
|
+
"FPU_SCHEDULER_CRON=0 15 2 * * *",
|
|
316
|
+
"FUNCTIONS_WORKER_RUNTIME=python",
|
|
317
|
+
"FUNCTIONS_EXTENSION_VERSION=~4",
|
|
318
|
+
"AzureWebJobsFeatureFlags=EnableWorkerIndexing",
|
|
319
|
+
"FUNCTIONS_WORKER_PROCESS_COUNT=1",
|
|
320
|
+
"WEBSITE_MAX_DYNAMIC_APPLICATION_SCALE_OUT=1",
|
|
321
|
+
"--output",
|
|
322
|
+
"table",
|
|
323
|
+
],
|
|
324
|
+
"configuring function app",
|
|
325
|
+
)
|
|
326
|
+
if not wait_for_function_app(function_app, config.resource_group):
|
|
327
|
+
print("Function App readiness check timed out; continuing with deployment.")
|
|
328
|
+
|
|
329
|
+
deploy_workspace = project_root / ".scratch" / "deploy"
|
|
330
|
+
try:
|
|
331
|
+
wheel_paths = build_wheels(project_root)
|
|
332
|
+
staging_dir = stage_function_app(
|
|
333
|
+
wheel_paths, deploy_workspace / "azure-functions"
|
|
334
|
+
)
|
|
335
|
+
_run_command(
|
|
336
|
+
[
|
|
337
|
+
"func",
|
|
338
|
+
"azure",
|
|
339
|
+
"functionapp",
|
|
340
|
+
"publish",
|
|
341
|
+
function_app,
|
|
342
|
+
"--python",
|
|
343
|
+
"--build",
|
|
344
|
+
"remote",
|
|
345
|
+
],
|
|
346
|
+
"publishing function app",
|
|
347
|
+
timeout=900,
|
|
348
|
+
cwd=staging_dir,
|
|
349
|
+
)
|
|
350
|
+
except Exception:
|
|
351
|
+
shutil.rmtree(deploy_workspace, ignore_errors=True)
|
|
352
|
+
raise
|
|
353
|
+
|
|
354
|
+
result = AzureDeploymentResult(
|
|
355
|
+
resource_group=config.resource_group,
|
|
356
|
+
location=config.location,
|
|
357
|
+
environment=config.environment,
|
|
358
|
+
storage_account=storage_account,
|
|
359
|
+
storage_container=config.storage_container,
|
|
360
|
+
function_app=function_app,
|
|
361
|
+
function_app_url=function_app_url,
|
|
362
|
+
config_path=_write_deployment_config(
|
|
363
|
+
project_root,
|
|
364
|
+
config=config,
|
|
365
|
+
storage_account=storage_account,
|
|
366
|
+
function_app=function_app,
|
|
367
|
+
function_app_url=function_app_url,
|
|
368
|
+
),
|
|
369
|
+
)
|
|
370
|
+
print("Deployment complete")
|
|
371
|
+
print(f"API: {result.function_app_url}")
|
|
372
|
+
return result
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def redeploy_azure_code(config: AzureCodeRedeployConfig) -> AzureCodeRedeployResult:
|
|
376
|
+
"""Redeploy code to an existing Function App without touching storage resources."""
|
|
377
|
+
|
|
378
|
+
_assert_prerequisites()
|
|
379
|
+
if not check_azure_login():
|
|
380
|
+
raise RuntimeError("Not logged into Azure CLI. Run 'az login' first.")
|
|
381
|
+
|
|
382
|
+
project_root = _resolve_project_root(config.project_root)
|
|
383
|
+
deployment_config = {}
|
|
384
|
+
if not (config.resource_group and config.function_app):
|
|
385
|
+
deployment_config = _read_deployment_config(project_root)
|
|
386
|
+
resource_group = config.resource_group or deployment_config.get("resourceGroup")
|
|
387
|
+
function_app = config.function_app or deployment_config.get("functionApp")
|
|
388
|
+
if not resource_group or not function_app:
|
|
389
|
+
raise RuntimeError(
|
|
390
|
+
"Redeploy needs a resource group and Function App. Pass --resource-group "
|
|
391
|
+
"and --function-app, or keep .scratch/deploy/azure-deployment.json."
|
|
392
|
+
)
|
|
393
|
+
function_app_url = (
|
|
394
|
+
f"https://{function_app}.azurewebsites.net"
|
|
395
|
+
if config.function_app
|
|
396
|
+
else deployment_config.get(
|
|
397
|
+
"functionAppUrl", f"https://{function_app}.azurewebsites.net"
|
|
398
|
+
)
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
print("Starting Barometer Azure code redeploy")
|
|
402
|
+
print(f"Resource group: {resource_group}")
|
|
403
|
+
print(f"Function app: {function_app}")
|
|
404
|
+
print("Storage resources: preserved")
|
|
405
|
+
|
|
406
|
+
_run_command(
|
|
407
|
+
[
|
|
408
|
+
"az",
|
|
409
|
+
"functionapp",
|
|
410
|
+
"config",
|
|
411
|
+
"appsettings",
|
|
412
|
+
"set",
|
|
413
|
+
"--name",
|
|
414
|
+
function_app,
|
|
415
|
+
"--resource-group",
|
|
416
|
+
resource_group,
|
|
417
|
+
"--settings",
|
|
418
|
+
f"FPU_SCHEDULER_CRON={config.scheduler_cron}",
|
|
419
|
+
"--output",
|
|
420
|
+
"table",
|
|
421
|
+
],
|
|
422
|
+
"configuring scheduler app setting",
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
redeploy_workspace = project_root / ".scratch" / "redeploy"
|
|
426
|
+
try:
|
|
427
|
+
wheel_paths = build_wheels(project_root)
|
|
428
|
+
staging_dir = stage_function_app(
|
|
429
|
+
wheel_paths, redeploy_workspace / "azure-functions"
|
|
430
|
+
)
|
|
431
|
+
_run_command(
|
|
432
|
+
[
|
|
433
|
+
"func",
|
|
434
|
+
"azure",
|
|
435
|
+
"functionapp",
|
|
436
|
+
"publish",
|
|
437
|
+
function_app,
|
|
438
|
+
"--python",
|
|
439
|
+
"--build",
|
|
440
|
+
"remote",
|
|
441
|
+
],
|
|
442
|
+
"publishing function app",
|
|
443
|
+
timeout=900,
|
|
444
|
+
cwd=staging_dir,
|
|
445
|
+
)
|
|
446
|
+
except Exception:
|
|
447
|
+
shutil.rmtree(redeploy_workspace, ignore_errors=True)
|
|
448
|
+
raise
|
|
449
|
+
|
|
450
|
+
result = AzureCodeRedeployResult(
|
|
451
|
+
resource_group=resource_group,
|
|
452
|
+
function_app=function_app,
|
|
453
|
+
function_app_url=function_app_url,
|
|
454
|
+
scheduler_cron=config.scheduler_cron,
|
|
455
|
+
config_path=project_root / ".scratch" / "deploy" / "azure-deployment.json",
|
|
456
|
+
)
|
|
457
|
+
print("Code redeploy complete")
|
|
458
|
+
print(f"API: {result.function_app_url}")
|
|
459
|
+
return result
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def upload_static_source_files(
|
|
463
|
+
project_root: Path,
|
|
464
|
+
*,
|
|
465
|
+
connection_string: str,
|
|
466
|
+
container_name: str,
|
|
467
|
+
) -> None:
|
|
468
|
+
"""Upload deploy-time static Connector source files to logical storage."""
|
|
469
|
+
|
|
470
|
+
for source_file in STATIC_SOURCE_FILES:
|
|
471
|
+
source_path = project_root / source_file.local_path
|
|
472
|
+
if not source_path.exists():
|
|
473
|
+
raise RuntimeError(f"Static source file not found: {source_path}")
|
|
474
|
+
_run_command(
|
|
475
|
+
[
|
|
476
|
+
"az",
|
|
477
|
+
"storage",
|
|
478
|
+
"blob",
|
|
479
|
+
"upload",
|
|
480
|
+
"--container-name",
|
|
481
|
+
container_name,
|
|
482
|
+
"--name",
|
|
483
|
+
source_file.logical_path,
|
|
484
|
+
"--file",
|
|
485
|
+
str(source_path),
|
|
486
|
+
"--connection-string",
|
|
487
|
+
connection_string,
|
|
488
|
+
"--overwrite",
|
|
489
|
+
"true",
|
|
490
|
+
"--output",
|
|
491
|
+
"none",
|
|
492
|
+
],
|
|
493
|
+
f"uploading static source {source_file.dataset}",
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def build_wheel(project_root: Path) -> Path:
|
|
498
|
+
"""Build and return the primary package wheel path."""
|
|
499
|
+
|
|
500
|
+
return build_wheels(project_root)[-1]
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def build_wheels(project_root: Path) -> list[Path]:
|
|
504
|
+
"""Build deployment package wheels into the workspace dist directory."""
|
|
505
|
+
|
|
506
|
+
dist = project_root / "dist"
|
|
507
|
+
if dist.exists():
|
|
508
|
+
shutil.rmtree(dist)
|
|
509
|
+
dist.mkdir(parents=True, exist_ok=True)
|
|
510
|
+
for package_root in _deployment_package_roots(project_root):
|
|
511
|
+
_run_command(
|
|
512
|
+
[
|
|
513
|
+
"python",
|
|
514
|
+
"-m",
|
|
515
|
+
"build",
|
|
516
|
+
"--wheel",
|
|
517
|
+
"--outdir",
|
|
518
|
+
str(dist),
|
|
519
|
+
str(package_root),
|
|
520
|
+
],
|
|
521
|
+
f"building package wheel for {package_root.name}",
|
|
522
|
+
timeout=180,
|
|
523
|
+
cwd=project_root,
|
|
524
|
+
)
|
|
525
|
+
wheels = sorted(dist.glob("*.whl"))
|
|
526
|
+
if not wheels:
|
|
527
|
+
raise RuntimeError("Build completed but no wheel was produced")
|
|
528
|
+
return wheels
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _deployment_package_roots(project_root: Path) -> list[Path]:
|
|
532
|
+
workspace_packages = [project_root / "packages" / "fpu-barometer", project_root / "packages" / "fpu-barometer-admin"]
|
|
533
|
+
if all((package_root / "pyproject.toml").exists() for package_root in workspace_packages):
|
|
534
|
+
return workspace_packages
|
|
535
|
+
return [project_root]
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _read_deployment_config(project_root: Path) -> dict[str, Any]:
|
|
539
|
+
config_path = project_root / ".scratch" / "deploy" / "azure-deployment.json"
|
|
540
|
+
if not config_path.exists():
|
|
541
|
+
return {}
|
|
542
|
+
return json.loads(config_path.read_text(encoding="utf-8"))
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _write_deployment_config(
|
|
546
|
+
project_root: Path,
|
|
547
|
+
*,
|
|
548
|
+
config: AzureDeployConfig,
|
|
549
|
+
storage_account: str,
|
|
550
|
+
function_app: str,
|
|
551
|
+
function_app_url: str,
|
|
552
|
+
) -> Path:
|
|
553
|
+
config_dir = project_root / ".scratch" / "deploy"
|
|
554
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
555
|
+
config_path = config_dir / "azure-deployment.json"
|
|
556
|
+
config_path.write_text(
|
|
557
|
+
json.dumps(
|
|
558
|
+
{
|
|
559
|
+
"resourceGroup": config.resource_group,
|
|
560
|
+
"location": config.location,
|
|
561
|
+
"environment": config.environment,
|
|
562
|
+
"storageAccount": storage_account,
|
|
563
|
+
"storageContainer": config.storage_container,
|
|
564
|
+
"functionApp": function_app,
|
|
565
|
+
"functionAppUrl": function_app_url,
|
|
566
|
+
"deployedAt": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
567
|
+
},
|
|
568
|
+
indent=2,
|
|
569
|
+
)
|
|
570
|
+
+ "\n",
|
|
571
|
+
encoding="utf-8",
|
|
572
|
+
)
|
|
573
|
+
return config_path
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _assert_prerequisites() -> None:
|
|
577
|
+
prereqs = check_prerequisites()
|
|
578
|
+
missing = [name for name, available in prereqs.items() if not available]
|
|
579
|
+
if missing:
|
|
580
|
+
install_hints = {
|
|
581
|
+
"azure_cli": "Install Azure CLI and run 'az login'.",
|
|
582
|
+
"func_tools": "Install Azure Functions Core Tools v4.",
|
|
583
|
+
"python_build": "Install Python build support with 'python -m pip install build'.",
|
|
584
|
+
}
|
|
585
|
+
hints = " ".join(install_hints[name] for name in missing)
|
|
586
|
+
raise RuntimeError(
|
|
587
|
+
f"Missing deployment prerequisites: {', '.join(missing)}. {hints}"
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _command_available(args: Sequence[str]) -> bool:
|
|
592
|
+
try:
|
|
593
|
+
result = subprocess.run(
|
|
594
|
+
_resolve_command_args(args),
|
|
595
|
+
capture_output=True,
|
|
596
|
+
text=True,
|
|
597
|
+
timeout=30,
|
|
598
|
+
shell=False,
|
|
599
|
+
)
|
|
600
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
601
|
+
return False
|
|
602
|
+
return result.returncode == 0
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _run_command(
|
|
606
|
+
args: Sequence[str],
|
|
607
|
+
description: str,
|
|
608
|
+
*,
|
|
609
|
+
timeout: int = 300,
|
|
610
|
+
cwd: Path | None = None,
|
|
611
|
+
) -> subprocess.CompletedProcess[str]:
|
|
612
|
+
resolved_args = _resolve_command_args(args)
|
|
613
|
+
result = subprocess.run(
|
|
614
|
+
resolved_args,
|
|
615
|
+
capture_output=True,
|
|
616
|
+
text=True,
|
|
617
|
+
timeout=timeout,
|
|
618
|
+
shell=False,
|
|
619
|
+
cwd=cwd,
|
|
620
|
+
)
|
|
621
|
+
if result.returncode != 0:
|
|
622
|
+
raise RuntimeError(
|
|
623
|
+
f"Failed while {description}.\n"
|
|
624
|
+
f"Command: {' '.join(_redact_args(resolved_args))}\n"
|
|
625
|
+
f"stdout:\n{_redact_text(result.stdout)}\n\n"
|
|
626
|
+
f"stderr:\n{_redact_text(result.stderr)}"
|
|
627
|
+
)
|
|
628
|
+
return result
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _resolve_command_args(args: Sequence[str]) -> list[str]:
|
|
632
|
+
resolved = list(args)
|
|
633
|
+
if resolved:
|
|
634
|
+
resolved[0] = _resolve_executable(str(resolved[0]))
|
|
635
|
+
return resolved
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _resolve_executable(executable: str) -> str:
|
|
639
|
+
if executable == "python":
|
|
640
|
+
return sys.executable
|
|
641
|
+
if os.path.dirname(executable):
|
|
642
|
+
return executable
|
|
643
|
+
path = shutil.which(executable)
|
|
644
|
+
if path:
|
|
645
|
+
return path
|
|
646
|
+
if os.name == "nt" and not Path(executable).suffix:
|
|
647
|
+
for suffix in (".cmd", ".exe", ".bat"):
|
|
648
|
+
path = shutil.which(f"{executable}{suffix}")
|
|
649
|
+
if path:
|
|
650
|
+
return path
|
|
651
|
+
return executable
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _redact_text(value: str) -> str:
|
|
655
|
+
redacted = value
|
|
656
|
+
markers = ("AccountKey=", "SharedAccessSignature=", "sig=")
|
|
657
|
+
for marker in markers:
|
|
658
|
+
start = redacted.find(marker)
|
|
659
|
+
while start != -1:
|
|
660
|
+
value_start = start + len(marker)
|
|
661
|
+
value_end = len(redacted)
|
|
662
|
+
for separator in (";", " ", "\n", "&"):
|
|
663
|
+
separator_index = redacted.find(separator, value_start)
|
|
664
|
+
if separator_index != -1:
|
|
665
|
+
value_end = min(value_end, separator_index)
|
|
666
|
+
redacted = redacted[:value_start] + "<redacted>" + redacted[value_end:]
|
|
667
|
+
start = redacted.find(marker, value_start + len("<redacted>"))
|
|
668
|
+
return redacted
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _redact_args(args: Sequence[str]) -> list[str]:
|
|
672
|
+
redacted: list[str] = []
|
|
673
|
+
secret_markers = (
|
|
674
|
+
"CONNECTION_STRING=",
|
|
675
|
+
"AccountKey=",
|
|
676
|
+
"SharedAccessSignature=",
|
|
677
|
+
"sig=",
|
|
678
|
+
)
|
|
679
|
+
for arg in args:
|
|
680
|
+
value = str(arg)
|
|
681
|
+
if any(marker in value for marker in secret_markers):
|
|
682
|
+
if "=" in value:
|
|
683
|
+
key = value.split("=", 1)[0]
|
|
684
|
+
redacted.append(f"{key}=<redacted>")
|
|
685
|
+
else:
|
|
686
|
+
redacted.append("<redacted>")
|
|
687
|
+
else:
|
|
688
|
+
redacted.append(value)
|
|
689
|
+
return redacted
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _resolve_project_root(project_root: Path | None) -> Path:
|
|
693
|
+
if project_root is not None:
|
|
694
|
+
root = Path(project_root)
|
|
695
|
+
if not (root / "pyproject.toml").exists():
|
|
696
|
+
raise RuntimeError(f"Project root does not contain pyproject.toml: {root}")
|
|
697
|
+
return root
|
|
698
|
+
for candidate in [Path.cwd(), *Path.cwd().parents]:
|
|
699
|
+
if (candidate / "pyproject.toml").exists():
|
|
700
|
+
return candidate
|
|
701
|
+
raise RuntimeError("Could not find project root containing pyproject.toml")
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
__all__ = [
|
|
705
|
+
"AzureCodeRedeployConfig",
|
|
706
|
+
"AzureCodeRedeployResult",
|
|
707
|
+
"AzureDeployConfig",
|
|
708
|
+
"AzureDeploymentResult",
|
|
709
|
+
"build_wheel",
|
|
710
|
+
"build_wheels",
|
|
711
|
+
"check_azure_login",
|
|
712
|
+
"check_prerequisites",
|
|
713
|
+
"deploy_azure",
|
|
714
|
+
"redeploy_azure_code",
|
|
715
|
+
"generate_unique_name",
|
|
716
|
+
"stage_function_app",
|
|
717
|
+
"upload_static_source_files",
|
|
718
|
+
"wait_for_function_app",
|
|
719
|
+
]
|