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,431 @@
|
|
|
1
|
+
"""Govern one create-only successor after a residual apply is verified incomplete."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from datetime import UTC, datetime, timedelta
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from fdai_deployment_cli.contracts import canonical_digest
|
|
12
|
+
from fdai_deployment_cli.private_output import write_private_output
|
|
13
|
+
from fdai_deployment_cli.standalone_host_state import (
|
|
14
|
+
file_digest,
|
|
15
|
+
moment,
|
|
16
|
+
private_json,
|
|
17
|
+
replace_private_json,
|
|
18
|
+
)
|
|
19
|
+
from fdai_deployment_cli.standalone_host_values import plan_summary
|
|
20
|
+
from fdai_deployment_cli.standalone_residual_apply import (
|
|
21
|
+
_DIGEST,
|
|
22
|
+
_STAGES,
|
|
23
|
+
_required_digest,
|
|
24
|
+
_required_source_commit,
|
|
25
|
+
_runtime_platform,
|
|
26
|
+
_runtime_profile_digest,
|
|
27
|
+
_validate_residual_claim,
|
|
28
|
+
_zero_change,
|
|
29
|
+
seal_terraform_plan,
|
|
30
|
+
)
|
|
31
|
+
from fdai_deployment_cli.standalone_review import validate_plan_review
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def successor_prerequisite_verified(work_dir: Path, stage: str) -> bool:
|
|
35
|
+
"""Require completed focused private access before the one substrate successor."""
|
|
36
|
+
|
|
37
|
+
if stage != "substrate":
|
|
38
|
+
return False
|
|
39
|
+
receipt_path = work_dir / "access-receipt.json"
|
|
40
|
+
if not receipt_path.exists() or receipt_path.is_symlink():
|
|
41
|
+
return False
|
|
42
|
+
receipt = private_json(receipt_path, "focused private access receipt")
|
|
43
|
+
return (
|
|
44
|
+
receipt.get("schema_version") == "fdai.standalone-application-apply-receipt.v1"
|
|
45
|
+
and receipt.get("state") == "applied"
|
|
46
|
+
and receipt.get("control_plane_readback_verified") is True
|
|
47
|
+
and receipt.get("mutation_performed") is True
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def prepare_residual_successor_review(
|
|
52
|
+
*,
|
|
53
|
+
work_dir: Path,
|
|
54
|
+
stage: str,
|
|
55
|
+
context: dict[str, object],
|
|
56
|
+
infra: Path,
|
|
57
|
+
variables: Path,
|
|
58
|
+
targets: tuple[str, ...],
|
|
59
|
+
original_review: dict[str, object],
|
|
60
|
+
original_claim: dict[str, object],
|
|
61
|
+
residual_review: dict[str, object],
|
|
62
|
+
residual_claim: dict[str, object],
|
|
63
|
+
successor_prerequisite_verified: bool,
|
|
64
|
+
) -> dict[str, object]:
|
|
65
|
+
"""Create one fresh create-only successor plan after verified residual non-convergence."""
|
|
66
|
+
|
|
67
|
+
_validate_residual_claim(
|
|
68
|
+
stage=stage,
|
|
69
|
+
context=context,
|
|
70
|
+
original_review=original_review,
|
|
71
|
+
original_claim=original_claim,
|
|
72
|
+
residual_review=residual_review,
|
|
73
|
+
residual_claim=residual_claim,
|
|
74
|
+
)
|
|
75
|
+
if not successor_prerequisite_verified:
|
|
76
|
+
raise ValueError("standalone residual successor prerequisite is not verified")
|
|
77
|
+
operation = _operation(stage)
|
|
78
|
+
plan_path = work_dir / f"{operation}.tfplan"
|
|
79
|
+
review_path = work_dir / f"{operation}-review.json"
|
|
80
|
+
claim_path = work_dir / f"{operation}-claim.json"
|
|
81
|
+
if claim_path.exists() or claim_path.is_symlink():
|
|
82
|
+
raise ValueError("standalone residual successor claim already exists")
|
|
83
|
+
plan_path.unlink(missing_ok=True)
|
|
84
|
+
review_path.unlink(missing_ok=True)
|
|
85
|
+
command = [
|
|
86
|
+
"terraform",
|
|
87
|
+
"plan",
|
|
88
|
+
"-detailed-exitcode",
|
|
89
|
+
"-input=false",
|
|
90
|
+
"-no-color",
|
|
91
|
+
f"-var-file={variables}",
|
|
92
|
+
f"-out={plan_path}",
|
|
93
|
+
*(f"-target={target}" for target in targets),
|
|
94
|
+
]
|
|
95
|
+
completed = subprocess.run(
|
|
96
|
+
command,
|
|
97
|
+
cwd=infra,
|
|
98
|
+
check=False,
|
|
99
|
+
capture_output=True,
|
|
100
|
+
timeout=1800,
|
|
101
|
+
umask=0o077,
|
|
102
|
+
)
|
|
103
|
+
if completed.returncode != 2:
|
|
104
|
+
plan_path.unlink(missing_ok=True)
|
|
105
|
+
raise ValueError("standalone residual successor plan is unavailable")
|
|
106
|
+
seal_terraform_plan(plan_path)
|
|
107
|
+
shown = subprocess.run(
|
|
108
|
+
("terraform", "show", "-json", str(plan_path)),
|
|
109
|
+
cwd=infra,
|
|
110
|
+
check=False,
|
|
111
|
+
capture_output=True,
|
|
112
|
+
text=True,
|
|
113
|
+
timeout=300,
|
|
114
|
+
umask=0o077,
|
|
115
|
+
)
|
|
116
|
+
if shown.returncode != 0:
|
|
117
|
+
raise ValueError("standalone residual successor projection failed")
|
|
118
|
+
try:
|
|
119
|
+
summary = plan_summary(json.loads(shown.stdout))
|
|
120
|
+
except json.JSONDecodeError as exc:
|
|
121
|
+
raise ValueError("standalone residual successor projection is invalid") from exc
|
|
122
|
+
counts = summary.get("action_counts")
|
|
123
|
+
if (
|
|
124
|
+
not isinstance(counts, dict)
|
|
125
|
+
or type(counts.get("create")) is not int
|
|
126
|
+
or counts["create"] <= 0
|
|
127
|
+
or any(counts.get(action, 0) != 0 for action in ("update", "delete"))
|
|
128
|
+
or type(counts.get("replace", 0)) is not int
|
|
129
|
+
or counts.get("replace", 0) not in {0, 1}
|
|
130
|
+
or (counts.get("replace", 0) == 1 and stage != "application")
|
|
131
|
+
):
|
|
132
|
+
raise ValueError(
|
|
133
|
+
"standalone residual successor must contain creates and at most one application replacement"
|
|
134
|
+
)
|
|
135
|
+
review: dict[str, object] = {
|
|
136
|
+
"schema_version": "fdai.standalone-application-plan.v1",
|
|
137
|
+
"stage": stage,
|
|
138
|
+
"plan_digest": file_digest(plan_path),
|
|
139
|
+
"target_binding": _required_digest(context, "target_binding"),
|
|
140
|
+
"source_commit": _required_source_commit(context),
|
|
141
|
+
"runtime_profile_digest": _runtime_profile_digest(context),
|
|
142
|
+
"runtime_platform": _runtime_platform(context),
|
|
143
|
+
"summary": summary,
|
|
144
|
+
"residual_successor": {
|
|
145
|
+
"operation": operation,
|
|
146
|
+
"original_claim_digest": canonical_digest(original_claim),
|
|
147
|
+
"residual_plan_digest": residual_review["plan_digest"],
|
|
148
|
+
"residual_claim_digest": canonical_digest(residual_claim),
|
|
149
|
+
},
|
|
150
|
+
"expires_at": moment(datetime.now(UTC) + timedelta(hours=1)),
|
|
151
|
+
"mutation_performed": False,
|
|
152
|
+
"subscription_ready": False,
|
|
153
|
+
}
|
|
154
|
+
review["review_digest"] = canonical_digest(review)
|
|
155
|
+
replace_private_json(review_path, review)
|
|
156
|
+
return review
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def apply_residual_successor(
|
|
160
|
+
*,
|
|
161
|
+
work_dir: Path,
|
|
162
|
+
stage: str,
|
|
163
|
+
context: dict[str, object],
|
|
164
|
+
infra: Path,
|
|
165
|
+
variables: Path,
|
|
166
|
+
targets: tuple[str, ...],
|
|
167
|
+
original_review: dict[str, object],
|
|
168
|
+
original_claim: dict[str, object],
|
|
169
|
+
residual_review: dict[str, object],
|
|
170
|
+
residual_claim: dict[str, object],
|
|
171
|
+
successor_review: dict[str, object],
|
|
172
|
+
approval: dict[str, object],
|
|
173
|
+
effect_readback: Callable[[], bool],
|
|
174
|
+
) -> dict[str, object]:
|
|
175
|
+
"""Apply one exact-approved create-only successor and prohibit another successor."""
|
|
176
|
+
|
|
177
|
+
_validate_review(
|
|
178
|
+
stage=stage,
|
|
179
|
+
context=context,
|
|
180
|
+
original_review=original_review,
|
|
181
|
+
original_claim=original_claim,
|
|
182
|
+
residual_review=residual_review,
|
|
183
|
+
residual_claim=residual_claim,
|
|
184
|
+
successor_review=successor_review,
|
|
185
|
+
)
|
|
186
|
+
operation = _operation(stage)
|
|
187
|
+
plan_path = work_dir / f"{operation}.tfplan"
|
|
188
|
+
if file_digest(plan_path) != successor_review["plan_digest"]:
|
|
189
|
+
raise ValueError("standalone residual successor plan changed before apply")
|
|
190
|
+
claim_path = work_dir / f"{operation}-claim.json"
|
|
191
|
+
claim = _claim(
|
|
192
|
+
stage=stage,
|
|
193
|
+
context=context,
|
|
194
|
+
original_claim=original_claim,
|
|
195
|
+
residual_claim=residual_claim,
|
|
196
|
+
successor_review=successor_review,
|
|
197
|
+
approval=approval,
|
|
198
|
+
)
|
|
199
|
+
try:
|
|
200
|
+
write_private_output(
|
|
201
|
+
claim_path,
|
|
202
|
+
json.dumps(claim, sort_keys=True, separators=(",", ":")) + "\n",
|
|
203
|
+
)
|
|
204
|
+
except FileExistsError as exc:
|
|
205
|
+
raise ValueError(
|
|
206
|
+
"standalone residual successor claim already exists; automatic retry is blocked"
|
|
207
|
+
) from exc
|
|
208
|
+
completed = subprocess.run(
|
|
209
|
+
("terraform", "apply", "-input=false", "-no-color", str(plan_path)),
|
|
210
|
+
cwd=infra,
|
|
211
|
+
check=False,
|
|
212
|
+
capture_output=True,
|
|
213
|
+
timeout=7200,
|
|
214
|
+
umask=0o077,
|
|
215
|
+
)
|
|
216
|
+
if completed.returncode != 0:
|
|
217
|
+
raise ValueError(
|
|
218
|
+
f"{stage} residual successor apply failed; verification-only recovery is required"
|
|
219
|
+
)
|
|
220
|
+
if not effect_readback() or not _zero_change(infra=infra, variables=variables, targets=targets):
|
|
221
|
+
raise ValueError(f"{stage} residual successor did not converge")
|
|
222
|
+
return _write_receipt(
|
|
223
|
+
work_dir=work_dir,
|
|
224
|
+
stage=stage,
|
|
225
|
+
context=context,
|
|
226
|
+
original_review=original_review,
|
|
227
|
+
original_claim=original_claim,
|
|
228
|
+
residual_review=residual_review,
|
|
229
|
+
residual_claim=residual_claim,
|
|
230
|
+
successor_review=successor_review,
|
|
231
|
+
successor_claim=claim,
|
|
232
|
+
verification_only=False,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def recover_residual_successor(
|
|
237
|
+
*,
|
|
238
|
+
work_dir: Path,
|
|
239
|
+
stage: str,
|
|
240
|
+
context: dict[str, object],
|
|
241
|
+
infra: Path,
|
|
242
|
+
variables: Path,
|
|
243
|
+
targets: tuple[str, ...],
|
|
244
|
+
original_review: dict[str, object],
|
|
245
|
+
original_claim: dict[str, object],
|
|
246
|
+
residual_review: dict[str, object],
|
|
247
|
+
residual_claim: dict[str, object],
|
|
248
|
+
effect_readback: Callable[[], bool],
|
|
249
|
+
) -> dict[str, object]:
|
|
250
|
+
"""Verify one claimed successor without permitting any further apply."""
|
|
251
|
+
|
|
252
|
+
operation = _operation(stage)
|
|
253
|
+
review = private_json(work_dir / f"{operation}-review.json", "residual successor review")
|
|
254
|
+
claim = private_json(work_dir / f"{operation}-claim.json", "residual successor claim")
|
|
255
|
+
_validate_review(
|
|
256
|
+
stage=stage,
|
|
257
|
+
context=context,
|
|
258
|
+
original_review=original_review,
|
|
259
|
+
original_claim=original_claim,
|
|
260
|
+
residual_review=residual_review,
|
|
261
|
+
residual_claim=residual_claim,
|
|
262
|
+
successor_review=review,
|
|
263
|
+
allow_expired=True,
|
|
264
|
+
)
|
|
265
|
+
expected = _claim_binding(
|
|
266
|
+
stage=stage,
|
|
267
|
+
context=context,
|
|
268
|
+
original_claim=original_claim,
|
|
269
|
+
residual_claim=residual_claim,
|
|
270
|
+
successor_review=review,
|
|
271
|
+
)
|
|
272
|
+
if (
|
|
273
|
+
claim.get("schema_version") != "fdai.standalone-residual-successor-claim.v1"
|
|
274
|
+
or any(claim.get(key) != value for key, value in expected.items())
|
|
275
|
+
or not isinstance(claim.get("approval_digest"), str)
|
|
276
|
+
or _DIGEST.fullmatch(str(claim["approval_digest"])) is None
|
|
277
|
+
or not isinstance(claim.get("claimed_at"), str)
|
|
278
|
+
or claim.get("mutation_performed") is not False
|
|
279
|
+
):
|
|
280
|
+
raise ValueError("standalone residual successor claim is invalid")
|
|
281
|
+
if not _zero_change(infra=infra, variables=variables, targets=targets):
|
|
282
|
+
raise ValueError("standalone residual successor is not recoverably converged")
|
|
283
|
+
if not effect_readback():
|
|
284
|
+
raise ValueError("standalone residual successor readback is incomplete")
|
|
285
|
+
return _write_receipt(
|
|
286
|
+
work_dir=work_dir,
|
|
287
|
+
stage=stage,
|
|
288
|
+
context=context,
|
|
289
|
+
original_review=original_review,
|
|
290
|
+
original_claim=original_claim,
|
|
291
|
+
residual_review=residual_review,
|
|
292
|
+
residual_claim=residual_claim,
|
|
293
|
+
successor_review=review,
|
|
294
|
+
successor_claim=claim,
|
|
295
|
+
verification_only=True,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _validate_review(
|
|
300
|
+
*,
|
|
301
|
+
stage: str,
|
|
302
|
+
context: dict[str, object],
|
|
303
|
+
original_review: dict[str, object],
|
|
304
|
+
original_claim: dict[str, object],
|
|
305
|
+
residual_review: dict[str, object],
|
|
306
|
+
residual_claim: dict[str, object],
|
|
307
|
+
successor_review: dict[str, object],
|
|
308
|
+
allow_expired: bool = False,
|
|
309
|
+
) -> None:
|
|
310
|
+
_validate_residual_claim(
|
|
311
|
+
stage=stage,
|
|
312
|
+
context=context,
|
|
313
|
+
original_review=original_review,
|
|
314
|
+
original_claim=original_claim,
|
|
315
|
+
residual_review=residual_review,
|
|
316
|
+
residual_claim=residual_claim,
|
|
317
|
+
)
|
|
318
|
+
validated_stage, _destructive = validate_plan_review(
|
|
319
|
+
successor_review, allow_expired=allow_expired
|
|
320
|
+
)
|
|
321
|
+
binding = successor_review.get("residual_successor")
|
|
322
|
+
if (
|
|
323
|
+
validated_stage != stage
|
|
324
|
+
or successor_review.get("target_binding") != context.get("target_binding")
|
|
325
|
+
or successor_review.get("source_commit") != context.get("source_commit")
|
|
326
|
+
or _runtime_profile_digest(successor_review) != _runtime_profile_digest(context)
|
|
327
|
+
or not isinstance(binding, dict)
|
|
328
|
+
or binding
|
|
329
|
+
!= {
|
|
330
|
+
"operation": _operation(stage),
|
|
331
|
+
"original_claim_digest": canonical_digest(original_claim),
|
|
332
|
+
"residual_plan_digest": residual_review.get("plan_digest"),
|
|
333
|
+
"residual_claim_digest": canonical_digest(residual_claim),
|
|
334
|
+
}
|
|
335
|
+
):
|
|
336
|
+
raise ValueError("standalone residual successor plan binding is invalid")
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _claim(
|
|
340
|
+
*,
|
|
341
|
+
stage: str,
|
|
342
|
+
context: dict[str, object],
|
|
343
|
+
original_claim: dict[str, object],
|
|
344
|
+
residual_claim: dict[str, object],
|
|
345
|
+
successor_review: dict[str, object],
|
|
346
|
+
approval: dict[str, object],
|
|
347
|
+
) -> dict[str, object]:
|
|
348
|
+
return {
|
|
349
|
+
"schema_version": "fdai.standalone-residual-successor-claim.v1",
|
|
350
|
+
**_claim_binding(
|
|
351
|
+
stage=stage,
|
|
352
|
+
context=context,
|
|
353
|
+
original_claim=original_claim,
|
|
354
|
+
residual_claim=residual_claim,
|
|
355
|
+
successor_review=successor_review,
|
|
356
|
+
),
|
|
357
|
+
"approval_digest": canonical_digest(approval),
|
|
358
|
+
"claimed_at": moment(datetime.now(UTC)),
|
|
359
|
+
"mutation_performed": False,
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _claim_binding(
|
|
364
|
+
*,
|
|
365
|
+
stage: str,
|
|
366
|
+
context: dict[str, object],
|
|
367
|
+
original_claim: dict[str, object],
|
|
368
|
+
residual_claim: dict[str, object],
|
|
369
|
+
successor_review: dict[str, object],
|
|
370
|
+
) -> dict[str, object]:
|
|
371
|
+
original_claim_digest = canonical_digest(original_claim)
|
|
372
|
+
residual_claim_digest = canonical_digest(residual_claim)
|
|
373
|
+
plan_digest = successor_review["plan_digest"]
|
|
374
|
+
return {
|
|
375
|
+
"stage": stage,
|
|
376
|
+
"operation": _operation(stage),
|
|
377
|
+
"original_claim_digest": original_claim_digest,
|
|
378
|
+
"residual_claim_digest": residual_claim_digest,
|
|
379
|
+
"plan_digest": plan_digest,
|
|
380
|
+
"idempotency_key": canonical_digest(
|
|
381
|
+
{
|
|
382
|
+
"target_binding": _required_digest(context, "target_binding"),
|
|
383
|
+
"original_claim_digest": original_claim_digest,
|
|
384
|
+
"residual_claim_digest": residual_claim_digest,
|
|
385
|
+
"plan_digest": plan_digest,
|
|
386
|
+
}
|
|
387
|
+
),
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _write_receipt(
|
|
392
|
+
*,
|
|
393
|
+
work_dir: Path,
|
|
394
|
+
stage: str,
|
|
395
|
+
context: dict[str, object],
|
|
396
|
+
original_review: dict[str, object],
|
|
397
|
+
original_claim: dict[str, object],
|
|
398
|
+
residual_review: dict[str, object],
|
|
399
|
+
residual_claim: dict[str, object],
|
|
400
|
+
successor_review: dict[str, object],
|
|
401
|
+
successor_claim: dict[str, object],
|
|
402
|
+
verification_only: bool,
|
|
403
|
+
) -> dict[str, object]:
|
|
404
|
+
receipt: dict[str, object] = {
|
|
405
|
+
"schema_version": "fdai.standalone-residual-successor-receipt.v1",
|
|
406
|
+
"state": "applied",
|
|
407
|
+
"stage": stage,
|
|
408
|
+
"plan_digest": successor_review["plan_digest"],
|
|
409
|
+
"original_plan_digest": original_review["plan_digest"],
|
|
410
|
+
"residual_plan_digest": residual_review["plan_digest"],
|
|
411
|
+
"runtime_profile_digest": _runtime_profile_digest(context),
|
|
412
|
+
"original_claim_digest": canonical_digest(original_claim),
|
|
413
|
+
"residual_claim_digest": canonical_digest(residual_claim),
|
|
414
|
+
"successor_claim_digest": canonical_digest(successor_claim),
|
|
415
|
+
"control_plane_readback_verified": True,
|
|
416
|
+
"terraform_zero_change_verified": True,
|
|
417
|
+
"residual_recovery": True,
|
|
418
|
+
"residual_successor": True,
|
|
419
|
+
"verification_only_recovery": verification_only,
|
|
420
|
+
"mutation_performed": True,
|
|
421
|
+
"subscription_ready": False,
|
|
422
|
+
}
|
|
423
|
+
receipt["receipt_digest"] = canonical_digest(receipt)
|
|
424
|
+
replace_private_json(work_dir / f"{stage}-receipt.json", receipt)
|
|
425
|
+
return receipt
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _operation(stage: str) -> str:
|
|
429
|
+
if stage not in _STAGES:
|
|
430
|
+
raise ValueError("standalone residual successor stage is invalid")
|
|
431
|
+
return f"{stage}-residual-successor"
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Validate the managed-host plan review before rendering or naming an approval file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from fdai_deployment_cli.contracts import canonical_digest
|
|
10
|
+
|
|
11
|
+
_FIELDS = frozenset(
|
|
12
|
+
{
|
|
13
|
+
"schema_version",
|
|
14
|
+
"stage",
|
|
15
|
+
"plan_digest",
|
|
16
|
+
"review_digest",
|
|
17
|
+
"target_binding",
|
|
18
|
+
"source_commit",
|
|
19
|
+
"runtime_profile_digest",
|
|
20
|
+
"runtime_platform",
|
|
21
|
+
"summary",
|
|
22
|
+
"expires_at",
|
|
23
|
+
"mutation_performed",
|
|
24
|
+
"subscription_ready",
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
_LEGACY_FIELDS = _FIELDS - {"runtime_profile_digest", "runtime_platform"}
|
|
28
|
+
_SERVICE_UPDATE_FIELDS = frozenset({"service", "image", "source_commit", "update_digest"})
|
|
29
|
+
_SERVICE_UPDATE_AUTHORITY_FIELDS = frozenset(
|
|
30
|
+
{"development_authority_operation", "development_authority_profile_digest"}
|
|
31
|
+
)
|
|
32
|
+
_HISTORICAL_RECONCILIATION_FIELDS = frozenset({"operation", "variables_digest", "mutations"})
|
|
33
|
+
_RESIDUAL_RECOVERY_FIELDS = frozenset(
|
|
34
|
+
{"operation", "original_plan_digest", "original_claim_digest"}
|
|
35
|
+
)
|
|
36
|
+
_RESIDUAL_SUCCESSOR_FIELDS = frozenset(
|
|
37
|
+
{
|
|
38
|
+
"operation",
|
|
39
|
+
"original_claim_digest",
|
|
40
|
+
"residual_plan_digest",
|
|
41
|
+
"residual_claim_digest",
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
_ACTIONS = frozenset({"create", "update", "delete", "replace", "read", "no-op"})
|
|
45
|
+
_ERROR = "standalone plan review is invalid or expired; request a current exact plan"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def validate_plan_review(review: dict[str, Any], *, allow_expired: bool = False) -> tuple[str, int]:
|
|
49
|
+
"""Return a safe stage and destructive count without granting execution authority."""
|
|
50
|
+
|
|
51
|
+
stage = review.get("stage")
|
|
52
|
+
summary = review.get("summary")
|
|
53
|
+
if (
|
|
54
|
+
set(review)
|
|
55
|
+
not in (
|
|
56
|
+
_LEGACY_FIELDS,
|
|
57
|
+
_LEGACY_FIELDS | {"service_update"},
|
|
58
|
+
_FIELDS,
|
|
59
|
+
_FIELDS | {"service_update"},
|
|
60
|
+
_FIELDS | {"operation"},
|
|
61
|
+
_FIELDS | {"operation", "service_update"},
|
|
62
|
+
_FIELDS | {"historical_reconciliation"},
|
|
63
|
+
_FIELDS | {"residual_recovery"},
|
|
64
|
+
_FIELDS | {"residual_successor"},
|
|
65
|
+
)
|
|
66
|
+
or review.get("schema_version") != "fdai.standalone-application-plan.v1"
|
|
67
|
+
or not isinstance(stage, str)
|
|
68
|
+
or stage not in {"access", "substrate", "runtime", "database", "application"}
|
|
69
|
+
or review.get("mutation_performed") is not False
|
|
70
|
+
or review.get("subscription_ready") is not False
|
|
71
|
+
or not isinstance(summary, dict)
|
|
72
|
+
or not set(summary).issubset({"action_counts", "resource_type_counts", "resource_changes"})
|
|
73
|
+
):
|
|
74
|
+
raise ValueError(_ERROR)
|
|
75
|
+
service_update = review.get("service_update")
|
|
76
|
+
service_update_fields = set(service_update) if isinstance(service_update, dict) else set()
|
|
77
|
+
if service_update is not None and (
|
|
78
|
+
stage != "application"
|
|
79
|
+
or not isinstance(service_update, dict)
|
|
80
|
+
or service_update_fields
|
|
81
|
+
not in {
|
|
82
|
+
_SERVICE_UPDATE_FIELDS,
|
|
83
|
+
_SERVICE_UPDATE_FIELDS | _SERVICE_UPDATE_AUTHORITY_FIELDS,
|
|
84
|
+
}
|
|
85
|
+
or service_update.get("service")
|
|
86
|
+
not in {
|
|
87
|
+
"core-control-plane",
|
|
88
|
+
"document-ingestion-api",
|
|
89
|
+
"document-processing-worker",
|
|
90
|
+
"isolated-executor",
|
|
91
|
+
"operator-service",
|
|
92
|
+
}
|
|
93
|
+
or not isinstance(service_update.get("image"), str)
|
|
94
|
+
or re.fullmatch(
|
|
95
|
+
r"[a-z0-9.-]+(?::[0-9]+)?/[a-z0-9._/-]+@sha256:[0-9a-f]{64}",
|
|
96
|
+
service_update["image"],
|
|
97
|
+
)
|
|
98
|
+
is None
|
|
99
|
+
or not isinstance(service_update.get("source_commit"), str)
|
|
100
|
+
or re.fullmatch(r"[0-9a-f]{40}", service_update["source_commit"]) is None
|
|
101
|
+
or not isinstance(service_update.get("update_digest"), str)
|
|
102
|
+
or re.fullmatch(r"[0-9a-f]{64}", service_update["update_digest"]) is None
|
|
103
|
+
):
|
|
104
|
+
raise ValueError(_ERROR)
|
|
105
|
+
if service_update_fields == _SERVICE_UPDATE_FIELDS | _SERVICE_UPDATE_AUTHORITY_FIELDS:
|
|
106
|
+
if not isinstance(service_update, dict):
|
|
107
|
+
raise TypeError(_ERROR)
|
|
108
|
+
authority_operation = service_update["development_authority_operation"]
|
|
109
|
+
authority_digest = service_update["development_authority_profile_digest"]
|
|
110
|
+
if (
|
|
111
|
+
service_update["service"] != "core-control-plane"
|
|
112
|
+
or authority_operation not in {"select", "clear"}
|
|
113
|
+
or (
|
|
114
|
+
authority_operation == "select"
|
|
115
|
+
and (
|
|
116
|
+
not isinstance(authority_digest, str)
|
|
117
|
+
or re.fullmatch(r"[0-9a-f]{64}", authority_digest) is None
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
or (authority_operation == "clear" and authority_digest != "")
|
|
121
|
+
):
|
|
122
|
+
raise ValueError(_ERROR)
|
|
123
|
+
if "operation" in review and not _operation_matches_stage(
|
|
124
|
+
stage, review.get("operation"), service_update
|
|
125
|
+
):
|
|
126
|
+
raise ValueError(_ERROR)
|
|
127
|
+
historical_reconciliation = review.get("historical_reconciliation")
|
|
128
|
+
if historical_reconciliation is not None and (
|
|
129
|
+
stage != "application"
|
|
130
|
+
or not isinstance(historical_reconciliation, dict)
|
|
131
|
+
or set(historical_reconciliation) != _HISTORICAL_RECONCILIATION_FIELDS
|
|
132
|
+
or historical_reconciliation.get("operation") != "historical-reconciliation"
|
|
133
|
+
or not isinstance(historical_reconciliation.get("variables_digest"), str)
|
|
134
|
+
or re.fullmatch(r"[0-9a-f]{64}", str(historical_reconciliation["variables_digest"])) is None
|
|
135
|
+
or not isinstance(historical_reconciliation.get("mutations"), list)
|
|
136
|
+
or not historical_reconciliation["mutations"]
|
|
137
|
+
or len(historical_reconciliation["mutations"]) > 64
|
|
138
|
+
or historical_reconciliation["mutations"]
|
|
139
|
+
!= sorted(set(historical_reconciliation["mutations"]))
|
|
140
|
+
or any(
|
|
141
|
+
not isinstance(item, str)
|
|
142
|
+
or not 0 < len(item) <= 512
|
|
143
|
+
or not item.isascii()
|
|
144
|
+
or not item.isprintable()
|
|
145
|
+
for item in historical_reconciliation["mutations"]
|
|
146
|
+
)
|
|
147
|
+
):
|
|
148
|
+
raise ValueError(_ERROR)
|
|
149
|
+
residual_recovery = review.get("residual_recovery")
|
|
150
|
+
if residual_recovery is not None and (
|
|
151
|
+
not isinstance(residual_recovery, dict)
|
|
152
|
+
or set(residual_recovery) != _RESIDUAL_RECOVERY_FIELDS
|
|
153
|
+
or residual_recovery.get("operation") != f"{stage}-residual"
|
|
154
|
+
or any(
|
|
155
|
+
not isinstance(residual_recovery.get(field), str)
|
|
156
|
+
or re.fullmatch(r"[0-9a-f]{64}", str(residual_recovery[field])) is None
|
|
157
|
+
for field in ("original_plan_digest", "original_claim_digest")
|
|
158
|
+
)
|
|
159
|
+
):
|
|
160
|
+
raise ValueError(_ERROR)
|
|
161
|
+
residual_successor = review.get("residual_successor")
|
|
162
|
+
if residual_successor is not None and (
|
|
163
|
+
not isinstance(residual_successor, dict)
|
|
164
|
+
or set(residual_successor) != _RESIDUAL_SUCCESSOR_FIELDS
|
|
165
|
+
or residual_successor.get("operation") != f"{stage}-residual-successor"
|
|
166
|
+
or any(
|
|
167
|
+
not isinstance(residual_successor.get(field), str)
|
|
168
|
+
or re.fullmatch(r"[0-9a-f]{64}", str(residual_successor[field])) is None
|
|
169
|
+
for field in (
|
|
170
|
+
"original_claim_digest",
|
|
171
|
+
"residual_plan_digest",
|
|
172
|
+
"residual_claim_digest",
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
):
|
|
176
|
+
raise ValueError(_ERROR)
|
|
177
|
+
for key, length in (
|
|
178
|
+
("plan_digest", 64),
|
|
179
|
+
("review_digest", 64),
|
|
180
|
+
("target_binding", 64),
|
|
181
|
+
("source_commit", 40),
|
|
182
|
+
):
|
|
183
|
+
value = review.get(key)
|
|
184
|
+
if not isinstance(value, str) or re.fullmatch(rf"[0-9a-f]{{{length}}}", value) is None:
|
|
185
|
+
raise ValueError(_ERROR)
|
|
186
|
+
if "runtime_profile_digest" in review or "runtime_platform" in review:
|
|
187
|
+
runtime_profile_digest = review.get("runtime_profile_digest")
|
|
188
|
+
if (
|
|
189
|
+
not isinstance(runtime_profile_digest, str)
|
|
190
|
+
or re.fullmatch(r"[0-9a-f]{64}", runtime_profile_digest) is None
|
|
191
|
+
or review.get("runtime_platform") not in {"aks", "container-apps"}
|
|
192
|
+
):
|
|
193
|
+
raise ValueError(_ERROR)
|
|
194
|
+
counts = summary.get("action_counts")
|
|
195
|
+
if (
|
|
196
|
+
not isinstance(counts, dict)
|
|
197
|
+
or not counts
|
|
198
|
+
or not set(counts).issubset(_ACTIONS)
|
|
199
|
+
or any(type(count) is not int or not 0 <= count <= 5000 for count in counts.values())
|
|
200
|
+
):
|
|
201
|
+
raise ValueError(_ERROR)
|
|
202
|
+
types = summary.get("resource_type_counts", {})
|
|
203
|
+
if (
|
|
204
|
+
not isinstance(types, dict)
|
|
205
|
+
or len(types) > 5000
|
|
206
|
+
or any(
|
|
207
|
+
not isinstance(name, str)
|
|
208
|
+
or re.fullmatch(r"[a-z][a-z0-9_]{0,127}", name) is None
|
|
209
|
+
or type(count) is not int
|
|
210
|
+
or not 0 <= count <= 5000
|
|
211
|
+
for name, count in types.items()
|
|
212
|
+
)
|
|
213
|
+
):
|
|
214
|
+
raise ValueError(_ERROR)
|
|
215
|
+
changes = summary.get("resource_changes", [])
|
|
216
|
+
if not isinstance(changes, list) or len(changes) > 5000:
|
|
217
|
+
raise ValueError(_ERROR)
|
|
218
|
+
for change in changes:
|
|
219
|
+
if not isinstance(change, dict) or set(change) != {"address", "actions"}:
|
|
220
|
+
raise ValueError(_ERROR)
|
|
221
|
+
address, actions = change["address"], change["actions"]
|
|
222
|
+
if (
|
|
223
|
+
not isinstance(address, str)
|
|
224
|
+
or not 0 < len(address) <= 512
|
|
225
|
+
or not address.isascii()
|
|
226
|
+
or not address.isprintable()
|
|
227
|
+
or not isinstance(actions, list)
|
|
228
|
+
or not actions
|
|
229
|
+
or any(not isinstance(action, str) or action not in _ACTIONS for action in actions)
|
|
230
|
+
):
|
|
231
|
+
raise ValueError(_ERROR)
|
|
232
|
+
expiry = review.get("expires_at")
|
|
233
|
+
if not isinstance(expiry, str):
|
|
234
|
+
raise ValueError(_ERROR) # noqa: TRY004 - public validator contract
|
|
235
|
+
try:
|
|
236
|
+
expires = datetime.fromisoformat(expiry)
|
|
237
|
+
except ValueError as exc:
|
|
238
|
+
raise ValueError(_ERROR) from exc
|
|
239
|
+
if expires.tzinfo is None or (not allow_expired and expires <= datetime.now(UTC)):
|
|
240
|
+
raise ValueError(_ERROR)
|
|
241
|
+
document = {key: value for key, value in review.items() if key != "review_digest"}
|
|
242
|
+
if review["review_digest"] != canonical_digest(document):
|
|
243
|
+
raise ValueError(_ERROR)
|
|
244
|
+
return stage, int(counts.get("delete", 0)) + int(counts.get("replace", 0))
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _operation_matches_stage(stage: str, operation: object, service_update: object) -> bool:
|
|
248
|
+
"""Bind the managed host's operation name to the reviewed stage or service update."""
|
|
249
|
+
|
|
250
|
+
if not isinstance(operation, str):
|
|
251
|
+
return False
|
|
252
|
+
if isinstance(service_update, dict):
|
|
253
|
+
service = str(service_update["service"])
|
|
254
|
+
return (
|
|
255
|
+
re.fullmatch(rf"service-update-{re.escape(service)}-[0-9a-f]{{12}}", operation)
|
|
256
|
+
is not None
|
|
257
|
+
)
|
|
258
|
+
if stage == "runtime":
|
|
259
|
+
return operation in {"runtime", "runtime-cluster"}
|
|
260
|
+
if stage == "application":
|
|
261
|
+
return operation in {
|
|
262
|
+
"application",
|
|
263
|
+
"application-input-rebind-update",
|
|
264
|
+
"license-application-update",
|
|
265
|
+
}
|
|
266
|
+
return operation == stage
|