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,237 @@
|
|
|
1
|
+
"""Remove managed-host transfer residue after a converged standalone application run."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import stat
|
|
11
|
+
import sys
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from fdai_deployment_cli.deployment_progress import begin_stage, progress_detail
|
|
17
|
+
from fdai_deployment_cli.standalone_host_state import (
|
|
18
|
+
absolute,
|
|
19
|
+
acquire_checkpoint_lock,
|
|
20
|
+
private_directory,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
PRUNE_SCHEMA = "fdai.standalone-transfer-prune.v1"
|
|
24
|
+
_MODULE = "fdai_deployment_cli.standalone_transfer_cleanup"
|
|
25
|
+
_TRANSFER = re.compile(r"\.fdai-transfer-[0-9a-f]{24}")
|
|
26
|
+
_CLAIM_SUFFIX = "-claim.json"
|
|
27
|
+
# Re-derivable copies and credential caches; claims, receipts, reviews, plans, and migration
|
|
28
|
+
# evidence stay with any directory whose claim has no receipt.
|
|
29
|
+
_DISPOSABLE_ROOT = ("kit", "kit.tar.gz", "venv")
|
|
30
|
+
_DISPOSABLE_APPLICATION = ("kit-work", "oci", "runtime-venv", "azure", "aks.kubeconfig")
|
|
31
|
+
_PROVIDER_DATA_PREFIX = "terraform-data"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def cleanup_remote_transfers(
|
|
35
|
+
tunnel: Any,
|
|
36
|
+
*,
|
|
37
|
+
transient_paths: tuple[str, ...],
|
|
38
|
+
remote_approval: str,
|
|
39
|
+
prune: Callable[[], dict[str, Any]],
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Remove this run's transient files, then prune transfers that earlier kits left."""
|
|
42
|
+
|
|
43
|
+
if not transient_paths:
|
|
44
|
+
raise ValueError("standalone remote transient cleanup has no transfer paths")
|
|
45
|
+
begin_stage("cleanup")
|
|
46
|
+
progress_detail("Removing transient transfers and verifying their absence")
|
|
47
|
+
cleanup = tunnel.ssh(("rm", "-f", "--", *transient_paths, remote_approval), timeout=300)
|
|
48
|
+
absent = [
|
|
49
|
+
tunnel.ssh(("test", "!", "-e", path), timeout=60)
|
|
50
|
+
for path in (*transient_paths, remote_approval)
|
|
51
|
+
]
|
|
52
|
+
if any(result.returncode != 0 for result in (cleanup, *absent)):
|
|
53
|
+
raise ValueError("standalone remote transient cleanup is incomplete")
|
|
54
|
+
try:
|
|
55
|
+
report = validate_prune_report(prune())
|
|
56
|
+
except ValueError as exc:
|
|
57
|
+
progress_detail(f"Superseded managed-host transfers were not pruned: {exc}")
|
|
58
|
+
return
|
|
59
|
+
freed = max(0, report["free_bytes_after"] - report["free_bytes_before"])
|
|
60
|
+
progress_detail(
|
|
61
|
+
f"Pruned {len(report['removed'])} superseded transfer(s), kept the evidence of "
|
|
62
|
+
f"{len(report['preserved'])}, skipped {len(report['skipped'])}; "
|
|
63
|
+
f"{freed / 1_000_000_000:.1f} GB freed"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def validate_prune_report(value: object) -> dict[str, Any]:
|
|
68
|
+
"""Accept only the bounded host prune report this package produces."""
|
|
69
|
+
|
|
70
|
+
if not isinstance(value, dict) or value.get("schema_version") != PRUNE_SCHEMA:
|
|
71
|
+
raise ValueError("managed-host prune report is invalid")
|
|
72
|
+
removed, preserved, skipped = value.get("removed"), value.get("preserved"), value.get("skipped")
|
|
73
|
+
if (
|
|
74
|
+
not isinstance(removed, list)
|
|
75
|
+
or not isinstance(preserved, list)
|
|
76
|
+
or not isinstance(skipped, list)
|
|
77
|
+
or any(not isinstance(name, str) or _TRANSFER.fullmatch(name) is None for name in removed)
|
|
78
|
+
or any(
|
|
79
|
+
not isinstance(item, dict)
|
|
80
|
+
or _TRANSFER.fullmatch(str(item.get("name"))) is None
|
|
81
|
+
or not isinstance(item.get("unmatched_claims"), list)
|
|
82
|
+
or not item["unmatched_claims"]
|
|
83
|
+
for item in preserved
|
|
84
|
+
)
|
|
85
|
+
or any(
|
|
86
|
+
not isinstance(item, dict) or _TRANSFER.fullmatch(str(item.get("name"))) is None
|
|
87
|
+
for item in skipped
|
|
88
|
+
)
|
|
89
|
+
or any(type(value.get(key)) is not int for key in ("free_bytes_before", "free_bytes_after"))
|
|
90
|
+
):
|
|
91
|
+
raise ValueError("managed-host prune report is invalid")
|
|
92
|
+
return value
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def prune_superseded_transfers(work_dir: Path) -> dict[str, object]:
|
|
96
|
+
"""Prune transfer directories from earlier kits once the current application converged."""
|
|
97
|
+
|
|
98
|
+
current = work_dir.parent
|
|
99
|
+
home = current.parent
|
|
100
|
+
if work_dir.name != "application" or _TRANSFER.fullmatch(current.name) is None:
|
|
101
|
+
raise ValueError("managed-host transfer layout is invalid")
|
|
102
|
+
if not (work_dir / "application-receipt.json").is_file():
|
|
103
|
+
raise ValueError("superseded transfers are pruned only after the application converges")
|
|
104
|
+
free_before = shutil.disk_usage(home).free
|
|
105
|
+
removed: list[str] = []
|
|
106
|
+
preserved: list[dict[str, object]] = []
|
|
107
|
+
skipped: list[dict[str, str]] = []
|
|
108
|
+
for entry in sorted(home.iterdir(), key=lambda path: path.name):
|
|
109
|
+
if entry.name == current.name or _TRANSFER.fullmatch(entry.name) is None:
|
|
110
|
+
continue
|
|
111
|
+
reason = _prune_one(entry, removed, preserved)
|
|
112
|
+
if reason is not None:
|
|
113
|
+
skipped.append({"name": entry.name, "reason": reason})
|
|
114
|
+
return {
|
|
115
|
+
"schema_version": PRUNE_SCHEMA,
|
|
116
|
+
"removed": removed,
|
|
117
|
+
"preserved": preserved,
|
|
118
|
+
"skipped": skipped,
|
|
119
|
+
"free_bytes_before": free_before,
|
|
120
|
+
"free_bytes_after": shutil.disk_usage(home).free,
|
|
121
|
+
"mutation_performed": bool(removed or preserved),
|
|
122
|
+
"subscription_ready": False,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _prune_one(entry: Path, removed: list[str], preserved: list[dict[str, object]]) -> str | None:
|
|
127
|
+
details = entry.lstat()
|
|
128
|
+
if not stat.S_ISDIR(details.st_mode) or details.st_uid != os.geteuid():
|
|
129
|
+
return "not-an-owned-directory"
|
|
130
|
+
application = entry / "application"
|
|
131
|
+
if os.path.lexists(application) and (application.is_symlink() or not application.is_dir()):
|
|
132
|
+
return "unexpected-layout"
|
|
133
|
+
lock: int | None = None
|
|
134
|
+
try:
|
|
135
|
+
if application.is_dir():
|
|
136
|
+
lock = acquire_checkpoint_lock(application)
|
|
137
|
+
unmatched = _unmatched_claims(application)
|
|
138
|
+
if unmatched:
|
|
139
|
+
for path in _disposable_paths(entry, application):
|
|
140
|
+
_remove(path)
|
|
141
|
+
preserved.append({"name": entry.name, "unmatched_claims": unmatched})
|
|
142
|
+
return None
|
|
143
|
+
_remove(entry)
|
|
144
|
+
removed.append(entry.name)
|
|
145
|
+
return None
|
|
146
|
+
except (OSError, ValueError):
|
|
147
|
+
return "in-use-or-not-removable"
|
|
148
|
+
finally:
|
|
149
|
+
if lock is not None:
|
|
150
|
+
os.close(lock)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _unmatched_claims(application: Path) -> list[str]:
|
|
154
|
+
if not application.is_dir():
|
|
155
|
+
return []
|
|
156
|
+
return sorted(
|
|
157
|
+
claim.name
|
|
158
|
+
for claim in application.glob(f"*{_CLAIM_SUFFIX}")
|
|
159
|
+
if not os.path.lexists(
|
|
160
|
+
application / f"{claim.name.removesuffix(_CLAIM_SUFFIX)}-receipt.json"
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _disposable_paths(entry: Path, application: Path) -> list[Path]:
|
|
166
|
+
paths = [entry / name for name in _DISPOSABLE_ROOT]
|
|
167
|
+
if application.is_dir():
|
|
168
|
+
paths.extend(application / name for name in _DISPOSABLE_APPLICATION)
|
|
169
|
+
paths.extend(
|
|
170
|
+
child for child in application.iterdir() if child.name.startswith(_PROVIDER_DATA_PREFIX)
|
|
171
|
+
)
|
|
172
|
+
return [path for path in paths if os.path.lexists(path)]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _remove(path: Path) -> None:
|
|
176
|
+
if path.is_symlink() or not path.is_dir():
|
|
177
|
+
path.unlink()
|
|
178
|
+
else:
|
|
179
|
+
shutil.rmtree(path)
|
|
180
|
+
if os.path.lexists(path):
|
|
181
|
+
raise OSError("managed-host transfer residue remains after removal")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def remote_prune(
|
|
185
|
+
tunnel: Any, *, remote_root: str, work_dir: str, timeout: int = 1800
|
|
186
|
+
) -> dict[str, Any]:
|
|
187
|
+
"""Run the managed-host prune from the current transfer's environment and validate it."""
|
|
188
|
+
|
|
189
|
+
result = tunnel.ssh(
|
|
190
|
+
(f"{remote_root}/venv/bin/python", "-m", _MODULE, "--work-dir", work_dir),
|
|
191
|
+
timeout=timeout,
|
|
192
|
+
)
|
|
193
|
+
if result.returncode != 0:
|
|
194
|
+
raise ValueError("managed-host prune failed")
|
|
195
|
+
try:
|
|
196
|
+
value = json.loads(result.stdout)
|
|
197
|
+
except (TypeError, json.JSONDecodeError) as exc:
|
|
198
|
+
raise ValueError("managed-host prune report is invalid") from exc
|
|
199
|
+
return validate_prune_report(value)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def main(argv: list[str] | None = None) -> int:
|
|
203
|
+
"""Prune superseded transfers under the current application's checkpoint lock."""
|
|
204
|
+
|
|
205
|
+
parser = argparse.ArgumentParser(prog=f"python -m {_MODULE}")
|
|
206
|
+
parser.add_argument("--work-dir", type=Path, required=True)
|
|
207
|
+
args = parser.parse_args(argv)
|
|
208
|
+
work_dir = absolute(args.work_dir)
|
|
209
|
+
lock: int | None = None
|
|
210
|
+
try:
|
|
211
|
+
if not work_dir.is_dir():
|
|
212
|
+
raise ValueError("managed-host transfer layout is invalid")
|
|
213
|
+
private_directory(work_dir)
|
|
214
|
+
lock = acquire_checkpoint_lock(work_dir)
|
|
215
|
+
report = prune_superseded_transfers(work_dir)
|
|
216
|
+
except (OSError, ValueError):
|
|
217
|
+
print(json.dumps({"schema_version": PRUNE_SCHEMA, "state": "refused"}), file=sys.stderr)
|
|
218
|
+
return 3
|
|
219
|
+
finally:
|
|
220
|
+
if lock is not None:
|
|
221
|
+
os.close(lock)
|
|
222
|
+
print(json.dumps(report, sort_keys=True, separators=(",", ":")))
|
|
223
|
+
return 0
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
__all__ = [
|
|
227
|
+
"PRUNE_SCHEMA",
|
|
228
|
+
"cleanup_remote_transfers",
|
|
229
|
+
"main",
|
|
230
|
+
"prune_superseded_transfers",
|
|
231
|
+
"remote_prune",
|
|
232
|
+
"validate_prune_report",
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
if __name__ == "__main__":
|
|
237
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Open the installation's Trial window during deployment, at its anchored creation time.
|
|
2
|
+
|
|
3
|
+
The window is anchored to the Terraform-recorded first-apply time of the installation,
|
|
4
|
+
never to the time of this run, so a rerun, an upgrade, or a recreated record cannot
|
|
5
|
+
renew it. The Core writer keeps a retained record unchanged and refuses one bound to
|
|
6
|
+
another installation; this step only supplies the bindings and records the outcome.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import subprocess
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from fdai_deployment_cli import standalone_planned_outputs
|
|
21
|
+
from fdai_deployment_cli.contracts import canonical_digest
|
|
22
|
+
from fdai_deployment_cli.standalone_host_state import private_json, replace_private_json
|
|
23
|
+
from fdai_deployment_cli.standalone_host_values import vault_name
|
|
24
|
+
|
|
25
|
+
_SCHEMA = "fdai.standalone-trial-activation-receipt.v1"
|
|
26
|
+
_WRITER_SCHEMA = "fdai.trial-activation.v1"
|
|
27
|
+
_DIGEST = re.compile(r"[0-9a-f]{64}")
|
|
28
|
+
_RUNTIME_NAME = re.compile(r"[a-z][a-z0-9-]{1,62}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def deployment_binding_digest(context: dict[str, Any], runtime_name: str) -> str:
|
|
32
|
+
"""Bind one deployment to its tenant, subscription, and runtime without naming them."""
|
|
33
|
+
|
|
34
|
+
if _RUNTIME_NAME.fullmatch(runtime_name) is None:
|
|
35
|
+
raise ValueError("Terraform runtime name is invalid")
|
|
36
|
+
material = f"{context['tenant_id']}\0{context['subscription_id']}\0{runtime_name}"
|
|
37
|
+
return hashlib.sha256(material.encode()).hexdigest()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def activate_trial(
|
|
41
|
+
context: dict[str, Any],
|
|
42
|
+
work_dir: Path,
|
|
43
|
+
*,
|
|
44
|
+
login: Callable[[dict[str, object], Path], None],
|
|
45
|
+
) -> dict[str, object]:
|
|
46
|
+
"""Run the Core writer once and persist a sanitized, digest-bound receipt."""
|
|
47
|
+
|
|
48
|
+
receipt_path = work_dir / "trial-activation-receipt.json"
|
|
49
|
+
if receipt_path.exists():
|
|
50
|
+
return require_trial_activation_receipt(
|
|
51
|
+
private_json(receipt_path, "standalone Trial activation receipt")
|
|
52
|
+
)
|
|
53
|
+
if not all(
|
|
54
|
+
(work_dir / name).is_file()
|
|
55
|
+
for name in ("migration-receipt.json", "application-receipt.json")
|
|
56
|
+
):
|
|
57
|
+
raise ValueError("Trial activation requires the migrated and applied application")
|
|
58
|
+
deployment_binding = str(context.get("deployment_binding", ""))
|
|
59
|
+
if _DIGEST.fullmatch(deployment_binding) is None:
|
|
60
|
+
raise ValueError("Trial activation requires the prepared deployment binding")
|
|
61
|
+
login(context, work_dir)
|
|
62
|
+
infra = Path(str(context["infra"]))
|
|
63
|
+
installation_binding = _output(infra, "installation_binding")
|
|
64
|
+
if _DIGEST.fullmatch(installation_binding) is None:
|
|
65
|
+
raise ValueError("Terraform installation binding is invalid")
|
|
66
|
+
activated_at = _output(infra, "installation_created_at")
|
|
67
|
+
bundle = infra.parent
|
|
68
|
+
result = subprocess.run(
|
|
69
|
+
(
|
|
70
|
+
str(work_dir / "runtime-venv/bin/python"),
|
|
71
|
+
"-m",
|
|
72
|
+
"fdai.runtime.licensing_trial_activation",
|
|
73
|
+
"--installation-binding",
|
|
74
|
+
installation_binding,
|
|
75
|
+
"--deployment-binding",
|
|
76
|
+
deployment_binding,
|
|
77
|
+
"--activated-at",
|
|
78
|
+
activated_at,
|
|
79
|
+
),
|
|
80
|
+
cwd=bundle,
|
|
81
|
+
env={
|
|
82
|
+
**os.environ,
|
|
83
|
+
"FDAI_STATE_STORE_DSN": _state_store_dsn(infra, work_dir),
|
|
84
|
+
"PYTHONPATH": os.pathsep.join(
|
|
85
|
+
(
|
|
86
|
+
str(bundle / "services/core-control-plane/src"),
|
|
87
|
+
str(bundle / "packages/service-contracts/src"),
|
|
88
|
+
)
|
|
89
|
+
),
|
|
90
|
+
},
|
|
91
|
+
check=False,
|
|
92
|
+
capture_output=True,
|
|
93
|
+
text=True,
|
|
94
|
+
timeout=300,
|
|
95
|
+
)
|
|
96
|
+
if result.returncode != 0:
|
|
97
|
+
raise ValueError("Trial activation failed")
|
|
98
|
+
window = _writer_result(result.stdout)
|
|
99
|
+
receipt: dict[str, object] = {
|
|
100
|
+
"schema_version": _SCHEMA,
|
|
101
|
+
"state": "activated",
|
|
102
|
+
"activated_at": window["activated_at"],
|
|
103
|
+
"expires_at": window["expires_at"],
|
|
104
|
+
"window_ended": window["window_ended"],
|
|
105
|
+
"clock_blocked": window["clock_blocked"],
|
|
106
|
+
"effect_verified": True,
|
|
107
|
+
"mutation_performed": True,
|
|
108
|
+
"subscription_ready": False,
|
|
109
|
+
}
|
|
110
|
+
receipt["receipt_digest"] = canonical_digest(receipt)
|
|
111
|
+
replace_private_json(receipt_path, receipt)
|
|
112
|
+
return receipt
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def require_trial_activation_receipt(value: dict[str, Any]) -> dict[str, Any]:
|
|
116
|
+
"""Accept only a digest-bound receipt for a committed, read-back Trial window."""
|
|
117
|
+
|
|
118
|
+
material = {key: item for key, item in value.items() if key != "receipt_digest"}
|
|
119
|
+
if (
|
|
120
|
+
value.get("receipt_digest") != canonical_digest(material)
|
|
121
|
+
or value.get("schema_version") != _SCHEMA
|
|
122
|
+
or value.get("state") != "activated"
|
|
123
|
+
or not isinstance(value.get("activated_at"), str)
|
|
124
|
+
or not isinstance(value.get("expires_at"), str)
|
|
125
|
+
or type(value.get("window_ended")) is not bool
|
|
126
|
+
or type(value.get("clock_blocked")) is not bool
|
|
127
|
+
or value.get("effect_verified") is not True
|
|
128
|
+
or value.get("mutation_performed") is not True
|
|
129
|
+
or value.get("subscription_ready") is not False
|
|
130
|
+
):
|
|
131
|
+
raise ValueError("standalone Trial activation receipt is invalid")
|
|
132
|
+
return value
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _writer_result(stdout: str) -> dict[str, Any]:
|
|
136
|
+
try:
|
|
137
|
+
value = json.loads(stdout)
|
|
138
|
+
except json.JSONDecodeError as error:
|
|
139
|
+
raise ValueError("Trial activation result is invalid") from error
|
|
140
|
+
if (
|
|
141
|
+
not isinstance(value, dict)
|
|
142
|
+
or value.get("schema_version") != _WRITER_SCHEMA
|
|
143
|
+
or value.get("outcome") != "retained"
|
|
144
|
+
or not isinstance(value.get("activated_at"), str)
|
|
145
|
+
or not isinstance(value.get("expires_at"), str)
|
|
146
|
+
or type(value.get("window_ended")) is not bool
|
|
147
|
+
or type(value.get("clock_blocked")) is not bool
|
|
148
|
+
):
|
|
149
|
+
raise ValueError("Trial activation result is invalid")
|
|
150
|
+
return value
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _state_store_dsn(infra: Path, work_dir: Path) -> str:
|
|
154
|
+
result = subprocess.run(
|
|
155
|
+
(
|
|
156
|
+
"az",
|
|
157
|
+
"keyvault",
|
|
158
|
+
"secret",
|
|
159
|
+
"show",
|
|
160
|
+
"--vault-name",
|
|
161
|
+
vault_name(_output(infra, "key_vault_uri")),
|
|
162
|
+
"--name",
|
|
163
|
+
"fdai-state-store-dsn",
|
|
164
|
+
"--query",
|
|
165
|
+
"value",
|
|
166
|
+
"--output",
|
|
167
|
+
"tsv",
|
|
168
|
+
"--only-show-errors",
|
|
169
|
+
),
|
|
170
|
+
cwd=work_dir,
|
|
171
|
+
check=False,
|
|
172
|
+
capture_output=True,
|
|
173
|
+
text=True,
|
|
174
|
+
timeout=120,
|
|
175
|
+
)
|
|
176
|
+
dsn = result.stdout.strip() if result.returncode == 0 else ""
|
|
177
|
+
if not dsn or any(character in dsn for character in "\r\n"):
|
|
178
|
+
raise ValueError("Trial activation database reference is unavailable")
|
|
179
|
+
return dsn
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _output(infra: Path, name: str) -> str:
|
|
183
|
+
value = standalone_planned_outputs.read_output(
|
|
184
|
+
infra, name, raw=True, reason="Terraform output readback failed"
|
|
185
|
+
)
|
|
186
|
+
return str(value)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
__all__ = [
|
|
190
|
+
"activate_trial",
|
|
191
|
+
"deployment_binding_digest",
|
|
192
|
+
"require_trial_activation_receipt",
|
|
193
|
+
]
|