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,575 @@
|
|
|
1
|
+
"""Reviewed deployment input for the optional operational evidence verifier."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
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.private_output import read_private_bytes
|
|
14
|
+
from fdai_deployment_cli.standalone_host_state import replace_or_verify_private_json
|
|
15
|
+
|
|
16
|
+
_SCHEMA = "fdai.operational-evidence-verifier-deployment-input.v1"
|
|
17
|
+
_STAGED_SCHEMA = "fdai.operational-evidence-verifier-staged-input.v1"
|
|
18
|
+
_MAX_INPUT_BYTES = 256 * 1024
|
|
19
|
+
_DIGEST = re.compile(r"[0-9a-f]{64}")
|
|
20
|
+
_GUID = re.compile(r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}")
|
|
21
|
+
_GUID_FRAGMENT = r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}"
|
|
22
|
+
_RESOURCE_GROUP_NAME = r"[A-Za-z0-9_.()-]{1,90}"
|
|
23
|
+
_RESOURCE_SCOPE = re.compile(
|
|
24
|
+
rf"/subscriptions/{_GUID_FRAGMENT}/resourceGroups/{_RESOURCE_GROUP_NAME}"
|
|
25
|
+
r"(?:/providers/[A-Za-z0-9.]+/[A-Za-z0-9_.()/:-]+)?"
|
|
26
|
+
)
|
|
27
|
+
_FIXED_VERIFIER_ROLE = "fdai_operational_evidence_verifier"
|
|
28
|
+
_TOP_FIELDS = frozenset(
|
|
29
|
+
{
|
|
30
|
+
"schema_version",
|
|
31
|
+
"trust_registry_path",
|
|
32
|
+
"trust_registry_pin",
|
|
33
|
+
"grant_registry_path",
|
|
34
|
+
"grant_registry_pin",
|
|
35
|
+
"anchors",
|
|
36
|
+
"caller_token_issuer",
|
|
37
|
+
"caller_token_audience",
|
|
38
|
+
"caller_token_jwks",
|
|
39
|
+
"role_readback_scopes",
|
|
40
|
+
"allowed_role_scopes",
|
|
41
|
+
"vertical_executor_principal_ids",
|
|
42
|
+
"writer_members",
|
|
43
|
+
"dev_gateway_executor_principal_id",
|
|
44
|
+
"verifier_database_role",
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
_ANCHORS_FIELDS = frozenset({"schema_version", "venue", "anchors"})
|
|
48
|
+
_ANCHOR_FIELDS = frozenset({"anchor_id", "principal_id", "evidence_class"})
|
|
49
|
+
_OPTIONAL_TOP_FIELDS = frozenset(
|
|
50
|
+
{
|
|
51
|
+
"grant_registry_json",
|
|
52
|
+
"observation_metric_workspace_id",
|
|
53
|
+
"observation_metric_queries_json",
|
|
54
|
+
"observation_scope_rows_json",
|
|
55
|
+
"forecast_history_sources_json",
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
_BINDING_FIELDS = frozenset(
|
|
59
|
+
{
|
|
60
|
+
"enabled",
|
|
61
|
+
"trust_registry_path",
|
|
62
|
+
"trust_registry_pin",
|
|
63
|
+
"grant_registry_path",
|
|
64
|
+
"grant_registry_pin",
|
|
65
|
+
"anchors_json",
|
|
66
|
+
"caller_token_issuer",
|
|
67
|
+
"caller_token_audience",
|
|
68
|
+
"caller_token_jwks_json",
|
|
69
|
+
"role_readback_scopes_json",
|
|
70
|
+
"allowed_role_scopes_json",
|
|
71
|
+
"vertical_executor_principals_json",
|
|
72
|
+
"writer_members_json",
|
|
73
|
+
"dev_gateway_executor_principal_id",
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
_SECRET_JWK_FIELDS = frozenset({"d", "p", "q", "dp", "dq", "qi", "k"})
|
|
77
|
+
_ALLOWED_ROLE_NAMES = frozenset(
|
|
78
|
+
{"AcrPull", "Key Vault Secrets User", "Reader", "Monitoring Reader"}
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True, slots=True)
|
|
83
|
+
class OperationalEvidenceVerifierDeploymentInput:
|
|
84
|
+
"""Validated authority-neutral binding for the read-only verifier workload."""
|
|
85
|
+
|
|
86
|
+
binding: dict[str, object]
|
|
87
|
+
input_digest: str
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def load_operational_evidence_verifier_input(
|
|
91
|
+
path: Path,
|
|
92
|
+
) -> OperationalEvidenceVerifierDeploymentInput:
|
|
93
|
+
"""Load one private reviewed verifier input from the operator-selected path."""
|
|
94
|
+
|
|
95
|
+
raw = _load_json_object(
|
|
96
|
+
path,
|
|
97
|
+
"operational evidence verifier deployment input",
|
|
98
|
+
)
|
|
99
|
+
return _from_mapping(raw, staged=False)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def staged_operational_evidence_verifier_input_from_path(
|
|
103
|
+
path: Path | None,
|
|
104
|
+
) -> OperationalEvidenceVerifierDeploymentInput | None:
|
|
105
|
+
"""Load a staged managed-host input, or report that the verifier was not selected."""
|
|
106
|
+
|
|
107
|
+
if path is None:
|
|
108
|
+
return None
|
|
109
|
+
raw = _load_json_object(
|
|
110
|
+
path,
|
|
111
|
+
"staged operational evidence verifier deployment input",
|
|
112
|
+
)
|
|
113
|
+
return _from_mapping(raw, staged=True)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def stage_operational_evidence_verifier_input(
|
|
117
|
+
value: OperationalEvidenceVerifierDeploymentInput | None,
|
|
118
|
+
*,
|
|
119
|
+
tunnel: Any,
|
|
120
|
+
prepared_root: Path,
|
|
121
|
+
remote_root: str,
|
|
122
|
+
) -> tuple[str, ...]:
|
|
123
|
+
"""Copy the reviewed non-secret input to the managed host and return CLI arguments."""
|
|
124
|
+
|
|
125
|
+
if value is None:
|
|
126
|
+
return ()
|
|
127
|
+
staged: dict[str, object] = {
|
|
128
|
+
"schema_version": _STAGED_SCHEMA,
|
|
129
|
+
"input_digest": value.input_digest,
|
|
130
|
+
"binding": value.binding,
|
|
131
|
+
}
|
|
132
|
+
local_path = prepared_root / "operational-evidence-verifier-input.json"
|
|
133
|
+
remote_path = f"{remote_root}/operational-evidence-verifier-input.json"
|
|
134
|
+
replace_or_verify_private_json(local_path, staged)
|
|
135
|
+
tunnel.copy_to(local_path, remote_path, timeout=120)
|
|
136
|
+
return ("--operational-evidence-verifier-input", remote_path)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def apply_operational_evidence_verifier_input(
|
|
140
|
+
values: dict[str, object],
|
|
141
|
+
verifier_input: OperationalEvidenceVerifierDeploymentInput | None,
|
|
142
|
+
) -> None:
|
|
143
|
+
"""Populate Terraform and application values only when the input is selected."""
|
|
144
|
+
|
|
145
|
+
if verifier_input is None:
|
|
146
|
+
return
|
|
147
|
+
values["enable_operational_evidence_verifier"] = True
|
|
148
|
+
values["operational_evidence_verifier"] = dict(verifier_input.binding)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def reconcile_retained_operational_evidence_verifier_input(
|
|
152
|
+
verifier_input: OperationalEvidenceVerifierDeploymentInput | None,
|
|
153
|
+
*,
|
|
154
|
+
retained: dict[str, object],
|
|
155
|
+
values: dict[str, object],
|
|
156
|
+
) -> bool:
|
|
157
|
+
"""Bind a newly selected verifier input onto a retained host context or fail on drift."""
|
|
158
|
+
|
|
159
|
+
previous = retained.get("oev_input_digest", "")
|
|
160
|
+
if not isinstance(previous, str):
|
|
161
|
+
raise ValueError("standalone host retained context differs")
|
|
162
|
+
current = verifier_input.input_digest if verifier_input is not None else ""
|
|
163
|
+
if previous and previous != current:
|
|
164
|
+
raise ValueError("standalone host retained context differs")
|
|
165
|
+
if verifier_input is None:
|
|
166
|
+
return False
|
|
167
|
+
apply_operational_evidence_verifier_input(values, verifier_input)
|
|
168
|
+
if previous == current:
|
|
169
|
+
return False
|
|
170
|
+
retained["oev_input_digest"] = current
|
|
171
|
+
return True
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _from_mapping(
|
|
175
|
+
value: Mapping[str, Any], *, staged: bool
|
|
176
|
+
) -> OperationalEvidenceVerifierDeploymentInput:
|
|
177
|
+
if staged:
|
|
178
|
+
if set(value) != {"schema_version", "input_digest", "binding"}:
|
|
179
|
+
raise ValueError("operational evidence verifier input fields are invalid")
|
|
180
|
+
if value.get("schema_version") != _STAGED_SCHEMA:
|
|
181
|
+
raise ValueError("operational evidence verifier input schema is unsupported")
|
|
182
|
+
binding = _staged_binding(
|
|
183
|
+
_mapping(value["binding"], "operational evidence verifier binding")
|
|
184
|
+
)
|
|
185
|
+
material = {
|
|
186
|
+
"schema_version": _SCHEMA,
|
|
187
|
+
**_input_material_from_binding(binding),
|
|
188
|
+
}
|
|
189
|
+
digest = canonical_digest(material)
|
|
190
|
+
if value.get("input_digest") != digest:
|
|
191
|
+
raise ValueError("operational evidence verifier input digest differs")
|
|
192
|
+
return OperationalEvidenceVerifierDeploymentInput(binding=binding, input_digest=digest)
|
|
193
|
+
if not _TOP_FIELDS.issubset(value) or not set(value).issubset(
|
|
194
|
+
_TOP_FIELDS | _OPTIONAL_TOP_FIELDS
|
|
195
|
+
):
|
|
196
|
+
raise ValueError("operational evidence verifier input fields are invalid")
|
|
197
|
+
if value.get("schema_version") != _SCHEMA:
|
|
198
|
+
raise ValueError("operational evidence verifier input schema is unsupported")
|
|
199
|
+
_reject_secret_material(value)
|
|
200
|
+
binding = _binding(value)
|
|
201
|
+
material = {
|
|
202
|
+
"schema_version": _SCHEMA,
|
|
203
|
+
**_input_material_from_binding(binding),
|
|
204
|
+
}
|
|
205
|
+
return OperationalEvidenceVerifierDeploymentInput(
|
|
206
|
+
binding=binding,
|
|
207
|
+
input_digest=canonical_digest(material),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _staged_binding(value: Mapping[str, Any]) -> dict[str, object]:
|
|
212
|
+
if (
|
|
213
|
+
not _BINDING_FIELDS.issubset(value)
|
|
214
|
+
or not set(value).issubset(_BINDING_FIELDS | _OPTIONAL_TOP_FIELDS)
|
|
215
|
+
or value.get("enabled") is not True
|
|
216
|
+
):
|
|
217
|
+
raise ValueError("operational evidence verifier binding fields are invalid")
|
|
218
|
+
anchors = _anchors(_json_field(value, "anchors_json"))
|
|
219
|
+
caller_jwks = _caller_jwks(_json_field(value, "caller_token_jwks_json"))
|
|
220
|
+
readback_scopes = _scopes(
|
|
221
|
+
_json_field(value, "role_readback_scopes_json"), "role_readback_scopes"
|
|
222
|
+
)
|
|
223
|
+
allowed_role_scopes = _allowed_role_scopes(_json_field(value, "allowed_role_scopes_json"))
|
|
224
|
+
vertical_principals = _guid_list(
|
|
225
|
+
_json_field(value, "vertical_executor_principals_json"),
|
|
226
|
+
"vertical_executor_principal_ids",
|
|
227
|
+
)
|
|
228
|
+
writer_members = _writer_members(_json_field(value, "writer_members_json"))
|
|
229
|
+
result: dict[str, object] = {
|
|
230
|
+
"enabled": True,
|
|
231
|
+
"trust_registry_path": _path_text(value, "trust_registry_path"),
|
|
232
|
+
"trust_registry_pin": _digest(value, "trust_registry_pin"),
|
|
233
|
+
"grant_registry_path": _path_text(value, "grant_registry_path"),
|
|
234
|
+
"grant_registry_pin": _digest(value, "grant_registry_pin"),
|
|
235
|
+
"anchors_json": _json_text(anchors),
|
|
236
|
+
"caller_token_issuer": _https_url(value, "caller_token_issuer"),
|
|
237
|
+
"caller_token_audience": _audience(value, "caller_token_audience"),
|
|
238
|
+
"caller_token_jwks_json": _json_text(caller_jwks),
|
|
239
|
+
"role_readback_scopes_json": _json_text(readback_scopes),
|
|
240
|
+
"allowed_role_scopes_json": _json_text(allowed_role_scopes),
|
|
241
|
+
"vertical_executor_principals_json": _json_text(vertical_principals),
|
|
242
|
+
"writer_members_json": _json_text(writer_members),
|
|
243
|
+
"dev_gateway_executor_principal_id": _guid(value, "dev_gateway_executor_principal_id"),
|
|
244
|
+
}
|
|
245
|
+
result.update(_optional_binding_fields(value))
|
|
246
|
+
return result
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _binding(value: Mapping[str, Any]) -> dict[str, object]:
|
|
250
|
+
trust_pin = _digest(value, "trust_registry_pin")
|
|
251
|
+
grant_pin = _digest(value, "grant_registry_pin")
|
|
252
|
+
anchors = _anchors(value["anchors"])
|
|
253
|
+
caller_jwks = _caller_jwks(value["caller_token_jwks"])
|
|
254
|
+
readback_scopes = _scopes(value["role_readback_scopes"], "role_readback_scopes")
|
|
255
|
+
allowed_role_scopes = _allowed_role_scopes(value["allowed_role_scopes"])
|
|
256
|
+
vertical_principals = _guid_list(
|
|
257
|
+
value["vertical_executor_principal_ids"],
|
|
258
|
+
"vertical_executor_principal_ids",
|
|
259
|
+
)
|
|
260
|
+
writer_members = _writer_members(value["writer_members"])
|
|
261
|
+
verifier_role = _text(value, "verifier_database_role")
|
|
262
|
+
if verifier_role != _FIXED_VERIFIER_ROLE:
|
|
263
|
+
raise ValueError("operational evidence verifier database role is fixed")
|
|
264
|
+
result: dict[str, object] = {
|
|
265
|
+
"enabled": True,
|
|
266
|
+
"trust_registry_path": _path_text(value, "trust_registry_path"),
|
|
267
|
+
"trust_registry_pin": trust_pin,
|
|
268
|
+
"grant_registry_path": _path_text(value, "grant_registry_path"),
|
|
269
|
+
"grant_registry_pin": grant_pin,
|
|
270
|
+
"anchors_json": _json_text(anchors),
|
|
271
|
+
"caller_token_issuer": _https_url(value, "caller_token_issuer"),
|
|
272
|
+
"caller_token_audience": _audience(value, "caller_token_audience"),
|
|
273
|
+
"caller_token_jwks_json": _json_text(caller_jwks),
|
|
274
|
+
"role_readback_scopes_json": _json_text(readback_scopes),
|
|
275
|
+
"allowed_role_scopes_json": _json_text(allowed_role_scopes),
|
|
276
|
+
"vertical_executor_principals_json": _json_text(vertical_principals),
|
|
277
|
+
"writer_members_json": _json_text(writer_members),
|
|
278
|
+
"dev_gateway_executor_principal_id": _guid(value, "dev_gateway_executor_principal_id"),
|
|
279
|
+
}
|
|
280
|
+
result.update(_optional_binding_fields(value))
|
|
281
|
+
return result
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _optional_binding_fields(value: Mapping[str, Any]) -> dict[str, object]:
|
|
285
|
+
optional: dict[str, object] = {}
|
|
286
|
+
if "observation_metric_workspace_id" in value:
|
|
287
|
+
optional["observation_metric_workspace_id"] = _guid(
|
|
288
|
+
value, "observation_metric_workspace_id"
|
|
289
|
+
)
|
|
290
|
+
for key in (
|
|
291
|
+
"grant_registry_json",
|
|
292
|
+
"observation_metric_queries_json",
|
|
293
|
+
"observation_scope_rows_json",
|
|
294
|
+
"forecast_history_sources_json",
|
|
295
|
+
):
|
|
296
|
+
if key in value:
|
|
297
|
+
optional[key] = _json_text(_json_field(value, key))
|
|
298
|
+
return optional
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _input_material_from_binding(binding: Mapping[str, object]) -> dict[str, object]:
|
|
302
|
+
material: dict[str, object] = {
|
|
303
|
+
"trust_registry_path": binding["trust_registry_path"],
|
|
304
|
+
"trust_registry_pin": binding["trust_registry_pin"],
|
|
305
|
+
"grant_registry_path": binding["grant_registry_path"],
|
|
306
|
+
"grant_registry_pin": binding["grant_registry_pin"],
|
|
307
|
+
"anchors": json.loads(str(binding["anchors_json"])),
|
|
308
|
+
"caller_token_issuer": binding["caller_token_issuer"],
|
|
309
|
+
"caller_token_audience": binding["caller_token_audience"],
|
|
310
|
+
"caller_token_jwks": json.loads(str(binding["caller_token_jwks_json"])),
|
|
311
|
+
"role_readback_scopes": json.loads(str(binding["role_readback_scopes_json"])),
|
|
312
|
+
"allowed_role_scopes": json.loads(str(binding["allowed_role_scopes_json"])),
|
|
313
|
+
"vertical_executor_principal_ids": json.loads(
|
|
314
|
+
str(binding["vertical_executor_principals_json"])
|
|
315
|
+
),
|
|
316
|
+
"writer_members": json.loads(str(binding["writer_members_json"])),
|
|
317
|
+
"dev_gateway_executor_principal_id": binding["dev_gateway_executor_principal_id"],
|
|
318
|
+
"verifier_database_role": _FIXED_VERIFIER_ROLE,
|
|
319
|
+
}
|
|
320
|
+
for key in _OPTIONAL_TOP_FIELDS:
|
|
321
|
+
if key in binding:
|
|
322
|
+
material[key] = binding[key]
|
|
323
|
+
return material
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _anchors(value: object) -> dict[str, object]:
|
|
327
|
+
data = _mapping(value, "operational evidence anchors")
|
|
328
|
+
if set(data) != _ANCHORS_FIELDS or data.get("schema_version") != "1.0.0":
|
|
329
|
+
raise ValueError("operational evidence anchors fields are invalid")
|
|
330
|
+
if data.get("venue") != "deployed":
|
|
331
|
+
raise ValueError("operational evidence anchors must target deployed venue")
|
|
332
|
+
items = data.get("anchors")
|
|
333
|
+
if not isinstance(items, list) or not items:
|
|
334
|
+
raise ValueError("operational evidence anchors must be a non-empty list")
|
|
335
|
+
seen: set[str] = set()
|
|
336
|
+
normalized: list[dict[str, str]] = []
|
|
337
|
+
for item in items:
|
|
338
|
+
anchor = _mapping(item, "operational evidence anchor")
|
|
339
|
+
if set(anchor) != _ANCHOR_FIELDS:
|
|
340
|
+
raise ValueError("operational evidence anchor fields are invalid")
|
|
341
|
+
anchor_id = _bounded_text(anchor, "anchor_id")
|
|
342
|
+
principal = _principal_text(anchor.get("principal_id"), "principal_id")
|
|
343
|
+
evidence_class = _bounded_text(anchor, "evidence_class")
|
|
344
|
+
if not anchor_id.startswith("anchor:"):
|
|
345
|
+
raise ValueError("operational evidence anchor id is invalid")
|
|
346
|
+
if evidence_class != "live":
|
|
347
|
+
raise ValueError("operational evidence anchors must use live evidence")
|
|
348
|
+
if anchor_id in seen:
|
|
349
|
+
raise ValueError("operational evidence anchor is duplicated")
|
|
350
|
+
seen.add(anchor_id)
|
|
351
|
+
normalized.append(
|
|
352
|
+
{
|
|
353
|
+
"anchor_id": anchor_id,
|
|
354
|
+
"principal_id": principal,
|
|
355
|
+
"evidence_class": evidence_class,
|
|
356
|
+
}
|
|
357
|
+
)
|
|
358
|
+
return {"schema_version": "1.0.0", "venue": "deployed", "anchors": normalized}
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _caller_jwks(value: object) -> dict[str, object]:
|
|
362
|
+
jwks = _mapping(value, "caller token JWKS")
|
|
363
|
+
if set(jwks) != {"keys"}:
|
|
364
|
+
raise ValueError("caller token JWKS fields are invalid")
|
|
365
|
+
keys = jwks["keys"]
|
|
366
|
+
if not isinstance(keys, list) or not keys:
|
|
367
|
+
raise ValueError("caller token JWKS must contain at least one public key")
|
|
368
|
+
normalized: list[dict[str, object]] = []
|
|
369
|
+
for item in keys:
|
|
370
|
+
key = _mapping(item, "caller token JWK")
|
|
371
|
+
if _SECRET_JWK_FIELDS & set(key):
|
|
372
|
+
raise ValueError("caller token JWKS contains secret key material")
|
|
373
|
+
if key.get("kty") in {"oct", "dir"}:
|
|
374
|
+
raise ValueError("caller token JWKS contains secret key material")
|
|
375
|
+
if not isinstance(key.get("kty"), str) or not str(key["kty"]).strip():
|
|
376
|
+
raise ValueError("caller token JWK is malformed")
|
|
377
|
+
normalized.append({str(name): member for name, member in key.items()})
|
|
378
|
+
return {"keys": normalized}
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _allowed_role_scopes(value: object) -> dict[str, list[str]]:
|
|
382
|
+
data = _mapping(value, "allowed role scopes")
|
|
383
|
+
if not data:
|
|
384
|
+
raise ValueError("allowed role scopes are required")
|
|
385
|
+
normalized: dict[str, list[str]] = {}
|
|
386
|
+
for role, scopes in data.items():
|
|
387
|
+
if role not in _ALLOWED_ROLE_NAMES:
|
|
388
|
+
raise ValueError("allowed role scopes include an unsupported role")
|
|
389
|
+
normalized[role] = _scopes(scopes, f"allowed scopes for {role}")
|
|
390
|
+
return normalized
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _writer_members(value: object) -> list[str]:
|
|
394
|
+
if value != [_FIXED_VERIFIER_ROLE]:
|
|
395
|
+
raise ValueError(
|
|
396
|
+
"operational evidence writer members must contain only the verifier writer"
|
|
397
|
+
)
|
|
398
|
+
return [_FIXED_VERIFIER_ROLE]
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _guid_list(value: object, label: str) -> list[str]:
|
|
402
|
+
if not isinstance(value, list) or not value:
|
|
403
|
+
raise ValueError(f"{label} must be a non-empty list of GUIDs")
|
|
404
|
+
return [_principal_text(item, label) for item in value]
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _scopes(value: object, label: str) -> list[str]:
|
|
408
|
+
if not isinstance(value, list) or not value:
|
|
409
|
+
raise ValueError(f"{label} must be a non-empty list")
|
|
410
|
+
scopes: list[str] = []
|
|
411
|
+
for item in value:
|
|
412
|
+
if not isinstance(item, str) or not item.strip():
|
|
413
|
+
raise ValueError(f"{label} entries must be strings")
|
|
414
|
+
scope = item.strip().rstrip("/")
|
|
415
|
+
match = _RESOURCE_SCOPE.fullmatch(scope)
|
|
416
|
+
if (
|
|
417
|
+
match is None
|
|
418
|
+
or "/resourceGroups/" not in scope
|
|
419
|
+
or _resource_group_ends_with_period(scope)
|
|
420
|
+
):
|
|
421
|
+
raise ValueError(f"{label} entries must be exact Azure resource scopes")
|
|
422
|
+
_reject_placeholder(scope, label)
|
|
423
|
+
scopes.append(scope)
|
|
424
|
+
if len(scopes) != len(set(scopes)):
|
|
425
|
+
raise ValueError(f"{label} entries must be unique")
|
|
426
|
+
return scopes
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _digest(value: Mapping[str, Any], field: str) -> str:
|
|
430
|
+
item = _text(value, field)
|
|
431
|
+
if _DIGEST.fullmatch(item) is None:
|
|
432
|
+
raise ValueError(f"{field} must be a lowercase SHA-256 digest")
|
|
433
|
+
_reject_placeholder(item, field)
|
|
434
|
+
return item
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _guid(value: Mapping[str, Any], field: str) -> str:
|
|
438
|
+
return _principal_text(value.get(field), field)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _principal_text(value: object, field: str) -> str:
|
|
442
|
+
if not isinstance(value, str) or _GUID.fullmatch(value) is None:
|
|
443
|
+
raise ValueError(f"{field} must be a GUID")
|
|
444
|
+
_reject_placeholder(value, field)
|
|
445
|
+
return value
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _path_text(value: Mapping[str, Any], field: str) -> str:
|
|
449
|
+
item = _text(value, field)
|
|
450
|
+
if not item or "\x00" in item or ".." in Path(item).parts or item in {"/", ".", "config"}:
|
|
451
|
+
raise ValueError(f"{field} is invalid")
|
|
452
|
+
_reject_placeholder(item, field)
|
|
453
|
+
return item
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _https_url(value: Mapping[str, Any], field: str) -> str:
|
|
457
|
+
item = _text(value, field)
|
|
458
|
+
if not item.startswith("https://") or " " in item or item.endswith("example.com"):
|
|
459
|
+
raise ValueError(f"{field} must be an HTTPS URL")
|
|
460
|
+
_reject_placeholder(item, field)
|
|
461
|
+
return item
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _audience(value: Mapping[str, Any], field: str) -> str:
|
|
465
|
+
item = _text(value, field)
|
|
466
|
+
if not item.startswith("api://") or len(item) > 256:
|
|
467
|
+
raise ValueError(f"{field} is invalid")
|
|
468
|
+
_reject_placeholder(item, field)
|
|
469
|
+
return item
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _text(value: Mapping[str, Any], field: str) -> str:
|
|
473
|
+
item = value.get(field)
|
|
474
|
+
if not isinstance(item, str) or not item.strip():
|
|
475
|
+
raise ValueError(f"{field} is required")
|
|
476
|
+
return item.strip()
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _bounded_text(value: Mapping[str, Any], field: str) -> str:
|
|
480
|
+
item = _text(value, field)
|
|
481
|
+
if len(item) > 256:
|
|
482
|
+
raise ValueError(f"{field} is too long")
|
|
483
|
+
_reject_placeholder(item, field)
|
|
484
|
+
return item
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _mapping(value: object, label: str) -> Mapping[str, Any]:
|
|
488
|
+
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
|
|
489
|
+
raise ValueError(f"{label} is invalid")
|
|
490
|
+
return value
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _load_json_object(path: Path, label: str) -> dict[str, Any]:
|
|
494
|
+
data = read_private_bytes(path, max_bytes=_MAX_INPUT_BYTES)
|
|
495
|
+
if not data:
|
|
496
|
+
raise ValueError(f"{label} is empty")
|
|
497
|
+
try:
|
|
498
|
+
value = json.loads(
|
|
499
|
+
data.decode("utf-8"),
|
|
500
|
+
object_pairs_hook=_strict_json_object,
|
|
501
|
+
)
|
|
502
|
+
except UnicodeDecodeError as exc:
|
|
503
|
+
raise ValueError(f"{label} is not valid UTF-8") from exc
|
|
504
|
+
except json.JSONDecodeError as exc:
|
|
505
|
+
raise ValueError(f"{label} is not valid JSON") from exc
|
|
506
|
+
if not isinstance(value, dict):
|
|
507
|
+
raise ValueError( # noqa: TRY004 - normalize untrusted JSON into a stable CLI error
|
|
508
|
+
f"{label} MUST be a JSON object"
|
|
509
|
+
)
|
|
510
|
+
return value
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _strict_json_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
514
|
+
value: dict[str, object] = {}
|
|
515
|
+
for key, item in pairs:
|
|
516
|
+
if key in value:
|
|
517
|
+
raise ValueError(f"duplicate JSON key: {key}")
|
|
518
|
+
value[key] = item
|
|
519
|
+
return value
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _json_text(value: object) -> str:
|
|
523
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"))
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _json_field(value: Mapping[str, Any], field: str) -> object:
|
|
527
|
+
raw = _text(value, field)
|
|
528
|
+
try:
|
|
529
|
+
return json.loads(raw, object_pairs_hook=_strict_json_object)
|
|
530
|
+
except json.JSONDecodeError as exc:
|
|
531
|
+
raise ValueError(f"{field} must be valid JSON") from exc
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _resource_group_ends_with_period(scope: str) -> bool:
|
|
535
|
+
marker = "/resourceGroups/"
|
|
536
|
+
group = scope.split(marker, 1)[1].split("/", 1)[0]
|
|
537
|
+
return group.endswith(".")
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _reject_placeholder(value: str, field: str) -> None:
|
|
541
|
+
lowered = value.casefold()
|
|
542
|
+
if (
|
|
543
|
+
set(value) <= {"0", "-"}
|
|
544
|
+
or "replace" in lowered
|
|
545
|
+
or "placeholder" in lowered
|
|
546
|
+
or "set-me" in lowered
|
|
547
|
+
or "<" in value
|
|
548
|
+
or ">" in value
|
|
549
|
+
):
|
|
550
|
+
raise ValueError(f"{field} contains a placeholder value")
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _reject_secret_material(value: object) -> None:
|
|
554
|
+
if isinstance(value, Mapping):
|
|
555
|
+
if any(key.casefold() in {"password", "client_secret", "private_key"} for key in value):
|
|
556
|
+
raise ValueError("operational evidence verifier input contains secret material")
|
|
557
|
+
for item in value.values():
|
|
558
|
+
_reject_secret_material(item)
|
|
559
|
+
elif isinstance(value, list):
|
|
560
|
+
for item in value:
|
|
561
|
+
_reject_secret_material(item)
|
|
562
|
+
elif isinstance(value, str):
|
|
563
|
+
lowered = value.casefold()
|
|
564
|
+
if "private key" in lowered or "client_secret" in lowered or "password=" in lowered:
|
|
565
|
+
raise ValueError("operational evidence verifier input contains secret material")
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
__all__ = [
|
|
569
|
+
"OperationalEvidenceVerifierDeploymentInput",
|
|
570
|
+
"apply_operational_evidence_verifier_input",
|
|
571
|
+
"load_operational_evidence_verifier_input",
|
|
572
|
+
"reconcile_retained_operational_evidence_verifier_input",
|
|
573
|
+
"stage_operational_evidence_verifier_input",
|
|
574
|
+
"staged_operational_evidence_verifier_input_from_path",
|
|
575
|
+
]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Private, non-secret Terraform plan input snapshot."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import stat
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from fdai_deployment_cli.contracts import canonical_bytes, load_json_object
|
|
12
|
+
from fdai_deployment_cli.target import compute_target_binding
|
|
13
|
+
|
|
14
|
+
PLAN_ONLY_PASSWORD = "FDAI-PLAN-ONLY-NOT-A-SECRET"
|
|
15
|
+
_REQUIRED = frozenset(
|
|
16
|
+
{
|
|
17
|
+
"region",
|
|
18
|
+
"tenant_id",
|
|
19
|
+
"subscription_id",
|
|
20
|
+
"target_binding",
|
|
21
|
+
"postgres_admin_login",
|
|
22
|
+
"postgres_admin_password",
|
|
23
|
+
"core_image",
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
_KEY = re.compile(r"^[a-z][a-z0-9_]{0,127}$")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class PlanInputContext:
|
|
31
|
+
"""Validated provider context removed from Terraform variable content."""
|
|
32
|
+
|
|
33
|
+
subscription_id: str
|
|
34
|
+
tenant_id: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def snapshot_plan_input(
|
|
38
|
+
source: Path,
|
|
39
|
+
destination: Path,
|
|
40
|
+
*,
|
|
41
|
+
expected_target_binding: str,
|
|
42
|
+
expected_region: str,
|
|
43
|
+
expected_environment: str,
|
|
44
|
+
) -> PlanInputContext:
|
|
45
|
+
"""Validate and copy a mode-0600 JSON plan input without real secret values."""
|
|
46
|
+
|
|
47
|
+
values = read_plan_input(source)
|
|
48
|
+
if set(values) != _REQUIRED:
|
|
49
|
+
raise ValueError("Terraform plan input fields do not match the secret-free schema")
|
|
50
|
+
if any(_KEY.fullmatch(key) is None for key in values):
|
|
51
|
+
raise ValueError("Terraform plan input contains an invalid key")
|
|
52
|
+
if values["postgres_admin_password"] != PLAN_ONLY_PASSWORD:
|
|
53
|
+
raise ValueError("Terraform plan input MUST use the plan-only password placeholder")
|
|
54
|
+
tenant_id = values["tenant_id"]
|
|
55
|
+
subscription_id = values["subscription_id"]
|
|
56
|
+
if not isinstance(tenant_id, str) or not isinstance(subscription_id, str):
|
|
57
|
+
raise ValueError("Terraform plan input Azure target values MUST be strings")
|
|
58
|
+
derived_binding = compute_target_binding(
|
|
59
|
+
tenant_id=tenant_id,
|
|
60
|
+
subscription_id=subscription_id,
|
|
61
|
+
)
|
|
62
|
+
if values["target_binding"] != derived_binding or derived_binding != expected_target_binding:
|
|
63
|
+
raise ValueError("Terraform plan input target binding does not match the profile")
|
|
64
|
+
if values["region"] != expected_region:
|
|
65
|
+
raise ValueError("Terraform plan input region does not match the profile")
|
|
66
|
+
if expected_environment not in {"dev", "staging", "prod"}:
|
|
67
|
+
raise ValueError("Terraform plan input environment is unsupported")
|
|
68
|
+
terraform_values = {
|
|
69
|
+
key: value
|
|
70
|
+
for key, value in values.items()
|
|
71
|
+
if key not in {"target_binding", "subscription_id"}
|
|
72
|
+
}
|
|
73
|
+
terraform_values["env"] = expected_environment
|
|
74
|
+
write_plan_input(destination, terraform_values)
|
|
75
|
+
return PlanInputContext(subscription_id=subscription_id, tenant_id=tenant_id)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def read_plan_input(source: Path) -> dict[str, object]:
|
|
79
|
+
"""Read bounded private JSON without following links or blocking on a FIFO."""
|
|
80
|
+
|
|
81
|
+
descriptor = os.open(source, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
82
|
+
with os.fdopen(descriptor, "rb") as stream:
|
|
83
|
+
details = os.fstat(stream.fileno())
|
|
84
|
+
if not stat.S_ISREG(details.st_mode) or stat.S_IMODE(details.st_mode) != 0o600:
|
|
85
|
+
raise PermissionError("Terraform plan input MUST be a mode-0600 regular file")
|
|
86
|
+
payload = stream.read(1_048_577)
|
|
87
|
+
return load_json_object(payload, label="Terraform plan input")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def write_plan_input(destination: Path, values: dict[str, object]) -> None:
|
|
91
|
+
"""Persist validated plan variables exclusively with private permissions."""
|
|
92
|
+
|
|
93
|
+
output = os.open(
|
|
94
|
+
destination,
|
|
95
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
|
96
|
+
0o600,
|
|
97
|
+
)
|
|
98
|
+
with os.fdopen(output, "wb") as stream:
|
|
99
|
+
stream.write(canonical_bytes(values) + b"\n")
|
|
100
|
+
stream.flush()
|
|
101
|
+
os.fsync(stream.fileno())
|