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,794 @@
1
+ """Materialize exact, replay-safe AKS Jobs from reviewed CronJob templates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import hashlib
7
+ import json
8
+ import re
9
+ from collections.abc import Mapping, Sequence
10
+ from dataclasses import dataclass
11
+ from typing import Any, Literal
12
+
13
+ from fdai_deployment_cli import aks_kubernetes_defaults as defaults
14
+ from fdai_deployment_cli.aks_job_environment import (
15
+ protected_environment_entries,
16
+ protected_environment_sources,
17
+ )
18
+
19
+ _DIGEST = re.compile(r"^[0-9a-f]{64}$")
20
+ _DNS_LABEL = re.compile(r"^[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?$")
21
+ _ENVIRONMENT_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
22
+ _IMAGE = re.compile(r"^[a-z0-9.-]+(?::[0-9]+)?/[a-z0-9._/-]+@sha256:[0-9a-f]{64}$")
23
+ _SOURCE_REVISION = re.compile(r"^[0-9a-f]{40}$")
24
+ _MAX_ACTIVE_DEADLINE_SECONDS = 1_800
25
+ _SENSITIVE_ENVIRONMENT_MARKERS = ("KEY", "PASSWORD", "SECRET", "TOKEN")
26
+ _CONTROLLED_LABEL_PREFIXES = (
27
+ "app.kubernetes.io/",
28
+ "azure.workload.identity/",
29
+ "fdai.io/",
30
+ )
31
+ _SERVER_ANNOTATIONS = frozenset({"batch.kubernetes.io/job-tracking"})
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class AksOneShotJob:
36
+ """One immutable Job derived from a reviewed AKS CronJob."""
37
+
38
+ name: str
39
+ execution_digest: str
40
+ manifest: dict[str, Any]
41
+
42
+
43
+ def protected_cronjob_template_digest(
44
+ scheduled_job: object,
45
+ *,
46
+ template_name: str,
47
+ namespace: str,
48
+ source_revision: str,
49
+ ) -> str:
50
+ """Derive the complete controlled CronJob contract from protected Terraform input."""
51
+
52
+ if (
53
+ _DNS_LABEL.fullmatch(template_name) is None
54
+ or _DNS_LABEL.fullmatch(namespace) is None
55
+ or _SOURCE_REVISION.fullmatch(source_revision) is None
56
+ ):
57
+ raise ValueError("protected AKS CronJob identity is invalid")
58
+ value = _mapping(scheduled_job, "protected AKS scheduled job")
59
+ component = _required_text(value, "component", "protected AKS Job component")
60
+ image = _required_text(value, "image", "protected AKS Job image")
61
+ command = _text_sequence(value.get("command"), "protected AKS Job command", required=True)
62
+ args = _text_sequence(value.get("args", []), "protected AKS Job arguments")
63
+ schedule = _required_text(value, "schedule", "protected AKS Job schedule")
64
+ suspend = value.get("suspend", False)
65
+ deadline = _strict_integer(value.get("deadline_seconds"), "protected AKS Job deadline")
66
+ retry_limit = _strict_integer(value.get("retry_limit"), "protected AKS Job retry limit")
67
+ cpu = _required_text(value, "cpu", "protected AKS Job CPU")
68
+ memory = _required_text(value, "memory", "protected AKS Job memory")
69
+ environment = _string_mapping(value.get("environment"), "protected AKS Job environment")
70
+ secret_environment = _string_mapping(
71
+ value.get("secret_environment", {}),
72
+ "protected AKS Job secret environment",
73
+ )
74
+ field_environment = _string_mapping(
75
+ value.get("field_environment", {}), "protected AKS Job field environment"
76
+ )
77
+ environment_entries = protected_environment_entries(
78
+ component=component,
79
+ template_name=template_name,
80
+ environment=environment,
81
+ secret_environment=secret_environment,
82
+ field_environment=field_environment,
83
+ )
84
+ protected_env_from = protected_environment_sources(value.get("env_from", []))
85
+ if (
86
+ _DNS_LABEL.fullmatch(component) is None
87
+ or _IMAGE.fullmatch(image) is None
88
+ or not isinstance(suspend, bool)
89
+ or not 1 <= deadline <= _MAX_ACTIVE_DEADLINE_SECONDS
90
+ or not 0 <= retry_limit <= 10
91
+ or not _safe_argument(schedule)
92
+ or not _safe_argument(cpu)
93
+ or not _safe_argument(memory)
94
+ or any(_ENVIRONMENT_NAME.fullmatch(name) is None for name in environment)
95
+ or any(_ENVIRONMENT_NAME.fullmatch(name) is None for name in secret_environment)
96
+ or set(environment) & set(secret_environment)
97
+ ):
98
+ raise ValueError("protected AKS scheduled job contract is invalid")
99
+ labels = {
100
+ "app.kubernetes.io/name": template_name,
101
+ "app.kubernetes.io/component": component,
102
+ "app.kubernetes.io/managed-by": "terraform",
103
+ "fdai.io/runtime": "aks",
104
+ "fdai.io/source-commit": source_revision,
105
+ }
106
+ pod_labels = {**labels, "azure.workload.identity/use": "true"}
107
+ has_secrets = bool(secret_environment)
108
+ contract = {
109
+ "apiVersion": "batch/v1",
110
+ "kind": "CronJob",
111
+ "metadata": {
112
+ "name": template_name,
113
+ "namespace": namespace,
114
+ "labels": labels,
115
+ "annotations": {},
116
+ },
117
+ "spec": {
118
+ "schedule": schedule,
119
+ "timeZone": "Etc/UTC" if component == "canary" else None,
120
+ "suspend": suspend,
121
+ "concurrencyPolicy": "Forbid",
122
+ "startingDeadlineSeconds": None,
123
+ "successfulJobsHistoryLimit": 3,
124
+ "failedJobsHistoryLimit": 3,
125
+ "jobTemplate": {
126
+ "metadata": {"labels": labels, "annotations": {}},
127
+ "spec": {
128
+ "completions": 1,
129
+ "parallelism": 1,
130
+ "backoffLimit": retry_limit,
131
+ "activeDeadlineSeconds": deadline,
132
+ "ttlSecondsAfterFinished": 3600,
133
+ "completionMode": "NonIndexed",
134
+ "backoffLimitPerIndex": None,
135
+ "maxFailedIndexes": None,
136
+ "podFailurePolicy": None,
137
+ "template": {
138
+ "metadata": {"labels": pod_labels, "annotations": {}},
139
+ "spec": {
140
+ "serviceAccountName": f"{template_name}-job",
141
+ "automountServiceAccountToken": True,
142
+ "restartPolicy": "Never",
143
+ "nodeSelector": {"fdai.io/pool": "runtime"},
144
+ "securityContext": {
145
+ "runAsNonRoot": True,
146
+ "seccompProfile": {"type": "RuntimeDefault"},
147
+ },
148
+ "containers": [
149
+ {
150
+ "name": template_name,
151
+ "image": image,
152
+ "imagePullPolicy": "IfNotPresent",
153
+ "command": command,
154
+ "args": args,
155
+ "env": environment_entries,
156
+ "envFrom": protected_env_from,
157
+ "resources": {
158
+ "requests": {"cpu": cpu, "memory": memory},
159
+ "limits": {"cpu": cpu, "memory": memory},
160
+ },
161
+ "securityContext": {
162
+ "allowPrivilegeEscalation": False,
163
+ "runAsNonRoot": True,
164
+ "capabilities": {"drop": ["ALL"]},
165
+ },
166
+ "volumeMounts": (
167
+ [
168
+ {
169
+ "name": "secrets",
170
+ "mountPath": "/mnt/secrets-store",
171
+ "readOnly": True,
172
+ }
173
+ ]
174
+ if has_secrets
175
+ else []
176
+ ),
177
+ "volumeDevices": [],
178
+ "resizePolicy": [],
179
+ "restartPolicy": None,
180
+ "restartPolicyRules": None,
181
+ "workingDir": None,
182
+ "ports": [],
183
+ "livenessProbe": None,
184
+ "readinessProbe": None,
185
+ "startupProbe": None,
186
+ "lifecycle": None,
187
+ "terminationMessagePath": "/dev/termination-log",
188
+ "terminationMessagePolicy": "File",
189
+ "stdin": False,
190
+ "stdinOnce": False,
191
+ "tty": False,
192
+ }
193
+ ],
194
+ "initContainers": [],
195
+ "volumes": (
196
+ [
197
+ {
198
+ "name": "secrets",
199
+ "csi": {
200
+ "driver": "secrets-store.csi.k8s.io",
201
+ "readOnly": True,
202
+ "volumeAttributes": {
203
+ "secretProviderClass": f"{template_name}-job"
204
+ },
205
+ },
206
+ }
207
+ ]
208
+ if has_secrets
209
+ else []
210
+ ),
211
+ "affinity": None,
212
+ "tolerations": [],
213
+ "topologySpreadConstraints": [],
214
+ "hostNetwork": None,
215
+ "hostPID": None,
216
+ "hostIPC": None,
217
+ "runtimeClassName": None,
218
+ "priorityClassName": None,
219
+ "imagePullSecrets": [],
220
+ },
221
+ },
222
+ },
223
+ },
224
+ },
225
+ }
226
+ return _digest(contract)
227
+
228
+
229
+ def materialize_cronjob_execution(
230
+ cronjob: object,
231
+ *,
232
+ purpose: str,
233
+ source_revision: str,
234
+ target_binding: str,
235
+ expected_template_name: str,
236
+ expected_container_name: str,
237
+ expected_image: str,
238
+ expected_command: tuple[str, ...],
239
+ expected_service_account: str,
240
+ args: tuple[str, ...],
241
+ environment: Mapping[str, str] | None = None,
242
+ require_suspended: bool | None = None,
243
+ protected_template_digest: str,
244
+ protected_identity_binding_digest: str,
245
+ ) -> AksOneShotJob:
246
+ """Derive one content-addressed Job without copying credential values."""
247
+
248
+ _validate_identity(
249
+ purpose=purpose,
250
+ source_revision=source_revision,
251
+ target_binding=target_binding,
252
+ expected_template_name=expected_template_name,
253
+ expected_container_name=expected_container_name,
254
+ expected_image=expected_image,
255
+ expected_command=expected_command,
256
+ expected_service_account=expected_service_account,
257
+ args=args,
258
+ )
259
+ overrides = dict(environment or {})
260
+ _validate_environment(overrides)
261
+ value = _mapping(cronjob, "AKS CronJob")
262
+ if value.get("apiVersion") != "batch/v1" or value.get("kind") != "CronJob":
263
+ raise ValueError("AKS one-shot source MUST be one batch/v1 CronJob")
264
+ if (
265
+ _DIGEST.fullmatch(protected_template_digest) is None
266
+ or _DIGEST.fullmatch(protected_identity_binding_digest) is None
267
+ or _digest(_cronjob_contract(value)) != protected_template_digest
268
+ ):
269
+ raise ValueError("AKS one-shot CronJob differs from the protected template digest")
270
+ metadata = _mapping(value.get("metadata"), "AKS CronJob metadata")
271
+ namespace = metadata.get("namespace")
272
+ if metadata.get("name") != expected_template_name or not isinstance(namespace, str):
273
+ raise ValueError("AKS one-shot CronJob identity differs from the reviewed template")
274
+ if _DNS_LABEL.fullmatch(namespace) is None:
275
+ raise ValueError("AKS one-shot CronJob namespace is invalid")
276
+ labels = _string_mapping(metadata.get("labels"), "AKS CronJob labels")
277
+ if labels.get("fdai.io/source-commit") != source_revision:
278
+ raise ValueError("AKS one-shot CronJob source revision is not exact")
279
+
280
+ cron_spec = _mapping(value.get("spec"), "AKS CronJob spec")
281
+ if require_suspended is not None and cron_spec.get("suspend", False) is not require_suspended:
282
+ raise ValueError("AKS one-shot CronJob suspension contract changed")
283
+ job_template = _mapping(cron_spec.get("jobTemplate"), "AKS CronJob Job template")
284
+ template_metadata = _mapping(job_template.get("metadata"), "AKS CronJob Job template metadata")
285
+ template_labels = _string_mapping(
286
+ template_metadata.get("labels"), "AKS CronJob Job template labels"
287
+ )
288
+ if template_labels.get("fdai.io/source-commit") != source_revision:
289
+ raise ValueError("AKS one-shot Job template source revision is not exact")
290
+ job_spec = copy.deepcopy(_mapping(job_template.get("spec"), "AKS CronJob Job spec"))
291
+ _validate_job_bounds(job_spec)
292
+ if purpose == "inventory" and args == ("--initial",):
293
+ job_spec["backoffLimit"] = 0
294
+ pod_template = _mapping(job_spec.get("template"), "AKS one-shot Pod template")
295
+ pod_metadata = _mapping(pod_template.get("metadata"), "AKS one-shot Pod metadata")
296
+ pod_labels = _string_mapping(pod_metadata.get("labels"), "AKS one-shot Pod labels")
297
+ if pod_labels.get("fdai.io/source-commit") != source_revision:
298
+ raise ValueError("AKS one-shot Pod source revision is not exact")
299
+ pod_spec = _mapping(pod_template.get("spec"), "AKS one-shot Pod spec")
300
+ if pod_spec.get("serviceAccountName") != expected_service_account:
301
+ raise ValueError("AKS one-shot Job does not use the reviewed workload identity")
302
+ containers = pod_spec.get("containers")
303
+ if not isinstance(containers, list) or len(containers) != 1:
304
+ raise ValueError("AKS one-shot Job MUST have exactly one container")
305
+ container = _mapping(containers[0], "AKS one-shot container")
306
+ if (
307
+ container.get("name") != expected_container_name
308
+ or container.get("image") != expected_image
309
+ or container.get("command") != list(expected_command)
310
+ ):
311
+ raise ValueError("AKS one-shot container differs from the reviewed runtime contract")
312
+ container["args"] = list(args)
313
+ container["env"] = _environment_with_overrides(container.get("env"), overrides)
314
+ containers[0] = container
315
+ pod_spec["containers"] = containers
316
+ pod_template["spec"] = pod_spec
317
+ job_spec["template"] = pod_template
318
+
319
+ material = {
320
+ "purpose": purpose,
321
+ "source_revision": source_revision,
322
+ "target_binding": target_binding,
323
+ "template_name": expected_template_name,
324
+ "template_namespace": namespace,
325
+ "protected_template_digest": protected_template_digest,
326
+ "protected_identity_binding_digest": protected_identity_binding_digest,
327
+ "template_spec": job_spec,
328
+ }
329
+ execution_digest = _digest(material)
330
+ name = f"fdai-{purpose}-{execution_digest[:16]}"
331
+ if len(name) > 63 or _DNS_LABEL.fullmatch(name) is None:
332
+ raise ValueError("AKS one-shot purpose produces an invalid Job name")
333
+ job_labels = {
334
+ **template_labels,
335
+ "fdai.io/one-shot": purpose,
336
+ "fdai.io/source-commit": source_revision,
337
+ }
338
+ pod_metadata["labels"] = {
339
+ **pod_labels,
340
+ "fdai.io/one-shot": purpose,
341
+ "fdai.io/source-commit": source_revision,
342
+ }
343
+ pod_template["metadata"] = pod_metadata
344
+ job_spec["template"] = pod_template
345
+ manifest = {
346
+ "apiVersion": "batch/v1",
347
+ "kind": "Job",
348
+ "metadata": {
349
+ "name": name,
350
+ "namespace": namespace,
351
+ "labels": job_labels,
352
+ "annotations": {
353
+ "fdai.io/execution-digest": execution_digest,
354
+ "fdai.io/identity-binding-digest": protected_identity_binding_digest,
355
+ "fdai.io/template-digest": protected_template_digest,
356
+ "fdai.io/target-binding": target_binding,
357
+ },
358
+ },
359
+ "spec": job_spec,
360
+ }
361
+ return AksOneShotJob(
362
+ name=name,
363
+ execution_digest=execution_digest,
364
+ manifest=manifest,
365
+ )
366
+
367
+
368
+ def validate_existing_job(job: object, *, expected: AksOneShotJob) -> None:
369
+ """Fail closed unless an existing Job is the same content-addressed attempt."""
370
+
371
+ value = _mapping(job, "existing AKS Job")
372
+ if value.get("apiVersion") != "batch/v1" or value.get("kind") != "Job":
373
+ raise ValueError("existing AKS one-shot object is not a batch/v1 Job")
374
+ if aks_job_contract_digest(value) != aks_job_contract_digest(expected.manifest):
375
+ raise ValueError("existing AKS one-shot Job controlled contract changed")
376
+
377
+
378
+ def aks_job_contract_digest(job: object) -> str:
379
+ """Digest every controlled Job field while excluding server-owned runtime metadata."""
380
+
381
+ value = _mapping(job, "AKS Job")
382
+ if value.get("apiVersion") != "batch/v1" or value.get("kind") != "Job":
383
+ raise ValueError("AKS one-shot object is not a batch/v1 Job")
384
+ spec = _mapping(value.get("spec"), "AKS Job spec")
385
+ _validate_job_bounds(spec)
386
+ return _digest(_job_contract(value))
387
+
388
+
389
+ def job_terminal_state(job: object) -> Literal["running", "succeeded", "failed"]:
390
+ """Return one bounded terminal projection from Kubernetes Job status."""
391
+
392
+ value = _mapping(job, "AKS Job")
393
+ status_value = value.get("status", {})
394
+ status = _mapping(status_value, "AKS Job status")
395
+ conditions = status.get("conditions", [])
396
+ if not isinstance(conditions, list):
397
+ raise ValueError("AKS Job conditions are invalid")
398
+ complete = False
399
+ failed = False
400
+ for item in conditions:
401
+ condition = _mapping(item, "AKS Job condition")
402
+ if condition.get("status") != "True":
403
+ continue
404
+ if condition.get("type") == "Complete":
405
+ complete = True
406
+ elif condition.get("type") == "Failed":
407
+ failed = True
408
+ failed_count = status.get("failed", 0)
409
+ succeeded_count = status.get("succeeded", 0)
410
+ if (
411
+ isinstance(failed_count, bool)
412
+ or not isinstance(failed_count, int)
413
+ or isinstance(succeeded_count, bool)
414
+ or not isinstance(succeeded_count, int)
415
+ or failed_count < 0
416
+ or succeeded_count < 0
417
+ ):
418
+ raise ValueError("AKS Job counters are invalid")
419
+ if complete and failed:
420
+ raise ValueError("AKS Job terminal conditions conflict")
421
+ if complete and succeeded_count == 1:
422
+ return "succeeded"
423
+ if complete or succeeded_count > 1:
424
+ raise ValueError("AKS Job completion evidence is inconsistent")
425
+ if failed:
426
+ return "failed"
427
+ spec_value = value.get("spec")
428
+ if spec_value is not None:
429
+ spec = _mapping(spec_value, "AKS Job spec")
430
+ backoff_limit = spec.get("backoffLimit")
431
+ if (
432
+ isinstance(backoff_limit, bool)
433
+ or not isinstance(backoff_limit, int)
434
+ or backoff_limit < 0
435
+ ):
436
+ raise ValueError("AKS Job backoff limit is invalid")
437
+ if failed_count > backoff_limit:
438
+ return "failed"
439
+ return "running"
440
+
441
+
442
+ def _cronjob_contract(value: Mapping[str, Any]) -> dict[str, object]:
443
+ metadata = _mapping(value.get("metadata"), "AKS CronJob metadata")
444
+ spec = _mapping(value.get("spec"), "AKS CronJob spec")
445
+ job_template = _mapping(spec.get("jobTemplate"), "AKS CronJob Job template")
446
+ job_metadata = _mapping(job_template.get("metadata"), "AKS CronJob Job template metadata")
447
+ job_spec = _mapping(job_template.get("spec"), "AKS CronJob Job spec")
448
+ return {
449
+ "apiVersion": value.get("apiVersion"),
450
+ "kind": value.get("kind"),
451
+ "metadata": _top_level_metadata_contract(metadata, "AKS CronJob metadata"),
452
+ "spec": {
453
+ "schedule": spec.get("schedule"),
454
+ "timeZone": spec.get("timeZone"),
455
+ "suspend": spec.get("suspend", False),
456
+ "concurrencyPolicy": spec.get("concurrencyPolicy"),
457
+ "startingDeadlineSeconds": spec.get("startingDeadlineSeconds"),
458
+ "successfulJobsHistoryLimit": spec.get("successfulJobsHistoryLimit"),
459
+ "failedJobsHistoryLimit": spec.get("failedJobsHistoryLimit"),
460
+ "jobTemplate": {
461
+ "metadata": _nested_metadata_contract(
462
+ job_metadata, "AKS CronJob Job template metadata"
463
+ ),
464
+ "spec": _job_spec_contract(job_spec),
465
+ },
466
+ },
467
+ }
468
+
469
+
470
+ def _job_contract(value: Mapping[str, Any]) -> dict[str, object]:
471
+ metadata = _mapping(value.get("metadata"), "AKS Job metadata")
472
+ spec = _mapping(value.get("spec"), "AKS Job spec")
473
+ return {
474
+ "apiVersion": value.get("apiVersion"),
475
+ "kind": value.get("kind"),
476
+ "metadata": _top_level_metadata_contract(metadata, "AKS Job metadata"),
477
+ "spec": _job_spec_contract(spec),
478
+ }
479
+
480
+
481
+ def _job_spec_contract(value: Mapping[str, Any]) -> dict[str, object]:
482
+ template = _mapping(value.get("template"), "AKS Job Pod template")
483
+ metadata = _mapping(template.get("metadata"), "AKS Job Pod metadata")
484
+ pod_spec = _mapping(template.get("spec"), "AKS Job Pod spec")
485
+ return {
486
+ "completions": value.get("completions"),
487
+ "parallelism": value.get("parallelism"),
488
+ "backoffLimit": value.get("backoffLimit"),
489
+ "activeDeadlineSeconds": value.get("activeDeadlineSeconds"),
490
+ "ttlSecondsAfterFinished": value.get("ttlSecondsAfterFinished"),
491
+ "completionMode": value.get("completionMode") or "NonIndexed",
492
+ "backoffLimitPerIndex": value.get("backoffLimitPerIndex"),
493
+ "maxFailedIndexes": value.get("maxFailedIndexes"),
494
+ "podFailurePolicy": copy.deepcopy(value.get("podFailurePolicy")),
495
+ "template": {
496
+ "metadata": _nested_metadata_contract(metadata, "AKS Job Pod metadata"),
497
+ "spec": _pod_spec_contract(pod_spec),
498
+ },
499
+ }
500
+
501
+
502
+ def _pod_spec_contract(value: Mapping[str, Any]) -> dict[str, object]:
503
+ return {
504
+ "serviceAccountName": value.get("serviceAccountName"),
505
+ "automountServiceAccountToken": value.get("automountServiceAccountToken"),
506
+ "restartPolicy": value.get("restartPolicy"),
507
+ "nodeSelector": _optional_mapping(value.get("nodeSelector"), "AKS node selector"),
508
+ "securityContext": _optional_mapping(
509
+ value.get("securityContext"), "AKS Pod security context"
510
+ ),
511
+ "containers": [_container_contract(_only_container(value))],
512
+ "initContainers": _object_sequence(value.get("initContainers", []), "AKS init containers"),
513
+ "volumes": defaults.volumes(
514
+ _named_object_sequence(value.get("volumes", []), "AKS volumes")
515
+ ),
516
+ "affinity": copy.deepcopy(value.get("affinity")),
517
+ "tolerations": _object_sequence(value.get("tolerations", []), "AKS tolerations"),
518
+ "topologySpreadConstraints": _object_sequence(
519
+ value.get("topologySpreadConstraints", []),
520
+ "AKS topology spread constraints",
521
+ ),
522
+ "hostNetwork": value.get("hostNetwork"),
523
+ "hostPID": value.get("hostPID"),
524
+ "hostIPC": value.get("hostIPC"),
525
+ "runtimeClassName": value.get("runtimeClassName"),
526
+ "priorityClassName": value.get("priorityClassName"),
527
+ "imagePullSecrets": _named_object_sequence(
528
+ value.get("imagePullSecrets", []), "AKS image pull secrets"
529
+ ),
530
+ }
531
+
532
+
533
+ def _container_contract(value: Mapping[str, Any]) -> dict[str, object]:
534
+ return {
535
+ "name": value.get("name"),
536
+ "image": value.get("image"),
537
+ "imagePullPolicy": value.get("imagePullPolicy"),
538
+ "command": _text_sequence(value.get("command", []), "AKS container command"),
539
+ "args": _text_sequence(value.get("args", []), "AKS container arguments"),
540
+ "env": _environment_entries(value.get("env")),
541
+ "envFrom": _object_sequence(value.get("envFrom", []), "AKS container envFrom"),
542
+ "resources": _optional_mapping(value.get("resources"), "AKS container resources"),
543
+ "securityContext": defaults.container_security_context(value.get("securityContext")),
544
+ "volumeMounts": defaults.volume_mounts(
545
+ _named_object_sequence(value.get("volumeMounts", []), "AKS volume mounts")
546
+ ),
547
+ "volumeDevices": _named_object_sequence(
548
+ value.get("volumeDevices", []), "AKS volume devices"
549
+ ),
550
+ "resizePolicy": _object_sequence(
551
+ value.get("resizePolicy", []), "AKS container resize policy"
552
+ ),
553
+ "restartPolicy": value.get("restartPolicy"),
554
+ "restartPolicyRules": copy.deepcopy(value.get("restartPolicyRules")),
555
+ "workingDir": value.get("workingDir"),
556
+ "ports": _object_sequence(value.get("ports", []), "AKS container ports"),
557
+ "livenessProbe": copy.deepcopy(value.get("livenessProbe")),
558
+ "readinessProbe": copy.deepcopy(value.get("readinessProbe")),
559
+ "startupProbe": copy.deepcopy(value.get("startupProbe")),
560
+ "lifecycle": copy.deepcopy(value.get("lifecycle")),
561
+ "terminationMessagePath": _defaulted(
562
+ value, "terminationMessagePath", "/dev/termination-log"
563
+ ),
564
+ "terminationMessagePolicy": _defaulted(value, "terminationMessagePolicy", "File"),
565
+ "stdin": _defaulted(value, "stdin", False),
566
+ "stdinOnce": _defaulted(value, "stdinOnce", False),
567
+ "tty": _defaulted(value, "tty", False),
568
+ }
569
+
570
+
571
+ def _top_level_metadata_contract(value: Mapping[str, Any], label: str) -> dict[str, object]:
572
+ name = value.get("name")
573
+ namespace = value.get("namespace")
574
+ if not isinstance(name, str) or not isinstance(namespace, str):
575
+ raise ValueError(f"{label} identity is invalid")
576
+ return {
577
+ "name": name,
578
+ "namespace": namespace,
579
+ **_nested_metadata_contract(value, label),
580
+ }
581
+
582
+
583
+ def _nested_metadata_contract(value: Mapping[str, Any], label: str) -> dict[str, object]:
584
+ labels = _string_mapping(value.get("labels", {}), f"{label} labels")
585
+ annotations = _string_mapping(value.get("annotations", {}), f"{label} annotations")
586
+ return {
587
+ "labels": {
588
+ key: labels[key] for key in sorted(labels) if key.startswith(_CONTROLLED_LABEL_PREFIXES)
589
+ },
590
+ "annotations": {
591
+ key: annotations[key] for key in sorted(annotations) if key not in _SERVER_ANNOTATIONS
592
+ },
593
+ }
594
+
595
+
596
+ def _environment_entries(value: object) -> list[dict[str, Any]]:
597
+ if not isinstance(value, list):
598
+ raise ValueError("AKS one-shot container environment MUST be an array")
599
+ entries: dict[str, dict[str, Any]] = {}
600
+ for item in value:
601
+ entry = copy.deepcopy(_mapping(item, "AKS one-shot environment entry"))
602
+ name = entry.get("name")
603
+ if (
604
+ not isinstance(name, str)
605
+ or _ENVIRONMENT_NAME.fullmatch(name) is None
606
+ or name in entries
607
+ ):
608
+ raise ValueError("AKS one-shot container environment is invalid")
609
+ entries[name] = defaults.environment_entry(entry)
610
+ return [entries[name] for name in sorted(entries)]
611
+
612
+
613
+ def _named_object_sequence(value: object, label: str) -> list[object]:
614
+ items = _object_sequence(value, label)
615
+ named: dict[str, object] = {}
616
+ for item in items:
617
+ mapping = _mapping(item, f"{label} entry")
618
+ name = mapping.get("name")
619
+ if not isinstance(name, str) or not name or name in named:
620
+ raise ValueError(f"{label} is invalid")
621
+ named[name] = mapping
622
+ return [named[name] for name in sorted(named)]
623
+
624
+
625
+ def _object_sequence(value: object, label: str) -> list[object]:
626
+ if not isinstance(value, list):
627
+ raise ValueError(f"{label} MUST be an array")
628
+ return copy.deepcopy(value)
629
+
630
+
631
+ def _optional_mapping(value: object, label: str) -> dict[str, Any] | None:
632
+ if value is None:
633
+ return None
634
+ return copy.deepcopy(_mapping(value, label))
635
+
636
+
637
+ def _required_text(value: Mapping[str, Any], key: str, label: str) -> str:
638
+ item = value.get(key)
639
+ if not isinstance(item, str) or not _safe_argument(item):
640
+ raise ValueError(f"{label} is invalid")
641
+ return item
642
+
643
+
644
+ def _text_sequence(value: object, label: str, *, required: bool = False) -> list[str]:
645
+ if (
646
+ isinstance(value, (str, bytes))
647
+ or not isinstance(value, Sequence)
648
+ or any(not isinstance(item, str) or not _safe_argument(item) for item in value)
649
+ or (required and not value)
650
+ ):
651
+ raise ValueError(f"{label} is invalid")
652
+ return list(value)
653
+
654
+
655
+ def _strict_integer(value: object, label: str) -> int:
656
+ if isinstance(value, bool) or not isinstance(value, int):
657
+ raise ValueError(f"{label} is invalid")
658
+ return value
659
+
660
+
661
+ def _defaulted(value: Mapping[str, Any], key: str, default: object) -> object:
662
+ return value[key] if key in value else default
663
+
664
+
665
+ def _validate_identity(
666
+ *,
667
+ purpose: str,
668
+ source_revision: str,
669
+ target_binding: str,
670
+ expected_template_name: str,
671
+ expected_container_name: str,
672
+ expected_image: str,
673
+ expected_command: tuple[str, ...],
674
+ expected_service_account: str,
675
+ args: tuple[str, ...],
676
+ ) -> None:
677
+ for value, label in (
678
+ (purpose, "purpose"),
679
+ (expected_template_name, "template name"),
680
+ (expected_container_name, "container name"),
681
+ (expected_service_account, "service account"),
682
+ ):
683
+ if _DNS_LABEL.fullmatch(value) is None:
684
+ raise ValueError(f"AKS one-shot {label} is invalid")
685
+ if _SOURCE_REVISION.fullmatch(source_revision) is None:
686
+ raise ValueError("AKS one-shot source revision MUST be an exact git SHA")
687
+ if _DIGEST.fullmatch(target_binding) is None:
688
+ raise ValueError("AKS one-shot target binding MUST be SHA-256")
689
+ if _IMAGE.fullmatch(expected_image) is None:
690
+ raise ValueError("AKS one-shot image MUST be digest-pinned")
691
+ if not expected_command or any(not _safe_argument(value) for value in expected_command):
692
+ raise ValueError("AKS one-shot command is invalid")
693
+ if any(not _safe_argument(value) for value in args):
694
+ raise ValueError("AKS one-shot arguments are invalid")
695
+
696
+
697
+ def _validate_environment(environment: Mapping[str, str]) -> None:
698
+ for name, value in environment.items():
699
+ if (
700
+ not isinstance(name, str)
701
+ or not isinstance(value, str)
702
+ or not name
703
+ or not _safe_environment_value(value)
704
+ or any(marker in name.upper() for marker in _SENSITIVE_ENVIRONMENT_MARKERS)
705
+ ):
706
+ raise ValueError("AKS one-shot environment overrides MUST be non-secret text")
707
+
708
+
709
+ def _validate_job_bounds(job_spec: dict[str, Any]) -> None:
710
+ deadline = job_spec.get("activeDeadlineSeconds")
711
+ if (
712
+ isinstance(deadline, bool)
713
+ or not isinstance(deadline, int)
714
+ or not 1 <= deadline <= _MAX_ACTIVE_DEADLINE_SECONDS
715
+ or job_spec.get("completions") != 1
716
+ or job_spec.get("parallelism") != 1
717
+ ):
718
+ raise ValueError("AKS one-shot Job bounds differ from the reviewed contract")
719
+
720
+
721
+ def _environment_with_overrides(
722
+ value: object,
723
+ overrides: Mapping[str, str],
724
+ ) -> list[dict[str, Any]]:
725
+ if not isinstance(value, list):
726
+ raise ValueError("AKS one-shot container environment MUST be an array")
727
+ retained: list[dict[str, Any]] = []
728
+ seen: set[str] = set()
729
+ for item in value:
730
+ environment = copy.deepcopy(_mapping(item, "AKS one-shot environment entry"))
731
+ name = environment.get("name")
732
+ if not isinstance(name, str) or not name or name in seen:
733
+ raise ValueError("AKS one-shot container environment is invalid")
734
+ seen.add(name)
735
+ if name not in overrides:
736
+ retained.append(environment)
737
+ retained.extend({"name": name, "value": overrides[name]} for name in sorted(overrides))
738
+ retained.sort(key=lambda entry: str(entry["name"]))
739
+ return retained
740
+
741
+
742
+ def _only_container(pod_spec: Mapping[str, Any]) -> dict[str, Any]:
743
+ containers = pod_spec.get("containers")
744
+ if not isinstance(containers, list) or len(containers) != 1:
745
+ raise ValueError("AKS one-shot Job MUST have exactly one container")
746
+ return _mapping(containers[0], "AKS one-shot container")
747
+
748
+
749
+ def _safe_argument(value: str) -> bool:
750
+ return (
751
+ isinstance(value, str)
752
+ and 0 < len(value) <= 4_096
753
+ and "\x00" not in value
754
+ and "\r" not in value
755
+ and "\n" not in value
756
+ )
757
+
758
+
759
+ def _safe_environment_value(value: str) -> bool:
760
+ return (
761
+ isinstance(value, str)
762
+ and len(value) <= 4_096
763
+ and "\x00" not in value
764
+ and "\r" not in value
765
+ and "\n" not in value
766
+ )
767
+
768
+
769
+ def _mapping(value: object, label: str) -> dict[str, Any]:
770
+ if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
771
+ raise ValueError(f"{label} MUST be an object")
772
+ return {str(key): item for key, item in value.items()}
773
+
774
+
775
+ def _string_mapping(value: object, label: str) -> dict[str, str]:
776
+ mapping = _mapping(value, label)
777
+ if any(not isinstance(item, str) for item in mapping.values()):
778
+ raise ValueError(f"{label} MUST contain string values")
779
+ return {key: str(item) for key, item in mapping.items()}
780
+
781
+
782
+ def _digest(value: object) -> str:
783
+ payload = json.dumps(value, ensure_ascii=True, separators=(",", ":"), sort_keys=True)
784
+ return hashlib.sha256(payload.encode()).hexdigest()
785
+
786
+
787
+ __all__ = [
788
+ "AksOneShotJob",
789
+ "aks_job_contract_digest",
790
+ "job_terminal_state",
791
+ "materialize_cronjob_execution",
792
+ "protected_cronjob_template_digest",
793
+ "validate_existing_job",
794
+ ]