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,311 @@
|
|
|
1
|
+
"""Customer-local adapter over the existing AKS source and signed-image coordinator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
from urllib.parse import urlsplit
|
|
15
|
+
from uuid import UUID
|
|
16
|
+
|
|
17
|
+
from fdai_deployment_cli.bootstrap_approval import approval_source
|
|
18
|
+
from fdai_deployment_cli.bootstrap_channel import _read, _write, prepare_directory
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BootstrapBlocked(ValueError):
|
|
22
|
+
"""A safe, actionable prerequisite failure, with no raw process output."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BootstrapExecutor:
|
|
26
|
+
"""One bounded first-install precursor, not a lifecycle reconciliation loop."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
area: Path,
|
|
31
|
+
decide: Callable[[dict[str, object]], dict[str, object]],
|
|
32
|
+
*,
|
|
33
|
+
run: Callable[..., Any] = subprocess.run,
|
|
34
|
+
planner: Callable[..., Any] | None = None,
|
|
35
|
+
hub_source: Callable[..., Any] | None = None,
|
|
36
|
+
):
|
|
37
|
+
self.area, self.decide, self.run = area, decide, run
|
|
38
|
+
self.planner, self.hub_source = planner, hub_source
|
|
39
|
+
prepare_directory(area)
|
|
40
|
+
self.azure = area / "azure"
|
|
41
|
+
prepare_directory(self.azure)
|
|
42
|
+
self.environment = {
|
|
43
|
+
key: value
|
|
44
|
+
for key, value in os.environ.items()
|
|
45
|
+
if key
|
|
46
|
+
in {
|
|
47
|
+
"HOME",
|
|
48
|
+
"PATH",
|
|
49
|
+
"LANG",
|
|
50
|
+
"SSL_CERT_FILE",
|
|
51
|
+
"REQUESTS_CA_BUNDLE",
|
|
52
|
+
"DISPLAY",
|
|
53
|
+
"WAYLAND_DISPLAY",
|
|
54
|
+
"XDG_RUNTIME_DIR",
|
|
55
|
+
"DBUS_SESSION_BUS_ADDRESS",
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
self.environment.update(
|
|
59
|
+
AZURE_CONFIG_DIR=str(self.azure),
|
|
60
|
+
AZURE_CORE_COLLECT_TELEMETRY="false",
|
|
61
|
+
AZURE_CORE_ONLY_SHOW_ERRORS="true",
|
|
62
|
+
AZURE_CORE_LOGIN_EXPERIENCE_V2="off",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def _az(self, *args: str, timeout: int = 60) -> object:
|
|
66
|
+
if self.run is subprocess.run and shutil.which("az") is None:
|
|
67
|
+
raise BootstrapBlocked(
|
|
68
|
+
"Install Azure CLI on this customer machine, then retry sign-in."
|
|
69
|
+
)
|
|
70
|
+
result = self.run(
|
|
71
|
+
["az", *args, "--output", "json", "--only-show-errors"],
|
|
72
|
+
env=self.environment,
|
|
73
|
+
stdin=subprocess.DEVNULL,
|
|
74
|
+
capture_output=True,
|
|
75
|
+
text=True,
|
|
76
|
+
timeout=timeout,
|
|
77
|
+
check=False,
|
|
78
|
+
)
|
|
79
|
+
if result.returncode:
|
|
80
|
+
raise BootstrapBlocked(
|
|
81
|
+
"Azure CLI operation failed. Sign in locally and inspect private diagnostics."
|
|
82
|
+
)
|
|
83
|
+
if args[0] == "login":
|
|
84
|
+
# Login output can contain identity and authorization data; never retain it.
|
|
85
|
+
return None
|
|
86
|
+
try:
|
|
87
|
+
return json.loads(result.stdout or "null")
|
|
88
|
+
except ValueError as exc:
|
|
89
|
+
raise BootstrapBlocked(
|
|
90
|
+
"Azure CLI returned an invalid response; no deployment was started."
|
|
91
|
+
) from exc
|
|
92
|
+
|
|
93
|
+
def accounts(self) -> list[dict[str, str]]:
|
|
94
|
+
result = self._az(
|
|
95
|
+
"account",
|
|
96
|
+
"list",
|
|
97
|
+
"--query",
|
|
98
|
+
"[?state=='Enabled'].{id:id,name:name,tenantId:tenantId,state:state}",
|
|
99
|
+
)
|
|
100
|
+
if not isinstance(result, list) or len(result) > 100:
|
|
101
|
+
raise BootstrapBlocked("Azure subscription listing is invalid or exceeds 100 entries.")
|
|
102
|
+
accounts = []
|
|
103
|
+
for item in result:
|
|
104
|
+
if not isinstance(item, dict) or item.get("state") != "Enabled":
|
|
105
|
+
continue
|
|
106
|
+
try:
|
|
107
|
+
account_id, tenant = str(UUID(item["id"])), str(UUID(item["tenantId"]))
|
|
108
|
+
except (ValueError, KeyError, TypeError, AttributeError) as exc:
|
|
109
|
+
raise BootstrapBlocked(
|
|
110
|
+
"Azure subscription listing contains invalid identifiers."
|
|
111
|
+
) from exc
|
|
112
|
+
name = item.get("name")
|
|
113
|
+
if not isinstance(name, str) or len(name) > 256 or any(ord(char) < 32 for char in name):
|
|
114
|
+
raise BootstrapBlocked(
|
|
115
|
+
"Azure subscription listing contains an invalid display name."
|
|
116
|
+
)
|
|
117
|
+
accounts.append({"id": account_id, "name": name, "tenant_id": tenant})
|
|
118
|
+
return accounts
|
|
119
|
+
|
|
120
|
+
def _target(self, subscription: str, tenant: str | None = None) -> dict[str, str]:
|
|
121
|
+
value = self._az(
|
|
122
|
+
"account",
|
|
123
|
+
"show",
|
|
124
|
+
"--query",
|
|
125
|
+
"{id:id,tenantId:tenantId,user:{type:user.type}}",
|
|
126
|
+
)
|
|
127
|
+
if (
|
|
128
|
+
not isinstance(value, dict)
|
|
129
|
+
or value.get("id", "").lower() != subscription.lower()
|
|
130
|
+
or not isinstance(value.get("user"), dict)
|
|
131
|
+
or value["user"].get("type") != "user"
|
|
132
|
+
or (tenant is not None and value.get("tenantId") != tenant)
|
|
133
|
+
):
|
|
134
|
+
raise BootstrapBlocked(
|
|
135
|
+
"Azure human identity or selected subscription changed. No approval was granted."
|
|
136
|
+
)
|
|
137
|
+
try:
|
|
138
|
+
tenant_id = str(UUID(value["tenantId"]))
|
|
139
|
+
except (ValueError, KeyError, TypeError, AttributeError) as exc:
|
|
140
|
+
raise BootstrapBlocked("Azure tenant identity is unavailable.") from exc
|
|
141
|
+
return {"subscription_id": subscription, "tenant_id": tenant_id}
|
|
142
|
+
|
|
143
|
+
def execute(self, action: str, payload: dict[str, object]) -> dict[str, object]:
|
|
144
|
+
if action == "login":
|
|
145
|
+
self._az("login", timeout=600)
|
|
146
|
+
return {
|
|
147
|
+
"accounts": self.accounts(),
|
|
148
|
+
"message": "Signed in. Select the intended subscription.",
|
|
149
|
+
}
|
|
150
|
+
if action == "accounts":
|
|
151
|
+
return {
|
|
152
|
+
"accounts": self.accounts(),
|
|
153
|
+
"message": "Available Azure subscriptions refreshed.",
|
|
154
|
+
}
|
|
155
|
+
if action == "inspect":
|
|
156
|
+
retained = _read(self.area, "configuration.json")
|
|
157
|
+
receipt = _read(self.area, "acceptance.json")
|
|
158
|
+
if retained:
|
|
159
|
+
self._target(str(retained["subscription_id"]), str(retained["tenant_id"]))
|
|
160
|
+
# A saved success is historical, not an independent current health observation.
|
|
161
|
+
return {
|
|
162
|
+
"message": "Retained installation exists. Explicitly resume to refresh plans and health."
|
|
163
|
+
if retained
|
|
164
|
+
else "No first-install configuration has been retained.",
|
|
165
|
+
"retained_acceptance": receipt.get("deployment_ready") is True,
|
|
166
|
+
}
|
|
167
|
+
return self._install(payload, resume=action == "resume")
|
|
168
|
+
|
|
169
|
+
def _install(self, payload: dict[str, object], *, resume: bool) -> dict[str, object]:
|
|
170
|
+
retained = _read(self.area, "configuration.json")
|
|
171
|
+
if resume:
|
|
172
|
+
if not retained:
|
|
173
|
+
raise BootstrapBlocked("No retained installation exists to resume.")
|
|
174
|
+
config = retained
|
|
175
|
+
self._target(str(config["subscription_id"]), str(config["tenant_id"]))
|
|
176
|
+
else:
|
|
177
|
+
if retained:
|
|
178
|
+
raise BootstrapBlocked(
|
|
179
|
+
"An installation is retained. Use resume without replacing its inputs."
|
|
180
|
+
)
|
|
181
|
+
config = dict(payload)
|
|
182
|
+
subscription = str(config["subscription_id"])
|
|
183
|
+
accounts = self.accounts()
|
|
184
|
+
if not any(item["id"] == subscription.lower() for item in accounts):
|
|
185
|
+
raise BootstrapBlocked(
|
|
186
|
+
"Selected subscription is not available to the signed-in user."
|
|
187
|
+
)
|
|
188
|
+
self._az("account", "set", "--subscription", subscription)
|
|
189
|
+
config.update(self._target(subscription))
|
|
190
|
+
_write(self.area, config, "configuration.json")
|
|
191
|
+
if (self.area / "native-ownership.json").exists():
|
|
192
|
+
raise BootstrapBlocked(
|
|
193
|
+
"Native lifecycle ownership was accepted; Bootstrap cannot mutate this installation."
|
|
194
|
+
)
|
|
195
|
+
mode = config["artifact_source"]
|
|
196
|
+
selection = None
|
|
197
|
+
if mode == "hub":
|
|
198
|
+
from fdai_deployment_cli.bootstrap_support import resolve_hub_source
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
source, selection = (self.hub_source or resolve_hub_source)(
|
|
202
|
+
self.area,
|
|
203
|
+
str(config["hub_public_key"]),
|
|
204
|
+
config.get("source_root"),
|
|
205
|
+
)
|
|
206
|
+
except (ValueError, OSError) as exc:
|
|
207
|
+
raise BootstrapBlocked(
|
|
208
|
+
"Signed Hub selection or pinned support source is unavailable. "
|
|
209
|
+
"Verify independent Release trust and support compatibility; no source-build fallback."
|
|
210
|
+
) from exc
|
|
211
|
+
else:
|
|
212
|
+
source = Path(str(config["source_root"])).absolute()
|
|
213
|
+
from fdai_deployment_cli.source_input import inspect_source
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
snapshot = inspect_source(source, expected_commit=config.get("source_commit"))
|
|
217
|
+
except (ValueError, OSError) as exc:
|
|
218
|
+
raise BootstrapBlocked(
|
|
219
|
+
"Provide the same clean committed FDAI checkout at the retained source root. "
|
|
220
|
+
"Source revision changes cannot resume this installation."
|
|
221
|
+
) from exc
|
|
222
|
+
scripts = source / "scripts/deployment/azure"
|
|
223
|
+
sys.path.insert(0, str(scripts))
|
|
224
|
+
try:
|
|
225
|
+
prompt = importlib.import_module("genesis_approval_prompt")
|
|
226
|
+
if (
|
|
227
|
+
getattr(prompt, "BOOTSTRAP_APPROVAL_PROTOCOL", None) != 1
|
|
228
|
+
or Path(str(prompt.__file__)).resolve().parent != scripts.resolve()
|
|
229
|
+
):
|
|
230
|
+
raise BootstrapBlocked(
|
|
231
|
+
"Selected support source lacks the Bootstrap approval adapter. "
|
|
232
|
+
"Use compatible pinned support source; no terminal approval fallback is allowed."
|
|
233
|
+
)
|
|
234
|
+
finally:
|
|
235
|
+
sys.path.remove(str(scripts))
|
|
236
|
+
config.update(source_root=str(source), source_commit=snapshot.commit)
|
|
237
|
+
_write(self.area, config, "configuration.json")
|
|
238
|
+
from fdai_deployment_cli.runtime_profile import RuntimeDeploymentProfile
|
|
239
|
+
from fdai_deployment_cli.source_azure import plan_source_installation
|
|
240
|
+
|
|
241
|
+
profile = RuntimeDeploymentProfile.create(
|
|
242
|
+
runtime_platform="aks",
|
|
243
|
+
database_placement="postgres-flex",
|
|
244
|
+
product_add_ons=("read-only-console", "enterprise-identity-governance"),
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
def exact_decision(review: dict[str, object]) -> dict[str, object]:
|
|
248
|
+
self._target(str(config["subscription_id"]), str(config["tenant_id"]))
|
|
249
|
+
if (self.area / "native-ownership.json").exists():
|
|
250
|
+
raise BootstrapBlocked("Native lifecycle ownership superseded Bootstrap.")
|
|
251
|
+
result = self.decide(review)
|
|
252
|
+
self._target(str(config["subscription_id"]), str(config["tenant_id"]))
|
|
253
|
+
return result
|
|
254
|
+
|
|
255
|
+
original_environment = dict(os.environ)
|
|
256
|
+
try:
|
|
257
|
+
os.environ.clear()
|
|
258
|
+
os.environ.update(self.environment)
|
|
259
|
+
with approval_source(exact_decision):
|
|
260
|
+
result = (self.planner or plan_source_installation)(
|
|
261
|
+
source_root=source,
|
|
262
|
+
work_dir=self.area / "deployment",
|
|
263
|
+
runtime_profile=profile,
|
|
264
|
+
region=str(config["region"]),
|
|
265
|
+
monthly_cost_ceiling=int(config["monthly_cost_ceiling"]),
|
|
266
|
+
timeout_seconds=14400,
|
|
267
|
+
hub_selection=selection,
|
|
268
|
+
)
|
|
269
|
+
finally:
|
|
270
|
+
os.environ.clear()
|
|
271
|
+
os.environ.update(original_environment)
|
|
272
|
+
self._target(str(config["subscription_id"]), str(config["tenant_id"]))
|
|
273
|
+
ready = result.get("deployment_ready") is True
|
|
274
|
+
console = result.get("console_url") if ready else None
|
|
275
|
+
if ready and console is None and result.get("browser_access_verified") is True:
|
|
276
|
+
from fdai_deployment_cli.contracts import canonical_digest
|
|
277
|
+
|
|
278
|
+
publication = _read(
|
|
279
|
+
self.area / "deployment/foundation", "console-publication-receipt.json"
|
|
280
|
+
)
|
|
281
|
+
digest = publication.pop("receipt_digest", None)
|
|
282
|
+
if (
|
|
283
|
+
digest != result.get("console_publication_receipt_digest")
|
|
284
|
+
or canonical_digest(publication) != digest
|
|
285
|
+
):
|
|
286
|
+
raise BootstrapBlocked(
|
|
287
|
+
"Console publication receipt differs from independent acceptance."
|
|
288
|
+
)
|
|
289
|
+
console = publication.get("console_origin")
|
|
290
|
+
if isinstance(console, str):
|
|
291
|
+
parsed = urlsplit(console)
|
|
292
|
+
if (
|
|
293
|
+
parsed.scheme != "https"
|
|
294
|
+
or not parsed.hostname
|
|
295
|
+
or parsed.username
|
|
296
|
+
or parsed.password
|
|
297
|
+
or parsed.query
|
|
298
|
+
or parsed.fragment
|
|
299
|
+
):
|
|
300
|
+
console = None
|
|
301
|
+
else:
|
|
302
|
+
console = None
|
|
303
|
+
safe: dict[str, object] = {
|
|
304
|
+
"deployment_ready": ready,
|
|
305
|
+
"console_url": console,
|
|
306
|
+
"message": "Independent health and zero-change acceptance completed."
|
|
307
|
+
if ready
|
|
308
|
+
else "Installation is not accepted. Inspect retained private evidence before resuming.",
|
|
309
|
+
}
|
|
310
|
+
_write(self.area, safe, "acceptance.json")
|
|
311
|
+
return safe
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Sealed intent and sanitized observations for Azure bootstrap reconciliation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
|
|
11
|
+
from fdai_deployment_cli.contracts import ProvisionProfile, canonical_digest
|
|
12
|
+
|
|
13
|
+
FOUNDATION_PROVIDER_NAMESPACES = (
|
|
14
|
+
"Microsoft.Authorization",
|
|
15
|
+
"Microsoft.Compute",
|
|
16
|
+
"Microsoft.DevTestLab",
|
|
17
|
+
"Microsoft.EventGrid",
|
|
18
|
+
"Microsoft.ManagedIdentity",
|
|
19
|
+
"Microsoft.Network",
|
|
20
|
+
"Microsoft.Resources",
|
|
21
|
+
"Microsoft.Storage",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_RESOURCE_GROUP_NAME = re.compile(r"^[A-Za-z0-9._()\-]{1,90}$")
|
|
25
|
+
_STORAGE_ACCOUNT_NAME = re.compile(r"^[a-z0-9]{3,24}$")
|
|
26
|
+
_SHA = re.compile(r"^[0-9a-f]{40}$")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Classification(StrEnum):
|
|
30
|
+
"""Disposition of one read-only bootstrap observation."""
|
|
31
|
+
|
|
32
|
+
COMPATIBLE = "compatible"
|
|
33
|
+
MISSING = "missing"
|
|
34
|
+
CONFLICT = "conflict"
|
|
35
|
+
INDETERMINATE = "indeterminate"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class BootstrapObservation:
|
|
40
|
+
"""Sanitized classification that contains no Azure resource identifiers."""
|
|
41
|
+
|
|
42
|
+
entry_id: str
|
|
43
|
+
classification: Classification
|
|
44
|
+
reason_code: str
|
|
45
|
+
|
|
46
|
+
def to_mapping(self) -> dict[str, str]:
|
|
47
|
+
"""Return canonical observation data."""
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
"entry_id": self.entry_id,
|
|
51
|
+
"classification": self.classification.value,
|
|
52
|
+
"reason_code": self.reason_code,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class BootstrapReconcileResult:
|
|
58
|
+
"""Sealed bootstrap intent plus separate time-bound observations."""
|
|
59
|
+
|
|
60
|
+
intent: Mapping[str, object]
|
|
61
|
+
observations: tuple[BootstrapObservation, ...]
|
|
62
|
+
created_at: str
|
|
63
|
+
expires_at: str
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def plan_digest(self) -> str:
|
|
67
|
+
"""Return the replay-stable digest of bootstrap intent only."""
|
|
68
|
+
|
|
69
|
+
return canonical_digest(self.intent)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def observation_digest(self) -> str:
|
|
73
|
+
"""Return the digest of the time-bound observed state."""
|
|
74
|
+
|
|
75
|
+
return canonical_digest(
|
|
76
|
+
{
|
|
77
|
+
"created_at": self.created_at,
|
|
78
|
+
"expires_at": self.expires_at,
|
|
79
|
+
"observations": [item.to_mapping() for item in self.observations],
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def blockers(self) -> tuple[str, ...]:
|
|
85
|
+
"""Return conflicts and unknown state that block approval."""
|
|
86
|
+
|
|
87
|
+
return tuple(
|
|
88
|
+
item.reason_code
|
|
89
|
+
for item in self.observations
|
|
90
|
+
if item.classification in {Classification.CONFLICT, Classification.INDETERMINATE}
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def to_mapping(self) -> dict[str, object]:
|
|
94
|
+
"""Return a private artifact without raw tenant or subscription identifiers."""
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
"schema_version": "fdai.bootstrap-reconcile-plan.v1",
|
|
98
|
+
"state": "review" if not self.blockers else "incomplete",
|
|
99
|
+
"plan": dict(self.intent),
|
|
100
|
+
"plan_digest": self.plan_digest,
|
|
101
|
+
"observation": {
|
|
102
|
+
"created_at": self.created_at,
|
|
103
|
+
"expires_at": self.expires_at,
|
|
104
|
+
"digest": self.observation_digest,
|
|
105
|
+
"entries": [item.to_mapping() for item in self.observations],
|
|
106
|
+
},
|
|
107
|
+
"reason_codes": list(self.blockers),
|
|
108
|
+
"mutation_performed": False,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def build_intent(
|
|
113
|
+
*,
|
|
114
|
+
profile: ProvisionProfile,
|
|
115
|
+
source_commit: str,
|
|
116
|
+
ops_resource_group: str,
|
|
117
|
+
app_resource_group: str,
|
|
118
|
+
state_storage_account: str,
|
|
119
|
+
) -> dict[str, object]:
|
|
120
|
+
"""Build deterministic foundation intent without live observations."""
|
|
121
|
+
|
|
122
|
+
operations = [
|
|
123
|
+
{
|
|
124
|
+
"entry_id": f"provider.{namespace.casefold()}",
|
|
125
|
+
"action": "ensure_registered",
|
|
126
|
+
"postcondition": "provider.registered",
|
|
127
|
+
"rollback_ref": "rollback.provider-registration",
|
|
128
|
+
}
|
|
129
|
+
for namespace in FOUNDATION_PROVIDER_NAMESPACES
|
|
130
|
+
]
|
|
131
|
+
operations.extend(
|
|
132
|
+
(
|
|
133
|
+
{
|
|
134
|
+
"entry_id": "ops-resource-group",
|
|
135
|
+
"action": "ensure",
|
|
136
|
+
"postcondition": "resource-group.compatible",
|
|
137
|
+
"rollback_ref": "rollback.ops-resource-group",
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
"entry_id": "application-resource-group",
|
|
141
|
+
"action": "ensure",
|
|
142
|
+
"postcondition": "resource-group.compatible",
|
|
143
|
+
"rollback_ref": "rollback.application-resource-group",
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
"entry_id": "state-storage",
|
|
147
|
+
"action": "ensure",
|
|
148
|
+
"postcondition": "state-storage.private-keyless-versioned",
|
|
149
|
+
"rollback_ref": "rollback.state-storage",
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
"entry_id": "ops-network",
|
|
153
|
+
"action": "ensure",
|
|
154
|
+
"postcondition": "ops-network.private-runner-ready",
|
|
155
|
+
"rollback_ref": "rollback.ops-network",
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
"entry_id": "deploy-identity",
|
|
159
|
+
"action": "ensure",
|
|
160
|
+
"postcondition": "deploy-identity.roles-effective",
|
|
161
|
+
"rollback_ref": "rollback.deploy-identity",
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
"entry_id": "runner",
|
|
165
|
+
"action": "ensure",
|
|
166
|
+
"postcondition": "runner.attested",
|
|
167
|
+
"rollback_ref": "rollback.runner",
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
"entry_id": "state-containers",
|
|
171
|
+
"action": "ensure",
|
|
172
|
+
"postcondition": "state-containers.private",
|
|
173
|
+
"rollback_ref": "rollback.state-containers",
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
"entry_id": "state-handoff",
|
|
177
|
+
"action": "verify",
|
|
178
|
+
"postcondition": "state-handoff.zero-change",
|
|
179
|
+
"rollback_ref": "rollback.state-handoff",
|
|
180
|
+
},
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
return {
|
|
184
|
+
"schema_version": "fdai.bootstrap-reconcile-intent.v1",
|
|
185
|
+
"source_commit": source_commit,
|
|
186
|
+
"profile_digest": canonical_digest(profile.to_mapping()),
|
|
187
|
+
"target_binding": profile.target_binding,
|
|
188
|
+
"environment": profile.environment,
|
|
189
|
+
"region": profile.region,
|
|
190
|
+
"resource_names": {
|
|
191
|
+
"ops_resource_group": ops_resource_group,
|
|
192
|
+
"application_resource_group": app_resource_group,
|
|
193
|
+
"state_storage_account": state_storage_account,
|
|
194
|
+
},
|
|
195
|
+
"state_storage_posture": {
|
|
196
|
+
"kind": "StorageV2",
|
|
197
|
+
"sku": "Standard_LRS",
|
|
198
|
+
"minimum_tls_version": "TLS1_2",
|
|
199
|
+
"public_network_access": "Disabled",
|
|
200
|
+
"shared_key_access": False,
|
|
201
|
+
"blob_public_access": False,
|
|
202
|
+
"cross_tenant_replication": False,
|
|
203
|
+
"blob_versioning": True,
|
|
204
|
+
"blob_delete_retention": True,
|
|
205
|
+
"container_delete_retention": True,
|
|
206
|
+
"containers": ["tfstate", "deployment-plans"],
|
|
207
|
+
},
|
|
208
|
+
"provider_namespaces": list(FOUNDATION_PROVIDER_NAMESPACES),
|
|
209
|
+
"operations": operations,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def validate_inputs(
|
|
214
|
+
*,
|
|
215
|
+
source_commit: str,
|
|
216
|
+
ops_resource_group: str,
|
|
217
|
+
app_resource_group: str,
|
|
218
|
+
state_storage_account: str,
|
|
219
|
+
ttl_seconds: int,
|
|
220
|
+
) -> None:
|
|
221
|
+
"""Reject names and bounds that Azure cannot safely apply later."""
|
|
222
|
+
|
|
223
|
+
if _SHA.fullmatch(source_commit) is None:
|
|
224
|
+
raise ValueError("source_commit MUST be a lowercase 40-character SHA")
|
|
225
|
+
for value in (ops_resource_group, app_resource_group):
|
|
226
|
+
if _RESOURCE_GROUP_NAME.fullmatch(value) is None or value.endswith("."):
|
|
227
|
+
raise ValueError("Azure resource group name is invalid")
|
|
228
|
+
if _STORAGE_ACCOUNT_NAME.fullmatch(state_storage_account) is None:
|
|
229
|
+
raise ValueError("Azure storage account name is invalid")
|
|
230
|
+
if not 60 <= ttl_seconds <= 3600:
|
|
231
|
+
raise ValueError("bootstrap plan ttl MUST be from 60 through 3600 seconds")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def timestamp(moment: datetime) -> str:
|
|
235
|
+
"""Render one canonical UTC timestamp."""
|
|
236
|
+
|
|
237
|
+
return moment.astimezone(UTC).isoformat().replace("+00:00", "Z")
|