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.
- fdai_deployment_cli/__about__.py +3 -0
- fdai_deployment_cli/__init__.py +5 -0
- fdai_deployment_cli/__main__.py +5 -0
- fdai_deployment_cli/acr_publish.py +585 -0
- fdai_deployment_cli/aks_catalog_review.py +137 -0
- fdai_deployment_cli/aks_historical_reconciliation.py +732 -0
- fdai_deployment_cli/aks_job_environment.py +59 -0
- fdai_deployment_cli/aks_job_execution.py +794 -0
- fdai_deployment_cli/aks_kubernetes_defaults.py +66 -0
- fdai_deployment_cli/aks_preflight.py +422 -0
- fdai_deployment_cli/aks_readiness.py +220 -0
- fdai_deployment_cli/aks_service_update.py +180 -0
- fdai_deployment_cli/aks_workload_jobs.py +281 -0
- fdai_deployment_cli/application_state_adoption.py +347 -0
- fdai_deployment_cli/azure_actor.py +61 -0
- fdai_deployment_cli/azure_naming.py +252 -0
- fdai_deployment_cli/azure_retail_prices.py +173 -0
- fdai_deployment_cli/bootstrap_approval.py +99 -0
- fdai_deployment_cli/bootstrap_channel.py +321 -0
- fdai_deployment_cli/bootstrap_executor.py +311 -0
- fdai_deployment_cli/bootstrap_plan.py +237 -0
- fdai_deployment_cli/bootstrap_reconcile.py +402 -0
- fdai_deployment_cli/bootstrap_support.py +138 -0
- fdai_deployment_cli/bootstrap_worker.py +448 -0
- fdai_deployment_cli/bundle.py +352 -0
- fdai_deployment_cli/capacity.py +143 -0
- fdai_deployment_cli/catalog_review_profile.py +306 -0
- fdai_deployment_cli/cli.py +798 -0
- fdai_deployment_cli/cli_help.py +214 -0
- fdai_deployment_cli/cli_parser.py +791 -0
- fdai_deployment_cli/cli_plan.py +450 -0
- fdai_deployment_cli/compiler.py +99 -0
- fdai_deployment_cli/console_artifact.py +218 -0
- fdai_deployment_cli/console_config.py +214 -0
- fdai_deployment_cli/console_update.py +832 -0
- fdai_deployment_cli/contracts.py +307 -0
- fdai_deployment_cli/control_package.py +140 -0
- fdai_deployment_cli/deadline_transport.py +70 -0
- fdai_deployment_cli/deployment_cost.py +131 -0
- fdai_deployment_cli/deployment_deadline.py +28 -0
- fdai_deployment_cli/deployment_kit.py +790 -0
- fdai_deployment_cli/deployment_kit_cache.py +191 -0
- fdai_deployment_cli/deployment_progress.py +648 -0
- fdai_deployment_cli/development_authority_cli.py +56 -0
- fdai_deployment_cli/development_authority_profile.py +319 -0
- fdai_deployment_cli/doctor.py +136 -0
- fdai_deployment_cli/entitlement_preflight.py +68 -0
- fdai_deployment_cli/entitlement_transport.py +49 -0
- fdai_deployment_cli/entra_bootstrap.py +579 -0
- fdai_deployment_cli/entra_control_profile.py +214 -0
- fdai_deployment_cli/entra_graph.py +349 -0
- fdai_deployment_cli/entra_profiles.py +215 -0
- fdai_deployment_cli/entra_source.py +244 -0
- fdai_deployment_cli/execution_bundle.py +478 -0
- fdai_deployment_cli/execution_copies.py +63 -0
- fdai_deployment_cli/foundation_adoption.py +438 -0
- fdai_deployment_cli/foundation_adoption_evidence.py +245 -0
- fdai_deployment_cli/foundation_adoption_host.py +157 -0
- fdai_deployment_cli/foundation_adoption_transport.py +51 -0
- fdai_deployment_cli/foundation_failure.py +72 -0
- fdai_deployment_cli/foundation_image.py +168 -0
- fdai_deployment_cli/foundation_input.py +235 -0
- fdai_deployment_cli/foundation_output.py +154 -0
- fdai_deployment_cli/foundation_plan.py +381 -0
- fdai_deployment_cli/foundation_process.py +64 -0
- fdai_deployment_cli/foundation_progress.py +231 -0
- fdai_deployment_cli/github_actions.py +171 -0
- fdai_deployment_cli/github_apply_artifact.py +259 -0
- fdai_deployment_cli/github_artifact_io.py +48 -0
- fdai_deployment_cli/github_dispatch.py +248 -0
- fdai_deployment_cli/github_plan_artifact.py +181 -0
- fdai_deployment_cli/github_provider_artifact.py +170 -0
- fdai_deployment_cli/github_transport.py +46 -0
- fdai_deployment_cli/github_workflow_values.py +312 -0
- fdai_deployment_cli/hub_image_deploy.py +354 -0
- fdai_deployment_cli/hub_image_stage.py +162 -0
- fdai_deployment_cli/hub_image_trust.py +281 -0
- fdai_deployment_cli/hub_ownership.py +161 -0
- fdai_deployment_cli/hub_workload_handoff.py +264 -0
- fdai_deployment_cli/hub_workload_handoff_inputs.py +227 -0
- fdai_deployment_cli/identity_profile.py +227 -0
- fdai_deployment_cli/image_targets.py +197 -0
- fdai_deployment_cli/installation_scope.py +280 -0
- fdai_deployment_cli/license.py +311 -0
- fdai_deployment_cli/license_issue.py +239 -0
- fdai_deployment_cli/lifecycle_authorization.py +252 -0
- fdai_deployment_cli/lifecycle_commands.py +173 -0
- fdai_deployment_cli/lifecycle_configuration.py +722 -0
- fdai_deployment_cli/lifecycle_configuration_artifact.py +45 -0
- fdai_deployment_cli/lifecycle_configuration_cli.py +324 -0
- fdai_deployment_cli/lifecycle_configuration_document.py +74 -0
- fdai_deployment_cli/lifecycle_configuration_entities.py +146 -0
- fdai_deployment_cli/lifecycle_configuration_package.py +492 -0
- fdai_deployment_cli/lifecycle_configuration_publication.py +277 -0
- fdai_deployment_cli/lifecycle_configuration_publication_cli.py +191 -0
- fdai_deployment_cli/lifecycle_configuration_sealing.py +127 -0
- fdai_deployment_cli/lifecycle_enrollment.py +45 -0
- fdai_deployment_cli/lifecycle_infrastructure_agent.py +174 -0
- fdai_deployment_cli/lifecycle_installation_binding.py +65 -0
- fdai_deployment_cli/lifecycle_native.py +247 -0
- fdai_deployment_cli/lifecycle_native_reconciliation.py +568 -0
- fdai_deployment_cli/lifecycle_native_transition.py +339 -0
- fdai_deployment_cli/lifecycle_ownership_exclusion.py +78 -0
- fdai_deployment_cli/lifecycle_plan.py +800 -0
- fdai_deployment_cli/lifecycle_promotion.py +185 -0
- fdai_deployment_cli/lifecycle_release.py +308 -0
- fdai_deployment_cli/lifecycle_self_upgrade.py +202 -0
- fdai_deployment_cli/lifecycle_upgrade_bundle.py +176 -0
- fdai_deployment_cli/notification_recipients_input.py +222 -0
- fdai_deployment_cli/observer_artifact.py +106 -0
- fdai_deployment_cli/oci_archive.py +353 -0
- fdai_deployment_cli/offline_kit.py +797 -0
- fdai_deployment_cli/offline_prepare.py +135 -0
- fdai_deployment_cli/operational_evidence_verifier_input.py +575 -0
- fdai_deployment_cli/plan_input.py +101 -0
- fdai_deployment_cli/private_output.py +192 -0
- fdai_deployment_cli/profile.py +70 -0
- fdai_deployment_cli/progress.py +183 -0
- fdai_deployment_cli/readiness.py +286 -0
- fdai_deployment_cli/run_command_receiver.py +418 -0
- fdai_deployment_cli/runtime_build.py +386 -0
- fdai_deployment_cli/runtime_profile.py +287 -0
- fdai_deployment_cli/runtime_release.py +745 -0
- fdai_deployment_cli/runtime_stage.py +82 -0
- fdai_deployment_cli/runtime_support_installation.py +215 -0
- fdai_deployment_cli/simulation.py +80 -0
- fdai_deployment_cli/source_application_inputs.py +397 -0
- fdai_deployment_cli/source_azure.py +586 -0
- fdai_deployment_cli/source_change_cli.py +107 -0
- fdai_deployment_cli/source_change_parser.py +75 -0
- fdai_deployment_cli/source_change_plan.py +181 -0
- fdai_deployment_cli/source_changed_update.py +196 -0
- fdai_deployment_cli/source_deploy.py +170 -0
- fdai_deployment_cli/source_foundation.py +174 -0
- fdai_deployment_cli/source_host_tools.py +163 -0
- fdai_deployment_cli/source_image_stage.py +400 -0
- fdai_deployment_cli/source_input.py +173 -0
- fdai_deployment_cli/source_projection.py +164 -0
- fdai_deployment_cli/source_receiver.py +76 -0
- fdai_deployment_cli/source_recovery.py +264 -0
- fdai_deployment_cli/source_runtime.py +81 -0
- fdai_deployment_cli/source_runtime_support.py +368 -0
- fdai_deployment_cli/source_service_update.py +726 -0
- fdai_deployment_cli/source_service_update_cli.py +47 -0
- fdai_deployment_cli/source_snapshot.py +178 -0
- fdai_deployment_cli/source_teardown.py +574 -0
- fdai_deployment_cli/source_terraform.py +85 -0
- fdai_deployment_cli/source_transport.py +399 -0
- fdai_deployment_cli/standalone_aks_inventory.py +558 -0
- fdai_deployment_cli/standalone_aks_job_execution.py +769 -0
- fdai_deployment_cli/standalone_aks_nodepool_guard.py +141 -0
- fdai_deployment_cli/standalone_application.py +977 -0
- fdai_deployment_cli/standalone_application_completion.py +151 -0
- fdai_deployment_cli/standalone_application_input_recovery.py +107 -0
- fdai_deployment_cli/standalone_application_recovery.py +116 -0
- fdai_deployment_cli/standalone_approval_input.py +24 -0
- fdai_deployment_cli/standalone_catalog_checkpoint.py +122 -0
- fdai_deployment_cli/standalone_catalog_review.py +251 -0
- fdai_deployment_cli/standalone_checkpoint_failure.py +127 -0
- fdai_deployment_cli/standalone_console_publish.py +174 -0
- fdai_deployment_cli/standalone_database_principals.py +41 -0
- fdai_deployment_cli/standalone_deploy.py +638 -0
- fdai_deployment_cli/standalone_development_authority.py +166 -0
- fdai_deployment_cli/standalone_foundation_adoption.py +87 -0
- fdai_deployment_cli/standalone_foundation_transition.py +767 -0
- fdai_deployment_cli/standalone_host.py +4608 -0
- fdai_deployment_cli/standalone_host_rebind.py +226 -0
- fdai_deployment_cli/standalone_host_residual.py +104 -0
- fdai_deployment_cli/standalone_host_state.py +145 -0
- fdai_deployment_cli/standalone_host_values.py +563 -0
- fdai_deployment_cli/standalone_kubernetes_tools.py +100 -0
- fdai_deployment_cli/standalone_license_installation.py +177 -0
- fdai_deployment_cli/standalone_management_egress.py +83 -0
- fdai_deployment_cli/standalone_migration_evidence.py +38 -0
- fdai_deployment_cli/standalone_operational_evidence.py +356 -0
- fdai_deployment_cli/standalone_planned_outputs.py +147 -0
- fdai_deployment_cli/standalone_private_access_probe.py +100 -0
- fdai_deployment_cli/standalone_product_profile.py +110 -0
- fdai_deployment_cli/standalone_remote_prepare.py +442 -0
- fdai_deployment_cli/standalone_residual_apply.py +540 -0
- fdai_deployment_cli/standalone_residual_successor.py +431 -0
- fdai_deployment_cli/standalone_review.py +266 -0
- fdai_deployment_cli/standalone_service_update_finalization.py +116 -0
- fdai_deployment_cli/standalone_service_update_recovery.py +277 -0
- fdai_deployment_cli/standalone_stage_targets.py +325 -0
- fdai_deployment_cli/standalone_status.py +67 -0
- fdai_deployment_cli/standalone_terraform_environment.py +237 -0
- fdai_deployment_cli/standalone_transfer_cleanup.py +237 -0
- fdai_deployment_cli/standalone_trial_activation.py +193 -0
- fdai_deployment_cli/state.py +434 -0
- fdai_deployment_cli/state_handoff.py +253 -0
- fdai_deployment_cli/status_projection.py +97 -0
- fdai_deployment_cli/support_install.py +257 -0
- fdai_deployment_cli/target.py +46 -0
- fdai_deployment_cli/trust/capabilities.json +62 -0
- fdai_deployment_cli/trust/deployment-bundle-root.pub +3 -0
- fdai_deployment_cli/trust/deployment-release-root.pub +3 -0
- fdai_deployment_cli/trust/upstream-signing-key.pub +3 -0
- fdai_deployment_cli/trust_roots.py +50 -0
- fdai_deployment_cli-0.1.2.dist-info/METADATA +112 -0
- fdai_deployment_cli-0.1.2.dist-info/RECORD +203 -0
- fdai_deployment_cli-0.1.2.dist-info/WHEEL +4 -0
- 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"]
|