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.
Files changed (203) hide show
  1. fdai_deployment_cli/__about__.py +3 -0
  2. fdai_deployment_cli/__init__.py +5 -0
  3. fdai_deployment_cli/__main__.py +5 -0
  4. fdai_deployment_cli/acr_publish.py +585 -0
  5. fdai_deployment_cli/aks_catalog_review.py +137 -0
  6. fdai_deployment_cli/aks_historical_reconciliation.py +732 -0
  7. fdai_deployment_cli/aks_job_environment.py +59 -0
  8. fdai_deployment_cli/aks_job_execution.py +794 -0
  9. fdai_deployment_cli/aks_kubernetes_defaults.py +66 -0
  10. fdai_deployment_cli/aks_preflight.py +422 -0
  11. fdai_deployment_cli/aks_readiness.py +220 -0
  12. fdai_deployment_cli/aks_service_update.py +180 -0
  13. fdai_deployment_cli/aks_workload_jobs.py +281 -0
  14. fdai_deployment_cli/application_state_adoption.py +347 -0
  15. fdai_deployment_cli/azure_actor.py +61 -0
  16. fdai_deployment_cli/azure_naming.py +252 -0
  17. fdai_deployment_cli/azure_retail_prices.py +173 -0
  18. fdai_deployment_cli/bootstrap_approval.py +99 -0
  19. fdai_deployment_cli/bootstrap_channel.py +321 -0
  20. fdai_deployment_cli/bootstrap_executor.py +311 -0
  21. fdai_deployment_cli/bootstrap_plan.py +237 -0
  22. fdai_deployment_cli/bootstrap_reconcile.py +402 -0
  23. fdai_deployment_cli/bootstrap_support.py +138 -0
  24. fdai_deployment_cli/bootstrap_worker.py +448 -0
  25. fdai_deployment_cli/bundle.py +352 -0
  26. fdai_deployment_cli/capacity.py +143 -0
  27. fdai_deployment_cli/catalog_review_profile.py +306 -0
  28. fdai_deployment_cli/cli.py +798 -0
  29. fdai_deployment_cli/cli_help.py +214 -0
  30. fdai_deployment_cli/cli_parser.py +791 -0
  31. fdai_deployment_cli/cli_plan.py +450 -0
  32. fdai_deployment_cli/compiler.py +99 -0
  33. fdai_deployment_cli/console_artifact.py +218 -0
  34. fdai_deployment_cli/console_config.py +214 -0
  35. fdai_deployment_cli/console_update.py +832 -0
  36. fdai_deployment_cli/contracts.py +307 -0
  37. fdai_deployment_cli/control_package.py +140 -0
  38. fdai_deployment_cli/deadline_transport.py +70 -0
  39. fdai_deployment_cli/deployment_cost.py +131 -0
  40. fdai_deployment_cli/deployment_deadline.py +28 -0
  41. fdai_deployment_cli/deployment_kit.py +790 -0
  42. fdai_deployment_cli/deployment_kit_cache.py +191 -0
  43. fdai_deployment_cli/deployment_progress.py +648 -0
  44. fdai_deployment_cli/development_authority_cli.py +56 -0
  45. fdai_deployment_cli/development_authority_profile.py +319 -0
  46. fdai_deployment_cli/doctor.py +136 -0
  47. fdai_deployment_cli/entitlement_preflight.py +68 -0
  48. fdai_deployment_cli/entitlement_transport.py +49 -0
  49. fdai_deployment_cli/entra_bootstrap.py +579 -0
  50. fdai_deployment_cli/entra_control_profile.py +214 -0
  51. fdai_deployment_cli/entra_graph.py +349 -0
  52. fdai_deployment_cli/entra_profiles.py +215 -0
  53. fdai_deployment_cli/entra_source.py +244 -0
  54. fdai_deployment_cli/execution_bundle.py +478 -0
  55. fdai_deployment_cli/execution_copies.py +63 -0
  56. fdai_deployment_cli/foundation_adoption.py +438 -0
  57. fdai_deployment_cli/foundation_adoption_evidence.py +245 -0
  58. fdai_deployment_cli/foundation_adoption_host.py +157 -0
  59. fdai_deployment_cli/foundation_adoption_transport.py +51 -0
  60. fdai_deployment_cli/foundation_failure.py +72 -0
  61. fdai_deployment_cli/foundation_image.py +168 -0
  62. fdai_deployment_cli/foundation_input.py +235 -0
  63. fdai_deployment_cli/foundation_output.py +154 -0
  64. fdai_deployment_cli/foundation_plan.py +381 -0
  65. fdai_deployment_cli/foundation_process.py +64 -0
  66. fdai_deployment_cli/foundation_progress.py +231 -0
  67. fdai_deployment_cli/github_actions.py +171 -0
  68. fdai_deployment_cli/github_apply_artifact.py +259 -0
  69. fdai_deployment_cli/github_artifact_io.py +48 -0
  70. fdai_deployment_cli/github_dispatch.py +248 -0
  71. fdai_deployment_cli/github_plan_artifact.py +181 -0
  72. fdai_deployment_cli/github_provider_artifact.py +170 -0
  73. fdai_deployment_cli/github_transport.py +46 -0
  74. fdai_deployment_cli/github_workflow_values.py +312 -0
  75. fdai_deployment_cli/hub_image_deploy.py +354 -0
  76. fdai_deployment_cli/hub_image_stage.py +162 -0
  77. fdai_deployment_cli/hub_image_trust.py +281 -0
  78. fdai_deployment_cli/hub_ownership.py +161 -0
  79. fdai_deployment_cli/hub_workload_handoff.py +264 -0
  80. fdai_deployment_cli/hub_workload_handoff_inputs.py +227 -0
  81. fdai_deployment_cli/identity_profile.py +227 -0
  82. fdai_deployment_cli/image_targets.py +197 -0
  83. fdai_deployment_cli/installation_scope.py +280 -0
  84. fdai_deployment_cli/license.py +311 -0
  85. fdai_deployment_cli/license_issue.py +239 -0
  86. fdai_deployment_cli/lifecycle_authorization.py +252 -0
  87. fdai_deployment_cli/lifecycle_commands.py +173 -0
  88. fdai_deployment_cli/lifecycle_configuration.py +722 -0
  89. fdai_deployment_cli/lifecycle_configuration_artifact.py +45 -0
  90. fdai_deployment_cli/lifecycle_configuration_cli.py +324 -0
  91. fdai_deployment_cli/lifecycle_configuration_document.py +74 -0
  92. fdai_deployment_cli/lifecycle_configuration_entities.py +146 -0
  93. fdai_deployment_cli/lifecycle_configuration_package.py +492 -0
  94. fdai_deployment_cli/lifecycle_configuration_publication.py +277 -0
  95. fdai_deployment_cli/lifecycle_configuration_publication_cli.py +191 -0
  96. fdai_deployment_cli/lifecycle_configuration_sealing.py +127 -0
  97. fdai_deployment_cli/lifecycle_enrollment.py +45 -0
  98. fdai_deployment_cli/lifecycle_infrastructure_agent.py +174 -0
  99. fdai_deployment_cli/lifecycle_installation_binding.py +65 -0
  100. fdai_deployment_cli/lifecycle_native.py +247 -0
  101. fdai_deployment_cli/lifecycle_native_reconciliation.py +568 -0
  102. fdai_deployment_cli/lifecycle_native_transition.py +339 -0
  103. fdai_deployment_cli/lifecycle_ownership_exclusion.py +78 -0
  104. fdai_deployment_cli/lifecycle_plan.py +800 -0
  105. fdai_deployment_cli/lifecycle_promotion.py +185 -0
  106. fdai_deployment_cli/lifecycle_release.py +308 -0
  107. fdai_deployment_cli/lifecycle_self_upgrade.py +202 -0
  108. fdai_deployment_cli/lifecycle_upgrade_bundle.py +176 -0
  109. fdai_deployment_cli/notification_recipients_input.py +222 -0
  110. fdai_deployment_cli/observer_artifact.py +106 -0
  111. fdai_deployment_cli/oci_archive.py +353 -0
  112. fdai_deployment_cli/offline_kit.py +797 -0
  113. fdai_deployment_cli/offline_prepare.py +135 -0
  114. fdai_deployment_cli/operational_evidence_verifier_input.py +575 -0
  115. fdai_deployment_cli/plan_input.py +101 -0
  116. fdai_deployment_cli/private_output.py +192 -0
  117. fdai_deployment_cli/profile.py +70 -0
  118. fdai_deployment_cli/progress.py +183 -0
  119. fdai_deployment_cli/readiness.py +286 -0
  120. fdai_deployment_cli/run_command_receiver.py +418 -0
  121. fdai_deployment_cli/runtime_build.py +386 -0
  122. fdai_deployment_cli/runtime_profile.py +287 -0
  123. fdai_deployment_cli/runtime_release.py +745 -0
  124. fdai_deployment_cli/runtime_stage.py +82 -0
  125. fdai_deployment_cli/runtime_support_installation.py +215 -0
  126. fdai_deployment_cli/simulation.py +80 -0
  127. fdai_deployment_cli/source_application_inputs.py +397 -0
  128. fdai_deployment_cli/source_azure.py +586 -0
  129. fdai_deployment_cli/source_change_cli.py +107 -0
  130. fdai_deployment_cli/source_change_parser.py +75 -0
  131. fdai_deployment_cli/source_change_plan.py +181 -0
  132. fdai_deployment_cli/source_changed_update.py +196 -0
  133. fdai_deployment_cli/source_deploy.py +170 -0
  134. fdai_deployment_cli/source_foundation.py +174 -0
  135. fdai_deployment_cli/source_host_tools.py +163 -0
  136. fdai_deployment_cli/source_image_stage.py +400 -0
  137. fdai_deployment_cli/source_input.py +173 -0
  138. fdai_deployment_cli/source_projection.py +164 -0
  139. fdai_deployment_cli/source_receiver.py +76 -0
  140. fdai_deployment_cli/source_recovery.py +264 -0
  141. fdai_deployment_cli/source_runtime.py +81 -0
  142. fdai_deployment_cli/source_runtime_support.py +368 -0
  143. fdai_deployment_cli/source_service_update.py +726 -0
  144. fdai_deployment_cli/source_service_update_cli.py +47 -0
  145. fdai_deployment_cli/source_snapshot.py +178 -0
  146. fdai_deployment_cli/source_teardown.py +574 -0
  147. fdai_deployment_cli/source_terraform.py +85 -0
  148. fdai_deployment_cli/source_transport.py +399 -0
  149. fdai_deployment_cli/standalone_aks_inventory.py +558 -0
  150. fdai_deployment_cli/standalone_aks_job_execution.py +769 -0
  151. fdai_deployment_cli/standalone_aks_nodepool_guard.py +141 -0
  152. fdai_deployment_cli/standalone_application.py +977 -0
  153. fdai_deployment_cli/standalone_application_completion.py +151 -0
  154. fdai_deployment_cli/standalone_application_input_recovery.py +107 -0
  155. fdai_deployment_cli/standalone_application_recovery.py +116 -0
  156. fdai_deployment_cli/standalone_approval_input.py +24 -0
  157. fdai_deployment_cli/standalone_catalog_checkpoint.py +122 -0
  158. fdai_deployment_cli/standalone_catalog_review.py +251 -0
  159. fdai_deployment_cli/standalone_checkpoint_failure.py +127 -0
  160. fdai_deployment_cli/standalone_console_publish.py +174 -0
  161. fdai_deployment_cli/standalone_database_principals.py +41 -0
  162. fdai_deployment_cli/standalone_deploy.py +638 -0
  163. fdai_deployment_cli/standalone_development_authority.py +166 -0
  164. fdai_deployment_cli/standalone_foundation_adoption.py +87 -0
  165. fdai_deployment_cli/standalone_foundation_transition.py +767 -0
  166. fdai_deployment_cli/standalone_host.py +4608 -0
  167. fdai_deployment_cli/standalone_host_rebind.py +226 -0
  168. fdai_deployment_cli/standalone_host_residual.py +104 -0
  169. fdai_deployment_cli/standalone_host_state.py +145 -0
  170. fdai_deployment_cli/standalone_host_values.py +563 -0
  171. fdai_deployment_cli/standalone_kubernetes_tools.py +100 -0
  172. fdai_deployment_cli/standalone_license_installation.py +177 -0
  173. fdai_deployment_cli/standalone_management_egress.py +83 -0
  174. fdai_deployment_cli/standalone_migration_evidence.py +38 -0
  175. fdai_deployment_cli/standalone_operational_evidence.py +356 -0
  176. fdai_deployment_cli/standalone_planned_outputs.py +147 -0
  177. fdai_deployment_cli/standalone_private_access_probe.py +100 -0
  178. fdai_deployment_cli/standalone_product_profile.py +110 -0
  179. fdai_deployment_cli/standalone_remote_prepare.py +442 -0
  180. fdai_deployment_cli/standalone_residual_apply.py +540 -0
  181. fdai_deployment_cli/standalone_residual_successor.py +431 -0
  182. fdai_deployment_cli/standalone_review.py +266 -0
  183. fdai_deployment_cli/standalone_service_update_finalization.py +116 -0
  184. fdai_deployment_cli/standalone_service_update_recovery.py +277 -0
  185. fdai_deployment_cli/standalone_stage_targets.py +325 -0
  186. fdai_deployment_cli/standalone_status.py +67 -0
  187. fdai_deployment_cli/standalone_terraform_environment.py +237 -0
  188. fdai_deployment_cli/standalone_transfer_cleanup.py +237 -0
  189. fdai_deployment_cli/standalone_trial_activation.py +193 -0
  190. fdai_deployment_cli/state.py +434 -0
  191. fdai_deployment_cli/state_handoff.py +253 -0
  192. fdai_deployment_cli/status_projection.py +97 -0
  193. fdai_deployment_cli/support_install.py +257 -0
  194. fdai_deployment_cli/target.py +46 -0
  195. fdai_deployment_cli/trust/capabilities.json +62 -0
  196. fdai_deployment_cli/trust/deployment-bundle-root.pub +3 -0
  197. fdai_deployment_cli/trust/deployment-release-root.pub +3 -0
  198. fdai_deployment_cli/trust/upstream-signing-key.pub +3 -0
  199. fdai_deployment_cli/trust_roots.py +50 -0
  200. fdai_deployment_cli-0.1.2.dist-info/METADATA +112 -0
  201. fdai_deployment_cli-0.1.2.dist-info/RECORD +203 -0
  202. fdai_deployment_cli-0.1.2.dist-info/WHEEL +4 -0
  203. 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())