Coverage for cli/commands/cluster_cmd.py: 98.82%
73 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-30 21:22 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-30 21:22 +0000
1"""GCO cluster connectivity commands.
3``gco cluster tunnel`` opens an AWS SSM Session Manager tunnel to a cluster's
4PRIVATE EKS API endpoint so ``kubectl`` works from a laptop outside the VPC —
5optionally provisioning a self-terminating ephemeral bastion (``--via-ssm
6auto``). It reuses the same tunnel/bastion machinery as ``gco monitoring open``
7(:mod:`cli.cluster_tunnel`).
9``--print`` emits the ready-to-run tunnel + ``kubectl`` commands (a connection
10plan) instead of opening the tunnel — the request/response form the MCP
11``cluster_tunnel_command`` tool returns, and handy for scripting or when you
12already have your own SSM instance.
13"""
15from __future__ import annotations
17import sys
18import time
19from typing import Any
21import click
23from ..cluster_tunnel import (
24 AUTO_BASTION,
25 DEFAULT_API_LOCAL_PORT,
26 open_api_server_tunnel,
27 resolve_region,
28 resolve_tunnel_plan,
29)
30from ..config import GCOConfig
31from ..ephemeral_bastion import DEFAULT_TTL_MINUTES
32from ..output import get_output_formatter
34pass_config = click.make_pass_decorator(GCOConfig, ensure=True)
37@click.group()
38@pass_config
39def cluster(config: Any) -> None:
40 """Cluster connectivity helpers (SSM tunnel to the private EKS API)."""
43@cluster.command("tunnel")
44@click.option("--region", help="Cluster region (defaults to the first cdk.json regional entry).")
45@click.option(
46 "--via-ssm",
47 "via_ssm",
48 metavar="INSTANCE_ID|auto",
49 help=(
50 "Tunnel through an SSM-managed instance id, or 'auto' to provision a "
51 "self-terminating ephemeral bastion and tear it down on exit."
52 ),
53)
54@click.option(
55 "--local-port",
56 type=int,
57 default=DEFAULT_API_LOCAL_PORT,
58 show_default=True,
59 help="Local port to bind for the API tunnel.",
60)
61@click.option(
62 "--print",
63 "print_plan",
64 is_flag=True,
65 help="Print the tunnel + kubectl commands (connection plan) instead of opening the tunnel.",
66)
67@click.option(
68 "--bastion-ttl-minutes",
69 type=int,
70 default=DEFAULT_TTL_MINUTES,
71 show_default=True,
72 help="Self-terminate backstop (minutes) for an `--via-ssm auto` bastion.",
73)
74@click.option(
75 "--yes",
76 "-y",
77 "assume_yes",
78 is_flag=True,
79 help="Skip the confirmation prompt when provisioning an `--via-ssm auto` bastion.",
80)
81@pass_config
82def tunnel_cmd(
83 config: Any,
84 region: str | None,
85 via_ssm: str | None,
86 local_port: int,
87 print_plan: bool,
88 bastion_ttl_minutes: int,
89 assume_yes: bool,
90) -> None:
91 """Open (or ``--print``) an SSM tunnel to a cluster's private EKS API endpoint.
93 Interactive mode holds the tunnel open in the foreground (Ctrl-C to stop) and
94 prints the ``kubectl`` flags to use in another shell. On a private-endpoint
95 cluster pass ``--via-ssm <instance-id>`` to tunnel through an existing
96 SSM-managed instance, or ``--via-ssm auto`` to provision a minimal,
97 self-terminating ephemeral bastion for the session.
98 """
99 formatter = get_output_formatter(config)
100 target_region = resolve_region(config, region)
101 cluster_name = f"{config.project_name}-{target_region}"
103 if print_plan:
104 _print_connection_plan(config, formatter, cluster_name, target_region, via_ssm, local_port)
105 return
107 from ..kubectl_helpers import update_kubeconfig
109 try:
110 update_kubeconfig(cluster_name, target_region)
111 except (RuntimeError, ValueError) as exc:
112 formatter.print_error(str(exc))
113 sys.exit(1)
115 try:
116 with open_api_server_tunnel(
117 formatter,
118 cluster=cluster_name,
119 region=target_region,
120 via_ssm=via_ssm,
121 local_port=local_port,
122 bastion_ttl_minutes=bastion_ttl_minutes,
123 assume_yes=assume_yes,
124 ) as session:
125 if session.active:
126 formatter.print_success(
127 f"SSM tunnel open: {session.server} → {session.plan.endpoint} (Ctrl-C to stop)"
128 )
129 formatter.print_info(
130 "Run kubectl in another shell with:\n kubectl "
131 + " ".join(session.plan.kubectl_flags())
132 + " get nodes"
133 )
134 _block_until_interrupt()
135 elif session.plan.public:
136 formatter.print_success(
137 f"{cluster_name} has a PUBLIC API endpoint — kubectl reaches it directly "
138 "(after `aws eks update-kubeconfig`); no tunnel needed."
139 )
140 # A private endpoint with no --via-ssm was already explained by the
141 # context manager (private_endpoint_guidance); nothing to hold open.
142 except (RuntimeError, ValueError) as exc:
143 formatter.print_error(str(exc))
144 sys.exit(1)
147def _print_connection_plan(
148 config: Any,
149 formatter: Any,
150 cluster_name: str,
151 region: str,
152 via_ssm: str | None,
153 local_port: int,
154) -> None:
155 """Resolve and emit the connection plan (JSON under -o json, else commands)."""
156 instance_id = via_ssm if (via_ssm and via_ssm != AUTO_BASTION) else None
157 try:
158 plan = resolve_tunnel_plan(cluster_name, region, local_port=local_port)
159 payload = plan.as_dict(instance_id)
160 except (RuntimeError, ValueError) as exc:
161 formatter.print_error(f"Failed to resolve tunnel plan: {exc}")
162 sys.exit(1)
164 if config.output_format in ("json", "yaml"):
165 formatter.print(payload)
166 else:
167 _echo_plan_human(payload)
170def _echo_plan_human(payload: dict[str, Any]) -> None:
171 """Print the connection plan as copy-paste shell commands."""
172 header = f"# {payload['cluster']} ({payload['region']})"
173 lines: list[str] = []
174 if payload.get("reachable") == "direct":
175 lines.append(f"{header}: PUBLIC endpoint — kubectl reaches it directly.")
176 lines.append("# " + payload["note"])
177 lines.append(" ".join(payload["update_kubeconfig"]))
178 else:
179 lines.append(f"{header}: PRIVATE endpoint — reach it over an SSM tunnel.")
180 lines.append("# 1) Ensure a kubeconfig context exists:")
181 lines.append(" ".join(payload["update_kubeconfig"]))
182 lines.append("# 2) Open the tunnel in one shell (keep it running):")
183 lines.append(payload.get("ssm_command_str") or payload.get("ssm_command_template", ""))
184 lines.append("# 3) In another shell, run kubectl through the tunnel:")
185 lines.append(payload["kubectl_example"])
186 if payload.get("note"): 186 ↛ 188line 186 didn't jump to line 188 because the condition on line 186 was always true
187 lines.append("# " + payload["note"])
188 click.echo("\n".join(lines))
191def _block_until_interrupt() -> None: # pragma: no cover - interactive wait
192 """Hold the process (and thus the tunnel) open until Ctrl-C."""
193 try:
194 while True:
195 time.sleep(3600)
196 except KeyboardInterrupt:
197 pass