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,386 @@
1
+ """Assemble a complete runtime release from prebuilt, locally verified artifacts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import stat
9
+ from pathlib import Path, PurePosixPath
10
+ from tempfile import TemporaryDirectory
11
+ from typing import cast
12
+
13
+ from fdai_deployment_cli.contracts import canonical_bytes
14
+ from fdai_deployment_cli.oci_archive import (
15
+ validate_dependency_oci_archive,
16
+ validate_oci_archive,
17
+ )
18
+ from fdai_deployment_cli.offline_kit import (
19
+ _MAX_FILE_BYTES,
20
+ _copy_verified_file,
21
+ _sha256_nofollow,
22
+ )
23
+ from fdai_deployment_cli.private_output import (
24
+ _open_private_parent,
25
+ read_private_bytes,
26
+ write_private_bytes,
27
+ )
28
+ from fdai_deployment_cli.runtime_release import (
29
+ RUNTIME_RELEASE_PATH,
30
+ RUNTIME_SERVICES,
31
+ RUNTIME_SIDECARS,
32
+ load_runtime_release,
33
+ validate_runtime_images,
34
+ )
35
+
36
+ _SCHEMA = "fdai.runtime-release-build.v1"
37
+ _OUTPUT_SCHEMA = "fdai.runtime-release-build-result.v1"
38
+ _MAX_DESCRIPTOR_BYTES = 1024 * 1024
39
+ _COMMIT = re.compile(r"[0-9a-f]{40}")
40
+ _DIGEST = re.compile(r"sha256:[0-9a-f]{64}")
41
+ _SHA256 = re.compile(r"[0-9a-f]{64}")
42
+ _PLATFORMS = frozenset({"linux-x86_64", "linux-aarch64"})
43
+ _IMAGE_FIELDS = frozenset(
44
+ {
45
+ "archive",
46
+ "archive_sha256",
47
+ "sbom",
48
+ "sbom_sha256",
49
+ "provenance",
50
+ "provenance_sha256",
51
+ "image_digest",
52
+ }
53
+ )
54
+ _ARCHIVE_FIELDS = frozenset({"archive", "archive_sha256", "sbom", "sbom_sha256"})
55
+ _ROOT_FIELDS = frozenset(
56
+ {
57
+ "schema_version",
58
+ "source_commit",
59
+ "platform_tag",
60
+ "services",
61
+ "sidecars",
62
+ "console",
63
+ "deployment_support",
64
+ }
65
+ )
66
+ _OUTPUT_NAMES = {
67
+ "archive": "image.oci.tar",
68
+ "sbom": "sbom.cdx.json",
69
+ "provenance": "provenance.jsonl",
70
+ }
71
+
72
+
73
+ def build_runtime_release(
74
+ source_root: Path,
75
+ descriptor_path: Path,
76
+ deployment_bundle: Path,
77
+ output_root: Path,
78
+ ) -> dict[str, object]:
79
+ """Build one exact runtime v2 tree without network access or artifact execution.
80
+
81
+ The private descriptor names only relative files below ``source_root``. Every
82
+ payload is copied through no-follow digest checks into a new private output.
83
+ Service OCI archives must carry the declared FDAI revision; the ClamAV archive
84
+ is revision-neutral. This function does not sign artifacts, attest provenance,
85
+ publish an image, or authorize deployment.
86
+ """
87
+
88
+ descriptor = _load_descriptor(
89
+ read_private_bytes(descriptor_path, max_bytes=_MAX_DESCRIPTOR_BYTES)
90
+ )
91
+ source_commit = _text(descriptor, "source_commit")
92
+ platform_tag = _text(descriptor, "platform_tag")
93
+ if _COMMIT.fullmatch(source_commit) is None:
94
+ raise ValueError("runtime build source_commit is invalid")
95
+ if platform_tag not in _PLATFORMS:
96
+ raise ValueError("runtime build platform_tag is unsupported")
97
+ _require_source_root(source_root)
98
+ if not output_root.is_absolute():
99
+ raise ValueError("runtime release output MUST be an absolute path")
100
+ parent = _open_private_parent(output_root)
101
+ os.close(parent)
102
+ if output_root.exists() or output_root.is_symlink():
103
+ raise ValueError("runtime release output already exists")
104
+ bundle_digest = _regular_digest(deployment_bundle, label="deployment bundle")
105
+
106
+ with TemporaryDirectory(prefix=".runtime-release-", dir=output_root.parent) as temporary:
107
+ temporary_root = Path(temporary)
108
+ temporary_root.chmod(0o700)
109
+ staging = temporary_root / "release"
110
+ staging.mkdir(mode=0o700)
111
+ services = _build_image_group(
112
+ source_root,
113
+ staging,
114
+ descriptor.get("services"),
115
+ section="services",
116
+ expected_names=RUNTIME_SERVICES,
117
+ source_commit=source_commit,
118
+ platform_tag=platform_tag,
119
+ )
120
+ sidecars = _build_image_group(
121
+ source_root,
122
+ staging,
123
+ descriptor.get("sidecars"),
124
+ section="sidecars",
125
+ expected_names=RUNTIME_SIDECARS,
126
+ source_commit=None,
127
+ platform_tag=platform_tag,
128
+ )
129
+ console = _build_archive_record(
130
+ source_root,
131
+ staging,
132
+ descriptor.get("console"),
133
+ section="console",
134
+ archive_name="console.tar.gz",
135
+ )
136
+ support = _build_archive_record(
137
+ source_root,
138
+ staging,
139
+ descriptor.get("deployment_support"),
140
+ section="deployment-support",
141
+ archive_name="deployment-support.tar.gz",
142
+ )
143
+ catalog = {
144
+ "schema_version": "fdai.runtime-release.v2",
145
+ "source_commit": source_commit,
146
+ "platform_tag": platform_tag,
147
+ "deployment_bundle_sha256": bundle_digest,
148
+ "services": services,
149
+ "sidecars": sidecars,
150
+ "console": console,
151
+ "deployment_support": support,
152
+ }
153
+ write_private_bytes(staging / RUNTIME_RELEASE_PATH, canonical_bytes(catalog))
154
+ release = load_runtime_release(
155
+ staging,
156
+ expected_source_commit=source_commit,
157
+ expected_platform_tag=platform_tag,
158
+ )
159
+ image_digests = validate_runtime_images(staging, release)
160
+
161
+ marker = staging / "runtime/.fdai-incomplete"
162
+ write_private_bytes(marker, b"fdai-runtime-release-build-v1\n")
163
+ os.mkdir(output_root, 0o700)
164
+ try:
165
+ os.rename(staging / "runtime", output_root / "runtime")
166
+ except BaseException:
167
+ os.rmdir(output_root)
168
+ raise
169
+ marker = output_root / "runtime/.fdai-incomplete"
170
+ try:
171
+ marker.unlink()
172
+ except OSError as exc:
173
+ raise ValueError(
174
+ "runtime release publication is incomplete; remove the marked output after review"
175
+ ) from exc
176
+
177
+ return {
178
+ "schema_version": _OUTPUT_SCHEMA,
179
+ "runtime_release_digest": release.digest,
180
+ "deployment_bundle_sha256": bundle_digest,
181
+ "source_commit": source_commit,
182
+ "platform_tag": platform_tag,
183
+ "artifact_count": len(release.artifact_paths),
184
+ "image_content_digests": image_digests,
185
+ "azure_mutation_performed": False,
186
+ "production_release_eligibility": "unverified",
187
+ }
188
+
189
+
190
+ def _load_descriptor(raw: bytes) -> dict[str, object]:
191
+ try:
192
+ value = json.loads(raw, object_pairs_hook=_unique_object)
193
+ except (UnicodeDecodeError, json.JSONDecodeError, RecursionError) as exc:
194
+ raise ValueError("runtime build descriptor is not valid JSON") from exc
195
+ if not isinstance(value, dict) or set(value) != _ROOT_FIELDS:
196
+ raise ValueError("runtime build descriptor fields do not match the schema")
197
+ result = cast(dict[str, object], value)
198
+ if result["schema_version"] != _SCHEMA:
199
+ raise ValueError("runtime build descriptor schema_version is unsupported")
200
+ return result
201
+
202
+
203
+ def _unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
204
+ result: dict[str, object] = {}
205
+ for key, value in pairs:
206
+ if key in result:
207
+ raise ValueError("runtime build descriptor contains duplicate JSON keys")
208
+ result[key] = value
209
+ return result
210
+
211
+
212
+ def _build_image_group(
213
+ source_root: Path,
214
+ staging: Path,
215
+ value: object,
216
+ *,
217
+ section: str,
218
+ expected_names: frozenset[str],
219
+ source_commit: str | None,
220
+ platform_tag: str,
221
+ ) -> dict[str, object]:
222
+ records = _object(value, set(expected_names), label=section)
223
+ result: dict[str, object] = {}
224
+ for name in sorted(expected_names):
225
+ record = _string_record(records[name], _IMAGE_FIELDS, label=f"{section} record")
226
+ image_digest = record["image_digest"]
227
+ if _DIGEST.fullmatch(image_digest) is None:
228
+ raise ValueError("runtime build image digest is invalid")
229
+ output: dict[str, str] = {"image_digest": image_digest}
230
+ for field in ("archive", "sbom", "provenance"):
231
+ relative = f"runtime/{section}/{name}/{_OUTPUT_NAMES[field]}"
232
+ digest = _copy_source(
233
+ source_root,
234
+ record[field],
235
+ staging / relative,
236
+ output_root=staging,
237
+ expected_digest=record[f"{field}_sha256"],
238
+ label=f"{section} {field}",
239
+ )
240
+ output[field] = relative
241
+ output[f"{field}_sha256"] = digest
242
+ arguments = {
243
+ "expected_archive_sha256": output["archive_sha256"],
244
+ "expected_manifest_digest": image_digest,
245
+ "expected_platform_tag": platform_tag,
246
+ }
247
+ archive = staging / output["archive"]
248
+ if source_commit is None:
249
+ validate_dependency_oci_archive(archive, **arguments)
250
+ else:
251
+ validate_oci_archive(
252
+ archive,
253
+ expected_source_commit=source_commit,
254
+ **arguments,
255
+ )
256
+ result[name] = output
257
+ return result
258
+
259
+
260
+ def _build_archive_record(
261
+ source_root: Path,
262
+ staging: Path,
263
+ value: object,
264
+ *,
265
+ section: str,
266
+ archive_name: str,
267
+ ) -> dict[str, str]:
268
+ record = _string_record(value, _ARCHIVE_FIELDS, label=f"{section} record")
269
+ output: dict[str, str] = {}
270
+ names = {"archive": archive_name, "sbom": "sbom.cdx.json"}
271
+ for field in ("archive", "sbom"):
272
+ relative = f"runtime/{section}/{names[field]}"
273
+ digest = _copy_source(
274
+ source_root,
275
+ record[field],
276
+ staging / relative,
277
+ output_root=staging,
278
+ expected_digest=record[f"{field}_sha256"],
279
+ label=f"{section} {field}",
280
+ )
281
+ output[field] = relative
282
+ output[f"{field}_sha256"] = digest
283
+ return output
284
+
285
+
286
+ def _copy_source(
287
+ source_root: Path,
288
+ relative: str,
289
+ target: Path,
290
+ *,
291
+ output_root: Path,
292
+ expected_digest: str,
293
+ label: str,
294
+ ) -> str:
295
+ if _SHA256.fullmatch(expected_digest) is None:
296
+ raise ValueError(f"runtime build {label} digest is invalid")
297
+ try:
298
+ source = _source_path(source_root, relative)
299
+ details = source.lstat()
300
+ except OSError as exc:
301
+ raise ValueError(f"runtime build {label} is unavailable") from exc
302
+ if not stat.S_ISREG(details.st_mode) or not 0 < details.st_size <= _MAX_FILE_BYTES:
303
+ raise ValueError(f"runtime build {label} MUST be a non-empty bounded regular file")
304
+ digest = _sha256_nofollow(source, expected=details)
305
+ if digest != expected_digest:
306
+ raise ValueError(f"runtime build {label} digest does not match")
307
+ _ensure_private_parent(target, root=output_root)
308
+ _copy_verified_file(
309
+ source,
310
+ target,
311
+ expected_digest=digest,
312
+ expected_size=details.st_size,
313
+ )
314
+ return digest
315
+
316
+
317
+ def _ensure_private_parent(path: Path, *, root: Path) -> None:
318
+ current = root
319
+ for part in path.parent.relative_to(root).parts:
320
+ current /= part
321
+ try:
322
+ os.mkdir(current, 0o700)
323
+ except FileExistsError:
324
+ details = current.lstat()
325
+ if not stat.S_ISDIR(details.st_mode):
326
+ raise ValueError(
327
+ "runtime release output directories MUST NOT be symlinks"
328
+ ) from None
329
+ current.chmod(0o700)
330
+
331
+
332
+ def _source_path(root: Path, value: str) -> Path:
333
+ path = PurePosixPath(value)
334
+ if (
335
+ not value
336
+ or value.startswith("/")
337
+ or "\\" in value
338
+ or any(part in {"", ".", ".."} for part in path.parts)
339
+ ):
340
+ raise ValueError("runtime build source path is invalid")
341
+ current = root
342
+ for part in path.parts[:-1]:
343
+ current /= part
344
+ if not stat.S_ISDIR(current.lstat().st_mode):
345
+ raise ValueError("runtime build source directories MUST NOT be symlinks")
346
+ return root / path.as_posix()
347
+
348
+
349
+ def _require_source_root(path: Path) -> None:
350
+ if not path.is_absolute() or not stat.S_ISDIR(path.lstat().st_mode):
351
+ raise ValueError("runtime build source root MUST be an absolute real directory")
352
+
353
+
354
+ def _regular_digest(path: Path, *, label: str) -> str:
355
+ if not path.is_absolute():
356
+ raise ValueError(f"{label} path MUST be absolute")
357
+ try:
358
+ details = path.lstat()
359
+ except OSError as exc:
360
+ raise ValueError(f"{label} is unavailable") from exc
361
+ if not stat.S_ISREG(details.st_mode) or not 0 < details.st_size <= _MAX_FILE_BYTES:
362
+ raise ValueError(f"{label} MUST be a non-empty bounded regular file")
363
+ return _sha256_nofollow(path, expected=details)
364
+
365
+
366
+ def _object(value: object, fields: set[str], *, label: str) -> dict[str, object]:
367
+ if not isinstance(value, dict) or set(value) != fields:
368
+ raise ValueError(f"runtime build {label} fields do not match the schema")
369
+ return cast(dict[str, object], value)
370
+
371
+
372
+ def _string_record(value: object, fields: frozenset[str], *, label: str) -> dict[str, str]:
373
+ record = _object(value, set(fields), label=label)
374
+ if not all(isinstance(item, str) for item in record.values()):
375
+ raise ValueError(f"runtime build {label} values MUST be strings")
376
+ return cast(dict[str, str], record)
377
+
378
+
379
+ def _text(value: dict[str, object], field: str) -> str:
380
+ item = value[field]
381
+ if not isinstance(item, str):
382
+ raise TypeError(f"runtime build {field} MUST be a string")
383
+ return item
384
+
385
+
386
+ __all__ = ["build_runtime_release"]
@@ -0,0 +1,287 @@
1
+ """Validate and seal the runtime substrate selected for a new deployment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from dataclasses import dataclass, field
7
+ from enum import StrEnum
8
+
9
+ from fdai_service_contracts.product_profile import (
10
+ ObservationDataSource,
11
+ ObservationReadPermissions,
12
+ ProductAddOn,
13
+ ProductProfile,
14
+ )
15
+
16
+ from fdai_deployment_cli.contracts import canonical_digest
17
+
18
+
19
+ class RuntimePlatform(StrEnum):
20
+ """Supported Azure runtime substrates for a new FDAI installation."""
21
+
22
+ CONTAINER_APPS = "container-apps"
23
+ AKS = "aks"
24
+
25
+
26
+ class DatabasePlacement(StrEnum):
27
+ """Supported PostgreSQL placement choices for a new installation."""
28
+
29
+ POSTGRES_FLEX = "postgres-flex"
30
+ POSTGRES_AKS = "postgres-aks"
31
+
32
+
33
+ # Explicit PostgreSQL Flexible Server sizes. Unset keeps the infrastructure default,
34
+ # GP_Standard_D4ads_v5, and leaves the profile mapping and digest unchanged.
35
+ DATABASE_SKUS = (
36
+ "B_Standard_B1ms",
37
+ "B_Standard_B2s",
38
+ "B_Standard_B2ms",
39
+ "GP_Standard_D2ds_v5",
40
+ "GP_Standard_D4ds_v5",
41
+ "GP_Standard_D4ads_v5",
42
+ )
43
+
44
+ LEGACY_FULL_PRODUCT_ADD_ONS = (
45
+ ProductAddOn.ENTERPRISE_IDENTITY_GOVERNANCE,
46
+ ProductAddOn.GOVERNED_EXECUTION,
47
+ ProductAddOn.NOTIFICATIONS,
48
+ ProductAddOn.READ_ONLY_CONSOLE,
49
+ )
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class RuntimeDeploymentProfile:
54
+ """Secret-free runtime and node-pool intent bound to exact deployment plans."""
55
+
56
+ runtime_platform: RuntimePlatform
57
+ database_placement: DatabasePlacement
58
+ system_node_count: int = 3
59
+ system_node_sku: str = "Standard_D4ds_v4"
60
+ user_node_min_count: int = 3
61
+ user_node_max_count: int = 5
62
+ user_node_sku: str = "Standard_D4ds_v4"
63
+ product_profile: ProductProfile = field(default_factory=ProductProfile)
64
+ database_sku: str | None = None
65
+
66
+ @property
67
+ def console_selected(self) -> bool:
68
+ """Report the explicitly selected read-only Console surface."""
69
+
70
+ return self.product_profile.selects(ProductAddOn.READ_ONLY_CONSOLE)
71
+
72
+ def __post_init__(self) -> None:
73
+ if self.database_sku is not None:
74
+ if self.database_sku not in DATABASE_SKUS:
75
+ raise ValueError("database SKU is unsupported")
76
+ if self.database_placement is not DatabasePlacement.POSTGRES_FLEX:
77
+ raise ValueError("database SKU requires postgres-flex placement")
78
+ if self.runtime_platform is RuntimePlatform.CONTAINER_APPS:
79
+ if self.database_placement is not DatabasePlacement.POSTGRES_FLEX:
80
+ raise ValueError("Container Apps requires postgres-flex")
81
+ return
82
+ if not 2 <= self.system_node_count <= 100:
83
+ raise ValueError("AKS system node count MUST be in [2, 100]")
84
+ minimum_user_nodes = 4 if self.database_placement is DatabasePlacement.POSTGRES_AKS else 3
85
+ if not minimum_user_nodes <= self.user_node_min_count <= 100:
86
+ raise ValueError(f"AKS user node minimum MUST be in [{minimum_user_nodes}, 100]")
87
+ if not self.user_node_min_count <= self.user_node_max_count <= 100:
88
+ raise ValueError("AKS user node maximum MUST cover the minimum and be at most 100")
89
+ for label, value in (
90
+ ("system node SKU", self.system_node_sku),
91
+ ("user node SKU", self.user_node_sku),
92
+ ):
93
+ if not value.startswith("Standard_") or any(character.isspace() for character in value):
94
+ raise ValueError(f"AKS {label} is invalid")
95
+
96
+ @property
97
+ def digest(self) -> str:
98
+ """Return a replay-stable digest over the complete runtime choice."""
99
+
100
+ return canonical_digest(self.to_mapping())
101
+
102
+ @classmethod
103
+ def create(
104
+ cls,
105
+ *,
106
+ runtime_platform: str,
107
+ database_placement: str,
108
+ system_node_count: int = 3,
109
+ system_node_sku: str | None = None,
110
+ user_node_min_count: int = 3,
111
+ user_node_max_count: int = 5,
112
+ user_node_sku: str = "Standard_D4ds_v4",
113
+ product_add_ons: tuple[str, ...] = (),
114
+ observation_data_sources: tuple[str, ...] = (),
115
+ database_sku: str | None = None,
116
+ ) -> RuntimeDeploymentProfile:
117
+ """Parse command values and reject unsupported runtime or database names."""
118
+
119
+ try:
120
+ runtime = RuntimePlatform(runtime_platform)
121
+ except ValueError as exc:
122
+ raise ValueError("runtime platform is unsupported") from exc
123
+ try:
124
+ database = DatabasePlacement(database_placement)
125
+ except ValueError as exc:
126
+ raise ValueError("database placement is unsupported") from exc
127
+ try:
128
+ add_ons = tuple(sorted({ProductAddOn(value) for value in product_add_ons}, key=str))
129
+ except ValueError as exc:
130
+ raise ValueError("product add-on is unsupported") from exc
131
+ if len(add_ons) != len(product_add_ons):
132
+ raise ValueError("product add-ons MUST NOT contain duplicates")
133
+ try:
134
+ data_sources = tuple(
135
+ sorted(
136
+ {ObservationDataSource(value) for value in observation_data_sources},
137
+ key=str,
138
+ )
139
+ )
140
+ except ValueError as exc:
141
+ raise ValueError("observation data source is unsupported") from exc
142
+ if len(data_sources) != len(observation_data_sources):
143
+ raise ValueError("observation data sources MUST NOT contain duplicates")
144
+ return cls(
145
+ runtime_platform=runtime,
146
+ database_placement=database,
147
+ system_node_count=system_node_count,
148
+ system_node_sku=system_node_sku
149
+ if system_node_sku is not None
150
+ else ("Standard_D4ds_v4" if runtime is RuntimePlatform.AKS else "Standard_D2as_v5"),
151
+ user_node_min_count=user_node_min_count,
152
+ user_node_max_count=user_node_max_count,
153
+ user_node_sku=user_node_sku,
154
+ product_profile=ProductProfile(
155
+ add_ons=add_ons,
156
+ observation_permissions=ObservationReadPermissions(
157
+ selected_sources=data_sources,
158
+ ),
159
+ ),
160
+ database_sku=database_sku,
161
+ )
162
+
163
+ @classmethod
164
+ def from_prepare_arguments(cls, args: argparse.Namespace) -> RuntimeDeploymentProfile:
165
+ """Rebuild the profile from managed-host prepare arguments with the same checks."""
166
+
167
+ return cls.create(
168
+ runtime_platform=str(args.runtime_platform),
169
+ database_placement=str(args.database_placement),
170
+ system_node_count=int(args.system_node_count),
171
+ system_node_sku=args.system_node_sku,
172
+ user_node_min_count=int(args.user_node_min_count),
173
+ user_node_max_count=int(args.user_node_max_count),
174
+ user_node_sku=str(args.user_node_sku),
175
+ product_add_ons=tuple(args.product_add_on),
176
+ observation_data_sources=tuple(args.observation_source),
177
+ database_sku=args.database_sku,
178
+ )
179
+
180
+ @classmethod
181
+ def from_cli_arguments(cls, args: argparse.Namespace) -> RuntimeDeploymentProfile:
182
+ """Build the selected profile from the public Azure command arguments."""
183
+
184
+ return cls.create(
185
+ runtime_platform=args.runtime,
186
+ database_placement=args.database,
187
+ system_node_count=args.system_nodes,
188
+ system_node_sku=args.system_node_sku,
189
+ user_node_min_count=args.user_nodes,
190
+ user_node_max_count=args.max_user_nodes,
191
+ user_node_sku=args.user_node_sku,
192
+ product_add_ons=tuple(args.product_add_ons),
193
+ observation_data_sources=tuple(args.observation_data_sources),
194
+ database_sku=args.database_sku,
195
+ )
196
+
197
+ def to_mapping(self) -> dict[str, object]:
198
+ """Return the canonical machine representation stored with deployment evidence."""
199
+
200
+ mapping: dict[str, object] = {
201
+ "schema_version": "fdai.runtime-deployment-profile.v2",
202
+ "runtime_platform": self.runtime_platform.value,
203
+ "database_placement": self.database_placement.value,
204
+ "system_node_count": self.system_node_count,
205
+ "system_node_sku": self.system_node_sku,
206
+ "user_node_min_count": self.user_node_min_count,
207
+ "user_node_max_count": self.user_node_max_count,
208
+ "user_node_sku": self.user_node_sku,
209
+ "product_profile": self.product_profile.model_dump(mode="json"),
210
+ }
211
+ if self.database_sku is not None:
212
+ mapping["database_sku"] = self.database_sku
213
+ return mapping
214
+
215
+ @classmethod
216
+ def from_mapping(cls, value: dict[str, object]) -> RuntimeDeploymentProfile:
217
+ """Read current profiles and preserve explicit legacy full-product behavior."""
218
+
219
+ schema_version = value.get("schema_version")
220
+ if schema_version not in {
221
+ "fdai.runtime-deployment-profile.v1",
222
+ "fdai.runtime-deployment-profile.v2",
223
+ }:
224
+ raise ValueError("runtime deployment profile schema is unsupported")
225
+ product = value.get("product_profile")
226
+ if schema_version == "fdai.runtime-deployment-profile.v1":
227
+ add_ons = tuple(item.value for item in LEGACY_FULL_PRODUCT_ADD_ONS)
228
+ data_sources = tuple(item.value for item in ObservationDataSource)
229
+ else:
230
+ if not isinstance(product, dict):
231
+ raise ValueError("runtime deployment product profile is missing")
232
+ parsed = ProductProfile.model_validate(product)
233
+ add_ons = tuple(item.value for item in parsed.add_ons)
234
+ data_sources = tuple(
235
+ item.value for item in parsed.observation_permissions.selected_sources
236
+ )
237
+ return cls.create(
238
+ runtime_platform=str(value.get("runtime_platform", "")),
239
+ database_placement=str(value.get("database_placement", "")),
240
+ system_node_count=value.get("system_node_count", 0), # type: ignore[arg-type]
241
+ system_node_sku=(
242
+ str(value["system_node_sku"]) if value.get("system_node_sku") is not None else None
243
+ ),
244
+ user_node_min_count=value.get("user_node_min_count", 0), # type: ignore[arg-type]
245
+ user_node_max_count=value.get("user_node_max_count", 0), # type: ignore[arg-type]
246
+ user_node_sku=str(value.get("user_node_sku", "")),
247
+ product_add_ons=add_ons,
248
+ observation_data_sources=data_sources,
249
+ database_sku=(
250
+ str(value["database_sku"]) if value.get("database_sku") is not None else None
251
+ ),
252
+ )
253
+
254
+ def matches_mapping(self, value: dict[str, object]) -> bool:
255
+ """Compare current mappings exactly and legacy mappings without invented fields."""
256
+
257
+ if value.get("schema_version") == "fdai.runtime-deployment-profile.v1":
258
+ return legacy_runtime_profile_mapping(self) == value
259
+ return self.to_mapping() == value
260
+
261
+
262
+ def legacy_runtime_profile_mapping(
263
+ profile: RuntimeDeploymentProfile,
264
+ ) -> dict[str, object]:
265
+ """Project the pre-product-axis mapping for immutable legacy evidence."""
266
+
267
+ current = profile.to_mapping()
268
+ current.pop("product_profile")
269
+ current["schema_version"] = "fdai.runtime-deployment-profile.v1"
270
+ return current
271
+
272
+
273
+ def legacy_runtime_profile_digest(profile: RuntimeDeploymentProfile) -> str:
274
+ """Return the digest retained by a context created before profile v2."""
275
+
276
+ return canonical_digest(legacy_runtime_profile_mapping(profile))
277
+
278
+
279
+ def legacy_full_product_matches(profile: RuntimeDeploymentProfile, retained_digest: str) -> bool:
280
+ """Match a pre-product-axis record only to the explicit legacy full-product selection."""
281
+
282
+ selected = profile.product_profile
283
+ return (
284
+ set(selected.add_ons) == set(LEGACY_FULL_PRODUCT_ADD_ONS)
285
+ and len(selected.observation_permissions.selected_sources) == len(ObservationDataSource)
286
+ and retained_digest == legacy_runtime_profile_digest(profile)
287
+ )