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,353 @@
|
|
|
1
|
+
"""Validate one OCI-layout image without extracting layers or granting provenance trust.
|
|
2
|
+
|
|
3
|
+
The layout may retain one family-consistent OCI image or Docker schema 2 image
|
|
4
|
+
manifest. Only uncompressed, regular USTAR-compatible archives are supported.
|
|
5
|
+
The bounded archive is retained as immutable private process memory; streaming
|
|
6
|
+
never reopens its original path. No snapshot, layer, or credential is written.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import tarfile
|
|
15
|
+
from collections.abc import Iterator
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import cast
|
|
19
|
+
|
|
20
|
+
from fdai_deployment_cli import offline_kit
|
|
21
|
+
|
|
22
|
+
OCI_MANIFEST = "application/vnd.oci.image.manifest.v1+json"
|
|
23
|
+
OCI_CONFIG = "application/vnd.oci.image.config.v1+json"
|
|
24
|
+
OCI_INDEX = "application/vnd.oci.image.index.v1+json"
|
|
25
|
+
DOCKER_MANIFEST = "application/vnd.docker.distribution.manifest.v2+json"
|
|
26
|
+
DOCKER_CONFIG = "application/vnd.docker.container.image.v1+json"
|
|
27
|
+
_OCI_LAYERS = {
|
|
28
|
+
"application/vnd.oci.image.layer.v1.tar",
|
|
29
|
+
"application/vnd.oci.image.layer.v1.tar+gzip",
|
|
30
|
+
"application/vnd.oci.image.layer.v1.tar+zstd",
|
|
31
|
+
}
|
|
32
|
+
_DOCKER_LAYERS = {"application/vnd.docker.image.rootfs.diff.tar.gzip"}
|
|
33
|
+
_IMAGE_FAMILIES = {
|
|
34
|
+
OCI_MANIFEST: ({OCI_CONFIG}, _OCI_LAYERS),
|
|
35
|
+
DOCKER_MANIFEST: ({DOCKER_CONFIG}, _DOCKER_LAYERS),
|
|
36
|
+
}
|
|
37
|
+
_PLATFORMS = {"linux-x86_64": "amd64", "linux-aarch64": "arm64"}
|
|
38
|
+
_DIGEST = re.compile(r"sha256:[0-9a-f]{64}")
|
|
39
|
+
_BLOB = re.compile(r"blobs/sha256/[0-9a-f]{64}")
|
|
40
|
+
_REVISION = "org.opencontainers.image.revision"
|
|
41
|
+
_CHUNK = 1024 * 1024
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class OciArchiveError(ValueError):
|
|
45
|
+
"""Sanitized structural-validation failure; stage and status are stable codes."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, stage: str, status: str = "invalid") -> None:
|
|
48
|
+
self.stage = stage
|
|
49
|
+
self.status = status
|
|
50
|
+
super().__init__(f"OCI archive {stage}: {status}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class OciDescriptor:
|
|
55
|
+
"""A hash-checked blob's media type, size, archive path, and byte offset."""
|
|
56
|
+
|
|
57
|
+
digest: str
|
|
58
|
+
size: int
|
|
59
|
+
media_type: str
|
|
60
|
+
path: str
|
|
61
|
+
offset: int
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class VerifiedOciImage[Revision: (str, None)]:
|
|
66
|
+
"""Content validation, not attestation or authority; retains a bounded snapshot."""
|
|
67
|
+
|
|
68
|
+
manifest: OciDescriptor
|
|
69
|
+
config: OciDescriptor
|
|
70
|
+
layers: tuple[OciDescriptor, ...]
|
|
71
|
+
source_commit: Revision
|
|
72
|
+
platform_tag: str
|
|
73
|
+
archive_sha256: str
|
|
74
|
+
_snapshot: bytes = field(repr=False, compare=False)
|
|
75
|
+
|
|
76
|
+
def iter_bytes(self, descriptor: OciDescriptor, *, chunk_size: int = _CHUNK) -> Iterator[bytes]:
|
|
77
|
+
"""Stream only a verified descriptor, in at most one-MiB immutable chunks."""
|
|
78
|
+
|
|
79
|
+
_require(descriptor in (self.manifest, self.config, *self.layers), "descriptor")
|
|
80
|
+
_require(type(chunk_size) is int and 0 < chunk_size <= _CHUNK, "descriptor")
|
|
81
|
+
end = descriptor.offset + descriptor.size
|
|
82
|
+
for start in range(descriptor.offset, end, chunk_size):
|
|
83
|
+
yield self._snapshot[start : min(end, start + chunk_size)]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def validate_oci_archive(
|
|
87
|
+
path: Path,
|
|
88
|
+
*,
|
|
89
|
+
expected_archive_sha256: str,
|
|
90
|
+
expected_manifest_digest: str,
|
|
91
|
+
expected_source_commit: str,
|
|
92
|
+
expected_platform_tag: str,
|
|
93
|
+
) -> VerifiedOciImage[str]:
|
|
94
|
+
"""Bind an immutable image snapshot to the caller's four explicit assertions.
|
|
95
|
+
|
|
96
|
+
Archive, member, count, and total limits inherit offline-kit ceilings. The
|
|
97
|
+
archive itself is at most the offline-kit per-file limit (512 MiB today).
|
|
98
|
+
Every blob is SHA-256 checked; compressed layer contents and rootfs diff_ids
|
|
99
|
+
are not interpreted. One coherent OCI or Docker schema 2 media family is
|
|
100
|
+
accepted. Nested indexes, multiple images, mixed media families, foreign
|
|
101
|
+
layers, links, extensions, duplicate paths/JSON keys, and extra blobs fail closed.
|
|
102
|
+
Source labels are content assertions, not independently trusted provenance.
|
|
103
|
+
Raises OciArchiveError without including paths or untrusted payloads.
|
|
104
|
+
"""
|
|
105
|
+
_require(
|
|
106
|
+
isinstance(expected_source_commit, str)
|
|
107
|
+
and bool(re.fullmatch(r"[0-9a-fA-F]{40}", expected_source_commit)),
|
|
108
|
+
"revision",
|
|
109
|
+
)
|
|
110
|
+
return _validate_archive(
|
|
111
|
+
path,
|
|
112
|
+
expected_archive_sha256=expected_archive_sha256,
|
|
113
|
+
expected_manifest_digest=expected_manifest_digest,
|
|
114
|
+
expected_source_commit=expected_source_commit,
|
|
115
|
+
expected_platform_tag=expected_platform_tag,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def validate_dependency_oci_archive(
|
|
120
|
+
path: Path,
|
|
121
|
+
*,
|
|
122
|
+
expected_archive_sha256: str,
|
|
123
|
+
expected_manifest_digest: str,
|
|
124
|
+
expected_platform_tag: str,
|
|
125
|
+
) -> VerifiedOciImage[None]:
|
|
126
|
+
"""Validate dependency content and platform without asserting an FDAI source revision.
|
|
127
|
+
|
|
128
|
+
All archive, blob, manifest and platform bounds are identical to service validation.
|
|
129
|
+
Vendor revision labels are not source attestation. The returned source_commit is None.
|
|
130
|
+
"""
|
|
131
|
+
return _validate_archive(
|
|
132
|
+
path,
|
|
133
|
+
expected_archive_sha256=expected_archive_sha256,
|
|
134
|
+
expected_manifest_digest=expected_manifest_digest,
|
|
135
|
+
expected_source_commit=None,
|
|
136
|
+
expected_platform_tag=expected_platform_tag,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _validate_archive[Revision: (str, None)](
|
|
141
|
+
path: Path,
|
|
142
|
+
*,
|
|
143
|
+
expected_archive_sha256: str,
|
|
144
|
+
expected_manifest_digest: str,
|
|
145
|
+
expected_source_commit: Revision,
|
|
146
|
+
expected_platform_tag: str,
|
|
147
|
+
) -> VerifiedOciImage[Revision]:
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
_require(bool(re.fullmatch(r"[0-9a-f]{64}", expected_archive_sha256)), "archive")
|
|
151
|
+
_require(bool(_DIGEST.fullmatch(expected_manifest_digest)), "manifest")
|
|
152
|
+
_require(expected_platform_tag in _PLATFORMS, "platform", "unsupported")
|
|
153
|
+
snapshot = offline_kit._read_regular(path, offline_kit._MAX_FILE_BYTES)
|
|
154
|
+
_require(len(snapshot) <= offline_kit._MAX_FILE_BYTES, "archive", "limit")
|
|
155
|
+
_require(
|
|
156
|
+
hashlib.sha256(snapshot).hexdigest() == expected_archive_sha256,
|
|
157
|
+
"archive",
|
|
158
|
+
"digest-mismatch",
|
|
159
|
+
)
|
|
160
|
+
entries = _scan(snapshot)
|
|
161
|
+
layout = _json(snapshot, entries["oci-layout"])
|
|
162
|
+
_require(layout == {"imageLayoutVersion": "1.0.0"}, "layout", "unsupported")
|
|
163
|
+
index = _json(snapshot, entries["index.json"])
|
|
164
|
+
_schema(index, OCI_INDEX, "index")
|
|
165
|
+
manifests = index.get("manifests")
|
|
166
|
+
_require(isinstance(manifests, list) and len(manifests) == 1, "index", "unsupported")
|
|
167
|
+
selected = _object(cast(list[object], manifests)[0])
|
|
168
|
+
manifest = _descriptor(selected, entries, set(_IMAGE_FAMILIES), "manifest")
|
|
169
|
+
_require(manifest.digest == expected_manifest_digest, "manifest", "digest-mismatch")
|
|
170
|
+
config_media_types, layer_media_types = _IMAGE_FAMILIES[manifest.media_type]
|
|
171
|
+
image = _json(snapshot, (manifest.offset, manifest.size))
|
|
172
|
+
_schema(image, manifest.media_type, "manifest")
|
|
173
|
+
config = _descriptor(image.get("config"), entries, config_media_types, "config")
|
|
174
|
+
layer_values = image.get("layers")
|
|
175
|
+
_require(isinstance(layer_values, list), "layers")
|
|
176
|
+
layers = tuple(
|
|
177
|
+
_descriptor(value, entries, layer_media_types, "layer")
|
|
178
|
+
for value in cast(list[object], layer_values)
|
|
179
|
+
)
|
|
180
|
+
configuration = _json(snapshot, (config.offset, config.size))
|
|
181
|
+
architecture = _PLATFORMS[expected_platform_tag]
|
|
182
|
+
_platform(configuration, architecture)
|
|
183
|
+
if "platform" in selected:
|
|
184
|
+
_platform(_object(selected["platform"]), architecture)
|
|
185
|
+
rootfs = _object(configuration.get("rootfs"))
|
|
186
|
+
diff_ids = rootfs.get("diff_ids")
|
|
187
|
+
_require(rootfs.get("type") == "layers" and isinstance(diff_ids, list), "config")
|
|
188
|
+
_require(len(cast(list[object], diff_ids)) == len(layers), "config")
|
|
189
|
+
for digest in cast(list[object], diff_ids):
|
|
190
|
+
_require(isinstance(digest, str) and bool(_DIGEST.fullmatch(digest)), "config")
|
|
191
|
+
if expected_source_commit is not None:
|
|
192
|
+
labels = _object(configuration.get("config", {})).get("Labels", {})
|
|
193
|
+
_revision(
|
|
194
|
+
(
|
|
195
|
+
labels,
|
|
196
|
+
selected.get("annotations", {}),
|
|
197
|
+
image.get("annotations", {}),
|
|
198
|
+
index.get("annotations", {}),
|
|
199
|
+
),
|
|
200
|
+
expected_source_commit,
|
|
201
|
+
)
|
|
202
|
+
expected_paths = {"oci-layout", "index.json", manifest.path, config.path}
|
|
203
|
+
expected_paths.update(layer.path for layer in layers)
|
|
204
|
+
_require(set(entries) == expected_paths, "archive", "extra-content")
|
|
205
|
+
return VerifiedOciImage(
|
|
206
|
+
manifest,
|
|
207
|
+
config,
|
|
208
|
+
layers,
|
|
209
|
+
expected_source_commit,
|
|
210
|
+
expected_platform_tag,
|
|
211
|
+
expected_archive_sha256,
|
|
212
|
+
snapshot,
|
|
213
|
+
)
|
|
214
|
+
except OciArchiveError:
|
|
215
|
+
raise
|
|
216
|
+
except (OSError, ValueError, TypeError, KeyError, RecursionError, tarfile.TarError):
|
|
217
|
+
raise OciArchiveError("archive", "invalid-or-unavailable") from None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _require(condition: bool, stage: str, status: str = "invalid") -> None:
|
|
221
|
+
if not condition:
|
|
222
|
+
raise OciArchiveError(stage, status)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _scan(snapshot: bytes) -> dict[str, tuple[int, int]]:
|
|
226
|
+
"""Parse physical headers, not TarFile's extension-skipping logical members."""
|
|
227
|
+
|
|
228
|
+
_require(len(snapshot) % 512 == 0, "archive")
|
|
229
|
+
entries: dict[str, tuple[int, int]] = {}
|
|
230
|
+
seen: set[str] = set()
|
|
231
|
+
position = total = 0
|
|
232
|
+
while position + 512 <= len(snapshot):
|
|
233
|
+
header = snapshot[position : position + 512]
|
|
234
|
+
if header == bytes(512):
|
|
235
|
+
_require(len(snapshot) - position >= 1024, "archive")
|
|
236
|
+
_require(not any(memoryview(snapshot)[position:]), "archive", "trailing-content")
|
|
237
|
+
_require({"oci-layout", "index.json"} <= entries.keys(), "layout")
|
|
238
|
+
return entries
|
|
239
|
+
member = tarfile.TarInfo.frombuf(header, encoding="ascii", errors="strict")
|
|
240
|
+
_require(
|
|
241
|
+
member.type in {tarfile.REGTYPE, tarfile.AREGTYPE, tarfile.DIRTYPE},
|
|
242
|
+
"archive",
|
|
243
|
+
"unsupported-member",
|
|
244
|
+
)
|
|
245
|
+
_require(not member.linkname and member.sparse is None, "archive", "unsupported-member")
|
|
246
|
+
name = member.name
|
|
247
|
+
_require(name not in seen, "archive", "duplicate-path")
|
|
248
|
+
seen.add(name)
|
|
249
|
+
_require(len(seen) <= offline_kit._MAX_FILES, "archive", "limit")
|
|
250
|
+
_require(0 <= member.size <= offline_kit._MAX_FILE_BYTES, "archive", "limit")
|
|
251
|
+
if member.isdir():
|
|
252
|
+
_require(name in {"blobs", "blobs/sha256"} and member.size == 0, "archive")
|
|
253
|
+
else:
|
|
254
|
+
_require(
|
|
255
|
+
name in {"oci-layout", "index.json"} or bool(_BLOB.fullmatch(name)),
|
|
256
|
+
"archive",
|
|
257
|
+
"invalid-path",
|
|
258
|
+
)
|
|
259
|
+
entries[name] = (position + 512, member.size)
|
|
260
|
+
total += member.size
|
|
261
|
+
_require(total <= offline_kit._MAX_TOTAL_BYTES, "archive", "limit")
|
|
262
|
+
start = position + 512
|
|
263
|
+
end = start + member.size
|
|
264
|
+
position = start + ((member.size + 511) // 512) * 512
|
|
265
|
+
_require(position <= len(snapshot), "archive", "truncated")
|
|
266
|
+
_require(not any(memoryview(snapshot)[end:position]), "archive", "invalid-padding")
|
|
267
|
+
if _BLOB.fullmatch(name):
|
|
268
|
+
digest = hashlib.sha256(memoryview(snapshot)[start:end]).hexdigest()
|
|
269
|
+
_require(digest == name.rsplit("/", 1)[1], "blob", "digest-mismatch")
|
|
270
|
+
raise OciArchiveError("archive", "missing-terminator")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _object(value: object) -> dict[str, object]:
|
|
274
|
+
_require(isinstance(value, dict), "metadata")
|
|
275
|
+
return cast(dict[str, object], value)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _unique(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
279
|
+
result: dict[str, object] = {}
|
|
280
|
+
for key, value in pairs:
|
|
281
|
+
_require(key not in result, "metadata", "duplicate-key")
|
|
282
|
+
result[key] = value
|
|
283
|
+
return result
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _invalid_constant(value: str) -> object:
|
|
287
|
+
raise OciArchiveError("metadata")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _json(snapshot: bytes, entry: tuple[int, int]) -> dict[str, object]:
|
|
291
|
+
start, size = entry
|
|
292
|
+
_require(size <= offline_kit._MAX_MANIFEST_BYTES, "metadata", "limit")
|
|
293
|
+
return _object(
|
|
294
|
+
json.loads(
|
|
295
|
+
snapshot[start : start + size],
|
|
296
|
+
object_pairs_hook=_unique,
|
|
297
|
+
parse_constant=_invalid_constant,
|
|
298
|
+
)
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _schema(value: dict[str, object], media_type: str, stage: str) -> None:
|
|
303
|
+
_require(type(value.get("schemaVersion")) is int and value["schemaVersion"] == 2, stage)
|
|
304
|
+
_require(value.get("mediaType", media_type) == media_type, stage, "unsupported")
|
|
305
|
+
_require(not ({"artifactType", "subject"} & value.keys()), stage, "unsupported")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _descriptor(
|
|
309
|
+
value: object,
|
|
310
|
+
entries: dict[str, tuple[int, int]],
|
|
311
|
+
media_types: set[str],
|
|
312
|
+
stage: str,
|
|
313
|
+
) -> OciDescriptor:
|
|
314
|
+
descriptor = _object(value)
|
|
315
|
+
digest, size, media_type = (
|
|
316
|
+
descriptor.get("digest"),
|
|
317
|
+
descriptor.get("size"),
|
|
318
|
+
descriptor.get("mediaType"),
|
|
319
|
+
)
|
|
320
|
+
_require(isinstance(digest, str) and bool(_DIGEST.fullmatch(digest)), stage)
|
|
321
|
+
_require(type(size) is int and 0 <= size <= offline_kit._MAX_FILE_BYTES, stage, "limit")
|
|
322
|
+
_require(isinstance(media_type, str) and media_type in media_types, stage, "unsupported")
|
|
323
|
+
_require(not ({"urls", "data", "artifactType"} & descriptor.keys()), stage, "unsupported")
|
|
324
|
+
path = "blobs/sha256/" + cast(str, digest)[7:]
|
|
325
|
+
_require(path in entries and entries[path][1] == size, stage, "size-or-path-mismatch")
|
|
326
|
+
return OciDescriptor(
|
|
327
|
+
cast(str, digest), cast(int, size), cast(str, media_type), path, entries[path][0]
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _platform(value: dict[str, object], architecture: str) -> None:
|
|
332
|
+
_require(
|
|
333
|
+
value.get("os") == "linux" and value.get("architecture") == architecture,
|
|
334
|
+
"platform",
|
|
335
|
+
"mismatch",
|
|
336
|
+
)
|
|
337
|
+
variants = {"", "v8"} if architecture == "arm64" else {""}
|
|
338
|
+
_require(value.get("variant", "") in variants, "platform", "unsupported")
|
|
339
|
+
_require(
|
|
340
|
+
not value.get("os.features") and not value.get("os.version"), "platform", "unsupported"
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _revision(sources: tuple[object, ...], expected: str) -> None:
|
|
345
|
+
revisions: list[str] = []
|
|
346
|
+
for source in sources:
|
|
347
|
+
mapping = _object(source)
|
|
348
|
+
_require(all(isinstance(value, str) for value in mapping.values()), "revision")
|
|
349
|
+
if _REVISION in mapping:
|
|
350
|
+
revisions.append(cast(str, mapping[_REVISION]))
|
|
351
|
+
_require(
|
|
352
|
+
bool(revisions) and all(value == expected for value in revisions), "revision", "mismatch"
|
|
353
|
+
)
|