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