azure-deploy-cli 0.1.12__py3-none-any.whl → 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.0'
32
+ __version_tuple__ = version_tuple = (1, 0, 0)
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.0
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
@@ -399,6 +399,7 @@ Requires-Dist: azure-mgmt-keyvault
399
399
  Requires-Dist: azure-mgmt-msi
400
400
  Requires-Dist: python-dotenv>=1.0.0
401
401
  Requires-Dist: pydantic>=2.0.0
402
+ Requires-Dist: pyyaml>=6.0
402
403
  Provides-Extra: dev
403
404
  Requires-Dist: ruff>=0.1.0; extra == "dev"
404
405
  Requires-Dist: mypy>=1.7.0; extra == "dev"
@@ -409,6 +410,7 @@ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
409
410
  Requires-Dist: commitizen>=3.0.0; extra == "dev"
410
411
  Requires-Dist: pre-commit>=3.0.0; extra == "dev"
411
412
  Requires-Dist: types-setuptools; extra == "dev"
413
+ Requires-Dist: types-PyYAML; extra == "dev"
412
414
  Dynamic: license-file
413
415
 
414
416
  # Azure Deploy CLI
@@ -459,11 +461,11 @@ pip install -e /path/to/scripts
459
461
 
460
462
  ### Azure Container Apps (ACA) Deployment
461
463
 
462
- The ACA deployment process is split into two stages for better control:
464
+ The ACA deployment process uses YAML configuration for containers and is split into two stages for better control:
463
465
 
464
466
  #### Stage 1: Deploy Revision
465
467
 
466
- Deploy a new container revision without affecting traffic:
468
+ Deploy a new container revision from YAML configuration without affecting traffic:
467
469
 
468
470
  ```bash
469
471
  azd azaca deploy \
@@ -474,25 +476,76 @@ azd azaca deploy \
474
476
  --user-assigned-identity-name my-identity \
475
477
  --container-app my-app \
476
478
  --registry-server myregistry.azurecr.io \
477
- --image-name my-image \
478
479
  --stage prod \
479
- --target-port 8000 \
480
- --cpu 0.5 \
481
- --memory 1.0 \
480
+ --target-port 8080 \
482
481
  --min-replicas 1 \
483
482
  --max-replicas 10 \
484
483
  --keyvault-name my-keyvault \
485
- --dockerfile ./Dockerfile \
486
- --env-vars ENV_VAR1 ENV_VAR2 \
484
+ --container-config ./container-config.yaml \
487
485
  --env-var-secrets SECRET1 SECRET2
488
486
  ```
489
487
 
490
488
  This command:
491
489
 
490
+ - Loads container configurations from YAML file
491
+ - Builds/pushes container images for all containers
492
492
  - Creates or updates a new revision with 0% traffic
493
+ - Supports multiple containers with independent configurations
493
494
  - Verifies the revision is healthy and active
494
495
  - Outputs the revision name for use in traffic management
495
496
 
497
+ **Container Configuration YAML:**
498
+
499
+ The `--container-config` file specifies container settings including images, resources, environment variables, and health probes:
500
+
501
+ ```yaml
502
+ containers:
503
+ - name: my-app
504
+ image_name: my-image
505
+ cpu: 0.5
506
+ memory: "1.0Gi"
507
+ env_vars:
508
+ - ENV_VAR1
509
+ - ENV_VAR2
510
+ # relative to the directory which command will run fromm
511
+ dockerfile: ./Dockerfile
512
+ probes:
513
+ - type: Liveness
514
+ http_get:
515
+ path: /health
516
+ port: 8080
517
+ initial_delay_seconds: 10
518
+ period_seconds: 30
519
+ - type: Readiness
520
+ http_get:
521
+ path: /ready
522
+ port: 8080
523
+ initial_delay_seconds: 5
524
+ period_seconds: 10
525
+
526
+ - name: sidecar
527
+ image_name: sidecar-image
528
+ cpu: 0.25
529
+ memory: "0.5Gi"
530
+ env_vars:
531
+ - SIDECAR_CONFIG
532
+ existing_image_tag: v1.0.0 # Optional: retag from existing image
533
+ ```
534
+
535
+ **Configuration Fields:**
536
+
537
+ - `containers` (required): List of container configurations
538
+ - `name`: Container name (required)
539
+ - `image_name`: Image name without registry/tag (required)
540
+ - `cpu`: CPU allocation (required, e.g., 0.5)
541
+ - `memory`: Memory allocation (required, e.g., "1.0Gi")
542
+ - `env_vars`: List of environment variable names to load (optional)
543
+ - `dockerfile`: Path to Dockerfile for building (required if existing_image_tag not provided)
544
+ - `existing_image_tag`: Tag to retag from instead of building (required if dockerfile not provided)
545
+ - `probes`: List of health probes (optional)
546
+
547
+ **Note:** Ingress configuration (target port) and scaling parameters (min/max replicas) are specified via CLI arguments, not in the YAML file.
548
+
496
549
  #### Stage 2: Update Traffic Weights
497
550
 
498
551
  Update traffic distribution and deactivate old revisions:
@@ -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=vLA4ITz09S-S435nq6yTF6l3qiSz6w4euS1rOxXgd1M,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.0.dist-info/licenses/LICENSE,sha256=Pz2eACSxkhsGfW9_iN60pgy-enjnbGTj8df8O3ebnQQ,16726
27
+ azure_deploy_cli-1.0.0.dist-info/METADATA,sha256=cQtDCD1kYILhA56bgSEAoeYHsZ-HqXlpsx1hB3zGdag,30298
28
+ azure_deploy_cli-1.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
29
+ azure_deploy_cli-1.0.0.dist-info/entry_points.txt,sha256=vuN79v5YJtDi5tF3rCd5qVvCGAMEQMImRHq64B-y-KQ,91
30
+ azure_deploy_cli-1.0.0.dist-info/top_level.txt,sha256=luw_MJJWFd2vFwCtx3wbmihlkCdlZxk1NZou1eToBtU,17
31
+ azure_deploy_cli-1.0.0.dist-info/RECORD,,