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,277 @@
1
+ """Venue-scoped persistence, not Git review, Hub acceptance or a global publisher lease."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+ import sqlite3
8
+ import time
9
+ from collections.abc import Callable, Iterator, Sequence
10
+ from contextlib import contextmanager
11
+ from dataclasses import dataclass
12
+ from datetime import UTC, datetime
13
+ from pathlib import Path
14
+ from uuid import uuid4
15
+
16
+ from fdai_deployment_cli.contracts import canonical_bytes
17
+ from fdai_deployment_cli.deployment_kit_cache import acquisition_lock, path_present
18
+ from fdai_deployment_cli.lifecycle_configuration_artifact import (
19
+ MAX_CONFIGURATION_ARTIFACT_BYTES,
20
+ configuration_artifact_bytes,
21
+ read_configuration_artifact,
22
+ )
23
+ from fdai_deployment_cli.lifecycle_configuration_package import SignedConfigurationPackage
24
+ from fdai_deployment_cli.lifecycle_configuration_sealing import read_object
25
+ from fdai_deployment_cli.private_output import read_private_bytes, write_private_bytes
26
+
27
+ MAX_PUBLICATIONS = 256
28
+ MAX_PUBLICATION_BYTES = 16 * 1024 * 1024
29
+ _DATABASE = "publication.sqlite3"
30
+ _FORMAT = "fdai.configuration-publication-state.v1"
31
+ _REVISION = re.compile(r"[0-9a-f]{40}")
32
+ _SHA256 = re.compile(r"[0-9a-f]{64}")
33
+ Build = Callable[[Sequence[SignedConfigurationPackage]], SignedConfigurationPackage]
34
+ Verify = Callable[[Sequence[SignedConfigurationPackage], SignedConfigurationPackage], None]
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class Publication:
39
+ venue_id: str
40
+ sequence: int
41
+ revision_id: str
42
+ created_at: str
43
+ package: SignedConfigurationPackage
44
+ artifact: bytes
45
+
46
+ @property
47
+ def artifact_name(self) -> str:
48
+ return f"{self.package.digest}.json"
49
+
50
+ def receipt(self) -> dict[str, object]:
51
+ value: dict[str, object] = {
52
+ "format": "fdai.configuration-publication-receipt.v1",
53
+ "venue_id": self.venue_id,
54
+ "sequence": self.sequence,
55
+ "revision_id": self.revision_id,
56
+ "predecessor_digest": read_object(self.package.payload)["predecessor_digest"],
57
+ "package_digest": self.package.digest,
58
+ "artifact_digest": hashlib.sha256(self.artifact).hexdigest(),
59
+ "artifact_name": self.artifact_name,
60
+ "created_at": self.created_at,
61
+ }
62
+ return {**value, "receipt_digest": hashlib.sha256(canonical_bytes(value)).hexdigest()}
63
+
64
+
65
+ def _revision_id(value: str) -> str:
66
+ if _REVISION.fullmatch(value) is None:
67
+ raise ValueError("publication revision must be 40 lowercase hexadecimal characters")
68
+ return value
69
+
70
+
71
+ @contextmanager
72
+ def _connection(
73
+ venue: Path, *, initial: bool = False, readonly: bool = False
74
+ ) -> Iterator[sqlite3.Connection]:
75
+ database = venue / _DATABASE
76
+ new = not path_present(database)
77
+ if new:
78
+ if readonly or not initial or any(venue.iterdir()):
79
+ raise ValueError("publication state is absent; preserve the venue for review")
80
+ write_private_bytes(database, b"")
81
+ else:
82
+ snapshot = read_private_bytes(database, max_bytes=2 * MAX_PUBLICATION_BYTES)
83
+ if not snapshot.startswith(b"SQLite format 3\0") or snapshot[18:20] != b"\x01\x01":
84
+ raise ValueError("publication journal storage format is corrupt or unsupported")
85
+ if any(path_present(venue / (_DATABASE + suffix)) for suffix in ("-journal", "-wal", "-shm")):
86
+ raise ValueError(
87
+ "retained publication transaction sidecar requires review, not automatic recovery"
88
+ )
89
+ connection = sqlite3.connect(
90
+ database.as_uri() + "?mode=ro" if readonly else str(database),
91
+ uri=readonly,
92
+ timeout=0.1,
93
+ isolation_level=None,
94
+ )
95
+ deadline = time.monotonic() + 10
96
+ connection.set_progress_handler(lambda: int(time.monotonic() > deadline), 1000)
97
+ try:
98
+ if new:
99
+ connection.executescript(
100
+ "PRAGMA journal_mode=DELETE; PRAGMA synchronous=FULL; BEGIN IMMEDIATE;"
101
+ "CREATE TABLE metadata (id INTEGER PRIMARY KEY, format TEXT NOT NULL, venue_id TEXT NOT NULL);"
102
+ "CREATE TABLE packages (sequence INTEGER PRIMARY KEY, revision_id TEXT NOT NULL UNIQUE,"
103
+ "receipt BLOB NOT NULL, artifact BLOB NOT NULL);"
104
+ )
105
+ connection.execute("INSERT INTO metadata VALUES (1, ?, ?)", (_FORMAT, uuid4().hex))
106
+ connection.execute("PRAGMA user_version=1")
107
+ connection.commit()
108
+ if connection.execute("PRAGMA user_version").fetchone() != (1,):
109
+ raise ValueError("unsupported publication journal version")
110
+ if connection.execute("PRAGMA quick_check(1)").fetchone() != ("ok",):
111
+ raise ValueError("publication journal integrity check failed")
112
+ tables = {
113
+ row[0]
114
+ for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")
115
+ }
116
+ if tables != {"metadata", "packages"}:
117
+ raise ValueError("publication journal schema is incomplete or unknown")
118
+ for table, columns in (
119
+ ("metadata", ("id", "format", "venue_id")),
120
+ ("packages", ("sequence", "revision_id", "receipt", "artifact")),
121
+ ):
122
+ actual = tuple(row[1] for row in connection.execute(f"PRAGMA table_info({table})")) # noqa: S608
123
+ if actual != columns:
124
+ raise ValueError("publication journal columns are invalid")
125
+ if not readonly:
126
+ connection.execute("PRAGMA synchronous=FULL")
127
+ yield connection
128
+ finally:
129
+ connection.close()
130
+
131
+
132
+ def _load(connection: sqlite3.Connection, venue: Path) -> tuple[str, list[Publication]]:
133
+ metadata = connection.execute("SELECT id, format, venue_id FROM metadata").fetchall()
134
+ if (
135
+ len(metadata) != 1
136
+ or metadata[0][0] != 1
137
+ or metadata[0][1] != _FORMAT
138
+ or not isinstance(metadata[0][2], str)
139
+ or re.fullmatch(r"[0-9a-f]{32}", metadata[0][2]) is None
140
+ ):
141
+ raise ValueError("publication venue binding is invalid")
142
+ venue_id = metadata[0][2]
143
+ count, total, receipt_size = connection.execute(
144
+ "SELECT COUNT(*), COALESCE(SUM(length(artifact)),0), COALESCE(MAX(length(receipt)),0) FROM packages"
145
+ ).fetchone()
146
+ if count > MAX_PUBLICATIONS or total > MAX_PUBLICATION_BYTES or receipt_size > 4096:
147
+ raise ValueError("retained publication history exceeds its bound")
148
+ result: list[Publication] = []
149
+ previous: str | None = None
150
+ for expected, row in enumerate(
151
+ connection.execute(
152
+ "SELECT sequence, revision_id, receipt, artifact FROM packages ORDER BY sequence"
153
+ ),
154
+ 1,
155
+ ):
156
+ sequence, revision, receipt_bytes, artifact = row
157
+ if (
158
+ sequence != expected
159
+ or not isinstance(revision, str)
160
+ or not isinstance(receipt_bytes, bytes)
161
+ or not isinstance(artifact, bytes)
162
+ ):
163
+ raise ValueError("retained publication identity is invalid")
164
+ _revision_id(revision)
165
+ package = read_configuration_artifact(artifact)
166
+ payload = read_object(package.payload)
167
+ if (
168
+ payload.get("revision_id") != revision
169
+ or "predecessor_digest" not in payload
170
+ or payload["predecessor_digest"] != previous
171
+ ):
172
+ raise ValueError("retained publication chain is incomplete or forked")
173
+ receipt = read_object(receipt_bytes)
174
+ created = receipt.get("created_at")
175
+ if not isinstance(created, str) or datetime.fromisoformat(created).utcoffset() is None:
176
+ raise ValueError("publication receipt time is invalid")
177
+ record = Publication(venue_id, sequence, revision, created, package, artifact)
178
+ if receipt != record.receipt() or canonical_bytes(receipt) != receipt_bytes:
179
+ raise ValueError("publication receipt or artifact digest is inconsistent")
180
+ result.append(record)
181
+ _artifact_state(venue, record, materialize=False)
182
+ previous = package.digest
183
+ expected_names = {_DATABASE} | {r.artifact_name for r in result}
184
+ if any(path.name not in expected_names for path in venue.iterdir()):
185
+ raise ValueError("publication venue contains unbound or orphan artifacts")
186
+ return venue_id, result
187
+
188
+
189
+ def _artifact_state(venue: Path, record: Publication, *, materialize: bool) -> str:
190
+ path = venue / record.artifact_name
191
+ if not path_present(path):
192
+ if not materialize:
193
+ return "missing"
194
+ write_private_bytes(path, record.artifact)
195
+ if read_private_bytes(path, max_bytes=MAX_CONFIGURATION_ARTIFACT_BYTES) != record.artifact:
196
+ raise ValueError("retained publication artifact conflicts; it will not be overwritten")
197
+ return "present"
198
+
199
+
200
+ def publish(
201
+ venue: Path,
202
+ revision_id: str,
203
+ build: Build,
204
+ verify: Verify,
205
+ *,
206
+ initial: bool = False,
207
+ expected_head: str | None = None,
208
+ ) -> Publication:
209
+ """Trusted callbacks verify complete history and output; historical retry never rewinds it."""
210
+ _revision_id(revision_id)
211
+ if not venue.is_absolute() or initial == (expected_head is not None):
212
+ raise ValueError("select explicit initial publication or retained publication head")
213
+ if expected_head is not None and _SHA256.fullmatch(expected_head) is None:
214
+ raise ValueError("publication head must be a SHA-256 digest")
215
+ with acquisition_lock(venue), _connection(venue, initial=initial) as connection:
216
+ connection.execute("BEGIN IMMEDIATE")
217
+ venue_id, records = _load(connection, venue)
218
+ existing = next((r for r in records if r.revision_id == revision_id), None)
219
+ head = records[-1].package.digest if records else None
220
+ predecessor = (
221
+ read_object(existing.package.payload)["predecessor_digest"] if existing else head
222
+ )
223
+ if initial:
224
+ if records and (existing is None or existing.sequence != 1):
225
+ raise ValueError("initial publication cannot advance retained history")
226
+ elif expected_head not in {head, predecessor}:
227
+ raise ValueError("publication head changed; review retained evidence")
228
+ if existing is None and len(records) >= MAX_PUBLICATIONS:
229
+ raise ValueError("new publication exceeds its count bound")
230
+ if existing is not None and existing.sequence < len(records):
231
+ verify(tuple(r.package for r in records[:-1]), records[-1].package)
232
+ selected = records[: existing.sequence]
233
+ else:
234
+ selected = records
235
+ package = build(tuple(r.package for r in selected))
236
+ if read_object(package.payload).get("revision_id") != revision_id:
237
+ raise ValueError("publication input revision changed during validation")
238
+ if existing is not None:
239
+ if package != existing.package:
240
+ raise ValueError("the revision already has different retained publication bytes")
241
+ record = existing
242
+ else:
243
+ artifact = configuration_artifact_bytes(package)
244
+ if (
245
+ sum(len(r.artifact) for r in records) + len(artifact) > MAX_PUBLICATION_BYTES
246
+ or read_object(package.payload).get("predecessor_digest") != head
247
+ ):
248
+ raise ValueError("new publication exceeds history bounds or forks its head")
249
+ record = Publication(
250
+ venue_id,
251
+ len(records) + 1,
252
+ revision_id,
253
+ datetime.now(UTC).isoformat(),
254
+ package,
255
+ artifact,
256
+ )
257
+ connection.execute(
258
+ "INSERT INTO packages VALUES (?, ?, ?, ?)",
259
+ (record.sequence, revision_id, canonical_bytes(record.receipt()), artifact),
260
+ )
261
+ connection.commit()
262
+ _artifact_state(venue, record, materialize=True)
263
+ return record
264
+
265
+
266
+ def inspect_publication(venue: Path, revision_id: str, verify: Verify) -> tuple[Publication, str]:
267
+ """Verify the entire retained chain with public inputs; never repair or create state."""
268
+ _revision_id(revision_id)
269
+ with acquisition_lock(venue), _connection(venue, readonly=True) as connection:
270
+ _, records = _load(connection, venue)
271
+ if not records:
272
+ raise ValueError("publication history is empty")
273
+ verify(tuple(r.package for r in records[:-1]), records[-1].package)
274
+ record = next((r for r in records if r.revision_id == revision_id), None)
275
+ if record is None:
276
+ raise ValueError("the revision has no retained publication")
277
+ return record, _artifact_state(venue, record, materialize=False)
@@ -0,0 +1,191 @@
1
+ """Local publication commands with explicit unverified authority boundaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sqlite3
8
+ from collections.abc import Sequence
9
+ from functools import partial
10
+ from pathlib import Path
11
+ from typing import Protocol
12
+
13
+ from cryptography.exceptions import UnsupportedAlgorithm
14
+
15
+ from fdai_deployment_cli.cli_help import command
16
+ from fdai_deployment_cli.lifecycle_configuration_package import SignedConfigurationPackage
17
+ from fdai_deployment_cli.lifecycle_configuration_publication import inspect_publication, publish
18
+ from fdai_deployment_cli.lifecycle_configuration_sealing import read_object
19
+ from fdai_deployment_cli.private_output import read_private_bytes
20
+
21
+
22
+ class ConfigurationExecutor(Protocol):
23
+ def __call__(
24
+ self,
25
+ args: argparse.Namespace,
26
+ *,
27
+ retained_history: Sequence[SignedConfigurationPackage] | None = None,
28
+ retained_package: SignedConfigurationPackage | None = None,
29
+ persist_output: bool = True,
30
+ ) -> SignedConfigurationPackage: ...
31
+
32
+
33
+ def register_publication_commands(
34
+ commands: argparse._SubParsersAction[argparse.ArgumentParser],
35
+ execute: ConfigurationExecutor,
36
+ ) -> None:
37
+ for operation in ("publish", "inspect-publication"):
38
+ parser = command(
39
+ commands,
40
+ operation,
41
+ f"{operation.title()} venue-scoped configuration evidence",
42
+ epilog="Local custody only. Git review, global publisher identity and Hub acceptance are unverified.",
43
+ )
44
+ for name, help_text in (
45
+ (
46
+ "release-input",
47
+ "Independent JSON Release/schema and optional opaque Entity selection",
48
+ ),
49
+ ("configuration-key", "Independently supplied Ed25519 verification key in PEM format"),
50
+ ("installation-key", "Explicit installation X25519 public key in PEM format"),
51
+ ):
52
+ parser.add_argument(
53
+ f"--{name}", type=Path, required=True, metavar="PRIVATE_FILE", help=help_text
54
+ )
55
+ parser.add_argument(
56
+ "--state-dir",
57
+ type=Path,
58
+ required=True,
59
+ metavar="PRIVATE_DIRECTORY",
60
+ help="Existing absolute operator-owned mode-0700 venue; no reset or destructive recovery",
61
+ )
62
+ if operation == "publish":
63
+ parser.add_argument(
64
+ "--input",
65
+ type=Path,
66
+ required=True,
67
+ metavar="PRIVATE_JSON_FILE",
68
+ help="Private v1/v2 revision inputs; plaintext identifiers are sealed, not journaled",
69
+ )
70
+ parser.add_argument(
71
+ "--signing-key",
72
+ type=Path,
73
+ required=True,
74
+ metavar="PRIVATE_PEM_FILE",
75
+ help="Private unencrypted PKCS8 Ed25519 PEM file; never supply key bytes in argv",
76
+ )
77
+ head = parser.add_mutually_exclusive_group(required=True)
78
+ head.add_argument(
79
+ "--initial-publication",
80
+ action="store_true",
81
+ help="First publication in an empty local venue or its exact retained retry",
82
+ )
83
+ head.add_argument(
84
+ "--expected-publication-head", help="Retained local publication head SHA-256"
85
+ )
86
+ else:
87
+ parser.add_argument(
88
+ "--revision-id", required=True, help="Full lowercase source revision shape"
89
+ )
90
+ parser.add_argument(
91
+ "--output",
92
+ choices=("text", "json"),
93
+ default="text",
94
+ help="Render local evidence as readable text or a JSON object",
95
+ )
96
+ parser.set_defaults(
97
+ handler=partial(_run, execute=execute),
98
+ configuration_operation=operation,
99
+ )
100
+
101
+
102
+ def _arguments(
103
+ args: argparse.Namespace,
104
+ operation: str,
105
+ history: Sequence[SignedConfigurationPackage],
106
+ ) -> argparse.Namespace:
107
+ return argparse.Namespace(
108
+ **{
109
+ **vars(args),
110
+ "configuration_operation": operation,
111
+ "history": [],
112
+ "initial_revision": not history,
113
+ "expected_predecessor": history[-1].digest if history else None,
114
+ }
115
+ )
116
+
117
+
118
+ def _run(args: argparse.Namespace, *, execute: ConfigurationExecutor) -> int:
119
+ def verify(
120
+ history: Sequence[SignedConfigurationPackage],
121
+ package: SignedConfigurationPackage,
122
+ ) -> None:
123
+ execute(
124
+ _arguments(args, "verify", history),
125
+ retained_history=history,
126
+ retained_package=package,
127
+ persist_output=False,
128
+ )
129
+
130
+ try:
131
+ if args.configuration_operation == "publish":
132
+ source = read_object(read_private_bytes(args.input, max_bytes=1024 * 1024))
133
+ revision = source.get("revision_id")
134
+ if not isinstance(revision, str):
135
+ raise ValueError("publication revision is required")
136
+
137
+ def build(history: Sequence[SignedConfigurationPackage]) -> SignedConfigurationPackage:
138
+ return execute(
139
+ _arguments(args, "build", history),
140
+ retained_history=history,
141
+ persist_output=False,
142
+ )
143
+
144
+ record = publish(
145
+ args.state_dir,
146
+ revision,
147
+ build,
148
+ verify,
149
+ initial=args.initial_publication,
150
+ expected_head=args.expected_publication_head,
151
+ )
152
+ artifact_state = "present"
153
+ else:
154
+ record, artifact_state = inspect_publication(args.state_dir, args.revision_id, verify)
155
+ except (
156
+ ValueError,
157
+ TypeError,
158
+ OSError,
159
+ OverflowError,
160
+ RecursionError,
161
+ UnsupportedAlgorithm,
162
+ sqlite3.Error,
163
+ ):
164
+ raise ValueError(
165
+ "configuration publication not confirmed; inspect retained evidence before retrying; "
166
+ "check the private venue, revision/head selection and independent keys/Release inputs"
167
+ ) from None
168
+ result = {
169
+ "operation": args.configuration_operation,
170
+ "receipt": record.receipt(),
171
+ "artifact_state": artifact_state,
172
+ "publication_confirmed": artifact_state == "present",
173
+ "verified": True,
174
+ "local_only": True,
175
+ "git_revision_existence": "unverified",
176
+ "git_review": "unverified",
177
+ "global_publisher_identity": "unverified",
178
+ "trust_registered": False,
179
+ "hub_accepted": False,
180
+ "deployment_activated": False,
181
+ "execution_authority": False,
182
+ "production_release_eligibility": "unverified",
183
+ }
184
+ print(
185
+ json.dumps(result, sort_keys=True)
186
+ if args.output == "json"
187
+ else f"verified retained configuration evidence sha256={record.package.digest}; artifact={artifact_state}; "
188
+ f"local publication confirmed={artifact_state == 'present'}; "
189
+ "no Git review, Hub acceptance or execution authority"
190
+ )
191
+ return 0
@@ -0,0 +1,127 @@
1
+ """Installation-only identifier encryption; no identity or enrollment authority."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import binascii
7
+ import hashlib
8
+ import json
9
+ import os
10
+ from collections.abc import Mapping
11
+
12
+ from cryptography.exceptions import InvalidTag
13
+ from cryptography.hazmat.primitives import hashes
14
+ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
15
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
16
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
17
+
18
+ from fdai_deployment_cli.contracts import canonical_bytes
19
+ from fdai_deployment_cli.lifecycle_configuration import validate_configuration_package_for_signing
20
+
21
+ _ALGORITHM = "X25519-HKDF-SHA256-AES256GCM"
22
+ _INFO = b"fdai.lifecycle-configuration.sealed.v1"
23
+
24
+
25
+ def encryption_key_digest(key: X25519PublicKey) -> str:
26
+ """Identify an explicitly bound encryption key, never an Ed25519 identity key."""
27
+ return hashlib.sha256(key.public_bytes_raw()).hexdigest()
28
+
29
+
30
+ def seal_identifiers(
31
+ values: Mapping[str, object], recipient: X25519PublicKey, *, context: bytes
32
+ ) -> dict[str, object]:
33
+ """Scan plaintext before encryption and bind ciphertext to the signed revision context."""
34
+ validate_configuration_package_for_signing(values)
35
+ plaintext = canonical_bytes(values)
36
+ ephemeral = X25519PrivateKey.generate()
37
+ nonce = os.urandom(12)
38
+ key = _derive_key(ephemeral, recipient, context)
39
+ ciphertext = AESGCM(key).encrypt(nonce, plaintext, context)
40
+ return {
41
+ "algorithm": _ALGORITHM,
42
+ "recipient_digest": encryption_key_digest(recipient),
43
+ "ephemeral_key": _encode(ephemeral.public_key().public_bytes_raw()),
44
+ "nonce": _encode(nonce),
45
+ "ciphertext": _encode(ciphertext),
46
+ }
47
+
48
+
49
+ def validate_sealed_identifiers(envelope: Mapping[str, object], *, recipient_digest: str) -> None:
50
+ """Check the public envelope without decrypting or exposing identifier values."""
51
+ if set(envelope) != {"algorithm", "recipient_digest", "ephemeral_key", "nonce", "ciphertext"}:
52
+ raise ValueError("sealed identifier envelope fields are invalid")
53
+ if envelope["algorithm"] != _ALGORITHM or envelope["recipient_digest"] != recipient_digest:
54
+ raise ValueError("sealed identifier recipient or algorithm is invalid")
55
+ if len(_decode(envelope["ephemeral_key"])) != 32 or len(_decode(envelope["nonce"])) != 12:
56
+ raise ValueError("sealed identifier key or nonce length is invalid")
57
+ if len(_decode(envelope["ciphertext"])) < 16:
58
+ raise ValueError("sealed identifier ciphertext is invalid")
59
+
60
+
61
+ def open_identifiers(
62
+ envelope: Mapping[str, object], recipient: X25519PrivateKey, *, context: bytes
63
+ ) -> dict[str, object]:
64
+ """Decrypt only inside the installation; a wrong key or changed revision fails closed."""
65
+ validate_sealed_identifiers(
66
+ envelope, recipient_digest=encryption_key_digest(recipient.public_key())
67
+ )
68
+ ephemeral = X25519PublicKey.from_public_bytes(_decode(envelope["ephemeral_key"]))
69
+ key = _derive_key(recipient, ephemeral, context)
70
+ try:
71
+ plaintext = AESGCM(key).decrypt(
72
+ _decode(envelope["nonce"]), _decode(envelope["ciphertext"]), context
73
+ )
74
+ except InvalidTag:
75
+ raise ValueError("sealed identifier authentication failed") from None
76
+ values = read_object(plaintext)
77
+ validate_configuration_package_for_signing(values)
78
+ return values
79
+
80
+
81
+ def read_object(payload: bytes) -> dict[str, object]:
82
+ """Read strict JSON without duplicate fields, nonfinite numbers, or error-data leakage."""
83
+ try:
84
+ value: object = json.loads(
85
+ payload, object_pairs_hook=_unique_object, parse_constant=_invalid_constant
86
+ )
87
+ except (ValueError, UnicodeError, RecursionError):
88
+ raise ValueError("configuration package JSON is invalid") from None
89
+ if not isinstance(value, dict):
90
+ raise ValueError("configuration package JSON must be an object")
91
+ return {str(key): item for key, item in value.items()}
92
+
93
+
94
+ def _unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
95
+ result: dict[str, object] = {}
96
+ for key, item in pairs:
97
+ if key in result:
98
+ raise ValueError("duplicate JSON field")
99
+ result[key] = item
100
+ return result
101
+
102
+
103
+ def _invalid_constant(value: str) -> object:
104
+ raise ValueError("nonfinite JSON number")
105
+
106
+
107
+ def _derive_key(private: X25519PrivateKey, public: X25519PublicKey, context: bytes) -> bytes:
108
+ try:
109
+ shared = private.exchange(public)
110
+ except ValueError:
111
+ raise ValueError("installation encryption key is invalid") from None
112
+ return HKDF(
113
+ algorithm=hashes.SHA256(), length=32, salt=hashlib.sha256(context).digest(), info=_INFO
114
+ ).derive(shared)
115
+
116
+
117
+ def _encode(value: bytes) -> str:
118
+ return base64.b64encode(value).decode("ascii")
119
+
120
+
121
+ def _decode(value: object) -> bytes:
122
+ if not isinstance(value, str):
123
+ raise ValueError("sealed identifier encoding is invalid")
124
+ try:
125
+ return base64.b64decode(value, validate=True)
126
+ except (ValueError, binascii.Error):
127
+ raise ValueError("sealed identifier encoding is invalid") from None
@@ -0,0 +1,45 @@
1
+ """The one canonical installation enrollment signing implementation.
2
+
3
+ The Lifecycle Hub's offline `sign-enrollment` and the Lifecycle Agent's online `enroll` both build
4
+ the signed request here, so the two cannot drift: the Hub verifies exactly these bytes.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ from base64 import b64encode
11
+ from collections.abc import Mapping
12
+ from datetime import datetime, timedelta
13
+
14
+ from cryptography.hazmat.primitives import serialization
15
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
16
+
17
+ from fdai_deployment_cli.contracts import canonical_bytes
18
+
19
+ # The Hub rejects a proof whose request time differs from its clock by more than this.
20
+ ENROLLMENT_PROOF_WINDOW = timedelta(minutes=5)
21
+
22
+
23
+ def raw_installation_public_key(key: Ed25519PrivateKey) -> bytes:
24
+ return key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
25
+
26
+
27
+ def installation_key_id(public_key: bytes) -> str:
28
+ """The id an approver compares with the key the installation shows out of band."""
29
+
30
+ return "installation-" + hashlib.sha256(public_key).hexdigest()[:16]
31
+
32
+
33
+ def sign_enrollment_payload(
34
+ template: Mapping[str, object], key: Ed25519PrivateKey, at: datetime
35
+ ) -> tuple[bytes, bytes]:
36
+ """Add the installation key and `at` to `template`, and return the payload and its proof."""
37
+
38
+ if at.tzinfo is None or at.utcoffset() is None:
39
+ raise ValueError("requested_at must be timezone-aware")
40
+ fields = {
41
+ "installation_key": b64encode(raw_installation_public_key(key)).decode("ascii"),
42
+ "requested_at": at.isoformat(),
43
+ }
44
+ payload = canonical_bytes({**template, **fields})
45
+ return payload, key.sign(payload)