Coverage for cli/cluster_tunnel.py: 100.00%

116 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-30 21:22 +0000

1"""Shared helpers for reaching a cluster's (possibly private) EKS API endpoint. 

2 

3GCO clusters default to a PRIVATE EKS API endpoint, so ``kubectl`` cannot reach 

4the API server from outside the VPC. Two CLI commands need the same machinery to 

5bridge that gap: 

6 

7* ``gco monitoring open`` — port-forward Grafana/Prometheus over the API server; 

8* ``gco cluster tunnel`` — a general SSM tunnel so plain ``kubectl`` works. 

9 

10Rather than duplicate "detect endpoint posture → (optionally provision an 

11ephemeral bastion) → open an SSM tunnel → tear everything down", both commands 

12share this module: 

13 

14* :class:`TunnelPlan` + :func:`resolve_tunnel_plan` — a pure, request/response 

15 friendly description of how to reach the endpoint (the ``aws ssm start-session`` 

16 command and the ``kubectl`` flags). This is what ``--print`` and the MCP 

17 ``cluster_tunnel_command`` tool return. 

18* :func:`provision_bastion` / :func:`teardown_bastion` — the ephemeral-bastion 

19 lifecycle with a confirmation prompt and an orphan-check hint on failure. 

20* :func:`open_api_server_tunnel` — a context manager that ties it together and 

21 guarantees teardown (including when setup fails before yielding). 

22 

23``describe_cluster_access`` and ``start_api_tunnel`` are reached via their 

24modules (not ``from ... import``) so tests can monkeypatch them at the source. 

25""" 

26 

27from __future__ import annotations 

28 

29from collections.abc import Iterator 

30from contextlib import contextmanager 

31from dataclasses import dataclass 

32from typing import Any 

33 

34import click 

35 

36from . import ephemeral_bastion, kubectl_helpers, ssm_tunnel 

37 

38# Sentinel for --via-ssm: provision (and later destroy) an ephemeral bastion 

39# instead of tunnelling through a caller-supplied instance id. 

40AUTO_BASTION = "auto" 

41 

42# Local port kubectl uses to reach the API server through the SSM tunnel. 

43DEFAULT_API_LOCAL_PORT = 8443 

44 

45# A syntactically valid placeholder instance id, used only to render a copy-paste 

46# command *template* when the caller hasn't supplied a real instance id. 

47_PLACEHOLDER_INSTANCE = "i-0123456789abcdef0" 

48 

49 

50@dataclass(frozen=True) 

51class TunnelPlan: 

52 """How to reach a cluster's EKS API endpoint from a laptop. 

53 

54 Pure data + pure builders — no side effects — so it can back a ``--print`` 

55 CLI mode and an MCP tool that just *describe* the connection. 

56 """ 

57 

58 cluster: str 

59 region: str 

60 endpoint: str 

61 public: bool 

62 private: bool 

63 local_port: int = DEFAULT_API_LOCAL_PORT 

64 

65 @property 

66 def endpoint_host(self) -> str: 

67 """The bare endpoint hostname (kubectl ``--tls-server-name``), or ``""``.""" 

68 try: 

69 return ssm_tunnel.endpoint_host(self.endpoint) 

70 except ValueError: 

71 return "" 

72 

73 def ssm_command(self, instance_id: str) -> list[str]: 

74 """The ``aws ssm start-session`` argv that tunnels to the API endpoint.""" 

75 return ssm_tunnel.build_remote_host_port_forward_command( 

76 instance_id, self.endpoint_host, self.local_port, self.region 

77 ) 

78 

79 def kubectl_flags(self) -> list[str]: 

80 """The ``kubectl`` flags that point at the tunnel with the right TLS SNI.""" 

81 return [ 

82 "--server", 

83 f"https://localhost:{self.local_port}", 

84 "--tls-server-name", 

85 self.endpoint_host, 

86 ] 

87 

88 def update_kubeconfig_command(self) -> list[str]: 

89 """The ``aws eks update-kubeconfig`` argv for this cluster.""" 

90 return [ 

91 "aws", 

92 "eks", 

93 "update-kubeconfig", 

94 "--name", 

95 self.cluster, 

96 "--region", 

97 self.region, 

98 ] 

99 

100 def as_dict(self, instance_id: str | None = None) -> dict[str, Any]: 

101 """A JSON-friendly connection plan (what ``--print`` / the MCP tool emit).""" 

102 data: dict[str, Any] = { 

103 "cluster": self.cluster, 

104 "region": self.region, 

105 "endpoint": self.endpoint, 

106 "endpoint_host": self.endpoint_host, 

107 "public": self.public, 

108 "private": self.private, 

109 "local_port": self.local_port, 

110 "update_kubeconfig": self.update_kubeconfig_command(), 

111 } 

112 if not self.private: 

113 data["reachable"] = "direct" 

114 data["note"] = ( 

115 "The API endpoint is publicly reachable — run `aws eks update-kubeconfig` " 

116 "then use kubectl directly; no SSM tunnel is required." 

117 ) 

118 return data 

119 

120 data["reachable"] = "ssm-tunnel" 

121 data["kubectl_flags"] = self.kubectl_flags() 

122 data["kubectl_example"] = "kubectl " + " ".join(self.kubectl_flags()) + " get nodes" 

123 if instance_id: 

124 cmd = self.ssm_command(instance_id) 

125 data["ssm_command"] = cmd 

126 data["ssm_command_str"] = " ".join(cmd) 

127 data["note"] = ( 

128 "Run ssm_command_str in one shell to open the tunnel (keep it open), then " 

129 "use kubectl_flags (or kubectl_example) in another shell." 

130 ) 

131 else: 

132 template = " ".join(self.ssm_command(_PLACEHOLDER_INSTANCE)).replace( 

133 _PLACEHOLDER_INSTANCE, "<INSTANCE_ID>" 

134 ) 

135 data["ssm_command_template"] = template 

136 data["note"] = ( 

137 "Private endpoint. Replace <INSTANCE_ID> in ssm_command_template with an " 

138 "SSM-managed instance in the cluster VPC and run it to open the tunnel " 

139 "(or run `gco cluster tunnel --via-ssm auto` to auto-provision one), then " 

140 "use kubectl_flags in another shell." 

141 ) 

142 return data 

143 

144 

145def resolve_tunnel_plan( 

146 cluster: str, region: str, *, local_port: int = DEFAULT_API_LOCAL_PORT 

147) -> TunnelPlan: 

148 """Describe how to reach ``cluster``'s API endpoint (raises on lookup failure).""" 

149 access = kubectl_helpers.describe_cluster_access(cluster, region) 

150 return TunnelPlan( 

151 cluster=cluster, 

152 region=region, 

153 endpoint=str(access.get("endpoint") or ""), 

154 public=bool(access.get("public")), 

155 private=bool(access.get("private")), 

156 local_port=local_port, 

157 ) 

158 

159 

160def resolve_region(config: Any, region: str | None) -> str: 

161 """Pick the target region: explicit flag, else first cdk.json regional, else default.""" 

162 if region: 

163 return str(region) 

164 from .config import _load_cdk_json 

165 

166 cdk = _load_cdk_json() 

167 if isinstance(cdk, dict) and cdk.get("regional"): 

168 return str(cdk["regional"][0]) 

169 return str(config.default_region or "us-east-1") 

170 

171 

172def _project_name_from_cluster(cluster: str, region: str) -> str: 

173 """Recover the project key from a ``{project_name}-{region}`` cluster name. 

174 

175 Cluster names are built as ``f"{config.project_name}-{region}"`` (cli/config.py), 

176 so stripping the ``-{region}`` suffix yields the project key the bastion's IAM 

177 role / instance profile are scoped to — matching the deployment's other 

178 project-scoped resources without threading a second config value down here. 

179 """ 

180 suffix = f"-{region}" 

181 return cluster[: -len(suffix)] if cluster.endswith(suffix) else cluster 

182 

183 

184def private_endpoint_guidance(cluster: str, region: str) -> str: 

185 """Actionable message when the API endpoint is private and no tunnel is set up.""" 

186 return ( 

187 f"Cluster {cluster!r} has a PRIVATE API endpoint, so kubectl cannot reach it from " 

188 "outside the VPC. Either:\n" 

189 " - re-run with `--via-ssm <instance-id>` to tunnel through an SSM-managed instance " 

190 "in the VPC,\n" 

191 " - re-run with `--via-ssm auto` to provision a self-terminating ephemeral bastion,\n" 

192 " - connect over a VPN / bastion / AWS SSM session first (see docs/MONITORING.md), or\n" 

193 f" - set eks_cluster.endpoint_access to PUBLIC_AND_PRIVATE and redeploy {region}.\n" 

194 "Attempting to continue in case you already have VPC connectivity." 

195 ) 

196 

197 

198def provision_bastion( 

199 formatter: Any, 

200 cluster: str, 

201 region: str, 

202 ttl_minutes: int, 

203 assume_yes: bool, 

204 project_name: str = ephemeral_bastion.DEFAULT_PROJECT_NAME, 

205) -> str: 

206 """Provision an ephemeral SSM bastion for the tunnel; return its instance id. 

207 

208 Prompts for confirmation (it launches a billable EC2 instance) unless 

209 ``assume_yes``. The instance self-terminates after ``ttl_minutes`` and is 

210 torn down by :func:`teardown_bastion` when the tunnel closes. Its IAM role / 

211 instance profile are named for ``project_name``. 

212 """ 

213 if not assume_yes: 

214 formatter.print_warning( 

215 f"--via-ssm auto will launch a {ephemeral_bastion.BASTION_INSTANCE_TYPE} ephemeral " 

216 f"bastion in the {cluster} VPC to reach the private API endpoint. It requires no " 

217 f"inbound ports (SSM is outbound-only), self-terminates after {ttl_minutes} minutes, " 

218 "and is torn down automatically when the tunnel closes." 

219 ) 

220 click.confirm("Provision the ephemeral bastion?", abort=True) 

221 

222 formatter.print_info( 

223 f"Provisioning ephemeral SSM bastion in {region} " 

224 "(waiting for it to register with SSM; this takes a minute)..." 

225 ) 

226 instance_id = ephemeral_bastion.create_ephemeral_bastion( 

227 cluster, region, project_name=project_name, ttl_minutes=ttl_minutes 

228 ) 

229 formatter.print_success(f"Ephemeral bastion {instance_id} is online.") 

230 return instance_id 

231 

232 

233def teardown_bastion( 

234 formatter: Any, 

235 instance_id: str, 

236 region: str, 

237 project_name: str = ephemeral_bastion.DEFAULT_PROJECT_NAME, 

238) -> None: 

239 """Tear down an ephemeral bastion; on failure, print the orphan-check command.""" 

240 try: 

241 formatter.print_info(f"Tearing down ephemeral bastion {instance_id}...") 

242 ephemeral_bastion.destroy_ephemeral_bastion(instance_id, region, project_name=project_name) 

243 formatter.print_success(f"Ephemeral bastion {instance_id} terminated.") 

244 except Exception as exc: # noqa: BLE001 — never crash teardown; guide the operator 

245 formatter.print_error( 

246 f"Failed to tear down bastion {instance_id}: {exc}\n" 

247 "Check for and terminate any orphan with:\n" 

248 f" aws ec2 describe-instances --region {region} " 

249 "--filters Name=tag:gco:ephemeral,Values=true " 

250 "Name=instance-state-name,Values=running,pending " 

251 "--query 'Reservations[].Instances[].InstanceId' --output text" 

252 ) 

253 

254 

255@dataclass(frozen=True) 

256class TunnelSession: 

257 """What :func:`open_api_server_tunnel` yields to the command body.""" 

258 

259 # kubectl --server / --tls-server-name overrides, or None when the endpoint 

260 # is reachable directly (public) or no tunnel could be established. 

261 server: str | None 

262 tls_server_name: str | None 

263 plan: TunnelPlan 

264 # True when an SSM tunnel process is running for this session. 

265 active: bool 

266 

267 

268@contextmanager 

269def open_api_server_tunnel( 

270 formatter: Any, 

271 *, 

272 cluster: str, 

273 region: str, 

274 via_ssm: str | None, 

275 local_port: int = DEFAULT_API_LOCAL_PORT, 

276 bastion_ttl_minutes: int | None = None, 

277 assume_yes: bool = False, 

278) -> Iterator[TunnelSession]: 

279 """Resolve endpoint posture and, when private + ``via_ssm``, open an SSM tunnel. 

280 

281 Yields a :class:`TunnelSession`. Manages the ephemeral-bastion (``--via-ssm 

282 auto``) and tunnel lifecycle and tears both down on exit — including when 

283 setup fails before the ``yield`` (so a failed tunnel never leaks a bastion). 

284 """ 

285 if bastion_ttl_minutes is None: 

286 bastion_ttl_minutes = ephemeral_bastion.DEFAULT_TTL_MINUTES 

287 # The bastion's IAM role/profile are scoped to the deployment's project key, 

288 # recovered from the cluster name (which is f"{project_name}-{region}"). 

289 project_name = _project_name_from_cluster(cluster, region) 

290 

291 try: 

292 plan = resolve_tunnel_plan(cluster, region, local_port=local_port) 

293 except (RuntimeError, ValueError) as exc: 

294 # Endpoint posture unknown — fall back to a direct attempt. 

295 formatter.print_warning(f"Could not determine endpoint access mode: {exc}") 

296 plan = TunnelPlan( 

297 cluster=cluster, 

298 region=region, 

299 endpoint="", 

300 public=True, 

301 private=False, 

302 local_port=local_port, 

303 ) 

304 

305 server: str | None = None 

306 tls_server_name: str | None = None 

307 tunnel = None 

308 created_bastion: str | None = None 

309 try: 

310 if not plan.public: 

311 instance_id = via_ssm 

312 if via_ssm == AUTO_BASTION: 

313 created_bastion = provision_bastion( 

314 formatter, cluster, region, bastion_ttl_minutes, assume_yes, project_name 

315 ) 

316 instance_id = created_bastion 

317 

318 if instance_id: 

319 formatter.print_info( 

320 f"Opening SSM tunnel to the private API endpoint via {instance_id}..." 

321 ) 

322 tunnel = ssm_tunnel.start_api_tunnel(instance_id, plan.endpoint, local_port, region) 

323 server = f"https://localhost:{local_port}" 

324 tls_server_name = plan.endpoint_host 

325 else: 

326 formatter.print_warning(private_endpoint_guidance(cluster, region)) 

327 

328 yield TunnelSession( 

329 server=server, 

330 tls_server_name=tls_server_name, 

331 plan=plan, 

332 active=tunnel is not None, 

333 ) 

334 finally: 

335 if tunnel is not None: 

336 tunnel.terminate() 

337 if created_bastion is not None: 

338 teardown_bastion(formatter, created_bastion, region, project_name)