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,767 @@
|
|
|
1
|
+
"""Detect and bind offline Foundation lineage transitions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import select
|
|
9
|
+
import stat
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
from collections.abc import Callable, Mapping
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import UTC, datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from fdai_deployment_cli.bundle import verify_bundle
|
|
20
|
+
from fdai_deployment_cli.contracts import canonical_digest, load_json_object
|
|
21
|
+
from fdai_deployment_cli.deployment_kit import DeploymentKit
|
|
22
|
+
from fdai_deployment_cli.foundation_adoption import write_lineage_adoption_receipt
|
|
23
|
+
from fdai_deployment_cli.foundation_input import snapshot_foundation_input
|
|
24
|
+
from fdai_deployment_cli.foundation_plan import REVIEW_NAME
|
|
25
|
+
from fdai_deployment_cli.plan_input import read_plan_input
|
|
26
|
+
from fdai_deployment_cli.private_output import read_private_bytes, write_private_output
|
|
27
|
+
from fdai_deployment_cli.profile import load_profile
|
|
28
|
+
from fdai_deployment_cli.trust_roots import deployment_bundle_root_pem
|
|
29
|
+
|
|
30
|
+
_COMMIT = re.compile(r"[0-9a-f]{40}")
|
|
31
|
+
_DIGEST = re.compile(r"[0-9a-f]{64}")
|
|
32
|
+
_GUID = re.compile(r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}")
|
|
33
|
+
_SAFE_REASON = re.compile(r"[A-Za-z0-9 _.,:;()'\[\]/-]{1,320}")
|
|
34
|
+
_PLAN_REF = re.compile(r"foundation-plan-attempt-[1-9][0-9]*")
|
|
35
|
+
_TRANSITION_REF = re.compile(r"foundation-transition-attempt-[1-9][0-9]*")
|
|
36
|
+
_CLAIM_NAME = "foundation-transition-claim.json"
|
|
37
|
+
_REMOTE_RECEIPT_NAME = "foundation-transition-remote-receipt.json"
|
|
38
|
+
_RUNNER_FAILURE = "Foundation transition remote operation failed"
|
|
39
|
+
_ACTION_KEYS = frozenset({"create", "update", "delete", "replace", "read", "no-op"})
|
|
40
|
+
_INPUT_KEYS = (
|
|
41
|
+
"terraform_root_digest",
|
|
42
|
+
"provider_lock_digest",
|
|
43
|
+
"bootstrap_artifacts_digest",
|
|
44
|
+
"runner_image_toolchain_digest",
|
|
45
|
+
"terraform_digest",
|
|
46
|
+
"runner_image_observation_digest",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class TransitionDecision:
|
|
52
|
+
"""Foundation input comparison result for an offline kit upgrade."""
|
|
53
|
+
|
|
54
|
+
changed: bool
|
|
55
|
+
current_inputs: dict[str, str]
|
|
56
|
+
retained_inputs: dict[str, str]
|
|
57
|
+
changed_keys: tuple[str, ...]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
TransitionRunner = Callable[..., dict[str, object]]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def decide_foundation_transition(
|
|
64
|
+
*,
|
|
65
|
+
kit: DeploymentKit,
|
|
66
|
+
run_root: Path,
|
|
67
|
+
retained_plan_ref: str,
|
|
68
|
+
) -> TransitionDecision:
|
|
69
|
+
"""Compare precise Foundation inputs while failing closed without the retained baseline."""
|
|
70
|
+
|
|
71
|
+
retained_plan = _retained_plan_dir(run_root, retained_plan_ref)
|
|
72
|
+
retained_review = _load_review(retained_plan, allow_expired=True, require_summary=False)
|
|
73
|
+
current = _current_input_digests(kit, retained_review)
|
|
74
|
+
retained = _verified_transition_inputs(run_root) or _retained_input_digests(
|
|
75
|
+
retained_plan, retained_review
|
|
76
|
+
)
|
|
77
|
+
changed = tuple(key for key in _INPUT_KEYS if current.get(key) != retained.get(key))
|
|
78
|
+
return TransitionDecision(
|
|
79
|
+
changed=bool(changed),
|
|
80
|
+
current_inputs=current,
|
|
81
|
+
retained_inputs=retained,
|
|
82
|
+
changed_keys=changed,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def run_foundation_transition(
|
|
87
|
+
*,
|
|
88
|
+
kit: DeploymentKit,
|
|
89
|
+
run_root: Path,
|
|
90
|
+
retained_plan_ref: str,
|
|
91
|
+
transition_plan_ref: str | None,
|
|
92
|
+
application_source_commit: str,
|
|
93
|
+
kit_manifest_digest: str,
|
|
94
|
+
runtime_release_digest: str,
|
|
95
|
+
tenant_id: str,
|
|
96
|
+
subscription_id: str,
|
|
97
|
+
region: str,
|
|
98
|
+
monthly_cost_ceiling: int,
|
|
99
|
+
transition_runner: TransitionRunner | None = None,
|
|
100
|
+
plan_runner: TransitionRunner | None = None,
|
|
101
|
+
) -> dict[str, object]:
|
|
102
|
+
"""Run plan-review-approve-apply-verify, then return the adoption receipt."""
|
|
103
|
+
|
|
104
|
+
runner = transition_runner or plan_runner or default_transition_runner
|
|
105
|
+
retained_plan = _retained_plan_dir(run_root, retained_plan_ref)
|
|
106
|
+
decision = decide_foundation_transition(
|
|
107
|
+
kit=kit,
|
|
108
|
+
run_root=run_root,
|
|
109
|
+
retained_plan_ref=retained_plan_ref,
|
|
110
|
+
)
|
|
111
|
+
if not decision.changed:
|
|
112
|
+
return write_lineage_adoption_receipt(
|
|
113
|
+
run_root=run_root,
|
|
114
|
+
plan_directory=retained_plan,
|
|
115
|
+
application_source_commit=application_source_commit,
|
|
116
|
+
kit_manifest_digest=kit_manifest_digest,
|
|
117
|
+
runtime_release_digest=runtime_release_digest,
|
|
118
|
+
tenant_id=tenant_id,
|
|
119
|
+
subscription_id=subscription_id,
|
|
120
|
+
region=region,
|
|
121
|
+
monthly_cost_ceiling=monthly_cost_ceiling,
|
|
122
|
+
)
|
|
123
|
+
transition_ref = _select_transition_ref(run_root, transition_plan_ref, decision.current_inputs)
|
|
124
|
+
transition_dir = run_root / transition_ref
|
|
125
|
+
transition_dir.mkdir(mode=0o700, exist_ok=True)
|
|
126
|
+
claim_exists = (transition_dir / _CLAIM_NAME).exists()
|
|
127
|
+
receipt_exists = (transition_dir / _REMOTE_RECEIPT_NAME).exists()
|
|
128
|
+
if claim_exists or receipt_exists:
|
|
129
|
+
review = _load_review(transition_dir, require_summary=True)
|
|
130
|
+
if _review_inputs(review) != decision.current_inputs:
|
|
131
|
+
raise ValueError("foundation_transition_attempt_inputs_differ")
|
|
132
|
+
plan_result = _plan_result_from_review(review)
|
|
133
|
+
else:
|
|
134
|
+
plan_result = runner(
|
|
135
|
+
operation="plan",
|
|
136
|
+
kit=kit,
|
|
137
|
+
run_root=run_root,
|
|
138
|
+
retained_plan_ref=retained_plan_ref,
|
|
139
|
+
transition_plan_ref=transition_ref,
|
|
140
|
+
decision=decision,
|
|
141
|
+
)
|
|
142
|
+
review = _write_transition_review(transition_dir, plan_result, decision.current_inputs)
|
|
143
|
+
if not (claim_exists or receipt_exists):
|
|
144
|
+
approve_transition_plan(transition_dir)
|
|
145
|
+
operation = (
|
|
146
|
+
"verify" if claim_exists or plan_result.get("zero_change_verified") is True else "apply"
|
|
147
|
+
)
|
|
148
|
+
apply_result = runner(
|
|
149
|
+
operation=operation,
|
|
150
|
+
kit=kit,
|
|
151
|
+
run_root=run_root,
|
|
152
|
+
retained_plan_ref=retained_plan_ref,
|
|
153
|
+
transition_plan_ref=transition_ref,
|
|
154
|
+
decision=decision,
|
|
155
|
+
expected_review_digest=plan_result["review_digest"],
|
|
156
|
+
expected_plan_digest=plan_result["plan_digest"],
|
|
157
|
+
)
|
|
158
|
+
adoption = write_lineage_adoption_receipt(
|
|
159
|
+
run_root=run_root,
|
|
160
|
+
plan_directory=retained_plan,
|
|
161
|
+
application_source_commit=application_source_commit,
|
|
162
|
+
kit_manifest_digest=kit_manifest_digest,
|
|
163
|
+
runtime_release_digest=runtime_release_digest,
|
|
164
|
+
tenant_id=tenant_id,
|
|
165
|
+
subscription_id=subscription_id,
|
|
166
|
+
region=region,
|
|
167
|
+
monthly_cost_ceiling=monthly_cost_ceiling,
|
|
168
|
+
)
|
|
169
|
+
receipt = _transition_receipt(
|
|
170
|
+
adoption=adoption,
|
|
171
|
+
decision=decision,
|
|
172
|
+
retained_plan_ref=retained_plan_ref,
|
|
173
|
+
transition_ref=transition_ref,
|
|
174
|
+
review=review,
|
|
175
|
+
apply_result=apply_result,
|
|
176
|
+
application_source_commit=application_source_commit,
|
|
177
|
+
kit_manifest_digest=kit_manifest_digest,
|
|
178
|
+
runtime_release_digest=runtime_release_digest,
|
|
179
|
+
)
|
|
180
|
+
_write_verified_receipt(run_root / "foundation-lineage-transition-receipt.json", receipt)
|
|
181
|
+
return adoption
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def approve_transition_plan(plan_directory: Path) -> None:
|
|
185
|
+
"""Approve one reviewed transition plan, with exact extra confirmation for destruction."""
|
|
186
|
+
|
|
187
|
+
review = _load_review(plan_directory, require_summary=True)
|
|
188
|
+
destructive = _destructive_count(review)
|
|
189
|
+
print(json.dumps(review, indent=2, sort_keys=True), file=sys.stderr)
|
|
190
|
+
print("Approved by this invocation: foundation-transition-apply", file=sys.stderr, flush=True)
|
|
191
|
+
if destructive:
|
|
192
|
+
print(
|
|
193
|
+
f"Foundation transition contains {destructive} delete or replacement action(s); "
|
|
194
|
+
"type foundation-transition-apply-destructive to approve: ",
|
|
195
|
+
end="",
|
|
196
|
+
file=sys.stderr,
|
|
197
|
+
flush=True,
|
|
198
|
+
)
|
|
199
|
+
if _approval_input(timeout_seconds=600) != "foundation-transition-apply-destructive":
|
|
200
|
+
raise ValueError("standalone destructive Foundation transition approval was denied")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def default_transition_runner(**kwargs: object) -> dict[str, object]:
|
|
204
|
+
"""Call the verified kit's transition transport script with supported options only."""
|
|
205
|
+
|
|
206
|
+
kit = kwargs["kit"]
|
|
207
|
+
run_root = kwargs["run_root"]
|
|
208
|
+
retained_plan_ref = kwargs["retained_plan_ref"]
|
|
209
|
+
transition_plan_ref = kwargs["transition_plan_ref"]
|
|
210
|
+
operation = kwargs["operation"]
|
|
211
|
+
if (
|
|
212
|
+
not hasattr(kit, "root")
|
|
213
|
+
or not hasattr(kit, "bundle_root")
|
|
214
|
+
or not isinstance(run_root, Path)
|
|
215
|
+
or not isinstance(retained_plan_ref, str)
|
|
216
|
+
or not isinstance(transition_plan_ref, str)
|
|
217
|
+
or operation not in {"plan", "apply", "verify"}
|
|
218
|
+
):
|
|
219
|
+
raise TypeError("Foundation transition runner received invalid inputs")
|
|
220
|
+
command = [
|
|
221
|
+
sys.executable,
|
|
222
|
+
str(kit.bundle_root / "scripts/deployment/azure/genesis_foundation_transition.py"),
|
|
223
|
+
str(operation),
|
|
224
|
+
"--retained-plan-directory",
|
|
225
|
+
str(run_root / retained_plan_ref),
|
|
226
|
+
"--transition-directory",
|
|
227
|
+
str(run_root / transition_plan_ref),
|
|
228
|
+
"--profile",
|
|
229
|
+
str(run_root / "profile.json"),
|
|
230
|
+
"--variables-file",
|
|
231
|
+
str(retained_variables_file(run_root, run_root / retained_plan_ref)),
|
|
232
|
+
"--offline-kit",
|
|
233
|
+
str(kit.root),
|
|
234
|
+
"--release-root",
|
|
235
|
+
str(run_root / "deployment-release-root.pub"),
|
|
236
|
+
"--bundle-public-key",
|
|
237
|
+
str(run_root / "deployment-bundle-root.pub"),
|
|
238
|
+
"--ssh-private-key",
|
|
239
|
+
str(run_root / "runner_ed25519"),
|
|
240
|
+
"--expected-foundation-receipt-digest",
|
|
241
|
+
_status_receipt_digest(run_root, "foundation_apply"),
|
|
242
|
+
"--expected-enrollment-receipt-digest",
|
|
243
|
+
_status_receipt_digest(run_root, "runner_enrollment"),
|
|
244
|
+
"--output",
|
|
245
|
+
"json",
|
|
246
|
+
]
|
|
247
|
+
script_operation = "verify" if operation == "verify" else str(operation)
|
|
248
|
+
command[2] = script_operation
|
|
249
|
+
if operation in {"apply", "verify"}:
|
|
250
|
+
command.extend(
|
|
251
|
+
[
|
|
252
|
+
"--expected-review-digest",
|
|
253
|
+
str(kwargs["expected_review_digest"]),
|
|
254
|
+
"--expected-plan-digest",
|
|
255
|
+
str(kwargs["expected_plan_digest"]),
|
|
256
|
+
]
|
|
257
|
+
)
|
|
258
|
+
completed = subprocess.run(command, check=False, capture_output=True, text=True, timeout=14_415)
|
|
259
|
+
if completed.returncode != 0:
|
|
260
|
+
raise ValueError(_runner_failure(completed.stdout))
|
|
261
|
+
value = json.loads(completed.stdout)
|
|
262
|
+
if not isinstance(value, dict):
|
|
263
|
+
raise ValueError("Foundation transition result is invalid")
|
|
264
|
+
return {str(key): item for key, item in value.items()}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _runner_failure(stdout: str) -> str:
|
|
268
|
+
"""Carry the transport's bounded, identifier-free reason into the coordinator failure."""
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
value = json.loads(stdout)
|
|
272
|
+
except ValueError:
|
|
273
|
+
return _RUNNER_FAILURE
|
|
274
|
+
reason = value.get("reason") if isinstance(value, dict) else None
|
|
275
|
+
if not isinstance(reason, str):
|
|
276
|
+
return _RUNNER_FAILURE
|
|
277
|
+
reason = _GUID.sub("redacted-id", reason).strip()
|
|
278
|
+
if _SAFE_REASON.fullmatch(reason) is None or reason == _RUNNER_FAILURE:
|
|
279
|
+
return _RUNNER_FAILURE
|
|
280
|
+
if reason.startswith(f"{_RUNNER_FAILURE}: "):
|
|
281
|
+
return reason
|
|
282
|
+
return f"{_RUNNER_FAILURE}: {reason}"
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# Candidate Foundation inputs the orchestrator may have planned with: the runner-image
|
|
286
|
+
# materialization, the adopted-runner-image materialization, and the plain prepared input.
|
|
287
|
+
_VARIABLE_CANDIDATES = (
|
|
288
|
+
"foundation-variables-with-image.json",
|
|
289
|
+
"foundation-with-runner-image.json",
|
|
290
|
+
"foundation-variables.json",
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def retained_variables_file(run_root: Path, retained_plan: Path) -> Path:
|
|
295
|
+
"""Select the Foundation input whose normalized digest the retained plan reviewed."""
|
|
296
|
+
|
|
297
|
+
review = _load_review(retained_plan, allow_expired=True)
|
|
298
|
+
expected = _required_digest(_context(review), "variables_digest")
|
|
299
|
+
profile = load_profile(run_root / "profile.json")
|
|
300
|
+
for name in _VARIABLE_CANDIDATES:
|
|
301
|
+
candidate = run_root / name
|
|
302
|
+
if candidate.is_symlink() or not candidate.is_file():
|
|
303
|
+
continue
|
|
304
|
+
with tempfile.TemporaryDirectory(prefix="foundation-input-", dir=run_root) as raw:
|
|
305
|
+
snapshot = Path(raw) / "snapshot.json"
|
|
306
|
+
try:
|
|
307
|
+
snapshot_foundation_input(
|
|
308
|
+
candidate,
|
|
309
|
+
snapshot,
|
|
310
|
+
expected_target_binding=profile.target_binding,
|
|
311
|
+
expected_region=profile.region,
|
|
312
|
+
expected_environment=profile.environment,
|
|
313
|
+
)
|
|
314
|
+
digest = canonical_digest(read_plan_input(snapshot))
|
|
315
|
+
except (OSError, ValueError):
|
|
316
|
+
continue
|
|
317
|
+
if digest == expected:
|
|
318
|
+
return candidate
|
|
319
|
+
raise ValueError("foundation_transition_variables_unverifiable")
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _current_input_digests(
|
|
323
|
+
kit: DeploymentKit, retained_review: Mapping[str, object]
|
|
324
|
+
) -> dict[str, str]:
|
|
325
|
+
root = kit.bundle_root / "infra"
|
|
326
|
+
context = _context(retained_review)
|
|
327
|
+
return {
|
|
328
|
+
"terraform_root_digest": _tree_digest(root / "genesis-foundation"),
|
|
329
|
+
"provider_lock_digest": _file_digest(root / "genesis-foundation/.terraform.lock.hcl"),
|
|
330
|
+
"bootstrap_artifacts_digest": _optional_tree_digest(root / "bootstrap"),
|
|
331
|
+
"runner_image_toolchain_digest": _tree_digest(root / "genesis-runner-image"),
|
|
332
|
+
"terraform_digest": str(
|
|
333
|
+
dict(kit.verification.file_digests)[kit.verification.terraform_binary]
|
|
334
|
+
),
|
|
335
|
+
"runner_image_observation_digest": str(context["runner_image_observation_digest"]),
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _retained_input_digests(
|
|
340
|
+
retained_plan: Path, retained_review: Mapping[str, object]
|
|
341
|
+
) -> dict[str, str]:
|
|
342
|
+
context = _context(retained_review)
|
|
343
|
+
root = _retained_bundle_root(retained_plan, context)
|
|
344
|
+
infra = root / "infra"
|
|
345
|
+
terraform_digest = _find_digest(retained_plan / "artifacts", str(context["terraform_digest"]))
|
|
346
|
+
return {
|
|
347
|
+
"terraform_root_digest": _tree_digest(infra / "genesis-foundation"),
|
|
348
|
+
"provider_lock_digest": _file_digest_checked(
|
|
349
|
+
infra / "genesis-foundation/.terraform.lock.hcl",
|
|
350
|
+
str(context["provider_lock_digest"]),
|
|
351
|
+
),
|
|
352
|
+
"bootstrap_artifacts_digest": _optional_tree_digest(infra / "bootstrap"),
|
|
353
|
+
"runner_image_toolchain_digest": _tree_digest(infra / "genesis-runner-image"),
|
|
354
|
+
"terraform_digest": terraform_digest,
|
|
355
|
+
"runner_image_observation_digest": str(context["runner_image_observation_digest"]),
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _verified_transition_inputs(run_root: Path) -> dict[str, str] | None:
|
|
360
|
+
path = run_root / "foundation-lineage-transition-receipt.json"
|
|
361
|
+
if not path.exists() and not path.is_symlink():
|
|
362
|
+
return None
|
|
363
|
+
value = load_json_object(read_private_bytes(path, max_bytes=1_048_576), label="transition")
|
|
364
|
+
if not isinstance(value, dict):
|
|
365
|
+
raise ValueError("Foundation transition receipt is invalid")
|
|
366
|
+
digest = value.get("receipt_digest")
|
|
367
|
+
unsigned = {key: item for key, item in value.items() if key != "receipt_digest"}
|
|
368
|
+
inputs = value.get("current_input_digests")
|
|
369
|
+
if (
|
|
370
|
+
value.get("schema_version") != "fdai.foundation-lineage-transition.v2"
|
|
371
|
+
or not isinstance(digest, str)
|
|
372
|
+
or canonical_digest(unsigned) != digest
|
|
373
|
+
or not isinstance(inputs, dict)
|
|
374
|
+
or any(
|
|
375
|
+
not isinstance(key, str) or not isinstance(item, str) for key, item in inputs.items()
|
|
376
|
+
)
|
|
377
|
+
):
|
|
378
|
+
raise ValueError("Foundation transition receipt is invalid")
|
|
379
|
+
return {str(key): str(item) for key, item in inputs.items()}
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _retained_bundle_root(retained_plan: Path, context: Mapping[str, object]) -> Path:
|
|
383
|
+
candidates = []
|
|
384
|
+
for parent in (retained_plan / "foundation-apply-bundle", retained_plan / "bundle"):
|
|
385
|
+
if parent.exists() and parent.is_dir() and not parent.is_symlink():
|
|
386
|
+
children = [child for child in parent.iterdir() if child.is_dir()]
|
|
387
|
+
candidates.extend(children or [parent])
|
|
388
|
+
for candidate in candidates:
|
|
389
|
+
try:
|
|
390
|
+
verification = verify_bundle(
|
|
391
|
+
candidate,
|
|
392
|
+
public_key_pem=deployment_bundle_root_pem(),
|
|
393
|
+
)
|
|
394
|
+
except ValueError:
|
|
395
|
+
continue
|
|
396
|
+
if verification.manifest_digest == context["deployment_bundle_digest"]:
|
|
397
|
+
return candidate
|
|
398
|
+
raise ValueError("foundation_transition_baseline_unverifiable")
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _find_digest(root: Path, expected: str) -> str:
|
|
402
|
+
if not root.exists() or not root.is_dir():
|
|
403
|
+
raise ValueError("foundation_transition_baseline_unverifiable")
|
|
404
|
+
for path in root.rglob("*"):
|
|
405
|
+
if path.is_file() and not path.is_symlink() and _file_digest(path) == expected:
|
|
406
|
+
return expected
|
|
407
|
+
raise ValueError("foundation_transition_baseline_unverifiable")
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _write_transition_review(
|
|
411
|
+
directory: Path, result: Mapping[str, object], current_inputs: Mapping[str, str]
|
|
412
|
+
) -> dict[str, object]:
|
|
413
|
+
summary = result.get("summary")
|
|
414
|
+
if not isinstance(summary, dict):
|
|
415
|
+
raise ValueError("Foundation transition plan summary is invalid")
|
|
416
|
+
review: dict[str, object] = {
|
|
417
|
+
"schema_version": "fdai.foundation-transition-review.v1",
|
|
418
|
+
"state": "review",
|
|
419
|
+
"plan_digest": _required_digest(result, "plan_digest"),
|
|
420
|
+
"plan_json_digest": _required_digest(result, "plan_json_digest"),
|
|
421
|
+
"remote_state_digest": _required_digest(result, "remote_state_digest"),
|
|
422
|
+
"archive_digest": _required_digest(result, "archive_digest"),
|
|
423
|
+
"helper_digest": _required_digest(result, "helper_digest"),
|
|
424
|
+
"transport_review_digest": _required_digest(result, "review_digest"),
|
|
425
|
+
"current_input_digests": {key: str(current_inputs[key]) for key in _INPUT_KEYS},
|
|
426
|
+
"summary": summary,
|
|
427
|
+
"transport_zero_change_verified": result.get("zero_change_verified") is True,
|
|
428
|
+
"mutation_performed": False,
|
|
429
|
+
"subscription_ready": False,
|
|
430
|
+
"created_at": datetime.now(UTC).replace(microsecond=0).isoformat(),
|
|
431
|
+
}
|
|
432
|
+
review["review_digest"] = canonical_digest(review)
|
|
433
|
+
_write_verified_receipt(directory / REVIEW_NAME, review)
|
|
434
|
+
return review
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _transition_receipt(
|
|
438
|
+
*,
|
|
439
|
+
adoption: Mapping[str, object],
|
|
440
|
+
decision: TransitionDecision,
|
|
441
|
+
retained_plan_ref: str,
|
|
442
|
+
transition_ref: str,
|
|
443
|
+
review: Mapping[str, object],
|
|
444
|
+
apply_result: Mapping[str, object],
|
|
445
|
+
application_source_commit: str,
|
|
446
|
+
kit_manifest_digest: str,
|
|
447
|
+
runtime_release_digest: str,
|
|
448
|
+
) -> dict[str, object]:
|
|
449
|
+
receipt: dict[str, object] = {
|
|
450
|
+
"schema_version": "fdai.foundation-lineage-transition.v2",
|
|
451
|
+
"state": "verified",
|
|
452
|
+
"foundation_source_commit": adoption["foundation_source_commit"],
|
|
453
|
+
"application_source_commit": application_source_commit,
|
|
454
|
+
"foundation_run_binding": adoption["foundation_run_binding"],
|
|
455
|
+
"adopted_run_binding": adoption["adopted_run_binding"],
|
|
456
|
+
"target_binding": adoption["target_binding"],
|
|
457
|
+
"retained_plan_ref": retained_plan_ref,
|
|
458
|
+
"transition_plan_ref": transition_ref,
|
|
459
|
+
"changed_input_keys": list(decision.changed_keys),
|
|
460
|
+
"current_input_digests": decision.current_inputs,
|
|
461
|
+
"retained_input_digests": decision.retained_inputs,
|
|
462
|
+
"transition_review_digest": review["review_digest"],
|
|
463
|
+
"transition_transport_review_digest": review["transport_review_digest"],
|
|
464
|
+
"transition_plan_digest": review["plan_digest"],
|
|
465
|
+
"transition_plan_json_digest": review["plan_json_digest"],
|
|
466
|
+
"transition_plan_summary_digest": _summary_digest(review),
|
|
467
|
+
"transition_remote_state_digest": apply_result["remote_state_digest"],
|
|
468
|
+
"transition_remote_plan_digest": apply_result["plan_json_digest"],
|
|
469
|
+
"transition_remote_receipt_digest": apply_result["receipt_digest"],
|
|
470
|
+
"foundation_adoption_receipt_digest": adoption["receipt_digest"],
|
|
471
|
+
"foundation_apply_receipt_digest": adoption["foundation_recovery_receipt_digest"],
|
|
472
|
+
"foundation_enrollment_receipt_digest": adoption["foundation_enrollment_receipt_digest"],
|
|
473
|
+
"foundation_state_receipt_digest": adoption["foundation_state_receipt_digest"],
|
|
474
|
+
"foundation_state_authority_digest": adoption["foundation_state_authority_digest"],
|
|
475
|
+
"kit_manifest_digest": kit_manifest_digest,
|
|
476
|
+
"runtime_release_digest": runtime_release_digest,
|
|
477
|
+
"destructive_action_count": _destructive_count(review),
|
|
478
|
+
"mutation_performed": apply_result.get("mutation_performed") is True,
|
|
479
|
+
"zero_change_verified": apply_result.get("zero_change_verified") is True,
|
|
480
|
+
"deployment_ready": False,
|
|
481
|
+
"subscription_ready": False,
|
|
482
|
+
"verified_at": datetime.now(UTC).replace(microsecond=0).isoformat(),
|
|
483
|
+
}
|
|
484
|
+
receipt["receipt_digest"] = canonical_digest(receipt)
|
|
485
|
+
return receipt
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _load_review(
|
|
489
|
+
plan_directory: Path, *, allow_expired: bool = False, require_summary: bool = False
|
|
490
|
+
) -> dict[str, Any]:
|
|
491
|
+
value = load_json_object(
|
|
492
|
+
read_private_bytes(plan_directory / REVIEW_NAME, max_bytes=1_048_576),
|
|
493
|
+
label="Foundation transition review",
|
|
494
|
+
)
|
|
495
|
+
if not isinstance(value, dict):
|
|
496
|
+
raise ValueError("Foundation transition review is invalid")
|
|
497
|
+
review = {str(key): item for key, item in value.items()}
|
|
498
|
+
if review.get("schema_version") == "fdai.foundation-transition-review.v1":
|
|
499
|
+
_validate_transition_review(review)
|
|
500
|
+
else:
|
|
501
|
+
_validate_retained_review(review, allow_expired=allow_expired)
|
|
502
|
+
if require_summary and "summary" not in review:
|
|
503
|
+
raise ValueError("Foundation transition review summary is invalid")
|
|
504
|
+
return review
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _validate_retained_review(review: Mapping[str, object], *, allow_expired: bool) -> None:
|
|
508
|
+
if (
|
|
509
|
+
review.get("schema_version") != "fdai.foundation-saved-plan.v1"
|
|
510
|
+
or review.get("state") != "review"
|
|
511
|
+
or review.get("apply_authorized") is not False
|
|
512
|
+
or review.get("mutation_performed") is not False
|
|
513
|
+
or review.get("subscription_ready") is not False
|
|
514
|
+
):
|
|
515
|
+
raise ValueError("Foundation transition review is invalid")
|
|
516
|
+
_context(review)
|
|
517
|
+
_validate_review_digest(review)
|
|
518
|
+
expires = review.get("expires_at")
|
|
519
|
+
if not isinstance(expires, str):
|
|
520
|
+
raise ValueError("Foundation transition review is invalid")
|
|
521
|
+
parsed = datetime.fromisoformat(expires.replace("Z", "+00:00"))
|
|
522
|
+
if parsed.tzinfo is None or (not allow_expired and parsed <= datetime.now(UTC)):
|
|
523
|
+
raise ValueError("Foundation transition review is invalid or expired")
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _validate_transition_review(review: Mapping[str, object]) -> None:
|
|
527
|
+
if (
|
|
528
|
+
review.get("schema_version") != "fdai.foundation-transition-review.v1"
|
|
529
|
+
or review.get("state") != "review"
|
|
530
|
+
or review.get("mutation_performed") is not False
|
|
531
|
+
or review.get("subscription_ready") is not False
|
|
532
|
+
):
|
|
533
|
+
raise ValueError("Foundation transition review is invalid")
|
|
534
|
+
for key in (
|
|
535
|
+
"plan_digest",
|
|
536
|
+
"plan_json_digest",
|
|
537
|
+
"remote_state_digest",
|
|
538
|
+
"archive_digest",
|
|
539
|
+
"transport_review_digest",
|
|
540
|
+
):
|
|
541
|
+
_required_digest(review, key)
|
|
542
|
+
if _review_inputs(review) is None:
|
|
543
|
+
raise ValueError("Foundation transition review inputs are invalid")
|
|
544
|
+
_summary_counts(review)
|
|
545
|
+
_summary_digest(review)
|
|
546
|
+
_validate_review_digest(review)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _review_inputs(review: Mapping[str, object]) -> dict[str, str] | None:
|
|
550
|
+
value = review.get("current_input_digests")
|
|
551
|
+
if (
|
|
552
|
+
not isinstance(value, dict)
|
|
553
|
+
or set(value) != set(_INPUT_KEYS)
|
|
554
|
+
or any(not isinstance(item, str) or not item for item in value.values())
|
|
555
|
+
):
|
|
556
|
+
return None
|
|
557
|
+
return {str(key): str(item) for key, item in value.items()}
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _validate_review_digest(review: Mapping[str, object]) -> None:
|
|
561
|
+
digest = review.get("review_digest")
|
|
562
|
+
unsigned = {key: value for key, value in review.items() if key != "review_digest"}
|
|
563
|
+
if not isinstance(digest, str) or canonical_digest(unsigned) != digest:
|
|
564
|
+
raise ValueError("Foundation transition review digest differs")
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _context(review: Mapping[str, object]) -> Mapping[str, object]:
|
|
568
|
+
context = review.get("context")
|
|
569
|
+
if not isinstance(context, dict):
|
|
570
|
+
raise ValueError("Foundation transition review context is invalid")
|
|
571
|
+
for key in (
|
|
572
|
+
"source_commit",
|
|
573
|
+
"provider_lock_digest",
|
|
574
|
+
"terraform_digest",
|
|
575
|
+
"runner_image_observation_digest",
|
|
576
|
+
"deployment_bundle_digest",
|
|
577
|
+
"foundation_context_digest",
|
|
578
|
+
):
|
|
579
|
+
value = context.get(key)
|
|
580
|
+
pattern = _COMMIT if key == "source_commit" else _DIGEST
|
|
581
|
+
if not isinstance(value, str) or pattern.fullmatch(value) is None:
|
|
582
|
+
raise ValueError("Foundation transition review context is invalid")
|
|
583
|
+
return context
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _destructive_count(review: Mapping[str, object]) -> int:
|
|
587
|
+
counts = _summary_counts(review)
|
|
588
|
+
return counts["delete"] + counts["replace"]
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _summary_counts(review: Mapping[str, object]) -> dict[str, int]:
|
|
592
|
+
summary = review.get("summary")
|
|
593
|
+
if not isinstance(summary, dict):
|
|
594
|
+
raise ValueError("Foundation transition review summary is invalid")
|
|
595
|
+
counts = summary.get("action_counts")
|
|
596
|
+
if (
|
|
597
|
+
not isinstance(counts, dict)
|
|
598
|
+
or set(counts) != _ACTION_KEYS
|
|
599
|
+
or any(type(counts[key]) is not int or counts[key] < 0 for key in _ACTION_KEYS)
|
|
600
|
+
):
|
|
601
|
+
raise ValueError("Foundation transition review summary is invalid")
|
|
602
|
+
return {key: int(counts[key]) for key in _ACTION_KEYS}
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _summary_digest(review: Mapping[str, object]) -> str:
|
|
606
|
+
summary = review.get("summary")
|
|
607
|
+
if not isinstance(summary, dict):
|
|
608
|
+
raise ValueError("Foundation transition review summary is invalid")
|
|
609
|
+
digest = summary.get("summary_digest")
|
|
610
|
+
unsigned = {key: value for key, value in summary.items() if key != "summary_digest"}
|
|
611
|
+
if not isinstance(digest, str) or canonical_digest(unsigned) != digest:
|
|
612
|
+
raise ValueError("Foundation transition review summary digest differs")
|
|
613
|
+
return digest
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _retained_plan_dir(run_root: Path, value: str) -> Path:
|
|
617
|
+
if _PLAN_REF.fullmatch(value) is None:
|
|
618
|
+
raise ValueError("Foundation retained plan reference is invalid")
|
|
619
|
+
return run_root / value
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _transition_ref(value: str) -> str:
|
|
623
|
+
if _TRANSITION_REF.fullmatch(value) is None:
|
|
624
|
+
raise ValueError("Foundation transition reference is invalid")
|
|
625
|
+
return value
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _next_transition_attempt(run_root: Path) -> int:
|
|
629
|
+
attempts = [
|
|
630
|
+
int(path.name.rsplit("-", 1)[1])
|
|
631
|
+
for path in run_root.glob("foundation-transition-attempt-*")
|
|
632
|
+
if _TRANSITION_REF.fullmatch(path.name)
|
|
633
|
+
]
|
|
634
|
+
return max(attempts, default=0) + 1
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _select_transition_ref(
|
|
638
|
+
run_root: Path, value: str | None, current_inputs: Mapping[str, str]
|
|
639
|
+
) -> str:
|
|
640
|
+
"""Resume only the newest attempt, and only for the current Foundation inputs.
|
|
641
|
+
|
|
642
|
+
A newer completed attempt supersedes every older one, so an older receipt never verifies a
|
|
643
|
+
later kit. An interrupted apply for other inputs fails closed instead of being replanned.
|
|
644
|
+
"""
|
|
645
|
+
|
|
646
|
+
if value is not None:
|
|
647
|
+
return _transition_ref(value)
|
|
648
|
+
attempts = sorted(
|
|
649
|
+
(
|
|
650
|
+
path
|
|
651
|
+
for path in run_root.glob("foundation-transition-attempt-*")
|
|
652
|
+
if _TRANSITION_REF.fullmatch(path.name)
|
|
653
|
+
),
|
|
654
|
+
key=lambda path: int(path.name.rsplit("-", 1)[1]),
|
|
655
|
+
reverse=True,
|
|
656
|
+
)
|
|
657
|
+
for attempt in attempts:
|
|
658
|
+
claim = (attempt / _CLAIM_NAME).exists()
|
|
659
|
+
remote_receipt = (attempt / _REMOTE_RECEIPT_NAME).exists()
|
|
660
|
+
if not claim and not remote_receipt:
|
|
661
|
+
continue
|
|
662
|
+
if _review_inputs(_load_review(attempt, require_summary=True)) == dict(current_inputs):
|
|
663
|
+
return attempt.name
|
|
664
|
+
if claim and not remote_receipt:
|
|
665
|
+
raise ValueError("foundation_transition_interrupted_for_other_inputs")
|
|
666
|
+
break
|
|
667
|
+
return f"foundation-transition-attempt-{_next_transition_attempt(run_root)}"
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _plan_result_from_review(review: Mapping[str, object]) -> dict[str, object]:
|
|
671
|
+
return {
|
|
672
|
+
"schema_version": "fdai.genesis-foundation-transition-plan.v1",
|
|
673
|
+
"state": "review",
|
|
674
|
+
"review_digest": review["transport_review_digest"],
|
|
675
|
+
"archive_digest": review["archive_digest"],
|
|
676
|
+
"helper_digest": review["helper_digest"],
|
|
677
|
+
"remote_state_digest": review["remote_state_digest"],
|
|
678
|
+
"plan_digest": review["plan_digest"],
|
|
679
|
+
"plan_json_digest": review["plan_json_digest"],
|
|
680
|
+
"summary": review["summary"],
|
|
681
|
+
"zero_change_verified": review.get("transport_zero_change_verified") is True,
|
|
682
|
+
"mutation_performed": False,
|
|
683
|
+
"subscription_ready": False,
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _status_receipt_digest(run_root: Path, field: str) -> str:
|
|
688
|
+
status = load_json_object(
|
|
689
|
+
read_private_bytes(run_root / "status.json", max_bytes=1_048_576),
|
|
690
|
+
label="Foundation status",
|
|
691
|
+
)
|
|
692
|
+
report = status.get("foundation_report") if isinstance(status, dict) else None
|
|
693
|
+
value = report.get(field) if isinstance(report, dict) else None
|
|
694
|
+
digest = value.get("receipt_digest") if isinstance(value, dict) else None
|
|
695
|
+
if not isinstance(digest, str) or _DIGEST.fullmatch(digest) is None:
|
|
696
|
+
raise ValueError("Foundation transition retained receipt digest is unavailable")
|
|
697
|
+
return digest
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _required_digest(value: Mapping[str, object], key: str) -> str:
|
|
701
|
+
item = value.get(key)
|
|
702
|
+
if not isinstance(item, str) or _DIGEST.fullmatch(item) is None:
|
|
703
|
+
raise ValueError(f"Foundation transition {key} is invalid")
|
|
704
|
+
return item
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _tree_digest(root: Path) -> str:
|
|
708
|
+
if not root.exists() or not root.is_dir() or root.is_symlink():
|
|
709
|
+
raise ValueError("foundation_transition_baseline_unverifiable")
|
|
710
|
+
digest = hashlib.sha256()
|
|
711
|
+
for path in sorted(root.rglob("*")):
|
|
712
|
+
relative = path.relative_to(root)
|
|
713
|
+
if ".terraform" in relative.parts:
|
|
714
|
+
continue
|
|
715
|
+
details = path.lstat()
|
|
716
|
+
if stat.S_ISDIR(details.st_mode):
|
|
717
|
+
continue
|
|
718
|
+
if not stat.S_ISREG(details.st_mode) or details.st_nlink != 1:
|
|
719
|
+
raise ValueError("Foundation input tree contains an unsafe file")
|
|
720
|
+
digest.update(relative.as_posix().encode())
|
|
721
|
+
digest.update(b"\0")
|
|
722
|
+
digest.update(hashlib.sha256(path.read_bytes()).digest())
|
|
723
|
+
return digest.hexdigest()
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _optional_tree_digest(root: Path) -> str:
|
|
727
|
+
return _tree_digest(root) if root.exists() else "0" * 64
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _file_digest(path: Path) -> str:
|
|
731
|
+
details = path.lstat()
|
|
732
|
+
if not stat.S_ISREG(details.st_mode) or details.st_nlink != 1:
|
|
733
|
+
raise ValueError("Foundation input file is unsafe")
|
|
734
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _file_digest_checked(path: Path, expected: str) -> str:
|
|
738
|
+
observed = _file_digest(path)
|
|
739
|
+
if observed != expected:
|
|
740
|
+
raise ValueError("foundation_transition_baseline_unverifiable")
|
|
741
|
+
return observed
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def _write_verified_receipt(path: Path, receipt: Mapping[str, object]) -> None:
|
|
745
|
+
content = json.dumps(dict(receipt), sort_keys=True, separators=(",", ":")) + "\n"
|
|
746
|
+
if path.exists() or path.is_symlink():
|
|
747
|
+
if read_private_bytes(path, max_bytes=1_048_576).decode("utf-8") == content:
|
|
748
|
+
return
|
|
749
|
+
path.unlink()
|
|
750
|
+
write_private_output(path, content)
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _approval_input(*, timeout_seconds: int) -> str:
|
|
754
|
+
if timeout_seconds <= 0:
|
|
755
|
+
raise TimeoutError("standalone approval deadline expired; no approval was granted")
|
|
756
|
+
if not sys.stdin.isatty():
|
|
757
|
+
raise ValueError("standalone approval requires an interactive terminal")
|
|
758
|
+
try:
|
|
759
|
+
readable, _, _ = select.select([sys.stdin], [], [], timeout_seconds)
|
|
760
|
+
except (OSError, ValueError):
|
|
761
|
+
raise ValueError("standalone approval input is unavailable") from None
|
|
762
|
+
if not readable:
|
|
763
|
+
raise TimeoutError("standalone approval input timed out; no approval was granted")
|
|
764
|
+
try:
|
|
765
|
+
return input("").strip()
|
|
766
|
+
except EOFError as exc:
|
|
767
|
+
raise ValueError("approval input closed; no new approval was granted") from exc
|