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,558 @@
|
|
|
1
|
+
"""AKS initial-inventory execution and independent closure for standalone deployment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
from datetime import UTC, datetime, timedelta
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from fdai_deployment_cli.contracts import canonical_digest
|
|
15
|
+
from fdai_deployment_cli.standalone_aks_job_execution import execute_aks_cronjob_once
|
|
16
|
+
from fdai_deployment_cli.standalone_host_state import private_json, replace_private_json
|
|
17
|
+
from fdai_deployment_cli.standalone_host_values import vault_name
|
|
18
|
+
|
|
19
|
+
_GUID = re.compile(r"^[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$")
|
|
20
|
+
_CLOSURE_CLOCK_SKEW = timedelta(seconds=60)
|
|
21
|
+
_INITIAL_INVENTORY_SOURCES = ("arg", "arm")
|
|
22
|
+
_ATTEMPT_SCHEMA = "fdai.aks-initial-inventory-attempt.v1"
|
|
23
|
+
_MAX_ATTEMPTS = 3
|
|
24
|
+
_CLOSURE_FAILURE = re.compile(r"inventory closure verification failed: ([a-z_]+(?:,[a-z_]+)*)")
|
|
25
|
+
_CLOSURE_CONDITIONS = frozenset(
|
|
26
|
+
{
|
|
27
|
+
"verified_progress",
|
|
28
|
+
"active",
|
|
29
|
+
"active_generation",
|
|
30
|
+
"observed",
|
|
31
|
+
"source",
|
|
32
|
+
"subscription_scope",
|
|
33
|
+
"full_scope",
|
|
34
|
+
"provider_coverage",
|
|
35
|
+
"unmapped_reconciled",
|
|
36
|
+
"overlay_closed",
|
|
37
|
+
"child_sources",
|
|
38
|
+
"projection",
|
|
39
|
+
"fresh_generation",
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
# Without retained independent closure, a superseded Job generation needs a new bounded attempt.
|
|
43
|
+
_SUPERSEDED_CLOSURE_CONDITIONS = frozenset({"active_generation"})
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _CapturedFailure(ValueError):
|
|
47
|
+
"""A captured command failed; the bounded stderr stays available for classification."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, reason: str, stderr: str) -> None:
|
|
50
|
+
super().__init__(reason)
|
|
51
|
+
self.stderr = stderr
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def initial_inventory_binding(subscription_id: str) -> dict[str, object]:
|
|
55
|
+
"""Return the protected full-subscription source and filter contract."""
|
|
56
|
+
|
|
57
|
+
if _GUID.fullmatch(subscription_id) is None:
|
|
58
|
+
raise ValueError("initial inventory subscription binding is invalid")
|
|
59
|
+
scope = f"/subscriptions/{subscription_id.casefold()}"
|
|
60
|
+
material: dict[str, object] = {
|
|
61
|
+
"schema_version": "fdai.initial-inventory-binding.v1",
|
|
62
|
+
"scope_digest": "sha256:" + hashlib.sha256(scope.encode()).hexdigest(),
|
|
63
|
+
"sources": list(_INITIAL_INVENTORY_SOURCES),
|
|
64
|
+
"resource_types": [],
|
|
65
|
+
}
|
|
66
|
+
return {**material, "binding_digest": canonical_digest(material)}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def run_initial_aks_inventory(
|
|
70
|
+
context: dict[str, Any],
|
|
71
|
+
work_dir: Path,
|
|
72
|
+
*,
|
|
73
|
+
receipt_path: Path,
|
|
74
|
+
) -> dict[str, object]:
|
|
75
|
+
"""Run the exact CronJob template and independently verify its active generation."""
|
|
76
|
+
|
|
77
|
+
source_revision = str(context.get("source_commit", ""))
|
|
78
|
+
target_binding = str(context.get("target_binding", ""))
|
|
79
|
+
binding = _validated_initial_inventory_binding(context)
|
|
80
|
+
sources = binding["sources"]
|
|
81
|
+
if not isinstance(sources, list):
|
|
82
|
+
raise ValueError( # noqa: TRY004 - normalize untrusted deployment state
|
|
83
|
+
"initial inventory source binding is invalid"
|
|
84
|
+
)
|
|
85
|
+
progress_url = str(context.get("inventory_progress_container_url", ""))
|
|
86
|
+
if not progress_url.startswith("https://"):
|
|
87
|
+
raise ValueError("initial inventory progress endpoint is invalid")
|
|
88
|
+
image_refs = _mapping(context.get("image_refs"), "runtime image references")
|
|
89
|
+
core_image = str(image_refs.get("core-control-plane", ""))
|
|
90
|
+
run_id = f"genesis.{source_revision}"
|
|
91
|
+
attempt = _begin_inventory_attempt(
|
|
92
|
+
work_dir,
|
|
93
|
+
source_revision=source_revision,
|
|
94
|
+
target_binding=target_binding,
|
|
95
|
+
)
|
|
96
|
+
attempt_id = (
|
|
97
|
+
"attempt."
|
|
98
|
+
+ hashlib.sha256(
|
|
99
|
+
f"{source_revision}:{target_binding}:initial-inventory:{attempt}".encode()
|
|
100
|
+
).hexdigest()[:32]
|
|
101
|
+
)
|
|
102
|
+
retry_attempt_id = (
|
|
103
|
+
"attempt."
|
|
104
|
+
+ hashlib.sha256(
|
|
105
|
+
f"{source_revision}:{target_binding}:initial-inventory:{attempt}:retry-1".encode()
|
|
106
|
+
).hexdigest()[:32]
|
|
107
|
+
)
|
|
108
|
+
second_retry_attempt_id = (
|
|
109
|
+
"attempt."
|
|
110
|
+
+ hashlib.sha256(
|
|
111
|
+
f"{source_revision}:{target_binding}:initial-inventory:{attempt}:retry-2".encode()
|
|
112
|
+
).hexdigest()[:32]
|
|
113
|
+
)
|
|
114
|
+
execution_environment = {
|
|
115
|
+
"FDAI_INVENTORY_EXECUTION_ATTEMPT": str(attempt),
|
|
116
|
+
"FDAI_INVENTORY_RESOURCE_TYPES": "",
|
|
117
|
+
"FDAI_INVENTORY_SCOPES": str(context["subscription_id"]),
|
|
118
|
+
"FDAI_INVENTORY_SOURCES": ",".join(str(source) for source in sources),
|
|
119
|
+
# The operations progress container is private to the managed host. The in-cluster
|
|
120
|
+
# Job records progress in PostgreSQL; the host closure completes the blob chain.
|
|
121
|
+
"FDAI_INVENTORY_PROGRESS_ATTEMPT_ID": attempt_id,
|
|
122
|
+
"FDAI_INVENTORY_PROGRESS_RUN_ID": run_id,
|
|
123
|
+
}
|
|
124
|
+
try:
|
|
125
|
+
execution = execute_aks_cronjob_once(
|
|
126
|
+
context,
|
|
127
|
+
work_dir,
|
|
128
|
+
template_name="inventory",
|
|
129
|
+
purpose="inventory",
|
|
130
|
+
expected_container_name="inventory",
|
|
131
|
+
expected_image=core_image,
|
|
132
|
+
expected_command=("python", "-m", "fdai.delivery.inventory_sync_cli"),
|
|
133
|
+
expected_service_account="inventory-job",
|
|
134
|
+
args=("--initial",),
|
|
135
|
+
environment=execution_environment,
|
|
136
|
+
require_suspended=False,
|
|
137
|
+
timeout_seconds=960,
|
|
138
|
+
failed_successor_limit=2,
|
|
139
|
+
retry_environments=(
|
|
140
|
+
{
|
|
141
|
+
**execution_environment,
|
|
142
|
+
"FDAI_INVENTORY_PROGRESS_ATTEMPT_ID": retry_attempt_id,
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
**execution_environment,
|
|
146
|
+
"FDAI_INVENTORY_PROGRESS_ATTEMPT_ID": second_retry_attempt_id,
|
|
147
|
+
},
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
except ValueError as exc:
|
|
151
|
+
if str(exc) == "AKS inventory Job failed":
|
|
152
|
+
_write_inventory_attempt(
|
|
153
|
+
work_dir,
|
|
154
|
+
source_revision=source_revision,
|
|
155
|
+
target_binding=target_binding,
|
|
156
|
+
attempt=attempt,
|
|
157
|
+
state="failed",
|
|
158
|
+
)
|
|
159
|
+
raise
|
|
160
|
+
_write_inventory_attempt(
|
|
161
|
+
work_dir,
|
|
162
|
+
source_revision=source_revision,
|
|
163
|
+
target_binding=target_binding,
|
|
164
|
+
attempt=attempt,
|
|
165
|
+
state="succeeded",
|
|
166
|
+
)
|
|
167
|
+
if execution.name.startswith("fdai-inventory-retry-1-"):
|
|
168
|
+
attempt_id = retry_attempt_id
|
|
169
|
+
elif execution.name.startswith("fdai-inventory-retry-2-"):
|
|
170
|
+
attempt_id = second_retry_attempt_id
|
|
171
|
+
infra = Path(str(context["infra"]))
|
|
172
|
+
bundle = infra.parent
|
|
173
|
+
key_vault_name = vault_name(_terraform_output(infra, "key_vault_uri"))
|
|
174
|
+
dsn = _capture(
|
|
175
|
+
(
|
|
176
|
+
"az",
|
|
177
|
+
"keyvault",
|
|
178
|
+
"secret",
|
|
179
|
+
"show",
|
|
180
|
+
"--vault-name",
|
|
181
|
+
key_vault_name,
|
|
182
|
+
"--name",
|
|
183
|
+
"fdai-state-store-dsn",
|
|
184
|
+
"--query",
|
|
185
|
+
"value",
|
|
186
|
+
"--output",
|
|
187
|
+
"tsv",
|
|
188
|
+
"--only-show-errors",
|
|
189
|
+
),
|
|
190
|
+
cwd=work_dir,
|
|
191
|
+
timeout=120,
|
|
192
|
+
reason="initial inventory database reference is unavailable",
|
|
193
|
+
).strip()
|
|
194
|
+
if not dsn or any(character in dsn for character in "\r\n"):
|
|
195
|
+
raise ValueError("initial inventory database reference is invalid")
|
|
196
|
+
runtime_python = work_dir / "runtime-venv/bin/python"
|
|
197
|
+
closure_environment = {
|
|
198
|
+
**os.environ,
|
|
199
|
+
"AZURE_CLIENT_ID": str(context["client_id"]),
|
|
200
|
+
"FDAI_MI_CLIENT_ID": str(context["client_id"]),
|
|
201
|
+
"FDAI_EXECUTION_VENUE": "deployed",
|
|
202
|
+
"FDAI_INVENTORY_DSN": dsn,
|
|
203
|
+
"FDAI_INVENTORY_EXPECTED_SCOPE_DIGEST": str(binding["scope_digest"]),
|
|
204
|
+
"FDAI_INVENTORY_RESOURCE_TYPES": "",
|
|
205
|
+
"FDAI_INVENTORY_SCOPES": str(context["subscription_id"]),
|
|
206
|
+
"FDAI_INVENTORY_SOURCES": ",".join(str(source) for source in sources),
|
|
207
|
+
"FDAI_INVENTORY_PROGRESS_CONTAINER_URL": progress_url,
|
|
208
|
+
"FDAI_INVENTORY_PROGRESS_RUN_ID": run_id,
|
|
209
|
+
"FDAI_INVENTORY_PROGRESS_ATTEMPT_ID": attempt_id,
|
|
210
|
+
"PYTHONPATH": os.pathsep.join(
|
|
211
|
+
(
|
|
212
|
+
str(bundle / "services/core-control-plane/src"),
|
|
213
|
+
str(bundle / "packages/service-contracts/src"),
|
|
214
|
+
)
|
|
215
|
+
),
|
|
216
|
+
}
|
|
217
|
+
attempt_record = private_json(work_dir / "initial-inventory-attempt.json", "inventory attempt")
|
|
218
|
+
closure_started_at = _parse_observed_at(attempt_record.get("started_at"))
|
|
219
|
+
if closure_started_at is None:
|
|
220
|
+
raise ValueError("initial inventory attempt start time is invalid")
|
|
221
|
+
try:
|
|
222
|
+
closure_output = _capture_env(
|
|
223
|
+
(str(runtime_python), "-m", "fdai.delivery.inventory_closure_cli"),
|
|
224
|
+
cwd=bundle,
|
|
225
|
+
env=closure_environment,
|
|
226
|
+
timeout=300,
|
|
227
|
+
reason="initial inventory independent closure failed",
|
|
228
|
+
)
|
|
229
|
+
except _CapturedFailure as exc:
|
|
230
|
+
conditions = _failed_closure_conditions(exc.stderr)
|
|
231
|
+
if conditions and conditions <= _SUPERSEDED_CLOSURE_CONDITIONS:
|
|
232
|
+
_write_inventory_attempt(
|
|
233
|
+
work_dir,
|
|
234
|
+
source_revision=source_revision,
|
|
235
|
+
target_binding=target_binding,
|
|
236
|
+
attempt=attempt,
|
|
237
|
+
state="failed",
|
|
238
|
+
)
|
|
239
|
+
raise ValueError(
|
|
240
|
+
"initial inventory independent closure failed: the Job generation was "
|
|
241
|
+
"superseded by a newer active generation; the next invocation starts a new "
|
|
242
|
+
f"attempt ({','.join(sorted(conditions))})"
|
|
243
|
+
) from None
|
|
244
|
+
if conditions:
|
|
245
|
+
raise ValueError(
|
|
246
|
+
f"initial inventory independent closure failed: {','.join(sorted(conditions))}"
|
|
247
|
+
) from None
|
|
248
|
+
raise
|
|
249
|
+
closure = _verified_closure(
|
|
250
|
+
closure_output,
|
|
251
|
+
expected_attempt_id=attempt_id,
|
|
252
|
+
expected_run_id=run_id,
|
|
253
|
+
expected_scope_digest=str(binding["scope_digest"]),
|
|
254
|
+
observed_after=closure_started_at,
|
|
255
|
+
)
|
|
256
|
+
closure_environment["FDAI_INVENTORY_DSN"] = ""
|
|
257
|
+
receipt: dict[str, object] = {
|
|
258
|
+
"schema_version": "fdai.standalone-aks-initial-inventory-receipt.v1",
|
|
259
|
+
"state": "inventory-verified",
|
|
260
|
+
"source_revision": source_revision,
|
|
261
|
+
"run_id": run_id,
|
|
262
|
+
"attempt_id": attempt_id,
|
|
263
|
+
"execution_ref_digest": hashlib.sha256(execution.name.encode()).hexdigest(),
|
|
264
|
+
"execution_template_digest": execution.execution_digest,
|
|
265
|
+
"execution_observer": "kubernetes-job",
|
|
266
|
+
"inventory_binding_digest": binding["binding_digest"],
|
|
267
|
+
"scope_digest": closure["scope_digest"],
|
|
268
|
+
"sources": list(sources),
|
|
269
|
+
"full_subscription": closure["subscription_root"],
|
|
270
|
+
"resource_type_filter": closure["resource_type_filter"],
|
|
271
|
+
"progress_persisted": True,
|
|
272
|
+
"complete_generation_readback_verified": True,
|
|
273
|
+
"active_generation_readback_verified": True,
|
|
274
|
+
"generation_digest": closure["generation_digest"],
|
|
275
|
+
"fresh_generation": closure["fresh_generation"],
|
|
276
|
+
"freshness_observed_at": closure["observed_at"],
|
|
277
|
+
"resource_count": closure["resource_count"],
|
|
278
|
+
"link_count": closure["link_count"],
|
|
279
|
+
"unmapped_object_count": closure["unmapped_object_count"],
|
|
280
|
+
"coverage_gap_count": closure["coverage_gap_count"],
|
|
281
|
+
"closure_receipt_digest": closure["receipt_digest"],
|
|
282
|
+
"closure_receipt_verified": True,
|
|
283
|
+
"effect_verified": True,
|
|
284
|
+
"mutation_performed": True,
|
|
285
|
+
"subscription_ready": False,
|
|
286
|
+
}
|
|
287
|
+
receipt["receipt_digest"] = canonical_digest(receipt)
|
|
288
|
+
replace_private_json(receipt_path, receipt)
|
|
289
|
+
return receipt
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _begin_inventory_attempt(
|
|
293
|
+
work_dir: Path,
|
|
294
|
+
*,
|
|
295
|
+
source_revision: str,
|
|
296
|
+
target_binding: str,
|
|
297
|
+
) -> int:
|
|
298
|
+
path = work_dir / "initial-inventory-attempt.json"
|
|
299
|
+
if not path.exists():
|
|
300
|
+
_write_inventory_attempt(
|
|
301
|
+
work_dir,
|
|
302
|
+
source_revision=source_revision,
|
|
303
|
+
target_binding=target_binding,
|
|
304
|
+
attempt=1,
|
|
305
|
+
state="active",
|
|
306
|
+
)
|
|
307
|
+
return 1
|
|
308
|
+
value = private_json(path, "initial inventory attempt")
|
|
309
|
+
digest = value.pop("record_digest", None)
|
|
310
|
+
attempt = value.get("attempt")
|
|
311
|
+
state = value.get("state")
|
|
312
|
+
if (
|
|
313
|
+
digest != canonical_digest(value)
|
|
314
|
+
or value.get("schema_version") != _ATTEMPT_SCHEMA
|
|
315
|
+
or value.get("source_revision") != source_revision
|
|
316
|
+
or value.get("target_binding") != target_binding
|
|
317
|
+
or type(attempt) is not int
|
|
318
|
+
or not 1 <= attempt <= _MAX_ATTEMPTS
|
|
319
|
+
or state not in {"active", "failed", "succeeded"}
|
|
320
|
+
or ("started_at" in value and _parse_observed_at(value["started_at"]) is None)
|
|
321
|
+
):
|
|
322
|
+
raise ValueError("initial inventory attempt is invalid")
|
|
323
|
+
if state != "failed":
|
|
324
|
+
return attempt
|
|
325
|
+
if attempt >= _MAX_ATTEMPTS:
|
|
326
|
+
raise ValueError("initial inventory attempts are exhausted")
|
|
327
|
+
next_attempt = attempt + 1
|
|
328
|
+
_write_inventory_attempt(
|
|
329
|
+
work_dir,
|
|
330
|
+
source_revision=source_revision,
|
|
331
|
+
target_binding=target_binding,
|
|
332
|
+
attempt=next_attempt,
|
|
333
|
+
state="active",
|
|
334
|
+
)
|
|
335
|
+
return next_attempt
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _write_inventory_attempt(
|
|
339
|
+
work_dir: Path,
|
|
340
|
+
*,
|
|
341
|
+
source_revision: str,
|
|
342
|
+
target_binding: str,
|
|
343
|
+
attempt: int,
|
|
344
|
+
state: str,
|
|
345
|
+
) -> None:
|
|
346
|
+
path = work_dir / "initial-inventory-attempt.json"
|
|
347
|
+
started_at = datetime.now(tz=UTC).isoformat()
|
|
348
|
+
if path.exists():
|
|
349
|
+
previous = private_json(path, "initial inventory attempt")
|
|
350
|
+
if previous.get("attempt") == attempt and "started_at" in previous:
|
|
351
|
+
started_at = str(previous["started_at"])
|
|
352
|
+
value: dict[str, object] = {
|
|
353
|
+
"schema_version": _ATTEMPT_SCHEMA,
|
|
354
|
+
"source_revision": source_revision,
|
|
355
|
+
"target_binding": target_binding,
|
|
356
|
+
"attempt": attempt,
|
|
357
|
+
"state": state,
|
|
358
|
+
"started_at": started_at,
|
|
359
|
+
}
|
|
360
|
+
value["record_digest"] = canonical_digest(value)
|
|
361
|
+
replace_private_json(path, value)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def require_initial_inventory_receipt(
|
|
365
|
+
value: dict[str, Any],
|
|
366
|
+
*,
|
|
367
|
+
runtime_platform: str,
|
|
368
|
+
) -> None:
|
|
369
|
+
"""Require AKS complete-generation evidence without changing Container Apps."""
|
|
370
|
+
|
|
371
|
+
if (
|
|
372
|
+
value.get("state") != "inventory-verified"
|
|
373
|
+
or value.get("active_generation_readback_verified") is not True
|
|
374
|
+
or value.get("progress_persisted") is not True
|
|
375
|
+
):
|
|
376
|
+
raise ValueError("standalone initial inventory is incomplete")
|
|
377
|
+
if runtime_platform != "aks":
|
|
378
|
+
return
|
|
379
|
+
receipt = {str(key): item for key, item in value.items()}
|
|
380
|
+
supplied_digest = receipt.pop("receipt_digest", None)
|
|
381
|
+
if (
|
|
382
|
+
supplied_digest != canonical_digest(receipt)
|
|
383
|
+
or receipt.get("complete_generation_readback_verified") is not True
|
|
384
|
+
or receipt.get("fresh_generation") is not True
|
|
385
|
+
or receipt.get("closure_receipt_verified") is not True
|
|
386
|
+
or receipt.get("sources") != list(_INITIAL_INVENTORY_SOURCES)
|
|
387
|
+
or re.fullmatch(r"sha256:[0-9a-f]{64}", str(receipt.get("scope_digest", ""))) is None
|
|
388
|
+
or re.fullmatch(
|
|
389
|
+
r"sha256:[0-9a-f]{64}",
|
|
390
|
+
str(receipt.get("closure_receipt_digest", "")),
|
|
391
|
+
)
|
|
392
|
+
is None
|
|
393
|
+
or re.fullmatch(
|
|
394
|
+
r"[0-9a-f]{64}",
|
|
395
|
+
str(receipt.get("inventory_binding_digest", "")),
|
|
396
|
+
)
|
|
397
|
+
is None
|
|
398
|
+
or not _coverage_counts_are_valid(receipt)
|
|
399
|
+
or _parse_observed_at(receipt.get("freshness_observed_at")) is None
|
|
400
|
+
):
|
|
401
|
+
raise ValueError("standalone initial inventory is incomplete")
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _verified_closure(
|
|
405
|
+
raw: str,
|
|
406
|
+
*,
|
|
407
|
+
expected_attempt_id: str,
|
|
408
|
+
expected_run_id: str,
|
|
409
|
+
expected_scope_digest: str,
|
|
410
|
+
observed_after: datetime,
|
|
411
|
+
) -> dict[str, object]:
|
|
412
|
+
try:
|
|
413
|
+
value = json.loads(raw)
|
|
414
|
+
except json.JSONDecodeError as exc:
|
|
415
|
+
raise ValueError("initial inventory closure receipt is invalid") from exc
|
|
416
|
+
if not isinstance(value, dict):
|
|
417
|
+
raise ValueError( # noqa: TRY004 - normalize untrusted receipt JSON
|
|
418
|
+
"initial inventory closure receipt is invalid"
|
|
419
|
+
)
|
|
420
|
+
required_true = (
|
|
421
|
+
"subscription_root",
|
|
422
|
+
"final_fence",
|
|
423
|
+
"provider_coverage_complete",
|
|
424
|
+
"active_generation_matches",
|
|
425
|
+
"child_sources_complete",
|
|
426
|
+
"fresh_generation",
|
|
427
|
+
"observer_distinct",
|
|
428
|
+
)
|
|
429
|
+
observed_at = _parse_observed_at(value.get("observed_at"))
|
|
430
|
+
supplied_digest = value.get("receipt_digest")
|
|
431
|
+
receipt_material = {str(key): item for key, item in value.items() if key != "receipt_digest"}
|
|
432
|
+
if (
|
|
433
|
+
value.get("schema_version") != "1.0.0"
|
|
434
|
+
or value.get("run_id") != expected_run_id
|
|
435
|
+
or value.get("attempt_id") != expected_attempt_id
|
|
436
|
+
or value.get("scope_digest") != expected_scope_digest
|
|
437
|
+
or any(value.get(name) is not True for name in required_true)
|
|
438
|
+
or value.get("resource_type_filter") is not False
|
|
439
|
+
or value.get("truncated") is not False
|
|
440
|
+
or value.get("overlay_open") is not False
|
|
441
|
+
or value.get("execution_authority") is not False
|
|
442
|
+
or not _coverage_counts_are_valid(value)
|
|
443
|
+
or observed_at is None
|
|
444
|
+
or observed_at < observed_after - _CLOSURE_CLOCK_SKEW
|
|
445
|
+
or observed_at > datetime.now(tz=UTC) + _CLOSURE_CLOCK_SKEW
|
|
446
|
+
or re.fullmatch(r"sha256:[0-9a-f]{64}", str(value.get("generation_digest", ""))) is None
|
|
447
|
+
or supplied_digest != _closure_receipt_digest(receipt_material)
|
|
448
|
+
):
|
|
449
|
+
raise ValueError("initial inventory closure receipt is incomplete")
|
|
450
|
+
return {str(key): item for key, item in value.items()}
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _validated_initial_inventory_binding(context: dict[str, Any]) -> dict[str, object]:
|
|
454
|
+
subscription_id = str(context.get("subscription_id", ""))
|
|
455
|
+
expected = initial_inventory_binding(subscription_id)
|
|
456
|
+
supplied = context.get("initial_inventory_binding")
|
|
457
|
+
if supplied != expected:
|
|
458
|
+
raise ValueError("initial inventory protected binding differs")
|
|
459
|
+
return expected
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _coverage_counts_are_valid(value: dict[str, Any]) -> bool:
|
|
463
|
+
return all(
|
|
464
|
+
isinstance(value.get(name), int)
|
|
465
|
+
and not isinstance(value.get(name), bool)
|
|
466
|
+
and int(value[name]) >= 0
|
|
467
|
+
for name in (
|
|
468
|
+
"resource_count",
|
|
469
|
+
"link_count",
|
|
470
|
+
"unmapped_object_count",
|
|
471
|
+
"coverage_gap_count",
|
|
472
|
+
)
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _parse_observed_at(value: object) -> datetime | None:
|
|
477
|
+
if not isinstance(value, str):
|
|
478
|
+
return None
|
|
479
|
+
try:
|
|
480
|
+
parsed = datetime.fromisoformat(value)
|
|
481
|
+
except ValueError:
|
|
482
|
+
return None
|
|
483
|
+
if parsed.tzinfo is None:
|
|
484
|
+
return None
|
|
485
|
+
return parsed.astimezone(UTC)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _closure_receipt_digest(value: dict[str, object]) -> str:
|
|
489
|
+
payload = json.dumps(value, ensure_ascii=True, separators=(",", ":"), sort_keys=True)
|
|
490
|
+
return "sha256:" + hashlib.sha256(payload.encode()).hexdigest()
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _terraform_output(infra: Path, name: str) -> str:
|
|
494
|
+
return _capture(
|
|
495
|
+
("terraform", "output", "-raw", name),
|
|
496
|
+
cwd=infra,
|
|
497
|
+
timeout=120,
|
|
498
|
+
reason="Terraform output readback failed",
|
|
499
|
+
).strip()
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _capture(command: tuple[str, ...], *, cwd: Path, timeout: int, reason: str) -> str:
|
|
503
|
+
result = subprocess.run(
|
|
504
|
+
command,
|
|
505
|
+
cwd=cwd,
|
|
506
|
+
check=False,
|
|
507
|
+
capture_output=True,
|
|
508
|
+
text=True,
|
|
509
|
+
timeout=timeout,
|
|
510
|
+
)
|
|
511
|
+
if result.returncode != 0:
|
|
512
|
+
raise ValueError(reason)
|
|
513
|
+
return result.stdout
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _capture_env(
|
|
517
|
+
command: tuple[str, ...],
|
|
518
|
+
*,
|
|
519
|
+
cwd: Path,
|
|
520
|
+
env: dict[str, str],
|
|
521
|
+
timeout: int,
|
|
522
|
+
reason: str,
|
|
523
|
+
) -> str:
|
|
524
|
+
result = subprocess.run(
|
|
525
|
+
command,
|
|
526
|
+
cwd=cwd,
|
|
527
|
+
env=env,
|
|
528
|
+
check=False,
|
|
529
|
+
capture_output=True,
|
|
530
|
+
text=True,
|
|
531
|
+
timeout=timeout,
|
|
532
|
+
)
|
|
533
|
+
if result.returncode != 0:
|
|
534
|
+
raise _CapturedFailure(reason, result.stderr)
|
|
535
|
+
return result.stdout
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _failed_closure_conditions(stderr: str) -> frozenset[str]:
|
|
539
|
+
"""Return only the named verifier conditions, never free-form process output."""
|
|
540
|
+
|
|
541
|
+
matches = _CLOSURE_FAILURE.findall(stderr)
|
|
542
|
+
if not matches:
|
|
543
|
+
return frozenset()
|
|
544
|
+
names = frozenset(matches[-1].split(","))
|
|
545
|
+
return names if names <= _CLOSURE_CONDITIONS else frozenset()
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _mapping(value: object, label: str) -> dict[str, Any]:
|
|
549
|
+
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
|
|
550
|
+
raise ValueError(f"{label} is invalid")
|
|
551
|
+
return {str(key): item for key, item in value.items()}
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
__all__ = [
|
|
555
|
+
"initial_inventory_binding",
|
|
556
|
+
"require_initial_inventory_receipt",
|
|
557
|
+
"run_initial_aks_inventory",
|
|
558
|
+
]
|