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,402 @@
|
|
|
1
|
+
"""Read-only Azure observations for the pre-runner bootstrap boundary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import UTC, datetime, timedelta
|
|
12
|
+
|
|
13
|
+
from fdai_deployment_cli.bootstrap_plan import (
|
|
14
|
+
FOUNDATION_PROVIDER_NAMESPACES,
|
|
15
|
+
BootstrapObservation,
|
|
16
|
+
BootstrapReconcileResult,
|
|
17
|
+
Classification,
|
|
18
|
+
build_intent,
|
|
19
|
+
timestamp,
|
|
20
|
+
validate_inputs,
|
|
21
|
+
)
|
|
22
|
+
from fdai_deployment_cli.contracts import ProvisionProfile
|
|
23
|
+
from fdai_deployment_cli.target import compute_target_binding
|
|
24
|
+
|
|
25
|
+
_NOT_FOUND_CODES = frozenset({"ResourceGroupNotFound", "ResourceNotFound"})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class CommandResult:
|
|
30
|
+
"""Bounded result from one fixed Azure CLI read."""
|
|
31
|
+
|
|
32
|
+
returncode: int
|
|
33
|
+
stdout: str
|
|
34
|
+
stderr: str = ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
CommandRunner = Callable[[tuple[str, ...]], CommandResult]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def reconcile_bootstrap(
|
|
41
|
+
profile: ProvisionProfile,
|
|
42
|
+
*,
|
|
43
|
+
source_commit: str,
|
|
44
|
+
ops_resource_group: str,
|
|
45
|
+
app_resource_group: str,
|
|
46
|
+
state_storage_account: str,
|
|
47
|
+
ttl_seconds: int = 3600,
|
|
48
|
+
now: datetime | None = None,
|
|
49
|
+
run: CommandRunner | None = None,
|
|
50
|
+
) -> BootstrapReconcileResult:
|
|
51
|
+
"""Read and seal the bounded state needed before a foundation approval."""
|
|
52
|
+
|
|
53
|
+
validate_inputs(
|
|
54
|
+
source_commit=source_commit,
|
|
55
|
+
ops_resource_group=ops_resource_group,
|
|
56
|
+
app_resource_group=app_resource_group,
|
|
57
|
+
state_storage_account=state_storage_account,
|
|
58
|
+
ttl_seconds=ttl_seconds,
|
|
59
|
+
)
|
|
60
|
+
command_runner = run or run_azure_cli
|
|
61
|
+
subscription_id, target_binding = _active_target(command_runner)
|
|
62
|
+
if target_binding != profile.target_binding:
|
|
63
|
+
raise ValueError("active Azure target does not match the provision profile")
|
|
64
|
+
|
|
65
|
+
observations = [
|
|
66
|
+
*(
|
|
67
|
+
_observe_provider(command_runner, namespace, subscription_id)
|
|
68
|
+
for namespace in FOUNDATION_PROVIDER_NAMESPACES
|
|
69
|
+
),
|
|
70
|
+
_observe_resource_group(
|
|
71
|
+
command_runner,
|
|
72
|
+
"ops-resource-group",
|
|
73
|
+
ops_resource_group,
|
|
74
|
+
profile.region,
|
|
75
|
+
subscription_id,
|
|
76
|
+
),
|
|
77
|
+
_observe_resource_group(
|
|
78
|
+
command_runner,
|
|
79
|
+
"application-resource-group",
|
|
80
|
+
app_resource_group,
|
|
81
|
+
profile.region,
|
|
82
|
+
subscription_id,
|
|
83
|
+
),
|
|
84
|
+
_observe_storage(
|
|
85
|
+
command_runner,
|
|
86
|
+
state_storage_account,
|
|
87
|
+
ops_resource_group,
|
|
88
|
+
profile.region,
|
|
89
|
+
subscription_id,
|
|
90
|
+
),
|
|
91
|
+
]
|
|
92
|
+
moment = (now or datetime.now(UTC)).astimezone(UTC)
|
|
93
|
+
return BootstrapReconcileResult(
|
|
94
|
+
intent=build_intent(
|
|
95
|
+
profile=profile,
|
|
96
|
+
source_commit=source_commit,
|
|
97
|
+
ops_resource_group=ops_resource_group,
|
|
98
|
+
app_resource_group=app_resource_group,
|
|
99
|
+
state_storage_account=state_storage_account,
|
|
100
|
+
),
|
|
101
|
+
observations=tuple(observations),
|
|
102
|
+
created_at=timestamp(moment),
|
|
103
|
+
expires_at=timestamp(moment + timedelta(seconds=ttl_seconds)),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def run_azure_cli(arguments: tuple[str, ...]) -> CommandResult:
|
|
108
|
+
"""Execute one fixed Azure CLI read without exposing provider error text."""
|
|
109
|
+
|
|
110
|
+
executable = shutil.which("az")
|
|
111
|
+
if executable is None:
|
|
112
|
+
raise OSError("Azure CLI is unavailable")
|
|
113
|
+
try:
|
|
114
|
+
completed = subprocess.run(
|
|
115
|
+
[executable, *arguments],
|
|
116
|
+
check=False,
|
|
117
|
+
capture_output=True,
|
|
118
|
+
text=True,
|
|
119
|
+
timeout=30,
|
|
120
|
+
)
|
|
121
|
+
except subprocess.TimeoutExpired:
|
|
122
|
+
return CommandResult(returncode=124, stdout="", stderr="")
|
|
123
|
+
return CommandResult(
|
|
124
|
+
returncode=completed.returncode,
|
|
125
|
+
stdout=completed.stdout[:65_536],
|
|
126
|
+
stderr=completed.stderr[:65_536],
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _active_target(run: CommandRunner) -> tuple[str, str]:
|
|
131
|
+
result = run(
|
|
132
|
+
(
|
|
133
|
+
"account",
|
|
134
|
+
"show",
|
|
135
|
+
"--query",
|
|
136
|
+
"{subscription:id,tenant:tenantId}",
|
|
137
|
+
"--output",
|
|
138
|
+
"json",
|
|
139
|
+
"--only-show-errors",
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
if result.returncode != 0:
|
|
143
|
+
raise ValueError("azure_target_unavailable")
|
|
144
|
+
payload = _json_object(result.stdout, "Azure target")
|
|
145
|
+
subscription = payload.get("subscription")
|
|
146
|
+
tenant = payload.get("tenant")
|
|
147
|
+
if not isinstance(subscription, str) or not isinstance(tenant, str):
|
|
148
|
+
raise ValueError("azure_target_invalid")
|
|
149
|
+
return subscription, compute_target_binding(tenant_id=tenant, subscription_id=subscription)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _observe_provider(
|
|
153
|
+
run: CommandRunner,
|
|
154
|
+
namespace: str,
|
|
155
|
+
subscription_id: str,
|
|
156
|
+
) -> BootstrapObservation:
|
|
157
|
+
result = run(
|
|
158
|
+
(
|
|
159
|
+
"provider",
|
|
160
|
+
"show",
|
|
161
|
+
"--namespace",
|
|
162
|
+
namespace,
|
|
163
|
+
"--subscription",
|
|
164
|
+
subscription_id,
|
|
165
|
+
"--query",
|
|
166
|
+
"registrationState",
|
|
167
|
+
"--output",
|
|
168
|
+
"tsv",
|
|
169
|
+
"--only-show-errors",
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
entry_id = f"provider.{namespace.casefold()}"
|
|
173
|
+
if result.returncode != 0:
|
|
174
|
+
return BootstrapObservation(entry_id, Classification.INDETERMINATE, "provider_read_failed")
|
|
175
|
+
state = result.stdout.strip()
|
|
176
|
+
if state == "Registered":
|
|
177
|
+
return BootstrapObservation(entry_id, Classification.COMPATIBLE, "provider_registered")
|
|
178
|
+
if state in {"NotRegistered", "Unregistered"}:
|
|
179
|
+
return BootstrapObservation(
|
|
180
|
+
entry_id, Classification.MISSING, "provider_registration_required"
|
|
181
|
+
)
|
|
182
|
+
return BootstrapObservation(entry_id, Classification.INDETERMINATE, "provider_state_unresolved")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _observe_resource_group(
|
|
186
|
+
run: CommandRunner,
|
|
187
|
+
entry_id: str,
|
|
188
|
+
name: str,
|
|
189
|
+
region: str,
|
|
190
|
+
subscription_id: str,
|
|
191
|
+
) -> BootstrapObservation:
|
|
192
|
+
result = run(
|
|
193
|
+
(
|
|
194
|
+
"group",
|
|
195
|
+
"show",
|
|
196
|
+
"--name",
|
|
197
|
+
name,
|
|
198
|
+
"--subscription",
|
|
199
|
+
subscription_id,
|
|
200
|
+
"--query",
|
|
201
|
+
"{name:name,location:location}",
|
|
202
|
+
"--output",
|
|
203
|
+
"json",
|
|
204
|
+
"--only-show-errors",
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
if result.returncode != 0:
|
|
208
|
+
if _error_code(result.stderr) == "ResourceGroupNotFound":
|
|
209
|
+
return BootstrapObservation(entry_id, Classification.MISSING, "resource_group_missing")
|
|
210
|
+
return BootstrapObservation(
|
|
211
|
+
entry_id, Classification.INDETERMINATE, "resource_group_read_failed"
|
|
212
|
+
)
|
|
213
|
+
payload = _json_object(result.stdout, "resource group")
|
|
214
|
+
if (
|
|
215
|
+
payload.get("name") != name
|
|
216
|
+
or str(payload.get("location", "")).casefold() != region.casefold()
|
|
217
|
+
):
|
|
218
|
+
return BootstrapObservation(entry_id, Classification.CONFLICT, "resource_group_conflict")
|
|
219
|
+
return BootstrapObservation(entry_id, Classification.COMPATIBLE, "resource_group_compatible")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _observe_storage(
|
|
223
|
+
run: CommandRunner,
|
|
224
|
+
name: str,
|
|
225
|
+
resource_group: str,
|
|
226
|
+
region: str,
|
|
227
|
+
subscription_id: str,
|
|
228
|
+
) -> BootstrapObservation:
|
|
229
|
+
result = run(
|
|
230
|
+
(
|
|
231
|
+
"storage",
|
|
232
|
+
"account",
|
|
233
|
+
"show",
|
|
234
|
+
"--name",
|
|
235
|
+
name,
|
|
236
|
+
"--resource-group",
|
|
237
|
+
resource_group,
|
|
238
|
+
"--subscription",
|
|
239
|
+
subscription_id,
|
|
240
|
+
"--query",
|
|
241
|
+
"{name:name,location:location,kind:kind,sku:sku.name,"
|
|
242
|
+
"minimum_tls_version:minimumTlsVersion,public_network_access:publicNetworkAccess,"
|
|
243
|
+
"shared_key_access:allowSharedKeyAccess,blob_public_access:allowBlobPublicAccess,"
|
|
244
|
+
"cross_tenant_replication:allowCrossTenantReplication}",
|
|
245
|
+
"--output",
|
|
246
|
+
"json",
|
|
247
|
+
"--only-show-errors",
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
if result.returncode == 0:
|
|
251
|
+
posture = _classify_existing_storage(result.stdout, name=name, region=region)
|
|
252
|
+
if posture.classification is not Classification.COMPATIBLE:
|
|
253
|
+
return posture
|
|
254
|
+
return _observe_storage_data_protection(
|
|
255
|
+
run,
|
|
256
|
+
name=name,
|
|
257
|
+
resource_group=resource_group,
|
|
258
|
+
subscription_id=subscription_id,
|
|
259
|
+
)
|
|
260
|
+
if _error_code(result.stderr) not in _NOT_FOUND_CODES:
|
|
261
|
+
return BootstrapObservation(
|
|
262
|
+
"state-storage", Classification.INDETERMINATE, "state_storage_read_failed"
|
|
263
|
+
)
|
|
264
|
+
return _observe_storage_name(run, name=name, subscription_id=subscription_id)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _classify_existing_storage(value: str, *, name: str, region: str) -> BootstrapObservation:
|
|
268
|
+
payload = _json_object(value, "state storage account")
|
|
269
|
+
expected: dict[str, object] = {
|
|
270
|
+
"name": name,
|
|
271
|
+
"location": region.casefold(),
|
|
272
|
+
"kind": "StorageV2",
|
|
273
|
+
"sku": "Standard_LRS",
|
|
274
|
+
"minimum_tls_version": "TLS1_2",
|
|
275
|
+
"public_network_access": "Disabled",
|
|
276
|
+
"shared_key_access": False,
|
|
277
|
+
"blob_public_access": False,
|
|
278
|
+
"cross_tenant_replication": False,
|
|
279
|
+
}
|
|
280
|
+
normalized = dict(payload)
|
|
281
|
+
normalized["location"] = str(normalized.get("location", "")).casefold()
|
|
282
|
+
if normalized != expected:
|
|
283
|
+
return BootstrapObservation(
|
|
284
|
+
"state-storage", Classification.CONFLICT, "state_storage_posture_conflict"
|
|
285
|
+
)
|
|
286
|
+
return BootstrapObservation(
|
|
287
|
+
"state-storage", Classification.COMPATIBLE, "state_storage_compatible"
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _observe_storage_data_protection(
|
|
292
|
+
run: CommandRunner,
|
|
293
|
+
*,
|
|
294
|
+
name: str,
|
|
295
|
+
resource_group: str,
|
|
296
|
+
subscription_id: str,
|
|
297
|
+
) -> BootstrapObservation:
|
|
298
|
+
result = run(
|
|
299
|
+
(
|
|
300
|
+
"storage",
|
|
301
|
+
"account",
|
|
302
|
+
"blob-service-properties",
|
|
303
|
+
"show",
|
|
304
|
+
"--account-name",
|
|
305
|
+
name,
|
|
306
|
+
"--resource-group",
|
|
307
|
+
resource_group,
|
|
308
|
+
"--subscription",
|
|
309
|
+
subscription_id,
|
|
310
|
+
"--query",
|
|
311
|
+
"{versioning:isVersioningEnabled,blob_delete_retention:deleteRetentionPolicy.enabled,"
|
|
312
|
+
"container_delete_retention:containerDeleteRetentionPolicy.enabled}",
|
|
313
|
+
"--output",
|
|
314
|
+
"json",
|
|
315
|
+
"--only-show-errors",
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
if result.returncode != 0:
|
|
319
|
+
return BootstrapObservation(
|
|
320
|
+
"state-storage",
|
|
321
|
+
Classification.INDETERMINATE,
|
|
322
|
+
"state_storage_data_protection_read_failed",
|
|
323
|
+
)
|
|
324
|
+
payload = _json_object(result.stdout, "state storage data protection")
|
|
325
|
+
if payload != {
|
|
326
|
+
"versioning": True,
|
|
327
|
+
"blob_delete_retention": True,
|
|
328
|
+
"container_delete_retention": True,
|
|
329
|
+
}:
|
|
330
|
+
return BootstrapObservation(
|
|
331
|
+
"state-storage",
|
|
332
|
+
Classification.MISSING,
|
|
333
|
+
"state_storage_data_protection_required",
|
|
334
|
+
)
|
|
335
|
+
return BootstrapObservation(
|
|
336
|
+
"state-storage",
|
|
337
|
+
Classification.COMPATIBLE,
|
|
338
|
+
"state_storage_compatible",
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _observe_storage_name(
|
|
343
|
+
run: CommandRunner,
|
|
344
|
+
*,
|
|
345
|
+
name: str,
|
|
346
|
+
subscription_id: str,
|
|
347
|
+
) -> BootstrapObservation:
|
|
348
|
+
result = run(
|
|
349
|
+
(
|
|
350
|
+
"storage",
|
|
351
|
+
"account",
|
|
352
|
+
"check-name",
|
|
353
|
+
"--name",
|
|
354
|
+
name,
|
|
355
|
+
"--subscription",
|
|
356
|
+
subscription_id,
|
|
357
|
+
"--query",
|
|
358
|
+
"{available:nameAvailable,reason:reason}",
|
|
359
|
+
"--output",
|
|
360
|
+
"json",
|
|
361
|
+
"--only-show-errors",
|
|
362
|
+
)
|
|
363
|
+
)
|
|
364
|
+
if result.returncode != 0:
|
|
365
|
+
return BootstrapObservation(
|
|
366
|
+
"state-storage", Classification.INDETERMINATE, "state_storage_name_check_failed"
|
|
367
|
+
)
|
|
368
|
+
available = _json_object(result.stdout, "state storage name").get("available")
|
|
369
|
+
if available is True:
|
|
370
|
+
return BootstrapObservation(
|
|
371
|
+
"state-storage", Classification.MISSING, "state_storage_missing"
|
|
372
|
+
)
|
|
373
|
+
if available is False:
|
|
374
|
+
return BootstrapObservation(
|
|
375
|
+
"state-storage", Classification.CONFLICT, "state_storage_name_unavailable"
|
|
376
|
+
)
|
|
377
|
+
return BootstrapObservation(
|
|
378
|
+
"state-storage", Classification.INDETERMINATE, "state_storage_name_unresolved"
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _json_object(value: str, label: str) -> dict[str, object]:
|
|
383
|
+
try:
|
|
384
|
+
payload = json.loads(value)
|
|
385
|
+
except json.JSONDecodeError as exc:
|
|
386
|
+
raise ValueError(f"{label} returned invalid JSON") from exc
|
|
387
|
+
if not isinstance(payload, dict):
|
|
388
|
+
raise ValueError(f"{label} returned invalid JSON")
|
|
389
|
+
return payload
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _error_code(value: str) -> str | None:
|
|
393
|
+
try:
|
|
394
|
+
payload = json.loads(value)
|
|
395
|
+
except json.JSONDecodeError:
|
|
396
|
+
payload = None
|
|
397
|
+
if isinstance(payload, dict):
|
|
398
|
+
error = payload.get("error", payload)
|
|
399
|
+
if isinstance(error, dict) and isinstance(error.get("code"), str):
|
|
400
|
+
return str(error["code"])
|
|
401
|
+
match = re.search(r"\b(ResourceGroupNotFound|ResourceNotFound)\b", value)
|
|
402
|
+
return match.group(1) if match else None
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Resolve immutable support source only after the existing signed Hub verification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from fdai_deployment_cli.hub_image_deploy import DEFAULT_HUB_URL, hub_request
|
|
11
|
+
from fdai_deployment_cli.hub_image_trust import (
|
|
12
|
+
MAX_SELECTION_BYTES,
|
|
13
|
+
HubImageSelection,
|
|
14
|
+
load_selection,
|
|
15
|
+
selection_bytes,
|
|
16
|
+
verify_export,
|
|
17
|
+
)
|
|
18
|
+
from fdai_deployment_cli.private_output import write_private_bytes
|
|
19
|
+
from fdai_deployment_cli.source_input import inspect_source
|
|
20
|
+
|
|
21
|
+
OFFICIAL_SOURCE = "https://github.com/dotnetpower/fdai.git"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def resolve_hub_source(
|
|
25
|
+
area: Path, pem: str, supplied: str | None
|
|
26
|
+
) -> tuple[Path, HubImageSelection]:
|
|
27
|
+
"""Fetch one verified content-addressed commit, never branches or executing hooks."""
|
|
28
|
+
selection_path = area / "deployment/hub-images/selection.json"
|
|
29
|
+
if selection_path.exists():
|
|
30
|
+
from fdai_deployment_cli.private_output import read_private_bytes
|
|
31
|
+
|
|
32
|
+
raw = read_private_bytes(selection_path, max_bytes=MAX_SELECTION_BYTES)
|
|
33
|
+
selection = load_selection(selection_path, hashlib.sha256(raw).hexdigest())
|
|
34
|
+
if (
|
|
35
|
+
selection.document["public_key"] != pem
|
|
36
|
+
or selection.document["hub_url"] != DEFAULT_HUB_URL
|
|
37
|
+
):
|
|
38
|
+
raise ValueError("Retained Hub trust changed")
|
|
39
|
+
# Refresh the signed catalog, recall and expiry using the existing immutable selection seam.
|
|
40
|
+
from fdai_deployment_cli.hub_image_deploy import prepare_hub_selection
|
|
41
|
+
|
|
42
|
+
source = Path(supplied) if supplied else area / "support-source"
|
|
43
|
+
key_path = area / "hub-public.pem"
|
|
44
|
+
if not key_path.exists():
|
|
45
|
+
write_private_bytes(key_path, pem.encode())
|
|
46
|
+
elif key_path.is_symlink() or key_path.read_bytes() != pem.encode():
|
|
47
|
+
raise ValueError("Retained Hub public key changed")
|
|
48
|
+
selection = prepare_hub_selection(
|
|
49
|
+
source_root=source,
|
|
50
|
+
work_dir=area / "deployment",
|
|
51
|
+
public_key_path=key_path,
|
|
52
|
+
)
|
|
53
|
+
return source, selection
|
|
54
|
+
_, headers, raw = hub_request(DEFAULT_HUB_URL, "/v1/catalog/export")
|
|
55
|
+
verified = verify_export(
|
|
56
|
+
raw,
|
|
57
|
+
signature=headers["x-fdai-export-signature"],
|
|
58
|
+
export_key_id=headers["x-fdai-export-key-id"],
|
|
59
|
+
trusted_pem=pem.encode(),
|
|
60
|
+
channel="RELEASE",
|
|
61
|
+
release_id=None,
|
|
62
|
+
)
|
|
63
|
+
commit = verified["source_commit"]
|
|
64
|
+
if not isinstance(commit, str) or re.fullmatch(r"[a-f0-9]{40}", commit) is None:
|
|
65
|
+
raise ValueError("Signed support source commit is invalid")
|
|
66
|
+
source = Path(supplied).absolute() if supplied else area / "support-source"
|
|
67
|
+
if supplied is None:
|
|
68
|
+
if source.exists():
|
|
69
|
+
inspect_source(source, expected_commit=commit)
|
|
70
|
+
deployment = area / "deployment"
|
|
71
|
+
if deployment.exists() and any(deployment.iterdir()):
|
|
72
|
+
raise ValueError("Hub support cannot adopt partial or source deployment state")
|
|
73
|
+
deployment.mkdir(mode=0o700, exist_ok=True)
|
|
74
|
+
else:
|
|
75
|
+
source.mkdir(mode=0o700)
|
|
76
|
+
commands = (
|
|
77
|
+
("git", "init", "--quiet", str(source)),
|
|
78
|
+
(
|
|
79
|
+
"git",
|
|
80
|
+
"-C",
|
|
81
|
+
str(source),
|
|
82
|
+
"-c",
|
|
83
|
+
"core.hooksPath=/dev/null",
|
|
84
|
+
"fetch",
|
|
85
|
+
"--quiet",
|
|
86
|
+
"--depth=1",
|
|
87
|
+
"--no-tags",
|
|
88
|
+
OFFICIAL_SOURCE,
|
|
89
|
+
commit,
|
|
90
|
+
),
|
|
91
|
+
(
|
|
92
|
+
"git",
|
|
93
|
+
"-C",
|
|
94
|
+
str(source),
|
|
95
|
+
"-c",
|
|
96
|
+
"core.hooksPath=/dev/null",
|
|
97
|
+
"checkout",
|
|
98
|
+
"--quiet",
|
|
99
|
+
"--detach",
|
|
100
|
+
commit,
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
import os
|
|
104
|
+
|
|
105
|
+
environment = {
|
|
106
|
+
key: value
|
|
107
|
+
for key, value in os.environ.items()
|
|
108
|
+
if key in {"PATH", "HOME", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE"}
|
|
109
|
+
}
|
|
110
|
+
environment.update(
|
|
111
|
+
GIT_CONFIG_GLOBAL=os.devnull,
|
|
112
|
+
GIT_CONFIG_NOSYSTEM="1",
|
|
113
|
+
GIT_TERMINAL_PROMPT="0",
|
|
114
|
+
)
|
|
115
|
+
for command in commands:
|
|
116
|
+
result = subprocess.run(
|
|
117
|
+
command,
|
|
118
|
+
stdin=subprocess.DEVNULL,
|
|
119
|
+
capture_output=True,
|
|
120
|
+
timeout=300,
|
|
121
|
+
check=False,
|
|
122
|
+
env=environment,
|
|
123
|
+
)
|
|
124
|
+
if result.returncode:
|
|
125
|
+
raise ValueError("Pinned official support source retrieval failed; no fallback")
|
|
126
|
+
inspect_source(source, expected_commit=commit)
|
|
127
|
+
selected = selection_bytes(
|
|
128
|
+
raw=raw,
|
|
129
|
+
signature=headers["x-fdai-export-signature"],
|
|
130
|
+
export_key_id=headers["x-fdai-export-key-id"],
|
|
131
|
+
pem=pem.encode(),
|
|
132
|
+
hub_url=DEFAULT_HUB_URL,
|
|
133
|
+
channel="RELEASE",
|
|
134
|
+
verified=verified,
|
|
135
|
+
)
|
|
136
|
+
selection_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
137
|
+
write_private_bytes(selection_path, selected)
|
|
138
|
+
return source, load_selection(selection_path, hashlib.sha256(selected).hexdigest())
|