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,800 @@
|
|
|
1
|
+
"""Pure shadow-only Lifecycle Plan admission and constraint checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass, field as dataclass_field
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from typing import Literal, cast
|
|
11
|
+
|
|
12
|
+
from fdai_deployment_cli.contracts import canonical_bytes
|
|
13
|
+
from fdai_deployment_cli.lifecycle_configuration import (
|
|
14
|
+
ConfigurationValidationError,
|
|
15
|
+
resolve_configuration_layers,
|
|
16
|
+
)
|
|
17
|
+
from fdai_deployment_cli.lifecycle_release import LIFECYCLE_IMAGE_SECTIONS, LifecycleRelease
|
|
18
|
+
from fdai_service_contracts.catalog_canonical import SignatureVerifier as SignatureVerifier
|
|
19
|
+
from fdai_deployment_cli.runtime_release import (
|
|
20
|
+
RecallRecord,
|
|
21
|
+
ReleaseDecision,
|
|
22
|
+
evaluate_recall_candidate,
|
|
23
|
+
validate_schema_transition,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
CapabilityMode = Literal["shadow", "enforce"]
|
|
27
|
+
SchemaDirection = Literal["upgrade", "rollback"]
|
|
28
|
+
PlanType = Literal[
|
|
29
|
+
"install",
|
|
30
|
+
"upgrade",
|
|
31
|
+
"configuration-change",
|
|
32
|
+
"recall-rolloff",
|
|
33
|
+
"rollback",
|
|
34
|
+
"drift-reconciliation",
|
|
35
|
+
]
|
|
36
|
+
SuppressionOrigin = Literal["manual", "failure"]
|
|
37
|
+
|
|
38
|
+
_MODE_ORDER: Mapping[CapabilityMode, int] = {"shadow": 0, "enforce": 1}
|
|
39
|
+
_SCOPE_ID = re.compile(r"[a-z][a-z0-9._-]*\Z", re.ASCII)
|
|
40
|
+
_SUPPORTED_PLAN_TYPES = frozenset(
|
|
41
|
+
{
|
|
42
|
+
"install",
|
|
43
|
+
"upgrade",
|
|
44
|
+
"configuration-change",
|
|
45
|
+
"recall-rolloff",
|
|
46
|
+
"rollback",
|
|
47
|
+
"drift-reconciliation",
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
_PLAN_PAYLOAD_KEYS = frozenset(
|
|
51
|
+
{
|
|
52
|
+
"audience",
|
|
53
|
+
"capability_ids",
|
|
54
|
+
"configuration_revision_digest",
|
|
55
|
+
"declared_duration_minutes",
|
|
56
|
+
"entity_ids",
|
|
57
|
+
"envelope",
|
|
58
|
+
"expires_at",
|
|
59
|
+
"fencing_generation",
|
|
60
|
+
"hub_key_epoch",
|
|
61
|
+
"hub_key_id",
|
|
62
|
+
"plan_id",
|
|
63
|
+
"plan_type",
|
|
64
|
+
"release_regions",
|
|
65
|
+
"rollback_target_plan_id",
|
|
66
|
+
"sequence",
|
|
67
|
+
"source_state_digest",
|
|
68
|
+
"target_release_digest",
|
|
69
|
+
"target_release_id",
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
_PLAN_OPTIONAL_PAYLOAD_KEYS = frozenset({"ownership_digests"})
|
|
73
|
+
_ENVELOPE_PAYLOAD_KEYS = frozenset(
|
|
74
|
+
{"capability_modes", "destructive_allowed", "entity_ids", "max_duration_minutes", "regions"}
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, slots=True)
|
|
79
|
+
class LifecycleEffectEnvelope:
|
|
80
|
+
"""Maximum effect surface a Plan may use; a Hub Plan can only narrow it."""
|
|
81
|
+
|
|
82
|
+
entity_ids: frozenset[str]
|
|
83
|
+
regions: frozenset[str]
|
|
84
|
+
capability_modes: Mapping[str, CapabilityMode]
|
|
85
|
+
destructive_allowed: bool
|
|
86
|
+
max_duration_minutes: int
|
|
87
|
+
|
|
88
|
+
def __post_init__(self) -> None:
|
|
89
|
+
if not isinstance(self.destructive_allowed, bool):
|
|
90
|
+
raise TypeError("destructive_allowed MUST be a boolean")
|
|
91
|
+
if (
|
|
92
|
+
isinstance(self.max_duration_minutes, bool)
|
|
93
|
+
or not isinstance(self.max_duration_minutes, int)
|
|
94
|
+
or self.max_duration_minutes < 1
|
|
95
|
+
):
|
|
96
|
+
raise ValueError("max_duration_minutes MUST be positive")
|
|
97
|
+
for capability, mode in self.capability_modes.items():
|
|
98
|
+
if not isinstance(capability, str):
|
|
99
|
+
raise TypeError("capability mode keys MUST be strings")
|
|
100
|
+
if mode not in _MODE_ORDER:
|
|
101
|
+
raise ValueError("capability mode is unsupported")
|
|
102
|
+
|
|
103
|
+
def narrowed_by(self, maximum: LifecycleEffectEnvelope) -> bool:
|
|
104
|
+
"""Return whether this envelope is equal to or narrower than the local maximum."""
|
|
105
|
+
|
|
106
|
+
if not self.entity_ids <= maximum.entity_ids:
|
|
107
|
+
return False
|
|
108
|
+
if not self.regions <= maximum.regions:
|
|
109
|
+
return False
|
|
110
|
+
if self.destructive_allowed and not maximum.destructive_allowed:
|
|
111
|
+
return False
|
|
112
|
+
if self.max_duration_minutes > maximum.max_duration_minutes:
|
|
113
|
+
return False
|
|
114
|
+
for capability, requested_mode in self.capability_modes.items():
|
|
115
|
+
maximum_mode = maximum.capability_modes.get(capability)
|
|
116
|
+
if maximum_mode is None or _MODE_ORDER[requested_mode] > _MODE_ORDER[maximum_mode]:
|
|
117
|
+
return False
|
|
118
|
+
return True
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True, slots=True)
|
|
122
|
+
class LifecyclePlan:
|
|
123
|
+
"""Parsed Plan fields relevant to local shadow-only admission."""
|
|
124
|
+
|
|
125
|
+
plan_id: str
|
|
126
|
+
audience: str
|
|
127
|
+
hub_key_epoch: int
|
|
128
|
+
hub_key_id: str
|
|
129
|
+
source_state_digest: str
|
|
130
|
+
sequence: int
|
|
131
|
+
fencing_generation: int
|
|
132
|
+
plan_type: PlanType
|
|
133
|
+
target_release_id: str
|
|
134
|
+
target_release_digest: str
|
|
135
|
+
configuration_revision_digest: str
|
|
136
|
+
entity_ids: frozenset[str]
|
|
137
|
+
capability_ids: tuple[str, ...]
|
|
138
|
+
release_regions: frozenset[str]
|
|
139
|
+
rollback_target_plan_id: str | None
|
|
140
|
+
declared_duration_minutes: int
|
|
141
|
+
envelope: LifecycleEffectEnvelope
|
|
142
|
+
expires_at: datetime
|
|
143
|
+
signed_payload: bytes
|
|
144
|
+
signature: bytes
|
|
145
|
+
ownership_digests: Mapping[str, str] = dataclass_field(default_factory=dict)
|
|
146
|
+
|
|
147
|
+
def __post_init__(self) -> None:
|
|
148
|
+
if isinstance(self.sequence, bool) or not isinstance(self.sequence, int):
|
|
149
|
+
raise TypeError("sequence MUST be an integer")
|
|
150
|
+
if self.sequence < 0:
|
|
151
|
+
raise ValueError("sequence MUST be non-negative")
|
|
152
|
+
if not isinstance(self.plan_type, str):
|
|
153
|
+
raise TypeError("plan_type MUST be a string")
|
|
154
|
+
if (
|
|
155
|
+
isinstance(self.declared_duration_minutes, bool)
|
|
156
|
+
or not isinstance(self.declared_duration_minutes, int)
|
|
157
|
+
or self.declared_duration_minutes < 1
|
|
158
|
+
):
|
|
159
|
+
raise ValueError("declared_duration_minutes MUST be positive")
|
|
160
|
+
if any(
|
|
161
|
+
_SCOPE_ID.fullmatch(entity_id) is None
|
|
162
|
+
or re.fullmatch(r"sha256:[0-9a-f]{64}", digest, re.ASCII) is None
|
|
163
|
+
for entity_id, digest in self.ownership_digests.items()
|
|
164
|
+
):
|
|
165
|
+
raise ValueError("ownership digests MUST bind canonical Entity ids to SHA-256 digests")
|
|
166
|
+
if not set(self.ownership_digests) <= self.entity_ids:
|
|
167
|
+
raise ValueError("ownership digests MUST stay inside the Plan Entity set")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass(frozen=True, slots=True)
|
|
171
|
+
class PlanAdmissionState:
|
|
172
|
+
"""Durable local state used to reject replayed or cross-installation Plans."""
|
|
173
|
+
|
|
174
|
+
expected_audience: str
|
|
175
|
+
expected_source_state_digest: str
|
|
176
|
+
last_accepted_sequence: int
|
|
177
|
+
current_hub_key_epoch: int
|
|
178
|
+
current_fencing_generation: int
|
|
179
|
+
active_hub_key_ids: frozenset[str]
|
|
180
|
+
revoked_hub_key_ids: frozenset[str]
|
|
181
|
+
hub_key_epochs: Mapping[str, int]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass(frozen=True, slots=True)
|
|
185
|
+
class PlanAdmissionDecision:
|
|
186
|
+
"""Typed allow or deny result for Plan admission."""
|
|
187
|
+
|
|
188
|
+
allowed: bool
|
|
189
|
+
reason_code: str
|
|
190
|
+
details: tuple[str, ...] = ()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@dataclass(frozen=True, slots=True)
|
|
194
|
+
class ArtifactRequirement:
|
|
195
|
+
"""One artifact digest required inside the installation boundary."""
|
|
196
|
+
|
|
197
|
+
name: str
|
|
198
|
+
digest: str
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass(frozen=True, slots=True)
|
|
202
|
+
class DataResidencyRequirement:
|
|
203
|
+
"""Allowed and proposed processing regions for a candidate Plan."""
|
|
204
|
+
|
|
205
|
+
allowed_regions: frozenset[str]
|
|
206
|
+
release_regions: frozenset[str]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@dataclass(frozen=True, slots=True)
|
|
210
|
+
class MaintenanceWindow:
|
|
211
|
+
"""A UTC maintenance window opened by customer configuration."""
|
|
212
|
+
|
|
213
|
+
starts_at: datetime
|
|
214
|
+
ends_at: datetime
|
|
215
|
+
allows_downtime: bool
|
|
216
|
+
|
|
217
|
+
def __post_init__(self) -> None:
|
|
218
|
+
_require_aware_datetime(self.starts_at, "starts_at")
|
|
219
|
+
_require_aware_datetime(self.ends_at, "ends_at")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@dataclass(frozen=True, slots=True)
|
|
223
|
+
class SuppressionWindow:
|
|
224
|
+
"""A UTC suppression window for an installation, entity, or plan type."""
|
|
225
|
+
|
|
226
|
+
scope: str
|
|
227
|
+
starts_at: datetime
|
|
228
|
+
ends_at: datetime
|
|
229
|
+
origin: SuppressionOrigin = "manual"
|
|
230
|
+
failed_plan_id: str | None = None
|
|
231
|
+
|
|
232
|
+
def __post_init__(self) -> None:
|
|
233
|
+
_require_aware_datetime(self.starts_at, "starts_at")
|
|
234
|
+
_require_aware_datetime(self.ends_at, "ends_at")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@dataclass(frozen=True, slots=True)
|
|
238
|
+
class LifecycleConstraintContext:
|
|
239
|
+
"""Candidate Plan facts and local evidence for pure constraint evaluation."""
|
|
240
|
+
|
|
241
|
+
now: datetime
|
|
242
|
+
plan_id: str
|
|
243
|
+
plan_type: PlanType
|
|
244
|
+
rollback_target_plan_id: str | None
|
|
245
|
+
declared_duration_minutes: int
|
|
246
|
+
requires_downtime: bool
|
|
247
|
+
target_release_version: str
|
|
248
|
+
candidate_release: LifecycleRelease
|
|
249
|
+
configuration_revision_digest: str
|
|
250
|
+
current_schema_revision: int
|
|
251
|
+
version_range: str
|
|
252
|
+
configuration_schema: Mapping[str, object]
|
|
253
|
+
environment_config: Mapping[str, object]
|
|
254
|
+
entity_overrides: tuple[Mapping[str, object], ...]
|
|
255
|
+
available_artifact_digests: frozenset[str]
|
|
256
|
+
data_residency: DataResidencyRequirement
|
|
257
|
+
maintenance_windows: tuple[MaintenanceWindow, ...]
|
|
258
|
+
suppression_windows: tuple[SuppressionWindow, ...]
|
|
259
|
+
entity_ids: frozenset[str]
|
|
260
|
+
capability_ids: tuple[str, ...]
|
|
261
|
+
recall_records: tuple[RecallRecord, ...]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@dataclass(frozen=True, slots=True)
|
|
265
|
+
class ConstraintBlock:
|
|
266
|
+
"""One blocking lifecycle constraint with a stable reason code."""
|
|
267
|
+
|
|
268
|
+
reason_code: str
|
|
269
|
+
details: tuple[str, ...] = ()
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def evaluate_plan_admission(
|
|
273
|
+
plan: LifecyclePlan,
|
|
274
|
+
*,
|
|
275
|
+
local_state: PlanAdmissionState,
|
|
276
|
+
locally_derived_maximum: LifecycleEffectEnvelope,
|
|
277
|
+
verify_signature: SignatureVerifier,
|
|
278
|
+
trusted_now: datetime,
|
|
279
|
+
) -> PlanAdmissionDecision:
|
|
280
|
+
"""Admit a signed Plan only when local replay, key, and envelope checks pass."""
|
|
281
|
+
|
|
282
|
+
if not plan.signed_payload or not plan.signature:
|
|
283
|
+
return _deny("plan_signature_invalid", plan.plan_id)
|
|
284
|
+
try:
|
|
285
|
+
verified = verify_signature(plan.hub_key_id, plan.signed_payload, plan.signature)
|
|
286
|
+
except (TypeError, ValueError):
|
|
287
|
+
return _deny("plan_signature_invalid", plan.plan_id)
|
|
288
|
+
if verified is not True:
|
|
289
|
+
return _deny("plan_signature_invalid", plan.plan_id)
|
|
290
|
+
if plan.signed_payload != canonical_plan_payload(plan):
|
|
291
|
+
return _deny("plan_payload_mismatch", plan.plan_id)
|
|
292
|
+
if plan.plan_type not in _SUPPORTED_PLAN_TYPES:
|
|
293
|
+
return _deny("plan_type_unsupported", plan.plan_type)
|
|
294
|
+
if plan.hub_key_id in local_state.revoked_hub_key_ids:
|
|
295
|
+
return _deny("hub_key_revoked", plan.hub_key_id)
|
|
296
|
+
if plan.hub_key_id not in local_state.active_hub_key_ids:
|
|
297
|
+
return _deny("hub_key_not_active", plan.hub_key_id)
|
|
298
|
+
if plan.hub_key_epoch != local_state.current_hub_key_epoch:
|
|
299
|
+
return _deny("hub_key_epoch_mismatch", str(plan.hub_key_epoch))
|
|
300
|
+
if local_state.hub_key_epochs.get(plan.hub_key_id) != plan.hub_key_epoch:
|
|
301
|
+
return _deny("hub_key_epoch_mismatch", plan.hub_key_id)
|
|
302
|
+
if trusted_now.tzinfo is None or trusted_now.utcoffset() is None:
|
|
303
|
+
return _deny("trusted_clock_timezone_missing")
|
|
304
|
+
if plan.expires_at.tzinfo is None or plan.expires_at.utcoffset() is None:
|
|
305
|
+
return _deny("plan_expiry_timezone_missing", plan.plan_id)
|
|
306
|
+
if plan.expires_at <= trusted_now:
|
|
307
|
+
return _deny("plan_expired", plan.plan_id)
|
|
308
|
+
if plan.audience != local_state.expected_audience:
|
|
309
|
+
return _deny("plan_audience_mismatch", plan.audience)
|
|
310
|
+
if plan.sequence <= local_state.last_accepted_sequence:
|
|
311
|
+
return _deny("plan_sequence_stale", str(plan.sequence))
|
|
312
|
+
if plan.fencing_generation != local_state.current_fencing_generation:
|
|
313
|
+
return _deny("plan_fencing_generation_mismatch", str(plan.fencing_generation))
|
|
314
|
+
if plan.source_state_digest != local_state.expected_source_state_digest:
|
|
315
|
+
return _deny("plan_source_state_stale", plan.source_state_digest)
|
|
316
|
+
if not plan.entity_ids <= plan.envelope.entity_ids:
|
|
317
|
+
return _deny("plan_entities_exceed_envelope", plan.plan_id)
|
|
318
|
+
if plan.declared_duration_minutes > plan.envelope.max_duration_minutes:
|
|
319
|
+
return _deny("plan_duration_exceeds_envelope", plan.plan_id)
|
|
320
|
+
if not set(plan.capability_ids) <= set(plan.envelope.capability_modes):
|
|
321
|
+
return _deny("plan_capabilities_exceed_envelope", plan.plan_id)
|
|
322
|
+
if not plan.release_regions <= plan.envelope.regions:
|
|
323
|
+
return _deny("plan_regions_exceed_envelope", plan.plan_id)
|
|
324
|
+
if not plan.envelope.narrowed_by(locally_derived_maximum):
|
|
325
|
+
return _deny("plan_envelope_exceeds_local_maximum", plan.plan_id)
|
|
326
|
+
return PlanAdmissionDecision(True, "allowed")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def canonical_plan_payload(plan: LifecyclePlan) -> bytes:
|
|
330
|
+
"""Return the byte-for-byte JSON payload the Hub key must sign."""
|
|
331
|
+
|
|
332
|
+
payload: dict[str, object] = {
|
|
333
|
+
"audience": plan.audience,
|
|
334
|
+
"envelope": {
|
|
335
|
+
"capability_modes": dict(sorted(plan.envelope.capability_modes.items())),
|
|
336
|
+
"destructive_allowed": plan.envelope.destructive_allowed,
|
|
337
|
+
"entity_ids": sorted(plan.envelope.entity_ids),
|
|
338
|
+
"max_duration_minutes": plan.envelope.max_duration_minutes,
|
|
339
|
+
"regions": sorted(plan.envelope.regions),
|
|
340
|
+
},
|
|
341
|
+
"expires_at": plan.expires_at.isoformat(),
|
|
342
|
+
"fencing_generation": plan.fencing_generation,
|
|
343
|
+
"hub_key_epoch": plan.hub_key_epoch,
|
|
344
|
+
"hub_key_id": plan.hub_key_id,
|
|
345
|
+
"capability_ids": list(plan.capability_ids),
|
|
346
|
+
"configuration_revision_digest": plan.configuration_revision_digest,
|
|
347
|
+
"declared_duration_minutes": plan.declared_duration_minutes,
|
|
348
|
+
"entity_ids": sorted(plan.entity_ids),
|
|
349
|
+
"plan_id": plan.plan_id,
|
|
350
|
+
"plan_type": plan.plan_type,
|
|
351
|
+
"rollback_target_plan_id": plan.rollback_target_plan_id,
|
|
352
|
+
"sequence": plan.sequence,
|
|
353
|
+
"source_state_digest": plan.source_state_digest,
|
|
354
|
+
"release_regions": sorted(plan.release_regions),
|
|
355
|
+
"target_release_digest": plan.target_release_digest,
|
|
356
|
+
"target_release_id": plan.target_release_id,
|
|
357
|
+
}
|
|
358
|
+
if plan.ownership_digests:
|
|
359
|
+
payload["ownership_digests"] = dict(sorted(plan.ownership_digests.items()))
|
|
360
|
+
return canonical_bytes(payload)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def decode_canonical_plan_payload(signed_payload: bytes, signature: bytes) -> LifecyclePlan:
|
|
364
|
+
"""Parse Hub-signed Plan bytes into a ``LifecyclePlan``; the inverse of
|
|
365
|
+
``canonical_plan_payload``.
|
|
366
|
+
|
|
367
|
+
The decoder checks shape only. It never verifies the signature, so callers MUST pass the
|
|
368
|
+
result to ``evaluate_plan_admission`` before trusting any field. Admission also rejects
|
|
369
|
+
bytes that parse but are not in canonical form. Raises ``ValueError`` with a sanitized
|
|
370
|
+
message for invalid UTF-8 or JSON, duplicate, unknown, or missing fields, wrong types,
|
|
371
|
+
an unsupported plan type or capability mode, and an ``expires_at`` without a timezone.
|
|
372
|
+
"""
|
|
373
|
+
|
|
374
|
+
if not isinstance(signed_payload, bytes) or not isinstance(signature, bytes):
|
|
375
|
+
raise TypeError("signed_payload and signature MUST be bytes")
|
|
376
|
+
try:
|
|
377
|
+
document = json.loads(
|
|
378
|
+
signed_payload.decode("utf-8"),
|
|
379
|
+
object_pairs_hook=_unique_plan_object,
|
|
380
|
+
parse_constant=_reject_plan_constant,
|
|
381
|
+
)
|
|
382
|
+
except UnicodeDecodeError as error:
|
|
383
|
+
raise ValueError("plan payload MUST be UTF-8 JSON") from error
|
|
384
|
+
except json.JSONDecodeError as error:
|
|
385
|
+
raise ValueError("plan payload MUST be valid JSON") from error
|
|
386
|
+
except RecursionError as error:
|
|
387
|
+
raise ValueError("plan payload nesting is too deep") from error
|
|
388
|
+
fields = _plan_object(document, None, "plan payload")
|
|
389
|
+
unknown = set(fields) - _PLAN_PAYLOAD_KEYS - _PLAN_OPTIONAL_PAYLOAD_KEYS
|
|
390
|
+
if unknown:
|
|
391
|
+
raise ValueError(f"plan payload contains unknown fields: {', '.join(sorted(unknown))}")
|
|
392
|
+
missing = _PLAN_PAYLOAD_KEYS - set(fields)
|
|
393
|
+
if missing:
|
|
394
|
+
raise ValueError(f"plan payload is missing fields: {', '.join(sorted(missing))}")
|
|
395
|
+
envelope = _plan_object(fields["envelope"], _ENVELOPE_PAYLOAD_KEYS, "envelope")
|
|
396
|
+
raw_modes = _plan_object(envelope["capability_modes"], None, "envelope.capability_modes")
|
|
397
|
+
capability_modes: dict[str, CapabilityMode] = {}
|
|
398
|
+
for capability, mode in raw_modes.items():
|
|
399
|
+
if mode not in _MODE_ORDER:
|
|
400
|
+
raise ValueError("envelope.capability_modes contains an unsupported mode")
|
|
401
|
+
capability_modes[capability] = cast(CapabilityMode, mode)
|
|
402
|
+
plan_type = _plan_text(fields["plan_type"], "plan_type")
|
|
403
|
+
if plan_type not in _SUPPORTED_PLAN_TYPES:
|
|
404
|
+
raise ValueError("plan_type is unsupported")
|
|
405
|
+
expires_at_text = _plan_text(fields["expires_at"], "expires_at")
|
|
406
|
+
try:
|
|
407
|
+
expires_at = datetime.fromisoformat(expires_at_text)
|
|
408
|
+
except ValueError as error:
|
|
409
|
+
raise ValueError("expires_at MUST be an ISO 8601 timestamp") from error
|
|
410
|
+
_require_aware_datetime(expires_at, "expires_at")
|
|
411
|
+
rollback_target = fields["rollback_target_plan_id"]
|
|
412
|
+
try:
|
|
413
|
+
return LifecyclePlan(
|
|
414
|
+
plan_id=_plan_text(fields["plan_id"], "plan_id"),
|
|
415
|
+
audience=_plan_text(fields["audience"], "audience"),
|
|
416
|
+
hub_key_epoch=_plan_integer(fields["hub_key_epoch"], "hub_key_epoch"),
|
|
417
|
+
hub_key_id=_plan_text(fields["hub_key_id"], "hub_key_id"),
|
|
418
|
+
source_state_digest=_plan_text(fields["source_state_digest"], "source_state_digest"),
|
|
419
|
+
sequence=_plan_integer(fields["sequence"], "sequence"),
|
|
420
|
+
fencing_generation=_plan_integer(fields["fencing_generation"], "fencing_generation"),
|
|
421
|
+
plan_type=cast(PlanType, plan_type),
|
|
422
|
+
target_release_id=_plan_text(fields["target_release_id"], "target_release_id"),
|
|
423
|
+
target_release_digest=_plan_text(
|
|
424
|
+
fields["target_release_digest"], "target_release_digest"
|
|
425
|
+
),
|
|
426
|
+
configuration_revision_digest=_plan_text(
|
|
427
|
+
fields["configuration_revision_digest"], "configuration_revision_digest"
|
|
428
|
+
),
|
|
429
|
+
entity_ids=frozenset(_plan_texts(fields["entity_ids"], "entity_ids")),
|
|
430
|
+
capability_ids=_plan_texts(fields["capability_ids"], "capability_ids"),
|
|
431
|
+
release_regions=frozenset(_plan_texts(fields["release_regions"], "release_regions")),
|
|
432
|
+
rollback_target_plan_id=(
|
|
433
|
+
None
|
|
434
|
+
if rollback_target is None
|
|
435
|
+
else _plan_text(rollback_target, "rollback_target_plan_id")
|
|
436
|
+
),
|
|
437
|
+
declared_duration_minutes=_plan_integer(
|
|
438
|
+
fields["declared_duration_minutes"], "declared_duration_minutes"
|
|
439
|
+
),
|
|
440
|
+
envelope=LifecycleEffectEnvelope(
|
|
441
|
+
entity_ids=frozenset(_plan_texts(envelope["entity_ids"], "envelope.entity_ids")),
|
|
442
|
+
regions=frozenset(_plan_texts(envelope["regions"], "envelope.regions")),
|
|
443
|
+
capability_modes=capability_modes,
|
|
444
|
+
destructive_allowed=_plan_boolean(
|
|
445
|
+
envelope["destructive_allowed"], "envelope.destructive_allowed"
|
|
446
|
+
),
|
|
447
|
+
max_duration_minutes=_plan_integer(
|
|
448
|
+
envelope["max_duration_minutes"], "envelope.max_duration_minutes"
|
|
449
|
+
),
|
|
450
|
+
),
|
|
451
|
+
expires_at=expires_at,
|
|
452
|
+
signed_payload=signed_payload,
|
|
453
|
+
signature=signature,
|
|
454
|
+
ownership_digests=_plan_digest_mapping(
|
|
455
|
+
fields.get("ownership_digests", {}), "ownership_digests"
|
|
456
|
+
),
|
|
457
|
+
)
|
|
458
|
+
except TypeError as error:
|
|
459
|
+
raise ValueError(f"plan payload field is invalid: {error}") from error
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _unique_plan_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
463
|
+
result: dict[str, object] = {}
|
|
464
|
+
for key, value in pairs:
|
|
465
|
+
if key in result:
|
|
466
|
+
raise ValueError("plan payload contains a duplicate field")
|
|
467
|
+
result[key] = value
|
|
468
|
+
return result
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _reject_plan_constant(value: str) -> object:
|
|
472
|
+
raise ValueError("plan payload contains a non-finite number")
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _plan_object(value: object, required: frozenset[str] | None, label: str) -> dict[str, object]:
|
|
476
|
+
if not isinstance(value, dict):
|
|
477
|
+
raise ValueError(f"{label} MUST be a JSON object")
|
|
478
|
+
if required is not None:
|
|
479
|
+
unknown = set(value) - required
|
|
480
|
+
if unknown:
|
|
481
|
+
raise ValueError(f"{label} contains unknown fields: {', '.join(sorted(unknown))}")
|
|
482
|
+
missing = required - set(value)
|
|
483
|
+
if missing:
|
|
484
|
+
raise ValueError(f"{label} is missing fields: {', '.join(sorted(missing))}")
|
|
485
|
+
return value
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _plan_text(value: object, field: str) -> str:
|
|
489
|
+
if not isinstance(value, str):
|
|
490
|
+
raise ValueError(f"{field} MUST be a string")
|
|
491
|
+
return value
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _plan_integer(value: object, field: str) -> int:
|
|
495
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
496
|
+
raise ValueError(f"{field} MUST be an integer")
|
|
497
|
+
return value
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _plan_boolean(value: object, field: str) -> bool:
|
|
501
|
+
if not isinstance(value, bool):
|
|
502
|
+
raise ValueError(f"{field} MUST be a boolean")
|
|
503
|
+
return value
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _plan_texts(value: object, field: str) -> tuple[str, ...]:
|
|
507
|
+
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
|
508
|
+
raise ValueError(f"{field} MUST be a list of strings")
|
|
509
|
+
return tuple(value)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _plan_digest_mapping(value: object, field: str) -> dict[str, str]:
|
|
513
|
+
parsed = _plan_object(value, None, field)
|
|
514
|
+
if any(
|
|
515
|
+
_SCOPE_ID.fullmatch(key) is None
|
|
516
|
+
or not isinstance(digest, str)
|
|
517
|
+
or re.fullmatch(r"sha256:[0-9a-f]{64}", digest, re.ASCII) is None
|
|
518
|
+
for key, digest in parsed.items()
|
|
519
|
+
):
|
|
520
|
+
raise ValueError(f"{field} MUST map canonical Entity ids to SHA-256 digests")
|
|
521
|
+
return {key: cast(str, digest) for key, digest in parsed.items()}
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def evaluate_lifecycle_constraints(
|
|
525
|
+
context: LifecycleConstraintContext,
|
|
526
|
+
*,
|
|
527
|
+
admitted_plan: LifecyclePlan,
|
|
528
|
+
) -> tuple[ConstraintBlock, ...]:
|
|
529
|
+
"""Return every blocking constraint for a candidate Plan without side effects."""
|
|
530
|
+
|
|
531
|
+
blocks: list[ConstraintBlock] = []
|
|
532
|
+
context_block = _plan_context_block(context, admitted_plan)
|
|
533
|
+
if context_block is not None:
|
|
534
|
+
blocks.append(context_block)
|
|
535
|
+
if context_block.reason_code == "plan_type_unsupported":
|
|
536
|
+
return tuple(blocks)
|
|
537
|
+
if context.candidate_release.digest != admitted_plan.target_release_digest:
|
|
538
|
+
blocks.append(
|
|
539
|
+
ConstraintBlock(
|
|
540
|
+
"target_release_digest_mismatch",
|
|
541
|
+
(context.candidate_release.digest, admitted_plan.target_release_digest),
|
|
542
|
+
)
|
|
543
|
+
)
|
|
544
|
+
_append_if_blocked(blocks, _maintenance_decision(context))
|
|
545
|
+
_append_if_blocked(blocks, _suppression_decision(context))
|
|
546
|
+
version_range_block = _version_range_block(
|
|
547
|
+
context.target_release_version, context.version_range
|
|
548
|
+
)
|
|
549
|
+
if version_range_block is not None:
|
|
550
|
+
blocks.append(version_range_block)
|
|
551
|
+
_append_if_blocked(
|
|
552
|
+
blocks,
|
|
553
|
+
validate_schema_transition(
|
|
554
|
+
current_schema_revision=context.current_schema_revision,
|
|
555
|
+
candidate=context.candidate_release,
|
|
556
|
+
direction=_schema_direction(context.plan_type),
|
|
557
|
+
),
|
|
558
|
+
)
|
|
559
|
+
required_artifacts = _required_artifacts(context.candidate_release)
|
|
560
|
+
if not required_artifacts:
|
|
561
|
+
blocks.append(ConstraintBlock("artifact_requirements_missing"))
|
|
562
|
+
else:
|
|
563
|
+
missing = tuple(
|
|
564
|
+
artifact.name
|
|
565
|
+
for artifact in required_artifacts
|
|
566
|
+
if artifact.digest not in context.available_artifact_digests
|
|
567
|
+
)
|
|
568
|
+
if missing:
|
|
569
|
+
blocks.append(ConstraintBlock("artifact_unavailable", missing))
|
|
570
|
+
override_block = _override_coverage_block(context)
|
|
571
|
+
if override_block is not None:
|
|
572
|
+
blocks.append(override_block)
|
|
573
|
+
if not context.data_residency.release_regions:
|
|
574
|
+
blocks.append(ConstraintBlock("data_residency_missing"))
|
|
575
|
+
elif not context.data_residency.release_regions <= context.data_residency.allowed_regions:
|
|
576
|
+
blocks.append(
|
|
577
|
+
ConstraintBlock(
|
|
578
|
+
"data_residency_mismatch",
|
|
579
|
+
tuple(sorted(context.data_residency.release_regions)),
|
|
580
|
+
)
|
|
581
|
+
)
|
|
582
|
+
_append_if_blocked(
|
|
583
|
+
blocks,
|
|
584
|
+
evaluate_recall_candidate(
|
|
585
|
+
release_id=context.target_release_version,
|
|
586
|
+
capability_ids=context.capability_ids,
|
|
587
|
+
recall_records=context.recall_records,
|
|
588
|
+
),
|
|
589
|
+
)
|
|
590
|
+
return tuple(blocks)
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _maintenance_decision(context: LifecycleConstraintContext) -> ReleaseDecision:
|
|
594
|
+
if context.declared_duration_minutes < 1:
|
|
595
|
+
return ReleaseDecision(False, "maintenance_window_unavailable")
|
|
596
|
+
requires_downtime = context.requires_downtime or bool(
|
|
597
|
+
context.entity_ids & set(context.candidate_release.downtime_entities)
|
|
598
|
+
)
|
|
599
|
+
for window in context.maintenance_windows:
|
|
600
|
+
if window.starts_at >= window.ends_at:
|
|
601
|
+
return ReleaseDecision(
|
|
602
|
+
False, "maintenance_window_invalid", (window.starts_at.isoformat(),)
|
|
603
|
+
)
|
|
604
|
+
if not window.starts_at <= context.now < window.ends_at:
|
|
605
|
+
continue
|
|
606
|
+
if requires_downtime and not window.allows_downtime:
|
|
607
|
+
continue
|
|
608
|
+
remaining_seconds = (window.ends_at - context.now).total_seconds()
|
|
609
|
+
if remaining_seconds >= context.declared_duration_minutes * 60:
|
|
610
|
+
return ReleaseDecision(True, "allowed")
|
|
611
|
+
return ReleaseDecision(False, "maintenance_window_unavailable")
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _suppression_decision(context: LifecycleConstraintContext) -> ReleaseDecision:
|
|
615
|
+
for window in context.suppression_windows:
|
|
616
|
+
if window.starts_at >= window.ends_at:
|
|
617
|
+
return ReleaseDecision(False, "suppression_window_invalid", (window.scope,))
|
|
618
|
+
if window.origin not in {"manual", "failure"}:
|
|
619
|
+
return ReleaseDecision(False, "suppression_window_invalid", (window.scope,))
|
|
620
|
+
if not window.starts_at <= context.now < window.ends_at:
|
|
621
|
+
continue
|
|
622
|
+
scope_decision = _suppression_scope_decision(window)
|
|
623
|
+
if scope_decision is not None:
|
|
624
|
+
return scope_decision
|
|
625
|
+
if (
|
|
626
|
+
window.origin == "failure"
|
|
627
|
+
and context.plan_type == "rollback"
|
|
628
|
+
and window.failed_plan_id is not None
|
|
629
|
+
and window.failed_plan_id == context.rollback_target_plan_id
|
|
630
|
+
):
|
|
631
|
+
continue
|
|
632
|
+
if _scope_matches(window.scope, context):
|
|
633
|
+
return ReleaseDecision(False, "suppression_window_active", (window.scope,))
|
|
634
|
+
return ReleaseDecision(True, "allowed")
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _suppression_scope_decision(window: SuppressionWindow) -> ReleaseDecision | None:
|
|
638
|
+
if window.scope == "installation":
|
|
639
|
+
return None
|
|
640
|
+
if window.scope.startswith("plan:"):
|
|
641
|
+
plan_type = window.scope.removeprefix("plan:")
|
|
642
|
+
if plan_type not in _SUPPORTED_PLAN_TYPES:
|
|
643
|
+
return ReleaseDecision(False, "suppression_scope_invalid", (window.scope,))
|
|
644
|
+
return None
|
|
645
|
+
if window.scope.startswith("entity:"):
|
|
646
|
+
entity_id = window.scope.removeprefix("entity:")
|
|
647
|
+
if _SCOPE_ID.fullmatch(entity_id) is None:
|
|
648
|
+
return ReleaseDecision(False, "suppression_scope_invalid", (window.scope,))
|
|
649
|
+
return None
|
|
650
|
+
return ReleaseDecision(False, "suppression_scope_invalid", (window.scope,))
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _scope_matches(scope: str, context: LifecycleConstraintContext) -> bool:
|
|
654
|
+
if scope == "installation":
|
|
655
|
+
return True
|
|
656
|
+
if scope == f"plan:{context.plan_type}":
|
|
657
|
+
return True
|
|
658
|
+
return any(scope == f"entity:{entity_id}" for entity_id in context.entity_ids)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _schema_direction(plan_type: PlanType) -> SchemaDirection:
|
|
662
|
+
if plan_type in {"rollback", "recall-rolloff"}:
|
|
663
|
+
return "rollback"
|
|
664
|
+
if plan_type in {"install", "upgrade", "configuration-change", "drift-reconciliation"}:
|
|
665
|
+
return "upgrade"
|
|
666
|
+
raise ValueError(f"unsupported plan type: {plan_type}")
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _plan_context_block(
|
|
670
|
+
context: LifecycleConstraintContext, plan: LifecyclePlan
|
|
671
|
+
) -> ConstraintBlock | None:
|
|
672
|
+
if context.plan_type not in _SUPPORTED_PLAN_TYPES:
|
|
673
|
+
return ConstraintBlock("plan_type_unsupported", (str(context.plan_type),))
|
|
674
|
+
if plan.plan_type not in _SUPPORTED_PLAN_TYPES:
|
|
675
|
+
return ConstraintBlock("plan_type_unsupported", (str(plan.plan_type),))
|
|
676
|
+
comparisons = (
|
|
677
|
+
("plan_id", context.plan_id, plan.plan_id),
|
|
678
|
+
("plan_type", context.plan_type, plan.plan_type),
|
|
679
|
+
("target_release_version", context.target_release_version, plan.target_release_id),
|
|
680
|
+
(
|
|
681
|
+
"configuration_revision_digest",
|
|
682
|
+
context.configuration_revision_digest,
|
|
683
|
+
plan.configuration_revision_digest,
|
|
684
|
+
),
|
|
685
|
+
("entity_ids", context.entity_ids, plan.entity_ids),
|
|
686
|
+
("capability_ids", context.capability_ids, plan.capability_ids),
|
|
687
|
+
("release_regions", context.data_residency.release_regions, plan.release_regions),
|
|
688
|
+
("rollback_target_plan_id", context.rollback_target_plan_id, plan.rollback_target_plan_id),
|
|
689
|
+
(
|
|
690
|
+
"declared_duration_minutes",
|
|
691
|
+
context.declared_duration_minutes,
|
|
692
|
+
plan.declared_duration_minutes,
|
|
693
|
+
),
|
|
694
|
+
)
|
|
695
|
+
for field, observed, expected in comparisons:
|
|
696
|
+
if observed != expected:
|
|
697
|
+
return ConstraintBlock("plan_context_mismatch", (field,))
|
|
698
|
+
return None
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _require_aware_datetime(value: datetime, field: str) -> None:
|
|
702
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
703
|
+
raise ValueError(f"{field} MUST include timezone information")
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _required_artifacts(release: LifecycleRelease) -> tuple[ArtifactRequirement, ...]:
|
|
707
|
+
catalog = release.to_mapping()
|
|
708
|
+
result: list[ArtifactRequirement] = []
|
|
709
|
+
for section in LIFECYCLE_IMAGE_SECTIONS:
|
|
710
|
+
records = catalog.get(section)
|
|
711
|
+
if not isinstance(records, Mapping):
|
|
712
|
+
continue
|
|
713
|
+
for name, raw_record in records.items():
|
|
714
|
+
if not isinstance(name, str) or not isinstance(raw_record, Mapping):
|
|
715
|
+
continue
|
|
716
|
+
digest = raw_record.get("image_digest")
|
|
717
|
+
if isinstance(digest, str):
|
|
718
|
+
result.append(ArtifactRequirement(f"{section}/{name}", digest))
|
|
719
|
+
return tuple(sorted(result, key=lambda artifact: artifact.name))
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def required_artifact_digests(release: LifecycleRelease) -> frozenset[str]:
|
|
723
|
+
"""Return the immutable image digests declared by a Release manifest."""
|
|
724
|
+
|
|
725
|
+
return frozenset(artifact.digest for artifact in _required_artifacts(release))
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def _version_range_block(release_version: str, version_range: str) -> ConstraintBlock | None:
|
|
729
|
+
try:
|
|
730
|
+
resolve_configuration_layers(
|
|
731
|
+
release_version=release_version,
|
|
732
|
+
configuration_schema=_version_range_schema(),
|
|
733
|
+
environment_config={},
|
|
734
|
+
entity_overrides=({"versions": version_range, "values": {"eligible": True}},),
|
|
735
|
+
)
|
|
736
|
+
except ConfigurationValidationError as error:
|
|
737
|
+
if error.code == "missing_matching_override_block":
|
|
738
|
+
return ConstraintBlock("release_version_outside_range", (version_range,))
|
|
739
|
+
return ConstraintBlock(_configuration_reason_code(error), error.path)
|
|
740
|
+
return None
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _override_coverage_block(context: LifecycleConstraintContext) -> ConstraintBlock | None:
|
|
744
|
+
try:
|
|
745
|
+
resolve_configuration_layers(
|
|
746
|
+
release_version=context.target_release_version,
|
|
747
|
+
configuration_schema=context.configuration_schema,
|
|
748
|
+
environment_config=context.environment_config,
|
|
749
|
+
entity_overrides=context.entity_overrides,
|
|
750
|
+
)
|
|
751
|
+
except ConfigurationValidationError as error:
|
|
752
|
+
if error.code == "missing_matching_override_block":
|
|
753
|
+
return ConstraintBlock("override_coverage_missing", error.path)
|
|
754
|
+
return ConstraintBlock(_configuration_reason_code(error), error.path)
|
|
755
|
+
return None
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _configuration_reason_code(error: ConfigurationValidationError) -> str:
|
|
759
|
+
if error.code == "invalid_version_range":
|
|
760
|
+
return "version_range_invalid"
|
|
761
|
+
if error.code == "invalid_release_version":
|
|
762
|
+
return "release_version_invalid"
|
|
763
|
+
return f"configuration_{error.code}"
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _version_range_schema() -> Mapping[str, object]:
|
|
767
|
+
return {
|
|
768
|
+
"eligible": {
|
|
769
|
+
"default": False,
|
|
770
|
+
"x-fdai-axis": "Release channel subscription",
|
|
771
|
+
"x-fdai-owner": "customer",
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _append_if_blocked(blocks: list[ConstraintBlock], decision: ReleaseDecision) -> None:
|
|
777
|
+
if not decision.allowed:
|
|
778
|
+
blocks.append(ConstraintBlock(decision.reason_code, decision.details))
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _deny(reason_code: str, *details: str) -> PlanAdmissionDecision:
|
|
782
|
+
return PlanAdmissionDecision(False, reason_code, tuple(details))
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
from fdai_deployment_cli.lifecycle_authorization import ( # noqa: E402
|
|
786
|
+
LifecycleAuthorizationReceipt as LifecycleAuthorizationReceipt,
|
|
787
|
+
LifecyclePhase as LifecyclePhase,
|
|
788
|
+
LifecyclePhaseCompletionReceipt as LifecyclePhaseCompletionReceipt,
|
|
789
|
+
evaluate_phase_authorization as evaluate_phase_authorization,
|
|
790
|
+
exact_plan_digest_for as exact_plan_digest_for,
|
|
791
|
+
issue_lifecycle_authorization_receipt as issue_lifecycle_authorization_receipt,
|
|
792
|
+
)
|
|
793
|
+
from fdai_deployment_cli.lifecycle_commands import ( # noqa: E402
|
|
794
|
+
BreakGlassBypass as BreakGlassBypass,
|
|
795
|
+
LifecycleCommand as LifecycleCommand,
|
|
796
|
+
LifecycleCommandKind as LifecycleCommandKind,
|
|
797
|
+
overrides_adopted as overrides_adopted,
|
|
798
|
+
require_valid_lifecycle_command as require_valid_lifecycle_command,
|
|
799
|
+
validate_lifecycle_command as validate_lifecycle_command,
|
|
800
|
+
)
|