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,434 @@
|
|
|
1
|
+
"""Hash-chained local run journal and legal resume decisions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fcntl
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import stat
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from enum import StrEnum
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import BinaryIO
|
|
16
|
+
|
|
17
|
+
from fdai_deployment_cli.contracts import canonical_digest, load_json_object
|
|
18
|
+
from fdai_deployment_cli.compiler import (
|
|
19
|
+
GENESIS_ENTRY_IDS_BY_VERSION,
|
|
20
|
+
GENESIS_MANIFEST_VERSION,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
GENESIS_HASH = "0" * 64
|
|
24
|
+
_LEGACY_EVENT_MANIFEST_VERSION = "genesis.v1"
|
|
25
|
+
_MAX_JOURNAL_BYTES = 8 * 1024 * 1024
|
|
26
|
+
_MAX_EVENTS = 10_000
|
|
27
|
+
_LOCK_TIMEOUT_SECONDS = 5.0
|
|
28
|
+
_LOCK_RETRY_SECONDS = 0.05
|
|
29
|
+
_ID = re.compile(r"^[a-z][a-z0-9._-]{0,127}$")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RunState(StrEnum):
|
|
33
|
+
"""Terminal and nonterminal provisioning run states."""
|
|
34
|
+
|
|
35
|
+
PLANNING = "planning"
|
|
36
|
+
WAITING = "waiting"
|
|
37
|
+
APPLYING = "applying"
|
|
38
|
+
VERIFYING = "verifying"
|
|
39
|
+
COMPLETED = "completed"
|
|
40
|
+
READY = "ready"
|
|
41
|
+
BLOCKED = "blocked"
|
|
42
|
+
FAILED = "failed"
|
|
43
|
+
CANCELLED = "cancelled"
|
|
44
|
+
INCOMPLETE = "incomplete"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ResumeAction(StrEnum):
|
|
48
|
+
"""Only safe continuations after an interrupted stage."""
|
|
49
|
+
|
|
50
|
+
REPLAN = "replan"
|
|
51
|
+
RESUME_VERIFICATION = "resume-verification"
|
|
52
|
+
COMPLETE = "complete"
|
|
53
|
+
REVIEW = "review"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class ProvisionEvent:
|
|
58
|
+
"""One append-only, replayable local run event."""
|
|
59
|
+
|
|
60
|
+
run_id: str
|
|
61
|
+
context_digest: str
|
|
62
|
+
sequence: int
|
|
63
|
+
stage: str
|
|
64
|
+
attempt: int
|
|
65
|
+
state: RunState
|
|
66
|
+
occurred_at: str
|
|
67
|
+
previous_digest: str
|
|
68
|
+
reason_code: str | None = None
|
|
69
|
+
evidence_digest: str | None = None
|
|
70
|
+
manifest_version: str = GENESIS_MANIFEST_VERSION
|
|
71
|
+
schema_version: str = "fdai.provision-event.v3"
|
|
72
|
+
|
|
73
|
+
def __post_init__(self) -> None:
|
|
74
|
+
if _ID.fullmatch(self.run_id) is None or _ID.fullmatch(self.stage) is None:
|
|
75
|
+
raise ValueError("run_id and stage MUST be stable identifiers")
|
|
76
|
+
if re.fullmatch(r"[0-9a-f]{64}", self.context_digest) is None:
|
|
77
|
+
raise ValueError("event context_digest MUST be a SHA-256")
|
|
78
|
+
if self.sequence < 1 or self.attempt < 1:
|
|
79
|
+
raise ValueError("event sequence and attempt MUST be positive")
|
|
80
|
+
try:
|
|
81
|
+
moment = datetime.fromisoformat(self.occurred_at)
|
|
82
|
+
except ValueError as exc:
|
|
83
|
+
raise ValueError("event occurred_at MUST be ISO 8601") from exc
|
|
84
|
+
if moment.tzinfo is None:
|
|
85
|
+
raise ValueError("event occurred_at MUST be timezone-aware")
|
|
86
|
+
if re.fullmatch(r"[0-9a-f]{64}", self.previous_digest) is None:
|
|
87
|
+
raise ValueError("event previous_digest MUST be a SHA-256")
|
|
88
|
+
if self.reason_code is not None and _ID.fullmatch(self.reason_code) is None:
|
|
89
|
+
raise ValueError("event reason_code MUST be a stable identifier")
|
|
90
|
+
if (
|
|
91
|
+
self.evidence_digest is not None
|
|
92
|
+
and re.fullmatch(r"[0-9a-f]{64}", self.evidence_digest) is None
|
|
93
|
+
):
|
|
94
|
+
raise ValueError("event evidence_digest MUST be a SHA-256")
|
|
95
|
+
if self.manifest_version not in GENESIS_ENTRY_IDS_BY_VERSION:
|
|
96
|
+
raise ValueError("event manifest_version is unsupported")
|
|
97
|
+
if self.schema_version not in {
|
|
98
|
+
"fdai.provision-event.v1",
|
|
99
|
+
"fdai.provision-event.v2",
|
|
100
|
+
"fdai.provision-event.v3",
|
|
101
|
+
}:
|
|
102
|
+
raise ValueError("event schema_version is unsupported")
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def digest(self) -> str:
|
|
106
|
+
"""Return the event hash-chain digest."""
|
|
107
|
+
|
|
108
|
+
return canonical_digest(self._body_mapping())
|
|
109
|
+
|
|
110
|
+
def to_mapping(self) -> dict[str, object]:
|
|
111
|
+
"""Return canonical event data."""
|
|
112
|
+
|
|
113
|
+
return {**self._body_mapping(), "event_digest": self.digest}
|
|
114
|
+
|
|
115
|
+
def _body_mapping(self) -> dict[str, object]:
|
|
116
|
+
body: dict[str, object] = {
|
|
117
|
+
"schema_version": self.schema_version,
|
|
118
|
+
"run_id": self.run_id,
|
|
119
|
+
"context_digest": self.context_digest,
|
|
120
|
+
"sequence": self.sequence,
|
|
121
|
+
"stage": self.stage,
|
|
122
|
+
"attempt": self.attempt,
|
|
123
|
+
"state": self.state.value,
|
|
124
|
+
"occurred_at": self.occurred_at,
|
|
125
|
+
"previous_digest": self.previous_digest,
|
|
126
|
+
"reason_code": self.reason_code,
|
|
127
|
+
}
|
|
128
|
+
if self.schema_version in {"fdai.provision-event.v2", "fdai.provision-event.v3"}:
|
|
129
|
+
body["manifest_version"] = self.manifest_version
|
|
130
|
+
if self.schema_version == "fdai.provision-event.v3":
|
|
131
|
+
body["evidence_digest"] = self.evidence_digest
|
|
132
|
+
return body
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def from_mapping(cls, value: dict[str, object]) -> ProvisionEvent:
|
|
136
|
+
"""Decode one strict journal event."""
|
|
137
|
+
|
|
138
|
+
common = {
|
|
139
|
+
"schema_version",
|
|
140
|
+
"run_id",
|
|
141
|
+
"context_digest",
|
|
142
|
+
"sequence",
|
|
143
|
+
"stage",
|
|
144
|
+
"attempt",
|
|
145
|
+
"state",
|
|
146
|
+
"occurred_at",
|
|
147
|
+
"previous_digest",
|
|
148
|
+
"reason_code",
|
|
149
|
+
"event_digest",
|
|
150
|
+
}
|
|
151
|
+
schema_version = value.get("schema_version")
|
|
152
|
+
if schema_version == "fdai.provision-event.v1":
|
|
153
|
+
expected = common
|
|
154
|
+
manifest_version = _LEGACY_EVENT_MANIFEST_VERSION
|
|
155
|
+
elif schema_version == "fdai.provision-event.v2":
|
|
156
|
+
expected = common | {"manifest_version"}
|
|
157
|
+
manifest_version = _required_text(value, "manifest_version")
|
|
158
|
+
evidence_digest = None
|
|
159
|
+
elif schema_version == "fdai.provision-event.v3":
|
|
160
|
+
expected = common | {"manifest_version", "evidence_digest"}
|
|
161
|
+
manifest_version = _required_text(value, "manifest_version")
|
|
162
|
+
evidence = value["evidence_digest"]
|
|
163
|
+
if evidence is not None and not isinstance(evidence, str):
|
|
164
|
+
raise ValueError("event evidence_digest MUST be a string or null")
|
|
165
|
+
evidence_digest = evidence
|
|
166
|
+
else:
|
|
167
|
+
raise ValueError("provision event schema does not match")
|
|
168
|
+
if schema_version == "fdai.provision-event.v1":
|
|
169
|
+
evidence_digest = None
|
|
170
|
+
if set(value) != expected:
|
|
171
|
+
raise ValueError("provision event schema does not match")
|
|
172
|
+
reason = value["reason_code"]
|
|
173
|
+
if reason is not None and not isinstance(reason, str):
|
|
174
|
+
raise ValueError("event reason_code MUST be a string or null")
|
|
175
|
+
event = cls(
|
|
176
|
+
run_id=_required_text(value, "run_id"),
|
|
177
|
+
context_digest=_required_text(value, "context_digest"),
|
|
178
|
+
sequence=_required_int(value, "sequence"),
|
|
179
|
+
stage=_required_text(value, "stage"),
|
|
180
|
+
attempt=_required_int(value, "attempt"),
|
|
181
|
+
state=RunState(_required_text(value, "state")),
|
|
182
|
+
occurred_at=_required_text(value, "occurred_at"),
|
|
183
|
+
previous_digest=_required_text(value, "previous_digest"),
|
|
184
|
+
reason_code=reason,
|
|
185
|
+
evidence_digest=evidence_digest,
|
|
186
|
+
manifest_version=manifest_version,
|
|
187
|
+
schema_version=schema_version,
|
|
188
|
+
)
|
|
189
|
+
if value["event_digest"] != event.digest:
|
|
190
|
+
raise ValueError("provision event digest does not match")
|
|
191
|
+
return event
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def append_event(path: Path, event: ProvisionEvent) -> None:
|
|
195
|
+
"""Append and fsync one event after checking sequence and hash continuity."""
|
|
196
|
+
|
|
197
|
+
if event.schema_version != "fdai.provision-event.v3":
|
|
198
|
+
raise ValueError("new provision events MUST use schema v3")
|
|
199
|
+
directory = _open_journal_directory(path, create=True)
|
|
200
|
+
try:
|
|
201
|
+
descriptor = os.open(
|
|
202
|
+
path.name,
|
|
203
|
+
os.O_RDWR | os.O_CREAT | os.O_APPEND | os.O_NOFOLLOW,
|
|
204
|
+
0o600,
|
|
205
|
+
dir_fd=directory,
|
|
206
|
+
)
|
|
207
|
+
finally:
|
|
208
|
+
os.close(directory)
|
|
209
|
+
with os.fdopen(descriptor, "a+b") as stream:
|
|
210
|
+
_validate_journal_descriptor(stream.fileno())
|
|
211
|
+
_acquire_exclusive_lock(stream.fileno())
|
|
212
|
+
details = _validate_journal_descriptor(stream.fileno())
|
|
213
|
+
events = _read_stream(stream)
|
|
214
|
+
if any(event.schema_version != "fdai.provision-event.v3" for event in events):
|
|
215
|
+
raise ValueError("legacy provision journals are replay-only")
|
|
216
|
+
previous = events[-1] if events else None
|
|
217
|
+
expected_sequence = 1 if previous is None else previous.sequence + 1
|
|
218
|
+
expected_digest = GENESIS_HASH if previous is None else previous.digest
|
|
219
|
+
if event.sequence != expected_sequence or event.previous_digest != expected_digest:
|
|
220
|
+
raise ValueError("provision event does not continue the journal")
|
|
221
|
+
if previous is not None and event.run_id != previous.run_id:
|
|
222
|
+
raise ValueError("provision event run_id does not match the journal")
|
|
223
|
+
if previous is not None and event.context_digest != previous.context_digest:
|
|
224
|
+
raise ValueError("provision event context does not match the journal")
|
|
225
|
+
if previous is not None and event.manifest_version != previous.manifest_version:
|
|
226
|
+
raise ValueError("provision event manifest version does not match the journal")
|
|
227
|
+
_validate_ready_history(events, event)
|
|
228
|
+
_validate_transition(events, previous, event)
|
|
229
|
+
payload = json.dumps(
|
|
230
|
+
event.to_mapping(),
|
|
231
|
+
sort_keys=True,
|
|
232
|
+
separators=(",", ":"),
|
|
233
|
+
ensure_ascii=True,
|
|
234
|
+
).encode()
|
|
235
|
+
if details.st_size + len(payload) + 1 > _MAX_JOURNAL_BYTES:
|
|
236
|
+
raise ValueError("provision journal append exceeds its size limit")
|
|
237
|
+
stream.seek(0, os.SEEK_END)
|
|
238
|
+
stream.write(payload + b"\n")
|
|
239
|
+
stream.flush()
|
|
240
|
+
os.fsync(stream.fileno())
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _acquire_exclusive_lock(
|
|
244
|
+
descriptor: int, *, timeout_seconds: float = _LOCK_TIMEOUT_SECONDS
|
|
245
|
+
) -> None:
|
|
246
|
+
deadline = time.monotonic() + timeout_seconds
|
|
247
|
+
while True:
|
|
248
|
+
try:
|
|
249
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
250
|
+
return
|
|
251
|
+
except BlockingIOError as exc:
|
|
252
|
+
remaining = deadline - time.monotonic()
|
|
253
|
+
if remaining <= 0:
|
|
254
|
+
raise TimeoutError("provision journal lock timed out") from exc
|
|
255
|
+
time.sleep(min(_LOCK_RETRY_SECONDS, remaining))
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _validate_journal_descriptor(descriptor: int) -> os.stat_result:
|
|
259
|
+
details = os.fstat(descriptor)
|
|
260
|
+
if not stat.S_ISREG(details.st_mode) or stat.S_IMODE(details.st_mode) != 0o600:
|
|
261
|
+
raise PermissionError("provision journal MUST be a mode-0600 regular file")
|
|
262
|
+
if details.st_size > _MAX_JOURNAL_BYTES:
|
|
263
|
+
raise ValueError("provision journal exceeds its size limit")
|
|
264
|
+
return details
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def read_journal(path: Path) -> tuple[ProvisionEvent, ...]:
|
|
268
|
+
"""Read and verify a complete local journal."""
|
|
269
|
+
|
|
270
|
+
directory = _open_journal_directory(path, create=False)
|
|
271
|
+
try:
|
|
272
|
+
descriptor = os.open(
|
|
273
|
+
path.name,
|
|
274
|
+
os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK,
|
|
275
|
+
dir_fd=directory,
|
|
276
|
+
)
|
|
277
|
+
finally:
|
|
278
|
+
os.close(directory)
|
|
279
|
+
with os.fdopen(descriptor, "rb") as stream:
|
|
280
|
+
details = os.fstat(stream.fileno())
|
|
281
|
+
if not stat.S_ISREG(details.st_mode) or stat.S_IMODE(details.st_mode) != 0o600:
|
|
282
|
+
raise PermissionError("provision journal MUST be a mode-0600 regular file")
|
|
283
|
+
if details.st_size > _MAX_JOURNAL_BYTES:
|
|
284
|
+
raise ValueError("provision journal exceeds its size limit")
|
|
285
|
+
return _read_stream(stream)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _open_journal_directory(path: Path, *, create: bool) -> int:
|
|
289
|
+
if path.name in {"", ".", ".."}:
|
|
290
|
+
raise ValueError("provision journal filename is invalid")
|
|
291
|
+
parent = path.parent
|
|
292
|
+
descriptor = os.open(
|
|
293
|
+
parent.anchor or ".",
|
|
294
|
+
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
|
|
295
|
+
)
|
|
296
|
+
parts = parent.parts[1:] if parent.anchor else parent.parts
|
|
297
|
+
try:
|
|
298
|
+
for part in parts:
|
|
299
|
+
if part in {"", "."}:
|
|
300
|
+
continue
|
|
301
|
+
if part == "..":
|
|
302
|
+
raise ValueError("provision journal path MUST NOT traverse parent directories")
|
|
303
|
+
if create:
|
|
304
|
+
try:
|
|
305
|
+
os.mkdir(part, mode=0o700, dir_fd=descriptor)
|
|
306
|
+
except FileExistsError:
|
|
307
|
+
pass
|
|
308
|
+
child = os.open(
|
|
309
|
+
part,
|
|
310
|
+
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
|
|
311
|
+
dir_fd=descriptor,
|
|
312
|
+
)
|
|
313
|
+
os.close(descriptor)
|
|
314
|
+
descriptor = child
|
|
315
|
+
details = os.fstat(descriptor)
|
|
316
|
+
if details.st_uid != os.geteuid() or stat.S_IMODE(details.st_mode) != 0o700:
|
|
317
|
+
raise PermissionError("provision journal directory MUST be current-UID mode 0700")
|
|
318
|
+
return descriptor
|
|
319
|
+
except BaseException:
|
|
320
|
+
os.close(descriptor)
|
|
321
|
+
raise
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def resume_action(*, claim: str, receipt: str, failed: bool) -> ResumeAction:
|
|
325
|
+
"""Return the only legal continuation for an apply claim state."""
|
|
326
|
+
|
|
327
|
+
if claim not in {"absent", "present"} or receipt not in {"absent", "present"}:
|
|
328
|
+
raise ValueError("claim and receipt states are unsupported")
|
|
329
|
+
if receipt == "present" and claim != "present":
|
|
330
|
+
raise ValueError("an apply receipt without a claim is invalid")
|
|
331
|
+
if failed and receipt == "present":
|
|
332
|
+
raise ValueError("a completed apply receipt cannot also be failed")
|
|
333
|
+
if failed:
|
|
334
|
+
return ResumeAction.REVIEW
|
|
335
|
+
if receipt == "present":
|
|
336
|
+
return ResumeAction.COMPLETE
|
|
337
|
+
if claim == "present":
|
|
338
|
+
return ResumeAction.RESUME_VERIFICATION
|
|
339
|
+
return ResumeAction.REPLAN
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _validate_transition(
|
|
343
|
+
events: tuple[ProvisionEvent, ...] | list[ProvisionEvent],
|
|
344
|
+
previous: ProvisionEvent | None,
|
|
345
|
+
current: ProvisionEvent,
|
|
346
|
+
) -> None:
|
|
347
|
+
expected = GENESIS_ENTRY_IDS_BY_VERSION[current.manifest_version]
|
|
348
|
+
if current.stage not in expected:
|
|
349
|
+
raise ValueError("provision event stage is not in the sealed manifest")
|
|
350
|
+
if (
|
|
351
|
+
current.schema_version == "fdai.provision-event.v3"
|
|
352
|
+
and current.state in {RunState.COMPLETED, RunState.READY}
|
|
353
|
+
and current.evidence_digest is None
|
|
354
|
+
):
|
|
355
|
+
raise ValueError(f"{current.state.value} requires receipt evidence")
|
|
356
|
+
if previous is not None and previous.state in {
|
|
357
|
+
RunState.READY,
|
|
358
|
+
RunState.BLOCKED,
|
|
359
|
+
RunState.FAILED,
|
|
360
|
+
RunState.CANCELLED,
|
|
361
|
+
RunState.INCOMPLETE,
|
|
362
|
+
}:
|
|
363
|
+
raise ValueError("terminal provision state cannot advance")
|
|
364
|
+
if current.state is RunState.COMPLETED:
|
|
365
|
+
completed = tuple(event.stage for event in events if event.state is RunState.COMPLETED)
|
|
366
|
+
candidate = (*completed, current.stage)
|
|
367
|
+
if candidate != expected[: len(candidate)]:
|
|
368
|
+
raise ValueError("completed provision stages MUST follow sealed manifest order")
|
|
369
|
+
if current.state is RunState.READY and (
|
|
370
|
+
previous is None
|
|
371
|
+
or previous.state is not RunState.COMPLETED
|
|
372
|
+
or previous.stage != "system-readiness"
|
|
373
|
+
or current.stage != "system-readiness"
|
|
374
|
+
):
|
|
375
|
+
raise ValueError("ready requires completed system-readiness evidence")
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _validate_ready_history(
|
|
379
|
+
events: tuple[ProvisionEvent, ...] | list[ProvisionEvent],
|
|
380
|
+
current: ProvisionEvent,
|
|
381
|
+
) -> None:
|
|
382
|
+
if current.state is not RunState.READY:
|
|
383
|
+
return
|
|
384
|
+
completed = tuple(event.stage for event in events if event.state is RunState.COMPLETED)
|
|
385
|
+
expected = GENESIS_ENTRY_IDS_BY_VERSION[current.manifest_version]
|
|
386
|
+
if completed != expected:
|
|
387
|
+
raise ValueError("ready requires every manifest entry completed in order")
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _read_stream(stream: BinaryIO) -> tuple[ProvisionEvent, ...]:
|
|
391
|
+
stream.seek(0)
|
|
392
|
+
events: list[ProvisionEvent] = []
|
|
393
|
+
previous_digest = GENESIS_HASH
|
|
394
|
+
previous: ProvisionEvent | None = None
|
|
395
|
+
run_id: str | None = None
|
|
396
|
+
context_digest: str | None = None
|
|
397
|
+
manifest_version: str | None = None
|
|
398
|
+
for sequence, line in enumerate(stream, start=1):
|
|
399
|
+
if sequence > _MAX_EVENTS:
|
|
400
|
+
raise ValueError("provision journal exceeds its event count limit")
|
|
401
|
+
event = ProvisionEvent.from_mapping(
|
|
402
|
+
load_json_object(line, label="provision journal event", max_bytes=64 * 1024)
|
|
403
|
+
)
|
|
404
|
+
if event.sequence != sequence or event.previous_digest != previous_digest:
|
|
405
|
+
raise ValueError("provision journal hash chain is invalid")
|
|
406
|
+
if run_id is not None and event.run_id != run_id:
|
|
407
|
+
raise ValueError("provision journal contains multiple run ids")
|
|
408
|
+
if context_digest is not None and event.context_digest != context_digest:
|
|
409
|
+
raise ValueError("provision journal contains multiple contexts")
|
|
410
|
+
if manifest_version is not None and event.manifest_version != manifest_version:
|
|
411
|
+
raise ValueError("provision journal contains multiple manifest versions")
|
|
412
|
+
_validate_ready_history(events, event)
|
|
413
|
+
_validate_transition(events, previous, event)
|
|
414
|
+
run_id = event.run_id
|
|
415
|
+
context_digest = event.context_digest
|
|
416
|
+
manifest_version = event.manifest_version
|
|
417
|
+
previous_digest = event.digest
|
|
418
|
+
previous = event
|
|
419
|
+
events.append(event)
|
|
420
|
+
return tuple(events)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _required_text(value: dict[str, object], field: str) -> str:
|
|
424
|
+
item = value[field]
|
|
425
|
+
if not isinstance(item, str):
|
|
426
|
+
raise ValueError(f"event {field} MUST be a string")
|
|
427
|
+
return item
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _required_int(value: dict[str, object], field: str) -> int:
|
|
431
|
+
item = value[field]
|
|
432
|
+
if isinstance(item, bool) or not isinstance(item, int):
|
|
433
|
+
raise ValueError(f"event {field} MUST be an integer")
|
|
434
|
+
return item
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Private Terraform state comparison, without migration or backend authority."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from fdai_deployment_cli.cli_help import command
|
|
10
|
+
from fdai_deployment_cli.contracts import canonical_digest
|
|
11
|
+
from fdai_deployment_cli.plan_input import read_plan_input
|
|
12
|
+
from fdai_deployment_cli.private_output import write_private_output
|
|
13
|
+
|
|
14
|
+
_MAX_DEPTH = 32
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def compare_foundation_state(
|
|
18
|
+
local: dict[str, object], remote: dict[str, object], plan: dict[str, object]
|
|
19
|
+
) -> dict[str, object]:
|
|
20
|
+
"""Compare state copies and the remote plan's prior managed identities.
|
|
21
|
+
|
|
22
|
+
Inputs must come from a protected, independently observed state handoff.
|
|
23
|
+
Equality is not evidence of their origin, lease, storage protection, or
|
|
24
|
+
authorization. This function never permits deletion of the recovery cache.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
local_identities = _state_identities(local)
|
|
28
|
+
remote_identities = _state_identities(remote)
|
|
29
|
+
local_serial = _state_serial(local)
|
|
30
|
+
remote_serial = _state_serial(remote)
|
|
31
|
+
if local["lineage"] != remote["lineage"] or remote_serial not in {
|
|
32
|
+
local_serial,
|
|
33
|
+
local_serial + 1,
|
|
34
|
+
}:
|
|
35
|
+
raise ValueError("foundation state lineage or serial differs after handoff")
|
|
36
|
+
if local_identities != remote_identities:
|
|
37
|
+
raise ValueError("foundation state resource identities differ after handoff")
|
|
38
|
+
local_content = {
|
|
39
|
+
key: value for key, value in local.items() if key not in {"serial", "check_results"}
|
|
40
|
+
}
|
|
41
|
+
remote_content = {
|
|
42
|
+
key: value for key, value in remote.items() if key not in {"serial", "check_results"}
|
|
43
|
+
}
|
|
44
|
+
if local_content != remote_content or _check_result_digests(local) != _check_result_digests(
|
|
45
|
+
remote
|
|
46
|
+
):
|
|
47
|
+
raise ValueError("foundation state content differs after handoff")
|
|
48
|
+
local_digest = canonical_digest(local)
|
|
49
|
+
version = plan.get("format_version")
|
|
50
|
+
if (
|
|
51
|
+
not isinstance(version, str)
|
|
52
|
+
or version not in {"1.1", "1.2"}
|
|
53
|
+
or plan.get("complete") is not True
|
|
54
|
+
or plan.get("errored") is not False
|
|
55
|
+
or plan.get("applyable") is not False
|
|
56
|
+
):
|
|
57
|
+
raise ValueError("foundation handoff requires a complete non-errored no-change plan")
|
|
58
|
+
no_change_after: dict[str, dict[str, object]] = {}
|
|
59
|
+
for item in _array(plan.get("resource_changes", [])):
|
|
60
|
+
resource_change = _object(item)
|
|
61
|
+
if "previous_address" in resource_change:
|
|
62
|
+
raise ValueError("foundation handoff plan contains an address move")
|
|
63
|
+
address = _text(resource_change.get("address"))
|
|
64
|
+
if address in no_change_after:
|
|
65
|
+
raise ValueError("foundation handoff plan contains duplicate resource changes")
|
|
66
|
+
resource_delta = _object(resource_change.get("change"))
|
|
67
|
+
_no_change(resource_delta)
|
|
68
|
+
no_change_after[address] = _object(resource_delta["after"])
|
|
69
|
+
for item in _array(plan.get("resource_drift", [])):
|
|
70
|
+
_refresh_only_drift(_object(item), no_change_after)
|
|
71
|
+
if _array(plan.get("deferred_changes", [])):
|
|
72
|
+
raise ValueError("foundation handoff plan contains deferred changes")
|
|
73
|
+
for output_change in _object(plan.get("output_changes", {})).values():
|
|
74
|
+
_no_change(output_change)
|
|
75
|
+
for check in _array(plan.get("checks", [])):
|
|
76
|
+
if _object(check).get("status") != "pass":
|
|
77
|
+
raise ValueError("foundation handoff plan checks must pass")
|
|
78
|
+
prior = _object(_object(plan.get("prior_state")).get("values"))
|
|
79
|
+
prior_identities: dict[str, str] = {}
|
|
80
|
+
_plan_identities(_object(prior.get("root_module")), prior_identities, depth=0)
|
|
81
|
+
if prior_identities != remote_identities:
|
|
82
|
+
raise ValueError("foundation handoff plan does not describe the compared remote state")
|
|
83
|
+
return {
|
|
84
|
+
"schema_version": "fdai.foundation-state-comparison.v1",
|
|
85
|
+
"state": "review",
|
|
86
|
+
"comparison_verified": True,
|
|
87
|
+
"state_digest": local_digest,
|
|
88
|
+
"plan_digest": canonical_digest(plan),
|
|
89
|
+
"managed_resource_count": len(remote_identities),
|
|
90
|
+
"remote_backend_authority_verified": False,
|
|
91
|
+
"local_state_deletion_authorized": False,
|
|
92
|
+
"mutation_performed": False,
|
|
93
|
+
"subscription_ready": False,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _check_result_digests(state: dict[str, object]) -> tuple[str, ...]:
|
|
98
|
+
return tuple(
|
|
99
|
+
sorted(canonical_digest(_object(item)) for item in _array(state.get("check_results", [])))
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _object(value: object) -> dict[str, object]:
|
|
104
|
+
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
|
|
105
|
+
raise ValueError("foundation handoff contains an invalid JSON object")
|
|
106
|
+
return {key: item for key, item in value.items()}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _array(value: object) -> list[object]:
|
|
110
|
+
if not isinstance(value, list) or len(value) > 10_000:
|
|
111
|
+
raise ValueError("foundation handoff contains an invalid JSON array")
|
|
112
|
+
return list(value)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _text(value: object) -> str:
|
|
116
|
+
if not isinstance(value, str) or not value or len(value) > 4096:
|
|
117
|
+
raise ValueError("foundation handoff contains an invalid resource identity")
|
|
118
|
+
return value
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _no_change(value: object) -> None:
|
|
122
|
+
change = _object(value)
|
|
123
|
+
if (
|
|
124
|
+
"before" not in change
|
|
125
|
+
or "after" not in change
|
|
126
|
+
or change.get("actions") != ["no-op"]
|
|
127
|
+
or change.get("importing") is not None
|
|
128
|
+
or change.get("before") != change.get("after")
|
|
129
|
+
):
|
|
130
|
+
raise ValueError("foundation handoff plan contains a change")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _refresh_only_drift(
|
|
134
|
+
drift: dict[str, object], no_change_after: dict[str, dict[str, object]]
|
|
135
|
+
) -> None:
|
|
136
|
+
if "previous_address" in drift:
|
|
137
|
+
raise ValueError("foundation handoff drift contains an address move")
|
|
138
|
+
address = _text(drift.get("address"))
|
|
139
|
+
change = _object(drift.get("change"))
|
|
140
|
+
before = _object(change.get("before"))
|
|
141
|
+
after = _object(change.get("after"))
|
|
142
|
+
if (
|
|
143
|
+
change.get("actions") != ["update"]
|
|
144
|
+
or change.get("importing") is not None
|
|
145
|
+
or before == after
|
|
146
|
+
or before.get("id") != after.get("id")
|
|
147
|
+
or no_change_after.get(address) != after
|
|
148
|
+
):
|
|
149
|
+
raise ValueError("foundation handoff plan contains unclosed resource drift")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _add_identity(identities: dict[str, str], address: str, resource_id: object) -> None:
|
|
153
|
+
if address in identities:
|
|
154
|
+
raise ValueError("foundation handoff contains duplicate managed identities")
|
|
155
|
+
identities[address] = _text(resource_id)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _state_identities(state: dict[str, object]) -> dict[str, str]:
|
|
159
|
+
if (
|
|
160
|
+
type(state.get("version")) is not int
|
|
161
|
+
or state["version"] != 4
|
|
162
|
+
or not isinstance(state.get("lineage"), str)
|
|
163
|
+
or not state["lineage"]
|
|
164
|
+
):
|
|
165
|
+
raise ValueError("foundation handoff requires version-4 state with lineage and serial")
|
|
166
|
+
_state_serial(state)
|
|
167
|
+
identities: dict[str, str] = {}
|
|
168
|
+
for entry in _array(state.get("resources")):
|
|
169
|
+
resource = _object(entry)
|
|
170
|
+
if resource.get("mode") == "data":
|
|
171
|
+
continue
|
|
172
|
+
if resource.get("mode") != "managed":
|
|
173
|
+
raise ValueError("foundation state resource mode is invalid")
|
|
174
|
+
base = _text(resource.get("type")) + "." + _text(resource.get("name"))
|
|
175
|
+
if "module" in resource:
|
|
176
|
+
base = _text(resource["module"]) + "." + base
|
|
177
|
+
for entry in _array(resource.get("instances")):
|
|
178
|
+
instance = _object(entry)
|
|
179
|
+
if instance.get("status") is not None or instance.get("deposed") is not None:
|
|
180
|
+
raise ValueError("foundation state contains a tainted or deposed instance")
|
|
181
|
+
index = instance.get("index_key")
|
|
182
|
+
if index is None:
|
|
183
|
+
suffix = ""
|
|
184
|
+
elif isinstance(index, str):
|
|
185
|
+
suffix = "[" + json.dumps(index, ensure_ascii=False) + "]"
|
|
186
|
+
elif type(index) is int and index >= 0:
|
|
187
|
+
suffix = f"[{index}]"
|
|
188
|
+
else:
|
|
189
|
+
raise ValueError("foundation state instance index is invalid")
|
|
190
|
+
_add_identity(identities, base + suffix, _object(instance.get("attributes")).get("id"))
|
|
191
|
+
if not identities:
|
|
192
|
+
raise ValueError("foundation handoff cannot verify empty managed state")
|
|
193
|
+
return identities
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _state_serial(state: dict[str, object]) -> int:
|
|
197
|
+
serial = state.get("serial")
|
|
198
|
+
if type(serial) is not int or serial < 0:
|
|
199
|
+
raise ValueError("foundation handoff requires version-4 state with lineage and serial")
|
|
200
|
+
return serial
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _plan_identities(module: dict[str, object], identities: dict[str, str], *, depth: int) -> None:
|
|
204
|
+
if depth > _MAX_DEPTH:
|
|
205
|
+
raise ValueError("foundation handoff plan exceeds the module depth limit")
|
|
206
|
+
for entry in _array(module.get("resources", [])):
|
|
207
|
+
resource = _object(entry)
|
|
208
|
+
if resource.get("mode") == "data":
|
|
209
|
+
continue
|
|
210
|
+
if resource.get("mode") != "managed":
|
|
211
|
+
raise ValueError("foundation plan resource mode is invalid")
|
|
212
|
+
_add_identity(
|
|
213
|
+
identities,
|
|
214
|
+
_text(resource.get("address")),
|
|
215
|
+
_object(resource.get("values")).get("id"),
|
|
216
|
+
)
|
|
217
|
+
for child in _array(module.get("child_modules", [])):
|
|
218
|
+
_plan_identities(_object(child), identities, depth=depth + 1)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def register_state_handoff_command(
|
|
222
|
+
commands: argparse._SubParsersAction[argparse.ArgumentParser],
|
|
223
|
+
) -> None:
|
|
224
|
+
"""Register a file-only comparison command; no Azure or Terraform execution."""
|
|
225
|
+
|
|
226
|
+
parser = command(
|
|
227
|
+
commands,
|
|
228
|
+
"verify-state-handoff",
|
|
229
|
+
"Compare local state copies and a zero-change plan; no migration",
|
|
230
|
+
)
|
|
231
|
+
parser.add_argument("--local-state", type=Path, required=True)
|
|
232
|
+
parser.add_argument("--remote-state", type=Path, required=True)
|
|
233
|
+
parser.add_argument("--plan-json", type=Path, required=True)
|
|
234
|
+
parser.add_argument("--output-receipt", type=Path, required=True)
|
|
235
|
+
parser.add_argument("--output", choices=("text", "json"), default="text")
|
|
236
|
+
parser.set_defaults(handler=_verify_state_handoff)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _verify_state_handoff(args: argparse.Namespace) -> int:
|
|
240
|
+
result = compare_foundation_state(
|
|
241
|
+
read_plan_input(args.local_state),
|
|
242
|
+
read_plan_input(args.remote_state),
|
|
243
|
+
read_plan_input(args.plan_json),
|
|
244
|
+
)
|
|
245
|
+
write_private_output(
|
|
246
|
+
args.output_receipt, json.dumps(result, sort_keys=True, separators=(",", ":")) + "\n"
|
|
247
|
+
)
|
|
248
|
+
print(
|
|
249
|
+
json.dumps(result, sort_keys=True, separators=(",", ":"))
|
|
250
|
+
if args.output == "json"
|
|
251
|
+
else "state copies match; backend authority and recovery-cache deletion remain unverified"
|
|
252
|
+
)
|
|
253
|
+
return 0
|