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,218 @@
1
+ """Build a deterministic Console update artifact from protected Git source."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import subprocess
9
+ import sys
10
+ from pathlib import Path
11
+ from tempfile import TemporaryDirectory
12
+
13
+ from fdai_deployment_cli.console_config import MANUAL_STUDIO_PLACEHOLDER_URL
14
+ from fdai_deployment_cli.console_update import _regular_digest
15
+ from fdai_deployment_cli.private_output import _open_private_parent, write_private_output
16
+
17
+ _COMMIT = re.compile(r"[0-9a-f]{40}")
18
+
19
+
20
+ def build_console_update_artifact(
21
+ *,
22
+ source_root: Path,
23
+ revision: str,
24
+ output_dir: Path,
25
+ timeout_seconds: int = 900,
26
+ require_protected_revision: bool = True,
27
+ ) -> dict[str, object]:
28
+ """Build one preconfigured-placeholder Console archive without Azure access.
29
+
30
+ A source deployment pins its own verified checkout commit and needs no protected branch, so it
31
+ passes `require_protected_revision=False`; the Console update command keeps the default.
32
+ """
33
+
34
+ if not 60 <= timeout_seconds <= 1800:
35
+ raise ValueError("Console artifact timeout MUST be between 60 and 1800 seconds")
36
+ if not source_root.is_absolute() or not source_root.is_dir() or source_root.is_symlink():
37
+ raise ValueError("Console artifact source MUST be an absolute regular directory")
38
+ if not output_dir.is_absolute() or output_dir.exists() or output_dir.is_symlink():
39
+ raise ValueError("Console artifact output MUST be a new absolute directory")
40
+ parent = _open_private_parent(output_dir)
41
+ os.close(parent)
42
+ source_commit = _resolve_revision(
43
+ source_root,
44
+ revision,
45
+ timeout_seconds=60,
46
+ require_protected_revision=require_protected_revision,
47
+ )
48
+ epoch = _git(
49
+ source_root,
50
+ "show",
51
+ "-s",
52
+ "--format=%ct",
53
+ source_commit,
54
+ timeout_seconds=60,
55
+ ).stdout.strip()
56
+ if not epoch.isdigit():
57
+ raise ValueError("Console artifact source timestamp is invalid")
58
+
59
+ with TemporaryDirectory(prefix=".console-artifact-", dir=output_dir.parent) as temporary:
60
+ temporary_root = Path(temporary)
61
+ temporary_root.chmod(0o700)
62
+ snapshot_archive = temporary_root / "source.tar"
63
+ snapshot = temporary_root / "source"
64
+ snapshot.mkdir(mode=0o700)
65
+ _run(
66
+ (
67
+ "git",
68
+ "-C",
69
+ str(source_root),
70
+ "archive",
71
+ "--format=tar",
72
+ f"--output={snapshot_archive}",
73
+ source_commit,
74
+ ),
75
+ timeout_seconds=120,
76
+ )
77
+ _run(("tar", "-xf", str(snapshot_archive), "-C", str(snapshot)), timeout_seconds=120)
78
+ _run(
79
+ (
80
+ "npm",
81
+ "--prefix",
82
+ str(snapshot / "console"),
83
+ "ci",
84
+ "--ignore-scripts",
85
+ "--no-audit",
86
+ "--no-fund",
87
+ ),
88
+ timeout_seconds=min(timeout_seconds, 600),
89
+ )
90
+ _run(
91
+ ("npm", "--prefix", str(snapshot / "console"), "run", "build:offline"),
92
+ timeout_seconds=timeout_seconds,
93
+ )
94
+ offline = snapshot / "console/dist/offline"
95
+ _run(
96
+ (
97
+ sys.executable,
98
+ str(snapshot / "scripts/deployment/azure/build_manual_studio_artifact.py"),
99
+ str(offline / "manuals"),
100
+ "--base-url",
101
+ MANUAL_STUDIO_PLACEHOLDER_URL,
102
+ "--repo-root",
103
+ str(snapshot),
104
+ ),
105
+ timeout_seconds=timeout_seconds,
106
+ )
107
+ if not (offline / "index.html").is_file() or not (offline / "fdai-config.js").is_file():
108
+ raise ValueError("Console artifact build output is incomplete")
109
+ if not all(
110
+ (offline / "manuals" / name).is_file()
111
+ for name in ("catalog.json", "library.html", "target-architecture.html")
112
+ ):
113
+ raise ValueError("Console Manual Studio artifact build output is incomplete")
114
+ staging = temporary_root / "artifact"
115
+ staging.mkdir(mode=0o700)
116
+ archive = staging / "console.tar.gz"
117
+ _run(
118
+ (
119
+ "tar",
120
+ "--sort=name",
121
+ f"--mtime=@{epoch}",
122
+ "--owner=0",
123
+ "--group=0",
124
+ "--numeric-owner",
125
+ "--transform=s,^offline,dist,",
126
+ "-czf",
127
+ str(archive),
128
+ "-C",
129
+ str(snapshot / "console/dist"),
130
+ "offline",
131
+ ),
132
+ timeout_seconds=120,
133
+ )
134
+ archive.chmod(0o600)
135
+ archive_digest = _regular_digest(archive)
136
+ manifest = {
137
+ "schema_version": "fdai.console-update-artifact.v1",
138
+ "source_commit": source_commit,
139
+ "archive_sha256": archive_digest,
140
+ }
141
+ write_private_output(
142
+ staging / "manifest.json",
143
+ json.dumps(manifest, sort_keys=True, separators=(",", ":")) + "\n",
144
+ )
145
+ os.rename(staging, output_dir)
146
+
147
+ return {
148
+ "schema_version": "fdai.console-update-artifact-build.v1",
149
+ "source_commit": source_commit,
150
+ "archive_sha256": archive_digest,
151
+ "artifact_directory": str(output_dir),
152
+ "azure_mutation_performed": False,
153
+ }
154
+
155
+
156
+ def _resolve_revision(
157
+ source_root: Path,
158
+ revision: str,
159
+ *,
160
+ timeout_seconds: int,
161
+ require_protected_revision: bool,
162
+ ) -> str:
163
+ source_commit = _git(
164
+ source_root,
165
+ "rev-parse",
166
+ "--verify",
167
+ f"{revision}^{{commit}}",
168
+ timeout_seconds=timeout_seconds,
169
+ ).stdout.strip()
170
+ if _COMMIT.fullmatch(source_commit) is None:
171
+ raise ValueError("Console artifact revision is invalid")
172
+ if not require_protected_revision:
173
+ return source_commit
174
+ protected_head = _git(
175
+ source_root,
176
+ "rev-parse",
177
+ "--verify",
178
+ "refs/remotes/origin/main^{commit}",
179
+ timeout_seconds=timeout_seconds,
180
+ ).stdout.strip()
181
+ if _COMMIT.fullmatch(protected_head) is None:
182
+ raise ValueError("protected origin/main revision is unavailable")
183
+ _git(
184
+ source_root,
185
+ "merge-base",
186
+ "--is-ancestor",
187
+ source_commit,
188
+ protected_head,
189
+ timeout_seconds=timeout_seconds,
190
+ )
191
+ return source_commit
192
+
193
+
194
+ def _git(
195
+ source_root: Path, *arguments: str, timeout_seconds: int
196
+ ) -> subprocess.CompletedProcess[str]:
197
+ completed = subprocess.run(
198
+ ("git", "-C", str(source_root), *arguments),
199
+ check=False,
200
+ capture_output=True,
201
+ text=True,
202
+ timeout=timeout_seconds,
203
+ )
204
+ if completed.returncode != 0:
205
+ raise ValueError("Console artifact source could not be verified")
206
+ return completed
207
+
208
+
209
+ def _run(command: tuple[str, ...], *, timeout_seconds: int) -> None:
210
+ completed = subprocess.run(
211
+ command,
212
+ check=False,
213
+ capture_output=True,
214
+ text=True,
215
+ timeout=timeout_seconds,
216
+ )
217
+ if completed.returncode != 0:
218
+ raise ValueError("Console artifact build command failed")
@@ -0,0 +1,214 @@
1
+ """Bind a generic Console build to public Entra and API deployment settings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import re
9
+ import stat
10
+ from pathlib import Path
11
+ from urllib.parse import urlsplit
12
+
13
+ from fdai_deployment_cli.contracts import load_json_object
14
+ from fdai_deployment_cli.offline_kit import _read_regular
15
+ from fdai_deployment_cli.private_output import _open_private_parent
16
+
17
+ CONFIG_FILENAME = "fdai-config.js"
18
+ CONFIG_PLACEHOLDER = b"globalThis.__FDAI_CONSOLE_CONFIG__ = null;\n"
19
+ MANUAL_STUDIO_PLACEHOLDER_URL = "https://manual-studio.invalid/manuals"
20
+ _UUID = r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
21
+ _FIELDS = {
22
+ "schema_version",
23
+ "operator_api_base_url",
24
+ "ingestion_api_base_url",
25
+ "tenant_id",
26
+ "spa_client_id",
27
+ "api_scope",
28
+ }
29
+
30
+
31
+ def render_console_config(settings: bytes) -> bytes:
32
+ """Validate public settings and render an external, authentication-only script.
33
+
34
+ This contains no secret or authorization decision. The browser still obtains
35
+ an Entra token and the APIs independently validate it. Unknown keys, including
36
+ authentication bypass flags, are rejected.
37
+ """
38
+ values = load_json_object(settings, label="Console settings", max_bytes=16_384)
39
+ if set(values) != _FIELDS or values["schema_version"] != "fdai.console-runtime.v1":
40
+ raise ValueError("Console settings fields do not match fdai.console-runtime.v1")
41
+ text_values: dict[str, str] = {}
42
+ for key, value in values.items():
43
+ if not isinstance(value, str) or not value.isascii():
44
+ raise ValueError("Console settings MUST contain only ASCII string values")
45
+ text_values[key] = value
46
+ for field in ("tenant_id", "spa_client_id"):
47
+ value = text_values[field]
48
+ if re.fullmatch(_UUID, value) is None:
49
+ raise ValueError(f"Console {field} MUST be a UUID")
50
+ for field in ("operator_api_base_url", "ingestion_api_base_url"):
51
+ value = text_values[field]
52
+ parsed = urlsplit(value)
53
+ if (
54
+ not value.lower().startswith("https://")
55
+ or any(
56
+ character.isspace() or ord(character) < 32 or ord(character) == 127
57
+ for character in value
58
+ )
59
+ or any(character in value for character in "\\?#")
60
+ or not parsed.hostname
61
+ or parsed.username is not None
62
+ or parsed.password is not None
63
+ or parsed.query
64
+ or parsed.fragment
65
+ ):
66
+ raise ValueError(
67
+ f"Console {field} MUST be HTTPS without credentials, query, or fragment"
68
+ )
69
+ try:
70
+ _ = parsed.port
71
+ except ValueError as exc:
72
+ raise ValueError(f"Console {field} has an invalid port") from exc
73
+ scope = text_values["api_scope"]
74
+ if re.fullmatch(rf"api://{_UUID}/[A-Za-z0-9._-]+", scope) is None:
75
+ raise ValueError("Console api_scope MUST identify an Entra API application scope")
76
+ document = json.dumps(text_values, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
77
+ return f"globalThis.__FDAI_CONSOLE_CONFIG__ = {document};\n".encode("ascii")
78
+
79
+
80
+ def configure_console(
81
+ directory: Path,
82
+ settings: Path,
83
+ *,
84
+ manual_studio_url: str | None = None,
85
+ ) -> dict[str, object]:
86
+ """Replace only the shipped placeholder in a private prebuilt Console directory.
87
+
88
+ Repeating identical settings is a no-op. A different existing configuration
89
+ requires a fresh copy of the original build, preventing an implicit tenant
90
+ switch. No package manager, build process, Azure call, or publication runs.
91
+ """
92
+ settings_parent = _open_private_parent(settings)
93
+ os.close(settings_parent)
94
+ details = settings.lstat()
95
+ if stat.S_IMODE(details.st_mode) != 0o600 or details.st_uid != os.geteuid():
96
+ raise PermissionError("Console settings MUST be a current-UID mode-0600 file")
97
+ rendered = render_console_config(_read_regular(settings, 16_384))
98
+ target = directory / CONFIG_FILENAME
99
+ parent = _open_private_parent(target)
100
+ temporary = ".fdai-config.js.pending"
101
+ created = False
102
+ try:
103
+ current = _read_regular(target, 16_384)
104
+ if current == rendered:
105
+ changed = False
106
+ elif current != CONFIG_PLACEHOLDER:
107
+ raise ValueError(
108
+ "Console config is not the shipped placeholder; use a fresh build copy"
109
+ )
110
+ else:
111
+ descriptor = os.open(
112
+ temporary,
113
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
114
+ 0o600,
115
+ dir_fd=parent,
116
+ )
117
+ created = True
118
+ with os.fdopen(descriptor, "wb") as stream:
119
+ stream.write(rendered)
120
+ stream.flush()
121
+ os.fsync(stream.fileno())
122
+ os.replace(temporary, CONFIG_FILENAME, src_dir_fd=parent, dst_dir_fd=parent)
123
+ created = False
124
+ os.fsync(parent)
125
+ changed = True
126
+ finally:
127
+ if created:
128
+ os.unlink(temporary, dir_fd=parent)
129
+ os.close(parent)
130
+ manual_pages_configured = _configure_manual_studio(directory, manual_studio_url)
131
+ return {
132
+ "schema_version": "fdai.console-configured.v1",
133
+ "runtime_config_digest": hashlib.sha256(rendered).hexdigest(),
134
+ "authentication": "entra",
135
+ "changed": changed,
136
+ "manual_studio_pages_configured": manual_pages_configured,
137
+ "cloud_mutation_performed": False,
138
+ "console_access_verified": False,
139
+ }
140
+
141
+
142
+ def _configure_manual_studio(directory: Path, manual_studio_url: str | None) -> int:
143
+ """Replace only the reserved Manual Studio origin in prebuilt share pages."""
144
+
145
+ manual_root = directory / "manuals"
146
+ if not manual_root.exists():
147
+ return 0
148
+ if manual_studio_url is None:
149
+ raise ValueError("Console Manual Studio URL is required for bundled share pages")
150
+ parsed = urlsplit(manual_studio_url)
151
+ if (
152
+ not manual_studio_url.startswith("https://")
153
+ or not parsed.hostname
154
+ or parsed.username is not None
155
+ or parsed.password is not None
156
+ or parsed.query
157
+ or parsed.fragment
158
+ or "\\" in manual_studio_url
159
+ or any(
160
+ character.isspace() or ord(character) < 32 or ord(character) > 126
161
+ for character in manual_studio_url
162
+ )
163
+ ):
164
+ raise ValueError("Console Manual Studio URL MUST be a safe HTTPS base URL")
165
+ manual_studio_url = manual_studio_url.rstrip("/")
166
+ if manual_studio_url == MANUAL_STUDIO_PLACEHOLDER_URL:
167
+ raise ValueError("Console Manual Studio URL MUST replace the build placeholder")
168
+ details = manual_root.lstat()
169
+ if (
170
+ not stat.S_ISDIR(details.st_mode)
171
+ or manual_root.is_symlink()
172
+ or details.st_uid != os.geteuid()
173
+ ):
174
+ raise ValueError("Console Manual Studio directory is invalid")
175
+ manual_root.chmod(0o700)
176
+ html_files = sorted(manual_root.glob("*.html"))
177
+ if not html_files:
178
+ raise ValueError("Console Manual Studio has no share pages")
179
+ placeholder = MANUAL_STUDIO_PLACEHOLDER_URL.encode("ascii")
180
+ replacement = manual_studio_url.encode("ascii")
181
+ parent = _open_private_parent(manual_root / "catalog.json")
182
+ pending: list[str] = []
183
+ try:
184
+ for path in html_files:
185
+ current = _read_regular(path, 4 * 1024 * 1024)
186
+ if placeholder in current:
187
+ configured = current.replace(placeholder, replacement)
188
+ elif replacement in current:
189
+ continue
190
+ else:
191
+ raise ValueError("Console Manual Studio share page is not configurable")
192
+ temporary = f".{path.name}.pending"
193
+ descriptor = os.open(
194
+ temporary,
195
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
196
+ 0o600,
197
+ dir_fd=parent,
198
+ )
199
+ pending.append(temporary)
200
+ with os.fdopen(descriptor, "wb") as stream:
201
+ stream.write(configured)
202
+ stream.flush()
203
+ os.fsync(stream.fileno())
204
+ for path in html_files:
205
+ temporary = f".{path.name}.pending"
206
+ if temporary in pending:
207
+ os.replace(temporary, path.name, src_dir_fd=parent, dst_dir_fd=parent)
208
+ pending.remove(temporary)
209
+ os.fsync(parent)
210
+ finally:
211
+ for temporary in pending:
212
+ os.unlink(temporary, dir_fd=parent)
213
+ os.close(parent)
214
+ return len(html_files)