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,574 @@
1
+ """Guarded source-installation teardown from retained ownership evidence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import re
9
+ import stat
10
+ import subprocess
11
+ import time
12
+ from collections.abc import Callable
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Protocol
16
+
17
+ from fdai_deployment_cli.contracts import canonical_digest, load_json_object
18
+ from fdai_deployment_cli.private_output import read_private_bytes, write_private_output
19
+ from fdai_deployment_cli.runtime_profile import RuntimeDeploymentProfile
20
+
21
+ _DIGEST = re.compile(r"[0-9a-f]{64}")
22
+ _GROUP = re.compile(r"rg-[a-z0-9-]{3,80}")
23
+
24
+
25
+ class ResourceGroupClient(Protocol):
26
+ def delete_group(self, *, subscription_id: str, name: str) -> None: ...
27
+
28
+ def group_absent(self, *, subscription_id: str, name: str) -> bool: ...
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class SourceTeardownPlan:
33
+ target_binding: str
34
+ source_commit: str
35
+ run_binding: str
36
+ installation_anchor: str
37
+ subscription_id: str
38
+ resource_groups: tuple[str, ...]
39
+
40
+ @property
41
+ def confirmation(self) -> str:
42
+ return f"delete {self.installation_anchor[:12]}"
43
+
44
+
45
+ def plan_source_teardown(
46
+ *,
47
+ work_dir: Path,
48
+ source_root: Path | None = None,
49
+ runtime_profile: RuntimeDeploymentProfile,
50
+ region: str,
51
+ monthly_cost_ceiling: int,
52
+ recovery_directory: Path | None = None,
53
+ ) -> SourceTeardownPlan:
54
+ """Prove exactly one retained source installation before naming resources to remove."""
55
+
56
+ # The intent is written unsigned; the signed preparation receipt binds its digest.
57
+ intent = _plain_json(work_dir / "source-intent.json", "source intent")
58
+ source = _object(intent.get("source"), "source intent source")
59
+ if (
60
+ intent.get("schema_version") != "fdai.source-deployment-intent.v1"
61
+ or intent.get("runtime_profile") != runtime_profile.to_mapping()
62
+ or intent.get("region") != region
63
+ or intent.get("monthly_cost_ceiling") != monthly_cost_ceiling
64
+ or source.get("provenance") != "operator-selected-source"
65
+ or source.get("release_signature_verified") is not False
66
+ ):
67
+ raise ValueError("source teardown intent differs from the selected installation")
68
+ source_commit = _text(source, "source_commit", pattern=r"[0-9a-f]{40}")
69
+ preparation = _verified(work_dir / "source-preparation.json", "source preparation", None)
70
+ if (
71
+ preparation.get("source_commit") != source_commit
72
+ or preparation.get("intent_digest") != canonical_digest(intent)
73
+ or preparation.get("provenance") != "operator-selected-source"
74
+ or preparation.get("release_signature_verified") is not False
75
+ ):
76
+ raise ValueError("source teardown preparation proof differs")
77
+ foundation = work_dir / "foundation"
78
+ marker = _verified(foundation / "source-genesis.json", "source genesis", None)
79
+ target_binding = _text(marker, "target_binding", pattern=r"[0-9a-f]{64}")
80
+ run_binding = _text(marker, "run_binding", pattern=r"[0-9a-f]{64}")
81
+ if marker.get("source_commit") != source_commit:
82
+ raise ValueError("source teardown Genesis proof differs")
83
+ status = _plain_json(foundation / "status.json", "source status")
84
+ report = _object(status.get("foundation_report"), "Foundation report")
85
+ plan_ref = _text(
86
+ _object(report.get("foundation_plan"), "Foundation plan"),
87
+ "plan_ref",
88
+ pattern=r"foundation-plan-attempt-[1-9][0-9]*",
89
+ )
90
+ if recovery_directory is not None:
91
+ handoff, located = _recovered_foundation_ownership(
92
+ recovery_directory=recovery_directory.absolute(),
93
+ source_commit=source_commit,
94
+ target_binding=target_binding,
95
+ run_binding=run_binding,
96
+ )
97
+ partial_claimed = False
98
+ else:
99
+ state_handoff = report.get("state_handoff")
100
+ if isinstance(state_handoff, dict):
101
+ handoff_digest = _text(
102
+ state_handoff,
103
+ "receipt_digest",
104
+ pattern=r"[0-9a-f]{64}",
105
+ )
106
+ handoff = _verified(
107
+ foundation / plan_ref / "foundation-state-handoff-receipt.json",
108
+ "Foundation handoff",
109
+ handoff_digest,
110
+ )
111
+ apply = _verified(
112
+ foundation / plan_ref / "foundation-apply-receipt.json",
113
+ "Foundation apply",
114
+ _text(handoff, "foundation_receipt_digest", pattern=r"[0-9a-f]{64}"),
115
+ )
116
+ if (
117
+ apply.get("schema_version") != "fdai.genesis-foundation-apply-receipt.v1"
118
+ or apply.get("state") != "applied"
119
+ or apply.get("source_commit") != source_commit
120
+ or apply.get("target_binding") != target_binding
121
+ ):
122
+ raise ValueError("source teardown Foundation apply proof differs")
123
+ located = _plain_json(
124
+ foundation / plan_ref / "foundation-private-handoff.json", "handoff"
125
+ )
126
+ if (
127
+ canonical_digest(located) != apply.get("handoff_digest")
128
+ or located.get("source_commit") != source_commit
129
+ or located.get("run_digest") != run_binding
130
+ ):
131
+ raise ValueError("source teardown Foundation location proof differs")
132
+ partial_claimed = False
133
+ else:
134
+ located = _claimed_partial_foundation_ownership(
135
+ foundation=foundation,
136
+ plan_ref=plan_ref,
137
+ source_commit=source_commit,
138
+ target_binding=target_binding,
139
+ run_binding=run_binding,
140
+ source_root=(source_root or Path.cwd()).resolve(),
141
+ )
142
+ handoff = {}
143
+ partial_claimed = True
144
+ if not partial_claimed and (
145
+ handoff.get("schema_version") != "fdai.genesis-foundation-state-handoff-receipt.v1"
146
+ or handoff.get("source_commit") != source_commit
147
+ or handoff.get("target_binding") != target_binding
148
+ or any(
149
+ handoff.get(key) is not True
150
+ for key in (
151
+ "effect_verified",
152
+ "runner_attested",
153
+ "remote_backend_authority_verified",
154
+ "zero_change_verified",
155
+ "remote_transient_deleted",
156
+ )
157
+ )
158
+ ):
159
+ raise ValueError("source teardown Foundation ownership proof is incomplete")
160
+ app = _object(located.get("app_resource_group"), "application resource group")
161
+ ops = _object(located.get("ops"), "operations resource group")
162
+ groups = tuple(
163
+ dict.fromkeys(
164
+ (
165
+ _text(app, "name", pattern=_GROUP.pattern),
166
+ _text(ops, "resource_group_name", pattern=_GROUP.pattern),
167
+ )
168
+ )
169
+ )
170
+ anchor = canonical_digest(
171
+ {
172
+ "schema_version": "fdai.source-teardown-anchor.v1",
173
+ "target_binding": target_binding,
174
+ "run_binding": run_binding,
175
+ "source_commit": source_commit,
176
+ "resource_groups": groups,
177
+ }
178
+ )
179
+ return SourceTeardownPlan(
180
+ target_binding=target_binding,
181
+ source_commit=source_commit,
182
+ run_binding=run_binding,
183
+ installation_anchor=anchor,
184
+ subscription_id=_text(located, "subscription_id"),
185
+ resource_groups=groups,
186
+ )
187
+
188
+
189
+ def _claimed_partial_foundation_ownership(
190
+ *,
191
+ foundation: Path,
192
+ plan_ref: str,
193
+ source_commit: str,
194
+ target_binding: str,
195
+ run_binding: str,
196
+ source_root: Path,
197
+ ) -> dict[str, object]:
198
+ """Verify a claimed partial Foundation without inventing effect completion."""
199
+ plan_directory = foundation / plan_ref
200
+ plan = _verified_full(
201
+ plan_directory / "foundation-plan.json",
202
+ "Foundation saved plan",
203
+ digest_field="review_digest",
204
+ )
205
+ claim = _plain_json(plan_directory / "foundation-apply-claim.json", "Foundation apply claim")
206
+ current_variables = _plain_json(
207
+ foundation / "foundation-variables.json", "Foundation variables"
208
+ )
209
+ context = _object(plan.get("context"), "Foundation saved plan context")
210
+ located = _plain_json(plan_directory / "foundation-private-handoff.json", "handoff")
211
+ terraform_root = _text(located, "terraform_root")
212
+ if terraform_root != "infra/genesis-foundation":
213
+ raise ValueError("source teardown Terraform root differs")
214
+ terraform_directory = (source_root / terraform_root).resolve()
215
+ try:
216
+ terraform_directory.relative_to(source_root)
217
+ except ValueError as exc:
218
+ raise ValueError("source teardown Terraform root escapes the selected source") from exc
219
+ if not terraform_directory.is_dir():
220
+ raise ValueError("source teardown Terraform root is unavailable")
221
+ normalized_variables = {
222
+ key: value for key, value in current_variables.items() if key != "target_binding"
223
+ }
224
+ normalized_variables["env"] = "dev"
225
+ variables = _approved_plan_variables(
226
+ plan_directory=plan_directory,
227
+ plan=plan,
228
+ context=context,
229
+ terraform_directory=terraform_directory,
230
+ expected_variables=normalized_variables,
231
+ )
232
+ expected_idempotency = canonical_digest(
233
+ {
234
+ "target_binding": target_binding,
235
+ "plan_digest": plan.get("plan_digest"),
236
+ }
237
+ )
238
+ if (
239
+ (plan_directory / "foundation-apply-receipt.json").exists()
240
+ or plan.get("schema_version") != "fdai.foundation-saved-source-plan.v1"
241
+ or plan.get("state") != "review"
242
+ or context.get("source_commit") != source_commit
243
+ or context.get("target_binding") != target_binding
244
+ or context.get("run_digest") != run_binding
245
+ or context.get("variables_digest") != canonical_digest(variables)
246
+ or any(
247
+ current_variables.get(key) != variables.get(key)
248
+ for key in (
249
+ "workload",
250
+ "region_short",
251
+ "source_commit",
252
+ "run_digest",
253
+ "subscription_id",
254
+ )
255
+ )
256
+ or current_variables.get("target_binding") != target_binding
257
+ or claim.get("schema_version") != "fdai.genesis-foundation-apply-claim.v1"
258
+ or claim.get("state") != "applying"
259
+ or claim.get("review_digest") != plan.get("review_digest")
260
+ or claim.get("plan_digest") != plan.get("plan_digest")
261
+ or claim.get("target_binding") != target_binding
262
+ or claim.get("idempotency_key") != expected_idempotency
263
+ or claim.get("mutation_performed") is not False
264
+ ):
265
+ raise ValueError("source teardown claimed Foundation proof differs")
266
+ workload = _text(variables, "workload", pattern=r"[a-z][a-z0-9]{1,15}")
267
+ region_short = _text(variables, "region_short", pattern=r"[a-z][a-z0-9]{1,7}")
268
+ app = _object(located.get("app_resource_group"), "application resource group")
269
+ ops = _object(located.get("ops"), "operations resource group")
270
+ if (
271
+ located.get("source_commit") != source_commit
272
+ or located.get("run_digest") != run_binding
273
+ or located.get("subscription_id") != variables.get("subscription_id")
274
+ or _text(app, "name", pattern=_GROUP.pattern) != f"rg-{workload}-dev-{region_short}"
275
+ or _text(ops, "resource_group_name", pattern=_GROUP.pattern)
276
+ != f"rg-{workload}-ops-{region_short}"
277
+ ):
278
+ raise ValueError("source teardown claimed Foundation location differs")
279
+ return located
280
+
281
+
282
+ def _approved_plan_variables(
283
+ *,
284
+ plan_directory: Path,
285
+ plan: dict[str, object],
286
+ context: dict[str, object],
287
+ terraform_directory: Path,
288
+ expected_variables: dict[str, object],
289
+ ) -> dict[str, object]:
290
+ saved_plan = plan_directory / "foundation.tfplan"
291
+ plan_bytes = read_private_bytes(saved_plan, max_bytes=64 * 1024 * 1024)
292
+ if hashlib.sha256(plan_bytes).hexdigest() != plan.get("plan_digest"):
293
+ raise ValueError("source teardown saved Foundation plan differs")
294
+ terraform = plan_directory / "terraform"
295
+ terraform_details = terraform.lstat()
296
+ if (
297
+ not stat.S_ISREG(terraform_details.st_mode)
298
+ or stat.S_IMODE(terraform_details.st_mode) != 0o700
299
+ or terraform_details.st_uid != os.geteuid()
300
+ or terraform_details.st_nlink != 1
301
+ or not 0 < terraform_details.st_size <= 128 * 1024 * 1024
302
+ ):
303
+ raise PermissionError("saved Terraform MUST be a current-UID mode-0700 executable")
304
+ terraform_bytes = terraform.read_bytes()
305
+ if hashlib.sha256(terraform_bytes).hexdigest() != context.get("terraform_digest"):
306
+ raise ValueError("source teardown saved Terraform differs")
307
+ completed = subprocess.run(
308
+ (str(terraform), "show", "-json", str(saved_plan)),
309
+ cwd=terraform_directory,
310
+ env={
311
+ **os.environ,
312
+ "TF_CLI_CONFIG_FILE": str(plan_directory / "source.tfrc"),
313
+ "TF_DATA_DIR": str(plan_directory / "terraform-data"),
314
+ },
315
+ check=False,
316
+ capture_output=True,
317
+ timeout=300,
318
+ umask=0o077,
319
+ )
320
+ if (
321
+ completed.returncode != 0
322
+ or len(completed.stdout) > 16 * 1024 * 1024
323
+ or hashlib.sha256(completed.stdout).hexdigest() != plan.get("plan_json_digest")
324
+ ):
325
+ raise ValueError("source teardown saved Foundation plan projection differs")
326
+ projection = load_json_object(
327
+ completed.stdout,
328
+ label="saved Foundation plan projection",
329
+ max_bytes=16 * 1024 * 1024,
330
+ )
331
+ raw_variables = projection.get("variables")
332
+ if not isinstance(raw_variables, dict) or any(
333
+ raw_variables.get(key) != {"value": value} for key, value in expected_variables.items()
334
+ ):
335
+ raise ValueError("source teardown saved Foundation variables are invalid")
336
+ return expected_variables
337
+
338
+
339
+ def _recovered_foundation_ownership(
340
+ *,
341
+ recovery_directory: Path,
342
+ source_commit: str,
343
+ target_binding: str,
344
+ run_binding: str,
345
+ ) -> tuple[dict[str, object], dict[str, object]]:
346
+ """Verify recovered Foundation ownership without fabricating an ordinary apply receipt."""
347
+
348
+ if (
349
+ not recovery_directory.is_dir()
350
+ or recovery_directory.is_symlink()
351
+ or re.fullmatch(r"foundation-recovery-attempt-[1-9][0-9]*", recovery_directory.name) is None
352
+ ):
353
+ raise ValueError("source teardown recovery directory is invalid")
354
+ review = _verified_full(
355
+ recovery_directory / "recovery-review.json",
356
+ "Foundation recovery review",
357
+ digest_field="review_digest",
358
+ )
359
+ apply = _verified_full(
360
+ recovery_directory / "recovery-apply-receipt.json",
361
+ "Foundation recovery apply",
362
+ )
363
+ handoff = _verified_full(
364
+ recovery_directory / "foundation-state-handoff-receipt.json",
365
+ "recovered Foundation handoff",
366
+ )
367
+ located = _plain_json(
368
+ recovery_directory / "recovery-private-handoff.json",
369
+ "recovered private handoff",
370
+ )
371
+ if (
372
+ review.get("schema_version") != "fdai.foundation-recovery-review.v1"
373
+ or review.get("source_commit") != source_commit
374
+ or review.get("target_binding") != target_binding
375
+ or apply.get("schema_version") != "fdai.foundation-recovery-receipt.v1"
376
+ or apply.get("state") != "verified"
377
+ or apply.get("source_commit") != source_commit
378
+ or apply.get("control_plane_readback_verified") is not True
379
+ or apply.get("zero_change_verified") is not True
380
+ or handoff.get("schema_version") != "fdai.genesis-foundation-state-handoff-receipt.v1"
381
+ or handoff.get("state") != "verified"
382
+ or handoff.get("source_commit") != source_commit
383
+ or handoff.get("target_binding") != target_binding
384
+ or handoff.get("foundation_receipt_digest") != apply.get("receipt_digest")
385
+ or any(
386
+ handoff.get(key) is not True
387
+ for key in (
388
+ "effect_verified",
389
+ "runner_attested",
390
+ "remote_backend_authority_verified",
391
+ "zero_change_verified",
392
+ "remote_transient_deleted",
393
+ "local_state_deleted",
394
+ )
395
+ )
396
+ or canonical_digest(located) != apply.get("handoff_digest")
397
+ or located.get("source_commit") != source_commit
398
+ or located.get("run_digest") != run_binding
399
+ ):
400
+ raise ValueError("source teardown recovered Foundation ownership proof differs")
401
+ return handoff, located
402
+
403
+
404
+ def apply_source_teardown(
405
+ *,
406
+ work_dir: Path,
407
+ source_root: Path | None = None,
408
+ runtime_profile: RuntimeDeploymentProfile,
409
+ region: str,
410
+ monthly_cost_ceiling: int,
411
+ confirmation: str | None,
412
+ client: ResourceGroupClient,
413
+ readback_timeout_seconds: float = 1800,
414
+ readback_interval_seconds: float = 30,
415
+ sleep: Callable[[float], None] = time.sleep,
416
+ recovery_directory: Path | None = None,
417
+ ) -> dict[str, object]:
418
+ """Delete only proven-owned groups, then read back their absence."""
419
+
420
+ plan = plan_source_teardown(
421
+ work_dir=work_dir,
422
+ source_root=source_root,
423
+ runtime_profile=runtime_profile,
424
+ region=region,
425
+ monthly_cost_ceiling=monthly_cost_ceiling,
426
+ recovery_directory=recovery_directory,
427
+ )
428
+ review = _review(plan)
429
+ if confirmation != plan.confirmation:
430
+ return {
431
+ **review,
432
+ "state": "review",
433
+ "reason_code": "source_teardown_confirmation_required",
434
+ "required_confirmation": plan.confirmation,
435
+ "mutation_performed": False,
436
+ "deployment_ready": False,
437
+ }
438
+ for group in plan.resource_groups:
439
+ # A rerun after a partial failure must not fail on a group that is already gone.
440
+ if not client.group_absent(subscription_id=plan.subscription_id, name=group):
441
+ client.delete_group(subscription_id=plan.subscription_id, name=group)
442
+ # Deletion is accepted asynchronously, so absence is read back until a bounded deadline.
443
+ deadline = time.monotonic() + readback_timeout_seconds
444
+ while True:
445
+ absent = {
446
+ group: client.group_absent(subscription_id=plan.subscription_id, name=group)
447
+ for group in plan.resource_groups
448
+ }
449
+ if all(absent.values()) or time.monotonic() >= deadline:
450
+ break
451
+ sleep(min(readback_interval_seconds, max(0.0, deadline - time.monotonic())))
452
+ if not all(absent.values()):
453
+ return {
454
+ **review,
455
+ "state": "partial-failure",
456
+ "reason_code": "source_teardown_readback_failed",
457
+ "absent": absent,
458
+ "mutation_performed": True,
459
+ "deployment_ready": False,
460
+ }
461
+ receipt = {
462
+ **review,
463
+ "state": "torn-down",
464
+ "absent": absent,
465
+ "mutation_performed": True,
466
+ "deployment_ready": False,
467
+ }
468
+ receipt["receipt_digest"] = canonical_digest(receipt)
469
+ write_private_output(
470
+ work_dir / "source-teardown-receipt.json",
471
+ json.dumps(receipt, sort_keys=True, separators=(",", ":")) + "\n",
472
+ )
473
+ return receipt
474
+
475
+
476
+ class AzureResourceGroupClient:
477
+ """Remove and read back resource groups through the current Azure CLI identity."""
478
+
479
+ def delete_group(self, *, subscription_id: str, name: str) -> None:
480
+ _run(
481
+ (
482
+ "az",
483
+ "group",
484
+ "delete",
485
+ "--subscription",
486
+ subscription_id,
487
+ "--name",
488
+ name,
489
+ "--yes",
490
+ "--no-wait",
491
+ "--only-show-errors",
492
+ ),
493
+ reason="source teardown resource deletion failed",
494
+ )
495
+
496
+ def group_absent(self, *, subscription_id: str, name: str) -> bool:
497
+ output = _run(
498
+ (
499
+ "az",
500
+ "group",
501
+ "exists",
502
+ "--subscription",
503
+ subscription_id,
504
+ "--name",
505
+ name,
506
+ "--output",
507
+ "tsv",
508
+ "--only-show-errors",
509
+ ),
510
+ reason="source teardown resource readback failed",
511
+ ).strip()
512
+ if output not in {"true", "false"}:
513
+ raise ValueError("source teardown resource readback is invalid")
514
+ return output == "false"
515
+
516
+
517
+ def _review(plan: SourceTeardownPlan) -> dict[str, object]:
518
+ return {
519
+ "schema_version": "fdai.source-teardown-review.v1",
520
+ "target_binding": plan.target_binding,
521
+ "source_commit": plan.source_commit,
522
+ "run_binding": plan.run_binding,
523
+ "installation_anchor": plan.installation_anchor,
524
+ "resource_groups": list(plan.resource_groups),
525
+ "owned_resources_verified": True,
526
+ "foreign_resources_refused": True,
527
+ "subscription_ready": False,
528
+ }
529
+
530
+
531
+ def _verified(path: Path, label: str, expected_digest: str | None) -> dict[str, object]:
532
+ value = _verified_full(path, label)
533
+ digest = value["receipt_digest"]
534
+ if expected_digest is not None and digest != expected_digest:
535
+ raise ValueError(f"{label} digest differs")
536
+ return {key: item for key, item in value.items() if key != "receipt_digest"}
537
+
538
+
539
+ def _verified_full(
540
+ path: Path, label: str, *, digest_field: str = "receipt_digest"
541
+ ) -> dict[str, object]:
542
+ value = _plain_json(path, label)
543
+ digest = value.get(digest_field)
544
+ unsigned = {key: item for key, item in value.items() if key != digest_field}
545
+ if not isinstance(digest, str) or canonical_digest(unsigned) != digest:
546
+ raise ValueError(f"{label} digest is invalid")
547
+ return value
548
+
549
+
550
+ def _plain_json(path: Path, label: str) -> dict[str, object]:
551
+ value = load_json_object(read_private_bytes(path, max_bytes=1024 * 1024), label=label)
552
+ if not isinstance(value, dict):
553
+ raise TypeError(f"{label} is invalid")
554
+ return {str(key): item for key, item in value.items()}
555
+
556
+
557
+ def _object(value: object, label: str) -> dict[str, object]:
558
+ if not isinstance(value, dict):
559
+ raise TypeError(f"{label} is invalid")
560
+ return {str(key): item for key, item in value.items()}
561
+
562
+
563
+ def _text(value: dict[str, object], key: str, *, pattern: str | None = None) -> str:
564
+ item = value.get(key)
565
+ if not isinstance(item, str) or (pattern is not None and re.fullmatch(pattern, item) is None):
566
+ raise ValueError(f"{key} is invalid")
567
+ return item
568
+
569
+
570
+ def _run(command: tuple[str, ...], *, reason: str) -> str:
571
+ completed = subprocess.run(command, check=False, capture_output=True, text=True, timeout=900)
572
+ if completed.returncode != 0:
573
+ raise ValueError(reason)
574
+ return completed.stdout
@@ -0,0 +1,85 @@
1
+ """Acquire the committed Terraform release for a source deployment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import http.client
7
+ import io
8
+ import platform
9
+ import re
10
+ import urllib.error
11
+ import urllib.request
12
+ import zipfile
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from fdai_deployment_cli.deployment_deadline import DeploymentDeadline
17
+ from fdai_deployment_cli.private_output import write_private_bytes
18
+
19
+ _RELEASES = "https://releases.hashicorp.com/terraform"
20
+ _MAX_ARCHIVE_BYTES = 64 * 1024 * 1024
21
+ _MAX_BINARY_BYTES = 128 * 1024 * 1024
22
+ _DIGEST = re.compile(r"[0-9a-f]{64}")
23
+
24
+
25
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
26
+ def redirect_request(self, *args: Any, **kwargs: Any) -> None:
27
+ raise ValueError("Terraform release redirects are not supported")
28
+
29
+
30
+ def download_pinned_terraform(
31
+ *, toolchain: dict[str, Any], destination: Path, timeout_seconds: int = 300
32
+ ) -> Path:
33
+ """Fetch the pinned release once and keep it only when both committed digests match.
34
+
35
+ The archive digest and the extracted binary digest both come from the source
36
+ snapshot's toolchain record, so the public release host is never trusted.
37
+ """
38
+ version = toolchain.get("terraform_version")
39
+ archive_digest = toolchain.get("terraform_sha256")
40
+ binary_digest = toolchain.get("terraform_binary_sha256")
41
+ if (
42
+ not isinstance(version, str)
43
+ or re.fullmatch(r"\d+\.\d+\.\d+", version) is None
44
+ or not isinstance(archive_digest, str)
45
+ or _DIGEST.fullmatch(archive_digest) is None
46
+ or not isinstance(binary_digest, str)
47
+ or _DIGEST.fullmatch(binary_digest) is None
48
+ ):
49
+ raise ValueError("source toolchain does not pin a Terraform release")
50
+ if platform.system() != "Linux" or platform.machine() not in {"x86_64", "AMD64"}:
51
+ raise ValueError(
52
+ f"install Terraform {version} on PATH; the committed digests cover linux_amd64 only"
53
+ )
54
+ deadline = DeploymentDeadline(timeout_seconds)
55
+ url = f"{_RELEASES}/{version}/terraform_{version}_linux_amd64.zip"
56
+ opener = urllib.request.build_opener(_NoRedirect())
57
+ try:
58
+ with opener.open(url, timeout=deadline.remaining(60)) as response:
59
+ chunks = bytearray()
60
+ while len(chunks) <= _MAX_ARCHIVE_BYTES:
61
+ deadline.remaining()
62
+ chunk = response.read1(min(1024 * 1024, _MAX_ARCHIVE_BYTES + 1 - len(chunks)))
63
+ if not chunk:
64
+ break
65
+ chunks.extend(chunk)
66
+ except (OSError, urllib.error.URLError, http.client.HTTPException):
67
+ raise ValueError(
68
+ f"Terraform {version} download unavailable; install it on PATH or retry"
69
+ ) from None
70
+ archive = bytes(chunks)
71
+ if len(archive) > _MAX_ARCHIVE_BYTES or hashlib.sha256(archive).hexdigest() != archive_digest:
72
+ raise ValueError("downloaded Terraform archive does not match the committed digest")
73
+ try:
74
+ with zipfile.ZipFile(io.BytesIO(archive)) as bundle:
75
+ member = bundle.getinfo("terraform")
76
+ if not 0 < member.file_size <= _MAX_BINARY_BYTES:
77
+ raise ValueError("downloaded Terraform binary exceeds its bound")
78
+ binary = bundle.read(member)
79
+ except (KeyError, zipfile.BadZipFile):
80
+ raise ValueError("downloaded Terraform archive has no terraform binary") from None
81
+ if hashlib.sha256(binary).hexdigest() != binary_digest:
82
+ raise ValueError("downloaded Terraform binary does not match the committed digest")
83
+ write_private_bytes(destination, binary)
84
+ destination.chmod(0o700)
85
+ return destination