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,154 @@
1
+ """Keep Foundation stderr in the existing display without owning subprocess execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import codecs
6
+ import os
7
+ import select
8
+ import sys
9
+ import threading
10
+ import time
11
+ from collections.abc import Iterator
12
+ from contextlib import ExitStack, contextmanager
13
+
14
+ from fdai_deployment_cli.foundation_progress import FoundationCheckpoint, FoundationProgressStream
15
+
16
+ _DRAIN_SECONDS = 1.0
17
+
18
+
19
+ class _Reader:
20
+ """Drain one nonblocking pipe; a display failure cannot backpressure the child forever."""
21
+
22
+ def __init__(self, descriptor: int, stream: FoundationProgressStream) -> None:
23
+ self.descriptor = descriptor
24
+ self.stream = stream
25
+ self.stop = threading.Event()
26
+ self.failure: BaseException | None = None
27
+
28
+ def run(self) -> None:
29
+ decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
30
+ drain_deadline: float | None = None
31
+ try:
32
+ while True:
33
+ if self.stop.is_set():
34
+ if drain_deadline is None:
35
+ drain_deadline = time.monotonic() + _DRAIN_SECONDS
36
+ if time.monotonic() >= drain_deadline:
37
+ self.failure = self.failure or TimeoutError(
38
+ "Foundation output drain expired"
39
+ )
40
+ break
41
+ readable, _, _ = select.select([self.descriptor], [], [], 0.05)
42
+ if not readable:
43
+ if drain_deadline is not None:
44
+ break
45
+ continue
46
+ try:
47
+ content = os.read(self.descriptor, 4096)
48
+ except BlockingIOError:
49
+ continue
50
+ if not content:
51
+ break
52
+ if self.failure is None:
53
+ try:
54
+ self.stream.feed(decoder.decode(content))
55
+ except Exception as exc: # noqa: BLE001 - rethrow in owner after draining the pipe.
56
+ self.failure = exc
57
+ if self.failure is None:
58
+ self.stream.feed(decoder.decode(b"", final=True))
59
+ self.stream.finish()
60
+ except Exception as exc: # noqa: BLE001 - transfer thread failures to the owning context.
61
+ self.failure = self.failure or exc
62
+ finally:
63
+ os.close(self.descriptor)
64
+
65
+
66
+ @contextmanager
67
+ def _capture(stream: FoundationProgressStream) -> Iterator[int]:
68
+ """Yield a child stderr descriptor while retaining the caller's run/timeout/exit policy."""
69
+ read_descriptor, write_descriptor = os.pipe()
70
+ try:
71
+ os.set_blocking(read_descriptor, False)
72
+ reader = _Reader(read_descriptor, stream)
73
+ thread = threading.Thread(target=reader.run, name="fdai-foundation-display", daemon=True)
74
+ thread.start()
75
+ except BaseException:
76
+ os.close(read_descriptor)
77
+ os.close(write_descriptor)
78
+ raise
79
+ primary_error: BaseException | None = None
80
+ try:
81
+ yield write_descriptor
82
+ except BaseException as exc:
83
+ primary_error = exc
84
+ raise
85
+ finally:
86
+ os.close(write_descriptor)
87
+ reader.stop.set()
88
+ thread.join(timeout=_DRAIN_SECONDS + 1)
89
+ failed = thread.is_alive() or reader.failure is not None
90
+ if failed:
91
+ message = "Foundation display output is incomplete; review retained execution evidence"
92
+ if primary_error is not None:
93
+ primary_error.add_note(message)
94
+ else:
95
+ raise ValueError(message) from reader.failure
96
+
97
+
98
+ @contextmanager
99
+ def foundation_output() -> Iterator[int | None]:
100
+ """Compact known presentation only for a live TTY dashboard; preserve all other modes.
101
+
102
+ Unknown output temporarily owns the terminal unchanged. Approvals use their separate existing
103
+ path. This adapter neither launches a process nor reads status, stdin, plans, or credentials.
104
+ """
105
+ from fdai_deployment_cli.deployment_progress import _CURRENT, terminal_output
106
+
107
+ progress = _CURRENT.get()
108
+ if progress is None or not progress.dynamic:
109
+ with terminal_output("Foundation checkpoint details"):
110
+ yield None
111
+ return
112
+
113
+ progress.reset_foundation()
114
+ native = ExitStack()
115
+ native_active = False
116
+
117
+ def close_native() -> None:
118
+ nonlocal native_active
119
+ native.close()
120
+ native_active = False
121
+
122
+ def output(text: str) -> None:
123
+ nonlocal native_active
124
+ if not native_active:
125
+ native.enter_context(progress.terminal_output("Foundation details"))
126
+ native_active = True
127
+ progress.console.file.write(text)
128
+ progress.console.file.flush()
129
+
130
+ def checkpoint(value: FoundationCheckpoint) -> None:
131
+ close_native()
132
+ progress.foundation_checkpoint(value)
133
+
134
+ def mode(value: str) -> None:
135
+ close_native()
136
+ progress.foundation_mode(value)
137
+
138
+ stream = FoundationProgressStream(
139
+ output=output,
140
+ checkpoint=checkpoint,
141
+ heartbeat=progress.foundation_signal,
142
+ mode=mode,
143
+ )
144
+ try:
145
+ with _capture(stream) as descriptor:
146
+ yield descriptor
147
+ finally:
148
+ original_error = sys.exception()
149
+ try:
150
+ close_native()
151
+ except (OSError, ValueError) as exc:
152
+ if original_error is None:
153
+ raise
154
+ original_error.add_note(f"Foundation display cleanup failed: {type(exc).__name__}")
@@ -0,0 +1,381 @@
1
+ """Private saved foundation plans and non-authoritative integrity verification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import re
10
+ import subprocess
11
+ from datetime import UTC, datetime, timedelta
12
+ from pathlib import Path
13
+
14
+ from fdai_deployment_cli.cli_help import command
15
+ from fdai_deployment_cli.contracts import (
16
+ ProvisionProfile,
17
+ canonical_bytes,
18
+ canonical_digest,
19
+ load_json_object,
20
+ )
21
+ from fdai_deployment_cli.private_output import (
22
+ read_private_bytes,
23
+ write_private_bytes,
24
+ write_private_output,
25
+ )
26
+ from fdai_deployment_cli.profile import load_profile
27
+
28
+ PLAN_NAME = "foundation.tfplan"
29
+ REVIEW_NAME = "foundation-plan.json"
30
+ _MAX_PLAN_BYTES = 64 * 1024 * 1024
31
+ _MAX_JSON_BYTES = 16 * 1024 * 1024
32
+ _SCHEMA = "fdai.foundation-saved-plan.v1"
33
+ SOURCE_REVIEW_SCHEMA = "fdai.foundation-saved-source-plan.v1"
34
+ _SOURCE_SCHEMA = SOURCE_REVIEW_SCHEMA
35
+ _DIGEST = re.compile(r"[0-9a-f]{64}")
36
+ _CONTEXT_KEYS = {
37
+ "offline_manifest_digest",
38
+ "deployment_bundle_digest",
39
+ "profile_digest",
40
+ "target_binding",
41
+ "variables_digest",
42
+ "terraform_digest",
43
+ "provider_lock_digest",
44
+ "runner_image_observation_digest",
45
+ "run_digest",
46
+ "foundation_context_digest",
47
+ "source_commit",
48
+ }
49
+ _AUTHORITY = {
50
+ "apply_authorized": False,
51
+ "source_eligibility_verified": False,
52
+ "plan_origin_verified": False,
53
+ "subscription_ready": False,
54
+ "mutation_performed": False,
55
+ }
56
+ _SOURCE_CONTEXT_KEYS = (_CONTEXT_KEYS - {"offline_manifest_digest", "deployment_bundle_digest"}) | {
57
+ "source_input_digest",
58
+ "source_snapshot_digest",
59
+ }
60
+
61
+
62
+ def foundation_plan_context(
63
+ *,
64
+ profile: ProvisionProfile,
65
+ variables: dict[str, object],
66
+ offline_manifest_digest: str,
67
+ deployment_bundle_digest: str,
68
+ terraform_digest: str,
69
+ provider_lock: bytes,
70
+ runner_image_observation_digest: str,
71
+ ) -> dict[str, object]:
72
+ """Bind verified artifact metadata to normalized variables, not source eligibility."""
73
+
74
+ result = {
75
+ "offline_manifest_digest": offline_manifest_digest,
76
+ "deployment_bundle_digest": deployment_bundle_digest,
77
+ "profile_digest": canonical_digest(profile.to_mapping()),
78
+ "target_binding": profile.target_binding,
79
+ "variables_digest": canonical_digest(variables),
80
+ "terraform_digest": terraform_digest,
81
+ "provider_lock_digest": hashlib.sha256(provider_lock).hexdigest(),
82
+ "runner_image_observation_digest": runner_image_observation_digest,
83
+ "source_commit": variables["source_commit"],
84
+ "run_digest": variables["run_digest"],
85
+ "foundation_context_digest": variables["foundation_context_digest"],
86
+ }
87
+ _validate_context(result)
88
+ return result
89
+
90
+
91
+ def source_foundation_plan_context(
92
+ *,
93
+ profile: ProvisionProfile,
94
+ variables: dict[str, object],
95
+ source_input_digest: str,
96
+ source_snapshot_digest: str,
97
+ terraform_digest: str,
98
+ provider_lock: bytes,
99
+ runner_image_observation_digest: str,
100
+ ) -> dict[str, object]:
101
+ """Bind selected source bytes without fabricating signed-kit provenance."""
102
+ if profile.environment != "dev" or profile.connectivity != "online":
103
+ raise ValueError("source Foundation plans require connected development")
104
+ result = {
105
+ "source_input_digest": source_input_digest,
106
+ "source_snapshot_digest": source_snapshot_digest,
107
+ "profile_digest": canonical_digest(profile.to_mapping()),
108
+ "target_binding": profile.target_binding,
109
+ "variables_digest": canonical_digest(variables),
110
+ "terraform_digest": terraform_digest,
111
+ "provider_lock_digest": hashlib.sha256(provider_lock).hexdigest(),
112
+ "runner_image_observation_digest": runner_image_observation_digest,
113
+ "source_commit": variables["source_commit"],
114
+ "run_digest": variables["run_digest"],
115
+ "foundation_context_digest": variables["foundation_context_digest"],
116
+ }
117
+ _validate_context(result)
118
+ return result
119
+
120
+
121
+ def save_foundation_plan(
122
+ *,
123
+ plan: Path,
124
+ destination: Path,
125
+ terraform: Path,
126
+ root: Path,
127
+ environment: dict[str, str],
128
+ context: dict[str, object],
129
+ variables: dict[str, object],
130
+ ) -> dict[str, object]:
131
+ """Validate the exact saved plan and publish private bytes plus a review receipt.
132
+
133
+ The caller owns a new private destination and a temporary private plan directory.
134
+ No provider values reach the receipt. Source SHA and local time are declarations,
135
+ not provenance or authenticated approval-time evidence. No apply is performed.
136
+ """
137
+
138
+ _validate_context(context)
139
+ if context["variables_digest"] != canonical_digest(variables):
140
+ raise ValueError("foundation plan variables do not match the saved context")
141
+ payload = read_private_bytes(plan, max_bytes=_MAX_PLAN_BYTES)
142
+ projection = plan.parent / "plan.json"
143
+ descriptor = os.open(projection, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
144
+ with os.fdopen(descriptor, "wb") as stream:
145
+ shown = subprocess.run(
146
+ [str(terraform), "show", "-json", str(plan)],
147
+ cwd=root,
148
+ env=environment,
149
+ stdout=stream,
150
+ stderr=subprocess.DEVNULL,
151
+ check=False,
152
+ timeout=300,
153
+ umask=0o077,
154
+ )
155
+ if shown.returncode != 0:
156
+ raise ValueError("saved foundation plan could not be inspected")
157
+ projected = read_private_bytes(projection, max_bytes=_MAX_JSON_BYTES)
158
+ details = load_json_object(projected, label="saved foundation plan", max_bytes=_MAX_JSON_BYTES)
159
+ version = _validate_projection(details, variables)
160
+ if read_private_bytes(plan, max_bytes=_MAX_PLAN_BYTES) != payload:
161
+ raise ValueError("saved foundation plan changed during inspection")
162
+ now = datetime.now(UTC).replace(microsecond=0)
163
+ receipt: dict[str, object] = {
164
+ "schema_version": _SOURCE_SCHEMA if set(context) == _SOURCE_CONTEXT_KEYS else _SCHEMA,
165
+ "state": "review",
166
+ **_AUTHORITY,
167
+ "context": context,
168
+ "plan_digest": hashlib.sha256(payload).hexdigest(),
169
+ "plan_json_digest": hashlib.sha256(projected).hexdigest(),
170
+ "summary": _plan_summary(details),
171
+ "terraform_version": version,
172
+ "created_at": now.isoformat(),
173
+ "expires_at": (now + timedelta(hours=1)).isoformat(),
174
+ }
175
+ receipt["review_digest"] = canonical_digest(receipt)
176
+ output = destination / PLAN_NAME
177
+ write_private_bytes(output, payload)
178
+ try:
179
+ write_private_output(
180
+ destination / REVIEW_NAME, (canonical_bytes(receipt) + b"\n").decode("utf-8")
181
+ )
182
+ except (OSError, ValueError):
183
+ output.unlink()
184
+ raise
185
+ return receipt
186
+
187
+
188
+ def verify_foundation_plan(
189
+ *,
190
+ directory: Path,
191
+ profile: ProvisionProfile,
192
+ expected_review_digest: str,
193
+ require_unexpired: bool = True,
194
+ ) -> dict[str, object]:
195
+ """Check private saved bytes against a separately retained digest; never approve apply."""
196
+
197
+ if _DIGEST.fullmatch(expected_review_digest) is None:
198
+ raise ValueError("expected foundation review digest is invalid")
199
+ receipt = load_json_object(
200
+ read_private_bytes(directory / REVIEW_NAME, max_bytes=65_536),
201
+ label="foundation review",
202
+ )
203
+ expected_keys = {
204
+ "schema_version",
205
+ "state",
206
+ "context",
207
+ "plan_digest",
208
+ "plan_json_digest",
209
+ "terraform_version",
210
+ "created_at",
211
+ "expires_at",
212
+ "review_digest",
213
+ *_AUTHORITY,
214
+ }
215
+ optional_keys = {"summary"}
216
+ if (
217
+ not expected_keys <= set(receipt) <= expected_keys | optional_keys
218
+ or receipt["schema_version"] not in {_SCHEMA, _SOURCE_SCHEMA}
219
+ or receipt["state"] != "review"
220
+ or any(receipt[key] is not value for key, value in _AUTHORITY.items())
221
+ ):
222
+ raise ValueError("foundation review schema or authority is invalid")
223
+ digest = receipt.pop("review_digest")
224
+ if digest != expected_review_digest or canonical_digest(receipt) != expected_review_digest:
225
+ raise ValueError("foundation review digest does not match")
226
+ context = receipt["context"]
227
+ expected_context_keys = (
228
+ _SOURCE_CONTEXT_KEYS if receipt["schema_version"] == _SOURCE_SCHEMA else _CONTEXT_KEYS
229
+ )
230
+ if not isinstance(context, dict) or set(context) != expected_context_keys:
231
+ raise ValueError("foundation review context is invalid")
232
+ _validate_context(context)
233
+ if (
234
+ context["profile_digest"] != canonical_digest(profile.to_mapping())
235
+ or context["target_binding"] != profile.target_binding
236
+ ):
237
+ raise ValueError("foundation review does not match the profile")
238
+ _validate_review_time(receipt, require_unexpired=require_unexpired)
239
+ payload = read_private_bytes(directory / PLAN_NAME, max_bytes=_MAX_PLAN_BYTES)
240
+ if hashlib.sha256(payload).hexdigest() != receipt["plan_digest"]:
241
+ raise ValueError("saved foundation plan digest does not match")
242
+ return {
243
+ "schema_version": "fdai.foundation-plan-integrity.v1",
244
+ "state": "review",
245
+ "review_digest": expected_review_digest,
246
+ "plan_digest": receipt["plan_digest"],
247
+ "integrity_verified": True,
248
+ **_AUTHORITY,
249
+ }
250
+
251
+
252
+ def _validate_context(context: dict[str, object]) -> None:
253
+ if set(context) not in (_CONTEXT_KEYS, _SOURCE_CONTEXT_KEYS):
254
+ raise ValueError("foundation plan context fields are invalid")
255
+ for key, value in context.items():
256
+ pattern = r"[0-9a-f]{40}" if key == "source_commit" else r"[0-9a-f]{64}"
257
+ if not isinstance(value, str) or re.fullmatch(pattern, value) is None:
258
+ raise ValueError("foundation plan context binding is invalid")
259
+
260
+
261
+ def _validate_projection(details: dict[str, object], variables: dict[str, object]) -> str:
262
+ if (
263
+ details.get("format_version") not in ("1.1", "1.2")
264
+ or details.get("complete") is not True
265
+ or details.get("errored") is not False
266
+ or type(details.get("applyable")) is not bool
267
+ or details.get("deferred_changes")
268
+ ):
269
+ raise ValueError("saved foundation plan is incomplete or unsupported")
270
+ version = details.get("terraform_version")
271
+ if not isinstance(version, str) or re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version) is None:
272
+ raise ValueError("saved foundation plan Terraform version is invalid")
273
+ actual = details.get("variables")
274
+ if not isinstance(actual, dict) or any(
275
+ canonical_bytes(actual.get(key)) != canonical_bytes({"value": value})
276
+ for key, value in variables.items()
277
+ ):
278
+ raise ValueError("saved foundation plan does not match the normalized input")
279
+ return version
280
+
281
+
282
+ def _plan_summary(details: dict[str, object]) -> dict[str, object]:
283
+ """Return a value-free action summary for approval and transition gating."""
284
+
285
+ counts = {"create": 0, "update": 0, "delete": 0, "replace": 0, "read": 0, "no-op": 0}
286
+ resource_type_counts: dict[str, int] = {}
287
+ summarized_changes: list[dict[str, object]] = []
288
+ changes = details.get("resource_changes", [])
289
+ if not isinstance(changes, list):
290
+ raise ValueError("saved foundation plan resource changes are invalid")
291
+ for item in changes:
292
+ if not isinstance(item, dict):
293
+ raise ValueError("saved foundation plan resource changes are invalid")
294
+ change = item.get("change")
295
+ actions_value = change.get("actions") if isinstance(change, dict) else None
296
+ if not isinstance(actions_value, list) or not actions_value:
297
+ raise ValueError("saved foundation plan action summary is invalid")
298
+ actions = tuple(str(action) for action in actions_value)
299
+ if actions == ("delete", "create"):
300
+ counts["replace"] += 1
301
+ elif len(actions) == 1 and actions[0] in counts:
302
+ counts[actions[0]] += 1
303
+ else:
304
+ raise ValueError("saved foundation plan action summary is invalid")
305
+ address = item.get("address")
306
+ resource_type = item.get("type")
307
+ if not isinstance(address, str) or not address:
308
+ raise ValueError("saved foundation plan action summary is invalid")
309
+ if not isinstance(resource_type, str) or not resource_type:
310
+ resource_type = address.split(".", 1)[0]
311
+ resource_type_counts[resource_type] = resource_type_counts.get(resource_type, 0) + 1
312
+ summarized_changes.append({"address": address, "actions": list(actions)})
313
+ summary: dict[str, object] = {
314
+ "schema_version": "fdai.foundation-plan-summary.v1",
315
+ "action_counts": counts,
316
+ "resource_type_counts": resource_type_counts,
317
+ "resource_changes": summarized_changes,
318
+ }
319
+ summary["summary_digest"] = canonical_digest(summary)
320
+ return summary
321
+
322
+
323
+ def _validate_review_time(receipt: dict[str, object], *, require_unexpired: bool = True) -> None:
324
+ timestamps: list[datetime] = []
325
+ for key in ("created_at", "expires_at"):
326
+ value = receipt[key]
327
+ if not isinstance(value, str) or not value:
328
+ raise ValueError("foundation review timestamp is invalid")
329
+ try:
330
+ parsed = datetime.fromisoformat(value)
331
+ except ValueError:
332
+ raise ValueError("foundation review timestamp is invalid") from None
333
+ if parsed.tzinfo is None or parsed.utcoffset() != timedelta(0):
334
+ raise ValueError("foundation review timestamp MUST be UTC")
335
+ timestamps.append(parsed)
336
+ created, expires = timestamps
337
+ now = datetime.now(UTC)
338
+ if (
339
+ expires - created != timedelta(hours=1)
340
+ or created > now
341
+ or (require_unexpired and now >= expires)
342
+ ):
343
+ raise ValueError("foundation review is expired or outside its local time window")
344
+
345
+
346
+ def register_foundation_plan_command(
347
+ commands: argparse._SubParsersAction[argparse.ArgumentParser],
348
+ ) -> None:
349
+ """Register local saved-plan verification without Azure access or execution."""
350
+
351
+ parser = command(
352
+ commands,
353
+ "verify-foundation-plan",
354
+ "Verify a saved Foundation plan; does not authorize apply",
355
+ )
356
+ parser.add_argument(
357
+ "--directory",
358
+ type=Path,
359
+ required=True,
360
+ help="Private directory of the saved Foundation plan and review",
361
+ )
362
+ parser.add_argument("--profile", type=Path, required=True)
363
+ parser.add_argument("--expected-review-digest", required=True)
364
+ parser.add_argument("--allow-expired-after-claim", action="store_true", help=argparse.SUPPRESS)
365
+ parser.add_argument("--output", choices=("text", "json"), default="text")
366
+ parser.set_defaults(handler=_verify_command)
367
+
368
+
369
+ def _verify_command(args: argparse.Namespace) -> int:
370
+ result = verify_foundation_plan(
371
+ directory=args.directory,
372
+ profile=load_profile(args.profile),
373
+ expected_review_digest=args.expected_review_digest,
374
+ require_unexpired=not args.allow_expired_after_claim,
375
+ )
376
+ print(
377
+ json.dumps(result, sort_keys=True, separators=(",", ":"))
378
+ if args.output == "json"
379
+ else "saved foundation plan integrity verified; protected approval remains required"
380
+ )
381
+ return 0
@@ -0,0 +1,64 @@
1
+ """Supervise the installed Foundation coordinator without abandoning claimed effects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import signal
7
+ import subprocess
8
+ import threading
9
+ from collections.abc import Mapping, Sequence
10
+ from pathlib import Path
11
+
12
+
13
+ def _interrupted(signum: int, _frame: object) -> None:
14
+ signal.signal(signal.SIGTERM, signal.SIG_IGN)
15
+ raise SystemExit(128 + signum)
16
+
17
+
18
+ def run_foundation_process(
19
+ command: Sequence[str],
20
+ *,
21
+ cwd: Path,
22
+ env: Mapping[str, str],
23
+ timeout: float,
24
+ stdout: int | None = None,
25
+ stderr: int | None = None,
26
+ ) -> subprocess.CompletedProcess[bytes]:
27
+ """Preserve terminal I/O and allow nested SIGTERM cleanup before forced termination.
28
+
29
+ Return the actual child exit code. Timeout or caller interruption stops the
30
+ process group with a five-second cleanup reserve; neither path grants retry.
31
+ Optional caller-owned output descriptors preserve presentation without changing
32
+ stdin, descriptor ownership, or the process-group termination contract.
33
+ """
34
+ process = subprocess.Popen( # noqa: S603 - fixed signed Foundation entrypoint.
35
+ command, cwd=cwd, env=env, stdout=stdout, stderr=stderr, start_new_session=True
36
+ )
37
+ previous = None
38
+ if threading.current_thread() is threading.main_thread():
39
+ previous = signal.signal(signal.SIGTERM, _interrupted)
40
+ try:
41
+ status = process.wait(timeout=timeout)
42
+ except BaseException:
43
+ _stop(process)
44
+ raise
45
+ finally:
46
+ if previous is not None:
47
+ signal.signal(signal.SIGTERM, previous)
48
+ return subprocess.CompletedProcess(command, status)
49
+
50
+
51
+ def _stop(process: subprocess.Popen[bytes]) -> None:
52
+ for selected_signal, grace in ((signal.SIGTERM, 5), (signal.SIGKILL, 1)):
53
+ try:
54
+ os.killpg(process.pid, selected_signal)
55
+ except ProcessLookupError:
56
+ return
57
+ try:
58
+ process.wait(timeout=grace)
59
+ # A terminated coordinator might have left another direct group member.
60
+ if selected_signal == signal.SIGTERM:
61
+ continue
62
+ return
63
+ except subprocess.TimeoutExpired:
64
+ continue