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,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
|
+
}
|