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,174 @@
1
+ """Infrastructure agent envelope guard, destructive-change confirmation hold, and coordinator seam."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Sequence
7
+ from dataclasses import dataclass
8
+ from datetime import datetime
9
+ from typing import Literal, Protocol
10
+
11
+ from fdai_deployment_cli.lifecycle_authorization import (
12
+ LifecycleAuthorizationReceipt,
13
+ LifecyclePhaseCompletionReceipt,
14
+ evaluate_phase_authorization,
15
+ exact_plan_digest_for,
16
+ )
17
+ from fdai_deployment_cli.lifecycle_plan import LifecyclePlan
18
+
19
+ ResourceAction = Literal["create", "update", "delete", "replace"]
20
+ InfraAgentOutcome = Literal["applied", "held-for-confirmation", "rejected"]
21
+
22
+ _PREFIXED_SHA256 = re.compile(r"sha256:[0-9a-f]{64}\Z", re.ASCII)
23
+ _DESTRUCTIVE_ACTIONS = frozenset({"delete", "replace"})
24
+
25
+
26
+ @dataclass(frozen=True, slots=True, kw_only=True)
27
+ class PlannedResourceChange:
28
+ """One resource change rendered in an exact Terraform plan."""
29
+
30
+ address: str
31
+ resource_id: str
32
+ entity_id: str
33
+ region: str
34
+ actions: tuple[ResourceAction, ...]
35
+
36
+ def __post_init__(self) -> None:
37
+ if not self.address.strip() or not self.resource_id.strip():
38
+ raise ValueError("address and resource_id MUST NOT be empty")
39
+ if not self.entity_id.strip() or not self.region.strip():
40
+ raise ValueError("entity_id and region MUST NOT be empty")
41
+ if not self.actions:
42
+ raise ValueError("actions MUST NOT be empty")
43
+
44
+ @property
45
+ def is_destructive(self) -> bool:
46
+ action_set = set(self.actions)
47
+ return bool(action_set & _DESTRUCTIVE_ACTIONS)
48
+
49
+
50
+ @dataclass(frozen=True, slots=True, kw_only=True)
51
+ class DestructiveChangeConfirmation:
52
+ """Operator confirmation bound to the exact Plan digest and destructive resource addresses."""
53
+
54
+ exact_plan_digest: str
55
+ confirmed_addresses: frozenset[str]
56
+ operator: str
57
+ reason: str
58
+ confirmed_at: datetime
59
+
60
+ def __post_init__(self) -> None:
61
+ if _PREFIXED_SHA256.fullmatch(self.exact_plan_digest) is None:
62
+ raise ValueError("exact_plan_digest MUST be sha256:<64 hex>")
63
+ if not self.confirmed_addresses or any(
64
+ not addr.strip() for addr in self.confirmed_addresses
65
+ ):
66
+ raise ValueError("confirmed_addresses MUST NOT be empty")
67
+ if not self.operator.strip() or not self.reason.strip():
68
+ raise ValueError("operator and reason MUST NOT be empty")
69
+ if self.confirmed_at.tzinfo is None or self.confirmed_at.utcoffset() is None:
70
+ raise ValueError("confirmed_at MUST include timezone information")
71
+
72
+
73
+ class ExactPlanCoordinator(Protocol):
74
+ """Existing exact-plan claim, apply, and verification-only recovery coordinator seam."""
75
+
76
+ def claim_apply_and_verify(
77
+ self,
78
+ *,
79
+ plan_id: str,
80
+ exact_plan_digest: str,
81
+ authorization_receipt_digest: str,
82
+ changes: tuple[PlannedResourceChange, ...],
83
+ ) -> bool: ...
84
+
85
+
86
+ @dataclass(frozen=True, slots=True, kw_only=True)
87
+ class InfrastructurePhaseResult:
88
+ """Outcome of one infrastructure agent phase execution."""
89
+
90
+ outcome: InfraAgentOutcome
91
+ reason_code: str
92
+ held_addresses: tuple[str, ...] = ()
93
+ completion_receipt: LifecyclePhaseCompletionReceipt | None = None
94
+
95
+
96
+ class InfrastructureAgent:
97
+ """Execute in-envelope infrastructure changes and hold deletions/replacements for confirmation."""
98
+
99
+ def __init__(self, coordinator: ExactPlanCoordinator) -> None:
100
+ self._coordinator = coordinator
101
+
102
+ def execute_phase(
103
+ self,
104
+ plan: LifecyclePlan,
105
+ *,
106
+ authorization_receipt: LifecycleAuthorizationReceipt | None,
107
+ planned_changes: Sequence[PlannedResourceChange],
108
+ trusted_now: datetime,
109
+ confirmation: DestructiveChangeConfirmation | None = None,
110
+ ) -> InfrastructurePhaseResult:
111
+ auth_decision = evaluate_phase_authorization(
112
+ plan,
113
+ phase="infrastructure",
114
+ authorization_receipt=authorization_receipt,
115
+ trusted_now=trusted_now,
116
+ )
117
+ if not auth_decision.allowed or authorization_receipt is None:
118
+ return InfrastructurePhaseResult(
119
+ outcome="rejected",
120
+ reason_code=auth_decision.reason_code,
121
+ )
122
+
123
+ changes = tuple(planned_changes)
124
+ for change in changes:
125
+ if (
126
+ change.entity_id not in plan.envelope.entity_ids
127
+ or change.region not in plan.envelope.regions
128
+ ):
129
+ return InfrastructurePhaseResult(
130
+ outcome="rejected",
131
+ reason_code="change_exceeds_envelope",
132
+ )
133
+
134
+ destructive_addresses = frozenset(
135
+ change.address for change in changes if change.is_destructive
136
+ )
137
+ expected_plan_digest = exact_plan_digest_for(plan)
138
+ if destructive_addresses:
139
+ if (
140
+ confirmation is None
141
+ or confirmation.exact_plan_digest != expected_plan_digest
142
+ or not destructive_addresses <= confirmation.confirmed_addresses
143
+ ):
144
+ return InfrastructurePhaseResult(
145
+ outcome="held-for-confirmation",
146
+ reason_code="destructive_change_confirmation_required",
147
+ held_addresses=tuple(sorted(destructive_addresses)),
148
+ )
149
+
150
+ applied = self._coordinator.claim_apply_and_verify(
151
+ plan_id=plan.plan_id,
152
+ exact_plan_digest=expected_plan_digest,
153
+ authorization_receipt_digest=authorization_receipt.digest,
154
+ changes=changes,
155
+ )
156
+ if not applied:
157
+ return InfrastructurePhaseResult(
158
+ outcome="rejected",
159
+ reason_code="exact_plan_coordinator_failed",
160
+ )
161
+
162
+ receipt = LifecyclePhaseCompletionReceipt(
163
+ plan_id=plan.plan_id,
164
+ exact_plan_digest=expected_plan_digest,
165
+ fencing_generation=plan.fencing_generation,
166
+ phase="infrastructure",
167
+ authorization_receipt_digest=authorization_receipt.digest,
168
+ completed_at=trusted_now,
169
+ )
170
+ return InfrastructurePhaseResult(
171
+ outcome="applied",
172
+ reason_code="applied",
173
+ completion_receipt=receipt,
174
+ )
@@ -0,0 +1,65 @@
1
+ """Private installation binding contract shared by the Hub infrastructure and workload agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from collections.abc import Mapping
8
+ from pathlib import Path
9
+
10
+ from fdai_deployment_cli.private_output import write_private_output
11
+
12
+ INSTALLATION_BINDING_FORMAT = "fdai.installation-binding.v1"
13
+ _BINDING_KEY = re.compile(r"[a-z][a-z0-9_.-]{0,62}\Z", re.ASCII)
14
+ _MAX_BINDING_VALUES = 128
15
+ _MAX_BINDING_VALUE_LENGTH = 1024
16
+ _CREDENTIAL_VALUE = re.compile(
17
+ r"(?i)([a-z][a-z0-9+.-]*://[^/\s]*@|\*{6}|accountkey=|sharedaccesskey=|-----BEGIN )"
18
+ )
19
+
20
+
21
+ def validate_installation_binding_values(
22
+ values: Mapping[str, object],
23
+ *,
24
+ allowed_keys: frozenset[str],
25
+ ) -> dict[str, str]:
26
+ """Validate exact reviewed Terraform outputs without exposing rejected values."""
27
+
28
+ if len(values) > _MAX_BINDING_VALUES:
29
+ raise ValueError("installation binding has too many values")
30
+ if set(values) - allowed_keys:
31
+ raise ValueError("installation binding names a key the bundle does not reference")
32
+ if allowed_keys - set(values):
33
+ raise ValueError("installation binding lacks a key the bundle references")
34
+ validated: dict[str, str] = {}
35
+ for key, value in values.items():
36
+ if not isinstance(key, str) or _BINDING_KEY.fullmatch(key) is None:
37
+ raise ValueError("installation binding key is invalid")
38
+ if (
39
+ not isinstance(value, str)
40
+ or not 1 <= len(value) <= _MAX_BINDING_VALUE_LENGTH
41
+ or any(ord(character) < 32 or ord(character) == 127 for character in value)
42
+ or _CREDENTIAL_VALUE.search(value)
43
+ ):
44
+ raise ValueError("installation binding value is invalid")
45
+ validated[key] = value
46
+ return validated
47
+
48
+
49
+ def write_installation_binding(
50
+ path: Path,
51
+ values: Mapping[str, object],
52
+ *,
53
+ allowed_keys: frozenset[str],
54
+ ) -> None:
55
+ """Persist exact reviewed Terraform outputs as one private installation binding."""
56
+
57
+ validated = validate_installation_binding_values(values, allowed_keys=allowed_keys)
58
+ document = {
59
+ "format": INSTALLATION_BINDING_FORMAT,
60
+ "values": validated,
61
+ }
62
+ write_private_output(
63
+ path,
64
+ json.dumps(document, sort_keys=True, separators=(",", ":")),
65
+ )
@@ -0,0 +1,247 @@
1
+ """Purpose-specific native workload creation evidence, not namespace ownership."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import binascii
7
+ import hashlib
8
+ from datetime import datetime, timedelta
9
+ from typing import Annotated, Literal, Self
10
+ from uuid import UUID
11
+
12
+ from cryptography.exceptions import InvalidSignature
13
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
14
+ Ed25519PrivateKey,
15
+ Ed25519PublicKey,
16
+ )
17
+ from pydantic import (
18
+ BaseModel,
19
+ ConfigDict,
20
+ Field,
21
+ SerializerFunctionWrapHandler,
22
+ StringConstraints,
23
+ model_serializer,
24
+ model_validator,
25
+ )
26
+
27
+ from fdai_deployment_cli.contracts import canonical_bytes, canonical_digest
28
+
29
+ type Digest = Annotated[str, StringConstraints(pattern=r"^sha256:[0-9a-f]{64}$")]
30
+ type PathId = Annotated[str, StringConstraints(pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")]
31
+ type DnsName = Annotated[str, StringConstraints(pattern=r"^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$")]
32
+ type ProductId = Annotated[str, StringConstraints(pattern=r"^[a-z][a-z0-9-]{0,62}$")]
33
+
34
+
35
+ class NativeScope(BaseModel):
36
+ """Exact private creation scope, independently supplied by the customer."""
37
+
38
+ model_config = ConfigDict(extra="forbid", strict=True, frozen=True)
39
+
40
+ installation_id: PathId
41
+ entity_id: Annotated[str, StringConstraints(pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$")]
42
+ product: ProductId
43
+ namespace: DnsName
44
+ components: Annotated[tuple[DnsName, ...], Field(min_length=1, max_length=64)]
45
+ service_components: Annotated[tuple[DnsName, ...], Field(max_length=64)] | None = None
46
+ scheduled_components: Annotated[tuple[DnsName, ...], Field(max_length=64)] = ()
47
+ autoscaled_components: Annotated[tuple[DnsName, ...], Field(max_length=64)] = ()
48
+
49
+ @model_validator(mode="after")
50
+ def validate_components(self) -> Self:
51
+ if tuple(sorted(set(self.components))) != self.components:
52
+ raise ValueError("native components must be unique and sorted")
53
+ if self.anchor_name in self.components:
54
+ raise ValueError("a workload component conflicts with the native anchor")
55
+ if self.service_components is not None and (
56
+ tuple(sorted(set(self.service_components))) != self.service_components
57
+ or not set(self.service_components) <= set(self.components)
58
+ ):
59
+ raise ValueError("native Services must be a canonical subset of Deployments")
60
+ if (
61
+ tuple(sorted(set(self.scheduled_components))) != self.scheduled_components
62
+ or set(self.components) & set(self.scheduled_components)
63
+ or self.anchor_name in self.scheduled_components
64
+ or len(self.components) + len(self.scheduled_components) > 64
65
+ ):
66
+ raise ValueError("native scheduled components must be distinct and canonical")
67
+ if tuple(sorted(set(self.autoscaled_components))) != self.autoscaled_components or not set(
68
+ self.autoscaled_components
69
+ ) <= set(self.components):
70
+ raise ValueError("native autoscalers must be a canonical subset of Deployments")
71
+ return self
72
+
73
+ @model_serializer(mode="wrap")
74
+ def preserve_original_scope(self, handler: SerializerFunctionWrapHandler) -> dict[str, object]:
75
+ document: dict[str, object] = handler(self)
76
+ if not self.scheduled_components:
77
+ document.pop("scheduled_components", None)
78
+ if self.service_components is None:
79
+ document.pop("service_components", None)
80
+ if not self.autoscaled_components:
81
+ document.pop("autoscaled_components", None)
82
+ return document
83
+
84
+ @property
85
+ def digest(self) -> str:
86
+ return "sha256:" + canonical_digest(self.model_dump(mode="json"))
87
+
88
+ @property
89
+ def anchor_name(self) -> str:
90
+ identity = canonical_bytes(
91
+ {"installation_id": self.installation_id, "entity_id": self.entity_id}
92
+ )
93
+ return "lifecycle-scope-" + hashlib.sha256(identity).hexdigest()[:20]
94
+
95
+ @property
96
+ def resources(self) -> frozenset[tuple[str, str]]:
97
+ services = self.components if self.service_components is None else self.service_components
98
+ return (
99
+ frozenset(("Deployment", component) for component in self.components)
100
+ | frozenset(("Service", component) for component in services)
101
+ | frozenset(("CronJob", component) for component in self.scheduled_components)
102
+ | frozenset(
103
+ ("HorizontalPodAutoscaler", component) for component in self.autoscaled_components
104
+ )
105
+ )
106
+
107
+
108
+ class NativeObjectIdentity(BaseModel):
109
+ """One existing Kubernetes object that a local approval permits the Agent to adopt."""
110
+
111
+ model_config = ConfigDict(extra="forbid", strict=True, frozen=True)
112
+
113
+ kind: Literal["Deployment", "Service", "CronJob", "HorizontalPodAutoscaler"]
114
+ name: DnsName
115
+ uid: Annotated[str, StringConstraints(pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")]
116
+
117
+ @property
118
+ def key(self) -> tuple[str, str]:
119
+ return self.kind, self.name
120
+
121
+
122
+ class NativeApproval(BaseModel):
123
+ """Local human approval. A signed package or Hub request cannot supply this input."""
124
+
125
+ model_config = ConfigDict(extra="forbid", strict=True, frozen=True)
126
+
127
+ schema_version: Literal["fdai.lifecycle-native-approval.v1"]
128
+ scope: NativeScope
129
+ profile: Literal["full-authority-development"]
130
+ approved_by: Annotated[str, StringConstraints(pattern=r"^[A-Za-z0-9][A-Za-z0-9@._ -]{0,127}$")]
131
+ approved_at: datetime
132
+ expires_at: datetime
133
+ existing_resources: tuple[NativeObjectIdentity, ...] = ()
134
+
135
+ @model_validator(mode="after")
136
+ def validate_interval(self) -> Self:
137
+ if self.approved_at.utcoffset() is None or self.expires_at.utcoffset() is None:
138
+ raise ValueError("native approval requires timezone-aware timestamps")
139
+ if not timedelta(0) < self.expires_at - self.approved_at <= timedelta(hours=4):
140
+ raise ValueError("native approval duration must be positive and at most four hours")
141
+ keys = tuple(item.key for item in self.existing_resources)
142
+ if tuple(sorted(set(keys))) != keys:
143
+ raise ValueError("existing native resources must be unique and sorted")
144
+ if not set(keys) <= self.scope.resources:
145
+ raise ValueError("existing native resources exceed the approved scope")
146
+ return self
147
+
148
+ @model_serializer(mode="wrap")
149
+ def preserve_creation_only_approval(
150
+ self, handler: SerializerFunctionWrapHandler
151
+ ) -> dict[str, object]:
152
+ document: dict[str, object] = handler(self)
153
+ if not self.existing_resources:
154
+ document.pop("existing_resources", None)
155
+ return document
156
+
157
+ def require_current(self, now: datetime) -> None:
158
+ if now.utcoffset() is None or not self.approved_at <= now < self.expires_at:
159
+ raise ValueError("native approval is not currently valid")
160
+
161
+ @property
162
+ def digest(self) -> str:
163
+ return "sha256:" + canonical_digest(self.model_dump(mode="json"))
164
+
165
+
166
+ class NativeScopeClaim(BaseModel):
167
+ """Public digests of actual local creation; no namespace, UID or credential leaves."""
168
+
169
+ model_config = ConfigDict(extra="forbid", strict=True, frozen=True)
170
+
171
+ schema_version: Literal["fdai.lifecycle-native-scope.v1"]
172
+ installation_id: PathId
173
+ entity_id: PathId
174
+ product: ProductId
175
+ scope_digest: Digest
176
+ approval_digest: Digest
177
+ creation_receipt_digest: Digest
178
+
179
+ @property
180
+ def payload(self) -> bytes:
181
+ return canonical_bytes(self.model_dump(mode="json"))
182
+
183
+ @property
184
+ def digest(self) -> str:
185
+ return "sha256:" + canonical_digest(self.model_dump(mode="json"))
186
+
187
+
188
+ class SignedNativeScope(BaseModel):
189
+ model_config = ConfigDict(extra="forbid", strict=True, frozen=True)
190
+
191
+ claim: NativeScopeClaim
192
+ signature: Annotated[str, StringConstraints(min_length=88, max_length=88)]
193
+
194
+ @model_validator(mode="after")
195
+ def validate_signature(self) -> Self:
196
+ try:
197
+ decoded = base64.b64decode(self.signature, validate=True)
198
+ except (ValueError, binascii.Error) as error:
199
+ raise ValueError("native scope signature encoding is invalid") from error
200
+ if len(decoded) != 64 or base64.b64encode(decoded).decode("ascii") != self.signature:
201
+ raise ValueError("native scope signature must be canonical Ed25519 bytes")
202
+ return self
203
+
204
+ def verify(self, public_key: bytes) -> bool:
205
+ try:
206
+ Ed25519PublicKey.from_public_bytes(public_key).verify(
207
+ base64.b64decode(self.signature, validate=True), self.claim.payload
208
+ )
209
+ except (ValueError, InvalidSignature):
210
+ return False
211
+ return True
212
+
213
+
214
+ def sign_native_scope(claim: NativeScopeClaim, key: Ed25519PrivateKey) -> SignedNativeScope:
215
+ return SignedNativeScope(
216
+ claim=claim, signature=base64.b64encode(key.sign(claim.payload)).decode("ascii")
217
+ )
218
+
219
+
220
+ class NativeOwnershipAcceptance(BaseModel):
221
+ """Separate customer-operator acceptance, serialized with installation planning."""
222
+
223
+ model_config = ConfigDict(extra="forbid", strict=True, frozen=True)
224
+
225
+ request_id: UUID
226
+ expected_settings_digest: Annotated[str, StringConstraints(pattern=r"^[0-9a-f]{64}$")]
227
+ proof: SignedNativeScope
228
+ reason: Annotated[str, StringConstraints(min_length=1, max_length=512)]
229
+ confirmation: Annotated[str, StringConstraints(max_length=512)]
230
+
231
+ @model_validator(mode="after")
232
+ def validate_reason(self) -> Self:
233
+ if not self.reason.strip() or any(ord(character) < 32 for character in self.reason):
234
+ raise ValueError("native acceptance requires a nonblank single-line reason")
235
+ return self
236
+
237
+ @property
238
+ def digest(self) -> str:
239
+ return canonical_digest(self.model_dump(mode="json"))
240
+
241
+ @property
242
+ def required_confirmation(self) -> str:
243
+ claim = self.proof.claim
244
+ return (
245
+ f"accept native scope {claim.installation_id} {claim.entity_id} "
246
+ f"{claim.digest} {self.expected_settings_digest}"
247
+ )