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,418 @@
|
|
|
1
|
+
"""Receive one digest-bound execution bundle inside an eligible WSL host."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import stat
|
|
12
|
+
import sys
|
|
13
|
+
import tarfile
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
from pathlib import Path, PurePosixPath
|
|
16
|
+
|
|
17
|
+
from fdai_deployment_cli.contracts import canonical_bytes, canonical_digest
|
|
18
|
+
from fdai_deployment_cli.private_output import read_private_bytes, write_private_bytes
|
|
19
|
+
|
|
20
|
+
_DIGEST = re.compile(r"[0-9a-f]{64}")
|
|
21
|
+
_OPERATION_ID = re.compile(r"[a-z0-9][a-z0-9-]{0,127}")
|
|
22
|
+
_ROOT = "fdai-execution"
|
|
23
|
+
_MANIFEST = f"{_ROOT}/manifest.json"
|
|
24
|
+
_RECEIPT = ".fdai-execution-receipt.json"
|
|
25
|
+
_RETAINED_MANIFEST = ".fdai-execution-manifest.json"
|
|
26
|
+
_MAX_ARCHIVE_BYTES = 512 * 1024 * 1024
|
|
27
|
+
_MAX_FILE_BYTES = 512 * 1024 * 1024
|
|
28
|
+
_MAX_TOTAL_BYTES = 2 * 1024 * 1024 * 1024
|
|
29
|
+
_MAX_MEMBERS = 20_000
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def receive_execution_bundle(
|
|
33
|
+
archive: Path | None,
|
|
34
|
+
destination: Path,
|
|
35
|
+
*,
|
|
36
|
+
expected_digest: str,
|
|
37
|
+
expected_operation_id: str,
|
|
38
|
+
claim_digest: str = "0" * 64,
|
|
39
|
+
verify_existing: bool = False,
|
|
40
|
+
) -> dict[str, object]:
|
|
41
|
+
"""Verify and atomically extract one fixed-inventory execution bundle."""
|
|
42
|
+
|
|
43
|
+
if _DIGEST.fullmatch(expected_digest) is None:
|
|
44
|
+
raise ValueError("execution bundle digest is invalid")
|
|
45
|
+
if _DIGEST.fullmatch(claim_digest) is None:
|
|
46
|
+
raise ValueError("execution bundle claim digest is invalid")
|
|
47
|
+
if _OPERATION_ID.fullmatch(expected_operation_id) is None:
|
|
48
|
+
raise ValueError("execution bundle operation id is invalid")
|
|
49
|
+
if destination.exists() or destination.is_symlink():
|
|
50
|
+
if not verify_existing:
|
|
51
|
+
raise ValueError("execution bundle destination already exists")
|
|
52
|
+
return _verify_existing(
|
|
53
|
+
destination,
|
|
54
|
+
bundle_digest=expected_digest,
|
|
55
|
+
operation_id=expected_operation_id,
|
|
56
|
+
claim_digest=claim_digest,
|
|
57
|
+
)
|
|
58
|
+
if verify_existing:
|
|
59
|
+
raise ValueError("execution bundle verification requires an existing destination")
|
|
60
|
+
if archive is None:
|
|
61
|
+
raise ValueError("execution bundle archive is required for fresh extraction")
|
|
62
|
+
observed_digest = _archive_digest(archive)
|
|
63
|
+
if observed_digest != expected_digest:
|
|
64
|
+
raise ValueError("execution bundle digest differs")
|
|
65
|
+
|
|
66
|
+
temporary = destination.with_name(f".{destination.name}.extract-{os.getpid()}")
|
|
67
|
+
if temporary.exists() or temporary.is_symlink():
|
|
68
|
+
raise ValueError("execution bundle temporary destination already exists")
|
|
69
|
+
temporary.mkdir(mode=0o700)
|
|
70
|
+
published = False
|
|
71
|
+
try:
|
|
72
|
+
manifest, files = _extract(archive, temporary)
|
|
73
|
+
_validate_manifest(manifest, expected_operation_id, files)
|
|
74
|
+
_apply_modes(temporary, manifest)
|
|
75
|
+
result = _receipt(
|
|
76
|
+
bundle_digest=observed_digest,
|
|
77
|
+
operation_id=expected_operation_id,
|
|
78
|
+
claim_digest=claim_digest,
|
|
79
|
+
files=files,
|
|
80
|
+
inventory_digest=_manifest_inventory_digest(manifest),
|
|
81
|
+
)
|
|
82
|
+
write_private_bytes(
|
|
83
|
+
temporary / _RETAINED_MANIFEST,
|
|
84
|
+
canonical_bytes(manifest),
|
|
85
|
+
)
|
|
86
|
+
write_private_bytes(temporary / _RECEIPT, canonical_bytes(result))
|
|
87
|
+
_sync_tree_directories(temporary)
|
|
88
|
+
os.rename(temporary, destination)
|
|
89
|
+
_sync_directory(destination.parent)
|
|
90
|
+
published = True
|
|
91
|
+
return result
|
|
92
|
+
finally:
|
|
93
|
+
if not published:
|
|
94
|
+
shutil.rmtree(temporary, ignore_errors=True)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _sync_directory(path: Path) -> None:
|
|
98
|
+
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
99
|
+
try:
|
|
100
|
+
os.fsync(descriptor)
|
|
101
|
+
finally:
|
|
102
|
+
os.close(descriptor)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _sync_tree_directories(root: Path) -> None:
|
|
106
|
+
directories = sorted(
|
|
107
|
+
(path for path in root.rglob("*") if path.is_dir()),
|
|
108
|
+
key=lambda path: len(path.parts),
|
|
109
|
+
reverse=True,
|
|
110
|
+
)
|
|
111
|
+
for directory in (*directories, root):
|
|
112
|
+
if directory.is_symlink():
|
|
113
|
+
raise ValueError("execution bundle extracted directory is a link")
|
|
114
|
+
_sync_directory(directory)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _extract(archive: Path, destination: Path) -> tuple[dict[str, object], dict[str, str]]:
|
|
118
|
+
manifest: dict[str, object] | None = None
|
|
119
|
+
files: dict[str, str] = {}
|
|
120
|
+
total_bytes = 0
|
|
121
|
+
try:
|
|
122
|
+
with tarfile.open(archive, mode="r:gz") as stream:
|
|
123
|
+
payload_count = 0
|
|
124
|
+
member_count = 0
|
|
125
|
+
for member in stream:
|
|
126
|
+
member_count += 1
|
|
127
|
+
if member_count > _MAX_MEMBERS + 1:
|
|
128
|
+
raise ValueError("execution bundle archive member count exceeds its limit")
|
|
129
|
+
relative = _member_path(member.name)
|
|
130
|
+
if member.isdir():
|
|
131
|
+
continue
|
|
132
|
+
if not member.isfile() or member.size > _MAX_FILE_BYTES:
|
|
133
|
+
raise ValueError("execution bundle member type or size is invalid")
|
|
134
|
+
source = stream.extractfile(member)
|
|
135
|
+
if source is None:
|
|
136
|
+
raise ValueError("execution bundle member is unreadable")
|
|
137
|
+
if relative == "manifest.json":
|
|
138
|
+
if manifest is not None or member.size > 4 * 1024 * 1024:
|
|
139
|
+
raise ValueError("execution bundle manifest is invalid")
|
|
140
|
+
payload = source.read(member.size + 1)
|
|
141
|
+
if len(payload) != member.size:
|
|
142
|
+
raise ValueError("execution bundle manifest size differs")
|
|
143
|
+
value = json.loads(payload)
|
|
144
|
+
if not isinstance(value, dict):
|
|
145
|
+
raise ValueError("execution bundle manifest is invalid")
|
|
146
|
+
manifest = value
|
|
147
|
+
continue
|
|
148
|
+
total_bytes += member.size
|
|
149
|
+
if total_bytes > _MAX_TOTAL_BYTES:
|
|
150
|
+
raise ValueError("execution bundle exceeds its total size limit")
|
|
151
|
+
payload_count += 1
|
|
152
|
+
if payload_count > _MAX_MEMBERS:
|
|
153
|
+
raise ValueError("execution bundle member count exceeds its limit")
|
|
154
|
+
if relative in files:
|
|
155
|
+
raise ValueError("execution bundle repeats a file")
|
|
156
|
+
target = destination.joinpath(*PurePosixPath(relative).parts)
|
|
157
|
+
target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
158
|
+
descriptor = os.open(
|
|
159
|
+
target,
|
|
160
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
|
161
|
+
0o600,
|
|
162
|
+
)
|
|
163
|
+
digest = hashlib.sha256()
|
|
164
|
+
copied = 0
|
|
165
|
+
with source, os.fdopen(descriptor, "wb") as output:
|
|
166
|
+
while chunk := source.read(1024 * 1024):
|
|
167
|
+
copied += len(chunk)
|
|
168
|
+
if copied > member.size:
|
|
169
|
+
raise ValueError("execution bundle member exceeded its declared size")
|
|
170
|
+
output.write(chunk)
|
|
171
|
+
digest.update(chunk)
|
|
172
|
+
output.flush()
|
|
173
|
+
os.fsync(output.fileno())
|
|
174
|
+
if copied != member.size:
|
|
175
|
+
raise ValueError("execution bundle member size differs")
|
|
176
|
+
files[relative] = digest.hexdigest()
|
|
177
|
+
except (tarfile.TarError, EOFError, json.JSONDecodeError) as exc:
|
|
178
|
+
raise ValueError("execution bundle archive is invalid") from exc
|
|
179
|
+
if manifest is None or not files:
|
|
180
|
+
raise ValueError("execution bundle manifest or payload is missing")
|
|
181
|
+
return manifest, dict(sorted(files.items()))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _member_path(value: str) -> str:
|
|
185
|
+
path = PurePosixPath(value)
|
|
186
|
+
if (
|
|
187
|
+
path.is_absolute()
|
|
188
|
+
or "\\" in value
|
|
189
|
+
or not path.parts
|
|
190
|
+
or path.parts[0] != _ROOT
|
|
191
|
+
or any(part in {"", ".", ".."} for part in path.parts)
|
|
192
|
+
):
|
|
193
|
+
raise ValueError("execution bundle path is invalid")
|
|
194
|
+
relative = PurePosixPath(*path.parts[1:]).as_posix()
|
|
195
|
+
if not relative or relative == _RECEIPT:
|
|
196
|
+
raise ValueError("execution bundle path is invalid")
|
|
197
|
+
return relative
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _validate_manifest(
|
|
201
|
+
manifest: dict[str, object], operation_id: str, files: dict[str, str]
|
|
202
|
+
) -> None:
|
|
203
|
+
declared = manifest.get("files")
|
|
204
|
+
if (
|
|
205
|
+
set(manifest) != {"schema_version", "operation_id", "files"}
|
|
206
|
+
or manifest.get("schema_version") != "fdai.execution-bundle-manifest.v1"
|
|
207
|
+
or manifest.get("operation_id") != operation_id
|
|
208
|
+
or not isinstance(declared, dict)
|
|
209
|
+
or set(declared) != set(files)
|
|
210
|
+
):
|
|
211
|
+
raise ValueError("execution bundle manifest differs from extracted payload")
|
|
212
|
+
for relative, digest in files.items():
|
|
213
|
+
record = declared.get(relative)
|
|
214
|
+
if (
|
|
215
|
+
not isinstance(record, dict)
|
|
216
|
+
or set(record) != {"sha256", "mode"}
|
|
217
|
+
or record.get("sha256") != digest
|
|
218
|
+
or record.get("mode") not in {"0600", "0700"}
|
|
219
|
+
):
|
|
220
|
+
raise ValueError("execution bundle manifest differs from extracted payload")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _receipt(
|
|
224
|
+
*,
|
|
225
|
+
bundle_digest: str,
|
|
226
|
+
operation_id: str,
|
|
227
|
+
claim_digest: str,
|
|
228
|
+
files: dict[str, str],
|
|
229
|
+
inventory_digest: str,
|
|
230
|
+
) -> dict[str, object]:
|
|
231
|
+
result: dict[str, object] = {
|
|
232
|
+
"schema_version": "fdai.run-command-bundle-host-result.v1",
|
|
233
|
+
"state": "verified",
|
|
234
|
+
"operation_id": operation_id,
|
|
235
|
+
"bundle_digest": bundle_digest,
|
|
236
|
+
"claim_digest": claim_digest,
|
|
237
|
+
"file_count": len(files),
|
|
238
|
+
"inventory_digest": inventory_digest,
|
|
239
|
+
"mutation_performed": False,
|
|
240
|
+
}
|
|
241
|
+
result["receipt_digest"] = canonical_digest(result)
|
|
242
|
+
return result
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _verify_existing(
|
|
246
|
+
destination: Path,
|
|
247
|
+
*,
|
|
248
|
+
bundle_digest: str,
|
|
249
|
+
operation_id: str,
|
|
250
|
+
claim_digest: str,
|
|
251
|
+
) -> dict[str, object]:
|
|
252
|
+
receipt = json.loads(read_private_bytes(destination / _RECEIPT, max_bytes=16_384))
|
|
253
|
+
if not isinstance(receipt, dict):
|
|
254
|
+
raise ValueError("execution bundle retained receipt is invalid")
|
|
255
|
+
manifest = json.loads(
|
|
256
|
+
read_private_bytes(destination / _RETAINED_MANIFEST, max_bytes=4 * 1024 * 1024)
|
|
257
|
+
)
|
|
258
|
+
if not isinstance(manifest, dict):
|
|
259
|
+
raise ValueError("execution bundle retained manifest is invalid")
|
|
260
|
+
files = _destination_files(destination, manifest)
|
|
261
|
+
_validate_manifest(manifest, operation_id, files)
|
|
262
|
+
expected = _receipt(
|
|
263
|
+
bundle_digest=bundle_digest,
|
|
264
|
+
operation_id=operation_id,
|
|
265
|
+
claim_digest=claim_digest,
|
|
266
|
+
files=files,
|
|
267
|
+
inventory_digest=_manifest_inventory_digest(manifest),
|
|
268
|
+
)
|
|
269
|
+
if receipt != expected:
|
|
270
|
+
raise ValueError("execution bundle retained destination differs")
|
|
271
|
+
return expected
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _destination_files(destination: Path, manifest: dict[str, object]) -> dict[str, str]:
|
|
275
|
+
root_details = destination.lstat()
|
|
276
|
+
if (
|
|
277
|
+
destination.is_symlink()
|
|
278
|
+
or not stat.S_ISDIR(root_details.st_mode)
|
|
279
|
+
or stat.S_IMODE(root_details.st_mode) != 0o700
|
|
280
|
+
or root_details.st_uid != os.geteuid()
|
|
281
|
+
):
|
|
282
|
+
raise ValueError("execution bundle retained destination is invalid")
|
|
283
|
+
declared = manifest.get("files")
|
|
284
|
+
if not isinstance(declared, dict):
|
|
285
|
+
raise ValueError("execution bundle manifest is invalid")
|
|
286
|
+
files: dict[str, str] = {}
|
|
287
|
+
total_bytes = 0
|
|
288
|
+
for directory, directories, names in os.walk(destination, followlinks=False):
|
|
289
|
+
base = Path(directory)
|
|
290
|
+
if any((base / name).is_symlink() for name in directories):
|
|
291
|
+
raise ValueError("execution bundle retained destination contains a link")
|
|
292
|
+
for name in names:
|
|
293
|
+
candidate = base / name
|
|
294
|
+
relative = candidate.relative_to(destination).as_posix()
|
|
295
|
+
if relative in {_RECEIPT, _RETAINED_MANIFEST}:
|
|
296
|
+
continue
|
|
297
|
+
if len(files) >= _MAX_MEMBERS:
|
|
298
|
+
raise ValueError("execution bundle retained destination exceeds its file limit")
|
|
299
|
+
details = candidate.lstat()
|
|
300
|
+
record = declared.get(relative)
|
|
301
|
+
if (
|
|
302
|
+
candidate.is_symlink()
|
|
303
|
+
or not stat.S_ISREG(details.st_mode)
|
|
304
|
+
or details.st_nlink != 1
|
|
305
|
+
or not isinstance(record, dict)
|
|
306
|
+
or record.get("mode") not in {"0600", "0700"}
|
|
307
|
+
or stat.S_IMODE(details.st_mode) != int(str(record["mode"]), 8)
|
|
308
|
+
or details.st_size > _MAX_FILE_BYTES
|
|
309
|
+
):
|
|
310
|
+
raise ValueError("execution bundle retained destination is invalid")
|
|
311
|
+
total_bytes += details.st_size
|
|
312
|
+
if total_bytes > _MAX_TOTAL_BYTES:
|
|
313
|
+
raise ValueError("execution bundle retained destination exceeds its size limit")
|
|
314
|
+
descriptor = os.open(candidate, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
315
|
+
digest = hashlib.sha256()
|
|
316
|
+
with os.fdopen(descriptor, "rb") as stream:
|
|
317
|
+
opened = os.fstat(stream.fileno())
|
|
318
|
+
copied = 0
|
|
319
|
+
while chunk := stream.read(1024 * 1024):
|
|
320
|
+
copied += len(chunk)
|
|
321
|
+
if copied > details.st_size:
|
|
322
|
+
raise ValueError("execution bundle retained file grew")
|
|
323
|
+
digest.update(chunk)
|
|
324
|
+
after = os.fstat(stream.fileno())
|
|
325
|
+
if (
|
|
326
|
+
copied != details.st_size
|
|
327
|
+
or opened.st_ino != details.st_ino
|
|
328
|
+
or after.st_size != opened.st_size
|
|
329
|
+
or after.st_mtime_ns != opened.st_mtime_ns
|
|
330
|
+
or after.st_ctime_ns != opened.st_ctime_ns
|
|
331
|
+
):
|
|
332
|
+
raise ValueError("execution bundle retained file changed")
|
|
333
|
+
files[relative] = digest.hexdigest()
|
|
334
|
+
return dict(sorted(files.items()))
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _apply_modes(destination: Path, manifest: dict[str, object]) -> None:
|
|
338
|
+
declared = manifest.get("files")
|
|
339
|
+
if not isinstance(declared, dict):
|
|
340
|
+
raise ValueError("execution bundle manifest is invalid")
|
|
341
|
+
for relative, value in declared.items():
|
|
342
|
+
if not isinstance(relative, str) or not isinstance(value, dict):
|
|
343
|
+
raise ValueError("execution bundle manifest is invalid")
|
|
344
|
+
mode = value.get("mode")
|
|
345
|
+
if mode not in {"0600", "0700"}:
|
|
346
|
+
raise ValueError("execution bundle manifest mode is invalid")
|
|
347
|
+
path = destination.joinpath(*PurePosixPath(relative).parts)
|
|
348
|
+
if path.is_symlink() or not path.is_file():
|
|
349
|
+
raise ValueError("execution bundle manifest file is invalid")
|
|
350
|
+
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
351
|
+
try:
|
|
352
|
+
os.fchmod(descriptor, int(mode, 8))
|
|
353
|
+
os.fsync(descriptor)
|
|
354
|
+
finally:
|
|
355
|
+
os.close(descriptor)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _manifest_inventory_digest(manifest: dict[str, object]) -> str:
|
|
359
|
+
declared = manifest.get("files")
|
|
360
|
+
if not isinstance(declared, dict):
|
|
361
|
+
raise ValueError("execution bundle manifest is invalid")
|
|
362
|
+
return canonical_digest({"files": declared})
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _archive_digest(path: Path) -> str:
|
|
366
|
+
details = path.lstat()
|
|
367
|
+
if (
|
|
368
|
+
path.is_symlink()
|
|
369
|
+
or not stat.S_ISREG(details.st_mode)
|
|
370
|
+
or not 0 < details.st_size <= _MAX_ARCHIVE_BYTES
|
|
371
|
+
):
|
|
372
|
+
raise ValueError("execution bundle archive type or size is invalid")
|
|
373
|
+
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
374
|
+
digest = hashlib.sha256()
|
|
375
|
+
with os.fdopen(descriptor, "rb") as stream:
|
|
376
|
+
opened = os.fstat(stream.fileno())
|
|
377
|
+
while chunk := stream.read(1024 * 1024):
|
|
378
|
+
digest.update(chunk)
|
|
379
|
+
after = os.fstat(stream.fileno())
|
|
380
|
+
if (
|
|
381
|
+
opened.st_ino != details.st_ino
|
|
382
|
+
or opened.st_size != details.st_size
|
|
383
|
+
or after.st_size != opened.st_size
|
|
384
|
+
or after.st_mtime_ns != opened.st_mtime_ns
|
|
385
|
+
):
|
|
386
|
+
raise ValueError("execution bundle archive changed while being read")
|
|
387
|
+
return digest.hexdigest()
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def main(arguments: Sequence[str] | None = None) -> int:
|
|
391
|
+
"""Receive one fixed execution bundle and emit a bounded machine result."""
|
|
392
|
+
|
|
393
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
394
|
+
parser.add_argument("--archive", type=Path)
|
|
395
|
+
parser.add_argument("--destination", type=Path, required=True)
|
|
396
|
+
parser.add_argument("--bundle-digest", required=True)
|
|
397
|
+
parser.add_argument("--operation-id", required=True)
|
|
398
|
+
parser.add_argument("--claim-digest", required=True)
|
|
399
|
+
parser.add_argument("--verify-existing", action="store_true")
|
|
400
|
+
args = parser.parse_args(arguments)
|
|
401
|
+
try:
|
|
402
|
+
result = receive_execution_bundle(
|
|
403
|
+
args.archive,
|
|
404
|
+
args.destination,
|
|
405
|
+
expected_digest=args.bundle_digest,
|
|
406
|
+
expected_operation_id=args.operation_id,
|
|
407
|
+
claim_digest=args.claim_digest,
|
|
408
|
+
verify_existing=args.verify_existing,
|
|
409
|
+
)
|
|
410
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
411
|
+
print("run command receiver failed; preserve retained evidence", file=sys.stderr)
|
|
412
|
+
return 3
|
|
413
|
+
print(canonical_bytes(result).decode())
|
|
414
|
+
return 0
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
if __name__ == "__main__":
|
|
418
|
+
raise SystemExit(main())
|