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,220 @@
|
|
|
1
|
+
"""Validate exact AKS Deployment and Pod observations without granting readiness alone."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from fdai_deployment_cli.contracts import load_json_object
|
|
10
|
+
|
|
11
|
+
_IMAGE = re.compile(r"[^\s@]+@sha256:[0-9a-f]{64}")
|
|
12
|
+
_CONFIG_DIGEST = re.compile(r"sha256:[0-9a-f]{64}")
|
|
13
|
+
_REQUIRED = {"core-control-plane", "operator-service", "isolated-executor"}
|
|
14
|
+
WORKLOAD_CONTRACT_KEYS = (
|
|
15
|
+
"image",
|
|
16
|
+
"replicas",
|
|
17
|
+
"max_replicas",
|
|
18
|
+
"source_commit",
|
|
19
|
+
"secret_environment",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def workload_contract(workload: Mapping[str, Any]) -> dict[str, Any]:
|
|
24
|
+
"""Select the readback-relevant fields of one rendered AKS workload."""
|
|
25
|
+
return {key: workload[key] for key in WORKLOAD_CONTRACT_KEYS}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def valid_secret_environment(value: object) -> bool:
|
|
29
|
+
"""Accept only a mapping of non-empty environment names to Key Vault object names."""
|
|
30
|
+
return isinstance(value, dict) and all(
|
|
31
|
+
isinstance(name, str) and name and isinstance(item, str) and item
|
|
32
|
+
for name, item in value.items()
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def verify_workload_health(
|
|
37
|
+
*,
|
|
38
|
+
deployments: str,
|
|
39
|
+
pods: str,
|
|
40
|
+
expected: dict[str, Any],
|
|
41
|
+
source_commit: str | None = None,
|
|
42
|
+
) -> bool:
|
|
43
|
+
"""Require complete exact-source workloads, available replicas and running image digests.
|
|
44
|
+
|
|
45
|
+
When a workload contract declares ``secret_environment``, the deployed container must
|
|
46
|
+
declare exactly those secret-backed environment bindings, so an out-of-band edit that
|
|
47
|
+
drops or adds one fails closed instead of passing as a healthy rollout.
|
|
48
|
+
|
|
49
|
+
Empty, duplicate, stale, malformed or partially healthy observations return false.
|
|
50
|
+
This proves only workload health, not transport, migrations, jobs or application readiness.
|
|
51
|
+
"""
|
|
52
|
+
if not expected or (
|
|
53
|
+
source_commit is not None and re.fullmatch(r"[0-9a-f]{40}", source_commit) is None
|
|
54
|
+
):
|
|
55
|
+
return False
|
|
56
|
+
try:
|
|
57
|
+
deployment_items = _items(deployments, "DeploymentList")
|
|
58
|
+
pod_items = _items(pods, "PodList")
|
|
59
|
+
names = [item["metadata"]["name"] for item in deployment_items]
|
|
60
|
+
if len(names) != len(set(names)) or set(names) != set(expected):
|
|
61
|
+
return False
|
|
62
|
+
for item in deployment_items:
|
|
63
|
+
name = item["metadata"]["name"]
|
|
64
|
+
contract = expected[name]
|
|
65
|
+
image = contract["image"]
|
|
66
|
+
expected_source = contract.get("source_commit", source_commit)
|
|
67
|
+
if not isinstance(image, str) or _IMAGE.fullmatch(image) is None:
|
|
68
|
+
return False
|
|
69
|
+
if (
|
|
70
|
+
not isinstance(expected_source, str)
|
|
71
|
+
or re.fullmatch(r"[0-9a-f]{40}", expected_source) is None
|
|
72
|
+
):
|
|
73
|
+
return False
|
|
74
|
+
replicas = item["spec"]["replicas"]
|
|
75
|
+
minimum, maximum = contract["replicas"], contract["max_replicas"]
|
|
76
|
+
if any(type(value) is not int for value in (replicas, minimum, maximum)):
|
|
77
|
+
return False
|
|
78
|
+
if not 1 <= minimum <= replicas <= maximum:
|
|
79
|
+
return False
|
|
80
|
+
generation = item["metadata"]["generation"]
|
|
81
|
+
status = item["status"]
|
|
82
|
+
if type(generation) is not int or generation < 1:
|
|
83
|
+
return False
|
|
84
|
+
if (
|
|
85
|
+
type(status.get("observedGeneration")) is not int
|
|
86
|
+
or status.get("observedGeneration") != generation
|
|
87
|
+
):
|
|
88
|
+
return False
|
|
89
|
+
if any(
|
|
90
|
+
type(status.get(key)) is not int or status.get(key) != replicas
|
|
91
|
+
for key in ("replicas", "updatedReplicas", "readyReplicas", "availableReplicas")
|
|
92
|
+
):
|
|
93
|
+
return False
|
|
94
|
+
if status.get("unavailableReplicas", 0) != 0:
|
|
95
|
+
return False
|
|
96
|
+
template = item["spec"]["template"]
|
|
97
|
+
if template["metadata"]["labels"].get("fdai.io/source-commit") != expected_source:
|
|
98
|
+
return False
|
|
99
|
+
if not any(
|
|
100
|
+
container.get("name") == name and container.get("image") == image
|
|
101
|
+
for container in template["spec"]["containers"]
|
|
102
|
+
):
|
|
103
|
+
return False
|
|
104
|
+
if not _secret_bindings_match(template, name, contract):
|
|
105
|
+
return False
|
|
106
|
+
selected = [
|
|
107
|
+
pod
|
|
108
|
+
for pod in pod_items
|
|
109
|
+
if pod["metadata"].get("labels", {}).get("app.kubernetes.io/name") == name
|
|
110
|
+
]
|
|
111
|
+
active = [pod for pod in selected if not pod["metadata"].get("deletionTimestamp")]
|
|
112
|
+
if len(active) != replicas or not all(
|
|
113
|
+
_healthy_pod(pod, name, image, expected_source) for pod in active
|
|
114
|
+
):
|
|
115
|
+
return False
|
|
116
|
+
return True
|
|
117
|
+
except (AttributeError, KeyError, TypeError, ValueError):
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _secret_bindings_match(template: dict[str, Any], name: str, contract: dict[str, Any]) -> bool:
|
|
122
|
+
"""Require the deployed container to declare exactly the contracted secret-backed env."""
|
|
123
|
+
required = contract.get("secret_environment")
|
|
124
|
+
if required is None:
|
|
125
|
+
return True
|
|
126
|
+
if not valid_secret_environment(required):
|
|
127
|
+
return False
|
|
128
|
+
container = next(
|
|
129
|
+
(item for item in template["spec"]["containers"] if item.get("name") == name),
|
|
130
|
+
None,
|
|
131
|
+
)
|
|
132
|
+
if container is None:
|
|
133
|
+
return False
|
|
134
|
+
environment = container.get("env", [])
|
|
135
|
+
if not isinstance(environment, list):
|
|
136
|
+
return False
|
|
137
|
+
observed: set[str] = set()
|
|
138
|
+
for entry in environment:
|
|
139
|
+
if not isinstance(entry, dict):
|
|
140
|
+
return False
|
|
141
|
+
reference = entry.get("valueFrom")
|
|
142
|
+
if reference is None:
|
|
143
|
+
continue
|
|
144
|
+
if not isinstance(reference, dict):
|
|
145
|
+
return False
|
|
146
|
+
secret_reference = reference.get("secretKeyRef")
|
|
147
|
+
if not isinstance(secret_reference, dict):
|
|
148
|
+
return False
|
|
149
|
+
env_name = entry.get("name")
|
|
150
|
+
if (
|
|
151
|
+
not isinstance(env_name, str)
|
|
152
|
+
or env_name in observed
|
|
153
|
+
or entry.get("value")
|
|
154
|
+
or secret_reference.get("name") != name
|
|
155
|
+
or secret_reference.get("key") != env_name
|
|
156
|
+
):
|
|
157
|
+
return False
|
|
158
|
+
observed.add(env_name)
|
|
159
|
+
return observed == set(required)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _items(raw: str, kind: str) -> list[dict[str, Any]]:
|
|
163
|
+
result = load_json_object(
|
|
164
|
+
raw.encode("utf-8"), label="AKS observation", max_bytes=16 * 1024 * 1024
|
|
165
|
+
)
|
|
166
|
+
items = result.get("items")
|
|
167
|
+
if not isinstance(items, list) or len(items) > 4096:
|
|
168
|
+
raise ValueError("AKS observation list is invalid")
|
|
169
|
+
if not all(isinstance(item, dict) for item in items):
|
|
170
|
+
raise ValueError("AKS observation item is invalid")
|
|
171
|
+
observed = result.get("kind")
|
|
172
|
+
# `kubectl get <resource> --output json` wraps results in a generic `v1.List`
|
|
173
|
+
# and moves the concrete type onto each item, while `kubectl get --raw` returns
|
|
174
|
+
# the typed collection. Accept the generic envelope only when every item
|
|
175
|
+
# declares the expected singular kind, so the type check is never weakened.
|
|
176
|
+
singular = kind.removesuffix("List")
|
|
177
|
+
if observed != kind and not (
|
|
178
|
+
observed == "List" and all(item.get("kind") == singular for item in items)
|
|
179
|
+
):
|
|
180
|
+
raise ValueError("AKS observation list is invalid")
|
|
181
|
+
return items
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _running_image_matches(container: dict[str, Any], image: str) -> bool:
|
|
185
|
+
"""Prove the running container resolves to the exact expected image digest.
|
|
186
|
+
|
|
187
|
+
``imageID`` carries the authoritative pulled reference. The sibling ``image``
|
|
188
|
+
field is runtime dependent: containerd reports the local config digest rather
|
|
189
|
+
than the deployed reference, so a bare ``sha256:`` value is accepted there.
|
|
190
|
+
"""
|
|
191
|
+
image_id = container.get("imageID")
|
|
192
|
+
if not isinstance(image_id, str) or not image_id.endswith("@" + image.split("@", 1)[1]):
|
|
193
|
+
return False
|
|
194
|
+
observed = container.get("image")
|
|
195
|
+
if not isinstance(observed, str) or not observed:
|
|
196
|
+
return False
|
|
197
|
+
return observed == image or _CONFIG_DIGEST.fullmatch(observed) is not None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _healthy_pod(pod: dict[str, Any], name: str, image: str, source_commit: str) -> bool:
|
|
201
|
+
labels = pod["metadata"].get("labels", {})
|
|
202
|
+
if (
|
|
203
|
+
labels.get("fdai.io/source-commit") != source_commit
|
|
204
|
+
or pod["status"].get("phase") != "Running"
|
|
205
|
+
):
|
|
206
|
+
return False
|
|
207
|
+
conditions = pod["status"].get("conditions", [])
|
|
208
|
+
if not any(
|
|
209
|
+
condition.get("type") == "Ready" and condition.get("status") == "True"
|
|
210
|
+
for condition in conditions
|
|
211
|
+
):
|
|
212
|
+
return False
|
|
213
|
+
containers = pod["status"].get("containerStatuses", [])
|
|
214
|
+
runtime = [container for container in containers if container.get("name") == name]
|
|
215
|
+
if len(runtime) != 1 or not all(container.get("ready") is True for container in containers):
|
|
216
|
+
return False
|
|
217
|
+
container = runtime[0]
|
|
218
|
+
return _running_image_matches(container, image) and isinstance(
|
|
219
|
+
container.get("state", {}).get("running"), dict
|
|
220
|
+
)
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Validate one digest-pinned AKS service rollout without widening its change scope."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from fdai_deployment_cli.aks_readiness import valid_secret_environment
|
|
9
|
+
from fdai_deployment_cli.contracts import load_json_object
|
|
10
|
+
|
|
11
|
+
SERVICES = frozenset(
|
|
12
|
+
{
|
|
13
|
+
"core-control-plane",
|
|
14
|
+
"document-ingestion-api",
|
|
15
|
+
"document-processing-worker",
|
|
16
|
+
"isolated-executor",
|
|
17
|
+
"operator-service",
|
|
18
|
+
}
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
_IMAGE = re.compile(r"[a-z0-9.-]+(?::[0-9]+)?/[a-z0-9._/-]+@sha256:[0-9a-f]{64}")
|
|
22
|
+
_SOURCE_COMMIT = re.compile(r"[0-9a-f]{40}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def validated_secret_environment(value: object) -> dict[str, str]:
|
|
26
|
+
"""Return one exact private workload secret contract or fail closed."""
|
|
27
|
+
if not valid_secret_environment(value):
|
|
28
|
+
raise ValueError("AKS workload secret environment is invalid")
|
|
29
|
+
return dict(value)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def validate_update_request(
|
|
33
|
+
*, service: str, image: str, source_commit: str, current_image: str
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Require one known service, immutable image, exact revision, and stable repository."""
|
|
36
|
+
|
|
37
|
+
if service not in SERVICES:
|
|
38
|
+
raise ValueError("AKS service update names an unsupported service")
|
|
39
|
+
if _IMAGE.fullmatch(image) is None:
|
|
40
|
+
raise ValueError("AKS service update image must be digest-pinned")
|
|
41
|
+
if _SOURCE_COMMIT.fullmatch(source_commit) is None:
|
|
42
|
+
raise ValueError("AKS service update source commit must be an exact lowercase revision")
|
|
43
|
+
if (
|
|
44
|
+
_IMAGE.fullmatch(current_image) is None
|
|
45
|
+
or image.partition("@")[0] != current_image.partition("@")[0]
|
|
46
|
+
):
|
|
47
|
+
raise ValueError("AKS service update cannot change the service image repository")
|
|
48
|
+
if image == current_image:
|
|
49
|
+
raise ValueError("AKS service update image must differ from the active desired image")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def deployment_snapshot(raw: str, *, expected_services: set[str]) -> dict[str, dict[str, object]]:
|
|
53
|
+
"""Return bounded rollout identity for the complete expected Deployment set."""
|
|
54
|
+
|
|
55
|
+
document = load_json_object(
|
|
56
|
+
raw.encode("utf-8"), label="AKS Deployment snapshot", max_bytes=16 * 1024 * 1024
|
|
57
|
+
)
|
|
58
|
+
items = document.get("items")
|
|
59
|
+
if document.get("kind") != "DeploymentList" or not isinstance(items, list):
|
|
60
|
+
raise ValueError("AKS Deployment snapshot is invalid")
|
|
61
|
+
result: dict[str, dict[str, object]] = {}
|
|
62
|
+
for item in items:
|
|
63
|
+
if not isinstance(item, dict):
|
|
64
|
+
raise ValueError("AKS Deployment snapshot item is invalid")
|
|
65
|
+
try:
|
|
66
|
+
metadata = item["metadata"]
|
|
67
|
+
template = item["spec"]["template"]
|
|
68
|
+
labels = template["metadata"]["labels"]
|
|
69
|
+
containers = template["spec"]["containers"]
|
|
70
|
+
name = metadata["name"]
|
|
71
|
+
uid = metadata["uid"]
|
|
72
|
+
generation = metadata["generation"]
|
|
73
|
+
except (KeyError, TypeError) as exc:
|
|
74
|
+
raise ValueError("AKS Deployment snapshot fields are incomplete") from exc
|
|
75
|
+
if (
|
|
76
|
+
not isinstance(name, str)
|
|
77
|
+
or name not in expected_services
|
|
78
|
+
or name in result
|
|
79
|
+
or not isinstance(uid, str)
|
|
80
|
+
or not uid
|
|
81
|
+
or type(generation) is not int
|
|
82
|
+
or generation < 1
|
|
83
|
+
or not isinstance(labels, dict)
|
|
84
|
+
or not isinstance(containers, list)
|
|
85
|
+
):
|
|
86
|
+
raise ValueError("AKS Deployment snapshot identity is invalid")
|
|
87
|
+
runtime = [
|
|
88
|
+
container
|
|
89
|
+
for container in containers
|
|
90
|
+
if isinstance(container, dict) and container.get("name") == name
|
|
91
|
+
]
|
|
92
|
+
source_commit = labels.get("fdai.io/source-commit")
|
|
93
|
+
image = runtime[0].get("image") if len(runtime) == 1 else None
|
|
94
|
+
if (
|
|
95
|
+
not isinstance(source_commit, str)
|
|
96
|
+
or _SOURCE_COMMIT.fullmatch(source_commit) is None
|
|
97
|
+
or not isinstance(image, str)
|
|
98
|
+
or _IMAGE.fullmatch(image) is None
|
|
99
|
+
):
|
|
100
|
+
raise ValueError("AKS Deployment snapshot rollout binding is invalid")
|
|
101
|
+
result[name] = {
|
|
102
|
+
"uid": uid,
|
|
103
|
+
"generation": generation,
|
|
104
|
+
"image": image,
|
|
105
|
+
"source_commit": source_commit,
|
|
106
|
+
}
|
|
107
|
+
if set(result) != expected_services:
|
|
108
|
+
raise ValueError("AKS Deployment snapshot is missing an expected service")
|
|
109
|
+
return dict(sorted(result.items()))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def validate_plan_scope(
|
|
113
|
+
summary: dict[str, Any],
|
|
114
|
+
*,
|
|
115
|
+
service: str,
|
|
116
|
+
allow_replacement: bool = False,
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Accept exactly one selected Deployment mutation and no other planned mutation."""
|
|
119
|
+
|
|
120
|
+
target = f'kubernetes_deployment_v1.workload["{service}"]'
|
|
121
|
+
changes = summary.get("resource_changes")
|
|
122
|
+
if not isinstance(changes, list):
|
|
123
|
+
raise ValueError("AKS service update plan inventory is invalid")
|
|
124
|
+
updates: list[str] = []
|
|
125
|
+
for item in changes:
|
|
126
|
+
if not isinstance(item, dict):
|
|
127
|
+
raise ValueError("AKS service update plan change is invalid")
|
|
128
|
+
address = item.get("address")
|
|
129
|
+
actions = item.get("actions")
|
|
130
|
+
if not isinstance(address, str) or not isinstance(actions, list):
|
|
131
|
+
raise ValueError("AKS service update plan change is invalid")
|
|
132
|
+
action_set = set(actions)
|
|
133
|
+
if action_set.issubset({"no-op", "read"}):
|
|
134
|
+
continue
|
|
135
|
+
if (
|
|
136
|
+
action_set == {"update"} or (allow_replacement and action_set == {"create", "delete"})
|
|
137
|
+
) and address == target:
|
|
138
|
+
updates.append(address)
|
|
139
|
+
continue
|
|
140
|
+
raise ValueError("AKS service update plan contains an out-of-scope mutation")
|
|
141
|
+
if updates != [target]:
|
|
142
|
+
raise ValueError("AKS service update plan must contain one selected Deployment update")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def service_update_review(update: dict[str, Any]) -> dict[str, str]:
|
|
146
|
+
"""Project one active service update into a bounded recovery identity."""
|
|
147
|
+
|
|
148
|
+
review = {
|
|
149
|
+
"service": str(update["service"]),
|
|
150
|
+
"image": str(update["image"]),
|
|
151
|
+
"source_commit": str(update["source_commit"]),
|
|
152
|
+
"update_digest": str(update["update_digest"]),
|
|
153
|
+
}
|
|
154
|
+
if update.get("development_authority_operation") not in {None, "preserve"}:
|
|
155
|
+
review["development_authority_operation"] = str(update["development_authority_operation"])
|
|
156
|
+
review["development_authority_profile_digest"] = str(
|
|
157
|
+
update.get("development_authority_profile_digest", "")
|
|
158
|
+
)
|
|
159
|
+
return review
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def service_update_target(service: str) -> str:
|
|
163
|
+
"""Return the exact Terraform address for one supported service."""
|
|
164
|
+
|
|
165
|
+
if service not in SERVICES:
|
|
166
|
+
raise ValueError("AKS service update target is unsupported")
|
|
167
|
+
return f'kubernetes_deployment_v1.workload["{service}"]'
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def peers_unchanged(
|
|
171
|
+
*,
|
|
172
|
+
before: dict[str, dict[str, object]],
|
|
173
|
+
after: dict[str, dict[str, object]],
|
|
174
|
+
service: str,
|
|
175
|
+
) -> bool:
|
|
176
|
+
"""Prove every unselected Deployment retained rollout identity and generation."""
|
|
177
|
+
|
|
178
|
+
if set(before) != set(after) or service not in before:
|
|
179
|
+
return False
|
|
180
|
+
return all(before[name] == after[name] for name in before if name != service)
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Render focused AKS scheduled-job values for standalone deployment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from fdai_deployment_cli.aks_catalog_review import aks_catalog_review_configuration
|
|
12
|
+
from fdai_deployment_cli.aks_job_execution import protected_cronjob_template_digest
|
|
13
|
+
from fdai_deployment_cli.standalone_aks_job_execution import (
|
|
14
|
+
protected_service_account_binding,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
_AKS_RESOURCE_ID = re.compile(
|
|
18
|
+
r"/subscriptions/[^/]+/resourcegroups/[^/]+/providers/"
|
|
19
|
+
r"microsoft\.containerservice/managedclusters/[^/]+",
|
|
20
|
+
re.IGNORECASE,
|
|
21
|
+
)
|
|
22
|
+
_SERVICE_ACCOUNT_ROOT = "/var/run/secrets/kubernetes.io/serviceaccount"
|
|
23
|
+
AKS_CANARY_TOPIC = "fdai.control.canary"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class AksScheduledJobPreparation:
|
|
28
|
+
"""Terraform job values plus independently retained execution bindings."""
|
|
29
|
+
|
|
30
|
+
jobs: dict[str, dict[str, object]]
|
|
31
|
+
protected_template_digests: dict[str, str]
|
|
32
|
+
protected_identity_bindings: dict[str, dict[str, object]]
|
|
33
|
+
catalog_review_available: bool
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def build_aks_scheduled_job(
|
|
37
|
+
refs: dict[str, Any],
|
|
38
|
+
identity: dict[str, Any],
|
|
39
|
+
command: list[str],
|
|
40
|
+
schedule: str,
|
|
41
|
+
environment: Mapping[str, object],
|
|
42
|
+
secret_environment: Mapping[str, str],
|
|
43
|
+
*,
|
|
44
|
+
component: str,
|
|
45
|
+
deadline_seconds: int,
|
|
46
|
+
retry_limit: int = 1,
|
|
47
|
+
suspend: bool = False,
|
|
48
|
+
) -> dict[str, object]:
|
|
49
|
+
"""Build one runtime-neutral scheduled or suspended AKS job value."""
|
|
50
|
+
|
|
51
|
+
image = refs.get("core-control-plane")
|
|
52
|
+
if not isinstance(image, str):
|
|
53
|
+
raise TypeError("AKS scheduled job image is unavailable")
|
|
54
|
+
return {
|
|
55
|
+
"component": component,
|
|
56
|
+
"image": image,
|
|
57
|
+
"identity_resource_id": identity["resource_id"],
|
|
58
|
+
"identity_client_id": identity["client_id"],
|
|
59
|
+
"command": command,
|
|
60
|
+
"args": [],
|
|
61
|
+
"schedule": schedule,
|
|
62
|
+
"suspend": suspend,
|
|
63
|
+
"deadline_seconds": deadline_seconds,
|
|
64
|
+
"retry_limit": retry_limit,
|
|
65
|
+
"cpu": "500m",
|
|
66
|
+
"memory": "1Gi",
|
|
67
|
+
"environment": {name: str(value) for name, value in environment.items()},
|
|
68
|
+
"secret_environment": secret_environment,
|
|
69
|
+
**(
|
|
70
|
+
{
|
|
71
|
+
"field_environment": {
|
|
72
|
+
"FDAI_CANARY_JOB_UID": "metadata.labels['batch.kubernetes.io/controller-uid']"
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if component == "canary"
|
|
76
|
+
else {}
|
|
77
|
+
),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def prepare_aks_scheduled_jobs(
|
|
82
|
+
*,
|
|
83
|
+
refs: dict[str, Any],
|
|
84
|
+
core_identity: dict[str, Any],
|
|
85
|
+
inventory_identity: dict[str, Any],
|
|
86
|
+
canary_identity: dict[str, Any],
|
|
87
|
+
core_environment: Mapping[str, object],
|
|
88
|
+
substrate_outputs: Mapping[str, object],
|
|
89
|
+
source_revision: str,
|
|
90
|
+
tenant_id: str,
|
|
91
|
+
subscription_id: str,
|
|
92
|
+
catalog_review_selected: bool,
|
|
93
|
+
cluster_id: str,
|
|
94
|
+
namespace: str,
|
|
95
|
+
) -> AksScheduledJobPreparation:
|
|
96
|
+
"""Build scheduled Jobs and seal their template and workload-identity contracts."""
|
|
97
|
+
|
|
98
|
+
inventory_environment = {
|
|
99
|
+
**core_environment,
|
|
100
|
+
"AZURE_CLIENT_ID": inventory_identity["client_id"],
|
|
101
|
+
"FDAI_MI_CLIENT_ID": inventory_identity["client_id"],
|
|
102
|
+
}
|
|
103
|
+
jobs = {
|
|
104
|
+
"analyzer": build_aks_scheduled_job(
|
|
105
|
+
refs,
|
|
106
|
+
inventory_identity,
|
|
107
|
+
["python", "-m", "fdai.delivery.analyzer_tick_cli"],
|
|
108
|
+
"* * * * *",
|
|
109
|
+
{
|
|
110
|
+
**inventory_environment,
|
|
111
|
+
"FDAI_ANALYZER_SCHEDULING_MODE": "kubernetes_cronjob",
|
|
112
|
+
"FDAI_TRACE_CONTINUITY_LOOKBACK_SECONDS": "900",
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"FDAI_STATE_STORE_DSN": "fdai-state-store-dsn",
|
|
116
|
+
"FDAI_INVENTORY_DSN": "fdai-state-store-dsn",
|
|
117
|
+
},
|
|
118
|
+
component="analysis",
|
|
119
|
+
deadline_seconds=240,
|
|
120
|
+
),
|
|
121
|
+
"canary": build_aks_scheduled_job(
|
|
122
|
+
refs,
|
|
123
|
+
canary_identity,
|
|
124
|
+
["python", "-m", "fdai.delivery.canary_cli"],
|
|
125
|
+
"*/5 * * * *",
|
|
126
|
+
{
|
|
127
|
+
"AZURE_CLIENT_ID": canary_identity["client_id"],
|
|
128
|
+
"KAFKA_BOOTSTRAP_SERVERS": substrate_outputs["operational_kafka"],
|
|
129
|
+
"FDAI_CANARY_TOPIC": AKS_CANARY_TOPIC,
|
|
130
|
+
"FDAI_MI_CLIENT_ID": canary_identity["client_id"],
|
|
131
|
+
},
|
|
132
|
+
{},
|
|
133
|
+
component="canary",
|
|
134
|
+
deadline_seconds=120,
|
|
135
|
+
retry_limit=2,
|
|
136
|
+
),
|
|
137
|
+
"inventory": build_aks_scheduled_job(
|
|
138
|
+
refs,
|
|
139
|
+
inventory_identity,
|
|
140
|
+
["python", "-m", "fdai.delivery.inventory_sync_cli"],
|
|
141
|
+
"* * * * *",
|
|
142
|
+
{
|
|
143
|
+
**inventory_environment,
|
|
144
|
+
**aks_inventory_binding_environment(cluster_id),
|
|
145
|
+
"FDAI_INVENTORY_SCOPES": subscription_id,
|
|
146
|
+
"FDAI_INVENTORY_SOURCES": "arg,arm",
|
|
147
|
+
"FDAI_MONITOR_WORKSPACE_ID": substrate_outputs["workspace"],
|
|
148
|
+
},
|
|
149
|
+
{"FDAI_INVENTORY_DSN": "fdai-state-store-dsn"},
|
|
150
|
+
component="inventory",
|
|
151
|
+
deadline_seconds=900,
|
|
152
|
+
),
|
|
153
|
+
"observation-campaign": build_aks_scheduled_job(
|
|
154
|
+
refs,
|
|
155
|
+
inventory_identity,
|
|
156
|
+
["python", "-m", "fdai.delivery.observation_campaign_cli"],
|
|
157
|
+
"* * * * *",
|
|
158
|
+
{
|
|
159
|
+
**inventory_environment,
|
|
160
|
+
"FDAI_OBSERVATION_SCOPES": subscription_id,
|
|
161
|
+
},
|
|
162
|
+
{"FDAI_OBSERVATION_DSN": "fdai-state-store-dsn"},
|
|
163
|
+
component="observation",
|
|
164
|
+
deadline_seconds=900,
|
|
165
|
+
),
|
|
166
|
+
}
|
|
167
|
+
catalog_review = aks_catalog_review_configuration(
|
|
168
|
+
substrate_outputs["catalog_review_gitops_binding"],
|
|
169
|
+
selected=catalog_review_selected,
|
|
170
|
+
source_revision=source_revision,
|
|
171
|
+
)
|
|
172
|
+
if catalog_review is not None:
|
|
173
|
+
jobs["catalog-review"] = build_aks_scheduled_job(
|
|
174
|
+
refs,
|
|
175
|
+
core_identity,
|
|
176
|
+
["python", "-m", "fdai.runtime.operational_catalog_review_trigger"],
|
|
177
|
+
"0 0 1 1 *",
|
|
178
|
+
{**core_environment, **catalog_review.environment},
|
|
179
|
+
{
|
|
180
|
+
**catalog_review.secret_environment,
|
|
181
|
+
"FDAI_STATE_STORE_DSN": "fdai-state-store-dsn",
|
|
182
|
+
},
|
|
183
|
+
component="catalog-review",
|
|
184
|
+
deadline_seconds=300,
|
|
185
|
+
retry_limit=0,
|
|
186
|
+
suspend=True,
|
|
187
|
+
)
|
|
188
|
+
operational_history_container_url = str(substrate_outputs["operational_history_container_url"])
|
|
189
|
+
if operational_history_container_url:
|
|
190
|
+
jobs["operational-history-lifecycle"] = build_aks_scheduled_job(
|
|
191
|
+
refs,
|
|
192
|
+
inventory_identity,
|
|
193
|
+
["python", "-m", "fdai.delivery.operational_history_lifecycle_runner"],
|
|
194
|
+
"0 * * * *",
|
|
195
|
+
{
|
|
196
|
+
**inventory_environment,
|
|
197
|
+
"FDAI_OPERATIONAL_HISTORY_CONTAINER_URL": operational_history_container_url,
|
|
198
|
+
"FDAI_OPERATIONAL_HISTORY_MODE": "shadow",
|
|
199
|
+
"FDAI_OPERATIONAL_HISTORY_MAX_PARTITIONS": "32",
|
|
200
|
+
},
|
|
201
|
+
{"FDAI_DATABASE_URL": "fdai-state-store-dsn"},
|
|
202
|
+
component="operational-history",
|
|
203
|
+
deadline_seconds=1800,
|
|
204
|
+
retry_limit=0,
|
|
205
|
+
)
|
|
206
|
+
template_digests = {
|
|
207
|
+
name: protected_cronjob_template_digest(
|
|
208
|
+
job,
|
|
209
|
+
template_name=name,
|
|
210
|
+
namespace=namespace,
|
|
211
|
+
source_revision=source_revision,
|
|
212
|
+
)
|
|
213
|
+
for name, job in jobs.items()
|
|
214
|
+
}
|
|
215
|
+
identity_bindings = {
|
|
216
|
+
name: protected_service_account_binding(
|
|
217
|
+
job,
|
|
218
|
+
template_name=name,
|
|
219
|
+
namespace=namespace,
|
|
220
|
+
tenant_id=tenant_id,
|
|
221
|
+
subscription_id=subscription_id,
|
|
222
|
+
)
|
|
223
|
+
for name, job in jobs.items()
|
|
224
|
+
}
|
|
225
|
+
return AksScheduledJobPreparation(
|
|
226
|
+
jobs=jobs,
|
|
227
|
+
protected_template_digests=template_digests,
|
|
228
|
+
protected_identity_bindings=identity_bindings,
|
|
229
|
+
catalog_review_available=catalog_review is not None,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def aks_inventory_binding_environment(cluster_id: str) -> dict[str, str]:
|
|
234
|
+
"""Bind inventory to its in-cluster read-only ServiceAccount projection."""
|
|
235
|
+
|
|
236
|
+
normalized_cluster_id = cluster_id.strip()
|
|
237
|
+
if _AKS_RESOURCE_ID.fullmatch(normalized_cluster_id) is None:
|
|
238
|
+
raise ValueError("AKS runtime cluster id is invalid")
|
|
239
|
+
return {
|
|
240
|
+
"FDAI_KUBERNETES_API_SERVER": "https://kubernetes.default.svc",
|
|
241
|
+
"FDAI_KUBERNETES_CLUSTER_REF": normalized_cluster_id,
|
|
242
|
+
"FDAI_KUBERNETES_AUTH_MODE": "service-account",
|
|
243
|
+
"FDAI_KUBERNETES_CA_PATH": f"{_SERVICE_ACCOUNT_ROOT}/ca.crt",
|
|
244
|
+
"FDAI_KUBERNETES_TOKEN_PATH": f"{_SERVICE_ACCOUNT_ROOT}/token",
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def aks_kubernetes_direct_api_environment(
|
|
249
|
+
cluster_id: str,
|
|
250
|
+
*,
|
|
251
|
+
namespace: str,
|
|
252
|
+
) -> dict[str, str]:
|
|
253
|
+
"""Bind namespace-limited Kubernetes effects to the isolated Executor."""
|
|
254
|
+
|
|
255
|
+
normalized_cluster_id = cluster_id.strip()
|
|
256
|
+
if _AKS_RESOURCE_ID.fullmatch(normalized_cluster_id) is None:
|
|
257
|
+
raise ValueError("AKS runtime cluster id is invalid")
|
|
258
|
+
if not re.fullmatch(r"[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?", namespace):
|
|
259
|
+
raise ValueError("AKS runtime namespace is invalid")
|
|
260
|
+
return {
|
|
261
|
+
"FDAI_KUBERNETES_DIRECT_API_JSON": json.dumps(
|
|
262
|
+
{
|
|
263
|
+
"allowed_namespaces": [namespace],
|
|
264
|
+
"api_server": "https://kubernetes.default.svc",
|
|
265
|
+
"ca_path": f"{_SERVICE_ACCOUNT_ROOT}/ca.crt",
|
|
266
|
+
"cluster_ref": normalized_cluster_id,
|
|
267
|
+
"token_path": f"{_SERVICE_ACCOUNT_ROOT}/token",
|
|
268
|
+
},
|
|
269
|
+
separators=(",", ":"),
|
|
270
|
+
sort_keys=True,
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
__all__ = [
|
|
276
|
+
"AksScheduledJobPreparation",
|
|
277
|
+
"aks_inventory_binding_environment",
|
|
278
|
+
"aks_kubernetes_direct_api_environment",
|
|
279
|
+
"build_aks_scheduled_job",
|
|
280
|
+
"prepare_aks_scheduled_jobs",
|
|
281
|
+
]
|