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,745 @@
|
|
|
1
|
+
"""Validate local runtime inventory and complete image content without execution.
|
|
2
|
+
|
|
3
|
+
The enclosing fdai.offline-kit.v1 files map signs runtime/release.json and its
|
|
4
|
+
artifacts. This module does not establish production trust, contact registries,
|
|
5
|
+
or install anything. Catalog loading checks file hashes; validate_runtime_images
|
|
6
|
+
also checks OCI content for complete v2 and v3 inventories. Neither establishes SBOM or
|
|
7
|
+
provenance semantics, image attestation, or operational success.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import stat
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path, PurePosixPath
|
|
19
|
+
from typing import Literal, Protocol, cast
|
|
20
|
+
|
|
21
|
+
from fdai_deployment_cli import offline_kit
|
|
22
|
+
from fdai_deployment_cli.contracts import canonical_bytes, load_json_object
|
|
23
|
+
from fdai_deployment_cli.lifecycle_configuration import validate_release_configuration_schema
|
|
24
|
+
from fdai_deployment_cli.oci_archive import (
|
|
25
|
+
VerifiedOciImage,
|
|
26
|
+
validate_dependency_oci_archive,
|
|
27
|
+
validate_oci_archive,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
RUNTIME_RELEASE_PATH = "runtime/release.json"
|
|
31
|
+
_MAX_CATALOG_BYTES = 1024 * 1024
|
|
32
|
+
_LEGACY_SCHEMA = "fdai.runtime-release.v1"
|
|
33
|
+
_SCHEMA = "fdai.runtime-release.v2"
|
|
34
|
+
_LIFECYCLE_SCHEMA = "fdai.runtime-release.v3"
|
|
35
|
+
_COMMIT = re.compile(r"[0-9a-fA-F]{40}")
|
|
36
|
+
_SHA256 = re.compile(r"[0-9a-f]{64}")
|
|
37
|
+
_PATH = re.compile(r"runtime/[A-Za-z0-9._+/-]+")
|
|
38
|
+
_TOKEN = re.compile(r"[A-Za-z0-9._:/-]+")
|
|
39
|
+
_SEMVER = re.compile(
|
|
40
|
+
r"(0|[1-9][0-9]*)\."
|
|
41
|
+
r"(0|[1-9][0-9]*)\."
|
|
42
|
+
r"(0|[1-9][0-9]*)"
|
|
43
|
+
r"(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?"
|
|
44
|
+
r"\Z",
|
|
45
|
+
re.ASCII,
|
|
46
|
+
)
|
|
47
|
+
_NUMERIC = re.compile(r"0|[1-9][0-9]*\Z", re.ASCII)
|
|
48
|
+
_ALPHANUMERIC_NUMERIC_SUFFIX = re.compile(r"([0-9A-Za-z-]*[A-Za-z-])([0-9]+)\Z", re.ASCII)
|
|
49
|
+
_PLATFORMS = {"linux-x86_64", "linux-aarch64"}
|
|
50
|
+
RUNTIME_SERVICES = frozenset(
|
|
51
|
+
{
|
|
52
|
+
"core-control-plane",
|
|
53
|
+
"operator-service",
|
|
54
|
+
"document-ingestion-api",
|
|
55
|
+
"document-processing-worker",
|
|
56
|
+
"isolated-executor",
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
RUNTIME_SIDECARS = frozenset({"clamav", "pgvector"})
|
|
60
|
+
RUNTIME_INSTALLATION_AGENTS = frozenset({"infrastructure-agent", "lifecycle-agent"})
|
|
61
|
+
_ARCHIVE_KEYS = {"archive", "archive_sha256", "sbom", "sbom_sha256"}
|
|
62
|
+
_SERVICE_KEYS = _ARCHIVE_KEYS | {"image_digest", "provenance", "provenance_sha256"}
|
|
63
|
+
_CATALOG_KEYS = {
|
|
64
|
+
"schema_version",
|
|
65
|
+
"source_commit",
|
|
66
|
+
"platform_tag",
|
|
67
|
+
"deployment_bundle_sha256",
|
|
68
|
+
"services",
|
|
69
|
+
"console",
|
|
70
|
+
"deployment_support",
|
|
71
|
+
}
|
|
72
|
+
_SCHEMA_KEYS = {"target", "tolerates"}
|
|
73
|
+
_SCHEMA_RANGE_KEYS = {"minimum", "maximum"}
|
|
74
|
+
_CAPABILITY_RECORD_KEYS = {"kind", "maximum_mode"}
|
|
75
|
+
_CAPABILITY_KINDS = {"ActionType", "Workflow"}
|
|
76
|
+
_CAPABILITY_MODES = {"shadow", "enforce"}
|
|
77
|
+
_DOWNTIME_KEYS = {"entities"}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class RuntimeReleaseError(offline_kit.OfflineKitVerificationError):
|
|
81
|
+
"""Runtime release schema, compatibility, or local content is invalid."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True, slots=True)
|
|
85
|
+
class SchemaRange:
|
|
86
|
+
"""Inclusive database schema revision range tolerated by one Release."""
|
|
87
|
+
|
|
88
|
+
minimum: int
|
|
89
|
+
maximum: int
|
|
90
|
+
|
|
91
|
+
def contains(self, revision: int) -> bool:
|
|
92
|
+
return self.minimum <= revision <= self.maximum
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class SchemaMetadata(Protocol):
|
|
96
|
+
@property
|
|
97
|
+
def schema_target(self) -> int | None: ...
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def schema_range(self) -> SchemaRange | None: ...
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True, slots=True)
|
|
104
|
+
class ReleaseDecision:
|
|
105
|
+
"""Typed allow/deny result for pure release planning checks."""
|
|
106
|
+
|
|
107
|
+
allowed: bool
|
|
108
|
+
reason_code: str
|
|
109
|
+
details: tuple[str, ...] = ()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
RecallScope = Literal["release", "capability"]
|
|
113
|
+
RecallAction = Literal["recall", "lift"]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True, slots=True)
|
|
117
|
+
class RecallRecord:
|
|
118
|
+
"""Append-only recall fact used by local shadow-only release decisions."""
|
|
119
|
+
|
|
120
|
+
scope: RecallScope
|
|
121
|
+
target: str
|
|
122
|
+
sequence: int
|
|
123
|
+
notice_digest: str
|
|
124
|
+
action: RecallAction = "recall"
|
|
125
|
+
lifting_release_id: str | None = None
|
|
126
|
+
|
|
127
|
+
def __post_init__(self) -> None:
|
|
128
|
+
if self.scope not in {"release", "capability"}:
|
|
129
|
+
raise ValueError("recall scope MUST be release or capability")
|
|
130
|
+
if self.action not in {"recall", "lift"}:
|
|
131
|
+
raise ValueError("recall action MUST be recall or lift")
|
|
132
|
+
if type(self.sequence) is not int or self.sequence < 0:
|
|
133
|
+
raise ValueError("recall sequence MUST be a non-negative integer")
|
|
134
|
+
if _TOKEN.fullmatch(self.target) is None:
|
|
135
|
+
raise ValueError("recall target is invalid")
|
|
136
|
+
if self.scope == "release" and _release_version_parts(self.target) is None:
|
|
137
|
+
raise ValueError("recall Release id is invalid")
|
|
138
|
+
if _SHA256.fullmatch(self.notice_digest) is None:
|
|
139
|
+
raise ValueError("recall notice digest is invalid")
|
|
140
|
+
if self.action == "lift":
|
|
141
|
+
if (
|
|
142
|
+
self.lifting_release_id is None
|
|
143
|
+
or _TOKEN.fullmatch(self.lifting_release_id) is None
|
|
144
|
+
or _release_version_parts(self.lifting_release_id) is None
|
|
145
|
+
):
|
|
146
|
+
raise ValueError("recall lift MUST name the lifting Release")
|
|
147
|
+
elif self.lifting_release_id is not None:
|
|
148
|
+
raise ValueError("recall records MUST NOT name a lifting Release")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(frozen=True, slots=True)
|
|
152
|
+
class ConfigurationSchema:
|
|
153
|
+
"""Manifest-bound schema snapshot; parsing alone establishes no Release trust."""
|
|
154
|
+
|
|
155
|
+
release_version: str
|
|
156
|
+
digest: str
|
|
157
|
+
_keys: bytes = field(repr=False)
|
|
158
|
+
|
|
159
|
+
def to_mapping(self) -> dict[str, object]:
|
|
160
|
+
"""Return detached key definitions, never mutable manifest state."""
|
|
161
|
+
return load_json_object(self._keys, label="Release configuration schema")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass(frozen=True, slots=True)
|
|
165
|
+
class RuntimeRelease:
|
|
166
|
+
"""Immutable validated metadata; not a trust or execution authorization."""
|
|
167
|
+
|
|
168
|
+
source_commit: str
|
|
169
|
+
platform_tag: str
|
|
170
|
+
deployment_bundle_sha256: str
|
|
171
|
+
digest: str
|
|
172
|
+
artifact_paths: tuple[str, ...]
|
|
173
|
+
_catalog: bytes = field(repr=False)
|
|
174
|
+
schema_version: str = _LEGACY_SCHEMA
|
|
175
|
+
schema_target: int | None = None
|
|
176
|
+
schema_range: SchemaRange | None = None
|
|
177
|
+
capability_maximums: dict[str, str] = field(default_factory=dict)
|
|
178
|
+
downtime_entities: tuple[str, ...] = ()
|
|
179
|
+
installation_agent_images: tuple[str, ...] = ()
|
|
180
|
+
configuration_schema: ConfigurationSchema | None = None
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def product(self) -> str | None:
|
|
184
|
+
"""Legacy inventories have no independent application binding."""
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
def to_mapping(self) -> dict[str, object]:
|
|
188
|
+
"""Return a detached canonical catalog mapping, without host paths or bytes."""
|
|
189
|
+
|
|
190
|
+
return load_json_object(self._catalog, label="runtime release")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def load_runtime_release(
|
|
194
|
+
root: Path, *, expected_source_commit: str, expected_platform_tag: str
|
|
195
|
+
) -> RuntimeRelease:
|
|
196
|
+
"""Check runtime/release.json and its exact local artifact set below kit root.
|
|
197
|
+
|
|
198
|
+
Reads are bounded by the existing offline-kit per-file and total limits.
|
|
199
|
+
Revision and platform must match before artifact reads. Raises
|
|
200
|
+
RuntimeReleaseError with sanitized context on any invalid or unavailable input.
|
|
201
|
+
No copy, extraction, execution, network access, or production trust is implied.
|
|
202
|
+
Artifact payloads are opaque; matching hashes do not validate their semantics.
|
|
203
|
+
Callers must compare deployment_bundle_sha256 with the separately verified
|
|
204
|
+
deployment bundle archive, and snapshot and reverify bytes before use.
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
_require_directory(root)
|
|
209
|
+
_require_directory(root / "runtime")
|
|
210
|
+
raw = offline_kit._read_regular(root / RUNTIME_RELEASE_PATH, _MAX_CATALOG_BYTES)
|
|
211
|
+
release, declared = _parse_runtime_release(
|
|
212
|
+
raw,
|
|
213
|
+
expected_source_commit=expected_source_commit,
|
|
214
|
+
expected_platform_tag=expected_platform_tag,
|
|
215
|
+
)
|
|
216
|
+
_verify_tree(root, {**declared, RUNTIME_RELEASE_PATH: hashlib.sha256(raw).hexdigest()})
|
|
217
|
+
return release
|
|
218
|
+
except RuntimeReleaseError:
|
|
219
|
+
raise
|
|
220
|
+
except (OSError, ValueError, TypeError, RecursionError) as exc:
|
|
221
|
+
raise RuntimeReleaseError("runtime release is invalid or unavailable") from exc
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def parse_runtime_release_manifest(raw: bytes) -> RuntimeRelease:
|
|
225
|
+
"""Validate one runtime release manifest from bytes without reading its artifacts.
|
|
226
|
+
|
|
227
|
+
The manifest checks match load_runtime_release, but no source commit, platform, or local
|
|
228
|
+
artifact tree is compared. A caller that holds no offline kit, such as a Lifecycle Hub
|
|
229
|
+
catalog, uses this to read a Release it received. Raises RuntimeReleaseError on any
|
|
230
|
+
invalid input. No signature, publication, or execution authority is implied.
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
release, _declared = _parse_runtime_release(
|
|
235
|
+
raw, expected_source_commit=None, expected_platform_tag=None
|
|
236
|
+
)
|
|
237
|
+
return release
|
|
238
|
+
except RuntimeReleaseError:
|
|
239
|
+
raise
|
|
240
|
+
except (ValueError, TypeError, RecursionError) as exc:
|
|
241
|
+
raise RuntimeReleaseError("runtime release is invalid") from exc
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def is_release_id(value: str) -> bool:
|
|
245
|
+
"""Return whether `value` is a canonical Semantic Versioning Release ID."""
|
|
246
|
+
|
|
247
|
+
return _release_version_parts(value) is not None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def compare_release_ids(left: str, right: str) -> int:
|
|
251
|
+
"""Return -1, 0, or 1 by Semantic Versioning precedence of two canonical Release IDs."""
|
|
252
|
+
|
|
253
|
+
left_parts = _release_version_parts(left)
|
|
254
|
+
right_parts = _release_version_parts(right)
|
|
255
|
+
if left_parts is None or right_parts is None:
|
|
256
|
+
raise RuntimeReleaseError("release id is not canonical semantic version")
|
|
257
|
+
return _compare_release_versions(left_parts, right_parts)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _parse_runtime_release(
|
|
261
|
+
raw: bytes,
|
|
262
|
+
*,
|
|
263
|
+
expected_source_commit: str | None,
|
|
264
|
+
expected_platform_tag: str | None,
|
|
265
|
+
) -> tuple[RuntimeRelease, dict[str, str]]:
|
|
266
|
+
payload = load_json_object(raw, label="runtime release", max_bytes=_MAX_CATALOG_BYTES)
|
|
267
|
+
# The shared decoder currently accepts duplicate keys; reject them at every depth.
|
|
268
|
+
json.loads(raw, object_pairs_hook=_unique_object)
|
|
269
|
+
schema = payload.get("schema_version")
|
|
270
|
+
if not isinstance(schema, str) or schema not in (
|
|
271
|
+
_LEGACY_SCHEMA,
|
|
272
|
+
_SCHEMA,
|
|
273
|
+
_LIFECYCLE_SCHEMA,
|
|
274
|
+
):
|
|
275
|
+
raise RuntimeReleaseError("runtime release schema version is unsupported")
|
|
276
|
+
schema_keys = set(_CATALOG_KEYS)
|
|
277
|
+
if schema in {_SCHEMA, _LIFECYCLE_SCHEMA}:
|
|
278
|
+
schema_keys.add("sidecars")
|
|
279
|
+
if schema == _LIFECYCLE_SCHEMA:
|
|
280
|
+
schema_keys.update({"installation_agents", "schema", "capabilities", "downtime"})
|
|
281
|
+
if "configuration_schema" in payload:
|
|
282
|
+
schema_keys.add("configuration_schema")
|
|
283
|
+
catalog = _object(payload, schema_keys)
|
|
284
|
+
commit, platform = catalog["source_commit"], catalog["platform_tag"]
|
|
285
|
+
if not isinstance(commit, str) or _COMMIT.fullmatch(commit) is None:
|
|
286
|
+
raise RuntimeReleaseError("runtime release source commit is invalid")
|
|
287
|
+
if not isinstance(platform, str) or platform not in _PLATFORMS:
|
|
288
|
+
raise RuntimeReleaseError("runtime release platform is invalid")
|
|
289
|
+
if expected_source_commit is not None and commit != expected_source_commit:
|
|
290
|
+
raise RuntimeReleaseError("runtime release source commit does not match")
|
|
291
|
+
if expected_platform_tag is not None and platform != expected_platform_tag:
|
|
292
|
+
raise RuntimeReleaseError("runtime release platform does not match")
|
|
293
|
+
bundle_digest = catalog["deployment_bundle_sha256"]
|
|
294
|
+
if not isinstance(bundle_digest, str) or _SHA256.fullmatch(bundle_digest) is None:
|
|
295
|
+
raise RuntimeReleaseError("runtime release deployment bundle digest is invalid")
|
|
296
|
+
services = _object(catalog["services"], set(RUNTIME_SERVICES))
|
|
297
|
+
declared: dict[str, str] = {}
|
|
298
|
+
for service in sorted(RUNTIME_SERVICES):
|
|
299
|
+
_declare_record(services[service], service=True, declared=declared)
|
|
300
|
+
if schema == _SCHEMA:
|
|
301
|
+
sidecars = _object(catalog["sidecars"], set(RUNTIME_SIDECARS))
|
|
302
|
+
for sidecar in sorted(RUNTIME_SIDECARS):
|
|
303
|
+
_declare_record(sidecars[sidecar], service=True, declared=declared)
|
|
304
|
+
installation_agent_images: tuple[str, ...] = ()
|
|
305
|
+
schema_target: int | None = None
|
|
306
|
+
schema_range: SchemaRange | None = None
|
|
307
|
+
capability_maximums: dict[str, str] = {}
|
|
308
|
+
downtime_entities: tuple[str, ...] = ()
|
|
309
|
+
configuration_schema: ConfigurationSchema | None = None
|
|
310
|
+
if schema == _LIFECYCLE_SCHEMA:
|
|
311
|
+
sidecars = _object(catalog["sidecars"], set(RUNTIME_SIDECARS))
|
|
312
|
+
for sidecar in sorted(RUNTIME_SIDECARS):
|
|
313
|
+
_declare_record(sidecars[sidecar], service=True, declared=declared)
|
|
314
|
+
agents = _object(catalog["installation_agents"], set(RUNTIME_INSTALLATION_AGENTS))
|
|
315
|
+
for agent in sorted(RUNTIME_INSTALLATION_AGENTS):
|
|
316
|
+
_declare_record(agents[agent], service=True, declared=declared)
|
|
317
|
+
installation_agent_images = tuple(sorted(RUNTIME_INSTALLATION_AGENTS))
|
|
318
|
+
schema_target, schema_range = _release_schema(catalog["schema"])
|
|
319
|
+
capability_maximums = _capability_maximums(catalog["capabilities"])
|
|
320
|
+
downtime_entities = _downtime_entities(catalog["downtime"])
|
|
321
|
+
if "configuration_schema" in catalog:
|
|
322
|
+
configuration_schema = parse_release_configuration_schema(
|
|
323
|
+
catalog["configuration_schema"]
|
|
324
|
+
)
|
|
325
|
+
for section in ("console", "deployment_support"):
|
|
326
|
+
_declare_record(catalog[section], service=False, declared=declared)
|
|
327
|
+
canonical = canonical_bytes(catalog)
|
|
328
|
+
release = RuntimeRelease(
|
|
329
|
+
source_commit=commit,
|
|
330
|
+
platform_tag=platform,
|
|
331
|
+
deployment_bundle_sha256=bundle_digest,
|
|
332
|
+
digest=hashlib.sha256(canonical).hexdigest(),
|
|
333
|
+
artifact_paths=tuple(sorted(declared)),
|
|
334
|
+
_catalog=canonical,
|
|
335
|
+
schema_version=schema,
|
|
336
|
+
schema_target=schema_target,
|
|
337
|
+
schema_range=schema_range,
|
|
338
|
+
capability_maximums=capability_maximums,
|
|
339
|
+
downtime_entities=downtime_entities,
|
|
340
|
+
installation_agent_images=installation_agent_images,
|
|
341
|
+
configuration_schema=configuration_schema,
|
|
342
|
+
)
|
|
343
|
+
return release, declared
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def parse_release_configuration_schema(value: object) -> ConfigurationSchema:
|
|
347
|
+
artifact = _object(value, {"format", "release_version", "schema_sha256", "keys"})
|
|
348
|
+
version = artifact["release_version"]
|
|
349
|
+
if artifact["format"] != "fdai.release-configuration-schema.v1":
|
|
350
|
+
raise RuntimeReleaseError("Release configuration schema format is unsupported")
|
|
351
|
+
if not isinstance(version, str) or not is_release_id(version):
|
|
352
|
+
raise RuntimeReleaseError("Release configuration schema version is invalid")
|
|
353
|
+
keys = artifact["keys"]
|
|
354
|
+
if not isinstance(keys, dict):
|
|
355
|
+
raise RuntimeReleaseError("Release configuration schema keys are invalid")
|
|
356
|
+
validate_release_configuration_schema(keys)
|
|
357
|
+
canonical = canonical_bytes(keys)
|
|
358
|
+
digest = hashlib.sha256(canonical).hexdigest()
|
|
359
|
+
if artifact["schema_sha256"] != digest:
|
|
360
|
+
raise RuntimeReleaseError("Release configuration schema digest does not match")
|
|
361
|
+
return ConfigurationSchema(version, digest, canonical)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def validate_runtime_images(root: Path, release: RuntimeRelease) -> dict[str, str]:
|
|
365
|
+
"""Validate v2 and v3 OCI images against a previously verified catalog snapshot.
|
|
366
|
+
|
|
367
|
+
Inspect a private snapshot; callers own signature and release-eligibility checks.
|
|
368
|
+
Images are inspected one at a time within the existing per-file limits; no layer
|
|
369
|
+
extraction, process, registry call, provenance verification, or execution authority
|
|
370
|
+
results from this check.
|
|
371
|
+
"""
|
|
372
|
+
if release.schema_version not in {_SCHEMA, _LIFECYCLE_SCHEMA}:
|
|
373
|
+
raise RuntimeReleaseError("complete preparation requires runtime release v2 or v3")
|
|
374
|
+
catalog = release.to_mapping()
|
|
375
|
+
digests: dict[str, str] = {}
|
|
376
|
+
image: VerifiedOciImage[str] | VerifiedOciImage[None]
|
|
377
|
+
sections: list[tuple[str, frozenset[str], bool]] = [
|
|
378
|
+
("services", RUNTIME_SERVICES, True),
|
|
379
|
+
("sidecars", RUNTIME_SIDECARS, False),
|
|
380
|
+
]
|
|
381
|
+
if release.schema_version == _LIFECYCLE_SCHEMA:
|
|
382
|
+
sections.append(("installation_agents", RUNTIME_INSTALLATION_AGENTS, True))
|
|
383
|
+
for section, names, source_bound in sections:
|
|
384
|
+
records = _object(catalog[section], set(names))
|
|
385
|
+
for name in sorted(names):
|
|
386
|
+
record = _string_record(records[name], _SERVICE_KEYS)
|
|
387
|
+
path = root / record["archive"]
|
|
388
|
+
expected = {
|
|
389
|
+
"expected_archive_sha256": record["archive_sha256"],
|
|
390
|
+
"expected_manifest_digest": record["image_digest"],
|
|
391
|
+
"expected_platform_tag": release.platform_tag,
|
|
392
|
+
}
|
|
393
|
+
if source_bound:
|
|
394
|
+
image = validate_oci_archive(
|
|
395
|
+
path, expected_source_commit=release.source_commit, **expected
|
|
396
|
+
)
|
|
397
|
+
else:
|
|
398
|
+
image = validate_dependency_oci_archive(path, **expected)
|
|
399
|
+
digests[f"{section}/{name}"] = image.manifest.digest
|
|
400
|
+
del image
|
|
401
|
+
return digests
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def validate_schema_transition(
|
|
405
|
+
*,
|
|
406
|
+
current_schema_revision: int,
|
|
407
|
+
candidate: SchemaMetadata,
|
|
408
|
+
direction: Literal["upgrade", "rollback"],
|
|
409
|
+
) -> ReleaseDecision:
|
|
410
|
+
"""Allow only schema-compatible upgrade and roll-back candidates."""
|
|
411
|
+
|
|
412
|
+
if type(current_schema_revision) is not int or current_schema_revision < 0:
|
|
413
|
+
return ReleaseDecision(False, "schema_revision_invalid")
|
|
414
|
+
if direction not in {"upgrade", "rollback"}:
|
|
415
|
+
return ReleaseDecision(False, "schema_transition_direction_invalid")
|
|
416
|
+
if candidate.schema_target is None or candidate.schema_range is None:
|
|
417
|
+
return ReleaseDecision(False, "release_manifest_missing_schema_range")
|
|
418
|
+
if not candidate.schema_range.contains(current_schema_revision):
|
|
419
|
+
return ReleaseDecision(
|
|
420
|
+
False,
|
|
421
|
+
"schema_revision_outside_tolerated_range",
|
|
422
|
+
(str(current_schema_revision),),
|
|
423
|
+
)
|
|
424
|
+
if direction == "upgrade" and candidate.schema_target < current_schema_revision:
|
|
425
|
+
return ReleaseDecision(
|
|
426
|
+
False,
|
|
427
|
+
"schema_target_not_forward",
|
|
428
|
+
(str(candidate.schema_target), str(current_schema_revision)),
|
|
429
|
+
)
|
|
430
|
+
return ReleaseDecision(True, "allowed")
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def evaluate_recall_candidate(
|
|
434
|
+
*,
|
|
435
|
+
release_id: str,
|
|
436
|
+
capability_ids: tuple[str, ...],
|
|
437
|
+
recall_records: tuple[RecallRecord, ...],
|
|
438
|
+
) -> ReleaseDecision:
|
|
439
|
+
"""Deny re-promotion of active Release or capability recalls."""
|
|
440
|
+
|
|
441
|
+
problem = _recall_record_problem(recall_records, release_id=release_id)
|
|
442
|
+
if problem is not None:
|
|
443
|
+
return problem
|
|
444
|
+
active = _active_recalls(recall_records, candidate_release_id=release_id)
|
|
445
|
+
if ("release", release_id) in active:
|
|
446
|
+
return ReleaseDecision(False, "release_recalled", (release_id,))
|
|
447
|
+
for capability_id in capability_ids:
|
|
448
|
+
if ("capability", capability_id) in active:
|
|
449
|
+
return ReleaseDecision(False, "capability_recalled", (capability_id,))
|
|
450
|
+
return ReleaseDecision(True, "allowed")
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def recall_target_ordering(
|
|
454
|
+
*,
|
|
455
|
+
release_id: str,
|
|
456
|
+
capability_ids: tuple[str, ...],
|
|
457
|
+
recall_records: tuple[RecallRecord, ...],
|
|
458
|
+
) -> tuple[str, ...]:
|
|
459
|
+
"""Return active recall targets in deterministic display order."""
|
|
460
|
+
|
|
461
|
+
problem = _recall_record_problem(recall_records, release_id=release_id)
|
|
462
|
+
if problem is not None:
|
|
463
|
+
raise RuntimeReleaseError(problem.reason_code)
|
|
464
|
+
active = _active_recalls(recall_records, candidate_release_id=release_id)
|
|
465
|
+
release_priority = (
|
|
466
|
+
(active[("release", release_id)], f"release:{release_id}")
|
|
467
|
+
if ("release", release_id) in active
|
|
468
|
+
else None
|
|
469
|
+
)
|
|
470
|
+
capability_priorities = [
|
|
471
|
+
(active[("capability", capability_id)], f"capability:{capability_id}")
|
|
472
|
+
for capability_id in capability_ids
|
|
473
|
+
if ("capability", capability_id) in active
|
|
474
|
+
]
|
|
475
|
+
ordered = []
|
|
476
|
+
if release_priority is not None:
|
|
477
|
+
ordered.append(release_priority[1])
|
|
478
|
+
ordered.extend(
|
|
479
|
+
item
|
|
480
|
+
for _sequence, item in sorted(
|
|
481
|
+
capability_priorities, key=lambda entry: (-entry[0], entry[1])
|
|
482
|
+
)
|
|
483
|
+
)
|
|
484
|
+
return tuple(ordered)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _recall_record_problem(
|
|
488
|
+
records: tuple[RecallRecord, ...], *, release_id: str
|
|
489
|
+
) -> ReleaseDecision | None:
|
|
490
|
+
if _release_version_parts(release_id) is None:
|
|
491
|
+
return ReleaseDecision(False, "release_version_invalid", (release_id,))
|
|
492
|
+
observed: set[tuple[str, str, int]] = set()
|
|
493
|
+
for record in records:
|
|
494
|
+
key = (record.scope, record.target, record.sequence)
|
|
495
|
+
if key in observed:
|
|
496
|
+
return ReleaseDecision(
|
|
497
|
+
False,
|
|
498
|
+
"recall_sequence_duplicate",
|
|
499
|
+
(record.scope, record.target, str(record.sequence)),
|
|
500
|
+
)
|
|
501
|
+
observed.add(key)
|
|
502
|
+
if record.action == "lift" and record.scope == "capability":
|
|
503
|
+
assert record.lifting_release_id is not None
|
|
504
|
+
if _release_version_parts(record.lifting_release_id) is None:
|
|
505
|
+
return ReleaseDecision(
|
|
506
|
+
False,
|
|
507
|
+
"release_version_invalid",
|
|
508
|
+
(release_id, record.lifting_release_id),
|
|
509
|
+
)
|
|
510
|
+
return None
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _active_recalls(
|
|
514
|
+
records: tuple[RecallRecord, ...], *, candidate_release_id: str
|
|
515
|
+
) -> dict[tuple[str, str], int]:
|
|
516
|
+
active: dict[tuple[str, str], int] = {}
|
|
517
|
+
for record in sorted(records, key=lambda item: (item.sequence, item.action == "recall")):
|
|
518
|
+
key = (record.scope, record.target)
|
|
519
|
+
if record.action == "recall":
|
|
520
|
+
active[key] = record.sequence
|
|
521
|
+
elif record.scope == "capability" and _release_at_or_after(
|
|
522
|
+
candidate_release_id, record.lifting_release_id
|
|
523
|
+
):
|
|
524
|
+
active.pop(key, None)
|
|
525
|
+
return active
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _release_at_or_after(candidate: str, baseline: str | None) -> bool:
|
|
529
|
+
candidate_parts = _release_version_parts(candidate)
|
|
530
|
+
baseline_parts = _release_version_parts(baseline)
|
|
531
|
+
return (
|
|
532
|
+
candidate_parts is not None
|
|
533
|
+
and baseline_parts is not None
|
|
534
|
+
and _compare_release_versions(candidate_parts, baseline_parts) >= 0
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _release_version_parts(
|
|
539
|
+
value: str | None,
|
|
540
|
+
) -> tuple[int, int, int, tuple[str, ...] | None] | None:
|
|
541
|
+
if value is None:
|
|
542
|
+
return None
|
|
543
|
+
match = _SEMVER.fullmatch(value)
|
|
544
|
+
if match is None:
|
|
545
|
+
return None
|
|
546
|
+
major, minor, patch, prerelease = match.groups()
|
|
547
|
+
prerelease_parts: tuple[str, ...] | None = None
|
|
548
|
+
if prerelease is not None:
|
|
549
|
+
prerelease_parts = tuple(prerelease.split("."))
|
|
550
|
+
if any(part.isdigit() and _NUMERIC.fullmatch(part) is None for part in prerelease_parts):
|
|
551
|
+
return None
|
|
552
|
+
return int(major), int(minor), int(patch), prerelease_parts
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _compare_release_versions(
|
|
556
|
+
left: tuple[int, int, int, tuple[str, ...] | None],
|
|
557
|
+
right: tuple[int, int, int, tuple[str, ...] | None],
|
|
558
|
+
) -> int:
|
|
559
|
+
left_core, right_core = left[:3], right[:3]
|
|
560
|
+
if left_core != right_core:
|
|
561
|
+
return 1 if left_core > right_core else -1
|
|
562
|
+
left_prerelease, right_prerelease = left[3], right[3]
|
|
563
|
+
if left_prerelease is None and right_prerelease is None:
|
|
564
|
+
return 0
|
|
565
|
+
if left_prerelease is None:
|
|
566
|
+
return 1
|
|
567
|
+
if right_prerelease is None:
|
|
568
|
+
return -1
|
|
569
|
+
return _compare_prerelease(left_prerelease, right_prerelease)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _compare_prerelease(left: tuple[str, ...], right: tuple[str, ...]) -> int:
|
|
573
|
+
for left_part, right_part in zip(left, right, strict=False):
|
|
574
|
+
left_numeric, right_numeric = left_part.isdigit(), right_part.isdigit()
|
|
575
|
+
if left_numeric and right_numeric:
|
|
576
|
+
left_number, right_number = int(left_part), int(right_part)
|
|
577
|
+
if left_number != right_number:
|
|
578
|
+
return 1 if left_number > right_number else -1
|
|
579
|
+
elif left_numeric != right_numeric:
|
|
580
|
+
return -1 if left_numeric else 1
|
|
581
|
+
elif (numeric_suffix_order := _compare_numeric_suffix(left_part, right_part)) is not None:
|
|
582
|
+
return numeric_suffix_order
|
|
583
|
+
elif left_part != right_part:
|
|
584
|
+
return 1 if left_part > right_part else -1
|
|
585
|
+
if len(left) == len(right):
|
|
586
|
+
return 0
|
|
587
|
+
return 1 if len(left) > len(right) else -1
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _compare_numeric_suffix(left: str, right: str) -> int | None:
|
|
591
|
+
left_match = _ALPHANUMERIC_NUMERIC_SUFFIX.fullmatch(left)
|
|
592
|
+
right_match = _ALPHANUMERIC_NUMERIC_SUFFIX.fullmatch(right)
|
|
593
|
+
if left_match is None or right_match is None or left_match.group(1) != right_match.group(1):
|
|
594
|
+
return None
|
|
595
|
+
left_number, right_number = int(left_match.group(2)), int(right_match.group(2))
|
|
596
|
+
if left_number == right_number:
|
|
597
|
+
return 0
|
|
598
|
+
return 1 if left_number > right_number else -1
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
602
|
+
result: dict[str, object] = {}
|
|
603
|
+
for key, value in pairs:
|
|
604
|
+
if key in result:
|
|
605
|
+
raise RuntimeReleaseError("runtime release contains duplicate JSON keys")
|
|
606
|
+
result[key] = value
|
|
607
|
+
return result
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _release_schema(value: object) -> tuple[int, SchemaRange]:
|
|
611
|
+
metadata = _object(value, _SCHEMA_KEYS)
|
|
612
|
+
target = _schema_revision(metadata["target"])
|
|
613
|
+
tolerated = _object(metadata["tolerates"], _SCHEMA_RANGE_KEYS)
|
|
614
|
+
minimum = _schema_revision(tolerated["minimum"])
|
|
615
|
+
maximum = _schema_revision(tolerated["maximum"])
|
|
616
|
+
if minimum > maximum:
|
|
617
|
+
raise RuntimeReleaseError("runtime release schema range is invalid")
|
|
618
|
+
if not minimum <= target <= maximum:
|
|
619
|
+
raise RuntimeReleaseError("runtime release schema target is outside its tolerated range")
|
|
620
|
+
return target, SchemaRange(minimum=minimum, maximum=maximum)
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _schema_revision(value: object) -> int:
|
|
624
|
+
if type(value) is not int or value < 0:
|
|
625
|
+
raise RuntimeReleaseError("runtime release schema revision is invalid")
|
|
626
|
+
return value
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _capability_maximums(value: object) -> dict[str, str]:
|
|
630
|
+
if not isinstance(value, dict) or not value:
|
|
631
|
+
raise RuntimeReleaseError("runtime release capability maximums are invalid")
|
|
632
|
+
result: dict[str, str] = {}
|
|
633
|
+
for raw_name, raw_record in value.items():
|
|
634
|
+
if not isinstance(raw_name, str) or _TOKEN.fullmatch(raw_name) is None:
|
|
635
|
+
raise RuntimeReleaseError("runtime release capability identifier is invalid")
|
|
636
|
+
record = _object(raw_record, _CAPABILITY_RECORD_KEYS)
|
|
637
|
+
kind, maximum_mode = record["kind"], record["maximum_mode"]
|
|
638
|
+
if kind not in _CAPABILITY_KINDS or maximum_mode not in _CAPABILITY_MODES:
|
|
639
|
+
raise RuntimeReleaseError("runtime release capability maximum is invalid")
|
|
640
|
+
result[raw_name] = maximum_mode
|
|
641
|
+
return dict(sorted(result.items()))
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _downtime_entities(value: object) -> tuple[str, ...]:
|
|
645
|
+
record = _object(value, _DOWNTIME_KEYS)
|
|
646
|
+
entities = record["entities"]
|
|
647
|
+
if not isinstance(entities, list):
|
|
648
|
+
raise RuntimeReleaseError("runtime release downtime entities are invalid")
|
|
649
|
+
result: list[str] = []
|
|
650
|
+
observed: set[str] = set()
|
|
651
|
+
for entity in entities:
|
|
652
|
+
if not isinstance(entity, str) or _TOKEN.fullmatch(entity) is None:
|
|
653
|
+
raise RuntimeReleaseError("runtime release downtime entity is invalid")
|
|
654
|
+
if entity in observed:
|
|
655
|
+
raise RuntimeReleaseError("runtime release downtime entities are duplicated")
|
|
656
|
+
observed.add(entity)
|
|
657
|
+
result.append(entity)
|
|
658
|
+
return tuple(result)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _object(value: object, keys: set[str]) -> dict[str, object]:
|
|
662
|
+
if not isinstance(value, dict) or set(value) != keys:
|
|
663
|
+
raise RuntimeReleaseError("runtime release fields do not match the schema")
|
|
664
|
+
return cast(dict[str, object], value)
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _declare_record(value: object, *, service: bool, declared: dict[str, str]) -> None:
|
|
668
|
+
fields = _string_record(value, _SERVICE_KEYS if service else _ARCHIVE_KEYS)
|
|
669
|
+
if service and re.fullmatch(r"sha256:[0-9a-f]{64}", fields["image_digest"]) is None:
|
|
670
|
+
raise RuntimeReleaseError("runtime release image digest is invalid")
|
|
671
|
+
for key in ("archive", "sbom", "provenance") if service else ("archive", "sbom"):
|
|
672
|
+
path, digest = fields[key], fields[f"{key}_sha256"]
|
|
673
|
+
if _PATH.fullmatch(path) is None or any(
|
|
674
|
+
part in {"", ".", ".."} for part in path.split("/")
|
|
675
|
+
):
|
|
676
|
+
raise RuntimeReleaseError("runtime release artifact path is invalid")
|
|
677
|
+
if path == RUNTIME_RELEASE_PATH or path in declared:
|
|
678
|
+
raise RuntimeReleaseError(
|
|
679
|
+
"runtime release artifact path is duplicated or self-referencing"
|
|
680
|
+
)
|
|
681
|
+
if _SHA256.fullmatch(digest) is None:
|
|
682
|
+
raise RuntimeReleaseError("runtime release artifact digest is invalid")
|
|
683
|
+
declared[path] = digest
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _string_record(value: object, keys: set[str]) -> dict[str, str]:
|
|
687
|
+
record = _object(value, keys)
|
|
688
|
+
if not all(isinstance(item, str) for item in record.values()):
|
|
689
|
+
raise RuntimeReleaseError("runtime release artifact fields MUST be strings")
|
|
690
|
+
return cast(dict[str, str], record)
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _require_directory(path: Path) -> None:
|
|
694
|
+
"""Reject directory symlinks, including supplied-root ancestors, without resolving them."""
|
|
695
|
+
|
|
696
|
+
if ".." in path.parts:
|
|
697
|
+
raise RuntimeReleaseError("runtime release root MUST NOT traverse parent directories")
|
|
698
|
+
for component in (path, *path.parents):
|
|
699
|
+
if not stat.S_ISDIR(component.lstat().st_mode):
|
|
700
|
+
raise RuntimeReleaseError("runtime release directories MUST NOT be symlinks")
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _walk_error(error: OSError) -> None:
|
|
704
|
+
raise RuntimeReleaseError("runtime release directory is unavailable") from error
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _verify_tree(root: Path, declared: dict[str, str]) -> None:
|
|
708
|
+
"""Enforce the runtime-only exact set using the shared no-follow digest reader."""
|
|
709
|
+
|
|
710
|
+
directories = {
|
|
711
|
+
parent.as_posix()
|
|
712
|
+
for path in declared
|
|
713
|
+
for parent in PurePosixPath(path).parents
|
|
714
|
+
if parent.as_posix() != "."
|
|
715
|
+
}
|
|
716
|
+
observed: set[str] = set()
|
|
717
|
+
total = 0
|
|
718
|
+
for directory, children, names in os.walk(
|
|
719
|
+
root / "runtime", followlinks=False, onerror=_walk_error
|
|
720
|
+
):
|
|
721
|
+
base = Path(directory)
|
|
722
|
+
for name in children:
|
|
723
|
+
child = base / name
|
|
724
|
+
if child.relative_to(root).as_posix() not in directories or not stat.S_ISDIR(
|
|
725
|
+
child.lstat().st_mode
|
|
726
|
+
):
|
|
727
|
+
raise RuntimeReleaseError("runtime release contains extra or symlinked directories")
|
|
728
|
+
for name in names:
|
|
729
|
+
candidate = base / name
|
|
730
|
+
relative = candidate.relative_to(root).as_posix()
|
|
731
|
+
if relative not in declared or relative in observed:
|
|
732
|
+
raise RuntimeReleaseError("runtime release exact file set does not match")
|
|
733
|
+
details = candidate.lstat()
|
|
734
|
+
if not stat.S_ISREG(details.st_mode):
|
|
735
|
+
raise RuntimeReleaseError("runtime release artifacts MUST be regular files")
|
|
736
|
+
if details.st_size > offline_kit._MAX_FILE_BYTES:
|
|
737
|
+
raise RuntimeReleaseError("runtime release artifact exceeds its size limit")
|
|
738
|
+
total += details.st_size
|
|
739
|
+
if total > offline_kit._MAX_TOTAL_BYTES:
|
|
740
|
+
raise RuntimeReleaseError("runtime release exceeds its total size limit")
|
|
741
|
+
if offline_kit._sha256_nofollow(candidate, expected=details) != declared[relative]:
|
|
742
|
+
raise RuntimeReleaseError("runtime release artifact digest does not match")
|
|
743
|
+
observed.add(relative)
|
|
744
|
+
if observed != set(declared):
|
|
745
|
+
raise RuntimeReleaseError("runtime release exact file set does not match")
|