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,431 @@
1
+ """Govern one create-only successor after a residual apply is verified incomplete."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ from collections.abc import Callable
8
+ from datetime import UTC, datetime, timedelta
9
+ from pathlib import Path
10
+
11
+ from fdai_deployment_cli.contracts import canonical_digest
12
+ from fdai_deployment_cli.private_output import write_private_output
13
+ from fdai_deployment_cli.standalone_host_state import (
14
+ file_digest,
15
+ moment,
16
+ private_json,
17
+ replace_private_json,
18
+ )
19
+ from fdai_deployment_cli.standalone_host_values import plan_summary
20
+ from fdai_deployment_cli.standalone_residual_apply import (
21
+ _DIGEST,
22
+ _STAGES,
23
+ _required_digest,
24
+ _required_source_commit,
25
+ _runtime_platform,
26
+ _runtime_profile_digest,
27
+ _validate_residual_claim,
28
+ _zero_change,
29
+ seal_terraform_plan,
30
+ )
31
+ from fdai_deployment_cli.standalone_review import validate_plan_review
32
+
33
+
34
+ def successor_prerequisite_verified(work_dir: Path, stage: str) -> bool:
35
+ """Require completed focused private access before the one substrate successor."""
36
+
37
+ if stage != "substrate":
38
+ return False
39
+ receipt_path = work_dir / "access-receipt.json"
40
+ if not receipt_path.exists() or receipt_path.is_symlink():
41
+ return False
42
+ receipt = private_json(receipt_path, "focused private access receipt")
43
+ return (
44
+ receipt.get("schema_version") == "fdai.standalone-application-apply-receipt.v1"
45
+ and receipt.get("state") == "applied"
46
+ and receipt.get("control_plane_readback_verified") is True
47
+ and receipt.get("mutation_performed") is True
48
+ )
49
+
50
+
51
+ def prepare_residual_successor_review(
52
+ *,
53
+ work_dir: Path,
54
+ stage: str,
55
+ context: dict[str, object],
56
+ infra: Path,
57
+ variables: Path,
58
+ targets: tuple[str, ...],
59
+ original_review: dict[str, object],
60
+ original_claim: dict[str, object],
61
+ residual_review: dict[str, object],
62
+ residual_claim: dict[str, object],
63
+ successor_prerequisite_verified: bool,
64
+ ) -> dict[str, object]:
65
+ """Create one fresh create-only successor plan after verified residual non-convergence."""
66
+
67
+ _validate_residual_claim(
68
+ stage=stage,
69
+ context=context,
70
+ original_review=original_review,
71
+ original_claim=original_claim,
72
+ residual_review=residual_review,
73
+ residual_claim=residual_claim,
74
+ )
75
+ if not successor_prerequisite_verified:
76
+ raise ValueError("standalone residual successor prerequisite is not verified")
77
+ operation = _operation(stage)
78
+ plan_path = work_dir / f"{operation}.tfplan"
79
+ review_path = work_dir / f"{operation}-review.json"
80
+ claim_path = work_dir / f"{operation}-claim.json"
81
+ if claim_path.exists() or claim_path.is_symlink():
82
+ raise ValueError("standalone residual successor claim already exists")
83
+ plan_path.unlink(missing_ok=True)
84
+ review_path.unlink(missing_ok=True)
85
+ command = [
86
+ "terraform",
87
+ "plan",
88
+ "-detailed-exitcode",
89
+ "-input=false",
90
+ "-no-color",
91
+ f"-var-file={variables}",
92
+ f"-out={plan_path}",
93
+ *(f"-target={target}" for target in targets),
94
+ ]
95
+ completed = subprocess.run(
96
+ command,
97
+ cwd=infra,
98
+ check=False,
99
+ capture_output=True,
100
+ timeout=1800,
101
+ umask=0o077,
102
+ )
103
+ if completed.returncode != 2:
104
+ plan_path.unlink(missing_ok=True)
105
+ raise ValueError("standalone residual successor plan is unavailable")
106
+ seal_terraform_plan(plan_path)
107
+ shown = subprocess.run(
108
+ ("terraform", "show", "-json", str(plan_path)),
109
+ cwd=infra,
110
+ check=False,
111
+ capture_output=True,
112
+ text=True,
113
+ timeout=300,
114
+ umask=0o077,
115
+ )
116
+ if shown.returncode != 0:
117
+ raise ValueError("standalone residual successor projection failed")
118
+ try:
119
+ summary = plan_summary(json.loads(shown.stdout))
120
+ except json.JSONDecodeError as exc:
121
+ raise ValueError("standalone residual successor projection is invalid") from exc
122
+ counts = summary.get("action_counts")
123
+ if (
124
+ not isinstance(counts, dict)
125
+ or type(counts.get("create")) is not int
126
+ or counts["create"] <= 0
127
+ or any(counts.get(action, 0) != 0 for action in ("update", "delete"))
128
+ or type(counts.get("replace", 0)) is not int
129
+ or counts.get("replace", 0) not in {0, 1}
130
+ or (counts.get("replace", 0) == 1 and stage != "application")
131
+ ):
132
+ raise ValueError(
133
+ "standalone residual successor must contain creates and at most one application replacement"
134
+ )
135
+ review: dict[str, object] = {
136
+ "schema_version": "fdai.standalone-application-plan.v1",
137
+ "stage": stage,
138
+ "plan_digest": file_digest(plan_path),
139
+ "target_binding": _required_digest(context, "target_binding"),
140
+ "source_commit": _required_source_commit(context),
141
+ "runtime_profile_digest": _runtime_profile_digest(context),
142
+ "runtime_platform": _runtime_platform(context),
143
+ "summary": summary,
144
+ "residual_successor": {
145
+ "operation": operation,
146
+ "original_claim_digest": canonical_digest(original_claim),
147
+ "residual_plan_digest": residual_review["plan_digest"],
148
+ "residual_claim_digest": canonical_digest(residual_claim),
149
+ },
150
+ "expires_at": moment(datetime.now(UTC) + timedelta(hours=1)),
151
+ "mutation_performed": False,
152
+ "subscription_ready": False,
153
+ }
154
+ review["review_digest"] = canonical_digest(review)
155
+ replace_private_json(review_path, review)
156
+ return review
157
+
158
+
159
+ def apply_residual_successor(
160
+ *,
161
+ work_dir: Path,
162
+ stage: str,
163
+ context: dict[str, object],
164
+ infra: Path,
165
+ variables: Path,
166
+ targets: tuple[str, ...],
167
+ original_review: dict[str, object],
168
+ original_claim: dict[str, object],
169
+ residual_review: dict[str, object],
170
+ residual_claim: dict[str, object],
171
+ successor_review: dict[str, object],
172
+ approval: dict[str, object],
173
+ effect_readback: Callable[[], bool],
174
+ ) -> dict[str, object]:
175
+ """Apply one exact-approved create-only successor and prohibit another successor."""
176
+
177
+ _validate_review(
178
+ stage=stage,
179
+ context=context,
180
+ original_review=original_review,
181
+ original_claim=original_claim,
182
+ residual_review=residual_review,
183
+ residual_claim=residual_claim,
184
+ successor_review=successor_review,
185
+ )
186
+ operation = _operation(stage)
187
+ plan_path = work_dir / f"{operation}.tfplan"
188
+ if file_digest(plan_path) != successor_review["plan_digest"]:
189
+ raise ValueError("standalone residual successor plan changed before apply")
190
+ claim_path = work_dir / f"{operation}-claim.json"
191
+ claim = _claim(
192
+ stage=stage,
193
+ context=context,
194
+ original_claim=original_claim,
195
+ residual_claim=residual_claim,
196
+ successor_review=successor_review,
197
+ approval=approval,
198
+ )
199
+ try:
200
+ write_private_output(
201
+ claim_path,
202
+ json.dumps(claim, sort_keys=True, separators=(",", ":")) + "\n",
203
+ )
204
+ except FileExistsError as exc:
205
+ raise ValueError(
206
+ "standalone residual successor claim already exists; automatic retry is blocked"
207
+ ) from exc
208
+ completed = subprocess.run(
209
+ ("terraform", "apply", "-input=false", "-no-color", str(plan_path)),
210
+ cwd=infra,
211
+ check=False,
212
+ capture_output=True,
213
+ timeout=7200,
214
+ umask=0o077,
215
+ )
216
+ if completed.returncode != 0:
217
+ raise ValueError(
218
+ f"{stage} residual successor apply failed; verification-only recovery is required"
219
+ )
220
+ if not effect_readback() or not _zero_change(infra=infra, variables=variables, targets=targets):
221
+ raise ValueError(f"{stage} residual successor did not converge")
222
+ return _write_receipt(
223
+ work_dir=work_dir,
224
+ stage=stage,
225
+ context=context,
226
+ original_review=original_review,
227
+ original_claim=original_claim,
228
+ residual_review=residual_review,
229
+ residual_claim=residual_claim,
230
+ successor_review=successor_review,
231
+ successor_claim=claim,
232
+ verification_only=False,
233
+ )
234
+
235
+
236
+ def recover_residual_successor(
237
+ *,
238
+ work_dir: Path,
239
+ stage: str,
240
+ context: dict[str, object],
241
+ infra: Path,
242
+ variables: Path,
243
+ targets: tuple[str, ...],
244
+ original_review: dict[str, object],
245
+ original_claim: dict[str, object],
246
+ residual_review: dict[str, object],
247
+ residual_claim: dict[str, object],
248
+ effect_readback: Callable[[], bool],
249
+ ) -> dict[str, object]:
250
+ """Verify one claimed successor without permitting any further apply."""
251
+
252
+ operation = _operation(stage)
253
+ review = private_json(work_dir / f"{operation}-review.json", "residual successor review")
254
+ claim = private_json(work_dir / f"{operation}-claim.json", "residual successor claim")
255
+ _validate_review(
256
+ stage=stage,
257
+ context=context,
258
+ original_review=original_review,
259
+ original_claim=original_claim,
260
+ residual_review=residual_review,
261
+ residual_claim=residual_claim,
262
+ successor_review=review,
263
+ allow_expired=True,
264
+ )
265
+ expected = _claim_binding(
266
+ stage=stage,
267
+ context=context,
268
+ original_claim=original_claim,
269
+ residual_claim=residual_claim,
270
+ successor_review=review,
271
+ )
272
+ if (
273
+ claim.get("schema_version") != "fdai.standalone-residual-successor-claim.v1"
274
+ or any(claim.get(key) != value for key, value in expected.items())
275
+ or not isinstance(claim.get("approval_digest"), str)
276
+ or _DIGEST.fullmatch(str(claim["approval_digest"])) is None
277
+ or not isinstance(claim.get("claimed_at"), str)
278
+ or claim.get("mutation_performed") is not False
279
+ ):
280
+ raise ValueError("standalone residual successor claim is invalid")
281
+ if not _zero_change(infra=infra, variables=variables, targets=targets):
282
+ raise ValueError("standalone residual successor is not recoverably converged")
283
+ if not effect_readback():
284
+ raise ValueError("standalone residual successor readback is incomplete")
285
+ return _write_receipt(
286
+ work_dir=work_dir,
287
+ stage=stage,
288
+ context=context,
289
+ original_review=original_review,
290
+ original_claim=original_claim,
291
+ residual_review=residual_review,
292
+ residual_claim=residual_claim,
293
+ successor_review=review,
294
+ successor_claim=claim,
295
+ verification_only=True,
296
+ )
297
+
298
+
299
+ def _validate_review(
300
+ *,
301
+ stage: str,
302
+ context: dict[str, object],
303
+ original_review: dict[str, object],
304
+ original_claim: dict[str, object],
305
+ residual_review: dict[str, object],
306
+ residual_claim: dict[str, object],
307
+ successor_review: dict[str, object],
308
+ allow_expired: bool = False,
309
+ ) -> None:
310
+ _validate_residual_claim(
311
+ stage=stage,
312
+ context=context,
313
+ original_review=original_review,
314
+ original_claim=original_claim,
315
+ residual_review=residual_review,
316
+ residual_claim=residual_claim,
317
+ )
318
+ validated_stage, _destructive = validate_plan_review(
319
+ successor_review, allow_expired=allow_expired
320
+ )
321
+ binding = successor_review.get("residual_successor")
322
+ if (
323
+ validated_stage != stage
324
+ or successor_review.get("target_binding") != context.get("target_binding")
325
+ or successor_review.get("source_commit") != context.get("source_commit")
326
+ or _runtime_profile_digest(successor_review) != _runtime_profile_digest(context)
327
+ or not isinstance(binding, dict)
328
+ or binding
329
+ != {
330
+ "operation": _operation(stage),
331
+ "original_claim_digest": canonical_digest(original_claim),
332
+ "residual_plan_digest": residual_review.get("plan_digest"),
333
+ "residual_claim_digest": canonical_digest(residual_claim),
334
+ }
335
+ ):
336
+ raise ValueError("standalone residual successor plan binding is invalid")
337
+
338
+
339
+ def _claim(
340
+ *,
341
+ stage: str,
342
+ context: dict[str, object],
343
+ original_claim: dict[str, object],
344
+ residual_claim: dict[str, object],
345
+ successor_review: dict[str, object],
346
+ approval: dict[str, object],
347
+ ) -> dict[str, object]:
348
+ return {
349
+ "schema_version": "fdai.standalone-residual-successor-claim.v1",
350
+ **_claim_binding(
351
+ stage=stage,
352
+ context=context,
353
+ original_claim=original_claim,
354
+ residual_claim=residual_claim,
355
+ successor_review=successor_review,
356
+ ),
357
+ "approval_digest": canonical_digest(approval),
358
+ "claimed_at": moment(datetime.now(UTC)),
359
+ "mutation_performed": False,
360
+ }
361
+
362
+
363
+ def _claim_binding(
364
+ *,
365
+ stage: str,
366
+ context: dict[str, object],
367
+ original_claim: dict[str, object],
368
+ residual_claim: dict[str, object],
369
+ successor_review: dict[str, object],
370
+ ) -> dict[str, object]:
371
+ original_claim_digest = canonical_digest(original_claim)
372
+ residual_claim_digest = canonical_digest(residual_claim)
373
+ plan_digest = successor_review["plan_digest"]
374
+ return {
375
+ "stage": stage,
376
+ "operation": _operation(stage),
377
+ "original_claim_digest": original_claim_digest,
378
+ "residual_claim_digest": residual_claim_digest,
379
+ "plan_digest": plan_digest,
380
+ "idempotency_key": canonical_digest(
381
+ {
382
+ "target_binding": _required_digest(context, "target_binding"),
383
+ "original_claim_digest": original_claim_digest,
384
+ "residual_claim_digest": residual_claim_digest,
385
+ "plan_digest": plan_digest,
386
+ }
387
+ ),
388
+ }
389
+
390
+
391
+ def _write_receipt(
392
+ *,
393
+ work_dir: Path,
394
+ stage: str,
395
+ context: dict[str, object],
396
+ original_review: dict[str, object],
397
+ original_claim: dict[str, object],
398
+ residual_review: dict[str, object],
399
+ residual_claim: dict[str, object],
400
+ successor_review: dict[str, object],
401
+ successor_claim: dict[str, object],
402
+ verification_only: bool,
403
+ ) -> dict[str, object]:
404
+ receipt: dict[str, object] = {
405
+ "schema_version": "fdai.standalone-residual-successor-receipt.v1",
406
+ "state": "applied",
407
+ "stage": stage,
408
+ "plan_digest": successor_review["plan_digest"],
409
+ "original_plan_digest": original_review["plan_digest"],
410
+ "residual_plan_digest": residual_review["plan_digest"],
411
+ "runtime_profile_digest": _runtime_profile_digest(context),
412
+ "original_claim_digest": canonical_digest(original_claim),
413
+ "residual_claim_digest": canonical_digest(residual_claim),
414
+ "successor_claim_digest": canonical_digest(successor_claim),
415
+ "control_plane_readback_verified": True,
416
+ "terraform_zero_change_verified": True,
417
+ "residual_recovery": True,
418
+ "residual_successor": True,
419
+ "verification_only_recovery": verification_only,
420
+ "mutation_performed": True,
421
+ "subscription_ready": False,
422
+ }
423
+ receipt["receipt_digest"] = canonical_digest(receipt)
424
+ replace_private_json(work_dir / f"{stage}-receipt.json", receipt)
425
+ return receipt
426
+
427
+
428
+ def _operation(stage: str) -> str:
429
+ if stage not in _STAGES:
430
+ raise ValueError("standalone residual successor stage is invalid")
431
+ return f"{stage}-residual-successor"
@@ -0,0 +1,266 @@
1
+ """Validate the managed-host plan review before rendering or naming an approval file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from datetime import UTC, datetime
7
+ from typing import Any
8
+
9
+ from fdai_deployment_cli.contracts import canonical_digest
10
+
11
+ _FIELDS = frozenset(
12
+ {
13
+ "schema_version",
14
+ "stage",
15
+ "plan_digest",
16
+ "review_digest",
17
+ "target_binding",
18
+ "source_commit",
19
+ "runtime_profile_digest",
20
+ "runtime_platform",
21
+ "summary",
22
+ "expires_at",
23
+ "mutation_performed",
24
+ "subscription_ready",
25
+ }
26
+ )
27
+ _LEGACY_FIELDS = _FIELDS - {"runtime_profile_digest", "runtime_platform"}
28
+ _SERVICE_UPDATE_FIELDS = frozenset({"service", "image", "source_commit", "update_digest"})
29
+ _SERVICE_UPDATE_AUTHORITY_FIELDS = frozenset(
30
+ {"development_authority_operation", "development_authority_profile_digest"}
31
+ )
32
+ _HISTORICAL_RECONCILIATION_FIELDS = frozenset({"operation", "variables_digest", "mutations"})
33
+ _RESIDUAL_RECOVERY_FIELDS = frozenset(
34
+ {"operation", "original_plan_digest", "original_claim_digest"}
35
+ )
36
+ _RESIDUAL_SUCCESSOR_FIELDS = frozenset(
37
+ {
38
+ "operation",
39
+ "original_claim_digest",
40
+ "residual_plan_digest",
41
+ "residual_claim_digest",
42
+ }
43
+ )
44
+ _ACTIONS = frozenset({"create", "update", "delete", "replace", "read", "no-op"})
45
+ _ERROR = "standalone plan review is invalid or expired; request a current exact plan"
46
+
47
+
48
+ def validate_plan_review(review: dict[str, Any], *, allow_expired: bool = False) -> tuple[str, int]:
49
+ """Return a safe stage and destructive count without granting execution authority."""
50
+
51
+ stage = review.get("stage")
52
+ summary = review.get("summary")
53
+ if (
54
+ set(review)
55
+ not in (
56
+ _LEGACY_FIELDS,
57
+ _LEGACY_FIELDS | {"service_update"},
58
+ _FIELDS,
59
+ _FIELDS | {"service_update"},
60
+ _FIELDS | {"operation"},
61
+ _FIELDS | {"operation", "service_update"},
62
+ _FIELDS | {"historical_reconciliation"},
63
+ _FIELDS | {"residual_recovery"},
64
+ _FIELDS | {"residual_successor"},
65
+ )
66
+ or review.get("schema_version") != "fdai.standalone-application-plan.v1"
67
+ or not isinstance(stage, str)
68
+ or stage not in {"access", "substrate", "runtime", "database", "application"}
69
+ or review.get("mutation_performed") is not False
70
+ or review.get("subscription_ready") is not False
71
+ or not isinstance(summary, dict)
72
+ or not set(summary).issubset({"action_counts", "resource_type_counts", "resource_changes"})
73
+ ):
74
+ raise ValueError(_ERROR)
75
+ service_update = review.get("service_update")
76
+ service_update_fields = set(service_update) if isinstance(service_update, dict) else set()
77
+ if service_update is not None and (
78
+ stage != "application"
79
+ or not isinstance(service_update, dict)
80
+ or service_update_fields
81
+ not in {
82
+ _SERVICE_UPDATE_FIELDS,
83
+ _SERVICE_UPDATE_FIELDS | _SERVICE_UPDATE_AUTHORITY_FIELDS,
84
+ }
85
+ or service_update.get("service")
86
+ not in {
87
+ "core-control-plane",
88
+ "document-ingestion-api",
89
+ "document-processing-worker",
90
+ "isolated-executor",
91
+ "operator-service",
92
+ }
93
+ or not isinstance(service_update.get("image"), str)
94
+ or re.fullmatch(
95
+ r"[a-z0-9.-]+(?::[0-9]+)?/[a-z0-9._/-]+@sha256:[0-9a-f]{64}",
96
+ service_update["image"],
97
+ )
98
+ is None
99
+ or not isinstance(service_update.get("source_commit"), str)
100
+ or re.fullmatch(r"[0-9a-f]{40}", service_update["source_commit"]) is None
101
+ or not isinstance(service_update.get("update_digest"), str)
102
+ or re.fullmatch(r"[0-9a-f]{64}", service_update["update_digest"]) is None
103
+ ):
104
+ raise ValueError(_ERROR)
105
+ if service_update_fields == _SERVICE_UPDATE_FIELDS | _SERVICE_UPDATE_AUTHORITY_FIELDS:
106
+ if not isinstance(service_update, dict):
107
+ raise TypeError(_ERROR)
108
+ authority_operation = service_update["development_authority_operation"]
109
+ authority_digest = service_update["development_authority_profile_digest"]
110
+ if (
111
+ service_update["service"] != "core-control-plane"
112
+ or authority_operation not in {"select", "clear"}
113
+ or (
114
+ authority_operation == "select"
115
+ and (
116
+ not isinstance(authority_digest, str)
117
+ or re.fullmatch(r"[0-9a-f]{64}", authority_digest) is None
118
+ )
119
+ )
120
+ or (authority_operation == "clear" and authority_digest != "")
121
+ ):
122
+ raise ValueError(_ERROR)
123
+ if "operation" in review and not _operation_matches_stage(
124
+ stage, review.get("operation"), service_update
125
+ ):
126
+ raise ValueError(_ERROR)
127
+ historical_reconciliation = review.get("historical_reconciliation")
128
+ if historical_reconciliation is not None and (
129
+ stage != "application"
130
+ or not isinstance(historical_reconciliation, dict)
131
+ or set(historical_reconciliation) != _HISTORICAL_RECONCILIATION_FIELDS
132
+ or historical_reconciliation.get("operation") != "historical-reconciliation"
133
+ or not isinstance(historical_reconciliation.get("variables_digest"), str)
134
+ or re.fullmatch(r"[0-9a-f]{64}", str(historical_reconciliation["variables_digest"])) is None
135
+ or not isinstance(historical_reconciliation.get("mutations"), list)
136
+ or not historical_reconciliation["mutations"]
137
+ or len(historical_reconciliation["mutations"]) > 64
138
+ or historical_reconciliation["mutations"]
139
+ != sorted(set(historical_reconciliation["mutations"]))
140
+ or any(
141
+ not isinstance(item, str)
142
+ or not 0 < len(item) <= 512
143
+ or not item.isascii()
144
+ or not item.isprintable()
145
+ for item in historical_reconciliation["mutations"]
146
+ )
147
+ ):
148
+ raise ValueError(_ERROR)
149
+ residual_recovery = review.get("residual_recovery")
150
+ if residual_recovery is not None and (
151
+ not isinstance(residual_recovery, dict)
152
+ or set(residual_recovery) != _RESIDUAL_RECOVERY_FIELDS
153
+ or residual_recovery.get("operation") != f"{stage}-residual"
154
+ or any(
155
+ not isinstance(residual_recovery.get(field), str)
156
+ or re.fullmatch(r"[0-9a-f]{64}", str(residual_recovery[field])) is None
157
+ for field in ("original_plan_digest", "original_claim_digest")
158
+ )
159
+ ):
160
+ raise ValueError(_ERROR)
161
+ residual_successor = review.get("residual_successor")
162
+ if residual_successor is not None and (
163
+ not isinstance(residual_successor, dict)
164
+ or set(residual_successor) != _RESIDUAL_SUCCESSOR_FIELDS
165
+ or residual_successor.get("operation") != f"{stage}-residual-successor"
166
+ or any(
167
+ not isinstance(residual_successor.get(field), str)
168
+ or re.fullmatch(r"[0-9a-f]{64}", str(residual_successor[field])) is None
169
+ for field in (
170
+ "original_claim_digest",
171
+ "residual_plan_digest",
172
+ "residual_claim_digest",
173
+ )
174
+ )
175
+ ):
176
+ raise ValueError(_ERROR)
177
+ for key, length in (
178
+ ("plan_digest", 64),
179
+ ("review_digest", 64),
180
+ ("target_binding", 64),
181
+ ("source_commit", 40),
182
+ ):
183
+ value = review.get(key)
184
+ if not isinstance(value, str) or re.fullmatch(rf"[0-9a-f]{{{length}}}", value) is None:
185
+ raise ValueError(_ERROR)
186
+ if "runtime_profile_digest" in review or "runtime_platform" in review:
187
+ runtime_profile_digest = review.get("runtime_profile_digest")
188
+ if (
189
+ not isinstance(runtime_profile_digest, str)
190
+ or re.fullmatch(r"[0-9a-f]{64}", runtime_profile_digest) is None
191
+ or review.get("runtime_platform") not in {"aks", "container-apps"}
192
+ ):
193
+ raise ValueError(_ERROR)
194
+ counts = summary.get("action_counts")
195
+ if (
196
+ not isinstance(counts, dict)
197
+ or not counts
198
+ or not set(counts).issubset(_ACTIONS)
199
+ or any(type(count) is not int or not 0 <= count <= 5000 for count in counts.values())
200
+ ):
201
+ raise ValueError(_ERROR)
202
+ types = summary.get("resource_type_counts", {})
203
+ if (
204
+ not isinstance(types, dict)
205
+ or len(types) > 5000
206
+ or any(
207
+ not isinstance(name, str)
208
+ or re.fullmatch(r"[a-z][a-z0-9_]{0,127}", name) is None
209
+ or type(count) is not int
210
+ or not 0 <= count <= 5000
211
+ for name, count in types.items()
212
+ )
213
+ ):
214
+ raise ValueError(_ERROR)
215
+ changes = summary.get("resource_changes", [])
216
+ if not isinstance(changes, list) or len(changes) > 5000:
217
+ raise ValueError(_ERROR)
218
+ for change in changes:
219
+ if not isinstance(change, dict) or set(change) != {"address", "actions"}:
220
+ raise ValueError(_ERROR)
221
+ address, actions = change["address"], change["actions"]
222
+ if (
223
+ not isinstance(address, str)
224
+ or not 0 < len(address) <= 512
225
+ or not address.isascii()
226
+ or not address.isprintable()
227
+ or not isinstance(actions, list)
228
+ or not actions
229
+ or any(not isinstance(action, str) or action not in _ACTIONS for action in actions)
230
+ ):
231
+ raise ValueError(_ERROR)
232
+ expiry = review.get("expires_at")
233
+ if not isinstance(expiry, str):
234
+ raise ValueError(_ERROR) # noqa: TRY004 - public validator contract
235
+ try:
236
+ expires = datetime.fromisoformat(expiry)
237
+ except ValueError as exc:
238
+ raise ValueError(_ERROR) from exc
239
+ if expires.tzinfo is None or (not allow_expired and expires <= datetime.now(UTC)):
240
+ raise ValueError(_ERROR)
241
+ document = {key: value for key, value in review.items() if key != "review_digest"}
242
+ if review["review_digest"] != canonical_digest(document):
243
+ raise ValueError(_ERROR)
244
+ return stage, int(counts.get("delete", 0)) + int(counts.get("replace", 0))
245
+
246
+
247
+ def _operation_matches_stage(stage: str, operation: object, service_update: object) -> bool:
248
+ """Bind the managed host's operation name to the reviewed stage or service update."""
249
+
250
+ if not isinstance(operation, str):
251
+ return False
252
+ if isinstance(service_update, dict):
253
+ service = str(service_update["service"])
254
+ return (
255
+ re.fullmatch(rf"service-update-{re.escape(service)}-[0-9a-f]{{12}}", operation)
256
+ is not None
257
+ )
258
+ if stage == "runtime":
259
+ return operation in {"runtime", "runtime-cluster"}
260
+ if stage == "application":
261
+ return operation in {
262
+ "application",
263
+ "application-input-rebind-update",
264
+ "license-application-update",
265
+ }
266
+ return operation == stage