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,448 @@
|
|
|
1
|
+
"""Attached customer executor: ``python -m fdai_deployment_cli.bootstrap_worker``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import fcntl
|
|
7
|
+
import json
|
|
8
|
+
import multiprocessing
|
|
9
|
+
import os
|
|
10
|
+
import signal
|
|
11
|
+
import stat
|
|
12
|
+
import subprocess
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from datetime import UTC, datetime
|
|
17
|
+
from multiprocessing.process import BaseProcess
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from types import FrameType
|
|
20
|
+
from uuid import uuid4
|
|
21
|
+
|
|
22
|
+
from fdai_deployment_cli.bootstrap_channel import (
|
|
23
|
+
_read,
|
|
24
|
+
_write,
|
|
25
|
+
initial_status,
|
|
26
|
+
locked,
|
|
27
|
+
prepare_directory,
|
|
28
|
+
validate_request,
|
|
29
|
+
)
|
|
30
|
+
from fdai_deployment_cli.bootstrap_executor import BootstrapBlocked, BootstrapExecutor
|
|
31
|
+
|
|
32
|
+
TOTAL_SECONDS = 8 * 3600
|
|
33
|
+
OPERATION_SECONDS = {
|
|
34
|
+
"login": 660,
|
|
35
|
+
"accounts": 120,
|
|
36
|
+
"inspect": 120,
|
|
37
|
+
"install": 14400,
|
|
38
|
+
"resume": 14400,
|
|
39
|
+
}
|
|
40
|
+
NO_PROGRESS_SECONDS = 1800
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _update(root: Path, **fields: object) -> None:
|
|
44
|
+
with locked(root):
|
|
45
|
+
record = _read(root)
|
|
46
|
+
status = record.get("status", initial_status())
|
|
47
|
+
status.update(fields)
|
|
48
|
+
record.update(status=status, progress=time.time())
|
|
49
|
+
_write(root, record)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def channel_decision(root: Path, review: dict[str, object]) -> dict[str, object]:
|
|
53
|
+
"""Expose one safe exact review and consume only its current one-shot decision."""
|
|
54
|
+
expires = datetime.fromisoformat(str(review["expires_at"]))
|
|
55
|
+
remaining = min(600, (expires - datetime.now(UTC)).total_seconds())
|
|
56
|
+
if remaining <= 0:
|
|
57
|
+
raise ValueError("Exact checkpoint expired before review")
|
|
58
|
+
current = {
|
|
59
|
+
"id": str(uuid4()),
|
|
60
|
+
"digest": review["digest"],
|
|
61
|
+
"title": review["title"],
|
|
62
|
+
"details": review["details"],
|
|
63
|
+
"confirmation_required": review["confirmation_required"],
|
|
64
|
+
"expires_at": review["expires_at"],
|
|
65
|
+
}
|
|
66
|
+
if len(json.dumps(current, allow_nan=False).encode()) > 16384:
|
|
67
|
+
raise BootstrapBlocked(
|
|
68
|
+
"Exact plan exceeds the bounded browser review. Inspect its retained plan "
|
|
69
|
+
"with the existing CLI; no browser approval was granted."
|
|
70
|
+
)
|
|
71
|
+
_update(
|
|
72
|
+
root,
|
|
73
|
+
phase="review",
|
|
74
|
+
review=current,
|
|
75
|
+
message="Review this exact checkpoint before continuing.",
|
|
76
|
+
)
|
|
77
|
+
deadline = time.monotonic() + remaining
|
|
78
|
+
while time.monotonic() < deadline:
|
|
79
|
+
with locked(root):
|
|
80
|
+
record = _read(root)
|
|
81
|
+
decision = record.get("decision")
|
|
82
|
+
if decision is not None:
|
|
83
|
+
status = record["status"]
|
|
84
|
+
if (
|
|
85
|
+
not isinstance(decision, dict)
|
|
86
|
+
or decision.get("request_id") != status["request_id"]
|
|
87
|
+
or decision.get("review_id") != current["id"]
|
|
88
|
+
or decision.get("digest") != current["digest"]
|
|
89
|
+
):
|
|
90
|
+
raise ValueError("Current checkpoint decision binding changed")
|
|
91
|
+
record["decision"] = None
|
|
92
|
+
_write(root, record)
|
|
93
|
+
return decision
|
|
94
|
+
time.sleep(0.1)
|
|
95
|
+
raise TimeoutError("Exact checkpoint decision expired; no approval was granted")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def run_request(
|
|
99
|
+
root: Path,
|
|
100
|
+
request: dict[str, object],
|
|
101
|
+
*,
|
|
102
|
+
executor_factory: Callable[
|
|
103
|
+
[Path, Callable[[dict[str, object]], dict[str, object]]], BootstrapExecutor
|
|
104
|
+
] = BootstrapExecutor,
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Execute a claimed request; failures never expose raw exception or process output."""
|
|
107
|
+
action = str(request["action"])
|
|
108
|
+
try:
|
|
109
|
+
raw_payload = request["payload"]
|
|
110
|
+
if not isinstance(raw_payload, dict):
|
|
111
|
+
raise ValueError("Malformed executor request") # noqa: TRY004 - IPC rejection contract
|
|
112
|
+
payload = validate_request(action, raw_payload)
|
|
113
|
+
executor = executor_factory(
|
|
114
|
+
root / "executor", lambda review: channel_decision(root, review)
|
|
115
|
+
)
|
|
116
|
+
if action == "login":
|
|
117
|
+
_update(root, message="Complete Microsoft browser sign-in on this customer machine.")
|
|
118
|
+
result = executor.execute(action, payload)
|
|
119
|
+
ready = result.get("deployment_ready") is True
|
|
120
|
+
retained = bool(_read(root / "executor", "configuration.json"))
|
|
121
|
+
message = result.get("message", "Customer executor operation completed.")
|
|
122
|
+
if retained and action in {"login", "accounts", "inspect"}:
|
|
123
|
+
message = "Retained installation preserved. Resume to refresh its exact target, plans and health."
|
|
124
|
+
_update(
|
|
125
|
+
root,
|
|
126
|
+
phase="ready"
|
|
127
|
+
if ready
|
|
128
|
+
else "blocked"
|
|
129
|
+
if action in {"install", "resume"} or retained
|
|
130
|
+
else "idle",
|
|
131
|
+
request_id=request["id"] if retained or action in {"install", "resume"} else None,
|
|
132
|
+
review=None,
|
|
133
|
+
deployment_ready=ready,
|
|
134
|
+
console_url=result.get("console_url") if ready else None,
|
|
135
|
+
message=message,
|
|
136
|
+
**({"accounts": result["accounts"]} if "accounts" in result else {}),
|
|
137
|
+
)
|
|
138
|
+
except BootstrapBlocked as exc:
|
|
139
|
+
_update(
|
|
140
|
+
root,
|
|
141
|
+
phase="blocked",
|
|
142
|
+
review=None,
|
|
143
|
+
deployment_ready=False,
|
|
144
|
+
console_url=None,
|
|
145
|
+
error_code="prerequisite_unavailable",
|
|
146
|
+
message=str(exc),
|
|
147
|
+
)
|
|
148
|
+
except (TimeoutError, subprocess.TimeoutExpired):
|
|
149
|
+
_update(
|
|
150
|
+
root,
|
|
151
|
+
phase="interrupted",
|
|
152
|
+
review=None,
|
|
153
|
+
deployment_ready=False,
|
|
154
|
+
console_url=None,
|
|
155
|
+
error_code="operation_timeout",
|
|
156
|
+
message="Operation expired. Inspect retained state; do not repeat ambiguous effects.",
|
|
157
|
+
)
|
|
158
|
+
except Exception: # noqa: BLE001 - executor boundary must never publish raw provider errors
|
|
159
|
+
_update(
|
|
160
|
+
root,
|
|
161
|
+
phase="failed",
|
|
162
|
+
review=None,
|
|
163
|
+
deployment_ready=False,
|
|
164
|
+
console_url=None,
|
|
165
|
+
error_code="executor_failed",
|
|
166
|
+
message="Executor stopped. Inspect private traces and retained evidence before resuming.",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _child(root: Path, request: dict[str, object], output: int, reader: int, parent: int) -> None:
|
|
171
|
+
os.setsid()
|
|
172
|
+
signal.signal(signal.SIGTERM, signal.SIG_DFL)
|
|
173
|
+
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
|
174
|
+
os.umask(0o077)
|
|
175
|
+
os.close(reader)
|
|
176
|
+
area = root / "executor"
|
|
177
|
+
prepare_directory(area)
|
|
178
|
+
os.dup2(output, 1)
|
|
179
|
+
os.dup2(output, 2)
|
|
180
|
+
os.close(output)
|
|
181
|
+
null = os.open(os.devnull, os.O_RDONLY)
|
|
182
|
+
os.dup2(null, 0)
|
|
183
|
+
os.close(null)
|
|
184
|
+
|
|
185
|
+
def parent_watchdog() -> None:
|
|
186
|
+
while os.getppid() == parent:
|
|
187
|
+
time.sleep(0.25)
|
|
188
|
+
# A forced worker kill must also stop the isolated operation's descendants.
|
|
189
|
+
os.killpg(os.getpid(), signal.SIGKILL)
|
|
190
|
+
|
|
191
|
+
threading.Thread(target=parent_watchdog, daemon=True).start()
|
|
192
|
+
run_request(root, request)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _trace(root: Path) -> int:
|
|
196
|
+
area = root / "executor"
|
|
197
|
+
prepare_directory(area)
|
|
198
|
+
current, previous = area / "engine-trace.log", area / "engine-trace.previous.log"
|
|
199
|
+
if current.is_symlink() or previous.is_symlink():
|
|
200
|
+
raise ValueError("Private executor traces must not be symlinks")
|
|
201
|
+
if current.exists():
|
|
202
|
+
info = current.lstat()
|
|
203
|
+
if (
|
|
204
|
+
not stat.S_ISREG(info.st_mode)
|
|
205
|
+
or info.st_uid != os.getuid()
|
|
206
|
+
or stat.S_IMODE(info.st_mode) != 0o600
|
|
207
|
+
or info.st_nlink != 1
|
|
208
|
+
):
|
|
209
|
+
raise ValueError("Private executor trace is unsafe")
|
|
210
|
+
os.replace(current, previous)
|
|
211
|
+
fd = os.open(current, os.O_WRONLY | os.O_CREAT | os.O_APPEND | os.O_NOFOLLOW, 0o600)
|
|
212
|
+
info = os.fstat(fd)
|
|
213
|
+
if (
|
|
214
|
+
not stat.S_ISREG(info.st_mode)
|
|
215
|
+
or info.st_uid != os.getuid()
|
|
216
|
+
or stat.S_IMODE(info.st_mode) != 0o600
|
|
217
|
+
or info.st_nlink != 1
|
|
218
|
+
):
|
|
219
|
+
os.close(fd)
|
|
220
|
+
raise ValueError("Private executor trace is unsafe")
|
|
221
|
+
return fd
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _drain(reader: int, trace: int) -> bool:
|
|
225
|
+
"""Keep engine traces bounded without limiting Terraform state or artifact file sizes."""
|
|
226
|
+
for _ in range(64):
|
|
227
|
+
try:
|
|
228
|
+
data = os.read(reader, 4096)
|
|
229
|
+
except BlockingIOError:
|
|
230
|
+
break
|
|
231
|
+
if not data:
|
|
232
|
+
break
|
|
233
|
+
if os.fstat(trace).st_size + len(data) > 16 * 1024 * 1024:
|
|
234
|
+
return False
|
|
235
|
+
os.write(trace, data)
|
|
236
|
+
return True
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _stop(process: BaseProcess) -> None:
|
|
240
|
+
if process.is_alive() and process.pid is not None:
|
|
241
|
+
try:
|
|
242
|
+
os.killpg(process.pid, signal.SIGTERM)
|
|
243
|
+
except ProcessLookupError:
|
|
244
|
+
process.terminate()
|
|
245
|
+
process.join(5)
|
|
246
|
+
try:
|
|
247
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
248
|
+
except ProcessLookupError:
|
|
249
|
+
pass
|
|
250
|
+
process.join(5)
|
|
251
|
+
elif process.pid is not None:
|
|
252
|
+
# Also remove children left behind by a crashed operation coordinator.
|
|
253
|
+
try:
|
|
254
|
+
os.killpg(process.pid, signal.SIGTERM)
|
|
255
|
+
except ProcessLookupError:
|
|
256
|
+
pass
|
|
257
|
+
try:
|
|
258
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
259
|
+
except ProcessLookupError:
|
|
260
|
+
pass
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def serve(root: Path, *, total_seconds: int = TOTAL_SECONDS) -> None:
|
|
264
|
+
"""Supervise serial process groups with heartbeat, hard deadlines and no replay."""
|
|
265
|
+
prepare_directory(root)
|
|
266
|
+
os.umask(0o077)
|
|
267
|
+
lock = os.open(root / "worker.lock", os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
|
268
|
+
info = os.fstat(lock)
|
|
269
|
+
if (
|
|
270
|
+
not stat.S_ISREG(info.st_mode)
|
|
271
|
+
or stat.S_IMODE(info.st_mode) != 0o600
|
|
272
|
+
or info.st_uid != os.getuid()
|
|
273
|
+
or info.st_nlink != 1
|
|
274
|
+
):
|
|
275
|
+
os.close(lock)
|
|
276
|
+
raise ValueError("Worker lock is unsafe")
|
|
277
|
+
try:
|
|
278
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
279
|
+
except BlockingIOError as exc:
|
|
280
|
+
os.close(lock)
|
|
281
|
+
raise ValueError("A customer executor already owns this directory") from exc
|
|
282
|
+
stopping = False
|
|
283
|
+
|
|
284
|
+
def stop(_signal: int, _frame: FrameType | None) -> None:
|
|
285
|
+
nonlocal stopping
|
|
286
|
+
stopping = True
|
|
287
|
+
|
|
288
|
+
old_handlers = {sig: signal.signal(sig, stop) for sig in (signal.SIGTERM, signal.SIGINT)}
|
|
289
|
+
process = None
|
|
290
|
+
reader = trace_fd = None
|
|
291
|
+
started = time.monotonic()
|
|
292
|
+
operation_started = started
|
|
293
|
+
progress_time = started
|
|
294
|
+
last_progress = None
|
|
295
|
+
action = ""
|
|
296
|
+
try:
|
|
297
|
+
with locked(root):
|
|
298
|
+
record = _read(root)
|
|
299
|
+
status = record.get("status", initial_status())
|
|
300
|
+
if (
|
|
301
|
+
status["phase"] in {"working", "review"}
|
|
302
|
+
or record.get("pending")
|
|
303
|
+
or record.get("active_request_id")
|
|
304
|
+
):
|
|
305
|
+
status.update(
|
|
306
|
+
phase="interrupted",
|
|
307
|
+
review=None,
|
|
308
|
+
deployment_ready=False,
|
|
309
|
+
console_url=None,
|
|
310
|
+
error_code="worker_restarted",
|
|
311
|
+
message="Prior work was interrupted. Inspect retained state before explicit resume.",
|
|
312
|
+
)
|
|
313
|
+
record.update(
|
|
314
|
+
status=status,
|
|
315
|
+
pending=None,
|
|
316
|
+
active_request_id=None,
|
|
317
|
+
decision=None,
|
|
318
|
+
heartbeat=time.time(),
|
|
319
|
+
)
|
|
320
|
+
_write(root, record)
|
|
321
|
+
while not stopping and time.monotonic() - started < min(total_seconds, TOTAL_SECONDS):
|
|
322
|
+
with locked(root):
|
|
323
|
+
record = _read(root)
|
|
324
|
+
record["heartbeat"] = time.time()
|
|
325
|
+
_write(root, record)
|
|
326
|
+
_write(
|
|
327
|
+
root,
|
|
328
|
+
{
|
|
329
|
+
"pid": os.getpid(),
|
|
330
|
+
"operation_pid": process.pid if process is not None else None,
|
|
331
|
+
"updated_at": datetime.now(UTC).isoformat(),
|
|
332
|
+
"heartbeat": time.time(),
|
|
333
|
+
},
|
|
334
|
+
"heartbeat.json",
|
|
335
|
+
)
|
|
336
|
+
pending = record.get("pending") if process is None else None
|
|
337
|
+
if pending is not None:
|
|
338
|
+
record.update(
|
|
339
|
+
pending=None, active_request_id=pending["id"], progress=time.time()
|
|
340
|
+
)
|
|
341
|
+
_write(root, record)
|
|
342
|
+
if pending is not None:
|
|
343
|
+
action = str(pending["action"])
|
|
344
|
+
trace_fd = _trace(root)
|
|
345
|
+
reader, writer = os.pipe()
|
|
346
|
+
os.set_blocking(reader, False)
|
|
347
|
+
process = multiprocessing.get_context("fork").Process(
|
|
348
|
+
target=_child, args=(root, pending, writer, reader, os.getpid())
|
|
349
|
+
)
|
|
350
|
+
process.start()
|
|
351
|
+
os.close(writer)
|
|
352
|
+
with locked(root):
|
|
353
|
+
_write(
|
|
354
|
+
root,
|
|
355
|
+
{
|
|
356
|
+
"pid": os.getpid(),
|
|
357
|
+
"operation_pid": process.pid,
|
|
358
|
+
"updated_at": datetime.now(UTC).isoformat(),
|
|
359
|
+
"heartbeat": time.time(),
|
|
360
|
+
},
|
|
361
|
+
"heartbeat.json",
|
|
362
|
+
)
|
|
363
|
+
operation_started = progress_time = time.monotonic()
|
|
364
|
+
last_progress = None
|
|
365
|
+
if process is not None:
|
|
366
|
+
assert reader is not None and trace_fd is not None
|
|
367
|
+
trace_within_bound = _drain(reader, trace_fd)
|
|
368
|
+
trace = root / "executor/engine-trace.log"
|
|
369
|
+
trace_size = (
|
|
370
|
+
trace.stat().st_size if trace.is_file() and not trace.is_symlink() else 0
|
|
371
|
+
)
|
|
372
|
+
progress = (record.get("progress"), trace_size)
|
|
373
|
+
if progress != last_progress:
|
|
374
|
+
last_progress, progress_time = progress, time.monotonic()
|
|
375
|
+
timed_out = (
|
|
376
|
+
time.monotonic() - operation_started > OPERATION_SECONDS.get(action, 120)
|
|
377
|
+
or time.monotonic() - progress_time > NO_PROGRESS_SECONDS
|
|
378
|
+
or not trace_within_bound
|
|
379
|
+
)
|
|
380
|
+
if timed_out or not process.is_alive():
|
|
381
|
+
_stop(process)
|
|
382
|
+
with locked(root):
|
|
383
|
+
record = _read(root)
|
|
384
|
+
status = record["status"]
|
|
385
|
+
if timed_out or status["phase"] in {"working", "review"}:
|
|
386
|
+
status.update(
|
|
387
|
+
phase="interrupted",
|
|
388
|
+
review=None,
|
|
389
|
+
deployment_ready=False,
|
|
390
|
+
console_url=None,
|
|
391
|
+
error_code="operation_interrupted",
|
|
392
|
+
message="Executor stopped. Inspect retained evidence before explicit resume.",
|
|
393
|
+
)
|
|
394
|
+
record.update(status=status, active_request_id=None, progress=time.time())
|
|
395
|
+
_write(root, record)
|
|
396
|
+
process.close()
|
|
397
|
+
process = None
|
|
398
|
+
os.close(reader)
|
|
399
|
+
os.close(trace_fd)
|
|
400
|
+
reader = trace_fd = None
|
|
401
|
+
time.sleep(2)
|
|
402
|
+
finally:
|
|
403
|
+
if process is not None:
|
|
404
|
+
_stop(process)
|
|
405
|
+
process.close()
|
|
406
|
+
_update(
|
|
407
|
+
root,
|
|
408
|
+
phase="interrupted",
|
|
409
|
+
review=None,
|
|
410
|
+
deployment_ready=False,
|
|
411
|
+
console_url=None,
|
|
412
|
+
error_code="worker_stopped",
|
|
413
|
+
message="Worker stopped; inspect before explicit resume.",
|
|
414
|
+
)
|
|
415
|
+
if reader is not None:
|
|
416
|
+
os.close(reader)
|
|
417
|
+
if trace_fd is not None:
|
|
418
|
+
os.close(trace_fd)
|
|
419
|
+
for sig, previous in old_handlers.items():
|
|
420
|
+
signal.signal(sig, previous)
|
|
421
|
+
os.close(lock)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def main() -> None:
|
|
425
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
426
|
+
parser.add_argument("--state-dir", type=Path, required=True)
|
|
427
|
+
args = parser.parse_args()
|
|
428
|
+
root = args.state_dir.absolute()
|
|
429
|
+
try:
|
|
430
|
+
serve(root)
|
|
431
|
+
except Exception: # noqa: BLE001 - startup errors must not leak paths or provider diagnostics
|
|
432
|
+
prepare_directory(root)
|
|
433
|
+
area = root / "executor"
|
|
434
|
+
prepare_directory(area)
|
|
435
|
+
_write(
|
|
436
|
+
area,
|
|
437
|
+
{
|
|
438
|
+
"error_code": "worker_startup_failed",
|
|
439
|
+
"message": "Worker startup failed. Check private directory permissions and worker ownership.",
|
|
440
|
+
"recorded_at": datetime.now(UTC).isoformat(),
|
|
441
|
+
},
|
|
442
|
+
"worker-diagnostics.json",
|
|
443
|
+
)
|
|
444
|
+
raise SystemExit(1) from None
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
if __name__ == "__main__":
|
|
448
|
+
main()
|