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,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
|
+
]
|