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,352 @@
|
|
|
1
|
+
"""Verification for signed deployment bundle directories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import stat
|
|
10
|
+
import tarfile
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path, PurePosixPath
|
|
13
|
+
|
|
14
|
+
from cryptography.exceptions import InvalidSignature
|
|
15
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
16
|
+
from cryptography.hazmat.primitives.serialization import load_pem_public_key
|
|
17
|
+
from fdai_service_contracts.release_channels import (
|
|
18
|
+
ReleaseChannelError,
|
|
19
|
+
verified_release_channel,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from fdai_deployment_cli.contracts import canonical_bytes, load_json_object
|
|
23
|
+
|
|
24
|
+
_DIGEST = re.compile(r"^[0-9a-f]{64}$")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BundleVerificationError(ValueError):
|
|
28
|
+
"""A signed deployment bundle is invalid or incompatible."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class BundleVerification:
|
|
33
|
+
"""Sanitized signed-bundle verification result."""
|
|
34
|
+
|
|
35
|
+
bundle_version: str
|
|
36
|
+
release_channel: str
|
|
37
|
+
file_count: int
|
|
38
|
+
manifest_digest: str
|
|
39
|
+
|
|
40
|
+
def to_json(self) -> str:
|
|
41
|
+
"""Return stable JSON."""
|
|
42
|
+
|
|
43
|
+
return json.dumps(
|
|
44
|
+
{
|
|
45
|
+
"schema_version": "fdai.deployment-bundle-verification.v1",
|
|
46
|
+
"bundle_version": self.bundle_version,
|
|
47
|
+
"release_channel": self.release_channel,
|
|
48
|
+
"file_count": self.file_count,
|
|
49
|
+
"manifest_digest": self.manifest_digest,
|
|
50
|
+
},
|
|
51
|
+
sort_keys=True,
|
|
52
|
+
separators=(",", ":"),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def verify_bundle(
|
|
57
|
+
root: Path,
|
|
58
|
+
*,
|
|
59
|
+
public_key_pem: bytes,
|
|
60
|
+
cli_version: str = "0.1.0",
|
|
61
|
+
) -> BundleVerification:
|
|
62
|
+
"""Verify signature, canonical manifest, exact file set, and every digest."""
|
|
63
|
+
|
|
64
|
+
manifest_path = root / "manifest.json"
|
|
65
|
+
signature_path = root / "manifest.json.sig"
|
|
66
|
+
manifest = _read_regular(manifest_path, 4 * 1024 * 1024)
|
|
67
|
+
signature = _read_regular(signature_path, 64)
|
|
68
|
+
if len(signature) != 64:
|
|
69
|
+
raise BundleVerificationError("deployment bundle signature MUST be 64 bytes")
|
|
70
|
+
_verify_signature(public_key_pem, manifest, signature)
|
|
71
|
+
payload = load_json_object(
|
|
72
|
+
manifest, label="deployment bundle manifest", max_bytes=4 * 1024 * 1024
|
|
73
|
+
)
|
|
74
|
+
expected = {
|
|
75
|
+
"schema_version",
|
|
76
|
+
"bundle_version",
|
|
77
|
+
"release_channel",
|
|
78
|
+
"min_cli_version",
|
|
79
|
+
"max_cli_version",
|
|
80
|
+
"sbom_path",
|
|
81
|
+
"files",
|
|
82
|
+
}
|
|
83
|
+
if set(payload) != expected or payload["schema_version"] != "fdai.deployment.bundle.v1":
|
|
84
|
+
raise BundleVerificationError("deployment bundle manifest schema does not match")
|
|
85
|
+
if canonical_bytes(payload) + b"\n" != manifest:
|
|
86
|
+
raise BundleVerificationError("deployment bundle manifest is not canonical")
|
|
87
|
+
try:
|
|
88
|
+
release_channel = verified_release_channel(_text(payload, "release_channel"))
|
|
89
|
+
except ReleaseChannelError as error:
|
|
90
|
+
raise BundleVerificationError(str(error)) from error
|
|
91
|
+
current = _semver(cli_version, "CLI version")
|
|
92
|
+
minimum = _semver(_text(payload, "min_cli_version"), "minimum CLI version")
|
|
93
|
+
maximum_value = payload["max_cli_version"]
|
|
94
|
+
if maximum_value is not None and not isinstance(maximum_value, str):
|
|
95
|
+
raise BundleVerificationError("deployment bundle maximum CLI version is invalid")
|
|
96
|
+
maximum = (
|
|
97
|
+
_semver(maximum_value, "maximum CLI version") if isinstance(maximum_value, str) else None
|
|
98
|
+
)
|
|
99
|
+
if current < minimum or (maximum is not None and current > maximum):
|
|
100
|
+
raise BundleVerificationError("deployment bundle is incompatible with this CLI version")
|
|
101
|
+
files_value = payload["files"]
|
|
102
|
+
if not isinstance(files_value, dict) or not files_value:
|
|
103
|
+
raise BundleVerificationError("deployment bundle files MUST be a non-empty object")
|
|
104
|
+
declared: dict[str, str] = {}
|
|
105
|
+
for raw_path, raw_digest in files_value.items():
|
|
106
|
+
if not isinstance(raw_path, str) or not isinstance(raw_digest, str):
|
|
107
|
+
raise BundleVerificationError("deployment bundle file entry is invalid")
|
|
108
|
+
path = _relative_path(raw_path)
|
|
109
|
+
if _DIGEST.fullmatch(raw_digest) is None:
|
|
110
|
+
raise BundleVerificationError("deployment bundle file digest is invalid")
|
|
111
|
+
declared[path] = raw_digest
|
|
112
|
+
observed: dict[str, str] = {}
|
|
113
|
+
total_bytes = 0
|
|
114
|
+
for directory, directories, names in os.walk(root, followlinks=False):
|
|
115
|
+
base = Path(directory)
|
|
116
|
+
if any((base / name).is_symlink() for name in directories):
|
|
117
|
+
raise BundleVerificationError("deployment bundle MUST NOT contain symlinks")
|
|
118
|
+
for name in names:
|
|
119
|
+
candidate = base / name
|
|
120
|
+
relative = candidate.relative_to(root).as_posix()
|
|
121
|
+
if relative in {"manifest.json", "manifest.json.sig"}:
|
|
122
|
+
continue
|
|
123
|
+
if len(observed) >= 20_000:
|
|
124
|
+
raise BundleVerificationError("deployment bundle exceeds its file count limit")
|
|
125
|
+
details = candidate.lstat()
|
|
126
|
+
if not stat.S_ISREG(details.st_mode):
|
|
127
|
+
raise BundleVerificationError("deployment bundle MUST contain regular files")
|
|
128
|
+
if details.st_size > 512 * 1024 * 1024:
|
|
129
|
+
raise BundleVerificationError("deployment bundle file exceeds its size limit")
|
|
130
|
+
total_bytes += details.st_size
|
|
131
|
+
if total_bytes > 8 * 1024 * 1024 * 1024:
|
|
132
|
+
raise BundleVerificationError("deployment bundle exceeds its total size limit")
|
|
133
|
+
observed[relative] = _sha256(candidate, expected=details)
|
|
134
|
+
if dict(sorted(observed.items())) != dict(sorted(declared.items())):
|
|
135
|
+
raise BundleVerificationError("deployment bundle exact file set or digest does not match")
|
|
136
|
+
_verify_sbom(root, payload, declared)
|
|
137
|
+
return BundleVerification(
|
|
138
|
+
bundle_version=_text(payload, "bundle_version"),
|
|
139
|
+
release_channel=release_channel,
|
|
140
|
+
file_count=len(observed),
|
|
141
|
+
manifest_digest=hashlib.sha256(manifest).hexdigest(),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def extract_bundle_archive(archive: Path, destination: Path) -> Path:
|
|
146
|
+
"""Extract one bounded regular-file bundle archive without path traversal."""
|
|
147
|
+
|
|
148
|
+
if destination.exists():
|
|
149
|
+
raise BundleVerificationError("deployment bundle destination already exists")
|
|
150
|
+
destination.mkdir(parents=True, mode=0o700)
|
|
151
|
+
total_bytes = 0
|
|
152
|
+
roots: set[str] = set()
|
|
153
|
+
try:
|
|
154
|
+
with tarfile.open(archive, mode="r:gz") as stream:
|
|
155
|
+
member_count = 0
|
|
156
|
+
for member in stream:
|
|
157
|
+
member_count += 1
|
|
158
|
+
if member_count > 20_000:
|
|
159
|
+
raise BundleVerificationError(
|
|
160
|
+
"deployment bundle archive member count is invalid"
|
|
161
|
+
)
|
|
162
|
+
path = PurePosixPath(member.name)
|
|
163
|
+
if (
|
|
164
|
+
path.is_absolute()
|
|
165
|
+
or any(part in {"", ".", ".."} for part in path.parts)
|
|
166
|
+
or not path.parts
|
|
167
|
+
):
|
|
168
|
+
raise BundleVerificationError("deployment bundle archive path is invalid")
|
|
169
|
+
roots.add(path.parts[0])
|
|
170
|
+
if member.isdir():
|
|
171
|
+
continue
|
|
172
|
+
if not member.isfile():
|
|
173
|
+
raise BundleVerificationError(
|
|
174
|
+
"deployment bundle archive MUST contain regular files"
|
|
175
|
+
)
|
|
176
|
+
if member.size > 512 * 1024 * 1024:
|
|
177
|
+
raise BundleVerificationError(
|
|
178
|
+
"deployment bundle archive member exceeds its size limit"
|
|
179
|
+
)
|
|
180
|
+
total_bytes += member.size
|
|
181
|
+
if total_bytes > 8 * 1024 * 1024 * 1024:
|
|
182
|
+
raise BundleVerificationError(
|
|
183
|
+
"deployment bundle archive exceeds its total size limit"
|
|
184
|
+
)
|
|
185
|
+
target = destination.joinpath(*path.parts)
|
|
186
|
+
target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
187
|
+
source = stream.extractfile(member)
|
|
188
|
+
if source is None:
|
|
189
|
+
raise BundleVerificationError("deployment bundle archive member is unreadable")
|
|
190
|
+
descriptor = os.open(
|
|
191
|
+
target,
|
|
192
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
|
193
|
+
0o600,
|
|
194
|
+
)
|
|
195
|
+
with source, os.fdopen(descriptor, "wb") as output:
|
|
196
|
+
copied = 0
|
|
197
|
+
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
|
198
|
+
copied += len(chunk)
|
|
199
|
+
if copied > member.size:
|
|
200
|
+
raise BundleVerificationError(
|
|
201
|
+
"deployment bundle archive member exceeded its declared size"
|
|
202
|
+
)
|
|
203
|
+
output.write(chunk)
|
|
204
|
+
if copied != member.size:
|
|
205
|
+
raise BundleVerificationError(
|
|
206
|
+
"deployment bundle archive member size does not match"
|
|
207
|
+
)
|
|
208
|
+
if member_count == 0:
|
|
209
|
+
raise BundleVerificationError("deployment bundle archive member count is invalid")
|
|
210
|
+
if len(roots) != 1:
|
|
211
|
+
raise BundleVerificationError("deployment bundle archive MUST have one root")
|
|
212
|
+
root = destination / roots.pop()
|
|
213
|
+
if not root.is_dir():
|
|
214
|
+
raise BundleVerificationError("deployment bundle archive root is invalid")
|
|
215
|
+
return root
|
|
216
|
+
except (tarfile.TarError, EOFError) as exc:
|
|
217
|
+
import shutil
|
|
218
|
+
|
|
219
|
+
shutil.rmtree(destination, ignore_errors=True)
|
|
220
|
+
raise BundleVerificationError("deployment bundle archive is invalid") from exc
|
|
221
|
+
except BaseException:
|
|
222
|
+
import shutil
|
|
223
|
+
|
|
224
|
+
shutil.rmtree(destination, ignore_errors=True)
|
|
225
|
+
raise
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _verify_signature(public_pem: bytes, document: bytes, signature: bytes) -> None:
|
|
229
|
+
try:
|
|
230
|
+
key = load_pem_public_key(public_pem)
|
|
231
|
+
except (TypeError, ValueError) as exc:
|
|
232
|
+
raise BundleVerificationError("deployment bundle public key is invalid") from exc
|
|
233
|
+
if not isinstance(key, Ed25519PublicKey):
|
|
234
|
+
raise BundleVerificationError("deployment bundle public key MUST be Ed25519")
|
|
235
|
+
try:
|
|
236
|
+
key.verify(signature, document)
|
|
237
|
+
except InvalidSignature as exc:
|
|
238
|
+
raise BundleVerificationError("deployment bundle signature is invalid") from exc
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _verify_sbom(
|
|
242
|
+
root: Path,
|
|
243
|
+
manifest: dict[str, object],
|
|
244
|
+
declared: dict[str, str],
|
|
245
|
+
) -> None:
|
|
246
|
+
sbom_value = manifest["sbom_path"]
|
|
247
|
+
if not isinstance(sbom_value, str):
|
|
248
|
+
raise BundleVerificationError("deployment bundle sbom_path is invalid")
|
|
249
|
+
sbom_path = _relative_path(sbom_value)
|
|
250
|
+
if sbom_path not in declared:
|
|
251
|
+
raise BundleVerificationError("deployment bundle SBOM is not declared")
|
|
252
|
+
sbom = load_json_object(
|
|
253
|
+
_read_regular(root / sbom_path, 16 * 1024 * 1024),
|
|
254
|
+
label="deployment bundle SBOM",
|
|
255
|
+
max_bytes=16 * 1024 * 1024,
|
|
256
|
+
)
|
|
257
|
+
if sbom.get("bomFormat") != "CycloneDX" or sbom.get("specVersion") != "1.5":
|
|
258
|
+
raise BundleVerificationError("deployment bundle SBOM format is unsupported")
|
|
259
|
+
components = sbom.get("components")
|
|
260
|
+
if not isinstance(components, list):
|
|
261
|
+
raise BundleVerificationError("deployment bundle SBOM components are invalid")
|
|
262
|
+
covered: dict[str, str] = {}
|
|
263
|
+
for component in components:
|
|
264
|
+
if not isinstance(component, dict):
|
|
265
|
+
raise BundleVerificationError("deployment bundle SBOM component is invalid")
|
|
266
|
+
name = component.get("name")
|
|
267
|
+
hashes = component.get("hashes")
|
|
268
|
+
if not isinstance(name, str) or not isinstance(hashes, list):
|
|
269
|
+
raise BundleVerificationError("deployment bundle SBOM component is invalid")
|
|
270
|
+
sha256_values = [
|
|
271
|
+
item.get("content")
|
|
272
|
+
for item in hashes
|
|
273
|
+
if isinstance(item, dict) and item.get("alg") == "SHA-256"
|
|
274
|
+
]
|
|
275
|
+
if len(sha256_values) != 1 or not isinstance(sha256_values[0], str):
|
|
276
|
+
raise BundleVerificationError("deployment bundle SBOM SHA-256 is invalid")
|
|
277
|
+
path = _relative_path(name)
|
|
278
|
+
if path in covered:
|
|
279
|
+
raise BundleVerificationError("deployment bundle SBOM contains duplicate paths")
|
|
280
|
+
covered[path] = sha256_values[0]
|
|
281
|
+
expected = {path: digest for path, digest in declared.items() if path != sbom_path}
|
|
282
|
+
if covered != expected:
|
|
283
|
+
raise BundleVerificationError("deployment bundle SBOM coverage does not match")
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _read_regular(path: Path, maximum: int) -> bytes:
|
|
287
|
+
details = path.lstat()
|
|
288
|
+
if not stat.S_ISREG(details.st_mode) or details.st_size > maximum:
|
|
289
|
+
raise BundleVerificationError("deployment bundle metadata is invalid")
|
|
290
|
+
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
291
|
+
with os.fdopen(descriptor, "rb") as stream:
|
|
292
|
+
opened = os.fstat(stream.fileno())
|
|
293
|
+
_require_same_file(details, opened)
|
|
294
|
+
payload = stream.read(maximum + 1)
|
|
295
|
+
_require_same_file(opened, os.fstat(stream.fileno()))
|
|
296
|
+
return payload
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _sha256(path: Path, *, expected: os.stat_result) -> str:
|
|
300
|
+
if not stat.S_ISREG(expected.st_mode):
|
|
301
|
+
raise BundleVerificationError("deployment bundle MUST contain regular files")
|
|
302
|
+
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
303
|
+
digest = hashlib.sha256()
|
|
304
|
+
copied = 0
|
|
305
|
+
with os.fdopen(descriptor, "rb") as stream:
|
|
306
|
+
opened = os.fstat(stream.fileno())
|
|
307
|
+
_require_same_file(expected, opened)
|
|
308
|
+
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
|
309
|
+
copied += len(chunk)
|
|
310
|
+
if copied > expected.st_size:
|
|
311
|
+
raise BundleVerificationError("deployment bundle file grew during verification")
|
|
312
|
+
digest.update(chunk)
|
|
313
|
+
_require_same_file(opened, os.fstat(stream.fileno()))
|
|
314
|
+
if copied != expected.st_size:
|
|
315
|
+
raise BundleVerificationError("deployment bundle file size changed during verification")
|
|
316
|
+
return digest.hexdigest()
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _require_same_file(expected: os.stat_result, observed: os.stat_result) -> None:
|
|
320
|
+
if (
|
|
321
|
+
not stat.S_ISREG(observed.st_mode)
|
|
322
|
+
or expected.st_dev != observed.st_dev
|
|
323
|
+
or expected.st_ino != observed.st_ino
|
|
324
|
+
or expected.st_size != observed.st_size
|
|
325
|
+
or expected.st_mtime_ns != observed.st_mtime_ns
|
|
326
|
+
):
|
|
327
|
+
raise BundleVerificationError("deployment bundle file changed during verification")
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _relative_path(value: str) -> str:
|
|
331
|
+
path = PurePosixPath(value)
|
|
332
|
+
if (
|
|
333
|
+
value.startswith("/")
|
|
334
|
+
or "\\" in value
|
|
335
|
+
or any(part in {"", ".", ".."} for part in path.parts)
|
|
336
|
+
):
|
|
337
|
+
raise BundleVerificationError("deployment bundle path is invalid")
|
|
338
|
+
return path.as_posix()
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _text(value: dict[str, object], field: str) -> str:
|
|
342
|
+
item = value[field]
|
|
343
|
+
if not isinstance(item, str) or not item:
|
|
344
|
+
raise BundleVerificationError(f"deployment bundle {field} MUST be text")
|
|
345
|
+
return item
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _semver(value: str, label: str) -> tuple[int, int, int]:
|
|
349
|
+
match = re.fullmatch(r"(0|[1-9][0-9]*)[.](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)", value)
|
|
350
|
+
if match is None:
|
|
351
|
+
raise BundleVerificationError(f"deployment bundle {label} is not semantic version")
|
|
352
|
+
return tuple(int(item) for item in match.groups()) # type: ignore[return-value]
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Deterministic workload-derived model capacity planning."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class WorkloadEnvelope:
|
|
11
|
+
"""Peak model demand and approved operating headroom."""
|
|
12
|
+
|
|
13
|
+
requests_per_minute: int
|
|
14
|
+
input_tokens_per_request: int
|
|
15
|
+
output_tokens_per_request: int
|
|
16
|
+
concurrent_requests: int
|
|
17
|
+
utilization_ceiling: float = 0.70
|
|
18
|
+
quota_reserve: float = 0.20
|
|
19
|
+
provider_unit_tpm: int = 1_000
|
|
20
|
+
|
|
21
|
+
def __post_init__(self) -> None:
|
|
22
|
+
for field_name in (
|
|
23
|
+
"requests_per_minute",
|
|
24
|
+
"input_tokens_per_request",
|
|
25
|
+
"output_tokens_per_request",
|
|
26
|
+
"concurrent_requests",
|
|
27
|
+
"provider_unit_tpm",
|
|
28
|
+
):
|
|
29
|
+
value = getattr(self, field_name)
|
|
30
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
31
|
+
raise ValueError(f"{field_name} MUST be positive")
|
|
32
|
+
if (
|
|
33
|
+
isinstance(self.utilization_ceiling, bool)
|
|
34
|
+
or not math.isfinite(self.utilization_ceiling)
|
|
35
|
+
or not 0 < self.utilization_ceiling <= 1
|
|
36
|
+
):
|
|
37
|
+
raise ValueError("utilization_ceiling MUST be in (0, 1]")
|
|
38
|
+
if (
|
|
39
|
+
isinstance(self.quota_reserve, bool)
|
|
40
|
+
or not math.isfinite(self.quota_reserve)
|
|
41
|
+
or not 0 <= self.quota_reserve < 1
|
|
42
|
+
):
|
|
43
|
+
raise ValueError("quota_reserve MUST be in [0, 1)")
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def minimum_tpm(self) -> int:
|
|
47
|
+
"""Return peak token demand rounded up to the provider allocation unit."""
|
|
48
|
+
|
|
49
|
+
tokens = self.input_tokens_per_request + self.output_tokens_per_request
|
|
50
|
+
raw = math.ceil(self.requests_per_minute * tokens / self.utilization_ceiling)
|
|
51
|
+
return math.ceil(raw / self.provider_unit_tpm) * self.provider_unit_tpm
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class CapabilityDemand:
|
|
56
|
+
"""One required or optional model capability."""
|
|
57
|
+
|
|
58
|
+
capability: str
|
|
59
|
+
deployment_key: str
|
|
60
|
+
required: bool
|
|
61
|
+
envelope: WorkloadEnvelope
|
|
62
|
+
|
|
63
|
+
def __post_init__(self) -> None:
|
|
64
|
+
if not self.capability.strip() or not self.deployment_key.strip():
|
|
65
|
+
raise ValueError("capability and deployment_key MUST be non-empty")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True, slots=True)
|
|
69
|
+
class DeploymentCapacity:
|
|
70
|
+
"""Aggregate TPM needed by capabilities sharing one approved deployment."""
|
|
71
|
+
|
|
72
|
+
deployment_key: str
|
|
73
|
+
required_tpm: int
|
|
74
|
+
optional_tpm: int
|
|
75
|
+
available_tpm: int
|
|
76
|
+
existing_allocated_tpm: int
|
|
77
|
+
reserve_tpm: int
|
|
78
|
+
combined_reserve_tpm: int
|
|
79
|
+
required_capabilities: tuple[str, ...]
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def sufficient(self) -> bool:
|
|
83
|
+
"""Return whether available quota covers demand and reserve."""
|
|
84
|
+
|
|
85
|
+
free = self.available_tpm - self.existing_allocated_tpm - self.reserve_tpm
|
|
86
|
+
return free >= self.required_tpm
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def optional_sufficient(self) -> bool:
|
|
90
|
+
"""Return whether quota also covers every optional capability."""
|
|
91
|
+
|
|
92
|
+
free = self.available_tpm - self.existing_allocated_tpm - self.combined_reserve_tpm
|
|
93
|
+
return free >= self.required_tpm + self.optional_tpm
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def plan_capacity(
|
|
97
|
+
demands: tuple[CapabilityDemand, ...],
|
|
98
|
+
*,
|
|
99
|
+
available_tpm_by_deployment: dict[str, int],
|
|
100
|
+
existing_tpm_by_deployment: dict[str, int] | None = None,
|
|
101
|
+
) -> tuple[DeploymentCapacity, ...]:
|
|
102
|
+
"""Aggregate shared deployment demand and fail closed on missing quota evidence."""
|
|
103
|
+
|
|
104
|
+
if not demands:
|
|
105
|
+
raise ValueError("model capacity plan MUST contain capabilities")
|
|
106
|
+
if not any(demand.required for demand in demands):
|
|
107
|
+
raise ValueError("model capacity plan MUST contain a required capability")
|
|
108
|
+
existing = existing_tpm_by_deployment or {}
|
|
109
|
+
grouped: dict[str, list[CapabilityDemand]] = {}
|
|
110
|
+
for demand in demands:
|
|
111
|
+
grouped.setdefault(demand.deployment_key, []).append(demand)
|
|
112
|
+
result: list[DeploymentCapacity] = []
|
|
113
|
+
for key in sorted(grouped):
|
|
114
|
+
if key not in available_tpm_by_deployment:
|
|
115
|
+
raise ValueError(f"quota evidence is missing for deployment {key!r}")
|
|
116
|
+
group = grouped[key]
|
|
117
|
+
required_tpm = sum(item.envelope.minimum_tpm for item in group if item.required)
|
|
118
|
+
optional_tpm = sum(item.envelope.minimum_tpm for item in group if not item.required)
|
|
119
|
+
required_reserve_ratio = max(
|
|
120
|
+
(item.envelope.quota_reserve for item in group if item.required),
|
|
121
|
+
default=0.0,
|
|
122
|
+
)
|
|
123
|
+
combined_reserve_ratio = max(item.envelope.quota_reserve for item in group)
|
|
124
|
+
available = available_tpm_by_deployment[key]
|
|
125
|
+
if available < 0 or existing.get(key, 0) < 0:
|
|
126
|
+
raise ValueError("quota values MUST be non-negative")
|
|
127
|
+
reserve = math.ceil(available * required_reserve_ratio)
|
|
128
|
+
combined_reserve = math.ceil(available * combined_reserve_ratio)
|
|
129
|
+
result.append(
|
|
130
|
+
DeploymentCapacity(
|
|
131
|
+
deployment_key=key,
|
|
132
|
+
required_tpm=required_tpm,
|
|
133
|
+
optional_tpm=optional_tpm,
|
|
134
|
+
available_tpm=available,
|
|
135
|
+
existing_allocated_tpm=existing.get(key, 0),
|
|
136
|
+
reserve_tpm=reserve,
|
|
137
|
+
combined_reserve_tpm=combined_reserve,
|
|
138
|
+
required_capabilities=tuple(
|
|
139
|
+
sorted(item.capability for item in group if item.required)
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
return tuple(result)
|