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,239 @@
1
+ """Issue a short-lived deployment-bound capability token from an operator-held key."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ import os
8
+ import stat
9
+ from datetime import UTC, datetime, timedelta
10
+ from importlib.resources import files
11
+ from pathlib import Path
12
+
13
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
14
+ Ed25519PrivateKey,
15
+ Ed25519PublicKey,
16
+ )
17
+ from cryptography.hazmat.primitives.serialization import (
18
+ load_pem_private_key,
19
+ load_pem_public_key,
20
+ )
21
+
22
+ from fdai_deployment_cli.contracts import canonical_bytes
23
+ from fdai_deployment_cli.license import (
24
+ INSTALLATION_ENTITLEMENT_SCHEMA,
25
+ inspect_installation_entitlement,
26
+ inspect_license,
27
+ )
28
+ from fdai_deployment_cli.private_output import read_private_bytes, write_private_output
29
+ from fdai_deployment_cli.trust_roots import license_public_key_pem
30
+
31
+ _ISSUER_KEY = Path("secrets/integrity-signing-key.pem")
32
+ _MAX_KEY_BYTES = 65_536
33
+
34
+
35
+ def discover_license_signing_key() -> Path | None:
36
+ """Return the checkout's upstream integrity key, or None to keep the Trial.
37
+
38
+ Only the fixed `secrets/integrity-signing-key.pem` of the working checkout is
39
+ considered. A present key that is not an owner-only regular file matching the
40
+ packaged public key raises instead of silently selecting the Trial.
41
+ """
42
+
43
+ path = Path.cwd() / _ISSUER_KEY
44
+ if not path.exists() and not path.is_symlink():
45
+ return None
46
+ _read_private_key(path)
47
+ return path
48
+
49
+
50
+ def issue_deployment_license(
51
+ *,
52
+ private_key: Path,
53
+ image_digest: str,
54
+ deployment_binding: str,
55
+ license_id: str,
56
+ valid_days: int = 30,
57
+ ) -> str:
58
+ """Issue and reverify one complete-catalog token for an exact image and deployment."""
59
+
60
+ if not 1 <= valid_days <= 30:
61
+ raise ValueError("license validity must be from 1 through 30 days")
62
+ capabilities = _capabilities()
63
+ now = datetime.now(UTC).replace(microsecond=0)
64
+ payload: dict[str, object] = {
65
+ "schema_version": "fdai.license.v1",
66
+ "license_id": license_id,
67
+ "distribution_id": "fdai-upstream",
68
+ "capability_ids": list(capabilities),
69
+ "not_before": _moment(now),
70
+ "not_after": _moment(now + timedelta(days=valid_days)),
71
+ "image_digest": image_digest,
72
+ "tenant_binding": deployment_binding,
73
+ }
74
+ document = canonical_bytes(payload)
75
+ key = _read_private_key(private_key)
76
+ token = f"{_encode(document)}.{_encode(key.sign(document))}"
77
+ inspect_license(
78
+ token,
79
+ public_key_pem=license_public_key_pem(),
80
+ expected_image_digest=image_digest,
81
+ expected_tenant_binding=deployment_binding,
82
+ )
83
+ return token
84
+
85
+
86
+ def write_license_token(path: Path, token: str) -> None:
87
+ """Write a verified token to a new private file for stdin-only handoff."""
88
+
89
+ inspect_license(token, public_key_pem=license_public_key_pem())
90
+ write_private_output(path, token)
91
+
92
+
93
+ def deployment_license_token(
94
+ *,
95
+ trial_token: Path | None,
96
+ image_digest: str,
97
+ deployment_binding: str,
98
+ work_ref: str,
99
+ installation_binding: str | None = None,
100
+ ) -> str | None:
101
+ """Return the key holder's grant, a verified supplied token, or None for observation-only.
102
+
103
+ A key holder whose runtime supplies an installation binding receives the no-expiry
104
+ installation entitlement; otherwise the key holder keeps the 30-day v1 token.
105
+ """
106
+
107
+ issuer = discover_license_signing_key()
108
+ if issuer is not None and installation_binding is not None:
109
+ return issue_installation_entitlement(
110
+ private_key=issuer,
111
+ entitlement_id=f"ent-{work_ref}",
112
+ installation_binding=installation_binding,
113
+ deployment_binding=deployment_binding,
114
+ )
115
+ if issuer is not None:
116
+ return issue_deployment_license(
117
+ private_key=issuer,
118
+ image_digest=image_digest,
119
+ deployment_binding=deployment_binding,
120
+ license_id=f"lic-{work_ref}",
121
+ )
122
+ if trial_token is None:
123
+ return None
124
+ path = trial_token if trial_token.is_absolute() else Path.cwd() / trial_token
125
+ token = read_private_bytes(path, max_bytes=8192).decode("ascii")
126
+ inspect_license(
127
+ token,
128
+ public_key_pem=license_public_key_pem(),
129
+ expected_image_digest=image_digest,
130
+ expected_tenant_binding=deployment_binding,
131
+ )
132
+ return token
133
+
134
+
135
+ def issue_installation_entitlement(
136
+ *,
137
+ private_key: Path,
138
+ entitlement_id: str,
139
+ installation_binding: str,
140
+ deployment_binding: str,
141
+ ) -> str:
142
+ """Issue and reverify one no-expiry, complete-catalog grant for one installation.
143
+
144
+ The document carries no image digest and no validity window, so upgrades and
145
+ restarts keep it valid; its exact installation and deployment digests are its
146
+ only limit, and it is useless anywhere else.
147
+ """
148
+
149
+ payload: dict[str, object] = {
150
+ "schema_version": INSTALLATION_ENTITLEMENT_SCHEMA,
151
+ "entitlement_id": entitlement_id,
152
+ "distribution_id": "fdai-upstream",
153
+ "installation_binding": installation_binding,
154
+ "deployment_binding": deployment_binding,
155
+ "issued_at": _moment(datetime.now(UTC).replace(microsecond=0)),
156
+ }
157
+ document = canonical_bytes(payload)
158
+ key = _read_private_key(private_key)
159
+ token = f"{_encode(document)}.{_encode(key.sign(document))}"
160
+ inspect_installation_entitlement(
161
+ token,
162
+ public_key_pem=license_public_key_pem(),
163
+ expected_installation_binding=installation_binding,
164
+ expected_deployment_binding=deployment_binding,
165
+ )
166
+ return token
167
+
168
+
169
+ def _read_private_key(path: Path) -> Ed25519PrivateKey:
170
+ details = path.lstat()
171
+ if (
172
+ not stat.S_ISREG(details.st_mode)
173
+ or stat.S_IMODE(details.st_mode) != 0o600
174
+ or details.st_uid != os.geteuid()
175
+ or details.st_nlink != 1
176
+ ):
177
+ raise PermissionError("license signing key must be a current-UID mode-0600 regular file")
178
+ try:
179
+ key = load_pem_private_key(_read_key_bytes(path), password=None)
180
+ public = load_pem_public_key(license_public_key_pem())
181
+ except (TypeError, ValueError) as exc:
182
+ raise ValueError("license signing key is invalid") from exc
183
+ if not isinstance(key, Ed25519PrivateKey) or not isinstance(public, Ed25519PublicKey):
184
+ raise ValueError("license signing key must be Ed25519")
185
+ if key.public_key().public_bytes_raw() != public.public_bytes_raw():
186
+ raise ValueError("license signing key does not match the packaged public key")
187
+ return key
188
+
189
+
190
+ def _read_key_bytes(path: Path) -> bytes:
191
+ """Read the key file under the same custody rule Core's issuer check applies.
192
+
193
+ The descriptor never follows a link or blocks on a special file. Unlike a
194
+ private deployment output, the key's directory needs no owner-only mode.
195
+ """
196
+
197
+ descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
198
+ with os.fdopen(descriptor, "rb") as stream:
199
+ details = os.fstat(stream.fileno())
200
+ if (
201
+ not stat.S_ISREG(details.st_mode)
202
+ or stat.S_IMODE(details.st_mode) != 0o600
203
+ or details.st_uid != os.geteuid()
204
+ or details.st_nlink != 1
205
+ ):
206
+ raise PermissionError(
207
+ "license signing key must be a current-UID mode-0600 regular file"
208
+ )
209
+ if not 0 < details.st_size <= _MAX_KEY_BYTES:
210
+ raise ValueError("license signing key is empty or exceeds 65536 bytes")
211
+ content = stream.read(_MAX_KEY_BYTES + 1)
212
+ if len(content) != details.st_size:
213
+ raise ValueError("license signing key changed while being read")
214
+ return content
215
+
216
+
217
+ def _capabilities() -> tuple[str, ...]:
218
+ raw = (
219
+ files("fdai_deployment_cli")
220
+ .joinpath("trust", "capabilities.json")
221
+ .read_text(encoding="utf-8")
222
+ )
223
+ value = json.loads(raw)
224
+ if (
225
+ not isinstance(value, list)
226
+ or not value
227
+ or not all(isinstance(item, str) and item for item in value)
228
+ or value != sorted(set(value))
229
+ ):
230
+ raise ValueError("packaged capability inventory is invalid")
231
+ return tuple(value)
232
+
233
+
234
+ def _encode(value: bytes) -> str:
235
+ return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
236
+
237
+
238
+ def _moment(value: datetime) -> str:
239
+ return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
@@ -0,0 +1,252 @@
1
+ """Local lifecycle authorization receipts and phase-fenced automatic apply guards."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass
9
+ from datetime import datetime
10
+ from typing import Literal
11
+
12
+ from fdai_deployment_cli.contracts import canonical_digest
13
+ from fdai_deployment_cli.lifecycle_plan import LifecyclePlan, PlanAdmissionDecision
14
+
15
+ LifecyclePhase = Literal["infrastructure", "workload"]
16
+
17
+ _SUPPORTED_PHASES = frozenset({"infrastructure", "workload"})
18
+ _PREFIXED_SHA256 = re.compile(r"sha256:[0-9a-f]{64}\Z", re.ASCII)
19
+ _HEX_SHA256 = re.compile(r"[0-9a-f]{64}\Z", re.ASCII)
20
+
21
+
22
+ def _require_aware(value: datetime, field: str) -> None:
23
+ if value.tzinfo is None or value.utcoffset() is None:
24
+ raise ValueError(f"{field} MUST include timezone information")
25
+
26
+
27
+ def exact_plan_digest_for(plan: LifecyclePlan) -> str:
28
+ """Return the ``sha256:<hex>`` digest of a Plan's canonical signed payload bytes."""
29
+
30
+ if not plan.signed_payload:
31
+ raise ValueError("plan signed_payload MUST NOT be empty")
32
+ return f"sha256:{hashlib.sha256(plan.signed_payload).hexdigest()}"
33
+
34
+
35
+ @dataclass(frozen=True, slots=True, kw_only=True)
36
+ class LifecyclePhaseCompletionReceipt:
37
+ """Recorded completion receipt for an earlier fenced Plan phase."""
38
+
39
+ plan_id: str
40
+ exact_plan_digest: str
41
+ fencing_generation: int
42
+ phase: LifecyclePhase
43
+ authorization_receipt_digest: str
44
+ completed_at: datetime
45
+
46
+ def __post_init__(self) -> None:
47
+ if not self.plan_id:
48
+ raise ValueError("plan_id MUST NOT be empty")
49
+ if _PREFIXED_SHA256.fullmatch(self.exact_plan_digest) is None:
50
+ raise ValueError("exact_plan_digest MUST be sha256:<64 hex>")
51
+ if (
52
+ isinstance(self.fencing_generation, bool)
53
+ or not isinstance(self.fencing_generation, int)
54
+ or self.fencing_generation < 1
55
+ ):
56
+ raise ValueError("fencing_generation MUST be a positive integer")
57
+ if self.phase not in _SUPPORTED_PHASES:
58
+ raise ValueError("phase is unsupported")
59
+ if _PREFIXED_SHA256.fullmatch(self.authorization_receipt_digest) is None:
60
+ raise ValueError("authorization_receipt_digest MUST be sha256:<64 hex>")
61
+ _require_aware(self.completed_at, "completed_at")
62
+
63
+ def to_mapping(self) -> dict[str, object]:
64
+ return {
65
+ "schema_version": "fdai.lifecycle-phase-completion-receipt.v1",
66
+ "plan_id": self.plan_id,
67
+ "exact_plan_digest": self.exact_plan_digest,
68
+ "fencing_generation": self.fencing_generation,
69
+ "phase": self.phase,
70
+ "authorization_receipt_digest": self.authorization_receipt_digest,
71
+ "completed_at": self.completed_at.isoformat(),
72
+ }
73
+
74
+ @property
75
+ def digest(self) -> str:
76
+ return f"sha256:{canonical_digest(self.to_mapping())}"
77
+
78
+
79
+ @dataclass(frozen=True, slots=True, kw_only=True)
80
+ class LifecycleAuthorizationReceipt:
81
+ """Local lifecycle authorization receipt binding exact plan digest, inputs, and phase fence."""
82
+
83
+ plan_id: str
84
+ exact_plan_digest: str
85
+ fencing_generation: int
86
+ phase: LifecyclePhase
87
+ target_release_id: str
88
+ target_release_digest: str
89
+ configuration_revision_digest: str
90
+ window_starts_at: datetime
91
+ window_ends_at: datetime
92
+ issued_at: datetime
93
+ prerequisite_phase_receipt_digest: str | None = None
94
+
95
+ def __post_init__(self) -> None:
96
+ if not self.plan_id or not self.target_release_id:
97
+ raise ValueError("plan_id and target_release_id MUST NOT be empty")
98
+ if _PREFIXED_SHA256.fullmatch(self.exact_plan_digest) is None:
99
+ raise ValueError("exact_plan_digest MUST be sha256:<64 hex>")
100
+ if (
101
+ isinstance(self.fencing_generation, bool)
102
+ or not isinstance(self.fencing_generation, int)
103
+ or self.fencing_generation < 1
104
+ ):
105
+ raise ValueError("fencing_generation MUST be a positive integer")
106
+ if self.phase not in _SUPPORTED_PHASES:
107
+ raise ValueError("phase is unsupported")
108
+ if _HEX_SHA256.fullmatch(self.target_release_digest) is None:
109
+ raise ValueError("target_release_digest MUST be 64 lowercase hex characters")
110
+ if _HEX_SHA256.fullmatch(self.configuration_revision_digest) is None:
111
+ raise ValueError("configuration_revision_digest MUST be 64 lowercase hex characters")
112
+ if (
113
+ self.prerequisite_phase_receipt_digest is not None
114
+ and _PREFIXED_SHA256.fullmatch(self.prerequisite_phase_receipt_digest) is None
115
+ ):
116
+ raise ValueError("prerequisite_phase_receipt_digest MUST be sha256:<64 hex>")
117
+ _require_aware(self.window_starts_at, "window_starts_at")
118
+ _require_aware(self.window_ends_at, "window_ends_at")
119
+ _require_aware(self.issued_at, "issued_at")
120
+ if self.window_starts_at >= self.window_ends_at:
121
+ raise ValueError("window_starts_at MUST precede window_ends_at")
122
+
123
+ def to_mapping(self) -> dict[str, object]:
124
+ return {
125
+ "schema_version": "fdai.lifecycle-authorization-receipt.v1",
126
+ "plan_id": self.plan_id,
127
+ "exact_plan_digest": self.exact_plan_digest,
128
+ "fencing_generation": self.fencing_generation,
129
+ "phase": self.phase,
130
+ "target_release_id": self.target_release_id,
131
+ "target_release_digest": self.target_release_digest,
132
+ "configuration_revision_digest": self.configuration_revision_digest,
133
+ "window_starts_at": self.window_starts_at.isoformat(),
134
+ "window_ends_at": self.window_ends_at.isoformat(),
135
+ "issued_at": self.issued_at.isoformat(),
136
+ "prerequisite_phase_receipt_digest": self.prerequisite_phase_receipt_digest,
137
+ }
138
+
139
+ @classmethod
140
+ def from_mapping(cls, raw: Mapping[str, object]) -> LifecycleAuthorizationReceipt:
141
+ if raw.get("schema_version") != "fdai.lifecycle-authorization-receipt.v1":
142
+ raise ValueError("unsupported lifecycle authorization receipt schema")
143
+ prereq = raw.get("prerequisite_phase_receipt_digest")
144
+ if prereq is not None and not isinstance(prereq, str):
145
+ raise ValueError("prerequisite_phase_receipt_digest MUST be a string or null")
146
+ phase = raw.get("phase")
147
+ if phase not in ("infrastructure", "workload"):
148
+ raise ValueError("phase is unsupported")
149
+ fencing = raw.get("fencing_generation")
150
+ if isinstance(fencing, bool) or not isinstance(fencing, int):
151
+ raise ValueError("fencing_generation MUST be an integer")
152
+ return cls(
153
+ plan_id=str(raw["plan_id"]),
154
+ exact_plan_digest=str(raw["exact_plan_digest"]),
155
+ fencing_generation=fencing,
156
+ phase=phase,
157
+ target_release_id=str(raw["target_release_id"]),
158
+ target_release_digest=str(raw["target_release_digest"]),
159
+ configuration_revision_digest=str(raw["configuration_revision_digest"]),
160
+ window_starts_at=datetime.fromisoformat(str(raw["window_starts_at"])),
161
+ window_ends_at=datetime.fromisoformat(str(raw["window_ends_at"])),
162
+ issued_at=datetime.fromisoformat(str(raw["issued_at"])),
163
+ prerequisite_phase_receipt_digest=prereq,
164
+ )
165
+
166
+ @property
167
+ def digest(self) -> str:
168
+ return f"sha256:{canonical_digest(self.to_mapping())}"
169
+
170
+
171
+ def issue_lifecycle_authorization_receipt(
172
+ plan: LifecyclePlan,
173
+ *,
174
+ phase: LifecyclePhase,
175
+ window_starts_at: datetime,
176
+ window_ends_at: datetime,
177
+ issued_at: datetime,
178
+ prerequisite_receipt: LifecyclePhaseCompletionReceipt | None = None,
179
+ ) -> LifecycleAuthorizationReceipt:
180
+ """Bind the exact signed Plan digest and optional earlier-phase receipt into a local receipt."""
181
+
182
+ return LifecycleAuthorizationReceipt(
183
+ plan_id=plan.plan_id,
184
+ exact_plan_digest=exact_plan_digest_for(plan),
185
+ fencing_generation=plan.fencing_generation,
186
+ phase=phase,
187
+ target_release_id=plan.target_release_id,
188
+ target_release_digest=plan.target_release_digest,
189
+ configuration_revision_digest=plan.configuration_revision_digest,
190
+ window_starts_at=window_starts_at,
191
+ window_ends_at=window_ends_at,
192
+ issued_at=issued_at,
193
+ prerequisite_phase_receipt_digest=(
194
+ None if prerequisite_receipt is None else prerequisite_receipt.digest
195
+ ),
196
+ )
197
+
198
+
199
+ def evaluate_phase_authorization(
200
+ plan: LifecyclePlan,
201
+ *,
202
+ phase: LifecyclePhase,
203
+ authorization_receipt: LifecycleAuthorizationReceipt | None,
204
+ trusted_now: datetime,
205
+ require_earlier_phase: bool = False,
206
+ earlier_phase_receipt: LifecyclePhaseCompletionReceipt | None = None,
207
+ ) -> PlanAdmissionDecision:
208
+ """Verify local lifecycle authorization receipt binding and earlier-phase fencing."""
209
+
210
+ _require_aware(trusted_now, "trusted_now")
211
+ expected_plan_digest = exact_plan_digest_for(plan)
212
+ if require_earlier_phase:
213
+ if earlier_phase_receipt is None:
214
+ return PlanAdmissionDecision(
215
+ False, "earlier_phase_receipt_missing", ("infrastructure",)
216
+ )
217
+ if (
218
+ earlier_phase_receipt.plan_id != plan.plan_id
219
+ or earlier_phase_receipt.exact_plan_digest != expected_plan_digest
220
+ or earlier_phase_receipt.fencing_generation != plan.fencing_generation
221
+ or earlier_phase_receipt.phase != "infrastructure"
222
+ ):
223
+ return PlanAdmissionDecision(
224
+ False, "earlier_phase_receipt_mismatch", (earlier_phase_receipt.plan_id,)
225
+ )
226
+ if authorization_receipt is None:
227
+ return PlanAdmissionDecision(False, "authorization_receipt_missing", (plan.plan_id,))
228
+ if authorization_receipt.fencing_generation != plan.fencing_generation:
229
+ return PlanAdmissionDecision(
230
+ False, "phase_fencing_mismatch", (str(authorization_receipt.fencing_generation),)
231
+ )
232
+ if (
233
+ authorization_receipt.plan_id != plan.plan_id
234
+ or authorization_receipt.exact_plan_digest != expected_plan_digest
235
+ or authorization_receipt.phase != phase
236
+ or authorization_receipt.target_release_id != plan.target_release_id
237
+ or authorization_receipt.target_release_digest != plan.target_release_digest
238
+ or authorization_receipt.configuration_revision_digest != plan.configuration_revision_digest
239
+ ):
240
+ return PlanAdmissionDecision(False, "authorization_receipt_plan_mismatch", (plan.plan_id,))
241
+ if (
242
+ require_earlier_phase
243
+ and earlier_phase_receipt is not None
244
+ and authorization_receipt.prerequisite_phase_receipt_digest != earlier_phase_receipt.digest
245
+ ):
246
+ return PlanAdmissionDecision(False, "earlier_phase_receipt_mismatch", (plan.plan_id,))
247
+ if not (
248
+ authorization_receipt.window_starts_at <= trusted_now < authorization_receipt.window_ends_at
249
+ and trusted_now < plan.expires_at
250
+ ):
251
+ return PlanAdmissionDecision(False, "authorization_receipt_window_closed", (plan.plan_id,))
252
+ return PlanAdmissionDecision(True, "allowed", ())
@@ -0,0 +1,173 @@
1
+ """Lifecycle commands with mandatory expiry, authority-lowering bounds, and break-glass rules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime
8
+ from typing import Literal
9
+
10
+ from fdai_deployment_cli.contracts import canonical_bytes
11
+ from fdai_deployment_cli.lifecycle_configuration import (
12
+ ConfigurationValidationError,
13
+ validate_configuration_package_for_signing,
14
+ )
15
+ from fdai_deployment_cli.lifecycle_plan import PlanAdmissionDecision
16
+
17
+ LifecycleCommandKind = Literal["suppression-window", "authority-lowering", "break-glass"]
18
+ BreakGlassBypass = Literal["maintenance-window", "suppression-window", "version-range"]
19
+
20
+ _SUPPORTED_COMMAND_KINDS = frozenset({"suppression-window", "authority-lowering", "break-glass"})
21
+ _ALLOWED_BREAK_GLASS_BYPASSES: frozenset[str] = frozenset(
22
+ {"maintenance-window", "suppression-window", "version-range"}
23
+ )
24
+ _ALLOWED_LOWERED_MODES = frozenset({"shadow"})
25
+ _ALLOWED_LOWERED_AUTONOMY = frozenset({"shadow", "hil"})
26
+
27
+
28
+ def _require_aware(value: datetime, field_name: str) -> None:
29
+ if value.tzinfo is None or value.utcoffset() is None:
30
+ raise ValueError(f"{field_name} MUST include timezone information")
31
+
32
+
33
+ @dataclass(frozen=True, slots=True, kw_only=True)
34
+ class LifecycleCommand:
35
+ """One time-bounded operator or break-glass command.
36
+
37
+ Every command requires an explicit ``expires_at`` after ``starts_at``. No command may
38
+ raise authority (promote an ActionType to ``enforce``, raise autonomy to ``auto``,
39
+ disengage the kill switch, or bypass signature, envelope, recall, schema, residency, or
40
+ destructive-change checks).
41
+ """
42
+
43
+ command_id: str
44
+ kind: LifecycleCommandKind
45
+ actor: str
46
+ reason: str
47
+ starts_at: datetime
48
+ expires_at: datetime | None = None
49
+ scope: str = "installation"
50
+ target_action_type: str | None = None
51
+ target_mode: str | None = None
52
+ target_autonomy: str | None = None
53
+ engage_kill_switch: bool = False
54
+ disengage_kill_switch: bool = False
55
+ bypass_scopes: frozenset[str] = field(default_factory=frozenset)
56
+ emergency_overrides: tuple[Mapping[str, object], ...] = ()
57
+ fresh_authenticated: bool = True
58
+
59
+ def is_active(self, now: datetime) -> bool:
60
+ _require_aware(now, "now")
61
+ if self.expires_at is None:
62
+ return False
63
+ return self.starts_at <= now < self.expires_at
64
+
65
+ def is_expired(self, now: datetime) -> bool:
66
+ _require_aware(now, "now")
67
+ if self.expires_at is None:
68
+ return False
69
+ return now >= self.expires_at
70
+
71
+
72
+ def validate_lifecycle_command(
73
+ command: LifecycleCommand,
74
+ *,
75
+ trusted_now: datetime | None = None,
76
+ ) -> PlanAdmissionDecision:
77
+ """Validate mandatory expiry, kind-specific bounds, and the no-authority-raising invariant."""
78
+
79
+ if not command.command_id.strip() or not command.actor.strip() or not command.reason.strip():
80
+ return PlanAdmissionDecision(False, "command_metadata_missing", (command.command_id,))
81
+ if command.kind not in _SUPPORTED_COMMAND_KINDS:
82
+ return PlanAdmissionDecision(False, "command_kind_unsupported", (str(command.kind),))
83
+ _require_aware(command.starts_at, "starts_at")
84
+ if command.expires_at is None:
85
+ return PlanAdmissionDecision(False, "command_expiry_required", (command.command_id,))
86
+ _require_aware(command.expires_at, "expires_at")
87
+ if command.expires_at <= command.starts_at:
88
+ return PlanAdmissionDecision(False, "command_window_invalid", (command.command_id,))
89
+ if trusted_now is not None:
90
+ _require_aware(trusted_now, "trusted_now")
91
+ if command.expires_at <= trusted_now:
92
+ return PlanAdmissionDecision(False, "command_expired", (command.command_id,))
93
+
94
+ # Authority-raising checks apply across every command kind.
95
+ if command.disengage_kill_switch:
96
+ return PlanAdmissionDecision(False, "command_raises_authority", ("disengage_kill_switch",))
97
+ if command.target_mode is not None and command.target_mode not in _ALLOWED_LOWERED_MODES:
98
+ return PlanAdmissionDecision(
99
+ False, "command_raises_authority", (f"target_mode:{command.target_mode}",)
100
+ )
101
+ if (
102
+ command.target_autonomy is not None
103
+ and command.target_autonomy not in _ALLOWED_LOWERED_AUTONOMY
104
+ ):
105
+ return PlanAdmissionDecision(
106
+ False, "command_raises_authority", (f"target_autonomy:{command.target_autonomy}",)
107
+ )
108
+ forbidden_bypasses = sorted(command.bypass_scopes - _ALLOWED_BREAK_GLASS_BYPASSES)
109
+ if forbidden_bypasses:
110
+ return PlanAdmissionDecision(False, "command_raises_authority", tuple(forbidden_bypasses))
111
+
112
+ if command.kind == "suppression-window":
113
+ if (
114
+ command.target_mode is not None
115
+ or command.target_autonomy is not None
116
+ or command.engage_kill_switch
117
+ or command.bypass_scopes
118
+ or command.emergency_overrides
119
+ ):
120
+ return PlanAdmissionDecision(False, "command_scope_invalid", (command.command_id,))
121
+ elif command.kind == "authority-lowering":
122
+ if command.bypass_scopes or command.emergency_overrides:
123
+ return PlanAdmissionDecision(False, "command_raises_authority", (command.command_id,))
124
+ if not (
125
+ command.target_mode == "shadow"
126
+ or command.target_autonomy in _ALLOWED_LOWERED_AUTONOMY
127
+ or command.engage_kill_switch
128
+ ):
129
+ return PlanAdmissionDecision(
130
+ False, "command_lowering_effect_missing", (command.command_id,)
131
+ )
132
+ elif command.kind == "break-glass":
133
+ if not command.fresh_authenticated:
134
+ return PlanAdmissionDecision(
135
+ False, "break_glass_fresh_auth_required", (command.command_id,)
136
+ )
137
+ if not (command.bypass_scopes or command.emergency_overrides):
138
+ return PlanAdmissionDecision(False, "break_glass_effect_missing", (command.command_id,))
139
+ if command.emergency_overrides:
140
+ try:
141
+ validate_configuration_package_for_signing(
142
+ {"overrides": command.emergency_overrides}
143
+ )
144
+ except ConfigurationValidationError as error:
145
+ return PlanAdmissionDecision(
146
+ False, f"configuration_{error.code}", (command.command_id,)
147
+ )
148
+ return PlanAdmissionDecision(True, "allowed", ())
149
+
150
+
151
+ def require_valid_lifecycle_command(
152
+ command: LifecycleCommand,
153
+ *,
154
+ trusted_now: datetime | None = None,
155
+ ) -> LifecycleCommand:
156
+ """Return ``command`` if valid, or raise ``ValueError`` with the rejection reason code."""
157
+
158
+ decision = validate_lifecycle_command(command, trusted_now=trusted_now)
159
+ if not decision.allowed:
160
+ raise ValueError(decision.reason_code)
161
+ return command
162
+
163
+
164
+ def overrides_adopted(
165
+ emergency_overrides: tuple[Mapping[str, object], ...],
166
+ approved_overrides: tuple[Mapping[str, object], ...],
167
+ ) -> bool:
168
+ """Return whether every emergency override block is present in the approved overrides."""
169
+
170
+ if not emergency_overrides:
171
+ return True
172
+ approved_canonical = {canonical_bytes(block) for block in approved_overrides}
173
+ return all(canonical_bytes(block) in approved_canonical for block in emergency_overrides)