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,399 @@
|
|
|
1
|
+
"""Transport verified source snapshots without release keys, archive paths or Git metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import gzip
|
|
7
|
+
import hashlib
|
|
8
|
+
import io
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import stat
|
|
13
|
+
import sys
|
|
14
|
+
import tarfile
|
|
15
|
+
from collections import deque
|
|
16
|
+
from concurrent.futures import Future, ThreadPoolExecutor
|
|
17
|
+
from pathlib import Path, PurePosixPath
|
|
18
|
+
from tempfile import TemporaryDirectory
|
|
19
|
+
from typing import BinaryIO, cast
|
|
20
|
+
|
|
21
|
+
from fdai_deployment_cli.contracts import canonical_bytes, canonical_digest, load_json_object
|
|
22
|
+
from fdai_deployment_cli.private_output import (
|
|
23
|
+
_open_private_parent,
|
|
24
|
+
read_private_bytes,
|
|
25
|
+
write_private_bytes,
|
|
26
|
+
)
|
|
27
|
+
from fdai_deployment_cli.source_input import _read_tracked
|
|
28
|
+
from fdai_deployment_cli.source_snapshot import verify_source_snapshot
|
|
29
|
+
|
|
30
|
+
_MANIFEST = "source-input.json"
|
|
31
|
+
_MAX_FILE = 64 * 1024 * 1024
|
|
32
|
+
_MAX_TOTAL = 2 * 1024 * 1024 * 1024
|
|
33
|
+
_MAX_ARCHIVE = _MAX_TOTAL + 128 * 1024 * 1024
|
|
34
|
+
_MAX_MANIFEST = 16 * 1024 * 1024
|
|
35
|
+
_TRANSPORT_COMPRESSION = "gzip"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def prepare_source_transport(
|
|
39
|
+
snapshot: Path, work_dir: Path, *, snapshot_digest: str
|
|
40
|
+
) -> dict[str, object]:
|
|
41
|
+
"""Prepare or reverify an exact local source transfer without touching a remote host."""
|
|
42
|
+
source = verify_source_snapshot(snapshot, expected_digest=snapshot_digest)
|
|
43
|
+
archive = work_dir / (
|
|
44
|
+
"source-transfer.tar.gz" if _supports_gzip_transport(snapshot) else "source-transfer.tar"
|
|
45
|
+
)
|
|
46
|
+
receipt_path = work_dir / "source-transfer.json"
|
|
47
|
+
if receipt_path.exists() or receipt_path.is_symlink():
|
|
48
|
+
receipt = load_json_object(
|
|
49
|
+
read_private_bytes(receipt_path, max_bytes=16384), label="source transfer receipt"
|
|
50
|
+
)
|
|
51
|
+
digest = receipt.pop("receipt_digest", None)
|
|
52
|
+
if (
|
|
53
|
+
canonical_digest(receipt) != digest
|
|
54
|
+
or receipt.get("snapshot_digest") != snapshot_digest
|
|
55
|
+
or receipt.get("source_commit") != source["source_commit"]
|
|
56
|
+
):
|
|
57
|
+
raise ValueError("source transfer receipt differs from current source")
|
|
58
|
+
archive_digest = receipt.get("archive_digest")
|
|
59
|
+
if not isinstance(archive_digest, str):
|
|
60
|
+
raise ValueError("source transfer archive digest is unavailable")
|
|
61
|
+
else:
|
|
62
|
+
archive_digest = archive_source_snapshot(snapshot, archive, snapshot_digest=snapshot_digest)
|
|
63
|
+
receipt = None
|
|
64
|
+
with TemporaryDirectory(prefix=".source-transfer-verify-", dir=work_dir) as temporary:
|
|
65
|
+
verified = receive_source_snapshot(
|
|
66
|
+
archive,
|
|
67
|
+
Path(temporary) / "snapshot",
|
|
68
|
+
archive_digest=archive_digest,
|
|
69
|
+
snapshot_digest=snapshot_digest,
|
|
70
|
+
)
|
|
71
|
+
result = {
|
|
72
|
+
**verified,
|
|
73
|
+
"archive_ref": archive.name,
|
|
74
|
+
"state": "prepared",
|
|
75
|
+
"remote_transfer_verified": False,
|
|
76
|
+
}
|
|
77
|
+
if receipt is not None and canonical_bytes(result) != canonical_bytes(receipt):
|
|
78
|
+
raise ValueError("source transfer receipt differs from verified archive")
|
|
79
|
+
result["receipt_digest"] = canonical_digest(result)
|
|
80
|
+
if receipt is None:
|
|
81
|
+
write_private_bytes(receipt_path, canonical_bytes(result))
|
|
82
|
+
return result
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def archive_source_snapshot(snapshot: Path, destination: Path, *, snapshot_digest: str) -> str:
|
|
86
|
+
"""Create a private deterministic source archive and return its independently retained hash.
|
|
87
|
+
|
|
88
|
+
Every member is a regular numbered blob; symlink targets are data, never tar links.
|
|
89
|
+
Existing archives are never replaced. Failed outputs remain for bounded inspection.
|
|
90
|
+
"""
|
|
91
|
+
verify_source_snapshot(snapshot, expected_digest=snapshot_digest)
|
|
92
|
+
manifest_bytes = read_private_bytes(snapshot / _MANIFEST, max_bytes=_MAX_MANIFEST)
|
|
93
|
+
records = _records(manifest_bytes, snapshot_digest)
|
|
94
|
+
parent = _open_private_parent(destination)
|
|
95
|
+
try:
|
|
96
|
+
descriptor = os.open(
|
|
97
|
+
destination.name,
|
|
98
|
+
os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
|
99
|
+
0o600,
|
|
100
|
+
dir_fd=parent,
|
|
101
|
+
)
|
|
102
|
+
finally:
|
|
103
|
+
os.close(parent)
|
|
104
|
+
with os.fdopen(descriptor, "w+b") as output:
|
|
105
|
+
if destination.name.endswith(".tar.gz"):
|
|
106
|
+
compressed = gzip.GzipFile(
|
|
107
|
+
fileobj=output,
|
|
108
|
+
mode="wb",
|
|
109
|
+
filename="",
|
|
110
|
+
mtime=0,
|
|
111
|
+
compresslevel=6,
|
|
112
|
+
)
|
|
113
|
+
archive_file = cast(BinaryIO, compressed)
|
|
114
|
+
elif destination.name.endswith(".tar"):
|
|
115
|
+
compressed = None
|
|
116
|
+
archive_file = output
|
|
117
|
+
else:
|
|
118
|
+
raise ValueError("source transport archive suffix is unsupported")
|
|
119
|
+
try:
|
|
120
|
+
with tarfile.open(
|
|
121
|
+
fileobj=archive_file,
|
|
122
|
+
mode="w",
|
|
123
|
+
format=tarfile.USTAR_FORMAT,
|
|
124
|
+
) as archive:
|
|
125
|
+
_add_blob(archive, _MANIFEST, manifest_bytes)
|
|
126
|
+
total = 0
|
|
127
|
+
for index, record in enumerate(records):
|
|
128
|
+
content = _read_tracked(snapshot / "tree", record["path"], mode=record["mode"])
|
|
129
|
+
total += len(content)
|
|
130
|
+
if len(content) > _MAX_FILE or total > _MAX_TOTAL:
|
|
131
|
+
raise ValueError("source transport content exceeds its bound")
|
|
132
|
+
if hashlib.sha256(content).hexdigest() != record["sha256"]:
|
|
133
|
+
raise ValueError("source transport content differs from its manifest")
|
|
134
|
+
_add_blob(archive, f"blobs/{index:08d}", content)
|
|
135
|
+
finally:
|
|
136
|
+
if compressed is not None:
|
|
137
|
+
compressed.close()
|
|
138
|
+
output.flush()
|
|
139
|
+
os.fsync(output.fileno())
|
|
140
|
+
verify_source_snapshot(snapshot, expected_digest=snapshot_digest)
|
|
141
|
+
return _digest(output)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def receive_source_snapshot(
|
|
145
|
+
archive_path: Path,
|
|
146
|
+
destination: Path,
|
|
147
|
+
*,
|
|
148
|
+
archive_digest: str,
|
|
149
|
+
snapshot_digest: str,
|
|
150
|
+
verify_existing: bool = False,
|
|
151
|
+
) -> dict[str, object]:
|
|
152
|
+
"""Verify the transferred bytes and recreate a fresh snapshot before any source execution.
|
|
153
|
+
|
|
154
|
+
Both expected digests must arrive through the authenticated deployment handoff, not
|
|
155
|
+
from the archive itself. No existing tree/state is adopted or deleted. Partial outputs
|
|
156
|
+
remain on failure and cannot be resumed as verified. This does not authorize deployment.
|
|
157
|
+
Verification-only recovery reads existing files without replacing or repairing them.
|
|
158
|
+
"""
|
|
159
|
+
for digest in (archive_digest, snapshot_digest):
|
|
160
|
+
if re.fullmatch(r"[0-9a-f]{64}", digest) is None:
|
|
161
|
+
raise ValueError("source transport requires exact independent digests")
|
|
162
|
+
parent = _open_private_parent(archive_path)
|
|
163
|
+
try:
|
|
164
|
+
descriptor = os.open(
|
|
165
|
+
archive_path.name, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW, dir_fd=parent
|
|
166
|
+
)
|
|
167
|
+
finally:
|
|
168
|
+
os.close(parent)
|
|
169
|
+
with os.fdopen(descriptor, "rb") as source:
|
|
170
|
+
before = os.fstat(source.fileno())
|
|
171
|
+
if (
|
|
172
|
+
not stat.S_ISREG(before.st_mode)
|
|
173
|
+
or stat.S_IMODE(before.st_mode) != 0o600
|
|
174
|
+
or before.st_uid != os.geteuid()
|
|
175
|
+
or before.st_nlink != 1
|
|
176
|
+
or not 0 < before.st_size <= _MAX_ARCHIVE
|
|
177
|
+
):
|
|
178
|
+
raise ValueError("source transport requires a private bounded single-link archive")
|
|
179
|
+
if _digest(source) != archive_digest:
|
|
180
|
+
raise ValueError("source transport archive digest differs")
|
|
181
|
+
source.seek(0)
|
|
182
|
+
with (
|
|
183
|
+
tarfile.open(fileobj=source, mode="r:*") as archive,
|
|
184
|
+
ThreadPoolExecutor(max_workers=4) as writer,
|
|
185
|
+
):
|
|
186
|
+
manifest_bytes = _blob(archive, _MANIFEST, _MAX_MANIFEST)
|
|
187
|
+
records = _records(manifest_bytes, snapshot_digest)
|
|
188
|
+
tree = destination / "tree"
|
|
189
|
+
if not verify_existing:
|
|
190
|
+
parent = _open_private_parent(destination)
|
|
191
|
+
try:
|
|
192
|
+
os.mkdir(destination.name, 0o700, dir_fd=parent)
|
|
193
|
+
finally:
|
|
194
|
+
os.close(parent)
|
|
195
|
+
tree.mkdir(mode=0o700)
|
|
196
|
+
write_private_bytes(destination / _MANIFEST, manifest_bytes)
|
|
197
|
+
links: list[tuple[Path, str]] = []
|
|
198
|
+
pending: deque[Future[None]] = deque()
|
|
199
|
+
total = 0
|
|
200
|
+
for index, record in enumerate(records):
|
|
201
|
+
content = _blob(archive, f"blobs/{index:08d}", _MAX_FILE)
|
|
202
|
+
total += len(content)
|
|
203
|
+
if total > _MAX_TOTAL or hashlib.sha256(content).hexdigest() != record["sha256"]:
|
|
204
|
+
raise ValueError("source transport blob differs or exceeds its bound")
|
|
205
|
+
if verify_existing:
|
|
206
|
+
continue
|
|
207
|
+
output = tree / record["path"]
|
|
208
|
+
output.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
209
|
+
for ancestor in output.parents:
|
|
210
|
+
if ancestor == tree:
|
|
211
|
+
break
|
|
212
|
+
ancestor.chmod(0o700)
|
|
213
|
+
if record["mode"] == "120000":
|
|
214
|
+
target = os.fsdecode(content)
|
|
215
|
+
if not target or "\x00" in target or Path(target).is_absolute():
|
|
216
|
+
raise ValueError("source transport symlink target is invalid")
|
|
217
|
+
links.append((output, target))
|
|
218
|
+
else:
|
|
219
|
+
pending.append(writer.submit(_restore_file, output, content, record["mode"]))
|
|
220
|
+
if len(pending) == 4:
|
|
221
|
+
pending.popleft().result()
|
|
222
|
+
for completed in pending:
|
|
223
|
+
completed.result()
|
|
224
|
+
if archive.next() is not None:
|
|
225
|
+
raise ValueError("source transport contains extra archive members")
|
|
226
|
+
for output, target in links:
|
|
227
|
+
output.symlink_to(target)
|
|
228
|
+
after = os.fstat(source.fileno())
|
|
229
|
+
if (
|
|
230
|
+
before.st_size != after.st_size
|
|
231
|
+
or before.st_mtime_ns != after.st_mtime_ns
|
|
232
|
+
or before.st_ctime_ns != after.st_ctime_ns
|
|
233
|
+
or _digest(source) != archive_digest
|
|
234
|
+
):
|
|
235
|
+
raise ValueError("source transport changed during reception")
|
|
236
|
+
source_record = verify_source_snapshot(destination, expected_digest=snapshot_digest)
|
|
237
|
+
return {
|
|
238
|
+
"schema_version": "fdai.source-transport-receipt.v1",
|
|
239
|
+
"source_commit": source_record["source_commit"],
|
|
240
|
+
"archive_digest": archive_digest,
|
|
241
|
+
"snapshot_digest": snapshot_digest,
|
|
242
|
+
"provenance": "operator-selected-source",
|
|
243
|
+
"release_signature_verified": False,
|
|
244
|
+
"apply_authorized": False,
|
|
245
|
+
"deployment_ready": False,
|
|
246
|
+
"mutation_performed": False,
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _supports_gzip_transport(snapshot: Path) -> bool:
|
|
251
|
+
relative = "packages/deployment-cli/src/fdai_deployment_cli/source_transport.py"
|
|
252
|
+
path = snapshot / "tree" / relative
|
|
253
|
+
if path.is_symlink() or not path.is_file():
|
|
254
|
+
return False
|
|
255
|
+
content = read_private_bytes(path, max_bytes=1024 * 1024)
|
|
256
|
+
return b'_TRANSPORT_COMPRESSION = "gzip"' in content
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _restore_file(output: Path, content: bytes, mode: str) -> None:
|
|
260
|
+
write_private_bytes(output, content)
|
|
261
|
+
if mode == "100755":
|
|
262
|
+
output.chmod(0o700)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _records(raw: bytes, expected_digest: str) -> list[dict[str, str]]:
|
|
266
|
+
manifest = load_json_object(raw, label="source transport manifest", max_bytes=_MAX_MANIFEST)
|
|
267
|
+
if canonical_digest(manifest) != expected_digest or canonical_bytes(manifest) != raw:
|
|
268
|
+
raise ValueError("source transport manifest digest differs")
|
|
269
|
+
if (
|
|
270
|
+
set(manifest) != {"schema_version", "source", "files"}
|
|
271
|
+
or manifest["schema_version"] != "fdai.source-snapshot.v1"
|
|
272
|
+
):
|
|
273
|
+
raise ValueError("source transport manifest schema is invalid")
|
|
274
|
+
source, records = manifest["source"], manifest["files"]
|
|
275
|
+
if (
|
|
276
|
+
not isinstance(source, dict)
|
|
277
|
+
or source.get("provenance") != "operator-selected-source"
|
|
278
|
+
or source.get("release_signature_verified") is not False
|
|
279
|
+
or not isinstance(records, list)
|
|
280
|
+
or not 1 <= len(records) <= 65536
|
|
281
|
+
or type(source.get("file_count")) is not int
|
|
282
|
+
or source["file_count"] != len(records)
|
|
283
|
+
or canonical_digest({"files": records}) != source.get("content_digest")
|
|
284
|
+
):
|
|
285
|
+
raise ValueError("source transport inventory is invalid")
|
|
286
|
+
if (
|
|
287
|
+
set(source)
|
|
288
|
+
!= {
|
|
289
|
+
"schema_version",
|
|
290
|
+
"provenance",
|
|
291
|
+
"source_commit",
|
|
292
|
+
"source_tree",
|
|
293
|
+
"content_digest",
|
|
294
|
+
"file_count",
|
|
295
|
+
"release_signature_verified",
|
|
296
|
+
}
|
|
297
|
+
or source["schema_version"] != "fdai.source-deployment-input.v1"
|
|
298
|
+
or not isinstance(source.get("source_commit"), str)
|
|
299
|
+
or re.fullmatch(r"[0-9a-f]{40}", source["source_commit"]) is None
|
|
300
|
+
or not isinstance(source.get("source_tree"), str)
|
|
301
|
+
or re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", source["source_tree"]) is None
|
|
302
|
+
):
|
|
303
|
+
raise ValueError("source transport source identity is invalid")
|
|
304
|
+
names: set[str] = set()
|
|
305
|
+
result = []
|
|
306
|
+
for record in records:
|
|
307
|
+
if (
|
|
308
|
+
not isinstance(record, dict)
|
|
309
|
+
or set(record) != {"path", "mode", "sha256"}
|
|
310
|
+
or not all(isinstance(value, str) for value in record.values())
|
|
311
|
+
):
|
|
312
|
+
raise ValueError("source transport file record is invalid")
|
|
313
|
+
path = PurePosixPath(record["path"])
|
|
314
|
+
if (
|
|
315
|
+
path.is_absolute()
|
|
316
|
+
or path.as_posix() != record["path"]
|
|
317
|
+
or ".." in path.parts
|
|
318
|
+
or not path.parts
|
|
319
|
+
or "\x00" in record["path"]
|
|
320
|
+
or ".git" in path.parts
|
|
321
|
+
or record["path"] in names
|
|
322
|
+
or record["mode"] not in {"100644", "100755", "120000"}
|
|
323
|
+
or re.fullmatch(r"[0-9a-f]{64}", record["sha256"]) is None
|
|
324
|
+
):
|
|
325
|
+
raise ValueError("source transport path or mode is invalid")
|
|
326
|
+
names.add(record["path"])
|
|
327
|
+
result.append(record)
|
|
328
|
+
if any(parent.as_posix() in names for name in names for parent in PurePosixPath(name).parents):
|
|
329
|
+
raise ValueError("source transport file and directory paths conflict")
|
|
330
|
+
return result
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _add_blob(archive: tarfile.TarFile, name: str, content: bytes) -> None:
|
|
334
|
+
member = tarfile.TarInfo(name)
|
|
335
|
+
member.size, member.mode = len(content), 0o600
|
|
336
|
+
archive.addfile(member, io.BytesIO(content))
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _blob(archive: tarfile.TarFile, name: str, maximum: int) -> bytes:
|
|
340
|
+
member = archive.next()
|
|
341
|
+
if (
|
|
342
|
+
member is None
|
|
343
|
+
or member.name != name
|
|
344
|
+
or not member.isfile()
|
|
345
|
+
or member.pax_headers
|
|
346
|
+
or not 0 <= member.size <= maximum
|
|
347
|
+
):
|
|
348
|
+
raise ValueError("source transport archive member is invalid")
|
|
349
|
+
stream = archive.extractfile(member)
|
|
350
|
+
if stream is None:
|
|
351
|
+
raise ValueError("source transport blob is unavailable")
|
|
352
|
+
with stream:
|
|
353
|
+
content = stream.read(maximum + 1)
|
|
354
|
+
if len(content) != member.size:
|
|
355
|
+
raise ValueError("source transport blob is truncated")
|
|
356
|
+
return content
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _digest(stream: BinaryIO) -> str:
|
|
360
|
+
stream.seek(0)
|
|
361
|
+
digest = hashlib.sha256()
|
|
362
|
+
total = 0
|
|
363
|
+
while chunk := stream.read(1024 * 1024):
|
|
364
|
+
total += len(chunk)
|
|
365
|
+
if total > _MAX_ARCHIVE:
|
|
366
|
+
raise ValueError("source transport archive exceeds its bound")
|
|
367
|
+
digest.update(chunk)
|
|
368
|
+
return digest.hexdigest()
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def main(argv: list[str] | None = None) -> int:
|
|
372
|
+
"""Receive a source-only archive on an attested host without executing its contents."""
|
|
373
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
374
|
+
parser.add_argument("--archive", type=Path, required=True)
|
|
375
|
+
parser.add_argument("--destination", type=Path, required=True)
|
|
376
|
+
parser.add_argument("--archive-digest", required=True)
|
|
377
|
+
parser.add_argument("--snapshot-digest", required=True)
|
|
378
|
+
parser.add_argument("--verify-existing", action="store_true")
|
|
379
|
+
args = parser.parse_args(argv)
|
|
380
|
+
try:
|
|
381
|
+
receipt = receive_source_snapshot(
|
|
382
|
+
args.archive,
|
|
383
|
+
args.destination,
|
|
384
|
+
archive_digest=args.archive_digest,
|
|
385
|
+
snapshot_digest=args.snapshot_digest,
|
|
386
|
+
verify_existing=args.verify_existing,
|
|
387
|
+
)
|
|
388
|
+
except (OSError, ValueError, tarfile.TarError):
|
|
389
|
+
print(
|
|
390
|
+
"source transport verification failed; preserve inputs and partial destination",
|
|
391
|
+
file=sys.stderr,
|
|
392
|
+
)
|
|
393
|
+
return 3
|
|
394
|
+
print(json.dumps(receipt, sort_keys=True, separators=(",", ":")))
|
|
395
|
+
return 0
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
if __name__ == "__main__":
|
|
399
|
+
raise SystemExit(main())
|