fdai-deployment-cli 0.1.2__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.
- fdai_deployment_cli/__about__.py +3 -0
- fdai_deployment_cli/__init__.py +5 -0
- fdai_deployment_cli/__main__.py +5 -0
- fdai_deployment_cli/acr_publish.py +585 -0
- fdai_deployment_cli/aks_catalog_review.py +137 -0
- fdai_deployment_cli/aks_historical_reconciliation.py +732 -0
- fdai_deployment_cli/aks_job_environment.py +59 -0
- fdai_deployment_cli/aks_job_execution.py +794 -0
- fdai_deployment_cli/aks_kubernetes_defaults.py +66 -0
- fdai_deployment_cli/aks_preflight.py +422 -0
- fdai_deployment_cli/aks_readiness.py +220 -0
- fdai_deployment_cli/aks_service_update.py +180 -0
- fdai_deployment_cli/aks_workload_jobs.py +281 -0
- fdai_deployment_cli/application_state_adoption.py +347 -0
- fdai_deployment_cli/azure_actor.py +61 -0
- fdai_deployment_cli/azure_naming.py +252 -0
- fdai_deployment_cli/azure_retail_prices.py +173 -0
- fdai_deployment_cli/bootstrap_approval.py +99 -0
- fdai_deployment_cli/bootstrap_channel.py +321 -0
- fdai_deployment_cli/bootstrap_executor.py +311 -0
- fdai_deployment_cli/bootstrap_plan.py +237 -0
- fdai_deployment_cli/bootstrap_reconcile.py +402 -0
- fdai_deployment_cli/bootstrap_support.py +138 -0
- fdai_deployment_cli/bootstrap_worker.py +448 -0
- fdai_deployment_cli/bundle.py +352 -0
- fdai_deployment_cli/capacity.py +143 -0
- fdai_deployment_cli/catalog_review_profile.py +306 -0
- fdai_deployment_cli/cli.py +798 -0
- fdai_deployment_cli/cli_help.py +214 -0
- fdai_deployment_cli/cli_parser.py +791 -0
- fdai_deployment_cli/cli_plan.py +450 -0
- fdai_deployment_cli/compiler.py +99 -0
- fdai_deployment_cli/console_artifact.py +218 -0
- fdai_deployment_cli/console_config.py +214 -0
- fdai_deployment_cli/console_update.py +832 -0
- fdai_deployment_cli/contracts.py +307 -0
- fdai_deployment_cli/control_package.py +140 -0
- fdai_deployment_cli/deadline_transport.py +70 -0
- fdai_deployment_cli/deployment_cost.py +131 -0
- fdai_deployment_cli/deployment_deadline.py +28 -0
- fdai_deployment_cli/deployment_kit.py +790 -0
- fdai_deployment_cli/deployment_kit_cache.py +191 -0
- fdai_deployment_cli/deployment_progress.py +648 -0
- fdai_deployment_cli/development_authority_cli.py +56 -0
- fdai_deployment_cli/development_authority_profile.py +319 -0
- fdai_deployment_cli/doctor.py +136 -0
- fdai_deployment_cli/entitlement_preflight.py +68 -0
- fdai_deployment_cli/entitlement_transport.py +49 -0
- fdai_deployment_cli/entra_bootstrap.py +579 -0
- fdai_deployment_cli/entra_control_profile.py +214 -0
- fdai_deployment_cli/entra_graph.py +349 -0
- fdai_deployment_cli/entra_profiles.py +215 -0
- fdai_deployment_cli/entra_source.py +244 -0
- fdai_deployment_cli/execution_bundle.py +478 -0
- fdai_deployment_cli/execution_copies.py +63 -0
- fdai_deployment_cli/foundation_adoption.py +438 -0
- fdai_deployment_cli/foundation_adoption_evidence.py +245 -0
- fdai_deployment_cli/foundation_adoption_host.py +157 -0
- fdai_deployment_cli/foundation_adoption_transport.py +51 -0
- fdai_deployment_cli/foundation_failure.py +72 -0
- fdai_deployment_cli/foundation_image.py +168 -0
- fdai_deployment_cli/foundation_input.py +235 -0
- fdai_deployment_cli/foundation_output.py +154 -0
- fdai_deployment_cli/foundation_plan.py +381 -0
- fdai_deployment_cli/foundation_process.py +64 -0
- fdai_deployment_cli/foundation_progress.py +231 -0
- fdai_deployment_cli/github_actions.py +171 -0
- fdai_deployment_cli/github_apply_artifact.py +259 -0
- fdai_deployment_cli/github_artifact_io.py +48 -0
- fdai_deployment_cli/github_dispatch.py +248 -0
- fdai_deployment_cli/github_plan_artifact.py +181 -0
- fdai_deployment_cli/github_provider_artifact.py +170 -0
- fdai_deployment_cli/github_transport.py +46 -0
- fdai_deployment_cli/github_workflow_values.py +312 -0
- fdai_deployment_cli/hub_image_deploy.py +354 -0
- fdai_deployment_cli/hub_image_stage.py +162 -0
- fdai_deployment_cli/hub_image_trust.py +281 -0
- fdai_deployment_cli/hub_ownership.py +161 -0
- fdai_deployment_cli/hub_workload_handoff.py +264 -0
- fdai_deployment_cli/hub_workload_handoff_inputs.py +227 -0
- fdai_deployment_cli/identity_profile.py +227 -0
- fdai_deployment_cli/image_targets.py +197 -0
- fdai_deployment_cli/installation_scope.py +280 -0
- fdai_deployment_cli/license.py +311 -0
- fdai_deployment_cli/license_issue.py +239 -0
- fdai_deployment_cli/lifecycle_authorization.py +252 -0
- fdai_deployment_cli/lifecycle_commands.py +173 -0
- fdai_deployment_cli/lifecycle_configuration.py +722 -0
- fdai_deployment_cli/lifecycle_configuration_artifact.py +45 -0
- fdai_deployment_cli/lifecycle_configuration_cli.py +324 -0
- fdai_deployment_cli/lifecycle_configuration_document.py +74 -0
- fdai_deployment_cli/lifecycle_configuration_entities.py +146 -0
- fdai_deployment_cli/lifecycle_configuration_package.py +492 -0
- fdai_deployment_cli/lifecycle_configuration_publication.py +277 -0
- fdai_deployment_cli/lifecycle_configuration_publication_cli.py +191 -0
- fdai_deployment_cli/lifecycle_configuration_sealing.py +127 -0
- fdai_deployment_cli/lifecycle_enrollment.py +45 -0
- fdai_deployment_cli/lifecycle_infrastructure_agent.py +174 -0
- fdai_deployment_cli/lifecycle_installation_binding.py +65 -0
- fdai_deployment_cli/lifecycle_native.py +247 -0
- fdai_deployment_cli/lifecycle_native_reconciliation.py +568 -0
- fdai_deployment_cli/lifecycle_native_transition.py +339 -0
- fdai_deployment_cli/lifecycle_ownership_exclusion.py +78 -0
- fdai_deployment_cli/lifecycle_plan.py +800 -0
- fdai_deployment_cli/lifecycle_promotion.py +185 -0
- fdai_deployment_cli/lifecycle_release.py +308 -0
- fdai_deployment_cli/lifecycle_self_upgrade.py +202 -0
- fdai_deployment_cli/lifecycle_upgrade_bundle.py +176 -0
- fdai_deployment_cli/notification_recipients_input.py +222 -0
- fdai_deployment_cli/observer_artifact.py +106 -0
- fdai_deployment_cli/oci_archive.py +353 -0
- fdai_deployment_cli/offline_kit.py +797 -0
- fdai_deployment_cli/offline_prepare.py +135 -0
- fdai_deployment_cli/operational_evidence_verifier_input.py +575 -0
- fdai_deployment_cli/plan_input.py +101 -0
- fdai_deployment_cli/private_output.py +192 -0
- fdai_deployment_cli/profile.py +70 -0
- fdai_deployment_cli/progress.py +183 -0
- fdai_deployment_cli/readiness.py +286 -0
- fdai_deployment_cli/run_command_receiver.py +418 -0
- fdai_deployment_cli/runtime_build.py +386 -0
- fdai_deployment_cli/runtime_profile.py +287 -0
- fdai_deployment_cli/runtime_release.py +745 -0
- fdai_deployment_cli/runtime_stage.py +82 -0
- fdai_deployment_cli/runtime_support_installation.py +215 -0
- fdai_deployment_cli/simulation.py +80 -0
- fdai_deployment_cli/source_application_inputs.py +397 -0
- fdai_deployment_cli/source_azure.py +586 -0
- fdai_deployment_cli/source_change_cli.py +107 -0
- fdai_deployment_cli/source_change_parser.py +75 -0
- fdai_deployment_cli/source_change_plan.py +181 -0
- fdai_deployment_cli/source_changed_update.py +196 -0
- fdai_deployment_cli/source_deploy.py +170 -0
- fdai_deployment_cli/source_foundation.py +174 -0
- fdai_deployment_cli/source_host_tools.py +163 -0
- fdai_deployment_cli/source_image_stage.py +400 -0
- fdai_deployment_cli/source_input.py +173 -0
- fdai_deployment_cli/source_projection.py +164 -0
- fdai_deployment_cli/source_receiver.py +76 -0
- fdai_deployment_cli/source_recovery.py +264 -0
- fdai_deployment_cli/source_runtime.py +81 -0
- fdai_deployment_cli/source_runtime_support.py +368 -0
- fdai_deployment_cli/source_service_update.py +726 -0
- fdai_deployment_cli/source_service_update_cli.py +47 -0
- fdai_deployment_cli/source_snapshot.py +178 -0
- fdai_deployment_cli/source_teardown.py +574 -0
- fdai_deployment_cli/source_terraform.py +85 -0
- fdai_deployment_cli/source_transport.py +399 -0
- fdai_deployment_cli/standalone_aks_inventory.py +558 -0
- fdai_deployment_cli/standalone_aks_job_execution.py +769 -0
- fdai_deployment_cli/standalone_aks_nodepool_guard.py +141 -0
- fdai_deployment_cli/standalone_application.py +977 -0
- fdai_deployment_cli/standalone_application_completion.py +151 -0
- fdai_deployment_cli/standalone_application_input_recovery.py +107 -0
- fdai_deployment_cli/standalone_application_recovery.py +116 -0
- fdai_deployment_cli/standalone_approval_input.py +24 -0
- fdai_deployment_cli/standalone_catalog_checkpoint.py +122 -0
- fdai_deployment_cli/standalone_catalog_review.py +251 -0
- fdai_deployment_cli/standalone_checkpoint_failure.py +127 -0
- fdai_deployment_cli/standalone_console_publish.py +174 -0
- fdai_deployment_cli/standalone_database_principals.py +41 -0
- fdai_deployment_cli/standalone_deploy.py +638 -0
- fdai_deployment_cli/standalone_development_authority.py +166 -0
- fdai_deployment_cli/standalone_foundation_adoption.py +87 -0
- fdai_deployment_cli/standalone_foundation_transition.py +767 -0
- fdai_deployment_cli/standalone_host.py +4608 -0
- fdai_deployment_cli/standalone_host_rebind.py +226 -0
- fdai_deployment_cli/standalone_host_residual.py +104 -0
- fdai_deployment_cli/standalone_host_state.py +145 -0
- fdai_deployment_cli/standalone_host_values.py +563 -0
- fdai_deployment_cli/standalone_kubernetes_tools.py +100 -0
- fdai_deployment_cli/standalone_license_installation.py +177 -0
- fdai_deployment_cli/standalone_management_egress.py +83 -0
- fdai_deployment_cli/standalone_migration_evidence.py +38 -0
- fdai_deployment_cli/standalone_operational_evidence.py +356 -0
- fdai_deployment_cli/standalone_planned_outputs.py +147 -0
- fdai_deployment_cli/standalone_private_access_probe.py +100 -0
- fdai_deployment_cli/standalone_product_profile.py +110 -0
- fdai_deployment_cli/standalone_remote_prepare.py +442 -0
- fdai_deployment_cli/standalone_residual_apply.py +540 -0
- fdai_deployment_cli/standalone_residual_successor.py +431 -0
- fdai_deployment_cli/standalone_review.py +266 -0
- fdai_deployment_cli/standalone_service_update_finalization.py +116 -0
- fdai_deployment_cli/standalone_service_update_recovery.py +277 -0
- fdai_deployment_cli/standalone_stage_targets.py +325 -0
- fdai_deployment_cli/standalone_status.py +67 -0
- fdai_deployment_cli/standalone_terraform_environment.py +237 -0
- fdai_deployment_cli/standalone_transfer_cleanup.py +237 -0
- fdai_deployment_cli/standalone_trial_activation.py +193 -0
- fdai_deployment_cli/state.py +434 -0
- fdai_deployment_cli/state_handoff.py +253 -0
- fdai_deployment_cli/status_projection.py +97 -0
- fdai_deployment_cli/support_install.py +257 -0
- fdai_deployment_cli/target.py +46 -0
- fdai_deployment_cli/trust/capabilities.json +62 -0
- fdai_deployment_cli/trust/deployment-bundle-root.pub +3 -0
- fdai_deployment_cli/trust/deployment-release-root.pub +3 -0
- fdai_deployment_cli/trust/upstream-signing-key.pub +3 -0
- fdai_deployment_cli/trust_roots.py +50 -0
- fdai_deployment_cli-0.1.2.dist-info/METADATA +112 -0
- fdai_deployment_cli-0.1.2.dist-info/RECORD +203 -0
- fdai_deployment_cli-0.1.2.dist-info/WHEEL +4 -0
- fdai_deployment_cli-0.1.2.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""Stage a verified public-development Terraform state for private-host adoption."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from fdai_deployment_cli.private_output import (
|
|
14
|
+
read_private_bytes,
|
|
15
|
+
write_private_bytes,
|
|
16
|
+
write_private_output,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
_DIGEST = re.compile(r"[0-9a-f]{64}")
|
|
20
|
+
_GUID = re.compile(r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}")
|
|
21
|
+
_SOURCE_COMMIT = re.compile(r"[0-9a-f]{40}")
|
|
22
|
+
_SUFFIX = re.compile(r"[a-z0-9]{6}")
|
|
23
|
+
_MAX_STATE_BYTES = 64 * 1024 * 1024
|
|
24
|
+
_MAX_MODEL_BYTES = 8 * 1024 * 1024
|
|
25
|
+
_RESOURCE_GROUP_ADDRESS = "module.resource_group.azurerm_resource_group.primary[0]"
|
|
26
|
+
_OWNERSHIP_ADDRESS = "module.resource_group.terraform_data.ownership"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class ApplicationStateAdoption:
|
|
31
|
+
"""Private staged state and value-free metadata for one verified adoption."""
|
|
32
|
+
|
|
33
|
+
state: Path
|
|
34
|
+
resolved_models: Path
|
|
35
|
+
descriptor: Path
|
|
36
|
+
resource_name_suffix: str
|
|
37
|
+
managed_resource_count: int
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def stage_application_state_adoption(
|
|
41
|
+
*,
|
|
42
|
+
source_state: Path,
|
|
43
|
+
recovery_receipt: Path,
|
|
44
|
+
resolved_models: Path,
|
|
45
|
+
output_directory: Path,
|
|
46
|
+
subscription_id: str,
|
|
47
|
+
resource_group_name: str,
|
|
48
|
+
environment: str,
|
|
49
|
+
region_short: str,
|
|
50
|
+
) -> ApplicationStateAdoption:
|
|
51
|
+
"""Validate and split a recovered public state without changing its source bytes."""
|
|
52
|
+
|
|
53
|
+
if _GUID.fullmatch(subscription_id) is None:
|
|
54
|
+
raise ValueError("application state adoption subscription id is invalid")
|
|
55
|
+
if environment != "dev" or re.fullmatch(r"[a-z][a-z0-9]{1,7}", region_short) is None:
|
|
56
|
+
raise ValueError("application state adoption supports one valid public dev target")
|
|
57
|
+
if resource_group_name != f"rg-fdai-dev-{region_short}":
|
|
58
|
+
raise ValueError("application state adoption resource group is invalid")
|
|
59
|
+
state_bytes = read_private_bytes(source_state, max_bytes=_MAX_STATE_BYTES)
|
|
60
|
+
recovery_bytes = read_private_bytes(recovery_receipt, max_bytes=65_536)
|
|
61
|
+
recovery = _object(json.loads(recovery_bytes))
|
|
62
|
+
state_digest = hashlib.sha256(state_bytes).hexdigest()
|
|
63
|
+
_validate_recovery(recovery, state_digest=state_digest)
|
|
64
|
+
state = _object(json.loads(state_bytes))
|
|
65
|
+
staged, suffix, count = _split_state(
|
|
66
|
+
state,
|
|
67
|
+
subscription_id=subscription_id,
|
|
68
|
+
resource_group_name=resource_group_name,
|
|
69
|
+
environment=environment,
|
|
70
|
+
region_short=region_short,
|
|
71
|
+
expected_tracked_count=recovery["tracked_resource_count"],
|
|
72
|
+
)
|
|
73
|
+
model_bytes = read_private_bytes(resolved_models, max_bytes=_MAX_MODEL_BYTES)
|
|
74
|
+
model_digest = hashlib.sha256(model_bytes).hexdigest()
|
|
75
|
+
resolved = _object(json.loads(model_bytes))
|
|
76
|
+
capabilities = _resolved_capabilities(resolved)
|
|
77
|
+
if output_directory.exists():
|
|
78
|
+
return _load_staged_adoption(
|
|
79
|
+
output_directory,
|
|
80
|
+
source_state_sha256=state_digest,
|
|
81
|
+
recovery_receipt_sha256=hashlib.sha256(recovery_bytes).hexdigest(),
|
|
82
|
+
resolved_models_sha256=model_digest,
|
|
83
|
+
expected_suffix=suffix,
|
|
84
|
+
expected_count=count,
|
|
85
|
+
)
|
|
86
|
+
output_directory.mkdir(mode=0o700, parents=True)
|
|
87
|
+
staged_state = output_directory / "application-state.json"
|
|
88
|
+
staged_models = output_directory / "resolved-models.json"
|
|
89
|
+
descriptor_path = output_directory / "adoption.json"
|
|
90
|
+
staged_bytes = (json.dumps(staged, sort_keys=True, separators=(",", ":")) + "\n").encode()
|
|
91
|
+
descriptor = {
|
|
92
|
+
"schema_version": "fdai.application-state-adoption.v1",
|
|
93
|
+
"state": "staged",
|
|
94
|
+
"source_state_sha256": state_digest,
|
|
95
|
+
"staged_state_sha256": hashlib.sha256(staged_bytes).hexdigest(),
|
|
96
|
+
"resolved_models_sha256": model_digest,
|
|
97
|
+
"recovery_receipt_sha256": hashlib.sha256(recovery_bytes).hexdigest(),
|
|
98
|
+
"source_commit": recovery["source_commit"],
|
|
99
|
+
"verified_source_commit": recovery["verified_source_commit"],
|
|
100
|
+
"resource_name_suffix": suffix,
|
|
101
|
+
"managed_resource_count": count,
|
|
102
|
+
"removed_addresses": [_OWNERSHIP_ADDRESS, _RESOURCE_GROUP_ADDRESS],
|
|
103
|
+
"resolved_capabilities": capabilities,
|
|
104
|
+
"original_state_retained": True,
|
|
105
|
+
"remote_backend_authority_verified": False,
|
|
106
|
+
"mutation_performed": False,
|
|
107
|
+
"subscription_ready": False,
|
|
108
|
+
}
|
|
109
|
+
try:
|
|
110
|
+
write_private_bytes(staged_state, staged_bytes)
|
|
111
|
+
write_private_bytes(staged_models, model_bytes)
|
|
112
|
+
write_private_output(
|
|
113
|
+
descriptor_path,
|
|
114
|
+
json.dumps(descriptor, sort_keys=True, separators=(",", ":")) + "\n",
|
|
115
|
+
)
|
|
116
|
+
except BaseException:
|
|
117
|
+
shutil.rmtree(output_directory)
|
|
118
|
+
raise
|
|
119
|
+
return ApplicationStateAdoption(
|
|
120
|
+
state=staged_state,
|
|
121
|
+
resolved_models=staged_models,
|
|
122
|
+
descriptor=descriptor_path,
|
|
123
|
+
resource_name_suffix=suffix,
|
|
124
|
+
managed_resource_count=count,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _load_staged_adoption(
|
|
129
|
+
output_directory: Path,
|
|
130
|
+
*,
|
|
131
|
+
source_state_sha256: str,
|
|
132
|
+
recovery_receipt_sha256: str,
|
|
133
|
+
resolved_models_sha256: str,
|
|
134
|
+
expected_suffix: str,
|
|
135
|
+
expected_count: int,
|
|
136
|
+
) -> ApplicationStateAdoption:
|
|
137
|
+
state = output_directory / "application-state.json"
|
|
138
|
+
models = output_directory / "resolved-models.json"
|
|
139
|
+
descriptor_path = output_directory / "adoption.json"
|
|
140
|
+
descriptor = _object(json.loads(read_private_bytes(descriptor_path, max_bytes=1_048_576)))
|
|
141
|
+
staged_state_digest = hashlib.sha256(
|
|
142
|
+
read_private_bytes(state, max_bytes=_MAX_STATE_BYTES)
|
|
143
|
+
).hexdigest()
|
|
144
|
+
staged_model_digest = hashlib.sha256(
|
|
145
|
+
read_private_bytes(models, max_bytes=_MAX_MODEL_BYTES)
|
|
146
|
+
).hexdigest()
|
|
147
|
+
if (
|
|
148
|
+
descriptor.get("schema_version") != "fdai.application-state-adoption.v1"
|
|
149
|
+
or descriptor.get("state") != "staged"
|
|
150
|
+
or descriptor.get("source_state_sha256") != source_state_sha256
|
|
151
|
+
or descriptor.get("recovery_receipt_sha256") != recovery_receipt_sha256
|
|
152
|
+
or descriptor.get("resolved_models_sha256") != resolved_models_sha256
|
|
153
|
+
or descriptor.get("staged_state_sha256") != staged_state_digest
|
|
154
|
+
or staged_model_digest != resolved_models_sha256
|
|
155
|
+
or descriptor.get("resource_name_suffix") != expected_suffix
|
|
156
|
+
or descriptor.get("managed_resource_count") != expected_count
|
|
157
|
+
or descriptor.get("original_state_retained") is not True
|
|
158
|
+
or descriptor.get("remote_backend_authority_verified") is not False
|
|
159
|
+
):
|
|
160
|
+
raise ValueError("retained application state adoption differs")
|
|
161
|
+
return ApplicationStateAdoption(
|
|
162
|
+
state=state,
|
|
163
|
+
resolved_models=models,
|
|
164
|
+
descriptor=descriptor_path,
|
|
165
|
+
resource_name_suffix=expected_suffix,
|
|
166
|
+
managed_resource_count=expected_count,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _validate_recovery(recovery: dict[str, Any], *, state_digest: str) -> None:
|
|
171
|
+
if (
|
|
172
|
+
recovery.get("schema_version") != "fdai.contributor-recovery.v1"
|
|
173
|
+
or recovery.get("state") != "failed-apply-observed"
|
|
174
|
+
or recovery.get("operational_verification") != "observed"
|
|
175
|
+
or recovery.get("state_sha256") != state_digest
|
|
176
|
+
or _DIGEST.fullmatch(str(recovery.get("plan_sha256", ""))) is None
|
|
177
|
+
or _SOURCE_COMMIT.fullmatch(str(recovery.get("source_commit", ""))) is None
|
|
178
|
+
or _SOURCE_COMMIT.fullmatch(str(recovery.get("verified_source_commit", ""))) is None
|
|
179
|
+
or type(recovery.get("tracked_resource_count")) is not int
|
|
180
|
+
or recovery["tracked_resource_count"] <= 2
|
|
181
|
+
):
|
|
182
|
+
raise ValueError("application state adoption recovery receipt is invalid")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _split_state(
|
|
186
|
+
state: dict[str, Any],
|
|
187
|
+
*,
|
|
188
|
+
subscription_id: str,
|
|
189
|
+
resource_group_name: str,
|
|
190
|
+
environment: str,
|
|
191
|
+
region_short: str,
|
|
192
|
+
expected_tracked_count: int,
|
|
193
|
+
) -> tuple[dict[str, Any], str, int]:
|
|
194
|
+
if (
|
|
195
|
+
state.get("version") != 4
|
|
196
|
+
or type(state.get("serial")) is not int
|
|
197
|
+
or state["serial"] < 0
|
|
198
|
+
or not isinstance(state.get("lineage"), str)
|
|
199
|
+
or not state["lineage"]
|
|
200
|
+
or not isinstance(state.get("resources"), list)
|
|
201
|
+
):
|
|
202
|
+
raise ValueError("application state adoption requires valid version-4 state")
|
|
203
|
+
expected_group_id = f"/subscriptions/{subscription_id}/resourceGroups/{resource_group_name}"
|
|
204
|
+
kept: list[object] = []
|
|
205
|
+
removed: set[str] = set()
|
|
206
|
+
registry_name = ""
|
|
207
|
+
managed_count = 0
|
|
208
|
+
tracked_count = 0
|
|
209
|
+
for value in state["resources"]:
|
|
210
|
+
resource = _object(value)
|
|
211
|
+
address = _resource_address(resource)
|
|
212
|
+
instances = resource.get("instances")
|
|
213
|
+
if not isinstance(instances, list):
|
|
214
|
+
raise ValueError( # noqa: TRY004 - normalize untrusted state into a stable CLI error
|
|
215
|
+
"application state adoption resource instances are invalid"
|
|
216
|
+
)
|
|
217
|
+
tracked_count += len(instances)
|
|
218
|
+
if not instances:
|
|
219
|
+
kept.append(value)
|
|
220
|
+
continue
|
|
221
|
+
if address == _RESOURCE_GROUP_ADDRESS:
|
|
222
|
+
attributes = _single_attributes(resource, index=0)
|
|
223
|
+
if str(attributes.get("id", "")).casefold() != expected_group_id.casefold():
|
|
224
|
+
raise ValueError("application state adoption resource group identity differs")
|
|
225
|
+
removed.add(address)
|
|
226
|
+
continue
|
|
227
|
+
if address == _OWNERSHIP_ADDRESS:
|
|
228
|
+
attributes = _single_attributes(resource)
|
|
229
|
+
if attributes.get("input") not in (
|
|
230
|
+
"managed",
|
|
231
|
+
{"value": "managed", "type": "string"},
|
|
232
|
+
):
|
|
233
|
+
raise ValueError("application state adoption ownership marker is invalid")
|
|
234
|
+
removed.add(address)
|
|
235
|
+
continue
|
|
236
|
+
if resource.get("mode") == "managed":
|
|
237
|
+
managed_count += len(instances)
|
|
238
|
+
if address == "module.container_registry.azurerm_container_registry.primary":
|
|
239
|
+
registry_name = str(_single_attributes(resource).get("name", ""))
|
|
240
|
+
kept.append(value)
|
|
241
|
+
if removed != {_OWNERSHIP_ADDRESS, _RESOURCE_GROUP_ADDRESS}:
|
|
242
|
+
raise ValueError("application state adoption resource-group owners are incomplete")
|
|
243
|
+
if tracked_count != expected_tracked_count:
|
|
244
|
+
raise ValueError("application state adoption tracked resource count differs")
|
|
245
|
+
if managed_count <= 0:
|
|
246
|
+
raise ValueError("application state adoption has no managed application resource")
|
|
247
|
+
prefix = f"crfdai{environment}{region_short}"
|
|
248
|
+
if not registry_name.startswith(prefix):
|
|
249
|
+
raise ValueError("application state adoption registry name does not match the target")
|
|
250
|
+
suffix = registry_name.removeprefix(prefix)
|
|
251
|
+
if _SUFFIX.fullmatch(suffix) is None:
|
|
252
|
+
raise ValueError("application state adoption resource suffix is invalid")
|
|
253
|
+
staged = dict(state)
|
|
254
|
+
staged["resources"] = kept
|
|
255
|
+
staged["serial"] = state["serial"] + 1
|
|
256
|
+
return staged, suffix, managed_count
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _resolved_capabilities(payload: dict[str, Any]) -> list[dict[str, object]]:
|
|
260
|
+
values = payload.get("capabilities")
|
|
261
|
+
if not isinstance(values, list) or not values:
|
|
262
|
+
raise ValueError("application state adoption model capabilities are invalid")
|
|
263
|
+
result: list[dict[str, object]] = []
|
|
264
|
+
for value in values:
|
|
265
|
+
item = _object(value)
|
|
266
|
+
if item.get("status") == "hil-only":
|
|
267
|
+
continue
|
|
268
|
+
if item.get("status") != "resolved" or item.get("publisher") not in {
|
|
269
|
+
"OpenAI",
|
|
270
|
+
"Anthropic",
|
|
271
|
+
"MistralAI",
|
|
272
|
+
}:
|
|
273
|
+
raise ValueError("application state adoption resolved capability is invalid")
|
|
274
|
+
required_strings = ("name", "publisher", "family", "version", "sku")
|
|
275
|
+
if any(
|
|
276
|
+
not isinstance(item.get(key), str) or not item[key].strip() for key in required_strings
|
|
277
|
+
):
|
|
278
|
+
raise ValueError("application state adoption resolved capability is incomplete")
|
|
279
|
+
capacity_tpm = item.get("capacity_tpm", 0)
|
|
280
|
+
capacity_value = item.get("capacity_value", 0)
|
|
281
|
+
capacity_unit = item.get("capacity_unit", "tpm")
|
|
282
|
+
if (
|
|
283
|
+
isinstance(capacity_tpm, bool)
|
|
284
|
+
or not isinstance(capacity_tpm, (int, float))
|
|
285
|
+
or isinstance(capacity_value, bool)
|
|
286
|
+
or not isinstance(capacity_value, (int, float))
|
|
287
|
+
):
|
|
288
|
+
raise ValueError( # noqa: TRY004 - normalize model JSON into a stable CLI error
|
|
289
|
+
"application state adoption resolved capability capacity is invalid"
|
|
290
|
+
)
|
|
291
|
+
if (
|
|
292
|
+
(capacity_unit == "tpm" and not (capacity_tpm >= 1000 and capacity_value == 0))
|
|
293
|
+
or (capacity_unit == "ptu" and not (capacity_tpm == 0 and capacity_value >= 1))
|
|
294
|
+
or capacity_unit not in {"tpm", "ptu"}
|
|
295
|
+
):
|
|
296
|
+
raise ValueError("application state adoption resolved capability capacity is invalid")
|
|
297
|
+
result.append(
|
|
298
|
+
{
|
|
299
|
+
key: item[key]
|
|
300
|
+
for key in (
|
|
301
|
+
"name",
|
|
302
|
+
"publisher",
|
|
303
|
+
"family",
|
|
304
|
+
"version",
|
|
305
|
+
"sku",
|
|
306
|
+
"capacity_tpm",
|
|
307
|
+
"capacity_unit",
|
|
308
|
+
"capacity_value",
|
|
309
|
+
)
|
|
310
|
+
if key in item
|
|
311
|
+
}
|
|
312
|
+
)
|
|
313
|
+
if not result:
|
|
314
|
+
raise ValueError("application state adoption has no resolved model capability")
|
|
315
|
+
return result
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _resource_address(resource: dict[str, Any]) -> str:
|
|
319
|
+
mode = resource.get("mode")
|
|
320
|
+
resource_type = resource.get("type")
|
|
321
|
+
name = resource.get("name")
|
|
322
|
+
if mode not in {"managed", "data"} or not all(
|
|
323
|
+
isinstance(value, str) and value for value in (resource_type, name)
|
|
324
|
+
):
|
|
325
|
+
raise ValueError("application state adoption resource is invalid")
|
|
326
|
+
module = resource.get("module")
|
|
327
|
+
prefix = f"{module}." if isinstance(module, str) and module else ""
|
|
328
|
+
base = f"{prefix}{resource_type}.{name}"
|
|
329
|
+
if base == "module.resource_group.azurerm_resource_group.primary":
|
|
330
|
+
return base + "[0]"
|
|
331
|
+
return base
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _single_attributes(resource: dict[str, Any], *, index: int | None = None) -> dict[str, Any]:
|
|
335
|
+
instances = resource.get("instances")
|
|
336
|
+
if not isinstance(instances, list) or len(instances) != 1:
|
|
337
|
+
raise ValueError("application state adoption expected one resource instance")
|
|
338
|
+
instance = _object(instances[0])
|
|
339
|
+
if instance.get("index_key") != index:
|
|
340
|
+
raise ValueError("application state adoption resource index is invalid")
|
|
341
|
+
return _object(instance.get("attributes"))
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _object(value: object) -> dict[str, Any]:
|
|
345
|
+
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
|
|
346
|
+
raise ValueError("application state adoption contains an invalid object")
|
|
347
|
+
return dict(value)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Bind exact-plan approval to the current authenticated Azure human."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import subprocess
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
|
|
10
|
+
from fdai_deployment_cli.target import (
|
|
11
|
+
azure_target_selection_arguments,
|
|
12
|
+
compute_target_binding,
|
|
13
|
+
require_selected_azure_target,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def actor_digest(
|
|
18
|
+
target_binding: str,
|
|
19
|
+
*,
|
|
20
|
+
timeout_seconds: int = 60,
|
|
21
|
+
runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
|
|
22
|
+
) -> str:
|
|
23
|
+
result = runner(
|
|
24
|
+
(
|
|
25
|
+
"az",
|
|
26
|
+
"account",
|
|
27
|
+
"show",
|
|
28
|
+
*azure_target_selection_arguments(),
|
|
29
|
+
"--query",
|
|
30
|
+
"{subscription_id:id,tenant_id:tenantId,user_name:user.name,user_type:user.type}",
|
|
31
|
+
"--output",
|
|
32
|
+
"json",
|
|
33
|
+
"--only-show-errors",
|
|
34
|
+
),
|
|
35
|
+
check=False,
|
|
36
|
+
capture_output=True,
|
|
37
|
+
text=True,
|
|
38
|
+
timeout=timeout_seconds,
|
|
39
|
+
)
|
|
40
|
+
try:
|
|
41
|
+
value = json.loads(result.stdout)
|
|
42
|
+
except json.JSONDecodeError as exc:
|
|
43
|
+
raise ValueError("authenticated Azure approval actor is unavailable") from exc
|
|
44
|
+
if not isinstance(value, dict) or result.returncode != 0 or value.get("user_type") != "user":
|
|
45
|
+
raise ValueError("authenticated Azure approval actor is unavailable")
|
|
46
|
+
subscription = value.get("subscription_id")
|
|
47
|
+
tenant = value.get("tenant_id")
|
|
48
|
+
login = value.get("user_name")
|
|
49
|
+
if (
|
|
50
|
+
not isinstance(subscription, str)
|
|
51
|
+
or not isinstance(tenant, str)
|
|
52
|
+
or not isinstance(login, str)
|
|
53
|
+
or not login.strip()
|
|
54
|
+
or compute_target_binding(tenant_id=tenant, subscription_id=subscription) != target_binding
|
|
55
|
+
):
|
|
56
|
+
raise ValueError("active Azure target does not match the approved plan")
|
|
57
|
+
require_selected_azure_target(subscription_id=subscription, tenant_id=tenant)
|
|
58
|
+
return hashlib.sha256(f"{target_binding}:{login.casefold()}".encode()).hexdigest()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
__all__ = ["actor_digest"]
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Stable Azure naming tokens shared by Foundation and application deployment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from fdai_deployment_cli.plan_input import read_plan_input
|
|
12
|
+
|
|
13
|
+
_REGION_SHORT_NAMES = {
|
|
14
|
+
"australiacentral": "auc",
|
|
15
|
+
"australiacentral2": "auc2",
|
|
16
|
+
"australiaeast": "aue",
|
|
17
|
+
"australiasoutheast": "ause",
|
|
18
|
+
"austriaeast": "ate",
|
|
19
|
+
"belgiumcentral": "bec",
|
|
20
|
+
"brazilsouth": "brs",
|
|
21
|
+
"brazilsoutheast": "brse",
|
|
22
|
+
"canadacentral": "cac",
|
|
23
|
+
"canadaeast": "cae",
|
|
24
|
+
"centralus": "cus",
|
|
25
|
+
"centraluseuap": "cuse",
|
|
26
|
+
"centralindia": "cin",
|
|
27
|
+
"chilecentral": "clc",
|
|
28
|
+
"eastasia": "ea",
|
|
29
|
+
"eastus": "eus",
|
|
30
|
+
"eastus2": "eus2",
|
|
31
|
+
"eastus2euap": "eus2e",
|
|
32
|
+
"francecentral": "frc",
|
|
33
|
+
"francesouth": "frs",
|
|
34
|
+
"germanynorth": "gen",
|
|
35
|
+
"germanywestcentral": "gwc",
|
|
36
|
+
"indonesiacentral": "idc",
|
|
37
|
+
"israelcentral": "ilc",
|
|
38
|
+
"italynorth": "itn",
|
|
39
|
+
"japaneast": "jpe",
|
|
40
|
+
"japanwest": "jpw",
|
|
41
|
+
"jioindiacentral": "jic",
|
|
42
|
+
"jioindiawest": "jiw",
|
|
43
|
+
"koreacentral": "krc",
|
|
44
|
+
"koreasouth": "krs",
|
|
45
|
+
"malaysiasouth": "mys",
|
|
46
|
+
"mexicocentral": "mxc",
|
|
47
|
+
"newzealandnorth": "nzn",
|
|
48
|
+
"northcentralus": "ncus",
|
|
49
|
+
"northeurope": "neu",
|
|
50
|
+
"norwayeast": "noe",
|
|
51
|
+
"norwaywest": "now",
|
|
52
|
+
"polandcentral": "plc",
|
|
53
|
+
"qatarcentral": "qac",
|
|
54
|
+
"southafricanorth": "zan",
|
|
55
|
+
"southafricawest": "zaw",
|
|
56
|
+
"southcentralus": "scus",
|
|
57
|
+
"southeastasia": "sea",
|
|
58
|
+
"southindia": "sin",
|
|
59
|
+
"spaincentral": "esc",
|
|
60
|
+
"swedencentral": "swc",
|
|
61
|
+
"swedensouth": "sws",
|
|
62
|
+
"switzerlandnorth": "chn",
|
|
63
|
+
"switzerlandwest": "chw",
|
|
64
|
+
"taiwannorth": "twn",
|
|
65
|
+
"taiwannorthwest": "twnw",
|
|
66
|
+
"uaecentral": "uaec",
|
|
67
|
+
"uaenorth": "uaen",
|
|
68
|
+
"uksouth": "uks",
|
|
69
|
+
"ukwest": "ukw",
|
|
70
|
+
"westcentralus": "wcus",
|
|
71
|
+
"westindia": "win",
|
|
72
|
+
"westus": "wus",
|
|
73
|
+
"westus2": "wus2",
|
|
74
|
+
"westus3": "wus3",
|
|
75
|
+
"westeurope": "weu",
|
|
76
|
+
}
|
|
77
|
+
_REGION = re.compile(r"[a-z][a-z0-9]{1,31}")
|
|
78
|
+
_TOKEN = re.compile(r"[a-z][a-z0-9]{1,7}")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def azure_region_short_name(region: str) -> str:
|
|
82
|
+
"""Return the stable FDAI token for one validated Azure region name."""
|
|
83
|
+
|
|
84
|
+
if _REGION.fullmatch(region) is None:
|
|
85
|
+
raise ValueError("Azure region name is invalid")
|
|
86
|
+
token = _REGION_SHORT_NAMES.get(region)
|
|
87
|
+
if token is None:
|
|
88
|
+
raise ValueError("Azure region has no reviewed FDAI short token")
|
|
89
|
+
_validate_region_short_name(token)
|
|
90
|
+
return token
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def reviewed_azure_region_short_names() -> dict[str, str]:
|
|
94
|
+
"""Return the reviewed public Azure region token table for tests and diagnostics."""
|
|
95
|
+
|
|
96
|
+
return dict(_REGION_SHORT_NAMES)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def selected_azure_region_short_name(
|
|
100
|
+
*,
|
|
101
|
+
region: str,
|
|
102
|
+
subscription_id: str,
|
|
103
|
+
environment: str,
|
|
104
|
+
workload: str,
|
|
105
|
+
retained_variables: Path | None = None,
|
|
106
|
+
azure_group_reader: Callable[[str], str] | None = None,
|
|
107
|
+
) -> str:
|
|
108
|
+
"""Select one region token, preserving retained or discovered installation names."""
|
|
109
|
+
|
|
110
|
+
retained = retained_azure_region_short_name(retained_variables)
|
|
111
|
+
if retained is not None:
|
|
112
|
+
return retained
|
|
113
|
+
discovered = discover_existing_azure_region_short_name(
|
|
114
|
+
subscription_id=subscription_id,
|
|
115
|
+
region=region,
|
|
116
|
+
environment=environment,
|
|
117
|
+
workload=workload,
|
|
118
|
+
azure_group_reader=azure_group_reader,
|
|
119
|
+
)
|
|
120
|
+
if discovered is not None:
|
|
121
|
+
return discovered
|
|
122
|
+
return azure_region_short_name(region)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def retained_azure_region_short_name(path: Path | None) -> str | None:
|
|
126
|
+
"""Read the existing Foundation token without recomputing it from the region table."""
|
|
127
|
+
|
|
128
|
+
if path is None or not path.exists():
|
|
129
|
+
return None
|
|
130
|
+
token = read_plan_input(path).get("region_short")
|
|
131
|
+
if not isinstance(token, str):
|
|
132
|
+
raise ValueError("retained Foundation region token is invalid")
|
|
133
|
+
_validate_region_short_name(token)
|
|
134
|
+
return token
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def discover_existing_azure_region_short_name(
|
|
138
|
+
*,
|
|
139
|
+
subscription_id: str,
|
|
140
|
+
region: str,
|
|
141
|
+
environment: str,
|
|
142
|
+
workload: str,
|
|
143
|
+
azure_group_reader: Callable[[str], str] | None = None,
|
|
144
|
+
) -> str | None:
|
|
145
|
+
"""Find a pre-existing FDAI token from tagged resource groups before planning.
|
|
146
|
+
|
|
147
|
+
Current installations did not tag the token explicitly. Until that tag exists,
|
|
148
|
+
discovery parses only FDAI-owned resource group names whose location and ownership
|
|
149
|
+
tags match the selected subscription, environment, workload, and region. Ambiguous
|
|
150
|
+
tokens fail closed before any effect.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
if _REGION.fullmatch(region) is None:
|
|
154
|
+
raise ValueError("Azure region name is invalid")
|
|
155
|
+
if environment not in {"dev", "staging", "prod"}:
|
|
156
|
+
raise ValueError("Azure deployment environment is invalid")
|
|
157
|
+
if re.fullmatch(r"[a-z][a-z0-9]{1,11}", workload) is None:
|
|
158
|
+
raise ValueError("Azure deployment workload token is invalid")
|
|
159
|
+
raw = (
|
|
160
|
+
azure_group_reader(subscription_id)
|
|
161
|
+
if azure_group_reader is not None
|
|
162
|
+
else _read_resource_groups(subscription_id)
|
|
163
|
+
)
|
|
164
|
+
try:
|
|
165
|
+
groups = json.loads(raw)
|
|
166
|
+
except json.JSONDecodeError as exc:
|
|
167
|
+
raise ValueError("Azure resource group discovery returned invalid JSON") from exc
|
|
168
|
+
if not isinstance(groups, list):
|
|
169
|
+
raise ValueError("Azure resource group discovery returned invalid JSON")
|
|
170
|
+
tokens: set[str] = set()
|
|
171
|
+
for group in groups:
|
|
172
|
+
token = _resource_group_region_token(
|
|
173
|
+
group,
|
|
174
|
+
region=region,
|
|
175
|
+
environment=environment,
|
|
176
|
+
workload=workload,
|
|
177
|
+
)
|
|
178
|
+
if token is not None:
|
|
179
|
+
tokens.add(token)
|
|
180
|
+
if len(tokens) > 1:
|
|
181
|
+
raise ValueError("existing FDAI installation region token is ambiguous")
|
|
182
|
+
return next(iter(tokens), None)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _resource_group_region_token(
|
|
186
|
+
group: object, *, region: str, environment: str, workload: str
|
|
187
|
+
) -> str | None:
|
|
188
|
+
if not isinstance(group, dict):
|
|
189
|
+
raise ValueError("Azure resource group discovery returned invalid JSON")
|
|
190
|
+
name = group.get("name")
|
|
191
|
+
location = group.get("location")
|
|
192
|
+
tags = group.get("tags")
|
|
193
|
+
if not isinstance(name, str) or not isinstance(location, str):
|
|
194
|
+
raise ValueError("Azure resource group discovery returned invalid JSON")
|
|
195
|
+
if location.casefold().replace(" ", "") != region:
|
|
196
|
+
return None
|
|
197
|
+
if not isinstance(tags, dict):
|
|
198
|
+
return None
|
|
199
|
+
normalized_tags = {str(key): str(value) for key, value in tags.items()}
|
|
200
|
+
if (
|
|
201
|
+
"fdai:managed" not in normalized_tags
|
|
202
|
+
or normalized_tags.get("fdai:env") != environment
|
|
203
|
+
or normalized_tags.get("fdai:workload") != workload
|
|
204
|
+
):
|
|
205
|
+
return None
|
|
206
|
+
tagged_token = normalized_tags.get("fdai:region-token")
|
|
207
|
+
if tagged_token is not None:
|
|
208
|
+
_validate_region_short_name(tagged_token)
|
|
209
|
+
return tagged_token
|
|
210
|
+
escaped_workload = re.escape(workload)
|
|
211
|
+
escaped_environment = re.escape(environment)
|
|
212
|
+
match = re.fullmatch(
|
|
213
|
+
rf"rg-{escaped_workload}-(?:{escaped_environment}|ops)-(?P<token>[a-z][a-z0-9]{{1,7}})",
|
|
214
|
+
name,
|
|
215
|
+
)
|
|
216
|
+
if match is None:
|
|
217
|
+
return None
|
|
218
|
+
token = match.group("token")
|
|
219
|
+
_validate_region_short_name(token)
|
|
220
|
+
return token
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _read_resource_groups(subscription_id: str) -> str:
|
|
224
|
+
try:
|
|
225
|
+
completed = subprocess.run(
|
|
226
|
+
(
|
|
227
|
+
"az",
|
|
228
|
+
"group",
|
|
229
|
+
"list",
|
|
230
|
+
"--subscription",
|
|
231
|
+
subscription_id,
|
|
232
|
+
"--query",
|
|
233
|
+
"[].{name:name,location:location,tags:tags}",
|
|
234
|
+
"--output",
|
|
235
|
+
"json",
|
|
236
|
+
"--only-show-errors",
|
|
237
|
+
),
|
|
238
|
+
check=False,
|
|
239
|
+
capture_output=True,
|
|
240
|
+
text=True,
|
|
241
|
+
timeout=60,
|
|
242
|
+
)
|
|
243
|
+
except (OSError, subprocess.SubprocessError):
|
|
244
|
+
raise ValueError("Azure resource group discovery failed before planning") from None
|
|
245
|
+
if completed.returncode != 0:
|
|
246
|
+
raise ValueError("Azure resource group discovery failed before planning")
|
|
247
|
+
return completed.stdout
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _validate_region_short_name(token: str) -> None:
|
|
251
|
+
if _TOKEN.fullmatch(token) is None:
|
|
252
|
+
raise ValueError("Azure region short name is invalid")
|