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,568 @@
|
|
|
1
|
+
"""Contracts for one bounded legacy native-transition state reconciliation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import binascii
|
|
7
|
+
import re
|
|
8
|
+
from collections import Counter
|
|
9
|
+
from datetime import UTC, datetime, timedelta
|
|
10
|
+
from typing import Annotated, Any, Literal, Self
|
|
11
|
+
from uuid import UUID
|
|
12
|
+
|
|
13
|
+
from cryptography.exceptions import InvalidSignature
|
|
14
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
15
|
+
Ed25519PrivateKey,
|
|
16
|
+
Ed25519PublicKey,
|
|
17
|
+
)
|
|
18
|
+
from pydantic import (
|
|
19
|
+
AwareDatetime,
|
|
20
|
+
BaseModel,
|
|
21
|
+
BeforeValidator,
|
|
22
|
+
ConfigDict,
|
|
23
|
+
Field,
|
|
24
|
+
PlainSerializer,
|
|
25
|
+
StringConstraints,
|
|
26
|
+
model_validator,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from fdai_deployment_cli.contracts import canonical_bytes, canonical_digest
|
|
30
|
+
from fdai_deployment_cli.lifecycle_native import Digest, NativeApproval, PathId
|
|
31
|
+
from fdai_deployment_cli.lifecycle_native_transition import (
|
|
32
|
+
HexDigest,
|
|
33
|
+
NativeScopeTransitionCompletionReceipt,
|
|
34
|
+
NativeScopeTransitionEntityState,
|
|
35
|
+
NativeScopeTransitionReportedState,
|
|
36
|
+
hub_key_id,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
Actor = Annotated[str, StringConstraints(pattern=r"^[A-Za-z0-9][A-Za-z0-9@._ -]{0,127}$")]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _serialize_reported_state_timestamp(value: datetime) -> str:
|
|
43
|
+
timespec = "milliseconds" if value.microsecond % 1000 == 0 else "microseconds"
|
|
44
|
+
return value.astimezone(UTC).isoformat(timespec=timespec).replace("+00:00", "Z")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
LegacyReportedStateTimestamp = Annotated[
|
|
48
|
+
AwareDatetime,
|
|
49
|
+
PlainSerializer(
|
|
50
|
+
_serialize_reported_state_timestamp,
|
|
51
|
+
return_type=str,
|
|
52
|
+
),
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
_IMMUTABLE_RECEIPT_TIMESTAMP_PATTERN = re.compile(
|
|
56
|
+
(
|
|
57
|
+
r"(?P<instant>[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:"
|
|
58
|
+
r"[0-9]{2}\.(?:[0-9]{3}|[0-9]{6}))(?P<zone>Z|\+00:00)"
|
|
59
|
+
),
|
|
60
|
+
flags=re.ASCII,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class LegacyNativeScopeTransitionReportedState(BaseModel):
|
|
65
|
+
"""Exact legacy state whose artifact inventory retains its stored sequence."""
|
|
66
|
+
|
|
67
|
+
model_config = ConfigDict(extra="forbid", strict=True, frozen=True)
|
|
68
|
+
|
|
69
|
+
digest: HexDigest
|
|
70
|
+
schema_revision: Annotated[int, Field(ge=0, le=2**31 - 1)]
|
|
71
|
+
entities: dict[PathId, NativeScopeTransitionEntityState]
|
|
72
|
+
observed_at: LegacyReportedStateTimestamp
|
|
73
|
+
available_artifact_digests: tuple[Digest, ...] | None = None
|
|
74
|
+
|
|
75
|
+
@model_validator(mode="after")
|
|
76
|
+
def validate_stored_digest(self) -> Self:
|
|
77
|
+
document = self.model_dump(mode="json", exclude={"digest"})
|
|
78
|
+
if self.available_artifact_digests is None:
|
|
79
|
+
document.pop("available_artifact_digests")
|
|
80
|
+
if self.digest != canonical_digest(document):
|
|
81
|
+
raise ValueError("legacy transition reported state digest is invalid")
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _strict_reported_state(value: object) -> object:
|
|
86
|
+
if isinstance(value, dict):
|
|
87
|
+
return NativeScopeTransitionReportedState.model_validate_json(canonical_bytes(value))
|
|
88
|
+
return value
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _legacy_reported_state(value: object) -> object:
|
|
92
|
+
if isinstance(value, dict):
|
|
93
|
+
return LegacyNativeScopeTransitionReportedState.model_validate_json(canonical_bytes(value))
|
|
94
|
+
return value
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _native_approval(value: object) -> object:
|
|
98
|
+
if isinstance(value, dict):
|
|
99
|
+
return NativeApproval.model_validate_json(canonical_bytes(value))
|
|
100
|
+
return value
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _reconciliation_request(value: object) -> object:
|
|
104
|
+
if isinstance(value, dict):
|
|
105
|
+
model = (
|
|
106
|
+
NativeScopeTransitionStateReconciliationRenewalRequest
|
|
107
|
+
if value.get("schema_version")
|
|
108
|
+
== "fdai.lifecycle-native-transition-state-reconciliation-renewal-request.v1"
|
|
109
|
+
else NativeScopeTransitionStateReconciliationRequest
|
|
110
|
+
)
|
|
111
|
+
return model.model_validate_json(canonical_bytes(value))
|
|
112
|
+
return value
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _reconciliation_approval(value: object) -> object:
|
|
116
|
+
if isinstance(value, dict):
|
|
117
|
+
return NativeScopeTransitionStateReconciliationApproval.model_validate_json(
|
|
118
|
+
canonical_bytes(value)
|
|
119
|
+
)
|
|
120
|
+
return value
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _signed_reconciliation_approval(value: object) -> object:
|
|
124
|
+
if isinstance(value, dict):
|
|
125
|
+
return SignedNativeScopeTransitionStateReconciliationApproval.model_validate_json(
|
|
126
|
+
canonical_bytes(value)
|
|
127
|
+
)
|
|
128
|
+
return value
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
StrictReportedStateReadback = Annotated[
|
|
132
|
+
NativeScopeTransitionReportedState,
|
|
133
|
+
BeforeValidator(_strict_reported_state),
|
|
134
|
+
]
|
|
135
|
+
LegacyReportedStateReadback = Annotated[
|
|
136
|
+
LegacyNativeScopeTransitionReportedState,
|
|
137
|
+
BeforeValidator(_legacy_reported_state),
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _immutable_receipt_timestamp(value: object) -> object:
|
|
142
|
+
if isinstance(value, str):
|
|
143
|
+
if _IMMUTABLE_RECEIPT_TIMESTAMP_PATTERN.fullmatch(value) is None:
|
|
144
|
+
raise ValueError("immutable transition timestamp must use exact UTC precision")
|
|
145
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
146
|
+
if isinstance(value, datetime) and value.utcoffset() != timedelta(0):
|
|
147
|
+
raise ValueError("immutable transition timestamp must use UTC")
|
|
148
|
+
return value
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
ImmutableReceiptTimestamp = Annotated[
|
|
152
|
+
AwareDatetime,
|
|
153
|
+
BeforeValidator(_immutable_receipt_timestamp),
|
|
154
|
+
PlainSerializer(
|
|
155
|
+
_serialize_reported_state_timestamp,
|
|
156
|
+
return_type=str,
|
|
157
|
+
),
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _immutable_receipt_record_digests(
|
|
162
|
+
document: dict[str, Any],
|
|
163
|
+
) -> tuple[str, str] | None:
|
|
164
|
+
"""Digest the same exact UTC receipt with its legacy and Hub timestamp spellings."""
|
|
165
|
+
|
|
166
|
+
completed_at = document.get("completed_at")
|
|
167
|
+
if not isinstance(completed_at, str):
|
|
168
|
+
return None
|
|
169
|
+
match = _IMMUTABLE_RECEIPT_TIMESTAMP_PATTERN.fullmatch(completed_at)
|
|
170
|
+
if match is None:
|
|
171
|
+
return None
|
|
172
|
+
instant = datetime.fromisoformat(completed_at.replace("Z", "+00:00"))
|
|
173
|
+
if instant.utcoffset() != timedelta(0):
|
|
174
|
+
return None
|
|
175
|
+
timestamp = match.group("instant")
|
|
176
|
+
legacy_document = {**document, "completed_at": timestamp + "+00:00"}
|
|
177
|
+
hub_document = {**document, "completed_at": timestamp + "Z"}
|
|
178
|
+
return (
|
|
179
|
+
"sha256:" + canonical_digest(legacy_document),
|
|
180
|
+
"sha256:" + canonical_digest(hub_document),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def transition_reported_state_document(
|
|
185
|
+
state: LegacyNativeScopeTransitionReportedState | NativeScopeTransitionReportedState,
|
|
186
|
+
*,
|
|
187
|
+
observed_at: datetime | None = None,
|
|
188
|
+
milliseconds: bool = False,
|
|
189
|
+
) -> dict[str, object]:
|
|
190
|
+
"""Return canonical state fields with an explicitly selected timestamp representation."""
|
|
191
|
+
|
|
192
|
+
timestamp = (observed_at or state.observed_at).astimezone(UTC)
|
|
193
|
+
document: dict[str, object] = {
|
|
194
|
+
"schema_revision": state.schema_revision,
|
|
195
|
+
"entities": {
|
|
196
|
+
entity_id: {
|
|
197
|
+
"release_id": entity.release_id,
|
|
198
|
+
"artifact_digests": list(entity.artifact_digests),
|
|
199
|
+
"health": entity.health,
|
|
200
|
+
}
|
|
201
|
+
for entity_id, entity in sorted(state.entities.items())
|
|
202
|
+
},
|
|
203
|
+
"observed_at": (
|
|
204
|
+
timestamp.isoformat(timespec="milliseconds") if milliseconds else timestamp.isoformat()
|
|
205
|
+
).replace("+00:00", "Z"),
|
|
206
|
+
}
|
|
207
|
+
if state.available_artifact_digests is not None:
|
|
208
|
+
document["available_artifact_digests"] = list(state.available_artifact_digests)
|
|
209
|
+
return document
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def transition_reported_state_digest(
|
|
213
|
+
state: LegacyNativeScopeTransitionReportedState | NativeScopeTransitionReportedState,
|
|
214
|
+
*,
|
|
215
|
+
observed_at: datetime | None = None,
|
|
216
|
+
milliseconds: bool = False,
|
|
217
|
+
) -> str:
|
|
218
|
+
return canonical_digest(
|
|
219
|
+
transition_reported_state_document(
|
|
220
|
+
state,
|
|
221
|
+
observed_at=observed_at,
|
|
222
|
+
milliseconds=milliseconds,
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def same_transition_state_fields(
|
|
228
|
+
left: LegacyNativeScopeTransitionReportedState | NativeScopeTransitionReportedState,
|
|
229
|
+
right: LegacyNativeScopeTransitionReportedState | NativeScopeTransitionReportedState,
|
|
230
|
+
) -> bool:
|
|
231
|
+
"""Compare non-time fields while preserving exact artifact multiplicity."""
|
|
232
|
+
|
|
233
|
+
left_artifacts = left.available_artifact_digests
|
|
234
|
+
right_artifacts = right.available_artifact_digests
|
|
235
|
+
return (
|
|
236
|
+
left.schema_revision == right.schema_revision
|
|
237
|
+
and left.entities == right.entities
|
|
238
|
+
and (
|
|
239
|
+
left_artifacts is None
|
|
240
|
+
and right_artifacts is None
|
|
241
|
+
or left_artifacts is not None
|
|
242
|
+
and right_artifacts is not None
|
|
243
|
+
and Counter(left_artifacts) == Counter(right_artifacts)
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def same_transition_state_semantic_inventory(
|
|
249
|
+
left: LegacyNativeScopeTransitionReportedState | NativeScopeTransitionReportedState,
|
|
250
|
+
right: LegacyNativeScopeTransitionReportedState | NativeScopeTransitionReportedState,
|
|
251
|
+
) -> bool:
|
|
252
|
+
"""Compare a legacy source with its deduplicated authoritative target."""
|
|
253
|
+
|
|
254
|
+
left_artifacts = left.available_artifact_digests
|
|
255
|
+
right_artifacts = right.available_artifact_digests
|
|
256
|
+
return (
|
|
257
|
+
left.schema_revision == right.schema_revision
|
|
258
|
+
and left.entities == right.entities
|
|
259
|
+
and left.observed_at == right.observed_at
|
|
260
|
+
and (
|
|
261
|
+
left_artifacts is None
|
|
262
|
+
and right_artifacts is None
|
|
263
|
+
or left_artifacts is not None
|
|
264
|
+
and right_artifacts is not None
|
|
265
|
+
and set(left_artifacts) == set(right_artifacts)
|
|
266
|
+
)
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def canonical_transition_reported_state(
|
|
271
|
+
source: LegacyNativeScopeTransitionReportedState,
|
|
272
|
+
) -> NativeScopeTransitionReportedState:
|
|
273
|
+
"""Build the strict target without mutating or normalizing the legacy source."""
|
|
274
|
+
|
|
275
|
+
inventory = source.available_artifact_digests
|
|
276
|
+
document = source.model_dump(mode="python")
|
|
277
|
+
document["digest"] = "0" * 64
|
|
278
|
+
document["observed_at"] = source.observed_at
|
|
279
|
+
document["available_artifact_digests"] = (
|
|
280
|
+
None if inventory is None else tuple(sorted(set(inventory)))
|
|
281
|
+
)
|
|
282
|
+
provisional = NativeScopeTransitionReportedState.model_validate(document)
|
|
283
|
+
document["digest"] = transition_reported_state_digest(provisional)
|
|
284
|
+
return NativeScopeTransitionReportedState.model_validate(document)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _require_strict_target_inventory(state: NativeScopeTransitionReportedState) -> None:
|
|
288
|
+
inventory = state.available_artifact_digests
|
|
289
|
+
if inventory is not None and tuple(sorted(set(inventory))) != inventory:
|
|
290
|
+
raise ValueError("authoritative transition artifacts must be unique and sorted")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class LegacyNativeScopeTransitionReceipt(BaseModel):
|
|
294
|
+
"""Immutable pre-fix receipt whose stored bytes retain their original precision."""
|
|
295
|
+
|
|
296
|
+
model_config = ConfigDict(extra="forbid", strict=True, frozen=True)
|
|
297
|
+
|
|
298
|
+
schema_version: Literal["fdai.lifecycle-native-transition-receipt.v1"]
|
|
299
|
+
request_digest: Digest
|
|
300
|
+
hub_approval_digest: Digest
|
|
301
|
+
installation_id: PathId
|
|
302
|
+
old_entity_id: PathId
|
|
303
|
+
new_entity_id: PathId
|
|
304
|
+
old_scope_digest: Digest
|
|
305
|
+
new_scope_digest: Digest
|
|
306
|
+
old_reported_state_digest: HexDigest
|
|
307
|
+
new_reported_state_digest: HexDigest
|
|
308
|
+
ownership_claim_digest: Digest
|
|
309
|
+
recovery_request_digest: Digest
|
|
310
|
+
existing_resources_digest: Digest
|
|
311
|
+
rollback_record_digest: Digest
|
|
312
|
+
completed_at: ImmutableReceiptTimestamp
|
|
313
|
+
record_digest: Digest
|
|
314
|
+
|
|
315
|
+
@model_validator(mode="before")
|
|
316
|
+
@classmethod
|
|
317
|
+
def validate_record_digest(cls, value: Any) -> Any:
|
|
318
|
+
if not isinstance(value, dict):
|
|
319
|
+
return value
|
|
320
|
+
document = dict(value)
|
|
321
|
+
digest = document.pop("record_digest", None)
|
|
322
|
+
expected = _immutable_receipt_record_digests(document)
|
|
323
|
+
if expected is None:
|
|
324
|
+
raise ValueError("legacy native transition receipt digest is invalid")
|
|
325
|
+
legacy_digest, hub_digest = expected
|
|
326
|
+
serialized_digest = (
|
|
327
|
+
hub_digest if str(document["completed_at"]).endswith("Z") else legacy_digest
|
|
328
|
+
)
|
|
329
|
+
if digest != legacy_digest or serialized_digest != "sha256:" + canonical_digest(document):
|
|
330
|
+
raise ValueError("legacy native transition receipt digest is invalid")
|
|
331
|
+
return value
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def regenerated_transition_receipt_digest(
|
|
335
|
+
receipt: LegacyNativeScopeTransitionReceipt,
|
|
336
|
+
*,
|
|
337
|
+
new_reported_state_digest: str,
|
|
338
|
+
) -> str:
|
|
339
|
+
"""Reproduce the Hub transition receipt without changing the immutable receipt."""
|
|
340
|
+
|
|
341
|
+
document = receipt.model_dump(mode="json", exclude={"record_digest"})
|
|
342
|
+
document["new_reported_state_digest"] = new_reported_state_digest
|
|
343
|
+
return "sha256:" + canonical_digest(document)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
class _NativeScopeTransitionStateReconciliationRequest(BaseModel):
|
|
347
|
+
"""Common immutable evidence and bounded timing for approval requests."""
|
|
348
|
+
|
|
349
|
+
model_config = ConfigDict(extra="forbid", strict=True, frozen=True)
|
|
350
|
+
|
|
351
|
+
request_id: UUID
|
|
352
|
+
transition_receipt: LegacyNativeScopeTransitionReceipt
|
|
353
|
+
completion_receipt: NativeScopeTransitionCompletionReceipt
|
|
354
|
+
native_approval: Annotated[NativeApproval, BeforeValidator(_native_approval)]
|
|
355
|
+
legacy_reported_state: LegacyReportedStateReadback
|
|
356
|
+
requested_at: AwareDatetime
|
|
357
|
+
expires_at: AwareDatetime
|
|
358
|
+
confirmation: Annotated[str, StringConstraints(min_length=1, max_length=1024)]
|
|
359
|
+
|
|
360
|
+
@model_validator(mode="after")
|
|
361
|
+
def validate_window(self) -> Self:
|
|
362
|
+
if not self.requested_at < self.expires_at <= self.requested_at + timedelta(minutes=10):
|
|
363
|
+
raise ValueError("native transition reconciliation window is invalid")
|
|
364
|
+
return self
|
|
365
|
+
|
|
366
|
+
@property
|
|
367
|
+
def installation_id(self) -> str:
|
|
368
|
+
return self.transition_receipt.installation_id
|
|
369
|
+
|
|
370
|
+
@property
|
|
371
|
+
def digest(self) -> str:
|
|
372
|
+
return "sha256:" + canonical_digest(self.model_dump(mode="json"))
|
|
373
|
+
|
|
374
|
+
def require_current(self, now: datetime) -> None:
|
|
375
|
+
if now.utcoffset() is None or not self.requested_at <= now < self.expires_at:
|
|
376
|
+
raise ValueError("native transition reconciliation request is not current")
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class NativeScopeTransitionStateReconciliationRequest(
|
|
380
|
+
_NativeScopeTransitionStateReconciliationRequest
|
|
381
|
+
):
|
|
382
|
+
"""Operator request carrying all immutable legacy and Hub completion evidence."""
|
|
383
|
+
|
|
384
|
+
schema_version: Literal["fdai.lifecycle-native-transition-state-reconciliation-request.v1"]
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
class NativeScopeTransitionStateReconciliationRenewalRequest(
|
|
388
|
+
_NativeScopeTransitionStateReconciliationRequest
|
|
389
|
+
):
|
|
390
|
+
"""Fresh bounded request that supersedes one expired unconsumed approval."""
|
|
391
|
+
|
|
392
|
+
schema_version: Literal[
|
|
393
|
+
"fdai.lifecycle-native-transition-state-reconciliation-renewal-request.v1"
|
|
394
|
+
]
|
|
395
|
+
supersedes_approval_digest: Digest
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
type NativeScopeTransitionStateReconciliationApprovalRequest = (
|
|
399
|
+
NativeScopeTransitionStateReconciliationRequest
|
|
400
|
+
| NativeScopeTransitionStateReconciliationRenewalRequest
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def reconciliation_request_evidence_digest(
|
|
405
|
+
request: NativeScopeTransitionStateReconciliationApprovalRequest,
|
|
406
|
+
) -> str:
|
|
407
|
+
"""Digest every request field that renewal is not allowed to change."""
|
|
408
|
+
|
|
409
|
+
document = request.model_dump(mode="json")
|
|
410
|
+
for field in (
|
|
411
|
+
"schema_version",
|
|
412
|
+
"request_id",
|
|
413
|
+
"requested_at",
|
|
414
|
+
"expires_at",
|
|
415
|
+
"supersedes_approval_digest",
|
|
416
|
+
):
|
|
417
|
+
document.pop(field, None)
|
|
418
|
+
return "sha256:" + canonical_digest(document)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
class NativeScopeTransitionStateReconciliationApproval(BaseModel):
|
|
422
|
+
"""Hub-authoritative state correction signed after completion-audit verification."""
|
|
423
|
+
|
|
424
|
+
model_config = ConfigDict(extra="forbid", strict=True, frozen=True)
|
|
425
|
+
|
|
426
|
+
schema_version: Literal["fdai.lifecycle-native-transition-state-reconciliation-approval.v1"]
|
|
427
|
+
request: Annotated[
|
|
428
|
+
NativeScopeTransitionStateReconciliationApprovalRequest,
|
|
429
|
+
BeforeValidator(_reconciliation_request),
|
|
430
|
+
]
|
|
431
|
+
source_hub_reported_state: LegacyReportedStateReadback
|
|
432
|
+
authoritative_reported_state: StrictReportedStateReadback
|
|
433
|
+
approved_by: Actor
|
|
434
|
+
approved_at: AwareDatetime
|
|
435
|
+
expires_at: AwareDatetime
|
|
436
|
+
completion_audit_digest: Digest
|
|
437
|
+
hub_key_id: Annotated[str, StringConstraints(pattern=r"^hub-[0-9a-f]{16}$")]
|
|
438
|
+
hub_key_epoch: Annotated[int, Field(ge=1)]
|
|
439
|
+
|
|
440
|
+
@model_validator(mode="after")
|
|
441
|
+
def validate_window(self) -> Self:
|
|
442
|
+
_require_strict_target_inventory(self.authoritative_reported_state)
|
|
443
|
+
if (
|
|
444
|
+
self.expires_at != self.request.expires_at
|
|
445
|
+
or not self.request.requested_at <= self.approved_at < self.expires_at
|
|
446
|
+
):
|
|
447
|
+
raise ValueError("native transition reconciliation approval window is invalid")
|
|
448
|
+
return self
|
|
449
|
+
|
|
450
|
+
@property
|
|
451
|
+
def digest(self) -> str:
|
|
452
|
+
return "sha256:" + canonical_digest(self.model_dump(mode="json"))
|
|
453
|
+
|
|
454
|
+
@property
|
|
455
|
+
def payload(self) -> bytes:
|
|
456
|
+
return b"fdai.lifecycle-native-transition-state-reconciliation-approval.v1\n" + (
|
|
457
|
+
canonical_bytes(self.model_dump(mode="json"))
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
def require_current(self, now: datetime) -> None:
|
|
461
|
+
self.request.require_current(now)
|
|
462
|
+
if now.utcoffset() is None or not self.approved_at <= now < self.expires_at:
|
|
463
|
+
raise ValueError("native transition reconciliation approval is not current")
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
class SignedNativeScopeTransitionStateReconciliationApproval(BaseModel):
|
|
467
|
+
model_config = ConfigDict(extra="forbid", strict=True, frozen=True)
|
|
468
|
+
|
|
469
|
+
approval: Annotated[
|
|
470
|
+
NativeScopeTransitionStateReconciliationApproval,
|
|
471
|
+
BeforeValidator(_reconciliation_approval),
|
|
472
|
+
]
|
|
473
|
+
hub_signature: Annotated[str, StringConstraints(min_length=88, max_length=88)]
|
|
474
|
+
|
|
475
|
+
@model_validator(mode="after")
|
|
476
|
+
def validate_signature_encoding(self) -> Self:
|
|
477
|
+
_signature_bytes(self.hub_signature)
|
|
478
|
+
return self
|
|
479
|
+
|
|
480
|
+
def verify(self, public_key: bytes, *, key_id: str, key_epoch: int) -> bool:
|
|
481
|
+
approval = self.approval
|
|
482
|
+
if (
|
|
483
|
+
approval.hub_key_id != key_id
|
|
484
|
+
or approval.hub_key_epoch != key_epoch
|
|
485
|
+
or hub_key_id(public_key) != key_id
|
|
486
|
+
):
|
|
487
|
+
return False
|
|
488
|
+
try:
|
|
489
|
+
Ed25519PublicKey.from_public_bytes(public_key).verify(
|
|
490
|
+
_signature_bytes(self.hub_signature),
|
|
491
|
+
approval.payload,
|
|
492
|
+
)
|
|
493
|
+
except (InvalidSignature, ValueError):
|
|
494
|
+
return False
|
|
495
|
+
return True
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
class NativeScopeTransitionStateReconciliationCompletion(BaseModel):
|
|
499
|
+
"""Agent-authenticated proof that only its durable current-state file was corrected."""
|
|
500
|
+
|
|
501
|
+
model_config = ConfigDict(extra="forbid", strict=True, frozen=True)
|
|
502
|
+
|
|
503
|
+
schema_version: Literal["fdai.lifecycle-native-transition-state-reconciliation-completion.v1"]
|
|
504
|
+
approval: Annotated[
|
|
505
|
+
SignedNativeScopeTransitionStateReconciliationApproval,
|
|
506
|
+
BeforeValidator(_signed_reconciliation_approval),
|
|
507
|
+
]
|
|
508
|
+
reported_state: NativeScopeTransitionReportedState
|
|
509
|
+
|
|
510
|
+
@model_validator(mode="after")
|
|
511
|
+
def validate_target_inventory(self) -> Self:
|
|
512
|
+
_require_strict_target_inventory(self.reported_state)
|
|
513
|
+
return self
|
|
514
|
+
|
|
515
|
+
@property
|
|
516
|
+
def digest(self) -> str:
|
|
517
|
+
return "sha256:" + canonical_digest(self.model_dump(mode="json"))
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
class NativeScopeTransitionStateReconciliationReceipt(BaseModel):
|
|
521
|
+
"""Hub readback retained unchanged by both Hub and Agent for exact retries."""
|
|
522
|
+
|
|
523
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
524
|
+
|
|
525
|
+
schema_version: Literal["fdai.lifecycle-native-transition-state-reconciliation-receipt.v1"]
|
|
526
|
+
completion_digest: Digest
|
|
527
|
+
request_digest: Digest
|
|
528
|
+
approval_digest: Digest
|
|
529
|
+
transition_receipt_digest: Digest
|
|
530
|
+
hub_completion_receipt_digest: Digest
|
|
531
|
+
installation_id: PathId
|
|
532
|
+
old_reported_state_digest: HexDigest
|
|
533
|
+
new_reported_state_digest: HexDigest
|
|
534
|
+
agent_principal: Actor
|
|
535
|
+
authoritative_reported_state: StrictReportedStateReadback
|
|
536
|
+
completed_at: AwareDatetime
|
|
537
|
+
receipt_digest: Digest
|
|
538
|
+
|
|
539
|
+
@model_validator(mode="before")
|
|
540
|
+
@classmethod
|
|
541
|
+
def validate_receipt_digest(cls, value: Any) -> Any:
|
|
542
|
+
if not isinstance(value, dict):
|
|
543
|
+
return value
|
|
544
|
+
document = dict(value)
|
|
545
|
+
digest = document.pop("receipt_digest", None)
|
|
546
|
+
if digest != "sha256:" + canonical_digest(document):
|
|
547
|
+
raise ValueError("native transition reconciliation receipt digest is invalid")
|
|
548
|
+
return value
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def sign_transition_state_reconciliation_approval(
|
|
552
|
+
approval: NativeScopeTransitionStateReconciliationApproval,
|
|
553
|
+
key: Ed25519PrivateKey,
|
|
554
|
+
) -> SignedNativeScopeTransitionStateReconciliationApproval:
|
|
555
|
+
return SignedNativeScopeTransitionStateReconciliationApproval(
|
|
556
|
+
approval=approval,
|
|
557
|
+
hub_signature=base64.b64encode(key.sign(approval.payload)).decode("ascii"),
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _signature_bytes(value: str) -> bytes:
|
|
562
|
+
try:
|
|
563
|
+
decoded = base64.b64decode(value, validate=True)
|
|
564
|
+
except (ValueError, binascii.Error) as error:
|
|
565
|
+
raise ValueError("Hub signature encoding is invalid") from error
|
|
566
|
+
if len(decoded) != 64 or base64.b64encode(decoded).decode("ascii") != value:
|
|
567
|
+
raise ValueError("Hub signature must contain canonical Ed25519 bytes")
|
|
568
|
+
return decoded
|