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,832 @@
1
+ """Plan exact Console content updates for existing Azure installations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fcntl
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import re
10
+ import stat
11
+ import subprocess
12
+ from collections.abc import Mapping
13
+ from dataclasses import dataclass
14
+ from datetime import UTC, datetime, timedelta
15
+ from pathlib import Path
16
+ from typing import cast
17
+ from urllib.parse import urlsplit
18
+
19
+ from fdai_deployment_cli.contracts import canonical_digest
20
+ from fdai_deployment_cli.private_output import (
21
+ copy_private_file,
22
+ read_private_bytes,
23
+ write_private_output,
24
+ )
25
+ from fdai_deployment_cli.standalone_application import publish_verified_console
26
+ from fdai_deployment_cli.target import compute_target_binding
27
+
28
+ _GUID = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")
29
+ _API_SCOPE = re.compile(rf"api://{_GUID.pattern}/[A-Za-z0-9._-]+")
30
+ _COMMIT = re.compile(r"[0-9a-f]{40}")
31
+ _HOSTNAME = re.compile(r"[a-z0-9-]+(?:[.][0-9]+)?[.]azurestaticapps[.]net")
32
+ _RESOURCE_ID = re.compile(
33
+ r"/subscriptions/([^/]+)/resourceGroups/([^/]+)/providers/"
34
+ r"Microsoft[.]Web/staticSites/([^/]+)",
35
+ re.IGNORECASE,
36
+ )
37
+ _MAX_TARGET_BYTES = 16_384
38
+ _MAX_MANIFEST_BYTES = 4096
39
+ _MAX_ARCHIVE_BYTES = 512 * 1024 * 1024
40
+ _ARTIFACT_FIELDS = frozenset({"schema_version", "source_commit", "archive_sha256"})
41
+ _TARGET_FIELDS = frozenset(
42
+ {
43
+ "schema_version",
44
+ "environment",
45
+ "subscription_id",
46
+ "tenant_id",
47
+ "console_static_web_app_id",
48
+ "console_hostname",
49
+ "operator_api_base_url",
50
+ "ingestion_api_base_url",
51
+ "entra_console_spa_client_id",
52
+ "entra_console_api_scope",
53
+ }
54
+ )
55
+ _PLAN_FIELDS = frozenset(
56
+ {
57
+ "schema_version",
58
+ "environment",
59
+ "source_commit",
60
+ "rollback_source_commit",
61
+ "target_binding",
62
+ "target_digest",
63
+ "candidate_archive_sha256",
64
+ "rollback_archive_sha256",
65
+ "action",
66
+ "stop_condition",
67
+ "rollback_action",
68
+ "blast_radius",
69
+ "logical_target",
70
+ "idempotency_key",
71
+ "created_at",
72
+ "expires_at",
73
+ "mutation_performed",
74
+ "plan_digest",
75
+ }
76
+ )
77
+
78
+
79
+ @dataclass(frozen=True, slots=True)
80
+ class ConsoleUpdateTarget:
81
+ """Secret-free existing Console binding supplied outside source control."""
82
+
83
+ environment: str
84
+ subscription_id: str
85
+ tenant_id: str
86
+ console_static_web_app_id: str
87
+ console_hostname: str
88
+ operator_api_base_url: str
89
+ ingestion_api_base_url: str
90
+ entra_console_spa_client_id: str
91
+ entra_console_api_scope: str
92
+
93
+ @classmethod
94
+ def from_mapping(cls, value: Mapping[str, object]) -> ConsoleUpdateTarget:
95
+ """Decode a closed target manifest and reject unsafe endpoint bindings."""
96
+
97
+ if (
98
+ set(value) != _TARGET_FIELDS
99
+ or value.get("schema_version") != "fdai.console-update-target.v1"
100
+ ):
101
+ raise ValueError("Console update target fields are invalid")
102
+ text = {
103
+ key: item
104
+ for key, item in value.items()
105
+ if key != "schema_version" and isinstance(item, str)
106
+ }
107
+ if len(text) != len(_TARGET_FIELDS) - 1:
108
+ raise ValueError("Console update target values MUST be strings")
109
+ target = cls(**text)
110
+ target.validate()
111
+ return target
112
+
113
+ def validate(self) -> None:
114
+ """Validate the development-only target and its public, non-secret bindings."""
115
+
116
+ if self.environment != "dev":
117
+ raise ValueError("existing Console source updates are supported only in dev")
118
+ for label, value in (
119
+ ("subscription", self.subscription_id),
120
+ ("tenant", self.tenant_id),
121
+ ("Console SPA client", self.entra_console_spa_client_id),
122
+ ):
123
+ if _GUID.fullmatch(value) is None:
124
+ raise ValueError(f"Console update {label} id is invalid")
125
+ match = _RESOURCE_ID.fullmatch(self.console_static_web_app_id)
126
+ if match is None or match.group(1).casefold() != self.subscription_id:
127
+ raise ValueError("Console update Static Web App resource id is invalid")
128
+ if _HOSTNAME.fullmatch(self.console_hostname) is None:
129
+ raise ValueError("Console update hostname is invalid")
130
+ for label, value in (
131
+ ("Operator API", self.operator_api_base_url),
132
+ ("ingestion API", self.ingestion_api_base_url),
133
+ ):
134
+ parsed = urlsplit(value)
135
+ if (
136
+ parsed.scheme != "https"
137
+ or not parsed.hostname
138
+ or parsed.username is not None
139
+ or parsed.password is not None
140
+ or parsed.query
141
+ or parsed.fragment
142
+ ):
143
+ raise ValueError(f"Console update {label} URL is invalid")
144
+ if _API_SCOPE.fullmatch(self.entra_console_api_scope) is None:
145
+ raise ValueError("Console update API scope is invalid")
146
+
147
+ @property
148
+ def target_binding(self) -> str:
149
+ """Return the canonical tenant and subscription binding."""
150
+
151
+ return compute_target_binding(
152
+ tenant_id=self.tenant_id,
153
+ subscription_id=self.subscription_id,
154
+ )
155
+
156
+ def to_mapping(self) -> dict[str, str]:
157
+ """Return canonical target data for a private plan."""
158
+
159
+ return {
160
+ "schema_version": "fdai.console-update-target.v1",
161
+ "environment": self.environment,
162
+ "subscription_id": self.subscription_id,
163
+ "tenant_id": self.tenant_id,
164
+ "console_static_web_app_id": self.console_static_web_app_id,
165
+ "console_hostname": self.console_hostname,
166
+ "operator_api_base_url": self.operator_api_base_url,
167
+ "ingestion_api_base_url": self.ingestion_api_base_url,
168
+ "entra_console_spa_client_id": self.entra_console_spa_client_id,
169
+ "entra_console_api_scope": self.entra_console_api_scope,
170
+ }
171
+
172
+
173
+ def prepare_console_update_plan(
174
+ *,
175
+ source_root: Path,
176
+ candidate_archive: Path,
177
+ candidate_manifest: Path,
178
+ rollback_archive: Path,
179
+ rollback_manifest: Path,
180
+ target_file: Path,
181
+ work_dir: Path,
182
+ ttl_seconds: int = 1200,
183
+ timeout_seconds: int = 60,
184
+ ) -> dict[str, object]:
185
+ """Create a private exact plan without changing Azure or published content."""
186
+
187
+ if not 60 <= ttl_seconds <= 1200:
188
+ raise ValueError("Console update plan TTL MUST be between 60 and 1200 seconds")
189
+ source_commit = _verified_source_commit(source_root, timeout_seconds=timeout_seconds)
190
+ target = _load_target(target_file)
191
+ _verify_active_target(target, timeout_seconds=timeout_seconds)
192
+ _verify_static_site(target, timeout_seconds=timeout_seconds)
193
+ candidate = _load_artifact_manifest(candidate_manifest, candidate_archive)
194
+ rollback = _load_artifact_manifest(rollback_manifest, rollback_archive)
195
+ if candidate["source_commit"] != source_commit:
196
+ raise ValueError("Console candidate artifact does not match protected origin/main")
197
+ if rollback["source_commit"] == source_commit:
198
+ raise ValueError("Console rollback artifact MUST use a distinct source revision")
199
+ candidate_digest = candidate["archive_sha256"]
200
+ rollback_digest = rollback["archive_sha256"]
201
+ if candidate_digest == rollback_digest:
202
+ raise ValueError("Console candidate and rollback archives MUST differ")
203
+ if work_dir.exists() or work_dir.is_symlink():
204
+ raise ValueError("Console update work directory already exists")
205
+ work_dir.mkdir(mode=0o700, parents=False)
206
+ copy_private_file(
207
+ candidate_archive, work_dir / "candidate.tar.gz", max_bytes=_MAX_ARCHIVE_BYTES
208
+ )
209
+ copy_private_file(rollback_archive, work_dir / "rollback.tar.gz", max_bytes=_MAX_ARCHIVE_BYTES)
210
+ target_copy = work_dir / "target.json"
211
+ write_private_output(
212
+ target_copy,
213
+ json.dumps(target.to_mapping(), sort_keys=True, separators=(",", ":")) + "\n",
214
+ )
215
+ plan: dict[str, object] = {
216
+ "schema_version": "fdai.console-update-plan.v1",
217
+ "environment": target.environment,
218
+ "source_commit": source_commit,
219
+ "rollback_source_commit": rollback["source_commit"],
220
+ "target_binding": target.target_binding,
221
+ "target_digest": canonical_digest(target.to_mapping()),
222
+ "candidate_archive_sha256": candidate_digest,
223
+ "rollback_archive_sha256": rollback_digest,
224
+ "action": "publish_console_static_content",
225
+ "stop_condition": "candidate_readback_mismatch",
226
+ "rollback_action": "republish_verified_rollback_archive",
227
+ "blast_radius": "one_existing_static_web_app",
228
+ "logical_target": canonical_digest(
229
+ {
230
+ "target_binding": target.target_binding,
231
+ "resource_id": target.console_static_web_app_id.casefold(),
232
+ }
233
+ ),
234
+ "idempotency_key": canonical_digest(
235
+ {
236
+ "target_binding": target.target_binding,
237
+ "candidate_archive_sha256": candidate_digest,
238
+ }
239
+ ),
240
+ "created_at": datetime.now(UTC).isoformat(),
241
+ "expires_at": (datetime.now(UTC) + timedelta(seconds=ttl_seconds)).isoformat(),
242
+ "mutation_performed": False,
243
+ }
244
+ plan["plan_digest"] = canonical_digest(plan)
245
+ write_private_output(
246
+ work_dir / "plan.json",
247
+ json.dumps(plan, sort_keys=True, separators=(",", ":")) + "\n",
248
+ )
249
+ return plan
250
+
251
+
252
+ def apply_console_update_plan(
253
+ *,
254
+ source_root: Path,
255
+ work_dir: Path,
256
+ approved_plan_digest: str,
257
+ scripts: Path,
258
+ timeout_seconds: int = 900,
259
+ ) -> dict[str, object]:
260
+ """Apply one exact Console plan, or recover an existing claim by readback."""
261
+
262
+ claim_path = work_dir / "claim.json"
263
+ plan = _load_plan(work_dir / "plan.json", allow_expired_claim=claim_path.is_file())
264
+ if approved_plan_digest != plan["plan_digest"]:
265
+ raise ValueError("Console update approval does not match the exact plan digest")
266
+ source_commit = _verified_source_commit(source_root, timeout_seconds=min(timeout_seconds, 60))
267
+ if source_commit != plan["source_commit"]:
268
+ raise ValueError("Console update source changed after planning")
269
+ target = _load_target(work_dir / "target.json")
270
+ if (
271
+ target.target_binding != plan["target_binding"]
272
+ or canonical_digest(target.to_mapping()) != plan["target_digest"]
273
+ ):
274
+ raise ValueError("Console update target changed after planning")
275
+ _verify_active_target(target, timeout_seconds=min(timeout_seconds, 60))
276
+ _verify_static_site(target, timeout_seconds=min(timeout_seconds, 60))
277
+ _require_digest(work_dir / "candidate.tar.gz", str(plan["candidate_archive_sha256"]))
278
+ _require_digest(work_dir / "rollback.tar.gz", str(plan["rollback_archive_sha256"]))
279
+
280
+ lock_descriptor = os.open(
281
+ work_dir / "target.lock",
282
+ os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW,
283
+ 0o600,
284
+ )
285
+ try:
286
+ try:
287
+ fcntl.flock(lock_descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
288
+ except BlockingIOError as exc:
289
+ raise ValueError("Console update target is locked by another local operation") from exc
290
+ receipt_path = work_dir / "receipt.json"
291
+ if receipt_path.exists():
292
+ return _load_receipt(receipt_path, plan)
293
+ failure_path = work_dir / "failure.json"
294
+ if failure_path.exists():
295
+ _load_failure(failure_path, plan)
296
+ raise ValueError(
297
+ "Console update previously failed and the verified rollback was restored"
298
+ )
299
+ actor_digest = _active_actor_digest(target, timeout_seconds=min(timeout_seconds, 60))
300
+ if claim_path.exists():
301
+ _load_claim(claim_path, plan)
302
+ try:
303
+ candidate = _publish(
304
+ source_root=source_root,
305
+ work_dir=work_dir / "candidate-verify",
306
+ archive=work_dir / "candidate.tar.gz",
307
+ archive_digest=str(plan["candidate_archive_sha256"]),
308
+ target=target,
309
+ scripts=scripts,
310
+ timeout_seconds=timeout_seconds,
311
+ verify_only=True,
312
+ )
313
+ except (OSError, subprocess.SubprocessError, ValueError) as verification_error:
314
+ try:
315
+ rollback = _publish(
316
+ source_root=source_root,
317
+ work_dir=work_dir / "rollback-verify",
318
+ archive=work_dir / "rollback.tar.gz",
319
+ archive_digest=str(plan["rollback_archive_sha256"]),
320
+ target=target,
321
+ scripts=scripts,
322
+ timeout_seconds=timeout_seconds,
323
+ verify_only=True,
324
+ verify_service_contracts=False,
325
+ )
326
+ reason = "claimed_candidate_readback_failed_rollback_already_present"
327
+ except (OSError, subprocess.SubprocessError, ValueError):
328
+ try:
329
+ rollback = _publish(
330
+ source_root=source_root,
331
+ work_dir=work_dir / "rollback-recovery",
332
+ archive=work_dir / "rollback.tar.gz",
333
+ archive_digest=str(plan["rollback_archive_sha256"]),
334
+ target=target,
335
+ scripts=scripts,
336
+ timeout_seconds=timeout_seconds,
337
+ verify_only=False,
338
+ verify_service_contracts=False,
339
+ )
340
+ reason = "claimed_candidate_readback_failed_rollback_restored"
341
+ except (OSError, subprocess.SubprocessError, ValueError) as rollback_error:
342
+ raise ValueError(
343
+ "Console update readback and rollback both failed; retain the claim for review"
344
+ ) from rollback_error
345
+ _write_failure_receipt(
346
+ failure_path,
347
+ plan=plan,
348
+ actor_digest=actor_digest,
349
+ rollback=rollback,
350
+ reason=reason,
351
+ )
352
+ raise ValueError(
353
+ "Console update readback failed and the verified rollback was restored"
354
+ ) from verification_error
355
+ return _write_apply_receipt(
356
+ receipt_path,
357
+ plan=plan,
358
+ actor_digest=actor_digest,
359
+ publication=candidate,
360
+ recovered_by_readback=True,
361
+ )
362
+ approval = {
363
+ "schema_version": "fdai.console-update-approval.v1",
364
+ "plan_digest": plan["plan_digest"],
365
+ "target_binding": plan["target_binding"],
366
+ "actor_digest": actor_digest,
367
+ "approved_at": datetime.now(UTC).isoformat(),
368
+ }
369
+ approval["approval_digest"] = canonical_digest(approval)
370
+ write_private_output(
371
+ work_dir / "approval.json",
372
+ json.dumps(approval, sort_keys=True, separators=(",", ":")) + "\n",
373
+ )
374
+ claim = {
375
+ "schema_version": "fdai.console-update-claim.v1",
376
+ "plan_digest": plan["plan_digest"],
377
+ "approval_digest": approval["approval_digest"],
378
+ "idempotency_key": plan["idempotency_key"],
379
+ "claimed_at": datetime.now(UTC).isoformat(),
380
+ }
381
+ claim["claim_digest"] = canonical_digest(claim)
382
+ write_private_output(
383
+ claim_path,
384
+ json.dumps(claim, sort_keys=True, separators=(",", ":")) + "\n",
385
+ )
386
+ try:
387
+ candidate = _publish(
388
+ source_root=source_root,
389
+ work_dir=work_dir / "candidate-publish",
390
+ archive=work_dir / "candidate.tar.gz",
391
+ archive_digest=str(plan["candidate_archive_sha256"]),
392
+ target=target,
393
+ scripts=scripts,
394
+ timeout_seconds=timeout_seconds,
395
+ verify_only=False,
396
+ )
397
+ except (OSError, subprocess.SubprocessError, ValueError) as publish_error:
398
+ try:
399
+ rollback = _publish(
400
+ source_root=source_root,
401
+ work_dir=work_dir / "rollback-publish",
402
+ archive=work_dir / "rollback.tar.gz",
403
+ archive_digest=str(plan["rollback_archive_sha256"]),
404
+ target=target,
405
+ scripts=scripts,
406
+ timeout_seconds=timeout_seconds,
407
+ verify_only=False,
408
+ verify_service_contracts=False,
409
+ )
410
+ except (OSError, subprocess.SubprocessError, ValueError) as rollback_error:
411
+ raise ValueError(
412
+ "Console update and rollback both failed; retain the claim for review"
413
+ ) from rollback_error
414
+ _write_failure_receipt(
415
+ failure_path,
416
+ plan=plan,
417
+ actor_digest=actor_digest,
418
+ rollback=rollback,
419
+ reason="candidate_publication_failed",
420
+ )
421
+ raise ValueError(
422
+ "Console update failed and the verified rollback was restored"
423
+ ) from publish_error
424
+ return _write_apply_receipt(
425
+ receipt_path,
426
+ plan=plan,
427
+ actor_digest=actor_digest,
428
+ publication=candidate,
429
+ recovered_by_readback=False,
430
+ )
431
+ finally:
432
+ os.close(lock_descriptor)
433
+
434
+
435
+ def _publish(
436
+ *,
437
+ source_root: Path,
438
+ work_dir: Path,
439
+ archive: Path,
440
+ archive_digest: str,
441
+ target: ConsoleUpdateTarget,
442
+ scripts: Path,
443
+ timeout_seconds: int,
444
+ verify_only: bool,
445
+ verify_service_contracts: bool = True,
446
+ ) -> dict[str, object]:
447
+ work_dir.mkdir(mode=0o700, exist_ok=True)
448
+ if work_dir.is_symlink() or not work_dir.is_dir():
449
+ raise ValueError("Console publication work directory is invalid")
450
+ work_dir.chmod(0o700)
451
+ return publish_verified_console(
452
+ console_archive=archive,
453
+ console_archive_sha256=archive_digest,
454
+ bundle_root=source_root,
455
+ prepared_root=work_dir,
456
+ entra_bindings={
457
+ "ENTRA_CONSOLE_SPA_CLIENT_ID": target.entra_console_spa_client_id,
458
+ "ENTRA_CONSOLE_API_SCOPE": target.entra_console_api_scope,
459
+ },
460
+ browser_console={
461
+ "console_hostname": target.console_hostname,
462
+ "console_origin": f"https://{target.console_hostname}",
463
+ "console_static_web_app_id": target.console_static_web_app_id,
464
+ "operator_api_base_url": target.operator_api_base_url,
465
+ "ingestion_api_base_url": target.ingestion_api_base_url,
466
+ },
467
+ scripts=scripts,
468
+ subscription_id=target.subscription_id,
469
+ tenant_id=target.tenant_id,
470
+ timeout_seconds=timeout_seconds,
471
+ redirect_changed=False,
472
+ verify_only=verify_only,
473
+ verify_service_contracts=verify_service_contracts,
474
+ )
475
+
476
+
477
+ def _write_apply_receipt(
478
+ path: Path,
479
+ *,
480
+ plan: dict[str, object],
481
+ actor_digest: str,
482
+ publication: dict[str, object],
483
+ recovered_by_readback: bool,
484
+ ) -> dict[str, object]:
485
+ receipt: dict[str, object] = {
486
+ "schema_version": "fdai.console-update-receipt.v1",
487
+ "state": "applied",
488
+ "plan_digest": plan["plan_digest"],
489
+ "target_binding": plan["target_binding"],
490
+ "candidate_archive_sha256": plan["candidate_archive_sha256"],
491
+ "actor_digest": actor_digest,
492
+ "publication_receipt_digest": publication["receipt_digest"],
493
+ "artifact_readback_verified": True,
494
+ "api_health_verified": publication["api_health_verified"],
495
+ "authorization_preflight_verified": publication["authorization_preflight_verified"],
496
+ "entra_redirect_verified": publication["entra_redirect_verified"],
497
+ "recovered_by_readback": recovered_by_readback,
498
+ "completed_at": datetime.now(UTC).isoformat(),
499
+ "mutation_performed": not recovered_by_readback,
500
+ }
501
+ receipt["receipt_digest"] = canonical_digest(receipt)
502
+ write_private_output(path, json.dumps(receipt, sort_keys=True, separators=(",", ":")) + "\n")
503
+ return receipt
504
+
505
+
506
+ def _write_failure_receipt(
507
+ path: Path,
508
+ *,
509
+ plan: dict[str, object],
510
+ actor_digest: str,
511
+ rollback: dict[str, object],
512
+ reason: str,
513
+ ) -> dict[str, object]:
514
+ receipt: dict[str, object] = {
515
+ "schema_version": "fdai.console-update-failure.v1",
516
+ "state": "rolled_back",
517
+ "reason": reason,
518
+ "plan_digest": plan["plan_digest"],
519
+ "target_binding": plan["target_binding"],
520
+ "rollback_archive_sha256": plan["rollback_archive_sha256"],
521
+ "actor_digest": actor_digest,
522
+ "rollback_publication_receipt_digest": rollback["receipt_digest"],
523
+ "rollback_readback_verified": True,
524
+ "api_health_verified": rollback["api_health_verified"],
525
+ "authorization_preflight_verified": rollback["authorization_preflight_verified"],
526
+ "entra_redirect_verified": rollback["entra_redirect_verified"],
527
+ "completed_at": datetime.now(UTC).isoformat(),
528
+ "mutation_performed": rollback.get("mutation_performed") is True,
529
+ }
530
+ receipt["receipt_digest"] = canonical_digest(receipt)
531
+ write_private_output(path, json.dumps(receipt, sort_keys=True, separators=(",", ":")) + "\n")
532
+ return receipt
533
+
534
+
535
+ def load_console_update_plan(path: Path, *, allow_expired_claim: bool = False) -> dict[str, object]:
536
+ """Load one private plan, allowing expiry only for claimed verification recovery."""
537
+
538
+ return _load_plan(path, allow_expired_claim=allow_expired_claim)
539
+
540
+
541
+ def _load_plan(path: Path, *, allow_expired_claim: bool = False) -> dict[str, object]:
542
+ try:
543
+ value = json.loads(read_private_bytes(path, max_bytes=32_768))
544
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
545
+ raise ValueError("Console update plan is not valid JSON") from exc
546
+ if not isinstance(value, dict) or set(value) != _PLAN_FIELDS:
547
+ raise ValueError("Console update plan fields are invalid")
548
+ plan = cast(dict[str, object], value)
549
+ digest = plan.get("plan_digest")
550
+ if not isinstance(digest, str) or re.fullmatch(r"[0-9a-f]{64}", digest) is None:
551
+ raise ValueError("Console update plan digest is invalid")
552
+ if (
553
+ canonical_digest({key: item for key, item in plan.items() if key != "plan_digest"})
554
+ != digest
555
+ ):
556
+ raise ValueError("Console update plan content digest is invalid")
557
+ expiry = plan.get("expires_at")
558
+ try:
559
+ expires_at = datetime.fromisoformat(str(expiry))
560
+ except ValueError as exc:
561
+ raise ValueError("Console update plan expiry is invalid") from exc
562
+ if expires_at.tzinfo is None or (expires_at <= datetime.now(UTC) and not allow_expired_claim):
563
+ raise ValueError("Console update plan is expired")
564
+ return plan
565
+
566
+
567
+ def _load_claim(path: Path, plan: dict[str, object]) -> dict[str, object]:
568
+ try:
569
+ value = json.loads(read_private_bytes(path, max_bytes=16_384))
570
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
571
+ raise ValueError("Console update claim is invalid") from exc
572
+ required = {
573
+ "schema_version",
574
+ "plan_digest",
575
+ "approval_digest",
576
+ "idempotency_key",
577
+ "claimed_at",
578
+ "claim_digest",
579
+ }
580
+ if (
581
+ not isinstance(value, dict)
582
+ or set(value) != required
583
+ or value.get("schema_version") != "fdai.console-update-claim.v1"
584
+ or value.get("plan_digest") != plan["plan_digest"]
585
+ or value.get("idempotency_key") != plan["idempotency_key"]
586
+ ):
587
+ raise ValueError("Console update claim does not match the plan")
588
+ digest = value.get("claim_digest")
589
+ if digest != canonical_digest(
590
+ {key: item for key, item in value.items() if key != "claim_digest"}
591
+ ):
592
+ raise ValueError("Console update claim digest is invalid")
593
+ return cast(dict[str, object], value)
594
+
595
+
596
+ def _load_receipt(path: Path, plan: dict[str, object]) -> dict[str, object]:
597
+ try:
598
+ value = json.loads(read_private_bytes(path, max_bytes=32_768))
599
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
600
+ raise ValueError("Console update receipt is invalid") from exc
601
+ if (
602
+ not isinstance(value, dict)
603
+ or value.get("schema_version") != "fdai.console-update-receipt.v1"
604
+ or value.get("state") != "applied"
605
+ or value.get("plan_digest") != plan["plan_digest"]
606
+ or value.get("artifact_readback_verified") is not True
607
+ ):
608
+ raise ValueError("Console update receipt does not match the plan")
609
+ digest = value.get("receipt_digest")
610
+ if digest != canonical_digest(
611
+ {key: item for key, item in value.items() if key != "receipt_digest"}
612
+ ):
613
+ raise ValueError("Console update receipt digest is invalid")
614
+ return cast(dict[str, object], value)
615
+
616
+
617
+ def _load_failure(path: Path, plan: dict[str, object]) -> dict[str, object]:
618
+ try:
619
+ value = json.loads(read_private_bytes(path, max_bytes=32_768))
620
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
621
+ raise ValueError("Console update failure receipt is invalid") from exc
622
+ if (
623
+ not isinstance(value, dict)
624
+ or value.get("schema_version") != "fdai.console-update-failure.v1"
625
+ or value.get("state") != "rolled_back"
626
+ or value.get("plan_digest") != plan["plan_digest"]
627
+ or value.get("rollback_readback_verified") is not True
628
+ ):
629
+ raise ValueError("Console update failure receipt does not match the plan")
630
+ digest = value.get("receipt_digest")
631
+ if digest != canonical_digest(
632
+ {key: item for key, item in value.items() if key != "receipt_digest"}
633
+ ):
634
+ raise ValueError("Console update failure receipt digest is invalid")
635
+ return cast(dict[str, object], value)
636
+
637
+
638
+ def _active_actor_digest(target: ConsoleUpdateTarget, *, timeout_seconds: int) -> str:
639
+ completed = subprocess.run(
640
+ (
641
+ "az",
642
+ "account",
643
+ "show",
644
+ "--query",
645
+ "{subscription_id:id,tenant_id:tenantId,user_name:user.name,user_type:user.type}",
646
+ "--output",
647
+ "json",
648
+ "--only-show-errors",
649
+ ),
650
+ check=False,
651
+ capture_output=True,
652
+ text=True,
653
+ timeout=timeout_seconds,
654
+ )
655
+ try:
656
+ value = json.loads(completed.stdout)
657
+ except json.JSONDecodeError as exc:
658
+ raise ValueError("Console update approval actor is unavailable") from exc
659
+ if (
660
+ completed.returncode != 0
661
+ or value.get("subscription_id") != target.subscription_id
662
+ or value.get("tenant_id") != target.tenant_id
663
+ or str(value.get("user_type", "")).casefold() != "user"
664
+ or not isinstance(value.get("user_name"), str)
665
+ or not value["user_name"]
666
+ ):
667
+ raise ValueError("Console update requires the active Azure human user")
668
+ return hashlib.sha256(
669
+ f"{target.target_binding}:{value['user_name'].casefold()}".encode()
670
+ ).hexdigest()
671
+
672
+
673
+ def _require_digest(path: Path, expected: str) -> None:
674
+ if _regular_digest(path) != expected:
675
+ raise ValueError("Console update archive changed after planning")
676
+
677
+
678
+ def _load_target(path: Path) -> ConsoleUpdateTarget:
679
+ try:
680
+ value = json.loads(read_private_bytes(path, max_bytes=_MAX_TARGET_BYTES))
681
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
682
+ raise ValueError("Console update target is not valid JSON") from exc
683
+ if not isinstance(value, dict):
684
+ raise TypeError("Console update target MUST contain an object")
685
+ return ConsoleUpdateTarget.from_mapping(value)
686
+
687
+
688
+ def _load_artifact_manifest(path: Path, archive: Path) -> dict[str, str]:
689
+ try:
690
+ value = json.loads(read_private_bytes(path, max_bytes=_MAX_MANIFEST_BYTES))
691
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
692
+ raise ValueError("Console artifact manifest is not valid JSON") from exc
693
+ if (
694
+ not isinstance(value, dict)
695
+ or set(value) != _ARTIFACT_FIELDS
696
+ or value.get("schema_version") != "fdai.console-update-artifact.v1"
697
+ or not isinstance(value.get("source_commit"), str)
698
+ or _COMMIT.fullmatch(str(value["source_commit"])) is None
699
+ or not isinstance(value.get("archive_sha256"), str)
700
+ or re.fullmatch(r"[0-9a-f]{64}", str(value["archive_sha256"])) is None
701
+ ):
702
+ raise ValueError("Console artifact manifest fields are invalid")
703
+ result = cast(dict[str, str], value)
704
+ if _regular_digest(archive) != result["archive_sha256"]:
705
+ raise ValueError("Console artifact does not match its manifest")
706
+ return result
707
+
708
+
709
+ def _verified_source_commit(source_root: Path, *, timeout_seconds: int) -> str:
710
+ if not source_root.is_absolute() or not source_root.is_dir() or source_root.is_symlink():
711
+ raise ValueError("Console update source MUST be an absolute regular directory")
712
+ head = _git(source_root, "rev-parse", "HEAD", timeout_seconds=timeout_seconds).stdout.strip()
713
+ if _COMMIT.fullmatch(head) is None:
714
+ raise ValueError("Console update source revision is invalid")
715
+ status = _git(
716
+ source_root,
717
+ "status",
718
+ "--porcelain=v1",
719
+ "--untracked-files=no",
720
+ timeout_seconds=timeout_seconds,
721
+ )
722
+ if status.stdout:
723
+ raise ValueError("Console update source has tracked changes")
724
+ protected_head = _git(
725
+ source_root,
726
+ "rev-parse",
727
+ "refs/remotes/origin/main",
728
+ timeout_seconds=timeout_seconds,
729
+ ).stdout.strip()
730
+ if head != protected_head:
731
+ raise ValueError("Console update source MUST match protected origin/main")
732
+ return head
733
+
734
+
735
+ def _git(
736
+ source_root: Path, *arguments: str, timeout_seconds: int
737
+ ) -> subprocess.CompletedProcess[str]:
738
+ completed = subprocess.run(
739
+ ("git", "-C", str(source_root), *arguments),
740
+ check=False,
741
+ capture_output=True,
742
+ text=True,
743
+ timeout=timeout_seconds,
744
+ )
745
+ if completed.returncode != 0:
746
+ raise ValueError("Console update source could not be verified")
747
+ return completed
748
+
749
+
750
+ def _verify_active_target(target: ConsoleUpdateTarget, *, timeout_seconds: int) -> None:
751
+ completed = subprocess.run(
752
+ (
753
+ "az",
754
+ "account",
755
+ "show",
756
+ "--query",
757
+ "{subscription_id:id,tenant_id:tenantId}",
758
+ "--output",
759
+ "json",
760
+ "--only-show-errors",
761
+ ),
762
+ check=False,
763
+ capture_output=True,
764
+ text=True,
765
+ timeout=timeout_seconds,
766
+ )
767
+ try:
768
+ observed = json.loads(completed.stdout)
769
+ except json.JSONDecodeError as exc:
770
+ raise ValueError("active Azure target is unavailable") from exc
771
+ if completed.returncode != 0 or observed != {
772
+ "subscription_id": target.subscription_id,
773
+ "tenant_id": target.tenant_id,
774
+ }:
775
+ raise ValueError("active Azure target does not match the Console update target")
776
+
777
+
778
+ def _verify_static_site(target: ConsoleUpdateTarget, *, timeout_seconds: int) -> None:
779
+ completed = subprocess.run(
780
+ (
781
+ "az",
782
+ "rest",
783
+ "--method",
784
+ "get",
785
+ "--url",
786
+ (
787
+ "https://management.azure.com"
788
+ f"{target.console_static_web_app_id}?api-version=2023-12-01"
789
+ ),
790
+ "--query",
791
+ "properties.defaultHostname",
792
+ "--output",
793
+ "tsv",
794
+ ),
795
+ check=False,
796
+ capture_output=True,
797
+ text=True,
798
+ timeout=timeout_seconds,
799
+ )
800
+ if completed.returncode != 0 or completed.stdout.strip().casefold() != target.console_hostname:
801
+ raise ValueError("Console update target does not match Azure readback")
802
+
803
+
804
+ def _regular_digest(path: Path) -> str:
805
+ if not path.is_absolute():
806
+ raise ValueError("Console archives MUST use absolute paths")
807
+ descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
808
+ try:
809
+ before = os.fstat(descriptor)
810
+ if (
811
+ not stat.S_ISREG(before.st_mode)
812
+ or before.st_uid != os.geteuid()
813
+ or before.st_nlink != 1
814
+ or not 0 < before.st_size <= _MAX_ARCHIVE_BYTES
815
+ ):
816
+ raise ValueError("Console archive is not an owned bounded regular file")
817
+ with os.fdopen(descriptor, "rb", closefd=False) as stream:
818
+ digest = hashlib.file_digest(stream, "sha256").hexdigest()
819
+ after = os.fstat(descriptor)
820
+ if (
821
+ before.st_size,
822
+ before.st_mtime_ns,
823
+ before.st_ctime_ns,
824
+ ) != (
825
+ after.st_size,
826
+ after.st_mtime_ns,
827
+ after.st_ctime_ns,
828
+ ):
829
+ raise ValueError("Console archive changed while it was read")
830
+ return digest
831
+ finally:
832
+ os.close(descriptor)