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,977 @@
1
+ """Control standalone managed-host application deployment through Azure Bastion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import json
7
+ import os
8
+ import re
9
+ import subprocess
10
+ import sys
11
+ import time
12
+ from datetime import UTC, datetime, timedelta
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from fdai_deployment_cli import azure_actor, bootstrap_approval, standalone_catalog_checkpoint
17
+ from fdai_deployment_cli.application_state_adoption import ApplicationStateAdoption
18
+ from fdai_deployment_cli.catalog_review_profile import (
19
+ CatalogReviewDeploymentProfile,
20
+ )
21
+ from fdai_deployment_cli.contracts import canonical_digest
22
+ from fdai_deployment_cli.control_package import ControlPackage
23
+ from fdai_deployment_cli.deadline_transport import DeadlineTransport
24
+ from fdai_deployment_cli.deployment_deadline import DeploymentDeadline
25
+ from fdai_deployment_cli.deployment_kit import DeploymentKit, archive_verified_kit
26
+ from fdai_deployment_cli.deployment_progress import begin_stage, progress_detail, terminal_output
27
+ from fdai_deployment_cli.development_authority_profile import (
28
+ DevelopmentAuthorityDeploymentProfile,
29
+ )
30
+ from fdai_deployment_cli.hub_image_deploy import application_selection, source_binding
31
+ from fdai_deployment_cli.hub_image_trust import HubImageSelection
32
+ from fdai_deployment_cli.license_issue import deployment_license_token as _license_token
33
+ from fdai_deployment_cli.notification_recipients_input import (
34
+ NotificationRecipientsDeploymentInput,
35
+ )
36
+ from fdai_deployment_cli.operational_evidence_verifier_input import (
37
+ OperationalEvidenceVerifierDeploymentInput,
38
+ )
39
+ from fdai_deployment_cli.private_output import read_private_bytes, write_private_output
40
+ from fdai_deployment_cli.runtime_profile import RuntimeDeploymentProfile
41
+ from fdai_deployment_cli.source_application_inputs import (
42
+ build_source_console,
43
+ prepare_source_recovery_host,
44
+ source_transfer_inputs,
45
+ )
46
+ from fdai_deployment_cli.standalone_application_recovery import (
47
+ converge_focused_private_access as _converge_focused_private_access,
48
+ )
49
+ from fdai_deployment_cli.standalone_application_recovery import (
50
+ recover_failed_exact_apply as _recover_failed_exact_apply,
51
+ )
52
+ from fdai_deployment_cli.standalone_approval_input import (
53
+ wait_for_approval_input as _wait_for_approval_input,
54
+ )
55
+ from fdai_deployment_cli.standalone_checkpoint_failure import remote_failure as _remote_failure
56
+ from fdai_deployment_cli.standalone_console_publish import publish_verified_console
57
+ from fdai_deployment_cli.standalone_remote_prepare import prepare_remote as _prepare_remote
58
+ from fdai_deployment_cli.standalone_review import validate_plan_review
59
+ from fdai_deployment_cli.standalone_transfer_cleanup import cleanup_remote_transfers, remote_prune
60
+
61
+ _DIGEST = re.compile(r"[0-9a-f]{64}")
62
+ _SSH_USER = re.compile(r"[a-z_][a-z0-9_-]{0,31}")
63
+ __all__ = ["deploy_standalone_application", "publish_verified_console"]
64
+
65
+
66
+ def deploy_standalone_application(
67
+ *,
68
+ kit: DeploymentKit | None,
69
+ prepared: Any,
70
+ foundation_status: dict[str, Any],
71
+ entra_bindings: dict[str, str] | None,
72
+ scripts: Path,
73
+ trial_token: Path | None,
74
+ timeout_seconds: int,
75
+ runtime_profile: RuntimeDeploymentProfile | None = None,
76
+ application_state_adoption: ApplicationStateAdoption | None = None,
77
+ catalog_review_profile: CatalogReviewDeploymentProfile | None = None,
78
+ operational_evidence_verifier_input: OperationalEvidenceVerifierDeploymentInput | None = None,
79
+ notification_recipients: NotificationRecipientsDeploymentInput | None = None,
80
+ development_authority_profile: DevelopmentAuthorityDeploymentProfile | None = None,
81
+ clear_development_authority_profile: bool = False,
82
+ control_package: ControlPackage | None = None,
83
+ source_snapshot: Path | None = None,
84
+ source_snapshot_digest: str | None = None,
85
+ source_root: Path | None = None,
86
+ foundation_recovery_directory: Path | None = None,
87
+ hub_selection: HubImageSelection | None = None,
88
+ ) -> dict[str, object]:
89
+ """Deploy and independently replan the application without a workflow host."""
90
+
91
+ deadline = DeploymentDeadline(timeout_seconds, clock=time.monotonic)
92
+ selected_runtime = runtime_profile or RuntimeDeploymentProfile.create(
93
+ runtime_platform="container-apps",
94
+ database_placement="postgres-flex",
95
+ )
96
+ source_mode = source_snapshot is not None
97
+ hub_selection = application_selection(hub_selection, source_mode, prepared)
98
+ begin_stage("transfer")
99
+ progress_detail(
100
+ "Verifying the handoff and preparing the source snapshot for Bastion transfer"
101
+ if source_mode
102
+ else "Verifying the handoff and preparing the signed kit for Bastion transfer"
103
+ )
104
+ report = _mapping(foundation_status.get("foundation_report"), "Foundation report")
105
+ if foundation_recovery_directory is None:
106
+ plan = _mapping(report.get("foundation_plan"), "Foundation plan")
107
+ plan_directory = prepared.root / str(plan["plan_ref"])
108
+ handoff_path = plan_directory / "foundation-private-handoff.json"
109
+ else:
110
+ plan_directory = foundation_recovery_directory.absolute()
111
+ if (
112
+ not source_mode
113
+ or plan_directory.parent != prepared.root
114
+ or re.fullmatch(r"foundation-recovery-attempt-[1-9][0-9]*", plan_directory.name) is None
115
+ or not plan_directory.is_dir()
116
+ or plan_directory.is_symlink()
117
+ ):
118
+ raise ValueError("source application recovery directory is invalid")
119
+ handoff_path = plan_directory / "recovery-private-handoff.json"
120
+ handoff = _private_json(handoff_path, "Foundation handoff")
121
+ adoption_descriptor_digest = (
122
+ canonical_digest(
123
+ _private_json(application_state_adoption.descriptor, "adoption descriptor")
124
+ )
125
+ if application_state_adoption is not None
126
+ else ""
127
+ )
128
+ runner = _mapping(handoff.get("runner"), "Foundation runner")
129
+ access = _mapping(handoff.get("access"), "Foundation access")
130
+ ops = _mapping(handoff.get("ops"), "Foundation operations")
131
+ known_hosts = plan_directory / "runner-known-hosts"
132
+ module = _import_bastion(scripts)
133
+ module.validate_known_hosts(known_hosts)
134
+ private_key = prepared.ssh_private_key
135
+ if module.validate_ssh_private_key(private_key) != runner.get("ssh_key_digest"):
136
+ raise ValueError("standalone host SSH key differs from Foundation evidence")
137
+ source_transfer = None
138
+ recovery_host: Path | None = None
139
+ recovery_host_digest: str | None = None
140
+ transient_paths: tuple[str, ...]
141
+ if source_mode:
142
+ if (
143
+ kit is not None
144
+ or source_snapshot_digest is None
145
+ or source_snapshot is None
146
+ or source_root is None
147
+ ):
148
+ raise ValueError("source application continuation inputs are incomplete")
149
+ source_transfer = source_transfer_inputs(
150
+ prepared_root=prepared.root,
151
+ source_snapshot=source_snapshot,
152
+ source_snapshot_digest=source_snapshot_digest,
153
+ )
154
+ if foundation_recovery_directory is not None:
155
+ recovery_host, recovery_host_digest = prepare_source_recovery_host(
156
+ source_root=source_root,
157
+ prepared_root=prepared.root,
158
+ )
159
+ transport_archive = source_transfer.archive
160
+ archive_digest = None
161
+ else:
162
+ if kit is None:
163
+ raise ValueError("standalone application requires a verified kit or source snapshot")
164
+ transport_archive = prepared.root / "standalone-kit.tar.gz"
165
+ archive_digest = archive_verified_kit(kit, transport_archive)
166
+ entra_path = prepared.root / "entra-bindings.json" if entra_bindings is not None else None
167
+ if entra_path is not None and entra_bindings is not None:
168
+ _replace_private_json(entra_path, entra_bindings)
169
+ work_binding: dict[str, object] = {
170
+ "target_binding": prepared.target_binding,
171
+ "source_commit": prepared.source_commit,
172
+ "runtime_profile_digest": selected_runtime.digest,
173
+ }
174
+ if source_mode:
175
+ work_binding.update(source_binding(source_snapshot_digest, hub_selection))
176
+ else:
177
+ work_binding["kit_manifest_digest"] = prepared.kit_manifest_digest
178
+ if control_package is not None:
179
+ work_binding["control_package_digest"] = control_package.archive_digest
180
+ work_ref = canonical_digest(work_binding)[:24]
181
+ username = str(runner["admin_username"])
182
+ if _SSH_USER.fullmatch(username) is None:
183
+ raise ValueError("Foundation runner SSH username is invalid")
184
+ remote_root = f"/home/{username}/.fdai-transfer-{work_ref}"
185
+ remote_archive = f"{remote_root}/kit.tar.gz"
186
+ remote_handoff = f"{remote_root}/foundation-handoff.json"
187
+ remote_entra = f"{remote_root}/entra-bindings.json" if entra_path is not None else None
188
+ remote_approval = f"{remote_root}/approval.json"
189
+ remote_adoption_state = f"{remote_root}/application-state.json"
190
+ remote_adoption_models = f"{remote_root}/resolved-models.json"
191
+ remote_adoption_descriptor = f"{remote_root}/application-state-adoption.json"
192
+ app_work = f"{remote_root}/application"
193
+ host_alias = module.stable_host_key_alias(str(runner["vm_id"]))
194
+ with module.BastionTunnel(
195
+ subscription_id=str(handoff["subscription_id"]),
196
+ resource_group=str(ops["resource_group_name"]),
197
+ bastion_name=str(access["bastion_name"]),
198
+ vm_id=str(runner["vm_id"]),
199
+ username=username,
200
+ private_key=private_key,
201
+ known_hosts=known_hosts,
202
+ host_key_alias=host_alias,
203
+ cwd=plan_directory,
204
+ timeout=deadline.remaining(),
205
+ trust_new_host_key=False,
206
+ ) as underlying_tunnel:
207
+ tunnel = DeadlineTransport(underlying_tunnel, deadline)
208
+ host_preparation = _prepare_remote(
209
+ tunnel,
210
+ remote_root=remote_root,
211
+ remote_archive=remote_archive,
212
+ archive=None if source_mode else transport_archive,
213
+ archive_digest=archive_digest,
214
+ handoff_path=handoff_path,
215
+ remote_handoff=remote_handoff,
216
+ entra_path=entra_path,
217
+ remote_entra=remote_entra,
218
+ app_work=app_work,
219
+ source_archive=transport_archive if source_mode else None,
220
+ source_archive_digest=(
221
+ source_transfer.archive_digest if source_transfer is not None else None
222
+ ),
223
+ source_receiver=source_transfer.receiver if source_transfer is not None else None,
224
+ source_receiver_digest=(
225
+ source_transfer.receiver_digest if source_transfer is not None else None
226
+ ),
227
+ source_snapshot_digest=source_snapshot_digest if source_mode else None,
228
+ source_runtime_support=(
229
+ source_transfer.runtime_support if source_transfer is not None else None
230
+ ),
231
+ source_runtime_support_digest=(
232
+ source_transfer.runtime_support_digest if source_transfer is not None else None
233
+ ),
234
+ **({"hub_selection": hub_selection} if hub_selection is not None else {}),
235
+ runtime_profile=selected_runtime,
236
+ application_state_adoption=application_state_adoption,
237
+ remote_adoption_state=remote_adoption_state,
238
+ remote_adoption_models=remote_adoption_models,
239
+ remote_adoption_descriptor=remote_adoption_descriptor,
240
+ timeout_seconds=deadline.remaining(),
241
+ catalog_review_profile=(
242
+ catalog_review_profile or CatalogReviewDeploymentProfile.unselected()
243
+ ),
244
+ operational_evidence_verifier_input=operational_evidence_verifier_input,
245
+ notification_recipients=notification_recipients,
246
+ development_authority_profile=development_authority_profile,
247
+ clear_development_authority_profile=clear_development_authority_profile,
248
+ control_package=control_package,
249
+ )
250
+ if recovery_host is not None:
251
+ assert recovery_host_digest is not None
252
+ remote_recovery_host = f"{remote_root}/source-recovery-host.pyz"
253
+ tunnel.copy_to(recovery_host, remote_recovery_host, timeout=300)
254
+ observed = tunnel.ssh(("sha256sum", remote_recovery_host), timeout=120)
255
+ if (
256
+ observed.returncode != 0
257
+ or observed.stdout.split(maxsplit=1)[0] != recovery_host_digest
258
+ ):
259
+ raise ValueError("source recovery host digest differs")
260
+ tunnel.host_program = remote_recovery_host
261
+ if (
262
+ isinstance(host_preparation, dict)
263
+ and host_preparation.get("focused_private_access") is True
264
+ ):
265
+ progress_detail("Converging the focused private data-plane access path")
266
+ _converge_focused_private_access(
267
+ tunnel,
268
+ remote_root=remote_root,
269
+ app_work=app_work,
270
+ prepared_root=prepared.root,
271
+ remote_approval=remote_approval,
272
+ deadline=deadline,
273
+ remote_json=_remote_json,
274
+ plan_after_recovery=_plan_after_recovery,
275
+ approve_plan=_approve_plan,
276
+ require_receipt=_require_receipt,
277
+ )
278
+ begin_stage("substrate")
279
+ progress_detail("Recovering by verification, or planning private infrastructure")
280
+ substrate_recovery = _remote_json(
281
+ tunnel,
282
+ remote_root,
283
+ app_work,
284
+ ("recover-apply", "--stage", "substrate"),
285
+ timeout=3600,
286
+ )
287
+ if substrate_recovery.get("state") == "applied":
288
+ substrate_receipt = substrate_recovery
289
+ else:
290
+ substrate_plan, substrate_apply_command = _plan_after_recovery(
291
+ tunnel,
292
+ remote_root,
293
+ app_work,
294
+ substrate_recovery,
295
+ stage="substrate",
296
+ timeout=3600,
297
+ )
298
+ if application_state_adoption is not None:
299
+ _require_nondestructive_adoption_plan(substrate_plan)
300
+ deadline.remaining()
301
+ substrate_approval = _approve_plan(prepared.root, substrate_plan, deadline=deadline)
302
+ tunnel.ssh(("rm", "-f", "--", remote_approval), timeout=60)
303
+ tunnel.copy_to(substrate_approval, remote_approval, timeout=120)
304
+ progress_detail("Applying the approved infrastructure plan and verifying its effects")
305
+ try:
306
+ substrate_receipt = _remote_json(
307
+ tunnel,
308
+ remote_root,
309
+ app_work,
310
+ (
311
+ substrate_apply_command,
312
+ "--stage",
313
+ "substrate",
314
+ "--approval",
315
+ remote_approval,
316
+ ),
317
+ timeout=7200,
318
+ )
319
+ except ValueError:
320
+ substrate_approval.unlink(missing_ok=True)
321
+ tunnel.ssh(("rm", "-f", "--", remote_approval), timeout=60)
322
+ private_access = _remote_json(
323
+ tunnel,
324
+ remote_root,
325
+ app_work,
326
+ ("refresh-private-access",),
327
+ timeout=deadline.remaining(300),
328
+ )
329
+ if private_access.get("focused_private_access") is True:
330
+ progress_detail(
331
+ "Converging policy-forced private access before effect recovery"
332
+ )
333
+ _converge_focused_private_access(
334
+ tunnel,
335
+ remote_root=remote_root,
336
+ app_work=app_work,
337
+ prepared_root=prepared.root,
338
+ remote_approval=remote_approval,
339
+ deadline=deadline,
340
+ remote_json=_remote_json,
341
+ plan_after_recovery=_plan_after_recovery,
342
+ approve_plan=_approve_plan,
343
+ require_receipt=_require_receipt,
344
+ )
345
+ progress_detail(
346
+ "Recovering the failed infrastructure apply without repeating its effect"
347
+ )
348
+ substrate_receipt = _recover_failed_exact_apply(
349
+ tunnel,
350
+ remote_root=remote_root,
351
+ app_work=app_work,
352
+ prepared_root=prepared.root,
353
+ remote_approval=remote_approval,
354
+ stage="substrate",
355
+ deadline=deadline,
356
+ remote_json=_remote_json,
357
+ plan_after_recovery=_plan_after_recovery,
358
+ approve_plan=_approve_plan,
359
+ )
360
+ else:
361
+ substrate_approval.unlink(missing_ok=True)
362
+ tunnel.ssh(("rm", "-f", "--", remote_approval), timeout=60)
363
+ _require_receipt(substrate_receipt, "substrate")
364
+ if selected_runtime.runtime_platform.value == "aks":
365
+ begin_stage("runtime")
366
+ progress_detail("Preparing and planning the private AKS runtime")
367
+ _remote_json(
368
+ tunnel,
369
+ remote_root,
370
+ app_work,
371
+ ("prepare-runtime",),
372
+ timeout=1800,
373
+ )
374
+ runtime_receipt: dict[str, Any] | None = None
375
+ for runtime_phase in range(2):
376
+ runtime_recovery = _remote_json(
377
+ tunnel,
378
+ remote_root,
379
+ app_work,
380
+ ("recover-apply", "--stage", "runtime"),
381
+ timeout=3600,
382
+ )
383
+ if runtime_recovery.get("state") == "applied":
384
+ runtime_receipt = runtime_recovery
385
+ else:
386
+ runtime_plan, runtime_apply_command = _plan_after_recovery(
387
+ tunnel,
388
+ remote_root,
389
+ app_work,
390
+ runtime_recovery,
391
+ stage="runtime",
392
+ timeout=3600,
393
+ )
394
+ deadline.remaining()
395
+ runtime_approval = _approve_plan(prepared.root, runtime_plan, deadline=deadline)
396
+ tunnel.ssh(("rm", "-f", "--", remote_approval), timeout=60)
397
+ tunnel.copy_to(runtime_approval, remote_approval, timeout=120)
398
+ runtime_receipt = _remote_json(
399
+ tunnel,
400
+ remote_root,
401
+ app_work,
402
+ (
403
+ runtime_apply_command,
404
+ "--stage",
405
+ "runtime",
406
+ "--approval",
407
+ remote_approval,
408
+ ),
409
+ timeout=7200,
410
+ )
411
+ runtime_approval.unlink(missing_ok=True)
412
+ tunnel.ssh(("rm", "-f", "--", remote_approval), timeout=60)
413
+ _require_receipt(runtime_receipt, "runtime")
414
+ if runtime_receipt.get("operation") != "runtime-cluster":
415
+ break
416
+ if runtime_phase == 1:
417
+ raise ValueError("fresh AKS runtime ordering did not reach the association")
418
+ progress_detail(
419
+ "Planning Container Insights association after AKS cluster creation"
420
+ )
421
+ if runtime_receipt is None:
422
+ raise ValueError("runtime apply receipt is unavailable")
423
+ _require_receipt(runtime_receipt, "runtime")
424
+ if selected_runtime.runtime_platform.value == "aks" and runtime_receipt is None:
425
+ raise ValueError("runtime apply receipt is unavailable")
426
+ runtime_receipt_digest = ""
427
+ if selected_runtime.runtime_platform.value == "aks":
428
+ if runtime_receipt is None:
429
+ raise ValueError("runtime apply receipt is unavailable")
430
+ runtime_receipt_digest = str(runtime_receipt["receipt_digest"])
431
+ binding_receipt = _remote_json(
432
+ tunnel,
433
+ remote_root,
434
+ app_work,
435
+ ("deployment-binding",),
436
+ timeout=300,
437
+ )
438
+ deployment_binding = str(binding_receipt.get("deployment_binding", ""))
439
+ installation_binding = str(binding_receipt.get("installation_binding", ""))
440
+ if (
441
+ binding_receipt.get("terraform_name_verified") is not True
442
+ or re.fullmatch(r"[0-9a-f]{64}", deployment_binding) is None
443
+ or re.fullmatch(r"[0-9a-f]{64}", installation_binding) is None
444
+ ):
445
+ raise ValueError("standalone deployment binding is not verified")
446
+ begin_stage("images")
447
+ progress_detail("Importing runtime images and reading back their digests")
448
+ image_receipt = _remote_json(
449
+ tunnel,
450
+ remote_root,
451
+ app_work,
452
+ ("import-images",),
453
+ timeout=5400,
454
+ )
455
+ image_digests = _mapping(image_receipt.get("image_digests"), "image import receipt")
456
+ core_digest = str(image_digests["core-control-plane"]).removeprefix("sha256:")
457
+ if selected_runtime.database_placement.value == "postgres-aks":
458
+ begin_stage("database")
459
+ progress_detail("Preparing and planning compact in-cluster PostgreSQL")
460
+ _remote_json(
461
+ tunnel,
462
+ remote_root,
463
+ app_work,
464
+ ("prepare-database",),
465
+ timeout=1800,
466
+ )
467
+ database_recovery = _remote_json(
468
+ tunnel,
469
+ remote_root,
470
+ app_work,
471
+ ("recover-apply", "--stage", "database"),
472
+ timeout=3600,
473
+ )
474
+ if database_recovery.get("state") == "applied":
475
+ database_receipt = database_recovery
476
+ else:
477
+ database_plan, database_apply_command = _plan_after_recovery(
478
+ tunnel,
479
+ remote_root,
480
+ app_work,
481
+ database_recovery,
482
+ stage="database",
483
+ timeout=3600,
484
+ )
485
+ deadline.remaining()
486
+ database_approval = _approve_plan(prepared.root, database_plan, deadline=deadline)
487
+ tunnel.ssh(("rm", "-f", "--", remote_approval), timeout=60)
488
+ tunnel.copy_to(database_approval, remote_approval, timeout=120)
489
+ database_receipt = _remote_json(
490
+ tunnel,
491
+ remote_root,
492
+ app_work,
493
+ (
494
+ database_apply_command,
495
+ "--stage",
496
+ "database",
497
+ "--approval",
498
+ remote_approval,
499
+ ),
500
+ timeout=3600,
501
+ )
502
+ database_approval.unlink(missing_ok=True)
503
+ tunnel.ssh(("rm", "-f", "--", remote_approval), timeout=60)
504
+ _require_receipt(database_receipt, "database")
505
+ begin_stage("capability")
506
+ aks = selected_runtime.runtime_platform.value == "aks"
507
+ token = _license_token(
508
+ trial_token=trial_token,
509
+ image_digest=core_digest,
510
+ deployment_binding=deployment_binding,
511
+ installation_binding=installation_binding if aks else None,
512
+ work_ref=work_ref,
513
+ )
514
+ license_mode = "observation-only"
515
+ if token is not None:
516
+ license_receipt = _remote_json(
517
+ tunnel,
518
+ remote_root,
519
+ app_work,
520
+ (
521
+ "install-license",
522
+ "--image-digest",
523
+ core_digest,
524
+ "--deployment-binding",
525
+ deployment_binding,
526
+ *(("--installation-binding", installation_binding) if aks else ()),
527
+ ),
528
+ timeout=300,
529
+ input_text=token,
530
+ )
531
+ token = ""
532
+ if (
533
+ license_receipt.get("secret_metadata_verified") is not True
534
+ or license_receipt.get("secret_content_verified") is not True
535
+ ):
536
+ raise ValueError("standalone license installation was not verified")
537
+ license_mode = "licensed"
538
+ else:
539
+ progress_detail(
540
+ "No capability token; the keyless Trial opens after the application deploys"
541
+ if aks
542
+ else "Observation-only mode; no license secret is installed"
543
+ )
544
+ begin_stage("migration")
545
+ progress_detail("Running database migrations and materializing catalogs")
546
+ migration_receipt = _remote_json(
547
+ tunnel,
548
+ remote_root,
549
+ app_work,
550
+ ("migrate",),
551
+ timeout=5400,
552
+ )
553
+ if (
554
+ migration_receipt.get("state") != "migrated"
555
+ or migration_receipt.get("catalogs_materialized") is not True
556
+ ):
557
+ raise ValueError("standalone database and catalog bootstrap is incomplete")
558
+ begin_stage("application")
559
+ progress_detail("Recovering by verification, or planning the application")
560
+ if selected_runtime.runtime_platform.value == "aks":
561
+ _remote_json(
562
+ tunnel,
563
+ remote_root,
564
+ app_work,
565
+ ("prepare-application",),
566
+ timeout=1800,
567
+ )
568
+ application_recovery = _remote_json(
569
+ tunnel,
570
+ remote_root,
571
+ app_work,
572
+ ("recover-apply", "--stage", "application"),
573
+ timeout=3600,
574
+ )
575
+ if application_recovery.get("state") == "applied":
576
+ application_receipt = application_recovery
577
+ else:
578
+ application_plan, application_apply_command = _plan_after_recovery(
579
+ tunnel,
580
+ remote_root,
581
+ app_work,
582
+ application_recovery,
583
+ stage="application",
584
+ timeout=3600,
585
+ )
586
+ if application_state_adoption is not None:
587
+ _require_nondestructive_adoption_plan(application_plan)
588
+ deadline.remaining()
589
+ application_approval = _approve_plan(prepared.root, application_plan, deadline=deadline)
590
+ tunnel.ssh(("rm", "-f", "--", remote_approval), timeout=60)
591
+ tunnel.copy_to(application_approval, remote_approval, timeout=120)
592
+ progress_detail("Applying the approved application plan and verifying its effects")
593
+ application_receipt = _remote_json(
594
+ tunnel,
595
+ remote_root,
596
+ app_work,
597
+ (
598
+ application_apply_command,
599
+ "--stage",
600
+ "application",
601
+ "--approval",
602
+ remote_approval,
603
+ ),
604
+ timeout=7200,
605
+ )
606
+ application_approval.unlink(missing_ok=True)
607
+ tunnel.ssh(("rm", "-f", "--", remote_approval), timeout=60)
608
+ _require_receipt(application_receipt, "application")
609
+ post_application = standalone_catalog_checkpoint.run_post_application_checkpoints(
610
+ tunnel,
611
+ remote_root=remote_root,
612
+ app_work=app_work,
613
+ runtime_platform=selected_runtime.runtime_platform.value,
614
+ remote_json=_remote_json,
615
+ )
616
+ initial_inventory = post_application.inventory
617
+ catalog_review = post_application.catalog_review
618
+ if license_mode == "observation-only" and post_application.trial_open:
619
+ license_mode = "trial"
620
+ begin_stage("verification")
621
+ progress_detail("Checking service health and a second zero-change Terraform plan")
622
+ verification = _remote_json(
623
+ tunnel,
624
+ remote_root,
625
+ app_work,
626
+ ("verify",),
627
+ timeout=3600,
628
+ )
629
+ if (
630
+ verification.get("terraform_zero_change_verified") is not True
631
+ or verification.get("runtime_health_verified") is not True
632
+ ):
633
+ raise ValueError("standalone application convergence is incomplete")
634
+ console_receipt: dict[str, object] | None = None
635
+ if selected_runtime.console_selected and selected_runtime.runtime_platform.value == "aks":
636
+ if entra_bindings is None:
637
+ raise ValueError("read-only Console requires enterprise identity bindings")
638
+ browser_console = _mapping(
639
+ verification.get("browser_console"), "browser Console verification"
640
+ )
641
+ progress_detail("Registering the Console redirect and publishing verified static files")
642
+ entra = _import_entra(scripts)
643
+ redirect_changed = entra.ensure_console_spa_redirect(
644
+ str(entra_bindings["ENTRA_CONSOLE_SPA_CLIENT_ID"]),
645
+ str(browser_console["console_origin"]),
646
+ )
647
+ if source_mode:
648
+ if source_root is None:
649
+ raise ValueError("source Console build requires a verified snapshot")
650
+ console_archive, console_archive_sha256 = build_source_console(
651
+ source_root=source_root,
652
+ source_commit=prepared.source_commit,
653
+ prepared_root=prepared.root,
654
+ timeout_seconds=deadline.remaining(1800),
655
+ )
656
+ assert source_snapshot is not None
657
+ bundle_root = source_snapshot / "tree"
658
+ else:
659
+ if kit is None:
660
+ raise ValueError("signed Console publication requires the verified kit")
661
+ runtime = kit.runtime.to_mapping()
662
+ console_artifact = _mapping(runtime.get("console"), "runtime Console artifact")
663
+ console_archive = kit.materialized_root / str(console_artifact["archive"])
664
+ console_archive_sha256 = str(console_artifact["archive_sha256"])
665
+ bundle_root = kit.bundle_root
666
+ console_receipt = publish_verified_console(
667
+ console_archive=console_archive,
668
+ console_archive_sha256=console_archive_sha256,
669
+ bundle_root=bundle_root,
670
+ prepared_root=prepared.root,
671
+ entra_bindings=entra_bindings,
672
+ browser_console=browser_console,
673
+ scripts=scripts,
674
+ subscription_id=str(handoff["subscription_id"]),
675
+ tenant_id=str(handoff["tenant_id"]),
676
+ timeout_seconds=deadline.remaining(),
677
+ redirect_changed=redirect_changed,
678
+ )
679
+ if source_mode:
680
+ assert source_transfer is not None
681
+ transient_paths = (
682
+ f"{remote_root}/{source_transfer.archive.name}",
683
+ f"{remote_root}/source-receiver.pyz",
684
+ )
685
+ else:
686
+ transient_paths = (remote_archive,)
687
+ cleanup_remote_transfers(
688
+ tunnel,
689
+ transient_paths=transient_paths,
690
+ remote_approval=remote_approval,
691
+ prune=lambda: remote_prune(tunnel, remote_root=remote_root, work_dir=app_work),
692
+ )
693
+ receipt: dict[str, object] = {
694
+ "schema_version": "fdai.standalone-application-terminal-receipt.v2",
695
+ "state": "application-converged",
696
+ "source_commit": prepared.source_commit,
697
+ "target_binding": prepared.target_binding,
698
+ "substrate_receipt_digest": substrate_receipt["receipt_digest"],
699
+ "initial_inventory_receipt_digest": initial_inventory["receipt_digest"],
700
+ "catalog_review_receipt_digest": catalog_review["receipt_digest"],
701
+ "catalog_review_state": catalog_review["state"],
702
+ "runtime_receipt_digest": runtime_receipt_digest,
703
+ "database_receipt_digest": (
704
+ database_receipt["receipt_digest"]
705
+ if selected_runtime.database_placement.value == "postgres-aks"
706
+ else ""
707
+ ),
708
+ "image_import_receipt_digest": image_receipt["receipt_digest"],
709
+ "migration_receipt_digest": migration_receipt["receipt_digest"],
710
+ "application_receipt_digest": application_receipt["receipt_digest"],
711
+ "verification_receipt_digest": verification["receipt_digest"],
712
+ "console_publication_receipt_digest": (
713
+ console_receipt["receipt_digest"] if console_receipt is not None else ""
714
+ ),
715
+ "runtime_profile_digest": selected_runtime.digest,
716
+ "runtime_platform": selected_runtime.runtime_platform.value,
717
+ "database_placement": selected_runtime.database_placement.value,
718
+ "product_profile": selected_runtime.product_profile.model_dump(mode="json"),
719
+ "remote_transient_cleanup_verified": True,
720
+ "application_state_adopted": application_state_adoption is not None,
721
+ "application_state_adoption_descriptor_digest": adoption_descriptor_digest,
722
+ "application_converged": True,
723
+ "browser_access_verified": console_receipt is not None,
724
+ "deployment_ready": True,
725
+ "inventory_ready": True,
726
+ "license_mode": license_mode,
727
+ "provenance": "operator-selected-source" if source_mode else "signed-release",
728
+ "release_signature_verified": not source_mode,
729
+ "mutation_performed": True,
730
+ "subscription_ready": False,
731
+ }
732
+ if source_mode:
733
+ receipt["source_snapshot_digest"] = source_snapshot_digest
734
+ elif kit is not None:
735
+ kit_verification = getattr(kit, "verification", None)
736
+ kit_runtime = getattr(kit, "runtime", None)
737
+ if kit_verification is not None:
738
+ receipt["kit_manifest_digest"] = kit_verification.manifest_digest
739
+ if kit_runtime is not None:
740
+ receipt["runtime_release_digest"] = kit_runtime.digest
741
+ if hub_selection is not None:
742
+ receipt.update(hub_selection.metadata())
743
+ receipt["receipt_digest"] = canonical_digest(receipt)
744
+ deadline.remaining()
745
+ _replace_private_json(prepared.root / "standalone-application-receipt.json", receipt)
746
+ return receipt
747
+
748
+
749
+ def _plan_after_recovery(
750
+ tunnel: Any,
751
+ remote_root: str,
752
+ app_work: str,
753
+ recovery: dict[str, Any],
754
+ *,
755
+ stage: str,
756
+ timeout: int,
757
+ ) -> tuple[dict[str, Any], str]:
758
+ """Use a separately bound residual review, or request an ordinary current plan."""
759
+
760
+ if isinstance(recovery.get("residual_successor"), dict):
761
+ return recovery, "apply-residual"
762
+ if isinstance(recovery.get("residual_recovery"), dict):
763
+ return recovery, "apply-residual"
764
+ return (
765
+ _remote_json(
766
+ tunnel,
767
+ remote_root,
768
+ app_work,
769
+ ("plan", "--stage", stage),
770
+ timeout=timeout,
771
+ ),
772
+ "apply",
773
+ )
774
+
775
+
776
+ def _remote_json(
777
+ tunnel: Any,
778
+ remote_root: str,
779
+ app_work: str,
780
+ arguments: tuple[str, ...],
781
+ *,
782
+ timeout: int,
783
+ input_text: str | None = None,
784
+ ) -> dict[str, Any]:
785
+ host_program = getattr(tunnel, "host_program", None)
786
+ invocation = (
787
+ (str(host_program),)
788
+ if isinstance(host_program, str) and host_program
789
+ else ("-m", "fdai_deployment_cli.standalone_host")
790
+ )
791
+ result = tunnel.ssh(
792
+ (
793
+ f"{remote_root}/venv/bin/python",
794
+ *invocation,
795
+ "--work-dir",
796
+ app_work,
797
+ *arguments,
798
+ ),
799
+ timeout=timeout,
800
+ input_text=input_text,
801
+ )
802
+ if result.returncode != 0:
803
+ failure = _remote_failure(result)
804
+ if failure is not None:
805
+ codes = failure.get("provider_error_codes")
806
+ code_text = (
807
+ f"; provider_error_code={','.join(str(code) for code in codes)}"
808
+ if isinstance(codes, list) and codes
809
+ else ""
810
+ )
811
+ raise ValueError(
812
+ "standalone managed-host checkpoint failed: "
813
+ f"reason_code={failure['reason_code']}{code_text}; "
814
+ f"excerpt={failure['message_excerpt']}"
815
+ )
816
+ raise ValueError("standalone managed-host checkpoint failed")
817
+ try:
818
+ value = json.loads(result.stdout)
819
+ except json.JSONDecodeError as exc:
820
+ raise ValueError("standalone managed-host checkpoint returned invalid output") from exc
821
+ return _mapping(value, "standalone managed-host result")
822
+
823
+
824
+ def _require_nondestructive_adoption_plan(review: dict[str, Any]) -> None:
825
+ _stage, destructive = validate_plan_review(review)
826
+ if destructive:
827
+ raise ValueError("recovered application state requires a zero-destroy plan")
828
+
829
+
830
+ @terminal_output("Review the exact application plan", approval=True)
831
+ def _approve_plan(
832
+ root: Path, review: dict[str, Any], *, deadline: DeploymentDeadline | None = None
833
+ ) -> Path:
834
+ """Record invocation approval; require one exact confirmation for delete or replace."""
835
+
836
+ stage, destructive = validate_plan_review(review)
837
+ approval_deadline = DeploymentDeadline(
838
+ min(600, deadline.remaining() if deadline is not None else 600), clock=time.monotonic
839
+ )
840
+
841
+ def approval_seconds(maximum: int = 600) -> int:
842
+ validate_plan_review(review)
843
+ plan_remaining = int(
844
+ (_parse_moment(str(review["expires_at"])) - datetime.now(UTC)).total_seconds()
845
+ )
846
+ remaining = min(plan_remaining, approval_deadline.remaining(maximum))
847
+ if remaining <= 0:
848
+ raise TimeoutError("standalone approval deadline expired; no approval was granted")
849
+ return remaining
850
+
851
+ operation, actor_before = bootstrap_approval.approve_application_checkpoint(
852
+ review,
853
+ stage,
854
+ destructive,
855
+ actor_digest=lambda: _azure_actor_digest(
856
+ str(review["target_binding"]), timeout_seconds=approval_seconds(60)
857
+ ),
858
+ )
859
+ expected = f"{operation}-apply"
860
+ injected = actor_before is not None
861
+ print(json.dumps(review, indent=2, sort_keys=True), file=sys.stderr)
862
+ # Constitution Article 1: the invocation approves a plan; only destruction needs confirmation.
863
+ print(f"Approved by this invocation: {expected}", file=sys.stderr, flush=True)
864
+ if destructive and not injected:
865
+ print(
866
+ f"Plan contains {destructive} delete or replacement action(s); type "
867
+ f"{expected}-destructive to approve: ",
868
+ end="",
869
+ file=sys.stderr,
870
+ flush=True,
871
+ )
872
+ confirmation = _approval_input(timeout_seconds=approval_seconds())
873
+ if confirmation != f"{expected}-destructive":
874
+ raise ValueError("standalone destructive application plan approval was denied")
875
+ actor = _azure_actor_digest(str(review["target_binding"]), timeout_seconds=approval_seconds(60))
876
+ if injected and actor != actor_before:
877
+ raise ValueError("Azure operator changed during exact application plan review")
878
+ approval_deadline.remaining()
879
+ validate_plan_review(review)
880
+ now = datetime.now(UTC).replace(microsecond=0)
881
+ expires = min(_parse_moment(str(review["expires_at"])), now + timedelta(hours=1))
882
+ approval = {
883
+ "schema_version": "fdai.standalone-plan-approval.v1",
884
+ "stage": stage,
885
+ "plan_digest": review["plan_digest"],
886
+ "review_digest": review["review_digest"],
887
+ "target_binding": review["target_binding"],
888
+ "source_commit": review["source_commit"],
889
+ "actor_digest": actor,
890
+ "approved_at": _moment(now),
891
+ "expires_at": _moment(expires),
892
+ }
893
+ path = root / f"{operation}-approval.json"
894
+ path.unlink(missing_ok=True)
895
+ write_private_output(path, json.dumps(approval, sort_keys=True, separators=(",", ":")) + "\n")
896
+ return path
897
+
898
+
899
+ def _approval_input(*, timeout_seconds: int = 600) -> str:
900
+ """Treat a closed input stream as denial, never as approval or an implicit retry."""
901
+
902
+ _wait_for_approval_input(timeout_seconds)
903
+ try:
904
+ return input("").strip()
905
+ except EOFError as exc:
906
+ raise ValueError("approval input closed; no new approval was granted") from exc
907
+
908
+
909
+ def _require_receipt(value: dict[str, Any], stage: str) -> None:
910
+ if (
911
+ value.get("state") != "applied"
912
+ or value.get("stage") != stage
913
+ or value.get("control_plane_readback_verified") is not True
914
+ or not isinstance(value.get("receipt_digest"), str)
915
+ or _DIGEST.fullmatch(str(value["receipt_digest"])) is None
916
+ ):
917
+ raise ValueError(f"standalone {stage} apply receipt is invalid")
918
+
919
+
920
+ def _import_bastion(scripts: Path) -> Any:
921
+ sys.path.insert(0, str(scripts))
922
+ try:
923
+ return importlib.import_module("genesis_bastion")
924
+ finally:
925
+ sys.path.remove(str(scripts))
926
+
927
+
928
+ def _import_entra(scripts: Path) -> Any:
929
+ sys.path.insert(0, str(scripts))
930
+ try:
931
+ return importlib.import_module("genesis_entra")
932
+ finally:
933
+ sys.path.remove(str(scripts))
934
+
935
+
936
+ def _azure_actor_digest(target_binding: str, *, timeout_seconds: int = 60) -> str:
937
+ return azure_actor.actor_digest(
938
+ target_binding,
939
+ timeout_seconds=timeout_seconds,
940
+ runner=subprocess.run,
941
+ )
942
+
943
+
944
+ def _private_json(path: Path, label: str) -> dict[str, Any]:
945
+ try:
946
+ value = json.loads(read_private_bytes(path, max_bytes=4 * 1024 * 1024))
947
+ except json.JSONDecodeError as exc:
948
+ raise ValueError(f"{label} is invalid") from exc
949
+ return _mapping(value, label)
950
+
951
+
952
+ def _replace_private_json(path: Path, value: dict[str, object] | dict[str, str]) -> None:
953
+ temporary = path.parent / f".{path.name}.tmp-{os.getpid()}"
954
+ temporary.unlink(missing_ok=True)
955
+ write_private_output(
956
+ temporary,
957
+ json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n",
958
+ )
959
+ os.replace(temporary, path)
960
+ path.chmod(0o600)
961
+
962
+
963
+ def _mapping(value: object, label: str) -> dict[str, Any]:
964
+ if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
965
+ raise ValueError(f"{label} is invalid")
966
+ return {str(key): item for key, item in value.items()}
967
+
968
+
969
+ def _moment(value: datetime) -> str:
970
+ return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
971
+
972
+
973
+ def _parse_moment(value: str) -> datetime:
974
+ result = datetime.fromisoformat(value)
975
+ if result.tzinfo is None:
976
+ raise ValueError("plan expiry is invalid")
977
+ return result.astimezone(UTC)