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,402 @@
1
+ """Read-only Azure observations for the pre-runner bootstrap boundary."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ from collections.abc import Callable
10
+ from dataclasses import dataclass
11
+ from datetime import UTC, datetime, timedelta
12
+
13
+ from fdai_deployment_cli.bootstrap_plan import (
14
+ FOUNDATION_PROVIDER_NAMESPACES,
15
+ BootstrapObservation,
16
+ BootstrapReconcileResult,
17
+ Classification,
18
+ build_intent,
19
+ timestamp,
20
+ validate_inputs,
21
+ )
22
+ from fdai_deployment_cli.contracts import ProvisionProfile
23
+ from fdai_deployment_cli.target import compute_target_binding
24
+
25
+ _NOT_FOUND_CODES = frozenset({"ResourceGroupNotFound", "ResourceNotFound"})
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class CommandResult:
30
+ """Bounded result from one fixed Azure CLI read."""
31
+
32
+ returncode: int
33
+ stdout: str
34
+ stderr: str = ""
35
+
36
+
37
+ CommandRunner = Callable[[tuple[str, ...]], CommandResult]
38
+
39
+
40
+ def reconcile_bootstrap(
41
+ profile: ProvisionProfile,
42
+ *,
43
+ source_commit: str,
44
+ ops_resource_group: str,
45
+ app_resource_group: str,
46
+ state_storage_account: str,
47
+ ttl_seconds: int = 3600,
48
+ now: datetime | None = None,
49
+ run: CommandRunner | None = None,
50
+ ) -> BootstrapReconcileResult:
51
+ """Read and seal the bounded state needed before a foundation approval."""
52
+
53
+ validate_inputs(
54
+ source_commit=source_commit,
55
+ ops_resource_group=ops_resource_group,
56
+ app_resource_group=app_resource_group,
57
+ state_storage_account=state_storage_account,
58
+ ttl_seconds=ttl_seconds,
59
+ )
60
+ command_runner = run or run_azure_cli
61
+ subscription_id, target_binding = _active_target(command_runner)
62
+ if target_binding != profile.target_binding:
63
+ raise ValueError("active Azure target does not match the provision profile")
64
+
65
+ observations = [
66
+ *(
67
+ _observe_provider(command_runner, namespace, subscription_id)
68
+ for namespace in FOUNDATION_PROVIDER_NAMESPACES
69
+ ),
70
+ _observe_resource_group(
71
+ command_runner,
72
+ "ops-resource-group",
73
+ ops_resource_group,
74
+ profile.region,
75
+ subscription_id,
76
+ ),
77
+ _observe_resource_group(
78
+ command_runner,
79
+ "application-resource-group",
80
+ app_resource_group,
81
+ profile.region,
82
+ subscription_id,
83
+ ),
84
+ _observe_storage(
85
+ command_runner,
86
+ state_storage_account,
87
+ ops_resource_group,
88
+ profile.region,
89
+ subscription_id,
90
+ ),
91
+ ]
92
+ moment = (now or datetime.now(UTC)).astimezone(UTC)
93
+ return BootstrapReconcileResult(
94
+ intent=build_intent(
95
+ profile=profile,
96
+ source_commit=source_commit,
97
+ ops_resource_group=ops_resource_group,
98
+ app_resource_group=app_resource_group,
99
+ state_storage_account=state_storage_account,
100
+ ),
101
+ observations=tuple(observations),
102
+ created_at=timestamp(moment),
103
+ expires_at=timestamp(moment + timedelta(seconds=ttl_seconds)),
104
+ )
105
+
106
+
107
+ def run_azure_cli(arguments: tuple[str, ...]) -> CommandResult:
108
+ """Execute one fixed Azure CLI read without exposing provider error text."""
109
+
110
+ executable = shutil.which("az")
111
+ if executable is None:
112
+ raise OSError("Azure CLI is unavailable")
113
+ try:
114
+ completed = subprocess.run(
115
+ [executable, *arguments],
116
+ check=False,
117
+ capture_output=True,
118
+ text=True,
119
+ timeout=30,
120
+ )
121
+ except subprocess.TimeoutExpired:
122
+ return CommandResult(returncode=124, stdout="", stderr="")
123
+ return CommandResult(
124
+ returncode=completed.returncode,
125
+ stdout=completed.stdout[:65_536],
126
+ stderr=completed.stderr[:65_536],
127
+ )
128
+
129
+
130
+ def _active_target(run: CommandRunner) -> tuple[str, str]:
131
+ result = run(
132
+ (
133
+ "account",
134
+ "show",
135
+ "--query",
136
+ "{subscription:id,tenant:tenantId}",
137
+ "--output",
138
+ "json",
139
+ "--only-show-errors",
140
+ )
141
+ )
142
+ if result.returncode != 0:
143
+ raise ValueError("azure_target_unavailable")
144
+ payload = _json_object(result.stdout, "Azure target")
145
+ subscription = payload.get("subscription")
146
+ tenant = payload.get("tenant")
147
+ if not isinstance(subscription, str) or not isinstance(tenant, str):
148
+ raise ValueError("azure_target_invalid")
149
+ return subscription, compute_target_binding(tenant_id=tenant, subscription_id=subscription)
150
+
151
+
152
+ def _observe_provider(
153
+ run: CommandRunner,
154
+ namespace: str,
155
+ subscription_id: str,
156
+ ) -> BootstrapObservation:
157
+ result = run(
158
+ (
159
+ "provider",
160
+ "show",
161
+ "--namespace",
162
+ namespace,
163
+ "--subscription",
164
+ subscription_id,
165
+ "--query",
166
+ "registrationState",
167
+ "--output",
168
+ "tsv",
169
+ "--only-show-errors",
170
+ )
171
+ )
172
+ entry_id = f"provider.{namespace.casefold()}"
173
+ if result.returncode != 0:
174
+ return BootstrapObservation(entry_id, Classification.INDETERMINATE, "provider_read_failed")
175
+ state = result.stdout.strip()
176
+ if state == "Registered":
177
+ return BootstrapObservation(entry_id, Classification.COMPATIBLE, "provider_registered")
178
+ if state in {"NotRegistered", "Unregistered"}:
179
+ return BootstrapObservation(
180
+ entry_id, Classification.MISSING, "provider_registration_required"
181
+ )
182
+ return BootstrapObservation(entry_id, Classification.INDETERMINATE, "provider_state_unresolved")
183
+
184
+
185
+ def _observe_resource_group(
186
+ run: CommandRunner,
187
+ entry_id: str,
188
+ name: str,
189
+ region: str,
190
+ subscription_id: str,
191
+ ) -> BootstrapObservation:
192
+ result = run(
193
+ (
194
+ "group",
195
+ "show",
196
+ "--name",
197
+ name,
198
+ "--subscription",
199
+ subscription_id,
200
+ "--query",
201
+ "{name:name,location:location}",
202
+ "--output",
203
+ "json",
204
+ "--only-show-errors",
205
+ )
206
+ )
207
+ if result.returncode != 0:
208
+ if _error_code(result.stderr) == "ResourceGroupNotFound":
209
+ return BootstrapObservation(entry_id, Classification.MISSING, "resource_group_missing")
210
+ return BootstrapObservation(
211
+ entry_id, Classification.INDETERMINATE, "resource_group_read_failed"
212
+ )
213
+ payload = _json_object(result.stdout, "resource group")
214
+ if (
215
+ payload.get("name") != name
216
+ or str(payload.get("location", "")).casefold() != region.casefold()
217
+ ):
218
+ return BootstrapObservation(entry_id, Classification.CONFLICT, "resource_group_conflict")
219
+ return BootstrapObservation(entry_id, Classification.COMPATIBLE, "resource_group_compatible")
220
+
221
+
222
+ def _observe_storage(
223
+ run: CommandRunner,
224
+ name: str,
225
+ resource_group: str,
226
+ region: str,
227
+ subscription_id: str,
228
+ ) -> BootstrapObservation:
229
+ result = run(
230
+ (
231
+ "storage",
232
+ "account",
233
+ "show",
234
+ "--name",
235
+ name,
236
+ "--resource-group",
237
+ resource_group,
238
+ "--subscription",
239
+ subscription_id,
240
+ "--query",
241
+ "{name:name,location:location,kind:kind,sku:sku.name,"
242
+ "minimum_tls_version:minimumTlsVersion,public_network_access:publicNetworkAccess,"
243
+ "shared_key_access:allowSharedKeyAccess,blob_public_access:allowBlobPublicAccess,"
244
+ "cross_tenant_replication:allowCrossTenantReplication}",
245
+ "--output",
246
+ "json",
247
+ "--only-show-errors",
248
+ )
249
+ )
250
+ if result.returncode == 0:
251
+ posture = _classify_existing_storage(result.stdout, name=name, region=region)
252
+ if posture.classification is not Classification.COMPATIBLE:
253
+ return posture
254
+ return _observe_storage_data_protection(
255
+ run,
256
+ name=name,
257
+ resource_group=resource_group,
258
+ subscription_id=subscription_id,
259
+ )
260
+ if _error_code(result.stderr) not in _NOT_FOUND_CODES:
261
+ return BootstrapObservation(
262
+ "state-storage", Classification.INDETERMINATE, "state_storage_read_failed"
263
+ )
264
+ return _observe_storage_name(run, name=name, subscription_id=subscription_id)
265
+
266
+
267
+ def _classify_existing_storage(value: str, *, name: str, region: str) -> BootstrapObservation:
268
+ payload = _json_object(value, "state storage account")
269
+ expected: dict[str, object] = {
270
+ "name": name,
271
+ "location": region.casefold(),
272
+ "kind": "StorageV2",
273
+ "sku": "Standard_LRS",
274
+ "minimum_tls_version": "TLS1_2",
275
+ "public_network_access": "Disabled",
276
+ "shared_key_access": False,
277
+ "blob_public_access": False,
278
+ "cross_tenant_replication": False,
279
+ }
280
+ normalized = dict(payload)
281
+ normalized["location"] = str(normalized.get("location", "")).casefold()
282
+ if normalized != expected:
283
+ return BootstrapObservation(
284
+ "state-storage", Classification.CONFLICT, "state_storage_posture_conflict"
285
+ )
286
+ return BootstrapObservation(
287
+ "state-storage", Classification.COMPATIBLE, "state_storage_compatible"
288
+ )
289
+
290
+
291
+ def _observe_storage_data_protection(
292
+ run: CommandRunner,
293
+ *,
294
+ name: str,
295
+ resource_group: str,
296
+ subscription_id: str,
297
+ ) -> BootstrapObservation:
298
+ result = run(
299
+ (
300
+ "storage",
301
+ "account",
302
+ "blob-service-properties",
303
+ "show",
304
+ "--account-name",
305
+ name,
306
+ "--resource-group",
307
+ resource_group,
308
+ "--subscription",
309
+ subscription_id,
310
+ "--query",
311
+ "{versioning:isVersioningEnabled,blob_delete_retention:deleteRetentionPolicy.enabled,"
312
+ "container_delete_retention:containerDeleteRetentionPolicy.enabled}",
313
+ "--output",
314
+ "json",
315
+ "--only-show-errors",
316
+ )
317
+ )
318
+ if result.returncode != 0:
319
+ return BootstrapObservation(
320
+ "state-storage",
321
+ Classification.INDETERMINATE,
322
+ "state_storage_data_protection_read_failed",
323
+ )
324
+ payload = _json_object(result.stdout, "state storage data protection")
325
+ if payload != {
326
+ "versioning": True,
327
+ "blob_delete_retention": True,
328
+ "container_delete_retention": True,
329
+ }:
330
+ return BootstrapObservation(
331
+ "state-storage",
332
+ Classification.MISSING,
333
+ "state_storage_data_protection_required",
334
+ )
335
+ return BootstrapObservation(
336
+ "state-storage",
337
+ Classification.COMPATIBLE,
338
+ "state_storage_compatible",
339
+ )
340
+
341
+
342
+ def _observe_storage_name(
343
+ run: CommandRunner,
344
+ *,
345
+ name: str,
346
+ subscription_id: str,
347
+ ) -> BootstrapObservation:
348
+ result = run(
349
+ (
350
+ "storage",
351
+ "account",
352
+ "check-name",
353
+ "--name",
354
+ name,
355
+ "--subscription",
356
+ subscription_id,
357
+ "--query",
358
+ "{available:nameAvailable,reason:reason}",
359
+ "--output",
360
+ "json",
361
+ "--only-show-errors",
362
+ )
363
+ )
364
+ if result.returncode != 0:
365
+ return BootstrapObservation(
366
+ "state-storage", Classification.INDETERMINATE, "state_storage_name_check_failed"
367
+ )
368
+ available = _json_object(result.stdout, "state storage name").get("available")
369
+ if available is True:
370
+ return BootstrapObservation(
371
+ "state-storage", Classification.MISSING, "state_storage_missing"
372
+ )
373
+ if available is False:
374
+ return BootstrapObservation(
375
+ "state-storage", Classification.CONFLICT, "state_storage_name_unavailable"
376
+ )
377
+ return BootstrapObservation(
378
+ "state-storage", Classification.INDETERMINATE, "state_storage_name_unresolved"
379
+ )
380
+
381
+
382
+ def _json_object(value: str, label: str) -> dict[str, object]:
383
+ try:
384
+ payload = json.loads(value)
385
+ except json.JSONDecodeError as exc:
386
+ raise ValueError(f"{label} returned invalid JSON") from exc
387
+ if not isinstance(payload, dict):
388
+ raise ValueError(f"{label} returned invalid JSON")
389
+ return payload
390
+
391
+
392
+ def _error_code(value: str) -> str | None:
393
+ try:
394
+ payload = json.loads(value)
395
+ except json.JSONDecodeError:
396
+ payload = None
397
+ if isinstance(payload, dict):
398
+ error = payload.get("error", payload)
399
+ if isinstance(error, dict) and isinstance(error.get("code"), str):
400
+ return str(error["code"])
401
+ match = re.search(r"\b(ResourceGroupNotFound|ResourceNotFound)\b", value)
402
+ return match.group(1) if match else None
@@ -0,0 +1,138 @@
1
+ """Resolve immutable support source only after the existing signed Hub verification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+ from fdai_deployment_cli.hub_image_deploy import DEFAULT_HUB_URL, hub_request
11
+ from fdai_deployment_cli.hub_image_trust import (
12
+ MAX_SELECTION_BYTES,
13
+ HubImageSelection,
14
+ load_selection,
15
+ selection_bytes,
16
+ verify_export,
17
+ )
18
+ from fdai_deployment_cli.private_output import write_private_bytes
19
+ from fdai_deployment_cli.source_input import inspect_source
20
+
21
+ OFFICIAL_SOURCE = "https://github.com/dotnetpower/fdai.git"
22
+
23
+
24
+ def resolve_hub_source(
25
+ area: Path, pem: str, supplied: str | None
26
+ ) -> tuple[Path, HubImageSelection]:
27
+ """Fetch one verified content-addressed commit, never branches or executing hooks."""
28
+ selection_path = area / "deployment/hub-images/selection.json"
29
+ if selection_path.exists():
30
+ from fdai_deployment_cli.private_output import read_private_bytes
31
+
32
+ raw = read_private_bytes(selection_path, max_bytes=MAX_SELECTION_BYTES)
33
+ selection = load_selection(selection_path, hashlib.sha256(raw).hexdigest())
34
+ if (
35
+ selection.document["public_key"] != pem
36
+ or selection.document["hub_url"] != DEFAULT_HUB_URL
37
+ ):
38
+ raise ValueError("Retained Hub trust changed")
39
+ # Refresh the signed catalog, recall and expiry using the existing immutable selection seam.
40
+ from fdai_deployment_cli.hub_image_deploy import prepare_hub_selection
41
+
42
+ source = Path(supplied) if supplied else area / "support-source"
43
+ key_path = area / "hub-public.pem"
44
+ if not key_path.exists():
45
+ write_private_bytes(key_path, pem.encode())
46
+ elif key_path.is_symlink() or key_path.read_bytes() != pem.encode():
47
+ raise ValueError("Retained Hub public key changed")
48
+ selection = prepare_hub_selection(
49
+ source_root=source,
50
+ work_dir=area / "deployment",
51
+ public_key_path=key_path,
52
+ )
53
+ return source, selection
54
+ _, headers, raw = hub_request(DEFAULT_HUB_URL, "/v1/catalog/export")
55
+ verified = verify_export(
56
+ raw,
57
+ signature=headers["x-fdai-export-signature"],
58
+ export_key_id=headers["x-fdai-export-key-id"],
59
+ trusted_pem=pem.encode(),
60
+ channel="RELEASE",
61
+ release_id=None,
62
+ )
63
+ commit = verified["source_commit"]
64
+ if not isinstance(commit, str) or re.fullmatch(r"[a-f0-9]{40}", commit) is None:
65
+ raise ValueError("Signed support source commit is invalid")
66
+ source = Path(supplied).absolute() if supplied else area / "support-source"
67
+ if supplied is None:
68
+ if source.exists():
69
+ inspect_source(source, expected_commit=commit)
70
+ deployment = area / "deployment"
71
+ if deployment.exists() and any(deployment.iterdir()):
72
+ raise ValueError("Hub support cannot adopt partial or source deployment state")
73
+ deployment.mkdir(mode=0o700, exist_ok=True)
74
+ else:
75
+ source.mkdir(mode=0o700)
76
+ commands = (
77
+ ("git", "init", "--quiet", str(source)),
78
+ (
79
+ "git",
80
+ "-C",
81
+ str(source),
82
+ "-c",
83
+ "core.hooksPath=/dev/null",
84
+ "fetch",
85
+ "--quiet",
86
+ "--depth=1",
87
+ "--no-tags",
88
+ OFFICIAL_SOURCE,
89
+ commit,
90
+ ),
91
+ (
92
+ "git",
93
+ "-C",
94
+ str(source),
95
+ "-c",
96
+ "core.hooksPath=/dev/null",
97
+ "checkout",
98
+ "--quiet",
99
+ "--detach",
100
+ commit,
101
+ ),
102
+ )
103
+ import os
104
+
105
+ environment = {
106
+ key: value
107
+ for key, value in os.environ.items()
108
+ if key in {"PATH", "HOME", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE"}
109
+ }
110
+ environment.update(
111
+ GIT_CONFIG_GLOBAL=os.devnull,
112
+ GIT_CONFIG_NOSYSTEM="1",
113
+ GIT_TERMINAL_PROMPT="0",
114
+ )
115
+ for command in commands:
116
+ result = subprocess.run(
117
+ command,
118
+ stdin=subprocess.DEVNULL,
119
+ capture_output=True,
120
+ timeout=300,
121
+ check=False,
122
+ env=environment,
123
+ )
124
+ if result.returncode:
125
+ raise ValueError("Pinned official support source retrieval failed; no fallback")
126
+ inspect_source(source, expected_commit=commit)
127
+ selected = selection_bytes(
128
+ raw=raw,
129
+ signature=headers["x-fdai-export-signature"],
130
+ export_key_id=headers["x-fdai-export-key-id"],
131
+ pem=pem.encode(),
132
+ hub_url=DEFAULT_HUB_URL,
133
+ channel="RELEASE",
134
+ verified=verified,
135
+ )
136
+ selection_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
137
+ write_private_bytes(selection_path, selected)
138
+ return source, load_selection(selection_path, hashlib.sha256(selected).hexdigest())