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.
- fdai_deployment_cli/__about__.py +3 -0
- fdai_deployment_cli/__init__.py +5 -0
- fdai_deployment_cli/__main__.py +5 -0
- fdai_deployment_cli/acr_publish.py +585 -0
- fdai_deployment_cli/aks_catalog_review.py +137 -0
- fdai_deployment_cli/aks_historical_reconciliation.py +732 -0
- fdai_deployment_cli/aks_job_environment.py +59 -0
- fdai_deployment_cli/aks_job_execution.py +794 -0
- fdai_deployment_cli/aks_kubernetes_defaults.py +66 -0
- fdai_deployment_cli/aks_preflight.py +422 -0
- fdai_deployment_cli/aks_readiness.py +220 -0
- fdai_deployment_cli/aks_service_update.py +180 -0
- fdai_deployment_cli/aks_workload_jobs.py +281 -0
- fdai_deployment_cli/application_state_adoption.py +347 -0
- fdai_deployment_cli/azure_actor.py +61 -0
- fdai_deployment_cli/azure_naming.py +252 -0
- fdai_deployment_cli/azure_retail_prices.py +173 -0
- fdai_deployment_cli/bootstrap_approval.py +99 -0
- fdai_deployment_cli/bootstrap_channel.py +321 -0
- fdai_deployment_cli/bootstrap_executor.py +311 -0
- fdai_deployment_cli/bootstrap_plan.py +237 -0
- fdai_deployment_cli/bootstrap_reconcile.py +402 -0
- fdai_deployment_cli/bootstrap_support.py +138 -0
- fdai_deployment_cli/bootstrap_worker.py +448 -0
- fdai_deployment_cli/bundle.py +352 -0
- fdai_deployment_cli/capacity.py +143 -0
- fdai_deployment_cli/catalog_review_profile.py +306 -0
- fdai_deployment_cli/cli.py +798 -0
- fdai_deployment_cli/cli_help.py +214 -0
- fdai_deployment_cli/cli_parser.py +791 -0
- fdai_deployment_cli/cli_plan.py +450 -0
- fdai_deployment_cli/compiler.py +99 -0
- fdai_deployment_cli/console_artifact.py +218 -0
- fdai_deployment_cli/console_config.py +214 -0
- fdai_deployment_cli/console_update.py +832 -0
- fdai_deployment_cli/contracts.py +307 -0
- fdai_deployment_cli/control_package.py +140 -0
- fdai_deployment_cli/deadline_transport.py +70 -0
- fdai_deployment_cli/deployment_cost.py +131 -0
- fdai_deployment_cli/deployment_deadline.py +28 -0
- fdai_deployment_cli/deployment_kit.py +790 -0
- fdai_deployment_cli/deployment_kit_cache.py +191 -0
- fdai_deployment_cli/deployment_progress.py +648 -0
- fdai_deployment_cli/development_authority_cli.py +56 -0
- fdai_deployment_cli/development_authority_profile.py +319 -0
- fdai_deployment_cli/doctor.py +136 -0
- fdai_deployment_cli/entitlement_preflight.py +68 -0
- fdai_deployment_cli/entitlement_transport.py +49 -0
- fdai_deployment_cli/entra_bootstrap.py +579 -0
- fdai_deployment_cli/entra_control_profile.py +214 -0
- fdai_deployment_cli/entra_graph.py +349 -0
- fdai_deployment_cli/entra_profiles.py +215 -0
- fdai_deployment_cli/entra_source.py +244 -0
- fdai_deployment_cli/execution_bundle.py +478 -0
- fdai_deployment_cli/execution_copies.py +63 -0
- fdai_deployment_cli/foundation_adoption.py +438 -0
- fdai_deployment_cli/foundation_adoption_evidence.py +245 -0
- fdai_deployment_cli/foundation_adoption_host.py +157 -0
- fdai_deployment_cli/foundation_adoption_transport.py +51 -0
- fdai_deployment_cli/foundation_failure.py +72 -0
- fdai_deployment_cli/foundation_image.py +168 -0
- fdai_deployment_cli/foundation_input.py +235 -0
- fdai_deployment_cli/foundation_output.py +154 -0
- fdai_deployment_cli/foundation_plan.py +381 -0
- fdai_deployment_cli/foundation_process.py +64 -0
- fdai_deployment_cli/foundation_progress.py +231 -0
- fdai_deployment_cli/github_actions.py +171 -0
- fdai_deployment_cli/github_apply_artifact.py +259 -0
- fdai_deployment_cli/github_artifact_io.py +48 -0
- fdai_deployment_cli/github_dispatch.py +248 -0
- fdai_deployment_cli/github_plan_artifact.py +181 -0
- fdai_deployment_cli/github_provider_artifact.py +170 -0
- fdai_deployment_cli/github_transport.py +46 -0
- fdai_deployment_cli/github_workflow_values.py +312 -0
- fdai_deployment_cli/hub_image_deploy.py +354 -0
- fdai_deployment_cli/hub_image_stage.py +162 -0
- fdai_deployment_cli/hub_image_trust.py +281 -0
- fdai_deployment_cli/hub_ownership.py +161 -0
- fdai_deployment_cli/hub_workload_handoff.py +264 -0
- fdai_deployment_cli/hub_workload_handoff_inputs.py +227 -0
- fdai_deployment_cli/identity_profile.py +227 -0
- fdai_deployment_cli/image_targets.py +197 -0
- fdai_deployment_cli/installation_scope.py +280 -0
- fdai_deployment_cli/license.py +311 -0
- fdai_deployment_cli/license_issue.py +239 -0
- fdai_deployment_cli/lifecycle_authorization.py +252 -0
- fdai_deployment_cli/lifecycle_commands.py +173 -0
- fdai_deployment_cli/lifecycle_configuration.py +722 -0
- fdai_deployment_cli/lifecycle_configuration_artifact.py +45 -0
- fdai_deployment_cli/lifecycle_configuration_cli.py +324 -0
- fdai_deployment_cli/lifecycle_configuration_document.py +74 -0
- fdai_deployment_cli/lifecycle_configuration_entities.py +146 -0
- fdai_deployment_cli/lifecycle_configuration_package.py +492 -0
- fdai_deployment_cli/lifecycle_configuration_publication.py +277 -0
- fdai_deployment_cli/lifecycle_configuration_publication_cli.py +191 -0
- fdai_deployment_cli/lifecycle_configuration_sealing.py +127 -0
- fdai_deployment_cli/lifecycle_enrollment.py +45 -0
- fdai_deployment_cli/lifecycle_infrastructure_agent.py +174 -0
- fdai_deployment_cli/lifecycle_installation_binding.py +65 -0
- fdai_deployment_cli/lifecycle_native.py +247 -0
- fdai_deployment_cli/lifecycle_native_reconciliation.py +568 -0
- fdai_deployment_cli/lifecycle_native_transition.py +339 -0
- fdai_deployment_cli/lifecycle_ownership_exclusion.py +78 -0
- fdai_deployment_cli/lifecycle_plan.py +800 -0
- fdai_deployment_cli/lifecycle_promotion.py +185 -0
- fdai_deployment_cli/lifecycle_release.py +308 -0
- fdai_deployment_cli/lifecycle_self_upgrade.py +202 -0
- fdai_deployment_cli/lifecycle_upgrade_bundle.py +176 -0
- fdai_deployment_cli/notification_recipients_input.py +222 -0
- fdai_deployment_cli/observer_artifact.py +106 -0
- fdai_deployment_cli/oci_archive.py +353 -0
- fdai_deployment_cli/offline_kit.py +797 -0
- fdai_deployment_cli/offline_prepare.py +135 -0
- fdai_deployment_cli/operational_evidence_verifier_input.py +575 -0
- fdai_deployment_cli/plan_input.py +101 -0
- fdai_deployment_cli/private_output.py +192 -0
- fdai_deployment_cli/profile.py +70 -0
- fdai_deployment_cli/progress.py +183 -0
- fdai_deployment_cli/readiness.py +286 -0
- fdai_deployment_cli/run_command_receiver.py +418 -0
- fdai_deployment_cli/runtime_build.py +386 -0
- fdai_deployment_cli/runtime_profile.py +287 -0
- fdai_deployment_cli/runtime_release.py +745 -0
- fdai_deployment_cli/runtime_stage.py +82 -0
- fdai_deployment_cli/runtime_support_installation.py +215 -0
- fdai_deployment_cli/simulation.py +80 -0
- fdai_deployment_cli/source_application_inputs.py +397 -0
- fdai_deployment_cli/source_azure.py +586 -0
- fdai_deployment_cli/source_change_cli.py +107 -0
- fdai_deployment_cli/source_change_parser.py +75 -0
- fdai_deployment_cli/source_change_plan.py +181 -0
- fdai_deployment_cli/source_changed_update.py +196 -0
- fdai_deployment_cli/source_deploy.py +170 -0
- fdai_deployment_cli/source_foundation.py +174 -0
- fdai_deployment_cli/source_host_tools.py +163 -0
- fdai_deployment_cli/source_image_stage.py +400 -0
- fdai_deployment_cli/source_input.py +173 -0
- fdai_deployment_cli/source_projection.py +164 -0
- fdai_deployment_cli/source_receiver.py +76 -0
- fdai_deployment_cli/source_recovery.py +264 -0
- fdai_deployment_cli/source_runtime.py +81 -0
- fdai_deployment_cli/source_runtime_support.py +368 -0
- fdai_deployment_cli/source_service_update.py +726 -0
- fdai_deployment_cli/source_service_update_cli.py +47 -0
- fdai_deployment_cli/source_snapshot.py +178 -0
- fdai_deployment_cli/source_teardown.py +574 -0
- fdai_deployment_cli/source_terraform.py +85 -0
- fdai_deployment_cli/source_transport.py +399 -0
- fdai_deployment_cli/standalone_aks_inventory.py +558 -0
- fdai_deployment_cli/standalone_aks_job_execution.py +769 -0
- fdai_deployment_cli/standalone_aks_nodepool_guard.py +141 -0
- fdai_deployment_cli/standalone_application.py +977 -0
- fdai_deployment_cli/standalone_application_completion.py +151 -0
- fdai_deployment_cli/standalone_application_input_recovery.py +107 -0
- fdai_deployment_cli/standalone_application_recovery.py +116 -0
- fdai_deployment_cli/standalone_approval_input.py +24 -0
- fdai_deployment_cli/standalone_catalog_checkpoint.py +122 -0
- fdai_deployment_cli/standalone_catalog_review.py +251 -0
- fdai_deployment_cli/standalone_checkpoint_failure.py +127 -0
- fdai_deployment_cli/standalone_console_publish.py +174 -0
- fdai_deployment_cli/standalone_database_principals.py +41 -0
- fdai_deployment_cli/standalone_deploy.py +638 -0
- fdai_deployment_cli/standalone_development_authority.py +166 -0
- fdai_deployment_cli/standalone_foundation_adoption.py +87 -0
- fdai_deployment_cli/standalone_foundation_transition.py +767 -0
- fdai_deployment_cli/standalone_host.py +4608 -0
- fdai_deployment_cli/standalone_host_rebind.py +226 -0
- fdai_deployment_cli/standalone_host_residual.py +104 -0
- fdai_deployment_cli/standalone_host_state.py +145 -0
- fdai_deployment_cli/standalone_host_values.py +563 -0
- fdai_deployment_cli/standalone_kubernetes_tools.py +100 -0
- fdai_deployment_cli/standalone_license_installation.py +177 -0
- fdai_deployment_cli/standalone_management_egress.py +83 -0
- fdai_deployment_cli/standalone_migration_evidence.py +38 -0
- fdai_deployment_cli/standalone_operational_evidence.py +356 -0
- fdai_deployment_cli/standalone_planned_outputs.py +147 -0
- fdai_deployment_cli/standalone_private_access_probe.py +100 -0
- fdai_deployment_cli/standalone_product_profile.py +110 -0
- fdai_deployment_cli/standalone_remote_prepare.py +442 -0
- fdai_deployment_cli/standalone_residual_apply.py +540 -0
- fdai_deployment_cli/standalone_residual_successor.py +431 -0
- fdai_deployment_cli/standalone_review.py +266 -0
- fdai_deployment_cli/standalone_service_update_finalization.py +116 -0
- fdai_deployment_cli/standalone_service_update_recovery.py +277 -0
- fdai_deployment_cli/standalone_stage_targets.py +325 -0
- fdai_deployment_cli/standalone_status.py +67 -0
- fdai_deployment_cli/standalone_terraform_environment.py +237 -0
- fdai_deployment_cli/standalone_transfer_cleanup.py +237 -0
- fdai_deployment_cli/standalone_trial_activation.py +193 -0
- fdai_deployment_cli/state.py +434 -0
- fdai_deployment_cli/state_handoff.py +253 -0
- fdai_deployment_cli/status_projection.py +97 -0
- fdai_deployment_cli/support_install.py +257 -0
- fdai_deployment_cli/target.py +46 -0
- fdai_deployment_cli/trust/capabilities.json +62 -0
- fdai_deployment_cli/trust/deployment-bundle-root.pub +3 -0
- fdai_deployment_cli/trust/deployment-release-root.pub +3 -0
- fdai_deployment_cli/trust/upstream-signing-key.pub +3 -0
- fdai_deployment_cli/trust_roots.py +50 -0
- fdai_deployment_cli-0.1.2.dist-info/METADATA +112 -0
- fdai_deployment_cli-0.1.2.dist-info/RECORD +203 -0
- fdai_deployment_cli-0.1.2.dist-info/WHEEL +4 -0
- 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)
|