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,251 @@
1
+ """Standalone AKS frozen-scenario catalog-review checkpoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import subprocess
8
+ from collections.abc import Callable
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from fdai_deployment_cli.contracts import canonical_digest
13
+ from fdai_deployment_cli.standalone_aks_job_execution import execute_aks_cronjob_once
14
+ from fdai_deployment_cli.standalone_host_state import private_json, replace_private_json
15
+
16
+ _NAMESPACE = "fdai-runtime"
17
+ _TRIGGER_SCHEMA = "fdai.operational-catalog-review-trigger-receipt.v3"
18
+ _SCHEMA = "fdai.standalone-catalog-review-receipt.v3"
19
+
20
+
21
+ def run_catalog_review(
22
+ context: dict[str, Any],
23
+ work_dir: Path,
24
+ *,
25
+ login: Callable[[dict[str, object], Path], None],
26
+ ) -> dict[str, object]:
27
+ """Run one deployed draft-only review and persist its sanitized receipt."""
28
+
29
+ receipt_path = work_dir / "catalog-review-receipt.json"
30
+ if receipt_path.exists():
31
+ return _validated_receipt(
32
+ private_json(receipt_path, "standalone catalog review receipt"),
33
+ context=context,
34
+ )
35
+ if context.get("catalog_review_selected") is not True:
36
+ receipt: dict[str, object] = {
37
+ "schema_version": _SCHEMA,
38
+ "state": "skipped",
39
+ "selected": False,
40
+ "reason": "not_selected",
41
+ "source_revision": str(context.get("source_commit", "")),
42
+ "catalog_review_profile_digest": str(context.get("catalog_review_profile_digest", "")),
43
+ "mutation_performed": False,
44
+ "subscription_ready": False,
45
+ }
46
+ receipt["receipt_digest"] = canonical_digest(receipt)
47
+ replace_private_json(receipt_path, receipt)
48
+ return receipt
49
+ if _runtime_platform(context) != "aks":
50
+ raise ValueError("catalog review one-shot execution requires AKS")
51
+ if not (work_dir / "application-receipt.json").is_file():
52
+ raise ValueError("catalog review application prerequisite is incomplete")
53
+ values = private_json(work_dir / "workloads.auto.tfvars.json", "AKS workload variables")
54
+ scheduled_jobs = _mapping(values.get("scheduled_jobs"), "AKS scheduled jobs")
55
+ job = scheduled_jobs.get("catalog-review")
56
+ if not isinstance(job, dict) or context.get("catalog_review_available") is not True:
57
+ raise ValueError("catalog review private GitOps binding is unavailable")
58
+ login(context, work_dir)
59
+ execution = execute_aks_cronjob_once(
60
+ context,
61
+ work_dir,
62
+ template_name="catalog-review",
63
+ purpose="catalog-review",
64
+ expected_container_name="catalog-review",
65
+ expected_image=str(job.get("image", "")),
66
+ expected_command=(
67
+ "python",
68
+ "-m",
69
+ "fdai.runtime.operational_catalog_review_trigger",
70
+ ),
71
+ expected_service_account="catalog-review-job",
72
+ args=(),
73
+ environment={},
74
+ require_suspended=True,
75
+ timeout_seconds=360,
76
+ )
77
+ kubeconfig = Path(str(context["kubeconfig"]))
78
+ logs = _capture(
79
+ (
80
+ "kubectl",
81
+ "logs",
82
+ f"job/{execution.name}",
83
+ "--namespace",
84
+ _NAMESPACE,
85
+ "--container",
86
+ "catalog-review",
87
+ "--tail",
88
+ "20",
89
+ "--limit-bytes",
90
+ "65536",
91
+ f"--kubeconfig={kubeconfig}",
92
+ ),
93
+ cwd=work_dir,
94
+ timeout=60,
95
+ reason="catalog review sanitized receipt readback failed",
96
+ )
97
+ trigger_receipt = _receipt_from_logs(
98
+ logs,
99
+ source_revision=str(context.get("source_commit", "")),
100
+ )
101
+ receipt = {key: item for key, item in trigger_receipt.items() if key != "receipt_digest"}
102
+ receipt.update(
103
+ schema_version=_SCHEMA,
104
+ selected=True,
105
+ catalog_review_profile_digest=str(context["catalog_review_profile_digest"]),
106
+ trigger_receipt_digest=trigger_receipt["receipt_digest"],
107
+ )
108
+ receipt["receipt_digest"] = canonical_digest(receipt)
109
+ replace_private_json(receipt_path, receipt)
110
+ return receipt
111
+
112
+
113
+ def _receipt_from_logs(logs: str, *, source_revision: str) -> dict[str, object]:
114
+ for line in reversed(logs.splitlines()):
115
+ try:
116
+ candidate = json.loads(line)
117
+ except json.JSONDecodeError:
118
+ continue
119
+ if isinstance(candidate, dict) and candidate.get("schema_version") == _TRIGGER_SCHEMA:
120
+ return _validated_trigger_receipt(candidate, source_revision=source_revision)
121
+ raise ValueError("catalog review sanitized receipt is unavailable")
122
+
123
+
124
+ def _validated_trigger_receipt(
125
+ value: dict[str, Any],
126
+ *,
127
+ source_revision: str,
128
+ ) -> dict[str, object]:
129
+ receipt = {str(key): item for key, item in value.items()}
130
+ supplied_digest = receipt.pop("receipt_digest", None)
131
+ required_labels = receipt.get("required_labels")
132
+ observed_labels = receipt.get("observed_labels")
133
+ if (
134
+ receipt.get("schema_version") != _TRIGGER_SCHEMA
135
+ or receipt.get("state") != "draft-review-published"
136
+ or receipt.get("source_revision") != source_revision
137
+ or re.fullmatch(r"[0-9a-f]{40}", source_revision) is None
138
+ or not isinstance(required_labels, list)
139
+ or any(not isinstance(label, str) for label in required_labels)
140
+ or len(required_labels) != len(set(required_labels))
141
+ or required_labels != sorted(required_labels)
142
+ or observed_labels != required_labels
143
+ or not {"draft", "shadow", "governance", "catalog-review"}.issubset(required_labels)
144
+ or sum(isinstance(label, str) and label.startswith("rule:") for label in required_labels)
145
+ != 1
146
+ or sum(isinstance(label, str) and label.startswith("action:") for label in required_labels)
147
+ != 1
148
+ or receipt.get("draft") is not True
149
+ or receipt.get("mode") != "shadow"
150
+ or receipt.get("catalog_activation_performed") is not False
151
+ or receipt.get("code_path_merge_authority") is not False
152
+ or receipt.get("independent_pr_observation_required") is not True
153
+ or receipt.get("independent_pr_observation_verified") is not True
154
+ or receipt.get("independent_observation_scope") != "gitops-pr"
155
+ or receipt.get("durable_intent_verified") is not True
156
+ or receipt.get("durable_terminal_verified") is not True
157
+ or receipt.get("managed_resource_mutation_status") != "unknown"
158
+ or receipt.get("managed_resource_mutation_performed") is not None
159
+ or receipt.get("grants_authority") is not False
160
+ or not isinstance(receipt.get("already_existed"), bool)
161
+ or receipt.get("subscription_ready") is not False
162
+ or any(
163
+ re.fullmatch(r"[0-9a-f]{64}", str(receipt.get(name, ""))) is None
164
+ for name in (
165
+ "candidate_digest",
166
+ "package_digest",
167
+ "review_ref_digest",
168
+ "binding_digest",
169
+ "publication_observation_digest",
170
+ "review_document_digest",
171
+ "durable_audit_digest",
172
+ )
173
+ )
174
+ or re.fullmatch(
175
+ r"(?:[0-9a-f]{40}|[0-9a-f]{64})",
176
+ str(receipt.get("publication_head_sha", "")),
177
+ )
178
+ is None
179
+ or supplied_digest != canonical_digest(receipt)
180
+ ):
181
+ raise ValueError("catalog review sanitized receipt is invalid")
182
+ receipt["receipt_digest"] = supplied_digest
183
+ return receipt
184
+
185
+
186
+ def _validated_receipt(
187
+ value: dict[str, Any],
188
+ *,
189
+ context: dict[str, Any],
190
+ ) -> dict[str, object]:
191
+ receipt = {str(key): item for key, item in value.items()}
192
+ supplied_digest = receipt.pop("receipt_digest", None)
193
+ if (
194
+ receipt.get("schema_version") != _SCHEMA
195
+ or receipt.get("source_revision") != context.get("source_commit")
196
+ or receipt.get("catalog_review_profile_digest")
197
+ != context.get("catalog_review_profile_digest")
198
+ or supplied_digest != canonical_digest(receipt)
199
+ ):
200
+ raise ValueError("catalog review sanitized receipt is invalid")
201
+ selected = context.get("catalog_review_selected") is True
202
+ if selected:
203
+ if (
204
+ receipt.get("selected") is not True
205
+ or receipt.get("state") != "draft-review-published"
206
+ or receipt.get("independent_pr_observation_verified") is not True
207
+ or receipt.get("independent_observation_scope") != "gitops-pr"
208
+ ):
209
+ raise ValueError("selected catalog review receipt is incomplete")
210
+ elif (
211
+ receipt.get("selected") is not False
212
+ or receipt.get("state") != "skipped"
213
+ or receipt.get("reason") != "not_selected"
214
+ ):
215
+ raise ValueError("unselected catalog review receipt is invalid")
216
+ receipt["receipt_digest"] = supplied_digest
217
+ return receipt
218
+
219
+
220
+ def _runtime_platform(context: dict[str, Any]) -> str:
221
+ value = context.get("runtime_profile")
222
+ if value is None:
223
+ return "container-apps"
224
+ profile = _mapping(value, "runtime deployment profile")
225
+ platform = profile.get("runtime_platform")
226
+ if platform not in {"container-apps", "aks"}:
227
+ raise ValueError("runtime deployment platform is invalid")
228
+ return str(platform)
229
+
230
+
231
+ def _capture(command: tuple[str, ...], *, cwd: Path, timeout: int, reason: str) -> str:
232
+ result = subprocess.run(
233
+ command,
234
+ cwd=cwd,
235
+ check=False,
236
+ capture_output=True,
237
+ text=True,
238
+ timeout=timeout,
239
+ )
240
+ if result.returncode != 0:
241
+ raise ValueError(reason)
242
+ return result.stdout
243
+
244
+
245
+ def _mapping(value: object, label: str) -> dict[str, Any]:
246
+ if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
247
+ raise ValueError(f"{label} is invalid")
248
+ return {str(key): item for key, item in value.items()}
249
+
250
+
251
+ __all__ = ["run_catalog_review"]
@@ -0,0 +1,127 @@
1
+ """Bounded failure summaries for standalone managed-host checkpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import subprocess
8
+ from typing import Any
9
+
10
+ _ARM_RESOURCE_ID = re.compile(r"/subscriptions/[^\s\"']+", re.IGNORECASE)
11
+ _GUID = re.compile(r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}")
12
+ _SECRET_ASSIGNMENT = re.compile(
13
+ r"(?i)\b(token|secret|password|key|connection[_ -]?string)\b\s*[:=]\s*\S+"
14
+ )
15
+ _HOSTNAME = re.compile(
16
+ r"\b(?=[A-Za-z0-9.-]{5,253}\b)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}"
17
+ r"[A-Za-z0-9])?\.)+(?:[A-Za-z]{2,63})\b"
18
+ )
19
+ _PROVIDER_ERROR_CODE = re.compile(
20
+ r"\b(?:OverconstrainedZonalAllocationRequest|AllocationFailed|ParameterOutOfRange|"
21
+ r"[A-Z][A-Za-z0-9]*(?:Failed|Failure|Denied|Error|NotFound|OutOfRange|Request))\b"
22
+ )
23
+ _FAILURE_EXCERPT_LIMIT = 700
24
+
25
+
26
+ class ManagedHostCheckpointError(ValueError):
27
+ """A checkpoint failure that can be returned to the workstation safely."""
28
+
29
+ def __init__(
30
+ self,
31
+ reason_code: str,
32
+ message: str,
33
+ *,
34
+ provider_error_codes: tuple[str, ...] = (),
35
+ excerpt: str = "",
36
+ ) -> None:
37
+ super().__init__(message)
38
+ self.reason_code = reason_code
39
+ self.provider_error_codes = provider_error_codes
40
+ self.excerpt = excerpt
41
+
42
+
43
+ def failure_record(exc: BaseException) -> dict[str, object]:
44
+ if isinstance(exc, ManagedHostCheckpointError):
45
+ reason_code = exc.reason_code
46
+ provider_error_codes = list(exc.provider_error_codes)
47
+ excerpt = exc.excerpt or sanitize_failure_text(str(exc))
48
+ else:
49
+ reason_code = "managed_host_checkpoint_failed"
50
+ provider_error_codes = provider_error_codes_from_text(str(exc))
51
+ excerpt = sanitize_failure_text(str(exc))
52
+ return {
53
+ "schema_version": "fdai.standalone-host-failure.v1",
54
+ "state": "failed",
55
+ "reason_code": reason_code,
56
+ "provider_error_codes": provider_error_codes,
57
+ "message_excerpt": excerpt,
58
+ "mutation_performed": False,
59
+ "subscription_ready": False,
60
+ }
61
+
62
+
63
+ def remote_failure(result: subprocess.CompletedProcess[object]) -> dict[str, Any] | None:
64
+ for stream in (getattr(result, "stderr", ""), getattr(result, "stdout", "")):
65
+ text = (
66
+ stream.decode("utf-8", errors="replace") if isinstance(stream, bytes) else str(stream)
67
+ )
68
+ for line in reversed(text.splitlines()):
69
+ try:
70
+ value = json.loads(line)
71
+ except json.JSONDecodeError:
72
+ continue
73
+ if (
74
+ isinstance(value, dict)
75
+ and value.get("schema_version") == "fdai.standalone-host-failure.v1"
76
+ and isinstance(value.get("reason_code"), str)
77
+ and isinstance(value.get("message_excerpt"), str)
78
+ ):
79
+ return value
80
+ return None
81
+
82
+
83
+ def terraform_failure(reason: str, result: subprocess.CompletedProcess[Any]) -> ValueError:
84
+ output = "\n".join(
85
+ _output_part(part)
86
+ for part in (getattr(result, "stdout", ""), getattr(result, "stderr", ""))
87
+ if part not in (None, b"", "")
88
+ )
89
+ codes = tuple(provider_error_codes_from_text(output))
90
+ if codes:
91
+ # Terraform prints progress before diagnostics; the bounded excerpt must keep the error.
92
+ first_error = output.find("Error: ")
93
+ excerpt = sanitize_failure_text(output[first_error:] if first_error >= 0 else output)
94
+ return ManagedHostCheckpointError(
95
+ "terraform_provider_error",
96
+ f"{reason}; provider_error_code={','.join(codes)}; excerpt={excerpt}",
97
+ provider_error_codes=codes,
98
+ excerpt=excerpt,
99
+ )
100
+ return ValueError(reason)
101
+
102
+
103
+ def sanitize_failure_text(value: object) -> str:
104
+ text = value.decode("utf-8", errors="replace") if isinstance(value, bytes) else str(value)
105
+ text = _ARM_RESOURCE_ID.sub("<redacted-resource-id>", text)
106
+ text = _GUID.sub("<redacted-guid>", text)
107
+ text = _SECRET_ASSIGNMENT.sub(lambda match: f"{match.group(1)}=<redacted-secret>", text)
108
+ text = re.sub(r"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+", "Bearer <redacted-token>", text)
109
+ text = _HOSTNAME.sub("<redacted-hostname>", text)
110
+ text = re.sub(r"\s+", " ", text).strip()
111
+ if len(text) > _FAILURE_EXCERPT_LIMIT:
112
+ return text[: _FAILURE_EXCERPT_LIMIT - 3].rstrip() + "..."
113
+ return text
114
+
115
+
116
+ def provider_error_codes_from_text(value: object) -> list[str]:
117
+ codes: list[str] = []
118
+ text = value.decode("utf-8", errors="replace") if isinstance(value, bytes) else str(value)
119
+ for match in _PROVIDER_ERROR_CODE.finditer(text):
120
+ code = match.group(0)
121
+ if code not in codes:
122
+ codes.append(code)
123
+ return codes[:5]
124
+
125
+
126
+ def _output_part(value: object) -> str:
127
+ return value.decode("utf-8", errors="replace") if isinstance(value, bytes) else str(value)
@@ -0,0 +1,174 @@
1
+ """Publish the verified standalone Console artifact."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import re
8
+ import shutil
9
+ import stat
10
+ import subprocess
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from fdai_deployment_cli.bundle import extract_bundle_archive
15
+ from fdai_deployment_cli.console_config import configure_console
16
+ from fdai_deployment_cli.contracts import canonical_digest
17
+ from fdai_deployment_cli.private_output import write_private_output
18
+ from fdai_deployment_cli.standalone_host_state import replace_private_json
19
+
20
+ _DIGEST = re.compile(r"[0-9a-f]{64}")
21
+ _PUBLISHER_FIXED_REASON = re.compile(r'echo "([^"$`\\]+)" >&2')
22
+
23
+
24
+ def publish_verified_console(
25
+ *,
26
+ console_archive: Path,
27
+ console_archive_sha256: str,
28
+ bundle_root: Path,
29
+ prepared_root: Path,
30
+ entra_bindings: dict[str, str],
31
+ browser_console: dict[str, Any],
32
+ scripts: Path,
33
+ subscription_id: str,
34
+ tenant_id: str,
35
+ timeout_seconds: int,
36
+ redirect_changed: bool,
37
+ verify_only: bool = False,
38
+ verify_service_contracts: bool = True,
39
+ ) -> dict[str, object]:
40
+ """Configure a verified Console artifact, publish it, and read back exact bytes."""
41
+
42
+ _require_archive_digest(console_archive, console_archive_sha256)
43
+ extraction = prepared_root / "console-publish"
44
+ # A retained extraction may contain a prior release, not the just-verified archive.
45
+ _remove_retained_extraction(extraction)
46
+ console_directory = extract_bundle_archive(console_archive, extraction)
47
+ console_directory.chmod(0o700)
48
+ settings: dict[str, object] = {
49
+ "schema_version": "fdai.console-runtime.v1",
50
+ "operator_api_base_url": str(browser_console["operator_api_base_url"]),
51
+ "ingestion_api_base_url": str(browser_console["ingestion_api_base_url"]),
52
+ "tenant_id": tenant_id,
53
+ "spa_client_id": str(entra_bindings["ENTRA_CONSOLE_SPA_CLIENT_ID"]),
54
+ "api_scope": str(entra_bindings["ENTRA_CONSOLE_API_SCOPE"]),
55
+ }
56
+ settings_path = prepared_root / "console-runtime-settings.json"
57
+ replace_private_json(settings_path, settings)
58
+ configured = configure_console(
59
+ console_directory,
60
+ settings_path,
61
+ manual_studio_url=f"{browser_console['console_origin']}/manuals",
62
+ )
63
+ summary = prepared_root / "console-publish-summary.txt"
64
+ if not summary.exists():
65
+ write_private_output(summary, "")
66
+ environment: dict[str, str] = {
67
+ **os.environ,
68
+ "EXPECTED_AZURE_TENANT_ID": str(settings["tenant_id"]),
69
+ "ENTRA_CONSOLE_SPA_CLIENT_ID": str(settings["spa_client_id"]),
70
+ "ENTRA_CONSOLE_API_SCOPE": str(settings["api_scope"]),
71
+ "ARM_SUBSCRIPTION_ID": subscription_id,
72
+ "CONSOLE_DEFAULT_HOSTNAME": str(browser_console["console_hostname"]),
73
+ "CONSOLE_STATIC_WEB_APP_ID": str(browser_console["console_static_web_app_id"]),
74
+ "BROWSER_GATEWAY_OPERATOR_URL": str(settings["operator_api_base_url"]),
75
+ "BROWSER_GATEWAY_INGESTION_URL": str(settings["ingestion_api_base_url"]),
76
+ "CONSOLE_PREBUILT_DIRECTORY": str(console_directory),
77
+ "FDAI_CONSOLE_VERIFY_ONLY": "1" if verify_only else "0",
78
+ "FDAI_CONSOLE_VERIFY_SERVICE_CONTRACTS": "1" if verify_service_contracts else "0",
79
+ "GITHUB_STEP_SUMMARY": str(summary),
80
+ }
81
+ completed = subprocess.run(
82
+ (
83
+ "/bin/bash",
84
+ str(scripts / "publish-console.sh"),
85
+ str(bundle_root / "infra/runtimes/aks/workloads"),
86
+ ),
87
+ cwd=bundle_root,
88
+ env=environment,
89
+ check=False,
90
+ capture_output=True,
91
+ text=True,
92
+ timeout=timeout_seconds,
93
+ )
94
+ if completed.returncode != 0:
95
+ reason = _publisher_failure_reason(scripts / "publish-console.sh", completed.stderr)
96
+ raise ValueError(
97
+ "prebuilt Console publication or browser verification failed"
98
+ + (f": {reason}" if reason else "")
99
+ )
100
+ receipt: dict[str, object] = {
101
+ "schema_version": "fdai.standalone-console-publication.v1",
102
+ "state": "verified" if verify_only else "published",
103
+ "console_origin": browser_console["console_origin"],
104
+ "console_archive_sha256": console_archive_sha256,
105
+ "runtime_config_digest": configured["runtime_config_digest"],
106
+ "entra_redirect_changed": redirect_changed,
107
+ "artifact_hash_verified": True,
108
+ "spa_fallback_verified": True,
109
+ "api_health_verified": verify_service_contracts,
110
+ "authorization_preflight_verified": verify_service_contracts,
111
+ "unauthenticated_denial_verified": verify_service_contracts,
112
+ "entra_redirect_verified": verify_service_contracts,
113
+ "mutation_performed": not verify_only,
114
+ "subscription_ready": False,
115
+ }
116
+ receipt["receipt_digest"] = canonical_digest(receipt)
117
+ replace_private_json(prepared_root / "console-publication-receipt.json", receipt)
118
+ return receipt
119
+
120
+
121
+ def _publisher_failure_reason(publisher: Path, stderr: str | None) -> str | None:
122
+ """Return the publisher's final literal failure reason, never a variable diagnostic."""
123
+
124
+ try:
125
+ fixed_reasons = set(_PUBLISHER_FIXED_REASON.findall(publisher.read_text(encoding="utf-8")))
126
+ except OSError:
127
+ return None
128
+ for line in reversed((stderr or "").splitlines()):
129
+ if line.strip():
130
+ return line.strip() if line.strip() in fixed_reasons else None
131
+ return None
132
+
133
+
134
+ def _remove_retained_extraction(extraction: Path) -> None:
135
+ """Remove a prior Console extraction without following a substituted link."""
136
+
137
+ try:
138
+ status = extraction.lstat()
139
+ except FileNotFoundError:
140
+ return
141
+ if not stat.S_ISDIR(status.st_mode) or status.st_uid != os.getuid():
142
+ raise ValueError("retained Console extraction is not a private directory")
143
+ shutil.rmtree(extraction)
144
+
145
+
146
+ def _require_archive_digest(path: Path, expected_digest: str) -> None:
147
+ if _DIGEST.fullmatch(expected_digest) is None:
148
+ raise ValueError("Console archive digest is invalid")
149
+ descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
150
+ try:
151
+ before = os.fstat(descriptor)
152
+ if not stat.S_ISREG(before.st_mode):
153
+ raise ValueError("Console archive is not a regular file")
154
+ with os.fdopen(descriptor, "rb", closefd=False) as stream:
155
+ observed = hashlib.file_digest(stream, "sha256").hexdigest()
156
+ after = os.fstat(descriptor)
157
+ if (
158
+ before.st_dev,
159
+ before.st_ino,
160
+ before.st_size,
161
+ before.st_mtime_ns,
162
+ before.st_ctime_ns,
163
+ ) != (
164
+ after.st_dev,
165
+ after.st_ino,
166
+ after.st_size,
167
+ after.st_mtime_ns,
168
+ after.st_ctime_ns,
169
+ ):
170
+ raise ValueError("Console archive changed while it was read")
171
+ if observed != expected_digest:
172
+ raise ValueError("Console archive digest does not match the signed release")
173
+ finally:
174
+ os.close(descriptor)
@@ -0,0 +1,41 @@
1
+ """Principal selection for the in-cluster PostgreSQL stage of an AKS installation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+ from fdai_deployment_cli.standalone_operational_evidence import (
9
+ runtime_principal_ids_with_operational_evidence_verifier,
10
+ )
11
+
12
+
13
+ def database_reader_principals(
14
+ identities: Mapping[str, Any],
15
+ ) -> tuple[set[str], str | None, str | None]:
16
+ """Return the DSN reader principals for the in-cluster database stage.
17
+
18
+ Core and inventory always run. The Operator, executor, and document-ingestion identities exist
19
+ only when their product option is selected, so an absent one is granted nothing instead of
20
+ stopping the stage. A present identity must still be well formed.
21
+ """
22
+
23
+ names = tuple(
24
+ name
25
+ for name in ("core", "operator", "executor", "inventory")
26
+ if name in {"core", "inventory"} or identities.get(name) is not None
27
+ )
28
+
29
+ def optional(name: str, label: str) -> str | None:
30
+ value = identities.get(name)
31
+ if value is None:
32
+ return None
33
+ if not isinstance(value, dict) or not isinstance(value.get("principal_id"), str):
34
+ raise ValueError(f"{label} is invalid")
35
+ return str(value["principal_id"])
36
+
37
+ return (
38
+ runtime_principal_ids_with_operational_evidence_verifier(identities, names),
39
+ optional("ingestion", "ingestion runtime identity"),
40
+ optional("ingestion_worker", "ingestion worker runtime identity"),
41
+ )