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,185 @@
|
|
|
1
|
+
"""Release channel promotion pipeline evaluator and boundary registry digest mirroring."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
from fdai_deployment_cli.lifecycle_plan import required_artifact_digests
|
|
10
|
+
from fdai_deployment_cli.lifecycle_release import LifecycleRelease
|
|
11
|
+
from fdai_deployment_cli.runtime_release import (
|
|
12
|
+
RecallRecord,
|
|
13
|
+
ReleaseDecision,
|
|
14
|
+
evaluate_recall_candidate,
|
|
15
|
+
is_release_id,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
_IMAGE_DIGEST = re.compile(r"sha256:[0-9a-f]{64}\Z", re.ASCII)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
22
|
+
class PromotionStageCriteria:
|
|
23
|
+
"""Criteria required to advance a Release from ``source_channel`` to ``target_channel``."""
|
|
24
|
+
|
|
25
|
+
source_channel: str
|
|
26
|
+
target_channel: str
|
|
27
|
+
required_labels: frozenset[str] = field(default_factory=frozenset)
|
|
28
|
+
min_soak_seconds: int = 0
|
|
29
|
+
min_healthy_canaries: int = 0
|
|
30
|
+
max_stage_timeout_seconds: int = 3600
|
|
31
|
+
|
|
32
|
+
def __post_init__(self) -> None:
|
|
33
|
+
if not self.source_channel.strip() or not self.target_channel.strip():
|
|
34
|
+
raise ValueError("source_channel and target_channel MUST NOT be empty")
|
|
35
|
+
if self.source_channel == self.target_channel:
|
|
36
|
+
raise ValueError("source_channel and target_channel MUST differ")
|
|
37
|
+
if any(not label.strip() for label in self.required_labels):
|
|
38
|
+
raise ValueError("required_labels MUST NOT contain blank labels")
|
|
39
|
+
if (
|
|
40
|
+
isinstance(self.min_soak_seconds, bool)
|
|
41
|
+
or not isinstance(self.min_soak_seconds, int)
|
|
42
|
+
or self.min_soak_seconds < 0
|
|
43
|
+
):
|
|
44
|
+
raise ValueError("min_soak_seconds MUST be a non-negative integer")
|
|
45
|
+
if (
|
|
46
|
+
isinstance(self.min_healthy_canaries, bool)
|
|
47
|
+
or not isinstance(self.min_healthy_canaries, int)
|
|
48
|
+
or self.min_healthy_canaries < 0
|
|
49
|
+
):
|
|
50
|
+
raise ValueError("min_healthy_canaries MUST be a non-negative integer")
|
|
51
|
+
if (
|
|
52
|
+
isinstance(self.max_stage_timeout_seconds, bool)
|
|
53
|
+
or not isinstance(self.max_stage_timeout_seconds, int)
|
|
54
|
+
or self.max_stage_timeout_seconds < 1
|
|
55
|
+
):
|
|
56
|
+
raise ValueError("max_stage_timeout_seconds MUST be a positive integer")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
60
|
+
class CanaryObservation:
|
|
61
|
+
"""Health and soak observation from one canary installation running a candidate Release."""
|
|
62
|
+
|
|
63
|
+
installation_id: str
|
|
64
|
+
release_id: str
|
|
65
|
+
healthy: bool
|
|
66
|
+
soak_seconds: int
|
|
67
|
+
|
|
68
|
+
def __post_init__(self) -> None:
|
|
69
|
+
if not self.installation_id.strip():
|
|
70
|
+
raise ValueError("installation_id MUST NOT be empty")
|
|
71
|
+
if not is_release_id(self.release_id):
|
|
72
|
+
raise ValueError("release_id MUST be canonical SemVer")
|
|
73
|
+
if not isinstance(self.healthy, bool):
|
|
74
|
+
raise TypeError("healthy MUST be a boolean")
|
|
75
|
+
if (
|
|
76
|
+
isinstance(self.soak_seconds, bool)
|
|
77
|
+
or not isinstance(self.soak_seconds, int)
|
|
78
|
+
or self.soak_seconds < 0
|
|
79
|
+
):
|
|
80
|
+
raise ValueError("soak_seconds MUST be a non-negative integer")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
84
|
+
class PromotionStageInput:
|
|
85
|
+
"""Observed evidence for one promotion-pipeline stage evaluation."""
|
|
86
|
+
|
|
87
|
+
release_id: str
|
|
88
|
+
present_in_source_channel: bool
|
|
89
|
+
labels: frozenset[str] = field(default_factory=frozenset)
|
|
90
|
+
canary_observations: tuple[CanaryObservation, ...] = ()
|
|
91
|
+
elapsed_stage_seconds: int = 0
|
|
92
|
+
capability_ids: tuple[str, ...] = ()
|
|
93
|
+
recall_records: tuple[RecallRecord, ...] = ()
|
|
94
|
+
|
|
95
|
+
def __post_init__(self) -> None:
|
|
96
|
+
if not is_release_id(self.release_id):
|
|
97
|
+
raise ValueError("release_id MUST be canonical SemVer")
|
|
98
|
+
if not isinstance(self.present_in_source_channel, bool):
|
|
99
|
+
raise TypeError("present_in_source_channel MUST be a boolean")
|
|
100
|
+
if (
|
|
101
|
+
isinstance(self.elapsed_stage_seconds, bool)
|
|
102
|
+
or not isinstance(self.elapsed_stage_seconds, int)
|
|
103
|
+
or self.elapsed_stage_seconds < 0
|
|
104
|
+
):
|
|
105
|
+
raise ValueError("elapsed_stage_seconds MUST be a non-negative integer")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def evaluate_promotion_stage(
|
|
109
|
+
criteria: PromotionStageCriteria,
|
|
110
|
+
stage_input: PromotionStageInput,
|
|
111
|
+
) -> ReleaseDecision:
|
|
112
|
+
"""Evaluate label requirements, soak time, canary health, timeouts, and recalls."""
|
|
113
|
+
|
|
114
|
+
recall_decision = evaluate_recall_candidate(
|
|
115
|
+
release_id=stage_input.release_id,
|
|
116
|
+
capability_ids=stage_input.capability_ids,
|
|
117
|
+
recall_records=stage_input.recall_records,
|
|
118
|
+
)
|
|
119
|
+
if not recall_decision.allowed:
|
|
120
|
+
return recall_decision
|
|
121
|
+
if not stage_input.present_in_source_channel:
|
|
122
|
+
return ReleaseDecision(
|
|
123
|
+
False, "promotion_source_channel_missing", (criteria.source_channel,)
|
|
124
|
+
)
|
|
125
|
+
if stage_input.elapsed_stage_seconds > criteria.max_stage_timeout_seconds:
|
|
126
|
+
return ReleaseDecision(
|
|
127
|
+
False,
|
|
128
|
+
"promotion_stage_timed_out",
|
|
129
|
+
(str(stage_input.elapsed_stage_seconds), str(criteria.max_stage_timeout_seconds)),
|
|
130
|
+
)
|
|
131
|
+
missing_labels = tuple(sorted(criteria.required_labels - stage_input.labels))
|
|
132
|
+
if missing_labels:
|
|
133
|
+
return ReleaseDecision(False, "promotion_labels_missing", missing_labels)
|
|
134
|
+
relevant = [
|
|
135
|
+
obs for obs in stage_input.canary_observations if obs.release_id == stage_input.release_id
|
|
136
|
+
]
|
|
137
|
+
unhealthy = tuple(sorted(obs.installation_id for obs in relevant if not obs.healthy))
|
|
138
|
+
if unhealthy:
|
|
139
|
+
return ReleaseDecision(False, "promotion_canary_unhealthy", unhealthy)
|
|
140
|
+
healthy = [obs for obs in relevant if obs.healthy]
|
|
141
|
+
if len(healthy) < criteria.min_healthy_canaries:
|
|
142
|
+
return ReleaseDecision(
|
|
143
|
+
False,
|
|
144
|
+
"promotion_canary_quorum_unmet",
|
|
145
|
+
(str(len(healthy)), str(criteria.min_healthy_canaries)),
|
|
146
|
+
)
|
|
147
|
+
soaked = [obs for obs in healthy if obs.soak_seconds >= criteria.min_soak_seconds]
|
|
148
|
+
if len(soaked) < criteria.min_healthy_canaries:
|
|
149
|
+
return ReleaseDecision(
|
|
150
|
+
False,
|
|
151
|
+
"promotion_soak_incomplete",
|
|
152
|
+
(str(len(soaked)), str(criteria.min_healthy_canaries)),
|
|
153
|
+
)
|
|
154
|
+
return ReleaseDecision(True, "allowed", ())
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass(frozen=True, slots=True)
|
|
158
|
+
class BoundaryRegistryMirror:
|
|
159
|
+
"""Connected-installation boundary registry mirror state for Release image digests."""
|
|
160
|
+
|
|
161
|
+
mirrored_digests: frozenset[str] = field(default_factory=frozenset)
|
|
162
|
+
|
|
163
|
+
def __post_init__(self) -> None:
|
|
164
|
+
if any(_IMAGE_DIGEST.fullmatch(digest) is None for digest in self.mirrored_digests):
|
|
165
|
+
raise ValueError("mirrored_digests MUST contain only sha256:<64 hex> digests")
|
|
166
|
+
|
|
167
|
+
def missing_for(self, release: LifecycleRelease) -> frozenset[str]:
|
|
168
|
+
"""Return required Release image digests not yet mirrored inside the boundary."""
|
|
169
|
+
|
|
170
|
+
return required_artifact_digests(release) - self.mirrored_digests
|
|
171
|
+
|
|
172
|
+
def mirror_release(
|
|
173
|
+
self,
|
|
174
|
+
release: LifecycleRelease,
|
|
175
|
+
*,
|
|
176
|
+
available_upstream_digests: Iterable[str],
|
|
177
|
+
) -> BoundaryRegistryMirror:
|
|
178
|
+
"""Mirror available required digests for ``release`` into the boundary registry."""
|
|
179
|
+
|
|
180
|
+
upstream = frozenset(available_upstream_digests)
|
|
181
|
+
if any(_IMAGE_DIGEST.fullmatch(digest) is None for digest in upstream):
|
|
182
|
+
raise ValueError("available_upstream_digests MUST contain only sha256:<64 hex> digests")
|
|
183
|
+
required = required_artifact_digests(release)
|
|
184
|
+
transferred = required & upstream
|
|
185
|
+
return BoundaryRegistryMirror(mirrored_digests=self.mirrored_digests | transferred)
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""Application-neutral lifecycle packages and the legacy runtime compatibility adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from types import MappingProxyType
|
|
9
|
+
from typing import Protocol
|
|
10
|
+
|
|
11
|
+
from fdai_deployment_cli.contracts import canonical_bytes, canonical_digest, load_json_object
|
|
12
|
+
from fdai_deployment_cli.runtime_release import (
|
|
13
|
+
ConfigurationSchema,
|
|
14
|
+
RuntimeReleaseError,
|
|
15
|
+
SchemaMetadata,
|
|
16
|
+
SchemaRange,
|
|
17
|
+
is_release_id,
|
|
18
|
+
parse_release_configuration_schema,
|
|
19
|
+
parse_runtime_release_manifest,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
APPLICATION_RELEASE_SCHEMA = "fdai.application-release.v1"
|
|
23
|
+
APPLICATION_COMPONENT_RELEASE_SCHEMA = "fdai.application-release.v2"
|
|
24
|
+
SCHEMA_EXPAND_JOB_SCHEMA = "fdai.schema-expand-job.v1"
|
|
25
|
+
MIGRATION_CLASSIFICATION_SCHEMA = "fdai.migration-classification.v1"
|
|
26
|
+
APPLICATION_RELEASE_SCHEMAS = frozenset(
|
|
27
|
+
{APPLICATION_RELEASE_SCHEMA, APPLICATION_COMPONENT_RELEASE_SCHEMA}
|
|
28
|
+
)
|
|
29
|
+
LIFECYCLE_RELEASE_SCHEMAS = frozenset({"fdai.runtime-release.v3", *APPLICATION_RELEASE_SCHEMAS})
|
|
30
|
+
LIFECYCLE_IMAGE_SECTIONS = (
|
|
31
|
+
"services",
|
|
32
|
+
"sidecars",
|
|
33
|
+
"installation_agents",
|
|
34
|
+
"workloads",
|
|
35
|
+
"components",
|
|
36
|
+
)
|
|
37
|
+
_IDENTIFIER = re.compile(r"[a-z][a-z0-9-]{0,62}\Z", re.ASCII)
|
|
38
|
+
_COMPONENT = re.compile(r"[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?\Z", re.ASCII)
|
|
39
|
+
_DIGEST = re.compile(r"sha256:[0-9a-f]{64}\Z", re.ASCII)
|
|
40
|
+
_REVISION_ID = re.compile(r"[A-Za-z0-9_]{1,128}\Z", re.ASCII)
|
|
41
|
+
_FIELDS = frozenset(
|
|
42
|
+
{"schema_version", "product", "release_version", "source_commit", "platform_tag", "workloads"}
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_product_id(value: object) -> bool:
|
|
47
|
+
return isinstance(value, str) and _IDENTIFIER.fullmatch(value) is not None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class LifecycleRelease(SchemaMetadata, Protocol):
|
|
51
|
+
@property
|
|
52
|
+
def product(self) -> str | None: ...
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def schema_version(self) -> str: ...
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def source_commit(self) -> str: ...
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def platform_tag(self) -> str: ...
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def digest(self) -> str: ...
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def capability_maximums(self) -> Mapping[str, str]: ...
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def downtime_entities(self) -> tuple[str, ...]: ...
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def configuration_schema(self) -> ConfigurationSchema | None: ...
|
|
74
|
+
|
|
75
|
+
def to_mapping(self) -> dict[str, object]: ...
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, slots=True)
|
|
79
|
+
class HttpWorkload:
|
|
80
|
+
image_digest: str
|
|
81
|
+
port: int = 8080
|
|
82
|
+
health_path: str = "/"
|
|
83
|
+
replicas: int = 1
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True, slots=True)
|
|
87
|
+
class SchemaExpandMigration:
|
|
88
|
+
classification: Mapping[str, str]
|
|
89
|
+
active_deadline_seconds: int
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True, slots=True)
|
|
93
|
+
class ApplicationRelease:
|
|
94
|
+
product: str
|
|
95
|
+
release_version: str
|
|
96
|
+
source_commit: str
|
|
97
|
+
platform_tag: str
|
|
98
|
+
digest: str
|
|
99
|
+
workloads: Mapping[str, HttpWorkload]
|
|
100
|
+
_catalog: bytes = field(repr=False)
|
|
101
|
+
schema_target: int = 0
|
|
102
|
+
schema_range: SchemaRange = SchemaRange(0, 0)
|
|
103
|
+
configuration_schema: ConfigurationSchema | None = None
|
|
104
|
+
schema_expand: SchemaExpandMigration | None = None
|
|
105
|
+
schema_version: str = APPLICATION_RELEASE_SCHEMA
|
|
106
|
+
components: Mapping[str, str] = field(default_factory=dict)
|
|
107
|
+
downtime_entities: tuple[str, ...] = field(default=(), init=False)
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def capability_maximums(self) -> Mapping[str, str]:
|
|
111
|
+
return {}
|
|
112
|
+
|
|
113
|
+
def to_mapping(self) -> dict[str, object]:
|
|
114
|
+
return load_json_object(self._catalog, label="application release", strict=True)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def parse_lifecycle_release_manifest(raw: bytes) -> LifecycleRelease:
|
|
118
|
+
"""Validate bounded strict JSON before either format reaches publication or execution."""
|
|
119
|
+
try:
|
|
120
|
+
document = load_json_object(raw, label="lifecycle release", strict=True)
|
|
121
|
+
schema_version = document.get("schema_version")
|
|
122
|
+
if schema_version not in APPLICATION_RELEASE_SCHEMAS:
|
|
123
|
+
return parse_runtime_release_manifest(raw)
|
|
124
|
+
section = (
|
|
125
|
+
"components" if schema_version == APPLICATION_COMPONENT_RELEASE_SCHEMA else "workloads"
|
|
126
|
+
)
|
|
127
|
+
required = (_FIELDS - {"workloads"}) | {section}
|
|
128
|
+
if (
|
|
129
|
+
not required
|
|
130
|
+
<= document.keys()
|
|
131
|
+
<= required
|
|
132
|
+
| {
|
|
133
|
+
"configuration_schema",
|
|
134
|
+
"schema",
|
|
135
|
+
"schema_expand",
|
|
136
|
+
}
|
|
137
|
+
):
|
|
138
|
+
raise ValueError("application fields are invalid")
|
|
139
|
+
product = document["product"]
|
|
140
|
+
version = document["release_version"]
|
|
141
|
+
commit = document["source_commit"]
|
|
142
|
+
platform = document["platform_tag"]
|
|
143
|
+
if not isinstance(product, str) or not is_product_id(product):
|
|
144
|
+
raise ValueError("application product is invalid")
|
|
145
|
+
if not isinstance(version, str) or not is_release_id(version) or len(version) > 64:
|
|
146
|
+
raise ValueError("application version is invalid")
|
|
147
|
+
if not isinstance(commit, str) or re.fullmatch(r"[0-9a-f]{40}", commit) is None:
|
|
148
|
+
raise ValueError("application source is invalid")
|
|
149
|
+
if not isinstance(platform, str) or platform not in {"linux-x86_64", "linux-aarch64"}:
|
|
150
|
+
raise ValueError("application platform is invalid")
|
|
151
|
+
workloads = document[section]
|
|
152
|
+
if not isinstance(workloads, dict) or not 1 <= len(workloads) <= 64:
|
|
153
|
+
raise ValueError("application requires bounded workloads")
|
|
154
|
+
component_only = schema_version == APPLICATION_COMPONENT_RELEASE_SCHEMA
|
|
155
|
+
parsed_workloads = (
|
|
156
|
+
{}
|
|
157
|
+
if component_only
|
|
158
|
+
else {name: _workload(name, value) for name, value in workloads.items()}
|
|
159
|
+
)
|
|
160
|
+
parsed_components = (
|
|
161
|
+
{
|
|
162
|
+
name: _image_record(name, value, allowed={"image_digest", "image_ref"})
|
|
163
|
+
for name, value in workloads.items()
|
|
164
|
+
}
|
|
165
|
+
if component_only
|
|
166
|
+
else {}
|
|
167
|
+
)
|
|
168
|
+
configuration = (
|
|
169
|
+
parse_release_configuration_schema(document["configuration_schema"])
|
|
170
|
+
if "configuration_schema" in document
|
|
171
|
+
else None
|
|
172
|
+
)
|
|
173
|
+
if configuration is not None and configuration.release_version != version:
|
|
174
|
+
raise ValueError("application configuration version differs")
|
|
175
|
+
target, schema_range = (
|
|
176
|
+
_schema(document["schema"]) if "schema" in document else (0, SchemaRange(0, 0))
|
|
177
|
+
)
|
|
178
|
+
schema_expand = (
|
|
179
|
+
_schema_expand(document["schema_expand"]) if "schema_expand" in document else None
|
|
180
|
+
)
|
|
181
|
+
if schema_expand is not None and (
|
|
182
|
+
not component_only or target == 0 or not schema_expand.classification
|
|
183
|
+
):
|
|
184
|
+
raise ValueError("schema expand Job requires a component Release schema transition")
|
|
185
|
+
catalog = canonical_bytes(document)
|
|
186
|
+
load_json_object(catalog, label="canonical application release", strict=True)
|
|
187
|
+
return ApplicationRelease(
|
|
188
|
+
product=product,
|
|
189
|
+
release_version=version,
|
|
190
|
+
source_commit=commit,
|
|
191
|
+
platform_tag=platform,
|
|
192
|
+
digest=canonical_digest(document),
|
|
193
|
+
workloads=MappingProxyType(parsed_workloads),
|
|
194
|
+
_catalog=catalog,
|
|
195
|
+
schema_target=target,
|
|
196
|
+
schema_range=schema_range,
|
|
197
|
+
configuration_schema=configuration,
|
|
198
|
+
schema_expand=schema_expand,
|
|
199
|
+
schema_version=str(schema_version),
|
|
200
|
+
components=MappingProxyType(parsed_components),
|
|
201
|
+
)
|
|
202
|
+
except RuntimeReleaseError:
|
|
203
|
+
raise
|
|
204
|
+
except (ValueError, TypeError, KeyError, RecursionError) as error:
|
|
205
|
+
raise RuntimeReleaseError("Lifecycle release metadata is invalid.") from error
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _image_record(name: object, value: object, *, allowed: set[str]) -> str:
|
|
209
|
+
if not isinstance(name, str) or _COMPONENT.fullmatch(name) is None:
|
|
210
|
+
raise ValueError("application component is invalid")
|
|
211
|
+
if not isinstance(value, dict) or not {"image_digest"} <= value.keys() <= allowed:
|
|
212
|
+
raise ValueError("application workload fields are invalid")
|
|
213
|
+
digest = value["image_digest"]
|
|
214
|
+
if not isinstance(digest, str) or _DIGEST.fullmatch(digest) is None:
|
|
215
|
+
raise ValueError("application image must be digest-pinned")
|
|
216
|
+
if "image_ref" in value:
|
|
217
|
+
reference = value["image_ref"]
|
|
218
|
+
if (
|
|
219
|
+
not isinstance(reference, str)
|
|
220
|
+
or len(reference) > 512
|
|
221
|
+
or re.fullmatch(
|
|
222
|
+
r"[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?"
|
|
223
|
+
r"(?::[1-9][0-9]{0,4})?/"
|
|
224
|
+
r"[a-z0-9]+(?:[._-][a-z0-9]+)*(?:/[a-z0-9]+(?:[._-][a-z0-9]+)*)*"
|
|
225
|
+
r"@sha256:[0-9a-f]{64}",
|
|
226
|
+
reference,
|
|
227
|
+
re.ASCII,
|
|
228
|
+
)
|
|
229
|
+
is None
|
|
230
|
+
or reference.rsplit("@", 1)[1] != digest
|
|
231
|
+
):
|
|
232
|
+
raise ValueError("application image source must match its signed digest")
|
|
233
|
+
return digest
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _workload(name: object, value: object) -> HttpWorkload:
|
|
237
|
+
digest = _image_record(name, value, allowed={"image_digest", "port", "health_path", "replicas"})
|
|
238
|
+
if not isinstance(value, dict):
|
|
239
|
+
raise ValueError("application workload fields are invalid")
|
|
240
|
+
port = value.get("port", 8080)
|
|
241
|
+
path = value.get("health_path", "/")
|
|
242
|
+
replicas = value.get("replicas", 1)
|
|
243
|
+
if type(port) is not int or not 1 <= port <= 65535:
|
|
244
|
+
raise ValueError("application port is invalid")
|
|
245
|
+
if (
|
|
246
|
+
not isinstance(path, str)
|
|
247
|
+
or path.startswith("//")
|
|
248
|
+
or not re.fullmatch(r"/[A-Za-z0-9/._~-]{0,255}", path, re.ASCII)
|
|
249
|
+
):
|
|
250
|
+
raise ValueError("application health path is invalid")
|
|
251
|
+
if type(replicas) is not int or not 1 <= replicas <= 3:
|
|
252
|
+
raise ValueError("application replicas are invalid")
|
|
253
|
+
return HttpWorkload(digest, port, path, replicas)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _schema_expand(value: object) -> SchemaExpandMigration:
|
|
257
|
+
if (
|
|
258
|
+
not isinstance(value, Mapping)
|
|
259
|
+
or set(value) != {"schema_version", "classification", "active_deadline_seconds"}
|
|
260
|
+
or value["schema_version"] != SCHEMA_EXPAND_JOB_SCHEMA
|
|
261
|
+
or not isinstance(value["classification"], Mapping)
|
|
262
|
+
):
|
|
263
|
+
raise ValueError("schema expand Job is invalid")
|
|
264
|
+
classification = value["classification"]
|
|
265
|
+
if (
|
|
266
|
+
set(classification) != {"schema", "revisions"}
|
|
267
|
+
or classification["schema"] != MIGRATION_CLASSIFICATION_SCHEMA
|
|
268
|
+
or not isinstance(classification["revisions"], Mapping)
|
|
269
|
+
or not classification["revisions"]
|
|
270
|
+
or not all(
|
|
271
|
+
isinstance(revision, str)
|
|
272
|
+
and _REVISION_ID.fullmatch(revision)
|
|
273
|
+
and migration_class in {"expand", "contract"}
|
|
274
|
+
for revision, migration_class in classification["revisions"].items()
|
|
275
|
+
)
|
|
276
|
+
):
|
|
277
|
+
raise ValueError("migration classification is invalid")
|
|
278
|
+
if any(
|
|
279
|
+
migration_class == "contract" for migration_class in classification["revisions"].values()
|
|
280
|
+
):
|
|
281
|
+
raise ValueError("schema expand Job cannot contain contract revisions")
|
|
282
|
+
deadline = value["active_deadline_seconds"]
|
|
283
|
+
if type(deadline) is not int or not 60 <= deadline <= 3600:
|
|
284
|
+
raise ValueError("schema expand Job deadline is invalid")
|
|
285
|
+
return SchemaExpandMigration(
|
|
286
|
+
MappingProxyType(
|
|
287
|
+
{
|
|
288
|
+
str(revision): str(migration_class)
|
|
289
|
+
for revision, migration_class in classification["revisions"].items()
|
|
290
|
+
}
|
|
291
|
+
),
|
|
292
|
+
deadline,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _schema(value: object) -> tuple[int, SchemaRange]:
|
|
297
|
+
if not isinstance(value, dict) or set(value) != {"target", "tolerates"}:
|
|
298
|
+
raise ValueError("application schema is invalid")
|
|
299
|
+
target = value["target"]
|
|
300
|
+
limits = value["tolerates"]
|
|
301
|
+
if not isinstance(limits, dict) or set(limits) != {"minimum", "maximum"}:
|
|
302
|
+
raise ValueError("application schema range is invalid")
|
|
303
|
+
minimum, maximum = limits["minimum"], limits["maximum"]
|
|
304
|
+
if any(type(item) is not int for item in (target, minimum, maximum)) or not (
|
|
305
|
+
0 <= minimum <= target <= maximum <= 2**31 - 1
|
|
306
|
+
):
|
|
307
|
+
raise ValueError("application schema revisions are invalid")
|
|
308
|
+
return target, SchemaRange(minimum, maximum)
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Dual-slot self-upgrade controller for installation agents and Target Hubs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass, replace
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
from fdai_deployment_cli.runtime_release import is_release_id
|
|
11
|
+
|
|
12
|
+
SelfUpgradeComponent = Literal["lifecycle-agent", "infrastructure-agent", "target-hub"]
|
|
13
|
+
SlotId = Literal["slot-a", "slot-b"]
|
|
14
|
+
SelfUpgradeOutcome = Literal["upgraded", "rolled-back"]
|
|
15
|
+
|
|
16
|
+
_SUPPORTED_COMPONENTS = frozenset({"lifecycle-agent", "infrastructure-agent", "target-hub"})
|
|
17
|
+
_IMAGE_DIGEST = re.compile(r"sha256:[0-9a-f]{64}\Z", re.ASCII)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _other_slot(slot: SlotId) -> SlotId:
|
|
21
|
+
return "slot-b" if slot == "slot-a" else "slot-a"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
25
|
+
class SlotRuntime:
|
|
26
|
+
"""State of one component slot copy."""
|
|
27
|
+
|
|
28
|
+
slot_id: SlotId
|
|
29
|
+
release_id: str | None
|
|
30
|
+
image_digest: str | None
|
|
31
|
+
running: bool
|
|
32
|
+
healthy: bool
|
|
33
|
+
write_authority: bool
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
37
|
+
class DualSlotState:
|
|
38
|
+
"""Two-slot state for a lifecycle agent, infrastructure agent, or Target Hub."""
|
|
39
|
+
|
|
40
|
+
component: SelfUpgradeComponent
|
|
41
|
+
active_slot: SlotId
|
|
42
|
+
fencing_generation: int
|
|
43
|
+
slots: dict[SlotId, SlotRuntime]
|
|
44
|
+
|
|
45
|
+
def __post_init__(self) -> None:
|
|
46
|
+
if self.component not in _SUPPORTED_COMPONENTS:
|
|
47
|
+
raise ValueError("component is unsupported")
|
|
48
|
+
if self.active_slot not in ("slot-a", "slot-b"):
|
|
49
|
+
raise ValueError("active_slot is invalid")
|
|
50
|
+
if (
|
|
51
|
+
isinstance(self.fencing_generation, bool)
|
|
52
|
+
or not isinstance(self.fencing_generation, int)
|
|
53
|
+
or self.fencing_generation < 1
|
|
54
|
+
):
|
|
55
|
+
raise ValueError("fencing_generation MUST be a positive integer")
|
|
56
|
+
if set(self.slots) != {"slot-a", "slot-b"}:
|
|
57
|
+
raise ValueError("slots MUST contain slot-a and slot-b")
|
|
58
|
+
writers = [slot_id for slot_id, s in self.slots.items() if s.write_authority]
|
|
59
|
+
if writers != [self.active_slot]:
|
|
60
|
+
raise ValueError("exactly the active slot MUST hold write_authority")
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def initial(
|
|
64
|
+
cls,
|
|
65
|
+
*,
|
|
66
|
+
component: SelfUpgradeComponent,
|
|
67
|
+
release_id: str,
|
|
68
|
+
image_digest: str,
|
|
69
|
+
active_slot: SlotId = "slot-a",
|
|
70
|
+
fencing_generation: int = 1,
|
|
71
|
+
) -> DualSlotState:
|
|
72
|
+
if not is_release_id(release_id):
|
|
73
|
+
raise ValueError("release_id MUST be canonical SemVer")
|
|
74
|
+
if _IMAGE_DIGEST.fullmatch(image_digest) is None:
|
|
75
|
+
raise ValueError("image_digest MUST be sha256:<64 hex>")
|
|
76
|
+
standby = _other_slot(active_slot)
|
|
77
|
+
return cls(
|
|
78
|
+
component=component,
|
|
79
|
+
active_slot=active_slot,
|
|
80
|
+
fencing_generation=fencing_generation,
|
|
81
|
+
slots={
|
|
82
|
+
active_slot: SlotRuntime(
|
|
83
|
+
slot_id=active_slot,
|
|
84
|
+
release_id=release_id,
|
|
85
|
+
image_digest=image_digest,
|
|
86
|
+
running=True,
|
|
87
|
+
healthy=True,
|
|
88
|
+
write_authority=True,
|
|
89
|
+
),
|
|
90
|
+
standby: SlotRuntime(
|
|
91
|
+
slot_id=standby,
|
|
92
|
+
release_id=None,
|
|
93
|
+
image_digest=None,
|
|
94
|
+
running=False,
|
|
95
|
+
healthy=False,
|
|
96
|
+
write_authority=False,
|
|
97
|
+
),
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
103
|
+
class SelfUpgradeResult:
|
|
104
|
+
"""Result of one dual-slot self-upgrade attempt."""
|
|
105
|
+
|
|
106
|
+
outcome: SelfUpgradeOutcome
|
|
107
|
+
reason_code: str
|
|
108
|
+
state: DualSlotState
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class DualSlotUpgradeController:
|
|
112
|
+
"""Start candidate without write authority, verify health, and cut over or roll back."""
|
|
113
|
+
|
|
114
|
+
def upgrade(
|
|
115
|
+
self,
|
|
116
|
+
state: DualSlotState,
|
|
117
|
+
*,
|
|
118
|
+
target_release_id: str,
|
|
119
|
+
target_image_digest: str,
|
|
120
|
+
check_candidate_health: Callable[[SelfUpgradeComponent, SlotId, str, str], bool],
|
|
121
|
+
verify_post_handoff_readback: Callable[[SelfUpgradeComponent, SlotId, int], bool]
|
|
122
|
+
| None = None,
|
|
123
|
+
) -> SelfUpgradeResult:
|
|
124
|
+
if not is_release_id(target_release_id):
|
|
125
|
+
raise ValueError("target_release_id MUST be canonical SemVer")
|
|
126
|
+
if _IMAGE_DIGEST.fullmatch(target_image_digest) is None:
|
|
127
|
+
raise ValueError("target_image_digest MUST be sha256:<64 hex>")
|
|
128
|
+
|
|
129
|
+
old_slot_id = state.active_slot
|
|
130
|
+
candidate_slot_id = _other_slot(old_slot_id)
|
|
131
|
+
old_slot = state.slots[old_slot_id]
|
|
132
|
+
|
|
133
|
+
# 1. Candidate starts beside the old slot with write_authority=False.
|
|
134
|
+
candidate_healthy = bool(
|
|
135
|
+
check_candidate_health(
|
|
136
|
+
state.component,
|
|
137
|
+
candidate_slot_id,
|
|
138
|
+
target_release_id,
|
|
139
|
+
target_image_digest,
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
if not candidate_healthy:
|
|
143
|
+
rolled_back_slots = {
|
|
144
|
+
old_slot_id: old_slot,
|
|
145
|
+
candidate_slot_id: SlotRuntime(
|
|
146
|
+
slot_id=candidate_slot_id,
|
|
147
|
+
release_id=target_release_id,
|
|
148
|
+
image_digest=target_image_digest,
|
|
149
|
+
running=False,
|
|
150
|
+
healthy=False,
|
|
151
|
+
write_authority=False,
|
|
152
|
+
),
|
|
153
|
+
}
|
|
154
|
+
return SelfUpgradeResult(
|
|
155
|
+
outcome="rolled-back",
|
|
156
|
+
reason_code="candidate_health_check_failed",
|
|
157
|
+
state=replace(state, slots=rolled_back_slots),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
next_generation = state.fencing_generation + 1
|
|
161
|
+
if verify_post_handoff_readback is not None and not bool(
|
|
162
|
+
verify_post_handoff_readback(state.component, candidate_slot_id, next_generation)
|
|
163
|
+
):
|
|
164
|
+
rolled_back_slots = {
|
|
165
|
+
old_slot_id: old_slot,
|
|
166
|
+
candidate_slot_id: SlotRuntime(
|
|
167
|
+
slot_id=candidate_slot_id,
|
|
168
|
+
release_id=target_release_id,
|
|
169
|
+
image_digest=target_image_digest,
|
|
170
|
+
running=False,
|
|
171
|
+
healthy=False,
|
|
172
|
+
write_authority=False,
|
|
173
|
+
),
|
|
174
|
+
}
|
|
175
|
+
return SelfUpgradeResult(
|
|
176
|
+
outcome="rolled-back",
|
|
177
|
+
reason_code="post_handoff_readback_failed",
|
|
178
|
+
state=replace(state, slots=rolled_back_slots),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# 2. Transfer fenced write authority to the healthy candidate; keep old slot as rollback target.
|
|
182
|
+
upgraded_slots = {
|
|
183
|
+
old_slot_id: replace(old_slot, write_authority=False),
|
|
184
|
+
candidate_slot_id: SlotRuntime(
|
|
185
|
+
slot_id=candidate_slot_id,
|
|
186
|
+
release_id=target_release_id,
|
|
187
|
+
image_digest=target_image_digest,
|
|
188
|
+
running=True,
|
|
189
|
+
healthy=True,
|
|
190
|
+
write_authority=True,
|
|
191
|
+
),
|
|
192
|
+
}
|
|
193
|
+
return SelfUpgradeResult(
|
|
194
|
+
outcome="upgraded",
|
|
195
|
+
reason_code="upgraded",
|
|
196
|
+
state=DualSlotState(
|
|
197
|
+
component=state.component,
|
|
198
|
+
active_slot=candidate_slot_id,
|
|
199
|
+
fencing_generation=next_generation,
|
|
200
|
+
slots=upgraded_slots,
|
|
201
|
+
),
|
|
202
|
+
)
|