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,478 @@
|
|
|
1
|
+
"""Build one exact private execution bundle for a governed host transport."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import gzip
|
|
6
|
+
import hashlib
|
|
7
|
+
import io
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import stat
|
|
11
|
+
import tarfile
|
|
12
|
+
import zipfile
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path, PurePosixPath
|
|
15
|
+
|
|
16
|
+
from fdai_deployment_cli.contracts import canonical_bytes, canonical_digest, load_json_object
|
|
17
|
+
from fdai_deployment_cli.private_output import read_private_bytes, write_private_bytes
|
|
18
|
+
|
|
19
|
+
_OPERATION_ID = re.compile(r"[a-z0-9][a-z0-9-]{0,127}")
|
|
20
|
+
_ALLOWED_SECTIONS = frozenset({"artifact", "evidence", "toolchain"})
|
|
21
|
+
_MAX_FILE_BYTES = 512 * 1024 * 1024
|
|
22
|
+
_MAX_TOTAL_BYTES = 2 * 1024 * 1024 * 1024
|
|
23
|
+
_MAX_FILES = 20_000
|
|
24
|
+
_ARCHIVE = "execution-bundle.tar.gz"
|
|
25
|
+
_RECEIPT = "execution-bundle-receipt.json"
|
|
26
|
+
_RECEIVER = "run-command-receiver.pyz"
|
|
27
|
+
_RECEIVER_MODULES = (
|
|
28
|
+
"__init__",
|
|
29
|
+
"__about__",
|
|
30
|
+
"contracts",
|
|
31
|
+
"private_output",
|
|
32
|
+
"run_command_receiver",
|
|
33
|
+
)
|
|
34
|
+
_RECEIVER_ENTRY = (
|
|
35
|
+
b"from fdai_deployment_cli.run_command_receiver import main\nraise SystemExit(main())\n"
|
|
36
|
+
)
|
|
37
|
+
_MAX_RECEIVER_BYTES = 4 * 1024 * 1024
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class ExecutionBundleSource:
|
|
42
|
+
"""Map one private regular file or directory into an allowlisted bundle section."""
|
|
43
|
+
|
|
44
|
+
source: Path
|
|
45
|
+
destination: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class _File:
|
|
50
|
+
root_descriptor: int
|
|
51
|
+
relative_parts: tuple[str, ...]
|
|
52
|
+
destination: str
|
|
53
|
+
sha256: str
|
|
54
|
+
mode: str
|
|
55
|
+
size: int
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def prepare_execution_bundle(
|
|
59
|
+
sources: tuple[ExecutionBundleSource, ...],
|
|
60
|
+
work_dir: Path,
|
|
61
|
+
*,
|
|
62
|
+
operation_id: str,
|
|
63
|
+
) -> dict[str, object]:
|
|
64
|
+
"""Create or reverify one deterministic bundle without granting execution authority."""
|
|
65
|
+
|
|
66
|
+
if not sources or _OPERATION_ID.fullmatch(operation_id) is None:
|
|
67
|
+
raise ValueError("execution bundle operation or source inventory is invalid")
|
|
68
|
+
details = work_dir.lstat()
|
|
69
|
+
if (
|
|
70
|
+
not stat.S_ISDIR(details.st_mode)
|
|
71
|
+
or stat.S_IMODE(details.st_mode) != 0o700
|
|
72
|
+
or details.st_uid != os.geteuid()
|
|
73
|
+
):
|
|
74
|
+
raise PermissionError("execution bundle work directory must be current-UID mode 0700")
|
|
75
|
+
files, source_descriptors = _inventory(sources)
|
|
76
|
+
try:
|
|
77
|
+
return _prepare_execution_bundle_files(files, work_dir, operation_id=operation_id)
|
|
78
|
+
finally:
|
|
79
|
+
for source_descriptor in source_descriptors:
|
|
80
|
+
os.close(source_descriptor)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _prepare_execution_bundle_files(
|
|
84
|
+
files: tuple[_File, ...], work_dir: Path, *, operation_id: str
|
|
85
|
+
) -> dict[str, object]:
|
|
86
|
+
inventory = {item.destination: {"sha256": item.sha256, "mode": item.mode} for item in files}
|
|
87
|
+
inventory_digest = canonical_digest({"files": inventory})
|
|
88
|
+
archive = work_dir / _ARCHIVE
|
|
89
|
+
receipt_path = work_dir / _RECEIPT
|
|
90
|
+
if (
|
|
91
|
+
archive.exists()
|
|
92
|
+
or archive.is_symlink()
|
|
93
|
+
or receipt_path.exists()
|
|
94
|
+
or receipt_path.is_symlink()
|
|
95
|
+
):
|
|
96
|
+
if not archive.is_file() or not receipt_path.is_file():
|
|
97
|
+
raise ValueError("execution bundle retained state is incomplete")
|
|
98
|
+
retained = load_json_object(
|
|
99
|
+
read_private_bytes(receipt_path, max_bytes=65_536),
|
|
100
|
+
label="execution bundle receipt",
|
|
101
|
+
max_bytes=65_536,
|
|
102
|
+
)
|
|
103
|
+
digest = _file_digest(archive, maximum=_MAX_FILE_BYTES)
|
|
104
|
+
expected = _receipt(
|
|
105
|
+
operation_id=operation_id,
|
|
106
|
+
bundle_digest=digest,
|
|
107
|
+
bundle_size=archive.stat().st_size,
|
|
108
|
+
file_count=len(files),
|
|
109
|
+
inventory_digest=inventory_digest,
|
|
110
|
+
)
|
|
111
|
+
if retained != expected:
|
|
112
|
+
raise ValueError("execution bundle retained receipt differs")
|
|
113
|
+
return expected
|
|
114
|
+
|
|
115
|
+
manifest = {
|
|
116
|
+
"schema_version": "fdai.execution-bundle-manifest.v1",
|
|
117
|
+
"operation_id": operation_id,
|
|
118
|
+
"files": inventory,
|
|
119
|
+
}
|
|
120
|
+
temporary = work_dir / f".{_ARCHIVE}.partial-{os.getpid()}"
|
|
121
|
+
descriptor = os.open(
|
|
122
|
+
temporary,
|
|
123
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
|
124
|
+
0o600,
|
|
125
|
+
)
|
|
126
|
+
published = False
|
|
127
|
+
try:
|
|
128
|
+
with os.fdopen(descriptor, "wb", closefd=False) as raw:
|
|
129
|
+
with gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as compressed:
|
|
130
|
+
with tarfile.open(
|
|
131
|
+
fileobj=compressed, mode="w", format=tarfile.PAX_FORMAT
|
|
132
|
+
) as stream:
|
|
133
|
+
_add(stream, "fdai-execution/manifest.json", canonical_bytes(manifest), 0o600)
|
|
134
|
+
for item in files:
|
|
135
|
+
_add(
|
|
136
|
+
stream,
|
|
137
|
+
f"fdai-execution/{item.destination}",
|
|
138
|
+
_read_exact(item),
|
|
139
|
+
int(item.mode, 8),
|
|
140
|
+
)
|
|
141
|
+
raw.flush()
|
|
142
|
+
os.fsync(raw.fileno())
|
|
143
|
+
os.close(descriptor)
|
|
144
|
+
descriptor = -1
|
|
145
|
+
if temporary.stat().st_size > _MAX_FILE_BYTES:
|
|
146
|
+
raise ValueError("execution bundle archive exceeds its size limit")
|
|
147
|
+
os.rename(temporary, archive)
|
|
148
|
+
published = True
|
|
149
|
+
finally:
|
|
150
|
+
if descriptor >= 0:
|
|
151
|
+
os.close(descriptor)
|
|
152
|
+
if not published:
|
|
153
|
+
temporary.unlink(missing_ok=True)
|
|
154
|
+
digest = _file_digest(archive, maximum=_MAX_FILE_BYTES)
|
|
155
|
+
receipt = _receipt(
|
|
156
|
+
operation_id=operation_id,
|
|
157
|
+
bundle_digest=digest,
|
|
158
|
+
bundle_size=archive.stat().st_size,
|
|
159
|
+
file_count=len(files),
|
|
160
|
+
inventory_digest=inventory_digest,
|
|
161
|
+
)
|
|
162
|
+
write_private_bytes(receipt_path, canonical_bytes(receipt))
|
|
163
|
+
return receipt
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def prepare_run_command_receiver(work_dir: Path) -> str:
|
|
167
|
+
"""Build or reverify the fixed dependency-free WSL receiver zipapp."""
|
|
168
|
+
|
|
169
|
+
payload = io.BytesIO()
|
|
170
|
+
with zipfile.ZipFile(payload, "w", compression=zipfile.ZIP_STORED) as archive:
|
|
171
|
+
_zip_add(archive, "__main__.py", _RECEIVER_ENTRY)
|
|
172
|
+
module_root = Path(__file__).parent
|
|
173
|
+
for module in _RECEIVER_MODULES:
|
|
174
|
+
content = _module_content(module_root / f"{module}.py")
|
|
175
|
+
if payload.tell() + len(content) > _MAX_RECEIVER_BYTES:
|
|
176
|
+
raise ValueError("run command receiver exceeds its size limit")
|
|
177
|
+
_zip_add(archive, f"fdai_deployment_cli/{module}.py", content)
|
|
178
|
+
content = payload.getvalue()
|
|
179
|
+
if not 0 < len(content) <= _MAX_RECEIVER_BYTES:
|
|
180
|
+
raise ValueError("run command receiver size is invalid")
|
|
181
|
+
destination = work_dir / _RECEIVER
|
|
182
|
+
if destination.exists() or destination.is_symlink():
|
|
183
|
+
if read_private_bytes(destination, max_bytes=_MAX_RECEIVER_BYTES) != content:
|
|
184
|
+
raise ValueError("retained run command receiver differs")
|
|
185
|
+
else:
|
|
186
|
+
write_private_bytes(destination, content)
|
|
187
|
+
return hashlib.sha256(content).hexdigest()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _inventory(
|
|
191
|
+
sources: tuple[ExecutionBundleSource, ...],
|
|
192
|
+
) -> tuple[tuple[_File, ...], tuple[int, ...]]:
|
|
193
|
+
selected: dict[str, tuple[int, tuple[str, ...], os.stat_result]] = {}
|
|
194
|
+
source_descriptors: list[int] = []
|
|
195
|
+
try:
|
|
196
|
+
for selected_source in sources:
|
|
197
|
+
destination = _destination(selected_source.destination)
|
|
198
|
+
source = selected_source.source.absolute()
|
|
199
|
+
if source.resolve(strict=True) != source or source.is_symlink():
|
|
200
|
+
raise ValueError("execution bundle source must be a regular single-link file")
|
|
201
|
+
if source.is_dir():
|
|
202
|
+
root_descriptor = os.open(source, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
203
|
+
source_descriptors.append(root_descriptor)
|
|
204
|
+
_walk_source_directory(
|
|
205
|
+
selected,
|
|
206
|
+
root_descriptor=root_descriptor,
|
|
207
|
+
directory_descriptor=root_descriptor,
|
|
208
|
+
relative_parts=(),
|
|
209
|
+
destination=destination,
|
|
210
|
+
)
|
|
211
|
+
else:
|
|
212
|
+
root_descriptor = os.open(
|
|
213
|
+
source.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
|
|
214
|
+
)
|
|
215
|
+
source_descriptors.append(root_descriptor)
|
|
216
|
+
details = os.stat(source.name, dir_fd=root_descriptor, follow_symlinks=False)
|
|
217
|
+
_select(
|
|
218
|
+
selected,
|
|
219
|
+
destination,
|
|
220
|
+
root_descriptor,
|
|
221
|
+
(source.name,),
|
|
222
|
+
details,
|
|
223
|
+
)
|
|
224
|
+
if not 0 < len(selected) <= _MAX_FILES:
|
|
225
|
+
raise ValueError("execution bundle file count is invalid")
|
|
226
|
+
files: list[_File] = []
|
|
227
|
+
total = 0
|
|
228
|
+
for destination, (root_descriptor, relative_parts, details) in sorted(selected.items()):
|
|
229
|
+
_validate_source_details(details)
|
|
230
|
+
total += details.st_size
|
|
231
|
+
if total > _MAX_TOTAL_BYTES:
|
|
232
|
+
raise ValueError("execution bundle source inventory exceeds its size limit")
|
|
233
|
+
mode = "0700" if details.st_mode & stat.S_IXUSR else "0600"
|
|
234
|
+
files.append(
|
|
235
|
+
_File(
|
|
236
|
+
root_descriptor=root_descriptor,
|
|
237
|
+
relative_parts=relative_parts,
|
|
238
|
+
destination=destination,
|
|
239
|
+
sha256=_descriptor_digest(
|
|
240
|
+
root_descriptor, relative_parts, maximum=_MAX_FILE_BYTES
|
|
241
|
+
),
|
|
242
|
+
mode=mode,
|
|
243
|
+
size=details.st_size,
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
return tuple(files), tuple(source_descriptors)
|
|
247
|
+
except BaseException:
|
|
248
|
+
for source_descriptor in source_descriptors:
|
|
249
|
+
os.close(source_descriptor)
|
|
250
|
+
raise
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _walk_source_directory(
|
|
254
|
+
selected: dict[str, tuple[int, tuple[str, ...], os.stat_result]],
|
|
255
|
+
*,
|
|
256
|
+
root_descriptor: int,
|
|
257
|
+
directory_descriptor: int,
|
|
258
|
+
relative_parts: tuple[str, ...],
|
|
259
|
+
destination: str,
|
|
260
|
+
) -> None:
|
|
261
|
+
for name in sorted(os.listdir(directory_descriptor)):
|
|
262
|
+
details = os.stat(name, dir_fd=directory_descriptor, follow_symlinks=False)
|
|
263
|
+
child_parts = (*relative_parts, name)
|
|
264
|
+
if stat.S_ISDIR(details.st_mode):
|
|
265
|
+
child = os.open(
|
|
266
|
+
name,
|
|
267
|
+
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
|
|
268
|
+
dir_fd=directory_descriptor,
|
|
269
|
+
)
|
|
270
|
+
try:
|
|
271
|
+
_walk_source_directory(
|
|
272
|
+
selected,
|
|
273
|
+
root_descriptor=root_descriptor,
|
|
274
|
+
directory_descriptor=child,
|
|
275
|
+
relative_parts=child_parts,
|
|
276
|
+
destination=destination,
|
|
277
|
+
)
|
|
278
|
+
finally:
|
|
279
|
+
os.close(child)
|
|
280
|
+
continue
|
|
281
|
+
relative = PurePosixPath(*child_parts).as_posix()
|
|
282
|
+
_select(
|
|
283
|
+
selected,
|
|
284
|
+
f"{destination}/{relative}",
|
|
285
|
+
root_descriptor,
|
|
286
|
+
child_parts,
|
|
287
|
+
details,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _select(
|
|
292
|
+
selected: dict[str, tuple[int, tuple[str, ...], os.stat_result]],
|
|
293
|
+
destination: str,
|
|
294
|
+
root_descriptor: int,
|
|
295
|
+
relative_parts: tuple[str, ...],
|
|
296
|
+
details: os.stat_result,
|
|
297
|
+
) -> None:
|
|
298
|
+
normalized = _destination(destination)
|
|
299
|
+
if normalized in selected:
|
|
300
|
+
raise ValueError("execution bundle destination is duplicated")
|
|
301
|
+
selected[normalized] = (root_descriptor, relative_parts, details)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _destination(value: str) -> str:
|
|
305
|
+
path = PurePosixPath(value)
|
|
306
|
+
if (
|
|
307
|
+
value.startswith("/")
|
|
308
|
+
or "\\" in value
|
|
309
|
+
or not path.parts
|
|
310
|
+
or path.parts[0] not in _ALLOWED_SECTIONS
|
|
311
|
+
or any(part in {"", ".", ".."} for part in path.parts)
|
|
312
|
+
):
|
|
313
|
+
raise ValueError("execution bundle destination is invalid")
|
|
314
|
+
return path.as_posix()
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _read_exact(item: _File) -> bytes:
|
|
318
|
+
descriptor = _open_relative(item.root_descriptor, item.relative_parts)
|
|
319
|
+
with os.fdopen(descriptor, "rb") as stream:
|
|
320
|
+
before = os.fstat(stream.fileno())
|
|
321
|
+
if (
|
|
322
|
+
not stat.S_ISREG(before.st_mode)
|
|
323
|
+
or stat.S_IMODE(before.st_mode) not in {0o600, 0o700}
|
|
324
|
+
or before.st_uid != os.geteuid()
|
|
325
|
+
or before.st_nlink != 1
|
|
326
|
+
or before.st_size != item.size
|
|
327
|
+
):
|
|
328
|
+
raise PermissionError("execution bundle source permissions changed")
|
|
329
|
+
content = stream.read(_MAX_FILE_BYTES + 1)
|
|
330
|
+
after = os.fstat(stream.fileno())
|
|
331
|
+
if (
|
|
332
|
+
len(content) != item.size
|
|
333
|
+
or hashlib.sha256(content).hexdigest() != item.sha256
|
|
334
|
+
or after.st_size != before.st_size
|
|
335
|
+
or after.st_mtime_ns != before.st_mtime_ns
|
|
336
|
+
or after.st_ctime_ns != before.st_ctime_ns
|
|
337
|
+
):
|
|
338
|
+
raise ValueError("execution bundle source changed during preparation")
|
|
339
|
+
return content
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _validate_source_details(details: os.stat_result) -> None:
|
|
343
|
+
if (
|
|
344
|
+
not stat.S_ISREG(details.st_mode)
|
|
345
|
+
or stat.S_IMODE(details.st_mode) not in {0o600, 0o700}
|
|
346
|
+
or details.st_nlink != 1
|
|
347
|
+
or details.st_uid != os.geteuid()
|
|
348
|
+
or not 0 < details.st_size <= _MAX_FILE_BYTES
|
|
349
|
+
):
|
|
350
|
+
raise ValueError("execution bundle source must be a private regular single-link file")
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _open_relative(root_descriptor: int, relative_parts: tuple[str, ...]) -> int:
|
|
354
|
+
if not relative_parts:
|
|
355
|
+
raise ValueError("execution bundle source path is empty")
|
|
356
|
+
parent = os.dup(root_descriptor)
|
|
357
|
+
try:
|
|
358
|
+
for part in relative_parts[:-1]:
|
|
359
|
+
child = os.open(
|
|
360
|
+
part,
|
|
361
|
+
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
|
|
362
|
+
dir_fd=parent,
|
|
363
|
+
)
|
|
364
|
+
os.close(parent)
|
|
365
|
+
parent = child
|
|
366
|
+
return os.open(
|
|
367
|
+
relative_parts[-1],
|
|
368
|
+
os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK,
|
|
369
|
+
dir_fd=parent,
|
|
370
|
+
)
|
|
371
|
+
finally:
|
|
372
|
+
os.close(parent)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _descriptor_digest(
|
|
376
|
+
root_descriptor: int, relative_parts: tuple[str, ...], *, maximum: int
|
|
377
|
+
) -> str:
|
|
378
|
+
descriptor = _open_relative(root_descriptor, relative_parts)
|
|
379
|
+
digest = hashlib.sha256()
|
|
380
|
+
with os.fdopen(descriptor, "rb") as stream:
|
|
381
|
+
before = os.fstat(stream.fileno())
|
|
382
|
+
_validate_source_details(before)
|
|
383
|
+
while chunk := stream.read(1024 * 1024):
|
|
384
|
+
digest.update(chunk)
|
|
385
|
+
after = os.fstat(stream.fileno())
|
|
386
|
+
if (
|
|
387
|
+
before.st_size > maximum
|
|
388
|
+
or after.st_size != before.st_size
|
|
389
|
+
or after.st_mtime_ns != before.st_mtime_ns
|
|
390
|
+
or after.st_ctime_ns != before.st_ctime_ns
|
|
391
|
+
):
|
|
392
|
+
raise ValueError("execution bundle source changed while being read")
|
|
393
|
+
return digest.hexdigest()
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _add(stream: tarfile.TarFile, name: str, content: bytes, mode: int) -> None:
|
|
397
|
+
member = tarfile.TarInfo(name)
|
|
398
|
+
member.size = len(content)
|
|
399
|
+
member.mode = mode
|
|
400
|
+
member.mtime = 0
|
|
401
|
+
member.uid = 0
|
|
402
|
+
member.gid = 0
|
|
403
|
+
member.uname = ""
|
|
404
|
+
member.gname = ""
|
|
405
|
+
stream.addfile(member, io.BytesIO(content))
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _zip_add(archive: zipfile.ZipFile, name: str, content: bytes) -> None:
|
|
409
|
+
member = zipfile.ZipInfo(name)
|
|
410
|
+
member.create_system = 3
|
|
411
|
+
member.date_time = (1980, 1, 1, 0, 0, 0)
|
|
412
|
+
member.external_attr = 0o100600 << 16
|
|
413
|
+
archive.writestr(member, content)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _module_content(path: Path) -> bytes:
|
|
417
|
+
details = path.lstat()
|
|
418
|
+
if path.is_symlink() or not stat.S_ISREG(details.st_mode) or details.st_nlink != 1:
|
|
419
|
+
raise ValueError("run command receiver module is invalid")
|
|
420
|
+
content = path.read_bytes()
|
|
421
|
+
after = path.lstat()
|
|
422
|
+
if (
|
|
423
|
+
len(content) != details.st_size
|
|
424
|
+
or after.st_ino != details.st_ino
|
|
425
|
+
or after.st_mtime_ns != details.st_mtime_ns
|
|
426
|
+
):
|
|
427
|
+
raise ValueError("run command receiver module changed")
|
|
428
|
+
return content
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _receipt(
|
|
432
|
+
*,
|
|
433
|
+
operation_id: str,
|
|
434
|
+
bundle_digest: str,
|
|
435
|
+
bundle_size: int,
|
|
436
|
+
file_count: int,
|
|
437
|
+
inventory_digest: str,
|
|
438
|
+
) -> dict[str, object]:
|
|
439
|
+
result: dict[str, object] = {
|
|
440
|
+
"schema_version": "fdai.execution-bundle-receipt.v1",
|
|
441
|
+
"state": "prepared",
|
|
442
|
+
"operation_id": operation_id,
|
|
443
|
+
"bundle_digest": bundle_digest,
|
|
444
|
+
"bundle_size": bundle_size,
|
|
445
|
+
"file_count": file_count,
|
|
446
|
+
"inventory_digest": inventory_digest,
|
|
447
|
+
"apply_authorized": False,
|
|
448
|
+
"deployment_ready": False,
|
|
449
|
+
"mutation_performed": False,
|
|
450
|
+
}
|
|
451
|
+
result["receipt_digest"] = canonical_digest(result)
|
|
452
|
+
return result
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _file_digest(path: Path, *, maximum: int) -> str:
|
|
456
|
+
details = path.lstat()
|
|
457
|
+
if (
|
|
458
|
+
path.is_symlink()
|
|
459
|
+
or not stat.S_ISREG(details.st_mode)
|
|
460
|
+
or details.st_nlink != 1
|
|
461
|
+
or not 0 < details.st_size <= maximum
|
|
462
|
+
):
|
|
463
|
+
raise ValueError("execution bundle file type or size is invalid")
|
|
464
|
+
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
465
|
+
digest = hashlib.sha256()
|
|
466
|
+
with os.fdopen(descriptor, "rb") as stream:
|
|
467
|
+
opened = os.fstat(stream.fileno())
|
|
468
|
+
while chunk := stream.read(1024 * 1024):
|
|
469
|
+
digest.update(chunk)
|
|
470
|
+
after = os.fstat(stream.fileno())
|
|
471
|
+
if (
|
|
472
|
+
opened.st_ino != details.st_ino
|
|
473
|
+
or opened.st_size != details.st_size
|
|
474
|
+
or after.st_size != opened.st_size
|
|
475
|
+
or after.st_mtime_ns != opened.st_mtime_ns
|
|
476
|
+
):
|
|
477
|
+
raise ValueError("execution bundle file changed while being read")
|
|
478
|
+
return digest.hexdigest()
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Bind the workstation coordinator's fresh kit execution copies to its process."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from contextvars import ContextVar
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
EXECUTION_COPY_PREFIXES = ("offline-source-", "bundle-recheck-")
|
|
15
|
+
_COPY_NAME = re.compile(r"(?:offline-source|bundle-recheck)-[A-Za-z0-9_]{8}")
|
|
16
|
+
_SCOPE: ContextVar[list[Path] | None] = ContextVar("fdai_execution_copy_scope", default=None)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def new_execution_copy(work_dir: Path, prefix: str) -> Path:
|
|
20
|
+
"""Create one fresh private execution-copy parent and register it with an open scope."""
|
|
21
|
+
|
|
22
|
+
if prefix not in EXECUTION_COPY_PREFIXES:
|
|
23
|
+
raise ValueError("deployment kit execution copy prefix is unsupported")
|
|
24
|
+
parent = Path(tempfile.mkdtemp(prefix=prefix, dir=work_dir))
|
|
25
|
+
scope = _SCOPE.get()
|
|
26
|
+
if scope is not None:
|
|
27
|
+
scope.append(parent)
|
|
28
|
+
return parent
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@contextmanager
|
|
32
|
+
def execution_copy_scope() -> Iterator[None]:
|
|
33
|
+
"""Remove the execution copies created inside this scope when it exits.
|
|
34
|
+
|
|
35
|
+
Only the workstation coordinator opens a scope. Its copies are re-derivable from the
|
|
36
|
+
operator-supplied signed kit and no retained record references them, so every exit removes
|
|
37
|
+
them. The managed host opens no scope, because later steps of a run execute Terraform from
|
|
38
|
+
its copy.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
created: list[Path] = []
|
|
42
|
+
token = _SCOPE.set(created)
|
|
43
|
+
try:
|
|
44
|
+
yield
|
|
45
|
+
finally:
|
|
46
|
+
_SCOPE.reset(token)
|
|
47
|
+
remaining = [parent for parent in created if not _remove_execution_copy(parent)]
|
|
48
|
+
if remaining:
|
|
49
|
+
print(
|
|
50
|
+
"fdaictl: warning: execution copies remain for manual removal: "
|
|
51
|
+
+ ", ".join(parent.name for parent in remaining),
|
|
52
|
+
file=sys.stderr,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _remove_execution_copy(parent: Path) -> bool:
|
|
57
|
+
if _COPY_NAME.fullmatch(parent.name) is None or parent.is_symlink():
|
|
58
|
+
return False
|
|
59
|
+
shutil.rmtree(parent, ignore_errors=True)
|
|
60
|
+
return not (parent.exists() or parent.is_symlink())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
__all__ = ["EXECUTION_COPY_PREFIXES", "execution_copy_scope", "new_execution_copy"]
|