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,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
|
+
)
|