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.
Files changed (203) hide show
  1. fdai_deployment_cli/__about__.py +3 -0
  2. fdai_deployment_cli/__init__.py +5 -0
  3. fdai_deployment_cli/__main__.py +5 -0
  4. fdai_deployment_cli/acr_publish.py +585 -0
  5. fdai_deployment_cli/aks_catalog_review.py +137 -0
  6. fdai_deployment_cli/aks_historical_reconciliation.py +732 -0
  7. fdai_deployment_cli/aks_job_environment.py +59 -0
  8. fdai_deployment_cli/aks_job_execution.py +794 -0
  9. fdai_deployment_cli/aks_kubernetes_defaults.py +66 -0
  10. fdai_deployment_cli/aks_preflight.py +422 -0
  11. fdai_deployment_cli/aks_readiness.py +220 -0
  12. fdai_deployment_cli/aks_service_update.py +180 -0
  13. fdai_deployment_cli/aks_workload_jobs.py +281 -0
  14. fdai_deployment_cli/application_state_adoption.py +347 -0
  15. fdai_deployment_cli/azure_actor.py +61 -0
  16. fdai_deployment_cli/azure_naming.py +252 -0
  17. fdai_deployment_cli/azure_retail_prices.py +173 -0
  18. fdai_deployment_cli/bootstrap_approval.py +99 -0
  19. fdai_deployment_cli/bootstrap_channel.py +321 -0
  20. fdai_deployment_cli/bootstrap_executor.py +311 -0
  21. fdai_deployment_cli/bootstrap_plan.py +237 -0
  22. fdai_deployment_cli/bootstrap_reconcile.py +402 -0
  23. fdai_deployment_cli/bootstrap_support.py +138 -0
  24. fdai_deployment_cli/bootstrap_worker.py +448 -0
  25. fdai_deployment_cli/bundle.py +352 -0
  26. fdai_deployment_cli/capacity.py +143 -0
  27. fdai_deployment_cli/catalog_review_profile.py +306 -0
  28. fdai_deployment_cli/cli.py +798 -0
  29. fdai_deployment_cli/cli_help.py +214 -0
  30. fdai_deployment_cli/cli_parser.py +791 -0
  31. fdai_deployment_cli/cli_plan.py +450 -0
  32. fdai_deployment_cli/compiler.py +99 -0
  33. fdai_deployment_cli/console_artifact.py +218 -0
  34. fdai_deployment_cli/console_config.py +214 -0
  35. fdai_deployment_cli/console_update.py +832 -0
  36. fdai_deployment_cli/contracts.py +307 -0
  37. fdai_deployment_cli/control_package.py +140 -0
  38. fdai_deployment_cli/deadline_transport.py +70 -0
  39. fdai_deployment_cli/deployment_cost.py +131 -0
  40. fdai_deployment_cli/deployment_deadline.py +28 -0
  41. fdai_deployment_cli/deployment_kit.py +790 -0
  42. fdai_deployment_cli/deployment_kit_cache.py +191 -0
  43. fdai_deployment_cli/deployment_progress.py +648 -0
  44. fdai_deployment_cli/development_authority_cli.py +56 -0
  45. fdai_deployment_cli/development_authority_profile.py +319 -0
  46. fdai_deployment_cli/doctor.py +136 -0
  47. fdai_deployment_cli/entitlement_preflight.py +68 -0
  48. fdai_deployment_cli/entitlement_transport.py +49 -0
  49. fdai_deployment_cli/entra_bootstrap.py +579 -0
  50. fdai_deployment_cli/entra_control_profile.py +214 -0
  51. fdai_deployment_cli/entra_graph.py +349 -0
  52. fdai_deployment_cli/entra_profiles.py +215 -0
  53. fdai_deployment_cli/entra_source.py +244 -0
  54. fdai_deployment_cli/execution_bundle.py +478 -0
  55. fdai_deployment_cli/execution_copies.py +63 -0
  56. fdai_deployment_cli/foundation_adoption.py +438 -0
  57. fdai_deployment_cli/foundation_adoption_evidence.py +245 -0
  58. fdai_deployment_cli/foundation_adoption_host.py +157 -0
  59. fdai_deployment_cli/foundation_adoption_transport.py +51 -0
  60. fdai_deployment_cli/foundation_failure.py +72 -0
  61. fdai_deployment_cli/foundation_image.py +168 -0
  62. fdai_deployment_cli/foundation_input.py +235 -0
  63. fdai_deployment_cli/foundation_output.py +154 -0
  64. fdai_deployment_cli/foundation_plan.py +381 -0
  65. fdai_deployment_cli/foundation_process.py +64 -0
  66. fdai_deployment_cli/foundation_progress.py +231 -0
  67. fdai_deployment_cli/github_actions.py +171 -0
  68. fdai_deployment_cli/github_apply_artifact.py +259 -0
  69. fdai_deployment_cli/github_artifact_io.py +48 -0
  70. fdai_deployment_cli/github_dispatch.py +248 -0
  71. fdai_deployment_cli/github_plan_artifact.py +181 -0
  72. fdai_deployment_cli/github_provider_artifact.py +170 -0
  73. fdai_deployment_cli/github_transport.py +46 -0
  74. fdai_deployment_cli/github_workflow_values.py +312 -0
  75. fdai_deployment_cli/hub_image_deploy.py +354 -0
  76. fdai_deployment_cli/hub_image_stage.py +162 -0
  77. fdai_deployment_cli/hub_image_trust.py +281 -0
  78. fdai_deployment_cli/hub_ownership.py +161 -0
  79. fdai_deployment_cli/hub_workload_handoff.py +264 -0
  80. fdai_deployment_cli/hub_workload_handoff_inputs.py +227 -0
  81. fdai_deployment_cli/identity_profile.py +227 -0
  82. fdai_deployment_cli/image_targets.py +197 -0
  83. fdai_deployment_cli/installation_scope.py +280 -0
  84. fdai_deployment_cli/license.py +311 -0
  85. fdai_deployment_cli/license_issue.py +239 -0
  86. fdai_deployment_cli/lifecycle_authorization.py +252 -0
  87. fdai_deployment_cli/lifecycle_commands.py +173 -0
  88. fdai_deployment_cli/lifecycle_configuration.py +722 -0
  89. fdai_deployment_cli/lifecycle_configuration_artifact.py +45 -0
  90. fdai_deployment_cli/lifecycle_configuration_cli.py +324 -0
  91. fdai_deployment_cli/lifecycle_configuration_document.py +74 -0
  92. fdai_deployment_cli/lifecycle_configuration_entities.py +146 -0
  93. fdai_deployment_cli/lifecycle_configuration_package.py +492 -0
  94. fdai_deployment_cli/lifecycle_configuration_publication.py +277 -0
  95. fdai_deployment_cli/lifecycle_configuration_publication_cli.py +191 -0
  96. fdai_deployment_cli/lifecycle_configuration_sealing.py +127 -0
  97. fdai_deployment_cli/lifecycle_enrollment.py +45 -0
  98. fdai_deployment_cli/lifecycle_infrastructure_agent.py +174 -0
  99. fdai_deployment_cli/lifecycle_installation_binding.py +65 -0
  100. fdai_deployment_cli/lifecycle_native.py +247 -0
  101. fdai_deployment_cli/lifecycle_native_reconciliation.py +568 -0
  102. fdai_deployment_cli/lifecycle_native_transition.py +339 -0
  103. fdai_deployment_cli/lifecycle_ownership_exclusion.py +78 -0
  104. fdai_deployment_cli/lifecycle_plan.py +800 -0
  105. fdai_deployment_cli/lifecycle_promotion.py +185 -0
  106. fdai_deployment_cli/lifecycle_release.py +308 -0
  107. fdai_deployment_cli/lifecycle_self_upgrade.py +202 -0
  108. fdai_deployment_cli/lifecycle_upgrade_bundle.py +176 -0
  109. fdai_deployment_cli/notification_recipients_input.py +222 -0
  110. fdai_deployment_cli/observer_artifact.py +106 -0
  111. fdai_deployment_cli/oci_archive.py +353 -0
  112. fdai_deployment_cli/offline_kit.py +797 -0
  113. fdai_deployment_cli/offline_prepare.py +135 -0
  114. fdai_deployment_cli/operational_evidence_verifier_input.py +575 -0
  115. fdai_deployment_cli/plan_input.py +101 -0
  116. fdai_deployment_cli/private_output.py +192 -0
  117. fdai_deployment_cli/profile.py +70 -0
  118. fdai_deployment_cli/progress.py +183 -0
  119. fdai_deployment_cli/readiness.py +286 -0
  120. fdai_deployment_cli/run_command_receiver.py +418 -0
  121. fdai_deployment_cli/runtime_build.py +386 -0
  122. fdai_deployment_cli/runtime_profile.py +287 -0
  123. fdai_deployment_cli/runtime_release.py +745 -0
  124. fdai_deployment_cli/runtime_stage.py +82 -0
  125. fdai_deployment_cli/runtime_support_installation.py +215 -0
  126. fdai_deployment_cli/simulation.py +80 -0
  127. fdai_deployment_cli/source_application_inputs.py +397 -0
  128. fdai_deployment_cli/source_azure.py +586 -0
  129. fdai_deployment_cli/source_change_cli.py +107 -0
  130. fdai_deployment_cli/source_change_parser.py +75 -0
  131. fdai_deployment_cli/source_change_plan.py +181 -0
  132. fdai_deployment_cli/source_changed_update.py +196 -0
  133. fdai_deployment_cli/source_deploy.py +170 -0
  134. fdai_deployment_cli/source_foundation.py +174 -0
  135. fdai_deployment_cli/source_host_tools.py +163 -0
  136. fdai_deployment_cli/source_image_stage.py +400 -0
  137. fdai_deployment_cli/source_input.py +173 -0
  138. fdai_deployment_cli/source_projection.py +164 -0
  139. fdai_deployment_cli/source_receiver.py +76 -0
  140. fdai_deployment_cli/source_recovery.py +264 -0
  141. fdai_deployment_cli/source_runtime.py +81 -0
  142. fdai_deployment_cli/source_runtime_support.py +368 -0
  143. fdai_deployment_cli/source_service_update.py +726 -0
  144. fdai_deployment_cli/source_service_update_cli.py +47 -0
  145. fdai_deployment_cli/source_snapshot.py +178 -0
  146. fdai_deployment_cli/source_teardown.py +574 -0
  147. fdai_deployment_cli/source_terraform.py +85 -0
  148. fdai_deployment_cli/source_transport.py +399 -0
  149. fdai_deployment_cli/standalone_aks_inventory.py +558 -0
  150. fdai_deployment_cli/standalone_aks_job_execution.py +769 -0
  151. fdai_deployment_cli/standalone_aks_nodepool_guard.py +141 -0
  152. fdai_deployment_cli/standalone_application.py +977 -0
  153. fdai_deployment_cli/standalone_application_completion.py +151 -0
  154. fdai_deployment_cli/standalone_application_input_recovery.py +107 -0
  155. fdai_deployment_cli/standalone_application_recovery.py +116 -0
  156. fdai_deployment_cli/standalone_approval_input.py +24 -0
  157. fdai_deployment_cli/standalone_catalog_checkpoint.py +122 -0
  158. fdai_deployment_cli/standalone_catalog_review.py +251 -0
  159. fdai_deployment_cli/standalone_checkpoint_failure.py +127 -0
  160. fdai_deployment_cli/standalone_console_publish.py +174 -0
  161. fdai_deployment_cli/standalone_database_principals.py +41 -0
  162. fdai_deployment_cli/standalone_deploy.py +638 -0
  163. fdai_deployment_cli/standalone_development_authority.py +166 -0
  164. fdai_deployment_cli/standalone_foundation_adoption.py +87 -0
  165. fdai_deployment_cli/standalone_foundation_transition.py +767 -0
  166. fdai_deployment_cli/standalone_host.py +4608 -0
  167. fdai_deployment_cli/standalone_host_rebind.py +226 -0
  168. fdai_deployment_cli/standalone_host_residual.py +104 -0
  169. fdai_deployment_cli/standalone_host_state.py +145 -0
  170. fdai_deployment_cli/standalone_host_values.py +563 -0
  171. fdai_deployment_cli/standalone_kubernetes_tools.py +100 -0
  172. fdai_deployment_cli/standalone_license_installation.py +177 -0
  173. fdai_deployment_cli/standalone_management_egress.py +83 -0
  174. fdai_deployment_cli/standalone_migration_evidence.py +38 -0
  175. fdai_deployment_cli/standalone_operational_evidence.py +356 -0
  176. fdai_deployment_cli/standalone_planned_outputs.py +147 -0
  177. fdai_deployment_cli/standalone_private_access_probe.py +100 -0
  178. fdai_deployment_cli/standalone_product_profile.py +110 -0
  179. fdai_deployment_cli/standalone_remote_prepare.py +442 -0
  180. fdai_deployment_cli/standalone_residual_apply.py +540 -0
  181. fdai_deployment_cli/standalone_residual_successor.py +431 -0
  182. fdai_deployment_cli/standalone_review.py +266 -0
  183. fdai_deployment_cli/standalone_service_update_finalization.py +116 -0
  184. fdai_deployment_cli/standalone_service_update_recovery.py +277 -0
  185. fdai_deployment_cli/standalone_stage_targets.py +325 -0
  186. fdai_deployment_cli/standalone_status.py +67 -0
  187. fdai_deployment_cli/standalone_terraform_environment.py +237 -0
  188. fdai_deployment_cli/standalone_transfer_cleanup.py +237 -0
  189. fdai_deployment_cli/standalone_trial_activation.py +193 -0
  190. fdai_deployment_cli/state.py +434 -0
  191. fdai_deployment_cli/state_handoff.py +253 -0
  192. fdai_deployment_cli/status_projection.py +97 -0
  193. fdai_deployment_cli/support_install.py +257 -0
  194. fdai_deployment_cli/target.py +46 -0
  195. fdai_deployment_cli/trust/capabilities.json +62 -0
  196. fdai_deployment_cli/trust/deployment-bundle-root.pub +3 -0
  197. fdai_deployment_cli/trust/deployment-release-root.pub +3 -0
  198. fdai_deployment_cli/trust/upstream-signing-key.pub +3 -0
  199. fdai_deployment_cli/trust_roots.py +50 -0
  200. fdai_deployment_cli-0.1.2.dist-info/METADATA +112 -0
  201. fdai_deployment_cli-0.1.2.dist-info/RECORD +203 -0
  202. fdai_deployment_cli-0.1.2.dist-info/WHEEL +4 -0
  203. fdai_deployment_cli-0.1.2.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,174 @@
1
+ """Create source-bound Foundation plans with locked tools and no kit or apply fallback."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import stat
8
+ import subprocess
9
+ from collections.abc import Callable
10
+ from pathlib import Path
11
+ from tempfile import TemporaryDirectory
12
+ from typing import Any
13
+
14
+ from fdai_deployment_cli.contracts import ProvisionProfile, canonical_digest, load_json_object
15
+ from fdai_deployment_cli.foundation_image import verify_foundation_runner_image
16
+ from fdai_deployment_cli.foundation_input import snapshot_foundation_input
17
+ from fdai_deployment_cli.foundation_plan import save_foundation_plan, source_foundation_plan_context
18
+ from fdai_deployment_cli.plan_input import read_plan_input
19
+ from fdai_deployment_cli.private_output import write_private_bytes
20
+ from fdai_deployment_cli.source_snapshot import verify_source_snapshot
21
+
22
+
23
+ def prepare_source_foundation_plan(
24
+ *,
25
+ snapshot: Path,
26
+ snapshot_digest: str,
27
+ terraform: Path,
28
+ profile: ProvisionProfile,
29
+ variables_file: Path,
30
+ work_dir: Path,
31
+ environment_builder: Callable[..., dict[str, str]],
32
+ ) -> dict[str, object]:
33
+ """Save one exact source plan after input, target and image verification.
34
+
35
+ The caller validates the active human target and creates an empty private work
36
+ directory. Provider installation is connected and lockfile-readonly; no source,
37
+ Terraform binary, provider lock or approval is selected from a release fallback.
38
+ """
39
+ if (
40
+ profile.environment != "dev"
41
+ or profile.connectivity != "online"
42
+ or profile.transport != "manual"
43
+ ):
44
+ raise ValueError("source Foundation requires connected manual development")
45
+ source = verify_source_snapshot(snapshot, expected_digest=snapshot_digest)
46
+ root = snapshot / "tree"
47
+ infra = root / "infra/genesis-foundation"
48
+ lock = infra / ".terraform.lock.hcl"
49
+ toolchain = load_json_object(
50
+ (root / "infra/genesis-runner-image/toolchain.json").read_bytes(), label="source toolchain"
51
+ )
52
+ tool_digest = toolchain.get("terraform_binary_sha256")
53
+ executable = _copy_terraform(terraform, work_dir / "terraform", expected_digest=tool_digest)
54
+ config = work_dir / "source.tfrc"
55
+ write_private_bytes(
56
+ config, b'provider_installation { direct { include = ["registry.terraform.io/*/*"] } }\n'
57
+ )
58
+ normalized = work_dir / "variables.json"
59
+ binding = snapshot_foundation_input(
60
+ variables_file,
61
+ normalized,
62
+ expected_target_binding=profile.target_binding,
63
+ expected_region=profile.region,
64
+ expected_environment=profile.environment,
65
+ )
66
+ try:
67
+ values = read_plan_input(normalized)
68
+ if values["source_commit"] != source.get("source_commit"):
69
+ raise ValueError("source Foundation variables do not match the selected source")
70
+ if profile.access_method == "bastion" and values.get("enable_bastion") is not True:
71
+ raise ValueError("source Foundation requires its selected Bastion path")
72
+ import shutil
73
+
74
+ environment = environment_builder(
75
+ work_dir=work_dir,
76
+ config=config,
77
+ source=os.environ,
78
+ subscription_id=binding.subscription_id,
79
+ tenant_id=binding.tenant_id,
80
+ azure_cli_path=Path(azure_cli) if (azure_cli := shutil.which("az")) else None,
81
+ )
82
+ image_observation = verify_foundation_runner_image(values, expected_region=profile.region)
83
+ provider_lock = lock.read_bytes()
84
+ context = source_foundation_plan_context(
85
+ profile=profile,
86
+ variables=values,
87
+ source_input_digest=canonical_digest(source),
88
+ source_snapshot_digest=snapshot_digest,
89
+ terraform_digest=str(tool_digest),
90
+ provider_lock=provider_lock,
91
+ runner_image_observation_digest=image_observation,
92
+ )
93
+ for arguments in (
94
+ ("init", "-backend=false", "-input=false", "-lockfile=readonly"),
95
+ ("validate", "-no-color"),
96
+ ):
97
+ _run((str(executable), *arguments), infra, environment)
98
+ with TemporaryDirectory(prefix="source-plan-", dir=work_dir) as temporary:
99
+ plan = Path(temporary) / "plan.tfplan"
100
+ _run(
101
+ (
102
+ str(executable),
103
+ "plan",
104
+ "-input=false",
105
+ "-no-color",
106
+ f"-var-file={normalized}",
107
+ f"-out={plan}",
108
+ ),
109
+ infra,
110
+ environment,
111
+ )
112
+ if lock.read_bytes() != provider_lock:
113
+ raise ValueError("source Foundation provider lock changed")
114
+ verify_source_snapshot(snapshot, expected_digest=snapshot_digest)
115
+ if hashlib.sha256(executable.read_bytes()).hexdigest() != tool_digest:
116
+ raise ValueError("source Foundation Terraform changed during planning")
117
+ review = save_foundation_plan(
118
+ plan=plan,
119
+ destination=work_dir,
120
+ terraform=executable,
121
+ root=infra,
122
+ environment=environment,
123
+ context=context,
124
+ variables=values,
125
+ )
126
+ return {
127
+ "schema_version": "fdai.provision-source-plan.v1",
128
+ "stage": "foundation",
129
+ "state": "review",
130
+ "saved_plan": review,
131
+ "source_snapshot_digest": snapshot_digest,
132
+ "provenance": "operator-selected-source",
133
+ "release_signature_verified": False,
134
+ "apply_authorized": False,
135
+ "mutation_performed": False,
136
+ "deployment_ready": False,
137
+ "subscription_ready": False,
138
+ }
139
+ finally:
140
+ normalized.unlink(missing_ok=True)
141
+
142
+
143
+ def _copy_terraform(source: Path, destination: Path, *, expected_digest: Any) -> Path:
144
+ descriptor = os.open(source, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
145
+ with os.fdopen(descriptor, "rb") as stream:
146
+ details = os.fstat(stream.fileno())
147
+ if (
148
+ not stat.S_ISREG(details.st_mode)
149
+ or details.st_mode & 0o022
150
+ or details.st_uid not in {0, os.geteuid()}
151
+ or not 0 < details.st_size <= 128 * 1024 * 1024
152
+ ):
153
+ raise ValueError("source Terraform must be an owned bounded non-writable executable")
154
+ data = stream.read(128 * 1024 * 1024 + 1)
155
+ if len(data) != details.st_size or hashlib.sha256(data).hexdigest() != expected_digest:
156
+ raise ValueError("source Terraform does not match the committed toolchain digest")
157
+ write_private_bytes(destination, data)
158
+ destination.chmod(0o700)
159
+ return destination
160
+
161
+
162
+ def _run(command: tuple[str, ...], root: Path, environment: dict[str, str]) -> None:
163
+ result = subprocess.run(
164
+ command,
165
+ cwd=root,
166
+ env=environment,
167
+ stdin=subprocess.DEVNULL,
168
+ capture_output=True,
169
+ check=False,
170
+ timeout=900,
171
+ umask=0o077,
172
+ )
173
+ if result.returncode != 0:
174
+ raise ValueError(f"source Foundation {command[1]} failed; preserve private plan evidence")
@@ -0,0 +1,163 @@
1
+ """Acquire the pinned Kubernetes client tools on a source-deployment managed host.
2
+
3
+ A signed kit ships ``kubectl`` and ``kubelogin`` in its ``bin`` directory. A source deployment
4
+ downloads the same pinned releases on the connected managed host and keeps them only when the
5
+ digests committed here match. A consistency test keeps these pins equal to the kit build script.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import http.client
12
+ import io
13
+ import json
14
+ import platform
15
+ import stat
16
+ import urllib.error
17
+ import urllib.parse
18
+ import urllib.request
19
+ import zipfile
20
+ from pathlib import Path, PurePosixPath
21
+ from typing import Any
22
+
23
+ from fdai_deployment_cli.deployment_deadline import DeploymentDeadline
24
+ from fdai_deployment_cli.private_output import (
25
+ read_private_bytes,
26
+ write_private_bytes,
27
+ write_private_output,
28
+ )
29
+
30
+ KUBECTL_VERSION = "1.31.14"
31
+ KUBECTL_SHA256 = "8791ec7c8966b61420d55103a5fb948de9f0ca3d7306d789734975ad9704bdb0"
32
+ KUBELOGIN_VERSION = "0.2.19"
33
+ KUBELOGIN_ARCHIVE_SHA256 = "ebaeff02aa899c5cae6a2b954b64fc02738185319df2570f7dc053451efa4b2f"
34
+ _KUBECTL_URL = f"https://dl.k8s.io/release/v{KUBECTL_VERSION}/bin/linux/amd64/kubectl"
35
+ _KUBELOGIN_URL = (
36
+ "https://github.com/Azure/kubelogin/releases/download/"
37
+ f"v{KUBELOGIN_VERSION}/kubelogin-linux-amd64.zip"
38
+ )
39
+ _MAX_BYTES = 256 * 1024 * 1024
40
+ _RECEIPT = "kubernetes-tools.json"
41
+ _SCHEMA = "fdai.source-kubernetes-tools.v1"
42
+
43
+
44
+ class _HttpsOnlyRedirect(urllib.request.HTTPRedirectHandler):
45
+ max_redirections = 3
46
+
47
+ def redirect_request(
48
+ self, request: Any, fp: Any, code: int, msg: str, headers: Any, newurl: str
49
+ ) -> Any:
50
+ if urllib.parse.urlsplit(newurl).scheme != "https":
51
+ raise ValueError("Kubernetes tool downloads accept HTTPS redirects only")
52
+ return super().redirect_request(request, fp, code, msg, headers, newurl)
53
+
54
+
55
+ def install_kubernetes_tools(directory: Path, *, timeout_seconds: int = 600) -> Path:
56
+ """Return a private directory holding verified ``kubectl`` and ``kubelogin``.
57
+
58
+ The release hosts are not trusted: each download is kept only when its committed digest
59
+ matches. A rerun reuses the tools when their recorded binary digests still match.
60
+ """
61
+ if platform.system() != "Linux" or platform.machine() not in {"x86_64", "AMD64"}:
62
+ raise ValueError("source Kubernetes tool pins cover linux_amd64 only")
63
+ directory.mkdir(mode=0o700, exist_ok=True)
64
+ details = directory.lstat()
65
+ if not stat.S_ISDIR(details.st_mode) or stat.S_IMODE(details.st_mode) != 0o700:
66
+ raise ValueError("source Kubernetes tool directory must be a private directory")
67
+ receipt_path = directory / _RECEIPT
68
+ if receipt_path.exists() and _retained_tools_match(directory, receipt_path):
69
+ return directory
70
+ deadline = DeploymentDeadline(timeout_seconds)
71
+ kubectl = _download(_KUBECTL_URL, deadline)
72
+ if hashlib.sha256(kubectl).hexdigest() != KUBECTL_SHA256:
73
+ raise ValueError("downloaded kubectl does not match the committed digest")
74
+ archive = _download(_KUBELOGIN_URL, deadline)
75
+ if hashlib.sha256(archive).hexdigest() != KUBELOGIN_ARCHIVE_SHA256:
76
+ raise ValueError("downloaded kubelogin archive does not match the committed digest")
77
+ kubelogin = _kubelogin_binary(archive)
78
+ binaries = {"kubectl": kubectl, "kubelogin": kubelogin}
79
+ for name, content in binaries.items():
80
+ path = directory / name
81
+ path.unlink(missing_ok=True)
82
+ write_private_bytes(path, content)
83
+ path.chmod(0o700)
84
+ receipt_path.unlink(missing_ok=True)
85
+ write_private_output(
86
+ receipt_path,
87
+ json.dumps(
88
+ {
89
+ "schema_version": _SCHEMA,
90
+ "kubectl_version": KUBECTL_VERSION,
91
+ "kubelogin_version": KUBELOGIN_VERSION,
92
+ "binary_sha256": {
93
+ name: hashlib.sha256(content).hexdigest()
94
+ for name, content in sorted(binaries.items())
95
+ },
96
+ },
97
+ sort_keys=True,
98
+ )
99
+ + "\n",
100
+ )
101
+ return directory
102
+
103
+
104
+ def _retained_tools_match(directory: Path, receipt_path: Path) -> bool:
105
+ try:
106
+ receipt = json.loads(read_private_bytes(receipt_path, max_bytes=4096))
107
+ digests = receipt["binary_sha256"]
108
+ return (
109
+ receipt.get("schema_version") == _SCHEMA
110
+ and receipt.get("kubectl_version") == KUBECTL_VERSION
111
+ and receipt.get("kubelogin_version") == KUBELOGIN_VERSION
112
+ and digests.get("kubectl") == KUBECTL_SHA256
113
+ and all(
114
+ _binary_digest(directory / name) == digests.get(name)
115
+ for name in ("kubectl", "kubelogin")
116
+ )
117
+ )
118
+ except (OSError, ValueError, KeyError, TypeError, AttributeError):
119
+ return False
120
+
121
+
122
+ def _binary_digest(path: Path) -> str:
123
+ details = path.lstat()
124
+ if (
125
+ not stat.S_ISREG(details.st_mode)
126
+ or stat.S_IMODE(details.st_mode) != 0o700
127
+ or not 0 < details.st_size <= _MAX_BYTES
128
+ ):
129
+ raise ValueError("retained Kubernetes tool is not a private executable")
130
+ return hashlib.sha256(path.read_bytes()).hexdigest()
131
+
132
+
133
+ def _download(url: str, deadline: DeploymentDeadline) -> bytes:
134
+ opener = urllib.request.build_opener(_HttpsOnlyRedirect())
135
+ chunks = bytearray()
136
+ try:
137
+ with opener.open(url, timeout=deadline.remaining(60)) as response:
138
+ while len(chunks) <= _MAX_BYTES:
139
+ deadline.remaining()
140
+ chunk = response.read1(min(1024 * 1024, _MAX_BYTES + 1 - len(chunks)))
141
+ if not chunk:
142
+ break
143
+ chunks.extend(chunk)
144
+ except (OSError, urllib.error.URLError, http.client.HTTPException):
145
+ raise ValueError("pinned Kubernetes tool download is unavailable") from None
146
+ if len(chunks) > _MAX_BYTES:
147
+ raise ValueError("pinned Kubernetes tool download exceeds its bound")
148
+ return bytes(chunks)
149
+
150
+
151
+ def _kubelogin_binary(archive: bytes) -> bytes:
152
+ try:
153
+ with zipfile.ZipFile(io.BytesIO(archive)) as bundle:
154
+ members = [item for item in bundle.infolist() if not item.is_dir()]
155
+ if (
156
+ len(members) != 1
157
+ or PurePosixPath(members[0].filename).name != "kubelogin"
158
+ or not 0 < members[0].file_size <= _MAX_BYTES
159
+ ):
160
+ raise ValueError("kubelogin archive member set is invalid")
161
+ return bundle.read(members[0])
162
+ except zipfile.BadZipFile:
163
+ raise ValueError("kubelogin archive is invalid") from None