fdai-deployment-cli 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. fdai_deployment_cli/__about__.py +3 -0
  2. fdai_deployment_cli/__init__.py +5 -0
  3. fdai_deployment_cli/__main__.py +5 -0
  4. fdai_deployment_cli/acr_publish.py +585 -0
  5. fdai_deployment_cli/aks_catalog_review.py +137 -0
  6. fdai_deployment_cli/aks_historical_reconciliation.py +732 -0
  7. fdai_deployment_cli/aks_job_environment.py +59 -0
  8. fdai_deployment_cli/aks_job_execution.py +794 -0
  9. fdai_deployment_cli/aks_kubernetes_defaults.py +66 -0
  10. fdai_deployment_cli/aks_preflight.py +422 -0
  11. fdai_deployment_cli/aks_readiness.py +220 -0
  12. fdai_deployment_cli/aks_service_update.py +180 -0
  13. fdai_deployment_cli/aks_workload_jobs.py +281 -0
  14. fdai_deployment_cli/application_state_adoption.py +347 -0
  15. fdai_deployment_cli/azure_actor.py +61 -0
  16. fdai_deployment_cli/azure_naming.py +252 -0
  17. fdai_deployment_cli/azure_retail_prices.py +173 -0
  18. fdai_deployment_cli/bootstrap_approval.py +99 -0
  19. fdai_deployment_cli/bootstrap_channel.py +321 -0
  20. fdai_deployment_cli/bootstrap_executor.py +311 -0
  21. fdai_deployment_cli/bootstrap_plan.py +237 -0
  22. fdai_deployment_cli/bootstrap_reconcile.py +402 -0
  23. fdai_deployment_cli/bootstrap_support.py +138 -0
  24. fdai_deployment_cli/bootstrap_worker.py +448 -0
  25. fdai_deployment_cli/bundle.py +352 -0
  26. fdai_deployment_cli/capacity.py +143 -0
  27. fdai_deployment_cli/catalog_review_profile.py +306 -0
  28. fdai_deployment_cli/cli.py +798 -0
  29. fdai_deployment_cli/cli_help.py +214 -0
  30. fdai_deployment_cli/cli_parser.py +791 -0
  31. fdai_deployment_cli/cli_plan.py +450 -0
  32. fdai_deployment_cli/compiler.py +99 -0
  33. fdai_deployment_cli/console_artifact.py +218 -0
  34. fdai_deployment_cli/console_config.py +214 -0
  35. fdai_deployment_cli/console_update.py +832 -0
  36. fdai_deployment_cli/contracts.py +307 -0
  37. fdai_deployment_cli/control_package.py +140 -0
  38. fdai_deployment_cli/deadline_transport.py +70 -0
  39. fdai_deployment_cli/deployment_cost.py +131 -0
  40. fdai_deployment_cli/deployment_deadline.py +28 -0
  41. fdai_deployment_cli/deployment_kit.py +790 -0
  42. fdai_deployment_cli/deployment_kit_cache.py +191 -0
  43. fdai_deployment_cli/deployment_progress.py +648 -0
  44. fdai_deployment_cli/development_authority_cli.py +56 -0
  45. fdai_deployment_cli/development_authority_profile.py +319 -0
  46. fdai_deployment_cli/doctor.py +136 -0
  47. fdai_deployment_cli/entitlement_preflight.py +68 -0
  48. fdai_deployment_cli/entitlement_transport.py +49 -0
  49. fdai_deployment_cli/entra_bootstrap.py +579 -0
  50. fdai_deployment_cli/entra_control_profile.py +214 -0
  51. fdai_deployment_cli/entra_graph.py +349 -0
  52. fdai_deployment_cli/entra_profiles.py +215 -0
  53. fdai_deployment_cli/entra_source.py +244 -0
  54. fdai_deployment_cli/execution_bundle.py +478 -0
  55. fdai_deployment_cli/execution_copies.py +63 -0
  56. fdai_deployment_cli/foundation_adoption.py +438 -0
  57. fdai_deployment_cli/foundation_adoption_evidence.py +245 -0
  58. fdai_deployment_cli/foundation_adoption_host.py +157 -0
  59. fdai_deployment_cli/foundation_adoption_transport.py +51 -0
  60. fdai_deployment_cli/foundation_failure.py +72 -0
  61. fdai_deployment_cli/foundation_image.py +168 -0
  62. fdai_deployment_cli/foundation_input.py +235 -0
  63. fdai_deployment_cli/foundation_output.py +154 -0
  64. fdai_deployment_cli/foundation_plan.py +381 -0
  65. fdai_deployment_cli/foundation_process.py +64 -0
  66. fdai_deployment_cli/foundation_progress.py +231 -0
  67. fdai_deployment_cli/github_actions.py +171 -0
  68. fdai_deployment_cli/github_apply_artifact.py +259 -0
  69. fdai_deployment_cli/github_artifact_io.py +48 -0
  70. fdai_deployment_cli/github_dispatch.py +248 -0
  71. fdai_deployment_cli/github_plan_artifact.py +181 -0
  72. fdai_deployment_cli/github_provider_artifact.py +170 -0
  73. fdai_deployment_cli/github_transport.py +46 -0
  74. fdai_deployment_cli/github_workflow_values.py +312 -0
  75. fdai_deployment_cli/hub_image_deploy.py +354 -0
  76. fdai_deployment_cli/hub_image_stage.py +162 -0
  77. fdai_deployment_cli/hub_image_trust.py +281 -0
  78. fdai_deployment_cli/hub_ownership.py +161 -0
  79. fdai_deployment_cli/hub_workload_handoff.py +264 -0
  80. fdai_deployment_cli/hub_workload_handoff_inputs.py +227 -0
  81. fdai_deployment_cli/identity_profile.py +227 -0
  82. fdai_deployment_cli/image_targets.py +197 -0
  83. fdai_deployment_cli/installation_scope.py +280 -0
  84. fdai_deployment_cli/license.py +311 -0
  85. fdai_deployment_cli/license_issue.py +239 -0
  86. fdai_deployment_cli/lifecycle_authorization.py +252 -0
  87. fdai_deployment_cli/lifecycle_commands.py +173 -0
  88. fdai_deployment_cli/lifecycle_configuration.py +722 -0
  89. fdai_deployment_cli/lifecycle_configuration_artifact.py +45 -0
  90. fdai_deployment_cli/lifecycle_configuration_cli.py +324 -0
  91. fdai_deployment_cli/lifecycle_configuration_document.py +74 -0
  92. fdai_deployment_cli/lifecycle_configuration_entities.py +146 -0
  93. fdai_deployment_cli/lifecycle_configuration_package.py +492 -0
  94. fdai_deployment_cli/lifecycle_configuration_publication.py +277 -0
  95. fdai_deployment_cli/lifecycle_configuration_publication_cli.py +191 -0
  96. fdai_deployment_cli/lifecycle_configuration_sealing.py +127 -0
  97. fdai_deployment_cli/lifecycle_enrollment.py +45 -0
  98. fdai_deployment_cli/lifecycle_infrastructure_agent.py +174 -0
  99. fdai_deployment_cli/lifecycle_installation_binding.py +65 -0
  100. fdai_deployment_cli/lifecycle_native.py +247 -0
  101. fdai_deployment_cli/lifecycle_native_reconciliation.py +568 -0
  102. fdai_deployment_cli/lifecycle_native_transition.py +339 -0
  103. fdai_deployment_cli/lifecycle_ownership_exclusion.py +78 -0
  104. fdai_deployment_cli/lifecycle_plan.py +800 -0
  105. fdai_deployment_cli/lifecycle_promotion.py +185 -0
  106. fdai_deployment_cli/lifecycle_release.py +308 -0
  107. fdai_deployment_cli/lifecycle_self_upgrade.py +202 -0
  108. fdai_deployment_cli/lifecycle_upgrade_bundle.py +176 -0
  109. fdai_deployment_cli/notification_recipients_input.py +222 -0
  110. fdai_deployment_cli/observer_artifact.py +106 -0
  111. fdai_deployment_cli/oci_archive.py +353 -0
  112. fdai_deployment_cli/offline_kit.py +797 -0
  113. fdai_deployment_cli/offline_prepare.py +135 -0
  114. fdai_deployment_cli/operational_evidence_verifier_input.py +575 -0
  115. fdai_deployment_cli/plan_input.py +101 -0
  116. fdai_deployment_cli/private_output.py +192 -0
  117. fdai_deployment_cli/profile.py +70 -0
  118. fdai_deployment_cli/progress.py +183 -0
  119. fdai_deployment_cli/readiness.py +286 -0
  120. fdai_deployment_cli/run_command_receiver.py +418 -0
  121. fdai_deployment_cli/runtime_build.py +386 -0
  122. fdai_deployment_cli/runtime_profile.py +287 -0
  123. fdai_deployment_cli/runtime_release.py +745 -0
  124. fdai_deployment_cli/runtime_stage.py +82 -0
  125. fdai_deployment_cli/runtime_support_installation.py +215 -0
  126. fdai_deployment_cli/simulation.py +80 -0
  127. fdai_deployment_cli/source_application_inputs.py +397 -0
  128. fdai_deployment_cli/source_azure.py +586 -0
  129. fdai_deployment_cli/source_change_cli.py +107 -0
  130. fdai_deployment_cli/source_change_parser.py +75 -0
  131. fdai_deployment_cli/source_change_plan.py +181 -0
  132. fdai_deployment_cli/source_changed_update.py +196 -0
  133. fdai_deployment_cli/source_deploy.py +170 -0
  134. fdai_deployment_cli/source_foundation.py +174 -0
  135. fdai_deployment_cli/source_host_tools.py +163 -0
  136. fdai_deployment_cli/source_image_stage.py +400 -0
  137. fdai_deployment_cli/source_input.py +173 -0
  138. fdai_deployment_cli/source_projection.py +164 -0
  139. fdai_deployment_cli/source_receiver.py +76 -0
  140. fdai_deployment_cli/source_recovery.py +264 -0
  141. fdai_deployment_cli/source_runtime.py +81 -0
  142. fdai_deployment_cli/source_runtime_support.py +368 -0
  143. fdai_deployment_cli/source_service_update.py +726 -0
  144. fdai_deployment_cli/source_service_update_cli.py +47 -0
  145. fdai_deployment_cli/source_snapshot.py +178 -0
  146. fdai_deployment_cli/source_teardown.py +574 -0
  147. fdai_deployment_cli/source_terraform.py +85 -0
  148. fdai_deployment_cli/source_transport.py +399 -0
  149. fdai_deployment_cli/standalone_aks_inventory.py +558 -0
  150. fdai_deployment_cli/standalone_aks_job_execution.py +769 -0
  151. fdai_deployment_cli/standalone_aks_nodepool_guard.py +141 -0
  152. fdai_deployment_cli/standalone_application.py +977 -0
  153. fdai_deployment_cli/standalone_application_completion.py +151 -0
  154. fdai_deployment_cli/standalone_application_input_recovery.py +107 -0
  155. fdai_deployment_cli/standalone_application_recovery.py +116 -0
  156. fdai_deployment_cli/standalone_approval_input.py +24 -0
  157. fdai_deployment_cli/standalone_catalog_checkpoint.py +122 -0
  158. fdai_deployment_cli/standalone_catalog_review.py +251 -0
  159. fdai_deployment_cli/standalone_checkpoint_failure.py +127 -0
  160. fdai_deployment_cli/standalone_console_publish.py +174 -0
  161. fdai_deployment_cli/standalone_database_principals.py +41 -0
  162. fdai_deployment_cli/standalone_deploy.py +638 -0
  163. fdai_deployment_cli/standalone_development_authority.py +166 -0
  164. fdai_deployment_cli/standalone_foundation_adoption.py +87 -0
  165. fdai_deployment_cli/standalone_foundation_transition.py +767 -0
  166. fdai_deployment_cli/standalone_host.py +4608 -0
  167. fdai_deployment_cli/standalone_host_rebind.py +226 -0
  168. fdai_deployment_cli/standalone_host_residual.py +104 -0
  169. fdai_deployment_cli/standalone_host_state.py +145 -0
  170. fdai_deployment_cli/standalone_host_values.py +563 -0
  171. fdai_deployment_cli/standalone_kubernetes_tools.py +100 -0
  172. fdai_deployment_cli/standalone_license_installation.py +177 -0
  173. fdai_deployment_cli/standalone_management_egress.py +83 -0
  174. fdai_deployment_cli/standalone_migration_evidence.py +38 -0
  175. fdai_deployment_cli/standalone_operational_evidence.py +356 -0
  176. fdai_deployment_cli/standalone_planned_outputs.py +147 -0
  177. fdai_deployment_cli/standalone_private_access_probe.py +100 -0
  178. fdai_deployment_cli/standalone_product_profile.py +110 -0
  179. fdai_deployment_cli/standalone_remote_prepare.py +442 -0
  180. fdai_deployment_cli/standalone_residual_apply.py +540 -0
  181. fdai_deployment_cli/standalone_residual_successor.py +431 -0
  182. fdai_deployment_cli/standalone_review.py +266 -0
  183. fdai_deployment_cli/standalone_service_update_finalization.py +116 -0
  184. fdai_deployment_cli/standalone_service_update_recovery.py +277 -0
  185. fdai_deployment_cli/standalone_stage_targets.py +325 -0
  186. fdai_deployment_cli/standalone_status.py +67 -0
  187. fdai_deployment_cli/standalone_terraform_environment.py +237 -0
  188. fdai_deployment_cli/standalone_transfer_cleanup.py +237 -0
  189. fdai_deployment_cli/standalone_trial_activation.py +193 -0
  190. fdai_deployment_cli/state.py +434 -0
  191. fdai_deployment_cli/state_handoff.py +253 -0
  192. fdai_deployment_cli/status_projection.py +97 -0
  193. fdai_deployment_cli/support_install.py +257 -0
  194. fdai_deployment_cli/target.py +46 -0
  195. fdai_deployment_cli/trust/capabilities.json +62 -0
  196. fdai_deployment_cli/trust/deployment-bundle-root.pub +3 -0
  197. fdai_deployment_cli/trust/deployment-release-root.pub +3 -0
  198. fdai_deployment_cli/trust/upstream-signing-key.pub +3 -0
  199. fdai_deployment_cli/trust_roots.py +50 -0
  200. fdai_deployment_cli-0.1.2.dist-info/METADATA +112 -0
  201. fdai_deployment_cli-0.1.2.dist-info/RECORD +203 -0
  202. fdai_deployment_cli-0.1.2.dist-info/WHEEL +4 -0
  203. fdai_deployment_cli-0.1.2.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,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