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,307 @@
1
+ """Strict, deterministic contracts for subscription genesis planning."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import re
8
+ from collections.abc import Mapping, Sequence
9
+ from dataclasses import dataclass
10
+ from enum import StrEnum
11
+ from typing import Any
12
+
13
+ from fdai_service_contracts.catalog_canonical import canonical_bytes as canonical_bytes
14
+ from fdai_service_contracts.catalog_canonical import canonical_digest as canonical_digest
15
+
16
+ _ID = re.compile(r"^[a-z][a-z0-9._-]{0,127}$")
17
+ _ENVIRONMENTS = frozenset({"dev", "staging", "prod"})
18
+ _CONNECTIVITY = frozenset({"online", "offline"})
19
+ _HOSTS = frozenset({"existing-host", "managed-vm"})
20
+ _TRANSPORTS = frozenset({"manual"})
21
+ _ACCESS_METHODS = frozenset({"internal_ssh", "temporary_public_ssh", "bastion", "run_command"})
22
+
23
+
24
+ class ApprovalClass(StrEnum):
25
+ """Approval strength required by one manifest entry."""
26
+
27
+ STANDARD = "standard"
28
+ HIGH_IMPACT = "high-impact"
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class ProvisionProfile:
33
+ """Secret-free desired state selected by an operator."""
34
+
35
+ environment: str
36
+ region: str
37
+ target_binding: str
38
+ connectivity: str
39
+ host: str
40
+ transport: str
41
+ access_method: str
42
+ shadow_only: bool
43
+ approval_quorum: int
44
+ monthly_cost_ceiling: int
45
+
46
+ def __post_init__(self) -> None:
47
+ if self.environment not in _ENVIRONMENTS:
48
+ raise ValueError("environment is unsupported")
49
+ if _ID.fullmatch(self.region) is None:
50
+ raise ValueError("region MUST be a lowercase stable identifier")
51
+ if re.fullmatch(r"[0-9a-f]{64}", self.target_binding) is None:
52
+ raise ValueError("target_binding MUST be a lowercase SHA-256")
53
+ if self.connectivity not in _CONNECTIVITY:
54
+ raise ValueError("connectivity is unsupported")
55
+ if self.host not in _HOSTS:
56
+ raise ValueError("host is unsupported")
57
+ if self.transport not in _TRANSPORTS:
58
+ raise ValueError("transport is unsupported")
59
+ if self.access_method not in _ACCESS_METHODS:
60
+ raise ValueError("access_method is unsupported")
61
+ if not self.shadow_only:
62
+ raise ValueError("subscription genesis MUST start shadow-only")
63
+ if self.approval_quorum < 1:
64
+ raise ValueError("approval_quorum MUST be positive")
65
+ if self.monthly_cost_ceiling < 0:
66
+ raise ValueError("monthly_cost_ceiling MUST be non-negative")
67
+
68
+ @classmethod
69
+ def from_mapping(cls, value: Mapping[str, object]) -> ProvisionProfile:
70
+ """Decode a profile and reject unknown or missing fields."""
71
+
72
+ expected = {
73
+ "schema_version",
74
+ "environment",
75
+ "region",
76
+ "target_binding",
77
+ "connectivity",
78
+ "host",
79
+ "transport",
80
+ "access_method",
81
+ "shadow_only",
82
+ "approval_quorum",
83
+ "monthly_cost_ceiling",
84
+ }
85
+ _require_exact_keys(value, expected, "provision profile")
86
+ if value["schema_version"] != "fdai.provision-profile.v1":
87
+ raise ValueError("provision profile schema_version is unsupported")
88
+ return cls(
89
+ environment=_text(value, "environment"),
90
+ region=_text(value, "region"),
91
+ target_binding=_text(value, "target_binding"),
92
+ connectivity=_text(value, "connectivity"),
93
+ host=_text(value, "host"),
94
+ transport=_text(value, "transport"),
95
+ access_method=_text(value, "access_method"),
96
+ shadow_only=_bool(value, "shadow_only"),
97
+ approval_quorum=_int(value, "approval_quorum"),
98
+ monthly_cost_ceiling=_int(value, "monthly_cost_ceiling"),
99
+ )
100
+
101
+ def to_mapping(self) -> dict[str, object]:
102
+ """Return the canonical JSON-compatible profile."""
103
+
104
+ return {
105
+ "schema_version": "fdai.provision-profile.v1",
106
+ "environment": self.environment,
107
+ "region": self.region,
108
+ "target_binding": self.target_binding,
109
+ "connectivity": self.connectivity,
110
+ "host": self.host,
111
+ "transport": self.transport,
112
+ "access_method": self.access_method,
113
+ "shadow_only": self.shadow_only,
114
+ "approval_quorum": self.approval_quorum,
115
+ "monthly_cost_ceiling": self.monthly_cost_ceiling,
116
+ }
117
+
118
+
119
+ @dataclass(frozen=True, slots=True)
120
+ class ManifestEntry:
121
+ """One finite, dependency-ordered provisioning responsibility."""
122
+
123
+ entry_id: str
124
+ owner: str
125
+ desired_state: str
126
+ prerequisites: tuple[str, ...]
127
+ approval_class: ApprovalClass
128
+ idempotency_key: str
129
+ timeout_seconds: int
130
+ no_progress_seconds: int
131
+ rollback_ref: str
132
+ observer: str
133
+
134
+ def __post_init__(self) -> None:
135
+ for label, value in (
136
+ ("entry_id", self.entry_id),
137
+ ("owner", self.owner),
138
+ ("desired_state", self.desired_state),
139
+ ("idempotency_key", self.idempotency_key),
140
+ ("rollback_ref", self.rollback_ref),
141
+ ("observer", self.observer),
142
+ ):
143
+ if _ID.fullmatch(value) is None:
144
+ raise ValueError(f"{label} MUST be a lowercase stable identifier")
145
+ if len(set(self.prerequisites)) != len(self.prerequisites):
146
+ raise ValueError("manifest prerequisites MUST be unique")
147
+ if any(_ID.fullmatch(item) is None for item in self.prerequisites):
148
+ raise ValueError("manifest prerequisites MUST be stable identifiers")
149
+ if self.entry_id in self.prerequisites:
150
+ raise ValueError("manifest entry cannot depend on itself")
151
+ if self.timeout_seconds < 1:
152
+ raise ValueError("timeout_seconds MUST be positive")
153
+ if not 1 <= self.no_progress_seconds <= self.timeout_seconds:
154
+ raise ValueError("no_progress_seconds MUST be within the stage timeout")
155
+
156
+ def to_mapping(self) -> dict[str, object]:
157
+ """Return canonical entry data."""
158
+
159
+ return {
160
+ "entry_id": self.entry_id,
161
+ "owner": self.owner,
162
+ "desired_state": self.desired_state,
163
+ "prerequisites": list(self.prerequisites),
164
+ "approval_class": self.approval_class.value,
165
+ "idempotency_key": self.idempotency_key,
166
+ "timeout_seconds": self.timeout_seconds,
167
+ "no_progress_seconds": self.no_progress_seconds,
168
+ "rollback_ref": self.rollback_ref,
169
+ "observer": self.observer,
170
+ }
171
+
172
+
173
+ @dataclass(frozen=True, slots=True)
174
+ class SubscriptionProvisioningManifest:
175
+ """Finite manifest whose digest seals one local plan."""
176
+
177
+ source_commit: str
178
+ profile_digest: str
179
+ entries: tuple[ManifestEntry, ...]
180
+
181
+ def __post_init__(self) -> None:
182
+ if re.fullmatch(r"[0-9a-f]{40}", self.source_commit) is None:
183
+ raise ValueError("source_commit MUST be a lowercase 40-character SHA")
184
+ if re.fullmatch(r"[0-9a-f]{64}", self.profile_digest) is None:
185
+ raise ValueError("profile_digest MUST be a lowercase SHA-256")
186
+ if not self.entries:
187
+ raise ValueError("provisioning manifest MUST contain entries")
188
+ ids = tuple(entry.entry_id for entry in self.entries)
189
+ if len(ids) != len(set(ids)):
190
+ raise ValueError("provisioning manifest entry ids MUST be unique")
191
+ known = set(ids)
192
+ for entry in self.entries:
193
+ unknown = set(entry.prerequisites) - known
194
+ if unknown:
195
+ raise ValueError(f"manifest entry {entry.entry_id!r} has unknown prerequisites")
196
+ _assert_acyclic(self.entries)
197
+
198
+ @property
199
+ def digest(self) -> str:
200
+ """Return the replay-stable manifest digest."""
201
+
202
+ return canonical_digest(self.to_mapping())
203
+
204
+ def to_mapping(self) -> dict[str, object]:
205
+ """Return canonical manifest data."""
206
+
207
+ return {
208
+ "schema_version": "fdai.subscription-provisioning-manifest.v1",
209
+ "source_commit": self.source_commit,
210
+ "profile_digest": self.profile_digest,
211
+ "entries": [entry.to_mapping() for entry in self.entries],
212
+ }
213
+
214
+
215
+ def _assert_acyclic(entries: Sequence[ManifestEntry]) -> None:
216
+ by_id = {entry.entry_id: entry for entry in entries}
217
+ visiting: set[str] = set()
218
+ visited: set[str] = set()
219
+
220
+ def visit(entry_id: str) -> None:
221
+ if entry_id in visiting:
222
+ raise ValueError("provisioning manifest dependency cycle detected")
223
+ if entry_id in visited:
224
+ return
225
+ visiting.add(entry_id)
226
+ for dependency in by_id[entry_id].prerequisites:
227
+ visit(dependency)
228
+ visiting.remove(entry_id)
229
+ visited.add(entry_id)
230
+
231
+ for key in sorted(by_id):
232
+ visit(key)
233
+
234
+
235
+ def _require_exact_keys(
236
+ value: Mapping[str, object],
237
+ expected: set[str],
238
+ label: str,
239
+ ) -> None:
240
+ if set(value) != expected:
241
+ raise ValueError(f"{label} fields do not match the schema")
242
+
243
+
244
+ def _text(value: Mapping[str, object], field: str) -> str:
245
+ item = value[field]
246
+ if not isinstance(item, str):
247
+ raise ValueError(f"{field} MUST be a string")
248
+ return item
249
+
250
+
251
+ def _bool(value: Mapping[str, object], field: str) -> bool:
252
+ item = value[field]
253
+ if not isinstance(item, bool):
254
+ raise ValueError(f"{field} MUST be a boolean")
255
+ return item
256
+
257
+
258
+ def _int(value: Mapping[str, object], field: str) -> int:
259
+ item = value[field]
260
+ if isinstance(item, bool) or not isinstance(item, int):
261
+ raise ValueError(f"{field} MUST be an integer")
262
+ return item
263
+
264
+
265
+ def load_json_object(
266
+ data: bytes, *, label: str, max_bytes: int = 1_048_576, strict: bool = False
267
+ ) -> dict[str, Any]:
268
+ """Decode one bounded JSON object."""
269
+
270
+ if not data or len(data) > max_bytes:
271
+ raise ValueError(f"{label} is empty or exceeds its size limit")
272
+ try:
273
+ value = (
274
+ json.loads(
275
+ data.decode("utf-8"),
276
+ object_pairs_hook=_unique_json_object,
277
+ parse_constant=_reject_json_constant,
278
+ parse_float=_finite_json_float,
279
+ )
280
+ if strict
281
+ else json.loads(data)
282
+ )
283
+ except (ValueError, RecursionError) as exc:
284
+ raise ValueError(f"{label} is not valid JSON") from exc
285
+ if not isinstance(value, dict):
286
+ raise ValueError(f"{label} MUST be a JSON object")
287
+ return value
288
+
289
+
290
+ def _unique_json_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
291
+ result: dict[str, object] = {}
292
+ for key, value in pairs:
293
+ if key in result:
294
+ raise ValueError("duplicate JSON field")
295
+ result[key] = value
296
+ return result
297
+
298
+
299
+ def _reject_json_constant(value: str) -> object:
300
+ raise ValueError("non-finite JSON number")
301
+
302
+
303
+ def _finite_json_float(value: str) -> float:
304
+ number = float(value)
305
+ if not math.isfinite(number):
306
+ raise ValueError("non-finite JSON number")
307
+ return number
@@ -0,0 +1,140 @@
1
+ """Verify one signed deployment-control wheelhouse for managed-host execution.
2
+
3
+ The complete kit keeps its signed runtime payload: images, Terraform bundle, toolchain, and
4
+ Console. A control package replaces only the managed-host ``fdai-deployment-cli`` installation,
5
+ so a deployment-control repair needs a new signed wheelhouse instead of a new complete kit.
6
+ The only package guarantee is one detached Ed25519 signature over ``SHA256SUMS``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import re
13
+ import stat
14
+ import tarfile
15
+ from dataclasses import dataclass
16
+ from pathlib import Path, PurePosixPath
17
+
18
+ from cryptography.exceptions import InvalidSignature
19
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
20
+ from cryptography.hazmat.primitives.serialization import load_pem_public_key
21
+
22
+ from fdai_deployment_cli.trust_roots import deployment_release_root_pem
23
+
24
+ PACKAGE_ROOT = "package"
25
+ _SUMS = "SHA256SUMS"
26
+ _SIGNATURE = "SHA256SUMS.sig"
27
+ _REQUIREMENTS = "requirements.txt"
28
+ _SUM_LINE = re.compile(r"([0-9a-f]{64}) ([A-Za-z0-9._+-]+(?:/[A-Za-z0-9._+-]+)*)")
29
+ _REQUIREMENT = re.compile(r"fdai-deployment-cli==([0-9]+\.[0-9]+\.[0-9]+)\n")
30
+ _MAX_ARCHIVE_BYTES = 128 * 1024 * 1024
31
+ _MAX_MEMBER_BYTES = 64 * 1024 * 1024
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class ControlPackage:
36
+ """A locally verified signed wheelhouse and the digests that bind it to one run."""
37
+
38
+ archive: Path
39
+ archive_digest: str
40
+ sums_digest: str
41
+ version: str
42
+
43
+
44
+ def verify_control_package(archive: Path, *, public_key_pem: bytes | None = None) -> ControlPackage:
45
+ """Verify signature, exact file set, and digests of one control-package archive.
46
+
47
+ Raises ``ValueError`` without secret or path values when the archive is not a regular
48
+ file, contains links or unsafe paths, lacks a valid signature, lists a different file set,
49
+ or does not install exactly one pinned ``fdai-deployment-cli`` version.
50
+ """
51
+
52
+ try:
53
+ details = archive.lstat()
54
+ except OSError as exc:
55
+ raise ValueError("control package archive is unavailable") from exc
56
+ if not stat.S_ISREG(details.st_mode) or details.st_size > _MAX_ARCHIVE_BYTES:
57
+ raise ValueError("control package archive must be a bounded regular file")
58
+ payload = archive.read_bytes()
59
+ files = _read_members(archive)
60
+ sums = files.pop(_SUMS, None)
61
+ signature = files.pop(_SIGNATURE, None)
62
+ if sums is None or signature is None:
63
+ raise ValueError("control package signature files are missing")
64
+ key = load_pem_public_key(public_key_pem or deployment_release_root_pem())
65
+ if not isinstance(key, Ed25519PublicKey):
66
+ raise ValueError("control package trust root must be Ed25519")
67
+ try:
68
+ key.verify(signature, sums)
69
+ except InvalidSignature as exc:
70
+ raise ValueError("control package signature is invalid") from exc
71
+ listed = _parse_sums(sums)
72
+ if set(listed) != set(files):
73
+ raise ValueError("control package file set differs from SHA256SUMS")
74
+ for relative, expected in listed.items():
75
+ if hashlib.sha256(files[relative]).hexdigest() != expected:
76
+ raise ValueError("control package file digest differs from SHA256SUMS")
77
+ requirement = _REQUIREMENT.fullmatch(files.get(_REQUIREMENTS, b"").decode("ascii", "replace"))
78
+ if requirement is None:
79
+ raise ValueError("control package must pin exactly fdai-deployment-cli")
80
+ version = requirement.group(1)
81
+ cli_wheels = [
82
+ name
83
+ for name in files
84
+ if name.startswith("wheels/fdai_deployment_cli-") and name.endswith(".whl")
85
+ ]
86
+ if cli_wheels != [f"wheels/fdai_deployment_cli-{version}-py3-none-any.whl"]:
87
+ raise ValueError("control package must contain exactly the pinned CLI wheel")
88
+ return ControlPackage(
89
+ archive=archive,
90
+ archive_digest=hashlib.sha256(payload).hexdigest(),
91
+ sums_digest=hashlib.sha256(sums).hexdigest(),
92
+ version=version,
93
+ )
94
+
95
+
96
+ def _read_members(archive: Path) -> dict[str, bytes]:
97
+ """Read regular package members, rejecting links, devices, and escaping paths."""
98
+
99
+ files: dict[str, bytes] = {}
100
+ try:
101
+ with tarfile.open(archive, mode="r:gz") as bundle:
102
+ for member in bundle:
103
+ path = PurePosixPath(member.name)
104
+ if path.is_absolute() or ".." in path.parts or not path.parts:
105
+ raise ValueError("control package member path is unsafe")
106
+ if path.parts[0] != PACKAGE_ROOT:
107
+ raise ValueError("control package member is outside the package root")
108
+ if member.isdir():
109
+ continue
110
+ if not member.isreg() or member.size > _MAX_MEMBER_BYTES:
111
+ raise ValueError("control package member must be a bounded regular file")
112
+ relative = PurePosixPath(*path.parts[1:]).as_posix()
113
+ if relative in files or relative == ".":
114
+ raise ValueError("control package member is duplicated")
115
+ extracted = bundle.extractfile(member)
116
+ if extracted is None:
117
+ raise ValueError("control package member is unreadable")
118
+ files[relative] = extracted.read()
119
+ except (OSError, tarfile.TarError, EOFError) as exc:
120
+ raise ValueError("control package archive is unreadable") from exc
121
+ return files
122
+
123
+
124
+ def _parse_sums(sums: bytes) -> dict[str, str]:
125
+ try:
126
+ text = sums.decode("ascii")
127
+ except UnicodeDecodeError as exc:
128
+ raise ValueError("control package SHA256SUMS is not ASCII") from exc
129
+ listed: dict[str, str] = {}
130
+ for line in text.splitlines():
131
+ match = _SUM_LINE.fullmatch(line)
132
+ if match is None or match.group(2) in listed:
133
+ raise ValueError("control package SHA256SUMS is malformed")
134
+ listed[match.group(2)] = match.group(1)
135
+ if not listed:
136
+ raise ValueError("control package SHA256SUMS is empty")
137
+ return listed
138
+
139
+
140
+ __all__ = ["PACKAGE_ROOT", "ControlPackage", "verify_control_package"]
@@ -0,0 +1,70 @@
1
+ """Bound an existing managed-host transport to one deployment deadline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from collections.abc import Sequence
7
+ from pathlib import Path
8
+ from typing import Protocol
9
+
10
+ from fdai_deployment_cli.deployment_deadline import DeploymentDeadline
11
+
12
+
13
+ class DeploymentTransport(Protocol):
14
+ """Existing authenticated transport; this wrapper never chooses identity or target."""
15
+
16
+ def ssh(
17
+ self, remote_arguments: Sequence[str], *, timeout: int, input_text: str | None = None
18
+ ) -> subprocess.CompletedProcess[str]:
19
+ """Run the unchanged remote command within the supplied process limit."""
20
+ ...
21
+
22
+ def copy_to(self, source: Path, destination: str, *, timeout: int) -> None:
23
+ """Copy the unchanged private file within the supplied process limit."""
24
+ ...
25
+
26
+
27
+ class DeadlineTransport:
28
+ """Clamp every command and copy to the current budget without adding retries.
29
+
30
+ The caller still owns the underlying tunnel's context and cleanup. An operation
31
+ returning after expiry cannot advance the next stage or produce a ready receipt.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ transport: DeploymentTransport,
37
+ deadline: DeploymentDeadline,
38
+ *,
39
+ host_program: str | None = None,
40
+ ) -> None:
41
+ self._transport = transport
42
+ self._deadline = deadline
43
+ self.host_program = host_program
44
+
45
+ def ssh(
46
+ self, remote_arguments: Sequence[str], *, timeout: int, input_text: str | None = None
47
+ ) -> subprocess.CompletedProcess[str]:
48
+ """Preserve command/stdin while enforcing the current deadline before and after I/O."""
49
+
50
+ remaining = self._deadline.remaining(timeout)
51
+ try:
52
+ result = self._transport.ssh(remote_arguments, timeout=remaining, input_text=input_text)
53
+ except (OSError, subprocess.SubprocessError):
54
+ raise ValueError(
55
+ "standalone managed-host command failed; inspect retained evidence before recovery"
56
+ ) from None
57
+ self._deadline.remaining()
58
+ return result
59
+
60
+ def copy_to(self, source: Path, destination: str, *, timeout: int) -> None:
61
+ """Preserve transfer paths while enforcing the current deadline before and after I/O."""
62
+
63
+ remaining = self._deadline.remaining(timeout)
64
+ try:
65
+ self._transport.copy_to(source, destination, timeout=remaining)
66
+ except (OSError, subprocess.SubprocessError):
67
+ raise ValueError(
68
+ "standalone managed-host transfer failed; inspect retained evidence before recovery"
69
+ ) from None
70
+ self._deadline.remaining()
@@ -0,0 +1,131 @@
1
+ """Inspect the AKS compute portion of deployment cost without claiming a complete budget."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from decimal import Decimal, ROUND_CEILING
6
+ from pathlib import Path
7
+
8
+ from fdai_deployment_cli.azure_retail_prices import read_linux_vm_price
9
+ from fdai_deployment_cli.contracts import canonical_digest, load_json_object
10
+ from fdai_deployment_cli.deployment_deadline import DeploymentDeadline
11
+ from fdai_deployment_cli.private_output import read_private_bytes
12
+ from fdai_deployment_cli.runtime_profile import RuntimeDeploymentProfile
13
+
14
+ # Covers the default AKS profile (8 Standard_D4ds_v4 nodes at most) at public US rates.
15
+ DEFAULT_MONTHLY_COST_CEILING = 1500
16
+
17
+
18
+ def resolve_monthly_cost_ceiling(requested: int | None, *, retained: tuple[Path, ...]) -> int:
19
+ """Keep a retained run's ceiling so resume, recovery and teardown match its intent.
20
+
21
+ Only a run with no retained record receives the current default.
22
+ """
23
+ if requested is not None:
24
+ return requested
25
+ for path in retained:
26
+ if not path.is_file():
27
+ continue
28
+ value = load_json_object(
29
+ read_private_bytes(path, max_bytes=1_048_576), label=path.name
30
+ ).get("monthly_cost_ceiling")
31
+ if type(value) is not int or value < 0:
32
+ raise ValueError(
33
+ f"{path.name} records no usable monthly_cost_ceiling; pass --monthly-cost-ceiling"
34
+ )
35
+ return value
36
+ return DEFAULT_MONTHLY_COST_CEILING
37
+
38
+
39
+ def resolve_work_dir_monthly_cost_ceiling(
40
+ requested: int | None, *, work_dir: Path, adopted_foundation: Path | None
41
+ ) -> int:
42
+ """Resolve the ceiling from the records a run in ``work_dir`` retains, in precedence order."""
43
+ retained: tuple[Path, ...] = (
44
+ work_dir / "source-intent.json",
45
+ work_dir / "run/profile.json",
46
+ work_dir / "foundation/profile.json",
47
+ )
48
+ if adopted_foundation is not None:
49
+ retained = (*retained, adopted_foundation / "profile.json")
50
+ return resolve_monthly_cost_ceiling(requested, retained=retained)
51
+
52
+
53
+ def inspect_aks_compute_cost(
54
+ *,
55
+ profile: RuntimeDeploymentProfile,
56
+ region: str,
57
+ monthly_cost_ceiling: int,
58
+ timeout_seconds: int = 120,
59
+ ) -> dict[str, object]:
60
+ """Price maximum steady-state nodes for 730 hours; unpriced components remain explicit.
61
+
62
+ This is a partial projection, not a quote for the installation. It may block
63
+ an over-budget profile but cannot approve one, reserve capacity or cap bills.
64
+ """
65
+ if (
66
+ profile.runtime_platform.value != "aks"
67
+ or type(monthly_cost_ceiling) is not int
68
+ or monthly_cost_ceiling <= 0
69
+ ):
70
+ raise ValueError("AKS cost review requires an AKS profile and a positive ceiling")
71
+ deadline = DeploymentDeadline(timeout_seconds)
72
+ quotes = {
73
+ sku: read_linux_vm_price(sku=sku, region=region, timeout_seconds=deadline.remaining(90))
74
+ for sku in sorted({profile.system_node_sku, profile.user_node_sku})
75
+ }
76
+ lines = []
77
+ for pool, sku, count in (
78
+ ("system", profile.system_node_sku, profile.system_node_count),
79
+ ("user", profile.user_node_sku, profile.user_node_max_count),
80
+ ):
81
+ hourly = Decimal(str(quotes[sku]["hourly_rate"]))
82
+ monthly = (hourly * count * 730).quantize(Decimal("0.01"), rounding=ROUND_CEILING)
83
+ lines.append(
84
+ {
85
+ "pool": pool,
86
+ "sku": sku,
87
+ "node_count": count,
88
+ "hours": 730,
89
+ "hourly_rate_usd": str(hourly),
90
+ "monthly_estimate_usd": str(monthly),
91
+ }
92
+ )
93
+ total = sum((Decimal(str(line["monthly_estimate_usd"])) for line in lines), Decimal(0))
94
+ exceeded = total > monthly_cost_ceiling
95
+ result: dict[str, object] = {
96
+ "schema_version": "fdai.deployment-compute-cost.v1",
97
+ "stage": "cost-review",
98
+ "state": "blocked" if exceeded else "partial",
99
+ "reason_code": "aks_compute_exceeds_monthly_ceiling"
100
+ if exceeded
101
+ else "whole_installation_cost_incomplete",
102
+ "runtime_profile_digest": profile.digest,
103
+ "region": region,
104
+ "monthly_cost_ceiling_usd": monthly_cost_ceiling,
105
+ "monthly_compute_estimate_usd": str(total),
106
+ "lines": lines,
107
+ "quotes": quotes,
108
+ "excluded_costs": [
109
+ "surge-and-setup",
110
+ "foundation",
111
+ "aks-control-plane",
112
+ "disks",
113
+ "database",
114
+ "network",
115
+ "registry",
116
+ "storage",
117
+ "monitoring",
118
+ "event-bus",
119
+ "models",
120
+ "taxes",
121
+ ],
122
+ "whole_installation_cost_verified": False,
123
+ "setup_cost_verified": False,
124
+ "billing_cap_enforced": False,
125
+ "apply_authorized": False,
126
+ "deployment_ready": False,
127
+ "mutation_performed": False,
128
+ }
129
+ deadline.remaining()
130
+ result["receipt_digest"] = canonical_digest(result)
131
+ return result
@@ -0,0 +1,28 @@
1
+ """Monotonic remaining-budget checks for standalone deployment stage scheduling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import Callable
7
+
8
+
9
+ class DeploymentDeadline:
10
+ """Keep one invocation budget; expiry cannot authorize another stage or ready receipt.
11
+
12
+ This scheduler guard does not interrupt a running command. Callers pass its current
13
+ remaining budget to their bounded process or transport and preserve its cleanup path.
14
+ """
15
+
16
+ def __init__(self, seconds: int, *, clock: Callable[[], float] | None = None) -> None:
17
+ if type(seconds) is not int or seconds <= 0:
18
+ raise ValueError("standalone deployment timeout must be a positive integer")
19
+ self._clock = clock or time.monotonic
20
+ self._expires = self._clock() + seconds
21
+
22
+ def remaining(self, maximum: int | None = None) -> int:
23
+ """Return whole seconds left, capped by the stage limit, or stop on expiry."""
24
+
25
+ remaining = int(self._expires - self._clock())
26
+ if remaining <= 0:
27
+ raise TimeoutError("standalone deployment deadline has no remaining budget")
28
+ return remaining if maximum is None else min(maximum, remaining)