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,280 @@
1
+ """Collect immutable initial installation preferences without granting apply authority."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import select
8
+ import sys
9
+ import time
10
+ from dataclasses import asdict, dataclass
11
+ from datetime import UTC, datetime, timedelta
12
+ from pathlib import Path
13
+
14
+ from fdai_service_contracts.product_profile import ProductAddOn
15
+
16
+ from fdai_deployment_cli.contracts import canonical_bytes, canonical_digest, load_json_object
17
+ from fdai_deployment_cli.private_output import read_private_bytes, write_private_bytes
18
+ from fdai_deployment_cli.runtime_profile import RuntimeDeploymentProfile
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class InstallationOptions:
23
+ """Requested cost, exposure and retention limits; no execution authorization."""
24
+
25
+ setup_cost_ceiling: int | None = None
26
+ console_access: str = "none"
27
+ allow_dedicated_identities: bool = False
28
+ cleanup_temporary_resources: bool = False
29
+
30
+ def __post_init__(self) -> None:
31
+ if self.setup_cost_ceiling is not None:
32
+ _budget(self.setup_cost_ceiling)
33
+ if self.console_access not in {"none", "public-https-entra", "private-https-entra"}:
34
+ raise ValueError("installation Console access is invalid")
35
+ if any(
36
+ type(value) is not bool
37
+ for value in (self.allow_dedicated_identities, self.cleanup_temporary_resources)
38
+ ):
39
+ raise ValueError("installation scope flags must be booleans")
40
+
41
+
42
+ def confirm_installation_scope(
43
+ *,
44
+ work_dir: Path,
45
+ binding: dict[str, object],
46
+ runtime_profile: RuntimeDeploymentProfile,
47
+ options: InstallationOptions,
48
+ interactive: bool,
49
+ timeout_seconds: int,
50
+ now: datetime | None = None,
51
+ ) -> dict[str, object]:
52
+ """Confirm once before execution, or verify an unchanged unexpired retained scope.
53
+
54
+ Noninteractive, denied, legacy-started, changed and expired runs never prompt
55
+ later or renew consent. The record is not accepted as a Genesis approval.
56
+ """
57
+ _validate_binding(binding)
58
+ _validate_product_options(runtime_profile, options)
59
+ if runtime_profile.digest != binding["runtime_profile_digest"]:
60
+ raise ValueError("installation scope changed: runtime profile digest differs")
61
+ if type(timeout_seconds) is not int or not 1 <= timeout_seconds <= 86400:
62
+ raise ValueError("installation scope lifetime must be between 1 and 86400 seconds")
63
+ moment = now or datetime.now(UTC)
64
+ _utc(moment)
65
+ path = work_dir / "installation-scope.json"
66
+ try:
67
+ retained = load_json_object(
68
+ read_private_bytes(path, max_bytes=16384), label="installation scope"
69
+ )
70
+ except FileNotFoundError:
71
+ retained = None
72
+ if retained is not None:
73
+ expected = {
74
+ "schema_version",
75
+ "scope",
76
+ "confirmed_at",
77
+ "expires_at",
78
+ "apply_authorized",
79
+ "digest",
80
+ }
81
+ unsigned = {key: value for key, value in retained.items() if key != "digest"}
82
+ if (
83
+ set(retained) != expected
84
+ or retained["schema_version"] != "fdai.installation-scope.v1"
85
+ or retained["apply_authorized"] is not False
86
+ or retained["digest"] != canonical_digest(unsigned)
87
+ ):
88
+ raise ValueError("retained installation scope is invalid")
89
+ scope = retained["scope"]
90
+ if not isinstance(scope, dict):
91
+ raise ValueError("retained installation scope is invalid")
92
+ setup_cost = options.setup_cost_ceiling
93
+ if setup_cost is None:
94
+ setup_cost = _budget(scope.get("setup_cost_ceiling"))
95
+ if canonical_bytes(scope) != canonical_bytes(
96
+ _scope(binding, runtime_profile, options, setup_cost)
97
+ ):
98
+ raise ValueError(
99
+ "installation scope changed; preserve the run and review before restarting"
100
+ )
101
+ confirmed = _timestamp(retained["confirmed_at"])
102
+ expires = _timestamp(retained["expires_at"])
103
+ valid_window = confirmed <= moment < expires and timedelta(
104
+ 0
105
+ ) < expires - confirmed <= timedelta(days=1)
106
+ if not valid_window and not started_installation_matches(work_dir, binding):
107
+ return _result("installation_scope_expired")
108
+ return _result("installation_scope_confirmed", str(retained["digest"]))
109
+ if (work_dir / "foundation/status.json").exists():
110
+ return _result("initial_confirmation_missing_for_started_run")
111
+ if not interactive:
112
+ return _result("initial_confirmation_required")
113
+ deadline = time.monotonic() + min(600, timeout_seconds)
114
+ setup_cost = options.setup_cost_ceiling
115
+ if setup_cost is None:
116
+ print(
117
+ "Initial setup cost estimate ceiling (USD, whole number): ",
118
+ end="",
119
+ file=sys.stderr,
120
+ flush=True,
121
+ )
122
+ answer = _read_initial_answer(deadline)
123
+ if re.fullmatch(r"[1-9][0-9]{0,6}", answer) is None:
124
+ return _result("initial_confirmation_denied")
125
+ setup_cost = _budget(int(answer))
126
+ scope = _scope(binding, runtime_profile, options, setup_cost)
127
+ print("Initial installation scope (not an exact-plan approval):", file=sys.stderr)
128
+ for key, value in scope.items():
129
+ print(f" {key}: {value}", file=sys.stderr)
130
+ print(
131
+ "Estimates are not billing caps. Existing resources and runtime authority are excluded.\n"
132
+ "Later missing authority or evidence stops the run without another prompt.\n"
133
+ "Type install to confirm these settings, or anything else to stop: ",
134
+ end="",
135
+ file=sys.stderr,
136
+ flush=True,
137
+ )
138
+ if _read_initial_answer(deadline) != "install":
139
+ return _result("initial_confirmation_denied")
140
+ elapsed = min(600, timeout_seconds) - max(0.0, deadline - time.monotonic())
141
+ confirmed_at = moment + timedelta(seconds=elapsed)
142
+ if confirmed_at >= moment + timedelta(seconds=timeout_seconds):
143
+ return _result("installation_scope_expired")
144
+ record: dict[str, object] = {
145
+ "schema_version": "fdai.installation-scope.v1",
146
+ "scope": scope,
147
+ "confirmed_at": confirmed_at.isoformat(),
148
+ "expires_at": (moment + timedelta(seconds=timeout_seconds)).isoformat(),
149
+ "apply_authorized": False,
150
+ }
151
+ record["digest"] = canonical_digest(record)
152
+ write_private_bytes(path, canonical_bytes(record))
153
+ return _result("installation_scope_confirmed", str(record["digest"]))
154
+
155
+
156
+ def _scope(
157
+ binding: dict[str, object],
158
+ runtime_profile: RuntimeDeploymentProfile,
159
+ options: InstallationOptions,
160
+ setup_cost: int,
161
+ ) -> dict[str, object]:
162
+ return {
163
+ **binding,
164
+ "runtime_profile": runtime_profile.to_mapping(),
165
+ **asdict(options),
166
+ "setup_cost_ceiling": setup_cost,
167
+ "resource_scope": "new-installation-only",
168
+ "retain_services": True,
169
+ "runtime_authority_change": False,
170
+ }
171
+
172
+
173
+ def _result(reason: str, digest: str | None = None) -> dict[str, object]:
174
+ return {
175
+ "schema_version": "fdai.installation-scope-result.v1",
176
+ "state": "confirmed" if digest else "review",
177
+ "stage": "initial-confirmation",
178
+ "reason_code": reason,
179
+ "scope_digest": digest,
180
+ "apply_authorized": False,
181
+ "mutation_performed": False,
182
+ "deployment_ready": False,
183
+ }
184
+
185
+
186
+ def started_installation_matches(
187
+ work_dir: Path,
188
+ binding: dict[str, object],
189
+ ) -> bool:
190
+ """Let an effected installation reuse its immutable preferences after their initial window."""
191
+ try:
192
+ status = load_json_object(
193
+ read_private_bytes(work_dir / "foundation/status.json", max_bytes=1024 * 1024),
194
+ label="Foundation status",
195
+ )
196
+ profile = load_json_object(
197
+ read_private_bytes(work_dir / "foundation/profile.json", max_bytes=16384),
198
+ label="Foundation profile",
199
+ )
200
+ except (FileNotFoundError, OSError, ValueError):
201
+ return False
202
+ return (
203
+ status.get("schema_version") == "fdai.genesis-orchestration-status.v2"
204
+ and status.get("mutation_performed") is True
205
+ and status.get("source_commit") == binding["source_commit"]
206
+ and profile.get("schema_version") == "fdai.provision-profile.v1"
207
+ and profile.get("target_binding") == binding["target_binding"]
208
+ )
209
+
210
+
211
+ def _validate_product_options(
212
+ runtime_profile: RuntimeDeploymentProfile,
213
+ options: InstallationOptions,
214
+ ) -> None:
215
+ console_selected = runtime_profile.product_profile.selects(ProductAddOn.READ_ONLY_CONSOLE)
216
+ if console_selected and options.console_access == "none":
217
+ raise ValueError("selected read-only Console requires an explicit access profile")
218
+ if not console_selected and options.console_access != "none":
219
+ raise ValueError("Console access requires the read-only-console product add-on")
220
+
221
+
222
+ def _validate_binding(binding: dict[str, object]) -> None:
223
+ if set(binding) != {
224
+ "source_commit",
225
+ "target_binding",
226
+ "preparation_digest",
227
+ "runtime_profile_digest",
228
+ "region",
229
+ "monthly_cost_ceiling",
230
+ }:
231
+ raise ValueError("installation scope binding fields are invalid")
232
+ for key in ("source_commit", "target_binding", "preparation_digest", "runtime_profile_digest"):
233
+ value = binding[key]
234
+ length = 40 if key == "source_commit" else 64
235
+ if not isinstance(value, str) or re.fullmatch(rf"[0-9a-f]{{{length}}}", value) is None:
236
+ raise ValueError("installation scope binding digest is invalid")
237
+ region = binding["region"]
238
+ if not isinstance(region, str) or re.fullmatch(r"[a-z][a-z0-9]{1,40}", region) is None:
239
+ raise ValueError("installation scope region is invalid")
240
+ _budget(binding["monthly_cost_ceiling"])
241
+
242
+
243
+ def _budget(value: object) -> int:
244
+ if type(value) is not int or not 1 <= value <= 1000000:
245
+ raise ValueError(
246
+ "installation cost ceiling must be a whole USD amount between 1 and 1000000"
247
+ )
248
+ return value
249
+
250
+
251
+ def _utc(value: datetime) -> None:
252
+ if value.tzinfo is None or value.utcoffset() != timedelta(0):
253
+ raise ValueError("installation scope timestamp must be UTC")
254
+
255
+
256
+ def _timestamp(value: object) -> datetime:
257
+ if not isinstance(value, str):
258
+ raise TypeError("installation scope timestamp is invalid")
259
+ result = datetime.fromisoformat(value)
260
+ _utc(result)
261
+ return result
262
+
263
+
264
+ def _read_initial_answer(deadline: float) -> str:
265
+ """Read one bounded TTY line without an unbounded readline after partial input."""
266
+ if not sys.stdin.isatty():
267
+ raise ValueError("initial confirmation requires an interactive terminal")
268
+ descriptor = sys.stdin.fileno()
269
+ answer = bytearray()
270
+ while len(answer) <= 64:
271
+ remaining = deadline - time.monotonic()
272
+ if remaining <= 0 or not select.select([descriptor], [], [], remaining)[0]:
273
+ raise TimeoutError("initial confirmation timed out; no consent was recorded")
274
+ chunk = os.read(descriptor, 1)
275
+ if not chunk:
276
+ raise ValueError("initial confirmation input closed; no consent was recorded")
277
+ if chunk == b"\n":
278
+ return answer.decode("ascii").strip()
279
+ answer.extend(chunk)
280
+ raise ValueError("initial confirmation input exceeds its limit")
@@ -0,0 +1,311 @@
1
+ """Offline capability-license inspection without runtime dependencies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import binascii
7
+ import json
8
+ import re
9
+ from dataclasses import dataclass
10
+ from datetime import UTC, datetime, timedelta
11
+
12
+ from cryptography.exceptions import InvalidSignature
13
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
14
+ from cryptography.hazmat.primitives.serialization import load_pem_public_key
15
+
16
+ from fdai_deployment_cli.contracts import canonical_bytes, load_json_object
17
+
18
+ _B64URL = re.compile(r"^[A-Za-z0-9_-]+$")
19
+ _ID = re.compile(r"^[a-z0-9][a-z0-9._-]{2,127}$")
20
+ _DIGEST = re.compile(r"^[0-9a-f]{64}$")
21
+ _MAX_CAPABILITIES = 512
22
+ _MAX_VALIDITY = timedelta(days=30)
23
+
24
+
25
+ class LicenseInspectionError(ValueError):
26
+ """A license token is malformed, untrusted, or inactive."""
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class LicenseInspection:
31
+ """Sanitized active-license result."""
32
+
33
+ license_id: str
34
+ distribution_id: str
35
+ capability_ids: tuple[str, ...]
36
+ not_before: str
37
+ not_after: str
38
+ active: bool
39
+
40
+ def to_json(self) -> str:
41
+ """Return stable JSON without the bearer token."""
42
+
43
+ return json.dumps(
44
+ {
45
+ "schema_version": "fdai.license-inspection.v1",
46
+ "license_id": self.license_id,
47
+ "distribution_id": self.distribution_id,
48
+ "capability_ids": list(self.capability_ids),
49
+ "not_before": self.not_before,
50
+ "not_after": self.not_after,
51
+ "active": self.active,
52
+ },
53
+ sort_keys=True,
54
+ separators=(",", ":"),
55
+ )
56
+
57
+
58
+ def inspect_license(
59
+ token: str,
60
+ *,
61
+ public_key_pem: bytes,
62
+ now: datetime | None = None,
63
+ expected_image_digest: str | None = None,
64
+ expected_tenant_binding: str | None = None,
65
+ ) -> LicenseInspection:
66
+ """Verify one signed canonical token and report active entitlement."""
67
+
68
+ if not token or len(token) > 8192:
69
+ raise LicenseInspectionError("license token is empty or exceeds its size limit")
70
+ parts = token.split(".")
71
+ if len(parts) != 2:
72
+ raise LicenseInspectionError("license token MUST contain document and signature")
73
+ document = _decode(parts[0], "document")
74
+ signature = _decode(parts[1], "signature")
75
+ if len(signature) != 64:
76
+ raise LicenseInspectionError("license signature MUST be 64 bytes")
77
+ _verify(public_key_pem, document, signature)
78
+ payload = load_json_object(document, label="license document", max_bytes=6144)
79
+ expected = {
80
+ "schema_version",
81
+ "license_id",
82
+ "distribution_id",
83
+ "capability_ids",
84
+ "not_before",
85
+ "not_after",
86
+ "image_digest",
87
+ "tenant_binding",
88
+ }
89
+ if set(payload) != expected or payload["schema_version"] != "fdai.license.v1":
90
+ raise LicenseInspectionError("license document schema does not match")
91
+ if canonical_bytes(payload) != document:
92
+ raise LicenseInspectionError("license document is not canonical")
93
+ not_before = _moment(payload, "not_before")
94
+ not_after = _moment(payload, "not_after")
95
+ if _text(payload, "not_before") != _canonical_moment(not_before) or _text(
96
+ payload, "not_after"
97
+ ) != _canonical_moment(not_after):
98
+ raise LicenseInspectionError("license timestamps are not canonically encoded")
99
+ if not_after <= not_before:
100
+ raise LicenseInspectionError("license validity window is invalid")
101
+ if not_after.astimezone(UTC) - not_before.astimezone(UTC) > _MAX_VALIDITY:
102
+ raise LicenseInspectionError("license validity MUST NOT exceed 30 elapsed UTC days")
103
+ evaluated = now or datetime.now(UTC)
104
+ if evaluated.tzinfo is None:
105
+ raise LicenseInspectionError("license evaluation time MUST be timezone-aware")
106
+ capabilities = payload["capability_ids"]
107
+ if (
108
+ not isinstance(capabilities, list)
109
+ or not capabilities
110
+ or not all(isinstance(item, str) and item for item in capabilities)
111
+ ):
112
+ raise LicenseInspectionError("license capability_ids are invalid")
113
+ if capabilities != sorted(set(capabilities)):
114
+ raise LicenseInspectionError("license capability_ids MUST be unique and sorted")
115
+ if len(capabilities) > _MAX_CAPABILITIES:
116
+ raise LicenseInspectionError("license capability_ids exceeds the supported count")
117
+ license_id = _text(payload, "license_id")
118
+ distribution_id = _text(payload, "distribution_id")
119
+ if _ID.fullmatch(license_id) is None or _ID.fullmatch(distribution_id) is None:
120
+ raise LicenseInspectionError("license identifiers MUST be lowercase stable identifiers")
121
+ if any(_ID.fullmatch(item) is None for item in capabilities):
122
+ raise LicenseInspectionError("license capability_ids MUST be lowercase stable identifiers")
123
+ _verify_optional_binding(
124
+ payload,
125
+ field="image_digest",
126
+ expected=expected_image_digest,
127
+ )
128
+ _verify_optional_binding(
129
+ payload,
130
+ field="tenant_binding",
131
+ expected=expected_tenant_binding,
132
+ )
133
+ active = not_before <= evaluated < not_after
134
+ if not active:
135
+ raise LicenseInspectionError("license is not active")
136
+ return LicenseInspection(
137
+ license_id=license_id,
138
+ distribution_id=distribution_id,
139
+ capability_ids=tuple(capabilities),
140
+ not_before=not_before.isoformat(),
141
+ not_after=not_after.isoformat(),
142
+ active=True,
143
+ )
144
+
145
+
146
+ INSTALLATION_ENTITLEMENT_SCHEMA = "fdai.installation-entitlement.v1"
147
+ _ENTITLEMENT_FIELDS = frozenset(
148
+ {
149
+ "schema_version",
150
+ "entitlement_id",
151
+ "distribution_id",
152
+ "installation_binding",
153
+ "deployment_binding",
154
+ "issued_at",
155
+ }
156
+ )
157
+
158
+
159
+ @dataclass(frozen=True, slots=True)
160
+ class InstallationEntitlementInspection:
161
+ """Sanitized result for an exactly bound installation entitlement."""
162
+
163
+ entitlement_id: str
164
+ distribution_id: str
165
+ issued_at: str
166
+
167
+ def to_json(self) -> str:
168
+ """Return stable JSON without the bearer token or either binding digest."""
169
+
170
+ return json.dumps(
171
+ {
172
+ "schema_version": "fdai.installation-entitlement-inspection.v1",
173
+ "entitlement_id": self.entitlement_id,
174
+ "distribution_id": self.distribution_id,
175
+ "issued_at": self.issued_at,
176
+ "complete_catalog": True,
177
+ "expires": False,
178
+ "active": True,
179
+ },
180
+ sort_keys=True,
181
+ separators=(",", ":"),
182
+ )
183
+
184
+
185
+ def signed_token_schema(token: str) -> str | None:
186
+ """Return the schema a token's document declares, without establishing trust."""
187
+
188
+ parts = token.split(".")
189
+ if len(parts) != 2 or len(token) > 8192:
190
+ return None
191
+ try:
192
+ payload = json.loads(_decode(parts[0], "document"))
193
+ except (LicenseInspectionError, UnicodeDecodeError, json.JSONDecodeError):
194
+ return None
195
+ schema = payload.get("schema_version") if isinstance(payload, dict) else None
196
+ return schema if isinstance(schema, str) else None
197
+
198
+
199
+ def inspect_installation_entitlement(
200
+ token: str,
201
+ *,
202
+ public_key_pem: bytes,
203
+ expected_installation_binding: str,
204
+ expected_deployment_binding: str,
205
+ ) -> InstallationEntitlementInspection:
206
+ """Verify one installation entitlement against the exact bindings it must carry."""
207
+
208
+ for label, expected in (
209
+ ("installation binding", expected_installation_binding),
210
+ ("deployment binding", expected_deployment_binding),
211
+ ):
212
+ if _DIGEST.fullmatch(expected) is None:
213
+ raise LicenseInspectionError(f"expected {label} MUST be a lowercase SHA-256")
214
+ if not token or len(token) > 8192:
215
+ raise LicenseInspectionError("entitlement is empty or exceeds its size limit")
216
+ parts = token.split(".")
217
+ if len(parts) != 2:
218
+ raise LicenseInspectionError("entitlement MUST contain document and signature")
219
+ document = _decode(parts[0], "document")
220
+ signature = _decode(parts[1], "signature")
221
+ if len(signature) != 64:
222
+ raise LicenseInspectionError("entitlement signature MUST be 64 bytes")
223
+ _verify(public_key_pem, document, signature)
224
+ payload = load_json_object(document, label="entitlement document", max_bytes=2048)
225
+ if (
226
+ set(payload) != _ENTITLEMENT_FIELDS
227
+ or payload["schema_version"] != INSTALLATION_ENTITLEMENT_SCHEMA
228
+ ):
229
+ raise LicenseInspectionError("entitlement document schema does not match")
230
+ if canonical_bytes(payload) != document:
231
+ raise LicenseInspectionError("entitlement document is not canonical")
232
+ entitlement_id = _text(payload, "entitlement_id")
233
+ distribution_id = _text(payload, "distribution_id")
234
+ if _ID.fullmatch(entitlement_id) is None or _ID.fullmatch(distribution_id) is None:
235
+ raise LicenseInspectionError("entitlement identifiers MUST be lowercase stable identifiers")
236
+ issued_at = _moment(payload, "issued_at")
237
+ if _text(payload, "issued_at") != _canonical_moment(issued_at):
238
+ raise LicenseInspectionError("entitlement issued_at is not canonically encoded")
239
+ if _text(payload, "installation_binding") != expected_installation_binding:
240
+ raise LicenseInspectionError("entitlement installation binding does not match")
241
+ if _text(payload, "deployment_binding") != expected_deployment_binding:
242
+ raise LicenseInspectionError("entitlement deployment binding does not match")
243
+ return InstallationEntitlementInspection(
244
+ entitlement_id=entitlement_id,
245
+ distribution_id=distribution_id,
246
+ issued_at=issued_at.isoformat(),
247
+ )
248
+
249
+
250
+ def _decode(value: str, label: str) -> bytes:
251
+ if _B64URL.fullmatch(value) is None:
252
+ raise LicenseInspectionError(f"license {label} is not canonical base64url")
253
+ try:
254
+ decoded = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
255
+ except (ValueError, binascii.Error) as exc:
256
+ raise LicenseInspectionError(f"license {label} is not base64url") from exc
257
+ if base64.urlsafe_b64encode(decoded).rstrip(b"=").decode() != value:
258
+ raise LicenseInspectionError(f"license {label} is not canonical base64url")
259
+ return decoded
260
+
261
+
262
+ def _verify_optional_binding(
263
+ payload: dict[str, object],
264
+ *,
265
+ field: str,
266
+ expected: str | None,
267
+ ) -> None:
268
+ value = payload[field]
269
+ if value is not None and (not isinstance(value, str) or _DIGEST.fullmatch(value) is None):
270
+ raise LicenseInspectionError(f"license {field} MUST be a lowercase SHA-256 or null")
271
+ if expected is not None and _DIGEST.fullmatch(expected) is None:
272
+ raise LicenseInspectionError(f"expected {field} MUST be a lowercase SHA-256")
273
+ if value is not None and expected is None:
274
+ raise LicenseInspectionError(f"license {field} requires an expected binding")
275
+ if expected is not None and value != expected:
276
+ raise LicenseInspectionError(f"license {field} does not match")
277
+
278
+
279
+ def _verify(public_pem: bytes, document: bytes, signature: bytes) -> None:
280
+ try:
281
+ key = load_pem_public_key(public_pem)
282
+ except (TypeError, ValueError) as exc:
283
+ raise LicenseInspectionError("license public key is invalid") from exc
284
+ if not isinstance(key, Ed25519PublicKey):
285
+ raise LicenseInspectionError("license public key MUST be Ed25519")
286
+ try:
287
+ key.verify(signature, document)
288
+ except InvalidSignature as exc:
289
+ raise LicenseInspectionError("license signature is invalid") from exc
290
+
291
+
292
+ def _moment(value: dict[str, object], field: str) -> datetime:
293
+ raw = _text(value, field)
294
+ try:
295
+ moment = datetime.fromisoformat(raw)
296
+ except ValueError as exc:
297
+ raise LicenseInspectionError(f"license {field} MUST be ISO 8601") from exc
298
+ if moment.tzinfo is None:
299
+ raise LicenseInspectionError(f"license {field} MUST be timezone-aware")
300
+ return moment
301
+
302
+
303
+ def _canonical_moment(moment: datetime) -> str:
304
+ return moment.astimezone(UTC).isoformat().replace("+00:00", "Z")
305
+
306
+
307
+ def _text(value: dict[str, object], field: str) -> str:
308
+ item = value[field]
309
+ if not isinstance(item, str) or not item:
310
+ raise LicenseInspectionError(f"license {field} MUST be text")
311
+ return item