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,264 @@
1
+ """Exact Terraform import, state-release, and rollback contract for Hub workload handoff."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Mapping, Sequence
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from fdai_deployment_cli.contracts import canonical_bytes, canonical_digest
11
+ from fdai_deployment_cli.hub_workload_handoff_inputs import (
12
+ INVENTORY_SCHEMA,
13
+ WorkloadStateObject,
14
+ build_handoff_inventory,
15
+ read_handoff_inventory,
16
+ write_handoff_operator_inputs,
17
+ )
18
+ from fdai_deployment_cli.private_output import write_private_bytes
19
+
20
+ TRANSITION_SCHEMA = "fdai.hub-workload-state-transition.v1"
21
+ RECEIPT_SCHEMA = "fdai.hub-workload-state-transition-receipt.v1"
22
+ ADOPTION_RELEASE_SCHEMA = "fdai.runtime-adoption-release.v1"
23
+ PLAN_RECEIPT_SCHEMA = "fdai.hub-workload-plan-verification.v1"
24
+ _DIGEST = re.compile(r"sha256:[0-9a-f]{64}\Z", re.ASCII)
25
+
26
+ __all__ = [
27
+ "WorkloadStateObject",
28
+ "build_handoff_inventory",
29
+ "read_handoff_inventory",
30
+ "verify_handoff_plan",
31
+ "verify_handoff_plans",
32
+ "write_handoff_operator_inputs",
33
+ "write_handoff_terraform_files",
34
+ ]
35
+
36
+
37
+ def verify_handoff_plan(
38
+ plan: Mapping[str, object],
39
+ objects: Sequence[WorkloadStateObject],
40
+ *,
41
+ phase: str,
42
+ inventory_digest: str,
43
+ ) -> dict[str, object]:
44
+ """Verify an exact import, forget-only release, or post-import zero-change plan."""
45
+
46
+ if (
47
+ phase not in {"import", "release", "zero-change"}
48
+ or _DIGEST.fullmatch(inventory_digest) is None
49
+ ):
50
+ raise ValueError("Hub handoff plan verification inputs are invalid")
51
+ expected = {item.address: item for item in objects}
52
+ if not expected or len(expected) != len(objects):
53
+ raise ValueError("Hub handoff plan objects must be unique")
54
+ _plan_header(plan, applyable=phase != "zero-change")
55
+ changes = _plan_changes(plan)
56
+ if set(changes) != set(expected):
57
+ raise ValueError(f"Hub handoff {phase} plan inventory differs")
58
+ for address, change in changes.items():
59
+ actions = change.get("actions")
60
+ importing = change.get("importing")
61
+ if phase == "import":
62
+ before = change.get("before")
63
+ if (
64
+ actions != ["no-op"]
65
+ or not isinstance(importing, Mapping)
66
+ or importing.get("id") != expected[address].import_id
67
+ or (before is not None and before != change.get("after"))
68
+ ):
69
+ raise ValueError("Hub handoff import plan is not exact import-only")
70
+ elif phase == "release":
71
+ if (
72
+ actions != ["forget"]
73
+ or importing is not None
74
+ or change.get("before") is None
75
+ or change.get("after") is not None
76
+ ):
77
+ raise ValueError("Hub handoff release plan is not exact forget-only")
78
+ elif (
79
+ actions != ["no-op"]
80
+ or importing is not None
81
+ or "before" not in change
82
+ or change.get("before") != change.get("after")
83
+ ):
84
+ raise ValueError("Hub handoff plan is not zero-change")
85
+ body: dict[str, object] = {
86
+ "schema_version": PLAN_RECEIPT_SCHEMA,
87
+ "phase": phase,
88
+ "inventory_digest": inventory_digest,
89
+ "plan_digest": "sha256:" + canonical_digest(plan),
90
+ "object_count": len(expected),
91
+ "live_mutation_authorized": False,
92
+ "state_effect": {
93
+ "import": "import-only",
94
+ "release": "forgotten-without-destroy",
95
+ "zero-change": "zero-change",
96
+ }[phase],
97
+ }
98
+ return body | {"receipt_digest": "sha256:" + canonical_digest(body)}
99
+
100
+
101
+ def write_handoff_terraform_files(
102
+ destination: Path,
103
+ objects: Sequence[WorkloadStateObject],
104
+ ) -> tuple[Path, Path]:
105
+ """Write exact removed/import blocks into a fresh private execution directory."""
106
+
107
+ if destination.exists() or destination.is_symlink():
108
+ raise ValueError("Hub handoff Terraform destination already exists")
109
+ by_address = {item.address: item for item in objects}
110
+ if not by_address or len(by_address) != len(objects):
111
+ raise ValueError("Hub handoff Terraform objects must be unique")
112
+ destination.mkdir(mode=0o700, parents=True)
113
+ release_path = destination / "hub-release.tf.json"
114
+ rollback_path = destination / "hub-rollback.tf.json"
115
+ write_private_bytes(
116
+ release_path,
117
+ canonical_bytes(
118
+ {
119
+ "removed": [
120
+ {
121
+ "from": f"${{{address}}}",
122
+ "lifecycle": {"destroy": False},
123
+ }
124
+ for address in sorted(by_address)
125
+ ]
126
+ }
127
+ )
128
+ + b"\n",
129
+ )
130
+ write_private_bytes(
131
+ rollback_path,
132
+ canonical_bytes(
133
+ {
134
+ "import": [
135
+ {
136
+ "to": f"${{{address}}}",
137
+ "id": by_address[address].import_id,
138
+ }
139
+ for address in sorted(by_address)
140
+ ]
141
+ }
142
+ )
143
+ + b"\n",
144
+ )
145
+ return release_path, rollback_path
146
+
147
+
148
+ def verify_handoff_plans(
149
+ release_plan: Mapping[str, object],
150
+ rollback_plan: Mapping[str, object],
151
+ objects: Sequence[WorkloadStateObject],
152
+ *,
153
+ adoption_release: Mapping[str, object],
154
+ ) -> dict[str, object]:
155
+ """Require exact forget-only release and zero-change rollback plans."""
156
+
157
+ adoption_release_digest = _adoption_release_digest(adoption_release)
158
+ inventory_body = {
159
+ "schema_version": INVENTORY_SCHEMA,
160
+ "objects": [
161
+ {
162
+ "address": item.address,
163
+ "import_id": item.import_id,
164
+ "render_digest": item.render_digest,
165
+ }
166
+ for item in sorted(objects, key=lambda value: value.address)
167
+ ],
168
+ }
169
+ inventory_digest = "sha256:" + canonical_digest(inventory_body)
170
+ release_verification = verify_handoff_plan(
171
+ release_plan,
172
+ objects,
173
+ phase="release",
174
+ inventory_digest=inventory_digest,
175
+ )
176
+ rollback_verification = verify_handoff_plan(
177
+ rollback_plan,
178
+ objects,
179
+ phase="zero-change",
180
+ inventory_digest=inventory_digest,
181
+ )
182
+ body: dict[str, object] = {
183
+ "schema_version": RECEIPT_SCHEMA,
184
+ "objects": [
185
+ {
186
+ "address": item.address,
187
+ "import_id": item.import_id,
188
+ "render_digest": item.render_digest,
189
+ }
190
+ for item in sorted(objects, key=lambda value: value.address)
191
+ ],
192
+ "release_plan_digest": "sha256:" + canonical_digest(release_plan),
193
+ "rollback_plan_digest": "sha256:" + canonical_digest(rollback_plan),
194
+ "release_verification_digest": release_verification["receipt_digest"],
195
+ "rollback_verification_digest": rollback_verification["receipt_digest"],
196
+ "adoption_release_receipt_digest": adoption_release_digest,
197
+ "release_state": "forgotten-without-destroy",
198
+ "rollback_state": "zero-change",
199
+ }
200
+ return body | {"receipt_digest": "sha256:" + canonical_digest(body)}
201
+
202
+
203
+ def _adoption_release_digest(receipt: Mapping[str, object]) -> str:
204
+ digest = receipt.get("receipt_digest")
205
+ body = {key: value for key, value in receipt.items() if key != "receipt_digest"}
206
+ if (
207
+ body.get("schema_version") != ADOPTION_RELEASE_SCHEMA
208
+ or body.get("reconciliation_released") is not True
209
+ or not isinstance(body.get("installation_id"), str)
210
+ or not isinstance(body.get("release_id"), str)
211
+ or _DIGEST.fullmatch(str(body.get("adoption_receipt_digest"))) is None
212
+ or not isinstance(digest, str)
213
+ or digest != "sha256:" + canonical_digest(body)
214
+ ):
215
+ raise ValueError("Agent reconciliation release receipt is invalid")
216
+ return digest
217
+
218
+
219
+ def _plan_header(plan: Mapping[str, object], *, applyable: bool) -> None:
220
+ if (
221
+ plan.get("format_version") not in {"1.1", "1.2"}
222
+ or plan.get("complete") is not True
223
+ or plan.get("errored") is not False
224
+ or plan.get("applyable") is not applyable
225
+ or plan.get("resource_drift", []) != []
226
+ or plan.get("deferred_changes", []) != []
227
+ ):
228
+ raise ValueError("Hub handoff plan metadata is invalid")
229
+ output_changes = plan.get("output_changes", {})
230
+ if not isinstance(output_changes, Mapping):
231
+ raise ValueError( # noqa: TRY004 - normalize untrusted plan JSON
232
+ "Hub handoff plan outputs are invalid"
233
+ )
234
+ if phase_changes := [
235
+ value
236
+ for value in output_changes.values()
237
+ if not isinstance(value, Mapping) or value.get("actions") != ["no-op"]
238
+ ]:
239
+ raise ValueError(f"Hub handoff plan contains {len(phase_changes)} output changes")
240
+ checks = plan.get("checks", [])
241
+ if not isinstance(checks, list) or any(
242
+ not isinstance(check, Mapping) or check.get("status") != "pass" for check in checks
243
+ ):
244
+ raise ValueError("Hub handoff plan checks must pass")
245
+
246
+
247
+ def _plan_changes(plan: Mapping[str, object]) -> dict[str, dict[str, Any]]:
248
+ changes = plan.get("resource_changes")
249
+ if not isinstance(changes, list):
250
+ raise ValueError( # noqa: TRY004 - normalize untrusted plan JSON
251
+ "Terraform plan resource changes are invalid"
252
+ )
253
+ result: dict[str, dict[str, Any]] = {}
254
+ for item in changes:
255
+ if (
256
+ not isinstance(item, Mapping)
257
+ or not isinstance(address := item.get("address"), str)
258
+ or item.get("previous_address") is not None
259
+ or not isinstance(change := item.get("change"), Mapping)
260
+ or address in result
261
+ ):
262
+ raise ValueError("Terraform plan resource change is invalid")
263
+ result[address] = dict(change)
264
+ return result
@@ -0,0 +1,227 @@
1
+ """Private full-topology inputs for the Hub workload Terraform state handoff."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Mapping, Sequence
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from fdai_deployment_cli.contracts import canonical_bytes, canonical_digest
11
+ from fdai_deployment_cli.private_output import write_private_bytes
12
+
13
+ INVENTORY_SCHEMA = "fdai.hub-workload-state-inventory.v1"
14
+ _ADDRESS = re.compile(
15
+ r"kubernetes_(?:deployment_v1|service_v1|cron_job_v1|horizontal_pod_autoscaler_v2)"
16
+ r"\.[a-z][a-z0-9_]*(?:\[[\"'][a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?[\"']\])?\Z",
17
+ re.ASCII,
18
+ )
19
+ _IMPORT_ID = re.compile(r"[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?/[A-Za-z0-9._/-]{1,253}\Z")
20
+ _DIGEST = re.compile(r"sha256:[0-9a-f]{64}\Z", re.ASCII)
21
+ _KIND_RESOURCES = {
22
+ "Deployment": ("kubernetes_deployment_v1", "workload", "deployments"),
23
+ "Service": ("kubernetes_service_v1", "workload", "services"),
24
+ "CronJob": ("kubernetes_cron_job_v1", "job", "cron_jobs"),
25
+ "HorizontalPodAutoscaler": (
26
+ "kubernetes_horizontal_pod_autoscaler_v2",
27
+ "workload",
28
+ "horizontal_pod_autoscalers",
29
+ ),
30
+ }
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class WorkloadStateObject:
35
+ address: str
36
+ import_id: str
37
+ render_digest: str
38
+
39
+ def __post_init__(self) -> None:
40
+ if (
41
+ _ADDRESS.fullmatch(self.address) is None
42
+ or _IMPORT_ID.fullmatch(self.import_id) is None
43
+ or _DIGEST.fullmatch(self.render_digest) is None
44
+ ):
45
+ raise ValueError("Hub workload state object is invalid")
46
+
47
+
48
+ def build_handoff_inventory(
49
+ native_scope: Mapping[str, object],
50
+ rendered_manifests: Sequence[Mapping[str, object]],
51
+ ) -> dict[str, object]:
52
+ """Bind one exact native scope to Terraform addresses, import IDs, and render digests."""
53
+
54
+ namespace = native_scope.get("namespace")
55
+ if not isinstance(namespace, str):
56
+ raise ValueError( # noqa: TRY004 - normalize untrusted JSON into one operator error
57
+ "Hub workload native scope namespace is invalid"
58
+ )
59
+ expected = _native_resources(native_scope)
60
+ objects: list[WorkloadStateObject] = []
61
+ actual: set[tuple[str, str]] = set()
62
+ tfvars: dict[str, dict[str, str]] = {
63
+ "deployments": {},
64
+ "services": {},
65
+ "cron_jobs": {},
66
+ "horizontal_pod_autoscalers": {},
67
+ }
68
+ for manifest in rendered_manifests:
69
+ kind = manifest.get("kind")
70
+ metadata = manifest.get("metadata")
71
+ if (
72
+ not isinstance(kind, str)
73
+ or kind not in _KIND_RESOURCES
74
+ or not isinstance(metadata, Mapping)
75
+ or not isinstance(name := metadata.get("name"), str)
76
+ or metadata.get("namespace") != namespace
77
+ ):
78
+ raise ValueError("Hub workload rendered manifest is invalid")
79
+ key = (kind, name)
80
+ if key in actual:
81
+ raise ValueError("Hub workload rendered manifests must be unique")
82
+ actual.add(key)
83
+ resource_type, resource_name, variable_name = _KIND_RESOURCES[kind]
84
+ address = f'{resource_type}.{resource_name}["{name}"]'
85
+ import_id = f"{namespace}/{name}"
86
+ objects.append(
87
+ WorkloadStateObject(
88
+ address=address,
89
+ import_id=import_id,
90
+ render_digest="sha256:" + canonical_digest(manifest),
91
+ )
92
+ )
93
+ tfvars[variable_name][name] = import_id
94
+ if actual != expected:
95
+ raise ValueError("Hub workload rendered inventory differs from the native scope")
96
+ ordered = sorted(objects, key=lambda item: item.address)
97
+ body: dict[str, object] = {
98
+ "schema_version": INVENTORY_SCHEMA,
99
+ "namespace": namespace,
100
+ "objects": [
101
+ {
102
+ "address": item.address,
103
+ "import_id": item.import_id,
104
+ "render_digest": item.render_digest,
105
+ }
106
+ for item in ordered
107
+ ],
108
+ "terraform_variables": {
109
+ "namespace": namespace,
110
+ **{key: dict(sorted(value.items())) for key, value in tfvars.items()},
111
+ },
112
+ }
113
+ return body | {"inventory_digest": "sha256:" + canonical_digest(body)}
114
+
115
+
116
+ def write_handoff_operator_inputs(
117
+ destination: Path,
118
+ native_scope: Mapping[str, object],
119
+ rendered_manifests: Sequence[Mapping[str, object]],
120
+ ) -> tuple[Path, Path, Path]:
121
+ """Write exact Terraform variables, verified inventory, and phase commands."""
122
+
123
+ inventory = build_handoff_inventory(native_scope, rendered_manifests)
124
+ inventory_path = destination / "workload-handoff-inventory.json"
125
+ tfvars_path = destination / "workload-handoff.tfvars.json"
126
+ commands_path = destination / "workload-handoff-commands.txt"
127
+ write_private_bytes(inventory_path, canonical_bytes(inventory) + b"\n")
128
+ write_private_bytes(
129
+ tfvars_path,
130
+ canonical_bytes(inventory["terraform_variables"]) + b"\n",
131
+ )
132
+ write_private_bytes(commands_path, _commands().encode())
133
+ return inventory_path, tfvars_path, commands_path
134
+
135
+
136
+ def read_handoff_inventory(document: Mapping[str, object]) -> tuple[WorkloadStateObject, ...]:
137
+ """Verify and load a generated handoff inventory without trusting its Terraform variables."""
138
+
139
+ digest = document.get("inventory_digest")
140
+ body = {key: value for key, value in document.items() if key != "inventory_digest"}
141
+ objects = body.get("objects")
142
+ if (
143
+ body.get("schema_version") != INVENTORY_SCHEMA
144
+ or not isinstance(body.get("namespace"), str)
145
+ or not isinstance(objects, list)
146
+ or not objects
147
+ or not isinstance(digest, str)
148
+ or digest != "sha256:" + canonical_digest(body)
149
+ ):
150
+ raise ValueError("Hub workload state inventory is invalid")
151
+ result: list[WorkloadStateObject] = []
152
+ for item in objects:
153
+ if not isinstance(item, Mapping):
154
+ raise ValueError( # noqa: TRY004 - normalize untrusted JSON into one operator error
155
+ "Hub workload state inventory is invalid"
156
+ )
157
+ result.append(
158
+ WorkloadStateObject(
159
+ address=str(item.get("address", "")),
160
+ import_id=str(item.get("import_id", "")),
161
+ render_digest=str(item.get("render_digest", "")),
162
+ )
163
+ )
164
+ if len({item.address for item in result}) != len(result):
165
+ raise ValueError("Hub workload state inventory is invalid")
166
+ return tuple(result)
167
+
168
+
169
+ def _native_resources(scope: Mapping[str, object]) -> set[tuple[str, str]]:
170
+ components = _names(scope.get("components"), required=True)
171
+ services = _names(scope.get("service_components"), required=False)
172
+ schedules = _names(scope.get("scheduled_components"), required=False)
173
+ autoscalers = _names(scope.get("autoscaled_components"), required=False)
174
+ if not services and scope.get("service_components") is None:
175
+ services = components
176
+ if not services <= components or not autoscalers <= components or components & schedules:
177
+ raise ValueError("Hub workload native scope is invalid")
178
+ return (
179
+ {("Deployment", name) for name in components}
180
+ | {("Service", name) for name in services}
181
+ | {("CronJob", name) for name in schedules}
182
+ | {("HorizontalPodAutoscaler", name) for name in autoscalers}
183
+ )
184
+
185
+
186
+ def _names(value: object, *, required: bool) -> set[str]:
187
+ if value is None and not required:
188
+ return set()
189
+ if (
190
+ not isinstance(value, (list, tuple))
191
+ or (required and not value)
192
+ or any(not isinstance(item, str) for item in value)
193
+ or len(set(value)) != len(value)
194
+ ):
195
+ raise ValueError("Hub workload native scope is invalid")
196
+ return set(value)
197
+
198
+
199
+ def _commands() -> str:
200
+ return """\
201
+ # Replace angle-bracket values with private operator paths. Reuse one absolute backend path.
202
+ umask 077
203
+ terraform -chdir=infra/hub-workload-state-handoff/import init -reconfigure -backend-config=path=<absolute-private-state-path>
204
+ terraform -chdir=infra/hub-workload-state-handoff/import plan -var-file=<workload-handoff.tfvars.json> -var=kubeconfig_path=<private-kubeconfig> -var=kubeconfig_context=<kube-context> -out=<import-plan>
205
+ terraform show -json <import-plan> > <import-plan.json>
206
+ fdai-lifecycle-manager verify-workload-handoff-plan --phase import --inventory <workload-handoff-inventory.json> --plan-json <import-plan.json> --destination <import-verification.json>
207
+ terraform -chdir=infra/hub-workload-state-handoff/import apply <import-plan>
208
+ terraform -chdir=infra/hub-workload-state-handoff/import plan -detailed-exitcode -var-file=<workload-handoff.tfvars.json> -var=kubeconfig_path=<private-kubeconfig> -var=kubeconfig_context=<kube-context> -out=<import-zero-plan>
209
+ terraform show -json <import-zero-plan> > <import-zero-plan.json>
210
+ fdai-lifecycle-manager verify-workload-handoff-plan --phase zero-change --inventory <workload-handoff-inventory.json> --plan-json <import-zero-plan.json> --destination <import-zero-verification.json>
211
+
212
+ terraform -chdir=infra/hub-workload-state-handoff/release init -reconfigure -backend-config=path=<absolute-private-state-path>
213
+ terraform -chdir=infra/hub-workload-state-handoff/release plan -var-file=<workload-handoff.tfvars.json> -var=kubeconfig_path=<private-kubeconfig> -var=kubeconfig_context=<kube-context> -out=<release-plan>
214
+ terraform show -json <release-plan> > <release-plan.json>
215
+ fdai-lifecycle-manager verify-workload-handoff-plan --phase release --inventory <workload-handoff-inventory.json> --plan-json <release-plan.json> --destination <release-verification.json>
216
+ terraform -chdir=infra/hub-workload-state-handoff/release apply <release-plan>
217
+
218
+ # Rollback only after the Agent reconciliation-release receipt is retained.
219
+ terraform -chdir=infra/hub-workload-state-handoff/import init -reconfigure -backend-config=path=<absolute-private-state-path>
220
+ terraform -chdir=infra/hub-workload-state-handoff/import plan -var-file=<workload-handoff.tfvars.json> -var=kubeconfig_path=<private-kubeconfig> -var=kubeconfig_context=<kube-context> -out=<rollback-import-plan>
221
+ terraform show -json <rollback-import-plan> > <rollback-import-plan.json>
222
+ fdai-lifecycle-manager verify-workload-handoff-plan --phase import --inventory <workload-handoff-inventory.json> --plan-json <rollback-import-plan.json> --destination <rollback-import-verification.json>
223
+ terraform -chdir=infra/hub-workload-state-handoff/import apply <rollback-import-plan>
224
+ terraform -chdir=infra/hub-workload-state-handoff/import plan -detailed-exitcode -var-file=<workload-handoff.tfvars.json> -var=kubeconfig_path=<private-kubeconfig> -var=kubeconfig_context=<kube-context> -out=<rollback-zero-plan>
225
+ terraform show -json <rollback-zero-plan> > <rollback-zero-plan.json>
226
+ fdai-lifecycle-manager verify-workload-handoff-plan --phase zero-change --inventory <workload-handoff-inventory.json> --plan-json <rollback-zero-plan.json> --destination <rollback-zero-verification.json>
227
+ """