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.
Files changed (203) hide show
  1. fdai_deployment_cli/__about__.py +3 -0
  2. fdai_deployment_cli/__init__.py +5 -0
  3. fdai_deployment_cli/__main__.py +5 -0
  4. fdai_deployment_cli/acr_publish.py +585 -0
  5. fdai_deployment_cli/aks_catalog_review.py +137 -0
  6. fdai_deployment_cli/aks_historical_reconciliation.py +732 -0
  7. fdai_deployment_cli/aks_job_environment.py +59 -0
  8. fdai_deployment_cli/aks_job_execution.py +794 -0
  9. fdai_deployment_cli/aks_kubernetes_defaults.py +66 -0
  10. fdai_deployment_cli/aks_preflight.py +422 -0
  11. fdai_deployment_cli/aks_readiness.py +220 -0
  12. fdai_deployment_cli/aks_service_update.py +180 -0
  13. fdai_deployment_cli/aks_workload_jobs.py +281 -0
  14. fdai_deployment_cli/application_state_adoption.py +347 -0
  15. fdai_deployment_cli/azure_actor.py +61 -0
  16. fdai_deployment_cli/azure_naming.py +252 -0
  17. fdai_deployment_cli/azure_retail_prices.py +173 -0
  18. fdai_deployment_cli/bootstrap_approval.py +99 -0
  19. fdai_deployment_cli/bootstrap_channel.py +321 -0
  20. fdai_deployment_cli/bootstrap_executor.py +311 -0
  21. fdai_deployment_cli/bootstrap_plan.py +237 -0
  22. fdai_deployment_cli/bootstrap_reconcile.py +402 -0
  23. fdai_deployment_cli/bootstrap_support.py +138 -0
  24. fdai_deployment_cli/bootstrap_worker.py +448 -0
  25. fdai_deployment_cli/bundle.py +352 -0
  26. fdai_deployment_cli/capacity.py +143 -0
  27. fdai_deployment_cli/catalog_review_profile.py +306 -0
  28. fdai_deployment_cli/cli.py +798 -0
  29. fdai_deployment_cli/cli_help.py +214 -0
  30. fdai_deployment_cli/cli_parser.py +791 -0
  31. fdai_deployment_cli/cli_plan.py +450 -0
  32. fdai_deployment_cli/compiler.py +99 -0
  33. fdai_deployment_cli/console_artifact.py +218 -0
  34. fdai_deployment_cli/console_config.py +214 -0
  35. fdai_deployment_cli/console_update.py +832 -0
  36. fdai_deployment_cli/contracts.py +307 -0
  37. fdai_deployment_cli/control_package.py +140 -0
  38. fdai_deployment_cli/deadline_transport.py +70 -0
  39. fdai_deployment_cli/deployment_cost.py +131 -0
  40. fdai_deployment_cli/deployment_deadline.py +28 -0
  41. fdai_deployment_cli/deployment_kit.py +790 -0
  42. fdai_deployment_cli/deployment_kit_cache.py +191 -0
  43. fdai_deployment_cli/deployment_progress.py +648 -0
  44. fdai_deployment_cli/development_authority_cli.py +56 -0
  45. fdai_deployment_cli/development_authority_profile.py +319 -0
  46. fdai_deployment_cli/doctor.py +136 -0
  47. fdai_deployment_cli/entitlement_preflight.py +68 -0
  48. fdai_deployment_cli/entitlement_transport.py +49 -0
  49. fdai_deployment_cli/entra_bootstrap.py +579 -0
  50. fdai_deployment_cli/entra_control_profile.py +214 -0
  51. fdai_deployment_cli/entra_graph.py +349 -0
  52. fdai_deployment_cli/entra_profiles.py +215 -0
  53. fdai_deployment_cli/entra_source.py +244 -0
  54. fdai_deployment_cli/execution_bundle.py +478 -0
  55. fdai_deployment_cli/execution_copies.py +63 -0
  56. fdai_deployment_cli/foundation_adoption.py +438 -0
  57. fdai_deployment_cli/foundation_adoption_evidence.py +245 -0
  58. fdai_deployment_cli/foundation_adoption_host.py +157 -0
  59. fdai_deployment_cli/foundation_adoption_transport.py +51 -0
  60. fdai_deployment_cli/foundation_failure.py +72 -0
  61. fdai_deployment_cli/foundation_image.py +168 -0
  62. fdai_deployment_cli/foundation_input.py +235 -0
  63. fdai_deployment_cli/foundation_output.py +154 -0
  64. fdai_deployment_cli/foundation_plan.py +381 -0
  65. fdai_deployment_cli/foundation_process.py +64 -0
  66. fdai_deployment_cli/foundation_progress.py +231 -0
  67. fdai_deployment_cli/github_actions.py +171 -0
  68. fdai_deployment_cli/github_apply_artifact.py +259 -0
  69. fdai_deployment_cli/github_artifact_io.py +48 -0
  70. fdai_deployment_cli/github_dispatch.py +248 -0
  71. fdai_deployment_cli/github_plan_artifact.py +181 -0
  72. fdai_deployment_cli/github_provider_artifact.py +170 -0
  73. fdai_deployment_cli/github_transport.py +46 -0
  74. fdai_deployment_cli/github_workflow_values.py +312 -0
  75. fdai_deployment_cli/hub_image_deploy.py +354 -0
  76. fdai_deployment_cli/hub_image_stage.py +162 -0
  77. fdai_deployment_cli/hub_image_trust.py +281 -0
  78. fdai_deployment_cli/hub_ownership.py +161 -0
  79. fdai_deployment_cli/hub_workload_handoff.py +264 -0
  80. fdai_deployment_cli/hub_workload_handoff_inputs.py +227 -0
  81. fdai_deployment_cli/identity_profile.py +227 -0
  82. fdai_deployment_cli/image_targets.py +197 -0
  83. fdai_deployment_cli/installation_scope.py +280 -0
  84. fdai_deployment_cli/license.py +311 -0
  85. fdai_deployment_cli/license_issue.py +239 -0
  86. fdai_deployment_cli/lifecycle_authorization.py +252 -0
  87. fdai_deployment_cli/lifecycle_commands.py +173 -0
  88. fdai_deployment_cli/lifecycle_configuration.py +722 -0
  89. fdai_deployment_cli/lifecycle_configuration_artifact.py +45 -0
  90. fdai_deployment_cli/lifecycle_configuration_cli.py +324 -0
  91. fdai_deployment_cli/lifecycle_configuration_document.py +74 -0
  92. fdai_deployment_cli/lifecycle_configuration_entities.py +146 -0
  93. fdai_deployment_cli/lifecycle_configuration_package.py +492 -0
  94. fdai_deployment_cli/lifecycle_configuration_publication.py +277 -0
  95. fdai_deployment_cli/lifecycle_configuration_publication_cli.py +191 -0
  96. fdai_deployment_cli/lifecycle_configuration_sealing.py +127 -0
  97. fdai_deployment_cli/lifecycle_enrollment.py +45 -0
  98. fdai_deployment_cli/lifecycle_infrastructure_agent.py +174 -0
  99. fdai_deployment_cli/lifecycle_installation_binding.py +65 -0
  100. fdai_deployment_cli/lifecycle_native.py +247 -0
  101. fdai_deployment_cli/lifecycle_native_reconciliation.py +568 -0
  102. fdai_deployment_cli/lifecycle_native_transition.py +339 -0
  103. fdai_deployment_cli/lifecycle_ownership_exclusion.py +78 -0
  104. fdai_deployment_cli/lifecycle_plan.py +800 -0
  105. fdai_deployment_cli/lifecycle_promotion.py +185 -0
  106. fdai_deployment_cli/lifecycle_release.py +308 -0
  107. fdai_deployment_cli/lifecycle_self_upgrade.py +202 -0
  108. fdai_deployment_cli/lifecycle_upgrade_bundle.py +176 -0
  109. fdai_deployment_cli/notification_recipients_input.py +222 -0
  110. fdai_deployment_cli/observer_artifact.py +106 -0
  111. fdai_deployment_cli/oci_archive.py +353 -0
  112. fdai_deployment_cli/offline_kit.py +797 -0
  113. fdai_deployment_cli/offline_prepare.py +135 -0
  114. fdai_deployment_cli/operational_evidence_verifier_input.py +575 -0
  115. fdai_deployment_cli/plan_input.py +101 -0
  116. fdai_deployment_cli/private_output.py +192 -0
  117. fdai_deployment_cli/profile.py +70 -0
  118. fdai_deployment_cli/progress.py +183 -0
  119. fdai_deployment_cli/readiness.py +286 -0
  120. fdai_deployment_cli/run_command_receiver.py +418 -0
  121. fdai_deployment_cli/runtime_build.py +386 -0
  122. fdai_deployment_cli/runtime_profile.py +287 -0
  123. fdai_deployment_cli/runtime_release.py +745 -0
  124. fdai_deployment_cli/runtime_stage.py +82 -0
  125. fdai_deployment_cli/runtime_support_installation.py +215 -0
  126. fdai_deployment_cli/simulation.py +80 -0
  127. fdai_deployment_cli/source_application_inputs.py +397 -0
  128. fdai_deployment_cli/source_azure.py +586 -0
  129. fdai_deployment_cli/source_change_cli.py +107 -0
  130. fdai_deployment_cli/source_change_parser.py +75 -0
  131. fdai_deployment_cli/source_change_plan.py +181 -0
  132. fdai_deployment_cli/source_changed_update.py +196 -0
  133. fdai_deployment_cli/source_deploy.py +170 -0
  134. fdai_deployment_cli/source_foundation.py +174 -0
  135. fdai_deployment_cli/source_host_tools.py +163 -0
  136. fdai_deployment_cli/source_image_stage.py +400 -0
  137. fdai_deployment_cli/source_input.py +173 -0
  138. fdai_deployment_cli/source_projection.py +164 -0
  139. fdai_deployment_cli/source_receiver.py +76 -0
  140. fdai_deployment_cli/source_recovery.py +264 -0
  141. fdai_deployment_cli/source_runtime.py +81 -0
  142. fdai_deployment_cli/source_runtime_support.py +368 -0
  143. fdai_deployment_cli/source_service_update.py +726 -0
  144. fdai_deployment_cli/source_service_update_cli.py +47 -0
  145. fdai_deployment_cli/source_snapshot.py +178 -0
  146. fdai_deployment_cli/source_teardown.py +574 -0
  147. fdai_deployment_cli/source_terraform.py +85 -0
  148. fdai_deployment_cli/source_transport.py +399 -0
  149. fdai_deployment_cli/standalone_aks_inventory.py +558 -0
  150. fdai_deployment_cli/standalone_aks_job_execution.py +769 -0
  151. fdai_deployment_cli/standalone_aks_nodepool_guard.py +141 -0
  152. fdai_deployment_cli/standalone_application.py +977 -0
  153. fdai_deployment_cli/standalone_application_completion.py +151 -0
  154. fdai_deployment_cli/standalone_application_input_recovery.py +107 -0
  155. fdai_deployment_cli/standalone_application_recovery.py +116 -0
  156. fdai_deployment_cli/standalone_approval_input.py +24 -0
  157. fdai_deployment_cli/standalone_catalog_checkpoint.py +122 -0
  158. fdai_deployment_cli/standalone_catalog_review.py +251 -0
  159. fdai_deployment_cli/standalone_checkpoint_failure.py +127 -0
  160. fdai_deployment_cli/standalone_console_publish.py +174 -0
  161. fdai_deployment_cli/standalone_database_principals.py +41 -0
  162. fdai_deployment_cli/standalone_deploy.py +638 -0
  163. fdai_deployment_cli/standalone_development_authority.py +166 -0
  164. fdai_deployment_cli/standalone_foundation_adoption.py +87 -0
  165. fdai_deployment_cli/standalone_foundation_transition.py +767 -0
  166. fdai_deployment_cli/standalone_host.py +4608 -0
  167. fdai_deployment_cli/standalone_host_rebind.py +226 -0
  168. fdai_deployment_cli/standalone_host_residual.py +104 -0
  169. fdai_deployment_cli/standalone_host_state.py +145 -0
  170. fdai_deployment_cli/standalone_host_values.py +563 -0
  171. fdai_deployment_cli/standalone_kubernetes_tools.py +100 -0
  172. fdai_deployment_cli/standalone_license_installation.py +177 -0
  173. fdai_deployment_cli/standalone_management_egress.py +83 -0
  174. fdai_deployment_cli/standalone_migration_evidence.py +38 -0
  175. fdai_deployment_cli/standalone_operational_evidence.py +356 -0
  176. fdai_deployment_cli/standalone_planned_outputs.py +147 -0
  177. fdai_deployment_cli/standalone_private_access_probe.py +100 -0
  178. fdai_deployment_cli/standalone_product_profile.py +110 -0
  179. fdai_deployment_cli/standalone_remote_prepare.py +442 -0
  180. fdai_deployment_cli/standalone_residual_apply.py +540 -0
  181. fdai_deployment_cli/standalone_residual_successor.py +431 -0
  182. fdai_deployment_cli/standalone_review.py +266 -0
  183. fdai_deployment_cli/standalone_service_update_finalization.py +116 -0
  184. fdai_deployment_cli/standalone_service_update_recovery.py +277 -0
  185. fdai_deployment_cli/standalone_stage_targets.py +325 -0
  186. fdai_deployment_cli/standalone_status.py +67 -0
  187. fdai_deployment_cli/standalone_terraform_environment.py +237 -0
  188. fdai_deployment_cli/standalone_transfer_cleanup.py +237 -0
  189. fdai_deployment_cli/standalone_trial_activation.py +193 -0
  190. fdai_deployment_cli/state.py +434 -0
  191. fdai_deployment_cli/state_handoff.py +253 -0
  192. fdai_deployment_cli/status_projection.py +97 -0
  193. fdai_deployment_cli/support_install.py +257 -0
  194. fdai_deployment_cli/target.py +46 -0
  195. fdai_deployment_cli/trust/capabilities.json +62 -0
  196. fdai_deployment_cli/trust/deployment-bundle-root.pub +3 -0
  197. fdai_deployment_cli/trust/deployment-release-root.pub +3 -0
  198. fdai_deployment_cli/trust/upstream-signing-key.pub +3 -0
  199. fdai_deployment_cli/trust_roots.py +50 -0
  200. fdai_deployment_cli-0.1.2.dist-info/METADATA +112 -0
  201. fdai_deployment_cli-0.1.2.dist-info/RECORD +203 -0
  202. fdai_deployment_cli-0.1.2.dist-info/WHEEL +4 -0
  203. 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")