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,977 @@
|
|
|
1
|
+
"""Control standalone managed-host application deployment through Azure Bastion."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from datetime import UTC, datetime, timedelta
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from fdai_deployment_cli import azure_actor, bootstrap_approval, standalone_catalog_checkpoint
|
|
17
|
+
from fdai_deployment_cli.application_state_adoption import ApplicationStateAdoption
|
|
18
|
+
from fdai_deployment_cli.catalog_review_profile import (
|
|
19
|
+
CatalogReviewDeploymentProfile,
|
|
20
|
+
)
|
|
21
|
+
from fdai_deployment_cli.contracts import canonical_digest
|
|
22
|
+
from fdai_deployment_cli.control_package import ControlPackage
|
|
23
|
+
from fdai_deployment_cli.deadline_transport import DeadlineTransport
|
|
24
|
+
from fdai_deployment_cli.deployment_deadline import DeploymentDeadline
|
|
25
|
+
from fdai_deployment_cli.deployment_kit import DeploymentKit, archive_verified_kit
|
|
26
|
+
from fdai_deployment_cli.deployment_progress import begin_stage, progress_detail, terminal_output
|
|
27
|
+
from fdai_deployment_cli.development_authority_profile import (
|
|
28
|
+
DevelopmentAuthorityDeploymentProfile,
|
|
29
|
+
)
|
|
30
|
+
from fdai_deployment_cli.hub_image_deploy import application_selection, source_binding
|
|
31
|
+
from fdai_deployment_cli.hub_image_trust import HubImageSelection
|
|
32
|
+
from fdai_deployment_cli.license_issue import deployment_license_token as _license_token
|
|
33
|
+
from fdai_deployment_cli.notification_recipients_input import (
|
|
34
|
+
NotificationRecipientsDeploymentInput,
|
|
35
|
+
)
|
|
36
|
+
from fdai_deployment_cli.operational_evidence_verifier_input import (
|
|
37
|
+
OperationalEvidenceVerifierDeploymentInput,
|
|
38
|
+
)
|
|
39
|
+
from fdai_deployment_cli.private_output import read_private_bytes, write_private_output
|
|
40
|
+
from fdai_deployment_cli.runtime_profile import RuntimeDeploymentProfile
|
|
41
|
+
from fdai_deployment_cli.source_application_inputs import (
|
|
42
|
+
build_source_console,
|
|
43
|
+
prepare_source_recovery_host,
|
|
44
|
+
source_transfer_inputs,
|
|
45
|
+
)
|
|
46
|
+
from fdai_deployment_cli.standalone_application_recovery import (
|
|
47
|
+
converge_focused_private_access as _converge_focused_private_access,
|
|
48
|
+
)
|
|
49
|
+
from fdai_deployment_cli.standalone_application_recovery import (
|
|
50
|
+
recover_failed_exact_apply as _recover_failed_exact_apply,
|
|
51
|
+
)
|
|
52
|
+
from fdai_deployment_cli.standalone_approval_input import (
|
|
53
|
+
wait_for_approval_input as _wait_for_approval_input,
|
|
54
|
+
)
|
|
55
|
+
from fdai_deployment_cli.standalone_checkpoint_failure import remote_failure as _remote_failure
|
|
56
|
+
from fdai_deployment_cli.standalone_console_publish import publish_verified_console
|
|
57
|
+
from fdai_deployment_cli.standalone_remote_prepare import prepare_remote as _prepare_remote
|
|
58
|
+
from fdai_deployment_cli.standalone_review import validate_plan_review
|
|
59
|
+
from fdai_deployment_cli.standalone_transfer_cleanup import cleanup_remote_transfers, remote_prune
|
|
60
|
+
|
|
61
|
+
_DIGEST = re.compile(r"[0-9a-f]{64}")
|
|
62
|
+
_SSH_USER = re.compile(r"[a-z_][a-z0-9_-]{0,31}")
|
|
63
|
+
__all__ = ["deploy_standalone_application", "publish_verified_console"]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def deploy_standalone_application(
|
|
67
|
+
*,
|
|
68
|
+
kit: DeploymentKit | None,
|
|
69
|
+
prepared: Any,
|
|
70
|
+
foundation_status: dict[str, Any],
|
|
71
|
+
entra_bindings: dict[str, str] | None,
|
|
72
|
+
scripts: Path,
|
|
73
|
+
trial_token: Path | None,
|
|
74
|
+
timeout_seconds: int,
|
|
75
|
+
runtime_profile: RuntimeDeploymentProfile | None = None,
|
|
76
|
+
application_state_adoption: ApplicationStateAdoption | None = None,
|
|
77
|
+
catalog_review_profile: CatalogReviewDeploymentProfile | None = None,
|
|
78
|
+
operational_evidence_verifier_input: OperationalEvidenceVerifierDeploymentInput | None = None,
|
|
79
|
+
notification_recipients: NotificationRecipientsDeploymentInput | None = None,
|
|
80
|
+
development_authority_profile: DevelopmentAuthorityDeploymentProfile | None = None,
|
|
81
|
+
clear_development_authority_profile: bool = False,
|
|
82
|
+
control_package: ControlPackage | None = None,
|
|
83
|
+
source_snapshot: Path | None = None,
|
|
84
|
+
source_snapshot_digest: str | None = None,
|
|
85
|
+
source_root: Path | None = None,
|
|
86
|
+
foundation_recovery_directory: Path | None = None,
|
|
87
|
+
hub_selection: HubImageSelection | None = None,
|
|
88
|
+
) -> dict[str, object]:
|
|
89
|
+
"""Deploy and independently replan the application without a workflow host."""
|
|
90
|
+
|
|
91
|
+
deadline = DeploymentDeadline(timeout_seconds, clock=time.monotonic)
|
|
92
|
+
selected_runtime = runtime_profile or RuntimeDeploymentProfile.create(
|
|
93
|
+
runtime_platform="container-apps",
|
|
94
|
+
database_placement="postgres-flex",
|
|
95
|
+
)
|
|
96
|
+
source_mode = source_snapshot is not None
|
|
97
|
+
hub_selection = application_selection(hub_selection, source_mode, prepared)
|
|
98
|
+
begin_stage("transfer")
|
|
99
|
+
progress_detail(
|
|
100
|
+
"Verifying the handoff and preparing the source snapshot for Bastion transfer"
|
|
101
|
+
if source_mode
|
|
102
|
+
else "Verifying the handoff and preparing the signed kit for Bastion transfer"
|
|
103
|
+
)
|
|
104
|
+
report = _mapping(foundation_status.get("foundation_report"), "Foundation report")
|
|
105
|
+
if foundation_recovery_directory is None:
|
|
106
|
+
plan = _mapping(report.get("foundation_plan"), "Foundation plan")
|
|
107
|
+
plan_directory = prepared.root / str(plan["plan_ref"])
|
|
108
|
+
handoff_path = plan_directory / "foundation-private-handoff.json"
|
|
109
|
+
else:
|
|
110
|
+
plan_directory = foundation_recovery_directory.absolute()
|
|
111
|
+
if (
|
|
112
|
+
not source_mode
|
|
113
|
+
or plan_directory.parent != prepared.root
|
|
114
|
+
or re.fullmatch(r"foundation-recovery-attempt-[1-9][0-9]*", plan_directory.name) is None
|
|
115
|
+
or not plan_directory.is_dir()
|
|
116
|
+
or plan_directory.is_symlink()
|
|
117
|
+
):
|
|
118
|
+
raise ValueError("source application recovery directory is invalid")
|
|
119
|
+
handoff_path = plan_directory / "recovery-private-handoff.json"
|
|
120
|
+
handoff = _private_json(handoff_path, "Foundation handoff")
|
|
121
|
+
adoption_descriptor_digest = (
|
|
122
|
+
canonical_digest(
|
|
123
|
+
_private_json(application_state_adoption.descriptor, "adoption descriptor")
|
|
124
|
+
)
|
|
125
|
+
if application_state_adoption is not None
|
|
126
|
+
else ""
|
|
127
|
+
)
|
|
128
|
+
runner = _mapping(handoff.get("runner"), "Foundation runner")
|
|
129
|
+
access = _mapping(handoff.get("access"), "Foundation access")
|
|
130
|
+
ops = _mapping(handoff.get("ops"), "Foundation operations")
|
|
131
|
+
known_hosts = plan_directory / "runner-known-hosts"
|
|
132
|
+
module = _import_bastion(scripts)
|
|
133
|
+
module.validate_known_hosts(known_hosts)
|
|
134
|
+
private_key = prepared.ssh_private_key
|
|
135
|
+
if module.validate_ssh_private_key(private_key) != runner.get("ssh_key_digest"):
|
|
136
|
+
raise ValueError("standalone host SSH key differs from Foundation evidence")
|
|
137
|
+
source_transfer = None
|
|
138
|
+
recovery_host: Path | None = None
|
|
139
|
+
recovery_host_digest: str | None = None
|
|
140
|
+
transient_paths: tuple[str, ...]
|
|
141
|
+
if source_mode:
|
|
142
|
+
if (
|
|
143
|
+
kit is not None
|
|
144
|
+
or source_snapshot_digest is None
|
|
145
|
+
or source_snapshot is None
|
|
146
|
+
or source_root is None
|
|
147
|
+
):
|
|
148
|
+
raise ValueError("source application continuation inputs are incomplete")
|
|
149
|
+
source_transfer = source_transfer_inputs(
|
|
150
|
+
prepared_root=prepared.root,
|
|
151
|
+
source_snapshot=source_snapshot,
|
|
152
|
+
source_snapshot_digest=source_snapshot_digest,
|
|
153
|
+
)
|
|
154
|
+
if foundation_recovery_directory is not None:
|
|
155
|
+
recovery_host, recovery_host_digest = prepare_source_recovery_host(
|
|
156
|
+
source_root=source_root,
|
|
157
|
+
prepared_root=prepared.root,
|
|
158
|
+
)
|
|
159
|
+
transport_archive = source_transfer.archive
|
|
160
|
+
archive_digest = None
|
|
161
|
+
else:
|
|
162
|
+
if kit is None:
|
|
163
|
+
raise ValueError("standalone application requires a verified kit or source snapshot")
|
|
164
|
+
transport_archive = prepared.root / "standalone-kit.tar.gz"
|
|
165
|
+
archive_digest = archive_verified_kit(kit, transport_archive)
|
|
166
|
+
entra_path = prepared.root / "entra-bindings.json" if entra_bindings is not None else None
|
|
167
|
+
if entra_path is not None and entra_bindings is not None:
|
|
168
|
+
_replace_private_json(entra_path, entra_bindings)
|
|
169
|
+
work_binding: dict[str, object] = {
|
|
170
|
+
"target_binding": prepared.target_binding,
|
|
171
|
+
"source_commit": prepared.source_commit,
|
|
172
|
+
"runtime_profile_digest": selected_runtime.digest,
|
|
173
|
+
}
|
|
174
|
+
if source_mode:
|
|
175
|
+
work_binding.update(source_binding(source_snapshot_digest, hub_selection))
|
|
176
|
+
else:
|
|
177
|
+
work_binding["kit_manifest_digest"] = prepared.kit_manifest_digest
|
|
178
|
+
if control_package is not None:
|
|
179
|
+
work_binding["control_package_digest"] = control_package.archive_digest
|
|
180
|
+
work_ref = canonical_digest(work_binding)[:24]
|
|
181
|
+
username = str(runner["admin_username"])
|
|
182
|
+
if _SSH_USER.fullmatch(username) is None:
|
|
183
|
+
raise ValueError("Foundation runner SSH username is invalid")
|
|
184
|
+
remote_root = f"/home/{username}/.fdai-transfer-{work_ref}"
|
|
185
|
+
remote_archive = f"{remote_root}/kit.tar.gz"
|
|
186
|
+
remote_handoff = f"{remote_root}/foundation-handoff.json"
|
|
187
|
+
remote_entra = f"{remote_root}/entra-bindings.json" if entra_path is not None else None
|
|
188
|
+
remote_approval = f"{remote_root}/approval.json"
|
|
189
|
+
remote_adoption_state = f"{remote_root}/application-state.json"
|
|
190
|
+
remote_adoption_models = f"{remote_root}/resolved-models.json"
|
|
191
|
+
remote_adoption_descriptor = f"{remote_root}/application-state-adoption.json"
|
|
192
|
+
app_work = f"{remote_root}/application"
|
|
193
|
+
host_alias = module.stable_host_key_alias(str(runner["vm_id"]))
|
|
194
|
+
with module.BastionTunnel(
|
|
195
|
+
subscription_id=str(handoff["subscription_id"]),
|
|
196
|
+
resource_group=str(ops["resource_group_name"]),
|
|
197
|
+
bastion_name=str(access["bastion_name"]),
|
|
198
|
+
vm_id=str(runner["vm_id"]),
|
|
199
|
+
username=username,
|
|
200
|
+
private_key=private_key,
|
|
201
|
+
known_hosts=known_hosts,
|
|
202
|
+
host_key_alias=host_alias,
|
|
203
|
+
cwd=plan_directory,
|
|
204
|
+
timeout=deadline.remaining(),
|
|
205
|
+
trust_new_host_key=False,
|
|
206
|
+
) as underlying_tunnel:
|
|
207
|
+
tunnel = DeadlineTransport(underlying_tunnel, deadline)
|
|
208
|
+
host_preparation = _prepare_remote(
|
|
209
|
+
tunnel,
|
|
210
|
+
remote_root=remote_root,
|
|
211
|
+
remote_archive=remote_archive,
|
|
212
|
+
archive=None if source_mode else transport_archive,
|
|
213
|
+
archive_digest=archive_digest,
|
|
214
|
+
handoff_path=handoff_path,
|
|
215
|
+
remote_handoff=remote_handoff,
|
|
216
|
+
entra_path=entra_path,
|
|
217
|
+
remote_entra=remote_entra,
|
|
218
|
+
app_work=app_work,
|
|
219
|
+
source_archive=transport_archive if source_mode else None,
|
|
220
|
+
source_archive_digest=(
|
|
221
|
+
source_transfer.archive_digest if source_transfer is not None else None
|
|
222
|
+
),
|
|
223
|
+
source_receiver=source_transfer.receiver if source_transfer is not None else None,
|
|
224
|
+
source_receiver_digest=(
|
|
225
|
+
source_transfer.receiver_digest if source_transfer is not None else None
|
|
226
|
+
),
|
|
227
|
+
source_snapshot_digest=source_snapshot_digest if source_mode else None,
|
|
228
|
+
source_runtime_support=(
|
|
229
|
+
source_transfer.runtime_support if source_transfer is not None else None
|
|
230
|
+
),
|
|
231
|
+
source_runtime_support_digest=(
|
|
232
|
+
source_transfer.runtime_support_digest if source_transfer is not None else None
|
|
233
|
+
),
|
|
234
|
+
**({"hub_selection": hub_selection} if hub_selection is not None else {}),
|
|
235
|
+
runtime_profile=selected_runtime,
|
|
236
|
+
application_state_adoption=application_state_adoption,
|
|
237
|
+
remote_adoption_state=remote_adoption_state,
|
|
238
|
+
remote_adoption_models=remote_adoption_models,
|
|
239
|
+
remote_adoption_descriptor=remote_adoption_descriptor,
|
|
240
|
+
timeout_seconds=deadline.remaining(),
|
|
241
|
+
catalog_review_profile=(
|
|
242
|
+
catalog_review_profile or CatalogReviewDeploymentProfile.unselected()
|
|
243
|
+
),
|
|
244
|
+
operational_evidence_verifier_input=operational_evidence_verifier_input,
|
|
245
|
+
notification_recipients=notification_recipients,
|
|
246
|
+
development_authority_profile=development_authority_profile,
|
|
247
|
+
clear_development_authority_profile=clear_development_authority_profile,
|
|
248
|
+
control_package=control_package,
|
|
249
|
+
)
|
|
250
|
+
if recovery_host is not None:
|
|
251
|
+
assert recovery_host_digest is not None
|
|
252
|
+
remote_recovery_host = f"{remote_root}/source-recovery-host.pyz"
|
|
253
|
+
tunnel.copy_to(recovery_host, remote_recovery_host, timeout=300)
|
|
254
|
+
observed = tunnel.ssh(("sha256sum", remote_recovery_host), timeout=120)
|
|
255
|
+
if (
|
|
256
|
+
observed.returncode != 0
|
|
257
|
+
or observed.stdout.split(maxsplit=1)[0] != recovery_host_digest
|
|
258
|
+
):
|
|
259
|
+
raise ValueError("source recovery host digest differs")
|
|
260
|
+
tunnel.host_program = remote_recovery_host
|
|
261
|
+
if (
|
|
262
|
+
isinstance(host_preparation, dict)
|
|
263
|
+
and host_preparation.get("focused_private_access") is True
|
|
264
|
+
):
|
|
265
|
+
progress_detail("Converging the focused private data-plane access path")
|
|
266
|
+
_converge_focused_private_access(
|
|
267
|
+
tunnel,
|
|
268
|
+
remote_root=remote_root,
|
|
269
|
+
app_work=app_work,
|
|
270
|
+
prepared_root=prepared.root,
|
|
271
|
+
remote_approval=remote_approval,
|
|
272
|
+
deadline=deadline,
|
|
273
|
+
remote_json=_remote_json,
|
|
274
|
+
plan_after_recovery=_plan_after_recovery,
|
|
275
|
+
approve_plan=_approve_plan,
|
|
276
|
+
require_receipt=_require_receipt,
|
|
277
|
+
)
|
|
278
|
+
begin_stage("substrate")
|
|
279
|
+
progress_detail("Recovering by verification, or planning private infrastructure")
|
|
280
|
+
substrate_recovery = _remote_json(
|
|
281
|
+
tunnel,
|
|
282
|
+
remote_root,
|
|
283
|
+
app_work,
|
|
284
|
+
("recover-apply", "--stage", "substrate"),
|
|
285
|
+
timeout=3600,
|
|
286
|
+
)
|
|
287
|
+
if substrate_recovery.get("state") == "applied":
|
|
288
|
+
substrate_receipt = substrate_recovery
|
|
289
|
+
else:
|
|
290
|
+
substrate_plan, substrate_apply_command = _plan_after_recovery(
|
|
291
|
+
tunnel,
|
|
292
|
+
remote_root,
|
|
293
|
+
app_work,
|
|
294
|
+
substrate_recovery,
|
|
295
|
+
stage="substrate",
|
|
296
|
+
timeout=3600,
|
|
297
|
+
)
|
|
298
|
+
if application_state_adoption is not None:
|
|
299
|
+
_require_nondestructive_adoption_plan(substrate_plan)
|
|
300
|
+
deadline.remaining()
|
|
301
|
+
substrate_approval = _approve_plan(prepared.root, substrate_plan, deadline=deadline)
|
|
302
|
+
tunnel.ssh(("rm", "-f", "--", remote_approval), timeout=60)
|
|
303
|
+
tunnel.copy_to(substrate_approval, remote_approval, timeout=120)
|
|
304
|
+
progress_detail("Applying the approved infrastructure plan and verifying its effects")
|
|
305
|
+
try:
|
|
306
|
+
substrate_receipt = _remote_json(
|
|
307
|
+
tunnel,
|
|
308
|
+
remote_root,
|
|
309
|
+
app_work,
|
|
310
|
+
(
|
|
311
|
+
substrate_apply_command,
|
|
312
|
+
"--stage",
|
|
313
|
+
"substrate",
|
|
314
|
+
"--approval",
|
|
315
|
+
remote_approval,
|
|
316
|
+
),
|
|
317
|
+
timeout=7200,
|
|
318
|
+
)
|
|
319
|
+
except ValueError:
|
|
320
|
+
substrate_approval.unlink(missing_ok=True)
|
|
321
|
+
tunnel.ssh(("rm", "-f", "--", remote_approval), timeout=60)
|
|
322
|
+
private_access = _remote_json(
|
|
323
|
+
tunnel,
|
|
324
|
+
remote_root,
|
|
325
|
+
app_work,
|
|
326
|
+
("refresh-private-access",),
|
|
327
|
+
timeout=deadline.remaining(300),
|
|
328
|
+
)
|
|
329
|
+
if private_access.get("focused_private_access") is True:
|
|
330
|
+
progress_detail(
|
|
331
|
+
"Converging policy-forced private access before effect recovery"
|
|
332
|
+
)
|
|
333
|
+
_converge_focused_private_access(
|
|
334
|
+
tunnel,
|
|
335
|
+
remote_root=remote_root,
|
|
336
|
+
app_work=app_work,
|
|
337
|
+
prepared_root=prepared.root,
|
|
338
|
+
remote_approval=remote_approval,
|
|
339
|
+
deadline=deadline,
|
|
340
|
+
remote_json=_remote_json,
|
|
341
|
+
plan_after_recovery=_plan_after_recovery,
|
|
342
|
+
approve_plan=_approve_plan,
|
|
343
|
+
require_receipt=_require_receipt,
|
|
344
|
+
)
|
|
345
|
+
progress_detail(
|
|
346
|
+
"Recovering the failed infrastructure apply without repeating its effect"
|
|
347
|
+
)
|
|
348
|
+
substrate_receipt = _recover_failed_exact_apply(
|
|
349
|
+
tunnel,
|
|
350
|
+
remote_root=remote_root,
|
|
351
|
+
app_work=app_work,
|
|
352
|
+
prepared_root=prepared.root,
|
|
353
|
+
remote_approval=remote_approval,
|
|
354
|
+
stage="substrate",
|
|
355
|
+
deadline=deadline,
|
|
356
|
+
remote_json=_remote_json,
|
|
357
|
+
plan_after_recovery=_plan_after_recovery,
|
|
358
|
+
approve_plan=_approve_plan,
|
|
359
|
+
)
|
|
360
|
+
else:
|
|
361
|
+
substrate_approval.unlink(missing_ok=True)
|
|
362
|
+
tunnel.ssh(("rm", "-f", "--", remote_approval), timeout=60)
|
|
363
|
+
_require_receipt(substrate_receipt, "substrate")
|
|
364
|
+
if selected_runtime.runtime_platform.value == "aks":
|
|
365
|
+
begin_stage("runtime")
|
|
366
|
+
progress_detail("Preparing and planning the private AKS runtime")
|
|
367
|
+
_remote_json(
|
|
368
|
+
tunnel,
|
|
369
|
+
remote_root,
|
|
370
|
+
app_work,
|
|
371
|
+
("prepare-runtime",),
|
|
372
|
+
timeout=1800,
|
|
373
|
+
)
|
|
374
|
+
runtime_receipt: dict[str, Any] | None = None
|
|
375
|
+
for runtime_phase in range(2):
|
|
376
|
+
runtime_recovery = _remote_json(
|
|
377
|
+
tunnel,
|
|
378
|
+
remote_root,
|
|
379
|
+
app_work,
|
|
380
|
+
("recover-apply", "--stage", "runtime"),
|
|
381
|
+
timeout=3600,
|
|
382
|
+
)
|
|
383
|
+
if runtime_recovery.get("state") == "applied":
|
|
384
|
+
runtime_receipt = runtime_recovery
|
|
385
|
+
else:
|
|
386
|
+
runtime_plan, runtime_apply_command = _plan_after_recovery(
|
|
387
|
+
tunnel,
|
|
388
|
+
remote_root,
|
|
389
|
+
app_work,
|
|
390
|
+
runtime_recovery,
|
|
391
|
+
stage="runtime",
|
|
392
|
+
timeout=3600,
|
|
393
|
+
)
|
|
394
|
+
deadline.remaining()
|
|
395
|
+
runtime_approval = _approve_plan(prepared.root, runtime_plan, deadline=deadline)
|
|
396
|
+
tunnel.ssh(("rm", "-f", "--", remote_approval), timeout=60)
|
|
397
|
+
tunnel.copy_to(runtime_approval, remote_approval, timeout=120)
|
|
398
|
+
runtime_receipt = _remote_json(
|
|
399
|
+
tunnel,
|
|
400
|
+
remote_root,
|
|
401
|
+
app_work,
|
|
402
|
+
(
|
|
403
|
+
runtime_apply_command,
|
|
404
|
+
"--stage",
|
|
405
|
+
"runtime",
|
|
406
|
+
"--approval",
|
|
407
|
+
remote_approval,
|
|
408
|
+
),
|
|
409
|
+
timeout=7200,
|
|
410
|
+
)
|
|
411
|
+
runtime_approval.unlink(missing_ok=True)
|
|
412
|
+
tunnel.ssh(("rm", "-f", "--", remote_approval), timeout=60)
|
|
413
|
+
_require_receipt(runtime_receipt, "runtime")
|
|
414
|
+
if runtime_receipt.get("operation") != "runtime-cluster":
|
|
415
|
+
break
|
|
416
|
+
if runtime_phase == 1:
|
|
417
|
+
raise ValueError("fresh AKS runtime ordering did not reach the association")
|
|
418
|
+
progress_detail(
|
|
419
|
+
"Planning Container Insights association after AKS cluster creation"
|
|
420
|
+
)
|
|
421
|
+
if runtime_receipt is None:
|
|
422
|
+
raise ValueError("runtime apply receipt is unavailable")
|
|
423
|
+
_require_receipt(runtime_receipt, "runtime")
|
|
424
|
+
if selected_runtime.runtime_platform.value == "aks" and runtime_receipt is None:
|
|
425
|
+
raise ValueError("runtime apply receipt is unavailable")
|
|
426
|
+
runtime_receipt_digest = ""
|
|
427
|
+
if selected_runtime.runtime_platform.value == "aks":
|
|
428
|
+
if runtime_receipt is None:
|
|
429
|
+
raise ValueError("runtime apply receipt is unavailable")
|
|
430
|
+
runtime_receipt_digest = str(runtime_receipt["receipt_digest"])
|
|
431
|
+
binding_receipt = _remote_json(
|
|
432
|
+
tunnel,
|
|
433
|
+
remote_root,
|
|
434
|
+
app_work,
|
|
435
|
+
("deployment-binding",),
|
|
436
|
+
timeout=300,
|
|
437
|
+
)
|
|
438
|
+
deployment_binding = str(binding_receipt.get("deployment_binding", ""))
|
|
439
|
+
installation_binding = str(binding_receipt.get("installation_binding", ""))
|
|
440
|
+
if (
|
|
441
|
+
binding_receipt.get("terraform_name_verified") is not True
|
|
442
|
+
or re.fullmatch(r"[0-9a-f]{64}", deployment_binding) is None
|
|
443
|
+
or re.fullmatch(r"[0-9a-f]{64}", installation_binding) is None
|
|
444
|
+
):
|
|
445
|
+
raise ValueError("standalone deployment binding is not verified")
|
|
446
|
+
begin_stage("images")
|
|
447
|
+
progress_detail("Importing runtime images and reading back their digests")
|
|
448
|
+
image_receipt = _remote_json(
|
|
449
|
+
tunnel,
|
|
450
|
+
remote_root,
|
|
451
|
+
app_work,
|
|
452
|
+
("import-images",),
|
|
453
|
+
timeout=5400,
|
|
454
|
+
)
|
|
455
|
+
image_digests = _mapping(image_receipt.get("image_digests"), "image import receipt")
|
|
456
|
+
core_digest = str(image_digests["core-control-plane"]).removeprefix("sha256:")
|
|
457
|
+
if selected_runtime.database_placement.value == "postgres-aks":
|
|
458
|
+
begin_stage("database")
|
|
459
|
+
progress_detail("Preparing and planning compact in-cluster PostgreSQL")
|
|
460
|
+
_remote_json(
|
|
461
|
+
tunnel,
|
|
462
|
+
remote_root,
|
|
463
|
+
app_work,
|
|
464
|
+
("prepare-database",),
|
|
465
|
+
timeout=1800,
|
|
466
|
+
)
|
|
467
|
+
database_recovery = _remote_json(
|
|
468
|
+
tunnel,
|
|
469
|
+
remote_root,
|
|
470
|
+
app_work,
|
|
471
|
+
("recover-apply", "--stage", "database"),
|
|
472
|
+
timeout=3600,
|
|
473
|
+
)
|
|
474
|
+
if database_recovery.get("state") == "applied":
|
|
475
|
+
database_receipt = database_recovery
|
|
476
|
+
else:
|
|
477
|
+
database_plan, database_apply_command = _plan_after_recovery(
|
|
478
|
+
tunnel,
|
|
479
|
+
remote_root,
|
|
480
|
+
app_work,
|
|
481
|
+
database_recovery,
|
|
482
|
+
stage="database",
|
|
483
|
+
timeout=3600,
|
|
484
|
+
)
|
|
485
|
+
deadline.remaining()
|
|
486
|
+
database_approval = _approve_plan(prepared.root, database_plan, deadline=deadline)
|
|
487
|
+
tunnel.ssh(("rm", "-f", "--", remote_approval), timeout=60)
|
|
488
|
+
tunnel.copy_to(database_approval, remote_approval, timeout=120)
|
|
489
|
+
database_receipt = _remote_json(
|
|
490
|
+
tunnel,
|
|
491
|
+
remote_root,
|
|
492
|
+
app_work,
|
|
493
|
+
(
|
|
494
|
+
database_apply_command,
|
|
495
|
+
"--stage",
|
|
496
|
+
"database",
|
|
497
|
+
"--approval",
|
|
498
|
+
remote_approval,
|
|
499
|
+
),
|
|
500
|
+
timeout=3600,
|
|
501
|
+
)
|
|
502
|
+
database_approval.unlink(missing_ok=True)
|
|
503
|
+
tunnel.ssh(("rm", "-f", "--", remote_approval), timeout=60)
|
|
504
|
+
_require_receipt(database_receipt, "database")
|
|
505
|
+
begin_stage("capability")
|
|
506
|
+
aks = selected_runtime.runtime_platform.value == "aks"
|
|
507
|
+
token = _license_token(
|
|
508
|
+
trial_token=trial_token,
|
|
509
|
+
image_digest=core_digest,
|
|
510
|
+
deployment_binding=deployment_binding,
|
|
511
|
+
installation_binding=installation_binding if aks else None,
|
|
512
|
+
work_ref=work_ref,
|
|
513
|
+
)
|
|
514
|
+
license_mode = "observation-only"
|
|
515
|
+
if token is not None:
|
|
516
|
+
license_receipt = _remote_json(
|
|
517
|
+
tunnel,
|
|
518
|
+
remote_root,
|
|
519
|
+
app_work,
|
|
520
|
+
(
|
|
521
|
+
"install-license",
|
|
522
|
+
"--image-digest",
|
|
523
|
+
core_digest,
|
|
524
|
+
"--deployment-binding",
|
|
525
|
+
deployment_binding,
|
|
526
|
+
*(("--installation-binding", installation_binding) if aks else ()),
|
|
527
|
+
),
|
|
528
|
+
timeout=300,
|
|
529
|
+
input_text=token,
|
|
530
|
+
)
|
|
531
|
+
token = ""
|
|
532
|
+
if (
|
|
533
|
+
license_receipt.get("secret_metadata_verified") is not True
|
|
534
|
+
or license_receipt.get("secret_content_verified") is not True
|
|
535
|
+
):
|
|
536
|
+
raise ValueError("standalone license installation was not verified")
|
|
537
|
+
license_mode = "licensed"
|
|
538
|
+
else:
|
|
539
|
+
progress_detail(
|
|
540
|
+
"No capability token; the keyless Trial opens after the application deploys"
|
|
541
|
+
if aks
|
|
542
|
+
else "Observation-only mode; no license secret is installed"
|
|
543
|
+
)
|
|
544
|
+
begin_stage("migration")
|
|
545
|
+
progress_detail("Running database migrations and materializing catalogs")
|
|
546
|
+
migration_receipt = _remote_json(
|
|
547
|
+
tunnel,
|
|
548
|
+
remote_root,
|
|
549
|
+
app_work,
|
|
550
|
+
("migrate",),
|
|
551
|
+
timeout=5400,
|
|
552
|
+
)
|
|
553
|
+
if (
|
|
554
|
+
migration_receipt.get("state") != "migrated"
|
|
555
|
+
or migration_receipt.get("catalogs_materialized") is not True
|
|
556
|
+
):
|
|
557
|
+
raise ValueError("standalone database and catalog bootstrap is incomplete")
|
|
558
|
+
begin_stage("application")
|
|
559
|
+
progress_detail("Recovering by verification, or planning the application")
|
|
560
|
+
if selected_runtime.runtime_platform.value == "aks":
|
|
561
|
+
_remote_json(
|
|
562
|
+
tunnel,
|
|
563
|
+
remote_root,
|
|
564
|
+
app_work,
|
|
565
|
+
("prepare-application",),
|
|
566
|
+
timeout=1800,
|
|
567
|
+
)
|
|
568
|
+
application_recovery = _remote_json(
|
|
569
|
+
tunnel,
|
|
570
|
+
remote_root,
|
|
571
|
+
app_work,
|
|
572
|
+
("recover-apply", "--stage", "application"),
|
|
573
|
+
timeout=3600,
|
|
574
|
+
)
|
|
575
|
+
if application_recovery.get("state") == "applied":
|
|
576
|
+
application_receipt = application_recovery
|
|
577
|
+
else:
|
|
578
|
+
application_plan, application_apply_command = _plan_after_recovery(
|
|
579
|
+
tunnel,
|
|
580
|
+
remote_root,
|
|
581
|
+
app_work,
|
|
582
|
+
application_recovery,
|
|
583
|
+
stage="application",
|
|
584
|
+
timeout=3600,
|
|
585
|
+
)
|
|
586
|
+
if application_state_adoption is not None:
|
|
587
|
+
_require_nondestructive_adoption_plan(application_plan)
|
|
588
|
+
deadline.remaining()
|
|
589
|
+
application_approval = _approve_plan(prepared.root, application_plan, deadline=deadline)
|
|
590
|
+
tunnel.ssh(("rm", "-f", "--", remote_approval), timeout=60)
|
|
591
|
+
tunnel.copy_to(application_approval, remote_approval, timeout=120)
|
|
592
|
+
progress_detail("Applying the approved application plan and verifying its effects")
|
|
593
|
+
application_receipt = _remote_json(
|
|
594
|
+
tunnel,
|
|
595
|
+
remote_root,
|
|
596
|
+
app_work,
|
|
597
|
+
(
|
|
598
|
+
application_apply_command,
|
|
599
|
+
"--stage",
|
|
600
|
+
"application",
|
|
601
|
+
"--approval",
|
|
602
|
+
remote_approval,
|
|
603
|
+
),
|
|
604
|
+
timeout=7200,
|
|
605
|
+
)
|
|
606
|
+
application_approval.unlink(missing_ok=True)
|
|
607
|
+
tunnel.ssh(("rm", "-f", "--", remote_approval), timeout=60)
|
|
608
|
+
_require_receipt(application_receipt, "application")
|
|
609
|
+
post_application = standalone_catalog_checkpoint.run_post_application_checkpoints(
|
|
610
|
+
tunnel,
|
|
611
|
+
remote_root=remote_root,
|
|
612
|
+
app_work=app_work,
|
|
613
|
+
runtime_platform=selected_runtime.runtime_platform.value,
|
|
614
|
+
remote_json=_remote_json,
|
|
615
|
+
)
|
|
616
|
+
initial_inventory = post_application.inventory
|
|
617
|
+
catalog_review = post_application.catalog_review
|
|
618
|
+
if license_mode == "observation-only" and post_application.trial_open:
|
|
619
|
+
license_mode = "trial"
|
|
620
|
+
begin_stage("verification")
|
|
621
|
+
progress_detail("Checking service health and a second zero-change Terraform plan")
|
|
622
|
+
verification = _remote_json(
|
|
623
|
+
tunnel,
|
|
624
|
+
remote_root,
|
|
625
|
+
app_work,
|
|
626
|
+
("verify",),
|
|
627
|
+
timeout=3600,
|
|
628
|
+
)
|
|
629
|
+
if (
|
|
630
|
+
verification.get("terraform_zero_change_verified") is not True
|
|
631
|
+
or verification.get("runtime_health_verified") is not True
|
|
632
|
+
):
|
|
633
|
+
raise ValueError("standalone application convergence is incomplete")
|
|
634
|
+
console_receipt: dict[str, object] | None = None
|
|
635
|
+
if selected_runtime.console_selected and selected_runtime.runtime_platform.value == "aks":
|
|
636
|
+
if entra_bindings is None:
|
|
637
|
+
raise ValueError("read-only Console requires enterprise identity bindings")
|
|
638
|
+
browser_console = _mapping(
|
|
639
|
+
verification.get("browser_console"), "browser Console verification"
|
|
640
|
+
)
|
|
641
|
+
progress_detail("Registering the Console redirect and publishing verified static files")
|
|
642
|
+
entra = _import_entra(scripts)
|
|
643
|
+
redirect_changed = entra.ensure_console_spa_redirect(
|
|
644
|
+
str(entra_bindings["ENTRA_CONSOLE_SPA_CLIENT_ID"]),
|
|
645
|
+
str(browser_console["console_origin"]),
|
|
646
|
+
)
|
|
647
|
+
if source_mode:
|
|
648
|
+
if source_root is None:
|
|
649
|
+
raise ValueError("source Console build requires a verified snapshot")
|
|
650
|
+
console_archive, console_archive_sha256 = build_source_console(
|
|
651
|
+
source_root=source_root,
|
|
652
|
+
source_commit=prepared.source_commit,
|
|
653
|
+
prepared_root=prepared.root,
|
|
654
|
+
timeout_seconds=deadline.remaining(1800),
|
|
655
|
+
)
|
|
656
|
+
assert source_snapshot is not None
|
|
657
|
+
bundle_root = source_snapshot / "tree"
|
|
658
|
+
else:
|
|
659
|
+
if kit is None:
|
|
660
|
+
raise ValueError("signed Console publication requires the verified kit")
|
|
661
|
+
runtime = kit.runtime.to_mapping()
|
|
662
|
+
console_artifact = _mapping(runtime.get("console"), "runtime Console artifact")
|
|
663
|
+
console_archive = kit.materialized_root / str(console_artifact["archive"])
|
|
664
|
+
console_archive_sha256 = str(console_artifact["archive_sha256"])
|
|
665
|
+
bundle_root = kit.bundle_root
|
|
666
|
+
console_receipt = publish_verified_console(
|
|
667
|
+
console_archive=console_archive,
|
|
668
|
+
console_archive_sha256=console_archive_sha256,
|
|
669
|
+
bundle_root=bundle_root,
|
|
670
|
+
prepared_root=prepared.root,
|
|
671
|
+
entra_bindings=entra_bindings,
|
|
672
|
+
browser_console=browser_console,
|
|
673
|
+
scripts=scripts,
|
|
674
|
+
subscription_id=str(handoff["subscription_id"]),
|
|
675
|
+
tenant_id=str(handoff["tenant_id"]),
|
|
676
|
+
timeout_seconds=deadline.remaining(),
|
|
677
|
+
redirect_changed=redirect_changed,
|
|
678
|
+
)
|
|
679
|
+
if source_mode:
|
|
680
|
+
assert source_transfer is not None
|
|
681
|
+
transient_paths = (
|
|
682
|
+
f"{remote_root}/{source_transfer.archive.name}",
|
|
683
|
+
f"{remote_root}/source-receiver.pyz",
|
|
684
|
+
)
|
|
685
|
+
else:
|
|
686
|
+
transient_paths = (remote_archive,)
|
|
687
|
+
cleanup_remote_transfers(
|
|
688
|
+
tunnel,
|
|
689
|
+
transient_paths=transient_paths,
|
|
690
|
+
remote_approval=remote_approval,
|
|
691
|
+
prune=lambda: remote_prune(tunnel, remote_root=remote_root, work_dir=app_work),
|
|
692
|
+
)
|
|
693
|
+
receipt: dict[str, object] = {
|
|
694
|
+
"schema_version": "fdai.standalone-application-terminal-receipt.v2",
|
|
695
|
+
"state": "application-converged",
|
|
696
|
+
"source_commit": prepared.source_commit,
|
|
697
|
+
"target_binding": prepared.target_binding,
|
|
698
|
+
"substrate_receipt_digest": substrate_receipt["receipt_digest"],
|
|
699
|
+
"initial_inventory_receipt_digest": initial_inventory["receipt_digest"],
|
|
700
|
+
"catalog_review_receipt_digest": catalog_review["receipt_digest"],
|
|
701
|
+
"catalog_review_state": catalog_review["state"],
|
|
702
|
+
"runtime_receipt_digest": runtime_receipt_digest,
|
|
703
|
+
"database_receipt_digest": (
|
|
704
|
+
database_receipt["receipt_digest"]
|
|
705
|
+
if selected_runtime.database_placement.value == "postgres-aks"
|
|
706
|
+
else ""
|
|
707
|
+
),
|
|
708
|
+
"image_import_receipt_digest": image_receipt["receipt_digest"],
|
|
709
|
+
"migration_receipt_digest": migration_receipt["receipt_digest"],
|
|
710
|
+
"application_receipt_digest": application_receipt["receipt_digest"],
|
|
711
|
+
"verification_receipt_digest": verification["receipt_digest"],
|
|
712
|
+
"console_publication_receipt_digest": (
|
|
713
|
+
console_receipt["receipt_digest"] if console_receipt is not None else ""
|
|
714
|
+
),
|
|
715
|
+
"runtime_profile_digest": selected_runtime.digest,
|
|
716
|
+
"runtime_platform": selected_runtime.runtime_platform.value,
|
|
717
|
+
"database_placement": selected_runtime.database_placement.value,
|
|
718
|
+
"product_profile": selected_runtime.product_profile.model_dump(mode="json"),
|
|
719
|
+
"remote_transient_cleanup_verified": True,
|
|
720
|
+
"application_state_adopted": application_state_adoption is not None,
|
|
721
|
+
"application_state_adoption_descriptor_digest": adoption_descriptor_digest,
|
|
722
|
+
"application_converged": True,
|
|
723
|
+
"browser_access_verified": console_receipt is not None,
|
|
724
|
+
"deployment_ready": True,
|
|
725
|
+
"inventory_ready": True,
|
|
726
|
+
"license_mode": license_mode,
|
|
727
|
+
"provenance": "operator-selected-source" if source_mode else "signed-release",
|
|
728
|
+
"release_signature_verified": not source_mode,
|
|
729
|
+
"mutation_performed": True,
|
|
730
|
+
"subscription_ready": False,
|
|
731
|
+
}
|
|
732
|
+
if source_mode:
|
|
733
|
+
receipt["source_snapshot_digest"] = source_snapshot_digest
|
|
734
|
+
elif kit is not None:
|
|
735
|
+
kit_verification = getattr(kit, "verification", None)
|
|
736
|
+
kit_runtime = getattr(kit, "runtime", None)
|
|
737
|
+
if kit_verification is not None:
|
|
738
|
+
receipt["kit_manifest_digest"] = kit_verification.manifest_digest
|
|
739
|
+
if kit_runtime is not None:
|
|
740
|
+
receipt["runtime_release_digest"] = kit_runtime.digest
|
|
741
|
+
if hub_selection is not None:
|
|
742
|
+
receipt.update(hub_selection.metadata())
|
|
743
|
+
receipt["receipt_digest"] = canonical_digest(receipt)
|
|
744
|
+
deadline.remaining()
|
|
745
|
+
_replace_private_json(prepared.root / "standalone-application-receipt.json", receipt)
|
|
746
|
+
return receipt
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _plan_after_recovery(
|
|
750
|
+
tunnel: Any,
|
|
751
|
+
remote_root: str,
|
|
752
|
+
app_work: str,
|
|
753
|
+
recovery: dict[str, Any],
|
|
754
|
+
*,
|
|
755
|
+
stage: str,
|
|
756
|
+
timeout: int,
|
|
757
|
+
) -> tuple[dict[str, Any], str]:
|
|
758
|
+
"""Use a separately bound residual review, or request an ordinary current plan."""
|
|
759
|
+
|
|
760
|
+
if isinstance(recovery.get("residual_successor"), dict):
|
|
761
|
+
return recovery, "apply-residual"
|
|
762
|
+
if isinstance(recovery.get("residual_recovery"), dict):
|
|
763
|
+
return recovery, "apply-residual"
|
|
764
|
+
return (
|
|
765
|
+
_remote_json(
|
|
766
|
+
tunnel,
|
|
767
|
+
remote_root,
|
|
768
|
+
app_work,
|
|
769
|
+
("plan", "--stage", stage),
|
|
770
|
+
timeout=timeout,
|
|
771
|
+
),
|
|
772
|
+
"apply",
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _remote_json(
|
|
777
|
+
tunnel: Any,
|
|
778
|
+
remote_root: str,
|
|
779
|
+
app_work: str,
|
|
780
|
+
arguments: tuple[str, ...],
|
|
781
|
+
*,
|
|
782
|
+
timeout: int,
|
|
783
|
+
input_text: str | None = None,
|
|
784
|
+
) -> dict[str, Any]:
|
|
785
|
+
host_program = getattr(tunnel, "host_program", None)
|
|
786
|
+
invocation = (
|
|
787
|
+
(str(host_program),)
|
|
788
|
+
if isinstance(host_program, str) and host_program
|
|
789
|
+
else ("-m", "fdai_deployment_cli.standalone_host")
|
|
790
|
+
)
|
|
791
|
+
result = tunnel.ssh(
|
|
792
|
+
(
|
|
793
|
+
f"{remote_root}/venv/bin/python",
|
|
794
|
+
*invocation,
|
|
795
|
+
"--work-dir",
|
|
796
|
+
app_work,
|
|
797
|
+
*arguments,
|
|
798
|
+
),
|
|
799
|
+
timeout=timeout,
|
|
800
|
+
input_text=input_text,
|
|
801
|
+
)
|
|
802
|
+
if result.returncode != 0:
|
|
803
|
+
failure = _remote_failure(result)
|
|
804
|
+
if failure is not None:
|
|
805
|
+
codes = failure.get("provider_error_codes")
|
|
806
|
+
code_text = (
|
|
807
|
+
f"; provider_error_code={','.join(str(code) for code in codes)}"
|
|
808
|
+
if isinstance(codes, list) and codes
|
|
809
|
+
else ""
|
|
810
|
+
)
|
|
811
|
+
raise ValueError(
|
|
812
|
+
"standalone managed-host checkpoint failed: "
|
|
813
|
+
f"reason_code={failure['reason_code']}{code_text}; "
|
|
814
|
+
f"excerpt={failure['message_excerpt']}"
|
|
815
|
+
)
|
|
816
|
+
raise ValueError("standalone managed-host checkpoint failed")
|
|
817
|
+
try:
|
|
818
|
+
value = json.loads(result.stdout)
|
|
819
|
+
except json.JSONDecodeError as exc:
|
|
820
|
+
raise ValueError("standalone managed-host checkpoint returned invalid output") from exc
|
|
821
|
+
return _mapping(value, "standalone managed-host result")
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _require_nondestructive_adoption_plan(review: dict[str, Any]) -> None:
|
|
825
|
+
_stage, destructive = validate_plan_review(review)
|
|
826
|
+
if destructive:
|
|
827
|
+
raise ValueError("recovered application state requires a zero-destroy plan")
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
@terminal_output("Review the exact application plan", approval=True)
|
|
831
|
+
def _approve_plan(
|
|
832
|
+
root: Path, review: dict[str, Any], *, deadline: DeploymentDeadline | None = None
|
|
833
|
+
) -> Path:
|
|
834
|
+
"""Record invocation approval; require one exact confirmation for delete or replace."""
|
|
835
|
+
|
|
836
|
+
stage, destructive = validate_plan_review(review)
|
|
837
|
+
approval_deadline = DeploymentDeadline(
|
|
838
|
+
min(600, deadline.remaining() if deadline is not None else 600), clock=time.monotonic
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
def approval_seconds(maximum: int = 600) -> int:
|
|
842
|
+
validate_plan_review(review)
|
|
843
|
+
plan_remaining = int(
|
|
844
|
+
(_parse_moment(str(review["expires_at"])) - datetime.now(UTC)).total_seconds()
|
|
845
|
+
)
|
|
846
|
+
remaining = min(plan_remaining, approval_deadline.remaining(maximum))
|
|
847
|
+
if remaining <= 0:
|
|
848
|
+
raise TimeoutError("standalone approval deadline expired; no approval was granted")
|
|
849
|
+
return remaining
|
|
850
|
+
|
|
851
|
+
operation, actor_before = bootstrap_approval.approve_application_checkpoint(
|
|
852
|
+
review,
|
|
853
|
+
stage,
|
|
854
|
+
destructive,
|
|
855
|
+
actor_digest=lambda: _azure_actor_digest(
|
|
856
|
+
str(review["target_binding"]), timeout_seconds=approval_seconds(60)
|
|
857
|
+
),
|
|
858
|
+
)
|
|
859
|
+
expected = f"{operation}-apply"
|
|
860
|
+
injected = actor_before is not None
|
|
861
|
+
print(json.dumps(review, indent=2, sort_keys=True), file=sys.stderr)
|
|
862
|
+
# Constitution Article 1: the invocation approves a plan; only destruction needs confirmation.
|
|
863
|
+
print(f"Approved by this invocation: {expected}", file=sys.stderr, flush=True)
|
|
864
|
+
if destructive and not injected:
|
|
865
|
+
print(
|
|
866
|
+
f"Plan contains {destructive} delete or replacement action(s); type "
|
|
867
|
+
f"{expected}-destructive to approve: ",
|
|
868
|
+
end="",
|
|
869
|
+
file=sys.stderr,
|
|
870
|
+
flush=True,
|
|
871
|
+
)
|
|
872
|
+
confirmation = _approval_input(timeout_seconds=approval_seconds())
|
|
873
|
+
if confirmation != f"{expected}-destructive":
|
|
874
|
+
raise ValueError("standalone destructive application plan approval was denied")
|
|
875
|
+
actor = _azure_actor_digest(str(review["target_binding"]), timeout_seconds=approval_seconds(60))
|
|
876
|
+
if injected and actor != actor_before:
|
|
877
|
+
raise ValueError("Azure operator changed during exact application plan review")
|
|
878
|
+
approval_deadline.remaining()
|
|
879
|
+
validate_plan_review(review)
|
|
880
|
+
now = datetime.now(UTC).replace(microsecond=0)
|
|
881
|
+
expires = min(_parse_moment(str(review["expires_at"])), now + timedelta(hours=1))
|
|
882
|
+
approval = {
|
|
883
|
+
"schema_version": "fdai.standalone-plan-approval.v1",
|
|
884
|
+
"stage": stage,
|
|
885
|
+
"plan_digest": review["plan_digest"],
|
|
886
|
+
"review_digest": review["review_digest"],
|
|
887
|
+
"target_binding": review["target_binding"],
|
|
888
|
+
"source_commit": review["source_commit"],
|
|
889
|
+
"actor_digest": actor,
|
|
890
|
+
"approved_at": _moment(now),
|
|
891
|
+
"expires_at": _moment(expires),
|
|
892
|
+
}
|
|
893
|
+
path = root / f"{operation}-approval.json"
|
|
894
|
+
path.unlink(missing_ok=True)
|
|
895
|
+
write_private_output(path, json.dumps(approval, sort_keys=True, separators=(",", ":")) + "\n")
|
|
896
|
+
return path
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
def _approval_input(*, timeout_seconds: int = 600) -> str:
|
|
900
|
+
"""Treat a closed input stream as denial, never as approval or an implicit retry."""
|
|
901
|
+
|
|
902
|
+
_wait_for_approval_input(timeout_seconds)
|
|
903
|
+
try:
|
|
904
|
+
return input("").strip()
|
|
905
|
+
except EOFError as exc:
|
|
906
|
+
raise ValueError("approval input closed; no new approval was granted") from exc
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def _require_receipt(value: dict[str, Any], stage: str) -> None:
|
|
910
|
+
if (
|
|
911
|
+
value.get("state") != "applied"
|
|
912
|
+
or value.get("stage") != stage
|
|
913
|
+
or value.get("control_plane_readback_verified") is not True
|
|
914
|
+
or not isinstance(value.get("receipt_digest"), str)
|
|
915
|
+
or _DIGEST.fullmatch(str(value["receipt_digest"])) is None
|
|
916
|
+
):
|
|
917
|
+
raise ValueError(f"standalone {stage} apply receipt is invalid")
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def _import_bastion(scripts: Path) -> Any:
|
|
921
|
+
sys.path.insert(0, str(scripts))
|
|
922
|
+
try:
|
|
923
|
+
return importlib.import_module("genesis_bastion")
|
|
924
|
+
finally:
|
|
925
|
+
sys.path.remove(str(scripts))
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def _import_entra(scripts: Path) -> Any:
|
|
929
|
+
sys.path.insert(0, str(scripts))
|
|
930
|
+
try:
|
|
931
|
+
return importlib.import_module("genesis_entra")
|
|
932
|
+
finally:
|
|
933
|
+
sys.path.remove(str(scripts))
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def _azure_actor_digest(target_binding: str, *, timeout_seconds: int = 60) -> str:
|
|
937
|
+
return azure_actor.actor_digest(
|
|
938
|
+
target_binding,
|
|
939
|
+
timeout_seconds=timeout_seconds,
|
|
940
|
+
runner=subprocess.run,
|
|
941
|
+
)
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def _private_json(path: Path, label: str) -> dict[str, Any]:
|
|
945
|
+
try:
|
|
946
|
+
value = json.loads(read_private_bytes(path, max_bytes=4 * 1024 * 1024))
|
|
947
|
+
except json.JSONDecodeError as exc:
|
|
948
|
+
raise ValueError(f"{label} is invalid") from exc
|
|
949
|
+
return _mapping(value, label)
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def _replace_private_json(path: Path, value: dict[str, object] | dict[str, str]) -> None:
|
|
953
|
+
temporary = path.parent / f".{path.name}.tmp-{os.getpid()}"
|
|
954
|
+
temporary.unlink(missing_ok=True)
|
|
955
|
+
write_private_output(
|
|
956
|
+
temporary,
|
|
957
|
+
json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n",
|
|
958
|
+
)
|
|
959
|
+
os.replace(temporary, path)
|
|
960
|
+
path.chmod(0o600)
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def _mapping(value: object, label: str) -> dict[str, Any]:
|
|
964
|
+
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
|
|
965
|
+
raise ValueError(f"{label} is invalid")
|
|
966
|
+
return {str(key): item for key, item in value.items()}
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
def _moment(value: datetime) -> str:
|
|
970
|
+
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def _parse_moment(value: str) -> datetime:
|
|
974
|
+
result = datetime.fromisoformat(value)
|
|
975
|
+
if result.tzinfo is None:
|
|
976
|
+
raise ValueError("plan expiry is invalid")
|
|
977
|
+
return result.astimezone(UTC)
|