fdai-deployment-cli 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. fdai_deployment_cli/__about__.py +3 -0
  2. fdai_deployment_cli/__init__.py +5 -0
  3. fdai_deployment_cli/__main__.py +5 -0
  4. fdai_deployment_cli/acr_publish.py +585 -0
  5. fdai_deployment_cli/aks_catalog_review.py +137 -0
  6. fdai_deployment_cli/aks_historical_reconciliation.py +732 -0
  7. fdai_deployment_cli/aks_job_environment.py +59 -0
  8. fdai_deployment_cli/aks_job_execution.py +794 -0
  9. fdai_deployment_cli/aks_kubernetes_defaults.py +66 -0
  10. fdai_deployment_cli/aks_preflight.py +422 -0
  11. fdai_deployment_cli/aks_readiness.py +220 -0
  12. fdai_deployment_cli/aks_service_update.py +180 -0
  13. fdai_deployment_cli/aks_workload_jobs.py +281 -0
  14. fdai_deployment_cli/application_state_adoption.py +347 -0
  15. fdai_deployment_cli/azure_actor.py +61 -0
  16. fdai_deployment_cli/azure_naming.py +252 -0
  17. fdai_deployment_cli/azure_retail_prices.py +173 -0
  18. fdai_deployment_cli/bootstrap_approval.py +99 -0
  19. fdai_deployment_cli/bootstrap_channel.py +321 -0
  20. fdai_deployment_cli/bootstrap_executor.py +311 -0
  21. fdai_deployment_cli/bootstrap_plan.py +237 -0
  22. fdai_deployment_cli/bootstrap_reconcile.py +402 -0
  23. fdai_deployment_cli/bootstrap_support.py +138 -0
  24. fdai_deployment_cli/bootstrap_worker.py +448 -0
  25. fdai_deployment_cli/bundle.py +352 -0
  26. fdai_deployment_cli/capacity.py +143 -0
  27. fdai_deployment_cli/catalog_review_profile.py +306 -0
  28. fdai_deployment_cli/cli.py +798 -0
  29. fdai_deployment_cli/cli_help.py +214 -0
  30. fdai_deployment_cli/cli_parser.py +791 -0
  31. fdai_deployment_cli/cli_plan.py +450 -0
  32. fdai_deployment_cli/compiler.py +99 -0
  33. fdai_deployment_cli/console_artifact.py +218 -0
  34. fdai_deployment_cli/console_config.py +214 -0
  35. fdai_deployment_cli/console_update.py +832 -0
  36. fdai_deployment_cli/contracts.py +307 -0
  37. fdai_deployment_cli/control_package.py +140 -0
  38. fdai_deployment_cli/deadline_transport.py +70 -0
  39. fdai_deployment_cli/deployment_cost.py +131 -0
  40. fdai_deployment_cli/deployment_deadline.py +28 -0
  41. fdai_deployment_cli/deployment_kit.py +790 -0
  42. fdai_deployment_cli/deployment_kit_cache.py +191 -0
  43. fdai_deployment_cli/deployment_progress.py +648 -0
  44. fdai_deployment_cli/development_authority_cli.py +56 -0
  45. fdai_deployment_cli/development_authority_profile.py +319 -0
  46. fdai_deployment_cli/doctor.py +136 -0
  47. fdai_deployment_cli/entitlement_preflight.py +68 -0
  48. fdai_deployment_cli/entitlement_transport.py +49 -0
  49. fdai_deployment_cli/entra_bootstrap.py +579 -0
  50. fdai_deployment_cli/entra_control_profile.py +214 -0
  51. fdai_deployment_cli/entra_graph.py +349 -0
  52. fdai_deployment_cli/entra_profiles.py +215 -0
  53. fdai_deployment_cli/entra_source.py +244 -0
  54. fdai_deployment_cli/execution_bundle.py +478 -0
  55. fdai_deployment_cli/execution_copies.py +63 -0
  56. fdai_deployment_cli/foundation_adoption.py +438 -0
  57. fdai_deployment_cli/foundation_adoption_evidence.py +245 -0
  58. fdai_deployment_cli/foundation_adoption_host.py +157 -0
  59. fdai_deployment_cli/foundation_adoption_transport.py +51 -0
  60. fdai_deployment_cli/foundation_failure.py +72 -0
  61. fdai_deployment_cli/foundation_image.py +168 -0
  62. fdai_deployment_cli/foundation_input.py +235 -0
  63. fdai_deployment_cli/foundation_output.py +154 -0
  64. fdai_deployment_cli/foundation_plan.py +381 -0
  65. fdai_deployment_cli/foundation_process.py +64 -0
  66. fdai_deployment_cli/foundation_progress.py +231 -0
  67. fdai_deployment_cli/github_actions.py +171 -0
  68. fdai_deployment_cli/github_apply_artifact.py +259 -0
  69. fdai_deployment_cli/github_artifact_io.py +48 -0
  70. fdai_deployment_cli/github_dispatch.py +248 -0
  71. fdai_deployment_cli/github_plan_artifact.py +181 -0
  72. fdai_deployment_cli/github_provider_artifact.py +170 -0
  73. fdai_deployment_cli/github_transport.py +46 -0
  74. fdai_deployment_cli/github_workflow_values.py +312 -0
  75. fdai_deployment_cli/hub_image_deploy.py +354 -0
  76. fdai_deployment_cli/hub_image_stage.py +162 -0
  77. fdai_deployment_cli/hub_image_trust.py +281 -0
  78. fdai_deployment_cli/hub_ownership.py +161 -0
  79. fdai_deployment_cli/hub_workload_handoff.py +264 -0
  80. fdai_deployment_cli/hub_workload_handoff_inputs.py +227 -0
  81. fdai_deployment_cli/identity_profile.py +227 -0
  82. fdai_deployment_cli/image_targets.py +197 -0
  83. fdai_deployment_cli/installation_scope.py +280 -0
  84. fdai_deployment_cli/license.py +311 -0
  85. fdai_deployment_cli/license_issue.py +239 -0
  86. fdai_deployment_cli/lifecycle_authorization.py +252 -0
  87. fdai_deployment_cli/lifecycle_commands.py +173 -0
  88. fdai_deployment_cli/lifecycle_configuration.py +722 -0
  89. fdai_deployment_cli/lifecycle_configuration_artifact.py +45 -0
  90. fdai_deployment_cli/lifecycle_configuration_cli.py +324 -0
  91. fdai_deployment_cli/lifecycle_configuration_document.py +74 -0
  92. fdai_deployment_cli/lifecycle_configuration_entities.py +146 -0
  93. fdai_deployment_cli/lifecycle_configuration_package.py +492 -0
  94. fdai_deployment_cli/lifecycle_configuration_publication.py +277 -0
  95. fdai_deployment_cli/lifecycle_configuration_publication_cli.py +191 -0
  96. fdai_deployment_cli/lifecycle_configuration_sealing.py +127 -0
  97. fdai_deployment_cli/lifecycle_enrollment.py +45 -0
  98. fdai_deployment_cli/lifecycle_infrastructure_agent.py +174 -0
  99. fdai_deployment_cli/lifecycle_installation_binding.py +65 -0
  100. fdai_deployment_cli/lifecycle_native.py +247 -0
  101. fdai_deployment_cli/lifecycle_native_reconciliation.py +568 -0
  102. fdai_deployment_cli/lifecycle_native_transition.py +339 -0
  103. fdai_deployment_cli/lifecycle_ownership_exclusion.py +78 -0
  104. fdai_deployment_cli/lifecycle_plan.py +800 -0
  105. fdai_deployment_cli/lifecycle_promotion.py +185 -0
  106. fdai_deployment_cli/lifecycle_release.py +308 -0
  107. fdai_deployment_cli/lifecycle_self_upgrade.py +202 -0
  108. fdai_deployment_cli/lifecycle_upgrade_bundle.py +176 -0
  109. fdai_deployment_cli/notification_recipients_input.py +222 -0
  110. fdai_deployment_cli/observer_artifact.py +106 -0
  111. fdai_deployment_cli/oci_archive.py +353 -0
  112. fdai_deployment_cli/offline_kit.py +797 -0
  113. fdai_deployment_cli/offline_prepare.py +135 -0
  114. fdai_deployment_cli/operational_evidence_verifier_input.py +575 -0
  115. fdai_deployment_cli/plan_input.py +101 -0
  116. fdai_deployment_cli/private_output.py +192 -0
  117. fdai_deployment_cli/profile.py +70 -0
  118. fdai_deployment_cli/progress.py +183 -0
  119. fdai_deployment_cli/readiness.py +286 -0
  120. fdai_deployment_cli/run_command_receiver.py +418 -0
  121. fdai_deployment_cli/runtime_build.py +386 -0
  122. fdai_deployment_cli/runtime_profile.py +287 -0
  123. fdai_deployment_cli/runtime_release.py +745 -0
  124. fdai_deployment_cli/runtime_stage.py +82 -0
  125. fdai_deployment_cli/runtime_support_installation.py +215 -0
  126. fdai_deployment_cli/simulation.py +80 -0
  127. fdai_deployment_cli/source_application_inputs.py +397 -0
  128. fdai_deployment_cli/source_azure.py +586 -0
  129. fdai_deployment_cli/source_change_cli.py +107 -0
  130. fdai_deployment_cli/source_change_parser.py +75 -0
  131. fdai_deployment_cli/source_change_plan.py +181 -0
  132. fdai_deployment_cli/source_changed_update.py +196 -0
  133. fdai_deployment_cli/source_deploy.py +170 -0
  134. fdai_deployment_cli/source_foundation.py +174 -0
  135. fdai_deployment_cli/source_host_tools.py +163 -0
  136. fdai_deployment_cli/source_image_stage.py +400 -0
  137. fdai_deployment_cli/source_input.py +173 -0
  138. fdai_deployment_cli/source_projection.py +164 -0
  139. fdai_deployment_cli/source_receiver.py +76 -0
  140. fdai_deployment_cli/source_recovery.py +264 -0
  141. fdai_deployment_cli/source_runtime.py +81 -0
  142. fdai_deployment_cli/source_runtime_support.py +368 -0
  143. fdai_deployment_cli/source_service_update.py +726 -0
  144. fdai_deployment_cli/source_service_update_cli.py +47 -0
  145. fdai_deployment_cli/source_snapshot.py +178 -0
  146. fdai_deployment_cli/source_teardown.py +574 -0
  147. fdai_deployment_cli/source_terraform.py +85 -0
  148. fdai_deployment_cli/source_transport.py +399 -0
  149. fdai_deployment_cli/standalone_aks_inventory.py +558 -0
  150. fdai_deployment_cli/standalone_aks_job_execution.py +769 -0
  151. fdai_deployment_cli/standalone_aks_nodepool_guard.py +141 -0
  152. fdai_deployment_cli/standalone_application.py +977 -0
  153. fdai_deployment_cli/standalone_application_completion.py +151 -0
  154. fdai_deployment_cli/standalone_application_input_recovery.py +107 -0
  155. fdai_deployment_cli/standalone_application_recovery.py +116 -0
  156. fdai_deployment_cli/standalone_approval_input.py +24 -0
  157. fdai_deployment_cli/standalone_catalog_checkpoint.py +122 -0
  158. fdai_deployment_cli/standalone_catalog_review.py +251 -0
  159. fdai_deployment_cli/standalone_checkpoint_failure.py +127 -0
  160. fdai_deployment_cli/standalone_console_publish.py +174 -0
  161. fdai_deployment_cli/standalone_database_principals.py +41 -0
  162. fdai_deployment_cli/standalone_deploy.py +638 -0
  163. fdai_deployment_cli/standalone_development_authority.py +166 -0
  164. fdai_deployment_cli/standalone_foundation_adoption.py +87 -0
  165. fdai_deployment_cli/standalone_foundation_transition.py +767 -0
  166. fdai_deployment_cli/standalone_host.py +4608 -0
  167. fdai_deployment_cli/standalone_host_rebind.py +226 -0
  168. fdai_deployment_cli/standalone_host_residual.py +104 -0
  169. fdai_deployment_cli/standalone_host_state.py +145 -0
  170. fdai_deployment_cli/standalone_host_values.py +563 -0
  171. fdai_deployment_cli/standalone_kubernetes_tools.py +100 -0
  172. fdai_deployment_cli/standalone_license_installation.py +177 -0
  173. fdai_deployment_cli/standalone_management_egress.py +83 -0
  174. fdai_deployment_cli/standalone_migration_evidence.py +38 -0
  175. fdai_deployment_cli/standalone_operational_evidence.py +356 -0
  176. fdai_deployment_cli/standalone_planned_outputs.py +147 -0
  177. fdai_deployment_cli/standalone_private_access_probe.py +100 -0
  178. fdai_deployment_cli/standalone_product_profile.py +110 -0
  179. fdai_deployment_cli/standalone_remote_prepare.py +442 -0
  180. fdai_deployment_cli/standalone_residual_apply.py +540 -0
  181. fdai_deployment_cli/standalone_residual_successor.py +431 -0
  182. fdai_deployment_cli/standalone_review.py +266 -0
  183. fdai_deployment_cli/standalone_service_update_finalization.py +116 -0
  184. fdai_deployment_cli/standalone_service_update_recovery.py +277 -0
  185. fdai_deployment_cli/standalone_stage_targets.py +325 -0
  186. fdai_deployment_cli/standalone_status.py +67 -0
  187. fdai_deployment_cli/standalone_terraform_environment.py +237 -0
  188. fdai_deployment_cli/standalone_transfer_cleanup.py +237 -0
  189. fdai_deployment_cli/standalone_trial_activation.py +193 -0
  190. fdai_deployment_cli/state.py +434 -0
  191. fdai_deployment_cli/state_handoff.py +253 -0
  192. fdai_deployment_cli/status_projection.py +97 -0
  193. fdai_deployment_cli/support_install.py +257 -0
  194. fdai_deployment_cli/target.py +46 -0
  195. fdai_deployment_cli/trust/capabilities.json +62 -0
  196. fdai_deployment_cli/trust/deployment-bundle-root.pub +3 -0
  197. fdai_deployment_cli/trust/deployment-release-root.pub +3 -0
  198. fdai_deployment_cli/trust/upstream-signing-key.pub +3 -0
  199. fdai_deployment_cli/trust_roots.py +50 -0
  200. fdai_deployment_cli-0.1.2.dist-info/METADATA +112 -0
  201. fdai_deployment_cli-0.1.2.dist-info/RECORD +203 -0
  202. fdai_deployment_cli-0.1.2.dist-info/WHEEL +4 -0
  203. fdai_deployment_cli-0.1.2.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,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")