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,586 @@
1
+ """Supervise connected source Foundation with exact human checkpoint approvals."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import importlib
7
+ import os
8
+ import re
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ from pathlib import Path
13
+ from types import SimpleNamespace
14
+
15
+ from fdai_service_contracts.product_profile import ProductAddOn
16
+
17
+ from fdai_deployment_cli.aks_preflight import current_target_binding, inspect_aks_target
18
+ from fdai_deployment_cli.bootstrap_approval import selected_source
19
+ from fdai_deployment_cli.catalog_review_profile import CatalogReviewDeploymentProfile
20
+ from fdai_deployment_cli.contracts import load_json_object
21
+ from fdai_deployment_cli.deployment_cost import inspect_aks_compute_cost
22
+ from fdai_deployment_cli.deployment_deadline import DeploymentDeadline
23
+ from fdai_deployment_cli.deployment_progress import (
24
+ begin_stage,
25
+ installer_activity,
26
+ progress_detail,
27
+ )
28
+ from fdai_deployment_cli.development_authority_profile import (
29
+ DevelopmentAuthorityDeploymentProfile,
30
+ )
31
+ from fdai_deployment_cli.hub_image_trust import HubImageSelection, load_selection
32
+ from fdai_deployment_cli.installation_scope import (
33
+ InstallationOptions,
34
+ confirm_installation_scope,
35
+ started_installation_matches,
36
+ )
37
+ from fdai_deployment_cli.notification_recipients_input import (
38
+ NotificationRecipientsDeploymentInput,
39
+ )
40
+ from fdai_deployment_cli.operational_evidence_verifier_input import (
41
+ OperationalEvidenceVerifierDeploymentInput,
42
+ )
43
+ from fdai_deployment_cli.private_output import read_private_bytes
44
+ from fdai_deployment_cli.runtime_profile import RuntimeDeploymentProfile
45
+ from fdai_deployment_cli.source_deploy import prepare_source_deployment
46
+ from fdai_deployment_cli.source_foundation import _copy_terraform
47
+ from fdai_deployment_cli.source_input import inspect_source
48
+ from fdai_deployment_cli.source_terraform import download_pinned_terraform
49
+ from fdai_deployment_cli.standalone_application_completion import complete_application
50
+ from fdai_deployment_cli.standalone_deploy import (
51
+ _current_operator_object_id as current_operator_object_id,
52
+ )
53
+ from fdai_deployment_cli.standalone_status import current_status, prior_attempt
54
+
55
+
56
+ def plan_source_installation(
57
+ *,
58
+ source_root: Path,
59
+ work_dir: Path,
60
+ runtime_profile: RuntimeDeploymentProfile,
61
+ region: str,
62
+ monthly_cost_ceiling: int,
63
+ timeout_seconds: int,
64
+ foundation_workload: str = "fdai",
65
+ interactive: bool = False,
66
+ approval_file: Path | None = None,
67
+ installation_options: InstallationOptions | None = None,
68
+ confirm_initial: bool = False,
69
+ foundation_recovery_directory: Path | None = None,
70
+ operational_evidence_verifier_input: OperationalEvidenceVerifierDeploymentInput | None = None,
71
+ notification_recipients: NotificationRecipientsDeploymentInput | None = None,
72
+ development_authority_profile: DevelopmentAuthorityDeploymentProfile | None = None,
73
+ clear_development_authority_profile: bool = False,
74
+ hub_selection: HubImageSelection | None = None,
75
+ ) -> dict[str, object]:
76
+ """Advance source Foundation through exact human approvals, or stop for review.
77
+
78
+ No kit construction, signature fallback or licence activation occurs here.
79
+ Noninteractive use passes only an explicitly supplied approval to the shared
80
+ verifier and never prompts or adopts an ambient approval. Foundation handoff
81
+ alone keeps deployment_ready=false until application acceptance is connected.
82
+ """
83
+ if interactive and approval_file is not None:
84
+ raise ValueError("explicit source approval cannot be combined with interactive approval")
85
+ if hub_selection is None and (work_dir / "hub-images").exists():
86
+ raise ValueError("source builds cannot resume a Hub-image deployment")
87
+ hub_target_binding = None
88
+ if hub_selection is not None:
89
+ from fdai_deployment_cli.aks_preflight import _active_human_target
90
+ from fdai_deployment_cli.hub_image_deploy import record_deployment_request
91
+ from fdai_deployment_cli.target import compute_target_binding
92
+
93
+ hub_selection = load_selection(hub_selection.path, hub_selection.digest)
94
+ inspect_source(source_root, expected_commit=hub_selection.source_commit)
95
+ subscription_id, tenant_id = _active_human_target(
96
+ DeploymentDeadline(min(timeout_seconds, 60))
97
+ )
98
+ hub_target_binding = compute_target_binding(
99
+ subscription_id=subscription_id, tenant_id=tenant_id
100
+ )
101
+ record_deployment_request(hub_selection, subscription_id=subscription_id, region=region)
102
+ if foundation_recovery_directory is not None:
103
+ if hub_selection is not None:
104
+ from fdai_deployment_cli.hub_image_deploy import require_retained_hub_recovery
105
+
106
+ assert hub_target_binding is not None
107
+ require_retained_hub_recovery(
108
+ hub_selection, work_dir=work_dir, target_binding=hub_target_binding
109
+ )
110
+ from fdai_deployment_cli.source_recovery import (
111
+ recovered_application_evidence,
112
+ resume_source_installation,
113
+ )
114
+
115
+ if interactive or confirm_initial or installation_options is not None:
116
+ raise ValueError("source recovery cannot replace the retained initial scope or prompt")
117
+ if operational_evidence_verifier_input is not None:
118
+ raise ValueError("source recovery cannot add a new operational evidence verifier input")
119
+ if notification_recipients is not None:
120
+ raise ValueError("source recovery cannot add notification recipients")
121
+ if development_authority_profile is not None:
122
+ raise ValueError("source recovery cannot add a development authority profile")
123
+ if clear_development_authority_profile:
124
+ raise ValueError("source recovery cannot clear a development authority profile")
125
+ work_dir = work_dir.absolute()
126
+ foundation_recovery_directory = foundation_recovery_directory.absolute()
127
+ progress = resume_source_installation(
128
+ source_root=source_root,
129
+ work_dir=work_dir,
130
+ recovery_directory=foundation_recovery_directory,
131
+ runtime_profile=runtime_profile,
132
+ region=region,
133
+ monthly_cost_ceiling=monthly_cost_ceiling,
134
+ timeout_seconds=timeout_seconds,
135
+ approval_file=approval_file,
136
+ )
137
+ if hub_target_binding is not None and progress.get("target_binding") != hub_target_binding:
138
+ raise ValueError("Hub request target differs from retained Foundation recovery")
139
+ if progress.get("stage") != "application-plan":
140
+ return progress
141
+ evidence = recovered_application_evidence(
142
+ work_dir=work_dir,
143
+ recovery_directory=foundation_recovery_directory,
144
+ progress=progress,
145
+ runtime_profile_digest=runtime_profile.digest,
146
+ region=region,
147
+ monthly_cost_ceiling=monthly_cost_ceiling,
148
+ )
149
+ execution_source = inspect_source(source_root)
150
+ prepared_source = SimpleNamespace(
151
+ root=work_dir / "foundation",
152
+ ssh_private_key=work_dir / "foundation/runner_ed25519",
153
+ target_binding=progress["target_binding"],
154
+ source_commit=progress["source_commit"],
155
+ run_binding=evidence.run_binding,
156
+ )
157
+ application = complete_application(
158
+ kit=None,
159
+ prepared=prepared_source,
160
+ status=evidence.status,
161
+ scripts=execution_source.root / "scripts/deployment/azure",
162
+ deadline=DeploymentDeadline(timeout_seconds),
163
+ selected_runtime=runtime_profile,
164
+ trial_token=None,
165
+ application_state_adoption=None,
166
+ foundation_state_receipt_digest=evidence.state_handoff_digest,
167
+ catalog_review_profile=CatalogReviewDeploymentProfile.unselected(),
168
+ current_operator_object_id=current_operator_object_id,
169
+ source_snapshot=work_dir / "source-snapshot",
170
+ source_snapshot_digest=evidence.source_snapshot_digest,
171
+ source_root=execution_source.root,
172
+ foundation_recovery_directory=foundation_recovery_directory,
173
+ hub_selection=hub_selection,
174
+ )
175
+ return {
176
+ **application,
177
+ "foundation_recovery_receipt_digest": progress["foundation_receipt_digest"],
178
+ "source_recovery_progress_digest": progress["receipt_digest"],
179
+ "recovery_execution_source_commit": progress["execution_source_commit"],
180
+ }
181
+ deadline = DeploymentDeadline(timeout_seconds)
182
+ begin_stage("discovery")
183
+ with installer_activity("Preparing the clean source snapshot"):
184
+ prepared = prepare_source_deployment(
185
+ source_root=source_root,
186
+ work_dir=work_dir,
187
+ runtime_profile=runtime_profile,
188
+ region=region,
189
+ monthly_cost_ceiling=monthly_cost_ceiling,
190
+ hub_selection_digest=hub_selection.digest if hub_selection is not None else None,
191
+ )
192
+ with installer_activity("Verifying source and tenant-local identity names"):
193
+ source = inspect_source(source_root, expected_commit=str(prepared["source_commit"]))
194
+ _require_unique_entra_display_names(
195
+ runtime_profile,
196
+ source.root / "scripts/deployment/azure",
197
+ target_binding=current_target_binding(timeout_seconds=deadline.remaining(60)),
198
+ )
199
+ with installer_activity("Checking AKS SKU, quota, and PostgreSQL availability"):
200
+ preflight = inspect_aks_target(
201
+ profile=runtime_profile,
202
+ region=region,
203
+ timeout_seconds=deadline.remaining(180),
204
+ owned_resource_group=_retained_application_group(work_dir, foundation_workload),
205
+ )
206
+ if hub_target_binding is not None and preflight.get("target_binding") != hub_target_binding:
207
+ raise ValueError("Azure target changed after the Hub deployment request")
208
+ work_dir = work_dir.absolute()
209
+ retained_binding = {
210
+ "source_commit": source.commit,
211
+ "target_binding": preflight.get("target_binding"),
212
+ }
213
+ if preflight["state"] != "feasible" and not started_installation_matches(
214
+ work_dir,
215
+ retained_binding,
216
+ ):
217
+ return {**preflight, "stage": "aks-preflight"}
218
+ if installation_options is not None:
219
+ progress_detail("Waiting for the one-time installation scope confirmation")
220
+ confirmation = confirm_installation_scope(
221
+ work_dir=work_dir,
222
+ binding={
223
+ "source_commit": source.commit,
224
+ "target_binding": preflight["target_binding"],
225
+ "preparation_digest": prepared["receipt_digest"],
226
+ "runtime_profile_digest": runtime_profile.digest,
227
+ "region": region,
228
+ "monthly_cost_ceiling": monthly_cost_ceiling,
229
+ },
230
+ runtime_profile=runtime_profile,
231
+ options=installation_options,
232
+ interactive=confirm_initial,
233
+ timeout_seconds=deadline.remaining(86400),
234
+ )
235
+ source.reverify()
236
+ if confirmation["state"] != "confirmed":
237
+ return {**confirmation, "source_commit": source.commit}
238
+ with installer_activity("Checking the public compute estimate"):
239
+ cost_review = inspect_aks_compute_cost(
240
+ profile=runtime_profile,
241
+ region=region,
242
+ monthly_cost_ceiling=monthly_cost_ceiling,
243
+ timeout_seconds=deadline.remaining(120),
244
+ )
245
+ source.reverify()
246
+ if cost_review["state"] == "blocked":
247
+ return {**cost_review, "source_commit": source.commit}
248
+ tools = work_dir / "source-tools"
249
+ tools.mkdir(mode=0o700, exist_ok=True)
250
+ terraform = tools / "terraform"
251
+ toolchain = load_json_object(
252
+ (source.root / "infra/genesis-runner-image/toolchain.json").read_bytes(),
253
+ label="source toolchain",
254
+ )
255
+ digest = toolchain.get("terraform_binary_sha256")
256
+ with installer_activity("Preparing the checksum-pinned Terraform executable"):
257
+ if terraform.exists():
258
+ if hashlib.sha256(terraform.read_bytes()).hexdigest() != digest:
259
+ raise ValueError("retained source Terraform differs; preserve the run")
260
+ else:
261
+ installed = shutil.which("terraform")
262
+ try:
263
+ if installed is None:
264
+ raise ValueError("no Terraform on PATH")
265
+ _copy_terraform(Path(installed), terraform, expected_digest=digest)
266
+ except (OSError, ValueError):
267
+ download_pinned_terraform(
268
+ toolchain=toolchain,
269
+ destination=terraform,
270
+ timeout_seconds=deadline.remaining(300),
271
+ )
272
+ scripts = source.root / "scripts/deployment/azure"
273
+ foundation = work_dir / "foundation"
274
+ begin_stage("foundation")
275
+ progress_detail("Preparing Foundation inputs and the retained execution context")
276
+ environment = {
277
+ key: value
278
+ for key, value in os.environ.items()
279
+ if key
280
+ in {"HOME", "PATH", "AZURE_CONFIG_DIR", "LANG", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE"}
281
+ }
282
+ environment["PYTHONPATH"] = str(source.root / "packages/deployment-cli/src")
283
+ preparation = _capture(
284
+ (
285
+ sys.executable,
286
+ str(scripts / "source_genesis.py"),
287
+ "--work-dir",
288
+ str(foundation),
289
+ "--source-commit",
290
+ source.commit,
291
+ "--target-binding",
292
+ str(preflight["target_binding"]),
293
+ "--region",
294
+ region,
295
+ "--monthly-cost-ceiling",
296
+ str(monthly_cost_ceiling),
297
+ "--workload",
298
+ foundation_workload,
299
+ "--register-providers",
300
+ ),
301
+ source.root,
302
+ environment,
303
+ deadline.remaining(900),
304
+ )
305
+ values = load_json_object(
306
+ read_private_bytes(foundation / "foundation-variables.json", max_bytes=65536),
307
+ label="source Foundation variables",
308
+ )
309
+ environment["AZURE_SUBSCRIPTION_ID"] = str(values["subscription_id"])
310
+ environment["AZURE_TENANT_ID"] = str(values["tenant_id"])
311
+ run_binding = preparation.get("run_binding")
312
+ if not isinstance(run_binding, str):
313
+ raise ValueError( # noqa: TRY004 - stable CLI invariant error, not caller type misuse
314
+ "source Foundation preparation has no retained run binding"
315
+ )
316
+ approval = (
317
+ approval_file.absolute()
318
+ if approval_file is not None
319
+ else foundation / "current-source-approval.json"
320
+ )
321
+ status_path = foundation / "status.json"
322
+ failed_advance_recovery_used = False
323
+ while True:
324
+ source.reverify()
325
+ previous = prior_attempt(status_path)
326
+ try:
327
+ result = _capture(
328
+ (
329
+ sys.executable,
330
+ str(scripts / "source_genesis.py"),
331
+ "--advance",
332
+ "--work-dir",
333
+ str(foundation),
334
+ "--source-commit",
335
+ source.commit,
336
+ "--target-binding",
337
+ str(preflight["target_binding"]),
338
+ "--region",
339
+ region,
340
+ "--monthly-cost-ceiling",
341
+ str(monthly_cost_ceiling),
342
+ "--workload",
343
+ foundation_workload,
344
+ "--register-providers",
345
+ "--source-snapshot",
346
+ str(work_dir / "source-snapshot"),
347
+ "--source-snapshot-digest",
348
+ str(prepared["source_snapshot_digest"]),
349
+ "--terraform",
350
+ str(terraform),
351
+ "--timeout-seconds",
352
+ str(deadline.remaining(14400)),
353
+ *(
354
+ ("--approval-file", str(approval))
355
+ if approval_file is not None
356
+ or ((interactive or selected_source() is not None) and approval.exists())
357
+ else ()
358
+ ),
359
+ ),
360
+ source.root,
361
+ environment,
362
+ deadline.remaining(14400),
363
+ )
364
+ except ValueError:
365
+ if failed_advance_recovery_used or not _claimed_foundation_apply_requires_recovery(
366
+ foundation
367
+ ):
368
+ raise
369
+ failed_advance_recovery_used = True
370
+ progress_detail("Recovering the claimed Foundation apply without repeating its effect")
371
+ continue
372
+ source.reverify()
373
+ if (
374
+ result.get("schema_version") != "fdai.source-foundation-progress.v1"
375
+ or result.get("state") != "review"
376
+ or result.get("source_commit") != source.commit
377
+ or result.get("run_binding") != run_binding
378
+ or result.get("provenance") != "operator-selected-source"
379
+ or result.get("release_signature_verified") is not False
380
+ or result.get("apply_authorized") is not False
381
+ or type(result.get("mutation_performed")) is not bool
382
+ or result.get("deployment_ready") is not False
383
+ ):
384
+ raise ValueError("source Foundation did not return a bound review result")
385
+ status = current_status(
386
+ status_path, previous=previous, source_commit=source.commit, run_binding=run_binding
387
+ )
388
+ if (
389
+ result.get("attempt") != status["attempt"]
390
+ or result.get("stage") != status.get("current_stage")
391
+ or result["mutation_performed"] != status.get("mutation_performed")
392
+ ):
393
+ raise ValueError("source Foundation progress differs from its current status")
394
+ if result["stage"] == "application-plan":
395
+ report = status.get("foundation_report")
396
+ handoff = report.get("state_handoff") if isinstance(report, dict) else None
397
+ plan = report.get("foundation_plan") if isinstance(report, dict) else None
398
+ plan_ref = plan.get("plan_ref") if isinstance(plan, dict) else None
399
+ completed = status.get("completed_stages")
400
+ if (
401
+ not isinstance(handoff, dict)
402
+ or not isinstance(plan_ref, str)
403
+ or re.fullmatch(r"foundation-plan-attempt-[1-9][0-9]*", plan_ref) is None
404
+ ):
405
+ raise ValueError("source application transfer requires verified Foundation handoff")
406
+ expected_handoff_digest = handoff.get("receipt_digest")
407
+ handoff = load_json_object(
408
+ read_private_bytes(
409
+ foundation / plan_ref / "foundation-state-handoff-receipt.json",
410
+ max_bytes=1024 * 1024,
411
+ ),
412
+ label="source Foundation handoff receipt",
413
+ max_bytes=1024 * 1024,
414
+ )
415
+ if (
416
+ status.get("route") != "private-runner"
417
+ or not isinstance(completed, list)
418
+ or "foundation-state" not in completed
419
+ or not isinstance(handoff, dict)
420
+ or handoff.get("schema_version")
421
+ != "fdai.genesis-foundation-state-handoff-receipt.v1"
422
+ or handoff.get("state") != "verified"
423
+ or handoff.get("source_commit") != source.commit
424
+ or handoff.get("target_binding") != preflight["target_binding"]
425
+ or any(
426
+ handoff.get(key) is not True
427
+ for key in (
428
+ "effect_verified",
429
+ "runner_attested",
430
+ "remote_backend_authority_verified",
431
+ "zero_change_verified",
432
+ "remote_transient_deleted",
433
+ )
434
+ )
435
+ ):
436
+ raise ValueError("source application transfer requires verified Foundation handoff")
437
+ from fdai_deployment_cli.contracts import canonical_digest
438
+
439
+ if (
440
+ canonical_digest(
441
+ {key: value for key, value in handoff.items() if key != "receipt_digest"}
442
+ )
443
+ != expected_handoff_digest
444
+ or handoff.get("receipt_digest") != expected_handoff_digest
445
+ ):
446
+ raise ValueError("source Foundation handoff digest differs")
447
+ prepared_source = SimpleNamespace(
448
+ root=foundation,
449
+ ssh_private_key=foundation / "runner_ed25519",
450
+ target_binding=preflight["target_binding"],
451
+ source_commit=source.commit,
452
+ run_binding=run_binding,
453
+ )
454
+ application = complete_application(
455
+ kit=None,
456
+ prepared=prepared_source,
457
+ status=status,
458
+ scripts=scripts,
459
+ deadline=deadline,
460
+ selected_runtime=runtime_profile,
461
+ trial_token=None,
462
+ application_state_adoption=None,
463
+ foundation_state_receipt_digest=str(expected_handoff_digest),
464
+ catalog_review_profile=CatalogReviewDeploymentProfile.unselected(),
465
+ operational_evidence_verifier_input=operational_evidence_verifier_input,
466
+ notification_recipients=notification_recipients,
467
+ development_authority_profile=development_authority_profile,
468
+ clear_development_authority_profile=clear_development_authority_profile,
469
+ current_operator_object_id=current_operator_object_id,
470
+ source_snapshot=work_dir / "source-snapshot",
471
+ source_snapshot_digest=str(prepared["source_snapshot_digest"]),
472
+ source_root=source.root,
473
+ hub_selection=hub_selection,
474
+ )
475
+ return {**application, "cost_review": cost_review}
476
+ if (not interactive and selected_source() is None) or result["stage"] not in {
477
+ "runner-image-apply",
478
+ "foundation-apply",
479
+ "runner-enrollment",
480
+ "foundation-state",
481
+ }:
482
+ return {**result, "cost_review": cost_review}
483
+ approval.unlink(missing_ok=True)
484
+ if selected_source() is not None:
485
+ sys.path.insert(0, str(scripts))
486
+ try:
487
+ approval_prompt = importlib.import_module("genesis_approval_prompt")
488
+ approval_prompt.approve_status(status_path, approval)
489
+ finally:
490
+ sys.path.remove(str(scripts))
491
+ continue
492
+ prompt = subprocess.run(
493
+ (
494
+ sys.executable,
495
+ str(scripts / "genesis_approval_prompt.py"),
496
+ "--status",
497
+ str(status_path),
498
+ "--output",
499
+ str(approval),
500
+ ),
501
+ cwd=source.root,
502
+ env=environment,
503
+ stdout=sys.stderr,
504
+ check=False,
505
+ timeout=deadline.remaining(600),
506
+ )
507
+ if prompt.returncode != 0:
508
+ raise ValueError("source Foundation exact approval was not granted")
509
+
510
+
511
+ def _retained_application_group(work_dir: Path, workload: str) -> str | None:
512
+ """Name the resource group a resumed run already owns, from its retained variables."""
513
+
514
+ path = work_dir.absolute() / "foundation" / "foundation-variables.json"
515
+ if not path.is_file() or path.is_symlink():
516
+ return None
517
+ try:
518
+ values = load_json_object(
519
+ read_private_bytes(path, max_bytes=65536), label="retained Foundation variables"
520
+ )
521
+ except (OSError, ValueError):
522
+ return None
523
+ region_short = values.get("region_short")
524
+ if (
525
+ values.get("workload") != workload
526
+ or not isinstance(region_short, str)
527
+ or re.fullmatch(r"[a-z][a-z0-9]{1,11}", region_short) is None
528
+ or re.fullmatch(r"[a-z][a-z0-9-]{1,30}", workload) is None
529
+ ):
530
+ return None
531
+ return f"rg-{workload}-dev-{region_short}"
532
+
533
+
534
+ def _capture(
535
+ command: tuple[str, ...], root: Path, environment: dict[str, str], timeout: int
536
+ ) -> dict[str, object]:
537
+ result = subprocess.run(
538
+ command,
539
+ cwd=root,
540
+ env=environment,
541
+ stdin=subprocess.DEVNULL,
542
+ stdout=subprocess.PIPE,
543
+ stderr=sys.stderr,
544
+ check=False,
545
+ timeout=timeout,
546
+ )
547
+ if result.returncode != 0:
548
+ raise ValueError(
549
+ "source planning stage failed; preserve private state and do not repeat effects"
550
+ )
551
+ return load_json_object(result.stdout, label="source planning result", max_bytes=1024 * 1024)
552
+
553
+
554
+ def _claimed_foundation_apply_requires_recovery(foundation: Path) -> bool:
555
+ claims = sorted(foundation.glob("foundation-plan-attempt-*/foundation-apply-claim.json"))
556
+ if len(claims) != 1 or claims[0].is_symlink():
557
+ return False
558
+ receipt = claims[0].with_name("foundation-apply-receipt.json")
559
+ return not receipt.exists() and not receipt.is_symlink()
560
+
561
+
562
+ def _require_unique_entra_display_names(
563
+ profile: RuntimeDeploymentProfile,
564
+ scripts: Path,
565
+ *,
566
+ target_binding: str,
567
+ ) -> None:
568
+ product = profile.product_profile
569
+ if not (
570
+ product.selects(ProductAddOn.READ_ONLY_CONSOLE)
571
+ or product.selects(ProductAddOn.ENTERPRISE_IDENTITY_GOVERNANCE)
572
+ ):
573
+ return
574
+ sys.path.insert(0, str(scripts))
575
+ try:
576
+ genesis_entra = importlib.import_module("genesis_entra")
577
+ spa_display_name = genesis_entra.scoped_spa_display_name(target_binding)
578
+ check = getattr(genesis_entra, "check_unique_display_names", None)
579
+ if callable(check):
580
+ check(spa_display_name=spa_display_name)
581
+ return
582
+ plan_entra = getattr(genesis_entra, "plan_entra", None)
583
+ if callable(plan_entra):
584
+ plan_entra(spa_display_name=spa_display_name)
585
+ finally:
586
+ sys.path.remove(str(scripts))
@@ -0,0 +1,107 @@
1
+ """CLI adapters for changed-source planning, execution, and timing evidence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from collections.abc import Callable, Mapping
8
+ from datetime import UTC, datetime
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from fdai_deployment_cli.deployment_progress import DeploymentProgress
13
+ from fdai_deployment_cli.private_output import write_private_output
14
+ from fdai_deployment_cli.source_change_plan import plan_git_changes
15
+ from fdai_deployment_cli.source_changed_update import deploy_changed_source
16
+ from fdai_deployment_cli.source_service_update import deploy_source_service_update
17
+ from fdai_deployment_cli.source_service_update_cli import source_service_update_inputs
18
+
19
+ PrintMapping = Callable[..., None]
20
+
21
+
22
+ def provision_plan(args: argparse.Namespace, print_mapping: PrintMapping) -> int:
23
+ result = plan_git_changes(args.source, args.base, args.head)
24
+ print_mapping(
25
+ result,
26
+ output=args.output,
27
+ text=(
28
+ f"source change lane: {result['lane']}; "
29
+ f"reason={result['reason_code']}; no deployment performed"
30
+ ),
31
+ )
32
+ return 0
33
+
34
+
35
+ def provision_update(
36
+ args: argparse.Namespace,
37
+ print_mapping: PrintMapping,
38
+ *,
39
+ deploy: Callable[..., dict[str, object]] = deploy_changed_source,
40
+ ) -> int:
41
+ result = deploy(
42
+ source_root=args.source,
43
+ base=args.base,
44
+ application_work_dir=args.application_work_dir,
45
+ work_dir=args.work_dir,
46
+ timeout_seconds=args.timeout_seconds,
47
+ )
48
+ services = result.get("services")
49
+ if not isinstance(services, list) or any(not isinstance(item, str) for item in services):
50
+ raise ValueError("source changed update returned an invalid service list")
51
+ print_mapping(
52
+ result,
53
+ output=args.output,
54
+ text=f"source changed update: {result['state']}; services={','.join(services) or 'none'}",
55
+ )
56
+ return 0 if result["state"] in {"applied", "no-deploy"} else 2
57
+
58
+
59
+ def provision_service_update(args: argparse.Namespace) -> int:
60
+ source, application, selected, development_profile, clear_development_profile = (
61
+ source_service_update_inputs(args)
62
+ )
63
+ result = deploy_source_service_update(
64
+ source_root=source,
65
+ application_work_dir=application,
66
+ work_dir=selected,
67
+ service=args.service,
68
+ timeout_seconds=args.timeout_seconds,
69
+ adopt_historical_binding=args.adopt_historical_binding,
70
+ adopt_historical_state=args.adopt_historical_state,
71
+ adopt_historical_variables=args.adopt_historical_variables,
72
+ adopt_historical_live=args.adopt_historical_live,
73
+ adopt_historical_plan=args.adopt_historical_plan,
74
+ development_authority_profile=development_profile,
75
+ clear_development_authority_profile=clear_development_profile,
76
+ )
77
+ print(
78
+ json.dumps(result, sort_keys=True, separators=(",", ":"))
79
+ if args.output == "json"
80
+ else f"source service update applied: {result['service']} at {result['source_commit']}"
81
+ )
82
+ return 0
83
+
84
+
85
+ def attach_timing(
86
+ result: Mapping[str, Any],
87
+ *,
88
+ progress: DeploymentProgress,
89
+ work_dir: Path,
90
+ ) -> dict[str, Any]:
91
+ timing = progress.timing_receipt(
92
+ lane="source-install",
93
+ outcome=str(result.get("state", "unknown")),
94
+ recorded_at=datetime.now(UTC).isoformat().replace("+00:00", "Z"),
95
+ )
96
+ timing_directory = work_dir / "timing"
97
+ timing_directory.mkdir(mode=0o700, parents=True, exist_ok=True)
98
+ timing_path = timing_directory / f"{timing['receipt_digest']}.json"
99
+ if not timing_path.exists():
100
+ write_private_output(
101
+ timing_path,
102
+ json.dumps(timing, sort_keys=True, separators=(",", ":")) + "\n",
103
+ )
104
+ return {**result, "timing_receipt_digest": timing["receipt_digest"]}
105
+
106
+
107
+ __all__ = ["attach_timing", "provision_plan", "provision_service_update", "provision_update"]