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

173 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-30 21:22 +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 subprocess 

21import sys 

22from typing import Any 

23 

24import click 

25import requests 

26 

27from ..config import GCOConfig 

28from ..output import 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} 

64 

65_MONITORING_NAMESPACE = "monitoring" 

66_GRAFANA_SECRET = "kube-prometheus-stack-grafana" 

67 

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

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

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

71_DEFAULT_BASTION_TTL_MINUTES = 120 

72 

73 

74@click.group() 

75@pass_config 

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

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

78 

79 

80# --------------------------------------------------------------------------- 

81# Toggle commands — status / enable / disable 

82# --------------------------------------------------------------------------- 

83 

84 

85@monitoring.command("status") 

86@pass_config 

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

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

89 from ..stacks import get_cluster_observability_config 

90 

91 formatter = get_output_formatter(config) 

92 try: 

93 current = get_cluster_observability_config() 

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

95 formatter.print(current) 

96 except Exception as exc: # noqa: BLE001 — surface every loader error 

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

98 sys.exit(1) 

99 

100 

101@monitoring.command("enable") 

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

103@pass_config 

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

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

106 

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

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

109 deploy automatically. 

110 """ 

111 from ..stacks import update_cluster_observability_config 

112 

113 formatter = get_output_formatter(config) 

114 if not yes: 

115 formatter.print_info( 

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

117 ) 

118 click.confirm("\nEnable cluster observability?", abort=True) 

119 

120 try: 

121 update_cluster_observability_config({"enabled": True}) 

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

123 formatter.print_info( 

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

125 ) 

126 except Exception as exc: # noqa: BLE001 — user-facing error from file I/O 

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

128 sys.exit(1) 

129 

130 

131@monitoring.command("disable") 

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

133@pass_config 

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

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

136 

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

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

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

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

141 """ 

142 from ..stacks import update_cluster_observability_config 

143 

144 formatter = get_output_formatter(config) 

145 if not yes: 

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

147 formatter.print_warning( 

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

149 ) 

150 click.confirm("Are you sure?", abort=True) 

151 

152 try: 

153 update_cluster_observability_config({"enabled": False}) 

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

155 formatter.print_info( 

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

157 ) 

158 except Exception as exc: # noqa: BLE001 — user-facing error from file I/O 

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

160 sys.exit(1) 

161 

162 

163# --------------------------------------------------------------------------- 

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

165# --------------------------------------------------------------------------- 

166 

167 

168@monitoring.command("open") 

169@click.option( 

170 "--service", 

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

172 default="grafana", 

173 show_default=True, 

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

175) 

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

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

178@click.option( 

179 "--via-ssm", 

180 "via_ssm", 

181 metavar="INSTANCE_ID|auto", 

182 help=( 

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

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

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

186 ), 

187) 

188@click.option( 

189 "--bastion-ttl-minutes", 

190 type=int, 

191 default=_DEFAULT_BASTION_TTL_MINUTES, 

192 show_default=True, 

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

194) 

195@click.option( 

196 "--yes", 

197 "-y", 

198 "assume_yes", 

199 is_flag=True, 

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

201) 

202@pass_config 

203def monitoring_open( 

204 config: Any, 

205 service: str, 

206 region: str | None, 

207 local_port: int | None, 

208 via_ssm: str | None, 

209 bastion_ttl_minutes: int, 

210 assume_yes: bool, 

211) -> None: 

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

213 

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

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

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

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

218 on exit. 

219 """ 

220 from ..cluster_tunnel import open_api_server_tunnel, resolve_region 

221 from ..kubectl_helpers import build_port_forward_command, update_kubeconfig 

222 

223 formatter = get_output_formatter(config) 

224 svc = _SERVICES[service] 

225 target_region = resolve_region(config, region) 

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

227 bind_port = local_port or svc["default_local_port"] 

228 

229 try: 

230 update_kubeconfig(cluster, target_region) 

231 except (RuntimeError, ValueError) as exc: 

232 formatter.print_error(str(exc)) 

233 sys.exit(1) 

234 

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

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

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

238 try: 

239 with open_api_server_tunnel( 

240 formatter, 

241 cluster=cluster, 

242 region=target_region, 

243 via_ssm=via_ssm, 

244 bastion_ttl_minutes=bastion_ttl_minutes, 

245 assume_yes=assume_yes, 

246 ) as session: 

247 cmd = build_port_forward_command( 

248 _MONITORING_NAMESPACE, 

249 svc["target"], 

250 bind_port, 

251 svc["remote_port"], 

252 server=session.server, 

253 tls_server_name=session.tls_server_name, 

254 ) 

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

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

257 if service == "grafana": 

258 formatter.print_info( 

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

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

261 ) 

262 try: 

263 _exec_port_forward(cmd) 

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

265 return 

266 except (RuntimeError, ValueError) as exc: 

267 formatter.print_error(str(exc)) 

268 sys.exit(1) 

269 

270 

271def _exec_port_forward(cmd: list[str]) -> None: 

272 """Run the (validated) kubectl port-forward argv in the foreground.""" 

273 subprocess.run( 

274 cmd, check=False 

275 ) # nosemgrep: dangerous-subprocess-use-audit - argv built by build_port_forward_command; list form, no shell=True 

276 

277 

278# --------------------------------------------------------------------------- 

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

280# --------------------------------------------------------------------------- 

281 

282 

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

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

285 from ..monitoring_user_mgmt import DEFAULT_GRAFANA_URL 

286 

287 func = click.option( 

288 "--grafana-url", 

289 default=DEFAULT_GRAFANA_URL, 

290 show_default=True, 

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

292 )(func) 

293 func = click.option( 

294 "--admin-user", 

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

296 )(func) 

297 func = click.option( 

298 "--admin-password", 

299 envvar="GCO_GRAFANA_ADMIN_PASSWORD", 

300 help=( 

301 "Grafana admin password (also $GCO_GRAFANA_ADMIN_PASSWORD; " 

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

303 ), 

304 )(func) 

305 return func 

306 

307 

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

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

310 if admin_password: 

311 return (admin_user or "admin", admin_password) 

312 from ..monitoring_user_mgmt import read_grafana_admin_credentials 

313 

314 return read_grafana_admin_credentials() 

315 

316 

317@monitoring.group("users") 

318@pass_config 

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

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

321 

322 

323@users_cmd.command("add") 

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

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

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

327@click.option( 

328 "--generate-password", 

329 is_flag=True, 

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

331) 

332@_grafana_conn_options 

333@pass_config 

334def users_add( 

335 config: Any, 

336 username: str, 

337 email: str | None, 

338 password: str | None, 

339 generate_password: bool, 

340 grafana_url: str, 

341 admin_user: str | None, 

342 admin_password: str | None, 

343) -> None: 

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

345 from ..monitoring_user_mgmt import create_user 

346 from ..monitoring_user_mgmt import generate_password as _gen 

347 

348 formatter = get_output_formatter(config) 

349 if password and generate_password: 

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

351 sys.exit(1) 

352 if not password and not generate_password: 

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

354 sys.exit(1) 

355 

356 final_password = password or _gen() 

357 try: 

358 auth = _resolve_grafana_auth(admin_user, admin_password) 

359 user_id = create_user( 

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

361 ) 

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

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

364 sys.exit(1) 

365 

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

367 if generate_password: 

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

369 

370 

371@users_cmd.command("list") 

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

373@_grafana_conn_options 

374@pass_config 

375def users_list( 

376 config: Any, 

377 as_json: bool, 

378 grafana_url: str, 

379 admin_user: str | None, 

380 admin_password: str | None, 

381) -> None: 

382 """List Grafana organisation users.""" 

383 from ..monitoring_user_mgmt import list_users 

384 

385 formatter = get_output_formatter(config) 

386 try: 

387 auth = _resolve_grafana_auth(admin_user, admin_password) 

388 users = list_users(grafana_url, auth) 

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

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

391 sys.exit(1) 

392 

393 if as_json: 393 ↛ 398line 393 didn't jump to line 398 because the condition on line 393 was always true

394 import json 

395 

396 print(json.dumps(users, indent=2)) 

397 return 

398 formatter.print(users) 

399 

400 

401@users_cmd.command("remove") 

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

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

404@_grafana_conn_options 

405@pass_config 

406def users_remove( 

407 config: Any, 

408 username: str, 

409 yes: bool, 

410 grafana_url: str, 

411 admin_user: str | None, 

412 admin_password: str | None, 

413) -> None: 

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

415 from ..monitoring_user_mgmt import delete_user, lookup_user_id 

416 

417 formatter = get_output_formatter(config) 

418 if not yes: 418 ↛ 419line 418 didn't jump to line 419 because the condition on line 418 was never true

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

420 

421 try: 

422 auth = _resolve_grafana_auth(admin_user, admin_password) 

423 user_id = lookup_user_id(grafana_url, auth, username) 

424 delete_user(grafana_url, auth, user_id) 

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

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

427 sys.exit(1) 

428 

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