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,116 @@
1
+ """Receipt-gated finalization for one completed AKS service update."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Any, Callable
9
+
10
+ from fdai_deployment_cli.contracts import canonical_digest
11
+
12
+
13
+ def add_finalization_parser(
14
+ subcommands: Any,
15
+ *,
16
+ services: frozenset[str],
17
+ ) -> None:
18
+ """Register the managed-host finalization checkpoint."""
19
+
20
+ parser = subcommands.add_parser("finalize-service-update")
21
+ parser.add_argument("--service", choices=sorted(services), required=True)
22
+ parser.add_argument("--operation", required=True)
23
+ parser.add_argument("--receipt-digest", required=True)
24
+ parser.set_defaults(handler=finalize_service_update)
25
+
26
+
27
+ def _finalize_service_update(
28
+ args: argparse.Namespace,
29
+ work_dir: Path,
30
+ *,
31
+ private_json: Callable[[Path, str], dict[str, Any]],
32
+ replace_private_json: Callable[[Path, dict[str, object]], None],
33
+ service_update_record: Callable[..., dict[str, Any] | None],
34
+ service_update_review: Callable[[dict[str, Any]], dict[str, str]],
35
+ ) -> dict[str, object]:
36
+ """Clear one active marker only after complete apply receipt revalidation."""
37
+
38
+ operation = str(args.operation)
39
+ service = str(args.service)
40
+ if re.fullmatch(rf"service-update-{re.escape(service)}-[0-9a-f]{{12}}", operation) is None:
41
+ raise ValueError("AKS service update finalization operation is invalid")
42
+ final_path = work_dir / f"{operation}-finalization-receipt.json"
43
+ if final_path.exists():
44
+ retained = private_json(final_path, "AKS service update finalization")
45
+ if retained.get("apply_receipt_digest") != args.receipt_digest:
46
+ raise ValueError("AKS service update finalization receipt differs")
47
+ return retained
48
+ context_path = work_dir / "context.json"
49
+ context = private_json(context_path, "standalone host context")
50
+ active = context.get("active_service_update")
51
+ if (
52
+ not isinstance(active, dict)
53
+ or active.get("operation") != operation
54
+ or active.get("service") != service
55
+ ):
56
+ raise ValueError("active AKS service update differs from finalization")
57
+ record = service_update_record(context, work_dir, stage="application", service=service)
58
+ if record is None:
59
+ raise ValueError("AKS service update record is unavailable")
60
+ apply_receipt = private_json(
61
+ work_dir / f"{operation}-receipt.json",
62
+ "AKS service update apply receipt",
63
+ )
64
+ digest = apply_receipt.get("receipt_digest")
65
+ material = {key: value for key, value in apply_receipt.items() if key != "receipt_digest"}
66
+ operation_bound = apply_receipt.get("operation") == operation or (
67
+ apply_receipt.get("operation") is None
68
+ and apply_receipt.get("verification_only_recovery") is True
69
+ )
70
+ if (
71
+ digest != args.receipt_digest
72
+ or digest != canonical_digest(material)
73
+ or apply_receipt.get("state") != "applied"
74
+ or not operation_bound
75
+ or apply_receipt.get("service_update") != service_update_review(record)
76
+ or apply_receipt.get("control_plane_readback_verified") is not True
77
+ or apply_receipt.get("peer_state_unchanged_verified") is not True
78
+ or apply_receipt.get("terraform_zero_change_verified") is not True
79
+ ):
80
+ raise ValueError("AKS service update apply receipt is incomplete")
81
+ context.pop("active_service_update")
82
+ replace_private_json(context_path, context)
83
+ result: dict[str, object] = {
84
+ "schema_version": "fdai.aks-service-update-finalization.v1",
85
+ "state": "finalized",
86
+ "operation": operation,
87
+ "service": service,
88
+ "update_digest": record["update_digest"],
89
+ "apply_receipt_digest": digest,
90
+ "mutation_performed": False,
91
+ "subscription_ready": False,
92
+ }
93
+ result["receipt_digest"] = canonical_digest(result)
94
+ replace_private_json(final_path, result)
95
+ return result
96
+
97
+
98
+ def finalize_service_update(
99
+ args: argparse.Namespace,
100
+ work_dir: Path,
101
+ ) -> dict[str, object]:
102
+ """Resolve host-owned readers lazily to avoid a module cycle."""
103
+
104
+ from fdai_deployment_cli import standalone_host
105
+
106
+ return _finalize_service_update(
107
+ args,
108
+ work_dir,
109
+ private_json=standalone_host._private_json,
110
+ replace_private_json=standalone_host._replace_private_json,
111
+ service_update_record=standalone_host._service_update_record,
112
+ service_update_review=standalone_host._service_update_review,
113
+ )
114
+
115
+
116
+ __all__ = ["add_finalization_parser", "finalize_service_update"]
@@ -0,0 +1,277 @@
1
+ """Admit one Core source update from a bounded partial AKS application baseline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from fdai_deployment_cli.aks_service_update import (
8
+ service_update_review,
9
+ validated_secret_environment,
10
+ validate_plan_scope,
11
+ )
12
+ from fdai_deployment_cli.contracts import canonical_digest
13
+ from fdai_deployment_cli.standalone_host_state import private_json
14
+
15
+ _CORE = 'kubernetes_deployment_v1.workload["core-control-plane"]'
16
+ _ALLOWED_CHANGES = {
17
+ _CORE: ("delete", "create"),
18
+ 'kubernetes_horizontal_pod_autoscaler_v2.workload["core-control-plane"]': ("create",),
19
+ 'kubernetes_horizontal_pod_autoscaler_v2.workload["document-ingestion-api"]': ("create",),
20
+ 'kubernetes_horizontal_pod_autoscaler_v2.workload["document-processing-worker"]': ("create",),
21
+ 'kubernetes_horizontal_pod_autoscaler_v2.workload["operator-service"]': ("create",),
22
+ "module.browser_gateway.azurerm_network_security_rule.browser_gateway[0]": ("create",),
23
+ }
24
+
25
+
26
+ def readback_context(
27
+ work_dir: Path,
28
+ context: dict[str, object],
29
+ ) -> dict[str, object]:
30
+ """Refresh readback-only secret contracts from the exact planned workload variables."""
31
+ values = private_json(work_dir / "workloads.auto.tfvars.json", "AKS workload variables")
32
+ workloads = values.get("workloads")
33
+ expected = context.get("expected_workloads")
34
+ if not isinstance(workloads, dict) or not isinstance(expected, dict):
35
+ raise ValueError("AKS service update workload inventory is invalid")
36
+ if set(workloads) != set(expected):
37
+ raise ValueError("AKS service update workload inventory differs")
38
+ refreshed: dict[str, dict[str, object]] = {}
39
+ for name, contract_value in expected.items():
40
+ workload = workloads.get(name)
41
+ if not isinstance(contract_value, dict) or not isinstance(workload, dict):
42
+ raise ValueError("AKS service update workload contract is invalid")
43
+ contract = dict(contract_value)
44
+ contract["secret_environment"] = validated_secret_environment(
45
+ workload.get("secret_environment", {})
46
+ )
47
+ refreshed[str(name)] = contract
48
+ return {**context, "expected_workloads": refreshed}
49
+
50
+
51
+ def partial_core_update_admissible(
52
+ work_dir: Path,
53
+ context: dict[str, object],
54
+ *,
55
+ service: str | None,
56
+ ) -> bool:
57
+ """Return whether exact retained recovery evidence permits only a Core image update."""
58
+
59
+ if service != "core-control-plane":
60
+ return False
61
+ paths = {
62
+ "original_claim": work_dir / "application-claim.json",
63
+ "residual_review": work_dir / "application-residual-review.json",
64
+ "residual_claim": work_dir / "application-residual-claim.json",
65
+ "successor_review": work_dir / "application-residual-successor-review.json",
66
+ "successor_claim": work_dir / "application-residual-successor-claim.json",
67
+ "rebind_receipt": work_dir / "application-input-rebind-receipt.json",
68
+ }
69
+ if any(not path.is_file() or path.is_symlink() for path in paths.values()):
70
+ return False
71
+ records = {name: private_json(path, name) for name, path in paths.items()}
72
+ original = records["original_claim"]
73
+ residual_review = records["residual_review"]
74
+ residual = records["residual_claim"]
75
+ successor_review = records["successor_review"]
76
+ successor = records["successor_claim"]
77
+ rebind = records["rebind_receipt"]
78
+ binding = successor_review.get("residual_successor")
79
+ summary = successor_review.get("summary")
80
+ changes = summary.get("resource_changes") if isinstance(summary, dict) else None
81
+ observed = (
82
+ {
83
+ str(item.get("address")): tuple(item.get("actions", ()))
84
+ for item in changes
85
+ if isinstance(item, dict) and item.get("actions") != ["no-op"]
86
+ }
87
+ if isinstance(changes, list)
88
+ else {}
89
+ )
90
+ receipt_digest = rebind.pop("receipt_digest", None)
91
+ return (
92
+ original.get("schema_version") == "fdai.standalone-application-claim.v1"
93
+ and original.get("stage") == "application"
94
+ and residual_review.get("schema_version") == "fdai.standalone-application-plan.v1"
95
+ and residual_review.get("stage") == "application"
96
+ and residual.get("schema_version") == "fdai.standalone-residual-claim.v1"
97
+ and residual.get("stage") == "application"
98
+ and successor_review.get("schema_version") == "fdai.standalone-application-plan.v1"
99
+ and successor_review.get("stage") == "application"
100
+ and successor_review.get("source_commit") == context.get("source_commit")
101
+ and isinstance(binding, dict)
102
+ and binding.get("original_claim_digest") == canonical_digest(original)
103
+ and binding.get("residual_plan_digest") == residual_review.get("plan_digest")
104
+ and binding.get("residual_claim_digest") == canonical_digest(residual)
105
+ and successor.get("schema_version") == "fdai.standalone-residual-successor-claim.v1"
106
+ and successor.get("stage") == "application"
107
+ and successor.get("plan_digest") == successor_review.get("plan_digest")
108
+ and successor.get("original_claim_digest") == canonical_digest(original)
109
+ and successor.get("residual_claim_digest") == canonical_digest(residual)
110
+ and canonical_digest(rebind) == receipt_digest
111
+ and rebind.get("schema_version") == "fdai.application-input-rebind-receipt.v1"
112
+ and rebind.get("field") == "browser_gateway.backend_nsg_id"
113
+ and rebind.get("mutation_performed") is True
114
+ and bool(observed)
115
+ and observed.get(_CORE) == ("delete", "create")
116
+ and set(observed).issubset(_ALLOWED_CHANGES)
117
+ and all(observed[address] == _ALLOWED_CHANGES[address] for address in observed)
118
+ )
119
+
120
+
121
+ def validate_service_update_plan(
122
+ summary: dict[str, object],
123
+ work_dir: Path,
124
+ context: dict[str, object],
125
+ update: dict[str, object],
126
+ ) -> None:
127
+ """Apply the ordinary or recovery-specific selected Deployment scope."""
128
+
129
+ service = update.get("service")
130
+ if not isinstance(service, str):
131
+ raise ValueError("AKS service update plan service is invalid")
132
+ validate_plan_scope(
133
+ summary,
134
+ service=service,
135
+ allow_replacement=partial_core_update_admissible(
136
+ work_dir,
137
+ context,
138
+ service=service,
139
+ ),
140
+ )
141
+
142
+
143
+ def failed_core_update_repair_digest(
144
+ work_dir: Path,
145
+ context: dict[str, object],
146
+ *,
147
+ service: str,
148
+ ) -> str | None:
149
+ """Return one exact failed Core update digest eligible for a single repair."""
150
+
151
+ active = context.get("active_service_update")
152
+ operation = active.get("operation") if isinstance(active, dict) else None
153
+ if (
154
+ not isinstance(active, dict)
155
+ or active.get("service") != service
156
+ or not isinstance(operation, str)
157
+ ):
158
+ return None
159
+ record_path = work_dir / f"{operation}.json"
160
+ review_path = work_dir / f"{operation}-review.json"
161
+ claim_path = work_dir / f"{operation}-claim.json"
162
+ receipt_path = work_dir / f"{operation}-receipt.json"
163
+ if (
164
+ any(
165
+ not path.is_file() or path.is_symlink()
166
+ for path in (record_path, review_path, claim_path)
167
+ )
168
+ or receipt_path.exists()
169
+ or receipt_path.is_symlink()
170
+ ):
171
+ return None
172
+ record = private_json(record_path, "failed Core service update")
173
+ review = private_json(review_path, "failed Core service update review")
174
+ claim = private_json(claim_path, "failed Core service update claim")
175
+ digest = record.get("update_digest")
176
+ document = {key: value for key, value in record.items() if key != "update_digest"}
177
+ counts = review.get("summary", {}).get("action_counts")
178
+ baseline = record.get("baseline")
179
+ previous = baseline.get("core-control-plane") if isinstance(baseline, dict) else None
180
+ ordinary_update = (
181
+ isinstance(counts, dict)
182
+ and counts.get("update") == 1
183
+ and counts.get("replace") == 0
184
+ and counts.get("delete") == 0
185
+ )
186
+ partial_replacement = (
187
+ partial_core_update_admissible(work_dir, context, service=service)
188
+ and isinstance(counts, dict)
189
+ and counts.get("replace") == 1
190
+ and counts.get("update") == 0
191
+ and counts.get("delete") == 0
192
+ )
193
+ return (
194
+ str(digest)
195
+ if isinstance(digest, str)
196
+ and active.get("update_digest") == digest
197
+ and digest == canonical_digest(document)
198
+ and record.get("schema_version") == "fdai.aks-service-update.v1"
199
+ and record.get("service") == "core-control-plane"
200
+ and isinstance(previous, dict)
201
+ and previous.get("image") == record.get("previous_image")
202
+ and isinstance(previous.get("source_commit"), str)
203
+ and review.get("service_update") == service_update_review(record)
204
+ and record.get("superseded_update_digest") is None
205
+ and review.get("schema_version") == "fdai.standalone-application-plan.v1"
206
+ and review.get("plan_digest") == claim.get("plan_digest")
207
+ and claim.get("schema_version") == "fdai.standalone-application-claim.v1"
208
+ and claim.get("stage") == "application"
209
+ and (ordinary_update or partial_replacement)
210
+ else None
211
+ )
212
+
213
+
214
+ def superseded_update_digest(
215
+ work_dir: Path,
216
+ context: dict[str, object],
217
+ *,
218
+ service: str,
219
+ ) -> str | None:
220
+ """Return the one failed update to supersede, or reject an incomplete operation."""
221
+
222
+ active = context.get("active_service_update")
223
+ if not isinstance(active, dict):
224
+ return None
225
+ operation = active.get("operation")
226
+ if isinstance(operation, str) and (work_dir / f"{operation}-receipt.json").is_file():
227
+ return None
228
+ digest = failed_core_update_repair_digest(work_dir, context, service=service)
229
+ if digest is None:
230
+ raise ValueError("AKS service update already has an incomplete active operation")
231
+ return digest
232
+
233
+
234
+ def validate_repair_binding(record: dict[str, object]) -> None:
235
+ """Require any optional superseded update binding to be one canonical digest."""
236
+
237
+ value = record.get("superseded_update_digest")
238
+ if value is not None and (
239
+ not isinstance(value, str)
240
+ or len(value) != 64
241
+ or any(char not in "0123456789abcdef" for char in value)
242
+ ):
243
+ raise ValueError("active AKS service update repair binding is invalid")
244
+
245
+
246
+ def source_image_import_paths(
247
+ work_dir: Path,
248
+ service: str,
249
+ source_commit: str,
250
+ image_digest: str,
251
+ ) -> tuple[Path, Path]:
252
+ """Preserve one legacy import while giving each later image an exact identity."""
253
+
254
+ prefix = f"source-image-import-{service}"
255
+ legacy_claim = work_dir / f"{prefix}-claim.json"
256
+ legacy_receipt = work_dir / f"{prefix}-receipt.json"
257
+ if not legacy_claim.exists() and not legacy_claim.is_symlink():
258
+ return legacy_claim, legacy_receipt
259
+ retained = private_json(legacy_claim, "source service image import claim")
260
+ if (
261
+ retained.get("source_commit") == source_commit
262
+ and retained.get("image_digest") == image_digest
263
+ ):
264
+ return legacy_claim, legacy_receipt
265
+ if not legacy_receipt.is_file() or legacy_receipt.is_symlink():
266
+ return legacy_claim, legacy_receipt
267
+ identity = canonical_digest(
268
+ {
269
+ "service": service,
270
+ "source_commit": source_commit,
271
+ "image_digest": image_digest,
272
+ }
273
+ )[:12]
274
+ return (
275
+ work_dir / f"{prefix}-{identity}-claim.json",
276
+ work_dir / f"{prefix}-{identity}-receipt.json",
277
+ )