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,648 @@
|
|
|
1
|
+
"""Presentation-only activity stream for the local standalone deployment coordinator.
|
|
2
|
+
|
|
3
|
+
The context-local display never writes operational evidence or grants authority. Callers advance
|
|
4
|
+
phases only after their existing checks pass, and explicitly confirm the final ready result.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from contextvars import ContextVar, Token
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from types import TracebackType
|
|
18
|
+
from typing import Literal, Self
|
|
19
|
+
|
|
20
|
+
from rich.console import Console, Group
|
|
21
|
+
from rich.live import Live
|
|
22
|
+
from rich.spinner import Spinner
|
|
23
|
+
from rich.table import Table
|
|
24
|
+
from rich.text import Text
|
|
25
|
+
|
|
26
|
+
from fdai_deployment_cli.contracts import canonical_digest
|
|
27
|
+
from fdai_deployment_cli.foundation_progress import FoundationCheckpoint
|
|
28
|
+
|
|
29
|
+
StageName = Literal[
|
|
30
|
+
"azure",
|
|
31
|
+
"kit",
|
|
32
|
+
"discovery",
|
|
33
|
+
"foundation",
|
|
34
|
+
"identity",
|
|
35
|
+
"transfer",
|
|
36
|
+
"substrate",
|
|
37
|
+
"runtime",
|
|
38
|
+
"images",
|
|
39
|
+
"database",
|
|
40
|
+
"capability",
|
|
41
|
+
"migration",
|
|
42
|
+
"application",
|
|
43
|
+
"initial-inventory",
|
|
44
|
+
"catalog-review",
|
|
45
|
+
"verification",
|
|
46
|
+
"cleanup",
|
|
47
|
+
]
|
|
48
|
+
_STAGES: tuple[tuple[StageName, str], ...] = (
|
|
49
|
+
("azure", "Azure sign-in"),
|
|
50
|
+
("kit", "Signed deployment kit"),
|
|
51
|
+
("discovery", "Foundation discovery"),
|
|
52
|
+
("foundation", "Foundation checkpoints"),
|
|
53
|
+
("identity", "Application identity"),
|
|
54
|
+
("transfer", "Managed-host transfer"),
|
|
55
|
+
("substrate", "Private infrastructure"),
|
|
56
|
+
("runtime", "AKS cluster and node pools"),
|
|
57
|
+
("images", "Runtime images"),
|
|
58
|
+
("database", "Database placement"),
|
|
59
|
+
("capability", "Capability mode"),
|
|
60
|
+
("migration", "Database and catalogs"),
|
|
61
|
+
("application", "Application deployment"),
|
|
62
|
+
("initial-inventory", "Initial resource inventory"),
|
|
63
|
+
("catalog-review", "Frozen catalog review"),
|
|
64
|
+
("verification", "Health and zero-change plan"),
|
|
65
|
+
("cleanup", "Cleanup and final receipt"),
|
|
66
|
+
)
|
|
67
|
+
_STATES = {
|
|
68
|
+
"pending": ("--", "dim"),
|
|
69
|
+
"running": ("RUN", "cyan"),
|
|
70
|
+
"waiting": ("WAIT", "yellow"),
|
|
71
|
+
"done": ("OK", "green"),
|
|
72
|
+
"skipped": ("SKIP", "dim"),
|
|
73
|
+
"failed": ("FAIL", "red"),
|
|
74
|
+
"interrupted": ("STOP", "yellow"),
|
|
75
|
+
"incomplete": ("HOLD", "yellow"),
|
|
76
|
+
}
|
|
77
|
+
_CURRENT: ContextVar[DeploymentProgress | None] = ContextVar("deployment_progress", default=None)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class _Phase:
|
|
82
|
+
label: str
|
|
83
|
+
state: str = "pending"
|
|
84
|
+
started: float | None = None
|
|
85
|
+
ended: float | None = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class DeploymentProgress:
|
|
89
|
+
"""Stream colored phase events and refresh only current work on stderr.
|
|
90
|
+
|
|
91
|
+
Suspend redraw before native details or an approval prompt owns the terminal. Events remain
|
|
92
|
+
in scrollback; redraw never replays them. Errors propagate unchanged, without retries or
|
|
93
|
+
inferred operational success. Plain output contains no terminal control sequences.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
*,
|
|
99
|
+
mode: str = "auto",
|
|
100
|
+
console: Console | None = None,
|
|
101
|
+
clock: Callable[[], float] = time.monotonic,
|
|
102
|
+
auto_refresh: bool = True,
|
|
103
|
+
) -> None:
|
|
104
|
+
if mode not in {"auto", "stream", "plain", "off"}:
|
|
105
|
+
raise ValueError("deployment progress mode must be auto, stream, plain, or off")
|
|
106
|
+
self.console = console or Console(
|
|
107
|
+
stderr=True, force_terminal=sys.stderr.isatty(), markup=False, highlight=False
|
|
108
|
+
)
|
|
109
|
+
self.enabled = mode != "off"
|
|
110
|
+
self.dynamic = (
|
|
111
|
+
mode == "auto"
|
|
112
|
+
and self.console.is_terminal
|
|
113
|
+
and os.environ.get("TERM") != "dumb"
|
|
114
|
+
and "NO_COLOR" not in os.environ
|
|
115
|
+
)
|
|
116
|
+
self.stream = mode == "stream" and self.console.is_terminal
|
|
117
|
+
self._clock = clock
|
|
118
|
+
self._lock = threading.RLock()
|
|
119
|
+
self._phases = {name: _Phase(label) for name, label in _STAGES}
|
|
120
|
+
self._active: StageName | None = None
|
|
121
|
+
self._detail = "Preparing the local coordinator"
|
|
122
|
+
self._headline = "Deployment in progress"
|
|
123
|
+
self._style = "cyan"
|
|
124
|
+
self._started = clock()
|
|
125
|
+
self._ended: float | None = None
|
|
126
|
+
self._ready = False
|
|
127
|
+
self._suspended = 0
|
|
128
|
+
self._last_download_notice = 0
|
|
129
|
+
self._checkpoint: FoundationCheckpoint | None = None
|
|
130
|
+
self._checkpoint_signal: float | None = None
|
|
131
|
+
self._foundation_mode = ""
|
|
132
|
+
self._token: Token[DeploymentProgress | None] | None = None
|
|
133
|
+
self._spinner = Spinner("dots", style="bold cyan")
|
|
134
|
+
self._auto_refresh = auto_refresh
|
|
135
|
+
self._live = self._new_live()
|
|
136
|
+
|
|
137
|
+
def _new_live(self) -> Live:
|
|
138
|
+
"""Start each terminal ownership interval with an empty cursor footprint."""
|
|
139
|
+
|
|
140
|
+
return Live(
|
|
141
|
+
get_renderable=self.render,
|
|
142
|
+
console=self.console,
|
|
143
|
+
auto_refresh=self._auto_refresh,
|
|
144
|
+
refresh_per_second=4,
|
|
145
|
+
transient=True,
|
|
146
|
+
redirect_stdout=False,
|
|
147
|
+
redirect_stderr=True,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
@classmethod
|
|
151
|
+
def for_source(
|
|
152
|
+
cls,
|
|
153
|
+
*,
|
|
154
|
+
mode: str,
|
|
155
|
+
output: str,
|
|
156
|
+
) -> DeploymentProgress:
|
|
157
|
+
"""Select the append-only installer stream for interactive source deployment."""
|
|
158
|
+
|
|
159
|
+
selected = "off" if output != "text" else "stream" if mode == "auto" else mode
|
|
160
|
+
return cls(mode=selected)
|
|
161
|
+
|
|
162
|
+
def begin_source(self) -> None:
|
|
163
|
+
"""Record source-route prerequisites without inventing signed-kit work."""
|
|
164
|
+
|
|
165
|
+
self.begin("azure")
|
|
166
|
+
self.detail("Using the authenticated Azure CLI session")
|
|
167
|
+
self.skip("kit", "Source checkout selected; no deployment kit is used")
|
|
168
|
+
|
|
169
|
+
def begin_hub(self) -> None:
|
|
170
|
+
"""Distinguish signed service images from checkout-supplied support artifacts."""
|
|
171
|
+
self.begin("azure")
|
|
172
|
+
self.detail("Using the authenticated Azure CLI session")
|
|
173
|
+
self.skip("kit", "Hub signed images and matching checkout support selected; no kit")
|
|
174
|
+
|
|
175
|
+
def finish_source(self, result: Mapping[str, object]) -> bool:
|
|
176
|
+
"""Render ready only for the source coordinator's verified terminal result."""
|
|
177
|
+
|
|
178
|
+
ready = result.get("deployment_ready") is True and result["state"] == "deployment-ready"
|
|
179
|
+
if ready:
|
|
180
|
+
self.ready()
|
|
181
|
+
return ready
|
|
182
|
+
|
|
183
|
+
def timing_receipt(
|
|
184
|
+
self,
|
|
185
|
+
*,
|
|
186
|
+
lane: str,
|
|
187
|
+
outcome: str,
|
|
188
|
+
recorded_at: str,
|
|
189
|
+
) -> dict[str, object]:
|
|
190
|
+
"""Return bounded duration evidence without granting deployment authority."""
|
|
191
|
+
|
|
192
|
+
if not lane or len(lane) > 64 or not outcome or len(outcome) > 128 or not recorded_at:
|
|
193
|
+
raise ValueError("deployment timing identity is invalid")
|
|
194
|
+
end = self._ended if self._ended is not None else self._clock()
|
|
195
|
+
phases = []
|
|
196
|
+
for name, phase in self._phases.items():
|
|
197
|
+
if phase.started is None:
|
|
198
|
+
continue
|
|
199
|
+
phase_end = phase.ended if phase.ended is not None else end
|
|
200
|
+
phases.append(
|
|
201
|
+
{
|
|
202
|
+
"name": name,
|
|
203
|
+
"state": phase.state,
|
|
204
|
+
"elapsed_ms": max(0, round((phase_end - phase.started) * 1000)),
|
|
205
|
+
}
|
|
206
|
+
)
|
|
207
|
+
receipt: dict[str, object] = {
|
|
208
|
+
"schema_version": "fdai.deployment-timing.v1",
|
|
209
|
+
"lane": lane,
|
|
210
|
+
"outcome": outcome,
|
|
211
|
+
"recorded_at": recorded_at,
|
|
212
|
+
"total_elapsed_ms": max(0, round((end - self._started) * 1000)),
|
|
213
|
+
"phases": phases,
|
|
214
|
+
"timing_evidence_only": True,
|
|
215
|
+
"apply_authorized": False,
|
|
216
|
+
"deployment_ready": False,
|
|
217
|
+
}
|
|
218
|
+
receipt["receipt_digest"] = canonical_digest(receipt)
|
|
219
|
+
return receipt
|
|
220
|
+
|
|
221
|
+
def __enter__(self) -> Self:
|
|
222
|
+
self._token = _CURRENT.set(self)
|
|
223
|
+
try:
|
|
224
|
+
self._emit(Text("FDAI | Azure deployment", style="bold cyan"))
|
|
225
|
+
if self.dynamic:
|
|
226
|
+
self._start_live()
|
|
227
|
+
except BaseException:
|
|
228
|
+
_CURRENT.reset(self._token)
|
|
229
|
+
self._token = None
|
|
230
|
+
raise
|
|
231
|
+
return self
|
|
232
|
+
|
|
233
|
+
def _start_live(self) -> None:
|
|
234
|
+
"""Restore partial Rich startup on interrupts before its render hook exists."""
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
self._live.start(refresh=True)
|
|
238
|
+
except BaseException as error:
|
|
239
|
+
try:
|
|
240
|
+
self._live.stop()
|
|
241
|
+
except (OSError, ValueError, IndexError):
|
|
242
|
+
error.add_note("Progress startup cleanup could not write to the terminal.")
|
|
243
|
+
finally:
|
|
244
|
+
try:
|
|
245
|
+
if self.dynamic:
|
|
246
|
+
self.console.show_cursor(True)
|
|
247
|
+
except (OSError, ValueError):
|
|
248
|
+
error.add_note("The terminal was unavailable while restoring the cursor.")
|
|
249
|
+
raise
|
|
250
|
+
|
|
251
|
+
def __exit__(
|
|
252
|
+
self,
|
|
253
|
+
exc_type: type[BaseException] | None,
|
|
254
|
+
exc: BaseException | None,
|
|
255
|
+
traceback: TracebackType | None,
|
|
256
|
+
) -> None:
|
|
257
|
+
del exc_type, traceback
|
|
258
|
+
try:
|
|
259
|
+
with self._lock:
|
|
260
|
+
self._ended = self._clock()
|
|
261
|
+
if exc is not None:
|
|
262
|
+
interrupted = isinstance(exc, KeyboardInterrupt)
|
|
263
|
+
self._headline = (
|
|
264
|
+
"Deployment interrupted" if interrupted else "Deployment failed"
|
|
265
|
+
)
|
|
266
|
+
self._style = "yellow" if interrupted else "red"
|
|
267
|
+
self._detail = "Preserve state and claims; recovery needs a reviewed plan."
|
|
268
|
+
if self._active is not None:
|
|
269
|
+
phase = self._phases[self._active]
|
|
270
|
+
phase.state = "interrupted" if interrupted else "failed"
|
|
271
|
+
phase.ended = self._ended
|
|
272
|
+
elif not self._ready:
|
|
273
|
+
self._headline = "Deployment ended without a ready result"
|
|
274
|
+
self._style = "yellow"
|
|
275
|
+
if self._active is not None:
|
|
276
|
+
phase = self._phases[self._active]
|
|
277
|
+
phase.state = "incomplete"
|
|
278
|
+
phase.ended = self._ended
|
|
279
|
+
if self.dynamic:
|
|
280
|
+
self._live.stop()
|
|
281
|
+
if not self._ready and self._active is not None:
|
|
282
|
+
self._phase_event(self._active)
|
|
283
|
+
self.console.print(self.render())
|
|
284
|
+
elif self.enabled:
|
|
285
|
+
if exc is not None and self._active is not None:
|
|
286
|
+
self._phase_event(self._active)
|
|
287
|
+
self._plain(self._headline)
|
|
288
|
+
except (OSError, ValueError):
|
|
289
|
+
if exc is None:
|
|
290
|
+
raise
|
|
291
|
+
exc.add_note("Progress output failed; the original deployment error is preserved.")
|
|
292
|
+
finally:
|
|
293
|
+
if self._token is not None:
|
|
294
|
+
_CURRENT.reset(self._token)
|
|
295
|
+
self._token = None
|
|
296
|
+
|
|
297
|
+
def begin(self, name: StageName) -> None:
|
|
298
|
+
"""Advance only after the caller's existing checks; retain one event per transition."""
|
|
299
|
+
|
|
300
|
+
with self._lock:
|
|
301
|
+
if name == self._active:
|
|
302
|
+
return
|
|
303
|
+
previous = self._active
|
|
304
|
+
now = self._clock()
|
|
305
|
+
if previous is not None:
|
|
306
|
+
self._phases[previous].state = "done"
|
|
307
|
+
self._phases[previous].ended = now
|
|
308
|
+
self._active = name
|
|
309
|
+
self._phases[name].state = "running"
|
|
310
|
+
self._phases[name].started = now
|
|
311
|
+
self._detail = ""
|
|
312
|
+
self._checkpoint = None
|
|
313
|
+
self._checkpoint_signal = None
|
|
314
|
+
self._foundation_mode = ""
|
|
315
|
+
if previous is not None:
|
|
316
|
+
self._phase_event(previous)
|
|
317
|
+
self._phase_event(name)
|
|
318
|
+
self._refresh()
|
|
319
|
+
|
|
320
|
+
def detail(self, text: str, *, log: bool = True) -> None:
|
|
321
|
+
"""Show bounded application-owned text, never raw provider output or secrets."""
|
|
322
|
+
|
|
323
|
+
text = "".join(character if character.isprintable() else " " for character in text)[:160]
|
|
324
|
+
with self._lock:
|
|
325
|
+
changed = text != self._detail
|
|
326
|
+
self._detail = text
|
|
327
|
+
if changed and log:
|
|
328
|
+
self._emit(Text(f" {text}", style="dim"))
|
|
329
|
+
if log:
|
|
330
|
+
self._refresh()
|
|
331
|
+
|
|
332
|
+
def skip(self, name: StageName, reason: str) -> None:
|
|
333
|
+
"""Record one route-inapplicable presentation phase without claiming an effect."""
|
|
334
|
+
|
|
335
|
+
reason = "".join(character if character.isprintable() else " " for character in reason)[
|
|
336
|
+
:120
|
|
337
|
+
]
|
|
338
|
+
with self._lock:
|
|
339
|
+
if name == self._active:
|
|
340
|
+
raise ValueError("active deployment phase cannot be skipped")
|
|
341
|
+
phase = self._phases[name]
|
|
342
|
+
if phase.state == "done":
|
|
343
|
+
raise ValueError("completed deployment phase cannot be skipped")
|
|
344
|
+
phase.state = "skipped"
|
|
345
|
+
phase.ended = self._clock()
|
|
346
|
+
self._phase_event(name)
|
|
347
|
+
self._emit(Text(f" {reason}", style="dim"))
|
|
348
|
+
self._refresh()
|
|
349
|
+
|
|
350
|
+
def reset_foundation(self) -> None:
|
|
351
|
+
"""Begin a new display-only child pass without changing the coordinator phase."""
|
|
352
|
+
with self._lock:
|
|
353
|
+
self._checkpoint = None
|
|
354
|
+
self._checkpoint_signal = None
|
|
355
|
+
self._foundation_mode = ""
|
|
356
|
+
self.detail("Starting Foundation checkpoints")
|
|
357
|
+
|
|
358
|
+
def foundation_mode(self, mode: str) -> None:
|
|
359
|
+
"""Retain the recognized child mode, never an approval or readiness decision."""
|
|
360
|
+
with self._lock:
|
|
361
|
+
self._foundation_mode = mode
|
|
362
|
+
self.detail(f"{mode.capitalize()} | exact plan approvals remain required")
|
|
363
|
+
|
|
364
|
+
def foundation_checkpoint(self, value: FoundationCheckpoint) -> None:
|
|
365
|
+
"""Append distinct checked child transitions without completing a coordinator phase."""
|
|
366
|
+
with self._lock:
|
|
367
|
+
changed = value != self._checkpoint
|
|
368
|
+
self._checkpoint = value
|
|
369
|
+
self._checkpoint_signal = self._clock()
|
|
370
|
+
if changed and self.dynamic:
|
|
371
|
+
status = "REPORTED COMPLETE" if value.state == "COMPLETE" else value.state
|
|
372
|
+
line = Text(f" [{value.number:02}/{value.total}] ", style="magenta")
|
|
373
|
+
line.append(f"{status} ", style=f"bold {_checkpoint_style(value)}")
|
|
374
|
+
line.append(value.label)
|
|
375
|
+
line.append(f" | {value.completed}/{value.total} complete", style="dim")
|
|
376
|
+
self._emit(line)
|
|
377
|
+
self._refresh()
|
|
378
|
+
|
|
379
|
+
def foundation_signal(self) -> None:
|
|
380
|
+
"""Refresh observed activity only; a heartbeat never adds history or completes work."""
|
|
381
|
+
with self._lock:
|
|
382
|
+
self._checkpoint_signal = self._clock()
|
|
383
|
+
self._refresh()
|
|
384
|
+
|
|
385
|
+
def downloaded(self, byte_count: int) -> None:
|
|
386
|
+
"""Report bytes actually read, without inventing a total, ETA, or verification."""
|
|
387
|
+
|
|
388
|
+
notice = self._last_download_notice == 0 or (
|
|
389
|
+
byte_count - self._last_download_notice >= 64 * 1024 * 1024
|
|
390
|
+
)
|
|
391
|
+
self.detail(
|
|
392
|
+
f"{byte_count / (1024 * 1024):.1f} MiB received; verification pending", log=notice
|
|
393
|
+
)
|
|
394
|
+
if notice:
|
|
395
|
+
self._last_download_notice = byte_count
|
|
396
|
+
|
|
397
|
+
@contextmanager
|
|
398
|
+
def installer_activity(
|
|
399
|
+
self,
|
|
400
|
+
label: str,
|
|
401
|
+
*,
|
|
402
|
+
heartbeat_seconds: float = 10.0,
|
|
403
|
+
) -> Iterator[None]:
|
|
404
|
+
"""Emit append-only start, heartbeat, and terminal events for one quiet operation."""
|
|
405
|
+
|
|
406
|
+
if heartbeat_seconds <= 0:
|
|
407
|
+
raise ValueError("installer heartbeat interval must be positive")
|
|
408
|
+
label = "".join(character if character.isprintable() else " " for character in label)[:120]
|
|
409
|
+
if not self.enabled or self.dynamic:
|
|
410
|
+
self.detail(label)
|
|
411
|
+
yield
|
|
412
|
+
return
|
|
413
|
+
started = self._clock()
|
|
414
|
+
stopped = threading.Event()
|
|
415
|
+
self._emit(Text.assemble((" => ", "bold cyan"), label))
|
|
416
|
+
|
|
417
|
+
def heartbeat() -> None:
|
|
418
|
+
while not stopped.wait(heartbeat_seconds):
|
|
419
|
+
line = Text(" [..] ", style="bold cyan")
|
|
420
|
+
line.append(label)
|
|
421
|
+
line.append(f" | elapsed {_elapsed(started, self._clock())}", style="dim")
|
|
422
|
+
with self._lock:
|
|
423
|
+
self._emit(line)
|
|
424
|
+
|
|
425
|
+
worker = threading.Thread(
|
|
426
|
+
target=heartbeat,
|
|
427
|
+
name="fdai-installer-progress",
|
|
428
|
+
daemon=True,
|
|
429
|
+
)
|
|
430
|
+
worker.start()
|
|
431
|
+
succeeded = False
|
|
432
|
+
try:
|
|
433
|
+
yield
|
|
434
|
+
succeeded = True
|
|
435
|
+
finally:
|
|
436
|
+
stopped.set()
|
|
437
|
+
worker.join(timeout=max(1.0, heartbeat_seconds))
|
|
438
|
+
elapsed = _elapsed(started, self._clock())
|
|
439
|
+
status = Text(" [OK] " if succeeded else " [FAIL] ")
|
|
440
|
+
status.stylize("bold green" if succeeded else "bold red")
|
|
441
|
+
status.append(label)
|
|
442
|
+
status.append(f" | elapsed {elapsed}", style="dim")
|
|
443
|
+
self._emit(status)
|
|
444
|
+
|
|
445
|
+
def ready(self) -> None:
|
|
446
|
+
"""Finish presentation only after the caller has a verified deployment-ready result."""
|
|
447
|
+
|
|
448
|
+
with self._lock:
|
|
449
|
+
if self._active is not None:
|
|
450
|
+
self._phases[self._active].state = "done"
|
|
451
|
+
self._phases[self._active].ended = self._clock()
|
|
452
|
+
self._ready = True
|
|
453
|
+
self._headline = "Deployment ready"
|
|
454
|
+
self._style = "green"
|
|
455
|
+
self._detail = "Subscription-wide assurance remains open."
|
|
456
|
+
if self._active is not None:
|
|
457
|
+
self._phase_event(self._active)
|
|
458
|
+
self._refresh()
|
|
459
|
+
|
|
460
|
+
@contextmanager
|
|
461
|
+
def terminal_output(self, label: str, *, approval: bool = False) -> Iterator[None]:
|
|
462
|
+
"""Yield the terminal to existing details or prompts without reading stdin."""
|
|
463
|
+
|
|
464
|
+
with self._lock:
|
|
465
|
+
phase = self._phases.get(self._active) if self._active is not None else None
|
|
466
|
+
previous_state = phase.state if phase is not None else "running"
|
|
467
|
+
if phase is not None and approval:
|
|
468
|
+
phase.state = "waiting"
|
|
469
|
+
self._suspended += 1
|
|
470
|
+
if self.dynamic and self._suspended == 1:
|
|
471
|
+
self._live.stop()
|
|
472
|
+
line = Text(
|
|
473
|
+
f"[{'WAIT' if approval else 'INFO'}] ", style="bold yellow" if approval else "cyan"
|
|
474
|
+
)
|
|
475
|
+
line.append(label, style="default")
|
|
476
|
+
self._emit(line)
|
|
477
|
+
succeeded = False
|
|
478
|
+
try:
|
|
479
|
+
yield
|
|
480
|
+
succeeded = True
|
|
481
|
+
finally:
|
|
482
|
+
with self._lock:
|
|
483
|
+
if phase is not None:
|
|
484
|
+
phase.state = previous_state
|
|
485
|
+
self._suspended -= 1
|
|
486
|
+
if self.dynamic and self._suspended == 0 and succeeded:
|
|
487
|
+
self._live = self._new_live()
|
|
488
|
+
self._start_live()
|
|
489
|
+
|
|
490
|
+
def render(self) -> Group:
|
|
491
|
+
"""Render at most six unboxed lines of current work, never replaying past events."""
|
|
492
|
+
with self._lock:
|
|
493
|
+
now = self._ended if self._ended is not None else self._clock()
|
|
494
|
+
phase = self._phases.get(self._active) if self._active is not None else None
|
|
495
|
+
state = phase.state if phase is not None else "running"
|
|
496
|
+
label = phase.label if phase is not None else "Preparing"
|
|
497
|
+
duration = _elapsed(
|
|
498
|
+
phase.started if phase else None,
|
|
499
|
+
phase.ended if phase is not None and phase.ended is not None else now,
|
|
500
|
+
)
|
|
501
|
+
completed = sum(item.state == "done" for item in self._phases.values())
|
|
502
|
+
skipped = sum(item.state == "skipped" for item in self._phases.values())
|
|
503
|
+
elapsed = _elapsed(self._started, now)
|
|
504
|
+
headline, style, detail = self._headline, self._style, self._detail
|
|
505
|
+
checkpoint = self._checkpoint
|
|
506
|
+
age = _elapsed(self._checkpoint_signal, now)
|
|
507
|
+
status, phase_style = _STATES[state]
|
|
508
|
+
row = Table.grid(padding=(0, 1), expand=True)
|
|
509
|
+
row.add_column(width=4)
|
|
510
|
+
row.add_column(ratio=1, no_wrap=True, overflow="ellipsis")
|
|
511
|
+
row.add_column(no_wrap=True)
|
|
512
|
+
row.add_row(
|
|
513
|
+
self._spinner if state == "running" else Text(status, style=f"bold {phase_style}"),
|
|
514
|
+
Text(label, style=f"bold {phase_style}"),
|
|
515
|
+
Text(duration, style="dim"),
|
|
516
|
+
)
|
|
517
|
+
lines = [
|
|
518
|
+
Text(f"FDAI | {headline}", style=f"bold {style}", no_wrap=True, overflow="ellipsis"),
|
|
519
|
+
Text(
|
|
520
|
+
f"{completed} complete | {skipped} skipped | "
|
|
521
|
+
f"{len(_STAGES) - completed - skipped} remaining | elapsed {elapsed}",
|
|
522
|
+
style="dim",
|
|
523
|
+
no_wrap=True,
|
|
524
|
+
overflow="ellipsis",
|
|
525
|
+
),
|
|
526
|
+
]
|
|
527
|
+
children: list[Text] = []
|
|
528
|
+
if checkpoint is not None:
|
|
529
|
+
child_style = _checkpoint_style(checkpoint)
|
|
530
|
+
child_state = (
|
|
531
|
+
"Reported complete"
|
|
532
|
+
if checkpoint.state == "COMPLETE"
|
|
533
|
+
else checkpoint.state.capitalize()
|
|
534
|
+
)
|
|
535
|
+
children = [
|
|
536
|
+
Text(
|
|
537
|
+
f"{checkpoint.number:02}/{checkpoint.total} {checkpoint.label}",
|
|
538
|
+
style=f"bold {child_style}",
|
|
539
|
+
no_wrap=True,
|
|
540
|
+
overflow="ellipsis",
|
|
541
|
+
),
|
|
542
|
+
Text(
|
|
543
|
+
f"{child_state} | {checkpoint.completed}/{checkpoint.total} complete | "
|
|
544
|
+
f"{checkpoint.skipped} skipped | last activity {age}",
|
|
545
|
+
style=child_style,
|
|
546
|
+
no_wrap=True,
|
|
547
|
+
overflow="ellipsis",
|
|
548
|
+
),
|
|
549
|
+
]
|
|
550
|
+
return Group(
|
|
551
|
+
*lines,
|
|
552
|
+
row,
|
|
553
|
+
*children,
|
|
554
|
+
Text(detail, style=style, no_wrap=True, overflow="ellipsis"),
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
def _refresh(self) -> None:
|
|
558
|
+
if self.dynamic and self._suspended == 0 and self._token is not None:
|
|
559
|
+
self._live.refresh()
|
|
560
|
+
|
|
561
|
+
def _emit(self, text: Text) -> None:
|
|
562
|
+
"""Serialize one event through Rich outside the state lock; keep plain output literal."""
|
|
563
|
+
if self.dynamic or self.stream:
|
|
564
|
+
self.console.print(text)
|
|
565
|
+
elif self.enabled:
|
|
566
|
+
self._plain(text.plain)
|
|
567
|
+
|
|
568
|
+
def _plain(self, text: str) -> None:
|
|
569
|
+
self.console.file.write(text + "\n")
|
|
570
|
+
self.console.file.flush()
|
|
571
|
+
|
|
572
|
+
def _phase_event(self, name: StageName) -> None:
|
|
573
|
+
with self._lock:
|
|
574
|
+
phase = self._phases[name]
|
|
575
|
+
index = list(self._phases).index(name) + 1
|
|
576
|
+
status, style = _STATES[phase.state]
|
|
577
|
+
end = phase.ended if phase.ended is not None else self._clock()
|
|
578
|
+
duration = _elapsed(phase.started, end)
|
|
579
|
+
line = Text(f"[{index:02}/{len(_STAGES)}] ", style="dim")
|
|
580
|
+
line.append(f"{status:4} ", style=f"bold {style}")
|
|
581
|
+
line.append(phase.label, style=style)
|
|
582
|
+
line.append(f" ({duration})", style="dim")
|
|
583
|
+
self._emit(line)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _checkpoint_style(value: FoundationCheckpoint) -> str:
|
|
587
|
+
if value.state in {"BLOCKED", "FAILED"}:
|
|
588
|
+
return "red"
|
|
589
|
+
return "yellow" if value.state == "WAITING" else "cyan"
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _elapsed(start: float | None, end: float) -> str:
|
|
593
|
+
seconds = max(0, int(end - start)) if start is not None else 0
|
|
594
|
+
hours, remainder = divmod(seconds, 3600)
|
|
595
|
+
minutes, seconds = divmod(remainder, 60)
|
|
596
|
+
return f"{hours:02}:{minutes:02}:{seconds:02}" if hours else f"{minutes:02}:{seconds:02}"
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def begin_stage(name: StageName) -> None:
|
|
600
|
+
"""Advance the active local display, or do nothing outside an interactive coordinator."""
|
|
601
|
+
|
|
602
|
+
if (progress := _CURRENT.get()) is not None:
|
|
603
|
+
progress.begin(name)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def progress_detail(text: str) -> None:
|
|
607
|
+
"""Publish one application-owned presentation detail without persisting it."""
|
|
608
|
+
|
|
609
|
+
if (progress := _CURRENT.get()) is not None:
|
|
610
|
+
progress.detail(text)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def skip_stage(name: StageName, reason: str) -> None:
|
|
614
|
+
"""Record one presentation-only stage as inapplicable to this deployment route."""
|
|
615
|
+
|
|
616
|
+
if (progress := _CURRENT.get()) is not None:
|
|
617
|
+
progress.skip(name, reason)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def downloaded_bytes(count: int) -> None:
|
|
621
|
+
"""Publish an observed byte count without changing artifact verification."""
|
|
622
|
+
|
|
623
|
+
if (progress := _CURRENT.get()) is not None:
|
|
624
|
+
progress.downloaded(count)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
@contextmanager
|
|
628
|
+
def installer_activity(label: str, *, heartbeat_seconds: float = 10.0) -> Iterator[None]:
|
|
629
|
+
"""Show append-only activity while a local preparation boundary is silent."""
|
|
630
|
+
|
|
631
|
+
progress = _CURRENT.get()
|
|
632
|
+
if progress is None:
|
|
633
|
+
yield
|
|
634
|
+
else:
|
|
635
|
+
with progress.installer_activity(label, heartbeat_seconds=heartbeat_seconds):
|
|
636
|
+
yield
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
@contextmanager
|
|
640
|
+
def terminal_output(label: str, *, approval: bool = False) -> Iterator[None]:
|
|
641
|
+
"""Pause the active display around existing subprocess output or human confirmation."""
|
|
642
|
+
|
|
643
|
+
progress = _CURRENT.get()
|
|
644
|
+
if progress is None:
|
|
645
|
+
yield
|
|
646
|
+
else:
|
|
647
|
+
with progress.terminal_output(label, approval=approval):
|
|
648
|
+
yield
|