fdai-deployment-cli 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. fdai_deployment_cli/__about__.py +3 -0
  2. fdai_deployment_cli/__init__.py +5 -0
  3. fdai_deployment_cli/__main__.py +5 -0
  4. fdai_deployment_cli/acr_publish.py +585 -0
  5. fdai_deployment_cli/aks_catalog_review.py +137 -0
  6. fdai_deployment_cli/aks_historical_reconciliation.py +732 -0
  7. fdai_deployment_cli/aks_job_environment.py +59 -0
  8. fdai_deployment_cli/aks_job_execution.py +794 -0
  9. fdai_deployment_cli/aks_kubernetes_defaults.py +66 -0
  10. fdai_deployment_cli/aks_preflight.py +422 -0
  11. fdai_deployment_cli/aks_readiness.py +220 -0
  12. fdai_deployment_cli/aks_service_update.py +180 -0
  13. fdai_deployment_cli/aks_workload_jobs.py +281 -0
  14. fdai_deployment_cli/application_state_adoption.py +347 -0
  15. fdai_deployment_cli/azure_actor.py +61 -0
  16. fdai_deployment_cli/azure_naming.py +252 -0
  17. fdai_deployment_cli/azure_retail_prices.py +173 -0
  18. fdai_deployment_cli/bootstrap_approval.py +99 -0
  19. fdai_deployment_cli/bootstrap_channel.py +321 -0
  20. fdai_deployment_cli/bootstrap_executor.py +311 -0
  21. fdai_deployment_cli/bootstrap_plan.py +237 -0
  22. fdai_deployment_cli/bootstrap_reconcile.py +402 -0
  23. fdai_deployment_cli/bootstrap_support.py +138 -0
  24. fdai_deployment_cli/bootstrap_worker.py +448 -0
  25. fdai_deployment_cli/bundle.py +352 -0
  26. fdai_deployment_cli/capacity.py +143 -0
  27. fdai_deployment_cli/catalog_review_profile.py +306 -0
  28. fdai_deployment_cli/cli.py +798 -0
  29. fdai_deployment_cli/cli_help.py +214 -0
  30. fdai_deployment_cli/cli_parser.py +791 -0
  31. fdai_deployment_cli/cli_plan.py +450 -0
  32. fdai_deployment_cli/compiler.py +99 -0
  33. fdai_deployment_cli/console_artifact.py +218 -0
  34. fdai_deployment_cli/console_config.py +214 -0
  35. fdai_deployment_cli/console_update.py +832 -0
  36. fdai_deployment_cli/contracts.py +307 -0
  37. fdai_deployment_cli/control_package.py +140 -0
  38. fdai_deployment_cli/deadline_transport.py +70 -0
  39. fdai_deployment_cli/deployment_cost.py +131 -0
  40. fdai_deployment_cli/deployment_deadline.py +28 -0
  41. fdai_deployment_cli/deployment_kit.py +790 -0
  42. fdai_deployment_cli/deployment_kit_cache.py +191 -0
  43. fdai_deployment_cli/deployment_progress.py +648 -0
  44. fdai_deployment_cli/development_authority_cli.py +56 -0
  45. fdai_deployment_cli/development_authority_profile.py +319 -0
  46. fdai_deployment_cli/doctor.py +136 -0
  47. fdai_deployment_cli/entitlement_preflight.py +68 -0
  48. fdai_deployment_cli/entitlement_transport.py +49 -0
  49. fdai_deployment_cli/entra_bootstrap.py +579 -0
  50. fdai_deployment_cli/entra_control_profile.py +214 -0
  51. fdai_deployment_cli/entra_graph.py +349 -0
  52. fdai_deployment_cli/entra_profiles.py +215 -0
  53. fdai_deployment_cli/entra_source.py +244 -0
  54. fdai_deployment_cli/execution_bundle.py +478 -0
  55. fdai_deployment_cli/execution_copies.py +63 -0
  56. fdai_deployment_cli/foundation_adoption.py +438 -0
  57. fdai_deployment_cli/foundation_adoption_evidence.py +245 -0
  58. fdai_deployment_cli/foundation_adoption_host.py +157 -0
  59. fdai_deployment_cli/foundation_adoption_transport.py +51 -0
  60. fdai_deployment_cli/foundation_failure.py +72 -0
  61. fdai_deployment_cli/foundation_image.py +168 -0
  62. fdai_deployment_cli/foundation_input.py +235 -0
  63. fdai_deployment_cli/foundation_output.py +154 -0
  64. fdai_deployment_cli/foundation_plan.py +381 -0
  65. fdai_deployment_cli/foundation_process.py +64 -0
  66. fdai_deployment_cli/foundation_progress.py +231 -0
  67. fdai_deployment_cli/github_actions.py +171 -0
  68. fdai_deployment_cli/github_apply_artifact.py +259 -0
  69. fdai_deployment_cli/github_artifact_io.py +48 -0
  70. fdai_deployment_cli/github_dispatch.py +248 -0
  71. fdai_deployment_cli/github_plan_artifact.py +181 -0
  72. fdai_deployment_cli/github_provider_artifact.py +170 -0
  73. fdai_deployment_cli/github_transport.py +46 -0
  74. fdai_deployment_cli/github_workflow_values.py +312 -0
  75. fdai_deployment_cli/hub_image_deploy.py +354 -0
  76. fdai_deployment_cli/hub_image_stage.py +162 -0
  77. fdai_deployment_cli/hub_image_trust.py +281 -0
  78. fdai_deployment_cli/hub_ownership.py +161 -0
  79. fdai_deployment_cli/hub_workload_handoff.py +264 -0
  80. fdai_deployment_cli/hub_workload_handoff_inputs.py +227 -0
  81. fdai_deployment_cli/identity_profile.py +227 -0
  82. fdai_deployment_cli/image_targets.py +197 -0
  83. fdai_deployment_cli/installation_scope.py +280 -0
  84. fdai_deployment_cli/license.py +311 -0
  85. fdai_deployment_cli/license_issue.py +239 -0
  86. fdai_deployment_cli/lifecycle_authorization.py +252 -0
  87. fdai_deployment_cli/lifecycle_commands.py +173 -0
  88. fdai_deployment_cli/lifecycle_configuration.py +722 -0
  89. fdai_deployment_cli/lifecycle_configuration_artifact.py +45 -0
  90. fdai_deployment_cli/lifecycle_configuration_cli.py +324 -0
  91. fdai_deployment_cli/lifecycle_configuration_document.py +74 -0
  92. fdai_deployment_cli/lifecycle_configuration_entities.py +146 -0
  93. fdai_deployment_cli/lifecycle_configuration_package.py +492 -0
  94. fdai_deployment_cli/lifecycle_configuration_publication.py +277 -0
  95. fdai_deployment_cli/lifecycle_configuration_publication_cli.py +191 -0
  96. fdai_deployment_cli/lifecycle_configuration_sealing.py +127 -0
  97. fdai_deployment_cli/lifecycle_enrollment.py +45 -0
  98. fdai_deployment_cli/lifecycle_infrastructure_agent.py +174 -0
  99. fdai_deployment_cli/lifecycle_installation_binding.py +65 -0
  100. fdai_deployment_cli/lifecycle_native.py +247 -0
  101. fdai_deployment_cli/lifecycle_native_reconciliation.py +568 -0
  102. fdai_deployment_cli/lifecycle_native_transition.py +339 -0
  103. fdai_deployment_cli/lifecycle_ownership_exclusion.py +78 -0
  104. fdai_deployment_cli/lifecycle_plan.py +800 -0
  105. fdai_deployment_cli/lifecycle_promotion.py +185 -0
  106. fdai_deployment_cli/lifecycle_release.py +308 -0
  107. fdai_deployment_cli/lifecycle_self_upgrade.py +202 -0
  108. fdai_deployment_cli/lifecycle_upgrade_bundle.py +176 -0
  109. fdai_deployment_cli/notification_recipients_input.py +222 -0
  110. fdai_deployment_cli/observer_artifact.py +106 -0
  111. fdai_deployment_cli/oci_archive.py +353 -0
  112. fdai_deployment_cli/offline_kit.py +797 -0
  113. fdai_deployment_cli/offline_prepare.py +135 -0
  114. fdai_deployment_cli/operational_evidence_verifier_input.py +575 -0
  115. fdai_deployment_cli/plan_input.py +101 -0
  116. fdai_deployment_cli/private_output.py +192 -0
  117. fdai_deployment_cli/profile.py +70 -0
  118. fdai_deployment_cli/progress.py +183 -0
  119. fdai_deployment_cli/readiness.py +286 -0
  120. fdai_deployment_cli/run_command_receiver.py +418 -0
  121. fdai_deployment_cli/runtime_build.py +386 -0
  122. fdai_deployment_cli/runtime_profile.py +287 -0
  123. fdai_deployment_cli/runtime_release.py +745 -0
  124. fdai_deployment_cli/runtime_stage.py +82 -0
  125. fdai_deployment_cli/runtime_support_installation.py +215 -0
  126. fdai_deployment_cli/simulation.py +80 -0
  127. fdai_deployment_cli/source_application_inputs.py +397 -0
  128. fdai_deployment_cli/source_azure.py +586 -0
  129. fdai_deployment_cli/source_change_cli.py +107 -0
  130. fdai_deployment_cli/source_change_parser.py +75 -0
  131. fdai_deployment_cli/source_change_plan.py +181 -0
  132. fdai_deployment_cli/source_changed_update.py +196 -0
  133. fdai_deployment_cli/source_deploy.py +170 -0
  134. fdai_deployment_cli/source_foundation.py +174 -0
  135. fdai_deployment_cli/source_host_tools.py +163 -0
  136. fdai_deployment_cli/source_image_stage.py +400 -0
  137. fdai_deployment_cli/source_input.py +173 -0
  138. fdai_deployment_cli/source_projection.py +164 -0
  139. fdai_deployment_cli/source_receiver.py +76 -0
  140. fdai_deployment_cli/source_recovery.py +264 -0
  141. fdai_deployment_cli/source_runtime.py +81 -0
  142. fdai_deployment_cli/source_runtime_support.py +368 -0
  143. fdai_deployment_cli/source_service_update.py +726 -0
  144. fdai_deployment_cli/source_service_update_cli.py +47 -0
  145. fdai_deployment_cli/source_snapshot.py +178 -0
  146. fdai_deployment_cli/source_teardown.py +574 -0
  147. fdai_deployment_cli/source_terraform.py +85 -0
  148. fdai_deployment_cli/source_transport.py +399 -0
  149. fdai_deployment_cli/standalone_aks_inventory.py +558 -0
  150. fdai_deployment_cli/standalone_aks_job_execution.py +769 -0
  151. fdai_deployment_cli/standalone_aks_nodepool_guard.py +141 -0
  152. fdai_deployment_cli/standalone_application.py +977 -0
  153. fdai_deployment_cli/standalone_application_completion.py +151 -0
  154. fdai_deployment_cli/standalone_application_input_recovery.py +107 -0
  155. fdai_deployment_cli/standalone_application_recovery.py +116 -0
  156. fdai_deployment_cli/standalone_approval_input.py +24 -0
  157. fdai_deployment_cli/standalone_catalog_checkpoint.py +122 -0
  158. fdai_deployment_cli/standalone_catalog_review.py +251 -0
  159. fdai_deployment_cli/standalone_checkpoint_failure.py +127 -0
  160. fdai_deployment_cli/standalone_console_publish.py +174 -0
  161. fdai_deployment_cli/standalone_database_principals.py +41 -0
  162. fdai_deployment_cli/standalone_deploy.py +638 -0
  163. fdai_deployment_cli/standalone_development_authority.py +166 -0
  164. fdai_deployment_cli/standalone_foundation_adoption.py +87 -0
  165. fdai_deployment_cli/standalone_foundation_transition.py +767 -0
  166. fdai_deployment_cli/standalone_host.py +4608 -0
  167. fdai_deployment_cli/standalone_host_rebind.py +226 -0
  168. fdai_deployment_cli/standalone_host_residual.py +104 -0
  169. fdai_deployment_cli/standalone_host_state.py +145 -0
  170. fdai_deployment_cli/standalone_host_values.py +563 -0
  171. fdai_deployment_cli/standalone_kubernetes_tools.py +100 -0
  172. fdai_deployment_cli/standalone_license_installation.py +177 -0
  173. fdai_deployment_cli/standalone_management_egress.py +83 -0
  174. fdai_deployment_cli/standalone_migration_evidence.py +38 -0
  175. fdai_deployment_cli/standalone_operational_evidence.py +356 -0
  176. fdai_deployment_cli/standalone_planned_outputs.py +147 -0
  177. fdai_deployment_cli/standalone_private_access_probe.py +100 -0
  178. fdai_deployment_cli/standalone_product_profile.py +110 -0
  179. fdai_deployment_cli/standalone_remote_prepare.py +442 -0
  180. fdai_deployment_cli/standalone_residual_apply.py +540 -0
  181. fdai_deployment_cli/standalone_residual_successor.py +431 -0
  182. fdai_deployment_cli/standalone_review.py +266 -0
  183. fdai_deployment_cli/standalone_service_update_finalization.py +116 -0
  184. fdai_deployment_cli/standalone_service_update_recovery.py +277 -0
  185. fdai_deployment_cli/standalone_stage_targets.py +325 -0
  186. fdai_deployment_cli/standalone_status.py +67 -0
  187. fdai_deployment_cli/standalone_terraform_environment.py +237 -0
  188. fdai_deployment_cli/standalone_transfer_cleanup.py +237 -0
  189. fdai_deployment_cli/standalone_trial_activation.py +193 -0
  190. fdai_deployment_cli/state.py +434 -0
  191. fdai_deployment_cli/state_handoff.py +253 -0
  192. fdai_deployment_cli/status_projection.py +97 -0
  193. fdai_deployment_cli/support_install.py +257 -0
  194. fdai_deployment_cli/target.py +46 -0
  195. fdai_deployment_cli/trust/capabilities.json +62 -0
  196. fdai_deployment_cli/trust/deployment-bundle-root.pub +3 -0
  197. fdai_deployment_cli/trust/deployment-release-root.pub +3 -0
  198. fdai_deployment_cli/trust/upstream-signing-key.pub +3 -0
  199. fdai_deployment_cli/trust_roots.py +50 -0
  200. fdai_deployment_cli-0.1.2.dist-info/METADATA +112 -0
  201. fdai_deployment_cli-0.1.2.dist-info/RECORD +203 -0
  202. fdai_deployment_cli-0.1.2.dist-info/WHEEL +4 -0
  203. fdai_deployment_cli-0.1.2.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,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