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,558 @@
1
+ """AKS initial-inventory execution and independent closure for standalone deployment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import re
9
+ import subprocess
10
+ from datetime import UTC, datetime, timedelta
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from fdai_deployment_cli.contracts import canonical_digest
15
+ from fdai_deployment_cli.standalone_aks_job_execution import execute_aks_cronjob_once
16
+ from fdai_deployment_cli.standalone_host_state import private_json, replace_private_json
17
+ from fdai_deployment_cli.standalone_host_values import vault_name
18
+
19
+ _GUID = re.compile(r"^[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$")
20
+ _CLOSURE_CLOCK_SKEW = timedelta(seconds=60)
21
+ _INITIAL_INVENTORY_SOURCES = ("arg", "arm")
22
+ _ATTEMPT_SCHEMA = "fdai.aks-initial-inventory-attempt.v1"
23
+ _MAX_ATTEMPTS = 3
24
+ _CLOSURE_FAILURE = re.compile(r"inventory closure verification failed: ([a-z_]+(?:,[a-z_]+)*)")
25
+ _CLOSURE_CONDITIONS = frozenset(
26
+ {
27
+ "verified_progress",
28
+ "active",
29
+ "active_generation",
30
+ "observed",
31
+ "source",
32
+ "subscription_scope",
33
+ "full_scope",
34
+ "provider_coverage",
35
+ "unmapped_reconciled",
36
+ "overlay_closed",
37
+ "child_sources",
38
+ "projection",
39
+ "fresh_generation",
40
+ }
41
+ )
42
+ # Without retained independent closure, a superseded Job generation needs a new bounded attempt.
43
+ _SUPERSEDED_CLOSURE_CONDITIONS = frozenset({"active_generation"})
44
+
45
+
46
+ class _CapturedFailure(ValueError):
47
+ """A captured command failed; the bounded stderr stays available for classification."""
48
+
49
+ def __init__(self, reason: str, stderr: str) -> None:
50
+ super().__init__(reason)
51
+ self.stderr = stderr
52
+
53
+
54
+ def initial_inventory_binding(subscription_id: str) -> dict[str, object]:
55
+ """Return the protected full-subscription source and filter contract."""
56
+
57
+ if _GUID.fullmatch(subscription_id) is None:
58
+ raise ValueError("initial inventory subscription binding is invalid")
59
+ scope = f"/subscriptions/{subscription_id.casefold()}"
60
+ material: dict[str, object] = {
61
+ "schema_version": "fdai.initial-inventory-binding.v1",
62
+ "scope_digest": "sha256:" + hashlib.sha256(scope.encode()).hexdigest(),
63
+ "sources": list(_INITIAL_INVENTORY_SOURCES),
64
+ "resource_types": [],
65
+ }
66
+ return {**material, "binding_digest": canonical_digest(material)}
67
+
68
+
69
+ def run_initial_aks_inventory(
70
+ context: dict[str, Any],
71
+ work_dir: Path,
72
+ *,
73
+ receipt_path: Path,
74
+ ) -> dict[str, object]:
75
+ """Run the exact CronJob template and independently verify its active generation."""
76
+
77
+ source_revision = str(context.get("source_commit", ""))
78
+ target_binding = str(context.get("target_binding", ""))
79
+ binding = _validated_initial_inventory_binding(context)
80
+ sources = binding["sources"]
81
+ if not isinstance(sources, list):
82
+ raise ValueError( # noqa: TRY004 - normalize untrusted deployment state
83
+ "initial inventory source binding is invalid"
84
+ )
85
+ progress_url = str(context.get("inventory_progress_container_url", ""))
86
+ if not progress_url.startswith("https://"):
87
+ raise ValueError("initial inventory progress endpoint is invalid")
88
+ image_refs = _mapping(context.get("image_refs"), "runtime image references")
89
+ core_image = str(image_refs.get("core-control-plane", ""))
90
+ run_id = f"genesis.{source_revision}"
91
+ attempt = _begin_inventory_attempt(
92
+ work_dir,
93
+ source_revision=source_revision,
94
+ target_binding=target_binding,
95
+ )
96
+ attempt_id = (
97
+ "attempt."
98
+ + hashlib.sha256(
99
+ f"{source_revision}:{target_binding}:initial-inventory:{attempt}".encode()
100
+ ).hexdigest()[:32]
101
+ )
102
+ retry_attempt_id = (
103
+ "attempt."
104
+ + hashlib.sha256(
105
+ f"{source_revision}:{target_binding}:initial-inventory:{attempt}:retry-1".encode()
106
+ ).hexdigest()[:32]
107
+ )
108
+ second_retry_attempt_id = (
109
+ "attempt."
110
+ + hashlib.sha256(
111
+ f"{source_revision}:{target_binding}:initial-inventory:{attempt}:retry-2".encode()
112
+ ).hexdigest()[:32]
113
+ )
114
+ execution_environment = {
115
+ "FDAI_INVENTORY_EXECUTION_ATTEMPT": str(attempt),
116
+ "FDAI_INVENTORY_RESOURCE_TYPES": "",
117
+ "FDAI_INVENTORY_SCOPES": str(context["subscription_id"]),
118
+ "FDAI_INVENTORY_SOURCES": ",".join(str(source) for source in sources),
119
+ # The operations progress container is private to the managed host. The in-cluster
120
+ # Job records progress in PostgreSQL; the host closure completes the blob chain.
121
+ "FDAI_INVENTORY_PROGRESS_ATTEMPT_ID": attempt_id,
122
+ "FDAI_INVENTORY_PROGRESS_RUN_ID": run_id,
123
+ }
124
+ try:
125
+ execution = execute_aks_cronjob_once(
126
+ context,
127
+ work_dir,
128
+ template_name="inventory",
129
+ purpose="inventory",
130
+ expected_container_name="inventory",
131
+ expected_image=core_image,
132
+ expected_command=("python", "-m", "fdai.delivery.inventory_sync_cli"),
133
+ expected_service_account="inventory-job",
134
+ args=("--initial",),
135
+ environment=execution_environment,
136
+ require_suspended=False,
137
+ timeout_seconds=960,
138
+ failed_successor_limit=2,
139
+ retry_environments=(
140
+ {
141
+ **execution_environment,
142
+ "FDAI_INVENTORY_PROGRESS_ATTEMPT_ID": retry_attempt_id,
143
+ },
144
+ {
145
+ **execution_environment,
146
+ "FDAI_INVENTORY_PROGRESS_ATTEMPT_ID": second_retry_attempt_id,
147
+ },
148
+ ),
149
+ )
150
+ except ValueError as exc:
151
+ if str(exc) == "AKS inventory Job failed":
152
+ _write_inventory_attempt(
153
+ work_dir,
154
+ source_revision=source_revision,
155
+ target_binding=target_binding,
156
+ attempt=attempt,
157
+ state="failed",
158
+ )
159
+ raise
160
+ _write_inventory_attempt(
161
+ work_dir,
162
+ source_revision=source_revision,
163
+ target_binding=target_binding,
164
+ attempt=attempt,
165
+ state="succeeded",
166
+ )
167
+ if execution.name.startswith("fdai-inventory-retry-1-"):
168
+ attempt_id = retry_attempt_id
169
+ elif execution.name.startswith("fdai-inventory-retry-2-"):
170
+ attempt_id = second_retry_attempt_id
171
+ infra = Path(str(context["infra"]))
172
+ bundle = infra.parent
173
+ key_vault_name = vault_name(_terraform_output(infra, "key_vault_uri"))
174
+ dsn = _capture(
175
+ (
176
+ "az",
177
+ "keyvault",
178
+ "secret",
179
+ "show",
180
+ "--vault-name",
181
+ key_vault_name,
182
+ "--name",
183
+ "fdai-state-store-dsn",
184
+ "--query",
185
+ "value",
186
+ "--output",
187
+ "tsv",
188
+ "--only-show-errors",
189
+ ),
190
+ cwd=work_dir,
191
+ timeout=120,
192
+ reason="initial inventory database reference is unavailable",
193
+ ).strip()
194
+ if not dsn or any(character in dsn for character in "\r\n"):
195
+ raise ValueError("initial inventory database reference is invalid")
196
+ runtime_python = work_dir / "runtime-venv/bin/python"
197
+ closure_environment = {
198
+ **os.environ,
199
+ "AZURE_CLIENT_ID": str(context["client_id"]),
200
+ "FDAI_MI_CLIENT_ID": str(context["client_id"]),
201
+ "FDAI_EXECUTION_VENUE": "deployed",
202
+ "FDAI_INVENTORY_DSN": dsn,
203
+ "FDAI_INVENTORY_EXPECTED_SCOPE_DIGEST": str(binding["scope_digest"]),
204
+ "FDAI_INVENTORY_RESOURCE_TYPES": "",
205
+ "FDAI_INVENTORY_SCOPES": str(context["subscription_id"]),
206
+ "FDAI_INVENTORY_SOURCES": ",".join(str(source) for source in sources),
207
+ "FDAI_INVENTORY_PROGRESS_CONTAINER_URL": progress_url,
208
+ "FDAI_INVENTORY_PROGRESS_RUN_ID": run_id,
209
+ "FDAI_INVENTORY_PROGRESS_ATTEMPT_ID": attempt_id,
210
+ "PYTHONPATH": os.pathsep.join(
211
+ (
212
+ str(bundle / "services/core-control-plane/src"),
213
+ str(bundle / "packages/service-contracts/src"),
214
+ )
215
+ ),
216
+ }
217
+ attempt_record = private_json(work_dir / "initial-inventory-attempt.json", "inventory attempt")
218
+ closure_started_at = _parse_observed_at(attempt_record.get("started_at"))
219
+ if closure_started_at is None:
220
+ raise ValueError("initial inventory attempt start time is invalid")
221
+ try:
222
+ closure_output = _capture_env(
223
+ (str(runtime_python), "-m", "fdai.delivery.inventory_closure_cli"),
224
+ cwd=bundle,
225
+ env=closure_environment,
226
+ timeout=300,
227
+ reason="initial inventory independent closure failed",
228
+ )
229
+ except _CapturedFailure as exc:
230
+ conditions = _failed_closure_conditions(exc.stderr)
231
+ if conditions and conditions <= _SUPERSEDED_CLOSURE_CONDITIONS:
232
+ _write_inventory_attempt(
233
+ work_dir,
234
+ source_revision=source_revision,
235
+ target_binding=target_binding,
236
+ attempt=attempt,
237
+ state="failed",
238
+ )
239
+ raise ValueError(
240
+ "initial inventory independent closure failed: the Job generation was "
241
+ "superseded by a newer active generation; the next invocation starts a new "
242
+ f"attempt ({','.join(sorted(conditions))})"
243
+ ) from None
244
+ if conditions:
245
+ raise ValueError(
246
+ f"initial inventory independent closure failed: {','.join(sorted(conditions))}"
247
+ ) from None
248
+ raise
249
+ closure = _verified_closure(
250
+ closure_output,
251
+ expected_attempt_id=attempt_id,
252
+ expected_run_id=run_id,
253
+ expected_scope_digest=str(binding["scope_digest"]),
254
+ observed_after=closure_started_at,
255
+ )
256
+ closure_environment["FDAI_INVENTORY_DSN"] = ""
257
+ receipt: dict[str, object] = {
258
+ "schema_version": "fdai.standalone-aks-initial-inventory-receipt.v1",
259
+ "state": "inventory-verified",
260
+ "source_revision": source_revision,
261
+ "run_id": run_id,
262
+ "attempt_id": attempt_id,
263
+ "execution_ref_digest": hashlib.sha256(execution.name.encode()).hexdigest(),
264
+ "execution_template_digest": execution.execution_digest,
265
+ "execution_observer": "kubernetes-job",
266
+ "inventory_binding_digest": binding["binding_digest"],
267
+ "scope_digest": closure["scope_digest"],
268
+ "sources": list(sources),
269
+ "full_subscription": closure["subscription_root"],
270
+ "resource_type_filter": closure["resource_type_filter"],
271
+ "progress_persisted": True,
272
+ "complete_generation_readback_verified": True,
273
+ "active_generation_readback_verified": True,
274
+ "generation_digest": closure["generation_digest"],
275
+ "fresh_generation": closure["fresh_generation"],
276
+ "freshness_observed_at": closure["observed_at"],
277
+ "resource_count": closure["resource_count"],
278
+ "link_count": closure["link_count"],
279
+ "unmapped_object_count": closure["unmapped_object_count"],
280
+ "coverage_gap_count": closure["coverage_gap_count"],
281
+ "closure_receipt_digest": closure["receipt_digest"],
282
+ "closure_receipt_verified": True,
283
+ "effect_verified": True,
284
+ "mutation_performed": True,
285
+ "subscription_ready": False,
286
+ }
287
+ receipt["receipt_digest"] = canonical_digest(receipt)
288
+ replace_private_json(receipt_path, receipt)
289
+ return receipt
290
+
291
+
292
+ def _begin_inventory_attempt(
293
+ work_dir: Path,
294
+ *,
295
+ source_revision: str,
296
+ target_binding: str,
297
+ ) -> int:
298
+ path = work_dir / "initial-inventory-attempt.json"
299
+ if not path.exists():
300
+ _write_inventory_attempt(
301
+ work_dir,
302
+ source_revision=source_revision,
303
+ target_binding=target_binding,
304
+ attempt=1,
305
+ state="active",
306
+ )
307
+ return 1
308
+ value = private_json(path, "initial inventory attempt")
309
+ digest = value.pop("record_digest", None)
310
+ attempt = value.get("attempt")
311
+ state = value.get("state")
312
+ if (
313
+ digest != canonical_digest(value)
314
+ or value.get("schema_version") != _ATTEMPT_SCHEMA
315
+ or value.get("source_revision") != source_revision
316
+ or value.get("target_binding") != target_binding
317
+ or type(attempt) is not int
318
+ or not 1 <= attempt <= _MAX_ATTEMPTS
319
+ or state not in {"active", "failed", "succeeded"}
320
+ or ("started_at" in value and _parse_observed_at(value["started_at"]) is None)
321
+ ):
322
+ raise ValueError("initial inventory attempt is invalid")
323
+ if state != "failed":
324
+ return attempt
325
+ if attempt >= _MAX_ATTEMPTS:
326
+ raise ValueError("initial inventory attempts are exhausted")
327
+ next_attempt = attempt + 1
328
+ _write_inventory_attempt(
329
+ work_dir,
330
+ source_revision=source_revision,
331
+ target_binding=target_binding,
332
+ attempt=next_attempt,
333
+ state="active",
334
+ )
335
+ return next_attempt
336
+
337
+
338
+ def _write_inventory_attempt(
339
+ work_dir: Path,
340
+ *,
341
+ source_revision: str,
342
+ target_binding: str,
343
+ attempt: int,
344
+ state: str,
345
+ ) -> None:
346
+ path = work_dir / "initial-inventory-attempt.json"
347
+ started_at = datetime.now(tz=UTC).isoformat()
348
+ if path.exists():
349
+ previous = private_json(path, "initial inventory attempt")
350
+ if previous.get("attempt") == attempt and "started_at" in previous:
351
+ started_at = str(previous["started_at"])
352
+ value: dict[str, object] = {
353
+ "schema_version": _ATTEMPT_SCHEMA,
354
+ "source_revision": source_revision,
355
+ "target_binding": target_binding,
356
+ "attempt": attempt,
357
+ "state": state,
358
+ "started_at": started_at,
359
+ }
360
+ value["record_digest"] = canonical_digest(value)
361
+ replace_private_json(path, value)
362
+
363
+
364
+ def require_initial_inventory_receipt(
365
+ value: dict[str, Any],
366
+ *,
367
+ runtime_platform: str,
368
+ ) -> None:
369
+ """Require AKS complete-generation evidence without changing Container Apps."""
370
+
371
+ if (
372
+ value.get("state") != "inventory-verified"
373
+ or value.get("active_generation_readback_verified") is not True
374
+ or value.get("progress_persisted") is not True
375
+ ):
376
+ raise ValueError("standalone initial inventory is incomplete")
377
+ if runtime_platform != "aks":
378
+ return
379
+ receipt = {str(key): item for key, item in value.items()}
380
+ supplied_digest = receipt.pop("receipt_digest", None)
381
+ if (
382
+ supplied_digest != canonical_digest(receipt)
383
+ or receipt.get("complete_generation_readback_verified") is not True
384
+ or receipt.get("fresh_generation") is not True
385
+ or receipt.get("closure_receipt_verified") is not True
386
+ or receipt.get("sources") != list(_INITIAL_INVENTORY_SOURCES)
387
+ or re.fullmatch(r"sha256:[0-9a-f]{64}", str(receipt.get("scope_digest", ""))) is None
388
+ or re.fullmatch(
389
+ r"sha256:[0-9a-f]{64}",
390
+ str(receipt.get("closure_receipt_digest", "")),
391
+ )
392
+ is None
393
+ or re.fullmatch(
394
+ r"[0-9a-f]{64}",
395
+ str(receipt.get("inventory_binding_digest", "")),
396
+ )
397
+ is None
398
+ or not _coverage_counts_are_valid(receipt)
399
+ or _parse_observed_at(receipt.get("freshness_observed_at")) is None
400
+ ):
401
+ raise ValueError("standalone initial inventory is incomplete")
402
+
403
+
404
+ def _verified_closure(
405
+ raw: str,
406
+ *,
407
+ expected_attempt_id: str,
408
+ expected_run_id: str,
409
+ expected_scope_digest: str,
410
+ observed_after: datetime,
411
+ ) -> dict[str, object]:
412
+ try:
413
+ value = json.loads(raw)
414
+ except json.JSONDecodeError as exc:
415
+ raise ValueError("initial inventory closure receipt is invalid") from exc
416
+ if not isinstance(value, dict):
417
+ raise ValueError( # noqa: TRY004 - normalize untrusted receipt JSON
418
+ "initial inventory closure receipt is invalid"
419
+ )
420
+ required_true = (
421
+ "subscription_root",
422
+ "final_fence",
423
+ "provider_coverage_complete",
424
+ "active_generation_matches",
425
+ "child_sources_complete",
426
+ "fresh_generation",
427
+ "observer_distinct",
428
+ )
429
+ observed_at = _parse_observed_at(value.get("observed_at"))
430
+ supplied_digest = value.get("receipt_digest")
431
+ receipt_material = {str(key): item for key, item in value.items() if key != "receipt_digest"}
432
+ if (
433
+ value.get("schema_version") != "1.0.0"
434
+ or value.get("run_id") != expected_run_id
435
+ or value.get("attempt_id") != expected_attempt_id
436
+ or value.get("scope_digest") != expected_scope_digest
437
+ or any(value.get(name) is not True for name in required_true)
438
+ or value.get("resource_type_filter") is not False
439
+ or value.get("truncated") is not False
440
+ or value.get("overlay_open") is not False
441
+ or value.get("execution_authority") is not False
442
+ or not _coverage_counts_are_valid(value)
443
+ or observed_at is None
444
+ or observed_at < observed_after - _CLOSURE_CLOCK_SKEW
445
+ or observed_at > datetime.now(tz=UTC) + _CLOSURE_CLOCK_SKEW
446
+ or re.fullmatch(r"sha256:[0-9a-f]{64}", str(value.get("generation_digest", ""))) is None
447
+ or supplied_digest != _closure_receipt_digest(receipt_material)
448
+ ):
449
+ raise ValueError("initial inventory closure receipt is incomplete")
450
+ return {str(key): item for key, item in value.items()}
451
+
452
+
453
+ def _validated_initial_inventory_binding(context: dict[str, Any]) -> dict[str, object]:
454
+ subscription_id = str(context.get("subscription_id", ""))
455
+ expected = initial_inventory_binding(subscription_id)
456
+ supplied = context.get("initial_inventory_binding")
457
+ if supplied != expected:
458
+ raise ValueError("initial inventory protected binding differs")
459
+ return expected
460
+
461
+
462
+ def _coverage_counts_are_valid(value: dict[str, Any]) -> bool:
463
+ return all(
464
+ isinstance(value.get(name), int)
465
+ and not isinstance(value.get(name), bool)
466
+ and int(value[name]) >= 0
467
+ for name in (
468
+ "resource_count",
469
+ "link_count",
470
+ "unmapped_object_count",
471
+ "coverage_gap_count",
472
+ )
473
+ )
474
+
475
+
476
+ def _parse_observed_at(value: object) -> datetime | None:
477
+ if not isinstance(value, str):
478
+ return None
479
+ try:
480
+ parsed = datetime.fromisoformat(value)
481
+ except ValueError:
482
+ return None
483
+ if parsed.tzinfo is None:
484
+ return None
485
+ return parsed.astimezone(UTC)
486
+
487
+
488
+ def _closure_receipt_digest(value: dict[str, object]) -> str:
489
+ payload = json.dumps(value, ensure_ascii=True, separators=(",", ":"), sort_keys=True)
490
+ return "sha256:" + hashlib.sha256(payload.encode()).hexdigest()
491
+
492
+
493
+ def _terraform_output(infra: Path, name: str) -> str:
494
+ return _capture(
495
+ ("terraform", "output", "-raw", name),
496
+ cwd=infra,
497
+ timeout=120,
498
+ reason="Terraform output readback failed",
499
+ ).strip()
500
+
501
+
502
+ def _capture(command: tuple[str, ...], *, cwd: Path, timeout: int, reason: str) -> str:
503
+ result = subprocess.run(
504
+ command,
505
+ cwd=cwd,
506
+ check=False,
507
+ capture_output=True,
508
+ text=True,
509
+ timeout=timeout,
510
+ )
511
+ if result.returncode != 0:
512
+ raise ValueError(reason)
513
+ return result.stdout
514
+
515
+
516
+ def _capture_env(
517
+ command: tuple[str, ...],
518
+ *,
519
+ cwd: Path,
520
+ env: dict[str, str],
521
+ timeout: int,
522
+ reason: str,
523
+ ) -> str:
524
+ result = subprocess.run(
525
+ command,
526
+ cwd=cwd,
527
+ env=env,
528
+ check=False,
529
+ capture_output=True,
530
+ text=True,
531
+ timeout=timeout,
532
+ )
533
+ if result.returncode != 0:
534
+ raise _CapturedFailure(reason, result.stderr)
535
+ return result.stdout
536
+
537
+
538
+ def _failed_closure_conditions(stderr: str) -> frozenset[str]:
539
+ """Return only the named verifier conditions, never free-form process output."""
540
+
541
+ matches = _CLOSURE_FAILURE.findall(stderr)
542
+ if not matches:
543
+ return frozenset()
544
+ names = frozenset(matches[-1].split(","))
545
+ return names if names <= _CLOSURE_CONDITIONS else frozenset()
546
+
547
+
548
+ def _mapping(value: object, label: str) -> dict[str, Any]:
549
+ if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
550
+ raise ValueError(f"{label} is invalid")
551
+ return {str(key): item for key, item in value.items()}
552
+
553
+
554
+ __all__ = [
555
+ "initial_inventory_binding",
556
+ "require_initial_inventory_receipt",
557
+ "run_initial_aks_inventory",
558
+ ]