Coverage for cli / commands / monitoring_cmd.py: 100.00%

178 statements  

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

1"""GCO cluster observability command group. 

2 

3Provides the ``gco monitoring`` sub-commands: 

4 

5* ``enable`` / ``disable`` / ``status`` — flip the 

6 ``cluster_observability.enabled`` toggle in ``cdk.json`` (on by default). 

7* ``open`` — ``kubectl port-forward`` to Grafana / Prometheus / Alertmanager 

8 over the PRIVATE EKS API endpoint. Because the endpoint is private by 

9 default, ``open`` detects that posture and can tunnel to the API through an 

10 SSM-managed instance (``--via-ssm``) so the forward works from a laptop. 

11* ``users`` — manage Grafana users via the admin HTTP API (see 

12 :mod:`cli.monitoring_user_mgmt`; wired in a follow-up command module). 

13 

14The Click wiring mirrors ``analytics_cmd.py``. In-cluster access always goes 

15through the private API endpoint — there is no public Grafana ingress. 

16""" 

17 

18from __future__ import annotations 

19 

20import sys 

21from typing import Any 

22 

23import click 

24import requests 

25 

26from .. import cluster_ui 

27from ..config import GCOConfig 

28from ..output import confirm, emit_structured_document, get_output_formatter 

29 

30pass_config = click.make_pass_decorator(GCOConfig, ensure=True) 

31 

32# kube-prometheus-stack + OpenCost service names (release name = chart key) 

33# and their service ports, plus a sensible default local port for each. 

34# OpenCost's UI container listens on 9090 in-cluster; its local default is 

35# 9091 so `gco monitoring open --service opencost` can run alongside a 

36# Prometheus forward on 9090 without a port clash. 

37_SERVICES: dict[str, dict[str, Any]] = { 

38 "grafana": { 

39 "target": "svc/kube-prometheus-stack-grafana", 

40 "remote_port": 80, 

41 "default_local_port": 3000, 

42 }, 

43 "prometheus": { 

44 "target": "svc/kube-prometheus-stack-prometheus", 

45 "remote_port": 9090, 

46 "default_local_port": 9090, 

47 }, 

48 "alertmanager": { 

49 "target": "svc/kube-prometheus-stack-alertmanager", 

50 "remote_port": 9093, 

51 "default_local_port": 9093, 

52 }, 

53 "opencost": { 

54 "target": "svc/opencost", 

55 "remote_port": 9090, 

56 "default_local_port": 9091, 

57 }, 

58 "opencost-api": { 

59 "target": "svc/opencost", 

60 "remote_port": 9003, 

61 "default_local_port": 9003, 

62 }, 

63 # MLflow tracking server (fullnameOverride keeps the bare release 

64 # name). The official chart exposes the server 1:1 — Service port 5000 

65 # to container port 5000 — and 5000 is also MLflow's canonical local 

66 # port, so it runs alongside the Grafana (3000) and Prometheus (9090) 

67 # forwards. 

68 "mlflow": { 

69 "target": "svc/mlflow", 

70 "remote_port": 5000, 

71 "default_local_port": 5000, 

72 }, 

73} 

74 

75_MONITORING_NAMESPACE = "monitoring" 

76_GRAFANA_SECRET = "kube-prometheus-stack-grafana" # nosec B105 # Kubernetes Secret name, not its contents 

77 

78# Default self-terminate backstop for an `--via-ssm auto` bastion. 

79# Mirrors cli.ephemeral_bastion.DEFAULT_TTL_MINUTES (kept literal to avoid an 

80# import at module load; the ephemeral_bastion module validates the range). 

81_DEFAULT_BASTION_TTL_MINUTES = 120 

82 

83 

84@click.group() 

85@pass_config 

86def monitoring(config: Any) -> None: 

87 """Manage in-cluster observability (Prometheus + Grafana + Alertmanager).""" 

88 

89 

90# --------------------------------------------------------------------------- 

91# Toggle commands — status / enable / disable 

92# --------------------------------------------------------------------------- 

93 

94 

95@monitoring.command("status") 

96@pass_config 

97def monitoring_status(config: Any) -> None: 

98 """Show the current cluster observability toggle state from cdk.json.""" 

99 from ..stacks import get_cluster_observability_config 

100 

101 formatter = get_output_formatter(config) 

102 try: 

103 current = get_cluster_observability_config() 

104 formatter.print_info("Cluster observability config:") 

105 formatter.print(current) 

106 except Exception as exc: # surface every loader error 

107 formatter.print_error(f"Failed to read cluster observability config: {exc}") 

108 sys.exit(1) 

109 

110 

111@monitoring.command("enable") 

112@click.option("--yes", "-y", is_flag=True, help="Skip confirmation.") 

113@pass_config 

114def monitoring_enable(config: Any, yes: bool) -> None: 

115 """Enable cluster observability in cdk.json (installs kube-prometheus-stack). 

116 

117 Observability is on by default; use this to re-enable after a 

118 ``gco monitoring disable``. Prints the follow-up deploy command — does not 

119 deploy automatically. 

120 """ 

121 from ..stacks import update_cluster_observability_config 

122 

123 formatter = get_output_formatter(config) 

124 if not yes: 

125 formatter.print_info( 

126 "Cluster observability (kube-prometheus-stack) will be enabled on every region." 

127 ) 

128 confirm("\nEnable cluster observability?", abort=True) 

129 

130 try: 

131 update_cluster_observability_config({"enabled": True}) 

132 formatter.print_success("Cluster observability enabled in cdk.json") 

133 formatter.print_info( 

134 f"Run `gco stacks deploy {config.project_name}-<region>` (or deploy-all) to apply" 

135 ) 

136 except Exception as exc: # user-facing error from file I/O 

137 formatter.print_error(f"Failed to enable cluster observability: {exc}") 

138 sys.exit(1) 

139 

140 

141@monitoring.command("disable") 

142@click.option("--yes", "-y", is_flag=True, help="Skip confirmation.") 

143@pass_config 

144def monitoring_disable(config: Any, yes: bool) -> None: 

145 """Disable cluster observability in cdk.json. 

146 

147 Flips ``cluster_observability.enabled`` to ``false``; the grafana / 

148 prometheus / alertmanager sub-blocks (sizes, retention, rotation schedule) 

149 are left untouched so preferences survive a disable/enable cycle. The 

150 in-cluster stack is removed on the next deploy. 

151 """ 

152 from ..stacks import update_cluster_observability_config 

153 

154 formatter = get_output_formatter(config) 

155 if not yes: 

156 formatter.print_warning("This will disable cluster observability.") 

157 formatter.print_warning( 

158 "Prometheus/Grafana/Alertmanager and their EBS volumes are removed on next deploy." 

159 ) 

160 confirm("Are you sure?", abort=True) 

161 

162 try: 

163 update_cluster_observability_config({"enabled": False}) 

164 formatter.print_success("Cluster observability disabled in cdk.json") 

165 formatter.print_info( 

166 f"Run `gco stacks deploy {config.project_name}-<region>` (or deploy-all) to apply" 

167 ) 

168 except Exception as exc: # user-facing error from file I/O 

169 formatter.print_error(f"Failed to disable cluster observability: {exc}") 

170 sys.exit(1) 

171 

172 

173# --------------------------------------------------------------------------- 

174# open — port-forward (private endpoint aware) 

175# --------------------------------------------------------------------------- 

176 

177 

178@monitoring.command("open") 

179@click.option( 

180 "--service", 

181 type=click.Choice(sorted(_SERVICES)), 

182 default="grafana", 

183 show_default=True, 

184 help="Which component to port-forward.", 

185) 

186@click.option("--region", help="Cluster region (defaults to the first cdk.json regional entry).") 

187@click.option("--local-port", type=int, help="Local port to bind (defaults per-service).") 

188@click.option( 

189 "--via-ssm", 

190 "via_ssm", 

191 metavar="INSTANCE_ID|auto", 

192 help=( 

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

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

195 "self-terminating ephemeral bastion and tear it down when the forward stops. " 

196 "Used whenever private endpoint access is on, PUBLIC_AND_PRIVATE included." 

197 ), 

198) 

199@click.option( 

200 "--bastion-ttl-minutes", 

201 type=int, 

202 default=_DEFAULT_BASTION_TTL_MINUTES, 

203 show_default=True, 

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

205) 

206@click.option( 

207 "--yes", 

208 "-y", 

209 "assume_yes", 

210 is_flag=True, 

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

212) 

213@pass_config 

214def monitoring_open( 

215 config: Any, 

216 service: str, 

217 region: str | None, 

218 local_port: int | None, 

219 via_ssm: str | None, 

220 bastion_ttl_minutes: int, 

221 assume_yes: bool, 

222) -> None: 

223 """Port-forward to a monitoring component over the private EKS endpoint. 

224 

225 Runs in the foreground; press Ctrl-C to stop. On a private-endpoint cluster 

226 (the default) pass ``--via-ssm <instance-id>`` to tunnel through an existing 

227 SSM-managed instance, or ``--via-ssm auto`` to have the CLI provision a 

228 minimal, self-terminating ephemeral bastion for the session and tear it down 

229 on exit. ``--via-ssm`` is honored on a PUBLIC_AND_PRIVATE cluster too. The 

230 local URL is printed once kubectl's listener is up, not before. 

231 """ 

232 from ..cluster_tunnel import open_api_server_tunnel, resolve_region 

233 from ..kubectl_helpers import build_port_forward_command, update_kubeconfig 

234 

235 formatter = get_output_formatter(config) 

236 svc = _SERVICES[service] 

237 target_region = resolve_region(config, region) 

238 cluster = f"{config.project_name}-{target_region}" 

239 bind_port = local_port or svc["default_local_port"] 

240 

241 try: 

242 update_kubeconfig(cluster, target_region) 

243 except (RuntimeError, ValueError) as exc: 

244 formatter.print_error(str(exc)) 

245 sys.exit(1) 

246 

247 # The shared context manager detects the endpoint posture, optionally 

248 # provisions an ephemeral bastion (`--via-ssm auto`), opens the SSM tunnel, 

249 # and guarantees teardown of both the tunnel and any bastion on exit. 

250 try: 

251 with open_api_server_tunnel( 

252 formatter, 

253 cluster=cluster, 

254 region=target_region, 

255 via_ssm=via_ssm, 

256 bastion_ttl_minutes=bastion_ttl_minutes, 

257 assume_yes=assume_yes, 

258 ) as session: 

259 cmd = build_port_forward_command( 

260 _MONITORING_NAMESPACE, 

261 svc["target"], 

262 bind_port, 

263 svc["remote_port"], 

264 server=session.server, 

265 tls_server_name=session.tls_server_name, 

266 ) 

267 url = f"http://localhost:{bind_port}" 

268 formatter.print_info( 

269 f"Starting kubectl port-forward for {service} on localhost:{bind_port}..." 

270 ) 

271 

272 def _announce() -> None: 

273 formatter.print_success(f"Forwarding {service} → {url} (Ctrl-C to stop)") 

274 if service == "grafana": 

275 formatter.print_info( 

276 "Log in with the Grafana admin credential from the " 

277 f"{_GRAFANA_SECRET} Secret (monitoring namespace)." 

278 ) 

279 

280 try: 

281 cluster_ui.exec_port_forward(cmd, bind_port, on_ready=_announce) 

282 except KeyboardInterrupt: # pragma: no cover - interactive Ctrl-C 

283 return 

284 except (RuntimeError, ValueError) as exc: 

285 formatter.print_error(str(exc)) 

286 sys.exit(1) 

287 

288 

289# --------------------------------------------------------------------------- 

290# users subgroup — Grafana native users over the admin HTTP API 

291# --------------------------------------------------------------------------- 

292 

293 

294def _grafana_conn_options(func: Any) -> Any: 

295 """Shared --grafana-url / --admin-user / --admin-password options.""" 

296 from ..monitoring_user_mgmt import DEFAULT_GRAFANA_URL 

297 

298 func = click.option( 

299 "--grafana-url", 

300 default=DEFAULT_GRAFANA_URL, 

301 show_default=True, 

302 help="Grafana base URL (reachable via `gco monitoring open`).", 

303 )(func) 

304 func = click.option( 

305 "--admin-user", 

306 help="Grafana admin username (default: read from the Grafana Secret).", 

307 )(func) 

308 func = click.option( 

309 "--admin-password", 

310 envvar="GCO_GRAFANA_ADMIN_PASSWORD", 

311 help=( 

312 "Grafana admin password (also $GCO_GRAFANA_ADMIN_PASSWORD; " 

313 "default: read from the Grafana Secret)." 

314 ), 

315 )(func) 

316 return func 

317 

318 

319def _resolve_grafana_auth(admin_user: str | None, admin_password: str | None) -> tuple[str, str]: 

320 """Return ``(user, password)`` from the flags, else from the Grafana Secret.""" 

321 if admin_password: 

322 return (admin_user or "admin", admin_password) 

323 from ..monitoring_user_mgmt import read_grafana_admin_credentials 

324 

325 return read_grafana_admin_credentials() 

326 

327 

328@monitoring.group("users") 

329@pass_config 

330def users_cmd(config: Any) -> None: 

331 """Manage Grafana users via the admin API (over `gco monitoring open`).""" 

332 

333 

334@users_cmd.command("add") 

335@click.option("--username", required=True, help="Grafana login for the new user.") 

336@click.option("--email", help="Email address for the new user (optional).") 

337@click.option("--password", help="Set this password. Mutually exclusive with --generate-password.") 

338@click.option( 

339 "--generate-password", 

340 is_flag=True, 

341 help="Generate a strong random password and print it once.", 

342) 

343@_grafana_conn_options 

344@pass_config 

345def users_add( 

346 config: Any, 

347 username: str, 

348 email: str | None, 

349 password: str | None, 

350 generate_password: bool, 

351 grafana_url: str, 

352 admin_user: str | None, 

353 admin_password: str | None, 

354) -> None: 

355 """Create a Grafana user via the admin HTTP API.""" 

356 from ..monitoring_user_mgmt import create_user 

357 from ..monitoring_user_mgmt import generate_password as _gen 

358 

359 formatter = get_output_formatter(config) 

360 if password and generate_password: 

361 formatter.print_error("--password and --generate-password are mutually exclusive") 

362 sys.exit(1) 

363 if not password and not generate_password: 

364 formatter.print_error("Pass --password or --generate-password") 

365 sys.exit(1) 

366 

367 final_password = password or _gen() 

368 try: 

369 auth = _resolve_grafana_auth(admin_user, admin_password) 

370 user_id = create_user( 

371 grafana_url, auth, login=username, password=final_password, email=email 

372 ) 

373 except (requests.RequestException, RuntimeError, ValueError) as exc: 

374 formatter.print_error(f"Failed to create Grafana user {username!r}: {exc}") 

375 sys.exit(1) 

376 

377 if config.output_format == "table": 

378 formatter.print_success(f"Created Grafana user {username!r} (id={user_id})") 

379 if generate_password: 

380 formatter.print_info(f"Generated password (printed exactly once): {final_password}") 

381 else: 

382 result: dict[str, Any] = { 

383 "created": True, 

384 "username": username, 

385 "user_id": user_id, 

386 "email": email, 

387 "password_state": "set", # nosec B105 # state label, not a credential 

388 "password_generated": generate_password, 

389 "password_source": "generated" if generate_password else "provided", 

390 } 

391 if generate_password: 

392 result["password"] = final_password 

393 formatter.print(result) 

394 

395 

396@users_cmd.command("list") 

397@click.option("--as-json", "as_json", is_flag=True, help="Emit JSON instead of a table.") 

398@_grafana_conn_options 

399@pass_config 

400def users_list( 

401 config: Any, 

402 as_json: bool, 

403 grafana_url: str, 

404 admin_user: str | None, 

405 admin_password: str | None, 

406) -> None: 

407 """List Grafana organisation users.""" 

408 from ..monitoring_user_mgmt import list_users 

409 

410 formatter = get_output_formatter(config) 

411 try: 

412 auth = _resolve_grafana_auth(admin_user, admin_password) 

413 users = list_users(grafana_url, auth) 

414 except (requests.RequestException, RuntimeError, ValueError) as exc: 

415 formatter.print_error(f"Failed to list Grafana users: {exc}") 

416 sys.exit(1) 

417 

418 if as_json: 

419 import json 

420 

421 emit_structured_document( 

422 users, 

423 output_format="json", 

424 rendered=json.dumps(users, indent=2), 

425 ) 

426 return 

427 formatter.print(users) 

428 

429 

430@users_cmd.command("remove") 

431@click.option("--username", required=True, help="Grafana login/email to remove.") 

432@click.option("--yes", is_flag=True, help="Skip the confirmation prompt.") 

433@_grafana_conn_options 

434@pass_config 

435def users_remove( 

436 config: Any, 

437 username: str, 

438 yes: bool, 

439 grafana_url: str, 

440 admin_user: str | None, 

441 admin_password: str | None, 

442) -> None: 

443 """Delete a Grafana user by login or email.""" 

444 from ..monitoring_user_mgmt import delete_user, lookup_user_id 

445 

446 formatter = get_output_formatter(config) 

447 if not yes: 

448 confirm(f"Delete Grafana user '{username}'?", abort=True) 

449 

450 try: 

451 auth = _resolve_grafana_auth(admin_user, admin_password) 

452 user_id = lookup_user_id(grafana_url, auth, username) 

453 delete_user(grafana_url, auth, user_id) 

454 except (requests.RequestException, RuntimeError, ValueError) as exc: 

455 formatter.print_error(f"Failed to remove Grafana user {username!r}: {exc}") 

456 sys.exit(1) 

457 

458 formatter.print_success(f"Deleted Grafana user {username!r}")