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.
@@ -0,0 +1,44 @@
1
+ from azure_deploy_cli.identity import (
2
+ AzureGroup,
3
+ ManagedIdentity,
4
+ RoleConfig,
5
+ RoleDefinition,
6
+ SPAuthCredentials,
7
+ SPAuthCredentialsWithSecret,
8
+ SPCreateResult,
9
+ assign_roles,
10
+ create_or_get_user_identity,
11
+ create_sp,
12
+ delete_user_identity,
13
+ get_identity_principal_id,
14
+ reset_sp_credentials,
15
+ )
16
+
17
+ # Get version using standard importlib.metadata (preferred method)
18
+ try:
19
+ from importlib.metadata import PackageNotFoundError, version
20
+
21
+ __version__ = version("azure-deploy-cli")
22
+ except PackageNotFoundError:
23
+ # Fallback to setuptools-scm generated file
24
+ try:
25
+ from azure_deploy_cli._version import __version__
26
+ except ImportError:
27
+ # Final fallback for development installations without git
28
+ __version__ = "0.0.0.dev0"
29
+
30
+ __all__ = [
31
+ "SPAuthCredentials",
32
+ "SPAuthCredentialsWithSecret",
33
+ "SPCreateResult",
34
+ "RoleConfig",
35
+ "RoleDefinition",
36
+ "ManagedIdentity",
37
+ "AzureGroup",
38
+ "create_sp",
39
+ "reset_sp_credentials",
40
+ "create_or_get_user_identity",
41
+ "delete_user_identity",
42
+ "get_identity_principal_id",
43
+ "assign_roles",
44
+ ]
@@ -0,0 +1,34 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
12
+
13
+ TYPE_CHECKING = False
14
+ if TYPE_CHECKING:
15
+ from typing import Tuple
16
+ from typing import Union
17
+
18
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
20
+ else:
21
+ VERSION_TUPLE = object
22
+ COMMIT_ID = object
23
+
24
+ version: str
25
+ __version__: str
26
+ __version_tuple__: VERSION_TUPLE
27
+ version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
30
+
31
+ __version__ = version = '0.1.6'
32
+ __version_tuple__ = version_tuple = (0, 1, 6)
33
+
34
+ __commit_id__ = commit_id = None
@@ -0,0 +1,518 @@
1
+ import argparse
2
+ import json
3
+ import os
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from azure.mgmt.appcontainers import ContainerAppsAPIClient
9
+
10
+ from ..identity.managed_identity import create_or_get_user_identity
11
+ from ..identity.role import assign_role_by_files
12
+ from ..utils.azure_cli import get_credential, get_subscription_and_tenant
13
+ from ..utils.key_vault import get_key_vault_client
14
+ from ..utils.logging import get_logger
15
+ from .deploy_aca import (
16
+ SecretKeyVaultConfig,
17
+ bind_aca_managed_certificate,
18
+ build_acr_image,
19
+ create_container_app_env,
20
+ deploy_revision,
21
+ generate_revision_suffix,
22
+ get_aca_docker_image_name,
23
+ update_traffic_weights,
24
+ validate_revision_suffix_and_throw,
25
+ )
26
+
27
+ logger = get_logger(__name__)
28
+
29
+ REGISTRY_PASS_SECRET_ENV_NAME = "ACA_REGISTRY_PASS"
30
+ REGISTRY_USER_SECRET_ENV_NAME = "ACA_REGISTRY_USER"
31
+
32
+ # Traffic weight configuration constants
33
+ MIN_TRAFFIC_WEIGHT = 0
34
+ MAX_TRAFFIC_WEIGHT = 100
35
+
36
+
37
+ def _label_weight_pair(pair_str: str) -> tuple[str, int]:
38
+ if "=" not in pair_str:
39
+ raise argparse.ArgumentTypeError(
40
+ f"Invalid format: '{pair_str}'. Expected format: label=weight"
41
+ )
42
+
43
+ label, weight_str = pair_str.split("=", 1)
44
+ label = label.strip()
45
+ weight_str = weight_str.strip()
46
+
47
+ if not label:
48
+ raise argparse.ArgumentTypeError("Label name cannot be empty")
49
+
50
+ try:
51
+ weight = int(weight_str)
52
+ except ValueError as e:
53
+ raise argparse.ArgumentTypeError(
54
+ f"Invalid weight '{weight_str}' for label '{label}'. Weight must be an integer"
55
+ ) from e
56
+
57
+ if weight < MIN_TRAFFIC_WEIGHT or weight > MAX_TRAFFIC_WEIGHT:
58
+ raise argparse.ArgumentTypeError(
59
+ f"Invalid weight {weight} for label '{label}'. "
60
+ f"Weight must be between {MIN_TRAFFIC_WEIGHT} and {MAX_TRAFFIC_WEIGHT}"
61
+ )
62
+
63
+ return (label, weight)
64
+
65
+
66
+ def _convert_label_traffic_args(label_traffic_list: list[tuple[str, int]]) -> dict[str, int]:
67
+ """
68
+ Convert list of (label, weight) tuples to dictionary.
69
+
70
+ Args:
71
+ label_traffic_list: List of (label, weight) tuples from argparse
72
+
73
+ Returns:
74
+ Dictionary mapping labels to traffic weights
75
+ """
76
+ return dict(label_traffic_list)
77
+
78
+
79
+ def _validate_cli_deploy(args: Any):
80
+ if args.revision_suffix:
81
+ validate_revision_suffix_and_throw(args.revision_suffix, args.stage)
82
+ if not os.getenv(REGISTRY_PASS_SECRET_ENV_NAME):
83
+ raise ValueError(f"Environment variable {REGISTRY_PASS_SECRET_ENV_NAME} is not set")
84
+
85
+ if args.role_config and args.role_env_vars_files:
86
+ pass
87
+ elif args.role_config:
88
+ raise ValueError("Role config provided without env vars files")
89
+ elif args.role_env_vars_files:
90
+ raise ValueError("Role env vars files provided without role config")
91
+
92
+
93
+ def cli_deploy(args: Any) -> None:
94
+ """
95
+ Note: this is an opinionated deployment flow for ACA.
96
+ Deploy Azure Container App revision without updating traffic.
97
+
98
+ This command orchestrates:
99
+ 1. Create/get user-assigned managed identity (if specified)
100
+ 2. Assign roles to the identity (if role config provided)
101
+ 3. Build and push container image (if not skipped)
102
+ 4. Deploy new revision with 0% traffic
103
+ 5. Verify revision activation and health
104
+ 6. Output the revision name for use in traffic management
105
+
106
+ Args:
107
+ args: Parsed command line arguments
108
+ """
109
+ _validate_cli_deploy(args)
110
+ registry_user = os.getenv(REGISTRY_USER_SECRET_ENV_NAME)
111
+ if not registry_user:
112
+ raise ValueError(f"Environment variable {REGISTRY_USER_SECRET_ENV_NAME} is not set")
113
+
114
+ try:
115
+ logger.critical("Starting ACA revision deployment process...")
116
+ subscription_id, _ = get_subscription_and_tenant()
117
+ credential = get_credential(cache=True)
118
+ container_apps_api_client = ContainerAppsAPIClient(credential, subscription_id)
119
+ key_vault_client = get_key_vault_client(
120
+ subscription_id=subscription_id,
121
+ resource_group=args.resource_group,
122
+ key_vault_name=args.keyvault_name,
123
+ )
124
+
125
+ revision_suffix = (
126
+ args.revision_suffix
127
+ if args.revision_suffix
128
+ else generate_revision_suffix(stage=args.stage)
129
+ )
130
+
131
+ logger.critical("Building and pushing ACR image...")
132
+ logger.info(f"Using revision suffix '{revision_suffix}' as target image tag")
133
+ build_acr_image(
134
+ registry_server=args.registry_server,
135
+ dockerfile=args.dockerfile,
136
+ full_image_name=get_aca_docker_image_name(
137
+ registry_server=args.registry_server,
138
+ image_name=args.image_name,
139
+ image_tag=revision_suffix,
140
+ ),
141
+ source_full_image_name=get_aca_docker_image_name(
142
+ registry_server=args.registry_server,
143
+ image_name=args.image_name,
144
+ image_tag=args.existing_image_tag,
145
+ )
146
+ if args.existing_image_tag
147
+ else None,
148
+ )
149
+
150
+ logger.critical("Setting up managed identity and roles...")
151
+ user_identity = create_or_get_user_identity(
152
+ args.user_assigned_identity_name, args.resource_group, subscription_id
153
+ )
154
+ if args.role_config and args.role_env_vars_files:
155
+ assign_role_by_files(
156
+ user_identity.principalId,
157
+ args.role_config,
158
+ args.role_env_vars_files,
159
+ )
160
+
161
+ logger.critical("Creating or getting Container App Environment...")
162
+ env = create_container_app_env(
163
+ container_apps_api_client,
164
+ resource_group=args.resource_group,
165
+ container_app_env_name=args.container_app_env,
166
+ location=args.location,
167
+ logs_workspace_id=args.logs_workspace_id,
168
+ )
169
+ if not env:
170
+ raise ValueError("Cannot create container app env")
171
+
172
+ logger.critical("Deploying new revision...")
173
+ result = deploy_revision(
174
+ client=container_apps_api_client,
175
+ subscription_id=subscription_id,
176
+ resource_group=args.resource_group,
177
+ container_app_env=env,
178
+ user_identity=user_identity,
179
+ container_app_name=args.container_app,
180
+ registry_server=args.registry_server,
181
+ registry_user=registry_user,
182
+ registry_pass_env_name=REGISTRY_PASS_SECRET_ENV_NAME,
183
+ image_name=args.image_name,
184
+ image_tag=revision_suffix,
185
+ revision_suffix=revision_suffix,
186
+ location=args.location,
187
+ stage=args.stage,
188
+ target_port=args.target_port,
189
+ cpu=args.cpu,
190
+ memory=args.memory,
191
+ min_replicas=args.min_replicas,
192
+ max_replicas=args.max_replicas,
193
+ secret_key_vault_config=SecretKeyVaultConfig(
194
+ key_vault_client=key_vault_client,
195
+ key_vault_name=args.keyvault_name,
196
+ secret_names=args.env_var_secrets or [],
197
+ user_identity=user_identity,
198
+ ),
199
+ env_var_names=args.env_vars,
200
+ existing_image_tag=args.existing_image_tag,
201
+ )
202
+
203
+ if args.custom_domains:
204
+ logger.critical("Binding SSL certificate to Container App...")
205
+ bind_aca_managed_certificate(
206
+ custom_domains=args.custom_domains,
207
+ container_app_name=args.container_app,
208
+ container_app_env_name=args.container_app_env,
209
+ resource_group=args.resource_group,
210
+ )
211
+
212
+ logger.success("========== Deployment Complete ==========")
213
+ logger.success(
214
+ f"Deployed revision: {result.revision_name} "
215
+ f"(active={result.active}, healthy={result.is_healthy})"
216
+ )
217
+ logger.stdout(
218
+ f"""
219
+ {
220
+ json.dumps(
221
+ {
222
+ "revisionName": result.revision_name,
223
+ "revisionUrl": result.revision_url,
224
+ }
225
+ )
226
+ }
227
+ """
228
+ )
229
+ if not result.is_healthy:
230
+ logger.error(
231
+ f"Revision '{result.revision_name}' is not healthy: "
232
+ f"active={result.active}, health={result.health_state}, "
233
+ f"provisioning={result.provisioning_state}, running={result.running_state}"
234
+ )
235
+ sys.exit(1)
236
+ except Exception:
237
+ logger.error("Failed to deploy revision", exc_info=True)
238
+ sys.exit(1)
239
+
240
+
241
+ def cli_update_traffic(args: Any) -> None:
242
+ """
243
+ Update traffic weights for Azure Container App labels.
244
+
245
+ This command:
246
+ 1. Updates traffic distribution across labels based on configuration
247
+ 2. Optionally deactivates revisions not receiving traffic
248
+ 3. Optionally deletes unused ACR images to prevent accumulation
249
+
250
+ Args:
251
+ args: Parsed command line arguments
252
+ """
253
+ label_traffic_map = _convert_label_traffic_args(args.label_stage_traffic)
254
+
255
+ try:
256
+ logger.critical("Starting traffic weight update process...")
257
+ subscription_id, _ = get_subscription_and_tenant()
258
+ credential = get_credential(cache=True)
259
+ container_apps_api_client = ContainerAppsAPIClient(credential, subscription_id)
260
+
261
+ logger.critical("Updating traffic weights...")
262
+ update_traffic_weights(
263
+ client=container_apps_api_client,
264
+ resource_group=args.resource_group,
265
+ container_app_name=args.container_app,
266
+ label_traffic_map=label_traffic_map,
267
+ deactivate_old_revisions=not args.no_deactivate,
268
+ should_delete_acr_images=args.delete_acr_images,
269
+ )
270
+
271
+ logger.success("========== Traffic Update Complete ==========")
272
+ except Exception:
273
+ logger.error("Failed to update traffic weights", exc_info=True)
274
+ sys.exit(1)
275
+
276
+
277
+ def add_commands(subparsers: argparse._SubParsersAction) -> None:
278
+ """
279
+ Register ACA namespace commands under the 'aca' subparser.
280
+
281
+ Args:
282
+ subparsers: The subparsers action from the main parser
283
+ """
284
+ aca_parser = subparsers.add_parser(
285
+ "azaca",
286
+ help="Azure Container Apps management",
287
+ description="Manage Azure Container Apps deployments and configurations",
288
+ formatter_class=argparse.RawDescriptionHelpFormatter,
289
+ add_help=True,
290
+ )
291
+
292
+ aca_subparsers = aca_parser.add_subparsers(dest="aca_command", help="ACA commands")
293
+
294
+ deploy_parser = aca_subparsers.add_parser(
295
+ "deploy",
296
+ help="Deploy ACA with optional identity and role setup",
297
+ description=(
298
+ "Set up managed identity and optionally assign roles before ACA deployment. "
299
+ "All unrecognized arguments are passed to the bash deployment script."
300
+ f"Required env vars: {REGISTRY_USER_SECRET_ENV_NAME} "
301
+ f"and {REGISTRY_PASS_SECRET_ENV_NAME}."
302
+ ),
303
+ add_help=True,
304
+ )
305
+
306
+ deploy_parser.add_argument(
307
+ "--resource-group",
308
+ required=True,
309
+ type=str,
310
+ help="Azure resource group name",
311
+ )
312
+ deploy_parser.add_argument(
313
+ "--location",
314
+ required=True,
315
+ type=str,
316
+ help="Azure region location (e.g., eastus, westus2)",
317
+ )
318
+ deploy_parser.add_argument(
319
+ "--container-app-env",
320
+ required=True,
321
+ type=str,
322
+ help="Name of the container app environment.",
323
+ )
324
+ deploy_parser.add_argument(
325
+ "--logs-workspace-id",
326
+ required=True,
327
+ type=str,
328
+ help="Log Analytics workspace ID for the container app environment.",
329
+ )
330
+ deploy_parser.add_argument(
331
+ "--user-assigned-identity-name",
332
+ required=True,
333
+ type=str,
334
+ help="Name of the user-assigned managed identity.",
335
+ )
336
+ deploy_parser.add_argument(
337
+ "--container-app",
338
+ required=True,
339
+ type=str,
340
+ help="Name of the container app.",
341
+ )
342
+ deploy_parser.add_argument(
343
+ "--revision-suffix",
344
+ required=False,
345
+ type=str,
346
+ help="Suffix to append to the revision name for identification.",
347
+ )
348
+ deploy_parser.add_argument(
349
+ "--registry-server",
350
+ required=True,
351
+ type=str,
352
+ help="Container registry server.",
353
+ )
354
+ deploy_parser.add_argument(
355
+ "--image-name",
356
+ required=True,
357
+ type=str,
358
+ help="Name of the container image.",
359
+ )
360
+ deploy_parser.add_argument(
361
+ "--existing-image-tag",
362
+ required=False,
363
+ type=str,
364
+ help=(
365
+ "Tag of an existing image to retag to the revision suffix. "
366
+ "If provided, the image with this tag will be pulled from the registry, "
367
+ "retagged to the revision suffix, and pushed. "
368
+ "If the image doesn't exist, deployment will fail."
369
+ ),
370
+ )
371
+ deploy_parser.add_argument(
372
+ "--target-port",
373
+ required=True,
374
+ type=int,
375
+ help="Target port for the container app.",
376
+ )
377
+ deploy_parser.add_argument(
378
+ "--cpu",
379
+ required=True,
380
+ type=float,
381
+ help="CPU allocation for the container app.",
382
+ )
383
+ deploy_parser.add_argument(
384
+ "--memory",
385
+ required=True,
386
+ type=str,
387
+ help="Memory allocation for the container app (e.g., '2.0Gi').",
388
+ )
389
+ deploy_parser.add_argument(
390
+ "--min-replicas",
391
+ required=True,
392
+ type=int,
393
+ help="Minimum number of replicas for the container app.",
394
+ )
395
+ deploy_parser.add_argument(
396
+ "--max-replicas",
397
+ required=True,
398
+ type=int,
399
+ help="Maximum number of replicas for the container app.",
400
+ )
401
+ deploy_parser.add_argument(
402
+ "--keyvault-name",
403
+ required=True,
404
+ type=str,
405
+ help="Name of the Key Vault for storing secrets.",
406
+ )
407
+
408
+ deploy_parser.add_argument(
409
+ "--stage",
410
+ required=True,
411
+ type=str,
412
+ help="Deployment stage label (e.g., staging, prod) used for revision naming.",
413
+ )
414
+ deploy_parser.add_argument(
415
+ "--env-var-secrets",
416
+ required=False,
417
+ type=str,
418
+ nargs="+",
419
+ help="Space-separated names of environment variables to be stored as secrets in Key Vault.",
420
+ )
421
+
422
+ deploy_parser.add_argument(
423
+ "--env-vars",
424
+ required=False,
425
+ type=str,
426
+ nargs="+",
427
+ help="Space-separated names of environment variables to pass to the container.",
428
+ )
429
+
430
+ deploy_parser.add_argument(
431
+ "--role-config",
432
+ required=False,
433
+ type=Path,
434
+ help=(
435
+ "Path to role configuration JSON file for role assignment. "
436
+ "Must be provided together with --role-env-vars-files."
437
+ ),
438
+ )
439
+
440
+ deploy_parser.add_argument(
441
+ "--role-env-vars-files",
442
+ required=False,
443
+ type=Path,
444
+ nargs="+",
445
+ help=(
446
+ "Environment files for variable substitution in role config scopes. "
447
+ "Must be provided together with --role-config."
448
+ ),
449
+ )
450
+
451
+ deploy_parser.add_argument(
452
+ "--custom-domains",
453
+ required=False,
454
+ type=str,
455
+ nargs="+",
456
+ help="Space-separated list of custom domains to "
457
+ + "bind SSL certificates to the container app.",
458
+ )
459
+
460
+ deploy_parser.add_argument(
461
+ "--dockerfile",
462
+ required=True,
463
+ type=str,
464
+ help="Path to the Dockerfile for building the container image.",
465
+ )
466
+
467
+ deploy_parser.set_defaults(func=cli_deploy)
468
+
469
+ # Add update-traffic command
470
+ update_traffic_parser = aca_subparsers.add_parser(
471
+ "update-traffic",
472
+ help="Update traffic weights and deactivate old revisions",
473
+ description=(
474
+ "Update traffic distribution across stage labels and optionally "
475
+ "deactivate revisions not receiving traffic and clean up ACR images."
476
+ ),
477
+ add_help=True,
478
+ )
479
+
480
+ update_traffic_parser.add_argument(
481
+ "--resource-group",
482
+ required=True,
483
+ type=str,
484
+ help="Azure resource group name",
485
+ )
486
+
487
+ update_traffic_parser.add_argument(
488
+ "--container-app",
489
+ required=True,
490
+ type=str,
491
+ help="Name of the container app.",
492
+ )
493
+
494
+ update_traffic_parser.add_argument(
495
+ "--label-stage-traffic",
496
+ type=_label_weight_pair,
497
+ nargs="+",
498
+ required=True,
499
+ metavar="LABEL=WEIGHT",
500
+ help=(
501
+ "Traffic weight configuration for stage labels (e.g., prod=100 staging=0). "
502
+ "Specify one or more label=weight pairs."
503
+ ),
504
+ )
505
+
506
+ update_traffic_parser.add_argument(
507
+ "--no-deactivate",
508
+ action="store_true",
509
+ help="Skip deactivation of revisions not receiving traffic.",
510
+ )
511
+
512
+ update_traffic_parser.add_argument(
513
+ "--delete-acr-images",
514
+ action="store_true",
515
+ help="Disable deletion of unused ACR images when deactivating revisions.",
516
+ )
517
+
518
+ update_traffic_parser.set_defaults(func=cli_update_traffic)