azure-deploy-cli 0.1.6__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.
- azure_deploy_cli/__init__.py +44 -0
- azure_deploy_cli/_version.py +34 -0
- azure_deploy_cli/aca/aca_cli.py +518 -0
- azure_deploy_cli/aca/bash/aca-cert/create.sh +203 -0
- azure_deploy_cli/aca/bash/aca-cert/destroy.sh +44 -0
- azure_deploy_cli/aca/deploy_aca.py +794 -0
- azure_deploy_cli/aca/model.py +35 -0
- azure_deploy_cli/cli.py +66 -0
- azure_deploy_cli/identity/__init__.py +36 -0
- azure_deploy_cli/identity/group.py +84 -0
- azure_deploy_cli/identity/identity_cli.py +453 -0
- azure_deploy_cli/identity/managed_identity.py +177 -0
- azure_deploy_cli/identity/models.py +167 -0
- azure_deploy_cli/identity/py.typed +0 -0
- azure_deploy_cli/identity/role.py +338 -0
- azure_deploy_cli/identity/service_principal.py +268 -0
- azure_deploy_cli/py.typed +0 -0
- azure_deploy_cli/utils/__init__.py +0 -0
- azure_deploy_cli/utils/azure_cli.py +96 -0
- azure_deploy_cli/utils/docker.py +137 -0
- azure_deploy_cli/utils/env.py +108 -0
- azure_deploy_cli/utils/key_vault.py +11 -0
- azure_deploy_cli/utils/logging.py +125 -0
- azure_deploy_cli/utils/py.typed +0 -0
- azure_deploy_cli-0.1.6.dist-info/METADATA +678 -0
- azure_deploy_cli-0.1.6.dist-info/RECORD +30 -0
- azure_deploy_cli-0.1.6.dist-info/WHEEL +5 -0
- azure_deploy_cli-0.1.6.dist-info/entry_points.txt +3 -0
- azure_deploy_cli-0.1.6.dist-info/licenses/LICENSE +373 -0
- azure_deploy_cli-0.1.6.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,794 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import time
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from azure.core.exceptions import (
|
|
9
|
+
ClientAuthenticationError,
|
|
10
|
+
HttpResponseError,
|
|
11
|
+
ResourceNotFoundError,
|
|
12
|
+
)
|
|
13
|
+
from azure.mgmt.appcontainers import ContainerAppsAPIClient
|
|
14
|
+
from azure.mgmt.appcontainers.models import (
|
|
15
|
+
ActiveRevisionsMode,
|
|
16
|
+
AppLogsConfiguration,
|
|
17
|
+
Container,
|
|
18
|
+
ContainerApp,
|
|
19
|
+
ContainerResources,
|
|
20
|
+
EnvironmentVar,
|
|
21
|
+
Ingress,
|
|
22
|
+
LogAnalyticsConfiguration,
|
|
23
|
+
ManagedEnvironment,
|
|
24
|
+
ManagedServiceIdentity,
|
|
25
|
+
RegistryCredentials,
|
|
26
|
+
Revision,
|
|
27
|
+
Scale,
|
|
28
|
+
Secret,
|
|
29
|
+
Template,
|
|
30
|
+
TrafficWeight,
|
|
31
|
+
UserAssignedIdentity,
|
|
32
|
+
)
|
|
33
|
+
from azure.mgmt.appcontainers.models import Configuration as ContainerAppConfiguration
|
|
34
|
+
from azure.mgmt.keyvault.models import SecretCreateOrUpdateParameters, SecretProperties
|
|
35
|
+
|
|
36
|
+
from ..identity.models import ManagedIdentity
|
|
37
|
+
from ..utils import docker
|
|
38
|
+
from ..utils.logging import get_logger
|
|
39
|
+
from .model import RevisionDeploymentResult, SecretKeyVaultConfig
|
|
40
|
+
|
|
41
|
+
logger = get_logger(__name__)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _login_to_acr(registry_server: str):
|
|
45
|
+
login_result = subprocess.run(
|
|
46
|
+
[
|
|
47
|
+
"az",
|
|
48
|
+
"acr",
|
|
49
|
+
"login",
|
|
50
|
+
"--name",
|
|
51
|
+
registry_server.split(".")[0],
|
|
52
|
+
],
|
|
53
|
+
capture_output=True,
|
|
54
|
+
text=True,
|
|
55
|
+
)
|
|
56
|
+
if login_result.returncode != 0:
|
|
57
|
+
raise RuntimeError(f"Failed to login to ACR: {login_result.stderr}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build_acr_image(
|
|
61
|
+
dockerfile: str,
|
|
62
|
+
full_image_name: str,
|
|
63
|
+
registry_server: str,
|
|
64
|
+
source_full_image_name: str | None = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
logger.info(f"Logging in to ACR '{registry_server}'...")
|
|
67
|
+
_login_to_acr(registry_server)
|
|
68
|
+
logger.info("Logged in successfully.")
|
|
69
|
+
|
|
70
|
+
if docker.image_exists(full_image_name):
|
|
71
|
+
logger.info(f"Docker image '{full_image_name}' found locally. Pushing to registry...")
|
|
72
|
+
docker.push_image(full_image_name)
|
|
73
|
+
logger.success(f"Docker image {full_image_name} pushed to registry successfully.")
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
if source_full_image_name:
|
|
77
|
+
logger.info(
|
|
78
|
+
f"Docker image '{full_image_name}' not found locally. "
|
|
79
|
+
f"Retagging from existing image '{source_full_image_name}'..."
|
|
80
|
+
)
|
|
81
|
+
docker.pull_retag_and_push_image(
|
|
82
|
+
source_full_image_name,
|
|
83
|
+
full_image_name,
|
|
84
|
+
)
|
|
85
|
+
logger.success(f"Docker image '{full_image_name}' pushed to registry successfully.")
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
logger.info(f"Building Docker image '{full_image_name}' from Dockerfile '{dockerfile}'...")
|
|
89
|
+
docker.build_and_push_image(
|
|
90
|
+
dockerfile,
|
|
91
|
+
full_image_name,
|
|
92
|
+
)
|
|
93
|
+
logger.success("Docker image built and pushed to registry successfully.")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def delete_acr_image(registry_server: str, full_image_name: str) -> None:
|
|
97
|
+
"""
|
|
98
|
+
Delete an image from Azure Container Registry.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
registry_server: ACR server name (e.g., myregistry.azurecr.io)
|
|
102
|
+
image_name: Name of the image repository
|
|
103
|
+
image_tag: Tag of the image to delete
|
|
104
|
+
"""
|
|
105
|
+
registry_name = registry_server.split(".")[0]
|
|
106
|
+
|
|
107
|
+
logger.info(f"Deleting ACR image '{full_image_name}' from registry '{registry_name}'...")
|
|
108
|
+
|
|
109
|
+
delete_result = subprocess.run(
|
|
110
|
+
[
|
|
111
|
+
"az",
|
|
112
|
+
"acr",
|
|
113
|
+
"repository",
|
|
114
|
+
"delete",
|
|
115
|
+
"--name",
|
|
116
|
+
registry_name,
|
|
117
|
+
"--image",
|
|
118
|
+
full_image_name,
|
|
119
|
+
"--yes",
|
|
120
|
+
],
|
|
121
|
+
capture_output=True,
|
|
122
|
+
text=True,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if delete_result.returncode != 0:
|
|
126
|
+
# Log warning but don't fail - image might not exist or already deleted
|
|
127
|
+
logger.warning(
|
|
128
|
+
f"Failed to delete ACR image '{full_image_name}': {delete_result.stderr.strip()}"
|
|
129
|
+
)
|
|
130
|
+
else:
|
|
131
|
+
logger.info(f"ACR image '{full_image_name}' deleted successfully")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def bind_aca_managed_certificate(
|
|
135
|
+
custom_domains: list[str],
|
|
136
|
+
container_app_name: str,
|
|
137
|
+
container_app_env_name: str,
|
|
138
|
+
resource_group: str,
|
|
139
|
+
):
|
|
140
|
+
result = subprocess.run(
|
|
141
|
+
[
|
|
142
|
+
"bash",
|
|
143
|
+
str(Path(__file__).parent / "bash" / "aca-cert" / "create.sh"),
|
|
144
|
+
"--custom-domains",
|
|
145
|
+
",".join(custom_domains),
|
|
146
|
+
"--container-app-name",
|
|
147
|
+
container_app_name,
|
|
148
|
+
"--resource-group",
|
|
149
|
+
resource_group,
|
|
150
|
+
"--env-resource-group",
|
|
151
|
+
resource_group,
|
|
152
|
+
"--container-app-env-name",
|
|
153
|
+
container_app_env_name,
|
|
154
|
+
],
|
|
155
|
+
env=os.environ.copy(),
|
|
156
|
+
)
|
|
157
|
+
if result.returncode != 0:
|
|
158
|
+
logger.error("Failed to bind certificate using aca-cert script.")
|
|
159
|
+
raise RuntimeError("Certificate binding failed.")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def create_container_app_env(
|
|
163
|
+
client: ContainerAppsAPIClient,
|
|
164
|
+
resource_group: str,
|
|
165
|
+
container_app_env_name: str,
|
|
166
|
+
location: str,
|
|
167
|
+
logs_workspace_id: str,
|
|
168
|
+
) -> ManagedEnvironment | None:
|
|
169
|
+
logger.info(f"Checking for Container App Environment '{container_app_env_name}'...")
|
|
170
|
+
try:
|
|
171
|
+
client.managed_environments.get(resource_group, container_app_env_name)
|
|
172
|
+
logger.success("Container App Environment already exists.")
|
|
173
|
+
except ResourceNotFoundError:
|
|
174
|
+
logger.info("Container App Environment not found. Creating a new one...")
|
|
175
|
+
env_poller = client.managed_environments.begin_create_or_update(
|
|
176
|
+
resource_group,
|
|
177
|
+
container_app_env_name,
|
|
178
|
+
environment_envelope=ManagedEnvironment(
|
|
179
|
+
location=location,
|
|
180
|
+
app_logs_configuration=AppLogsConfiguration(
|
|
181
|
+
destination="log-analytics",
|
|
182
|
+
log_analytics_configuration=LogAnalyticsConfiguration(
|
|
183
|
+
customer_id=logs_workspace_id
|
|
184
|
+
),
|
|
185
|
+
),
|
|
186
|
+
),
|
|
187
|
+
)
|
|
188
|
+
env_poller.result()
|
|
189
|
+
logger.success("Container App Environment created successfully.")
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
env = client.managed_environments.get(resource_group, container_app_env_name)
|
|
193
|
+
return env
|
|
194
|
+
except ResourceNotFoundError:
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def deploy_revision(
|
|
199
|
+
client: ContainerAppsAPIClient,
|
|
200
|
+
subscription_id: str,
|
|
201
|
+
resource_group: str,
|
|
202
|
+
container_app_env: ManagedEnvironment,
|
|
203
|
+
user_identity: ManagedIdentity,
|
|
204
|
+
container_app_name: str,
|
|
205
|
+
registry_server: str,
|
|
206
|
+
registry_user: str,
|
|
207
|
+
registry_pass_env_name: str,
|
|
208
|
+
image_name: str,
|
|
209
|
+
image_tag: str,
|
|
210
|
+
location: str,
|
|
211
|
+
stage: str,
|
|
212
|
+
target_port: int,
|
|
213
|
+
cpu: float,
|
|
214
|
+
memory: str,
|
|
215
|
+
min_replicas: int,
|
|
216
|
+
max_replicas: int,
|
|
217
|
+
secret_key_vault_config: SecretKeyVaultConfig,
|
|
218
|
+
env_var_names: list[str],
|
|
219
|
+
revision_suffix: str,
|
|
220
|
+
existing_image_tag: str | None = None,
|
|
221
|
+
) -> RevisionDeploymentResult:
|
|
222
|
+
"""
|
|
223
|
+
Deploy a new revision without updating traffic weights.
|
|
224
|
+
|
|
225
|
+
This function creates a new revision with existing traffic preserved and checks
|
|
226
|
+
if the activation succeeds. Returns revision information for use in subsequent
|
|
227
|
+
traffic management operations.
|
|
228
|
+
|
|
229
|
+
The image_tag parameter should be the revision suffix when building images with
|
|
230
|
+
revision-specific tags for rollback support.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
revision_suffix: Optional pre-generated revision suffix. If not provided,
|
|
234
|
+
one will be generated.
|
|
235
|
+
existing_image_tag: If provided, the image with this tag will be pulled,
|
|
236
|
+
retagged to image_tag, and pushed. If not provided,
|
|
237
|
+
the existing behavior is used (check for image with
|
|
238
|
+
image_tag and push or build as needed).
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
RevisionDeploymentResult with revision name and status information
|
|
242
|
+
|
|
243
|
+
Raises:
|
|
244
|
+
RuntimeError: If the deployment fails or if existing_image_tag is provided
|
|
245
|
+
but the image doesn't exist
|
|
246
|
+
"""
|
|
247
|
+
logger.info(f"Deploying new revision for Container App '{container_app_name}'...")
|
|
248
|
+
secret_key_vault_config.secret_names.append(registry_pass_env_name)
|
|
249
|
+
secrets, env_vars = _prepare_secrets_and_env_vars(
|
|
250
|
+
secret_config=secret_key_vault_config,
|
|
251
|
+
subscription_id=subscription_id,
|
|
252
|
+
env_var_names=env_var_names,
|
|
253
|
+
resource_group=resource_group,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
validate_revision_suffix_and_throw(revision_suffix, stage)
|
|
257
|
+
revision_name = generate_revision_name(container_app_name, revision_suffix, stage)
|
|
258
|
+
full_image_name = get_aca_docker_image_name(registry_server, image_name, image_tag)
|
|
259
|
+
|
|
260
|
+
# Handle image retagging if existing_image_tag is provided
|
|
261
|
+
if existing_image_tag:
|
|
262
|
+
logger.info(f"Retagging existing image with tag '{existing_image_tag}' to '{image_tag}'...")
|
|
263
|
+
source_full_image_name = get_aca_docker_image_name(
|
|
264
|
+
registry_server, image_name, existing_image_tag
|
|
265
|
+
)
|
|
266
|
+
target_full_image_name = get_aca_docker_image_name(registry_server, image_name, image_tag)
|
|
267
|
+
|
|
268
|
+
try:
|
|
269
|
+
docker.pull_retag_and_push_image(source_full_image_name, target_full_image_name)
|
|
270
|
+
except RuntimeError as e:
|
|
271
|
+
logger.error(f"Failed to retag image from '{existing_image_tag}' to '{image_tag}': {e}")
|
|
272
|
+
raise RuntimeError(
|
|
273
|
+
f"Image with tag '{existing_image_tag}' does not exist or retagging failed. "
|
|
274
|
+
f"Please ensure the source image exists in the registry."
|
|
275
|
+
) from e
|
|
276
|
+
|
|
277
|
+
existing_app = _get_container_app(client, resource_group, container_app_name)
|
|
278
|
+
existing_traffic_weights = None
|
|
279
|
+
if existing_app and existing_app.configuration and existing_app.configuration.ingress:
|
|
280
|
+
existing_traffic_weights = existing_app.configuration.ingress.traffic
|
|
281
|
+
|
|
282
|
+
logger.info(
|
|
283
|
+
f"Deploying revision '{revision_name}' with image '{full_image_name}'"
|
|
284
|
+
+ " and existing traffic preserved"
|
|
285
|
+
)
|
|
286
|
+
poller = client.container_apps.begin_create_or_update(
|
|
287
|
+
resource_group_name=resource_group,
|
|
288
|
+
container_app_name=container_app_name,
|
|
289
|
+
container_app_envelope=ContainerApp(
|
|
290
|
+
location=location,
|
|
291
|
+
environment_id=container_app_env.id,
|
|
292
|
+
configuration=ContainerAppConfiguration(
|
|
293
|
+
ingress=Ingress(
|
|
294
|
+
external=True,
|
|
295
|
+
target_port=target_port,
|
|
296
|
+
transport="auto",
|
|
297
|
+
traffic=existing_traffic_weights, # Preserve existing traffic
|
|
298
|
+
),
|
|
299
|
+
registries=[
|
|
300
|
+
RegistryCredentials(
|
|
301
|
+
server=registry_server,
|
|
302
|
+
username=registry_user,
|
|
303
|
+
password_secret_ref=_sanitize_secret_name(registry_pass_env_name),
|
|
304
|
+
)
|
|
305
|
+
],
|
|
306
|
+
secrets=secrets,
|
|
307
|
+
active_revisions_mode=ActiveRevisionsMode.MULTIPLE,
|
|
308
|
+
),
|
|
309
|
+
template=Template(
|
|
310
|
+
revision_suffix=revision_suffix,
|
|
311
|
+
containers=[
|
|
312
|
+
Container(
|
|
313
|
+
image=full_image_name,
|
|
314
|
+
name=container_app_name,
|
|
315
|
+
env=env_vars,
|
|
316
|
+
resources=ContainerResources(cpu=cpu, memory=f"{memory}Gi"),
|
|
317
|
+
)
|
|
318
|
+
],
|
|
319
|
+
scale=Scale(min_replicas=min_replicas, max_replicas=max_replicas),
|
|
320
|
+
),
|
|
321
|
+
identity=ManagedServiceIdentity(
|
|
322
|
+
type="UserAssigned",
|
|
323
|
+
user_assigned_identities={user_identity.resourceId: UserAssignedIdentity()},
|
|
324
|
+
),
|
|
325
|
+
),
|
|
326
|
+
)
|
|
327
|
+
logger.info("Waiting for revision deployment to complete...")
|
|
328
|
+
poller.result()
|
|
329
|
+
|
|
330
|
+
logger.info(f"Fetching revision '{revision_name}' details...")
|
|
331
|
+
revision = _wait_for_revision_activation(
|
|
332
|
+
client, resource_group, container_app_name, revision_name
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
result = RevisionDeploymentResult(
|
|
336
|
+
revision_name=revision.name or revision_name,
|
|
337
|
+
active=revision.active or False,
|
|
338
|
+
health_state=str(revision.health_state) if revision.health_state else "Unknown",
|
|
339
|
+
provisioning_state=str(revision.provisioning_state)
|
|
340
|
+
if revision.provisioning_state
|
|
341
|
+
else "Unknown",
|
|
342
|
+
running_state=str(revision.running_state) if revision.running_state else "Unknown",
|
|
343
|
+
revision_url=revision.fqdn,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
logger.info(
|
|
347
|
+
f"Revision deployed: active={result.active}, health={result.health_state}, "
|
|
348
|
+
f"provisioning={result.provisioning_state}, running={result.running_state}"
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
return result
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _wait_for_revision_activation(
|
|
355
|
+
client: ContainerAppsAPIClient,
|
|
356
|
+
resource_group: str,
|
|
357
|
+
container_app_name: str,
|
|
358
|
+
revision_name: str,
|
|
359
|
+
timeout_seconds: int = 300,
|
|
360
|
+
poll_interval_seconds: int = 10,
|
|
361
|
+
) -> Revision:
|
|
362
|
+
"""
|
|
363
|
+
Polls the revision until it is no longer in the 'Activating' state.
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
client: The ContainerAppsAPIClient.
|
|
367
|
+
resource_group: The resource group name.
|
|
368
|
+
container_app_name: The container app name.
|
|
369
|
+
revision_name: The revision name.
|
|
370
|
+
timeout_seconds: The maximum time to wait.
|
|
371
|
+
poll_interval_seconds: The interval between polls.
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
The final revision object.
|
|
375
|
+
|
|
376
|
+
Raises:
|
|
377
|
+
RuntimeError: If the timeout is reached.
|
|
378
|
+
"""
|
|
379
|
+
start_time = time.time()
|
|
380
|
+
while time.time() - start_time < timeout_seconds:
|
|
381
|
+
revision = client.container_apps_revisions.get_revision(
|
|
382
|
+
resource_group_name=resource_group,
|
|
383
|
+
container_app_name=container_app_name,
|
|
384
|
+
revision_name=revision_name,
|
|
385
|
+
)
|
|
386
|
+
if revision.running_state != "Activating":
|
|
387
|
+
logger.info(f"Revision '{revision_name}' is now in '{revision.running_state}' state.")
|
|
388
|
+
return revision
|
|
389
|
+
logger.info(
|
|
390
|
+
f"Revision '{revision_name}' is still 'Activating'. Waiting..."
|
|
391
|
+
f" {int(time.time() - start_time)}s elapsed."
|
|
392
|
+
)
|
|
393
|
+
time.sleep(poll_interval_seconds)
|
|
394
|
+
|
|
395
|
+
raise RuntimeError(
|
|
396
|
+
f"Timeout reached waiting for revision '{revision_name}' to activate. "
|
|
397
|
+
f"Last state was '{revision.running_state}'."
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def get_aca_docker_image_name(registry_server: str, image_name: str, image_tag: str) -> str:
|
|
402
|
+
return f"{registry_server}/{image_name}:{image_tag}"
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _prepare_secrets_and_env_vars(
|
|
406
|
+
secret_config: SecretKeyVaultConfig,
|
|
407
|
+
subscription_id: str,
|
|
408
|
+
env_var_names: list[str],
|
|
409
|
+
resource_group: str,
|
|
410
|
+
) -> tuple[list[Secret], list[EnvironmentVar]]:
|
|
411
|
+
env_vars: dict[str, str] = _load_env_vars(env_var_names)
|
|
412
|
+
|
|
413
|
+
logger.info(f"Processing secrets for Key Vault '{secret_config.key_vault_name}'...")
|
|
414
|
+
|
|
415
|
+
secrets: list[Secret] = []
|
|
416
|
+
envs: list[EnvironmentVar] = []
|
|
417
|
+
for secret_name in list(set(secret_config.secret_names)):
|
|
418
|
+
logger.info(
|
|
419
|
+
f"Setting secret '{secret_name}' in Key Vault '{secret_config.key_vault_name}'..."
|
|
420
|
+
)
|
|
421
|
+
if secret_name not in os.environ:
|
|
422
|
+
raise ValueError(f"Environment variable '{secret_name}' is not set in the environment.")
|
|
423
|
+
secret, env_var = _prepare_secret_and_env(
|
|
424
|
+
secret_name=secret_name,
|
|
425
|
+
secret_value=os.environ[secret_name],
|
|
426
|
+
user_identity_resource_id=secret_config.user_identity.resourceId,
|
|
427
|
+
secret_config=secret_config,
|
|
428
|
+
resource_group=resource_group,
|
|
429
|
+
)
|
|
430
|
+
secrets.append(secret)
|
|
431
|
+
envs.append(env_var)
|
|
432
|
+
if secret_name in env_vars:
|
|
433
|
+
del env_vars[secret_name] # Remove from plain env vars
|
|
434
|
+
|
|
435
|
+
for key, value in env_vars.items():
|
|
436
|
+
envs.append(EnvironmentVar(name=key, value=value))
|
|
437
|
+
|
|
438
|
+
return secrets, envs
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _sanitize_secret_name(name: str) -> str:
|
|
442
|
+
return name.replace("_", "-").lower()
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _prepare_secret_and_env(
|
|
446
|
+
secret_name: str,
|
|
447
|
+
secret_value: str,
|
|
448
|
+
user_identity_resource_id: str,
|
|
449
|
+
secret_config: SecretKeyVaultConfig,
|
|
450
|
+
resource_group: str,
|
|
451
|
+
) -> tuple[Secret, EnvironmentVar]:
|
|
452
|
+
sanitized_name = _sanitize_secret_name(secret_name)
|
|
453
|
+
secret_result = secret_config.key_vault_client.secrets.create_or_update(
|
|
454
|
+
resource_group_name=resource_group,
|
|
455
|
+
vault_name=secret_config.key_vault_name,
|
|
456
|
+
secret_name=sanitized_name,
|
|
457
|
+
parameters=SecretCreateOrUpdateParameters(properties=SecretProperties(value=secret_value)),
|
|
458
|
+
)
|
|
459
|
+
secret = Secret(
|
|
460
|
+
name=sanitized_name,
|
|
461
|
+
key_vault_url=secret_result.properties.secret_uri,
|
|
462
|
+
identity=secret_config.user_identity.resourceId,
|
|
463
|
+
)
|
|
464
|
+
env_var = EnvironmentVar(name=secret_name, secret_ref=sanitized_name)
|
|
465
|
+
return secret, env_var
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _load_env_vars(env_var_names: list[str]) -> dict[str, str]:
|
|
469
|
+
env_var_dict = {}
|
|
470
|
+
for var_name in env_var_names:
|
|
471
|
+
if var_name in os.environ:
|
|
472
|
+
env_var_dict[var_name] = os.environ[var_name]
|
|
473
|
+
else:
|
|
474
|
+
raise ValueError(f"Environment variable '{var_name}' is not set in the environment.")
|
|
475
|
+
|
|
476
|
+
return env_var_dict
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _traffic_weight_str(
|
|
480
|
+
traffic_weight: list[TrafficWeight], selected_revision: list[Revision]
|
|
481
|
+
) -> str:
|
|
482
|
+
parts = []
|
|
483
|
+
for t in traffic_weight:
|
|
484
|
+
rev_name = t.revision_name
|
|
485
|
+
found_revision = next((rev for rev in selected_revision if rev.name == rev_name), None)
|
|
486
|
+
is_healthy = _is_revision_healthy(found_revision) if found_revision else False
|
|
487
|
+
parts.append(f"{t.label}:{t.as_dict()}%->{rev_name} (healthy={is_healthy})")
|
|
488
|
+
return " | ".join(parts)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _get_label_from_rev_name(rev_name: str, container_app_name: str) -> str | None:
|
|
492
|
+
if not rev_name:
|
|
493
|
+
return None
|
|
494
|
+
prefix = f"{container_app_name}--"
|
|
495
|
+
if rev_name.startswith(prefix):
|
|
496
|
+
label_part = rev_name[len(prefix) :]
|
|
497
|
+
label = label_part.split("-")[0]
|
|
498
|
+
return label
|
|
499
|
+
return None
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _get_active_revisions_by_label_group(
|
|
503
|
+
client: ContainerAppsAPIClient,
|
|
504
|
+
resource_group: str,
|
|
505
|
+
container_app_name: str,
|
|
506
|
+
labels: set[str],
|
|
507
|
+
) -> dict[str, list[Revision]]:
|
|
508
|
+
revision_results = client.container_apps_revisions.list_revisions(
|
|
509
|
+
resource_group_name=resource_group,
|
|
510
|
+
container_app_name=container_app_name,
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
all_revisions = list(revision_results)
|
|
514
|
+
|
|
515
|
+
label_group: dict[str, list] = defaultdict(list)
|
|
516
|
+
for rev in all_revisions:
|
|
517
|
+
if not rev.active:
|
|
518
|
+
continue
|
|
519
|
+
if not rev.name:
|
|
520
|
+
continue
|
|
521
|
+
label = _get_label_from_rev_name(rev.name, container_app_name)
|
|
522
|
+
if not label:
|
|
523
|
+
continue
|
|
524
|
+
if label not in labels:
|
|
525
|
+
continue
|
|
526
|
+
label_group[label].append(rev)
|
|
527
|
+
|
|
528
|
+
for label in label_group.keys():
|
|
529
|
+
label_group[label] = sorted(label_group[label], key=lambda r: r.name or "")
|
|
530
|
+
|
|
531
|
+
return label_group
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _is_revision_healthy(rev: Revision) -> bool:
|
|
535
|
+
return (
|
|
536
|
+
(rev.active or False)
|
|
537
|
+
and rev.health_state == "Healthy"
|
|
538
|
+
and rev.provisioning_state == "Provisioned"
|
|
539
|
+
and rev.running_state not in ("Stopped", "Degraded", "Failed")
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _filter_healthy_revisions(revisions: list[Revision]) -> list[Revision]:
|
|
544
|
+
healthy_revisions = []
|
|
545
|
+
for rev in revisions:
|
|
546
|
+
if rev.name:
|
|
547
|
+
is_healthy = (
|
|
548
|
+
rev.active
|
|
549
|
+
and rev.health_state == "Healthy"
|
|
550
|
+
and rev.provisioning_state == "Provisioned"
|
|
551
|
+
and rev.running_state not in ("Stopped", "Degraded", "Failed")
|
|
552
|
+
)
|
|
553
|
+
if is_healthy:
|
|
554
|
+
healthy_revisions.append(rev)
|
|
555
|
+
return healthy_revisions
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _get_latest_revision_by_label(
|
|
559
|
+
label_revision_groups: dict[str, list[Revision]],
|
|
560
|
+
label: str,
|
|
561
|
+
require_healthy: bool = False,
|
|
562
|
+
) -> Revision | None:
|
|
563
|
+
all_revisions_for_label = label_revision_groups.get(label, [])
|
|
564
|
+
if not all_revisions_for_label:
|
|
565
|
+
return None
|
|
566
|
+
|
|
567
|
+
for rev in reversed(all_revisions_for_label):
|
|
568
|
+
if _is_revision_healthy(rev):
|
|
569
|
+
return rev
|
|
570
|
+
|
|
571
|
+
if require_healthy:
|
|
572
|
+
return None
|
|
573
|
+
|
|
574
|
+
logger.warning(f"No healthy revisions found for label '{label}', using latest revision")
|
|
575
|
+
return all_revisions_for_label[-1] if all_revisions_for_label else None
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def validate_revision_suffix_and_throw(revision_suffix: str, stage: str) -> bool:
|
|
579
|
+
stage_label, random_string = revision_suffix.split("-")
|
|
580
|
+
if stage_label != stage:
|
|
581
|
+
raise ValueError(
|
|
582
|
+
f"Revision suffix stage label '{stage_label}' does not match "
|
|
583
|
+
f"the provided stage '{stage}'"
|
|
584
|
+
)
|
|
585
|
+
if not random_string.isalnum():
|
|
586
|
+
raise ValueError(f"Revision suffix random string '{random_string}' must be alphanumeric")
|
|
587
|
+
return True
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def generate_revision_suffix(stage: str) -> str:
|
|
591
|
+
revision_suffix = stage + "-" + datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S")
|
|
592
|
+
if validate_revision_suffix_and_throw(revision_suffix, stage):
|
|
593
|
+
return revision_suffix
|
|
594
|
+
raise RuntimeError("Failed to generate valid revision suffix.")
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def generate_revision_name(container_app_name: str, revision_suffix: str, stage: str) -> str:
|
|
598
|
+
if validate_revision_suffix_and_throw(revision_suffix, stage):
|
|
599
|
+
return f"{container_app_name}--{revision_suffix}"
|
|
600
|
+
raise RuntimeError("Failed to generate valid revision name.")
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def extract_revision_suffix(revision_name: str) -> str | None:
|
|
604
|
+
if "--" in revision_name:
|
|
605
|
+
return revision_name.split("--", 1)[1]
|
|
606
|
+
return None
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _get_container_app(
|
|
610
|
+
client: ContainerAppsAPIClient,
|
|
611
|
+
resource_group: str,
|
|
612
|
+
container_app_name: str,
|
|
613
|
+
) -> ContainerApp | None:
|
|
614
|
+
try:
|
|
615
|
+
return client.container_apps.get(
|
|
616
|
+
resource_group_name=resource_group,
|
|
617
|
+
container_app_name=container_app_name,
|
|
618
|
+
)
|
|
619
|
+
except ResourceNotFoundError:
|
|
620
|
+
return None
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def __label_revision_group_to_str(label_revision_groups: dict[str, list[Revision]]) -> str:
|
|
624
|
+
parts = []
|
|
625
|
+
for label, revisions in label_revision_groups.items():
|
|
626
|
+
rev_names = [rev.name for rev in revisions if rev.name]
|
|
627
|
+
parts.append(f"{label}:[{', '.join(rev_names)}]")
|
|
628
|
+
return " | ".join(parts)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def update_traffic_weights(
|
|
632
|
+
client: ContainerAppsAPIClient,
|
|
633
|
+
resource_group: str,
|
|
634
|
+
container_app_name: str,
|
|
635
|
+
label_traffic_map: dict[str, int],
|
|
636
|
+
deactivate_old_revisions: bool = True,
|
|
637
|
+
should_delete_acr_images: bool = True,
|
|
638
|
+
) -> None:
|
|
639
|
+
"""
|
|
640
|
+
Update traffic weights for all labels and optionally deactivate old revisions.
|
|
641
|
+
|
|
642
|
+
Args:
|
|
643
|
+
client: Azure Container Apps API client
|
|
644
|
+
resource_group: Resource group name
|
|
645
|
+
container_app_name: Container app name
|
|
646
|
+
label_traffic_map: Dictionary mapping labels to traffic weights
|
|
647
|
+
deactivate_old_revisions: If True, deactivate revisions not receiving traffic
|
|
648
|
+
registry_server: Optional ACR server name for image cleanup during deactivation
|
|
649
|
+
image_name: Optional image name for image cleanup during deactivation
|
|
650
|
+
|
|
651
|
+
Raises:
|
|
652
|
+
RuntimeError: If traffic update fails
|
|
653
|
+
"""
|
|
654
|
+
if should_delete_acr_images and (not deactivate_old_revisions):
|
|
655
|
+
logger.warning(
|
|
656
|
+
"ACR image deletion is enabled but old revision deactivation is disabled. "
|
|
657
|
+
"No images will be deleted."
|
|
658
|
+
)
|
|
659
|
+
logger.info(f"Updating traffic weights for container app '{container_app_name}'...")
|
|
660
|
+
label_revision_groups = _get_active_revisions_by_label_group(
|
|
661
|
+
client, resource_group, container_app_name, labels=set(label_traffic_map.keys())
|
|
662
|
+
)
|
|
663
|
+
logger.info(f"Label revision groups: {__label_revision_group_to_str(label_revision_groups)}")
|
|
664
|
+
|
|
665
|
+
traffic_weights: list[TrafficWeight] = []
|
|
666
|
+
active_revisions: set[str] = set()
|
|
667
|
+
|
|
668
|
+
selected_revisions = []
|
|
669
|
+
for label, weight in label_traffic_map.items():
|
|
670
|
+
latest_revision = _get_latest_revision_by_label(
|
|
671
|
+
label_revision_groups, label, require_healthy=False
|
|
672
|
+
)
|
|
673
|
+
if not latest_revision or not latest_revision.name:
|
|
674
|
+
raise RuntimeError(
|
|
675
|
+
f"No revision found for label '{label}'. "
|
|
676
|
+
"Cannot configure traffic for a label with no revisions."
|
|
677
|
+
)
|
|
678
|
+
traffic_weights.append(
|
|
679
|
+
TrafficWeight(
|
|
680
|
+
label=label,
|
|
681
|
+
weight=weight,
|
|
682
|
+
revision_name=latest_revision.name,
|
|
683
|
+
latest_revision=False,
|
|
684
|
+
)
|
|
685
|
+
)
|
|
686
|
+
active_revisions.add(latest_revision.name)
|
|
687
|
+
selected_revisions.append(latest_revision)
|
|
688
|
+
|
|
689
|
+
if len(traffic_weights) == 0:
|
|
690
|
+
raise RuntimeError("No valid traffic configuration could be built")
|
|
691
|
+
|
|
692
|
+
app = _get_container_app(client, resource_group, container_app_name)
|
|
693
|
+
if not app:
|
|
694
|
+
raise RuntimeError(f"Container app '{container_app_name}' not found")
|
|
695
|
+
|
|
696
|
+
if not app.configuration or not app.configuration.ingress:
|
|
697
|
+
raise RuntimeError(f"Container app '{container_app_name}' has no ingress configuration")
|
|
698
|
+
|
|
699
|
+
logger.info(
|
|
700
|
+
f"Applying new traffic weights: {_traffic_weight_str(traffic_weights, selected_revisions)}"
|
|
701
|
+
)
|
|
702
|
+
app.configuration.ingress.traffic = traffic_weights
|
|
703
|
+
|
|
704
|
+
poller = client.container_apps.begin_update(
|
|
705
|
+
resource_group_name=resource_group,
|
|
706
|
+
container_app_name=container_app_name,
|
|
707
|
+
container_app_envelope=app,
|
|
708
|
+
)
|
|
709
|
+
poller.result()
|
|
710
|
+
logger.success("Traffic weights updated successfully")
|
|
711
|
+
|
|
712
|
+
if deactivate_old_revisions:
|
|
713
|
+
deactivate_unused_revisions(
|
|
714
|
+
client,
|
|
715
|
+
resource_group,
|
|
716
|
+
container_app_name,
|
|
717
|
+
active_revisions,
|
|
718
|
+
label_revision_groups,
|
|
719
|
+
should_delete_acr_images,
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def _get_revision_container_images(revision: Revision) -> list[str]:
|
|
724
|
+
if revision.template and revision.template.containers and len(revision.template.containers) > 0:
|
|
725
|
+
return [c.image for c in revision.template.containers if c.image]
|
|
726
|
+
return []
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def deactivate_unused_revisions(
|
|
730
|
+
client: ContainerAppsAPIClient,
|
|
731
|
+
resource_group: str,
|
|
732
|
+
container_app_name: str,
|
|
733
|
+
active_revisions: set[str],
|
|
734
|
+
label_revision_groups: dict[str, list[Revision]],
|
|
735
|
+
should_delete_acr_images: bool = True,
|
|
736
|
+
) -> None:
|
|
737
|
+
"""
|
|
738
|
+
Deactivate revisions that are not receiving traffic and optionally delete their ACR images.
|
|
739
|
+
|
|
740
|
+
Args:
|
|
741
|
+
client: Azure Container Apps API client
|
|
742
|
+
resource_group: Resource group name
|
|
743
|
+
container_app_name: Container app name
|
|
744
|
+
active_revisions: Set of revision names that should remain active
|
|
745
|
+
label_revision_groups: All revisions grouped by label
|
|
746
|
+
"""
|
|
747
|
+
logger.info("Deactivating unused revisions...")
|
|
748
|
+
|
|
749
|
+
all_revisions: set[str] = set()
|
|
750
|
+
name_to_revision: dict[str, Revision] = {}
|
|
751
|
+
for revisions in label_revision_groups.values():
|
|
752
|
+
revision_names = {rev.name for rev in revisions if rev.name and rev.active}
|
|
753
|
+
all_revisions.update(revision_names)
|
|
754
|
+
name_to_revision.update({rev.name: rev for rev in revisions if rev.name})
|
|
755
|
+
|
|
756
|
+
revisions_to_deactivate = all_revisions.difference(active_revisions)
|
|
757
|
+
|
|
758
|
+
if not revisions_to_deactivate:
|
|
759
|
+
logger.info("No revisions to deactivate")
|
|
760
|
+
return
|
|
761
|
+
|
|
762
|
+
deactivated_count = 0
|
|
763
|
+
for revision_name in revisions_to_deactivate:
|
|
764
|
+
try:
|
|
765
|
+
logger.info(f"Deactivating revision '{revision_name}'...")
|
|
766
|
+
client.container_apps_revisions.deactivate_revision(
|
|
767
|
+
resource_group_name=resource_group,
|
|
768
|
+
container_app_name=container_app_name,
|
|
769
|
+
revision_name=revision_name,
|
|
770
|
+
)
|
|
771
|
+
deactivated_count += 1
|
|
772
|
+
logger.info(f"Revision '{revision_name}' deactivated")
|
|
773
|
+
|
|
774
|
+
revision_suffix = extract_revision_suffix(revision_name)
|
|
775
|
+
|
|
776
|
+
if not should_delete_acr_images:
|
|
777
|
+
logger.debug("ACR image deletion is disabled. Skipping image deletion.")
|
|
778
|
+
continue
|
|
779
|
+
|
|
780
|
+
if revision_suffix:
|
|
781
|
+
container_images = _get_revision_container_images(name_to_revision[revision_name])
|
|
782
|
+
for image in container_images:
|
|
783
|
+
registry_server, image_name = image.split("/")
|
|
784
|
+
logger.info(f"Deleting ACR image '{image}' for revision '{revision_name}'...")
|
|
785
|
+
delete_acr_image(registry_server, image_name)
|
|
786
|
+
else:
|
|
787
|
+
logger.warning(
|
|
788
|
+
f"Could not extract revision suffix from '{revision_name}'. "
|
|
789
|
+
"Skipping ACR image deletion."
|
|
790
|
+
)
|
|
791
|
+
except (ResourceNotFoundError, HttpResponseError, ClientAuthenticationError) as e:
|
|
792
|
+
logger.warning(f"Failed to deactivate revision '{revision_name}': {e}")
|
|
793
|
+
|
|
794
|
+
logger.success(f"Deactivated {deactivated_count} unused revision(s)")
|