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,732 @@
|
|
|
1
|
+
"""Validate bounded reconciliation of a historical AKS workload baseline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from fdai_deployment_cli.aks_service_update import SERVICES
|
|
11
|
+
|
|
12
|
+
_GUID = re.compile(r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}")
|
|
13
|
+
_IMAGE = re.compile(r"[a-z0-9.-]+(?::[0-9]+)?/[a-z0-9._/-]+@sha256:[0-9a-f]{64}")
|
|
14
|
+
_IDENTITY_ID = re.compile(
|
|
15
|
+
r"/subscriptions/[^/]+/resourceGroups/[^/]+/providers/"
|
|
16
|
+
r"Microsoft[.]ManagedIdentity/userAssignedIdentities/[^/]+",
|
|
17
|
+
re.IGNORECASE,
|
|
18
|
+
)
|
|
19
|
+
_COMMAND_FIC = "workload-operator-service-command"
|
|
20
|
+
_INVENTORY_RESOURCES = {
|
|
21
|
+
"kubernetes_cluster_role_v1.inventory_reader[0]",
|
|
22
|
+
"kubernetes_cluster_role_binding_v1.inventory_reader[0]",
|
|
23
|
+
}
|
|
24
|
+
_JOBS = {"analyzer", "canary", "inventory", "observation-campaign"}
|
|
25
|
+
_BRIDGE_SCRIPT = "/opt/fdai-compat/identity_bridge.py"
|
|
26
|
+
_EXTERNAL_SERVICES = {"core-control-plane", "document-processing-worker", "isolated-executor"}
|
|
27
|
+
_LEGACY_RUNTIME_COMMANDS = {
|
|
28
|
+
"core-control-plane": (
|
|
29
|
+
["/app/.venv/bin/python"],
|
|
30
|
+
[
|
|
31
|
+
"/opt/fdai-compat/identity_bridge.py",
|
|
32
|
+
"--",
|
|
33
|
+
"/app/.venv/bin/fdai-core-control-plane",
|
|
34
|
+
],
|
|
35
|
+
),
|
|
36
|
+
"document-ingestion-api": (
|
|
37
|
+
["/app/.venv/bin/python"],
|
|
38
|
+
[
|
|
39
|
+
"/opt/fdai-compat/identity_bridge.py",
|
|
40
|
+
"--",
|
|
41
|
+
"/app/.venv/bin/fdai-document-ingestion-api",
|
|
42
|
+
],
|
|
43
|
+
),
|
|
44
|
+
"document-processing-worker": (
|
|
45
|
+
["/app/.venv/bin/python"],
|
|
46
|
+
[
|
|
47
|
+
"/opt/fdai-compat/identity_bridge.py",
|
|
48
|
+
"--",
|
|
49
|
+
"/app/.venv/bin/fdai-document-processing-worker",
|
|
50
|
+
],
|
|
51
|
+
),
|
|
52
|
+
"isolated-executor": (
|
|
53
|
+
["/app/.venv/bin/python"],
|
|
54
|
+
[
|
|
55
|
+
"/opt/fdai-compat/identity_bridge.py",
|
|
56
|
+
"--",
|
|
57
|
+
"/app/.venv/bin/fdai-isolated-executor-service",
|
|
58
|
+
],
|
|
59
|
+
),
|
|
60
|
+
"operator-service": (
|
|
61
|
+
["/app/.venv/bin/python"],
|
|
62
|
+
[
|
|
63
|
+
"/opt/fdai-compat/identity_bridge.py",
|
|
64
|
+
"--",
|
|
65
|
+
"/app/.venv/bin/fdai-operator-service",
|
|
66
|
+
],
|
|
67
|
+
),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def reconciled_variables(
|
|
72
|
+
*, state: dict[str, Any], variables: dict[str, Any], live: dict[str, Any]
|
|
73
|
+
) -> dict[str, Any]:
|
|
74
|
+
"""Restore only safety-critical variable values proven by retained state and live AKS."""
|
|
75
|
+
|
|
76
|
+
result = copy.deepcopy(variables)
|
|
77
|
+
workloads = _mapping(result.get("workloads"), "historical AKS workloads")
|
|
78
|
+
if set(workloads) != set(SERVICES):
|
|
79
|
+
raise ValueError("historical AKS workload inventory is incomplete")
|
|
80
|
+
deployments = _live_deployments(live)
|
|
81
|
+
identity_bridge = _identity_bridge(state)
|
|
82
|
+
result["identity_bridge"] = identity_bridge
|
|
83
|
+
|
|
84
|
+
for name, workload_value in workloads.items():
|
|
85
|
+
workload = _mapping(workload_value, f"historical AKS {name} workload")
|
|
86
|
+
command, arguments = _LEGACY_RUNTIME_COMMANDS[name]
|
|
87
|
+
live_container = _runtime_container(deployments[name], name)
|
|
88
|
+
if (
|
|
89
|
+
live_container.get("command") != command
|
|
90
|
+
or live_container.get("args") != arguments
|
|
91
|
+
or workload.get("command", command) != command
|
|
92
|
+
or workload.get("args", arguments) != arguments
|
|
93
|
+
):
|
|
94
|
+
raise ValueError(f"historical AKS {name} identity bridge command is invalid")
|
|
95
|
+
if workload.get("identity_bridge_enabled") not in (None, True):
|
|
96
|
+
raise ValueError(f"historical AKS {name} identity bridge binding differs")
|
|
97
|
+
workload["identity_bridge_enabled"] = True
|
|
98
|
+
|
|
99
|
+
jobs = _mapping(result.get("scheduled_jobs", {}), "historical AKS scheduled jobs")
|
|
100
|
+
for name, job_value in jobs.items():
|
|
101
|
+
job = _mapping(job_value, f"historical AKS {name} scheduled job")
|
|
102
|
+
arguments = job.get("args") or []
|
|
103
|
+
if list(arguments[:2]) != [_BRIDGE_SCRIPT, "--"]:
|
|
104
|
+
continue
|
|
105
|
+
if job.get("identity_bridge_enabled") not in (None, True):
|
|
106
|
+
raise ValueError(f"historical AKS {name} scheduled job identity bridge binding differs")
|
|
107
|
+
job["identity_bridge_enabled"] = True
|
|
108
|
+
|
|
109
|
+
operator = _mapping(workloads.get("operator-service"), "operator workload")
|
|
110
|
+
operator_live = deployments["operator-service"]
|
|
111
|
+
command_client_id = _environment_value(
|
|
112
|
+
_runtime_container(operator_live, "operator-service"),
|
|
113
|
+
"FDAI_COMMAND_MI_CLIENT_ID",
|
|
114
|
+
)
|
|
115
|
+
if _GUID.fullmatch(command_client_id) is None:
|
|
116
|
+
raise ValueError("historical AKS command identity client id is invalid")
|
|
117
|
+
command_identity = _command_identity(state, variables=variables)
|
|
118
|
+
command_identity["client_id"] = command_client_id
|
|
119
|
+
existing_identities = _mapping(
|
|
120
|
+
operator.get("additional_identities", {}), "operator additional identities"
|
|
121
|
+
)
|
|
122
|
+
if existing_identities not in ({}, {"command": command_identity}):
|
|
123
|
+
raise ValueError("historical AKS operator additional identities differ")
|
|
124
|
+
operator["additional_identities"] = {"command": command_identity}
|
|
125
|
+
|
|
126
|
+
worker = _mapping(workloads.get("document-processing-worker"), "document worker")
|
|
127
|
+
clamav, fs_group = _clamav_sidecar(deployments["document-processing-worker"])
|
|
128
|
+
existing_sidecars = _mapping(worker.get("sidecars", {}), "document worker sidecars")
|
|
129
|
+
if existing_sidecars not in ({}, {"clamav": clamav}):
|
|
130
|
+
raise ValueError("historical AKS document worker sidecars differ")
|
|
131
|
+
worker["sidecars"] = {"clamav": clamav}
|
|
132
|
+
if worker.get("fs_group") not in (None, fs_group):
|
|
133
|
+
raise ValueError("historical AKS document worker fs_group differs")
|
|
134
|
+
worker["fs_group"] = fs_group
|
|
135
|
+
return result
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _identity_bridge(state: dict[str, Any]) -> dict[str, str]:
|
|
139
|
+
matches: list[dict[str, Any]] = []
|
|
140
|
+
for raw_resource in state.get("resources", []):
|
|
141
|
+
if not isinstance(raw_resource, dict):
|
|
142
|
+
continue
|
|
143
|
+
if (
|
|
144
|
+
raw_resource.get("mode") != "managed"
|
|
145
|
+
or raw_resource.get("type") != "kubernetes_config_map_v1"
|
|
146
|
+
or raw_resource.get("name") != "identity_bridge"
|
|
147
|
+
):
|
|
148
|
+
continue
|
|
149
|
+
for raw_instance in raw_resource.get("instances", []):
|
|
150
|
+
if isinstance(raw_instance, dict):
|
|
151
|
+
matches.append(_mapping(raw_instance.get("attributes"), "identity bridge"))
|
|
152
|
+
if len(matches) != 1:
|
|
153
|
+
raise ValueError("historical AKS identity bridge state is invalid")
|
|
154
|
+
attributes = matches[0]
|
|
155
|
+
metadata = attributes.get("metadata")
|
|
156
|
+
data = _mapping(attributes.get("data"), "identity bridge data")
|
|
157
|
+
script = data.get("identity_bridge.py")
|
|
158
|
+
if (
|
|
159
|
+
not isinstance(metadata, list)
|
|
160
|
+
or len(metadata) != 1
|
|
161
|
+
or not isinstance(metadata[0], dict)
|
|
162
|
+
or metadata[0].get("name") != "fdai-identity-bridge"
|
|
163
|
+
or set(data) != {"identity_bridge.py"}
|
|
164
|
+
or not isinstance(script, str)
|
|
165
|
+
or not script
|
|
166
|
+
or len(script.encode()) > 65536
|
|
167
|
+
):
|
|
168
|
+
raise ValueError("historical AKS identity bridge contract is invalid")
|
|
169
|
+
return {
|
|
170
|
+
"config_map_name": "fdai-identity-bridge",
|
|
171
|
+
"script": script,
|
|
172
|
+
"runtime_state_size_limit": "256Mi",
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def validate_reconciliation_plan(
|
|
177
|
+
plan: dict[str, Any], *, variables: dict[str, Any]
|
|
178
|
+
) -> tuple[str, ...]:
|
|
179
|
+
"""Accept only the known legacy normalization while preserving rollout identity."""
|
|
180
|
+
|
|
181
|
+
changes = plan.get("resource_changes")
|
|
182
|
+
if plan.get("errored") is not False or plan.get("applyable") is not True:
|
|
183
|
+
raise ValueError("historical AKS reconciliation plan is invalid")
|
|
184
|
+
if not isinstance(changes, list) or not changes:
|
|
185
|
+
raise ValueError("historical AKS reconciliation plan inventory is invalid")
|
|
186
|
+
workloads = _mapping(variables.get("workloads"), "reconciled AKS workloads")
|
|
187
|
+
if set(workloads) != set(SERVICES):
|
|
188
|
+
raise ValueError("reconciled AKS workload inventory is incomplete")
|
|
189
|
+
|
|
190
|
+
mutated: list[str] = []
|
|
191
|
+
command_fic_seen = False
|
|
192
|
+
for raw_change in changes:
|
|
193
|
+
change = _mapping(raw_change, "historical AKS reconciliation change")
|
|
194
|
+
address = change.get("address")
|
|
195
|
+
actions = _mapping(change.get("change"), "historical AKS reconciliation actions").get(
|
|
196
|
+
"actions"
|
|
197
|
+
)
|
|
198
|
+
if (
|
|
199
|
+
not isinstance(address, str)
|
|
200
|
+
or not isinstance(actions, list)
|
|
201
|
+
or not all(isinstance(action, str) for action in actions)
|
|
202
|
+
):
|
|
203
|
+
raise ValueError("historical AKS reconciliation change is invalid")
|
|
204
|
+
if address == f'azurerm_federated_identity_credential.identity["{_COMMAND_FIC}"]':
|
|
205
|
+
command_fic_seen = True
|
|
206
|
+
if set(actions) - {"no-op", "read"}:
|
|
207
|
+
raise ValueError("historical AKS reconciliation changes the command identity")
|
|
208
|
+
if set(actions).issubset({"no-op", "read"}):
|
|
209
|
+
continue
|
|
210
|
+
mutated.append(address)
|
|
211
|
+
before_raw = change["change"].get("before")
|
|
212
|
+
after_raw = change["change"].get("after")
|
|
213
|
+
before = (
|
|
214
|
+
{}
|
|
215
|
+
if before_raw is None and actions == ["create"]
|
|
216
|
+
else _mapping(before_raw, "reconciliation prior value")
|
|
217
|
+
)
|
|
218
|
+
after = (
|
|
219
|
+
{}
|
|
220
|
+
if after_raw is None and actions == ["delete"]
|
|
221
|
+
else _mapping(after_raw, "reconciliation planned value")
|
|
222
|
+
)
|
|
223
|
+
if address in _INVENTORY_RESOURCES and actions == ["create"]:
|
|
224
|
+
if before:
|
|
225
|
+
raise ValueError("historical AKS inventory read role already has prior state")
|
|
226
|
+
continue
|
|
227
|
+
workload = _indexed_name(address, "kubernetes_deployment_v1.workload")
|
|
228
|
+
if workload is not None and workload in SERVICES and actions == ["update"]:
|
|
229
|
+
_validate_workload_update(before, after, name=workload, contract=workloads[workload])
|
|
230
|
+
continue
|
|
231
|
+
job = _indexed_name(address, "kubernetes_cron_job_v1.job")
|
|
232
|
+
if job is not None and job in _JOBS and actions == ["update"]:
|
|
233
|
+
if _normalized_cron_job(before) != _normalized_cron_job(after):
|
|
234
|
+
raise ValueError("historical AKS reconciliation changes a scheduled job contract")
|
|
235
|
+
continue
|
|
236
|
+
provider = _indexed_name(address, "kubernetes_manifest.workload_secret_provider")
|
|
237
|
+
if provider is not None and provider in SERVICES and actions == ["update"]:
|
|
238
|
+
if not _effective_secret_binding_unchanged(before, after):
|
|
239
|
+
raise ValueError("historical AKS reconciliation changes a secret binding")
|
|
240
|
+
continue
|
|
241
|
+
service = _indexed_name(address, "kubernetes_service_v1.workload")
|
|
242
|
+
if service is not None and service in _EXTERNAL_SERVICES and actions == ["update"]:
|
|
243
|
+
prior = copy.deepcopy(before)
|
|
244
|
+
planned = copy.deepcopy(after)
|
|
245
|
+
prior.pop("wait_for_load_balancer", None)
|
|
246
|
+
planned.pop("wait_for_load_balancer", None)
|
|
247
|
+
if prior != planned:
|
|
248
|
+
raise ValueError("historical AKS reconciliation changes a Service contract")
|
|
249
|
+
continue
|
|
250
|
+
raise ValueError("historical AKS reconciliation contains an out-of-scope mutation")
|
|
251
|
+
if not command_fic_seen or not mutated:
|
|
252
|
+
raise ValueError("historical AKS reconciliation omits required evidence")
|
|
253
|
+
return tuple(sorted(mutated))
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _command_identity(state: dict[str, Any], *, variables: dict[str, Any]) -> dict[str, str]:
|
|
257
|
+
matches: list[dict[str, Any]] = []
|
|
258
|
+
for raw_resource in state.get("resources", []):
|
|
259
|
+
if not isinstance(raw_resource, dict):
|
|
260
|
+
continue
|
|
261
|
+
if (
|
|
262
|
+
raw_resource.get("mode") != "managed"
|
|
263
|
+
or raw_resource.get("type") != "azurerm_federated_identity_credential"
|
|
264
|
+
or raw_resource.get("name") != "identity"
|
|
265
|
+
):
|
|
266
|
+
continue
|
|
267
|
+
for raw_instance in raw_resource.get("instances", []):
|
|
268
|
+
if isinstance(raw_instance, dict) and raw_instance.get("index_key") == _COMMAND_FIC:
|
|
269
|
+
matches.append(_mapping(raw_instance.get("attributes"), "command identity"))
|
|
270
|
+
if len(matches) != 1:
|
|
271
|
+
raise ValueError("historical AKS command identity state is invalid")
|
|
272
|
+
attributes = matches[0]
|
|
273
|
+
namespace = variables.get("namespace")
|
|
274
|
+
if (
|
|
275
|
+
attributes.get("audience") != ["api://AzureADTokenExchange"]
|
|
276
|
+
or attributes.get("issuer") != variables.get("oidc_issuer_url")
|
|
277
|
+
or attributes.get("subject") != f"system:serviceaccount:{namespace}:operator-service"
|
|
278
|
+
or not isinstance(attributes.get("parent_id"), str)
|
|
279
|
+
or _IDENTITY_ID.fullmatch(attributes["parent_id"]) is None
|
|
280
|
+
):
|
|
281
|
+
raise ValueError("historical AKS command identity binding is invalid")
|
|
282
|
+
return {"resource_id": attributes["parent_id"]}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _live_deployments(live: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
|
286
|
+
items = live.get("items")
|
|
287
|
+
if live.get("apiVersion") != "apps/v1" or live.get("kind") != "DeploymentList":
|
|
288
|
+
raise ValueError("historical AKS reconciliation requires a typed DeploymentList")
|
|
289
|
+
if not isinstance(items, list):
|
|
290
|
+
raise ValueError("historical AKS reconciliation Deployment inventory is invalid")
|
|
291
|
+
result: dict[str, dict[str, Any]] = {}
|
|
292
|
+
for raw_item in items:
|
|
293
|
+
item = _mapping(raw_item, "historical AKS live Deployment")
|
|
294
|
+
name = _mapping(item.get("metadata"), "historical AKS Deployment metadata").get("name")
|
|
295
|
+
if not isinstance(name, str) or name not in SERVICES or name in result:
|
|
296
|
+
raise ValueError("historical AKS live Deployment identity is invalid")
|
|
297
|
+
result[name] = item
|
|
298
|
+
if set(result) != set(SERVICES):
|
|
299
|
+
raise ValueError("historical AKS live Deployment inventory is incomplete")
|
|
300
|
+
return result
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _runtime_container(deployment: dict[str, Any], name: str) -> dict[str, Any]:
|
|
304
|
+
containers = _pod_spec(deployment).get("containers")
|
|
305
|
+
if not isinstance(containers, list):
|
|
306
|
+
raise ValueError("historical AKS live containers are invalid")
|
|
307
|
+
selected = [item for item in containers if isinstance(item, dict) and item.get("name") == name]
|
|
308
|
+
if len(selected) != 1:
|
|
309
|
+
raise ValueError("historical AKS live runtime container is invalid")
|
|
310
|
+
return dict(selected[0])
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _environment_value(container: dict[str, Any], name: str) -> str:
|
|
314
|
+
values = [
|
|
315
|
+
item.get("value")
|
|
316
|
+
for item in container.get("env", [])
|
|
317
|
+
if isinstance(item, dict) and item.get("name") == name and set(item) == {"name", "value"}
|
|
318
|
+
]
|
|
319
|
+
if len(values) != 1 or not isinstance(values[0], str):
|
|
320
|
+
raise ValueError(f"historical AKS live {name} environment is invalid")
|
|
321
|
+
return values[0]
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _clamav_sidecar(deployment: dict[str, Any]) -> tuple[dict[str, object], int]:
|
|
325
|
+
container = _runtime_container(deployment, "clamav")
|
|
326
|
+
image = container.get("image")
|
|
327
|
+
ports = container.get("ports")
|
|
328
|
+
resources = _mapping(container.get("resources"), "ClamAV resources")
|
|
329
|
+
requests = _mapping(resources.get("requests"), "ClamAV requests")
|
|
330
|
+
limits = _mapping(resources.get("limits"), "ClamAV limits")
|
|
331
|
+
security = _mapping(container.get("securityContext"), "ClamAV security context")
|
|
332
|
+
capabilities = _mapping(security.get("capabilities"), "ClamAV capabilities")
|
|
333
|
+
if (
|
|
334
|
+
not isinstance(image, str)
|
|
335
|
+
or _IMAGE.fullmatch(image) is None
|
|
336
|
+
or ports != [{"containerPort": 3310, "protocol": "TCP"}]
|
|
337
|
+
or set(requests) != {"cpu", "memory"}
|
|
338
|
+
or requests != limits
|
|
339
|
+
or not all(isinstance(requests[key], str) and requests[key] for key in requests)
|
|
340
|
+
or security.get("allowPrivilegeEscalation") is not False
|
|
341
|
+
or security.get("readOnlyRootFilesystem") is not True
|
|
342
|
+
or security.get("runAsUser") != 100
|
|
343
|
+
or security.get("runAsGroup") != 101
|
|
344
|
+
or capabilities.get("drop") != ["ALL"]
|
|
345
|
+
):
|
|
346
|
+
raise ValueError("historical AKS live ClamAV contract is invalid")
|
|
347
|
+
expected_paths = {
|
|
348
|
+
"clamav-database": ("/var/lib/clamav", "1Gi"),
|
|
349
|
+
"clamav-run": ("/run/clamav", "64Mi"),
|
|
350
|
+
"clamav-tmp": ("/tmp", "256Mi"),
|
|
351
|
+
}
|
|
352
|
+
mounts = {
|
|
353
|
+
item.get("name"): item.get("mountPath")
|
|
354
|
+
for item in container.get("volumeMounts", [])
|
|
355
|
+
if isinstance(item, dict)
|
|
356
|
+
}
|
|
357
|
+
volumes = {
|
|
358
|
+
item.get("name"): _mapping(item.get("emptyDir"), "ClamAV volume").get("sizeLimit")
|
|
359
|
+
for item in _pod_spec(deployment).get("volumes", [])
|
|
360
|
+
if isinstance(item, dict) and item.get("name") in expected_paths
|
|
361
|
+
}
|
|
362
|
+
if mounts != {name: path for name, (path, _size) in expected_paths.items()} or volumes != {
|
|
363
|
+
name: size for name, (_path, size) in expected_paths.items()
|
|
364
|
+
}:
|
|
365
|
+
raise ValueError("historical AKS live ClamAV writable paths are invalid")
|
|
366
|
+
pod_security = _mapping(_pod_spec(deployment).get("securityContext"), "worker security")
|
|
367
|
+
init_containers = _pod_spec(deployment).get("initContainers")
|
|
368
|
+
if pod_security.get("fsGroup") != 101 or not isinstance(init_containers, list):
|
|
369
|
+
raise ValueError("historical AKS live ClamAV Pod security is invalid")
|
|
370
|
+
selected_init = [
|
|
371
|
+
item
|
|
372
|
+
for item in init_containers
|
|
373
|
+
if isinstance(item, dict) and item.get("name") == "clamav-database"
|
|
374
|
+
]
|
|
375
|
+
if len(selected_init) != 1:
|
|
376
|
+
raise ValueError("historical AKS live ClamAV initialization is invalid")
|
|
377
|
+
init = selected_init[0]
|
|
378
|
+
init_security = _mapping(init.get("securityContext"), "ClamAV init security")
|
|
379
|
+
init_mounts = init.get("volumeMounts")
|
|
380
|
+
init_mount = init_mounts[0] if isinstance(init_mounts, list) and len(init_mounts) == 1 else None
|
|
381
|
+
if (
|
|
382
|
+
init.get("image") != image
|
|
383
|
+
or init.get("imagePullPolicy") != "IfNotPresent"
|
|
384
|
+
or init.get("command") != ["/bin/sh", "-c"]
|
|
385
|
+
or init.get("args") != ["cp -a /var/lib/clamav/. /target/"]
|
|
386
|
+
or init_security.get("allowPrivilegeEscalation") is not False
|
|
387
|
+
or init_security.get("readOnlyRootFilesystem") is not True
|
|
388
|
+
or init_security.get("runAsUser") != 100
|
|
389
|
+
or init_security.get("runAsGroup") != 101
|
|
390
|
+
or _mapping(init_security.get("capabilities"), "ClamAV init capabilities").get("drop")
|
|
391
|
+
!= ["ALL"]
|
|
392
|
+
or not isinstance(init_mount, dict)
|
|
393
|
+
or init_mount.get("name") != "clamav-database"
|
|
394
|
+
or init_mount.get("mountPath") != "/target"
|
|
395
|
+
or init_mount.get("readOnly") not in (None, False)
|
|
396
|
+
or init_mount.get("mountPropagation") not in (None, "None")
|
|
397
|
+
):
|
|
398
|
+
raise ValueError("historical AKS live ClamAV initialization is invalid")
|
|
399
|
+
command = container.get("command", [])
|
|
400
|
+
arguments = container.get("args", [])
|
|
401
|
+
if (
|
|
402
|
+
not isinstance(command, list)
|
|
403
|
+
or not isinstance(arguments, list)
|
|
404
|
+
or not all(isinstance(value, str) for value in [*command, *arguments])
|
|
405
|
+
):
|
|
406
|
+
raise ValueError("historical AKS live ClamAV command is invalid")
|
|
407
|
+
return {
|
|
408
|
+
"image": image,
|
|
409
|
+
"command": command,
|
|
410
|
+
"args": arguments,
|
|
411
|
+
"cpu": requests["cpu"],
|
|
412
|
+
"memory": requests["memory"],
|
|
413
|
+
"port": 3310,
|
|
414
|
+
"run_as_user": 100,
|
|
415
|
+
"run_as_group": 101,
|
|
416
|
+
"init": {
|
|
417
|
+
"name": "clamav-database",
|
|
418
|
+
"command": ["/bin/sh", "-c"],
|
|
419
|
+
"args": ["cp -a /var/lib/clamav/. /target/"],
|
|
420
|
+
"image_pull_policy": "IfNotPresent",
|
|
421
|
+
"run_as_user": 100,
|
|
422
|
+
"run_as_group": 101,
|
|
423
|
+
"writable_path": "database",
|
|
424
|
+
"mount_path": "/target",
|
|
425
|
+
},
|
|
426
|
+
"writable_paths": {
|
|
427
|
+
name.removeprefix("clamav-"): {"mount_path": path, "size_limit": size}
|
|
428
|
+
for name, (path, size) in expected_paths.items()
|
|
429
|
+
},
|
|
430
|
+
}, 101
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _validate_workload_update(
|
|
434
|
+
before: dict[str, Any], after: dict[str, Any], *, name: str, contract: object
|
|
435
|
+
) -> None:
|
|
436
|
+
expected = _mapping(contract, f"reconciled workload {name}")
|
|
437
|
+
for value in (before, after):
|
|
438
|
+
container = _terraform_runtime_container(value, name)
|
|
439
|
+
labels = _terraform_template_metadata(value).get("labels")
|
|
440
|
+
if (
|
|
441
|
+
container.get("image") != expected.get("image")
|
|
442
|
+
or not isinstance(labels, dict)
|
|
443
|
+
or labels.get("fdai.io/source-commit") != expected.get("source_commit")
|
|
444
|
+
):
|
|
445
|
+
raise ValueError("historical AKS reconciliation changes rollout identity")
|
|
446
|
+
if name == "document-processing-worker":
|
|
447
|
+
expected_clamav = _mapping(
|
|
448
|
+
_mapping(expected.get("sidecars"), "reconciled sidecars").get("clamav"),
|
|
449
|
+
"reconciled ClamAV sidecar",
|
|
450
|
+
)
|
|
451
|
+
planned = _terraform_runtime_container(after, "clamav")
|
|
452
|
+
if planned.get("image") != expected_clamav.get("image"):
|
|
453
|
+
raise ValueError("historical AKS reconciliation removes the ClamAV sidecar")
|
|
454
|
+
if _normalized_pod_owner(before) != _normalized_pod_owner(after):
|
|
455
|
+
raise ValueError("historical AKS reconciliation changes a workload contract")
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _normalized_pod_owner(value: dict[str, Any]) -> dict[str, Any]:
|
|
459
|
+
result = copy.deepcopy(value)
|
|
460
|
+
result.pop("wait_for_rollout", None)
|
|
461
|
+
_normalize_template(_terraform_template(result))
|
|
462
|
+
return result
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _normalized_cron_job(value: dict[str, Any]) -> dict[str, Any]:
|
|
466
|
+
result = copy.deepcopy(value)
|
|
467
|
+
try:
|
|
468
|
+
template = result["spec"][0]["job_template"][0]["spec"][0]["template"][0]
|
|
469
|
+
except (KeyError, IndexError, TypeError) as exc:
|
|
470
|
+
raise ValueError("historical AKS planned CronJob template is invalid") from exc
|
|
471
|
+
if not isinstance(template, dict):
|
|
472
|
+
raise ValueError("historical AKS planned CronJob template is invalid")
|
|
473
|
+
_normalize_template(template)
|
|
474
|
+
return result
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _normalize_container_environment(container: dict[str, Any]) -> None:
|
|
478
|
+
"""Compare environment by name and binding, not by Terraform list order.
|
|
479
|
+
|
|
480
|
+
Kubernetes resolves ``$(VAR)`` references in declaration order, so ordering is only
|
|
481
|
+
incidental while no value uses that syntax. When one does, the order is preserved and a
|
|
482
|
+
reordering stays a real contract change. Terraform also records an unset literal as ``""``
|
|
483
|
+
in state and ``None`` in a plan for the same secret-backed entry; both mean "no literal".
|
|
484
|
+
"""
|
|
485
|
+
|
|
486
|
+
entries = container.get("env")
|
|
487
|
+
if not isinstance(entries, list):
|
|
488
|
+
return
|
|
489
|
+
normalized: list[Any] = []
|
|
490
|
+
interpolated = False
|
|
491
|
+
for entry in entries:
|
|
492
|
+
if not isinstance(entry, dict):
|
|
493
|
+
normalized.append(entry)
|
|
494
|
+
continue
|
|
495
|
+
item = copy.deepcopy(entry)
|
|
496
|
+
value = item.get("value")
|
|
497
|
+
if isinstance(value, str) and "$(" in value:
|
|
498
|
+
interpolated = True
|
|
499
|
+
if value in (None, ""):
|
|
500
|
+
item.pop("value", None)
|
|
501
|
+
for source in (
|
|
502
|
+
item.get("value_from", []) if isinstance(item.get("value_from"), list) else []
|
|
503
|
+
):
|
|
504
|
+
if not isinstance(source, dict):
|
|
505
|
+
continue
|
|
506
|
+
for refs in source.values():
|
|
507
|
+
if not isinstance(refs, list):
|
|
508
|
+
continue
|
|
509
|
+
for ref in refs:
|
|
510
|
+
if isinstance(ref, dict) and not ref.get("optional"):
|
|
511
|
+
ref.pop("optional", None)
|
|
512
|
+
normalized.append(item)
|
|
513
|
+
if interpolated:
|
|
514
|
+
container["env"] = normalized
|
|
515
|
+
return
|
|
516
|
+
container["env"] = sorted(
|
|
517
|
+
normalized,
|
|
518
|
+
key=lambda item: item.get("name", "") if isinstance(item, dict) else "",
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _sorted_block_array(document: str) -> str:
|
|
523
|
+
"""Order a generated ``yamlencode`` array so entry order stops being a difference.
|
|
524
|
+
|
|
525
|
+
Terraform renders the array from a map, so its order follows the map key order while a
|
|
526
|
+
live object can retain an older order. Only the recognized ``key:`` header followed by
|
|
527
|
+
``- `` entries is reordered; any other shape is returned unchanged so a real difference
|
|
528
|
+
still fails.
|
|
529
|
+
"""
|
|
530
|
+
|
|
531
|
+
lines = document.splitlines(keepends=True)
|
|
532
|
+
start = next((index for index, line in enumerate(lines) if line.startswith("- ")), None)
|
|
533
|
+
if start is None:
|
|
534
|
+
return document
|
|
535
|
+
header = lines[:start]
|
|
536
|
+
blocks: list[list[str]] = []
|
|
537
|
+
for line in lines[start:]:
|
|
538
|
+
if line.startswith("- "):
|
|
539
|
+
blocks.append([line])
|
|
540
|
+
elif blocks and (line.startswith((" ", "\t")) or not line.strip()):
|
|
541
|
+
blocks[-1].append(line)
|
|
542
|
+
else:
|
|
543
|
+
return document
|
|
544
|
+
return "".join(header) + "".join("".join(block) for block in sorted("".join(b) for b in blocks))
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def normalized_secret_provider(value: dict[str, Any]) -> dict[str, Any]:
|
|
548
|
+
"""Compare a workload SecretProviderClass by its bindings, not by generated order."""
|
|
549
|
+
|
|
550
|
+
result = copy.deepcopy(value)
|
|
551
|
+
spec = result.get("object", {})
|
|
552
|
+
spec = spec.get("spec") if isinstance(spec, dict) else None
|
|
553
|
+
if not isinstance(spec, dict):
|
|
554
|
+
return result
|
|
555
|
+
secret_objects = spec.get("secretObjects")
|
|
556
|
+
if isinstance(secret_objects, list):
|
|
557
|
+
for entry in secret_objects:
|
|
558
|
+
if not isinstance(entry, dict):
|
|
559
|
+
continue
|
|
560
|
+
data = entry.get("data")
|
|
561
|
+
if isinstance(data, list):
|
|
562
|
+
entry["data"] = sorted(data, key=lambda item: json.dumps(item, sort_keys=True))
|
|
563
|
+
parameters = spec.get("parameters")
|
|
564
|
+
if isinstance(parameters, dict) and isinstance(parameters.get("objects"), str):
|
|
565
|
+
parameters["objects"] = _sorted_block_array(parameters["objects"])
|
|
566
|
+
return result
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _effective_secret_binding_unchanged(before: object, after: object) -> bool:
|
|
570
|
+
"""Compare the effective SecretProviderClass, not Terraform's recorded manifest.
|
|
571
|
+
|
|
572
|
+
``kubernetes_manifest`` records both the configured ``manifest`` and the ``object`` the
|
|
573
|
+
cluster holds. A recorded manifest can lag behind an object that already carries a binding,
|
|
574
|
+
and correcting it removes nothing. Only an effective change matters here, so an absent or
|
|
575
|
+
unknown object fails closed.
|
|
576
|
+
"""
|
|
577
|
+
|
|
578
|
+
if not isinstance(before, dict) or not isinstance(after, dict):
|
|
579
|
+
return False
|
|
580
|
+
prior = before.get("object")
|
|
581
|
+
planned = after.get("object")
|
|
582
|
+
if not isinstance(prior, dict) or not isinstance(planned, dict):
|
|
583
|
+
return False
|
|
584
|
+
return normalized_secret_provider({"object": prior}) == normalized_secret_provider(
|
|
585
|
+
{"object": planned}
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def secret_binding_reordered(address: str, change: object) -> bool:
|
|
590
|
+
"""Report a workload SecretProviderClass update that only reorders the same bindings."""
|
|
591
|
+
|
|
592
|
+
match = re.fullmatch(r'kubernetes_manifest[.]workload_secret_provider\["([^"\\]+)"\]', address)
|
|
593
|
+
if match is None or match.group(1) not in SERVICES:
|
|
594
|
+
return False
|
|
595
|
+
detail = change if isinstance(change, dict) else {}
|
|
596
|
+
if detail.get("actions") != ["update"]:
|
|
597
|
+
return False
|
|
598
|
+
return _effective_secret_binding_unchanged(detail.get("before"), detail.get("after"))
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _normalize_template(template: dict[str, Any]) -> None:
|
|
602
|
+
metadata = _mapping(template.get("metadata", [{}])[0], "planned Pod metadata")
|
|
603
|
+
annotations = metadata.get("annotations")
|
|
604
|
+
if isinstance(annotations, dict):
|
|
605
|
+
annotations.pop("kubectl.kubernetes.io/restartedAt", None)
|
|
606
|
+
labels = metadata.get("labels")
|
|
607
|
+
if isinstance(labels, dict):
|
|
608
|
+
labels.pop("fdai.io/identity-bridge-sha", None)
|
|
609
|
+
spec = _mapping(template.get("spec", [{}])[0], "planned Pod spec")
|
|
610
|
+
for container_key in ("container", "init_container"):
|
|
611
|
+
containers = spec.get(container_key)
|
|
612
|
+
if not isinstance(containers, list):
|
|
613
|
+
continue
|
|
614
|
+
retained = []
|
|
615
|
+
for container in containers:
|
|
616
|
+
if not isinstance(container, dict):
|
|
617
|
+
retained.append(container)
|
|
618
|
+
continue
|
|
619
|
+
item = copy.deepcopy(container)
|
|
620
|
+
if container_key == "init_container" and item.get("name") == "identity-bridge":
|
|
621
|
+
continue
|
|
622
|
+
legacy_command = _LEGACY_RUNTIME_COMMANDS.get(str(item.get("name")))
|
|
623
|
+
if (
|
|
624
|
+
legacy_command is not None
|
|
625
|
+
and item.get("command") == legacy_command[0]
|
|
626
|
+
and item.get("args") == legacy_command[1]
|
|
627
|
+
):
|
|
628
|
+
item["command"] = []
|
|
629
|
+
item["args"] = []
|
|
630
|
+
security = item.get("security_context")
|
|
631
|
+
if isinstance(security, list) and len(security) == 1 and isinstance(security[0], dict):
|
|
632
|
+
security[0].pop("run_as_non_root", None)
|
|
633
|
+
_normalize_container_environment(item)
|
|
634
|
+
mounts = item.get("volume_mount")
|
|
635
|
+
if isinstance(mounts, list):
|
|
636
|
+
item["volume_mount"] = [
|
|
637
|
+
mount
|
|
638
|
+
for mount in mounts
|
|
639
|
+
if not isinstance(mount, dict)
|
|
640
|
+
or mount.get("name") not in {"identity-bridge", "runtime-state"}
|
|
641
|
+
]
|
|
642
|
+
for mount in item["volume_mount"]:
|
|
643
|
+
if not isinstance(mount, dict):
|
|
644
|
+
continue
|
|
645
|
+
for field in ("sub_path", "sub_path_expr"):
|
|
646
|
+
if mount.get(field) in (None, ""):
|
|
647
|
+
mount.pop(field, None)
|
|
648
|
+
retained.append(item)
|
|
649
|
+
spec[container_key] = retained
|
|
650
|
+
volumes = spec.get("volume")
|
|
651
|
+
if isinstance(volumes, list):
|
|
652
|
+
spec["volume"] = [
|
|
653
|
+
volume
|
|
654
|
+
for volume in volumes
|
|
655
|
+
if not isinstance(volume, dict)
|
|
656
|
+
or volume.get("name") not in {"identity-bridge", "runtime-state"}
|
|
657
|
+
]
|
|
658
|
+
for volume in spec["volume"]:
|
|
659
|
+
if not isinstance(volume, dict):
|
|
660
|
+
continue
|
|
661
|
+
csi = volume.get("csi")
|
|
662
|
+
if (
|
|
663
|
+
isinstance(csi, list)
|
|
664
|
+
and len(csi) == 1
|
|
665
|
+
and isinstance(csi[0], dict)
|
|
666
|
+
and csi[0].get("fs_type") in (None, "")
|
|
667
|
+
):
|
|
668
|
+
csi[0].pop("fs_type", None)
|
|
669
|
+
empty_dir = volume.get("empty_dir")
|
|
670
|
+
if (
|
|
671
|
+
isinstance(empty_dir, list)
|
|
672
|
+
and len(empty_dir) == 1
|
|
673
|
+
and isinstance(empty_dir[0], dict)
|
|
674
|
+
and empty_dir[0].get("medium") in (None, "")
|
|
675
|
+
):
|
|
676
|
+
empty_dir[0].pop("medium", None)
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _terraform_runtime_container(value: dict[str, Any], name: str) -> dict[str, Any]:
|
|
680
|
+
containers = _mapping(_terraform_template(value).get("spec", [{}])[0], "planned Pod spec").get(
|
|
681
|
+
"container"
|
|
682
|
+
)
|
|
683
|
+
if not isinstance(containers, list):
|
|
684
|
+
raise ValueError("historical AKS planned containers are invalid")
|
|
685
|
+
selected = [item for item in containers if isinstance(item, dict) and item.get("name") == name]
|
|
686
|
+
if len(selected) != 1:
|
|
687
|
+
raise ValueError("historical AKS planned runtime container is invalid")
|
|
688
|
+
return dict(selected[0])
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _terraform_template(value: dict[str, Any]) -> dict[str, Any]:
|
|
692
|
+
spec = value.get("spec")
|
|
693
|
+
if not isinstance(spec, list) or len(spec) != 1 or not isinstance(spec[0], dict):
|
|
694
|
+
raise ValueError("historical AKS planned workload spec is invalid")
|
|
695
|
+
template = spec[0].get("template")
|
|
696
|
+
if not isinstance(template, list) or len(template) != 1 or not isinstance(template[0], dict):
|
|
697
|
+
raise ValueError("historical AKS planned Pod template is invalid")
|
|
698
|
+
return template[0]
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _terraform_template_metadata(value: dict[str, Any]) -> dict[str, Any]:
|
|
702
|
+
metadata = _terraform_template(value).get("metadata")
|
|
703
|
+
if not isinstance(metadata, list) or len(metadata) != 1 or not isinstance(metadata[0], dict):
|
|
704
|
+
raise ValueError("historical AKS planned Pod metadata is invalid")
|
|
705
|
+
return metadata[0]
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _pod_spec(deployment: dict[str, Any]) -> dict[str, Any]:
|
|
709
|
+
try:
|
|
710
|
+
value = deployment["spec"]["template"]["spec"]
|
|
711
|
+
except (KeyError, TypeError) as exc:
|
|
712
|
+
raise ValueError("historical AKS live Pod spec is invalid") from exc
|
|
713
|
+
return _mapping(value, "historical AKS live Pod spec")
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _indexed_name(address: str, prefix: str) -> str | None:
|
|
717
|
+
match = re.fullmatch(rf'{re.escape(prefix)}\["([^"\\]+)"\]', address)
|
|
718
|
+
return match.group(1) if match is not None else None
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _mapping(value: object, label: str) -> dict[str, Any]:
|
|
722
|
+
if not isinstance(value, dict):
|
|
723
|
+
raise ValueError(f"{label} is invalid")
|
|
724
|
+
return value
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def single_block(value: object) -> dict[str, Any]:
|
|
728
|
+
"""Return one required Terraform nested block."""
|
|
729
|
+
|
|
730
|
+
if not isinstance(value, list) or len(value) != 1 or not isinstance(value[0], dict):
|
|
731
|
+
raise ValueError("historical AKS Terraform nested block is invalid")
|
|
732
|
+
return dict(value[0])
|