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,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
|