Coverage for cli / cluster_ui.py: 100.00%

171 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-10-28 16:19 +0000

1"""Shared plumbing for the in-cluster web UIs GCO reaches through kubectl. 

2 

3``gco gitops`` (Argo CD) and ``gco crossplane`` (the Crossview dashboard) sit 

4on the same three steps ``gco monitoring open`` takes for Grafana — reach the 

5(private by default) EKS API through :func:`cli.cluster_tunnel.open_api_server_tunnel`, 

6``kubectl port-forward`` a ClusterIP Service to localhost, and point a browser 

7or a headless Playwright page at it — plus two reads they need around it: a 

8key out of a Kubernetes Secret (the Argo CD admin password) and the pinned 

9chart entry from ``lambda/helm-installer/charts.yaml`` (for ``status``). 

10:func:`exec_port_forward` is the foreground forward all four ``open``-style 

11commands (``monitoring open``, ``costs dashboard``, ``gitops open``, 

12``crossplane open``) share, so none announces a URL before kubectl listens. 

13 

14Everything here is list-form subprocess calls and pure helpers, so the command 

15modules stay thin and the tests can drive them with fakes. Playwright is 

16imported lazily so the CLI loads without the rendering extra installed. 

17""" 

18 

19from __future__ import annotations 

20 

21import base64 

22import json 

23import re 

24import socket 

25import subprocess 

26import threading 

27import time 

28from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence 

29from contextlib import contextmanager 

30from pathlib import Path 

31from typing import IO, Any, cast 

32 

33import click 

34import yaml 

35 

36#: Viewport for UI captures (matches the monitoring screenshots). 

37VIEWPORT_WIDTH = 1600 

38VIEWPORT_HEIGHT = 900 

39 

40#: How long a capture waits after navigation for single-page apps that poll 

41#: (network-idle never comes), mirroring the Grafana captures. 

42RENDER_WAIT_MS = 5000 

43 

44#: How long a background port-forward may take to accept connections. 

45PORT_FORWARD_READY_TIMEOUT_SECONDS = 30.0 

46 

47#: How long a foreground port-forward may take to report its listener. kubectl 

48#: gives up on an unreachable API server by itself well inside this. 

49FOREGROUND_PORT_FORWARD_READY_TIMEOUT_SECONDS = 120.0 

50 

51#: kubectl prints one of these per local address it binds, once the API server 

52#: has accepted the port-forward (client-go tools/portforward). 

53_FORWARDING_LINE = re.compile(r"^Forwarding from \S+:(?P<port>\d+) -> \d+") 

54 

55#: How often the foreground readiness wait re-checks kubectl (it stays 

56#: responsive to Ctrl-C between checks). 

57_READY_POLL_SECONDS = 0.2 

58 

59#: How long the output relay may take to drain once kubectl has exited. 

60_RELAY_DRAIN_SECONDS = 2.0 

61 

62#: Default self-terminate backstop for an ``--via-ssm auto`` bastion (mirrors 

63#: ``cli.ephemeral_bastion.DEFAULT_TTL_MINUTES``; kept literal so importing 

64#: this module stays cheap). 

65DEFAULT_BASTION_TTL_MINUTES = 120 

66 

67_INSTALL_HINT = ( 

68 "Playwright is not installed. Install the rendering extra and its browser: " 

69 "pip install 'gco[diagrams]' && playwright install chromium" 

70) 

71 

72_NAME_RE = re.compile(r"^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$") 

73 

74#: charts.yaml as shipped next to this package in a source checkout. 

75CHARTS_YAML = Path(__file__).resolve().parents[1] / "lambda" / "helm-installer" / "charts.yaml" 

76 

77#: The kubectl-applier manifest directory in a source checkout. 

78MANIFESTS_DIR = ( 

79 Path(__file__).resolve().parents[1] / "lambda" / "kubectl-applier-simple" / "manifests" 

80) 

81 

82 

83def load_cdk_context(cdk_json_path: Path | None = None) -> dict[str, Any]: 

84 """The ``context`` object of cdk.json (the working directory's by default). 

85 

86 Raises ``RuntimeError`` when cdk.json is missing or is not JSON; a 

87 document without a ``context`` object reads as ``{}``. 

88 """ 

89 path = cdk_json_path or Path.cwd() / "cdk.json" 

90 if not path.exists(): 

91 raise RuntimeError(f"cdk.json not found at {path}") 

92 try: 

93 document = json.loads(path.read_text(encoding="utf-8")) 

94 except json.JSONDecodeError as exc: 

95 raise RuntimeError(f"cdk.json at {path} is not valid JSON: {exc}") from exc 

96 context = document.get("context") if isinstance(document, dict) else None 

97 return context if isinstance(context, dict) else {} 

98 

99 

100def regional_regions(context: Mapping[str, Any]) -> list[str]: 

101 """``deployment_regions.regional`` from a cdk.json context (``[]`` when absent).""" 

102 regions = context.get("deployment_regions") 

103 regional = regions.get("regional") if isinstance(regions, Mapping) else None 

104 return [str(region) for region in regional] if isinstance(regional, list) else [] 

105 

106 

107def chart_entry(name: str, charts_yaml: Path | None = None) -> dict[str, Any] | None: 

108 """The pinned ``charts.yaml`` entry (chart, version, namespace), or ``None``. 

109 

110 ``None`` when the file is not there (an installed CLI without the source 

111 tree) or has no such chart; status commands then just omit the pin. 

112 """ 

113 path = charts_yaml or CHARTS_YAML 

114 try: 

115 document = yaml.safe_load(path.read_text(encoding="utf-8")) or {} 

116 except OSError, yaml.YAMLError: 

117 return None 

118 charts = document.get("charts") if isinstance(document, dict) else None 

119 entry = charts.get(name) if isinstance(charts, dict) else None 

120 if not isinstance(entry, dict): 

121 return None 

122 return { 

123 "name": name, 

124 "chart": str(entry.get("chart") or name), 

125 "version": str(entry.get("version") or ""), 

126 "namespace": str(entry.get("namespace") or ""), 

127 "repo_url": str(entry.get("repo_url") or ""), 

128 } 

129 

130 

131def manifest_documents(filename: str, manifests_dir: Path | None = None) -> list[dict[str, Any]]: 

132 """Parse one shipped kubectl-applier manifest (``[]`` when it is not there). 

133 

134 ``{{TOKEN}}`` placeholders are left in place; callers read only fields 

135 that never carry one (kinds, names, pinned package references). 

136 """ 

137 path = (manifests_dir or MANIFESTS_DIR) / filename 

138 try: 

139 text = path.read_text(encoding="utf-8") 

140 except OSError: 

141 return [] 

142 # Structural tokens are bare (not quoted) and are not YAML on their own. 

143 text = re.sub(r"(?<![\"'])\{\{[A-Z0-9_]+\}\}", "null", text) 

144 return [doc for doc in yaml.safe_load_all(text) if isinstance(doc, dict)] 

145 

146 

147def kubectl_server_flags(server: str | None, tls_server_name: str | None) -> list[str]: 

148 """``--server`` / ``--tls-server-name`` for a tunnelled API endpoint (or nothing).""" 

149 flags: list[str] = [] 

150 if server is not None: 

151 if not server.startswith("https://"): 

152 raise ValueError(f"Invalid --server {server!r}: must start with https://") 

153 flags += ["--server", server] 

154 if tls_server_name is not None: 

155 if not re.fullmatch(r"[a-zA-Z0-9.\-]{1,255}", tls_server_name): 

156 raise ValueError(f"Invalid --tls-server-name {tls_server_name!r}") 

157 flags += ["--tls-server-name", tls_server_name] 

158 return flags 

159 

160 

161def read_secret_key( 

162 namespace: str, 

163 name: str, 

164 key: str, 

165 *, 

166 server: str | None = None, 

167 tls_server_name: str | None = None, 

168) -> str: 

169 """Decode one key of a Kubernetes Secret through kubectl. 

170 

171 Raises ``RuntimeError`` naming the Secret when kubectl is missing, the 

172 read fails, or the key is absent (the message never carries the value). 

173 """ 

174 for label, value in (("namespace", namespace), ("Secret name", name)): 

175 if not _NAME_RE.match(value): 

176 raise ValueError(f"Invalid {label} {value!r}") 

177 cmd = [ 

178 "kubectl", 

179 "get", 

180 "secret", 

181 name, 

182 "-n", 

183 namespace, 

184 "-o", 

185 "json", 

186 *kubectl_server_flags(server, tls_server_name), 

187 ] 

188 try: 

189 result = subprocess.run( # nosemgrep: dangerous-subprocess-use-audit - validated argv, list form, no shell=True 

190 cmd, capture_output=True, text=True, check=False, timeout=60 

191 ) 

192 except FileNotFoundError as exc: 

193 raise RuntimeError( 

194 "kubectl not found. Install kubectl and ensure it's on your PATH." 

195 ) from exc 

196 except subprocess.TimeoutExpired as exc: 

197 raise RuntimeError(f"Timed out reading Secret {namespace}/{name}") from exc 

198 if result.returncode != 0: 

199 detail = (result.stderr or "").strip() 

200 raise RuntimeError(f"Failed to read Secret {namespace}/{name}: {detail}") 

201 data = (json.loads(result.stdout or "{}").get("data")) or {} 

202 if key not in data: 

203 raise RuntimeError(f"Secret {namespace}/{name} has no {key!r} key") 

204 return base64.b64decode(data[key]).decode("utf-8") 

205 

206 

207def wait_for_local_port( 

208 port: int, 

209 process: subprocess.Popen[bytes] | None = None, 

210 *, 

211 timeout: float = PORT_FORWARD_READY_TIMEOUT_SECONDS, 

212) -> None: 

213 """Block until 127.0.0.1:``port`` accepts a TCP connection. 

214 

215 Raises ``RuntimeError`` when ``process`` (the port-forward) exits first or 

216 the deadline passes. 

217 """ 

218 deadline = time.monotonic() + timeout 

219 while True: 

220 if process is not None and process.poll() is not None: 

221 stderr = process.stderr.read().decode("utf-8", "replace") if process.stderr else "" 

222 raise RuntimeError( 

223 f"kubectl port-forward exited before localhost:{port} was ready: " 

224 f"{stderr.strip() or f'exit code {process.returncode}'}" 

225 ) 

226 try: 

227 with socket.create_connection(("127.0.0.1", port), timeout=1.0): 

228 return 

229 except OSError: 

230 if time.monotonic() >= deadline: 

231 raise RuntimeError( 

232 f"localhost:{port} did not accept connections within {int(timeout)}s" 

233 ) from None 

234 time.sleep(0.5) 

235 

236 

237@contextmanager 

238def background_port_forward( 

239 cmd: Sequence[str], 

240 local_port: int, 

241 *, 

242 timeout: float = PORT_FORWARD_READY_TIMEOUT_SECONDS, 

243) -> Iterator[subprocess.Popen[bytes]]: 

244 """Run a validated ``kubectl port-forward`` argv in the background until exit. 

245 

246 Yields once the local port accepts connections; always terminates the 

247 process (kill after a short grace period) on the way out. 

248 """ 

249 process = subprocess.Popen( # nosemgrep: dangerous-subprocess-use-audit - argv built by build_port_forward_command; list form, no shell=True 

250 list(cmd), stdout=subprocess.DEVNULL, stderr=subprocess.PIPE 

251 ) 

252 try: 

253 wait_for_local_port(local_port, process, timeout=timeout) 

254 yield process 

255 finally: 

256 _stop_process(process) 

257 

258 

259def _stop_process(process: subprocess.Popen[bytes]) -> None: 

260 """Terminate ``process`` if it still runs, killing it after a short grace period.""" 

261 if process.poll() is None: 

262 process.terminate() 

263 try: 

264 process.wait(timeout=5) 

265 except subprocess.TimeoutExpired: 

266 process.kill() 

267 process.wait(timeout=5) 

268 

269 

270def capture_page( 

271 url: str, 

272 output: Path, 

273 *, 

274 cookies: Sequence[Mapping[str, Any]] = (), 

275 headless: bool = True, 

276 render_wait_ms: int = RENDER_WAIT_MS, 

277) -> Path: 

278 """Screenshot ``url`` into ``output`` (full page) with Playwright's Chromium. 

279 

280 ``cookies`` are added to the browser context before navigation (the Argo 

281 CD session token rides this way). Parent directories are created. 

282 """ 

283 try: 

284 from playwright.sync_api import sync_playwright 

285 except ImportError as exc: 

286 raise RuntimeError(_INSTALL_HINT) from exc 

287 

288 output = Path(output) 

289 output.parent.mkdir(parents=True, exist_ok=True) 

290 with sync_playwright() as playwright: 

291 browser = playwright.chromium.launch(headless=headless) 

292 try: 

293 context = browser.new_context( 

294 viewport={"width": VIEWPORT_WIDTH, "height": VIEWPORT_HEIGHT} 

295 ) 

296 if cookies: 

297 # Playwright types the list as its SetCookieParam TypedDict; the 

298 # callers pass plain name/value/url mappings of that shape. 

299 context.add_cookies(cast(Any, [dict(cookie) for cookie in cookies])) 

300 page = context.new_page() 

301 page.goto(url, wait_until="load") 

302 page.wait_for_timeout(render_wait_ms) 

303 page.screenshot(path=str(output), full_page=True) 

304 finally: 

305 browser.close() 

306 return output 

307 

308 

309def tunnel_options(func: Any) -> Any: 

310 """The --region / --via-ssm / --bastion-ttl-minutes / --yes options UI commands share.""" 

311 func = click.option( 

312 "--yes", 

313 "-y", 

314 "assume_yes", 

315 is_flag=True, 

316 help="Skip the confirmation prompt when provisioning an `--via-ssm auto` bastion.", 

317 )(func) 

318 func = click.option( 

319 "--bastion-ttl-minutes", 

320 type=int, 

321 default=DEFAULT_BASTION_TTL_MINUTES, 

322 show_default=True, 

323 help="Self-terminate backstop (minutes) for an `--via-ssm auto` bastion.", 

324 )(func) 

325 func = click.option( 

326 "--via-ssm", 

327 "via_ssm", 

328 metavar="INSTANCE_ID|auto", 

329 help=( 

330 "Tunnel to the private API endpoint through an SSM-managed instance. " 

331 "Pass an instance id to use an existing one, or 'auto' to provision a " 

332 "self-terminating ephemeral bastion and tear it down afterwards. Used " 

333 "whenever private endpoint access is on, PUBLIC_AND_PRIVATE included." 

334 ), 

335 )(func) 

336 func = click.option( 

337 "--region", "-r", help="Cluster region (defaults to the first cdk.json regional entry)." 

338 )(func) 

339 return func 

340 

341 

342def _relay_port_forward_output( 

343 stream: Iterable[bytes], local_port: int, ready: threading.Event 

344) -> None: 

345 """Echo kubectl's stdout lines, setting ``ready`` at its listener on ``local_port``.""" 

346 for raw in stream: 

347 line = raw.decode("utf-8", "replace").rstrip("\r\n") 

348 click.echo(line) 

349 match = _FORWARDING_LINE.match(line) 

350 if match is not None and int(match.group("port")) == local_port: 

351 ready.set() 

352 

353 

354def exec_port_forward( 

355 cmd: Sequence[str], 

356 local_port: int, 

357 *, 

358 on_ready: Callable[[], object] | None = None, 

359 ready_timeout: float = FOREGROUND_PORT_FORWARD_READY_TIMEOUT_SECONDS, 

360) -> None: 

361 """Run the (validated) kubectl port-forward argv in the foreground until it exits. 

362 

363 kubectl's output is relayed line by line, and ``on_ready`` runs once kubectl 

364 reports a listener on ``local_port`` (``Forwarding from 127.0.0.1:PORT -> 

365 ...``). kubectl prints that only after the API server accepted the forward, 

366 so a caller's "forwarding" message never precedes a working forward. 

367 

368 Raises ``RuntimeError`` when kubectl exits, or ``ready_timeout`` passes, 

369 before that line, and when kubectl exits non-zero after it. kubectl's own 

370 error reaches the terminal on its inherited stderr. The process is stopped 

371 on the way out, Ctrl-C included. 

372 """ 

373 process = subprocess.Popen( # nosemgrep: dangerous-subprocess-use-audit - argv built by build_port_forward_command; list form, no shell=True 

374 list(cmd), stdout=subprocess.PIPE 

375 ) 

376 ready = threading.Event() 

377 relay = threading.Thread( 

378 target=_relay_port_forward_output, 

379 args=(cast(IO[bytes], process.stdout), local_port, ready), 

380 name="kubectl-port-forward-output", 

381 daemon=True, 

382 ) 

383 relay.start() 

384 try: 

385 deadline = time.monotonic() + ready_timeout 

386 while not ready.wait(_READY_POLL_SECONDS): 

387 if process.poll() is not None: 

388 # The listener line may still be in flight from the relay. 

389 relay.join(_RELAY_DRAIN_SECONDS) 

390 if ready.is_set(): 

391 break 

392 raise RuntimeError( 

393 f"kubectl port-forward exited with code {process.returncode} before " 

394 f"localhost:{local_port} was listening; its error is above." 

395 ) 

396 if time.monotonic() >= deadline: 

397 raise RuntimeError( 

398 f"kubectl port-forward did not open localhost:{local_port} within " 

399 f"{ready_timeout:g}s." 

400 ) 

401 if on_ready is not None: 

402 on_ready() 

403 returncode = process.wait() 

404 if returncode != 0: 

405 raise RuntimeError(f"kubectl port-forward exited with code {returncode}.") 

406 finally: 

407 _stop_process(process) 

408 relay.join(_RELAY_DRAIN_SECONDS)