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,268 @@
1
+ """Service principal lifecycle management."""
2
+
3
+ import json
4
+ import subprocess
5
+ from typing import Any
6
+
7
+ from ..utils.azure_cli import get_subscription_and_tenant, run_command
8
+ from ..utils.logging import get_logger
9
+ from .models import SPAuthCredentials, SPAuthCredentialsWithSecret, SPCreateResult
10
+
11
+ logger = get_logger(__name__)
12
+
13
+
14
+ def list_cmd(sp_name: str) -> list[str]:
15
+ """Build command to list service principals by name."""
16
+ return [
17
+ "az",
18
+ "ad",
19
+ "sp",
20
+ "list",
21
+ "--filter",
22
+ f"displayName eq '{sp_name}'",
23
+ "--output",
24
+ "json",
25
+ ]
26
+
27
+
28
+ def exists_sp(sp_name: str) -> str | None:
29
+ """
30
+ Check if service principal exists by name.
31
+
32
+ Args:
33
+ sp_name: Display name of the service principal
34
+
35
+ Returns:
36
+ Object ID if found, None otherwise
37
+
38
+ Raises:
39
+ ValueError: If multiple service principals with same name found
40
+ """
41
+ result: list[dict[str, Any]] = run_command(list_cmd(sp_name))
42
+
43
+ if not result or len(result) == 0:
44
+ return None
45
+
46
+ if len(result) > 1:
47
+ raise ValueError(
48
+ f"Multiple service principals found with name '{sp_name}'. "
49
+ "Please use a more specific name."
50
+ )
51
+
52
+ sp_object_id: str = result[0]["id"]
53
+ return sp_object_id
54
+
55
+
56
+ def get_sp(sp_name: str, subscription_id: str, tenant_id: str) -> SPCreateResult | None:
57
+ """
58
+ Get existing service principal by name.
59
+
60
+ Args:
61
+ sp_name: Display name of the service principal
62
+ subscription_id: Azure subscription ID
63
+ tenant_id: Azure tenant ID
64
+
65
+ Returns:
66
+ SPCreateResult if found, None otherwise
67
+ """
68
+ list_cmd_args: list[str] = list_cmd(sp_name)
69
+
70
+ try:
71
+ existing_sps: list[dict[str, Any]] = run_command(list_cmd_args)
72
+ if existing_sps:
73
+ logger.warning(f"Service principal '{sp_name}' already exists")
74
+ sp = existing_sps[0]
75
+ object_id: str = sp.get("id", "")
76
+ app_id: str = sp.get("appId", "")
77
+
78
+ if not object_id or not app_id:
79
+ raise ValueError("Failed to load existing service principal details")
80
+
81
+ # Note: existing SP won't have clientSecret - caller must reset
82
+ # credentials
83
+ return SPCreateResult(
84
+ objectId=object_id,
85
+ authCredentials=SPAuthCredentials(
86
+ clientId=app_id,
87
+ subscriptionId=subscription_id,
88
+ tenantId=tenant_id,
89
+ ),
90
+ )
91
+ except (subprocess.CalledProcessError, json.JSONDecodeError):
92
+ return None
93
+ return None
94
+
95
+
96
+ def create_sp(
97
+ sp_name: str,
98
+ skip_assignment: bool = True,
99
+ ) -> SPCreateResult:
100
+ """
101
+ Create a service principal if it doesn't already exist.
102
+
103
+ Args:
104
+ sp_name: Name of the service principal to create
105
+ skip_assignment: Whether to skip role assignment during creation
106
+
107
+ Returns:
108
+ SPCreateResult containing object ID and credentials
109
+
110
+ Raises:
111
+ subprocess.CalledProcessError: If creation fails
112
+ ValueError: If service principal creation is unsuccessful
113
+ """
114
+ try:
115
+ subscription_id, tenant_id = get_subscription_and_tenant()
116
+
117
+ logger.info(f"Checking if service principal '{sp_name}' exists")
118
+ result = get_sp(sp_name, subscription_id, tenant_id)
119
+ if result is not None:
120
+ logger.warning(f"Service principal '{sp_name}' already exists")
121
+ return result
122
+
123
+ logger.info(f"Creating service principal '{sp_name}'")
124
+ create_cmd: list[str] = [
125
+ "az",
126
+ "ad",
127
+ "sp",
128
+ "create-for-rbac",
129
+ "--name",
130
+ sp_name,
131
+ "--output",
132
+ "json",
133
+ ]
134
+
135
+ if skip_assignment:
136
+ create_cmd.insert(4, "--skip-assignment")
137
+
138
+ sp_output: dict[str, Any] = run_command(create_cmd)
139
+ get_sp_output = get_sp(sp_name, subscription_id, tenant_id)
140
+ if not get_sp_output:
141
+ raise ValueError("Failed to create service principal")
142
+ logger.info(f"{sp_output} {get_sp_output}")
143
+
144
+ object_id = get_sp_output.objectId
145
+ app_id = sp_output.get("appId", "")
146
+ client_secret = sp_output.get("password", "")
147
+
148
+ logger.success(f"Service principal '{sp_name}' created successfully")
149
+
150
+ return SPCreateResult(
151
+ objectId=object_id,
152
+ authCredentials=SPAuthCredentialsWithSecret(
153
+ clientId=app_id,
154
+ clientSecret=client_secret,
155
+ subscriptionId=subscription_id,
156
+ tenantId=tenant_id,
157
+ ),
158
+ )
159
+
160
+ except subprocess.CalledProcessError as e:
161
+ logger.error(f"Failed to create service principal: {str(e)}")
162
+ raise
163
+
164
+
165
+ def reset_sp_credentials(
166
+ sp_name: str,
167
+ credential_name: str = "Grant Scraper",
168
+ years: int = 2,
169
+ ) -> SPAuthCredentialsWithSecret:
170
+ """
171
+ Reset service principal credentials.
172
+
173
+ Args:
174
+ sp_name: The display name of the service principal
175
+ credential_name: Display name for the credential
176
+ years: Number of years for the credential validity
177
+
178
+ Returns:
179
+ AuthCredentialsWithSecret with clientId, clientSecret,
180
+ subscriptionId, tenantId
181
+
182
+ Raises:
183
+ subprocess.CalledProcessError: If reset fails
184
+ ValueError: If service principal not found
185
+ """
186
+ try:
187
+ subscription_id, tenant_id = get_subscription_and_tenant()
188
+
189
+ logger.critical(f"Looking up service principal '{sp_name}'")
190
+
191
+ # Get the app ID for the service principal by name
192
+ sp_result = get_sp(sp_name, subscription_id, tenant_id)
193
+ if not sp_result:
194
+ raise ValueError(f"Service principal '{sp_name}' not found")
195
+
196
+ client_id = sp_result.authCredentials.clientId
197
+ logger.info(f"Found service principal with app ID: {client_id}")
198
+
199
+ logger.critical(f"Resetting credentials for service principal '{sp_name}'")
200
+
201
+ reset_cmd: list[str] = [
202
+ "az",
203
+ "ad",
204
+ "sp",
205
+ "credential",
206
+ "reset",
207
+ "--id",
208
+ client_id,
209
+ "--display-name",
210
+ credential_name,
211
+ "--years",
212
+ str(years),
213
+ "--output",
214
+ "json",
215
+ ]
216
+
217
+ result: dict[str, Any] = run_command(reset_cmd)
218
+
219
+ logger.success(f"Credentials reset for service principal '{sp_name}'")
220
+
221
+ return SPAuthCredentialsWithSecret(
222
+ clientId=client_id,
223
+ clientSecret=result.get("password", ""),
224
+ subscriptionId=subscription_id,
225
+ tenantId=tenant_id,
226
+ )
227
+
228
+ except subprocess.CalledProcessError as e:
229
+ logger.error(f"Failed to reset service principal credentials: {str(e)}")
230
+ raise
231
+
232
+
233
+ def delete_service_principal_by_name(sp_name: str) -> None:
234
+ """
235
+ Delete a service principal by display name.
236
+
237
+ Args:
238
+ sp_name: The display name of the service principal to delete
239
+
240
+ Raises:
241
+ subprocess.CalledProcessError: If the delete operation fails
242
+ ValueError: If the service principal is not found
243
+ """
244
+ try:
245
+ logger.info(f"Looking up service principal '{sp_name}'")
246
+ sp_object_id = exists_sp(sp_name)
247
+ if not sp_object_id:
248
+ logger.success(f"Service principal '{sp_name}' does not exist; nothing to delete")
249
+ return
250
+ logger.info(f"Found service principal with object ID: {sp_object_id}")
251
+
252
+ delete_cmd: list[str] = [
253
+ "az",
254
+ "ad",
255
+ "sp",
256
+ "delete",
257
+ "--id",
258
+ sp_object_id,
259
+ "--output",
260
+ "json",
261
+ ]
262
+
263
+ run_command(delete_cmd)
264
+ logger.success(f"Service principal '{sp_name}' deleted successfully")
265
+
266
+ except subprocess.CalledProcessError as e:
267
+ logger.error(f"Failed to delete service principal: {str(e)}")
268
+ raise
File without changes
File without changes
@@ -0,0 +1,96 @@
1
+ import json
2
+ import os
3
+ import subprocess
4
+ from typing import Any
5
+
6
+ from .logging import get_logger
7
+
8
+ logger = get_logger(__name__)
9
+
10
+ # Singleton credential instance
11
+ _credential = None
12
+
13
+
14
+ def run_command(command: list[str]) -> Any:
15
+ """
16
+ Run an Azure CLI command and return the JSON output.
17
+
18
+ Args:
19
+ command: List of command arguments starting with 'az'
20
+
21
+ Returns:
22
+ Parsed JSON output from the command
23
+
24
+ Raises:
25
+ subprocess.CalledProcessError: If command fails
26
+ """
27
+ try:
28
+ # Set environment to suppress Azure authentication logs
29
+ env = os.environ.copy()
30
+ env["AZURE_CORE_ONLY_SHOW_ERRORS"] = "True"
31
+
32
+ result = subprocess.run(
33
+ command,
34
+ capture_output=True,
35
+ text=True,
36
+ check=True,
37
+ env=env,
38
+ )
39
+ if result.stdout.strip() == "":
40
+ return {}
41
+ return json.loads(result.stdout)
42
+ except subprocess.CalledProcessError as e:
43
+ logger.error(f"Command failed: {' '.join(command)}")
44
+ logger.error(f"Error: {e.stderr}")
45
+ raise
46
+ except json.JSONDecodeError as e:
47
+ logger.error(f"Failed to parse JSON output: {e}")
48
+ return {}
49
+
50
+
51
+ def get_subscription_and_tenant() -> tuple[str, str]:
52
+ """
53
+ Get subscription ID and tenant ID from Azure CLI.
54
+
55
+ Returns:
56
+ Tuple of (subscription_id, tenant_id)
57
+
58
+ Raises:
59
+ ValueError: If subscription or tenant ID cannot be retrieved
60
+ """
61
+ account_info = run_command(["az", "account", "show", "--output", "json"])
62
+ subscription_id: str = account_info.get("id", "")
63
+ tenant_id: str = account_info.get("tenantId", "")
64
+
65
+ if not subscription_id or not tenant_id:
66
+ raise ValueError("Failed to retrieve subscription or tenant information")
67
+
68
+ return subscription_id, tenant_id
69
+
70
+
71
+ def get_credential(cache: bool = True):
72
+ """
73
+ Get or create Azure CLI credential.
74
+
75
+ Args:
76
+ cache: If True, returns cached credential singleton. If False,
77
+ creates new credential each time.
78
+
79
+ Returns:
80
+ AzureCliCredential instance
81
+
82
+ Note:
83
+ Using cached credentials (cache=True) is recommended for most use cases to avoid
84
+ repeated authentication overhead. Set cache=False only if you need isolated credentials
85
+ for testing or specific scenarios.
86
+ """
87
+ from azure.identity import AzureCliCredential
88
+
89
+ global _credential
90
+
91
+ if cache:
92
+ if _credential is None:
93
+ _credential = AzureCliCredential()
94
+ return _credential
95
+ else:
96
+ return AzureCliCredential()
@@ -0,0 +1,137 @@
1
+ """Docker utility functions for image operations."""
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+
7
+ def image_exists(full_image_name: str) -> bool:
8
+ """
9
+ Check if a Docker image exists locally.
10
+
11
+ Args:
12
+ full_image_name: Full image name including registry, repository, and tag
13
+
14
+ Returns:
15
+ True if the image exists locally, False otherwise
16
+ """
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
23
+
24
+
25
+ def push_image(full_image_name: str) -> None:
26
+ """
27
+ Push a Docker image to the registry.
28
+
29
+ Args:
30
+ full_image_name: Full image name including registry, repository, and tag
31
+
32
+ Raises:
33
+ RuntimeError: If the docker push command fails
34
+ """
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}")
42
+
43
+
44
+ def pull_image(full_image_name: str) -> None:
45
+ """
46
+ Pull a Docker image from the registry.
47
+
48
+ Args:
49
+ full_image_name: Full image name including registry, repository, and tag
50
+
51
+ Raises:
52
+ RuntimeError: If the docker pull command fails
53
+ """
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}")
61
+
62
+
63
+ def tag_image(source_image: str, target_image: str) -> None:
64
+ """
65
+ Tag a Docker image locally.
66
+
67
+ Args:
68
+ source_image: Full name of the source image
69
+ target_image: Full name of the target image
70
+
71
+ Raises:
72
+ RuntimeError: If the docker tag command fails
73
+ """
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}")
81
+
82
+
83
+ def pull_retag_and_push_image(
84
+ source_full_image_name: str,
85
+ target_full_image_name: str,
86
+ ) -> None:
87
+ """
88
+ Pull an existing image, retag it, and push to registry.
89
+
90
+ Args:
91
+ source_full_image_name: Full name of the source image (registry/image:tag)
92
+ target_full_image_name: Full name of the target image (registry/image:new_tag)
93
+
94
+ Raises:
95
+ RuntimeError: If the source image doesn't exist or operations fail
96
+ """
97
+ if not image_exists(source_full_image_name):
98
+ pull_image(source_full_image_name)
99
+
100
+ tag_image(source_full_image_name, target_full_image_name)
101
+ push_image(target_full_image_name)
102
+
103
+
104
+ def build_and_push_image(
105
+ dockerfile: str,
106
+ full_image_name: str,
107
+ ) -> None:
108
+ """
109
+ Build a Docker image using buildx and push to registry.
110
+
111
+ Args:
112
+ dockerfile: Path to the Dockerfile
113
+ full_image_name: Full image name including registry, repository, and tag
114
+
115
+ Raises:
116
+ RuntimeError: If the docker build and push command fails
117
+ """
118
+ src_folder = str(Path(dockerfile).parent)
119
+ build_result = subprocess.run(
120
+ [
121
+ "docker",
122
+ "buildx",
123
+ "build",
124
+ "--platform",
125
+ "linux/amd64",
126
+ "-t",
127
+ full_image_name,
128
+ "-f",
129
+ dockerfile,
130
+ src_folder,
131
+ "--push",
132
+ ],
133
+ capture_output=True,
134
+ text=True,
135
+ )
136
+ if build_result.returncode != 0:
137
+ raise RuntimeError(f"Docker build and push failed: {build_result.stderr}")
@@ -0,0 +1,108 @@
1
+ """Environment variable and credential file handling utilities."""
2
+
3
+ from pathlib import Path
4
+ from string import Template
5
+
6
+ from dotenv import dotenv_values
7
+
8
+ from .logging import get_logger
9
+
10
+ logger = get_logger(__name__)
11
+
12
+
13
+ def substitute_env_vars(value: str, env_vars: dict[str, str]) -> str:
14
+ """
15
+ Substitute environment variables in a string using Template format.
16
+
17
+ All ${VAR_NAME} placeholders in the value must have corresponding entries in
18
+ env_vars, otherwise a KeyError is raised.
19
+
20
+ Args:
21
+ value: String with ${VAR_NAME} placeholders
22
+ env_vars: Dictionary of variable values
23
+
24
+ Returns:
25
+ Fully substituted string with all variables replaced
26
+
27
+ Raises:
28
+ KeyError: If any ${VAR_NAME} placeholders in value are not found in
29
+ env_vars
30
+
31
+ Example:
32
+ >>> env_vars = {
33
+ ... "SUBSCRIPTION_ID": "12345678-1234-1234-1234-123456789012",
34
+ ... "RESOURCE_GROUP": "my-rg",
35
+ ... "OPENAI_RESOURCE_NAME": "my-openai",
36
+ ... }
37
+ >>> scope = "/subscriptions/${SUBSCRIPTION_ID}/resourceGroups/\
38
+ ${RESOURCE_GROUP}/providers/Microsoft.CognitiveServices/accounts/\
39
+ ${OPENAI_RESOURCE_NAME}"
40
+ >>> substitute_env_vars(scope, env_vars)
41
+ '/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/\
42
+ my-rg/providers/Microsoft.CognitiveServices/accounts/my-openai'
43
+ """
44
+ template = Template(value)
45
+ result = template.substitute(env_vars)
46
+ return result
47
+
48
+
49
+ def load_env_vars_from_files(
50
+ env_file_paths: list[Path] | None,
51
+ ) -> dict[str, str]:
52
+ """
53
+ Load environment variables from multiple .env files using python-dotenv.
54
+
55
+ Files are loaded in order and merged, with later files overriding earlier
56
+ ones.
57
+
58
+ Args:
59
+ env_file_paths: List of paths to .env files with KEY=VALUE format
60
+
61
+ Returns:
62
+ Merged dictionary of environment variables
63
+
64
+ Raises:
65
+ FileNotFoundError: If any file doesn't exist
66
+ Exception: If file parsing fails
67
+ """
68
+ if not env_file_paths:
69
+ return {}
70
+
71
+ merged_vars: dict[str, str | None] = {}
72
+
73
+ for env_file_path in env_file_paths:
74
+ env_vars = dotenv_values(env_file_path)
75
+ merged_vars.update(env_vars)
76
+ logger.info(f"Loaded {len(env_vars)} variables from {env_file_path}")
77
+
78
+ logger.info(f"Total merged: {len(merged_vars)} unique environment variables")
79
+ merged_vars_cleaned: dict[str, str] = {k: v for k, v in merged_vars.items() if v is not None}
80
+ return merged_vars_cleaned
81
+
82
+
83
+ def add_var_to_env_file(
84
+ env_var_map: dict[str, str],
85
+ env_file_path: Path,
86
+ ) -> None:
87
+ """
88
+ Add or update in an environment file.
89
+
90
+ Args:
91
+ env_var_map: Dictionary of environment variable key-value pairs
92
+ env_file_path: Path to the environment file
93
+ """
94
+ env_file_path.parent.mkdir(parents=True, exist_ok=True)
95
+ existing_content = ""
96
+ if env_file_path.exists():
97
+ with open(env_file_path) as f:
98
+ existing_content = f.read()
99
+ lines = [
100
+ line
101
+ for line in existing_content.split("\n")
102
+ if not any(line.startswith(f"{env_var_key}=") for env_var_key in env_var_map.keys())
103
+ ]
104
+ with open(env_file_path, "w") as f:
105
+ f.write("\n".join(lines).rstrip())
106
+ for env_var_key, env_var_value in env_var_map.items():
107
+ env_var_line = f"{env_var_key}={env_var_value}\n"
108
+ f.write("\n" + env_var_line)
@@ -0,0 +1,11 @@
1
+ from azure.mgmt.keyvault import KeyVaultManagementClient
2
+
3
+ from ..utils.azure_cli import get_credential
4
+
5
+
6
+ def get_key_vault_client(
7
+ subscription_id: str, resource_group: str, key_vault_name: str
8
+ ) -> KeyVaultManagementClient:
9
+ credential = get_credential()
10
+ kv_client = KeyVaultManagementClient(credential, subscription_id)
11
+ return kv_client