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,769 @@
1
+ """Execute one content-addressed AKS Job from a reviewed CronJob template."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import subprocess
8
+ import time
9
+ from datetime import UTC, datetime, timedelta
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from fdai_deployment_cli.aks_job_execution import (
14
+ AksOneShotJob,
15
+ aks_job_contract_digest,
16
+ job_terminal_state,
17
+ materialize_cronjob_execution,
18
+ validate_existing_job,
19
+ )
20
+ from fdai_deployment_cli.contracts import canonical_digest
21
+ from fdai_deployment_cli.private_output import read_private_bytes
22
+ from fdai_deployment_cli.standalone_host_state import (
23
+ private_json,
24
+ replace_or_verify_private_json,
25
+ )
26
+
27
+ _NAMESPACE = "fdai-runtime"
28
+ _CHECKPOINT_SCHEMA = "fdai.aks-one-shot-execution-checkpoint.v1"
29
+ _GUID = re.compile(r"^[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$")
30
+ _MANAGED_IDENTITY_ID = re.compile(
31
+ r"^/subscriptions/([0-9a-f-]{36})/resourcegroups/[^/]+/providers/"
32
+ r"microsoft[.]managedidentity/userassignedidentities/[^/]+$"
33
+ )
34
+ _WORKLOAD_IDENTITY_ANNOTATION_PREFIX = "azure.workload.identity/"
35
+ _JOB_TTL_SECONDS = 3_600
36
+ _DISPATCH_ABSENCE_MARGIN = timedelta(seconds=60)
37
+ _CHECKPOINT_CLOCK_SKEW = timedelta(seconds=60)
38
+
39
+
40
+ def protected_service_account_binding(
41
+ scheduled_job: object,
42
+ *,
43
+ template_name: str,
44
+ namespace: str,
45
+ tenant_id: str,
46
+ subscription_id: str,
47
+ ) -> dict[str, object]:
48
+ """Retain the reviewed UAMI binding independently from the live ServiceAccount."""
49
+
50
+ if not isinstance(scheduled_job, dict) or any(
51
+ not isinstance(key, str) for key in scheduled_job
52
+ ):
53
+ raise ValueError("protected AKS scheduled job identity binding is invalid")
54
+ client_id = scheduled_job.get("identity_client_id")
55
+ resource_id = scheduled_job.get("identity_resource_id")
56
+ if (
57
+ not isinstance(client_id, str)
58
+ or _GUID.fullmatch(client_id) is None
59
+ or not isinstance(resource_id, str)
60
+ or _GUID.fullmatch(tenant_id) is None
61
+ or _GUID.fullmatch(subscription_id) is None
62
+ or re.fullmatch(r"[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?", template_name) is None
63
+ or re.fullmatch(r"[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?", namespace) is None
64
+ ):
65
+ raise ValueError("protected AKS scheduled job identity binding is invalid")
66
+ normalized_resource_id = resource_id.casefold()
67
+ resource_match = _MANAGED_IDENTITY_ID.fullmatch(normalized_resource_id)
68
+ if resource_match is None or resource_match.group(1) != subscription_id.casefold():
69
+ raise ValueError("protected AKS scheduled job identity resource is invalid")
70
+ normalized_client_id = client_id.casefold()
71
+ normalized_tenant_id = tenant_id.casefold()
72
+ material: dict[str, object] = {
73
+ "schema_version": "fdai.aks-service-account-binding.v1",
74
+ "service_account_name": f"{template_name}-job",
75
+ "namespace": namespace,
76
+ "identity_client_id": normalized_client_id,
77
+ "identity_resource_id": normalized_resource_id,
78
+ "tenant_id": normalized_tenant_id,
79
+ "subscription_id": subscription_id.casefold(),
80
+ "annotations": {
81
+ "azure.workload.identity/client-id": normalized_client_id,
82
+ "azure.workload.identity/tenant-id": normalized_tenant_id,
83
+ },
84
+ }
85
+ return {**material, "binding_digest": canonical_digest(material)}
86
+
87
+
88
+ def execute_aks_cronjob_once(
89
+ context: dict[str, Any],
90
+ work_dir: Path,
91
+ *,
92
+ template_name: str,
93
+ purpose: str,
94
+ expected_container_name: str,
95
+ expected_image: str,
96
+ expected_command: tuple[str, ...],
97
+ expected_service_account: str,
98
+ args: tuple[str, ...],
99
+ environment: dict[str, str],
100
+ require_suspended: bool,
101
+ timeout_seconds: int,
102
+ failed_successor_limit: int = 0,
103
+ retry_environments: tuple[dict[str, str], ...] = (),
104
+ _retry_index: int = 0,
105
+ _retry_base_purpose: str | None = None,
106
+ ) -> AksOneShotJob:
107
+ """Create once, or run bounded distinct successors after explicit terminal failures."""
108
+
109
+ kubeconfig = Path(str(context.get("kubeconfig", "")))
110
+ if (
111
+ type(failed_successor_limit) is not int
112
+ or not 0 <= failed_successor_limit <= 2
113
+ or len(retry_environments) != failed_successor_limit
114
+ or not 0 <= _retry_index <= failed_successor_limit
115
+ ):
116
+ raise ValueError("AKS one-shot failed-successor configuration is invalid")
117
+ retry_base_purpose = _retry_base_purpose or purpose
118
+ if not kubeconfig.is_file():
119
+ raise ValueError("AKS kubeconfig is unavailable for one-shot execution")
120
+ read_private_bytes(kubeconfig, max_bytes=1024 * 1024)
121
+ cronjob = _json_capture(
122
+ (
123
+ "kubectl",
124
+ "get",
125
+ "cronjob",
126
+ template_name,
127
+ "--namespace",
128
+ _NAMESPACE,
129
+ "--output",
130
+ "json",
131
+ f"--kubeconfig={kubeconfig}",
132
+ ),
133
+ cwd=work_dir,
134
+ timeout=60,
135
+ reason=f"AKS {purpose} CronJob template is unavailable",
136
+ )
137
+ protected_template_digest = _protected_template_digest(
138
+ context,
139
+ template_name=template_name,
140
+ )
141
+ identity_binding = _protected_identity_binding(
142
+ context,
143
+ template_name=template_name,
144
+ expected_service_account=expected_service_account,
145
+ )
146
+ _verify_service_account(
147
+ _json_capture(
148
+ (
149
+ "kubectl",
150
+ "get",
151
+ "serviceaccount",
152
+ expected_service_account,
153
+ "--namespace",
154
+ _NAMESPACE,
155
+ "--output",
156
+ "json",
157
+ f"--kubeconfig={kubeconfig}",
158
+ ),
159
+ cwd=work_dir,
160
+ timeout=60,
161
+ reason=f"AKS {purpose} ServiceAccount identity is unavailable",
162
+ ),
163
+ expected=identity_binding,
164
+ )
165
+ identity_binding_digest = str(identity_binding["binding_digest"])
166
+ execution = materialize_cronjob_execution(
167
+ cronjob,
168
+ purpose=purpose,
169
+ source_revision=str(context.get("source_commit", "")),
170
+ target_binding=str(context.get("target_binding", "")),
171
+ expected_template_name=template_name,
172
+ expected_container_name=expected_container_name,
173
+ expected_image=expected_image,
174
+ expected_command=expected_command,
175
+ expected_service_account=expected_service_account,
176
+ args=args,
177
+ environment=environment,
178
+ require_suspended=require_suspended,
179
+ protected_template_digest=protected_template_digest,
180
+ protected_identity_binding_digest=identity_binding_digest,
181
+ )
182
+ manifest_path = work_dir / f"{execution.name}.json"
183
+ replace_or_verify_private_json(manifest_path, execution.manifest)
184
+ checkpoint = _checkpoint_binding(
185
+ execution,
186
+ protected_template_digest=protected_template_digest,
187
+ protected_identity_binding_digest=identity_binding_digest,
188
+ )
189
+ prepared_path = work_dir / f"{execution.name}.prepared.json"
190
+ dispatch_intent_path = work_dir / f"{execution.name}.dispatch-intent.json"
191
+ dispatched_path = work_dir / f"{execution.name}.dispatched.json"
192
+ ambiguous_path = work_dir / f"{execution.name}.dispatch-ambiguous.json"
193
+ terminal_path = work_dir / f"{execution.name}.terminal.json"
194
+ replace_or_verify_private_json(
195
+ prepared_path,
196
+ _checkpoint_record(checkpoint, state="prepared"),
197
+ )
198
+ if terminal_path.exists():
199
+ outcome = _terminal_checkpoint_outcome(
200
+ private_json(terminal_path, f"AKS {purpose} terminal checkpoint"),
201
+ checkpoint=checkpoint,
202
+ execution=execution,
203
+ )
204
+ if outcome == "succeeded":
205
+ return execution
206
+ if _retry_index < failed_successor_limit:
207
+ next_retry = _retry_index + 1
208
+ return execute_aks_cronjob_once(
209
+ context,
210
+ work_dir,
211
+ template_name=template_name,
212
+ purpose=f"{retry_base_purpose}-retry-{next_retry}",
213
+ expected_container_name=expected_container_name,
214
+ expected_image=expected_image,
215
+ expected_command=expected_command,
216
+ expected_service_account=expected_service_account,
217
+ args=args,
218
+ environment=retry_environments[next_retry - 1],
219
+ require_suspended=require_suspended,
220
+ timeout_seconds=timeout_seconds,
221
+ failed_successor_limit=failed_successor_limit,
222
+ retry_environments=retry_environments,
223
+ _retry_index=next_retry,
224
+ _retry_base_purpose=retry_base_purpose,
225
+ )
226
+ raise ValueError(f"AKS {purpose} Job failed")
227
+
228
+ dispatch_intent_at: datetime | None = None
229
+ if dispatch_intent_path.exists():
230
+ dispatch_intent_at = _validate_timed_checkpoint(
231
+ private_json(dispatch_intent_path, f"AKS {purpose} dispatch intent checkpoint"),
232
+ checkpoint=checkpoint,
233
+ state="dispatch-intent",
234
+ )
235
+ dispatched = dispatched_path.exists()
236
+ if dispatched:
237
+ _validate_checkpoint(
238
+ private_json(dispatched_path, f"AKS {purpose} dispatch checkpoint"),
239
+ checkpoint=checkpoint,
240
+ state="dispatched",
241
+ )
242
+ ambiguous = ambiguous_path.exists()
243
+ if ambiguous:
244
+ _validate_timed_checkpoint(
245
+ private_json(ambiguous_path, f"AKS {purpose} ambiguous dispatch checkpoint"),
246
+ checkpoint=checkpoint,
247
+ state="dispatch-ambiguous",
248
+ reason="create-timeout",
249
+ )
250
+ existing = _existing_job(
251
+ execution,
252
+ kubeconfig=kubeconfig,
253
+ work_dir=work_dir,
254
+ purpose=purpose,
255
+ )
256
+ if existing is not None:
257
+ if dispatch_intent_at is None:
258
+ raise ValueError(f"AKS {purpose} Job exists without a retained dispatch intent")
259
+ validate_existing_job(existing, expected=execution)
260
+ if not dispatched:
261
+ replace_or_verify_private_json(
262
+ dispatched_path,
263
+ _checkpoint_record(checkpoint, state="dispatched"),
264
+ )
265
+ else:
266
+ if dispatched:
267
+ raise ValueError(
268
+ f"AKS {purpose} Job outcome is ambiguous; the dispatched execution was not repeated"
269
+ )
270
+ if ambiguous:
271
+ raise ValueError(
272
+ f"AKS {purpose} Job create outcome remains ambiguous; "
273
+ "the dispatch intent was not repeated"
274
+ )
275
+ now = _utc_now()
276
+ if dispatch_intent_at is None:
277
+ dispatch_intent_at = now
278
+ replace_or_verify_private_json(
279
+ dispatch_intent_path,
280
+ _timed_checkpoint_record(
281
+ checkpoint,
282
+ state="dispatch-intent",
283
+ recorded_at=dispatch_intent_at,
284
+ ),
285
+ )
286
+ elif not _absent_job_can_be_dispatched(
287
+ dispatch_intent_at,
288
+ observed_at=now,
289
+ ):
290
+ raise ValueError(
291
+ f"AKS {purpose} Job absence may follow terminal TTL deletion; "
292
+ "the dispatch intent was not repeated"
293
+ )
294
+ try:
295
+ _run(
296
+ (
297
+ "kubectl",
298
+ "create",
299
+ "--filename",
300
+ str(manifest_path),
301
+ f"--kubeconfig={kubeconfig}",
302
+ ),
303
+ cwd=work_dir,
304
+ timeout=60,
305
+ reason=f"AKS {purpose} Job start failed",
306
+ )
307
+ except subprocess.TimeoutExpired as exc:
308
+ replace_or_verify_private_json(
309
+ ambiguous_path,
310
+ _timed_checkpoint_record(
311
+ checkpoint,
312
+ state="dispatch-ambiguous",
313
+ recorded_at=_utc_now(),
314
+ reason="create-timeout",
315
+ ),
316
+ )
317
+ existing = _existing_job(
318
+ execution,
319
+ kubeconfig=kubeconfig,
320
+ work_dir=work_dir,
321
+ purpose=purpose,
322
+ )
323
+ if existing is None:
324
+ raise ValueError(
325
+ f"AKS {purpose} Job create timed out with an ambiguous outcome; "
326
+ "the dispatched execution was not repeated"
327
+ ) from exc
328
+ validate_existing_job(existing, expected=execution)
329
+ replace_or_verify_private_json(
330
+ dispatched_path,
331
+ _checkpoint_record(checkpoint, state="dispatched"),
332
+ )
333
+ else:
334
+ replace_or_verify_private_json(
335
+ dispatched_path,
336
+ _checkpoint_record(checkpoint, state="dispatched"),
337
+ )
338
+
339
+ deadline = time.monotonic() + timeout_seconds
340
+ current = existing
341
+ while time.monotonic() < deadline:
342
+ if current is None:
343
+ current = _existing_job(
344
+ execution,
345
+ kubeconfig=kubeconfig,
346
+ work_dir=work_dir,
347
+ purpose=purpose,
348
+ )
349
+ if current is None:
350
+ raise ValueError(
351
+ f"AKS {purpose} Job terminal evidence is unavailable; "
352
+ "the dispatched execution was not repeated"
353
+ )
354
+ validate_existing_job(current, expected=execution)
355
+ state = job_terminal_state(current)
356
+ if state == "succeeded":
357
+ replace_or_verify_private_json(
358
+ terminal_path,
359
+ _terminal_checkpoint_record(
360
+ checkpoint,
361
+ execution=execution,
362
+ job=current,
363
+ outcome="succeeded",
364
+ ),
365
+ )
366
+ return execution
367
+ if state == "failed":
368
+ replace_or_verify_private_json(
369
+ terminal_path,
370
+ _terminal_checkpoint_record(
371
+ checkpoint,
372
+ execution=execution,
373
+ job=current,
374
+ outcome="failed",
375
+ ),
376
+ )
377
+ if _retry_index < failed_successor_limit:
378
+ next_retry = _retry_index + 1
379
+ return execute_aks_cronjob_once(
380
+ context,
381
+ work_dir,
382
+ template_name=template_name,
383
+ purpose=f"{retry_base_purpose}-retry-{next_retry}",
384
+ expected_container_name=expected_container_name,
385
+ expected_image=expected_image,
386
+ expected_command=expected_command,
387
+ expected_service_account=expected_service_account,
388
+ args=args,
389
+ environment=retry_environments[next_retry - 1],
390
+ require_suspended=require_suspended,
391
+ timeout_seconds=timeout_seconds,
392
+ failed_successor_limit=failed_successor_limit,
393
+ retry_environments=retry_environments,
394
+ _retry_index=next_retry,
395
+ _retry_base_purpose=retry_base_purpose,
396
+ )
397
+ raise ValueError(f"AKS {purpose} Job failed")
398
+ current = None
399
+ time.sleep(5)
400
+ raise ValueError(f"AKS {purpose} Job exceeded its bounded deadline")
401
+
402
+
403
+ def _protected_template_digest(
404
+ context: dict[str, Any],
405
+ *,
406
+ template_name: str,
407
+ ) -> str:
408
+ values = context.get("protected_aks_job_template_digests")
409
+ if not isinstance(values, dict) or any(not isinstance(key, str) for key in values):
410
+ raise ValueError("protected AKS Job template digests are unavailable")
411
+ digest = values.get(template_name)
412
+ if not isinstance(digest, str):
413
+ raise TypeError("protected AKS Job template digest is unavailable")
414
+ return digest
415
+
416
+
417
+ def _protected_identity_binding(
418
+ context: dict[str, Any],
419
+ *,
420
+ template_name: str,
421
+ expected_service_account: str,
422
+ ) -> dict[str, object]:
423
+ bindings = context.get("protected_aks_job_identity_bindings")
424
+ if not isinstance(bindings, dict) or any(not isinstance(key, str) for key in bindings):
425
+ raise ValueError("protected AKS Job identity bindings are unavailable")
426
+ value = bindings.get(template_name)
427
+ if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
428
+ raise ValueError("protected AKS Job identity binding is unavailable")
429
+ binding = {str(key): item for key, item in value.items()}
430
+ supplied_digest = binding.pop("binding_digest", None)
431
+ context_subscription = str(context.get("subscription_id", "")).casefold()
432
+ context_tenant = str(context.get("tenant_id", "")).casefold()
433
+ resource_id = binding.get("identity_resource_id")
434
+ client_id = binding.get("identity_client_id")
435
+ resource_match = (
436
+ _MANAGED_IDENTITY_ID.fullmatch(resource_id) if isinstance(resource_id, str) else None
437
+ )
438
+ expected_annotations = {
439
+ "azure.workload.identity/client-id": client_id,
440
+ "azure.workload.identity/tenant-id": context_tenant,
441
+ }
442
+ if (
443
+ set(binding)
444
+ != {
445
+ "schema_version",
446
+ "service_account_name",
447
+ "namespace",
448
+ "identity_client_id",
449
+ "identity_resource_id",
450
+ "tenant_id",
451
+ "subscription_id",
452
+ "annotations",
453
+ }
454
+ or supplied_digest != canonical_digest(binding)
455
+ or binding.get("schema_version") != "fdai.aks-service-account-binding.v1"
456
+ or binding.get("service_account_name") != expected_service_account
457
+ or binding.get("namespace") != _NAMESPACE
458
+ or binding.get("tenant_id") != context_tenant
459
+ or binding.get("subscription_id") != context_subscription
460
+ or resource_match is None
461
+ or resource_match.group(1) != context_subscription
462
+ or not isinstance(client_id, str)
463
+ or _GUID.fullmatch(client_id) is None
464
+ or binding.get("annotations") != expected_annotations
465
+ ):
466
+ raise ValueError("protected AKS Job identity binding differs")
467
+ binding["binding_digest"] = supplied_digest
468
+ return binding
469
+
470
+
471
+ def _verify_service_account(value: object, *, expected: dict[str, object]) -> None:
472
+ if (
473
+ not isinstance(value, dict)
474
+ or value.get("apiVersion") != "v1"
475
+ or value.get("kind") != "ServiceAccount"
476
+ ):
477
+ raise ValueError("AKS one-shot ServiceAccount is invalid")
478
+ metadata = value.get("metadata")
479
+ if not isinstance(metadata, dict):
480
+ raise TypeError("AKS one-shot ServiceAccount metadata is invalid")
481
+ annotations = metadata.get("annotations", {})
482
+ if not isinstance(annotations, dict) or any(
483
+ not isinstance(key, str) or not isinstance(item, str) for key, item in annotations.items()
484
+ ):
485
+ raise ValueError("AKS one-shot ServiceAccount annotations are invalid")
486
+ workload_annotations = {
487
+ key: annotations[key]
488
+ for key in sorted(annotations)
489
+ if key.startswith(_WORKLOAD_IDENTITY_ANNOTATION_PREFIX)
490
+ }
491
+ if (
492
+ metadata.get("name") != expected.get("service_account_name")
493
+ or metadata.get("namespace") != expected.get("namespace")
494
+ or workload_annotations != expected.get("annotations")
495
+ ):
496
+ raise ValueError("AKS one-shot ServiceAccount identity binding changed")
497
+
498
+
499
+ def _existing_job(
500
+ execution: AksOneShotJob,
501
+ *,
502
+ kubeconfig: Path,
503
+ work_dir: Path,
504
+ purpose: str,
505
+ ) -> object | None:
506
+ raw = _capture(
507
+ (
508
+ "kubectl",
509
+ "get",
510
+ "job",
511
+ execution.name,
512
+ "--namespace",
513
+ _NAMESPACE,
514
+ "--output",
515
+ "json",
516
+ "--ignore-not-found",
517
+ f"--kubeconfig={kubeconfig}",
518
+ ),
519
+ cwd=work_dir,
520
+ timeout=60,
521
+ reason=f"AKS {purpose} Job recovery readback failed",
522
+ ).strip()
523
+ if not raw:
524
+ return None
525
+ try:
526
+ value: object = json.loads(raw)
527
+ except json.JSONDecodeError as exc:
528
+ raise ValueError(f"AKS {purpose} Job recovery readback is invalid") from exc
529
+ return value
530
+
531
+
532
+ def _checkpoint_binding(
533
+ execution: AksOneShotJob,
534
+ *,
535
+ protected_template_digest: str,
536
+ protected_identity_binding_digest: str,
537
+ ) -> dict[str, object]:
538
+ return {
539
+ "schema_version": _CHECKPOINT_SCHEMA,
540
+ "name": execution.name,
541
+ "execution_digest": execution.execution_digest,
542
+ "manifest_digest": canonical_digest(execution.manifest),
543
+ "protected_template_digest": protected_template_digest,
544
+ "protected_identity_binding_digest": protected_identity_binding_digest,
545
+ "job_ttl_seconds": _JOB_TTL_SECONDS,
546
+ }
547
+
548
+
549
+ def _checkpoint_record(
550
+ checkpoint: dict[str, object],
551
+ *,
552
+ state: str,
553
+ ) -> dict[str, object]:
554
+ value = {**checkpoint, "state": state}
555
+ value["checkpoint_digest"] = canonical_digest(value)
556
+ return value
557
+
558
+
559
+ def _validate_checkpoint(
560
+ value: dict[str, Any],
561
+ *,
562
+ checkpoint: dict[str, object],
563
+ state: str,
564
+ ) -> None:
565
+ supplied_digest = value.get("checkpoint_digest")
566
+ material = {str(key): item for key, item in value.items() if key != "checkpoint_digest"}
567
+ if material != {**checkpoint, "state": state} or supplied_digest != canonical_digest(material):
568
+ raise ValueError(f"retained AKS Job {state} checkpoint differs")
569
+
570
+
571
+ def _timed_checkpoint_record(
572
+ checkpoint: dict[str, object],
573
+ *,
574
+ state: str,
575
+ recorded_at: datetime,
576
+ reason: str | None = None,
577
+ ) -> dict[str, object]:
578
+ value: dict[str, object] = {
579
+ **checkpoint,
580
+ "state": state,
581
+ "recorded_at": _format_moment(recorded_at),
582
+ }
583
+ if reason is not None:
584
+ value["reason"] = reason
585
+ value["checkpoint_digest"] = canonical_digest(value)
586
+ return value
587
+
588
+
589
+ def _validate_timed_checkpoint(
590
+ value: dict[str, Any],
591
+ *,
592
+ checkpoint: dict[str, object],
593
+ state: str,
594
+ reason: str | None = None,
595
+ ) -> datetime:
596
+ supplied_digest = value.get("checkpoint_digest")
597
+ material = {str(key): item for key, item in value.items() if key != "checkpoint_digest"}
598
+ recorded_at = _parse_moment(material.get("recorded_at"))
599
+ expected = {**checkpoint, "state": state, "recorded_at": material.get("recorded_at")}
600
+ if reason is not None:
601
+ expected["reason"] = reason
602
+ if (
603
+ recorded_at is None
604
+ or recorded_at > _utc_now() + _CHECKPOINT_CLOCK_SKEW
605
+ or material != expected
606
+ or supplied_digest != canonical_digest(material)
607
+ ):
608
+ raise ValueError(f"retained AKS Job {state} checkpoint differs")
609
+ return recorded_at
610
+
611
+
612
+ def _absent_job_can_be_dispatched(
613
+ intent_at: datetime,
614
+ *,
615
+ observed_at: datetime,
616
+ ) -> bool:
617
+ if observed_at < intent_at - _CHECKPOINT_CLOCK_SKEW:
618
+ raise ValueError("AKS Job dispatch intent clock is invalid")
619
+ possible_ttl_deletion_at = intent_at + timedelta(seconds=_JOB_TTL_SECONDS)
620
+ return observed_at < possible_ttl_deletion_at - _DISPATCH_ABSENCE_MARGIN
621
+
622
+
623
+ def _utc_now() -> datetime:
624
+ return datetime.now(tz=UTC)
625
+
626
+
627
+ def _format_moment(value: datetime) -> str:
628
+ return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
629
+
630
+
631
+ def _parse_moment(value: object) -> datetime | None:
632
+ if not isinstance(value, str):
633
+ return None
634
+ try:
635
+ parsed = datetime.fromisoformat(value)
636
+ except ValueError:
637
+ return None
638
+ if parsed.tzinfo is None:
639
+ return None
640
+ return parsed.astimezone(UTC)
641
+
642
+
643
+ def _terminal_checkpoint_record(
644
+ checkpoint: dict[str, object],
645
+ *,
646
+ execution: AksOneShotJob,
647
+ job: object,
648
+ outcome: str,
649
+ ) -> dict[str, object]:
650
+ evidence = _terminal_evidence(job)
651
+ value: dict[str, object] = {
652
+ **checkpoint,
653
+ "state": "terminal",
654
+ "outcome": outcome,
655
+ "observed_job_contract_digest": aks_job_contract_digest(job),
656
+ "terminal_evidence": evidence,
657
+ "terminal_evidence_digest": canonical_digest(evidence),
658
+ "effect_verified": outcome == "succeeded",
659
+ "mutation_performed": True,
660
+ }
661
+ if value["observed_job_contract_digest"] != aks_job_contract_digest(execution.manifest):
662
+ raise ValueError("AKS Job terminal controlled contract changed")
663
+ value["checkpoint_digest"] = canonical_digest(value)
664
+ return value
665
+
666
+
667
+ def _terminal_checkpoint_outcome(
668
+ value: dict[str, Any],
669
+ *,
670
+ checkpoint: dict[str, object],
671
+ execution: AksOneShotJob,
672
+ ) -> str:
673
+ supplied_digest = value.get("checkpoint_digest")
674
+ material = {str(key): item for key, item in value.items() if key != "checkpoint_digest"}
675
+ evidence = material.get("terminal_evidence")
676
+ outcome = material.get("outcome")
677
+ if (
678
+ any(material.get(key) != expected for key, expected in checkpoint.items())
679
+ or material.get("state") != "terminal"
680
+ or outcome not in {"succeeded", "failed"}
681
+ or not isinstance(evidence, dict)
682
+ or material.get("terminal_evidence_digest") != canonical_digest(evidence)
683
+ or material.get("observed_job_contract_digest")
684
+ != aks_job_contract_digest(execution.manifest)
685
+ or material.get("effect_verified") is not (outcome == "succeeded")
686
+ or material.get("mutation_performed") is not True
687
+ or supplied_digest != canonical_digest(material)
688
+ ):
689
+ raise ValueError("retained AKS Job terminal checkpoint differs")
690
+ return str(outcome)
691
+
692
+
693
+ def _terminal_evidence(job: object) -> dict[str, object]:
694
+ if not isinstance(job, dict):
695
+ raise TypeError("AKS Job terminal evidence is invalid")
696
+ status = job.get("status")
697
+ if not isinstance(status, dict):
698
+ raise TypeError("AKS Job terminal evidence is invalid")
699
+ conditions = status.get("conditions", [])
700
+ if not isinstance(conditions, list):
701
+ raise TypeError("AKS Job terminal evidence is invalid")
702
+ normalized_conditions: list[dict[str, str]] = []
703
+ for item in conditions:
704
+ if not isinstance(item, dict):
705
+ raise TypeError("AKS Job terminal evidence is invalid")
706
+ condition_type = item.get("type")
707
+ condition_status = item.get("status")
708
+ if not isinstance(condition_type, str) or not isinstance(condition_status, str):
709
+ raise TypeError("AKS Job terminal evidence is invalid")
710
+ normalized_conditions.append(
711
+ {
712
+ "type": condition_type,
713
+ "status": condition_status,
714
+ **({"reason": str(item["reason"])} if isinstance(item.get("reason"), str) else {}),
715
+ }
716
+ )
717
+ return {
718
+ "conditions": sorted(
719
+ normalized_conditions,
720
+ key=lambda condition: (condition["type"], condition["status"]),
721
+ ),
722
+ "succeeded": status.get("succeeded", 0),
723
+ "failed": status.get("failed", 0),
724
+ "completion_time": status.get("completionTime"),
725
+ }
726
+
727
+
728
+ def _json_capture(
729
+ command: tuple[str, ...],
730
+ *,
731
+ cwd: Path,
732
+ timeout: int,
733
+ reason: str,
734
+ ) -> object:
735
+ raw = _capture(command, cwd=cwd, timeout=timeout, reason=reason)
736
+ try:
737
+ return json.loads(raw)
738
+ except json.JSONDecodeError as exc:
739
+ raise ValueError(f"{reason}: invalid JSON") from exc
740
+
741
+
742
+ def _capture(command: tuple[str, ...], *, cwd: Path, timeout: int, reason: str) -> str:
743
+ result = subprocess.run(
744
+ command,
745
+ cwd=cwd,
746
+ check=False,
747
+ capture_output=True,
748
+ text=True,
749
+ timeout=timeout,
750
+ )
751
+ if result.returncode != 0:
752
+ raise ValueError(reason)
753
+ return result.stdout
754
+
755
+
756
+ def _run(command: tuple[str, ...], *, cwd: Path, timeout: int, reason: str) -> None:
757
+ result = subprocess.run(
758
+ command,
759
+ cwd=cwd,
760
+ check=False,
761
+ stdout=subprocess.DEVNULL,
762
+ stderr=subprocess.DEVNULL,
763
+ timeout=timeout,
764
+ )
765
+ if result.returncode != 0:
766
+ raise ValueError(reason)
767
+
768
+
769
+ __all__ = ["execute_aks_cronjob_once", "protected_service_account_binding"]