fdai-deployment-cli 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fdai_deployment_cli/__about__.py +3 -0
- fdai_deployment_cli/__init__.py +5 -0
- fdai_deployment_cli/__main__.py +5 -0
- fdai_deployment_cli/acr_publish.py +585 -0
- fdai_deployment_cli/aks_catalog_review.py +137 -0
- fdai_deployment_cli/aks_historical_reconciliation.py +732 -0
- fdai_deployment_cli/aks_job_environment.py +59 -0
- fdai_deployment_cli/aks_job_execution.py +794 -0
- fdai_deployment_cli/aks_kubernetes_defaults.py +66 -0
- fdai_deployment_cli/aks_preflight.py +422 -0
- fdai_deployment_cli/aks_readiness.py +220 -0
- fdai_deployment_cli/aks_service_update.py +180 -0
- fdai_deployment_cli/aks_workload_jobs.py +281 -0
- fdai_deployment_cli/application_state_adoption.py +347 -0
- fdai_deployment_cli/azure_actor.py +61 -0
- fdai_deployment_cli/azure_naming.py +252 -0
- fdai_deployment_cli/azure_retail_prices.py +173 -0
- fdai_deployment_cli/bootstrap_approval.py +99 -0
- fdai_deployment_cli/bootstrap_channel.py +321 -0
- fdai_deployment_cli/bootstrap_executor.py +311 -0
- fdai_deployment_cli/bootstrap_plan.py +237 -0
- fdai_deployment_cli/bootstrap_reconcile.py +402 -0
- fdai_deployment_cli/bootstrap_support.py +138 -0
- fdai_deployment_cli/bootstrap_worker.py +448 -0
- fdai_deployment_cli/bundle.py +352 -0
- fdai_deployment_cli/capacity.py +143 -0
- fdai_deployment_cli/catalog_review_profile.py +306 -0
- fdai_deployment_cli/cli.py +798 -0
- fdai_deployment_cli/cli_help.py +214 -0
- fdai_deployment_cli/cli_parser.py +791 -0
- fdai_deployment_cli/cli_plan.py +450 -0
- fdai_deployment_cli/compiler.py +99 -0
- fdai_deployment_cli/console_artifact.py +218 -0
- fdai_deployment_cli/console_config.py +214 -0
- fdai_deployment_cli/console_update.py +832 -0
- fdai_deployment_cli/contracts.py +307 -0
- fdai_deployment_cli/control_package.py +140 -0
- fdai_deployment_cli/deadline_transport.py +70 -0
- fdai_deployment_cli/deployment_cost.py +131 -0
- fdai_deployment_cli/deployment_deadline.py +28 -0
- fdai_deployment_cli/deployment_kit.py +790 -0
- fdai_deployment_cli/deployment_kit_cache.py +191 -0
- fdai_deployment_cli/deployment_progress.py +648 -0
- fdai_deployment_cli/development_authority_cli.py +56 -0
- fdai_deployment_cli/development_authority_profile.py +319 -0
- fdai_deployment_cli/doctor.py +136 -0
- fdai_deployment_cli/entitlement_preflight.py +68 -0
- fdai_deployment_cli/entitlement_transport.py +49 -0
- fdai_deployment_cli/entra_bootstrap.py +579 -0
- fdai_deployment_cli/entra_control_profile.py +214 -0
- fdai_deployment_cli/entra_graph.py +349 -0
- fdai_deployment_cli/entra_profiles.py +215 -0
- fdai_deployment_cli/entra_source.py +244 -0
- fdai_deployment_cli/execution_bundle.py +478 -0
- fdai_deployment_cli/execution_copies.py +63 -0
- fdai_deployment_cli/foundation_adoption.py +438 -0
- fdai_deployment_cli/foundation_adoption_evidence.py +245 -0
- fdai_deployment_cli/foundation_adoption_host.py +157 -0
- fdai_deployment_cli/foundation_adoption_transport.py +51 -0
- fdai_deployment_cli/foundation_failure.py +72 -0
- fdai_deployment_cli/foundation_image.py +168 -0
- fdai_deployment_cli/foundation_input.py +235 -0
- fdai_deployment_cli/foundation_output.py +154 -0
- fdai_deployment_cli/foundation_plan.py +381 -0
- fdai_deployment_cli/foundation_process.py +64 -0
- fdai_deployment_cli/foundation_progress.py +231 -0
- fdai_deployment_cli/github_actions.py +171 -0
- fdai_deployment_cli/github_apply_artifact.py +259 -0
- fdai_deployment_cli/github_artifact_io.py +48 -0
- fdai_deployment_cli/github_dispatch.py +248 -0
- fdai_deployment_cli/github_plan_artifact.py +181 -0
- fdai_deployment_cli/github_provider_artifact.py +170 -0
- fdai_deployment_cli/github_transport.py +46 -0
- fdai_deployment_cli/github_workflow_values.py +312 -0
- fdai_deployment_cli/hub_image_deploy.py +354 -0
- fdai_deployment_cli/hub_image_stage.py +162 -0
- fdai_deployment_cli/hub_image_trust.py +281 -0
- fdai_deployment_cli/hub_ownership.py +161 -0
- fdai_deployment_cli/hub_workload_handoff.py +264 -0
- fdai_deployment_cli/hub_workload_handoff_inputs.py +227 -0
- fdai_deployment_cli/identity_profile.py +227 -0
- fdai_deployment_cli/image_targets.py +197 -0
- fdai_deployment_cli/installation_scope.py +280 -0
- fdai_deployment_cli/license.py +311 -0
- fdai_deployment_cli/license_issue.py +239 -0
- fdai_deployment_cli/lifecycle_authorization.py +252 -0
- fdai_deployment_cli/lifecycle_commands.py +173 -0
- fdai_deployment_cli/lifecycle_configuration.py +722 -0
- fdai_deployment_cli/lifecycle_configuration_artifact.py +45 -0
- fdai_deployment_cli/lifecycle_configuration_cli.py +324 -0
- fdai_deployment_cli/lifecycle_configuration_document.py +74 -0
- fdai_deployment_cli/lifecycle_configuration_entities.py +146 -0
- fdai_deployment_cli/lifecycle_configuration_package.py +492 -0
- fdai_deployment_cli/lifecycle_configuration_publication.py +277 -0
- fdai_deployment_cli/lifecycle_configuration_publication_cli.py +191 -0
- fdai_deployment_cli/lifecycle_configuration_sealing.py +127 -0
- fdai_deployment_cli/lifecycle_enrollment.py +45 -0
- fdai_deployment_cli/lifecycle_infrastructure_agent.py +174 -0
- fdai_deployment_cli/lifecycle_installation_binding.py +65 -0
- fdai_deployment_cli/lifecycle_native.py +247 -0
- fdai_deployment_cli/lifecycle_native_reconciliation.py +568 -0
- fdai_deployment_cli/lifecycle_native_transition.py +339 -0
- fdai_deployment_cli/lifecycle_ownership_exclusion.py +78 -0
- fdai_deployment_cli/lifecycle_plan.py +800 -0
- fdai_deployment_cli/lifecycle_promotion.py +185 -0
- fdai_deployment_cli/lifecycle_release.py +308 -0
- fdai_deployment_cli/lifecycle_self_upgrade.py +202 -0
- fdai_deployment_cli/lifecycle_upgrade_bundle.py +176 -0
- fdai_deployment_cli/notification_recipients_input.py +222 -0
- fdai_deployment_cli/observer_artifact.py +106 -0
- fdai_deployment_cli/oci_archive.py +353 -0
- fdai_deployment_cli/offline_kit.py +797 -0
- fdai_deployment_cli/offline_prepare.py +135 -0
- fdai_deployment_cli/operational_evidence_verifier_input.py +575 -0
- fdai_deployment_cli/plan_input.py +101 -0
- fdai_deployment_cli/private_output.py +192 -0
- fdai_deployment_cli/profile.py +70 -0
- fdai_deployment_cli/progress.py +183 -0
- fdai_deployment_cli/readiness.py +286 -0
- fdai_deployment_cli/run_command_receiver.py +418 -0
- fdai_deployment_cli/runtime_build.py +386 -0
- fdai_deployment_cli/runtime_profile.py +287 -0
- fdai_deployment_cli/runtime_release.py +745 -0
- fdai_deployment_cli/runtime_stage.py +82 -0
- fdai_deployment_cli/runtime_support_installation.py +215 -0
- fdai_deployment_cli/simulation.py +80 -0
- fdai_deployment_cli/source_application_inputs.py +397 -0
- fdai_deployment_cli/source_azure.py +586 -0
- fdai_deployment_cli/source_change_cli.py +107 -0
- fdai_deployment_cli/source_change_parser.py +75 -0
- fdai_deployment_cli/source_change_plan.py +181 -0
- fdai_deployment_cli/source_changed_update.py +196 -0
- fdai_deployment_cli/source_deploy.py +170 -0
- fdai_deployment_cli/source_foundation.py +174 -0
- fdai_deployment_cli/source_host_tools.py +163 -0
- fdai_deployment_cli/source_image_stage.py +400 -0
- fdai_deployment_cli/source_input.py +173 -0
- fdai_deployment_cli/source_projection.py +164 -0
- fdai_deployment_cli/source_receiver.py +76 -0
- fdai_deployment_cli/source_recovery.py +264 -0
- fdai_deployment_cli/source_runtime.py +81 -0
- fdai_deployment_cli/source_runtime_support.py +368 -0
- fdai_deployment_cli/source_service_update.py +726 -0
- fdai_deployment_cli/source_service_update_cli.py +47 -0
- fdai_deployment_cli/source_snapshot.py +178 -0
- fdai_deployment_cli/source_teardown.py +574 -0
- fdai_deployment_cli/source_terraform.py +85 -0
- fdai_deployment_cli/source_transport.py +399 -0
- fdai_deployment_cli/standalone_aks_inventory.py +558 -0
- fdai_deployment_cli/standalone_aks_job_execution.py +769 -0
- fdai_deployment_cli/standalone_aks_nodepool_guard.py +141 -0
- fdai_deployment_cli/standalone_application.py +977 -0
- fdai_deployment_cli/standalone_application_completion.py +151 -0
- fdai_deployment_cli/standalone_application_input_recovery.py +107 -0
- fdai_deployment_cli/standalone_application_recovery.py +116 -0
- fdai_deployment_cli/standalone_approval_input.py +24 -0
- fdai_deployment_cli/standalone_catalog_checkpoint.py +122 -0
- fdai_deployment_cli/standalone_catalog_review.py +251 -0
- fdai_deployment_cli/standalone_checkpoint_failure.py +127 -0
- fdai_deployment_cli/standalone_console_publish.py +174 -0
- fdai_deployment_cli/standalone_database_principals.py +41 -0
- fdai_deployment_cli/standalone_deploy.py +638 -0
- fdai_deployment_cli/standalone_development_authority.py +166 -0
- fdai_deployment_cli/standalone_foundation_adoption.py +87 -0
- fdai_deployment_cli/standalone_foundation_transition.py +767 -0
- fdai_deployment_cli/standalone_host.py +4608 -0
- fdai_deployment_cli/standalone_host_rebind.py +226 -0
- fdai_deployment_cli/standalone_host_residual.py +104 -0
- fdai_deployment_cli/standalone_host_state.py +145 -0
- fdai_deployment_cli/standalone_host_values.py +563 -0
- fdai_deployment_cli/standalone_kubernetes_tools.py +100 -0
- fdai_deployment_cli/standalone_license_installation.py +177 -0
- fdai_deployment_cli/standalone_management_egress.py +83 -0
- fdai_deployment_cli/standalone_migration_evidence.py +38 -0
- fdai_deployment_cli/standalone_operational_evidence.py +356 -0
- fdai_deployment_cli/standalone_planned_outputs.py +147 -0
- fdai_deployment_cli/standalone_private_access_probe.py +100 -0
- fdai_deployment_cli/standalone_product_profile.py +110 -0
- fdai_deployment_cli/standalone_remote_prepare.py +442 -0
- fdai_deployment_cli/standalone_residual_apply.py +540 -0
- fdai_deployment_cli/standalone_residual_successor.py +431 -0
- fdai_deployment_cli/standalone_review.py +266 -0
- fdai_deployment_cli/standalone_service_update_finalization.py +116 -0
- fdai_deployment_cli/standalone_service_update_recovery.py +277 -0
- fdai_deployment_cli/standalone_stage_targets.py +325 -0
- fdai_deployment_cli/standalone_status.py +67 -0
- fdai_deployment_cli/standalone_terraform_environment.py +237 -0
- fdai_deployment_cli/standalone_transfer_cleanup.py +237 -0
- fdai_deployment_cli/standalone_trial_activation.py +193 -0
- fdai_deployment_cli/state.py +434 -0
- fdai_deployment_cli/state_handoff.py +253 -0
- fdai_deployment_cli/status_projection.py +97 -0
- fdai_deployment_cli/support_install.py +257 -0
- fdai_deployment_cli/target.py +46 -0
- fdai_deployment_cli/trust/capabilities.json +62 -0
- fdai_deployment_cli/trust/deployment-bundle-root.pub +3 -0
- fdai_deployment_cli/trust/deployment-release-root.pub +3 -0
- fdai_deployment_cli/trust/upstream-signing-key.pub +3 -0
- fdai_deployment_cli/trust_roots.py +50 -0
- fdai_deployment_cli-0.1.2.dist-info/METADATA +112 -0
- fdai_deployment_cli-0.1.2.dist-info/RECORD +203 -0
- fdai_deployment_cli-0.1.2.dist-info/WHEEL +4 -0
- fdai_deployment_cli-0.1.2.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,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)
|