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.
Files changed (203) hide show
  1. fdai_deployment_cli/__about__.py +3 -0
  2. fdai_deployment_cli/__init__.py +5 -0
  3. fdai_deployment_cli/__main__.py +5 -0
  4. fdai_deployment_cli/acr_publish.py +585 -0
  5. fdai_deployment_cli/aks_catalog_review.py +137 -0
  6. fdai_deployment_cli/aks_historical_reconciliation.py +732 -0
  7. fdai_deployment_cli/aks_job_environment.py +59 -0
  8. fdai_deployment_cli/aks_job_execution.py +794 -0
  9. fdai_deployment_cli/aks_kubernetes_defaults.py +66 -0
  10. fdai_deployment_cli/aks_preflight.py +422 -0
  11. fdai_deployment_cli/aks_readiness.py +220 -0
  12. fdai_deployment_cli/aks_service_update.py +180 -0
  13. fdai_deployment_cli/aks_workload_jobs.py +281 -0
  14. fdai_deployment_cli/application_state_adoption.py +347 -0
  15. fdai_deployment_cli/azure_actor.py +61 -0
  16. fdai_deployment_cli/azure_naming.py +252 -0
  17. fdai_deployment_cli/azure_retail_prices.py +173 -0
  18. fdai_deployment_cli/bootstrap_approval.py +99 -0
  19. fdai_deployment_cli/bootstrap_channel.py +321 -0
  20. fdai_deployment_cli/bootstrap_executor.py +311 -0
  21. fdai_deployment_cli/bootstrap_plan.py +237 -0
  22. fdai_deployment_cli/bootstrap_reconcile.py +402 -0
  23. fdai_deployment_cli/bootstrap_support.py +138 -0
  24. fdai_deployment_cli/bootstrap_worker.py +448 -0
  25. fdai_deployment_cli/bundle.py +352 -0
  26. fdai_deployment_cli/capacity.py +143 -0
  27. fdai_deployment_cli/catalog_review_profile.py +306 -0
  28. fdai_deployment_cli/cli.py +798 -0
  29. fdai_deployment_cli/cli_help.py +214 -0
  30. fdai_deployment_cli/cli_parser.py +791 -0
  31. fdai_deployment_cli/cli_plan.py +450 -0
  32. fdai_deployment_cli/compiler.py +99 -0
  33. fdai_deployment_cli/console_artifact.py +218 -0
  34. fdai_deployment_cli/console_config.py +214 -0
  35. fdai_deployment_cli/console_update.py +832 -0
  36. fdai_deployment_cli/contracts.py +307 -0
  37. fdai_deployment_cli/control_package.py +140 -0
  38. fdai_deployment_cli/deadline_transport.py +70 -0
  39. fdai_deployment_cli/deployment_cost.py +131 -0
  40. fdai_deployment_cli/deployment_deadline.py +28 -0
  41. fdai_deployment_cli/deployment_kit.py +790 -0
  42. fdai_deployment_cli/deployment_kit_cache.py +191 -0
  43. fdai_deployment_cli/deployment_progress.py +648 -0
  44. fdai_deployment_cli/development_authority_cli.py +56 -0
  45. fdai_deployment_cli/development_authority_profile.py +319 -0
  46. fdai_deployment_cli/doctor.py +136 -0
  47. fdai_deployment_cli/entitlement_preflight.py +68 -0
  48. fdai_deployment_cli/entitlement_transport.py +49 -0
  49. fdai_deployment_cli/entra_bootstrap.py +579 -0
  50. fdai_deployment_cli/entra_control_profile.py +214 -0
  51. fdai_deployment_cli/entra_graph.py +349 -0
  52. fdai_deployment_cli/entra_profiles.py +215 -0
  53. fdai_deployment_cli/entra_source.py +244 -0
  54. fdai_deployment_cli/execution_bundle.py +478 -0
  55. fdai_deployment_cli/execution_copies.py +63 -0
  56. fdai_deployment_cli/foundation_adoption.py +438 -0
  57. fdai_deployment_cli/foundation_adoption_evidence.py +245 -0
  58. fdai_deployment_cli/foundation_adoption_host.py +157 -0
  59. fdai_deployment_cli/foundation_adoption_transport.py +51 -0
  60. fdai_deployment_cli/foundation_failure.py +72 -0
  61. fdai_deployment_cli/foundation_image.py +168 -0
  62. fdai_deployment_cli/foundation_input.py +235 -0
  63. fdai_deployment_cli/foundation_output.py +154 -0
  64. fdai_deployment_cli/foundation_plan.py +381 -0
  65. fdai_deployment_cli/foundation_process.py +64 -0
  66. fdai_deployment_cli/foundation_progress.py +231 -0
  67. fdai_deployment_cli/github_actions.py +171 -0
  68. fdai_deployment_cli/github_apply_artifact.py +259 -0
  69. fdai_deployment_cli/github_artifact_io.py +48 -0
  70. fdai_deployment_cli/github_dispatch.py +248 -0
  71. fdai_deployment_cli/github_plan_artifact.py +181 -0
  72. fdai_deployment_cli/github_provider_artifact.py +170 -0
  73. fdai_deployment_cli/github_transport.py +46 -0
  74. fdai_deployment_cli/github_workflow_values.py +312 -0
  75. fdai_deployment_cli/hub_image_deploy.py +354 -0
  76. fdai_deployment_cli/hub_image_stage.py +162 -0
  77. fdai_deployment_cli/hub_image_trust.py +281 -0
  78. fdai_deployment_cli/hub_ownership.py +161 -0
  79. fdai_deployment_cli/hub_workload_handoff.py +264 -0
  80. fdai_deployment_cli/hub_workload_handoff_inputs.py +227 -0
  81. fdai_deployment_cli/identity_profile.py +227 -0
  82. fdai_deployment_cli/image_targets.py +197 -0
  83. fdai_deployment_cli/installation_scope.py +280 -0
  84. fdai_deployment_cli/license.py +311 -0
  85. fdai_deployment_cli/license_issue.py +239 -0
  86. fdai_deployment_cli/lifecycle_authorization.py +252 -0
  87. fdai_deployment_cli/lifecycle_commands.py +173 -0
  88. fdai_deployment_cli/lifecycle_configuration.py +722 -0
  89. fdai_deployment_cli/lifecycle_configuration_artifact.py +45 -0
  90. fdai_deployment_cli/lifecycle_configuration_cli.py +324 -0
  91. fdai_deployment_cli/lifecycle_configuration_document.py +74 -0
  92. fdai_deployment_cli/lifecycle_configuration_entities.py +146 -0
  93. fdai_deployment_cli/lifecycle_configuration_package.py +492 -0
  94. fdai_deployment_cli/lifecycle_configuration_publication.py +277 -0
  95. fdai_deployment_cli/lifecycle_configuration_publication_cli.py +191 -0
  96. fdai_deployment_cli/lifecycle_configuration_sealing.py +127 -0
  97. fdai_deployment_cli/lifecycle_enrollment.py +45 -0
  98. fdai_deployment_cli/lifecycle_infrastructure_agent.py +174 -0
  99. fdai_deployment_cli/lifecycle_installation_binding.py +65 -0
  100. fdai_deployment_cli/lifecycle_native.py +247 -0
  101. fdai_deployment_cli/lifecycle_native_reconciliation.py +568 -0
  102. fdai_deployment_cli/lifecycle_native_transition.py +339 -0
  103. fdai_deployment_cli/lifecycle_ownership_exclusion.py +78 -0
  104. fdai_deployment_cli/lifecycle_plan.py +800 -0
  105. fdai_deployment_cli/lifecycle_promotion.py +185 -0
  106. fdai_deployment_cli/lifecycle_release.py +308 -0
  107. fdai_deployment_cli/lifecycle_self_upgrade.py +202 -0
  108. fdai_deployment_cli/lifecycle_upgrade_bundle.py +176 -0
  109. fdai_deployment_cli/notification_recipients_input.py +222 -0
  110. fdai_deployment_cli/observer_artifact.py +106 -0
  111. fdai_deployment_cli/oci_archive.py +353 -0
  112. fdai_deployment_cli/offline_kit.py +797 -0
  113. fdai_deployment_cli/offline_prepare.py +135 -0
  114. fdai_deployment_cli/operational_evidence_verifier_input.py +575 -0
  115. fdai_deployment_cli/plan_input.py +101 -0
  116. fdai_deployment_cli/private_output.py +192 -0
  117. fdai_deployment_cli/profile.py +70 -0
  118. fdai_deployment_cli/progress.py +183 -0
  119. fdai_deployment_cli/readiness.py +286 -0
  120. fdai_deployment_cli/run_command_receiver.py +418 -0
  121. fdai_deployment_cli/runtime_build.py +386 -0
  122. fdai_deployment_cli/runtime_profile.py +287 -0
  123. fdai_deployment_cli/runtime_release.py +745 -0
  124. fdai_deployment_cli/runtime_stage.py +82 -0
  125. fdai_deployment_cli/runtime_support_installation.py +215 -0
  126. fdai_deployment_cli/simulation.py +80 -0
  127. fdai_deployment_cli/source_application_inputs.py +397 -0
  128. fdai_deployment_cli/source_azure.py +586 -0
  129. fdai_deployment_cli/source_change_cli.py +107 -0
  130. fdai_deployment_cli/source_change_parser.py +75 -0
  131. fdai_deployment_cli/source_change_plan.py +181 -0
  132. fdai_deployment_cli/source_changed_update.py +196 -0
  133. fdai_deployment_cli/source_deploy.py +170 -0
  134. fdai_deployment_cli/source_foundation.py +174 -0
  135. fdai_deployment_cli/source_host_tools.py +163 -0
  136. fdai_deployment_cli/source_image_stage.py +400 -0
  137. fdai_deployment_cli/source_input.py +173 -0
  138. fdai_deployment_cli/source_projection.py +164 -0
  139. fdai_deployment_cli/source_receiver.py +76 -0
  140. fdai_deployment_cli/source_recovery.py +264 -0
  141. fdai_deployment_cli/source_runtime.py +81 -0
  142. fdai_deployment_cli/source_runtime_support.py +368 -0
  143. fdai_deployment_cli/source_service_update.py +726 -0
  144. fdai_deployment_cli/source_service_update_cli.py +47 -0
  145. fdai_deployment_cli/source_snapshot.py +178 -0
  146. fdai_deployment_cli/source_teardown.py +574 -0
  147. fdai_deployment_cli/source_terraform.py +85 -0
  148. fdai_deployment_cli/source_transport.py +399 -0
  149. fdai_deployment_cli/standalone_aks_inventory.py +558 -0
  150. fdai_deployment_cli/standalone_aks_job_execution.py +769 -0
  151. fdai_deployment_cli/standalone_aks_nodepool_guard.py +141 -0
  152. fdai_deployment_cli/standalone_application.py +977 -0
  153. fdai_deployment_cli/standalone_application_completion.py +151 -0
  154. fdai_deployment_cli/standalone_application_input_recovery.py +107 -0
  155. fdai_deployment_cli/standalone_application_recovery.py +116 -0
  156. fdai_deployment_cli/standalone_approval_input.py +24 -0
  157. fdai_deployment_cli/standalone_catalog_checkpoint.py +122 -0
  158. fdai_deployment_cli/standalone_catalog_review.py +251 -0
  159. fdai_deployment_cli/standalone_checkpoint_failure.py +127 -0
  160. fdai_deployment_cli/standalone_console_publish.py +174 -0
  161. fdai_deployment_cli/standalone_database_principals.py +41 -0
  162. fdai_deployment_cli/standalone_deploy.py +638 -0
  163. fdai_deployment_cli/standalone_development_authority.py +166 -0
  164. fdai_deployment_cli/standalone_foundation_adoption.py +87 -0
  165. fdai_deployment_cli/standalone_foundation_transition.py +767 -0
  166. fdai_deployment_cli/standalone_host.py +4608 -0
  167. fdai_deployment_cli/standalone_host_rebind.py +226 -0
  168. fdai_deployment_cli/standalone_host_residual.py +104 -0
  169. fdai_deployment_cli/standalone_host_state.py +145 -0
  170. fdai_deployment_cli/standalone_host_values.py +563 -0
  171. fdai_deployment_cli/standalone_kubernetes_tools.py +100 -0
  172. fdai_deployment_cli/standalone_license_installation.py +177 -0
  173. fdai_deployment_cli/standalone_management_egress.py +83 -0
  174. fdai_deployment_cli/standalone_migration_evidence.py +38 -0
  175. fdai_deployment_cli/standalone_operational_evidence.py +356 -0
  176. fdai_deployment_cli/standalone_planned_outputs.py +147 -0
  177. fdai_deployment_cli/standalone_private_access_probe.py +100 -0
  178. fdai_deployment_cli/standalone_product_profile.py +110 -0
  179. fdai_deployment_cli/standalone_remote_prepare.py +442 -0
  180. fdai_deployment_cli/standalone_residual_apply.py +540 -0
  181. fdai_deployment_cli/standalone_residual_successor.py +431 -0
  182. fdai_deployment_cli/standalone_review.py +266 -0
  183. fdai_deployment_cli/standalone_service_update_finalization.py +116 -0
  184. fdai_deployment_cli/standalone_service_update_recovery.py +277 -0
  185. fdai_deployment_cli/standalone_stage_targets.py +325 -0
  186. fdai_deployment_cli/standalone_status.py +67 -0
  187. fdai_deployment_cli/standalone_terraform_environment.py +237 -0
  188. fdai_deployment_cli/standalone_transfer_cleanup.py +237 -0
  189. fdai_deployment_cli/standalone_trial_activation.py +193 -0
  190. fdai_deployment_cli/state.py +434 -0
  191. fdai_deployment_cli/state_handoff.py +253 -0
  192. fdai_deployment_cli/status_projection.py +97 -0
  193. fdai_deployment_cli/support_install.py +257 -0
  194. fdai_deployment_cli/target.py +46 -0
  195. fdai_deployment_cli/trust/capabilities.json +62 -0
  196. fdai_deployment_cli/trust/deployment-bundle-root.pub +3 -0
  197. fdai_deployment_cli/trust/deployment-release-root.pub +3 -0
  198. fdai_deployment_cli/trust/upstream-signing-key.pub +3 -0
  199. fdai_deployment_cli/trust_roots.py +50 -0
  200. fdai_deployment_cli-0.1.2.dist-info/METADATA +112 -0
  201. fdai_deployment_cli-0.1.2.dist-info/RECORD +203 -0
  202. fdai_deployment_cli-0.1.2.dist-info/WHEEL +4 -0
  203. 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
+ ]