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,450 @@
1
+ """Offline and source Foundation planning for fdaictl."""
2
+
3
+ from __future__ import annotations
4
+ import argparse
5
+ import json
6
+ import os
7
+ import platform
8
+ import shutil
9
+ import stat
10
+ import subprocess
11
+ import sys
12
+ from collections.abc import Mapping
13
+ from contextlib import nullcontext
14
+ from pathlib import Path
15
+ from tempfile import TemporaryDirectory
16
+ from typing import cast
17
+ from fdai_deployment_cli.__about__ import __version__
18
+ from fdai_deployment_cli.bundle import (
19
+ extract_bundle_archive,
20
+ verify_bundle,
21
+ )
22
+ from fdai_deployment_cli.contracts import canonical_digest
23
+ from fdai_deployment_cli.doctor import (
24
+ azure_active_target_binding,
25
+ )
26
+ from fdai_deployment_cli.foundation_image import verify_foundation_runner_image
27
+ from fdai_deployment_cli.foundation_input import snapshot_foundation_input
28
+ from fdai_deployment_cli.foundation_plan import (
29
+ foundation_plan_context,
30
+ save_foundation_plan,
31
+ )
32
+ from fdai_deployment_cli.offline_kit import materialize_verified_artifacts, verify_offline_kit
33
+ from fdai_deployment_cli.plan_input import read_plan_input as read_plan_input
34
+ from fdai_deployment_cli.plan_input import snapshot_plan_input
35
+ from fdai_deployment_cli.profile import load_profile
36
+ from fdai_deployment_cli.source_foundation import prepare_source_foundation_plan
37
+ from fdai_deployment_cli.target import compute_target_binding
38
+
39
+
40
+ def _provision_plan(args: argparse.Namespace) -> int:
41
+ work_dir = _absolute_work_dir(args.work_dir)
42
+ profile = load_profile(args.profile)
43
+ foundation = args.stage == "foundation"
44
+ source_mode = args.source_snapshot is not None
45
+ if source_mode:
46
+ if (
47
+ not foundation
48
+ or not args.save_plan
49
+ or args.source_snapshot_digest is None
50
+ or args.terraform is None
51
+ ):
52
+ raise ValueError(
53
+ "source plans require foundation, --save-plan, snapshot digest and Terraform"
54
+ )
55
+ if args.release_root is not None or args.bundle_public_key is not None:
56
+ raise ValueError("source plans cannot accept kit verification keys")
57
+ elif args.release_root is None or args.bundle_public_key is None:
58
+ raise ValueError("kit plans require release and bundle verification keys")
59
+ elif args.source_snapshot_digest is not None or args.terraform is not None:
60
+ raise ValueError("kit plans cannot accept source execution inputs")
61
+ if args.save_plan and not foundation:
62
+ raise ValueError("saved local plans are supported only for the foundation stage")
63
+ if foundation and (profile.host != "managed-vm" or profile.monthly_cost_ceiling <= 0):
64
+ raise ValueError("foundation planning requires a managed-vm profile and cost ceiling")
65
+ active_binding = azure_active_target_binding()
66
+ if foundation and active_binding != profile.target_binding:
67
+ raise ValueError("foundation planning requires the authenticated operator target")
68
+ if foundation and os.environ.get("ARM_USE_MSI", ""):
69
+ raise ValueError(
70
+ "foundation planning uses the authenticated operator, not managed identity"
71
+ )
72
+ _validate_plan_target(
73
+ profile_binding=profile.target_binding,
74
+ active_binding=active_binding,
75
+ use_managed_identity=os.environ.get("ARM_USE_MSI", "").casefold() == "true",
76
+ )
77
+ if source_mode:
78
+ _create_private_work_dir(work_dir)
79
+ result = prepare_source_foundation_plan(
80
+ snapshot=_absolute_work_dir(args.source_snapshot),
81
+ snapshot_digest=cast(str, args.source_snapshot_digest),
82
+ terraform=_absolute_work_dir(cast(Path, args.terraform)),
83
+ profile=profile,
84
+ variables_file=args.variables_file,
85
+ work_dir=work_dir,
86
+ environment_builder=_terraform_environment,
87
+ )
88
+ _print_mapping(
89
+ result,
90
+ output=args.output,
91
+ text="source Foundation plan saved; exact human approval remains required",
92
+ )
93
+ return 0
94
+ verification = verify_offline_kit(
95
+ args.offline_kit,
96
+ release_root_pem=_read_public_key(cast(Path, args.release_root)),
97
+ cli_version=__version__,
98
+ platform_tag=_runtime_platform_tag(),
99
+ )
100
+ _create_private_work_dir(work_dir)
101
+ artifacts = materialize_verified_artifacts(
102
+ args.offline_kit,
103
+ verification,
104
+ work_dir / "artifacts",
105
+ )
106
+ terraform = artifacts.terraform_binary
107
+ mirror = artifacts.provider_mirror
108
+ bundle_root = extract_bundle_archive(
109
+ artifacts.deployment_bundle,
110
+ work_dir / "bundle",
111
+ )
112
+ bundle_verification = verify_bundle(
113
+ bundle_root,
114
+ public_key_pem=_read_public_key(cast(Path, args.bundle_public_key)),
115
+ cli_version=__version__,
116
+ )
117
+ _require_bundle_version(
118
+ kit_version=verification.bundle_version,
119
+ bundle_version=bundle_verification.bundle_version,
120
+ )
121
+ infra_dir = bundle_root / "infra"
122
+ if not infra_dir.is_dir():
123
+ raise ValueError("verified deployment bundle does not contain infra")
124
+ if foundation:
125
+ infra_dir = infra_dir / "genesis-foundation"
126
+ if not infra_dir.is_dir() or not (infra_dir / ".terraform.lock.hcl").is_file():
127
+ raise ValueError(
128
+ "verified deployment bundle does not contain the locked foundation root"
129
+ )
130
+ config = work_dir / "offline.tfrc"
131
+ _write_private_text(
132
+ config,
133
+ "provider_installation {\n"
134
+ " filesystem_mirror {\n"
135
+ f' path = "{mirror}"\n'
136
+ ' include = ["*/*"]\n'
137
+ " }\n"
138
+ " direct {\n"
139
+ ' exclude = ["*/*"]\n'
140
+ " }\n"
141
+ "}\n",
142
+ )
143
+ variables_file = work_dir / "plan.auto.tfvars.json"
144
+ snapshot_input = snapshot_foundation_input if foundation else snapshot_plan_input
145
+ plan_context = snapshot_input(
146
+ args.variables_file,
147
+ variables_file,
148
+ expected_target_binding=profile.target_binding,
149
+ expected_region=profile.region,
150
+ expected_environment=profile.environment,
151
+ )
152
+ try:
153
+ environment = _terraform_environment(
154
+ work_dir=work_dir,
155
+ config=config,
156
+ source=os.environ,
157
+ subscription_id=plan_context.subscription_id,
158
+ tenant_id=plan_context.tenant_id,
159
+ azure_cli_path=Path(azure_cli) if (azure_cli := shutil.which("az")) else None,
160
+ )
161
+ variables = read_plan_input(variables_file) if args.save_plan else {}
162
+ if (
163
+ args.save_plan
164
+ and foundation
165
+ and profile.access_method == "bastion"
166
+ and variables.get("enable_bastion") is not True
167
+ ):
168
+ raise ValueError("Foundation Bastion profile requires Bastion in the exact plan")
169
+ runner_image_observation_digest = (
170
+ verify_foundation_runner_image(variables, expected_region=profile.region)
171
+ if args.save_plan and foundation
172
+ else ""
173
+ )
174
+ provider_lock = (infra_dir / ".terraform.lock.hcl").read_bytes() if args.save_plan else b""
175
+ saved_context = (
176
+ foundation_plan_context(
177
+ profile=profile,
178
+ variables=variables,
179
+ offline_manifest_digest=verification.manifest_digest,
180
+ deployment_bundle_digest=bundle_verification.manifest_digest,
181
+ terraform_digest=dict(verification.file_digests)[verification.terraform_binary],
182
+ provider_lock=provider_lock,
183
+ runner_image_observation_digest=runner_image_observation_digest,
184
+ )
185
+ if args.save_plan
186
+ else {}
187
+ )
188
+ initialized = subprocess.run(
189
+ [str(terraform), "init", "-backend=false", "-input=false", "-lockfile=readonly"],
190
+ cwd=infra_dir,
191
+ env=environment,
192
+ check=False,
193
+ capture_output=True,
194
+ text=True,
195
+ timeout=300,
196
+ )
197
+ if initialized.returncode != 0:
198
+ raise ValueError("terraform initialization failed with the locked offline providers")
199
+ saved_review = None
200
+ with (
201
+ TemporaryDirectory(prefix="foundation-plan-", dir=work_dir)
202
+ if args.save_plan
203
+ else nullcontext(None)
204
+ ) as temporary:
205
+ command = [
206
+ str(terraform),
207
+ "plan",
208
+ "-input=false",
209
+ "-no-color",
210
+ f"-var-file={variables_file}",
211
+ ]
212
+ if temporary is not None:
213
+ command.append(f"-out={Path(temporary) / 'plan.tfplan'}")
214
+ completed = subprocess.run(
215
+ command,
216
+ cwd=infra_dir,
217
+ env=environment,
218
+ check=False,
219
+ capture_output=True,
220
+ text=True,
221
+ timeout=300,
222
+ umask=0o077,
223
+ )
224
+ if completed.returncode != 0:
225
+ raise ValueError(_safe_plan_error(completed.stdout + completed.stderr))
226
+ if temporary is not None:
227
+ if (infra_dir / ".terraform.lock.hcl").read_bytes() != provider_lock:
228
+ raise ValueError("foundation provider lock changed during planning")
229
+ saved_review = save_foundation_plan(
230
+ plan=Path(temporary) / "plan.tfplan",
231
+ destination=work_dir,
232
+ terraform=terraform,
233
+ root=infra_dir,
234
+ environment=environment,
235
+ context=saved_context,
236
+ variables=variables,
237
+ )
238
+ finally:
239
+ variables_file.unlink(missing_ok=True)
240
+ result = {
241
+ "schema_version": "fdai.provision-plan.v1",
242
+ "offline_manifest_digest": verification.manifest_digest,
243
+ "mutation_performed": False,
244
+ }
245
+ if foundation:
246
+ result.update(
247
+ {
248
+ "stage": "foundation",
249
+ "state": "review",
250
+ "subscription_ready": False,
251
+ "apply_authorized": False,
252
+ "deployment_bundle_digest": bundle_verification.manifest_digest,
253
+ "profile_digest": canonical_digest(profile.to_mapping()),
254
+ }
255
+ )
256
+ if saved_review is not None:
257
+ result["saved_plan"] = saved_review
258
+ text = (
259
+ f"foundation plan saved; review digest: {saved_review['review_digest']}; no apply authorized"
260
+ if saved_review is not None
261
+ else "foundation dry run completed; approval and installation remain required"
262
+ if foundation
263
+ else "plan completed"
264
+ )
265
+ print(
266
+ json.dumps(result, sort_keys=True, separators=(",", ":")) if args.output == "json" else text
267
+ )
268
+ return 0
269
+
270
+
271
+ def _safe_plan_error(output: str) -> str:
272
+ """Map Terraform output to bounded stable errors without echoing provider values."""
273
+
274
+ if "No value for required variable" in output:
275
+ return "terraform plan requires deployment input: No value for required variable"
276
+ authentication_markers = (
277
+ "please run 'az login'",
278
+ "could not configure azurecli authorizer",
279
+ "managedidentitycredential authentication failed",
280
+ "error building arm config",
281
+ "unable to build authorizer",
282
+ )
283
+ normalized = output.casefold()
284
+ if any(marker in normalized for marker in authentication_markers):
285
+ return "terraform_provider_authentication_unavailable"
286
+ return "terraform plan failed after offline provider initialization"
287
+
288
+
289
+ def _require_bundle_version(*, kit_version: str, bundle_version: str) -> None:
290
+ if kit_version != bundle_version:
291
+ raise ValueError("offline kit and deployment bundle versions do not match")
292
+
293
+
294
+ def _validate_plan_target(
295
+ *,
296
+ profile_binding: str,
297
+ active_binding: str | None,
298
+ use_managed_identity: bool,
299
+ ) -> None:
300
+ if use_managed_identity:
301
+ return
302
+ if active_binding is not None and active_binding != profile_binding:
303
+ raise ValueError("active Azure target does not match the provision profile")
304
+
305
+
306
+ def _terraform_environment(
307
+ *,
308
+ work_dir: Path,
309
+ config: Path,
310
+ source: Mapping[str, str],
311
+ subscription_id: str,
312
+ tenant_id: str,
313
+ azure_cli_path: Path | None,
314
+ ) -> dict[str, str]:
315
+ """Build a minimal Terraform environment and reject ambient control injection."""
316
+
317
+ forbidden = {
318
+ key
319
+ for key in source
320
+ if key.startswith("TF_CLI_ARGS")
321
+ or key in {"TF_WORKSPACE", "TF_DATA_DIR", "TF_CLI_CONFIG_FILE"}
322
+ }
323
+ if forbidden:
324
+ raise ValueError("ambient Terraform control variables are not accepted")
325
+ allowed = ("HOME", "TMPDIR", "TEMP", "TMP", "SSL_CERT_FILE", "SSL_CERT_DIR")
326
+ environment = {key: source[key] for key in allowed if key in source}
327
+ azure_config_value = source.get("AZURE_CONFIG_DIR")
328
+ if azure_config_value is not None:
329
+ azure_config = Path(azure_config_value)
330
+ if not azure_config.is_absolute():
331
+ raise ValueError("AZURE_CONFIG_DIR MUST be absolute")
332
+ details = azure_config.lstat()
333
+ if not azure_config.is_dir() or azure_config.is_symlink() or details.st_mode & 0o077:
334
+ raise ValueError("AZURE_CONFIG_DIR MUST be a private regular directory")
335
+ environment["AZURE_CONFIG_DIR"] = str(azure_config)
336
+ use_msi = source.get("ARM_USE_MSI", "").casefold()
337
+ if use_msi not in {"", "true"}:
338
+ raise ValueError("ARM_USE_MSI MUST be true when supplied")
339
+ tenant_value = source.get("ARM_TENANT_ID")
340
+ if tenant_value is not None and tenant_value.casefold() != tenant_id.casefold():
341
+ raise ValueError("ARM_TENANT_ID does not match the verified plan target")
342
+ client_id = source.get("ARM_CLIENT_ID")
343
+ if client_id is not None:
344
+ compute_target_binding(tenant_id=tenant_id, subscription_id=client_id)
345
+ if azure_cli_path is not None:
346
+ resolved_cli = azure_cli_path.resolve(strict=True)
347
+ details = resolved_cli.stat()
348
+ if not resolved_cli.is_file() or details.st_mode & 0o022:
349
+ raise ValueError("Azure CLI executable is not trusted")
350
+ path_entries = tuple(dict.fromkeys((str(resolved_cli.parent), "/usr/bin", "/bin")))
351
+ environment["PATH"] = os.pathsep.join(path_entries)
352
+ elif use_msi != "true":
353
+ raise ValueError("Terraform plan requires Azure CLI or managed identity")
354
+ if use_msi == "true":
355
+ environment["ARM_USE_MSI"] = "true"
356
+ environment["ARM_TENANT_ID"] = tenant_id
357
+ if client_id is not None:
358
+ environment["ARM_CLIENT_ID"] = client_id
359
+ data_dir = work_dir / "terraform-data"
360
+ data_dir.mkdir(mode=0o700)
361
+ data_dir.chmod(0o700)
362
+ environment.update(
363
+ {
364
+ "TF_CLI_CONFIG_FILE": str(config),
365
+ "TF_DATA_DIR": str(data_dir),
366
+ "TF_IN_AUTOMATION": "1",
367
+ "ARM_SUBSCRIPTION_ID": subscription_id,
368
+ "ARM_RESOURCE_PROVIDER_REGISTRATIONS": "none",
369
+ }
370
+ )
371
+ return environment
372
+
373
+
374
+ def _create_private_work_dir(path: Path) -> None:
375
+ """Create a new private work directory and reject every existing destination."""
376
+
377
+ _require_nonreplaceable_parent_chain(path)
378
+ os.mkdir(path, 0o700)
379
+ descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
380
+ try:
381
+ details = os.fstat(descriptor)
382
+ if details.st_uid != os.geteuid():
383
+ raise PermissionError("plan work directory MUST be owned by the current UID")
384
+ os.fchmod(descriptor, 0o700)
385
+ finally:
386
+ os.close(descriptor)
387
+
388
+
389
+ def _require_nonreplaceable_parent_chain(path: Path) -> None:
390
+ trusted_owners = {0, os.geteuid(), Path("/").lstat().st_uid}
391
+ current = path.parent
392
+ while True:
393
+ details = current.lstat()
394
+ if not stat.S_ISDIR(details.st_mode):
395
+ raise PermissionError("plan work directory parent chain MUST contain only directories")
396
+ if details.st_uid not in trusted_owners:
397
+ raise PermissionError("plan work directory parent chain has an unsafe owner")
398
+ mode = stat.S_IMODE(details.st_mode)
399
+ if mode & 0o022 and not (details.st_mode & stat.S_ISVTX):
400
+ raise PermissionError("plan work directory parent chain is replaceable")
401
+ if current == current.parent:
402
+ return
403
+ current = current.parent
404
+
405
+
406
+ def _absolute_work_dir(path: Path) -> Path:
407
+ """Make a path absolute without resolving a potentially hostile symlink."""
408
+
409
+ return path if path.is_absolute() else Path.cwd() / path
410
+
411
+
412
+ def _write_private_text(path: Path, content: str) -> None:
413
+ descriptor = os.open(
414
+ path,
415
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
416
+ 0o600,
417
+ )
418
+ with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
419
+ stream.write(content)
420
+ stream.flush()
421
+ os.fsync(stream.fileno())
422
+
423
+
424
+ def _runtime_platform_tag() -> str:
425
+ """Return the supported runtime platform identity without caller input."""
426
+
427
+ machine = platform.machine().casefold()
428
+ architectures = {
429
+ "x86_64": "x86_64",
430
+ "amd64": "x86_64",
431
+ "aarch64": "aarch64",
432
+ "arm64": "aarch64",
433
+ }
434
+ architecture = architectures.get(machine)
435
+ if sys.platform != "linux" or architecture is None:
436
+ raise ValueError("this deployment CLI build supports linux x86_64 or aarch64")
437
+ return f"linux-{architecture}"
438
+
439
+
440
+ def _read_public_key(path: Path) -> bytes:
441
+ descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
442
+ with os.fdopen(descriptor, "rb") as stream:
443
+ details = os.fstat(stream.fileno())
444
+ if not stat.S_ISREG(details.st_mode) or details.st_size > 65_536:
445
+ raise ValueError("public key MUST be a regular file within 65536 bytes")
446
+ return stream.read(65_537)
447
+
448
+
449
+ def _print_mapping(result: Mapping[str, object], *, output: str, text: str) -> None:
450
+ print(json.dumps(result, sort_keys=True, separators=(",", ":")) if output == "json" else text)
@@ -0,0 +1,99 @@
1
+ """Compile the fixed subscription-genesis dependency graph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fdai_deployment_cli.contracts import (
6
+ ApprovalClass,
7
+ ManifestEntry,
8
+ ProvisionProfile,
9
+ SubscriptionProvisioningManifest,
10
+ canonical_digest,
11
+ )
12
+
13
+ _GENESIS_V1_ENTRIES: tuple[tuple[str, ApprovalClass], ...] = (
14
+ ("inspect-context", ApprovalClass.STANDARD),
15
+ ("reconcile-current-state", ApprovalClass.STANDARD),
16
+ ("provider-registrations", ApprovalClass.HIGH_IMPACT),
17
+ ("ops-resource-group", ApprovalClass.HIGH_IMPACT),
18
+ ("application-resource-group", ApprovalClass.HIGH_IMPACT),
19
+ ("state-storage", ApprovalClass.HIGH_IMPACT),
20
+ ("ops-network", ApprovalClass.HIGH_IMPACT),
21
+ ("deploy-identity", ApprovalClass.HIGH_IMPACT),
22
+ ("foundation", ApprovalClass.HIGH_IMPACT),
23
+ ("runner", ApprovalClass.HIGH_IMPACT),
24
+ ("attest-runner", ApprovalClass.STANDARD),
25
+ ("application-network", ApprovalClass.HIGH_IMPACT),
26
+ ("key-vault", ApprovalClass.HIGH_IMPACT),
27
+ ("postgres", ApprovalClass.HIGH_IMPACT),
28
+ ("event-hubs-primary", ApprovalClass.HIGH_IMPACT),
29
+ ("event-hubs-operational", ApprovalClass.HIGH_IMPACT),
30
+ ("container-registry", ApprovalClass.HIGH_IMPACT),
31
+ ("log-analytics", ApprovalClass.HIGH_IMPACT),
32
+ ("application-insights", ApprovalClass.HIGH_IMPACT),
33
+ ("container-apps-environment", ApprovalClass.HIGH_IMPACT),
34
+ ("database", ApprovalClass.HIGH_IMPACT),
35
+ ("semantic-defaults", ApprovalClass.STANDARD),
36
+ ("model-account-openai", ApprovalClass.HIGH_IMPACT),
37
+ ("model-account-foundry", ApprovalClass.HIGH_IMPACT),
38
+ ("model-deployments", ApprovalClass.HIGH_IMPACT),
39
+ ("runtime-identities", ApprovalClass.HIGH_IMPACT),
40
+ ("core-control-plane", ApprovalClass.HIGH_IMPACT),
41
+ ("operator-service", ApprovalClass.HIGH_IMPACT),
42
+ ("document-ingestion-api", ApprovalClass.HIGH_IMPACT),
43
+ ("document-processing-worker", ApprovalClass.HIGH_IMPACT),
44
+ ("isolated-executor", ApprovalClass.HIGH_IMPACT),
45
+ ("inventory-job", ApprovalClass.STANDARD),
46
+ ("canary-job", ApprovalClass.STANDARD),
47
+ ("initial-inventory", ApprovalClass.STANDARD),
48
+ ("console", ApprovalClass.STANDARD),
49
+ ("monitoring", ApprovalClass.STANDARD),
50
+ ("system-readiness", ApprovalClass.STANDARD),
51
+ )
52
+
53
+ _ENTRIES: tuple[tuple[str, ApprovalClass], ...] = (
54
+ *_GENESIS_V1_ENTRIES[:33],
55
+ ("console", ApprovalClass.STANDARD),
56
+ ("initial-inventory", ApprovalClass.STANDARD),
57
+ *_GENESIS_V1_ENTRIES[35:],
58
+ )
59
+ GENESIS_MANIFEST_VERSION = "genesis.v2"
60
+ GENESIS_ENTRY_IDS = tuple(entry_id for entry_id, _approval in _ENTRIES)
61
+ GENESIS_ENTRY_IDS_BY_VERSION = {
62
+ "genesis.v1": tuple(entry_id for entry_id, _approval in _GENESIS_V1_ENTRIES),
63
+ GENESIS_MANIFEST_VERSION: GENESIS_ENTRY_IDS,
64
+ }
65
+
66
+
67
+ def compile_manifest(
68
+ profile: ProvisionProfile,
69
+ *,
70
+ source_commit: str,
71
+ ) -> SubscriptionProvisioningManifest:
72
+ """Compile the finite stage chain and bind every idempotency key to the profile."""
73
+
74
+ profile_digest = canonical_digest(profile.to_mapping())
75
+ entries: list[ManifestEntry] = []
76
+ previous: str | None = None
77
+ for stage, approval in _ENTRIES:
78
+ timeout = 3_600 if approval is ApprovalClass.HIGH_IMPACT else 900
79
+ no_progress = 300 if approval is ApprovalClass.HIGH_IMPACT else 120
80
+ entries.append(
81
+ ManifestEntry(
82
+ entry_id=stage,
83
+ owner="deployment",
84
+ desired_state="ready",
85
+ prerequisites=() if previous is None else (previous,),
86
+ approval_class=approval,
87
+ idempotency_key=(f"genesis.{profile_digest[:16]}.{source_commit}.{stage}"),
88
+ timeout_seconds=timeout,
89
+ no_progress_seconds=no_progress,
90
+ rollback_ref=f"rollback.{stage}",
91
+ observer=f"observer.{stage}",
92
+ )
93
+ )
94
+ previous = stage
95
+ return SubscriptionProvisioningManifest(
96
+ source_commit=source_commit,
97
+ profile_digest=profile_digest,
98
+ entries=tuple(entries),
99
+ )