fdai-deployment-cli 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. fdai_deployment_cli/__about__.py +3 -0
  2. fdai_deployment_cli/__init__.py +5 -0
  3. fdai_deployment_cli/__main__.py +5 -0
  4. fdai_deployment_cli/acr_publish.py +585 -0
  5. fdai_deployment_cli/aks_catalog_review.py +137 -0
  6. fdai_deployment_cli/aks_historical_reconciliation.py +732 -0
  7. fdai_deployment_cli/aks_job_environment.py +59 -0
  8. fdai_deployment_cli/aks_job_execution.py +794 -0
  9. fdai_deployment_cli/aks_kubernetes_defaults.py +66 -0
  10. fdai_deployment_cli/aks_preflight.py +422 -0
  11. fdai_deployment_cli/aks_readiness.py +220 -0
  12. fdai_deployment_cli/aks_service_update.py +180 -0
  13. fdai_deployment_cli/aks_workload_jobs.py +281 -0
  14. fdai_deployment_cli/application_state_adoption.py +347 -0
  15. fdai_deployment_cli/azure_actor.py +61 -0
  16. fdai_deployment_cli/azure_naming.py +252 -0
  17. fdai_deployment_cli/azure_retail_prices.py +173 -0
  18. fdai_deployment_cli/bootstrap_approval.py +99 -0
  19. fdai_deployment_cli/bootstrap_channel.py +321 -0
  20. fdai_deployment_cli/bootstrap_executor.py +311 -0
  21. fdai_deployment_cli/bootstrap_plan.py +237 -0
  22. fdai_deployment_cli/bootstrap_reconcile.py +402 -0
  23. fdai_deployment_cli/bootstrap_support.py +138 -0
  24. fdai_deployment_cli/bootstrap_worker.py +448 -0
  25. fdai_deployment_cli/bundle.py +352 -0
  26. fdai_deployment_cli/capacity.py +143 -0
  27. fdai_deployment_cli/catalog_review_profile.py +306 -0
  28. fdai_deployment_cli/cli.py +798 -0
  29. fdai_deployment_cli/cli_help.py +214 -0
  30. fdai_deployment_cli/cli_parser.py +791 -0
  31. fdai_deployment_cli/cli_plan.py +450 -0
  32. fdai_deployment_cli/compiler.py +99 -0
  33. fdai_deployment_cli/console_artifact.py +218 -0
  34. fdai_deployment_cli/console_config.py +214 -0
  35. fdai_deployment_cli/console_update.py +832 -0
  36. fdai_deployment_cli/contracts.py +307 -0
  37. fdai_deployment_cli/control_package.py +140 -0
  38. fdai_deployment_cli/deadline_transport.py +70 -0
  39. fdai_deployment_cli/deployment_cost.py +131 -0
  40. fdai_deployment_cli/deployment_deadline.py +28 -0
  41. fdai_deployment_cli/deployment_kit.py +790 -0
  42. fdai_deployment_cli/deployment_kit_cache.py +191 -0
  43. fdai_deployment_cli/deployment_progress.py +648 -0
  44. fdai_deployment_cli/development_authority_cli.py +56 -0
  45. fdai_deployment_cli/development_authority_profile.py +319 -0
  46. fdai_deployment_cli/doctor.py +136 -0
  47. fdai_deployment_cli/entitlement_preflight.py +68 -0
  48. fdai_deployment_cli/entitlement_transport.py +49 -0
  49. fdai_deployment_cli/entra_bootstrap.py +579 -0
  50. fdai_deployment_cli/entra_control_profile.py +214 -0
  51. fdai_deployment_cli/entra_graph.py +349 -0
  52. fdai_deployment_cli/entra_profiles.py +215 -0
  53. fdai_deployment_cli/entra_source.py +244 -0
  54. fdai_deployment_cli/execution_bundle.py +478 -0
  55. fdai_deployment_cli/execution_copies.py +63 -0
  56. fdai_deployment_cli/foundation_adoption.py +438 -0
  57. fdai_deployment_cli/foundation_adoption_evidence.py +245 -0
  58. fdai_deployment_cli/foundation_adoption_host.py +157 -0
  59. fdai_deployment_cli/foundation_adoption_transport.py +51 -0
  60. fdai_deployment_cli/foundation_failure.py +72 -0
  61. fdai_deployment_cli/foundation_image.py +168 -0
  62. fdai_deployment_cli/foundation_input.py +235 -0
  63. fdai_deployment_cli/foundation_output.py +154 -0
  64. fdai_deployment_cli/foundation_plan.py +381 -0
  65. fdai_deployment_cli/foundation_process.py +64 -0
  66. fdai_deployment_cli/foundation_progress.py +231 -0
  67. fdai_deployment_cli/github_actions.py +171 -0
  68. fdai_deployment_cli/github_apply_artifact.py +259 -0
  69. fdai_deployment_cli/github_artifact_io.py +48 -0
  70. fdai_deployment_cli/github_dispatch.py +248 -0
  71. fdai_deployment_cli/github_plan_artifact.py +181 -0
  72. fdai_deployment_cli/github_provider_artifact.py +170 -0
  73. fdai_deployment_cli/github_transport.py +46 -0
  74. fdai_deployment_cli/github_workflow_values.py +312 -0
  75. fdai_deployment_cli/hub_image_deploy.py +354 -0
  76. fdai_deployment_cli/hub_image_stage.py +162 -0
  77. fdai_deployment_cli/hub_image_trust.py +281 -0
  78. fdai_deployment_cli/hub_ownership.py +161 -0
  79. fdai_deployment_cli/hub_workload_handoff.py +264 -0
  80. fdai_deployment_cli/hub_workload_handoff_inputs.py +227 -0
  81. fdai_deployment_cli/identity_profile.py +227 -0
  82. fdai_deployment_cli/image_targets.py +197 -0
  83. fdai_deployment_cli/installation_scope.py +280 -0
  84. fdai_deployment_cli/license.py +311 -0
  85. fdai_deployment_cli/license_issue.py +239 -0
  86. fdai_deployment_cli/lifecycle_authorization.py +252 -0
  87. fdai_deployment_cli/lifecycle_commands.py +173 -0
  88. fdai_deployment_cli/lifecycle_configuration.py +722 -0
  89. fdai_deployment_cli/lifecycle_configuration_artifact.py +45 -0
  90. fdai_deployment_cli/lifecycle_configuration_cli.py +324 -0
  91. fdai_deployment_cli/lifecycle_configuration_document.py +74 -0
  92. fdai_deployment_cli/lifecycle_configuration_entities.py +146 -0
  93. fdai_deployment_cli/lifecycle_configuration_package.py +492 -0
  94. fdai_deployment_cli/lifecycle_configuration_publication.py +277 -0
  95. fdai_deployment_cli/lifecycle_configuration_publication_cli.py +191 -0
  96. fdai_deployment_cli/lifecycle_configuration_sealing.py +127 -0
  97. fdai_deployment_cli/lifecycle_enrollment.py +45 -0
  98. fdai_deployment_cli/lifecycle_infrastructure_agent.py +174 -0
  99. fdai_deployment_cli/lifecycle_installation_binding.py +65 -0
  100. fdai_deployment_cli/lifecycle_native.py +247 -0
  101. fdai_deployment_cli/lifecycle_native_reconciliation.py +568 -0
  102. fdai_deployment_cli/lifecycle_native_transition.py +339 -0
  103. fdai_deployment_cli/lifecycle_ownership_exclusion.py +78 -0
  104. fdai_deployment_cli/lifecycle_plan.py +800 -0
  105. fdai_deployment_cli/lifecycle_promotion.py +185 -0
  106. fdai_deployment_cli/lifecycle_release.py +308 -0
  107. fdai_deployment_cli/lifecycle_self_upgrade.py +202 -0
  108. fdai_deployment_cli/lifecycle_upgrade_bundle.py +176 -0
  109. fdai_deployment_cli/notification_recipients_input.py +222 -0
  110. fdai_deployment_cli/observer_artifact.py +106 -0
  111. fdai_deployment_cli/oci_archive.py +353 -0
  112. fdai_deployment_cli/offline_kit.py +797 -0
  113. fdai_deployment_cli/offline_prepare.py +135 -0
  114. fdai_deployment_cli/operational_evidence_verifier_input.py +575 -0
  115. fdai_deployment_cli/plan_input.py +101 -0
  116. fdai_deployment_cli/private_output.py +192 -0
  117. fdai_deployment_cli/profile.py +70 -0
  118. fdai_deployment_cli/progress.py +183 -0
  119. fdai_deployment_cli/readiness.py +286 -0
  120. fdai_deployment_cli/run_command_receiver.py +418 -0
  121. fdai_deployment_cli/runtime_build.py +386 -0
  122. fdai_deployment_cli/runtime_profile.py +287 -0
  123. fdai_deployment_cli/runtime_release.py +745 -0
  124. fdai_deployment_cli/runtime_stage.py +82 -0
  125. fdai_deployment_cli/runtime_support_installation.py +215 -0
  126. fdai_deployment_cli/simulation.py +80 -0
  127. fdai_deployment_cli/source_application_inputs.py +397 -0
  128. fdai_deployment_cli/source_azure.py +586 -0
  129. fdai_deployment_cli/source_change_cli.py +107 -0
  130. fdai_deployment_cli/source_change_parser.py +75 -0
  131. fdai_deployment_cli/source_change_plan.py +181 -0
  132. fdai_deployment_cli/source_changed_update.py +196 -0
  133. fdai_deployment_cli/source_deploy.py +170 -0
  134. fdai_deployment_cli/source_foundation.py +174 -0
  135. fdai_deployment_cli/source_host_tools.py +163 -0
  136. fdai_deployment_cli/source_image_stage.py +400 -0
  137. fdai_deployment_cli/source_input.py +173 -0
  138. fdai_deployment_cli/source_projection.py +164 -0
  139. fdai_deployment_cli/source_receiver.py +76 -0
  140. fdai_deployment_cli/source_recovery.py +264 -0
  141. fdai_deployment_cli/source_runtime.py +81 -0
  142. fdai_deployment_cli/source_runtime_support.py +368 -0
  143. fdai_deployment_cli/source_service_update.py +726 -0
  144. fdai_deployment_cli/source_service_update_cli.py +47 -0
  145. fdai_deployment_cli/source_snapshot.py +178 -0
  146. fdai_deployment_cli/source_teardown.py +574 -0
  147. fdai_deployment_cli/source_terraform.py +85 -0
  148. fdai_deployment_cli/source_transport.py +399 -0
  149. fdai_deployment_cli/standalone_aks_inventory.py +558 -0
  150. fdai_deployment_cli/standalone_aks_job_execution.py +769 -0
  151. fdai_deployment_cli/standalone_aks_nodepool_guard.py +141 -0
  152. fdai_deployment_cli/standalone_application.py +977 -0
  153. fdai_deployment_cli/standalone_application_completion.py +151 -0
  154. fdai_deployment_cli/standalone_application_input_recovery.py +107 -0
  155. fdai_deployment_cli/standalone_application_recovery.py +116 -0
  156. fdai_deployment_cli/standalone_approval_input.py +24 -0
  157. fdai_deployment_cli/standalone_catalog_checkpoint.py +122 -0
  158. fdai_deployment_cli/standalone_catalog_review.py +251 -0
  159. fdai_deployment_cli/standalone_checkpoint_failure.py +127 -0
  160. fdai_deployment_cli/standalone_console_publish.py +174 -0
  161. fdai_deployment_cli/standalone_database_principals.py +41 -0
  162. fdai_deployment_cli/standalone_deploy.py +638 -0
  163. fdai_deployment_cli/standalone_development_authority.py +166 -0
  164. fdai_deployment_cli/standalone_foundation_adoption.py +87 -0
  165. fdai_deployment_cli/standalone_foundation_transition.py +767 -0
  166. fdai_deployment_cli/standalone_host.py +4608 -0
  167. fdai_deployment_cli/standalone_host_rebind.py +226 -0
  168. fdai_deployment_cli/standalone_host_residual.py +104 -0
  169. fdai_deployment_cli/standalone_host_state.py +145 -0
  170. fdai_deployment_cli/standalone_host_values.py +563 -0
  171. fdai_deployment_cli/standalone_kubernetes_tools.py +100 -0
  172. fdai_deployment_cli/standalone_license_installation.py +177 -0
  173. fdai_deployment_cli/standalone_management_egress.py +83 -0
  174. fdai_deployment_cli/standalone_migration_evidence.py +38 -0
  175. fdai_deployment_cli/standalone_operational_evidence.py +356 -0
  176. fdai_deployment_cli/standalone_planned_outputs.py +147 -0
  177. fdai_deployment_cli/standalone_private_access_probe.py +100 -0
  178. fdai_deployment_cli/standalone_product_profile.py +110 -0
  179. fdai_deployment_cli/standalone_remote_prepare.py +442 -0
  180. fdai_deployment_cli/standalone_residual_apply.py +540 -0
  181. fdai_deployment_cli/standalone_residual_successor.py +431 -0
  182. fdai_deployment_cli/standalone_review.py +266 -0
  183. fdai_deployment_cli/standalone_service_update_finalization.py +116 -0
  184. fdai_deployment_cli/standalone_service_update_recovery.py +277 -0
  185. fdai_deployment_cli/standalone_stage_targets.py +325 -0
  186. fdai_deployment_cli/standalone_status.py +67 -0
  187. fdai_deployment_cli/standalone_terraform_environment.py +237 -0
  188. fdai_deployment_cli/standalone_transfer_cleanup.py +237 -0
  189. fdai_deployment_cli/standalone_trial_activation.py +193 -0
  190. fdai_deployment_cli/state.py +434 -0
  191. fdai_deployment_cli/state_handoff.py +253 -0
  192. fdai_deployment_cli/status_projection.py +97 -0
  193. fdai_deployment_cli/support_install.py +257 -0
  194. fdai_deployment_cli/target.py +46 -0
  195. fdai_deployment_cli/trust/capabilities.json +62 -0
  196. fdai_deployment_cli/trust/deployment-bundle-root.pub +3 -0
  197. fdai_deployment_cli/trust/deployment-release-root.pub +3 -0
  198. fdai_deployment_cli/trust/upstream-signing-key.pub +3 -0
  199. fdai_deployment_cli/trust_roots.py +50 -0
  200. fdai_deployment_cli-0.1.2.dist-info/METADATA +112 -0
  201. fdai_deployment_cli-0.1.2.dist-info/RECORD +203 -0
  202. fdai_deployment_cli-0.1.2.dist-info/WHEEL +4 -0
  203. fdai_deployment_cli-0.1.2.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,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
+ )