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,790 @@
1
+ """Acquire and authenticate one complete online or artifact-offline deployment kit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import errno
6
+ import gzip
7
+ import hashlib
8
+ import http.client
9
+ import json
10
+ import os
11
+ import platform
12
+ import re
13
+ import shutil
14
+ import stat
15
+ import tarfile
16
+ import tempfile
17
+ import time
18
+ import urllib.error
19
+ import urllib.parse
20
+ import urllib.request
21
+ from dataclasses import dataclass
22
+ from pathlib import Path, PurePosixPath
23
+ from typing import IO, Any, BinaryIO, Final, cast
24
+
25
+ from fdai_deployment_cli.__about__ import __version__
26
+ from fdai_deployment_cli.bundle import extract_bundle_archive, verify_bundle
27
+ from fdai_deployment_cli.deployment_deadline import DeploymentDeadline
28
+ from fdai_deployment_cli.deployment_kit_cache import (
29
+ acquisition_lock,
30
+ bind_online_source,
31
+ execution_bundle_destination,
32
+ path_present,
33
+ validate_cached_tree,
34
+ validate_retained_archive,
35
+ verify_retained_artifacts,
36
+ )
37
+ from fdai_deployment_cli.deployment_progress import downloaded_bytes, progress_detail
38
+ from fdai_deployment_cli.execution_copies import new_execution_copy
39
+ from fdai_deployment_cli.offline_kit import (
40
+ ROOT_MANIFEST_NAME,
41
+ ROOT_SIGNATURE_NAME,
42
+ OfflineKitVerification,
43
+ materialize_verified_artifacts,
44
+ verify_offline_kit,
45
+ verify_root_manifest,
46
+ )
47
+ from fdai_deployment_cli.private_output import read_private_bytes, write_private_output
48
+ from fdai_deployment_cli.runtime_release import (
49
+ RuntimeRelease,
50
+ load_runtime_release,
51
+ validate_runtime_images,
52
+ )
53
+ from fdai_deployment_cli.trust_roots import (
54
+ deployment_bundle_root_pem,
55
+ deployment_release_channel,
56
+ deployment_release_root_pem,
57
+ )
58
+
59
+ _MAX_ARCHIVE_BYTES: Final = 8 * 1024 * 1024 * 1024
60
+ _MAX_ARCHIVE_FILES: Final = 20_000
61
+ _MAX_MEMBER_BYTES: Final = 512 * 1024 * 1024
62
+ _BUFFER_BYTES: Final = 1024 * 1024
63
+ _DOWNLOAD_SECONDS: Final = 900
64
+ _COMMIT = re.compile(r"[0-9a-f]{40}")
65
+ _DIGEST = re.compile(r"[0-9a-f]{64}")
66
+ _SAFE_ONLINE_HOSTS = frozenset(
67
+ {"github.com", "objects.githubusercontent.com", "release-assets.githubusercontent.com"}
68
+ )
69
+
70
+
71
+ @dataclass(frozen=True, slots=True)
72
+ class DeploymentKit:
73
+ """Verified complete deployment bytes and their immutable source binding."""
74
+
75
+ root: Path
76
+ materialized_root: Path
77
+ bundle_root: Path
78
+ verification: OfflineKitVerification
79
+ runtime: RuntimeRelease
80
+ bundle_manifest_digest: str
81
+
82
+ @property
83
+ def source_commit(self) -> str:
84
+ """Return the source revision authenticated by the complete runtime release."""
85
+
86
+ return self.runtime.source_commit
87
+
88
+
89
+ def default_online_kit_url() -> str:
90
+ """Return the versioned upstream kit URL for compatibility callers."""
91
+
92
+ platform_tag = runtime_platform_tag()
93
+ return (
94
+ "https://github.com/dotnetpower/fdai/releases/download/"
95
+ f"deployment-v{__version__}/fdai-deployment-kit-{__version__}-{platform_tag}.tar.gz"
96
+ )
97
+
98
+
99
+ def acquire_deployment_kit(
100
+ *,
101
+ work_dir: Path,
102
+ offline_kit: Path | None,
103
+ online: bool = False,
104
+ online_url: str | None = None,
105
+ ) -> DeploymentKit:
106
+ """Verify one selected kit source for internal compatibility callers."""
107
+
108
+ if online == (offline_kit is not None):
109
+ raise ValueError("select exactly one of online mode or an offline kit")
110
+ runtime_platform_tag()
111
+ _require_private_directory(work_dir)
112
+ with acquisition_lock(work_dir):
113
+ return _acquire_deployment_kit(
114
+ work_dir=work_dir,
115
+ online=online,
116
+ offline_kit=offline_kit,
117
+ online_url=online_url,
118
+ )
119
+
120
+
121
+ def _acquire_deployment_kit(
122
+ *,
123
+ work_dir: Path,
124
+ online: bool,
125
+ offline_kit: Path | None,
126
+ online_url: str | None,
127
+ ) -> DeploymentKit:
128
+ """Acquire under lock and authenticate before returning any paths."""
129
+
130
+ kit_root = work_dir / "kit"
131
+ retained_offline_kit_root: Path | None = None
132
+ legacy_cache = False
133
+ if online:
134
+ default_url = default_online_kit_url()
135
+ url = online_url or default_url
136
+ if not _approved_online_url(urllib.parse.urlparse(url)):
137
+ raise ValueError("online deployment kit URL is not an approved HTTPS release host")
138
+ legacy_cache = bind_online_source(work_dir, url=url, default_url=default_url)
139
+ archive = work_dir / "downloaded-kit.tar.gz"
140
+ if path_present(archive):
141
+ validate_retained_archive(archive, max_bytes=_MAX_ARCHIVE_BYTES)
142
+ if path_present(kit_root):
143
+ progress_detail("Revalidating the retained signed deployment kit; no download")
144
+ else:
145
+ if not path_present(archive):
146
+ progress_detail("Downloading the signed release kit")
147
+ _download(url, archive)
148
+ else:
149
+ progress_detail("Reading the retained deployment archive; verification pending")
150
+ progress_detail("Extracting the deployment kit")
151
+ _extract_kit_archive(archive, kit_root)
152
+ validate_cached_tree(kit_root)
153
+ else:
154
+ progress_detail("Reading the local deployment kit; no artifact network fallback")
155
+ assert offline_kit is not None
156
+ source = offline_kit if offline_kit.is_absolute() else Path.cwd() / offline_kit
157
+ details = source.lstat()
158
+ if stat.S_ISDIR(details.st_mode):
159
+ kit_root = source
160
+ elif stat.S_ISREG(details.st_mode):
161
+ if path_present(kit_root):
162
+ retained_offline_kit_root = kit_root
163
+ kit_root = new_execution_copy(work_dir, "offline-source-") / "kit"
164
+ _extract_kit_archive(source, kit_root)
165
+ else:
166
+ raise ValueError("offline kit must be a regular archive or directory")
167
+ progress_detail("Verifying kit signature, compatibility, and file digests")
168
+ verification = verify_offline_kit(
169
+ kit_root,
170
+ release_root_pem=deployment_release_root_pem(),
171
+ cli_version=__version__,
172
+ platform_tag=runtime_platform_tag(),
173
+ )
174
+ if (
175
+ verification.deployment_root_required
176
+ or path_present(kit_root / ROOT_MANIFEST_NAME)
177
+ or path_present(kit_root / ROOT_SIGNATURE_NAME)
178
+ ):
179
+ verify_root_manifest(
180
+ kit_root,
181
+ release_root_pem=deployment_release_root_pem(),
182
+ expected_profile="offline",
183
+ )
184
+ if legacy_cache and (
185
+ verification.kit_version != __version__ or verification.bundle_version != __version__
186
+ ):
187
+ raise ValueError("retained legacy deployment kit version differs; preserve it for review")
188
+ files = dict(verification.file_digests)
189
+ if "runtime/release.json" not in files:
190
+ raise ValueError("complete deployment kit requires a runtime v2 release")
191
+ if "support/python/inventory.json" not in files or not any(
192
+ name.startswith("support/python/wheels/") and name.endswith(".whl") for name in files
193
+ ):
194
+ raise ValueError("complete deployment kit requires runtime migration support wheels")
195
+ materialized = work_dir / "verified"
196
+ if path_present(materialized):
197
+ progress_detail("Rechecking every retained verified artifact")
198
+ try:
199
+ artifacts = verify_retained_artifacts(materialized, verification)
200
+ except ValueError:
201
+ if retained_offline_kit_root is None:
202
+ raise
203
+ _rotate_retained_offline_snapshot(
204
+ work_dir=work_dir,
205
+ retained_kit_root=retained_offline_kit_root,
206
+ retained_materialized=materialized,
207
+ new_kit_root=kit_root,
208
+ new_verification=verification,
209
+ )
210
+ kit_root = work_dir / "kit"
211
+ progress_detail("Materializing verified artifacts from the new signed kit")
212
+ artifacts = materialize_verified_artifacts(
213
+ kit_root,
214
+ verification,
215
+ materialized,
216
+ include_all=True,
217
+ )
218
+ else:
219
+ progress_detail("Materializing verified artifacts")
220
+ artifacts = materialize_verified_artifacts(
221
+ kit_root,
222
+ verification,
223
+ materialized,
224
+ include_all=True,
225
+ )
226
+ runtime_source = _runtime_source_commit(materialized)
227
+ runtime = load_runtime_release(
228
+ materialized,
229
+ expected_source_commit=runtime_source,
230
+ expected_platform_tag=runtime_platform_tag(),
231
+ )
232
+ if (
233
+ runtime.schema_version != "fdai.runtime-release.v2"
234
+ or _COMMIT.fullmatch(runtime.source_commit) is None
235
+ ):
236
+ raise ValueError("complete deployment kit runtime source is invalid")
237
+ progress_detail("Verifying runtime images and the signed deployment bundle")
238
+ validate_runtime_images(materialized, runtime)
239
+ bundle_root = extract_bundle_archive(
240
+ artifacts.deployment_bundle,
241
+ execution_bundle_destination(work_dir),
242
+ )
243
+ bundle = verify_bundle(
244
+ bundle_root,
245
+ public_key_pem=deployment_bundle_root_pem(),
246
+ cli_version=__version__,
247
+ )
248
+ if bundle.release_channel != deployment_release_channel():
249
+ raise ValueError("deployment bundle release channel is not trusted by this package")
250
+ if (
251
+ bundle.bundle_version != verification.bundle_version
252
+ or runtime.deployment_bundle_sha256 != files[verification.deployment_bundle]
253
+ ):
254
+ raise ValueError("deployment kit bundle binding is invalid")
255
+ return DeploymentKit(
256
+ root=kit_root,
257
+ materialized_root=materialized,
258
+ bundle_root=bundle_root,
259
+ verification=verification,
260
+ runtime=runtime,
261
+ bundle_manifest_digest=bundle.manifest_digest,
262
+ )
263
+
264
+
265
+ def runtime_platform_tag() -> str:
266
+ """Return the supported package platform identity."""
267
+
268
+ architecture = {
269
+ "x86_64": "x86_64",
270
+ "amd64": "x86_64",
271
+ }.get(platform.machine().casefold())
272
+ if os.name != "posix" or platform.system() != "Linux" or architecture is None:
273
+ raise ValueError("standalone deployment currently supports Linux x86_64")
274
+ return f"linux-{architecture}"
275
+
276
+
277
+ def archive_verified_kit(kit: DeploymentKit, destination: Path) -> str:
278
+ """Write one private archive while rechecking every signed source file."""
279
+
280
+ expected = dict(kit.verification.file_digests)
281
+ root_metadata_present = (
282
+ kit.verification.deployment_root_required
283
+ or path_present(kit.root / ROOT_MANIFEST_NAME)
284
+ or path_present(kit.root / ROOT_SIGNATURE_NAME)
285
+ )
286
+ if root_metadata_present:
287
+ verify_root_manifest(
288
+ kit.root,
289
+ release_root_pem=deployment_release_root_pem(),
290
+ expected_profile="offline",
291
+ )
292
+ if destination.exists() or destination.is_symlink():
293
+ digest = _sha256_private_file(destination)
294
+ binding = _archive_binding(destination)
295
+ if binding.get("archive_sha256") != digest:
296
+ raise ValueError("deployment kit transport binding is invalid")
297
+ if binding != _expected_archive_binding(kit, digest):
298
+ _rotate_transport_archive(destination, binding)
299
+ else:
300
+ return digest
301
+ descriptor = os.open(
302
+ destination,
303
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
304
+ 0o600,
305
+ )
306
+ try:
307
+ with os.fdopen(descriptor, "wb") as raw:
308
+ with (
309
+ gzip.GzipFile(fileobj=raw, mode="wb", filename="", mtime=0) as zipped,
310
+ tarfile.open(fileobj=zipped, mode="w", format=tarfile.GNU_FORMAT) as archive,
311
+ ):
312
+ for relative, digest in sorted(expected.items()):
313
+ source = kit.root / relative
314
+ _add_verified_member(
315
+ archive,
316
+ source,
317
+ f"kit/{relative}",
318
+ expected_digest=digest,
319
+ )
320
+ metadata_names = ["offline-kit.json", "offline-kit.json.sig"]
321
+ if root_metadata_present:
322
+ metadata_names.extend((ROOT_MANIFEST_NAME, ROOT_SIGNATURE_NAME))
323
+ for name in metadata_names:
324
+ if name in expected:
325
+ continue
326
+ source = kit.root / name
327
+ try:
328
+ details = source.lstat()
329
+ except OSError as exc:
330
+ raise ValueError("deployment kit signature material is invalid") from exc
331
+ if not stat.S_ISREG(details.st_mode) or details.st_size > _MAX_MEMBER_BYTES:
332
+ raise ValueError("deployment kit signature material is invalid")
333
+ info = tarfile.TarInfo(f"kit/{name}")
334
+ info.size = details.st_size
335
+ info.mode = 0o600
336
+ info.mtime = 0
337
+ info.uid = 0
338
+ info.gid = 0
339
+ info.uname = ""
340
+ info.gname = ""
341
+ with source.open("rb") as stream:
342
+ archive.addfile(info, stream)
343
+ raw.flush()
344
+ os.fsync(raw.fileno())
345
+ except BaseException:
346
+ destination.unlink(missing_ok=True)
347
+ raise
348
+ digest = _sha256_file(destination)
349
+ sidecar = destination.with_suffix(destination.suffix + ".sha256")
350
+ write_private_output(
351
+ sidecar,
352
+ json.dumps(
353
+ _expected_archive_binding(kit, digest),
354
+ sort_keys=True,
355
+ separators=(",", ":"),
356
+ )
357
+ + "\n",
358
+ )
359
+ return digest
360
+
361
+
362
+ def _rotate_retained_offline_snapshot(
363
+ *,
364
+ work_dir: Path,
365
+ retained_kit_root: Path,
366
+ retained_materialized: Path,
367
+ new_kit_root: Path,
368
+ new_verification: OfflineKitVerification,
369
+ ) -> None:
370
+ """Move a previously verified offline kit aside before accepting a newer kit."""
371
+
372
+ old_verification = verify_offline_kit(
373
+ retained_kit_root,
374
+ release_root_pem=deployment_release_root_pem(),
375
+ cli_version=__version__,
376
+ platform_tag=runtime_platform_tag(),
377
+ )
378
+ if (
379
+ old_verification.deployment_root_required
380
+ or path_present(retained_kit_root / ROOT_MANIFEST_NAME)
381
+ or path_present(retained_kit_root / ROOT_SIGNATURE_NAME)
382
+ ):
383
+ verify_root_manifest(
384
+ retained_kit_root,
385
+ release_root_pem=deployment_release_root_pem(),
386
+ expected_profile="offline",
387
+ )
388
+ verify_retained_artifacts(retained_materialized, old_verification)
389
+ if old_verification.manifest_digest == new_verification.manifest_digest:
390
+ raise ValueError(
391
+ "retained deployment kit snapshot differs or is incomplete; preserve it for review"
392
+ )
393
+ review = _unique_private_review_dir(
394
+ work_dir / "retained-kit-review",
395
+ f"{old_verification.manifest_digest[:16]}-to-{new_verification.manifest_digest[:16]}",
396
+ )
397
+ os.rename(retained_kit_root, review / "kit")
398
+ os.rename(retained_materialized, review / "verified")
399
+ write_private_output(
400
+ review / "rotation.json",
401
+ json.dumps(
402
+ {
403
+ "schema_version": "fdai.retained-kit-rotation.v1",
404
+ "previous_kit_manifest_digest": old_verification.manifest_digest,
405
+ "new_kit_manifest_digest": new_verification.manifest_digest,
406
+ "reason": "offline-kit-upgrade",
407
+ },
408
+ sort_keys=True,
409
+ separators=(",", ":"),
410
+ )
411
+ + "\n",
412
+ )
413
+ os.rename(new_kit_root, work_dir / "kit")
414
+
415
+
416
+ def _rotate_transport_archive(destination: Path, binding: dict[str, str]) -> None:
417
+ """Preserve the previous managed-host transport archive before writing a new one."""
418
+
419
+ digest = binding["archive_sha256"]
420
+ review = _unique_private_review_dir(
421
+ destination.parent / "standalone-kit-review",
422
+ digest[:16],
423
+ )
424
+ sidecar = destination.with_suffix(destination.suffix + ".sha256")
425
+ os.rename(destination, review / destination.name)
426
+ os.rename(sidecar, review / sidecar.name)
427
+ write_private_output(
428
+ review / "rotation.json",
429
+ json.dumps(
430
+ {
431
+ "schema_version": "fdai.standalone-kit-transport-rotation.v1",
432
+ "previous_archive_sha256": digest,
433
+ "previous_kit_manifest_digest": binding["kit_manifest_digest"],
434
+ "previous_bundle_manifest_digest": binding["bundle_manifest_digest"],
435
+ "previous_runtime_release_digest": binding["runtime_release_digest"],
436
+ },
437
+ sort_keys=True,
438
+ separators=(",", ":"),
439
+ )
440
+ + "\n",
441
+ )
442
+
443
+
444
+ def _unique_private_review_dir(root: Path, stem: str) -> Path:
445
+ root.mkdir(mode=0o700, exist_ok=True)
446
+ root.chmod(0o700)
447
+ for suffix in ("", *[f"-{index}" for index in range(1, 1000)]):
448
+ candidate = root / f"{stem}{suffix}"
449
+ try:
450
+ candidate.mkdir(mode=0o700)
451
+ except FileExistsError:
452
+ continue
453
+ return candidate
454
+ raise ValueError("retained deployment kit review directory is exhausted")
455
+
456
+
457
+ def _download(url: str, destination: Path) -> None:
458
+ """Download once to an exclusive file and expose only value-safe failure categories."""
459
+
460
+ parsed = urllib.parse.urlparse(url)
461
+ if not _approved_online_url(parsed):
462
+ raise ValueError("online deployment kit URL is not an approved HTTPS release host")
463
+ if path_present(destination):
464
+ raise ValueError(
465
+ "deployment kit download destination already exists; retained artifact was not replaced"
466
+ )
467
+ request = urllib.request.Request(url, headers={"User-Agent": f"fdaictl/{__version__}"})
468
+ deadline = DeploymentDeadline(_DOWNLOAD_SECONDS, clock=time.monotonic)
469
+ try:
470
+ with _open_approved_url(request, timeout=deadline.remaining(30)) as response:
471
+ final = urllib.parse.urlparse(response.geturl())
472
+ if not _approved_online_url(final):
473
+ raise ValueError("online deployment kit redirect is not approved")
474
+ _write_bounded_stream(response, destination, deadline=deadline)
475
+ except urllib.error.HTTPError as exc:
476
+ raise ValueError(
477
+ f"online deployment kit download failed (HTTP {exc.code}); "
478
+ "check release availability and access; no automatic retry"
479
+ ) from exc
480
+ except urllib.error.URLError as exc:
481
+ raise ValueError(
482
+ "online deployment kit connection failed; check DNS, HTTPS, proxy, and TLS trust"
483
+ ) from exc
484
+ except TimeoutError as exc:
485
+ raise ValueError("online deployment kit download timed out; no automatic retry") from exc
486
+ except FileExistsError as exc:
487
+ raise ValueError(
488
+ "deployment kit download destination already exists; retained artifact was not replaced"
489
+ ) from exc
490
+ except PermissionError as exc:
491
+ raise ValueError(
492
+ "deployment kit download permission denied; check local cache access"
493
+ ) from exc
494
+ except OSError as exc:
495
+ if exc.errno in {errno.ENOSPC, errno.EDQUOT}:
496
+ raise ValueError(
497
+ "deployment kit local storage is full; preserve deployment state and free storage"
498
+ ) from exc
499
+ raise ValueError(
500
+ "deployment kit transfer I/O failed; check connection and local storage"
501
+ ) from exc
502
+ except http.client.HTTPException as exc:
503
+ raise ValueError(
504
+ "online deployment kit transfer was incomplete; no automatic retry"
505
+ ) from exc
506
+
507
+
508
+ def _approved_online_url(value: urllib.parse.ParseResult) -> bool:
509
+ try:
510
+ port = value.port
511
+ except ValueError:
512
+ return False
513
+ return (
514
+ value.scheme == "https"
515
+ and value.hostname in _SAFE_ONLINE_HOSTS
516
+ and port in {None, 443}
517
+ and value.username is None
518
+ and value.password is None
519
+ )
520
+
521
+
522
+ class _ReleaseRedirects(urllib.request.HTTPRedirectHandler):
523
+ """Validate each redirect before urllib can contact its destination."""
524
+
525
+ def redirect_request(
526
+ self,
527
+ req: urllib.request.Request,
528
+ fp: Any,
529
+ code: int,
530
+ msg: str,
531
+ headers: Any,
532
+ newurl: str,
533
+ ) -> urllib.request.Request | None:
534
+ if not _approved_online_url(urllib.parse.urlparse(newurl)):
535
+ raise ValueError("online deployment kit redirect is not approved")
536
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
537
+
538
+
539
+ def _open_approved_url(
540
+ request: urllib.request.Request, *, timeout: int
541
+ ) -> http.client.HTTPResponse:
542
+ """Open one allowlisted release request without mutating urllib's global opener."""
543
+
544
+ return cast(
545
+ http.client.HTTPResponse,
546
+ urllib.request.build_opener(_ReleaseRedirects()).open(request, timeout=timeout),
547
+ )
548
+
549
+
550
+ def _write_bounded_stream(
551
+ source: BinaryIO, destination: Path, *, deadline: DeploymentDeadline | None = None
552
+ ) -> None:
553
+ budget = deadline or DeploymentDeadline(_DOWNLOAD_SECONDS, clock=time.monotonic)
554
+ budget.remaining()
555
+ # HTTPResponse.read may fill the whole buffer through many successful socket reads.
556
+ # read1 returns after available data so a trickle cannot hide the total deadline.
557
+ read_chunk = getattr(source, "read1", source.read)
558
+ descriptor = os.open(
559
+ destination,
560
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
561
+ 0o600,
562
+ )
563
+ total = 0
564
+ try:
565
+ with os.fdopen(descriptor, "wb") as stream:
566
+ while chunk := read_chunk(_BUFFER_BYTES):
567
+ budget.remaining()
568
+ total += len(chunk)
569
+ if total > _MAX_ARCHIVE_BYTES:
570
+ raise ValueError("deployment kit archive exceeds its byte limit")
571
+ stream.write(chunk)
572
+ downloaded_bytes(total)
573
+ budget.remaining()
574
+ stream.flush()
575
+ os.fsync(stream.fileno())
576
+ except BaseException:
577
+ destination.unlink(missing_ok=True)
578
+ raise
579
+ if total == 0:
580
+ destination.unlink(missing_ok=True)
581
+ raise ValueError("deployment kit archive is empty")
582
+
583
+
584
+ def _extract_kit_archive(archive: Path, destination: Path) -> None:
585
+ if destination.exists() or destination.is_symlink():
586
+ raise ValueError("deployment kit destination already exists")
587
+ temporary = Path(
588
+ tempfile.mkdtemp(prefix=f".{destination.name}.extract-", dir=destination.parent)
589
+ )
590
+ try:
591
+ descriptor = os.open(archive, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
592
+ with os.fdopen(descriptor, "rb") as raw:
593
+ details = os.fstat(raw.fileno())
594
+ if not stat.S_ISREG(details.st_mode) or not 0 < details.st_size <= _MAX_ARCHIVE_BYTES:
595
+ raise ValueError("deployment kit archive is unsafe or exceeds its byte limit")
596
+ _extract_kit_members(raw, temporary)
597
+ temporary.rename(destination)
598
+ except (tarfile.TarError, EOFError) as exc:
599
+ shutil.rmtree(temporary, ignore_errors=True)
600
+ raise ValueError(
601
+ "deployment kit archive is incomplete or invalid; retained input was not replaced"
602
+ ) from exc
603
+ except BaseException:
604
+ shutil.rmtree(temporary, ignore_errors=True)
605
+ raise
606
+
607
+
608
+ def _extract_kit_members(raw: BinaryIO, temporary: Path) -> None:
609
+ total = 0
610
+ count = 0
611
+ with tarfile.open(fileobj=raw, mode="r:gz") as stream:
612
+ for member in stream:
613
+ count += 1
614
+ path = PurePosixPath(member.name)
615
+ if (
616
+ count > _MAX_ARCHIVE_FILES
617
+ or path.is_absolute()
618
+ or not path.parts
619
+ or path.parts[0] != "kit"
620
+ or any(part in {"", ".", ".."} for part in path.parts)
621
+ or member.issym()
622
+ or member.islnk()
623
+ or member.isdev()
624
+ or member.isfifo()
625
+ or not (member.isdir() or member.isfile())
626
+ ):
627
+ raise ValueError("deployment kit archive member is invalid")
628
+ if member.size < 0 or member.size > _MAX_MEMBER_BYTES:
629
+ raise ValueError("deployment kit archive member exceeds its byte limit")
630
+ total += member.size
631
+ if total > _MAX_ARCHIVE_BYTES:
632
+ raise ValueError("deployment kit archive exceeds its expanded byte limit")
633
+ relative = Path(*path.parts[1:])
634
+ target = temporary / relative
635
+ if member.isdir():
636
+ target.mkdir(mode=0o700, parents=True, exist_ok=True)
637
+ continue
638
+ target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
639
+ extracted = stream.extractfile(member)
640
+ if extracted is None:
641
+ raise ValueError("deployment kit archive file is unreadable")
642
+ _write_bounded_member(extracted, target, member.size)
643
+ if count == 0:
644
+ raise ValueError("deployment kit archive is empty")
645
+
646
+
647
+ def _write_bounded_member(source: IO[bytes], destination: Path, expected_size: int) -> None:
648
+ descriptor = os.open(
649
+ destination,
650
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
651
+ 0o600,
652
+ )
653
+ total = 0
654
+ with os.fdopen(descriptor, "wb") as stream:
655
+ while chunk := source.read(_BUFFER_BYTES):
656
+ total += len(chunk)
657
+ if total > expected_size:
658
+ raise ValueError("deployment kit archive member changed size")
659
+ stream.write(chunk)
660
+ stream.flush()
661
+ os.fsync(stream.fileno())
662
+ if total != expected_size:
663
+ raise ValueError("deployment kit archive member is truncated")
664
+
665
+
666
+ def _add_verified_member(
667
+ archive: tarfile.TarFile,
668
+ source: Path,
669
+ member_name: str,
670
+ *,
671
+ expected_digest: str,
672
+ ) -> None:
673
+ descriptor = os.open(source, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
674
+ digest = hashlib.sha256()
675
+
676
+ class DigestingReader:
677
+ def __init__(self, stream: BinaryIO) -> None:
678
+ self.stream = stream
679
+
680
+ def read(self, size: int = -1) -> bytes:
681
+ chunk = self.stream.read(size)
682
+ digest.update(chunk)
683
+ return chunk
684
+
685
+ with os.fdopen(descriptor, "rb") as stream:
686
+ details = os.fstat(stream.fileno())
687
+ if not stat.S_ISREG(details.st_mode) or not 0 < details.st_size <= _MAX_MEMBER_BYTES:
688
+ raise ValueError("deployment kit changed before transport")
689
+ info = tarfile.TarInfo(member_name)
690
+ info.size = details.st_size
691
+ info.mode = 0o600
692
+ info.mtime = 0
693
+ info.uid = 0
694
+ info.gid = 0
695
+ info.uname = ""
696
+ info.gname = ""
697
+ archive.addfile(info, DigestingReader(stream))
698
+ after = os.fstat(stream.fileno())
699
+ if (
700
+ digest.hexdigest() != expected_digest
701
+ or after.st_dev != details.st_dev
702
+ or after.st_ino != details.st_ino
703
+ or after.st_size != details.st_size
704
+ or after.st_mtime_ns != details.st_mtime_ns
705
+ or after.st_ctime_ns != details.st_ctime_ns
706
+ ):
707
+ raise ValueError("deployment kit changed before transport")
708
+
709
+
710
+ def _runtime_source_commit(root: Path) -> str:
711
+ try:
712
+ value = json.loads((root / "runtime/release.json").read_text(encoding="utf-8"))
713
+ except (OSError, json.JSONDecodeError) as exc:
714
+ raise ValueError("complete deployment kit runtime catalog is unavailable") from exc
715
+ source = value.get("source_commit") if isinstance(value, dict) else None
716
+ if not isinstance(source, str) or _COMMIT.fullmatch(source) is None:
717
+ raise ValueError("complete deployment kit runtime source is invalid")
718
+ return source
719
+
720
+
721
+ def _require_private_directory(path: Path) -> None:
722
+ details = path.lstat()
723
+ if (
724
+ not path.is_absolute()
725
+ or not stat.S_ISDIR(details.st_mode)
726
+ or stat.S_IMODE(details.st_mode) != 0o700
727
+ or details.st_uid != os.geteuid()
728
+ ):
729
+ raise PermissionError("deployment work directory must be current-UID mode 0700")
730
+
731
+
732
+ def _sha256_file(path: Path) -> str:
733
+ digest = hashlib.sha256()
734
+ with path.open("rb") as stream:
735
+ for chunk in iter(lambda: stream.read(_BUFFER_BYTES), b""):
736
+ digest.update(chunk)
737
+ return digest.hexdigest()
738
+
739
+
740
+ def _sha256_private_file(path: Path) -> str:
741
+ descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
742
+ digest = hashlib.sha256()
743
+ with os.fdopen(descriptor, "rb") as stream:
744
+ details = os.fstat(stream.fileno())
745
+ if (
746
+ not stat.S_ISREG(details.st_mode)
747
+ or stat.S_IMODE(details.st_mode) != 0o600
748
+ or details.st_uid != os.geteuid()
749
+ or details.st_nlink != 1
750
+ ):
751
+ raise ValueError("existing deployment kit transport archive is invalid")
752
+ for chunk in iter(lambda: stream.read(_BUFFER_BYTES), b""):
753
+ digest.update(chunk)
754
+ return digest.hexdigest()
755
+
756
+
757
+ def _archive_binding(path: Path) -> dict[str, str]:
758
+ sidecar = path.with_suffix(path.suffix + ".sha256")
759
+ try:
760
+ value = json.loads(read_private_bytes(sidecar, max_bytes=4096))
761
+ except json.JSONDecodeError as exc:
762
+ raise ValueError("deployment kit transport binding is invalid") from exc
763
+ expected = {
764
+ "schema_version",
765
+ "archive_sha256",
766
+ "kit_manifest_digest",
767
+ "bundle_manifest_digest",
768
+ "runtime_release_digest",
769
+ }
770
+ if (
771
+ not isinstance(value, dict)
772
+ or set(value) != expected
773
+ or value.get("schema_version") != "fdai.standalone-kit-transport.v1"
774
+ or any(
775
+ not isinstance(value.get(key), str) or _DIGEST.fullmatch(str(value[key])) is None
776
+ for key in expected - {"schema_version"}
777
+ )
778
+ ):
779
+ raise ValueError("deployment kit transport binding is invalid")
780
+ return {str(key): str(item) for key, item in value.items()}
781
+
782
+
783
+ def _expected_archive_binding(kit: DeploymentKit, digest: str) -> dict[str, str]:
784
+ return {
785
+ "schema_version": "fdai.standalone-kit-transport.v1",
786
+ "archive_sha256": digest,
787
+ "kit_manifest_digest": kit.verification.manifest_digest,
788
+ "bundle_manifest_digest": kit.bundle_manifest_digest,
789
+ "runtime_release_digest": kit.runtime.digest,
790
+ }