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,66 @@
|
|
|
1
|
+
"""Drop Kubernetes defaults that the API server or provider may write explicitly.
|
|
2
|
+
|
|
3
|
+
Each field is dropped only while it holds its default, so a non-default value, such as
|
|
4
|
+
``privileged: true``, still differs from a protected template.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import copy
|
|
10
|
+
from collections.abc import Mapping, Sequence
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
_CONTAINER_SECURITY: Mapping[str, object] = {"privileged": False, "readOnlyRootFilesystem": False}
|
|
14
|
+
_VOLUME_MOUNT: Mapping[str, object] = {"mountPropagation": "None"}
|
|
15
|
+
_KEY_REFERENCE: Mapping[str, object] = {"optional": False}
|
|
16
|
+
_CSI_VOLUME: Mapping[str, object] = {"fsType": ""}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _without(value: Mapping[str, Any], defaults: Mapping[str, object]) -> dict[str, Any]:
|
|
20
|
+
return {
|
|
21
|
+
key: item
|
|
22
|
+
for key, item in value.items()
|
|
23
|
+
if not (key in defaults and type(item) is type(defaults[key]) and item == defaults[key])
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def container_security_context(value: object) -> dict[str, Any] | None:
|
|
28
|
+
"""Return a copied container security context without default-valued flags."""
|
|
29
|
+
|
|
30
|
+
return None if value is None else _without(copy.deepcopy(_mapping(value)), _CONTAINER_SECURITY)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def volume_mounts(values: Sequence[object]) -> list[dict[str, Any]]:
|
|
34
|
+
"""Return volume mounts without the default mount propagation."""
|
|
35
|
+
|
|
36
|
+
return [_without(_mapping(value), _VOLUME_MOUNT) for value in values]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def environment_entry(entry: dict[str, Any]) -> dict[str, Any]:
|
|
40
|
+
"""Return an environment entry without an empty value or key-reference ``optional: false``."""
|
|
41
|
+
|
|
42
|
+
if "valueFrom" not in entry and entry.get("value") == "":
|
|
43
|
+
# The API server omits an empty value, which Kubernetes reads as the empty string.
|
|
44
|
+
del entry["value"]
|
|
45
|
+
value_from = entry.get("valueFrom")
|
|
46
|
+
if isinstance(value_from, dict):
|
|
47
|
+
for reference in ("secretKeyRef", "configMapKeyRef"):
|
|
48
|
+
if isinstance(value_from.get(reference), dict):
|
|
49
|
+
value_from[reference] = _without(value_from[reference], _KEY_REFERENCE)
|
|
50
|
+
return entry
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def volumes(values: Sequence[object]) -> list[dict[str, Any]]:
|
|
54
|
+
"""Return volumes whose CSI sources omit the empty default filesystem type."""
|
|
55
|
+
|
|
56
|
+
result = [copy.deepcopy(_mapping(value)) for value in values]
|
|
57
|
+
for volume in result:
|
|
58
|
+
if isinstance(volume.get("csi"), dict):
|
|
59
|
+
volume["csi"] = _without(volume["csi"], _CSI_VOLUME)
|
|
60
|
+
return result
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _mapping(value: object) -> dict[str, Any]:
|
|
64
|
+
if not isinstance(value, Mapping) or any(not isinstance(key, str) for key in value):
|
|
65
|
+
raise ValueError("Kubernetes object entry MUST be a mapping")
|
|
66
|
+
return dict(value)
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"""Bounded read-only AKS feasibility checks; no provider registration or resource mutation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import re
|
|
8
|
+
import subprocess
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from typing import Any, cast
|
|
12
|
+
|
|
13
|
+
from fdai_deployment_cli.contracts import canonical_digest
|
|
14
|
+
from fdai_deployment_cli.deployment_deadline import DeploymentDeadline
|
|
15
|
+
from fdai_deployment_cli.runtime_profile import RuntimeDeploymentProfile
|
|
16
|
+
from fdai_deployment_cli.target import (
|
|
17
|
+
azure_target_selection_arguments,
|
|
18
|
+
compute_target_binding,
|
|
19
|
+
require_selected_azure_target,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# Major version created by infra/modules/state-store/postgres-flex; a parity test keeps them equal.
|
|
23
|
+
POSTGRES_FLEX_MAJOR_VERSION = "16"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _active_human_target(deadline: DeploymentDeadline) -> tuple[str, str]:
|
|
27
|
+
account = _json(
|
|
28
|
+
(
|
|
29
|
+
"account",
|
|
30
|
+
"show",
|
|
31
|
+
*azure_target_selection_arguments(),
|
|
32
|
+
"--query",
|
|
33
|
+
"{id:id,tenantId:tenantId,state:state,userType:user.type}",
|
|
34
|
+
),
|
|
35
|
+
deadline,
|
|
36
|
+
)
|
|
37
|
+
if (
|
|
38
|
+
not isinstance(account, dict)
|
|
39
|
+
or account.get("state") != "Enabled"
|
|
40
|
+
or account.get("userType") != "user"
|
|
41
|
+
):
|
|
42
|
+
raise ValueError("AKS preflight requires an enabled subscription and active human identity")
|
|
43
|
+
subscription, tenant = account.get("id"), account.get("tenantId")
|
|
44
|
+
if not isinstance(subscription, str) or not isinstance(tenant, str):
|
|
45
|
+
raise ValueError("AKS preflight target binding is incomplete") # noqa: TRY004
|
|
46
|
+
require_selected_azure_target(subscription_id=subscription, tenant_id=tenant)
|
|
47
|
+
return subscription, tenant
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def current_target_binding(*, timeout_seconds: int = 60) -> str:
|
|
51
|
+
"""Bind the active human Azure target before any provider read, without retaining either id."""
|
|
52
|
+
|
|
53
|
+
subscription, tenant = _active_human_target(DeploymentDeadline(timeout_seconds))
|
|
54
|
+
return compute_target_binding(subscription_id=subscription, tenant_id=tenant)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def inspect_aks_target(
|
|
58
|
+
*,
|
|
59
|
+
profile: RuntimeDeploymentProfile,
|
|
60
|
+
region: str,
|
|
61
|
+
timeout_seconds: int = 180,
|
|
62
|
+
owned_resource_group: str | None = None,
|
|
63
|
+
) -> dict[str, object]:
|
|
64
|
+
"""Inspect the current human target once, returning sanitized feasibility evidence.
|
|
65
|
+
|
|
66
|
+
Quota covers both pools at autoscaler maximum plus simultaneous 33-percent surge,
|
|
67
|
+
not Foundation or other future resources. When a resumed installation already owns AKS
|
|
68
|
+
pools in ``owned_resource_group``, the vCPUs those pools use are not new demand and are
|
|
69
|
+
credited back; an unreadable group earns no credit. A ``postgres-flex`` profile also requires the
|
|
70
|
+
regional Flexible Server catalog to offer the deployed major version. The report never
|
|
71
|
+
claims full readiness, reserves capacity, logs target IDs, or retries a failed provider call.
|
|
72
|
+
"""
|
|
73
|
+
if profile.runtime_platform.value != "aks" or re.fullmatch(r"[a-z][a-z0-9]+", region) is None:
|
|
74
|
+
raise ValueError("AKS preflight requires an AKS profile and valid region")
|
|
75
|
+
deadline = DeploymentDeadline(timeout_seconds)
|
|
76
|
+
subscription, tenant = _active_human_target(deadline)
|
|
77
|
+
target_binding = compute_target_binding(subscription_id=subscription, tenant_id=tenant)
|
|
78
|
+
skus = _selected_skus(
|
|
79
|
+
subscription=subscription,
|
|
80
|
+
region=region,
|
|
81
|
+
names={profile.system_node_sku, profile.user_node_sku},
|
|
82
|
+
deadline=deadline,
|
|
83
|
+
)
|
|
84
|
+
usage = _json(
|
|
85
|
+
("vm", "list-usage", "--subscription", subscription, "--location", region), deadline
|
|
86
|
+
)
|
|
87
|
+
owned = (
|
|
88
|
+
_owned_pool_vcpus(subscription, owned_resource_group, skus, deadline)
|
|
89
|
+
if owned_resource_group is not None
|
|
90
|
+
else {}
|
|
91
|
+
)
|
|
92
|
+
assessment = assess_aks_capacity(
|
|
93
|
+
profile=profile, region=region, skus=skus, usage=usage, owned_vcpus=owned
|
|
94
|
+
)
|
|
95
|
+
if profile.database_placement.value == "postgres-flex":
|
|
96
|
+
capabilities = _json(
|
|
97
|
+
(
|
|
98
|
+
"postgres",
|
|
99
|
+
"flexible-server",
|
|
100
|
+
"list-skus",
|
|
101
|
+
"--subscription",
|
|
102
|
+
subscription,
|
|
103
|
+
"--location",
|
|
104
|
+
region,
|
|
105
|
+
),
|
|
106
|
+
deadline,
|
|
107
|
+
)
|
|
108
|
+
database = assess_postgres_flex_region(capabilities)
|
|
109
|
+
blockers = sorted(
|
|
110
|
+
{str(item) for item in cast(list[object], assessment["blockers"])}
|
|
111
|
+
| {str(item) for item in cast(list[object], database["blockers"])}
|
|
112
|
+
)
|
|
113
|
+
assessment = {
|
|
114
|
+
**assessment,
|
|
115
|
+
"state": "blocked" if blockers else "feasible",
|
|
116
|
+
"blockers": blockers,
|
|
117
|
+
"database": database,
|
|
118
|
+
}
|
|
119
|
+
result = {
|
|
120
|
+
"schema_version": "fdai.aks-capacity-preflight.v1",
|
|
121
|
+
"target_binding": target_binding,
|
|
122
|
+
"runtime_profile_digest": profile.digest,
|
|
123
|
+
"region": region,
|
|
124
|
+
"observed_at": datetime.now(UTC).isoformat(),
|
|
125
|
+
**assessment,
|
|
126
|
+
"deployment_ready": False,
|
|
127
|
+
"apply_authorized": False,
|
|
128
|
+
"mutation_performed": False,
|
|
129
|
+
}
|
|
130
|
+
deadline.remaining()
|
|
131
|
+
result["receipt_digest"] = canonical_digest(result)
|
|
132
|
+
return result
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _selected_skus(
|
|
136
|
+
*,
|
|
137
|
+
subscription: str,
|
|
138
|
+
region: str,
|
|
139
|
+
names: set[str],
|
|
140
|
+
deadline: DeploymentDeadline,
|
|
141
|
+
) -> list[object]:
|
|
142
|
+
"""Read the regional catalog once while serializing only exact selected SKUs."""
|
|
143
|
+
|
|
144
|
+
ordered = sorted(names)
|
|
145
|
+
if not ordered or any(
|
|
146
|
+
re.fullmatch(r"Standard_[A-Za-z0-9_]{1,80}", name) is None for name in ordered
|
|
147
|
+
):
|
|
148
|
+
raise ValueError("AKS selected SKU names are invalid")
|
|
149
|
+
query = "[?" + " || ".join(f"name=='{name}'" for name in ordered) + "]"
|
|
150
|
+
rows = _json(
|
|
151
|
+
(
|
|
152
|
+
"vm",
|
|
153
|
+
"list-skus",
|
|
154
|
+
"--subscription",
|
|
155
|
+
subscription,
|
|
156
|
+
"--location",
|
|
157
|
+
region,
|
|
158
|
+
"--resource-type",
|
|
159
|
+
"virtualMachines",
|
|
160
|
+
"--all",
|
|
161
|
+
"--query",
|
|
162
|
+
query,
|
|
163
|
+
),
|
|
164
|
+
deadline,
|
|
165
|
+
)
|
|
166
|
+
if not isinstance(rows, list):
|
|
167
|
+
raise ValueError("AKS selected SKU observation must be a list") # noqa: TRY004
|
|
168
|
+
return rows
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def assess_aks_capacity(
|
|
172
|
+
*,
|
|
173
|
+
profile: RuntimeDeploymentProfile,
|
|
174
|
+
region: str,
|
|
175
|
+
skus: object,
|
|
176
|
+
usage: object,
|
|
177
|
+
owned_vcpus: Mapping[str, int] | None = None,
|
|
178
|
+
) -> dict[str, object]:
|
|
179
|
+
"""Evaluate complete provider observations and report missing evidence as a blocker.
|
|
180
|
+
|
|
181
|
+
``owned_vcpus`` maps a lower-case quota family to vCPUs already consumed by this
|
|
182
|
+
installation's own pools, so a resumed run does not count them as new demand."""
|
|
183
|
+
if not isinstance(skus, list) or not isinstance(usage, list):
|
|
184
|
+
raise ValueError("AKS feasibility observations must be lists") # noqa: TRY004
|
|
185
|
+
blockers: list[str] = []
|
|
186
|
+
demand: dict[str, int] = {}
|
|
187
|
+
pools: list[dict[str, object]] = []
|
|
188
|
+
for role, name, nodes in (
|
|
189
|
+
("system", profile.system_node_sku, profile.system_node_count),
|
|
190
|
+
("user", profile.user_node_sku, profile.user_node_max_count),
|
|
191
|
+
):
|
|
192
|
+
matches = [
|
|
193
|
+
sku
|
|
194
|
+
for sku in skus
|
|
195
|
+
if isinstance(sku, dict)
|
|
196
|
+
and sku.get("name") == name
|
|
197
|
+
and sku.get("resourceType") == "virtualMachines"
|
|
198
|
+
]
|
|
199
|
+
if len(matches) != 1:
|
|
200
|
+
blockers.append(f"{role}_sku_missing_or_ambiguous")
|
|
201
|
+
continue
|
|
202
|
+
sku = matches[0]
|
|
203
|
+
family = sku.get("family")
|
|
204
|
+
capabilities = sku.get("capabilities")
|
|
205
|
+
locations = sku.get("locationInfo")
|
|
206
|
+
if (
|
|
207
|
+
not isinstance(family, str)
|
|
208
|
+
or not isinstance(capabilities, list)
|
|
209
|
+
or not isinstance(locations, list)
|
|
210
|
+
):
|
|
211
|
+
blockers.append(f"{role}_sku_evidence_incomplete")
|
|
212
|
+
continue
|
|
213
|
+
values = {
|
|
214
|
+
entry.get("name"): entry.get("value")
|
|
215
|
+
for entry in capabilities
|
|
216
|
+
if isinstance(entry, dict)
|
|
217
|
+
}
|
|
218
|
+
try:
|
|
219
|
+
cores = int(str(values.get("vCPUs")))
|
|
220
|
+
memory = float(str(values.get("MemoryGB")))
|
|
221
|
+
except (TypeError, ValueError):
|
|
222
|
+
blockers.append(f"{role}_sku_capacity_unknown")
|
|
223
|
+
continue
|
|
224
|
+
if cores <= 0 or not math.isfinite(memory) or memory <= 0:
|
|
225
|
+
blockers.append(f"{role}_sku_capacity_invalid")
|
|
226
|
+
continue
|
|
227
|
+
if role == "system" and (cores < 4 or memory < 4 or name.startswith("Standard_B")):
|
|
228
|
+
blockers.append("system_pool_minimum_not_met")
|
|
229
|
+
if str(values.get("EncryptionAtHostSupported", "")).casefold() != "true":
|
|
230
|
+
blockers.append(f"{role}_host_encryption_unavailable")
|
|
231
|
+
if str(values.get("CpuArchitectureType", "")).casefold() != "x64":
|
|
232
|
+
blockers.append(f"{role}_architecture_not_x64")
|
|
233
|
+
regional = [
|
|
234
|
+
entry
|
|
235
|
+
for entry in locations
|
|
236
|
+
if isinstance(entry, dict)
|
|
237
|
+
and str(entry.get("location", "")).casefold() == region.casefold()
|
|
238
|
+
]
|
|
239
|
+
if (
|
|
240
|
+
len(regional) != 1
|
|
241
|
+
or not isinstance(regional[0].get("zones"), list)
|
|
242
|
+
or not {"1", "2", "3"}.issubset(regional[0]["zones"])
|
|
243
|
+
):
|
|
244
|
+
blockers.append(f"{role}_required_zones_unavailable")
|
|
245
|
+
restrictions = sku.get("restrictions")
|
|
246
|
+
if not isinstance(restrictions, list) or restrictions:
|
|
247
|
+
blockers.append(f"{role}_sku_restricted_or_unknown")
|
|
248
|
+
surge = math.ceil(nodes * 33 / 100)
|
|
249
|
+
required = (nodes + surge) * cores
|
|
250
|
+
demand[family.casefold()] = demand.get(family.casefold(), 0) + required
|
|
251
|
+
pools.append(
|
|
252
|
+
{
|
|
253
|
+
"pool": role,
|
|
254
|
+
"sku": name,
|
|
255
|
+
"nodes": nodes,
|
|
256
|
+
"surge_nodes": surge,
|
|
257
|
+
"required_vcpus": required,
|
|
258
|
+
}
|
|
259
|
+
)
|
|
260
|
+
if len(pools) == 2:
|
|
261
|
+
demand["cores"] = sum(demand.values())
|
|
262
|
+
quotas = []
|
|
263
|
+
for family, required in sorted(demand.items()):
|
|
264
|
+
matches = [
|
|
265
|
+
entry
|
|
266
|
+
for entry in usage
|
|
267
|
+
if isinstance(entry, dict)
|
|
268
|
+
and isinstance(entry.get("name"), dict)
|
|
269
|
+
and str(entry["name"].get("value", "")).casefold() == family
|
|
270
|
+
]
|
|
271
|
+
if len(matches) != 1:
|
|
272
|
+
blockers.append(f"quota_{family}_missing_or_ambiguous")
|
|
273
|
+
continue
|
|
274
|
+
current = _quota_integer(matches[0].get("currentValue"))
|
|
275
|
+
limit = _quota_integer(matches[0].get("limit"))
|
|
276
|
+
if current is None or limit is None:
|
|
277
|
+
blockers.append(f"quota_{family}_invalid")
|
|
278
|
+
continue
|
|
279
|
+
remaining = limit - current + (owned_vcpus or {}).get(family, 0)
|
|
280
|
+
quotas.append({"family": family, "required_vcpus": required, "remaining_vcpus": remaining})
|
|
281
|
+
if required > remaining:
|
|
282
|
+
blockers.append(f"quota_{family}_insufficient")
|
|
283
|
+
return {
|
|
284
|
+
"state": "blocked" if blockers else "feasible",
|
|
285
|
+
"blockers": sorted(set(blockers)),
|
|
286
|
+
"pools": pools,
|
|
287
|
+
"quotas": quotas,
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def assess_postgres_flex_region(
|
|
292
|
+
observation: object,
|
|
293
|
+
*,
|
|
294
|
+
required_version: str = POSTGRES_FLEX_MAJOR_VERSION,
|
|
295
|
+
) -> dict[str, object]:
|
|
296
|
+
"""Require the regional Flexible Server catalog to offer the deployed major version.
|
|
297
|
+
|
|
298
|
+
A subscription restricted from a region receives an empty version list and a reason
|
|
299
|
+
instead of an error, so the server create fails only after the Foundation and part of
|
|
300
|
+
the substrate exist. Missing or malformed evidence blocks like an unavailable version.
|
|
301
|
+
"""
|
|
302
|
+
evidence: dict[str, object] = {
|
|
303
|
+
"placement": "postgres-flex",
|
|
304
|
+
"required_version": required_version,
|
|
305
|
+
"offered_versions": [],
|
|
306
|
+
}
|
|
307
|
+
if (
|
|
308
|
+
not isinstance(observation, list)
|
|
309
|
+
or not observation
|
|
310
|
+
or not all(isinstance(entry, dict) for entry in observation)
|
|
311
|
+
):
|
|
312
|
+
return {**evidence, "blockers": ["postgres_flex_evidence_incomplete"]}
|
|
313
|
+
offered: set[str] = set()
|
|
314
|
+
restricted = False
|
|
315
|
+
for capability in observation:
|
|
316
|
+
versions = capability.get("supportedServerVersions")
|
|
317
|
+
if not isinstance(versions, list):
|
|
318
|
+
return {**evidence, "blockers": ["postgres_flex_evidence_incomplete"]}
|
|
319
|
+
offered.update(
|
|
320
|
+
entry["name"]
|
|
321
|
+
for entry in versions
|
|
322
|
+
if isinstance(entry, dict) and isinstance(entry.get("name"), str)
|
|
323
|
+
)
|
|
324
|
+
reason = capability.get("reason")
|
|
325
|
+
restricted = restricted or (isinstance(reason, str) and bool(reason.strip()))
|
|
326
|
+
blockers: list[str] = []
|
|
327
|
+
if required_version not in offered:
|
|
328
|
+
blockers.append(
|
|
329
|
+
"postgres_flex_region_restricted"
|
|
330
|
+
if restricted and not offered
|
|
331
|
+
else "postgres_flex_version_unavailable"
|
|
332
|
+
)
|
|
333
|
+
return {**evidence, "offered_versions": sorted(offered), "blockers": blockers}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _owned_pool_vcpus(
|
|
337
|
+
subscription: str,
|
|
338
|
+
resource_group: str,
|
|
339
|
+
skus: object,
|
|
340
|
+
deadline: DeploymentDeadline,
|
|
341
|
+
) -> dict[str, int]:
|
|
342
|
+
"""Return vCPUs this installation's existing AKS pools already use, by quota family."""
|
|
343
|
+
|
|
344
|
+
if re.fullmatch(r"[A-Za-z0-9._()-]{1,90}", resource_group) is None or not isinstance(
|
|
345
|
+
skus, list
|
|
346
|
+
):
|
|
347
|
+
return {}
|
|
348
|
+
try:
|
|
349
|
+
pools = _json(
|
|
350
|
+
(
|
|
351
|
+
"aks",
|
|
352
|
+
"list",
|
|
353
|
+
"--subscription",
|
|
354
|
+
subscription,
|
|
355
|
+
"--resource-group",
|
|
356
|
+
resource_group,
|
|
357
|
+
"--query",
|
|
358
|
+
"[].agentPoolProfiles[].{size:vmSize,count:count}",
|
|
359
|
+
),
|
|
360
|
+
deadline,
|
|
361
|
+
)
|
|
362
|
+
except ValueError:
|
|
363
|
+
return {}
|
|
364
|
+
if not isinstance(pools, list):
|
|
365
|
+
return {}
|
|
366
|
+
by_size: dict[str, tuple[str, int]] = {}
|
|
367
|
+
for sku in skus:
|
|
368
|
+
if not isinstance(sku, dict) or not isinstance(sku.get("family"), str):
|
|
369
|
+
continue
|
|
370
|
+
capabilities = sku.get("capabilities")
|
|
371
|
+
values = {
|
|
372
|
+
entry.get("name"): entry.get("value")
|
|
373
|
+
for entry in capabilities or []
|
|
374
|
+
if isinstance(entry, dict)
|
|
375
|
+
}
|
|
376
|
+
try:
|
|
377
|
+
by_size[str(sku.get("name"))] = (
|
|
378
|
+
str(sku["family"]).casefold(),
|
|
379
|
+
int(str(values["vCPUs"])),
|
|
380
|
+
)
|
|
381
|
+
except (KeyError, TypeError, ValueError):
|
|
382
|
+
continue
|
|
383
|
+
owned: dict[str, int] = {}
|
|
384
|
+
for pool in pools:
|
|
385
|
+
if not isinstance(pool, dict) or pool.get("size") not in by_size:
|
|
386
|
+
continue
|
|
387
|
+
count = _quota_integer(pool.get("count"))
|
|
388
|
+
if count is None:
|
|
389
|
+
continue
|
|
390
|
+
family, cores = by_size[str(pool["size"])]
|
|
391
|
+
owned[family] = owned.get(family, 0) + count * cores
|
|
392
|
+
owned["cores"] = owned.get("cores", 0) + count * cores
|
|
393
|
+
return owned
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _quota_integer(value: object) -> int | None:
|
|
397
|
+
if type(value) is int and value >= 0:
|
|
398
|
+
return value
|
|
399
|
+
if isinstance(value, str) and re.fullmatch(r"(?:0|[1-9][0-9]{0,18})", value):
|
|
400
|
+
return int(value)
|
|
401
|
+
return None
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _json(arguments: tuple[str, ...], deadline: DeploymentDeadline) -> Any:
|
|
405
|
+
try:
|
|
406
|
+
result = subprocess.run(
|
|
407
|
+
("az", *arguments, "--output", "json", "--only-show-errors"),
|
|
408
|
+
stdin=subprocess.DEVNULL,
|
|
409
|
+
capture_output=True,
|
|
410
|
+
check=False,
|
|
411
|
+
timeout=deadline.remaining(90),
|
|
412
|
+
)
|
|
413
|
+
except (OSError, subprocess.SubprocessError):
|
|
414
|
+
raise ValueError(
|
|
415
|
+
"AKS preflight provider unavailable; no retry or mutation performed"
|
|
416
|
+
) from None
|
|
417
|
+
if result.returncode != 0 or len(result.stdout) > 16 * 1024 * 1024:
|
|
418
|
+
raise ValueError("AKS preflight provider observation failed or exceeded its bound")
|
|
419
|
+
try:
|
|
420
|
+
return json.loads(result.stdout)
|
|
421
|
+
except (UnicodeDecodeError, ValueError):
|
|
422
|
+
raise ValueError("AKS preflight provider observation is not valid JSON") from None
|