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,574 @@
|
|
|
1
|
+
"""Guarded source-installation teardown from retained ownership evidence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import stat
|
|
10
|
+
import subprocess
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Protocol
|
|
16
|
+
|
|
17
|
+
from fdai_deployment_cli.contracts import canonical_digest, load_json_object
|
|
18
|
+
from fdai_deployment_cli.private_output import read_private_bytes, write_private_output
|
|
19
|
+
from fdai_deployment_cli.runtime_profile import RuntimeDeploymentProfile
|
|
20
|
+
|
|
21
|
+
_DIGEST = re.compile(r"[0-9a-f]{64}")
|
|
22
|
+
_GROUP = re.compile(r"rg-[a-z0-9-]{3,80}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ResourceGroupClient(Protocol):
|
|
26
|
+
def delete_group(self, *, subscription_id: str, name: str) -> None: ...
|
|
27
|
+
|
|
28
|
+
def group_absent(self, *, subscription_id: str, name: str) -> bool: ...
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class SourceTeardownPlan:
|
|
33
|
+
target_binding: str
|
|
34
|
+
source_commit: str
|
|
35
|
+
run_binding: str
|
|
36
|
+
installation_anchor: str
|
|
37
|
+
subscription_id: str
|
|
38
|
+
resource_groups: tuple[str, ...]
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def confirmation(self) -> str:
|
|
42
|
+
return f"delete {self.installation_anchor[:12]}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def plan_source_teardown(
|
|
46
|
+
*,
|
|
47
|
+
work_dir: Path,
|
|
48
|
+
source_root: Path | None = None,
|
|
49
|
+
runtime_profile: RuntimeDeploymentProfile,
|
|
50
|
+
region: str,
|
|
51
|
+
monthly_cost_ceiling: int,
|
|
52
|
+
recovery_directory: Path | None = None,
|
|
53
|
+
) -> SourceTeardownPlan:
|
|
54
|
+
"""Prove exactly one retained source installation before naming resources to remove."""
|
|
55
|
+
|
|
56
|
+
# The intent is written unsigned; the signed preparation receipt binds its digest.
|
|
57
|
+
intent = _plain_json(work_dir / "source-intent.json", "source intent")
|
|
58
|
+
source = _object(intent.get("source"), "source intent source")
|
|
59
|
+
if (
|
|
60
|
+
intent.get("schema_version") != "fdai.source-deployment-intent.v1"
|
|
61
|
+
or intent.get("runtime_profile") != runtime_profile.to_mapping()
|
|
62
|
+
or intent.get("region") != region
|
|
63
|
+
or intent.get("monthly_cost_ceiling") != monthly_cost_ceiling
|
|
64
|
+
or source.get("provenance") != "operator-selected-source"
|
|
65
|
+
or source.get("release_signature_verified") is not False
|
|
66
|
+
):
|
|
67
|
+
raise ValueError("source teardown intent differs from the selected installation")
|
|
68
|
+
source_commit = _text(source, "source_commit", pattern=r"[0-9a-f]{40}")
|
|
69
|
+
preparation = _verified(work_dir / "source-preparation.json", "source preparation", None)
|
|
70
|
+
if (
|
|
71
|
+
preparation.get("source_commit") != source_commit
|
|
72
|
+
or preparation.get("intent_digest") != canonical_digest(intent)
|
|
73
|
+
or preparation.get("provenance") != "operator-selected-source"
|
|
74
|
+
or preparation.get("release_signature_verified") is not False
|
|
75
|
+
):
|
|
76
|
+
raise ValueError("source teardown preparation proof differs")
|
|
77
|
+
foundation = work_dir / "foundation"
|
|
78
|
+
marker = _verified(foundation / "source-genesis.json", "source genesis", None)
|
|
79
|
+
target_binding = _text(marker, "target_binding", pattern=r"[0-9a-f]{64}")
|
|
80
|
+
run_binding = _text(marker, "run_binding", pattern=r"[0-9a-f]{64}")
|
|
81
|
+
if marker.get("source_commit") != source_commit:
|
|
82
|
+
raise ValueError("source teardown Genesis proof differs")
|
|
83
|
+
status = _plain_json(foundation / "status.json", "source status")
|
|
84
|
+
report = _object(status.get("foundation_report"), "Foundation report")
|
|
85
|
+
plan_ref = _text(
|
|
86
|
+
_object(report.get("foundation_plan"), "Foundation plan"),
|
|
87
|
+
"plan_ref",
|
|
88
|
+
pattern=r"foundation-plan-attempt-[1-9][0-9]*",
|
|
89
|
+
)
|
|
90
|
+
if recovery_directory is not None:
|
|
91
|
+
handoff, located = _recovered_foundation_ownership(
|
|
92
|
+
recovery_directory=recovery_directory.absolute(),
|
|
93
|
+
source_commit=source_commit,
|
|
94
|
+
target_binding=target_binding,
|
|
95
|
+
run_binding=run_binding,
|
|
96
|
+
)
|
|
97
|
+
partial_claimed = False
|
|
98
|
+
else:
|
|
99
|
+
state_handoff = report.get("state_handoff")
|
|
100
|
+
if isinstance(state_handoff, dict):
|
|
101
|
+
handoff_digest = _text(
|
|
102
|
+
state_handoff,
|
|
103
|
+
"receipt_digest",
|
|
104
|
+
pattern=r"[0-9a-f]{64}",
|
|
105
|
+
)
|
|
106
|
+
handoff = _verified(
|
|
107
|
+
foundation / plan_ref / "foundation-state-handoff-receipt.json",
|
|
108
|
+
"Foundation handoff",
|
|
109
|
+
handoff_digest,
|
|
110
|
+
)
|
|
111
|
+
apply = _verified(
|
|
112
|
+
foundation / plan_ref / "foundation-apply-receipt.json",
|
|
113
|
+
"Foundation apply",
|
|
114
|
+
_text(handoff, "foundation_receipt_digest", pattern=r"[0-9a-f]{64}"),
|
|
115
|
+
)
|
|
116
|
+
if (
|
|
117
|
+
apply.get("schema_version") != "fdai.genesis-foundation-apply-receipt.v1"
|
|
118
|
+
or apply.get("state") != "applied"
|
|
119
|
+
or apply.get("source_commit") != source_commit
|
|
120
|
+
or apply.get("target_binding") != target_binding
|
|
121
|
+
):
|
|
122
|
+
raise ValueError("source teardown Foundation apply proof differs")
|
|
123
|
+
located = _plain_json(
|
|
124
|
+
foundation / plan_ref / "foundation-private-handoff.json", "handoff"
|
|
125
|
+
)
|
|
126
|
+
if (
|
|
127
|
+
canonical_digest(located) != apply.get("handoff_digest")
|
|
128
|
+
or located.get("source_commit") != source_commit
|
|
129
|
+
or located.get("run_digest") != run_binding
|
|
130
|
+
):
|
|
131
|
+
raise ValueError("source teardown Foundation location proof differs")
|
|
132
|
+
partial_claimed = False
|
|
133
|
+
else:
|
|
134
|
+
located = _claimed_partial_foundation_ownership(
|
|
135
|
+
foundation=foundation,
|
|
136
|
+
plan_ref=plan_ref,
|
|
137
|
+
source_commit=source_commit,
|
|
138
|
+
target_binding=target_binding,
|
|
139
|
+
run_binding=run_binding,
|
|
140
|
+
source_root=(source_root or Path.cwd()).resolve(),
|
|
141
|
+
)
|
|
142
|
+
handoff = {}
|
|
143
|
+
partial_claimed = True
|
|
144
|
+
if not partial_claimed and (
|
|
145
|
+
handoff.get("schema_version") != "fdai.genesis-foundation-state-handoff-receipt.v1"
|
|
146
|
+
or handoff.get("source_commit") != source_commit
|
|
147
|
+
or handoff.get("target_binding") != target_binding
|
|
148
|
+
or any(
|
|
149
|
+
handoff.get(key) is not True
|
|
150
|
+
for key in (
|
|
151
|
+
"effect_verified",
|
|
152
|
+
"runner_attested",
|
|
153
|
+
"remote_backend_authority_verified",
|
|
154
|
+
"zero_change_verified",
|
|
155
|
+
"remote_transient_deleted",
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
):
|
|
159
|
+
raise ValueError("source teardown Foundation ownership proof is incomplete")
|
|
160
|
+
app = _object(located.get("app_resource_group"), "application resource group")
|
|
161
|
+
ops = _object(located.get("ops"), "operations resource group")
|
|
162
|
+
groups = tuple(
|
|
163
|
+
dict.fromkeys(
|
|
164
|
+
(
|
|
165
|
+
_text(app, "name", pattern=_GROUP.pattern),
|
|
166
|
+
_text(ops, "resource_group_name", pattern=_GROUP.pattern),
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
anchor = canonical_digest(
|
|
171
|
+
{
|
|
172
|
+
"schema_version": "fdai.source-teardown-anchor.v1",
|
|
173
|
+
"target_binding": target_binding,
|
|
174
|
+
"run_binding": run_binding,
|
|
175
|
+
"source_commit": source_commit,
|
|
176
|
+
"resource_groups": groups,
|
|
177
|
+
}
|
|
178
|
+
)
|
|
179
|
+
return SourceTeardownPlan(
|
|
180
|
+
target_binding=target_binding,
|
|
181
|
+
source_commit=source_commit,
|
|
182
|
+
run_binding=run_binding,
|
|
183
|
+
installation_anchor=anchor,
|
|
184
|
+
subscription_id=_text(located, "subscription_id"),
|
|
185
|
+
resource_groups=groups,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _claimed_partial_foundation_ownership(
|
|
190
|
+
*,
|
|
191
|
+
foundation: Path,
|
|
192
|
+
plan_ref: str,
|
|
193
|
+
source_commit: str,
|
|
194
|
+
target_binding: str,
|
|
195
|
+
run_binding: str,
|
|
196
|
+
source_root: Path,
|
|
197
|
+
) -> dict[str, object]:
|
|
198
|
+
"""Verify a claimed partial Foundation without inventing effect completion."""
|
|
199
|
+
plan_directory = foundation / plan_ref
|
|
200
|
+
plan = _verified_full(
|
|
201
|
+
plan_directory / "foundation-plan.json",
|
|
202
|
+
"Foundation saved plan",
|
|
203
|
+
digest_field="review_digest",
|
|
204
|
+
)
|
|
205
|
+
claim = _plain_json(plan_directory / "foundation-apply-claim.json", "Foundation apply claim")
|
|
206
|
+
current_variables = _plain_json(
|
|
207
|
+
foundation / "foundation-variables.json", "Foundation variables"
|
|
208
|
+
)
|
|
209
|
+
context = _object(plan.get("context"), "Foundation saved plan context")
|
|
210
|
+
located = _plain_json(plan_directory / "foundation-private-handoff.json", "handoff")
|
|
211
|
+
terraform_root = _text(located, "terraform_root")
|
|
212
|
+
if terraform_root != "infra/genesis-foundation":
|
|
213
|
+
raise ValueError("source teardown Terraform root differs")
|
|
214
|
+
terraform_directory = (source_root / terraform_root).resolve()
|
|
215
|
+
try:
|
|
216
|
+
terraform_directory.relative_to(source_root)
|
|
217
|
+
except ValueError as exc:
|
|
218
|
+
raise ValueError("source teardown Terraform root escapes the selected source") from exc
|
|
219
|
+
if not terraform_directory.is_dir():
|
|
220
|
+
raise ValueError("source teardown Terraform root is unavailable")
|
|
221
|
+
normalized_variables = {
|
|
222
|
+
key: value for key, value in current_variables.items() if key != "target_binding"
|
|
223
|
+
}
|
|
224
|
+
normalized_variables["env"] = "dev"
|
|
225
|
+
variables = _approved_plan_variables(
|
|
226
|
+
plan_directory=plan_directory,
|
|
227
|
+
plan=plan,
|
|
228
|
+
context=context,
|
|
229
|
+
terraform_directory=terraform_directory,
|
|
230
|
+
expected_variables=normalized_variables,
|
|
231
|
+
)
|
|
232
|
+
expected_idempotency = canonical_digest(
|
|
233
|
+
{
|
|
234
|
+
"target_binding": target_binding,
|
|
235
|
+
"plan_digest": plan.get("plan_digest"),
|
|
236
|
+
}
|
|
237
|
+
)
|
|
238
|
+
if (
|
|
239
|
+
(plan_directory / "foundation-apply-receipt.json").exists()
|
|
240
|
+
or plan.get("schema_version") != "fdai.foundation-saved-source-plan.v1"
|
|
241
|
+
or plan.get("state") != "review"
|
|
242
|
+
or context.get("source_commit") != source_commit
|
|
243
|
+
or context.get("target_binding") != target_binding
|
|
244
|
+
or context.get("run_digest") != run_binding
|
|
245
|
+
or context.get("variables_digest") != canonical_digest(variables)
|
|
246
|
+
or any(
|
|
247
|
+
current_variables.get(key) != variables.get(key)
|
|
248
|
+
for key in (
|
|
249
|
+
"workload",
|
|
250
|
+
"region_short",
|
|
251
|
+
"source_commit",
|
|
252
|
+
"run_digest",
|
|
253
|
+
"subscription_id",
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
or current_variables.get("target_binding") != target_binding
|
|
257
|
+
or claim.get("schema_version") != "fdai.genesis-foundation-apply-claim.v1"
|
|
258
|
+
or claim.get("state") != "applying"
|
|
259
|
+
or claim.get("review_digest") != plan.get("review_digest")
|
|
260
|
+
or claim.get("plan_digest") != plan.get("plan_digest")
|
|
261
|
+
or claim.get("target_binding") != target_binding
|
|
262
|
+
or claim.get("idempotency_key") != expected_idempotency
|
|
263
|
+
or claim.get("mutation_performed") is not False
|
|
264
|
+
):
|
|
265
|
+
raise ValueError("source teardown claimed Foundation proof differs")
|
|
266
|
+
workload = _text(variables, "workload", pattern=r"[a-z][a-z0-9]{1,15}")
|
|
267
|
+
region_short = _text(variables, "region_short", pattern=r"[a-z][a-z0-9]{1,7}")
|
|
268
|
+
app = _object(located.get("app_resource_group"), "application resource group")
|
|
269
|
+
ops = _object(located.get("ops"), "operations resource group")
|
|
270
|
+
if (
|
|
271
|
+
located.get("source_commit") != source_commit
|
|
272
|
+
or located.get("run_digest") != run_binding
|
|
273
|
+
or located.get("subscription_id") != variables.get("subscription_id")
|
|
274
|
+
or _text(app, "name", pattern=_GROUP.pattern) != f"rg-{workload}-dev-{region_short}"
|
|
275
|
+
or _text(ops, "resource_group_name", pattern=_GROUP.pattern)
|
|
276
|
+
!= f"rg-{workload}-ops-{region_short}"
|
|
277
|
+
):
|
|
278
|
+
raise ValueError("source teardown claimed Foundation location differs")
|
|
279
|
+
return located
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _approved_plan_variables(
|
|
283
|
+
*,
|
|
284
|
+
plan_directory: Path,
|
|
285
|
+
plan: dict[str, object],
|
|
286
|
+
context: dict[str, object],
|
|
287
|
+
terraform_directory: Path,
|
|
288
|
+
expected_variables: dict[str, object],
|
|
289
|
+
) -> dict[str, object]:
|
|
290
|
+
saved_plan = plan_directory / "foundation.tfplan"
|
|
291
|
+
plan_bytes = read_private_bytes(saved_plan, max_bytes=64 * 1024 * 1024)
|
|
292
|
+
if hashlib.sha256(plan_bytes).hexdigest() != plan.get("plan_digest"):
|
|
293
|
+
raise ValueError("source teardown saved Foundation plan differs")
|
|
294
|
+
terraform = plan_directory / "terraform"
|
|
295
|
+
terraform_details = terraform.lstat()
|
|
296
|
+
if (
|
|
297
|
+
not stat.S_ISREG(terraform_details.st_mode)
|
|
298
|
+
or stat.S_IMODE(terraform_details.st_mode) != 0o700
|
|
299
|
+
or terraform_details.st_uid != os.geteuid()
|
|
300
|
+
or terraform_details.st_nlink != 1
|
|
301
|
+
or not 0 < terraform_details.st_size <= 128 * 1024 * 1024
|
|
302
|
+
):
|
|
303
|
+
raise PermissionError("saved Terraform MUST be a current-UID mode-0700 executable")
|
|
304
|
+
terraform_bytes = terraform.read_bytes()
|
|
305
|
+
if hashlib.sha256(terraform_bytes).hexdigest() != context.get("terraform_digest"):
|
|
306
|
+
raise ValueError("source teardown saved Terraform differs")
|
|
307
|
+
completed = subprocess.run(
|
|
308
|
+
(str(terraform), "show", "-json", str(saved_plan)),
|
|
309
|
+
cwd=terraform_directory,
|
|
310
|
+
env={
|
|
311
|
+
**os.environ,
|
|
312
|
+
"TF_CLI_CONFIG_FILE": str(plan_directory / "source.tfrc"),
|
|
313
|
+
"TF_DATA_DIR": str(plan_directory / "terraform-data"),
|
|
314
|
+
},
|
|
315
|
+
check=False,
|
|
316
|
+
capture_output=True,
|
|
317
|
+
timeout=300,
|
|
318
|
+
umask=0o077,
|
|
319
|
+
)
|
|
320
|
+
if (
|
|
321
|
+
completed.returncode != 0
|
|
322
|
+
or len(completed.stdout) > 16 * 1024 * 1024
|
|
323
|
+
or hashlib.sha256(completed.stdout).hexdigest() != plan.get("plan_json_digest")
|
|
324
|
+
):
|
|
325
|
+
raise ValueError("source teardown saved Foundation plan projection differs")
|
|
326
|
+
projection = load_json_object(
|
|
327
|
+
completed.stdout,
|
|
328
|
+
label="saved Foundation plan projection",
|
|
329
|
+
max_bytes=16 * 1024 * 1024,
|
|
330
|
+
)
|
|
331
|
+
raw_variables = projection.get("variables")
|
|
332
|
+
if not isinstance(raw_variables, dict) or any(
|
|
333
|
+
raw_variables.get(key) != {"value": value} for key, value in expected_variables.items()
|
|
334
|
+
):
|
|
335
|
+
raise ValueError("source teardown saved Foundation variables are invalid")
|
|
336
|
+
return expected_variables
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _recovered_foundation_ownership(
|
|
340
|
+
*,
|
|
341
|
+
recovery_directory: Path,
|
|
342
|
+
source_commit: str,
|
|
343
|
+
target_binding: str,
|
|
344
|
+
run_binding: str,
|
|
345
|
+
) -> tuple[dict[str, object], dict[str, object]]:
|
|
346
|
+
"""Verify recovered Foundation ownership without fabricating an ordinary apply receipt."""
|
|
347
|
+
|
|
348
|
+
if (
|
|
349
|
+
not recovery_directory.is_dir()
|
|
350
|
+
or recovery_directory.is_symlink()
|
|
351
|
+
or re.fullmatch(r"foundation-recovery-attempt-[1-9][0-9]*", recovery_directory.name) is None
|
|
352
|
+
):
|
|
353
|
+
raise ValueError("source teardown recovery directory is invalid")
|
|
354
|
+
review = _verified_full(
|
|
355
|
+
recovery_directory / "recovery-review.json",
|
|
356
|
+
"Foundation recovery review",
|
|
357
|
+
digest_field="review_digest",
|
|
358
|
+
)
|
|
359
|
+
apply = _verified_full(
|
|
360
|
+
recovery_directory / "recovery-apply-receipt.json",
|
|
361
|
+
"Foundation recovery apply",
|
|
362
|
+
)
|
|
363
|
+
handoff = _verified_full(
|
|
364
|
+
recovery_directory / "foundation-state-handoff-receipt.json",
|
|
365
|
+
"recovered Foundation handoff",
|
|
366
|
+
)
|
|
367
|
+
located = _plain_json(
|
|
368
|
+
recovery_directory / "recovery-private-handoff.json",
|
|
369
|
+
"recovered private handoff",
|
|
370
|
+
)
|
|
371
|
+
if (
|
|
372
|
+
review.get("schema_version") != "fdai.foundation-recovery-review.v1"
|
|
373
|
+
or review.get("source_commit") != source_commit
|
|
374
|
+
or review.get("target_binding") != target_binding
|
|
375
|
+
or apply.get("schema_version") != "fdai.foundation-recovery-receipt.v1"
|
|
376
|
+
or apply.get("state") != "verified"
|
|
377
|
+
or apply.get("source_commit") != source_commit
|
|
378
|
+
or apply.get("control_plane_readback_verified") is not True
|
|
379
|
+
or apply.get("zero_change_verified") is not True
|
|
380
|
+
or handoff.get("schema_version") != "fdai.genesis-foundation-state-handoff-receipt.v1"
|
|
381
|
+
or handoff.get("state") != "verified"
|
|
382
|
+
or handoff.get("source_commit") != source_commit
|
|
383
|
+
or handoff.get("target_binding") != target_binding
|
|
384
|
+
or handoff.get("foundation_receipt_digest") != apply.get("receipt_digest")
|
|
385
|
+
or any(
|
|
386
|
+
handoff.get(key) is not True
|
|
387
|
+
for key in (
|
|
388
|
+
"effect_verified",
|
|
389
|
+
"runner_attested",
|
|
390
|
+
"remote_backend_authority_verified",
|
|
391
|
+
"zero_change_verified",
|
|
392
|
+
"remote_transient_deleted",
|
|
393
|
+
"local_state_deleted",
|
|
394
|
+
)
|
|
395
|
+
)
|
|
396
|
+
or canonical_digest(located) != apply.get("handoff_digest")
|
|
397
|
+
or located.get("source_commit") != source_commit
|
|
398
|
+
or located.get("run_digest") != run_binding
|
|
399
|
+
):
|
|
400
|
+
raise ValueError("source teardown recovered Foundation ownership proof differs")
|
|
401
|
+
return handoff, located
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def apply_source_teardown(
|
|
405
|
+
*,
|
|
406
|
+
work_dir: Path,
|
|
407
|
+
source_root: Path | None = None,
|
|
408
|
+
runtime_profile: RuntimeDeploymentProfile,
|
|
409
|
+
region: str,
|
|
410
|
+
monthly_cost_ceiling: int,
|
|
411
|
+
confirmation: str | None,
|
|
412
|
+
client: ResourceGroupClient,
|
|
413
|
+
readback_timeout_seconds: float = 1800,
|
|
414
|
+
readback_interval_seconds: float = 30,
|
|
415
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
416
|
+
recovery_directory: Path | None = None,
|
|
417
|
+
) -> dict[str, object]:
|
|
418
|
+
"""Delete only proven-owned groups, then read back their absence."""
|
|
419
|
+
|
|
420
|
+
plan = plan_source_teardown(
|
|
421
|
+
work_dir=work_dir,
|
|
422
|
+
source_root=source_root,
|
|
423
|
+
runtime_profile=runtime_profile,
|
|
424
|
+
region=region,
|
|
425
|
+
monthly_cost_ceiling=monthly_cost_ceiling,
|
|
426
|
+
recovery_directory=recovery_directory,
|
|
427
|
+
)
|
|
428
|
+
review = _review(plan)
|
|
429
|
+
if confirmation != plan.confirmation:
|
|
430
|
+
return {
|
|
431
|
+
**review,
|
|
432
|
+
"state": "review",
|
|
433
|
+
"reason_code": "source_teardown_confirmation_required",
|
|
434
|
+
"required_confirmation": plan.confirmation,
|
|
435
|
+
"mutation_performed": False,
|
|
436
|
+
"deployment_ready": False,
|
|
437
|
+
}
|
|
438
|
+
for group in plan.resource_groups:
|
|
439
|
+
# A rerun after a partial failure must not fail on a group that is already gone.
|
|
440
|
+
if not client.group_absent(subscription_id=plan.subscription_id, name=group):
|
|
441
|
+
client.delete_group(subscription_id=plan.subscription_id, name=group)
|
|
442
|
+
# Deletion is accepted asynchronously, so absence is read back until a bounded deadline.
|
|
443
|
+
deadline = time.monotonic() + readback_timeout_seconds
|
|
444
|
+
while True:
|
|
445
|
+
absent = {
|
|
446
|
+
group: client.group_absent(subscription_id=plan.subscription_id, name=group)
|
|
447
|
+
for group in plan.resource_groups
|
|
448
|
+
}
|
|
449
|
+
if all(absent.values()) or time.monotonic() >= deadline:
|
|
450
|
+
break
|
|
451
|
+
sleep(min(readback_interval_seconds, max(0.0, deadline - time.monotonic())))
|
|
452
|
+
if not all(absent.values()):
|
|
453
|
+
return {
|
|
454
|
+
**review,
|
|
455
|
+
"state": "partial-failure",
|
|
456
|
+
"reason_code": "source_teardown_readback_failed",
|
|
457
|
+
"absent": absent,
|
|
458
|
+
"mutation_performed": True,
|
|
459
|
+
"deployment_ready": False,
|
|
460
|
+
}
|
|
461
|
+
receipt = {
|
|
462
|
+
**review,
|
|
463
|
+
"state": "torn-down",
|
|
464
|
+
"absent": absent,
|
|
465
|
+
"mutation_performed": True,
|
|
466
|
+
"deployment_ready": False,
|
|
467
|
+
}
|
|
468
|
+
receipt["receipt_digest"] = canonical_digest(receipt)
|
|
469
|
+
write_private_output(
|
|
470
|
+
work_dir / "source-teardown-receipt.json",
|
|
471
|
+
json.dumps(receipt, sort_keys=True, separators=(",", ":")) + "\n",
|
|
472
|
+
)
|
|
473
|
+
return receipt
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
class AzureResourceGroupClient:
|
|
477
|
+
"""Remove and read back resource groups through the current Azure CLI identity."""
|
|
478
|
+
|
|
479
|
+
def delete_group(self, *, subscription_id: str, name: str) -> None:
|
|
480
|
+
_run(
|
|
481
|
+
(
|
|
482
|
+
"az",
|
|
483
|
+
"group",
|
|
484
|
+
"delete",
|
|
485
|
+
"--subscription",
|
|
486
|
+
subscription_id,
|
|
487
|
+
"--name",
|
|
488
|
+
name,
|
|
489
|
+
"--yes",
|
|
490
|
+
"--no-wait",
|
|
491
|
+
"--only-show-errors",
|
|
492
|
+
),
|
|
493
|
+
reason="source teardown resource deletion failed",
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
def group_absent(self, *, subscription_id: str, name: str) -> bool:
|
|
497
|
+
output = _run(
|
|
498
|
+
(
|
|
499
|
+
"az",
|
|
500
|
+
"group",
|
|
501
|
+
"exists",
|
|
502
|
+
"--subscription",
|
|
503
|
+
subscription_id,
|
|
504
|
+
"--name",
|
|
505
|
+
name,
|
|
506
|
+
"--output",
|
|
507
|
+
"tsv",
|
|
508
|
+
"--only-show-errors",
|
|
509
|
+
),
|
|
510
|
+
reason="source teardown resource readback failed",
|
|
511
|
+
).strip()
|
|
512
|
+
if output not in {"true", "false"}:
|
|
513
|
+
raise ValueError("source teardown resource readback is invalid")
|
|
514
|
+
return output == "false"
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _review(plan: SourceTeardownPlan) -> dict[str, object]:
|
|
518
|
+
return {
|
|
519
|
+
"schema_version": "fdai.source-teardown-review.v1",
|
|
520
|
+
"target_binding": plan.target_binding,
|
|
521
|
+
"source_commit": plan.source_commit,
|
|
522
|
+
"run_binding": plan.run_binding,
|
|
523
|
+
"installation_anchor": plan.installation_anchor,
|
|
524
|
+
"resource_groups": list(plan.resource_groups),
|
|
525
|
+
"owned_resources_verified": True,
|
|
526
|
+
"foreign_resources_refused": True,
|
|
527
|
+
"subscription_ready": False,
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _verified(path: Path, label: str, expected_digest: str | None) -> dict[str, object]:
|
|
532
|
+
value = _verified_full(path, label)
|
|
533
|
+
digest = value["receipt_digest"]
|
|
534
|
+
if expected_digest is not None and digest != expected_digest:
|
|
535
|
+
raise ValueError(f"{label} digest differs")
|
|
536
|
+
return {key: item for key, item in value.items() if key != "receipt_digest"}
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _verified_full(
|
|
540
|
+
path: Path, label: str, *, digest_field: str = "receipt_digest"
|
|
541
|
+
) -> dict[str, object]:
|
|
542
|
+
value = _plain_json(path, label)
|
|
543
|
+
digest = value.get(digest_field)
|
|
544
|
+
unsigned = {key: item for key, item in value.items() if key != digest_field}
|
|
545
|
+
if not isinstance(digest, str) or canonical_digest(unsigned) != digest:
|
|
546
|
+
raise ValueError(f"{label} digest is invalid")
|
|
547
|
+
return value
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _plain_json(path: Path, label: str) -> dict[str, object]:
|
|
551
|
+
value = load_json_object(read_private_bytes(path, max_bytes=1024 * 1024), label=label)
|
|
552
|
+
if not isinstance(value, dict):
|
|
553
|
+
raise TypeError(f"{label} is invalid")
|
|
554
|
+
return {str(key): item for key, item in value.items()}
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _object(value: object, label: str) -> dict[str, object]:
|
|
558
|
+
if not isinstance(value, dict):
|
|
559
|
+
raise TypeError(f"{label} is invalid")
|
|
560
|
+
return {str(key): item for key, item in value.items()}
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _text(value: dict[str, object], key: str, *, pattern: str | None = None) -> str:
|
|
564
|
+
item = value.get(key)
|
|
565
|
+
if not isinstance(item, str) or (pattern is not None and re.fullmatch(pattern, item) is None):
|
|
566
|
+
raise ValueError(f"{key} is invalid")
|
|
567
|
+
return item
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _run(command: tuple[str, ...], *, reason: str) -> str:
|
|
571
|
+
completed = subprocess.run(command, check=False, capture_output=True, text=True, timeout=900)
|
|
572
|
+
if completed.returncode != 0:
|
|
573
|
+
raise ValueError(reason)
|
|
574
|
+
return completed.stdout
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Acquire the committed Terraform release for a source deployment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import http.client
|
|
7
|
+
import io
|
|
8
|
+
import platform
|
|
9
|
+
import re
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.request
|
|
12
|
+
import zipfile
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from fdai_deployment_cli.deployment_deadline import DeploymentDeadline
|
|
17
|
+
from fdai_deployment_cli.private_output import write_private_bytes
|
|
18
|
+
|
|
19
|
+
_RELEASES = "https://releases.hashicorp.com/terraform"
|
|
20
|
+
_MAX_ARCHIVE_BYTES = 64 * 1024 * 1024
|
|
21
|
+
_MAX_BINARY_BYTES = 128 * 1024 * 1024
|
|
22
|
+
_DIGEST = re.compile(r"[0-9a-f]{64}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
26
|
+
def redirect_request(self, *args: Any, **kwargs: Any) -> None:
|
|
27
|
+
raise ValueError("Terraform release redirects are not supported")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def download_pinned_terraform(
|
|
31
|
+
*, toolchain: dict[str, Any], destination: Path, timeout_seconds: int = 300
|
|
32
|
+
) -> Path:
|
|
33
|
+
"""Fetch the pinned release once and keep it only when both committed digests match.
|
|
34
|
+
|
|
35
|
+
The archive digest and the extracted binary digest both come from the source
|
|
36
|
+
snapshot's toolchain record, so the public release host is never trusted.
|
|
37
|
+
"""
|
|
38
|
+
version = toolchain.get("terraform_version")
|
|
39
|
+
archive_digest = toolchain.get("terraform_sha256")
|
|
40
|
+
binary_digest = toolchain.get("terraform_binary_sha256")
|
|
41
|
+
if (
|
|
42
|
+
not isinstance(version, str)
|
|
43
|
+
or re.fullmatch(r"\d+\.\d+\.\d+", version) is None
|
|
44
|
+
or not isinstance(archive_digest, str)
|
|
45
|
+
or _DIGEST.fullmatch(archive_digest) is None
|
|
46
|
+
or not isinstance(binary_digest, str)
|
|
47
|
+
or _DIGEST.fullmatch(binary_digest) is None
|
|
48
|
+
):
|
|
49
|
+
raise ValueError("source toolchain does not pin a Terraform release")
|
|
50
|
+
if platform.system() != "Linux" or platform.machine() not in {"x86_64", "AMD64"}:
|
|
51
|
+
raise ValueError(
|
|
52
|
+
f"install Terraform {version} on PATH; the committed digests cover linux_amd64 only"
|
|
53
|
+
)
|
|
54
|
+
deadline = DeploymentDeadline(timeout_seconds)
|
|
55
|
+
url = f"{_RELEASES}/{version}/terraform_{version}_linux_amd64.zip"
|
|
56
|
+
opener = urllib.request.build_opener(_NoRedirect())
|
|
57
|
+
try:
|
|
58
|
+
with opener.open(url, timeout=deadline.remaining(60)) as response:
|
|
59
|
+
chunks = bytearray()
|
|
60
|
+
while len(chunks) <= _MAX_ARCHIVE_BYTES:
|
|
61
|
+
deadline.remaining()
|
|
62
|
+
chunk = response.read1(min(1024 * 1024, _MAX_ARCHIVE_BYTES + 1 - len(chunks)))
|
|
63
|
+
if not chunk:
|
|
64
|
+
break
|
|
65
|
+
chunks.extend(chunk)
|
|
66
|
+
except (OSError, urllib.error.URLError, http.client.HTTPException):
|
|
67
|
+
raise ValueError(
|
|
68
|
+
f"Terraform {version} download unavailable; install it on PATH or retry"
|
|
69
|
+
) from None
|
|
70
|
+
archive = bytes(chunks)
|
|
71
|
+
if len(archive) > _MAX_ARCHIVE_BYTES or hashlib.sha256(archive).hexdigest() != archive_digest:
|
|
72
|
+
raise ValueError("downloaded Terraform archive does not match the committed digest")
|
|
73
|
+
try:
|
|
74
|
+
with zipfile.ZipFile(io.BytesIO(archive)) as bundle:
|
|
75
|
+
member = bundle.getinfo("terraform")
|
|
76
|
+
if not 0 < member.file_size <= _MAX_BINARY_BYTES:
|
|
77
|
+
raise ValueError("downloaded Terraform binary exceeds its bound")
|
|
78
|
+
binary = bundle.read(member)
|
|
79
|
+
except (KeyError, zipfile.BadZipFile):
|
|
80
|
+
raise ValueError("downloaded Terraform archive has no terraform binary") from None
|
|
81
|
+
if hashlib.sha256(binary).hexdigest() != binary_digest:
|
|
82
|
+
raise ValueError("downloaded Terraform binary does not match the committed digest")
|
|
83
|
+
write_private_bytes(destination, binary)
|
|
84
|
+
destination.chmod(0o700)
|
|
85
|
+
return destination
|