fdai-deployment-cli 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. fdai_deployment_cli/__about__.py +3 -0
  2. fdai_deployment_cli/__init__.py +5 -0
  3. fdai_deployment_cli/__main__.py +5 -0
  4. fdai_deployment_cli/acr_publish.py +585 -0
  5. fdai_deployment_cli/aks_catalog_review.py +137 -0
  6. fdai_deployment_cli/aks_historical_reconciliation.py +732 -0
  7. fdai_deployment_cli/aks_job_environment.py +59 -0
  8. fdai_deployment_cli/aks_job_execution.py +794 -0
  9. fdai_deployment_cli/aks_kubernetes_defaults.py +66 -0
  10. fdai_deployment_cli/aks_preflight.py +422 -0
  11. fdai_deployment_cli/aks_readiness.py +220 -0
  12. fdai_deployment_cli/aks_service_update.py +180 -0
  13. fdai_deployment_cli/aks_workload_jobs.py +281 -0
  14. fdai_deployment_cli/application_state_adoption.py +347 -0
  15. fdai_deployment_cli/azure_actor.py +61 -0
  16. fdai_deployment_cli/azure_naming.py +252 -0
  17. fdai_deployment_cli/azure_retail_prices.py +173 -0
  18. fdai_deployment_cli/bootstrap_approval.py +99 -0
  19. fdai_deployment_cli/bootstrap_channel.py +321 -0
  20. fdai_deployment_cli/bootstrap_executor.py +311 -0
  21. fdai_deployment_cli/bootstrap_plan.py +237 -0
  22. fdai_deployment_cli/bootstrap_reconcile.py +402 -0
  23. fdai_deployment_cli/bootstrap_support.py +138 -0
  24. fdai_deployment_cli/bootstrap_worker.py +448 -0
  25. fdai_deployment_cli/bundle.py +352 -0
  26. fdai_deployment_cli/capacity.py +143 -0
  27. fdai_deployment_cli/catalog_review_profile.py +306 -0
  28. fdai_deployment_cli/cli.py +798 -0
  29. fdai_deployment_cli/cli_help.py +214 -0
  30. fdai_deployment_cli/cli_parser.py +791 -0
  31. fdai_deployment_cli/cli_plan.py +450 -0
  32. fdai_deployment_cli/compiler.py +99 -0
  33. fdai_deployment_cli/console_artifact.py +218 -0
  34. fdai_deployment_cli/console_config.py +214 -0
  35. fdai_deployment_cli/console_update.py +832 -0
  36. fdai_deployment_cli/contracts.py +307 -0
  37. fdai_deployment_cli/control_package.py +140 -0
  38. fdai_deployment_cli/deadline_transport.py +70 -0
  39. fdai_deployment_cli/deployment_cost.py +131 -0
  40. fdai_deployment_cli/deployment_deadline.py +28 -0
  41. fdai_deployment_cli/deployment_kit.py +790 -0
  42. fdai_deployment_cli/deployment_kit_cache.py +191 -0
  43. fdai_deployment_cli/deployment_progress.py +648 -0
  44. fdai_deployment_cli/development_authority_cli.py +56 -0
  45. fdai_deployment_cli/development_authority_profile.py +319 -0
  46. fdai_deployment_cli/doctor.py +136 -0
  47. fdai_deployment_cli/entitlement_preflight.py +68 -0
  48. fdai_deployment_cli/entitlement_transport.py +49 -0
  49. fdai_deployment_cli/entra_bootstrap.py +579 -0
  50. fdai_deployment_cli/entra_control_profile.py +214 -0
  51. fdai_deployment_cli/entra_graph.py +349 -0
  52. fdai_deployment_cli/entra_profiles.py +215 -0
  53. fdai_deployment_cli/entra_source.py +244 -0
  54. fdai_deployment_cli/execution_bundle.py +478 -0
  55. fdai_deployment_cli/execution_copies.py +63 -0
  56. fdai_deployment_cli/foundation_adoption.py +438 -0
  57. fdai_deployment_cli/foundation_adoption_evidence.py +245 -0
  58. fdai_deployment_cli/foundation_adoption_host.py +157 -0
  59. fdai_deployment_cli/foundation_adoption_transport.py +51 -0
  60. fdai_deployment_cli/foundation_failure.py +72 -0
  61. fdai_deployment_cli/foundation_image.py +168 -0
  62. fdai_deployment_cli/foundation_input.py +235 -0
  63. fdai_deployment_cli/foundation_output.py +154 -0
  64. fdai_deployment_cli/foundation_plan.py +381 -0
  65. fdai_deployment_cli/foundation_process.py +64 -0
  66. fdai_deployment_cli/foundation_progress.py +231 -0
  67. fdai_deployment_cli/github_actions.py +171 -0
  68. fdai_deployment_cli/github_apply_artifact.py +259 -0
  69. fdai_deployment_cli/github_artifact_io.py +48 -0
  70. fdai_deployment_cli/github_dispatch.py +248 -0
  71. fdai_deployment_cli/github_plan_artifact.py +181 -0
  72. fdai_deployment_cli/github_provider_artifact.py +170 -0
  73. fdai_deployment_cli/github_transport.py +46 -0
  74. fdai_deployment_cli/github_workflow_values.py +312 -0
  75. fdai_deployment_cli/hub_image_deploy.py +354 -0
  76. fdai_deployment_cli/hub_image_stage.py +162 -0
  77. fdai_deployment_cli/hub_image_trust.py +281 -0
  78. fdai_deployment_cli/hub_ownership.py +161 -0
  79. fdai_deployment_cli/hub_workload_handoff.py +264 -0
  80. fdai_deployment_cli/hub_workload_handoff_inputs.py +227 -0
  81. fdai_deployment_cli/identity_profile.py +227 -0
  82. fdai_deployment_cli/image_targets.py +197 -0
  83. fdai_deployment_cli/installation_scope.py +280 -0
  84. fdai_deployment_cli/license.py +311 -0
  85. fdai_deployment_cli/license_issue.py +239 -0
  86. fdai_deployment_cli/lifecycle_authorization.py +252 -0
  87. fdai_deployment_cli/lifecycle_commands.py +173 -0
  88. fdai_deployment_cli/lifecycle_configuration.py +722 -0
  89. fdai_deployment_cli/lifecycle_configuration_artifact.py +45 -0
  90. fdai_deployment_cli/lifecycle_configuration_cli.py +324 -0
  91. fdai_deployment_cli/lifecycle_configuration_document.py +74 -0
  92. fdai_deployment_cli/lifecycle_configuration_entities.py +146 -0
  93. fdai_deployment_cli/lifecycle_configuration_package.py +492 -0
  94. fdai_deployment_cli/lifecycle_configuration_publication.py +277 -0
  95. fdai_deployment_cli/lifecycle_configuration_publication_cli.py +191 -0
  96. fdai_deployment_cli/lifecycle_configuration_sealing.py +127 -0
  97. fdai_deployment_cli/lifecycle_enrollment.py +45 -0
  98. fdai_deployment_cli/lifecycle_infrastructure_agent.py +174 -0
  99. fdai_deployment_cli/lifecycle_installation_binding.py +65 -0
  100. fdai_deployment_cli/lifecycle_native.py +247 -0
  101. fdai_deployment_cli/lifecycle_native_reconciliation.py +568 -0
  102. fdai_deployment_cli/lifecycle_native_transition.py +339 -0
  103. fdai_deployment_cli/lifecycle_ownership_exclusion.py +78 -0
  104. fdai_deployment_cli/lifecycle_plan.py +800 -0
  105. fdai_deployment_cli/lifecycle_promotion.py +185 -0
  106. fdai_deployment_cli/lifecycle_release.py +308 -0
  107. fdai_deployment_cli/lifecycle_self_upgrade.py +202 -0
  108. fdai_deployment_cli/lifecycle_upgrade_bundle.py +176 -0
  109. fdai_deployment_cli/notification_recipients_input.py +222 -0
  110. fdai_deployment_cli/observer_artifact.py +106 -0
  111. fdai_deployment_cli/oci_archive.py +353 -0
  112. fdai_deployment_cli/offline_kit.py +797 -0
  113. fdai_deployment_cli/offline_prepare.py +135 -0
  114. fdai_deployment_cli/operational_evidence_verifier_input.py +575 -0
  115. fdai_deployment_cli/plan_input.py +101 -0
  116. fdai_deployment_cli/private_output.py +192 -0
  117. fdai_deployment_cli/profile.py +70 -0
  118. fdai_deployment_cli/progress.py +183 -0
  119. fdai_deployment_cli/readiness.py +286 -0
  120. fdai_deployment_cli/run_command_receiver.py +418 -0
  121. fdai_deployment_cli/runtime_build.py +386 -0
  122. fdai_deployment_cli/runtime_profile.py +287 -0
  123. fdai_deployment_cli/runtime_release.py +745 -0
  124. fdai_deployment_cli/runtime_stage.py +82 -0
  125. fdai_deployment_cli/runtime_support_installation.py +215 -0
  126. fdai_deployment_cli/simulation.py +80 -0
  127. fdai_deployment_cli/source_application_inputs.py +397 -0
  128. fdai_deployment_cli/source_azure.py +586 -0
  129. fdai_deployment_cli/source_change_cli.py +107 -0
  130. fdai_deployment_cli/source_change_parser.py +75 -0
  131. fdai_deployment_cli/source_change_plan.py +181 -0
  132. fdai_deployment_cli/source_changed_update.py +196 -0
  133. fdai_deployment_cli/source_deploy.py +170 -0
  134. fdai_deployment_cli/source_foundation.py +174 -0
  135. fdai_deployment_cli/source_host_tools.py +163 -0
  136. fdai_deployment_cli/source_image_stage.py +400 -0
  137. fdai_deployment_cli/source_input.py +173 -0
  138. fdai_deployment_cli/source_projection.py +164 -0
  139. fdai_deployment_cli/source_receiver.py +76 -0
  140. fdai_deployment_cli/source_recovery.py +264 -0
  141. fdai_deployment_cli/source_runtime.py +81 -0
  142. fdai_deployment_cli/source_runtime_support.py +368 -0
  143. fdai_deployment_cli/source_service_update.py +726 -0
  144. fdai_deployment_cli/source_service_update_cli.py +47 -0
  145. fdai_deployment_cli/source_snapshot.py +178 -0
  146. fdai_deployment_cli/source_teardown.py +574 -0
  147. fdai_deployment_cli/source_terraform.py +85 -0
  148. fdai_deployment_cli/source_transport.py +399 -0
  149. fdai_deployment_cli/standalone_aks_inventory.py +558 -0
  150. fdai_deployment_cli/standalone_aks_job_execution.py +769 -0
  151. fdai_deployment_cli/standalone_aks_nodepool_guard.py +141 -0
  152. fdai_deployment_cli/standalone_application.py +977 -0
  153. fdai_deployment_cli/standalone_application_completion.py +151 -0
  154. fdai_deployment_cli/standalone_application_input_recovery.py +107 -0
  155. fdai_deployment_cli/standalone_application_recovery.py +116 -0
  156. fdai_deployment_cli/standalone_approval_input.py +24 -0
  157. fdai_deployment_cli/standalone_catalog_checkpoint.py +122 -0
  158. fdai_deployment_cli/standalone_catalog_review.py +251 -0
  159. fdai_deployment_cli/standalone_checkpoint_failure.py +127 -0
  160. fdai_deployment_cli/standalone_console_publish.py +174 -0
  161. fdai_deployment_cli/standalone_database_principals.py +41 -0
  162. fdai_deployment_cli/standalone_deploy.py +638 -0
  163. fdai_deployment_cli/standalone_development_authority.py +166 -0
  164. fdai_deployment_cli/standalone_foundation_adoption.py +87 -0
  165. fdai_deployment_cli/standalone_foundation_transition.py +767 -0
  166. fdai_deployment_cli/standalone_host.py +4608 -0
  167. fdai_deployment_cli/standalone_host_rebind.py +226 -0
  168. fdai_deployment_cli/standalone_host_residual.py +104 -0
  169. fdai_deployment_cli/standalone_host_state.py +145 -0
  170. fdai_deployment_cli/standalone_host_values.py +563 -0
  171. fdai_deployment_cli/standalone_kubernetes_tools.py +100 -0
  172. fdai_deployment_cli/standalone_license_installation.py +177 -0
  173. fdai_deployment_cli/standalone_management_egress.py +83 -0
  174. fdai_deployment_cli/standalone_migration_evidence.py +38 -0
  175. fdai_deployment_cli/standalone_operational_evidence.py +356 -0
  176. fdai_deployment_cli/standalone_planned_outputs.py +147 -0
  177. fdai_deployment_cli/standalone_private_access_probe.py +100 -0
  178. fdai_deployment_cli/standalone_product_profile.py +110 -0
  179. fdai_deployment_cli/standalone_remote_prepare.py +442 -0
  180. fdai_deployment_cli/standalone_residual_apply.py +540 -0
  181. fdai_deployment_cli/standalone_residual_successor.py +431 -0
  182. fdai_deployment_cli/standalone_review.py +266 -0
  183. fdai_deployment_cli/standalone_service_update_finalization.py +116 -0
  184. fdai_deployment_cli/standalone_service_update_recovery.py +277 -0
  185. fdai_deployment_cli/standalone_stage_targets.py +325 -0
  186. fdai_deployment_cli/standalone_status.py +67 -0
  187. fdai_deployment_cli/standalone_terraform_environment.py +237 -0
  188. fdai_deployment_cli/standalone_transfer_cleanup.py +237 -0
  189. fdai_deployment_cli/standalone_trial_activation.py +193 -0
  190. fdai_deployment_cli/state.py +434 -0
  191. fdai_deployment_cli/state_handoff.py +253 -0
  192. fdai_deployment_cli/status_projection.py +97 -0
  193. fdai_deployment_cli/support_install.py +257 -0
  194. fdai_deployment_cli/target.py +46 -0
  195. fdai_deployment_cli/trust/capabilities.json +62 -0
  196. fdai_deployment_cli/trust/deployment-bundle-root.pub +3 -0
  197. fdai_deployment_cli/trust/deployment-release-root.pub +3 -0
  198. fdai_deployment_cli/trust/upstream-signing-key.pub +3 -0
  199. fdai_deployment_cli/trust_roots.py +50 -0
  200. fdai_deployment_cli-0.1.2.dist-info/METADATA +112 -0
  201. fdai_deployment_cli-0.1.2.dist-info/RECORD +203 -0
  202. fdai_deployment_cli-0.1.2.dist-info/WHEEL +4 -0
  203. fdai_deployment_cli-0.1.2.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,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
+ ]