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,173 @@
|
|
|
1
|
+
"""Read bounded public Linux VM retail prices without Azure identity or mutation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from decimal import Decimal, InvalidOperation
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from fdai_deployment_cli.deployment_deadline import DeploymentDeadline
|
|
16
|
+
|
|
17
|
+
_ENDPOINT = "https://prices.azure.com/api/retail/prices"
|
|
18
|
+
_MAX_BYTES = 2 * 1024 * 1024
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
22
|
+
def redirect_request(self, *args: Any, **kwargs: Any) -> None:
|
|
23
|
+
raise ValueError("Azure retail price redirects are not supported")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def read_linux_vm_price(*, sku: str, region: str, timeout_seconds: int = 90) -> dict[str, object]:
|
|
27
|
+
"""Fetch one complete exact-SKU catalog and select a unique primary USD hourly rate.
|
|
28
|
+
|
|
29
|
+
At most four pages share one deadline. Redirects, foreign continuation URLs,
|
|
30
|
+
repeated pages, malformed responses and ambiguous prices fail without retry.
|
|
31
|
+
"""
|
|
32
|
+
if (
|
|
33
|
+
re.fullmatch(r"Standard_[A-Za-z0-9_]{1,80}", sku) is None
|
|
34
|
+
or re.fullmatch(r"[a-z][a-z0-9]{1,40}", region) is None
|
|
35
|
+
):
|
|
36
|
+
raise ValueError("Azure retail price selection is invalid")
|
|
37
|
+
deadline = DeploymentDeadline(timeout_seconds)
|
|
38
|
+
query = urllib.parse.urlencode(
|
|
39
|
+
{
|
|
40
|
+
"$filter": f"armSkuName eq '{sku}' and armRegionName eq '{region}' and serviceName eq 'Virtual Machines' and priceType eq 'Consumption'",
|
|
41
|
+
"currencyCode": "USD",
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
url = f"{_ENDPOINT}?{query}"
|
|
45
|
+
seen: set[str] = set()
|
|
46
|
+
rows: list[object] = []
|
|
47
|
+
evidence = hashlib.sha256()
|
|
48
|
+
opener = urllib.request.build_opener(_NoRedirect())
|
|
49
|
+
for _page in range(4):
|
|
50
|
+
parsed = urllib.parse.urlsplit(url)
|
|
51
|
+
if (
|
|
52
|
+
parsed.scheme != "https"
|
|
53
|
+
or parsed.netloc != "prices.azure.com"
|
|
54
|
+
or parsed.path != "/api/retail/prices"
|
|
55
|
+
or parsed.fragment
|
|
56
|
+
or url in seen
|
|
57
|
+
):
|
|
58
|
+
raise ValueError("Azure retail price continuation is invalid")
|
|
59
|
+
seen.add(url)
|
|
60
|
+
try:
|
|
61
|
+
with opener.open(url, timeout=deadline.remaining(30)) as response:
|
|
62
|
+
chunks = bytearray()
|
|
63
|
+
while len(chunks) <= _MAX_BYTES:
|
|
64
|
+
deadline.remaining()
|
|
65
|
+
chunk = response.read1(min(65536, _MAX_BYTES + 1 - len(chunks)))
|
|
66
|
+
if not chunk:
|
|
67
|
+
break
|
|
68
|
+
chunks.extend(chunk)
|
|
69
|
+
payload = bytes(chunks)
|
|
70
|
+
except (OSError, urllib.error.URLError):
|
|
71
|
+
raise ValueError(
|
|
72
|
+
"Azure retail price observation unavailable; no retry performed"
|
|
73
|
+
) from None
|
|
74
|
+
deadline.remaining()
|
|
75
|
+
if len(payload) > _MAX_BYTES:
|
|
76
|
+
raise ValueError("Azure retail price observation exceeds its bound")
|
|
77
|
+
evidence.update(hashlib.sha256(payload).digest())
|
|
78
|
+
page = json.loads(payload, parse_float=Decimal)
|
|
79
|
+
if (
|
|
80
|
+
not isinstance(page, dict)
|
|
81
|
+
or not isinstance(page.get("Items"), list)
|
|
82
|
+
or "NextPageLink" not in page
|
|
83
|
+
or len(page["Items"]) > 1000
|
|
84
|
+
or type(page.get("Count")) is not int
|
|
85
|
+
or page["Count"] != len(page["Items"])
|
|
86
|
+
):
|
|
87
|
+
raise ValueError("Azure retail price page is invalid")
|
|
88
|
+
rows.extend(page["Items"])
|
|
89
|
+
next_page = page.get("NextPageLink")
|
|
90
|
+
if next_page in (None, ""):
|
|
91
|
+
now = datetime.now(UTC)
|
|
92
|
+
result = select_linux_vm_price(rows, sku=sku, region=region, now=now)
|
|
93
|
+
deadline.remaining()
|
|
94
|
+
return {
|
|
95
|
+
**result,
|
|
96
|
+
"observed_at": now.isoformat(),
|
|
97
|
+
"catalog_digest": evidence.hexdigest(),
|
|
98
|
+
}
|
|
99
|
+
if not isinstance(next_page, str) or len(next_page) > 8192:
|
|
100
|
+
raise ValueError("Azure retail price continuation is invalid")
|
|
101
|
+
url = next_page
|
|
102
|
+
raise ValueError("Azure retail price catalog exceeds the page limit")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def select_linux_vm_price(
|
|
106
|
+
rows: list[object], *, sku: str, region: str, now: datetime
|
|
107
|
+
) -> dict[str, object]:
|
|
108
|
+
"""Select standard Linux consumption, never Windows, Spot, reservation or a foreign meter."""
|
|
109
|
+
if now.tzinfo is None or now.utcoffset() is None:
|
|
110
|
+
raise ValueError("Azure retail price selection requires an aware time")
|
|
111
|
+
matches = []
|
|
112
|
+
for row in rows:
|
|
113
|
+
if not isinstance(row, dict):
|
|
114
|
+
raise ValueError("Azure retail price row is invalid")
|
|
115
|
+
product, meter, sku_name = row.get("productName"), row.get("meterName"), row.get("skuName")
|
|
116
|
+
if (
|
|
117
|
+
not isinstance(product, str)
|
|
118
|
+
or not isinstance(meter, str)
|
|
119
|
+
or not isinstance(sku_name, str)
|
|
120
|
+
):
|
|
121
|
+
raise ValueError("Azure retail price labels are incomplete")
|
|
122
|
+
if (
|
|
123
|
+
row.get("armSkuName") != sku
|
|
124
|
+
or row.get("armRegionName") != region
|
|
125
|
+
or row.get("serviceName") != "Virtual Machines"
|
|
126
|
+
or row.get("type") != "Consumption"
|
|
127
|
+
or row.get("currencyCode") != "USD"
|
|
128
|
+
or row.get("unitOfMeasure") != "1 Hour"
|
|
129
|
+
or row.get("isPrimaryMeterRegion") is not True
|
|
130
|
+
or row.get("tierMinimumUnits") != 0
|
|
131
|
+
or type(row.get("tierMinimumUnits")) is bool
|
|
132
|
+
or not product.startswith("Virtual Machines ")
|
|
133
|
+
or any(
|
|
134
|
+
token in f"{product} {meter} {sku_name}".casefold()
|
|
135
|
+
for token in ("windows", "spot", "low priority")
|
|
136
|
+
)
|
|
137
|
+
):
|
|
138
|
+
continue
|
|
139
|
+
try:
|
|
140
|
+
effective = datetime.fromisoformat(
|
|
141
|
+
str(row.get("effectiveStartDate")).replace("Z", "+00:00")
|
|
142
|
+
)
|
|
143
|
+
raw_rate = row.get("retailPrice")
|
|
144
|
+
if isinstance(raw_rate, bool) or not isinstance(raw_rate, (int, float, Decimal)):
|
|
145
|
+
raise ValueError("invalid price")
|
|
146
|
+
rate = Decimal(str(raw_rate))
|
|
147
|
+
if (
|
|
148
|
+
not rate.is_finite()
|
|
149
|
+
or not Decimal(0) < rate <= Decimal(1000000)
|
|
150
|
+
or not -12 <= rate.adjusted() <= 6
|
|
151
|
+
or len(rate.as_tuple().digits) > 18
|
|
152
|
+
):
|
|
153
|
+
raise ValueError("invalid price")
|
|
154
|
+
except (ValueError, InvalidOperation):
|
|
155
|
+
raise ValueError("Azure retail price or effective time is invalid") from None
|
|
156
|
+
if effective.tzinfo is None:
|
|
157
|
+
raise ValueError("Azure retail price effective time is not timezone aware")
|
|
158
|
+
if effective > now:
|
|
159
|
+
continue
|
|
160
|
+
matches.append((rate, effective))
|
|
161
|
+
if len(matches) != 1:
|
|
162
|
+
raise ValueError("Azure Linux VM retail price is missing or ambiguous")
|
|
163
|
+
rate, effective = matches[0]
|
|
164
|
+
return {
|
|
165
|
+
"sku": sku,
|
|
166
|
+
"region": region,
|
|
167
|
+
"currency": "USD",
|
|
168
|
+
"unit": "hour",
|
|
169
|
+
"hourly_rate": format(rate, "f"),
|
|
170
|
+
"effective_at": effective.isoformat(),
|
|
171
|
+
"pricing_basis": "public-linux-consumption",
|
|
172
|
+
"billing_cap_enforced": False,
|
|
173
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Scoped approval-source injection; terminal execution remains the default."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Iterator
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from contextvars import ContextVar
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
ApprovalSource = Callable[[dict[str, object]], dict[str, object]]
|
|
12
|
+
_SOURCE: ContextVar[ApprovalSource | None] = ContextVar("bootstrap_approval_source", default=None)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@contextmanager
|
|
16
|
+
def approval_source(source: ApprovalSource) -> Iterator[None]:
|
|
17
|
+
"""Select a human decision adapter for this operation, without changing input()."""
|
|
18
|
+
token = _SOURCE.set(source)
|
|
19
|
+
try:
|
|
20
|
+
yield
|
|
21
|
+
finally:
|
|
22
|
+
_SOURCE.reset(token)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def selected_source() -> ApprovalSource | None:
|
|
26
|
+
return _SOURCE.get()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def require_decision(review: dict[str, object]) -> None:
|
|
30
|
+
"""Reject missing, denied, expired or differently bound adapter decisions."""
|
|
31
|
+
source = selected_source()
|
|
32
|
+
if source is None:
|
|
33
|
+
raise ValueError("No injected approval source is selected")
|
|
34
|
+
if datetime.fromisoformat(str(review["expires_at"])) <= datetime.now(UTC):
|
|
35
|
+
raise ValueError("Exact checkpoint review has expired")
|
|
36
|
+
decision = source(dict(review))
|
|
37
|
+
if (
|
|
38
|
+
not isinstance(decision, dict)
|
|
39
|
+
or decision.get("approved") is not True
|
|
40
|
+
or decision.get("digest") != review["digest"]
|
|
41
|
+
or decision.get("confirmation") != review["confirmation_required"]
|
|
42
|
+
or datetime.fromisoformat(str(review["expires_at"])) <= datetime.now(UTC)
|
|
43
|
+
):
|
|
44
|
+
raise ValueError("Exact checkpoint approval was denied, expired or mismatched")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def approve_application_checkpoint(
|
|
48
|
+
review: dict[str, Any],
|
|
49
|
+
stage: str,
|
|
50
|
+
destructive: int,
|
|
51
|
+
*,
|
|
52
|
+
actor_digest: Callable[[], str],
|
|
53
|
+
) -> tuple[str, str | None]:
|
|
54
|
+
"""Return the exact operation and pre-review actor; None keeps terminal approval."""
|
|
55
|
+
historical_reconciliation = review.get("historical_reconciliation")
|
|
56
|
+
residual_recovery = review.get("residual_recovery")
|
|
57
|
+
residual_successor = review.get("residual_successor")
|
|
58
|
+
operation = (
|
|
59
|
+
str(residual_successor["operation"])
|
|
60
|
+
if isinstance(residual_successor, dict)
|
|
61
|
+
else str(residual_recovery["operation"])
|
|
62
|
+
if isinstance(residual_recovery, dict)
|
|
63
|
+
else str(historical_reconciliation["operation"])
|
|
64
|
+
if isinstance(historical_reconciliation, dict)
|
|
65
|
+
else stage
|
|
66
|
+
)
|
|
67
|
+
if selected_source() is None:
|
|
68
|
+
return operation, None
|
|
69
|
+
actor_before = actor_digest()
|
|
70
|
+
expected = f"{operation}-apply"
|
|
71
|
+
require_decision(
|
|
72
|
+
{
|
|
73
|
+
"digest": str(review["review_digest"]),
|
|
74
|
+
"title": f"Approve exact {operation} plan",
|
|
75
|
+
"details": [
|
|
76
|
+
f"Plan digest: {review['plan_digest']}",
|
|
77
|
+
f"Source commit: {review['source_commit']}",
|
|
78
|
+
f"Delete or replacement actions: {destructive}",
|
|
79
|
+
"Action counts: "
|
|
80
|
+
+ ", ".join(
|
|
81
|
+
f"{action}={count}"
|
|
82
|
+
for action, count in sorted(review["summary"]["action_counts"].items())
|
|
83
|
+
),
|
|
84
|
+
*(
|
|
85
|
+
f"Resource type {name}: {count}"
|
|
86
|
+
for name, count in sorted(
|
|
87
|
+
review["summary"].get("resource_type_counts", {}).items()
|
|
88
|
+
)
|
|
89
|
+
),
|
|
90
|
+
*(
|
|
91
|
+
f"{','.join(change['actions'])}: {change['address']}"
|
|
92
|
+
for change in review["summary"].get("resource_changes", [])
|
|
93
|
+
),
|
|
94
|
+
],
|
|
95
|
+
"confirmation_required": f"{expected}-destructive" if destructive else expected,
|
|
96
|
+
"expires_at": str(review["expires_at"]),
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
return operation, actor_before
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""Private, bounded, atomic IPC for the customer-side first-install worker."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fcntl
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import stat
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
from uuid import UUID, uuid4
|
|
17
|
+
|
|
18
|
+
from fdai_deployment_cli.contracts import load_json_object
|
|
19
|
+
|
|
20
|
+
MAX_BYTES = 65536
|
|
21
|
+
STALE_SECONDS = 12
|
|
22
|
+
INSTALL_FIELDS = {
|
|
23
|
+
"subscription_id",
|
|
24
|
+
"region",
|
|
25
|
+
"monthly_cost_ceiling",
|
|
26
|
+
"artifact_source",
|
|
27
|
+
"source_root",
|
|
28
|
+
"hub_public_key",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def prepare_directory(root: Path) -> None:
|
|
33
|
+
"""Create an owner-only IPC root; refuse symlinks and unsafe existing paths."""
|
|
34
|
+
root = root.absolute()
|
|
35
|
+
for path in (*reversed(root.parents), root):
|
|
36
|
+
if path.is_symlink():
|
|
37
|
+
raise ValueError("Bootstrap directory must not contain symlinks")
|
|
38
|
+
root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
39
|
+
info = root.lstat()
|
|
40
|
+
if (
|
|
41
|
+
not stat.S_ISDIR(info.st_mode)
|
|
42
|
+
or info.st_uid != os.getuid()
|
|
43
|
+
or stat.S_IMODE(info.st_mode) != 0o700
|
|
44
|
+
):
|
|
45
|
+
raise ValueError("Bootstrap directory must be owned by this user with mode 0700")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _read(root: Path, name: str = "channel.json") -> dict[str, Any]:
|
|
49
|
+
path = root / name
|
|
50
|
+
try:
|
|
51
|
+
fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW)
|
|
52
|
+
except FileNotFoundError:
|
|
53
|
+
return {}
|
|
54
|
+
except OSError as exc:
|
|
55
|
+
raise ValueError("Bootstrap private file is unavailable or unsafe") from exc
|
|
56
|
+
with os.fdopen(fd, "rb") as stream:
|
|
57
|
+
info = os.fstat(stream.fileno())
|
|
58
|
+
if (
|
|
59
|
+
not stat.S_ISREG(info.st_mode)
|
|
60
|
+
or info.st_uid != os.getuid()
|
|
61
|
+
or stat.S_IMODE(info.st_mode) != 0o600
|
|
62
|
+
or info.st_nlink != 1
|
|
63
|
+
):
|
|
64
|
+
raise ValueError("Bootstrap private files require mode 0600 and one owner")
|
|
65
|
+
raw = stream.read(MAX_BYTES + 1)
|
|
66
|
+
try:
|
|
67
|
+
value = load_json_object(raw, label="Bootstrap state", max_bytes=MAX_BYTES, strict=True)
|
|
68
|
+
except (ValueError, UnicodeError) as exc:
|
|
69
|
+
raise ValueError("Bootstrap state is malformed; preserve the private directory") from exc
|
|
70
|
+
if len(raw) > MAX_BYTES or not isinstance(value, dict):
|
|
71
|
+
raise ValueError("Bootstrap state exceeds its bounded object schema")
|
|
72
|
+
return value
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _write(root: Path, value: dict[str, object], name: str = "channel.json") -> None:
|
|
76
|
+
raw = json.dumps(value, separators=(",", ":"), allow_nan=False).encode()
|
|
77
|
+
if len(raw) > MAX_BYTES:
|
|
78
|
+
raise ValueError("Bootstrap state exceeds its size bound")
|
|
79
|
+
target = root / name
|
|
80
|
+
if target.is_symlink():
|
|
81
|
+
raise ValueError("Bootstrap private files must not be symlinks")
|
|
82
|
+
staging = root / f".write-{uuid4().hex}"
|
|
83
|
+
fd = os.open(staging, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
|
84
|
+
try:
|
|
85
|
+
with os.fdopen(fd, "wb") as stream:
|
|
86
|
+
stream.write(raw)
|
|
87
|
+
stream.flush()
|
|
88
|
+
os.fsync(stream.fileno())
|
|
89
|
+
os.replace(staging, target)
|
|
90
|
+
directory = os.open(root, os.O_RDONLY | os.O_DIRECTORY)
|
|
91
|
+
try:
|
|
92
|
+
os.fsync(directory)
|
|
93
|
+
finally:
|
|
94
|
+
os.close(directory)
|
|
95
|
+
finally:
|
|
96
|
+
staging.unlink(missing_ok=True)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@contextmanager
|
|
100
|
+
def locked(root: Path) -> Iterator[None]:
|
|
101
|
+
prepare_directory(root)
|
|
102
|
+
fd = os.open(root / "channel.lock", os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
|
103
|
+
try:
|
|
104
|
+
info = os.fstat(fd)
|
|
105
|
+
if (
|
|
106
|
+
not stat.S_ISREG(info.st_mode)
|
|
107
|
+
or stat.S_IMODE(info.st_mode) != 0o600
|
|
108
|
+
or info.st_uid != os.getuid()
|
|
109
|
+
or info.st_nlink != 1
|
|
110
|
+
):
|
|
111
|
+
raise ValueError("Bootstrap lock is unsafe")
|
|
112
|
+
deadline = time.monotonic() + 5
|
|
113
|
+
while True:
|
|
114
|
+
try:
|
|
115
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
116
|
+
break
|
|
117
|
+
except BlockingIOError as exc:
|
|
118
|
+
if time.monotonic() >= deadline:
|
|
119
|
+
raise ValueError(
|
|
120
|
+
"Bootstrap state is busy; wait for the current private update."
|
|
121
|
+
) from exc
|
|
122
|
+
time.sleep(0.01)
|
|
123
|
+
yield
|
|
124
|
+
finally:
|
|
125
|
+
os.close(fd)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def initial_status() -> dict[str, object]:
|
|
129
|
+
return {
|
|
130
|
+
"phase": "idle",
|
|
131
|
+
"request_id": None,
|
|
132
|
+
"action": None,
|
|
133
|
+
"message": "Sign in to Azure to begin first installation.",
|
|
134
|
+
"error_code": None,
|
|
135
|
+
"accounts": [],
|
|
136
|
+
"review": None,
|
|
137
|
+
"deployment_ready": False,
|
|
138
|
+
"console_url": None,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _status(record: dict[str, object]) -> dict[str, object]:
|
|
143
|
+
stored = record.get("status")
|
|
144
|
+
result = initial_status()
|
|
145
|
+
if isinstance(stored, dict):
|
|
146
|
+
result.update({key: stored[key] for key in result if key in stored})
|
|
147
|
+
if record.get("active_request_id") is not None and result["phase"] in {
|
|
148
|
+
"idle",
|
|
149
|
+
"ready",
|
|
150
|
+
"blocked",
|
|
151
|
+
"failed",
|
|
152
|
+
}:
|
|
153
|
+
result.update(
|
|
154
|
+
phase="working",
|
|
155
|
+
deployment_ready=False,
|
|
156
|
+
console_url=None,
|
|
157
|
+
message="Finalizing local executor cleanup before accepting another request.",
|
|
158
|
+
)
|
|
159
|
+
heartbeat = record.get("heartbeat")
|
|
160
|
+
if result["phase"] in {"working", "review"} and (
|
|
161
|
+
not isinstance(heartbeat, (int, float)) or time.time() - heartbeat > STALE_SECONDS
|
|
162
|
+
):
|
|
163
|
+
result.update(
|
|
164
|
+
phase="interrupted",
|
|
165
|
+
review=None,
|
|
166
|
+
deployment_ready=False,
|
|
167
|
+
console_url=None,
|
|
168
|
+
error_code="worker_interrupted",
|
|
169
|
+
message="Worker interrupted. Inspect retained state before explicitly resuming.",
|
|
170
|
+
)
|
|
171
|
+
if result["phase"] == "ready" and result["deployment_ready"] is not True:
|
|
172
|
+
result.update(phase="blocked", message="Independent deployment acceptance is unavailable.")
|
|
173
|
+
return result
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def read_status(root: Path) -> dict[str, object]:
|
|
177
|
+
"""Read only allowlisted setup metadata, never engine output or Azure credentials."""
|
|
178
|
+
with locked(root):
|
|
179
|
+
return _status(_read(root))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def validate_request(action: str, payload: dict[str, object]) -> dict[str, object]:
|
|
183
|
+
if (
|
|
184
|
+
not isinstance(action, str)
|
|
185
|
+
or action not in {"login", "accounts", "install", "resume", "inspect"}
|
|
186
|
+
or not isinstance(payload, dict)
|
|
187
|
+
):
|
|
188
|
+
raise ValueError("Unsupported Bootstrap action or payload")
|
|
189
|
+
try:
|
|
190
|
+
encoded = json.dumps(payload, allow_nan=False).encode()
|
|
191
|
+
except (ValueError, TypeError, RecursionError) as exc:
|
|
192
|
+
raise ValueError("Bootstrap payload must be a bounded JSON object") from exc
|
|
193
|
+
if len(encoded) > 16384:
|
|
194
|
+
raise ValueError("Bootstrap payload exceeds its size bound")
|
|
195
|
+
if action != "install":
|
|
196
|
+
if payload:
|
|
197
|
+
raise ValueError("This action accepts no replacement inputs")
|
|
198
|
+
return {}
|
|
199
|
+
if set(payload) - INSTALL_FIELDS:
|
|
200
|
+
raise ValueError("Unknown installation fields; use only the documented setup inputs")
|
|
201
|
+
subscription = payload.get("subscription_id")
|
|
202
|
+
try:
|
|
203
|
+
if not isinstance(subscription, str) or str(UUID(subscription)) != subscription.lower():
|
|
204
|
+
raise ValueError
|
|
205
|
+
except ValueError as exc:
|
|
206
|
+
raise ValueError("Select one valid Azure subscription UUID") from exc
|
|
207
|
+
region, ceiling = payload.get("region"), payload.get("monthly_cost_ceiling")
|
|
208
|
+
if not isinstance(region, str) or re.fullmatch(r"[a-z][a-z0-9]{1,31}", region) is None:
|
|
209
|
+
raise ValueError("Choose a valid Azure region token")
|
|
210
|
+
if type(ceiling) is not int or ceiling <= 0 or ceiling > 1_000_000_000:
|
|
211
|
+
raise ValueError("Monthly cost ceiling must be a positive bounded integer")
|
|
212
|
+
result = {**payload, "artifact_source": payload.get("artifact_source", "hub")}
|
|
213
|
+
if not isinstance(result["artifact_source"], str) or result["artifact_source"] not in {
|
|
214
|
+
"hub",
|
|
215
|
+
"source",
|
|
216
|
+
}:
|
|
217
|
+
raise ValueError("Artifact source must be hub or source")
|
|
218
|
+
source = payload.get("source_root")
|
|
219
|
+
if source is not None and (
|
|
220
|
+
not isinstance(source, str) or not source or len(source) > 4096 or "\0" in source
|
|
221
|
+
):
|
|
222
|
+
raise ValueError("Source root must be a local checkout path")
|
|
223
|
+
key = payload.get("hub_public_key")
|
|
224
|
+
if result["artifact_source"] == "source":
|
|
225
|
+
if not source or key is not None:
|
|
226
|
+
raise ValueError("Source mode requires source_root and does not accept a Hub key")
|
|
227
|
+
else:
|
|
228
|
+
if not isinstance(key, str) or len(key) > 4096:
|
|
229
|
+
raise ValueError("Hub mode requires an independently supplied Ed25519 public PEM")
|
|
230
|
+
from fdai_deployment_cli.hub_image_trust import public_key
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
public_key(key.encode())
|
|
234
|
+
except (ValueError, TypeError) as exc:
|
|
235
|
+
raise ValueError(
|
|
236
|
+
"Hub trust requires an Ed25519 public PEM, never a private key"
|
|
237
|
+
) from exc
|
|
238
|
+
return result
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def submit_request(root: Path, action: str, payload: dict[str, object]) -> dict[str, object]:
|
|
242
|
+
payload = validate_request(action, payload)
|
|
243
|
+
with locked(root):
|
|
244
|
+
record = _read(root)
|
|
245
|
+
status = _status(record)
|
|
246
|
+
stored_status = record.get("status", {})
|
|
247
|
+
if (
|
|
248
|
+
record.get("pending") is not None
|
|
249
|
+
or record.get("active_request_id") is not None
|
|
250
|
+
or stored_status.get("phase") in {"working", "review"}
|
|
251
|
+
):
|
|
252
|
+
raise ValueError(
|
|
253
|
+
"A Bootstrap request is already pending or running. "
|
|
254
|
+
"If its worker is interrupted, restart setup before explicit resume."
|
|
255
|
+
)
|
|
256
|
+
if status["phase"] == "interrupted" and action not in {
|
|
257
|
+
"inspect",
|
|
258
|
+
"resume",
|
|
259
|
+
"login",
|
|
260
|
+
"accounts",
|
|
261
|
+
}:
|
|
262
|
+
raise ValueError(
|
|
263
|
+
"Inspect or resume the interrupted installation before starting other work"
|
|
264
|
+
)
|
|
265
|
+
request_id = str(uuid4())
|
|
266
|
+
status.update(
|
|
267
|
+
phase="working",
|
|
268
|
+
request_id=request_id,
|
|
269
|
+
action=action,
|
|
270
|
+
review=None,
|
|
271
|
+
error_code=None,
|
|
272
|
+
message="Request queued for the local customer executor.",
|
|
273
|
+
deployment_ready=False,
|
|
274
|
+
console_url=None,
|
|
275
|
+
)
|
|
276
|
+
record.update(
|
|
277
|
+
status=status,
|
|
278
|
+
heartbeat=time.time(),
|
|
279
|
+
decision=None,
|
|
280
|
+
pending={"id": request_id, "action": action, "payload": payload},
|
|
281
|
+
)
|
|
282
|
+
_write(root, record)
|
|
283
|
+
return dict(status)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def submit_decision(root: Path, payload: dict[str, object]) -> dict[str, object]:
|
|
287
|
+
fields = {"request_id", "review_id", "digest", "approved", "confirmation"}
|
|
288
|
+
if (
|
|
289
|
+
not isinstance(payload, dict)
|
|
290
|
+
or set(payload) != fields
|
|
291
|
+
or type(payload.get("approved")) is not bool
|
|
292
|
+
or any(not isinstance(payload.get(key), str) for key in fields - {"approved"})
|
|
293
|
+
):
|
|
294
|
+
raise ValueError(
|
|
295
|
+
"Decision requires exact request, review, digest, approval and confirmation"
|
|
296
|
+
)
|
|
297
|
+
with locked(root):
|
|
298
|
+
record = _read(root)
|
|
299
|
+
status = _status(record)
|
|
300
|
+
review = status.get("review")
|
|
301
|
+
if (
|
|
302
|
+
status["phase"] != "review"
|
|
303
|
+
or not isinstance(review, dict)
|
|
304
|
+
or record.get("decision") is not None
|
|
305
|
+
or payload["request_id"] != status["request_id"]
|
|
306
|
+
or payload["review_id"] != review["id"]
|
|
307
|
+
or payload["digest"] != review["digest"]
|
|
308
|
+
):
|
|
309
|
+
raise ValueError("Decision does not match the current pending review")
|
|
310
|
+
expires = datetime.fromisoformat(str(review["expires_at"]))
|
|
311
|
+
if expires <= datetime.now(UTC):
|
|
312
|
+
raise ValueError("Review expired; explicitly resume to obtain a fresh exact plan")
|
|
313
|
+
if payload["approved"] and payload["confirmation"] != review["confirmation_required"]:
|
|
314
|
+
raise ValueError("Type the exact confirmation shown for this checkpoint")
|
|
315
|
+
record["decision"] = payload
|
|
316
|
+
status.update(
|
|
317
|
+
phase="working", review=None, message="Decision submitted to the exact checkpoint."
|
|
318
|
+
)
|
|
319
|
+
record["status"] = status
|
|
320
|
+
_write(root, record)
|
|
321
|
+
return dict(status)
|