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,726 @@
1
+ """Build and deploy one source-selected service through retained AKS state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fcntl
6
+ import json
7
+ import os
8
+ import re
9
+ import stat
10
+ import subprocess
11
+ import sys
12
+ import time
13
+ from collections.abc import Callable
14
+ from datetime import UTC, datetime, timedelta
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from fdai_deployment_cli.aks_service_update import SERVICES
19
+ from fdai_deployment_cli.contracts import canonical_bytes, canonical_digest, load_json_object
20
+ from fdai_deployment_cli.deployment_deadline import DeploymentDeadline
21
+ from fdai_deployment_cli.development_authority_profile import (
22
+ DevelopmentAuthorityDeploymentProfile,
23
+ )
24
+ from fdai_deployment_cli.private_output import read_private_bytes, write_private_bytes
25
+ from fdai_deployment_cli.source_input import inspect_source
26
+ from fdai_deployment_cli.source_projection import service_source_projection
27
+ from fdai_deployment_cli.source_snapshot import materialize_source, verify_source_snapshot
28
+ from fdai_deployment_cli.standalone_application import (
29
+ _approval_input,
30
+ _approve_plan,
31
+ _azure_actor_digest,
32
+ )
33
+
34
+ _DIGEST = re.compile(r"[0-9a-f]{64}")
35
+ _COMMIT = re.compile(r"[0-9a-f]{40}")
36
+ _IMAGE_DIGEST = re.compile(r"sha256:[0-9a-f]{64}")
37
+
38
+
39
+ def deploy_source_service_update(
40
+ *,
41
+ source_root: Path,
42
+ application_work_dir: Path,
43
+ work_dir: Path,
44
+ service: str,
45
+ timeout_seconds: int,
46
+ adopt_historical_binding: Path | None = None,
47
+ adopt_historical_state: Path | None = None,
48
+ adopt_historical_variables: Path | None = None,
49
+ adopt_historical_live: Path | None = None,
50
+ adopt_historical_plan: Path | None = None,
51
+ approve_import: Callable[[Path, dict[str, Any], DeploymentDeadline], Path] | None = None,
52
+ approve_plan: Callable[[Path, dict[str, Any]], Path] | None = None,
53
+ development_authority_profile: DevelopmentAuthorityDeploymentProfile | None = None,
54
+ clear_development_authority_profile: bool = False,
55
+ expected_source_commit: str | None = None,
56
+ expected_current_source_commit: str | None = None,
57
+ ) -> dict[str, object]:
58
+ """Build, import, plan, approve, apply, and verify one dev AKS service update."""
59
+
60
+ operation_started = time.monotonic()
61
+ phase_started = operation_started
62
+ phase_timings: list[dict[str, object]] = []
63
+ if service not in SERVICES:
64
+ raise ValueError("source service update names an unsupported service")
65
+ if (
66
+ development_authority_profile is not None or clear_development_authority_profile
67
+ ) and service != "core-control-plane":
68
+ raise ValueError("development authority changes require a Core service update")
69
+ if development_authority_profile is not None and clear_development_authority_profile:
70
+ raise ValueError("development authority select and clear are mutually exclusive")
71
+ if any(
72
+ value is not None and _COMMIT.fullmatch(value) is None
73
+ for value in (expected_source_commit, expected_current_source_commit)
74
+ ):
75
+ raise ValueError("source service update expected revisions are invalid")
76
+ deadline = DeploymentDeadline(timeout_seconds)
77
+ source = (
78
+ inspect_source(source_root)
79
+ if expected_source_commit is None
80
+ else inspect_source(source_root, expected_commit=expected_source_commit)
81
+ )
82
+ application = application_work_dir.absolute()
83
+ _require_private_directory(application, create=False)
84
+ work = work_dir.absolute()
85
+ if work.resolve().is_relative_to(source.root) or application == work:
86
+ raise ValueError("source service update work directories are invalid")
87
+ _require_private_directory(work, create=True)
88
+ lock = _lock(work)
89
+ try:
90
+ development_authority_profile_path: Path | None = None
91
+ adoption_inputs = (
92
+ adopt_historical_binding,
93
+ adopt_historical_state,
94
+ adopt_historical_variables,
95
+ adopt_historical_live,
96
+ adopt_historical_plan,
97
+ )
98
+ if any(value is not None for value in adoption_inputs) and not all(
99
+ isinstance(value, Path) for value in adoption_inputs
100
+ ):
101
+ raise ValueError("historical AKS adoption requires all five evidence inputs")
102
+ if all(isinstance(value, Path) for value in adoption_inputs):
103
+ binding, state, variables, live, plan = adoption_inputs
104
+ assert isinstance(binding, Path)
105
+ assert isinstance(state, Path)
106
+ assert isinstance(variables, Path)
107
+ assert isinstance(live, Path)
108
+ assert isinstance(plan, Path)
109
+ adoption_arguments = (
110
+ "adopt-historical-aks-application",
111
+ "--binding",
112
+ str(binding.absolute()),
113
+ "--state",
114
+ str(state.absolute()),
115
+ "--variables",
116
+ str(variables.absolute()),
117
+ "--live",
118
+ str(live.absolute()),
119
+ "--plan",
120
+ str(plan.absolute()),
121
+ )
122
+ adopted = _host_json(
123
+ application,
124
+ adoption_arguments,
125
+ timeout=deadline.remaining(2400),
126
+ )
127
+ if adopted.get("state") == "reconciliation-required":
128
+ review = adopted.get("reconciliation_review")
129
+ if (
130
+ not isinstance(review, dict)
131
+ or review.get("schema_version") != "fdai.standalone-application-plan.v1"
132
+ or review.get("stage") != "application"
133
+ ):
134
+ raise ValueError("historical AKS reconciliation review is invalid")
135
+ recovered = _host_json(
136
+ application,
137
+ ("recover-historical-aks-reconciliation",),
138
+ timeout=deadline.remaining(2100),
139
+ )
140
+ if recovered.get("state") == "applied":
141
+ if (
142
+ recovered.get("effect_verified") is not True
143
+ or recovered.get("terraform_zero_change_verified") is not True
144
+ ):
145
+ raise ValueError("historical AKS reconciliation recovery is incomplete")
146
+ else:
147
+ reconciliation_approval = (
148
+ approve_plan(work, review)
149
+ if approve_plan is not None
150
+ else _approve_plan(work, review, deadline=deadline)
151
+ )
152
+ reconciled = _host_json(
153
+ application,
154
+ (
155
+ "apply-historical-aks-reconciliation",
156
+ "--approval",
157
+ str(reconciliation_approval),
158
+ ),
159
+ timeout=deadline.remaining(7500),
160
+ )
161
+ if (
162
+ reconciled.get("state") != "applied"
163
+ or reconciled.get("effect_verified") is not True
164
+ or reconciled.get("terraform_zero_change_verified") is not True
165
+ ):
166
+ raise ValueError("historical AKS reconciliation is incomplete")
167
+ adopted = _host_json(
168
+ application,
169
+ adoption_arguments,
170
+ timeout=deadline.remaining(2400),
171
+ )
172
+ if (
173
+ adopted.get("schema_version") != "fdai.historical-aks-application-adoption.v1"
174
+ or adopted.get("state") != "adopted"
175
+ or adopted.get("managed_identity_verified") is not True
176
+ or adopted.get("remote_state_verified") is not True
177
+ or adopted.get("live_baseline_verified") is not True
178
+ or adopted.get("terraform_zero_change_verified") is not True
179
+ or adopted.get("azure_resource_mutation_performed") is not False
180
+ ):
181
+ raise ValueError("historical AKS application adoption is incomplete")
182
+ context = _host_json(
183
+ application,
184
+ ("service-update-context", "--service", service),
185
+ timeout=deadline.remaining(120),
186
+ )
187
+ if (
188
+ context.get("schema_version") != "fdai.source-service-update-context.v1"
189
+ or context.get("state") != "verified"
190
+ or context.get("service") != service
191
+ or not isinstance(context.get("target_binding"), str)
192
+ or _DIGEST.fullmatch(str(context["target_binding"])) is None
193
+ ):
194
+ raise ValueError("retained AKS service update context is invalid")
195
+ receipt_path = work / "source-service-update-receipt.json"
196
+ if (
197
+ not receipt_path.exists()
198
+ and not receipt_path.is_symlink()
199
+ and expected_current_source_commit is not None
200
+ and context.get("current_source_commit") != expected_current_source_commit
201
+ ):
202
+ raise ValueError("retained AKS service source differs from the changed-update base")
203
+ intent = {
204
+ "schema_version": "fdai.source-service-update-intent.v1",
205
+ "source": source.to_mapping(),
206
+ "service": service,
207
+ "target_binding": context["target_binding"],
208
+ "application_work_binding": canonical_digest(
209
+ {"path": str(application), "target_binding": context["target_binding"]}
210
+ ),
211
+ "development_authority_operation": (
212
+ "select"
213
+ if development_authority_profile is not None
214
+ else "clear"
215
+ if clear_development_authority_profile
216
+ else "preserve"
217
+ ),
218
+ "development_authority_profile_digest": (
219
+ development_authority_profile.input_digest
220
+ if development_authority_profile is not None
221
+ else ""
222
+ ),
223
+ }
224
+ intent_path = work / "source-service-update-intent.json"
225
+ snapshot = work / "source-snapshot"
226
+ if intent_path.exists() or intent_path.is_symlink():
227
+ if _read(intent_path) != intent:
228
+ raise ValueError("retained source service update intent differs")
229
+ snapshot_digest = str(_read(work / "source-snapshot-receipt.json")["snapshot_digest"])
230
+ verify_source_snapshot(snapshot, expected_digest=snapshot_digest)
231
+ else:
232
+ if any(path.name != "source-service-update.lock" for path in work.iterdir()):
233
+ raise ValueError("source service update cannot adopt existing work state")
234
+ write_private_bytes(intent_path, canonical_bytes(intent))
235
+ snapshot_digest = materialize_source(
236
+ source,
237
+ snapshot,
238
+ selected_paths=service_source_projection(source, service),
239
+ purpose=f"service-image:{service}",
240
+ )
241
+ write_private_bytes(
242
+ work / "source-snapshot-receipt.json",
243
+ canonical_bytes({"snapshot_digest": snapshot_digest}),
244
+ )
245
+ snapshot_schema, projection_files, projection_bytes = _projection_facts(snapshot)
246
+ phase_started = _record_timing(phase_timings, "source-projection", phase_started)
247
+ if development_authority_profile is not None:
248
+ development_authority_profile_path = work / "development-authority-profile.json"
249
+ profile_bytes = development_authority_profile.profile_json.encode("utf-8")
250
+ if development_authority_profile_path.exists():
251
+ if (
252
+ read_private_bytes(
253
+ development_authority_profile_path,
254
+ max_bytes=256 * 1024,
255
+ )
256
+ != profile_bytes
257
+ ):
258
+ raise ValueError("retained development authority profile differs")
259
+ else:
260
+ write_private_bytes(development_authority_profile_path, profile_bytes)
261
+ completed = _verified_completed_receipt(
262
+ work,
263
+ source_commit=source.commit,
264
+ service=service,
265
+ target_binding=str(context["target_binding"]),
266
+ context=context,
267
+ )
268
+ if completed is not None:
269
+ return completed
270
+ if (
271
+ expected_current_source_commit is not None
272
+ and context.get("current_source_commit") != expected_current_source_commit
273
+ ):
274
+ raise ValueError("retained AKS service source differs from the changed-update base")
275
+ source.reverify()
276
+ image = _build_image(
277
+ snapshot=snapshot,
278
+ snapshot_digest=snapshot_digest,
279
+ work_dir=work / "source-images",
280
+ service=service,
281
+ timeout_seconds=deadline.remaining(3600),
282
+ )
283
+ if (
284
+ image.get("state") != "built"
285
+ or image.get("source_commit") != source.commit
286
+ or image.get("service") != service
287
+ ):
288
+ raise ValueError("source service image build did not produce exact evidence")
289
+ phase_started = _record_timing(phase_timings, "image-build", phase_started)
290
+ import_review = {
291
+ "schema_version": "fdai.source-service-image-import-review.v1",
292
+ "service": service,
293
+ "source_commit": source.commit,
294
+ "archive_sha256": image["archive_sha256"],
295
+ "image_digest": image["image_digest"],
296
+ "target_binding": context["target_binding"],
297
+ "expires_at": _moment(datetime.now(UTC) + timedelta(hours=1)),
298
+ "mutation_performed": False,
299
+ }
300
+ import_review["review_digest"] = canonical_digest(import_review)
301
+ import_approval = work / "source-image-import-approval.json"
302
+ if import_approval.is_symlink():
303
+ raise ValueError("source service image import approval must not be a symlink")
304
+ if not import_approval.is_file():
305
+ import_approval = (approve_import or _approve_import)(work, import_review, deadline)
306
+ archive = work / "source-images" / str(image["archive_ref"])
307
+ imported = _host_json(
308
+ application,
309
+ (
310
+ "import-source-image",
311
+ "--service",
312
+ service,
313
+ "--archive",
314
+ str(archive),
315
+ "--archive-sha256",
316
+ str(image["archive_sha256"]),
317
+ "--image-digest",
318
+ str(image["image_digest"]),
319
+ "--source-commit",
320
+ source.commit,
321
+ "--approval",
322
+ str(import_approval),
323
+ ),
324
+ timeout=deadline.remaining(2400),
325
+ )
326
+ _validate_import_receipt(
327
+ imported,
328
+ service=service,
329
+ source_commit=source.commit,
330
+ archive_sha256=str(image["archive_sha256"]),
331
+ image_digest=str(image["image_digest"]),
332
+ )
333
+ phase_started = _record_timing(phase_timings, "image-import", phase_started)
334
+ expected_update = {
335
+ "service": service,
336
+ "image": imported.get("image"),
337
+ "source_commit": source.commit,
338
+ }
339
+ if intent["development_authority_operation"] != "preserve":
340
+ expected_update.update(
341
+ development_authority_operation=intent["development_authority_operation"],
342
+ development_authority_profile_digest=intent["development_authority_profile_digest"],
343
+ )
344
+ active = context.get("active_service_update")
345
+ active_matches = isinstance(active, dict) and all(
346
+ active.get(key) == value for key, value in expected_update.items()
347
+ )
348
+ if active is None or (service == "core-control-plane" and not active_matches):
349
+ prepared = _host_json(
350
+ application,
351
+ (
352
+ "prepare-service-update",
353
+ "--service",
354
+ service,
355
+ "--image",
356
+ str(imported["image"]),
357
+ "--source-commit",
358
+ source.commit,
359
+ *(
360
+ (
361
+ "--development-authority-profile",
362
+ str(development_authority_profile_path),
363
+ )
364
+ if development_authority_profile is not None
365
+ else ()
366
+ ),
367
+ *(
368
+ ("--clear-development-profile",)
369
+ if clear_development_authority_profile
370
+ else ()
371
+ ),
372
+ ),
373
+ timeout=deadline.remaining(300),
374
+ )
375
+ if any(prepared.get(key) != value for key, value in expected_update.items()):
376
+ raise ValueError("prepared source service update differs from imported image")
377
+ elif not active_matches:
378
+ raise ValueError("another AKS service update is active")
379
+ recovered = _host_json(
380
+ application,
381
+ ("recover-apply", "--stage", "application", "--service", service),
382
+ timeout=deadline.remaining(2100),
383
+ )
384
+ if recovered.get("state") == "applied":
385
+ applied = recovered
386
+ else:
387
+ review = _host_json(
388
+ application,
389
+ ("plan", "--stage", "application", "--service", service),
390
+ timeout=deadline.remaining(3900),
391
+ )
392
+ plan_approval = (
393
+ approve_plan(work, review)
394
+ if approve_plan is not None
395
+ else _approve_plan(work, review, deadline=deadline)
396
+ )
397
+ applied = _host_json(
398
+ application,
399
+ (
400
+ "apply",
401
+ "--stage",
402
+ "application",
403
+ "--service",
404
+ service,
405
+ "--approval",
406
+ str(plan_approval),
407
+ ),
408
+ timeout=deadline.remaining(7500),
409
+ )
410
+ phase_started = _record_timing(phase_timings, "plan-apply-verify", phase_started)
411
+ result: dict[str, object] = {
412
+ "schema_version": "fdai.source-service-update.v1",
413
+ "state": "applied",
414
+ "service": service,
415
+ "source_commit": source.commit,
416
+ "image_digest": image["image_digest"],
417
+ "target_binding": context["target_binding"],
418
+ "image_import_receipt_digest": imported["receipt_digest"],
419
+ "application_receipt_digest": applied["receipt_digest"],
420
+ "effect_verified": applied.get("control_plane_readback_verified") is True,
421
+ "peer_state_unchanged_verified": applied.get("peer_state_unchanged_verified") is True,
422
+ "terraform_zero_change_verified": applied.get("terraform_zero_change_verified") is True,
423
+ "mutation_performed": True,
424
+ "subscription_ready": False,
425
+ }
426
+ if not all(
427
+ result[key] is True
428
+ for key in (
429
+ "effect_verified",
430
+ "peer_state_unchanged_verified",
431
+ "terraform_zero_change_verified",
432
+ )
433
+ ):
434
+ raise ValueError("source service update effect verification is incomplete")
435
+ finalized = _host_json(
436
+ application,
437
+ (
438
+ "finalize-service-update",
439
+ "--service",
440
+ service,
441
+ "--operation",
442
+ str(applied["operation"]),
443
+ "--receipt-digest",
444
+ str(applied["receipt_digest"]),
445
+ ),
446
+ timeout=deadline.remaining(120),
447
+ )
448
+ if (
449
+ finalized.get("state") != "finalized"
450
+ or finalized.get("service") != service
451
+ or finalized.get("apply_receipt_digest") != applied["receipt_digest"]
452
+ or finalized.get("mutation_performed") is not False
453
+ ):
454
+ raise ValueError("source service update finalization is incomplete")
455
+ result["finalization_receipt_digest"] = finalized["receipt_digest"]
456
+ result["receipt_digest"] = canonical_digest(result)
457
+ _record_timing(phase_timings, "finalization", phase_started)
458
+ receipt_path = work / "source-service-update-receipt.json"
459
+ if receipt_path.exists() or receipt_path.is_symlink():
460
+ if _read(receipt_path) != result:
461
+ raise ValueError("retained source service update receipt differs")
462
+ else:
463
+ write_private_bytes(receipt_path, canonical_bytes(result))
464
+ timing: dict[str, object] = {
465
+ "schema_version": "fdai.deployment-timing.v1",
466
+ "lane": "source-service-update",
467
+ "service": service,
468
+ "source_commit": source.commit,
469
+ "outcome": "applied",
470
+ "recorded_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
471
+ "total_elapsed_ms": round((time.monotonic() - operation_started) * 1000),
472
+ "phases": phase_timings,
473
+ "source_snapshot_schema": snapshot_schema,
474
+ "source_projection_file_count": projection_files,
475
+ "source_projection_bytes": projection_bytes,
476
+ "image_archive_bytes": archive.stat().st_size,
477
+ "effect_receipt_digest": result["receipt_digest"],
478
+ "timing_evidence_only": True,
479
+ "apply_authorized": False,
480
+ "deployment_ready": False,
481
+ }
482
+ timing["receipt_digest"] = canonical_digest(timing)
483
+ timing_directory = work / "timing"
484
+ timing_directory.mkdir(mode=0o700)
485
+ write_private_bytes(
486
+ timing_directory / f"{timing['receipt_digest']}.json",
487
+ canonical_bytes(timing),
488
+ )
489
+ return result
490
+ finally:
491
+ os.close(lock)
492
+
493
+
494
+ def _record_timing(
495
+ timings: list[dict[str, object]],
496
+ name: str,
497
+ started: float,
498
+ ) -> float:
499
+ now = time.monotonic()
500
+ timings.append({"name": name, "elapsed_ms": max(0, round((now - started) * 1000))})
501
+ return now
502
+
503
+
504
+ def _projection_facts(snapshot: Path) -> tuple[str, int, int]:
505
+ manifest = load_json_object(
506
+ read_private_bytes(snapshot / "source-input.json", max_bytes=16 * 1024 * 1024),
507
+ label="source service projection",
508
+ max_bytes=16 * 1024 * 1024,
509
+ )
510
+ projection = manifest.get("projection")
511
+ records = manifest.get("files")
512
+ schema = manifest.get("schema_version")
513
+ if not isinstance(records, list) or schema not in {
514
+ "fdai.source-snapshot.v1",
515
+ "fdai.source-snapshot.v2",
516
+ }:
517
+ raise ValueError("source service update snapshot inventory is invalid")
518
+ if schema == "fdai.source-snapshot.v2" and (
519
+ not isinstance(projection, dict) or projection.get("file_count") != len(records)
520
+ ):
521
+ raise ValueError("source service projection inventory is invalid")
522
+ total = 0
523
+ for record in records:
524
+ if not isinstance(record, dict) or not isinstance(record.get("path"), str):
525
+ raise TypeError("source service projection inventory is invalid")
526
+ path = snapshot / "tree" / record["path"]
527
+ if not path.is_symlink():
528
+ total += path.stat().st_size
529
+ return schema, len(records), total
530
+
531
+
532
+ def _verified_completed_receipt(
533
+ work: Path,
534
+ *,
535
+ source_commit: str,
536
+ service: str,
537
+ target_binding: str,
538
+ context: dict[str, Any],
539
+ ) -> dict[str, object] | None:
540
+ receipt_path = work / "source-service-update-receipt.json"
541
+ if not receipt_path.exists() and not receipt_path.is_symlink():
542
+ return None
543
+ receipt = _read(receipt_path)
544
+ image_digest = receipt.get("image_digest")
545
+ receipt_digest = receipt.get("receipt_digest")
546
+ unsigned = {key: value for key, value in receipt.items() if key != "receipt_digest"}
547
+ if (
548
+ receipt.get("schema_version") != "fdai.source-service-update.v1"
549
+ or receipt.get("state") != "applied"
550
+ or receipt.get("service") != service
551
+ or receipt.get("source_commit") != source_commit
552
+ or receipt.get("target_binding") != target_binding
553
+ or not isinstance(image_digest, str)
554
+ or _IMAGE_DIGEST.fullmatch(image_digest) is None
555
+ or not isinstance(receipt_digest, str)
556
+ or _DIGEST.fullmatch(receipt_digest) is None
557
+ or canonical_digest(unsigned) != receipt_digest
558
+ or any(
559
+ not isinstance(receipt.get(key), str) or _DIGEST.fullmatch(str(receipt[key])) is None
560
+ for key in (
561
+ "image_import_receipt_digest",
562
+ "application_receipt_digest",
563
+ "finalization_receipt_digest",
564
+ )
565
+ )
566
+ or any(
567
+ receipt.get(key) is not True
568
+ for key in (
569
+ "effect_verified",
570
+ "peer_state_unchanged_verified",
571
+ "terraform_zero_change_verified",
572
+ )
573
+ )
574
+ or context.get("current_source_commit") != source_commit
575
+ or not isinstance(context.get("current_image"), str)
576
+ or not str(context["current_image"]).endswith(f"@{image_digest}")
577
+ or context.get("active_service_update") is not None
578
+ ):
579
+ raise ValueError("retained source service update receipt is not currently verified")
580
+ return receipt
581
+
582
+
583
+ def _validate_import_receipt(
584
+ receipt: dict[str, Any],
585
+ *,
586
+ service: str,
587
+ source_commit: str,
588
+ archive_sha256: str,
589
+ image_digest: str,
590
+ ) -> None:
591
+ receipt_digest = receipt.get("receipt_digest")
592
+ claim_digest = receipt.get("claim_digest")
593
+ image = receipt.get("image")
594
+ unsigned = {key: value for key, value in receipt.items() if key != "receipt_digest"}
595
+ if (
596
+ receipt.get("schema_version") != "fdai.source-service-image-import-receipt.v1"
597
+ or receipt.get("state") != "imported"
598
+ or receipt.get("service") != service
599
+ or receipt.get("source_commit") != source_commit
600
+ or receipt.get("archive_sha256") != archive_sha256
601
+ or receipt.get("image_digest") != image_digest
602
+ or not isinstance(image, str)
603
+ or not image.endswith(f"/{service}@{image_digest}")
604
+ or receipt.get("effect_verified") is not True
605
+ or not isinstance(claim_digest, str)
606
+ or _DIGEST.fullmatch(claim_digest) is None
607
+ or not isinstance(receipt_digest, str)
608
+ or _DIGEST.fullmatch(receipt_digest) is None
609
+ or canonical_digest(unsigned) != receipt_digest
610
+ ):
611
+ raise ValueError("source service image import receipt is invalid")
612
+
613
+
614
+ def _approve_import(root: Path, review: dict[str, Any], deadline: DeploymentDeadline) -> Path:
615
+ print(json.dumps(review, indent=2, sort_keys=True), file=sys.stderr)
616
+ expected = f"import-{review['service']}"
617
+ print(f"Type {expected} to approve the exact source image import: ", end="", file=sys.stderr)
618
+ supplied = _approval_input(timeout_seconds=deadline.remaining(600))
619
+ if supplied != expected:
620
+ raise ValueError("source service image import approval was denied")
621
+ actor = _azure_actor_digest(str(review["target_binding"]), timeout_seconds=60)
622
+ now = datetime.now(UTC).replace(microsecond=0)
623
+ approval = {
624
+ "schema_version": "fdai.source-service-image-import-approval.v1",
625
+ "service": review["service"],
626
+ "source_commit": review["source_commit"],
627
+ "archive_sha256": review["archive_sha256"],
628
+ "image_digest": review["image_digest"],
629
+ "target_binding": review["target_binding"],
630
+ "actor_digest": actor,
631
+ "approved_at": _moment(now),
632
+ "expires_at": _moment(min(now + timedelta(hours=1), _parse(review["expires_at"]))),
633
+ }
634
+ approval["approval_digest"] = canonical_digest(approval)
635
+ path = root / "source-image-import-approval.json"
636
+ path.unlink(missing_ok=True)
637
+ write_private_bytes(path, canonical_bytes(approval))
638
+ return path
639
+
640
+
641
+ def _build_image(**kwargs: Any) -> dict[str, object]:
642
+ snapshot = Path(kwargs["snapshot"])
643
+ scripts = snapshot / "tree/scripts/deployment/azure/source_image_build.py"
644
+ if not scripts.is_file():
645
+ raise ValueError("source service image builder is unavailable in the selected snapshot")
646
+ command = (
647
+ sys.executable,
648
+ str(scripts),
649
+ "--snapshot",
650
+ str(snapshot),
651
+ "--snapshot-digest",
652
+ str(kwargs["snapshot_digest"]),
653
+ "--work-dir",
654
+ str(kwargs["work_dir"]),
655
+ "--service",
656
+ str(kwargs["service"]),
657
+ "--timeout-seconds",
658
+ str(kwargs["timeout_seconds"]),
659
+ )
660
+ completed = subprocess.run(
661
+ command,
662
+ check=False,
663
+ env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"},
664
+ stdout=subprocess.PIPE,
665
+ stderr=sys.stderr,
666
+ timeout=kwargs["timeout_seconds"],
667
+ )
668
+ if completed.returncode != 0:
669
+ raise ValueError("source service image build failed; preserve retained evidence")
670
+ return load_json_object(completed.stdout, label="source service image build")
671
+
672
+
673
+ def _host_json(work_dir: Path, arguments: tuple[str, ...], *, timeout: int) -> dict[str, Any]:
674
+ completed = subprocess.run(
675
+ (
676
+ sys.executable,
677
+ "-m",
678
+ "fdai_deployment_cli.standalone_host",
679
+ "--work-dir",
680
+ str(work_dir),
681
+ *arguments,
682
+ ),
683
+ check=False,
684
+ capture_output=True,
685
+ timeout=timeout,
686
+ )
687
+ if completed.returncode != 0:
688
+ raise ValueError("managed-host source service update checkpoint failed")
689
+ return load_json_object(completed.stdout, label="managed-host source service update")
690
+
691
+
692
+ def _require_private_directory(path: Path, *, create: bool) -> None:
693
+ if create:
694
+ path.mkdir(mode=0o700, parents=True, exist_ok=True)
695
+ details = path.lstat()
696
+ if (
697
+ not stat.S_ISDIR(details.st_mode)
698
+ or details.st_uid != os.geteuid()
699
+ or stat.S_IMODE(details.st_mode) != 0o700
700
+ ):
701
+ raise ValueError("source service update requires private owned work directories")
702
+
703
+
704
+ def _lock(work: Path) -> int:
705
+ path = work / "source-service-update.lock"
706
+ descriptor = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
707
+ try:
708
+ fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
709
+ except BlockingIOError:
710
+ os.close(descriptor)
711
+ raise ValueError("source service update is already active") from None
712
+ return descriptor
713
+
714
+
715
+ def _read(path: Path) -> dict[str, object]:
716
+ return load_json_object(read_private_bytes(path, max_bytes=65536), label="source update state")
717
+
718
+
719
+ def _parse(value: object) -> datetime:
720
+ if not isinstance(value, str):
721
+ raise TypeError("source image import review expiry is invalid")
722
+ return datetime.fromisoformat(value)
723
+
724
+
725
+ def _moment(value: datetime) -> str:
726
+ return value.astimezone(UTC).isoformat().replace("+00:00", "Z")