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,563 @@
|
|
|
1
|
+
"""Pure value validation and projection for standalone deployment checkpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from fdai_deployment_cli.aks_workload_jobs import AKS_CANARY_TOPIC
|
|
11
|
+
from fdai_deployment_cli.contracts import canonical_digest
|
|
12
|
+
|
|
13
|
+
_GUID = re.compile(r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def aks_core_environment(
|
|
17
|
+
*,
|
|
18
|
+
context: dict[str, object],
|
|
19
|
+
application_values: dict[str, object],
|
|
20
|
+
substrate_outputs: dict[str, object],
|
|
21
|
+
resource_group: str,
|
|
22
|
+
core_identity: dict[str, object],
|
|
23
|
+
topics: list[object],
|
|
24
|
+
deployment_binding: str,
|
|
25
|
+
) -> dict[str, object]:
|
|
26
|
+
"""Project the baseline AKS Core environment from verified deployment values."""
|
|
27
|
+
|
|
28
|
+
environment: dict[str, object] = {
|
|
29
|
+
"AZURE_TENANT_ID": context["tenant_id"],
|
|
30
|
+
"AZURE_SUBSCRIPTION_ID": context["subscription_id"],
|
|
31
|
+
"AZURE_RESOURCE_GROUP": resource_group,
|
|
32
|
+
"AZURE_REGION": application_values["region"],
|
|
33
|
+
"AZURE_CLIENT_ID": core_identity["client_id"],
|
|
34
|
+
"KAFKA_BOOTSTRAP_SERVERS": substrate_outputs["kafka"],
|
|
35
|
+
"KAFKA_TOPIC_EVENTS": str(topics[0]),
|
|
36
|
+
"FDAI_CANARY_TOPIC": AKS_CANARY_TOPIC,
|
|
37
|
+
"POSTGRES_HOST": substrate_outputs["postgres_fqdn"],
|
|
38
|
+
"POSTGRES_DATABASE": substrate_outputs["postgres_database"],
|
|
39
|
+
"RUNTIME_ENV": application_values["env"],
|
|
40
|
+
"AUTONOMY_MODE_DEFAULT": "shadow",
|
|
41
|
+
"FDAI_MONITOR_WORKSPACE_ID": substrate_outputs["workspace"],
|
|
42
|
+
"FDAI_OPERATING_MODEL_TOPIC": substrate_outputs["operating_model_topic"],
|
|
43
|
+
"FDAI_AUXILIARY_KAFKA_BOOTSTRAP_SERVERS": substrate_outputs["operational_kafka"],
|
|
44
|
+
"FDAI_ISOLATED_EXECUTOR_AUTHORITY_CUTOVER": "1",
|
|
45
|
+
"FDAI_PRODUCT_PROFILE_JSON": str(application_values["product_profile_json"]),
|
|
46
|
+
"FDAI_START_CONSUMER": "1",
|
|
47
|
+
"FDAI_INSTALLATION_BINDING": str(substrate_outputs["installation_binding"]),
|
|
48
|
+
"FDAI_LICENSE_DEPLOYMENT_BINDING": deployment_binding,
|
|
49
|
+
}
|
|
50
|
+
profile = str(application_values.get("development_authority_profile_json", "")).strip()
|
|
51
|
+
executor = str(application_values.get("development_authority_executor_principal", "")).strip()
|
|
52
|
+
if bool(profile) != bool(executor):
|
|
53
|
+
raise ValueError("development authority profile and executor must be selected together")
|
|
54
|
+
if profile:
|
|
55
|
+
environment["FDAI_FULL_AUTHORITY_DEVELOPMENT_PROFILE_JSON"] = profile
|
|
56
|
+
environment["FDAI_FULL_AUTHORITY_DEVELOPMENT_EXECUTOR_PRINCIPAL"] = executor
|
|
57
|
+
if stewardship_governance_selected(application_values):
|
|
58
|
+
environment["FDAI_STEWARDSHIP_GOVERNANCE_ENABLED"] = "true"
|
|
59
|
+
environment.update(_stewardship_bindings_environment(application_values))
|
|
60
|
+
return environment
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def stewardship_governance_selected(application_values: dict[str, object]) -> bool:
|
|
64
|
+
"""Report whether the selected product surfaces already carry the handover lifecycle.
|
|
65
|
+
|
|
66
|
+
The Console, the Operator API with enterprise identity bindings, and document ingestion are each
|
|
67
|
+
explicit selections. Together they produce handover drafts and name the accountable stewards, so
|
|
68
|
+
Core then runs the Console-governed candidate worker. The worker stays inert: an Owner activates
|
|
69
|
+
a candidate in the Console, and no GitHub credential is involved.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
application_values.get("enable_operator_api") is True
|
|
74
|
+
and application_values.get("enable_document_ingestion") is True
|
|
75
|
+
and bool(str(application_values.get("stewardship_maintainers", "")).strip())
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _stewardship_bindings_environment(application_values: dict[str, object]) -> dict[str, object]:
|
|
80
|
+
bindings = application_values["stewardship_agent_bindings"]
|
|
81
|
+
if not isinstance(bindings, dict):
|
|
82
|
+
raise ValueError("stewardship bindings are invalid") # noqa: TRY004
|
|
83
|
+
environment: dict[str, object] = {
|
|
84
|
+
"FDAI_MAINTAINERS": application_values["stewardship_maintainers"]
|
|
85
|
+
}
|
|
86
|
+
environment.update(
|
|
87
|
+
{f"FDAI_STEWARD_{name.upper()}": binding for name, binding in bindings.items()}
|
|
88
|
+
)
|
|
89
|
+
return environment
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def plan_summary(value: object) -> dict[str, object]:
|
|
93
|
+
"""Project one bounded Terraform plan into review-safe action counts and addresses."""
|
|
94
|
+
changes = value.get("resource_changes") if isinstance(value, dict) else None
|
|
95
|
+
if not isinstance(changes, list) or len(changes) > 5000:
|
|
96
|
+
raise ValueError("Terraform plan resource change inventory is invalid")
|
|
97
|
+
counts = {name: 0 for name in ("create", "update", "delete", "replace", "read", "no-op")}
|
|
98
|
+
types: dict[str, int] = {}
|
|
99
|
+
projected: list[dict[str, object]] = []
|
|
100
|
+
for item in changes:
|
|
101
|
+
if (
|
|
102
|
+
not isinstance(item, dict)
|
|
103
|
+
or not isinstance(item.get("type"), str)
|
|
104
|
+
or not isinstance(item.get("address"), str)
|
|
105
|
+
or not item["address"]
|
|
106
|
+
):
|
|
107
|
+
raise ValueError("Terraform plan resource change is invalid")
|
|
108
|
+
change = item.get("change")
|
|
109
|
+
actions = change.get("actions") if isinstance(change, dict) else None
|
|
110
|
+
if not isinstance(actions, list) or not all(isinstance(action, str) for action in actions):
|
|
111
|
+
raise ValueError("Terraform plan action is invalid")
|
|
112
|
+
action_set = set(actions)
|
|
113
|
+
if action_set == {"create", "delete"}:
|
|
114
|
+
counts["replace"] += 1
|
|
115
|
+
else:
|
|
116
|
+
for action in action_set:
|
|
117
|
+
if action in counts:
|
|
118
|
+
counts[action] += 1
|
|
119
|
+
resource_type = str(item["type"])
|
|
120
|
+
types[resource_type] = types.get(resource_type, 0) + 1
|
|
121
|
+
projected.append({"address": item["address"], "actions": actions})
|
|
122
|
+
return {
|
|
123
|
+
"action_counts": counts,
|
|
124
|
+
"resource_type_counts": dict(sorted(types.items())),
|
|
125
|
+
"resource_changes": projected,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def required_image_digest(records: dict[str, object], name: str) -> str:
|
|
130
|
+
"""Return one exact OCI image digest from a validated runtime record."""
|
|
131
|
+
record = records.get(name)
|
|
132
|
+
if not isinstance(record, dict) or any(not isinstance(key, str) for key in record):
|
|
133
|
+
raise ValueError(f"runtime image {name} is invalid")
|
|
134
|
+
digest = record.get("image_digest")
|
|
135
|
+
if not isinstance(digest, str) or re.fullmatch(r"sha256:[0-9a-f]{64}", digest) is None:
|
|
136
|
+
raise ValueError("runtime image digest is invalid")
|
|
137
|
+
return digest
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def foundation_binding_digest(
|
|
141
|
+
handoff: dict[str, object],
|
|
142
|
+
*,
|
|
143
|
+
runner: dict[str, object],
|
|
144
|
+
state: dict[str, object],
|
|
145
|
+
ops: dict[str, object],
|
|
146
|
+
app: dict[str, object],
|
|
147
|
+
) -> str:
|
|
148
|
+
"""Bind the selected Foundation resources to the exact source handoff."""
|
|
149
|
+
return canonical_digest(
|
|
150
|
+
{
|
|
151
|
+
"source_commit": handoff.get("source_commit"),
|
|
152
|
+
"run_digest": handoff.get("run_digest"),
|
|
153
|
+
"region": handoff.get("region"),
|
|
154
|
+
"region_short": handoff.get("region_short"),
|
|
155
|
+
"runner": runner,
|
|
156
|
+
"state": state,
|
|
157
|
+
"ops": ops,
|
|
158
|
+
"app_resource_group": app,
|
|
159
|
+
}
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def foundation_application_workload(
|
|
164
|
+
app: dict[str, object], *, environment: str, region_short: str
|
|
165
|
+
) -> str:
|
|
166
|
+
"""Recover the bounded workload token from one reviewed resource-group name."""
|
|
167
|
+
name = app.get("name")
|
|
168
|
+
suffix = f"-{environment}-{region_short}"
|
|
169
|
+
if not isinstance(name, str) or not name.startswith("rg-") or not name.endswith(suffix):
|
|
170
|
+
raise ValueError("Foundation application resource-group name is invalid")
|
|
171
|
+
workload = name[3 : -len(suffix)]
|
|
172
|
+
if re.fullmatch(r"[a-z][a-z0-9]{1,11}", workload) is None:
|
|
173
|
+
raise ValueError("Foundation application workload is invalid")
|
|
174
|
+
return workload
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def vault_name(uri: str) -> str:
|
|
178
|
+
"""Extract one bounded public-cloud Key Vault name from its exact HTTPS URI."""
|
|
179
|
+
match = re.fullmatch(r"https://([a-z0-9-]{3,24})[.]vault[.]azure[.]net/?", uri)
|
|
180
|
+
if match is None:
|
|
181
|
+
raise ValueError("Terraform Key Vault URI is invalid")
|
|
182
|
+
return match.group(1)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def registry_selector(registry_id: str, expected_name: str) -> tuple[str, ...]:
|
|
186
|
+
"""Return ``az acr show`` selectors for a state-owned registry ID of the expected name."""
|
|
187
|
+
|
|
188
|
+
match = re.fullmatch(
|
|
189
|
+
r"/subscriptions/([^/]+)/resourceGroups/([^/]+)/providers/"
|
|
190
|
+
r"Microsoft\.ContainerRegistry/registries/([^/]+)",
|
|
191
|
+
registry_id,
|
|
192
|
+
flags=re.IGNORECASE,
|
|
193
|
+
)
|
|
194
|
+
if match is None or match.group(3) != expected_name:
|
|
195
|
+
raise ValueError("Terraform registry differs from the planned image registry")
|
|
196
|
+
subscription, resource_group, name = match.groups()
|
|
197
|
+
return ("--name", name, "--resource-group", resource_group, "--subscription", subscription)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def planned_container_registry_name(
|
|
201
|
+
*, workload: str, environment: str, region_short: str, resource_suffix: str
|
|
202
|
+
) -> str:
|
|
203
|
+
"""Return the exact container registry name rendered by the verified Terraform root."""
|
|
204
|
+
|
|
205
|
+
if (
|
|
206
|
+
re.fullmatch(r"[a-z][a-z0-9]{1,11}", workload) is None
|
|
207
|
+
or re.fullmatch(r"[a-z][a-z0-9-]{1,15}", environment) is None
|
|
208
|
+
or re.fullmatch(r"[a-z][a-z0-9]{1,7}", region_short) is None
|
|
209
|
+
or re.fullmatch(r"[0-9a-f]{6}", resource_suffix) is None
|
|
210
|
+
):
|
|
211
|
+
raise ValueError("planned container registry name inputs are invalid")
|
|
212
|
+
return f"cr{workload}{environment.replace('-', '')}{region_short}{resource_suffix}"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def planned_key_vault_name(
|
|
216
|
+
*, workload: str, environment: str, region_short: str, resource_suffix: str
|
|
217
|
+
) -> str:
|
|
218
|
+
"""Return the exact Key Vault name rendered by the verified Terraform root."""
|
|
219
|
+
|
|
220
|
+
if (
|
|
221
|
+
re.fullmatch(r"[a-z][a-z0-9]{1,11}", workload) is None
|
|
222
|
+
or re.fullmatch(r"[a-z][a-z0-9-]{1,15}", environment) is None
|
|
223
|
+
or re.fullmatch(r"[a-z][a-z0-9]{1,7}", region_short) is None
|
|
224
|
+
or re.fullmatch(r"[0-9a-f]{6}", resource_suffix) is None
|
|
225
|
+
):
|
|
226
|
+
raise ValueError("planned Key Vault name inputs are invalid")
|
|
227
|
+
full_name = f"kv-{workload}-{environment}-{region_short}-{resource_suffix}"
|
|
228
|
+
return (
|
|
229
|
+
full_name
|
|
230
|
+
if len(full_name) <= 24
|
|
231
|
+
else f"kv-aip-{hashlib.sha256(full_name.encode()).hexdigest()[:8]}"
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def planned_document_storage_name(
|
|
236
|
+
*, subscription_id: str, workload: str, environment: str, region_short: str
|
|
237
|
+
) -> str:
|
|
238
|
+
"""Return the exact document-storage name rendered by the verified Terraform root."""
|
|
239
|
+
|
|
240
|
+
if (
|
|
241
|
+
re.fullmatch(r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}", subscription_id)
|
|
242
|
+
is None
|
|
243
|
+
or re.fullmatch(r"[a-z][a-z0-9]{1,11}", workload) is None
|
|
244
|
+
or re.fullmatch(r"[a-z][a-z0-9-]{1,15}", environment) is None
|
|
245
|
+
or re.fullmatch(r"[a-z][a-z0-9]{1,7}", region_short) is None
|
|
246
|
+
):
|
|
247
|
+
raise ValueError("planned document storage name inputs are invalid")
|
|
248
|
+
unique = hashlib.md5(
|
|
249
|
+
f"{subscription_id.casefold()}:{environment}".encode(), usedforsecurity=False
|
|
250
|
+
).hexdigest()[:6]
|
|
251
|
+
return f"st{workload}doc{environment}{region_short}{unique}"[:24]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def console_origin(hostname: str) -> str:
|
|
255
|
+
"""Return the HTTPS origin for one deployed Static Web App hostname."""
|
|
256
|
+
if (
|
|
257
|
+
re.fullmatch(
|
|
258
|
+
r"[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:[.][0-9]+)?[.]azurestaticapps[.]net",
|
|
259
|
+
hostname,
|
|
260
|
+
)
|
|
261
|
+
is None
|
|
262
|
+
):
|
|
263
|
+
raise ValueError("Console Static Web App hostname is invalid")
|
|
264
|
+
return f"https://{hostname}"
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def aks_operator_environment(
|
|
268
|
+
*,
|
|
269
|
+
application_values: dict[str, Any],
|
|
270
|
+
core_environment: dict[str, Any],
|
|
271
|
+
substrate_outputs: dict[str, Any],
|
|
272
|
+
semantic_topics: list[Any],
|
|
273
|
+
operator_identity: dict[str, Any],
|
|
274
|
+
command_identity: dict[str, Any],
|
|
275
|
+
tenant_id: str,
|
|
276
|
+
console_origin: str,
|
|
277
|
+
) -> dict[str, object]:
|
|
278
|
+
"""Bind the Operator API pod environment selected by the enterprise identity surface."""
|
|
279
|
+
|
|
280
|
+
bindings = application_values["stewardship_agent_bindings"]
|
|
281
|
+
if not isinstance(bindings, dict):
|
|
282
|
+
raise ValueError("stewardship bindings are invalid") # noqa: TRY004
|
|
283
|
+
environment: dict[str, object] = {
|
|
284
|
+
"AZURE_CLIENT_ID": operator_identity["client_id"],
|
|
285
|
+
"FDAI_COMMAND_MI_CLIENT_ID": command_identity["client_id"],
|
|
286
|
+
"FDAI_ENTRA_TENANT_ID": tenant_id,
|
|
287
|
+
"FDAI_API_AUDIENCE": application_values["operator_api_audience"],
|
|
288
|
+
"FDAI_RBAC_READERS_GROUP_ID": application_values["rbac_readers_group_id"],
|
|
289
|
+
"FDAI_RBAC_CONTRIBUTORS_GROUP_ID": application_values["rbac_contributors_group_id"],
|
|
290
|
+
"FDAI_RBAC_APPROVERS_GROUP_ID": application_values["rbac_approvers_group_id"],
|
|
291
|
+
"FDAI_RBAC_OWNERS_GROUP_ID": application_values["rbac_owners_group_id"],
|
|
292
|
+
"FDAI_RBAC_BREAK_GLASS_GROUP_ID": application_values["rbac_break_glass_group_id"],
|
|
293
|
+
"FDAI_STEWARDSHIP_REQUIRE_BINDINGS": "1",
|
|
294
|
+
"FDAI_MAINTAINERS": application_values["stewardship_maintainers"],
|
|
295
|
+
"FDAI_KAFKA_BOOTSTRAP_SERVERS": core_environment["KAFKA_BOOTSTRAP_SERVERS"],
|
|
296
|
+
"KAFKA_TOPIC_EVENTS": core_environment["KAFKA_TOPIC_EVENTS"],
|
|
297
|
+
"FDAI_SEMANTIC_TURN_REQUEST_TOPIC": str(semantic_topics[0]),
|
|
298
|
+
"FDAI_SEMANTIC_TURN_PROJECTION_TOPIC": str(semantic_topics[1]),
|
|
299
|
+
"FDAI_SEMANTIC_TURN_PHYSICAL_TOPIC": substrate_outputs["semantic_physical"],
|
|
300
|
+
"FDAI_READ_INVESTIGATION_REQUEST_TOPIC": str(semantic_topics[2]),
|
|
301
|
+
"FDAI_OPERATOR_API_CORS_ALLOW_ORIGINS": console_origin,
|
|
302
|
+
}
|
|
303
|
+
environment.update(
|
|
304
|
+
{f"FDAI_STEWARD_{name.upper()}": binding for name, binding in bindings.items()}
|
|
305
|
+
)
|
|
306
|
+
return environment
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# Shared-root Event Hubs startup readiness defaults (`infra/variables.tf`); the AKS
|
|
310
|
+
# renderer must supply them because Core's code defaults are too short for a consumer join.
|
|
311
|
+
AKS_CORE_STARTUP_READINESS: dict[str, str] = {
|
|
312
|
+
"FDAI_STARTUP_KAFKA_SETTLE_SECONDS": "12",
|
|
313
|
+
"FDAI_STARTUP_PROBE_TIMEOUT_SECONDS": "30",
|
|
314
|
+
"FDAI_STARTUP_PHASE_TIMEOUT_SECONDS": "60",
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
_KEY_VAULT_SECRET_ID = re.compile(
|
|
318
|
+
r"/subscriptions/[^/]+/resourceGroups/[^/]+/providers/"
|
|
319
|
+
r"Microsoft[.]KeyVault/vaults/[A-Za-z0-9-]{3,24}/secrets/([A-Za-z0-9-]{1,127})",
|
|
320
|
+
re.IGNORECASE,
|
|
321
|
+
)
|
|
322
|
+
_KEY_VAULT_SECRET_URL = re.compile(
|
|
323
|
+
r"https://[A-Za-z0-9-]{3,24}[.]vault[.]azure[.]net/"
|
|
324
|
+
r"secrets/([A-Za-z0-9-]{1,127})",
|
|
325
|
+
re.IGNORECASE,
|
|
326
|
+
)
|
|
327
|
+
_OPERATOR_REQUEST_SEED_NAMES = {
|
|
328
|
+
"core_signing_seed_secret_id": "fdai-operator-request-core-signing-seed",
|
|
329
|
+
"operator_signing_seed_secret_id": "fdai-operator-request-operator-signing-seed",
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def aks_operator_request_receipts(
|
|
334
|
+
binding: object,
|
|
335
|
+
) -> tuple[dict[str, str], dict[str, str], dict[str, str], dict[str, str]]:
|
|
336
|
+
"""Bind Core and Operator to the deployment-owned operator_request receipt seeds.
|
|
337
|
+
|
|
338
|
+
Returns Core plain and secret environments, then Operator plain and secret environments.
|
|
339
|
+
Secret environments map variable names to Key Vault secret names.
|
|
340
|
+
"""
|
|
341
|
+
|
|
342
|
+
if not isinstance(binding, dict):
|
|
343
|
+
raise ValueError("operator_request receipt binding is invalid") # noqa: TRY004
|
|
344
|
+
names: dict[str, str] = {}
|
|
345
|
+
for field, expected_name in _OPERATOR_REQUEST_SEED_NAMES.items():
|
|
346
|
+
reference = str(binding.get(field, ""))
|
|
347
|
+
match = _KEY_VAULT_SECRET_ID.fullmatch(reference) or _KEY_VAULT_SECRET_URL.fullmatch(
|
|
348
|
+
reference
|
|
349
|
+
)
|
|
350
|
+
if match is None or match.group(1) != expected_name:
|
|
351
|
+
raise ValueError("operator_request receipt seed reference is invalid")
|
|
352
|
+
names[field] = match.group(1)
|
|
353
|
+
core_producer = binding.get("core_producer_id")
|
|
354
|
+
operator_producer = binding.get("operator_producer_id")
|
|
355
|
+
if (
|
|
356
|
+
core_producer != "core-control-plane"
|
|
357
|
+
or not isinstance(operator_producer, str)
|
|
358
|
+
or not operator_producer
|
|
359
|
+
or operator_producer == core_producer
|
|
360
|
+
or names["core_signing_seed_secret_id"] == names["operator_signing_seed_secret_id"]
|
|
361
|
+
):
|
|
362
|
+
raise ValueError("operator_request receipt producers are invalid")
|
|
363
|
+
return (
|
|
364
|
+
{
|
|
365
|
+
"FDAI_OPERATOR_REQUEST_CORE_PRODUCER_ID": core_producer,
|
|
366
|
+
"FDAI_OPERATOR_REQUEST_OPERATOR_PRODUCER_ID": operator_producer,
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
"FDAI_OPERATOR_REQUEST_CORE_SIGNING_SEED": names["core_signing_seed_secret_id"],
|
|
370
|
+
"FDAI_OPERATOR_REQUEST_OPERATOR_TRUST_SEED": names["operator_signing_seed_secret_id"],
|
|
371
|
+
},
|
|
372
|
+
{"FDAI_OPERATOR_REQUEST_RECEIPT_PRODUCER_ID": operator_producer},
|
|
373
|
+
{"FDAI_OPERATOR_REQUEST_OPERATOR_SIGNING_SEED": names["operator_signing_seed_secret_id"]},
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def aks_notification_environment(
|
|
378
|
+
*,
|
|
379
|
+
application_values: dict[str, object],
|
|
380
|
+
email_binding: object,
|
|
381
|
+
identity: dict[str, Any],
|
|
382
|
+
) -> dict[str, object]:
|
|
383
|
+
"""Bind ACS Email delivery to the Core workload for the selected notifications add-on."""
|
|
384
|
+
|
|
385
|
+
binding = mapping(email_binding, "notification email binding")
|
|
386
|
+
recipients = application_values.get("notification_email_recipients")
|
|
387
|
+
if (
|
|
388
|
+
not isinstance(recipients, list)
|
|
389
|
+
or not recipients
|
|
390
|
+
or not all(isinstance(address, str) and address for address in recipients)
|
|
391
|
+
):
|
|
392
|
+
raise ValueError("notifications add-on requires its private recipient list")
|
|
393
|
+
endpoint = binding.get("endpoint")
|
|
394
|
+
sender = binding.get("sender_address")
|
|
395
|
+
client_id = identity.get("client_id")
|
|
396
|
+
if not all(isinstance(value, str) and value for value in (endpoint, sender, client_id)):
|
|
397
|
+
raise ValueError("notification email binding is incomplete")
|
|
398
|
+
return {
|
|
399
|
+
"FDAI_EMAIL_ENDPOINT": endpoint,
|
|
400
|
+
"FDAI_EMAIL_SENDER_ADDRESS": sender,
|
|
401
|
+
"FDAI_EMAIL_RECIPIENT_ADDRESSES_JSON": json.dumps(recipients, separators=(",", ":")),
|
|
402
|
+
"FDAI_NOTIFICATION_MI_CLIENT_ID": client_id,
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def aks_core_conversation_environment(
|
|
407
|
+
*,
|
|
408
|
+
application_values: dict[str, Any],
|
|
409
|
+
substrate_outputs: dict[str, object],
|
|
410
|
+
semantic_topics: list[object],
|
|
411
|
+
) -> dict[str, str]:
|
|
412
|
+
"""Build a complete Core semantic binding or reject incomplete model outputs."""
|
|
413
|
+
|
|
414
|
+
if len(semantic_topics) < 3:
|
|
415
|
+
raise ValueError("AKS semantic topic output contract is incomplete")
|
|
416
|
+
|
|
417
|
+
def required_text(value: object, label: str) -> str:
|
|
418
|
+
if not isinstance(value, str) or not value.strip():
|
|
419
|
+
raise ValueError(f"AKS {label} output is unavailable")
|
|
420
|
+
return value.strip()
|
|
421
|
+
|
|
422
|
+
environment = {
|
|
423
|
+
"FDAI_SEMANTIC_TURN_REQUEST_TOPIC": required_text(
|
|
424
|
+
semantic_topics[0], "semantic request topic"
|
|
425
|
+
),
|
|
426
|
+
"FDAI_SEMANTIC_TURN_PROJECTION_TOPIC": required_text(
|
|
427
|
+
semantic_topics[1], "semantic projection topic"
|
|
428
|
+
),
|
|
429
|
+
"FDAI_READ_INVESTIGATION_REQUEST_TOPIC": required_text(
|
|
430
|
+
semantic_topics[2], "read investigation topic"
|
|
431
|
+
),
|
|
432
|
+
"FDAI_SEMANTIC_TURN_PHYSICAL_TOPIC": required_text(
|
|
433
|
+
substrate_outputs.get("semantic_physical"), "semantic physical topic"
|
|
434
|
+
),
|
|
435
|
+
}
|
|
436
|
+
enable_llm = application_values.get("enable_llm")
|
|
437
|
+
if not isinstance(enable_llm, bool):
|
|
438
|
+
raise TypeError("AKS enable_llm setting MUST be a boolean")
|
|
439
|
+
if not enable_llm:
|
|
440
|
+
return environment
|
|
441
|
+
|
|
442
|
+
endpoint = required_text(substrate_outputs.get("llm_endpoint"), "LLM endpoint")
|
|
443
|
+
digest = required_text(substrate_outputs.get("resolved_models_sha256"), "resolved-model digest")
|
|
444
|
+
if re.fullmatch(r"[0-9a-f]{64}", digest) is None:
|
|
445
|
+
raise ValueError("AKS resolved-model digest MUST be a lowercase SHA-256 digest")
|
|
446
|
+
model_endpoints = substrate_outputs.get("llm_model_endpoints")
|
|
447
|
+
if (
|
|
448
|
+
not isinstance(model_endpoints, dict)
|
|
449
|
+
or not model_endpoints
|
|
450
|
+
or any(
|
|
451
|
+
not isinstance(reference, str)
|
|
452
|
+
or not reference.strip()
|
|
453
|
+
or not isinstance(model_endpoint, str)
|
|
454
|
+
or not model_endpoint.strip()
|
|
455
|
+
for reference, model_endpoint in model_endpoints.items()
|
|
456
|
+
)
|
|
457
|
+
):
|
|
458
|
+
raise ValueError("AKS LLM model endpoint outputs are incomplete")
|
|
459
|
+
environment.update(
|
|
460
|
+
{
|
|
461
|
+
"LLM_MODE": "azure",
|
|
462
|
+
"LLM_RESOLVED_MODELS_PATH": "/app/resolved-models.json",
|
|
463
|
+
"LLM_RESOLVED_MODELS_SHA256": digest,
|
|
464
|
+
"FDAI_LLM_ENDPOINT": endpoint,
|
|
465
|
+
"FDAI_MODEL_ENDPOINTS_JSON": json.dumps(
|
|
466
|
+
model_endpoints,
|
|
467
|
+
separators=(",", ":"),
|
|
468
|
+
sort_keys=True,
|
|
469
|
+
),
|
|
470
|
+
}
|
|
471
|
+
)
|
|
472
|
+
return environment
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def selected_identity(identities: dict[str, Any], name: str, selected: bool) -> dict[str, Any]:
|
|
476
|
+
"""Return one selected runtime identity or an empty optional binding."""
|
|
477
|
+
|
|
478
|
+
if not selected:
|
|
479
|
+
return {}
|
|
480
|
+
return mapping(identities.get(name), f"{name.replace('_', ' ')} runtime identity")
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def mapping(value: object, label: str) -> dict[str, Any]:
|
|
484
|
+
"""Require one string-keyed mapping."""
|
|
485
|
+
|
|
486
|
+
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
|
|
487
|
+
raise ValueError(f"{label} is invalid")
|
|
488
|
+
return {str(key): item for key, item in value.items()}
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def required_guid(value: dict[str, Any], field: str) -> str:
|
|
492
|
+
"""Return one required GUID field."""
|
|
493
|
+
|
|
494
|
+
item = value.get(field)
|
|
495
|
+
if not isinstance(item, str) or _GUID.fullmatch(item) is None:
|
|
496
|
+
raise ValueError(f"{field} is invalid")
|
|
497
|
+
return item
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def require_browser_gateway_backend_nsg(*, enabled: bool, backend_nsg_id: str) -> str:
|
|
501
|
+
"""Require an attached AKS subnet NSG whenever the browser gateway is enabled."""
|
|
502
|
+
|
|
503
|
+
if enabled and not backend_nsg_id.strip():
|
|
504
|
+
raise ValueError(
|
|
505
|
+
"AKS subnet network security group is required when the browser gateway is enabled"
|
|
506
|
+
)
|
|
507
|
+
return backend_nsg_id
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def verify_browser_gateway_backend_nsg(
|
|
511
|
+
workloads_values: dict[str, object],
|
|
512
|
+
*,
|
|
513
|
+
observed_nsg_id: str,
|
|
514
|
+
) -> None:
|
|
515
|
+
"""Verify that the live AKS subnet NSG still matches the prepared gateway binding."""
|
|
516
|
+
|
|
517
|
+
gateway = mapping(workloads_values.get("browser_gateway"), "AKS browser gateway")
|
|
518
|
+
expected_nsg_id = require_browser_gateway_backend_nsg(
|
|
519
|
+
enabled=gateway.get("enabled") is True,
|
|
520
|
+
backend_nsg_id=str(gateway.get("backend_nsg_id", "")),
|
|
521
|
+
)
|
|
522
|
+
if expected_nsg_id and observed_nsg_id.strip().casefold() != expected_nsg_id.strip().casefold():
|
|
523
|
+
raise ValueError(
|
|
524
|
+
"AKS subnet network security group differs from the browser gateway binding"
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def validate_browser_console_binding(
|
|
529
|
+
*,
|
|
530
|
+
subscription_id: str,
|
|
531
|
+
hostname: str,
|
|
532
|
+
console_origin: str,
|
|
533
|
+
resource_id: str,
|
|
534
|
+
operator_url: str,
|
|
535
|
+
ingestion_url: str,
|
|
536
|
+
) -> dict[str, str]:
|
|
537
|
+
"""Validate cross-state SWA and APIM outputs for the AKS browser Console."""
|
|
538
|
+
|
|
539
|
+
static_site_match = re.fullmatch(
|
|
540
|
+
r"/subscriptions/([^/]+)/resourceGroups/[^/]+/providers/"
|
|
541
|
+
r"Microsoft[.]Web/staticSites/[^/]+",
|
|
542
|
+
resource_id,
|
|
543
|
+
flags=re.IGNORECASE,
|
|
544
|
+
)
|
|
545
|
+
if static_site_match is None:
|
|
546
|
+
raise ValueError("Console Static Web App resource ID is invalid")
|
|
547
|
+
if static_site_match.group(1).casefold() != subscription_id.casefold():
|
|
548
|
+
raise ValueError("Console Static Web App belongs to a different subscription")
|
|
549
|
+
operator_match = re.fullmatch(
|
|
550
|
+
r"https://([a-z0-9](?:[a-z0-9-]*[a-z0-9])?[.]azure-api[.]net)",
|
|
551
|
+
operator_url,
|
|
552
|
+
)
|
|
553
|
+
if operator_match is None:
|
|
554
|
+
raise ValueError("browser gateway Operator URL is invalid")
|
|
555
|
+
if ingestion_url != f"{operator_url}/ingestion":
|
|
556
|
+
raise ValueError("browser gateway ingestion URL is not bound to the Operator gateway")
|
|
557
|
+
return {
|
|
558
|
+
"console_hostname": hostname,
|
|
559
|
+
"console_origin": console_origin,
|
|
560
|
+
"console_static_web_app_id": resource_id,
|
|
561
|
+
"operator_api_base_url": operator_url,
|
|
562
|
+
"ingestion_api_base_url": ingestion_url,
|
|
563
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Verified managed-host Kubernetes tool execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
from collections.abc import Callable, Mapping
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.parse import urlencode
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def tools(context: Mapping[str, object]) -> tuple[str, str, dict[str, str]]:
|
|
13
|
+
"""Return the sealed kubectl pair and an environment that resolves its exec plugin."""
|
|
14
|
+
|
|
15
|
+
root = Path(str(context.get("kit_bin", "")))
|
|
16
|
+
selected: list[str] = []
|
|
17
|
+
for name in ("kubectl", "kubelogin"):
|
|
18
|
+
path = root / name
|
|
19
|
+
try:
|
|
20
|
+
details = path.lstat()
|
|
21
|
+
except OSError as exc:
|
|
22
|
+
raise ValueError(f"managed-host {name} is unavailable") from exc
|
|
23
|
+
if (
|
|
24
|
+
not stat.S_ISREG(details.st_mode)
|
|
25
|
+
or details.st_uid != os.geteuid()
|
|
26
|
+
or not details.st_mode & stat.S_IXUSR
|
|
27
|
+
):
|
|
28
|
+
raise ValueError(f"managed-host {name} is unavailable")
|
|
29
|
+
selected.append(str(path))
|
|
30
|
+
environment = dict(os.environ)
|
|
31
|
+
current_path = environment.get("PATH", "")
|
|
32
|
+
environment["PATH"] = str(root) if not current_path else f"{root}{os.pathsep}{current_path}"
|
|
33
|
+
return selected[0], selected[1], environment
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def capture_deployments(
|
|
37
|
+
context: Mapping[str, object],
|
|
38
|
+
*,
|
|
39
|
+
namespace: str,
|
|
40
|
+
service: str | None,
|
|
41
|
+
capture: Callable[..., str],
|
|
42
|
+
) -> str:
|
|
43
|
+
"""Read the typed Deployment collection with the sealed Kubernetes tools."""
|
|
44
|
+
|
|
45
|
+
kubeconfig = Path(str(context.get("kubeconfig", "")))
|
|
46
|
+
if not kubeconfig.is_file():
|
|
47
|
+
raise ValueError("AKS kubeconfig is unavailable for service update readback")
|
|
48
|
+
kubectl, _kubelogin, environment = tools(context)
|
|
49
|
+
endpoint = f"/apis/apps/v1/namespaces/{namespace}/deployments"
|
|
50
|
+
if service is not None:
|
|
51
|
+
endpoint = f"{endpoint}?{urlencode({'labelSelector': f'app.kubernetes.io/name={service}'})}"
|
|
52
|
+
return capture(
|
|
53
|
+
(
|
|
54
|
+
kubectl,
|
|
55
|
+
"get",
|
|
56
|
+
f"--raw={endpoint}",
|
|
57
|
+
"--request-timeout=60s",
|
|
58
|
+
f"--kubeconfig={kubeconfig}",
|
|
59
|
+
),
|
|
60
|
+
cwd=kubeconfig.parent,
|
|
61
|
+
timeout=90,
|
|
62
|
+
reason="AKS Deployment observation failed",
|
|
63
|
+
env=environment,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def capture_pods(
|
|
68
|
+
context: Mapping[str, object],
|
|
69
|
+
*,
|
|
70
|
+
namespace: str,
|
|
71
|
+
service: str,
|
|
72
|
+
capture: Callable[..., str],
|
|
73
|
+
) -> str:
|
|
74
|
+
"""Read one service's Pod collection with the sealed Kubernetes tools."""
|
|
75
|
+
|
|
76
|
+
kubeconfig = Path(str(context.get("kubeconfig", "")))
|
|
77
|
+
if not kubeconfig.is_file():
|
|
78
|
+
raise ValueError("AKS kubeconfig is unavailable for service update readback")
|
|
79
|
+
kubectl, _kubelogin, environment = tools(context)
|
|
80
|
+
return capture(
|
|
81
|
+
(
|
|
82
|
+
kubectl,
|
|
83
|
+
"get",
|
|
84
|
+
"pods",
|
|
85
|
+
"--namespace",
|
|
86
|
+
namespace,
|
|
87
|
+
"--output",
|
|
88
|
+
"json",
|
|
89
|
+
"--request-timeout=60s",
|
|
90
|
+
f"--selector=app.kubernetes.io/name={service}",
|
|
91
|
+
f"--kubeconfig={kubeconfig}",
|
|
92
|
+
),
|
|
93
|
+
cwd=kubeconfig.parent,
|
|
94
|
+
timeout=90,
|
|
95
|
+
reason="AKS Pod observation failed",
|
|
96
|
+
env=environment,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
__all__ = ["capture_deployments", "capture_pods", "tools"]
|