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,492 @@
1
+ """Offline signed configuration packages using the existing configuration validators.
2
+
3
+ Callers own trust registration and atomic durable history. This library neither enrolls keys
4
+ nor imports packages into the Hub or changes the existing Agent artifact protocol.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import re
11
+ from collections.abc import Mapping, Sequence
12
+ from dataclasses import dataclass
13
+
14
+ from cryptography.exceptions import InvalidSignature
15
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
16
+ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
17
+
18
+ from fdai_deployment_cli.contracts import canonical_bytes, canonical_digest
19
+ from fdai_deployment_cli.lifecycle_configuration import (
20
+ LayerResolution,
21
+ validate_configuration_package_for_signing,
22
+ )
23
+ from fdai_deployment_cli.lifecycle_configuration_document import (
24
+ validate_configuration_document,
25
+ )
26
+ from fdai_deployment_cli.lifecycle_configuration_entities import (
27
+ INSTALLATION_CONFIGURATION_FORMAT,
28
+ InstallationConfigurationInput,
29
+ InstallationLayerResolution,
30
+ prepare_installation_configuration,
31
+ resolve_installation_configuration,
32
+ )
33
+ from fdai_deployment_cli.lifecycle_configuration_sealing import (
34
+ encryption_key_digest,
35
+ open_identifiers,
36
+ read_object,
37
+ seal_identifiers,
38
+ validate_sealed_identifiers,
39
+ )
40
+
41
+ _FORMAT = "fdai.lifecycle-configuration-package.v1"
42
+ _DIGEST = re.compile(r"[0-9a-f]{64}\Z", re.ASCII)
43
+ _REVISION = re.compile(r"(?:[0-9a-f]{40}|[0-9a-f]{64})\Z", re.ASCII)
44
+ _MAX_BYTES = 1024 * 1024
45
+ _FIELDS = frozenset(
46
+ {
47
+ "format",
48
+ "revision_id",
49
+ "predecessor_digest",
50
+ "release_version",
51
+ "installation_key_digest",
52
+ "input_digest",
53
+ "configuration",
54
+ "sealed_keys",
55
+ "sealed",
56
+ }
57
+ )
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class SignedConfigurationPackage:
62
+ """Immutable detached-signature artifact; safe to persist without plaintext identifiers."""
63
+
64
+ payload: bytes
65
+ signature: bytes
66
+
67
+ @property
68
+ def digest(self) -> str:
69
+ return hashlib.sha256(self.payload).hexdigest()
70
+
71
+
72
+ @dataclass(frozen=True, slots=True, kw_only=True)
73
+ class ConfigurationPackageInput:
74
+ """One complete customer revision for the existing single-Entity configuration contract."""
75
+
76
+ revision_id: str
77
+ release_version: str
78
+ configuration_schema: Mapping[str, object]
79
+ environment_config: Mapping[str, object]
80
+ entity_overrides: Sequence[Mapping[str, object]]
81
+ sealed_values: Mapping[str, object]
82
+ sealed_keys: frozenset[str]
83
+
84
+
85
+ @dataclass(frozen=True, slots=True)
86
+ class ConfigurationHistoryBinding:
87
+ """Independent public trust for one ancestor, not current signing permission."""
88
+
89
+ configuration_key: Ed25519PublicKey
90
+ installation_key_digest: str
91
+
92
+
93
+ def build_configuration_package(
94
+ source: ConfigurationPackageInput,
95
+ *,
96
+ signing_key: Ed25519PrivateKey,
97
+ installation_key: X25519PublicKey,
98
+ history: Sequence[SignedConfigurationPackage] = (),
99
+ history_bindings: Sequence[ConfigurationHistoryBinding] | None = None,
100
+ ) -> SignedConfigurationPackage:
101
+ """Validate, seal, and sign a revision, or return its exact prior bytes on retry.
102
+
103
+ History must be the complete accepted chain in order. A caller must serialize builds
104
+ and persist the result atomically; passing an empty or stale history cannot prove that.
105
+ No key material is read from files or discovered from the environment.
106
+ """
107
+ sealed_values = read_object(canonical_bytes(source.sealed_values))
108
+ if set(sealed_values) != source.sealed_keys:
109
+ raise ValueError("sealed identifier values do not match the trusted sealed keys")
110
+ configuration = read_object(
111
+ canonical_bytes(
112
+ {
113
+ "schema": source.configuration_schema,
114
+ "environment": source.environment_config,
115
+ "entity_overrides": source.entity_overrides,
116
+ }
117
+ )
118
+ )
119
+ validate_configuration_document(
120
+ configuration, source.configuration_schema, source.release_version, set(source.sealed_keys)
121
+ )
122
+ return _build_signed_revision(
123
+ revision_id=source.revision_id,
124
+ release_version=source.release_version,
125
+ configuration=configuration,
126
+ sealed_values=sealed_values,
127
+ sealed_keys=source.sealed_keys,
128
+ package_format=_FORMAT,
129
+ release_digest=None,
130
+ signing_key=signing_key,
131
+ installation_key=installation_key,
132
+ history=history,
133
+ history_bindings=history_bindings,
134
+ )
135
+
136
+
137
+ def _build_signed_revision(
138
+ *,
139
+ revision_id: str,
140
+ release_version: str,
141
+ configuration: dict[str, object],
142
+ sealed_values: dict[str, object],
143
+ sealed_keys: frozenset[str],
144
+ package_format: str,
145
+ release_digest: str | None,
146
+ signing_key: Ed25519PrivateKey,
147
+ installation_key: X25519PublicKey,
148
+ history: Sequence[SignedConfigurationPackage],
149
+ history_bindings: Sequence[ConfigurationHistoryBinding] | None = None,
150
+ ) -> SignedConfigurationPackage:
151
+ recipient_digest = encryption_key_digest(installation_key)
152
+ chain = _verify_history(history, signing_key.public_key(), recipient_digest, history_bindings)
153
+ validate_configuration_package_for_signing(sealed_values)
154
+ _revision(revision_id)
155
+ _sealed_keys(sorted(sealed_keys))
156
+ inputs: dict[str, object] = {
157
+ "configuration": configuration,
158
+ "release_version": release_version,
159
+ "sealed_values": sealed_values,
160
+ "installation_key_digest": recipient_digest,
161
+ "sealed_keys": sorted(sealed_keys),
162
+ }
163
+ if release_digest is not None:
164
+ if not _DIGEST.fullmatch(release_digest):
165
+ raise ValueError("configuration Release manifest digest is invalid")
166
+ inputs["release_digest"] = release_digest
167
+ read_object(canonical_bytes(inputs))
168
+ input_digest = canonical_digest(inputs)
169
+ for package, previous in zip(history, chain, strict=True):
170
+ if previous["revision_id"] == revision_id:
171
+ if package != history[-1] or previous["input_digest"] != input_digest:
172
+ raise ValueError(
173
+ "configuration revision was already packaged with different inputs"
174
+ )
175
+ return package
176
+ document: dict[str, object] = {
177
+ "format": package_format,
178
+ "revision_id": revision_id,
179
+ "predecessor_digest": history[-1].digest if history else None,
180
+ "release_version": release_version,
181
+ "installation_key_digest": recipient_digest,
182
+ "input_digest": input_digest,
183
+ "configuration": configuration,
184
+ "sealed_keys": sorted(sealed_keys),
185
+ }
186
+ if release_digest is not None:
187
+ document["release_digest"] = release_digest
188
+ context = canonical_bytes(document)
189
+ document["sealed"] = seal_identifiers(sealed_values, installation_key, context=context)
190
+ payload = canonical_bytes(document)
191
+ if len(payload) > _MAX_BYTES:
192
+ raise ValueError("configuration package exceeds the size limit")
193
+ return SignedConfigurationPackage(payload, signing_key.sign(payload))
194
+
195
+
196
+ def verify_configuration_package(
197
+ package: SignedConfigurationPackage,
198
+ *,
199
+ configuration_key: Ed25519PublicKey,
200
+ installation_key_digest: str,
201
+ configuration_schema: Mapping[str, object],
202
+ sealed_keys: frozenset[str],
203
+ history: Sequence[SignedConfigurationPackage] = (),
204
+ history_bindings: Sequence[ConfigurationHistoryBinding] | None = None,
205
+ ) -> LayerResolution:
206
+ """Verify an import candidate against trusted schema, recipient, and complete history.
207
+
208
+ An exact retry of the current head is a no-op. An older revision cannot become current.
209
+ The returned values are public configuration only, never decrypted identifiers.
210
+ """
211
+ document = _verify_candidate(
212
+ package, configuration_key, installation_key_digest, sealed_keys, history, history_bindings
213
+ )
214
+ if document["format"] != _FORMAT:
215
+ raise ValueError("single-Entity reader cannot consume an installation revision")
216
+ configuration = _object(document["configuration"])
217
+ release_version = _text(document["release_version"])
218
+ return validate_configuration_document(
219
+ configuration, configuration_schema, release_version, _sealed_keys(document["sealed_keys"])
220
+ )
221
+
222
+
223
+ def decrypt_configuration_identifiers(
224
+ package: SignedConfigurationPackage,
225
+ *,
226
+ configuration_key: Ed25519PublicKey,
227
+ installation_key: X25519PrivateKey,
228
+ configuration_schema: Mapping[str, object],
229
+ sealed_keys: frozenset[str],
230
+ history: Sequence[SignedConfigurationPackage] = (),
231
+ history_bindings: Sequence[ConfigurationHistoryBinding] | None = None,
232
+ ) -> dict[str, object]:
233
+ """Authenticate the package and its chain before installation-local decryption."""
234
+ verify_configuration_package(
235
+ package,
236
+ configuration_key=configuration_key,
237
+ installation_key_digest=encryption_key_digest(installation_key.public_key()),
238
+ configuration_schema=configuration_schema,
239
+ sealed_keys=sealed_keys,
240
+ history=history,
241
+ history_bindings=history_bindings,
242
+ )
243
+ return _decrypt_values(package, installation_key)
244
+
245
+
246
+ def _decrypt_values(
247
+ package: SignedConfigurationPackage, installation_key: X25519PrivateKey
248
+ ) -> dict[str, object]:
249
+ document = read_object(package.payload)
250
+ envelope = _object(document.pop("sealed"))
251
+ values = open_identifiers(envelope, installation_key, context=canonical_bytes(document))
252
+ if document["format"] == _FORMAT and set(values) != _sealed_keys(document["sealed_keys"]):
253
+ raise ValueError("sealed identifier keys do not match the manifest")
254
+ inputs = {
255
+ "configuration": document["configuration"],
256
+ "release_version": document["release_version"],
257
+ "sealed_values": values,
258
+ "installation_key_digest": document["installation_key_digest"],
259
+ "sealed_keys": document["sealed_keys"],
260
+ }
261
+ if document["format"] == INSTALLATION_CONFIGURATION_FORMAT:
262
+ inputs["release_digest"] = document["release_digest"]
263
+ if canonical_digest(inputs) != document["input_digest"]:
264
+ raise ValueError("configuration input digest does not match")
265
+ return values
266
+
267
+
268
+ def _verify_history(
269
+ history: Sequence[SignedConfigurationPackage],
270
+ key: Ed25519PublicKey,
271
+ recipient_digest: str,
272
+ bindings: Sequence[ConfigurationHistoryBinding] | None = None,
273
+ ) -> list[dict[str, object]]:
274
+ if len(history) > 256:
275
+ raise ValueError("configuration history exceeds its bound")
276
+ if bindings is not None and len(bindings) != len(history):
277
+ raise ValueError("configuration history verification bindings are incomplete")
278
+ chain: list[dict[str, object]] = []
279
+ predecessor: str | None = None
280
+ for index, package in enumerate(history):
281
+ binding = bindings[index] if bindings is not None else None
282
+ document = _verified_document(
283
+ package,
284
+ binding.configuration_key if binding else key,
285
+ binding.installation_key_digest if binding else recipient_digest,
286
+ )
287
+ _check_successor(document, predecessor, chain)
288
+ chain.append(document)
289
+ predecessor = package.digest
290
+ return chain
291
+
292
+
293
+ def verify_configuration_history(
294
+ history: Sequence[SignedConfigurationPackage],
295
+ *,
296
+ bindings: Sequence[ConfigurationHistoryBinding],
297
+ ) -> None:
298
+ """Authenticate a retained chain with exact per-ancestor public trust, not current authority."""
299
+ if len(history) != len(bindings):
300
+ raise ValueError("configuration history verification bindings are incomplete")
301
+ if history:
302
+ _verify_history(
303
+ history, bindings[0].configuration_key, bindings[0].installation_key_digest, bindings
304
+ )
305
+
306
+
307
+ def _check_successor(
308
+ document: Mapping[str, object], predecessor: str | None, chain: Sequence[Mapping[str, object]]
309
+ ) -> None:
310
+ if document["predecessor_digest"] != predecessor:
311
+ raise ValueError("configuration predecessor gap or fork")
312
+ if any(item["revision_id"] == document["revision_id"] for item in chain):
313
+ raise ValueError("configuration revision was already imported")
314
+
315
+
316
+ def _verified_document(
317
+ package: SignedConfigurationPackage, key: Ed25519PublicKey, recipient_digest: str
318
+ ) -> dict[str, object]:
319
+ if not package.signature:
320
+ raise ValueError("configuration package signature is missing")
321
+ if len(package.payload) > _MAX_BYTES:
322
+ raise ValueError("configuration package exceeds the size limit")
323
+ try:
324
+ key.verify(package.signature, package.payload)
325
+ except InvalidSignature:
326
+ raise ValueError("configuration package signature is invalid") from None
327
+ document = read_object(package.payload)
328
+ package_format = document.get("format")
329
+ fields = _FIELDS | (
330
+ {"release_digest"} if package_format == INSTALLATION_CONFIGURATION_FORMAT else set()
331
+ )
332
+ if set(document) != fields or package_format not in (
333
+ _FORMAT,
334
+ INSTALLATION_CONFIGURATION_FORMAT,
335
+ ):
336
+ raise ValueError("configuration package fields or format are invalid")
337
+ if canonical_bytes(document) != package.payload:
338
+ raise ValueError("configuration package must use canonical JSON")
339
+ _revision(_text(document["revision_id"]))
340
+ for name in ("installation_key_digest", "input_digest"):
341
+ if not _DIGEST.fullmatch(_text(document[name])):
342
+ raise ValueError("configuration package digest is invalid")
343
+ if package_format == INSTALLATION_CONFIGURATION_FORMAT and not _DIGEST.fullmatch(
344
+ _text(document["release_digest"])
345
+ ):
346
+ raise ValueError("configuration Release manifest digest is invalid")
347
+ predecessor = document["predecessor_digest"]
348
+ if predecessor is not None and not _DIGEST.fullmatch(_text(predecessor)):
349
+ raise ValueError("configuration predecessor digest is invalid")
350
+ if document["installation_key_digest"] != recipient_digest:
351
+ raise ValueError("configuration package belongs to another installation key")
352
+ _sealed_keys(document["sealed_keys"])
353
+ validate_sealed_identifiers(_object(document["sealed"]), recipient_digest=recipient_digest)
354
+ return document
355
+
356
+
357
+ def _verify_candidate(
358
+ package: SignedConfigurationPackage,
359
+ configuration_key: Ed25519PublicKey,
360
+ recipient_digest: str,
361
+ sealed_keys: frozenset[str],
362
+ history: Sequence[SignedConfigurationPackage],
363
+ history_bindings: Sequence[ConfigurationHistoryBinding] | None = None,
364
+ ) -> dict[str, object]:
365
+ chain = _verify_history(history, configuration_key, recipient_digest, history_bindings)
366
+ document = _verified_document(package, configuration_key, recipient_digest)
367
+ if _sealed_keys(document["sealed_keys"]) != sealed_keys:
368
+ raise ValueError("sealed identifier keys do not match the trusted Release keys")
369
+ if not history or package != history[-1]:
370
+ _check_successor(document, history[-1].digest if history else None, chain)
371
+ return document
372
+
373
+
374
+ def build_installation_configuration_package(
375
+ source: InstallationConfigurationInput,
376
+ *,
377
+ entity_ids: frozenset[str],
378
+ signing_key: Ed25519PrivateKey,
379
+ installation_key: X25519PublicKey,
380
+ history: Sequence[SignedConfigurationPackage] = (),
381
+ history_bindings: Sequence[ConfigurationHistoryBinding] | None = None,
382
+ ) -> SignedConfigurationPackage:
383
+ """Produce one signed full revision, checked against the independently selected Entity set."""
384
+ configuration, sealed_values = prepare_installation_configuration(source, entity_ids=entity_ids)
385
+ return _build_signed_revision(
386
+ revision_id=source.revision_id,
387
+ release_version=source.release_version,
388
+ configuration=configuration,
389
+ sealed_values=sealed_values,
390
+ sealed_keys=source.sealed_keys,
391
+ package_format=INSTALLATION_CONFIGURATION_FORMAT,
392
+ release_digest=source.release_digest,
393
+ signing_key=signing_key,
394
+ installation_key=installation_key,
395
+ history=history,
396
+ history_bindings=history_bindings,
397
+ )
398
+
399
+
400
+ def verify_installation_configuration_package(
401
+ package: SignedConfigurationPackage,
402
+ *,
403
+ configuration_key: Ed25519PublicKey,
404
+ installation_key_digest: str,
405
+ configuration_schema: Mapping[str, object],
406
+ sealed_keys: frozenset[str],
407
+ entity_ids: frozenset[str],
408
+ release_version: str,
409
+ release_digest: str,
410
+ history: Sequence[SignedConfigurationPackage] = (),
411
+ history_bindings: Sequence[ConfigurationHistoryBinding] | None = None,
412
+ ) -> InstallationLayerResolution:
413
+ """Authenticate the full revision against independent Release, selection, keys and history."""
414
+ document = _verify_candidate(
415
+ package, configuration_key, installation_key_digest, sealed_keys, history, history_bindings
416
+ )
417
+ if (
418
+ document["format"] != INSTALLATION_CONFIGURATION_FORMAT
419
+ or document["release_version"] != release_version
420
+ or document["release_digest"] != release_digest
421
+ ):
422
+ raise ValueError("installation configuration format or Release does not match")
423
+ return resolve_installation_configuration(
424
+ _object(document["configuration"]),
425
+ configuration_schema=configuration_schema,
426
+ release_version=release_version,
427
+ sealed_keys=sealed_keys,
428
+ entity_ids=entity_ids,
429
+ )
430
+
431
+
432
+ def decrypt_installation_configuration_identifiers(
433
+ package: SignedConfigurationPackage,
434
+ *,
435
+ entity_id: str,
436
+ configuration_key: Ed25519PublicKey,
437
+ installation_key: X25519PrivateKey,
438
+ configuration_schema: Mapping[str, object],
439
+ sealed_keys: frozenset[str],
440
+ entity_ids: frozenset[str],
441
+ release_version: str,
442
+ release_digest: str,
443
+ history: Sequence[SignedConfigurationPackage] = (),
444
+ history_bindings: Sequence[ConfigurationHistoryBinding] | None = None,
445
+ ) -> dict[str, object]:
446
+ """Verify the whole revision before returning identifiers for exactly one selected Entity."""
447
+ verify_installation_configuration_package(
448
+ package,
449
+ configuration_key=configuration_key,
450
+ installation_key_digest=encryption_key_digest(installation_key.public_key()),
451
+ configuration_schema=configuration_schema,
452
+ sealed_keys=sealed_keys,
453
+ entity_ids=entity_ids,
454
+ release_version=release_version,
455
+ release_digest=release_digest,
456
+ history=history,
457
+ history_bindings=history_bindings,
458
+ )
459
+ if entity_id not in entity_ids:
460
+ raise ValueError("requested Entity is not selected")
461
+ values = _decrypt_values(package, installation_key)
462
+ if set(values) != entity_ids or any(
463
+ set(_object(item)) != sealed_keys for item in values.values()
464
+ ):
465
+ raise ValueError("sealed Entity identifier coverage does not match")
466
+ return _object(values[entity_id])
467
+
468
+
469
+ def _sealed_keys(value: object) -> set[str]:
470
+ if not isinstance(value, list) or any(not isinstance(item, str) or not item for item in value):
471
+ raise ValueError("sealed identifier keys are invalid")
472
+ keys = {_text(item) for item in value}
473
+ if len(keys) != len(value):
474
+ raise ValueError("sealed identifier keys are duplicated")
475
+ return keys
476
+
477
+
478
+ def _object(value: object) -> dict[str, object]:
479
+ if not isinstance(value, Mapping) or any(not isinstance(key, str) for key in value):
480
+ raise ValueError("configuration field must be an object")
481
+ return {str(key): item for key, item in value.items()}
482
+
483
+
484
+ def _text(value: object) -> str:
485
+ if not isinstance(value, str):
486
+ raise ValueError("configuration field must be text")
487
+ return value
488
+
489
+
490
+ def _revision(value: str) -> None:
491
+ if not _REVISION.fullmatch(value):
492
+ raise ValueError("configuration revision must be a full Git commit identifier")