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,307 @@
|
|
|
1
|
+
"""Strict, deterministic contracts for subscription genesis planning."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from fdai_service_contracts.catalog_canonical import canonical_bytes as canonical_bytes
|
|
14
|
+
from fdai_service_contracts.catalog_canonical import canonical_digest as canonical_digest
|
|
15
|
+
|
|
16
|
+
_ID = re.compile(r"^[a-z][a-z0-9._-]{0,127}$")
|
|
17
|
+
_ENVIRONMENTS = frozenset({"dev", "staging", "prod"})
|
|
18
|
+
_CONNECTIVITY = frozenset({"online", "offline"})
|
|
19
|
+
_HOSTS = frozenset({"existing-host", "managed-vm"})
|
|
20
|
+
_TRANSPORTS = frozenset({"manual"})
|
|
21
|
+
_ACCESS_METHODS = frozenset({"internal_ssh", "temporary_public_ssh", "bastion", "run_command"})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ApprovalClass(StrEnum):
|
|
25
|
+
"""Approval strength required by one manifest entry."""
|
|
26
|
+
|
|
27
|
+
STANDARD = "standard"
|
|
28
|
+
HIGH_IMPACT = "high-impact"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class ProvisionProfile:
|
|
33
|
+
"""Secret-free desired state selected by an operator."""
|
|
34
|
+
|
|
35
|
+
environment: str
|
|
36
|
+
region: str
|
|
37
|
+
target_binding: str
|
|
38
|
+
connectivity: str
|
|
39
|
+
host: str
|
|
40
|
+
transport: str
|
|
41
|
+
access_method: str
|
|
42
|
+
shadow_only: bool
|
|
43
|
+
approval_quorum: int
|
|
44
|
+
monthly_cost_ceiling: int
|
|
45
|
+
|
|
46
|
+
def __post_init__(self) -> None:
|
|
47
|
+
if self.environment not in _ENVIRONMENTS:
|
|
48
|
+
raise ValueError("environment is unsupported")
|
|
49
|
+
if _ID.fullmatch(self.region) is None:
|
|
50
|
+
raise ValueError("region MUST be a lowercase stable identifier")
|
|
51
|
+
if re.fullmatch(r"[0-9a-f]{64}", self.target_binding) is None:
|
|
52
|
+
raise ValueError("target_binding MUST be a lowercase SHA-256")
|
|
53
|
+
if self.connectivity not in _CONNECTIVITY:
|
|
54
|
+
raise ValueError("connectivity is unsupported")
|
|
55
|
+
if self.host not in _HOSTS:
|
|
56
|
+
raise ValueError("host is unsupported")
|
|
57
|
+
if self.transport not in _TRANSPORTS:
|
|
58
|
+
raise ValueError("transport is unsupported")
|
|
59
|
+
if self.access_method not in _ACCESS_METHODS:
|
|
60
|
+
raise ValueError("access_method is unsupported")
|
|
61
|
+
if not self.shadow_only:
|
|
62
|
+
raise ValueError("subscription genesis MUST start shadow-only")
|
|
63
|
+
if self.approval_quorum < 1:
|
|
64
|
+
raise ValueError("approval_quorum MUST be positive")
|
|
65
|
+
if self.monthly_cost_ceiling < 0:
|
|
66
|
+
raise ValueError("monthly_cost_ceiling MUST be non-negative")
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_mapping(cls, value: Mapping[str, object]) -> ProvisionProfile:
|
|
70
|
+
"""Decode a profile and reject unknown or missing fields."""
|
|
71
|
+
|
|
72
|
+
expected = {
|
|
73
|
+
"schema_version",
|
|
74
|
+
"environment",
|
|
75
|
+
"region",
|
|
76
|
+
"target_binding",
|
|
77
|
+
"connectivity",
|
|
78
|
+
"host",
|
|
79
|
+
"transport",
|
|
80
|
+
"access_method",
|
|
81
|
+
"shadow_only",
|
|
82
|
+
"approval_quorum",
|
|
83
|
+
"monthly_cost_ceiling",
|
|
84
|
+
}
|
|
85
|
+
_require_exact_keys(value, expected, "provision profile")
|
|
86
|
+
if value["schema_version"] != "fdai.provision-profile.v1":
|
|
87
|
+
raise ValueError("provision profile schema_version is unsupported")
|
|
88
|
+
return cls(
|
|
89
|
+
environment=_text(value, "environment"),
|
|
90
|
+
region=_text(value, "region"),
|
|
91
|
+
target_binding=_text(value, "target_binding"),
|
|
92
|
+
connectivity=_text(value, "connectivity"),
|
|
93
|
+
host=_text(value, "host"),
|
|
94
|
+
transport=_text(value, "transport"),
|
|
95
|
+
access_method=_text(value, "access_method"),
|
|
96
|
+
shadow_only=_bool(value, "shadow_only"),
|
|
97
|
+
approval_quorum=_int(value, "approval_quorum"),
|
|
98
|
+
monthly_cost_ceiling=_int(value, "monthly_cost_ceiling"),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def to_mapping(self) -> dict[str, object]:
|
|
102
|
+
"""Return the canonical JSON-compatible profile."""
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
"schema_version": "fdai.provision-profile.v1",
|
|
106
|
+
"environment": self.environment,
|
|
107
|
+
"region": self.region,
|
|
108
|
+
"target_binding": self.target_binding,
|
|
109
|
+
"connectivity": self.connectivity,
|
|
110
|
+
"host": self.host,
|
|
111
|
+
"transport": self.transport,
|
|
112
|
+
"access_method": self.access_method,
|
|
113
|
+
"shadow_only": self.shadow_only,
|
|
114
|
+
"approval_quorum": self.approval_quorum,
|
|
115
|
+
"monthly_cost_ceiling": self.monthly_cost_ceiling,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True, slots=True)
|
|
120
|
+
class ManifestEntry:
|
|
121
|
+
"""One finite, dependency-ordered provisioning responsibility."""
|
|
122
|
+
|
|
123
|
+
entry_id: str
|
|
124
|
+
owner: str
|
|
125
|
+
desired_state: str
|
|
126
|
+
prerequisites: tuple[str, ...]
|
|
127
|
+
approval_class: ApprovalClass
|
|
128
|
+
idempotency_key: str
|
|
129
|
+
timeout_seconds: int
|
|
130
|
+
no_progress_seconds: int
|
|
131
|
+
rollback_ref: str
|
|
132
|
+
observer: str
|
|
133
|
+
|
|
134
|
+
def __post_init__(self) -> None:
|
|
135
|
+
for label, value in (
|
|
136
|
+
("entry_id", self.entry_id),
|
|
137
|
+
("owner", self.owner),
|
|
138
|
+
("desired_state", self.desired_state),
|
|
139
|
+
("idempotency_key", self.idempotency_key),
|
|
140
|
+
("rollback_ref", self.rollback_ref),
|
|
141
|
+
("observer", self.observer),
|
|
142
|
+
):
|
|
143
|
+
if _ID.fullmatch(value) is None:
|
|
144
|
+
raise ValueError(f"{label} MUST be a lowercase stable identifier")
|
|
145
|
+
if len(set(self.prerequisites)) != len(self.prerequisites):
|
|
146
|
+
raise ValueError("manifest prerequisites MUST be unique")
|
|
147
|
+
if any(_ID.fullmatch(item) is None for item in self.prerequisites):
|
|
148
|
+
raise ValueError("manifest prerequisites MUST be stable identifiers")
|
|
149
|
+
if self.entry_id in self.prerequisites:
|
|
150
|
+
raise ValueError("manifest entry cannot depend on itself")
|
|
151
|
+
if self.timeout_seconds < 1:
|
|
152
|
+
raise ValueError("timeout_seconds MUST be positive")
|
|
153
|
+
if not 1 <= self.no_progress_seconds <= self.timeout_seconds:
|
|
154
|
+
raise ValueError("no_progress_seconds MUST be within the stage timeout")
|
|
155
|
+
|
|
156
|
+
def to_mapping(self) -> dict[str, object]:
|
|
157
|
+
"""Return canonical entry data."""
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
"entry_id": self.entry_id,
|
|
161
|
+
"owner": self.owner,
|
|
162
|
+
"desired_state": self.desired_state,
|
|
163
|
+
"prerequisites": list(self.prerequisites),
|
|
164
|
+
"approval_class": self.approval_class.value,
|
|
165
|
+
"idempotency_key": self.idempotency_key,
|
|
166
|
+
"timeout_seconds": self.timeout_seconds,
|
|
167
|
+
"no_progress_seconds": self.no_progress_seconds,
|
|
168
|
+
"rollback_ref": self.rollback_ref,
|
|
169
|
+
"observer": self.observer,
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass(frozen=True, slots=True)
|
|
174
|
+
class SubscriptionProvisioningManifest:
|
|
175
|
+
"""Finite manifest whose digest seals one local plan."""
|
|
176
|
+
|
|
177
|
+
source_commit: str
|
|
178
|
+
profile_digest: str
|
|
179
|
+
entries: tuple[ManifestEntry, ...]
|
|
180
|
+
|
|
181
|
+
def __post_init__(self) -> None:
|
|
182
|
+
if re.fullmatch(r"[0-9a-f]{40}", self.source_commit) is None:
|
|
183
|
+
raise ValueError("source_commit MUST be a lowercase 40-character SHA")
|
|
184
|
+
if re.fullmatch(r"[0-9a-f]{64}", self.profile_digest) is None:
|
|
185
|
+
raise ValueError("profile_digest MUST be a lowercase SHA-256")
|
|
186
|
+
if not self.entries:
|
|
187
|
+
raise ValueError("provisioning manifest MUST contain entries")
|
|
188
|
+
ids = tuple(entry.entry_id for entry in self.entries)
|
|
189
|
+
if len(ids) != len(set(ids)):
|
|
190
|
+
raise ValueError("provisioning manifest entry ids MUST be unique")
|
|
191
|
+
known = set(ids)
|
|
192
|
+
for entry in self.entries:
|
|
193
|
+
unknown = set(entry.prerequisites) - known
|
|
194
|
+
if unknown:
|
|
195
|
+
raise ValueError(f"manifest entry {entry.entry_id!r} has unknown prerequisites")
|
|
196
|
+
_assert_acyclic(self.entries)
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def digest(self) -> str:
|
|
200
|
+
"""Return the replay-stable manifest digest."""
|
|
201
|
+
|
|
202
|
+
return canonical_digest(self.to_mapping())
|
|
203
|
+
|
|
204
|
+
def to_mapping(self) -> dict[str, object]:
|
|
205
|
+
"""Return canonical manifest data."""
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
"schema_version": "fdai.subscription-provisioning-manifest.v1",
|
|
209
|
+
"source_commit": self.source_commit,
|
|
210
|
+
"profile_digest": self.profile_digest,
|
|
211
|
+
"entries": [entry.to_mapping() for entry in self.entries],
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _assert_acyclic(entries: Sequence[ManifestEntry]) -> None:
|
|
216
|
+
by_id = {entry.entry_id: entry for entry in entries}
|
|
217
|
+
visiting: set[str] = set()
|
|
218
|
+
visited: set[str] = set()
|
|
219
|
+
|
|
220
|
+
def visit(entry_id: str) -> None:
|
|
221
|
+
if entry_id in visiting:
|
|
222
|
+
raise ValueError("provisioning manifest dependency cycle detected")
|
|
223
|
+
if entry_id in visited:
|
|
224
|
+
return
|
|
225
|
+
visiting.add(entry_id)
|
|
226
|
+
for dependency in by_id[entry_id].prerequisites:
|
|
227
|
+
visit(dependency)
|
|
228
|
+
visiting.remove(entry_id)
|
|
229
|
+
visited.add(entry_id)
|
|
230
|
+
|
|
231
|
+
for key in sorted(by_id):
|
|
232
|
+
visit(key)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _require_exact_keys(
|
|
236
|
+
value: Mapping[str, object],
|
|
237
|
+
expected: set[str],
|
|
238
|
+
label: str,
|
|
239
|
+
) -> None:
|
|
240
|
+
if set(value) != expected:
|
|
241
|
+
raise ValueError(f"{label} fields do not match the schema")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _text(value: Mapping[str, object], field: str) -> str:
|
|
245
|
+
item = value[field]
|
|
246
|
+
if not isinstance(item, str):
|
|
247
|
+
raise ValueError(f"{field} MUST be a string")
|
|
248
|
+
return item
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _bool(value: Mapping[str, object], field: str) -> bool:
|
|
252
|
+
item = value[field]
|
|
253
|
+
if not isinstance(item, bool):
|
|
254
|
+
raise ValueError(f"{field} MUST be a boolean")
|
|
255
|
+
return item
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _int(value: Mapping[str, object], field: str) -> int:
|
|
259
|
+
item = value[field]
|
|
260
|
+
if isinstance(item, bool) or not isinstance(item, int):
|
|
261
|
+
raise ValueError(f"{field} MUST be an integer")
|
|
262
|
+
return item
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def load_json_object(
|
|
266
|
+
data: bytes, *, label: str, max_bytes: int = 1_048_576, strict: bool = False
|
|
267
|
+
) -> dict[str, Any]:
|
|
268
|
+
"""Decode one bounded JSON object."""
|
|
269
|
+
|
|
270
|
+
if not data or len(data) > max_bytes:
|
|
271
|
+
raise ValueError(f"{label} is empty or exceeds its size limit")
|
|
272
|
+
try:
|
|
273
|
+
value = (
|
|
274
|
+
json.loads(
|
|
275
|
+
data.decode("utf-8"),
|
|
276
|
+
object_pairs_hook=_unique_json_object,
|
|
277
|
+
parse_constant=_reject_json_constant,
|
|
278
|
+
parse_float=_finite_json_float,
|
|
279
|
+
)
|
|
280
|
+
if strict
|
|
281
|
+
else json.loads(data)
|
|
282
|
+
)
|
|
283
|
+
except (ValueError, RecursionError) as exc:
|
|
284
|
+
raise ValueError(f"{label} is not valid JSON") from exc
|
|
285
|
+
if not isinstance(value, dict):
|
|
286
|
+
raise ValueError(f"{label} MUST be a JSON object")
|
|
287
|
+
return value
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _unique_json_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
291
|
+
result: dict[str, object] = {}
|
|
292
|
+
for key, value in pairs:
|
|
293
|
+
if key in result:
|
|
294
|
+
raise ValueError("duplicate JSON field")
|
|
295
|
+
result[key] = value
|
|
296
|
+
return result
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _reject_json_constant(value: str) -> object:
|
|
300
|
+
raise ValueError("non-finite JSON number")
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _finite_json_float(value: str) -> float:
|
|
304
|
+
number = float(value)
|
|
305
|
+
if not math.isfinite(number):
|
|
306
|
+
raise ValueError("non-finite JSON number")
|
|
307
|
+
return number
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Verify one signed deployment-control wheelhouse for managed-host execution.
|
|
2
|
+
|
|
3
|
+
The complete kit keeps its signed runtime payload: images, Terraform bundle, toolchain, and
|
|
4
|
+
Console. A control package replaces only the managed-host ``fdai-deployment-cli`` installation,
|
|
5
|
+
so a deployment-control repair needs a new signed wheelhouse instead of a new complete kit.
|
|
6
|
+
The only package guarantee is one detached Ed25519 signature over ``SHA256SUMS``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import re
|
|
13
|
+
import stat
|
|
14
|
+
import tarfile
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path, PurePosixPath
|
|
17
|
+
|
|
18
|
+
from cryptography.exceptions import InvalidSignature
|
|
19
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
20
|
+
from cryptography.hazmat.primitives.serialization import load_pem_public_key
|
|
21
|
+
|
|
22
|
+
from fdai_deployment_cli.trust_roots import deployment_release_root_pem
|
|
23
|
+
|
|
24
|
+
PACKAGE_ROOT = "package"
|
|
25
|
+
_SUMS = "SHA256SUMS"
|
|
26
|
+
_SIGNATURE = "SHA256SUMS.sig"
|
|
27
|
+
_REQUIREMENTS = "requirements.txt"
|
|
28
|
+
_SUM_LINE = re.compile(r"([0-9a-f]{64}) ([A-Za-z0-9._+-]+(?:/[A-Za-z0-9._+-]+)*)")
|
|
29
|
+
_REQUIREMENT = re.compile(r"fdai-deployment-cli==([0-9]+\.[0-9]+\.[0-9]+)\n")
|
|
30
|
+
_MAX_ARCHIVE_BYTES = 128 * 1024 * 1024
|
|
31
|
+
_MAX_MEMBER_BYTES = 64 * 1024 * 1024
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class ControlPackage:
|
|
36
|
+
"""A locally verified signed wheelhouse and the digests that bind it to one run."""
|
|
37
|
+
|
|
38
|
+
archive: Path
|
|
39
|
+
archive_digest: str
|
|
40
|
+
sums_digest: str
|
|
41
|
+
version: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def verify_control_package(archive: Path, *, public_key_pem: bytes | None = None) -> ControlPackage:
|
|
45
|
+
"""Verify signature, exact file set, and digests of one control-package archive.
|
|
46
|
+
|
|
47
|
+
Raises ``ValueError`` without secret or path values when the archive is not a regular
|
|
48
|
+
file, contains links or unsafe paths, lacks a valid signature, lists a different file set,
|
|
49
|
+
or does not install exactly one pinned ``fdai-deployment-cli`` version.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
details = archive.lstat()
|
|
54
|
+
except OSError as exc:
|
|
55
|
+
raise ValueError("control package archive is unavailable") from exc
|
|
56
|
+
if not stat.S_ISREG(details.st_mode) or details.st_size > _MAX_ARCHIVE_BYTES:
|
|
57
|
+
raise ValueError("control package archive must be a bounded regular file")
|
|
58
|
+
payload = archive.read_bytes()
|
|
59
|
+
files = _read_members(archive)
|
|
60
|
+
sums = files.pop(_SUMS, None)
|
|
61
|
+
signature = files.pop(_SIGNATURE, None)
|
|
62
|
+
if sums is None or signature is None:
|
|
63
|
+
raise ValueError("control package signature files are missing")
|
|
64
|
+
key = load_pem_public_key(public_key_pem or deployment_release_root_pem())
|
|
65
|
+
if not isinstance(key, Ed25519PublicKey):
|
|
66
|
+
raise ValueError("control package trust root must be Ed25519")
|
|
67
|
+
try:
|
|
68
|
+
key.verify(signature, sums)
|
|
69
|
+
except InvalidSignature as exc:
|
|
70
|
+
raise ValueError("control package signature is invalid") from exc
|
|
71
|
+
listed = _parse_sums(sums)
|
|
72
|
+
if set(listed) != set(files):
|
|
73
|
+
raise ValueError("control package file set differs from SHA256SUMS")
|
|
74
|
+
for relative, expected in listed.items():
|
|
75
|
+
if hashlib.sha256(files[relative]).hexdigest() != expected:
|
|
76
|
+
raise ValueError("control package file digest differs from SHA256SUMS")
|
|
77
|
+
requirement = _REQUIREMENT.fullmatch(files.get(_REQUIREMENTS, b"").decode("ascii", "replace"))
|
|
78
|
+
if requirement is None:
|
|
79
|
+
raise ValueError("control package must pin exactly fdai-deployment-cli")
|
|
80
|
+
version = requirement.group(1)
|
|
81
|
+
cli_wheels = [
|
|
82
|
+
name
|
|
83
|
+
for name in files
|
|
84
|
+
if name.startswith("wheels/fdai_deployment_cli-") and name.endswith(".whl")
|
|
85
|
+
]
|
|
86
|
+
if cli_wheels != [f"wheels/fdai_deployment_cli-{version}-py3-none-any.whl"]:
|
|
87
|
+
raise ValueError("control package must contain exactly the pinned CLI wheel")
|
|
88
|
+
return ControlPackage(
|
|
89
|
+
archive=archive,
|
|
90
|
+
archive_digest=hashlib.sha256(payload).hexdigest(),
|
|
91
|
+
sums_digest=hashlib.sha256(sums).hexdigest(),
|
|
92
|
+
version=version,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _read_members(archive: Path) -> dict[str, bytes]:
|
|
97
|
+
"""Read regular package members, rejecting links, devices, and escaping paths."""
|
|
98
|
+
|
|
99
|
+
files: dict[str, bytes] = {}
|
|
100
|
+
try:
|
|
101
|
+
with tarfile.open(archive, mode="r:gz") as bundle:
|
|
102
|
+
for member in bundle:
|
|
103
|
+
path = PurePosixPath(member.name)
|
|
104
|
+
if path.is_absolute() or ".." in path.parts or not path.parts:
|
|
105
|
+
raise ValueError("control package member path is unsafe")
|
|
106
|
+
if path.parts[0] != PACKAGE_ROOT:
|
|
107
|
+
raise ValueError("control package member is outside the package root")
|
|
108
|
+
if member.isdir():
|
|
109
|
+
continue
|
|
110
|
+
if not member.isreg() or member.size > _MAX_MEMBER_BYTES:
|
|
111
|
+
raise ValueError("control package member must be a bounded regular file")
|
|
112
|
+
relative = PurePosixPath(*path.parts[1:]).as_posix()
|
|
113
|
+
if relative in files or relative == ".":
|
|
114
|
+
raise ValueError("control package member is duplicated")
|
|
115
|
+
extracted = bundle.extractfile(member)
|
|
116
|
+
if extracted is None:
|
|
117
|
+
raise ValueError("control package member is unreadable")
|
|
118
|
+
files[relative] = extracted.read()
|
|
119
|
+
except (OSError, tarfile.TarError, EOFError) as exc:
|
|
120
|
+
raise ValueError("control package archive is unreadable") from exc
|
|
121
|
+
return files
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _parse_sums(sums: bytes) -> dict[str, str]:
|
|
125
|
+
try:
|
|
126
|
+
text = sums.decode("ascii")
|
|
127
|
+
except UnicodeDecodeError as exc:
|
|
128
|
+
raise ValueError("control package SHA256SUMS is not ASCII") from exc
|
|
129
|
+
listed: dict[str, str] = {}
|
|
130
|
+
for line in text.splitlines():
|
|
131
|
+
match = _SUM_LINE.fullmatch(line)
|
|
132
|
+
if match is None or match.group(2) in listed:
|
|
133
|
+
raise ValueError("control package SHA256SUMS is malformed")
|
|
134
|
+
listed[match.group(2)] = match.group(1)
|
|
135
|
+
if not listed:
|
|
136
|
+
raise ValueError("control package SHA256SUMS is empty")
|
|
137
|
+
return listed
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
__all__ = ["PACKAGE_ROOT", "ControlPackage", "verify_control_package"]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Bound an existing managed-host transport to one deployment deadline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
|
|
10
|
+
from fdai_deployment_cli.deployment_deadline import DeploymentDeadline
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DeploymentTransport(Protocol):
|
|
14
|
+
"""Existing authenticated transport; this wrapper never chooses identity or target."""
|
|
15
|
+
|
|
16
|
+
def ssh(
|
|
17
|
+
self, remote_arguments: Sequence[str], *, timeout: int, input_text: str | None = None
|
|
18
|
+
) -> subprocess.CompletedProcess[str]:
|
|
19
|
+
"""Run the unchanged remote command within the supplied process limit."""
|
|
20
|
+
...
|
|
21
|
+
|
|
22
|
+
def copy_to(self, source: Path, destination: str, *, timeout: int) -> None:
|
|
23
|
+
"""Copy the unchanged private file within the supplied process limit."""
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DeadlineTransport:
|
|
28
|
+
"""Clamp every command and copy to the current budget without adding retries.
|
|
29
|
+
|
|
30
|
+
The caller still owns the underlying tunnel's context and cleanup. An operation
|
|
31
|
+
returning after expiry cannot advance the next stage or produce a ready receipt.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
transport: DeploymentTransport,
|
|
37
|
+
deadline: DeploymentDeadline,
|
|
38
|
+
*,
|
|
39
|
+
host_program: str | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
self._transport = transport
|
|
42
|
+
self._deadline = deadline
|
|
43
|
+
self.host_program = host_program
|
|
44
|
+
|
|
45
|
+
def ssh(
|
|
46
|
+
self, remote_arguments: Sequence[str], *, timeout: int, input_text: str | None = None
|
|
47
|
+
) -> subprocess.CompletedProcess[str]:
|
|
48
|
+
"""Preserve command/stdin while enforcing the current deadline before and after I/O."""
|
|
49
|
+
|
|
50
|
+
remaining = self._deadline.remaining(timeout)
|
|
51
|
+
try:
|
|
52
|
+
result = self._transport.ssh(remote_arguments, timeout=remaining, input_text=input_text)
|
|
53
|
+
except (OSError, subprocess.SubprocessError):
|
|
54
|
+
raise ValueError(
|
|
55
|
+
"standalone managed-host command failed; inspect retained evidence before recovery"
|
|
56
|
+
) from None
|
|
57
|
+
self._deadline.remaining()
|
|
58
|
+
return result
|
|
59
|
+
|
|
60
|
+
def copy_to(self, source: Path, destination: str, *, timeout: int) -> None:
|
|
61
|
+
"""Preserve transfer paths while enforcing the current deadline before and after I/O."""
|
|
62
|
+
|
|
63
|
+
remaining = self._deadline.remaining(timeout)
|
|
64
|
+
try:
|
|
65
|
+
self._transport.copy_to(source, destination, timeout=remaining)
|
|
66
|
+
except (OSError, subprocess.SubprocessError):
|
|
67
|
+
raise ValueError(
|
|
68
|
+
"standalone managed-host transfer failed; inspect retained evidence before recovery"
|
|
69
|
+
) from None
|
|
70
|
+
self._deadline.remaining()
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Inspect the AKS compute portion of deployment cost without claiming a complete budget."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from decimal import Decimal, ROUND_CEILING
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from fdai_deployment_cli.azure_retail_prices import read_linux_vm_price
|
|
9
|
+
from fdai_deployment_cli.contracts import canonical_digest, load_json_object
|
|
10
|
+
from fdai_deployment_cli.deployment_deadline import DeploymentDeadline
|
|
11
|
+
from fdai_deployment_cli.private_output import read_private_bytes
|
|
12
|
+
from fdai_deployment_cli.runtime_profile import RuntimeDeploymentProfile
|
|
13
|
+
|
|
14
|
+
# Covers the default AKS profile (8 Standard_D4ds_v4 nodes at most) at public US rates.
|
|
15
|
+
DEFAULT_MONTHLY_COST_CEILING = 1500
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def resolve_monthly_cost_ceiling(requested: int | None, *, retained: tuple[Path, ...]) -> int:
|
|
19
|
+
"""Keep a retained run's ceiling so resume, recovery and teardown match its intent.
|
|
20
|
+
|
|
21
|
+
Only a run with no retained record receives the current default.
|
|
22
|
+
"""
|
|
23
|
+
if requested is not None:
|
|
24
|
+
return requested
|
|
25
|
+
for path in retained:
|
|
26
|
+
if not path.is_file():
|
|
27
|
+
continue
|
|
28
|
+
value = load_json_object(
|
|
29
|
+
read_private_bytes(path, max_bytes=1_048_576), label=path.name
|
|
30
|
+
).get("monthly_cost_ceiling")
|
|
31
|
+
if type(value) is not int or value < 0:
|
|
32
|
+
raise ValueError(
|
|
33
|
+
f"{path.name} records no usable monthly_cost_ceiling; pass --monthly-cost-ceiling"
|
|
34
|
+
)
|
|
35
|
+
return value
|
|
36
|
+
return DEFAULT_MONTHLY_COST_CEILING
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def resolve_work_dir_monthly_cost_ceiling(
|
|
40
|
+
requested: int | None, *, work_dir: Path, adopted_foundation: Path | None
|
|
41
|
+
) -> int:
|
|
42
|
+
"""Resolve the ceiling from the records a run in ``work_dir`` retains, in precedence order."""
|
|
43
|
+
retained: tuple[Path, ...] = (
|
|
44
|
+
work_dir / "source-intent.json",
|
|
45
|
+
work_dir / "run/profile.json",
|
|
46
|
+
work_dir / "foundation/profile.json",
|
|
47
|
+
)
|
|
48
|
+
if adopted_foundation is not None:
|
|
49
|
+
retained = (*retained, adopted_foundation / "profile.json")
|
|
50
|
+
return resolve_monthly_cost_ceiling(requested, retained=retained)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def inspect_aks_compute_cost(
|
|
54
|
+
*,
|
|
55
|
+
profile: RuntimeDeploymentProfile,
|
|
56
|
+
region: str,
|
|
57
|
+
monthly_cost_ceiling: int,
|
|
58
|
+
timeout_seconds: int = 120,
|
|
59
|
+
) -> dict[str, object]:
|
|
60
|
+
"""Price maximum steady-state nodes for 730 hours; unpriced components remain explicit.
|
|
61
|
+
|
|
62
|
+
This is a partial projection, not a quote for the installation. It may block
|
|
63
|
+
an over-budget profile but cannot approve one, reserve capacity or cap bills.
|
|
64
|
+
"""
|
|
65
|
+
if (
|
|
66
|
+
profile.runtime_platform.value != "aks"
|
|
67
|
+
or type(monthly_cost_ceiling) is not int
|
|
68
|
+
or monthly_cost_ceiling <= 0
|
|
69
|
+
):
|
|
70
|
+
raise ValueError("AKS cost review requires an AKS profile and a positive ceiling")
|
|
71
|
+
deadline = DeploymentDeadline(timeout_seconds)
|
|
72
|
+
quotes = {
|
|
73
|
+
sku: read_linux_vm_price(sku=sku, region=region, timeout_seconds=deadline.remaining(90))
|
|
74
|
+
for sku in sorted({profile.system_node_sku, profile.user_node_sku})
|
|
75
|
+
}
|
|
76
|
+
lines = []
|
|
77
|
+
for pool, sku, count in (
|
|
78
|
+
("system", profile.system_node_sku, profile.system_node_count),
|
|
79
|
+
("user", profile.user_node_sku, profile.user_node_max_count),
|
|
80
|
+
):
|
|
81
|
+
hourly = Decimal(str(quotes[sku]["hourly_rate"]))
|
|
82
|
+
monthly = (hourly * count * 730).quantize(Decimal("0.01"), rounding=ROUND_CEILING)
|
|
83
|
+
lines.append(
|
|
84
|
+
{
|
|
85
|
+
"pool": pool,
|
|
86
|
+
"sku": sku,
|
|
87
|
+
"node_count": count,
|
|
88
|
+
"hours": 730,
|
|
89
|
+
"hourly_rate_usd": str(hourly),
|
|
90
|
+
"monthly_estimate_usd": str(monthly),
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
total = sum((Decimal(str(line["monthly_estimate_usd"])) for line in lines), Decimal(0))
|
|
94
|
+
exceeded = total > monthly_cost_ceiling
|
|
95
|
+
result: dict[str, object] = {
|
|
96
|
+
"schema_version": "fdai.deployment-compute-cost.v1",
|
|
97
|
+
"stage": "cost-review",
|
|
98
|
+
"state": "blocked" if exceeded else "partial",
|
|
99
|
+
"reason_code": "aks_compute_exceeds_monthly_ceiling"
|
|
100
|
+
if exceeded
|
|
101
|
+
else "whole_installation_cost_incomplete",
|
|
102
|
+
"runtime_profile_digest": profile.digest,
|
|
103
|
+
"region": region,
|
|
104
|
+
"monthly_cost_ceiling_usd": monthly_cost_ceiling,
|
|
105
|
+
"monthly_compute_estimate_usd": str(total),
|
|
106
|
+
"lines": lines,
|
|
107
|
+
"quotes": quotes,
|
|
108
|
+
"excluded_costs": [
|
|
109
|
+
"surge-and-setup",
|
|
110
|
+
"foundation",
|
|
111
|
+
"aks-control-plane",
|
|
112
|
+
"disks",
|
|
113
|
+
"database",
|
|
114
|
+
"network",
|
|
115
|
+
"registry",
|
|
116
|
+
"storage",
|
|
117
|
+
"monitoring",
|
|
118
|
+
"event-bus",
|
|
119
|
+
"models",
|
|
120
|
+
"taxes",
|
|
121
|
+
],
|
|
122
|
+
"whole_installation_cost_verified": False,
|
|
123
|
+
"setup_cost_verified": False,
|
|
124
|
+
"billing_cap_enforced": False,
|
|
125
|
+
"apply_authorized": False,
|
|
126
|
+
"deployment_ready": False,
|
|
127
|
+
"mutation_performed": False,
|
|
128
|
+
}
|
|
129
|
+
deadline.remaining()
|
|
130
|
+
result["receipt_digest"] = canonical_digest(result)
|
|
131
|
+
return result
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Monotonic remaining-budget checks for standalone deployment stage scheduling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DeploymentDeadline:
|
|
10
|
+
"""Keep one invocation budget; expiry cannot authorize another stage or ready receipt.
|
|
11
|
+
|
|
12
|
+
This scheduler guard does not interrupt a running command. Callers pass its current
|
|
13
|
+
remaining budget to their bounded process or transport and preserve its cleanup path.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, seconds: int, *, clock: Callable[[], float] | None = None) -> None:
|
|
17
|
+
if type(seconds) is not int or seconds <= 0:
|
|
18
|
+
raise ValueError("standalone deployment timeout must be a positive integer")
|
|
19
|
+
self._clock = clock or time.monotonic
|
|
20
|
+
self._expires = self._clock() + seconds
|
|
21
|
+
|
|
22
|
+
def remaining(self, maximum: int | None = None) -> int:
|
|
23
|
+
"""Return whole seconds left, capped by the stage limit, or stop on expiry."""
|
|
24
|
+
|
|
25
|
+
remaining = int(self._expires - self._clock())
|
|
26
|
+
if remaining <= 0:
|
|
27
|
+
raise TimeoutError("standalone deployment deadline has no remaining budget")
|
|
28
|
+
return remaining if maximum is None else min(maximum, remaining)
|