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,251 @@
|
|
|
1
|
+
"""Standalone AKS frozen-scenario catalog-review checkpoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from fdai_deployment_cli.contracts import canonical_digest
|
|
13
|
+
from fdai_deployment_cli.standalone_aks_job_execution import execute_aks_cronjob_once
|
|
14
|
+
from fdai_deployment_cli.standalone_host_state import private_json, replace_private_json
|
|
15
|
+
|
|
16
|
+
_NAMESPACE = "fdai-runtime"
|
|
17
|
+
_TRIGGER_SCHEMA = "fdai.operational-catalog-review-trigger-receipt.v3"
|
|
18
|
+
_SCHEMA = "fdai.standalone-catalog-review-receipt.v3"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run_catalog_review(
|
|
22
|
+
context: dict[str, Any],
|
|
23
|
+
work_dir: Path,
|
|
24
|
+
*,
|
|
25
|
+
login: Callable[[dict[str, object], Path], None],
|
|
26
|
+
) -> dict[str, object]:
|
|
27
|
+
"""Run one deployed draft-only review and persist its sanitized receipt."""
|
|
28
|
+
|
|
29
|
+
receipt_path = work_dir / "catalog-review-receipt.json"
|
|
30
|
+
if receipt_path.exists():
|
|
31
|
+
return _validated_receipt(
|
|
32
|
+
private_json(receipt_path, "standalone catalog review receipt"),
|
|
33
|
+
context=context,
|
|
34
|
+
)
|
|
35
|
+
if context.get("catalog_review_selected") is not True:
|
|
36
|
+
receipt: dict[str, object] = {
|
|
37
|
+
"schema_version": _SCHEMA,
|
|
38
|
+
"state": "skipped",
|
|
39
|
+
"selected": False,
|
|
40
|
+
"reason": "not_selected",
|
|
41
|
+
"source_revision": str(context.get("source_commit", "")),
|
|
42
|
+
"catalog_review_profile_digest": str(context.get("catalog_review_profile_digest", "")),
|
|
43
|
+
"mutation_performed": False,
|
|
44
|
+
"subscription_ready": False,
|
|
45
|
+
}
|
|
46
|
+
receipt["receipt_digest"] = canonical_digest(receipt)
|
|
47
|
+
replace_private_json(receipt_path, receipt)
|
|
48
|
+
return receipt
|
|
49
|
+
if _runtime_platform(context) != "aks":
|
|
50
|
+
raise ValueError("catalog review one-shot execution requires AKS")
|
|
51
|
+
if not (work_dir / "application-receipt.json").is_file():
|
|
52
|
+
raise ValueError("catalog review application prerequisite is incomplete")
|
|
53
|
+
values = private_json(work_dir / "workloads.auto.tfvars.json", "AKS workload variables")
|
|
54
|
+
scheduled_jobs = _mapping(values.get("scheduled_jobs"), "AKS scheduled jobs")
|
|
55
|
+
job = scheduled_jobs.get("catalog-review")
|
|
56
|
+
if not isinstance(job, dict) or context.get("catalog_review_available") is not True:
|
|
57
|
+
raise ValueError("catalog review private GitOps binding is unavailable")
|
|
58
|
+
login(context, work_dir)
|
|
59
|
+
execution = execute_aks_cronjob_once(
|
|
60
|
+
context,
|
|
61
|
+
work_dir,
|
|
62
|
+
template_name="catalog-review",
|
|
63
|
+
purpose="catalog-review",
|
|
64
|
+
expected_container_name="catalog-review",
|
|
65
|
+
expected_image=str(job.get("image", "")),
|
|
66
|
+
expected_command=(
|
|
67
|
+
"python",
|
|
68
|
+
"-m",
|
|
69
|
+
"fdai.runtime.operational_catalog_review_trigger",
|
|
70
|
+
),
|
|
71
|
+
expected_service_account="catalog-review-job",
|
|
72
|
+
args=(),
|
|
73
|
+
environment={},
|
|
74
|
+
require_suspended=True,
|
|
75
|
+
timeout_seconds=360,
|
|
76
|
+
)
|
|
77
|
+
kubeconfig = Path(str(context["kubeconfig"]))
|
|
78
|
+
logs = _capture(
|
|
79
|
+
(
|
|
80
|
+
"kubectl",
|
|
81
|
+
"logs",
|
|
82
|
+
f"job/{execution.name}",
|
|
83
|
+
"--namespace",
|
|
84
|
+
_NAMESPACE,
|
|
85
|
+
"--container",
|
|
86
|
+
"catalog-review",
|
|
87
|
+
"--tail",
|
|
88
|
+
"20",
|
|
89
|
+
"--limit-bytes",
|
|
90
|
+
"65536",
|
|
91
|
+
f"--kubeconfig={kubeconfig}",
|
|
92
|
+
),
|
|
93
|
+
cwd=work_dir,
|
|
94
|
+
timeout=60,
|
|
95
|
+
reason="catalog review sanitized receipt readback failed",
|
|
96
|
+
)
|
|
97
|
+
trigger_receipt = _receipt_from_logs(
|
|
98
|
+
logs,
|
|
99
|
+
source_revision=str(context.get("source_commit", "")),
|
|
100
|
+
)
|
|
101
|
+
receipt = {key: item for key, item in trigger_receipt.items() if key != "receipt_digest"}
|
|
102
|
+
receipt.update(
|
|
103
|
+
schema_version=_SCHEMA,
|
|
104
|
+
selected=True,
|
|
105
|
+
catalog_review_profile_digest=str(context["catalog_review_profile_digest"]),
|
|
106
|
+
trigger_receipt_digest=trigger_receipt["receipt_digest"],
|
|
107
|
+
)
|
|
108
|
+
receipt["receipt_digest"] = canonical_digest(receipt)
|
|
109
|
+
replace_private_json(receipt_path, receipt)
|
|
110
|
+
return receipt
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _receipt_from_logs(logs: str, *, source_revision: str) -> dict[str, object]:
|
|
114
|
+
for line in reversed(logs.splitlines()):
|
|
115
|
+
try:
|
|
116
|
+
candidate = json.loads(line)
|
|
117
|
+
except json.JSONDecodeError:
|
|
118
|
+
continue
|
|
119
|
+
if isinstance(candidate, dict) and candidate.get("schema_version") == _TRIGGER_SCHEMA:
|
|
120
|
+
return _validated_trigger_receipt(candidate, source_revision=source_revision)
|
|
121
|
+
raise ValueError("catalog review sanitized receipt is unavailable")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _validated_trigger_receipt(
|
|
125
|
+
value: dict[str, Any],
|
|
126
|
+
*,
|
|
127
|
+
source_revision: str,
|
|
128
|
+
) -> dict[str, object]:
|
|
129
|
+
receipt = {str(key): item for key, item in value.items()}
|
|
130
|
+
supplied_digest = receipt.pop("receipt_digest", None)
|
|
131
|
+
required_labels = receipt.get("required_labels")
|
|
132
|
+
observed_labels = receipt.get("observed_labels")
|
|
133
|
+
if (
|
|
134
|
+
receipt.get("schema_version") != _TRIGGER_SCHEMA
|
|
135
|
+
or receipt.get("state") != "draft-review-published"
|
|
136
|
+
or receipt.get("source_revision") != source_revision
|
|
137
|
+
or re.fullmatch(r"[0-9a-f]{40}", source_revision) is None
|
|
138
|
+
or not isinstance(required_labels, list)
|
|
139
|
+
or any(not isinstance(label, str) for label in required_labels)
|
|
140
|
+
or len(required_labels) != len(set(required_labels))
|
|
141
|
+
or required_labels != sorted(required_labels)
|
|
142
|
+
or observed_labels != required_labels
|
|
143
|
+
or not {"draft", "shadow", "governance", "catalog-review"}.issubset(required_labels)
|
|
144
|
+
or sum(isinstance(label, str) and label.startswith("rule:") for label in required_labels)
|
|
145
|
+
!= 1
|
|
146
|
+
or sum(isinstance(label, str) and label.startswith("action:") for label in required_labels)
|
|
147
|
+
!= 1
|
|
148
|
+
or receipt.get("draft") is not True
|
|
149
|
+
or receipt.get("mode") != "shadow"
|
|
150
|
+
or receipt.get("catalog_activation_performed") is not False
|
|
151
|
+
or receipt.get("code_path_merge_authority") is not False
|
|
152
|
+
or receipt.get("independent_pr_observation_required") is not True
|
|
153
|
+
or receipt.get("independent_pr_observation_verified") is not True
|
|
154
|
+
or receipt.get("independent_observation_scope") != "gitops-pr"
|
|
155
|
+
or receipt.get("durable_intent_verified") is not True
|
|
156
|
+
or receipt.get("durable_terminal_verified") is not True
|
|
157
|
+
or receipt.get("managed_resource_mutation_status") != "unknown"
|
|
158
|
+
or receipt.get("managed_resource_mutation_performed") is not None
|
|
159
|
+
or receipt.get("grants_authority") is not False
|
|
160
|
+
or not isinstance(receipt.get("already_existed"), bool)
|
|
161
|
+
or receipt.get("subscription_ready") is not False
|
|
162
|
+
or any(
|
|
163
|
+
re.fullmatch(r"[0-9a-f]{64}", str(receipt.get(name, ""))) is None
|
|
164
|
+
for name in (
|
|
165
|
+
"candidate_digest",
|
|
166
|
+
"package_digest",
|
|
167
|
+
"review_ref_digest",
|
|
168
|
+
"binding_digest",
|
|
169
|
+
"publication_observation_digest",
|
|
170
|
+
"review_document_digest",
|
|
171
|
+
"durable_audit_digest",
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
or re.fullmatch(
|
|
175
|
+
r"(?:[0-9a-f]{40}|[0-9a-f]{64})",
|
|
176
|
+
str(receipt.get("publication_head_sha", "")),
|
|
177
|
+
)
|
|
178
|
+
is None
|
|
179
|
+
or supplied_digest != canonical_digest(receipt)
|
|
180
|
+
):
|
|
181
|
+
raise ValueError("catalog review sanitized receipt is invalid")
|
|
182
|
+
receipt["receipt_digest"] = supplied_digest
|
|
183
|
+
return receipt
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _validated_receipt(
|
|
187
|
+
value: dict[str, Any],
|
|
188
|
+
*,
|
|
189
|
+
context: dict[str, Any],
|
|
190
|
+
) -> dict[str, object]:
|
|
191
|
+
receipt = {str(key): item for key, item in value.items()}
|
|
192
|
+
supplied_digest = receipt.pop("receipt_digest", None)
|
|
193
|
+
if (
|
|
194
|
+
receipt.get("schema_version") != _SCHEMA
|
|
195
|
+
or receipt.get("source_revision") != context.get("source_commit")
|
|
196
|
+
or receipt.get("catalog_review_profile_digest")
|
|
197
|
+
!= context.get("catalog_review_profile_digest")
|
|
198
|
+
or supplied_digest != canonical_digest(receipt)
|
|
199
|
+
):
|
|
200
|
+
raise ValueError("catalog review sanitized receipt is invalid")
|
|
201
|
+
selected = context.get("catalog_review_selected") is True
|
|
202
|
+
if selected:
|
|
203
|
+
if (
|
|
204
|
+
receipt.get("selected") is not True
|
|
205
|
+
or receipt.get("state") != "draft-review-published"
|
|
206
|
+
or receipt.get("independent_pr_observation_verified") is not True
|
|
207
|
+
or receipt.get("independent_observation_scope") != "gitops-pr"
|
|
208
|
+
):
|
|
209
|
+
raise ValueError("selected catalog review receipt is incomplete")
|
|
210
|
+
elif (
|
|
211
|
+
receipt.get("selected") is not False
|
|
212
|
+
or receipt.get("state") != "skipped"
|
|
213
|
+
or receipt.get("reason") != "not_selected"
|
|
214
|
+
):
|
|
215
|
+
raise ValueError("unselected catalog review receipt is invalid")
|
|
216
|
+
receipt["receipt_digest"] = supplied_digest
|
|
217
|
+
return receipt
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _runtime_platform(context: dict[str, Any]) -> str:
|
|
221
|
+
value = context.get("runtime_profile")
|
|
222
|
+
if value is None:
|
|
223
|
+
return "container-apps"
|
|
224
|
+
profile = _mapping(value, "runtime deployment profile")
|
|
225
|
+
platform = profile.get("runtime_platform")
|
|
226
|
+
if platform not in {"container-apps", "aks"}:
|
|
227
|
+
raise ValueError("runtime deployment platform is invalid")
|
|
228
|
+
return str(platform)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _capture(command: tuple[str, ...], *, cwd: Path, timeout: int, reason: str) -> str:
|
|
232
|
+
result = subprocess.run(
|
|
233
|
+
command,
|
|
234
|
+
cwd=cwd,
|
|
235
|
+
check=False,
|
|
236
|
+
capture_output=True,
|
|
237
|
+
text=True,
|
|
238
|
+
timeout=timeout,
|
|
239
|
+
)
|
|
240
|
+
if result.returncode != 0:
|
|
241
|
+
raise ValueError(reason)
|
|
242
|
+
return result.stdout
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _mapping(value: object, label: str) -> dict[str, Any]:
|
|
246
|
+
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
|
|
247
|
+
raise ValueError(f"{label} is invalid")
|
|
248
|
+
return {str(key): item for key, item in value.items()}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
__all__ = ["run_catalog_review"]
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Bounded failure summaries for standalone managed-host checkpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
_ARM_RESOURCE_ID = re.compile(r"/subscriptions/[^\s\"']+", re.IGNORECASE)
|
|
11
|
+
_GUID = re.compile(r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}")
|
|
12
|
+
_SECRET_ASSIGNMENT = re.compile(
|
|
13
|
+
r"(?i)\b(token|secret|password|key|connection[_ -]?string)\b\s*[:=]\s*\S+"
|
|
14
|
+
)
|
|
15
|
+
_HOSTNAME = re.compile(
|
|
16
|
+
r"\b(?=[A-Za-z0-9.-]{5,253}\b)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}"
|
|
17
|
+
r"[A-Za-z0-9])?\.)+(?:[A-Za-z]{2,63})\b"
|
|
18
|
+
)
|
|
19
|
+
_PROVIDER_ERROR_CODE = re.compile(
|
|
20
|
+
r"\b(?:OverconstrainedZonalAllocationRequest|AllocationFailed|ParameterOutOfRange|"
|
|
21
|
+
r"[A-Z][A-Za-z0-9]*(?:Failed|Failure|Denied|Error|NotFound|OutOfRange|Request))\b"
|
|
22
|
+
)
|
|
23
|
+
_FAILURE_EXCERPT_LIMIT = 700
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ManagedHostCheckpointError(ValueError):
|
|
27
|
+
"""A checkpoint failure that can be returned to the workstation safely."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
reason_code: str,
|
|
32
|
+
message: str,
|
|
33
|
+
*,
|
|
34
|
+
provider_error_codes: tuple[str, ...] = (),
|
|
35
|
+
excerpt: str = "",
|
|
36
|
+
) -> None:
|
|
37
|
+
super().__init__(message)
|
|
38
|
+
self.reason_code = reason_code
|
|
39
|
+
self.provider_error_codes = provider_error_codes
|
|
40
|
+
self.excerpt = excerpt
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def failure_record(exc: BaseException) -> dict[str, object]:
|
|
44
|
+
if isinstance(exc, ManagedHostCheckpointError):
|
|
45
|
+
reason_code = exc.reason_code
|
|
46
|
+
provider_error_codes = list(exc.provider_error_codes)
|
|
47
|
+
excerpt = exc.excerpt or sanitize_failure_text(str(exc))
|
|
48
|
+
else:
|
|
49
|
+
reason_code = "managed_host_checkpoint_failed"
|
|
50
|
+
provider_error_codes = provider_error_codes_from_text(str(exc))
|
|
51
|
+
excerpt = sanitize_failure_text(str(exc))
|
|
52
|
+
return {
|
|
53
|
+
"schema_version": "fdai.standalone-host-failure.v1",
|
|
54
|
+
"state": "failed",
|
|
55
|
+
"reason_code": reason_code,
|
|
56
|
+
"provider_error_codes": provider_error_codes,
|
|
57
|
+
"message_excerpt": excerpt,
|
|
58
|
+
"mutation_performed": False,
|
|
59
|
+
"subscription_ready": False,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def remote_failure(result: subprocess.CompletedProcess[object]) -> dict[str, Any] | None:
|
|
64
|
+
for stream in (getattr(result, "stderr", ""), getattr(result, "stdout", "")):
|
|
65
|
+
text = (
|
|
66
|
+
stream.decode("utf-8", errors="replace") if isinstance(stream, bytes) else str(stream)
|
|
67
|
+
)
|
|
68
|
+
for line in reversed(text.splitlines()):
|
|
69
|
+
try:
|
|
70
|
+
value = json.loads(line)
|
|
71
|
+
except json.JSONDecodeError:
|
|
72
|
+
continue
|
|
73
|
+
if (
|
|
74
|
+
isinstance(value, dict)
|
|
75
|
+
and value.get("schema_version") == "fdai.standalone-host-failure.v1"
|
|
76
|
+
and isinstance(value.get("reason_code"), str)
|
|
77
|
+
and isinstance(value.get("message_excerpt"), str)
|
|
78
|
+
):
|
|
79
|
+
return value
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def terraform_failure(reason: str, result: subprocess.CompletedProcess[Any]) -> ValueError:
|
|
84
|
+
output = "\n".join(
|
|
85
|
+
_output_part(part)
|
|
86
|
+
for part in (getattr(result, "stdout", ""), getattr(result, "stderr", ""))
|
|
87
|
+
if part not in (None, b"", "")
|
|
88
|
+
)
|
|
89
|
+
codes = tuple(provider_error_codes_from_text(output))
|
|
90
|
+
if codes:
|
|
91
|
+
# Terraform prints progress before diagnostics; the bounded excerpt must keep the error.
|
|
92
|
+
first_error = output.find("Error: ")
|
|
93
|
+
excerpt = sanitize_failure_text(output[first_error:] if first_error >= 0 else output)
|
|
94
|
+
return ManagedHostCheckpointError(
|
|
95
|
+
"terraform_provider_error",
|
|
96
|
+
f"{reason}; provider_error_code={','.join(codes)}; excerpt={excerpt}",
|
|
97
|
+
provider_error_codes=codes,
|
|
98
|
+
excerpt=excerpt,
|
|
99
|
+
)
|
|
100
|
+
return ValueError(reason)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def sanitize_failure_text(value: object) -> str:
|
|
104
|
+
text = value.decode("utf-8", errors="replace") if isinstance(value, bytes) else str(value)
|
|
105
|
+
text = _ARM_RESOURCE_ID.sub("<redacted-resource-id>", text)
|
|
106
|
+
text = _GUID.sub("<redacted-guid>", text)
|
|
107
|
+
text = _SECRET_ASSIGNMENT.sub(lambda match: f"{match.group(1)}=<redacted-secret>", text)
|
|
108
|
+
text = re.sub(r"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+", "Bearer <redacted-token>", text)
|
|
109
|
+
text = _HOSTNAME.sub("<redacted-hostname>", text)
|
|
110
|
+
text = re.sub(r"\s+", " ", text).strip()
|
|
111
|
+
if len(text) > _FAILURE_EXCERPT_LIMIT:
|
|
112
|
+
return text[: _FAILURE_EXCERPT_LIMIT - 3].rstrip() + "..."
|
|
113
|
+
return text
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def provider_error_codes_from_text(value: object) -> list[str]:
|
|
117
|
+
codes: list[str] = []
|
|
118
|
+
text = value.decode("utf-8", errors="replace") if isinstance(value, bytes) else str(value)
|
|
119
|
+
for match in _PROVIDER_ERROR_CODE.finditer(text):
|
|
120
|
+
code = match.group(0)
|
|
121
|
+
if code not in codes:
|
|
122
|
+
codes.append(code)
|
|
123
|
+
return codes[:5]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _output_part(value: object) -> str:
|
|
127
|
+
return value.decode("utf-8", errors="replace") if isinstance(value, bytes) else str(value)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Publish the verified standalone Console artifact."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import stat
|
|
10
|
+
import subprocess
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from fdai_deployment_cli.bundle import extract_bundle_archive
|
|
15
|
+
from fdai_deployment_cli.console_config import configure_console
|
|
16
|
+
from fdai_deployment_cli.contracts import canonical_digest
|
|
17
|
+
from fdai_deployment_cli.private_output import write_private_output
|
|
18
|
+
from fdai_deployment_cli.standalone_host_state import replace_private_json
|
|
19
|
+
|
|
20
|
+
_DIGEST = re.compile(r"[0-9a-f]{64}")
|
|
21
|
+
_PUBLISHER_FIXED_REASON = re.compile(r'echo "([^"$`\\]+)" >&2')
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def publish_verified_console(
|
|
25
|
+
*,
|
|
26
|
+
console_archive: Path,
|
|
27
|
+
console_archive_sha256: str,
|
|
28
|
+
bundle_root: Path,
|
|
29
|
+
prepared_root: Path,
|
|
30
|
+
entra_bindings: dict[str, str],
|
|
31
|
+
browser_console: dict[str, Any],
|
|
32
|
+
scripts: Path,
|
|
33
|
+
subscription_id: str,
|
|
34
|
+
tenant_id: str,
|
|
35
|
+
timeout_seconds: int,
|
|
36
|
+
redirect_changed: bool,
|
|
37
|
+
verify_only: bool = False,
|
|
38
|
+
verify_service_contracts: bool = True,
|
|
39
|
+
) -> dict[str, object]:
|
|
40
|
+
"""Configure a verified Console artifact, publish it, and read back exact bytes."""
|
|
41
|
+
|
|
42
|
+
_require_archive_digest(console_archive, console_archive_sha256)
|
|
43
|
+
extraction = prepared_root / "console-publish"
|
|
44
|
+
# A retained extraction may contain a prior release, not the just-verified archive.
|
|
45
|
+
_remove_retained_extraction(extraction)
|
|
46
|
+
console_directory = extract_bundle_archive(console_archive, extraction)
|
|
47
|
+
console_directory.chmod(0o700)
|
|
48
|
+
settings: dict[str, object] = {
|
|
49
|
+
"schema_version": "fdai.console-runtime.v1",
|
|
50
|
+
"operator_api_base_url": str(browser_console["operator_api_base_url"]),
|
|
51
|
+
"ingestion_api_base_url": str(browser_console["ingestion_api_base_url"]),
|
|
52
|
+
"tenant_id": tenant_id,
|
|
53
|
+
"spa_client_id": str(entra_bindings["ENTRA_CONSOLE_SPA_CLIENT_ID"]),
|
|
54
|
+
"api_scope": str(entra_bindings["ENTRA_CONSOLE_API_SCOPE"]),
|
|
55
|
+
}
|
|
56
|
+
settings_path = prepared_root / "console-runtime-settings.json"
|
|
57
|
+
replace_private_json(settings_path, settings)
|
|
58
|
+
configured = configure_console(
|
|
59
|
+
console_directory,
|
|
60
|
+
settings_path,
|
|
61
|
+
manual_studio_url=f"{browser_console['console_origin']}/manuals",
|
|
62
|
+
)
|
|
63
|
+
summary = prepared_root / "console-publish-summary.txt"
|
|
64
|
+
if not summary.exists():
|
|
65
|
+
write_private_output(summary, "")
|
|
66
|
+
environment: dict[str, str] = {
|
|
67
|
+
**os.environ,
|
|
68
|
+
"EXPECTED_AZURE_TENANT_ID": str(settings["tenant_id"]),
|
|
69
|
+
"ENTRA_CONSOLE_SPA_CLIENT_ID": str(settings["spa_client_id"]),
|
|
70
|
+
"ENTRA_CONSOLE_API_SCOPE": str(settings["api_scope"]),
|
|
71
|
+
"ARM_SUBSCRIPTION_ID": subscription_id,
|
|
72
|
+
"CONSOLE_DEFAULT_HOSTNAME": str(browser_console["console_hostname"]),
|
|
73
|
+
"CONSOLE_STATIC_WEB_APP_ID": str(browser_console["console_static_web_app_id"]),
|
|
74
|
+
"BROWSER_GATEWAY_OPERATOR_URL": str(settings["operator_api_base_url"]),
|
|
75
|
+
"BROWSER_GATEWAY_INGESTION_URL": str(settings["ingestion_api_base_url"]),
|
|
76
|
+
"CONSOLE_PREBUILT_DIRECTORY": str(console_directory),
|
|
77
|
+
"FDAI_CONSOLE_VERIFY_ONLY": "1" if verify_only else "0",
|
|
78
|
+
"FDAI_CONSOLE_VERIFY_SERVICE_CONTRACTS": "1" if verify_service_contracts else "0",
|
|
79
|
+
"GITHUB_STEP_SUMMARY": str(summary),
|
|
80
|
+
}
|
|
81
|
+
completed = subprocess.run(
|
|
82
|
+
(
|
|
83
|
+
"/bin/bash",
|
|
84
|
+
str(scripts / "publish-console.sh"),
|
|
85
|
+
str(bundle_root / "infra/runtimes/aks/workloads"),
|
|
86
|
+
),
|
|
87
|
+
cwd=bundle_root,
|
|
88
|
+
env=environment,
|
|
89
|
+
check=False,
|
|
90
|
+
capture_output=True,
|
|
91
|
+
text=True,
|
|
92
|
+
timeout=timeout_seconds,
|
|
93
|
+
)
|
|
94
|
+
if completed.returncode != 0:
|
|
95
|
+
reason = _publisher_failure_reason(scripts / "publish-console.sh", completed.stderr)
|
|
96
|
+
raise ValueError(
|
|
97
|
+
"prebuilt Console publication or browser verification failed"
|
|
98
|
+
+ (f": {reason}" if reason else "")
|
|
99
|
+
)
|
|
100
|
+
receipt: dict[str, object] = {
|
|
101
|
+
"schema_version": "fdai.standalone-console-publication.v1",
|
|
102
|
+
"state": "verified" if verify_only else "published",
|
|
103
|
+
"console_origin": browser_console["console_origin"],
|
|
104
|
+
"console_archive_sha256": console_archive_sha256,
|
|
105
|
+
"runtime_config_digest": configured["runtime_config_digest"],
|
|
106
|
+
"entra_redirect_changed": redirect_changed,
|
|
107
|
+
"artifact_hash_verified": True,
|
|
108
|
+
"spa_fallback_verified": True,
|
|
109
|
+
"api_health_verified": verify_service_contracts,
|
|
110
|
+
"authorization_preflight_verified": verify_service_contracts,
|
|
111
|
+
"unauthenticated_denial_verified": verify_service_contracts,
|
|
112
|
+
"entra_redirect_verified": verify_service_contracts,
|
|
113
|
+
"mutation_performed": not verify_only,
|
|
114
|
+
"subscription_ready": False,
|
|
115
|
+
}
|
|
116
|
+
receipt["receipt_digest"] = canonical_digest(receipt)
|
|
117
|
+
replace_private_json(prepared_root / "console-publication-receipt.json", receipt)
|
|
118
|
+
return receipt
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _publisher_failure_reason(publisher: Path, stderr: str | None) -> str | None:
|
|
122
|
+
"""Return the publisher's final literal failure reason, never a variable diagnostic."""
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
fixed_reasons = set(_PUBLISHER_FIXED_REASON.findall(publisher.read_text(encoding="utf-8")))
|
|
126
|
+
except OSError:
|
|
127
|
+
return None
|
|
128
|
+
for line in reversed((stderr or "").splitlines()):
|
|
129
|
+
if line.strip():
|
|
130
|
+
return line.strip() if line.strip() in fixed_reasons else None
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _remove_retained_extraction(extraction: Path) -> None:
|
|
135
|
+
"""Remove a prior Console extraction without following a substituted link."""
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
status = extraction.lstat()
|
|
139
|
+
except FileNotFoundError:
|
|
140
|
+
return
|
|
141
|
+
if not stat.S_ISDIR(status.st_mode) or status.st_uid != os.getuid():
|
|
142
|
+
raise ValueError("retained Console extraction is not a private directory")
|
|
143
|
+
shutil.rmtree(extraction)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _require_archive_digest(path: Path, expected_digest: str) -> None:
|
|
147
|
+
if _DIGEST.fullmatch(expected_digest) is None:
|
|
148
|
+
raise ValueError("Console archive digest is invalid")
|
|
149
|
+
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
|
150
|
+
try:
|
|
151
|
+
before = os.fstat(descriptor)
|
|
152
|
+
if not stat.S_ISREG(before.st_mode):
|
|
153
|
+
raise ValueError("Console archive is not a regular file")
|
|
154
|
+
with os.fdopen(descriptor, "rb", closefd=False) as stream:
|
|
155
|
+
observed = hashlib.file_digest(stream, "sha256").hexdigest()
|
|
156
|
+
after = os.fstat(descriptor)
|
|
157
|
+
if (
|
|
158
|
+
before.st_dev,
|
|
159
|
+
before.st_ino,
|
|
160
|
+
before.st_size,
|
|
161
|
+
before.st_mtime_ns,
|
|
162
|
+
before.st_ctime_ns,
|
|
163
|
+
) != (
|
|
164
|
+
after.st_dev,
|
|
165
|
+
after.st_ino,
|
|
166
|
+
after.st_size,
|
|
167
|
+
after.st_mtime_ns,
|
|
168
|
+
after.st_ctime_ns,
|
|
169
|
+
):
|
|
170
|
+
raise ValueError("Console archive changed while it was read")
|
|
171
|
+
if observed != expected_digest:
|
|
172
|
+
raise ValueError("Console archive digest does not match the signed release")
|
|
173
|
+
finally:
|
|
174
|
+
os.close(descriptor)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Principal selection for the in-cluster PostgreSQL stage of an AKS installation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from fdai_deployment_cli.standalone_operational_evidence import (
|
|
9
|
+
runtime_principal_ids_with_operational_evidence_verifier,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def database_reader_principals(
|
|
14
|
+
identities: Mapping[str, Any],
|
|
15
|
+
) -> tuple[set[str], str | None, str | None]:
|
|
16
|
+
"""Return the DSN reader principals for the in-cluster database stage.
|
|
17
|
+
|
|
18
|
+
Core and inventory always run. The Operator, executor, and document-ingestion identities exist
|
|
19
|
+
only when their product option is selected, so an absent one is granted nothing instead of
|
|
20
|
+
stopping the stage. A present identity must still be well formed.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
names = tuple(
|
|
24
|
+
name
|
|
25
|
+
for name in ("core", "operator", "executor", "inventory")
|
|
26
|
+
if name in {"core", "inventory"} or identities.get(name) is not None
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def optional(name: str, label: str) -> str | None:
|
|
30
|
+
value = identities.get(name)
|
|
31
|
+
if value is None:
|
|
32
|
+
return None
|
|
33
|
+
if not isinstance(value, dict) or not isinstance(value.get("principal_id"), str):
|
|
34
|
+
raise ValueError(f"{label} is invalid")
|
|
35
|
+
return str(value["principal_id"])
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
runtime_principal_ids_with_operational_evidence_verifier(identities, names),
|
|
39
|
+
optional("ingestion", "ingestion runtime identity"),
|
|
40
|
+
optional("ingestion_worker", "ingestion worker runtime identity"),
|
|
41
|
+
)
|