azure-deploy-cli 0.1.12__py3-none-any.whl → 1.0.1__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.
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.1.12'
32
- __version_tuple__ = version_tuple = (0, 1, 12)
31
+ __version__ = version = '1.0.1'
32
+ __version_tuple__ = version_tuple = (1, 0, 1)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -15,14 +15,13 @@ from ..utils.logging import get_logger
15
15
  from .deploy_aca import (
16
16
  SecretKeyVaultConfig,
17
17
  bind_aca_managed_certificate,
18
- build_acr_image,
19
18
  create_container_app_env,
20
19
  deploy_revision,
21
20
  generate_revision_suffix,
22
- get_aca_docker_image_name,
23
21
  update_traffic_weights,
24
22
  validate_revision_suffix_and_throw,
25
23
  )
24
+ from .yaml_loader import ContainerAppConfig, load_app_config_yaml
26
25
 
27
26
  logger = get_logger(__name__)
28
27
 
@@ -92,16 +91,16 @@ def _validate_cli_deploy(args: Any):
92
91
 
93
92
  def cli_deploy(args: Any) -> None:
94
93
  """
95
- Note: this is an opinionated deployment flow for ACA.
96
- Deploy Azure Container App revision without updating traffic.
94
+ Deploy Azure Container App revision from YAML configuration without updating traffic.
97
95
 
98
96
  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
97
+ 1. Load container configuration from YAML
98
+ 2. Create/get user-assigned managed identity (if specified)
99
+ 3. Assign roles to the identity (if role config provided)
100
+ 4. Build/push container images for all containers
101
+ 5. Deploy new revision with 0% traffic
102
+ 6. Verify revision activation and health
103
+ 7. Output the revision name for use in traffic management
105
104
 
106
105
  Args:
107
106
  args: Parsed command line arguments
@@ -128,24 +127,9 @@ def cli_deploy(args: Any) -> None:
128
127
  else generate_revision_suffix(stage=args.stage)
129
128
  )
130
129
 
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
- )
130
+ logger.critical(f"Loading container configuration from '{args.container_config}'...")
131
+ app_config: ContainerAppConfig = load_app_config_yaml(args.container_config)
132
+ logger.critical(f"Loaded configuration with {len(app_config.containers)} container(s)")
149
133
 
150
134
  logger.critical("Setting up managed identity and roles...")
151
135
  user_identity = create_or_get_user_identity(
@@ -180,14 +164,13 @@ def cli_deploy(args: Any) -> None:
180
164
  registry_server=args.registry_server,
181
165
  registry_user=registry_user,
182
166
  registry_pass_env_name=REGISTRY_PASS_SECRET_ENV_NAME,
183
- image_name=args.image_name,
184
- image_tag=revision_suffix,
185
167
  revision_suffix=revision_suffix,
186
168
  location=args.location,
187
169
  stage=args.stage,
170
+ container_configs=app_config.containers,
188
171
  target_port=args.target_port,
189
- cpu=args.cpu,
190
- memory=args.memory,
172
+ ingress_external=args.ingress_external,
173
+ ingress_transport=args.ingress_transport,
191
174
  min_replicas=args.min_replicas,
192
175
  max_replicas=args.max_replicas,
193
176
  secret_key_vault_config=SecretKeyVaultConfig(
@@ -196,8 +179,6 @@ def cli_deploy(args: Any) -> None:
196
179
  secret_names=args.env_var_secrets or [],
197
180
  user_identity=user_identity,
198
181
  ),
199
- env_var_names=args.env_vars,
200
- existing_image_tag=args.existing_image_tag,
201
182
  )
202
183
 
203
184
  if args.custom_domains:
@@ -352,65 +333,57 @@ def add_commands(subparsers: argparse._SubParsersAction) -> None:
352
333
  help="Container registry server.",
353
334
  )
354
335
  deploy_parser.add_argument(
355
- "--image-name",
336
+ "--keyvault-name",
356
337
  required=True,
357
338
  type=str,
358
- help="Name of the container image.",
339
+ help="Name of the Key Vault for storing secrets.",
359
340
  )
341
+
360
342
  deploy_parser.add_argument(
361
- "--existing-image-tag",
362
- required=False,
343
+ "--stage",
344
+ required=True,
363
345
  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
- ),
346
+ help="Deployment stage label (e.g., staging, prod) used for revision naming.",
370
347
  )
348
+
371
349
  deploy_parser.add_argument(
372
350
  "--target-port",
373
351
  required=True,
374
352
  type=int,
375
- help="Target port for the container app.",
353
+ help="Target port for the container app ingress.",
376
354
  )
355
+
377
356
  deploy_parser.add_argument(
378
- "--cpu",
379
- required=True,
380
- type=float,
381
- help="CPU allocation for the container app.",
357
+ "--ingress-external",
358
+ required=False,
359
+ type=bool,
360
+ default=True,
361
+ help="Whether ingress is external (default: True).",
382
362
  )
363
+
383
364
  deploy_parser.add_argument(
384
- "--memory",
385
- required=True,
365
+ "--ingress-transport",
366
+ required=False,
386
367
  type=str,
387
- help="Memory allocation for the container app (e.g., '2.0Gi').",
368
+ default="auto",
369
+ choices=["auto", "http", "http2", "tcp"],
370
+ help="Ingress transport protocol (default: auto).",
388
371
  )
372
+
389
373
  deploy_parser.add_argument(
390
374
  "--min-replicas",
391
375
  required=True,
392
376
  type=int,
393
377
  help="Minimum number of replicas for the container app.",
394
378
  )
379
+
395
380
  deploy_parser.add_argument(
396
381
  "--max-replicas",
397
382
  required=True,
398
383
  type=int,
399
384
  help="Maximum number of replicas for the container app.",
400
385
  )
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
386
 
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
387
  deploy_parser.add_argument(
415
388
  "--env-var-secrets",
416
389
  required=False,
@@ -419,14 +392,6 @@ def add_commands(subparsers: argparse._SubParsersAction) -> None:
419
392
  help="Space-separated names of environment variables to be stored as secrets in Key Vault.",
420
393
  )
421
394
 
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
395
  deploy_parser.add_argument(
431
396
  "--role-config",
432
397
  required=False,
@@ -458,10 +423,11 @@ def add_commands(subparsers: argparse._SubParsersAction) -> None:
458
423
  )
459
424
 
460
425
  deploy_parser.add_argument(
461
- "--dockerfile",
426
+ "--container-config",
462
427
  required=True,
463
- type=str,
464
- help="Path to the Dockerfile for building the container image.",
428
+ type=Path,
429
+ help="Path to YAML file containing container configurations "
430
+ "(includes image names, cpu, memory, env_vars, probes, ingress, and scale settings)",
465
431
  )
466
432
 
467
433
  deploy_parser.set_defaults(func=cli_deploy)
@@ -30,13 +30,15 @@ from azure.mgmt.appcontainers.models import (
30
30
  TrafficWeight,
31
31
  UserAssignedIdentity,
32
32
  )
33
- from azure.mgmt.appcontainers.models import Configuration as ContainerAppConfiguration
33
+ from azure.mgmt.appcontainers.models import (
34
+ Configuration as ContainerAppConfiguration,
35
+ )
34
36
  from azure.mgmt.keyvault.models import SecretCreateOrUpdateParameters, SecretProperties
35
37
 
36
38
  from ..identity.models import ManagedIdentity
37
39
  from ..utils import docker
38
40
  from ..utils.logging import get_logger
39
- from .model import RevisionDeploymentResult, SecretKeyVaultConfig
41
+ from .model import ContainerConfig, RevisionDeploymentResult, SecretKeyVaultConfig
40
42
 
41
43
  logger = get_logger(__name__)
42
44
 
@@ -195,6 +197,45 @@ def create_container_app_env(
195
197
  return None
196
198
 
197
199
 
200
+ def build_container_images(
201
+ container_configs: list[ContainerConfig],
202
+ registry_server: str,
203
+ revision_suffix: str,
204
+ ) -> list[str]:
205
+ image_names = []
206
+
207
+ for container_config in container_configs:
208
+ image_tag = revision_suffix
209
+ target_full_image_name = get_aca_docker_image_name(
210
+ registry_server, container_config.image_name, image_tag
211
+ )
212
+
213
+ if container_config.existing_image_tag:
214
+ logger.info(
215
+ f"Retagging existing image '{container_config.image_name}:"
216
+ f"{container_config.existing_image_tag}' to '{image_tag}'..."
217
+ )
218
+ source_full_image_name = get_aca_docker_image_name(
219
+ registry_server, container_config.image_name, container_config.existing_image_tag
220
+ )
221
+ docker.pull_retag_and_push_image(source_full_image_name, target_full_image_name)
222
+ logger.success(f"Image retagged successfully to '{image_tag}'")
223
+ elif container_config.dockerfile:
224
+ logger.info(
225
+ f"Building image '{container_config.image_name}' from "
226
+ f"Dockerfile '{container_config.dockerfile}'..."
227
+ )
228
+ build_acr_image(
229
+ dockerfile=container_config.dockerfile,
230
+ full_image_name=target_full_image_name,
231
+ registry_server=registry_server,
232
+ )
233
+ logger.success("Image built successfully")
234
+ image_names.append(target_full_image_name)
235
+
236
+ return image_names
237
+
238
+
198
239
  def deploy_revision(
199
240
  client: ContainerAppsAPIClient,
200
241
  subscription_id: str,
@@ -205,84 +246,106 @@ def deploy_revision(
205
246
  registry_server: str,
206
247
  registry_user: str,
207
248
  registry_pass_env_name: str,
208
- image_name: str,
209
- image_tag: str,
249
+ revision_suffix: str,
210
250
  location: str,
211
251
  stage: str,
252
+ container_configs: list[ContainerConfig], # list[ContainerConfig] from yaml_loader
212
253
  target_port: int,
213
- cpu: float,
214
- memory: str,
254
+ ingress_external: bool,
255
+ ingress_transport: str,
215
256
  min_replicas: int,
216
257
  max_replicas: int,
217
258
  secret_key_vault_config: SecretKeyVaultConfig,
218
- env_var_names: list[str],
219
- revision_suffix: str,
220
- existing_image_tag: str | None = None,
221
259
  ) -> RevisionDeploymentResult:
222
260
  """
223
- Deploy a new revision without updating traffic weights.
261
+ Deploy a new revision with multiple containers without updating traffic weights.
224
262
 
225
263
  This function creates a new revision with existing traffic preserved and checks
226
264
  if the activation succeeds. Returns revision information for use in subsequent
227
265
  traffic management operations.
228
266
 
229
- The image_tag parameter should be the revision suffix when building images with
230
- revision-specific tags for rollback support.
231
-
232
267
  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).
268
+ client: ContainerAppsAPIClient instance
269
+ subscription_id: Azure subscription ID
270
+ resource_group: Resource group name
271
+ container_app_env: Managed environment for the container app
272
+ user_identity: User-assigned managed identity
273
+ container_app_name: Name of the container app
274
+ registry_server: Container registry server URL
275
+ registry_user: Registry username
276
+ registry_pass_env_name: Name of the registry password environment variable
277
+ revision_suffix: Revision suffix for naming
278
+ location: Azure location
279
+ stage: Deployment stage label
280
+ container_configs: List of ContainerConfig objects from YAML
281
+ target_port: Target port for ingress
282
+ ingress_external: Whether ingress is external
283
+ ingress_transport: Ingress transport protocol
284
+ min_replicas: Minimum number of replicas
285
+ max_replicas: Maximum number of replicas
286
+ secret_key_vault_config: Key Vault configuration for secrets
239
287
 
240
288
  Returns:
241
289
  RevisionDeploymentResult with revision name and status information
242
290
 
243
291
  Raises:
244
- RuntimeError: If the deployment fails or if existing_image_tag is provided
245
- but the image doesn't exist
292
+ RuntimeError: If the deployment fails or image operations fail
246
293
  """
294
+ validate_revision_suffix_and_throw(revision_suffix, stage)
295
+
247
296
  logger.info(f"Deploying new revision for Container App '{container_app_name}'...")
297
+ logger.info(f"Building and deploying {len(container_configs)} container(s)...")
298
+
248
299
  secret_key_vault_config.secret_names.append(registry_pass_env_name)
249
- secrets, env_vars = _prepare_secrets_and_env_vars(
300
+ secrets, env_vars_dict = _prepare_secrets_and_env_vars(
250
301
  secret_config=secret_key_vault_config,
251
302
  subscription_id=subscription_id,
252
- env_var_names=env_var_names,
303
+ env_var_names=[
304
+ env_var
305
+ for container_config in container_configs
306
+ for env_var in container_config.env_vars
307
+ ],
253
308
  resource_group=resource_group,
254
309
  )
255
310
 
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
311
+ full_image_names = build_container_images(container_configs, registry_server, revision_suffix)
312
+ if len(full_image_names) != len(container_configs):
313
+ raise RuntimeError("Mismatch in number of built images and container configurations.")
314
+
315
+ # prepare container definitions
316
+ containers: list[Container] = []
317
+ for target_full_image_name, container_config in zip(
318
+ full_image_names, container_configs, strict=True
319
+ ):
320
+ container_env_vars = [
321
+ env_var for env_var in env_vars_dict if env_var.name in container_config.env_vars
322
+ ]
323
+ containers.append(
324
+ Container(
325
+ image=target_full_image_name,
326
+ name=container_config.name,
327
+ env=container_env_vars,
328
+ resources=ContainerResources(
329
+ cpu=container_config.cpu, memory=container_config.memory
330
+ ),
331
+ probes=container_config.probes,
332
+ )
265
333
  )
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
334
 
335
+ # prepare ingress with existing traffic weights
277
336
  existing_app = _get_container_app(client, resource_group, container_app_name)
278
337
  existing_traffic_weights = None
279
338
  if existing_app and existing_app.configuration and existing_app.configuration.ingress:
280
339
  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"
340
+ ingress: Ingress = Ingress(
341
+ external=ingress_external,
342
+ target_port=target_port,
343
+ transport=ingress_transport,
344
+ traffic=existing_traffic_weights, # Preserve existing traffic
285
345
  )
346
+
347
+ revision_name = generate_revision_name(container_app_name, revision_suffix, stage)
348
+ logger.info(f"Deploying revision '{revision_name}' with existing traffic preserved")
286
349
  poller = client.container_apps.begin_create_or_update(
287
350
  resource_group_name=resource_group,
288
351
  container_app_name=container_app_name,
@@ -290,12 +353,7 @@ def deploy_revision(
290
353
  location=location,
291
354
  environment_id=container_app_env.id,
292
355
  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
- ),
356
+ ingress=ingress,
299
357
  registries=[
300
358
  RegistryCredentials(
301
359
  server=registry_server,
@@ -308,14 +366,7 @@ def deploy_revision(
308
366
  ),
309
367
  template=Template(
310
368
  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
- ],
369
+ containers=containers,
319
370
  scale=Scale(min_replicas=min_replicas, max_replicas=max_replicas),
320
371
  ),
321
372
  identity=ManagedServiceIdentity(
@@ -1,6 +1,9 @@
1
1
  from dataclasses import dataclass
2
+ from typing import Any
2
3
 
4
+ from azure.mgmt.appcontainers.models import ContainerAppProbe
3
5
  from azure.mgmt.keyvault import KeyVaultManagementClient
6
+ from pydantic import BaseModel, Field, field_validator
4
7
 
5
8
  from ..identity.models import ManagedIdentity
6
9
 
@@ -33,3 +36,46 @@ class RevisionDeploymentResult:
33
36
  and self.provisioning_state == "Provisioned"
34
37
  and self.running_state not in ("Stopped", "Degraded", "Failed")
35
38
  )
39
+
40
+
41
+ class ContainerConfig(BaseModel):
42
+ """Configuration for a single container from YAML."""
43
+
44
+ name: str
45
+ image_name: str = Field(..., description="Just the image name, no registry or tag")
46
+ cpu: float
47
+ memory: str
48
+ env_vars: list[str] = Field(
49
+ default_factory=list, description="List of environment variable names to load"
50
+ )
51
+ probes: list[ContainerAppProbe] | None = Field(
52
+ default=None, description="List of probe configurations"
53
+ )
54
+ existing_image_tag: str | None = Field(default=None, description="Optional tag to retag from")
55
+ dockerfile: str | None = Field(default=None, description="Optional dockerfile path")
56
+
57
+ def post_init(self):
58
+ if not (self.dockerfile or self.existing_image_tag):
59
+ raise ValueError(
60
+ f"Container '{self.name}' must have either 'dockerfile' "
61
+ f"or 'existing_image_tag' specified"
62
+ )
63
+
64
+ @field_validator("probes", mode="before")
65
+ @classmethod
66
+ def parse_probes(cls, v: list[dict[str, Any]] | None) -> list[ContainerAppProbe] | None:
67
+ """Parse probe dictionaries to ContainerAppProbe objects."""
68
+ if v is None:
69
+ return None
70
+ return [ContainerAppProbe(**probe_data) for probe_data in v]
71
+
72
+ class Config:
73
+ """Pydantic configuration for ContainerConfig."""
74
+
75
+ arbitrary_types_allowed = True
76
+
77
+
78
+ class ContainerAppConfig(BaseModel):
79
+ containers: list[ContainerConfig] = Field(
80
+ ..., min_length=1, description="List of container configurations"
81
+ )
@@ -0,0 +1,53 @@
1
+ from pathlib import Path
2
+ from typing import Any
3
+
4
+ import yaml
5
+
6
+ from .model import ContainerAppConfig
7
+
8
+
9
+ def load_app_config_yaml(yaml_path: Path) -> ContainerAppConfig:
10
+ """
11
+ Load container configurations from YAML file using Pydantic for validation.
12
+
13
+ The YAML should have the following structure:
14
+ ```yaml
15
+ containers:
16
+ - name: my-app
17
+ image_name: my-image # Just the image name
18
+ cpu: 0.5
19
+ memory: "1.0Gi"
20
+ env_vars: # List of env var names to load from environment
21
+ - ENV_VAR1
22
+ - ENV_VAR2
23
+ dockerfile: ./Dockerfile # optional
24
+ existing_image_tag: v1.0 # optional
25
+ probes: # optional - use snake_case keys
26
+ - type: Liveness
27
+ http_get:
28
+ path: /health
29
+ port: 8080
30
+ initial_delay_seconds: 10
31
+ period_seconds: 30
32
+ ```
33
+
34
+ Args:
35
+ yaml_path: Path to the YAML configuration file
36
+
37
+ Returns:
38
+ List of ContainerConfig instances
39
+
40
+ Raises:
41
+ ValueError: If YAML structure is invalid or validation fails
42
+ """
43
+ with open(yaml_path) as f:
44
+ data: dict[str, Any] = yaml.safe_load(f)
45
+
46
+ if not data:
47
+ raise ValueError("YAML file is empty")
48
+
49
+ try:
50
+ app_config = ContainerAppConfig(**data)
51
+ return app_config
52
+ except Exception as e:
53
+ raise ValueError(f"Invalid YAML configuration: {e}") from e
@@ -3,6 +3,34 @@
3
3
  import subprocess
4
4
  from pathlib import Path
5
5
 
6
+ from .logging import get_logger
7
+
8
+ logger = get_logger(__name__)
9
+
10
+
11
+ def _run_and_stream(cmd: list[str], show_output: bool = True) -> int:
12
+ """Run a command and stream output to stderr in real-time.
13
+
14
+ Args:
15
+ cmd: Command and arguments to run
16
+ show_output: Whether to display output to stderr
17
+
18
+ Returns:
19
+ The return code of the process
20
+ """
21
+ process = subprocess.Popen(
22
+ cmd,
23
+ stdout=subprocess.PIPE,
24
+ stderr=subprocess.STDOUT,
25
+ text=True,
26
+ )
27
+ if process.stdout is not None:
28
+ for line in iter(process.stdout.readline, ""):
29
+ if line and show_output:
30
+ logger.info(line.rstrip("\n"))
31
+ process.stdout.close()
32
+ return process.wait()
33
+
6
34
 
7
35
  def image_exists(full_image_name: str) -> bool:
8
36
  """
@@ -14,12 +42,8 @@ def image_exists(full_image_name: str) -> bool:
14
42
  Returns:
15
43
  True if the image exists locally, False otherwise
16
44
  """
17
- check_image_result = subprocess.run(
18
- ["docker", "image", "inspect", full_image_name],
19
- capture_output=True,
20
- text=True,
21
- )
22
- return check_image_result.returncode == 0
45
+ returncode = _run_and_stream(["docker", "image", "inspect", full_image_name], show_output=False)
46
+ return returncode == 0
23
47
 
24
48
 
25
49
  def push_image(full_image_name: str) -> None:
@@ -32,13 +56,9 @@ def push_image(full_image_name: str) -> None:
32
56
  Raises:
33
57
  RuntimeError: If the docker push command fails
34
58
  """
35
- push_result = subprocess.run(
36
- ["docker", "push", full_image_name],
37
- capture_output=True,
38
- text=True,
39
- )
40
- if push_result.returncode != 0:
41
- raise RuntimeError(f"Docker push failed {push_result.stderr}")
59
+ returncode = _run_and_stream(["docker", "push", full_image_name])
60
+ if returncode != 0:
61
+ raise RuntimeError("Docker push failed")
42
62
 
43
63
 
44
64
  def pull_image(full_image_name: str) -> None:
@@ -51,13 +71,9 @@ def pull_image(full_image_name: str) -> None:
51
71
  Raises:
52
72
  RuntimeError: If the docker pull command fails
53
73
  """
54
- pull_result = subprocess.run(
55
- ["docker", "pull", full_image_name],
56
- capture_output=True,
57
- text=True,
58
- )
59
- if pull_result.returncode != 0:
60
- raise RuntimeError(f"Docker pull failed: {pull_result.stderr}")
74
+ returncode = _run_and_stream(["docker", "pull", full_image_name])
75
+ if returncode != 0:
76
+ raise RuntimeError("Docker pull failed")
61
77
 
62
78
 
63
79
  def tag_image(source_image: str, target_image: str) -> None:
@@ -71,13 +87,9 @@ def tag_image(source_image: str, target_image: str) -> None:
71
87
  Raises:
72
88
  RuntimeError: If the docker tag command fails
73
89
  """
74
- tag_result = subprocess.run(
75
- ["docker", "tag", source_image, target_image],
76
- capture_output=True,
77
- text=True,
78
- )
79
- if tag_result.returncode != 0:
80
- raise RuntimeError(f"Docker tag failed: {tag_result.stderr}")
90
+ returncode = _run_and_stream(["docker", "tag", source_image, target_image])
91
+ if returncode != 0:
92
+ raise RuntimeError("Docker tag failed")
81
93
 
82
94
 
83
95
  def pull_retag_and_push_image(
@@ -116,7 +128,7 @@ def build_and_push_image(
116
128
  RuntimeError: If the docker build and push command fails
117
129
  """
118
130
  src_folder = str(Path(dockerfile).parent)
119
- build_result = subprocess.run(
131
+ returncode = _run_and_stream(
120
132
  [
121
133
  "docker",
122
134
  "buildx",
@@ -129,9 +141,7 @@ def build_and_push_image(
129
141
  dockerfile,
130
142
  src_folder,
131
143
  "--push",
132
- ],
133
- capture_output=True,
134
- text=True,
144
+ ]
135
145
  )
136
- if build_result.returncode != 0:
137
- raise RuntimeError(f"Docker build and push failed: {build_result.stderr}")
146
+ if returncode != 0:
147
+ raise RuntimeError("Docker build and push failed")
@@ -88,7 +88,7 @@ def configure_logging(level: str = "info") -> None:
88
88
  stderr_handler = logging.StreamHandler(sys.stderr)
89
89
  stderr_handler.setLevel(log_level)
90
90
  stderr_handler.addFilter(lambda record: record.levelno != STDOUT_LEVEL)
91
- stderr_handler.setFormatter(ColoredFormatter("%(message)s"))
91
+ stderr_handler.setFormatter(ColoredFormatter("%(asctime)s %(message)s"))
92
92
  root_logger.addHandler(stderr_handler)
93
93
 
94
94
  # Handler for stdout (only for STDOUT level)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: azure-deploy-cli
3
- Version: 0.1.12
3
+ Version: 1.0.1
4
4
  Summary: Python CLI for Azure deployment automation - identity, roles, and container apps management
5
5
  Author-email: decewei <celinew1221@gmail.com>
6
6
  License: Mozilla Public License Version 2.0
@@ -386,84 +386,68 @@ Classifier: Intended Audience :: Developers
386
386
  Classifier: License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)
387
387
  Classifier: Natural Language :: English
388
388
  Classifier: Programming Language :: Python :: 3
389
- Classifier: Programming Language :: Python :: 3.10
390
389
  Classifier: Programming Language :: Python :: 3.11
391
390
  Classifier: Programming Language :: Python :: 3.12
392
- Requires-Python: >=3.10
391
+ Requires-Python: >=3.11
393
392
  Description-Content-Type: text/markdown
394
393
  License-File: LICENSE
395
- Requires-Dist: azure-identity>=1.14.0
396
- Requires-Dist: azure-mgmt-appcontainers
397
- Requires-Dist: azure-mgmt-authorization
398
- Requires-Dist: azure-mgmt-keyvault
399
- Requires-Dist: azure-mgmt-msi
400
- Requires-Dist: python-dotenv>=1.0.0
401
- Requires-Dist: pydantic>=2.0.0
394
+ Requires-Dist: azure-identity==1.25.1
395
+ Requires-Dist: azure-mgmt-appcontainers==4.0.0
396
+ Requires-Dist: azure-mgmt-authorization==4.0.0
397
+ Requires-Dist: azure-mgmt-keyvault==13.0.0
398
+ Requires-Dist: azure-mgmt-msi==7.1.0
399
+ Requires-Dist: python-dotenv==1.2.1
400
+ Requires-Dist: pydantic==2.12.5
401
+ Requires-Dist: PyYAML==6.0.3
402
402
  Provides-Extra: dev
403
- Requires-Dist: ruff>=0.1.0; extra == "dev"
404
- Requires-Dist: mypy>=1.7.0; extra == "dev"
405
- Requires-Dist: pytest>=7.0; extra == "dev"
406
- Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
407
- Requires-Dist: coverage>=7.3.0; extra == "dev"
408
- Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
409
- Requires-Dist: commitizen>=3.0.0; extra == "dev"
410
- Requires-Dist: pre-commit>=3.0.0; extra == "dev"
411
- Requires-Dist: types-setuptools; extra == "dev"
403
+ Requires-Dist: ruff==0.14.10; extra == "dev"
404
+ Requires-Dist: mypy==1.19.1; extra == "dev"
405
+ Requires-Dist: pytest==9.0.2; extra == "dev"
406
+ Requires-Dist: pytest-asyncio==1.3.0; extra == "dev"
407
+ Requires-Dist: coverage==7.13.1; extra == "dev"
408
+ Requires-Dist: pytest-cov==7.0.0; extra == "dev"
409
+ Requires-Dist: commitizen==4.11.0; extra == "dev"
410
+ Requires-Dist: pre-commit==4.1.0; extra == "dev"
411
+ Requires-Dist: types-setuptools==80.9.0.20251223; extra == "dev"
412
+ Requires-Dist: types-PyYAML==6.0.12.20250915; extra == "dev"
412
413
  Dynamic: license-file
413
414
 
414
415
  # Azure Deploy CLI
415
416
 
416
417
  Python CLI for Azure deployment automation - manage identities, roles, and Container Apps deployments.
417
418
 
418
- ## Version Management and Changelog
419
-
420
- This project uses a dual-tool approach:
421
-
422
- - **[setuptools-scm](https://setuptools-scm.readthedocs.io/)** - Automatic versioning based on git tags (dynamic at build time)
423
- - **[commitizen](https://commitizen-tools.github.io/commitizen/)** - Version bumping and tagging with semantic versioning
424
- - **[git-cliff](https://git-cliff.org/)** - Automatic changelog generation from conventional commits
425
-
426
- **Release workflow:**
427
- 1. git-cliff generates changelog from commits since last tag
428
- 2. commitizen bumps version and creates git tag
429
- 3. New version is committed alongside updated changelog
430
- 4. Tag triggers PyPI publishing and GitHub Release
431
-
432
- No manual version or changelog updates are needed.
433
-
434
419
  ## Quick Start
435
420
 
436
421
  **Install for development:**
437
422
 
438
423
  ```bash
439
424
  cd /path/to/azure-deploy-cli
440
- pip install -e ".[dev]"
425
+ source setup.sh -i
441
426
  azd --help
442
427
  ```
443
428
 
444
429
  **Use in another project:**
445
430
 
446
431
  ```bash
447
- pip install -e /path/to/scripts
432
+ pip install azure-deploy-cli
448
433
  ```
449
434
 
450
435
  ## Installation
451
436
 
452
- | Method | Command |
453
- |--------|----------|
454
- | Local development | `pip install -e ".[dev]"` |
455
- | Local changes | `pip install -e /path/to/azure-deploy-cli` |
456
- | From PyPI | `pip install azure-deploy-cli` |
437
+ | Method | Command |
438
+ | ------------------- | ----------------------------- |
439
+ | Local development | `source setup.sh -i` |
440
+ | From PyPI | `pip install azure-deploy-cli`|
457
441
 
458
442
  ## CLI Commands
459
443
 
460
444
  ### Azure Container Apps (ACA) Deployment
461
445
 
462
- The ACA deployment process is split into two stages for better control:
446
+ The ACA deployment process uses YAML configuration for containers and is split into two stages for better control:
463
447
 
464
448
  #### Stage 1: Deploy Revision
465
449
 
466
- Deploy a new container revision without affecting traffic:
450
+ Deploy a new container revision from YAML configuration without affecting traffic:
467
451
 
468
452
  ```bash
469
453
  azd azaca deploy \
@@ -474,25 +458,76 @@ azd azaca deploy \
474
458
  --user-assigned-identity-name my-identity \
475
459
  --container-app my-app \
476
460
  --registry-server myregistry.azurecr.io \
477
- --image-name my-image \
478
461
  --stage prod \
479
- --target-port 8000 \
480
- --cpu 0.5 \
481
- --memory 1.0 \
462
+ --target-port 8080 \
482
463
  --min-replicas 1 \
483
464
  --max-replicas 10 \
484
465
  --keyvault-name my-keyvault \
485
- --dockerfile ./Dockerfile \
486
- --env-vars ENV_VAR1 ENV_VAR2 \
466
+ --container-config ./container-config.yaml \
487
467
  --env-var-secrets SECRET1 SECRET2
488
468
  ```
489
469
 
490
470
  This command:
491
471
 
472
+ - Loads container configurations from YAML file
473
+ - Builds/pushes container images for all containers
492
474
  - Creates or updates a new revision with 0% traffic
475
+ - Supports multiple containers with independent configurations
493
476
  - Verifies the revision is healthy and active
494
477
  - Outputs the revision name for use in traffic management
495
478
 
479
+ **Container Configuration YAML:**
480
+
481
+ The `--container-config` file specifies container settings including images, resources, environment variables, and health probes:
482
+
483
+ ```yaml
484
+ containers:
485
+ - name: my-app
486
+ image_name: my-image
487
+ cpu: 0.5
488
+ memory: "1.0Gi"
489
+ env_vars:
490
+ - ENV_VAR1
491
+ - ENV_VAR2
492
+ # relative to the directory which command will run fromm
493
+ dockerfile: ./Dockerfile
494
+ probes:
495
+ - type: Liveness
496
+ http_get:
497
+ path: /health
498
+ port: 8080
499
+ initial_delay_seconds: 10
500
+ period_seconds: 30
501
+ - type: Readiness
502
+ http_get:
503
+ path: /ready
504
+ port: 8080
505
+ initial_delay_seconds: 5
506
+ period_seconds: 10
507
+
508
+ - name: sidecar
509
+ image_name: sidecar-image
510
+ cpu: 0.25
511
+ memory: "0.5Gi"
512
+ env_vars:
513
+ - SIDECAR_CONFIG
514
+ existing_image_tag: v1.0.0 # Optional: retag from existing image
515
+ ```
516
+
517
+ **Configuration Fields:**
518
+
519
+ - `containers` (required): List of container configurations
520
+ - `name`: Container name (required)
521
+ - `image_name`: Image name without registry/tag (required)
522
+ - `cpu`: CPU allocation (required, e.g., 0.5)
523
+ - `memory`: Memory allocation (required, e.g., "1.0Gi")
524
+ - `env_vars`: List of environment variable names to load (optional)
525
+ - `dockerfile`: Path to Dockerfile for building (required if existing_image_tag not provided)
526
+ - `existing_image_tag`: Tag to retag from instead of building (required if dockerfile not provided)
527
+ - `probes`: List of health probes (optional)
528
+
529
+ **Note:** Ingress configuration (target port) and scaling parameters (min/max replicas) are specified via CLI arguments, not in the YAML file.
530
+
496
531
  #### Stage 2: Update Traffic Weights
497
532
 
498
533
  Update traffic distribution and deactivate old revisions:
@@ -603,20 +638,6 @@ azd create-and-assign \
603
638
  --print
604
639
  ```
605
640
 
606
- ## Development
607
-
608
- ```bash
609
- make install-dev # Install with dev tools
610
- make build # Run lint + type-check + test
611
- make lint # Code linting with ruff
612
- make format # Auto-format code
613
- make type-check # Type checking with mypy
614
- make test # Run tests with pytest
615
- make clean # Remove build artifacts
616
- ```
617
-
618
- Commit using [Conventional Commits](https://www.conventionalcommits.org/) format (e.g., `feat: add feature`, `fix: resolve bug`)
619
-
620
641
  ## Scripting and Output Handling
621
642
 
622
643
  This CLI is designed for both interactive use and automated scripting. To support this, it follows the standard practice of separating output streams:
@@ -1,10 +1,11 @@
1
1
  azure_deploy_cli/__init__.py,sha256=Xj8ZMqmqKWc2hhR8rtSETkOmkvrFgh020MYVB_yHp4s,1134
2
- azure_deploy_cli/_version.py,sha256=cEPXLUpTV7EzqolnyXW8nf8Hr6IVyBji9CzB6Cq_Ar0,706
2
+ azure_deploy_cli/_version.py,sha256=JvmBpae6cHui8lSCsCcZQAxzawN2NERHGsr-rIUeJMo,704
3
3
  azure_deploy_cli/cli.py,sha256=I1hXskorvbryjNg0XXdqLjdBuQTPKgZeW03tLHlLxzk,1663
4
4
  azure_deploy_cli/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
- azure_deploy_cli/aca/aca_cli.py,sha256=h-AOoCBrRLV92fitzmF-t9orSHNataj23IeqxD5kY3U,17042
6
- azure_deploy_cli/aca/deploy_aca.py,sha256=HTLGwSSNdjDVTrQX7ZCrKDWQbd6XJLsS8NDFjuyVbjo,28426
7
- azure_deploy_cli/aca/model.py,sha256=-ZnJ8RaiKAHqIgWUbmrWXrUa2MTeEqKTZFyNLbo4FUM,894
5
+ azure_deploy_cli/aca/aca_cli.py,sha256=_6c6l7iH5W2Prue3JHSHBQL12WGs9twGivmsZ9h8STU,15944
6
+ azure_deploy_cli/aca/deploy_aca.py,sha256=puuQgP6j-0CJ9rRUFs_yBLiZkEm9bVlQUpRoSon52ac,30303
7
+ azure_deploy_cli/aca/model.py,sha256=rCIVd9WF89g7r7AOZoCPTG6siMaZwSXNR_RzqmKRQEQ,2581
8
+ azure_deploy_cli/aca/yaml_loader.py,sha256=awCJLx88j8ErLm-tLIdV8mFtlSQqGezIqugU0v4m9BI,1413
8
9
  azure_deploy_cli/aca/bash/aca-cert/create.sh,sha256=QOPm7b1cTguBDJ2T6Nm65rMLlG1_ccfTcev00IQ-CI0,6485
9
10
  azure_deploy_cli/aca/bash/aca-cert/destroy.sh,sha256=iao35H_c4YnZHLDpKaYeyd3I7RhHKJzSAUbNovUdcFg,1171
10
11
  azure_deploy_cli/identity/__init__.py,sha256=6s9SoMMhHjMNWNyjkQxWhp2cTbGru3bmrF56-8nCftw,919
@@ -17,14 +18,14 @@ azure_deploy_cli/identity/role.py,sha256=WlSJmn54Urar58x4HmJUC0adbeuSTPyW-0lGodv
17
18
  azure_deploy_cli/identity/service_principal.py,sha256=IDBKVVTQP4PBso0Cv0gcApexKEdXUlKEQ3sMr4OjC1U,7958
18
19
  azure_deploy_cli/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
19
20
  azure_deploy_cli/utils/azure_cli.py,sha256=mQXHedduPShY6NqG4JePijWJnK3alCMxrTjqHCn2MaM,2576
20
- azure_deploy_cli/utils/docker.py,sha256=pMV_edtU-zDMvzygvVx2j7MEUNHBrbhXWOqL7TK1RRU,3675
21
+ azure_deploy_cli/utils/docker.py,sha256=S-X5YkuxiC6TEphwrXlM8Hk_Ik-i_eTnmAs6ymvN4Z8,3965
21
22
  azure_deploy_cli/utils/env.py,sha256=Qoc0mlrEcyCGk6TLl0AJNfuvhdBuok_93EW2g7Clpu8,3451
22
23
  azure_deploy_cli/utils/key_vault.py,sha256=9JYE1pmoPhggU7TZXod_qmu8TgeVmdM0OEIVHPYtPvM,354
23
- azure_deploy_cli/utils/logging.py,sha256=9R-Kxwzp6KhvcDFwjDMRbQemcC0uPsAbhgJY41xgl8M,3801
24
+ azure_deploy_cli/utils/logging.py,sha256=NxrhhNGnS0SWReKbwJ56A8LYLmjRd77ZBVOVQe9bgQE,3813
24
25
  azure_deploy_cli/utils/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
25
- azure_deploy_cli-0.1.12.dist-info/licenses/LICENSE,sha256=Pz2eACSxkhsGfW9_iN60pgy-enjnbGTj8df8O3ebnQQ,16726
26
- azure_deploy_cli-0.1.12.dist-info/METADATA,sha256=dXyhFVNHCwCZGcFjSDQ3qecdHqXFhqPmmM9R4IeuxZg,28420
27
- azure_deploy_cli-0.1.12.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
28
- azure_deploy_cli-0.1.12.dist-info/entry_points.txt,sha256=vuN79v5YJtDi5tF3rCd5qVvCGAMEQMImRHq64B-y-KQ,91
29
- azure_deploy_cli-0.1.12.dist-info/top_level.txt,sha256=luw_MJJWFd2vFwCtx3wbmihlkCdlZxk1NZou1eToBtU,17
30
- azure_deploy_cli-0.1.12.dist-info/RECORD,,
26
+ azure_deploy_cli-1.0.1.dist-info/licenses/LICENSE,sha256=Pz2eACSxkhsGfW9_iN60pgy-enjnbGTj8df8O3ebnQQ,16726
27
+ azure_deploy_cli-1.0.1.dist-info/METADATA,sha256=n5aerU-WqGhS9bkhb03M2iYdRvZpBcu01W78TKFqqRk,29137
28
+ azure_deploy_cli-1.0.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
29
+ azure_deploy_cli-1.0.1.dist-info/entry_points.txt,sha256=vuN79v5YJtDi5tF3rCd5qVvCGAMEQMImRHq64B-y-KQ,91
30
+ azure_deploy_cli-1.0.1.dist-info/top_level.txt,sha256=luw_MJJWFd2vFwCtx3wbmihlkCdlZxk1NZou1eToBtU,17
31
+ azure_deploy_cli-1.0.1.dist-info/RECORD,,