Coverage for cli / cluster_doctor.py: 100.00%

189 statements  

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

1"""Diagnosis for the three independent layers of EKS cluster access. 

2 

3Reaching a GCO cluster's API from a laptop needs three things to be right at 

4once, and each fails with a misleading symptom: 

5 

6* **Reachability** — the endpoint mode (PRIVATE needs an SSM tunnel, VPN, or 

7 bastion; PUBLIC_AND_PRIVATE may carry a CIDR allowlist your egress IP is 

8 not in). Failure symptom: kubectl timeouts. 

9* **Authentication** — the cluster authenticates through EKS access entries, 

10 and by default only platform Lambda roles have one. Failure symptom: 

11 ``Unauthorized`` even though the network path is fine. 

12* **Authorization** — an access entry with no associated access policy 

13 authenticates but can do nothing. Failure symptom: RBAC ``Forbidden``. 

14 

15Plus the kubeconfig context itself, which is where the two most-confused 

16failures live: a stale context pointing at a **destroyed** cluster produces 

17the same kubectl symptom (``no such host``) as a private-only endpoint, and 

18the two remedies are completely different. ``gco cluster doctor`` names each 

19layer's state separately and the remedy per case. 

20 

21The module is split into subprocess probes (thin, monkeypatchable seams that 

22mirror :mod:`cli.kubectl_helpers`' aws-CLI usage) and a pure 

23:func:`diagnose` over their results, so the decision table is directly 

24testable without any AWS access. 

25""" 

26 

27from __future__ import annotations 

28 

29import json 

30import re 

31import subprocess 

32from dataclasses import dataclass 

33from typing import Any 

34from urllib.parse import urlsplit 

35 

36from . import kubectl_helpers 

37 

38_ASSUMED_ROLE_RE = re.compile(r":assumed-role/([^/]+)/") 

39 

40# Symptom string kubectl prints for a DNS-dead endpoint; used in findings so 

41# an operator can match what they saw to the diagnosis. 

42NO_SUCH_HOST = "no such host" 

43 

44 

45@dataclass(frozen=True) 

46class DoctorCheck: 

47 """One layer's diagnosis: what was found and what fixes it.""" 

48 

49 layer: str # "cluster" | "reachability" | "authentication" | "authorization" | "kubeconfig" 

50 status: str # "ok" | "warn" | "fail" | "unknown" 

51 finding: str 

52 remedy: str | None = None 

53 

54 def as_dict(self) -> dict[str, Any]: 

55 payload: dict[str, Any] = { 

56 "layer": self.layer, 

57 "status": self.status, 

58 "finding": self.finding, 

59 } 

60 if self.remedy: 

61 payload["remedy"] = self.remedy 

62 return payload 

63 

64 

65@dataclass(frozen=True) 

66class ClusterProbe: 

67 """Everything the probes could learn about one cluster's access layers.""" 

68 

69 cluster: str 

70 region: str 

71 exists: bool 

72 describe_error: str | None 

73 endpoint: str 

74 public: bool 

75 private: bool 

76 public_cidrs: list[str] 

77 caller_arn: str | None 

78 access_entries: list[str] | None 

79 associated_policies: list[str] | None 

80 kubeconfig_server: str | None 

81 kubeconfig_tunnel_pinned: bool 

82 

83 

84def _run_aws(args: list[str]) -> subprocess.CompletedProcess[str]: 

85 """Run one aws-CLI command (list form, never a shell string).""" 

86 return subprocess.run( # nosemgrep: dangerous-subprocess-use-audit - fixed argv head, validated inputs, list form, no shell=True 

87 ["aws", *args], 

88 capture_output=True, 

89 text=True, 

90 check=False, 

91 ) 

92 

93 

94def caller_principal_arn() -> str | None: 

95 """The caller's IAM principal, with assumed-role ARNs normalized. 

96 

97 EKS access entries are created for the base role 

98 (``arn:...:role/Name``), while STS reports an assumed-role session 

99 (``arn:...:assumed-role/Name/session``); comparing the raw session ARN 

100 against the entry list would report a false "no access entry". The same 

101 normalization ``gco stacks access`` applies when creating the entry. 

102 """ 

103 try: 

104 result = _run_aws(["sts", "get-caller-identity", "--output", "json"]) 

105 except FileNotFoundError: 

106 return None 

107 if result.returncode != 0: 

108 return None 

109 try: 

110 payload = json.loads(result.stdout or "{}") 

111 except json.JSONDecodeError: 

112 return None 

113 arn = str(payload.get("Arn") or "") 

114 if not arn: 

115 return None 

116 assumed = _ASSUMED_ROLE_RE.search(arn) 

117 if assumed: 

118 partition = arn.split(":")[1] if arn.count(":") >= 2 else "aws" 

119 account = str(payload.get("Account") or "") 

120 return f"arn:{partition}:iam::{account}:role/{assumed.group(1)}" 

121 return arn 

122 

123 

124def list_access_entries(cluster: str, region: str) -> list[str] | None: 

125 """Principal ARNs holding an EKS access entry, or ``None`` when unknowable.""" 

126 try: 

127 result = _run_aws( 

128 [ 

129 "eks", 

130 "list-access-entries", 

131 "--cluster-name", 

132 cluster, 

133 "--region", 

134 region, 

135 "--output", 

136 "json", 

137 ] 

138 ) 

139 except FileNotFoundError: 

140 return None 

141 if result.returncode != 0: 

142 return None 

143 try: 

144 payload = json.loads(result.stdout or "{}") 

145 except json.JSONDecodeError: 

146 return None 

147 return [str(entry) for entry in payload.get("accessEntries", [])] 

148 

149 

150def list_associated_access_policies( 

151 cluster: str, region: str, principal_arn: str 

152) -> list[str] | None: 

153 """Access-policy ARNs associated with one principal, or ``None`` when unknowable.""" 

154 try: 

155 result = _run_aws( 

156 [ 

157 "eks", 

158 "list-associated-access-policies", 

159 "--cluster-name", 

160 cluster, 

161 "--region", 

162 region, 

163 "--principal-arn", 

164 principal_arn, 

165 "--output", 

166 "json", 

167 ] 

168 ) 

169 except FileNotFoundError: 

170 return None 

171 if result.returncode != 0: 

172 return None 

173 try: 

174 payload = json.loads(result.stdout or "{}") 

175 except json.JSONDecodeError: 

176 return None 

177 policies = payload.get("associatedAccessPolicies", []) 

178 return [ 

179 str(policy.get("policyArn", "")) 

180 for policy in policies 

181 if isinstance(policy, dict) and policy.get("policyArn") 

182 ] 

183 

184 

185def kubeconfig_cluster_entry(cluster_name: str) -> tuple[str, bool] | None: 

186 """The kubeconfig ``(server, tunnel_pinned)`` recorded for this cluster. 

187 

188 Matches entries the same way :func:`cli.kubectl_helpers._tunnel_pinned_server` 

189 does (exact name or the ARN-shaped ``…cluster/<name>`` suffix), but 

190 returns the server for ANY matching entry — the doctor needs to see a 

191 stale non-tunnel server too, not just tunnel pins. 

192 """ 

193 import yaml 

194 

195 path = kubectl_helpers._kubeconfig_file() 

196 try: 

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

198 except OSError, yaml.YAMLError: 

199 return None 

200 expected_suffix = f"cluster/{cluster_name}" 

201 for entry in config.get("clusters", []) or []: 

202 name = str(entry.get("name", "")) 

203 if name != cluster_name and not name.endswith(expected_suffix): 

204 continue 

205 cluster = entry.get("cluster") or {} 

206 server = str(cluster.get("server", "")) 

207 host = urlsplit(server).hostname or "" 

208 pinned = host in kubectl_helpers._LOCAL_TUNNEL_HOSTS and bool( 

209 cluster.get("tls-server-name") 

210 ) 

211 return (server, pinned) 

212 return None 

213 

214 

215def probe_cluster(cluster: str, region: str) -> ClusterProbe: 

216 """Collect every layer's raw state for :func:`diagnose`.""" 

217 exists = True 

218 describe_error: str | None = None 

219 access: dict[str, Any] = {"endpoint": "", "public": False, "private": False, "public_cidrs": []} 

220 try: 

221 access = kubectl_helpers.describe_cluster_access(cluster, region) 

222 except (RuntimeError, ValueError) as exc: 

223 exists = False 

224 describe_error = str(exc) 

225 

226 caller = caller_principal_arn() 

227 entries = list_access_entries(cluster, region) if exists else None 

228 policies: list[str] | None = None 

229 if exists and caller and entries is not None and caller in entries: 

230 policies = list_associated_access_policies(cluster, region, caller) 

231 

232 kubeconfig = kubeconfig_cluster_entry(cluster) 

233 return ClusterProbe( 

234 cluster=cluster, 

235 region=region, 

236 exists=exists, 

237 describe_error=describe_error, 

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

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

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

241 public_cidrs=[str(cidr) for cidr in access.get("public_cidrs") or []], 

242 caller_arn=caller, 

243 access_entries=entries, 

244 associated_policies=policies, 

245 kubeconfig_server=kubeconfig[0] if kubeconfig else None, 

246 kubeconfig_tunnel_pinned=kubeconfig[1] if kubeconfig else False, 

247 ) 

248 

249 

250def _host(url: str) -> str: 

251 return (urlsplit(url).hostname or "").lower() 

252 

253 

254def _diagnose_missing_cluster(probe: ClusterProbe) -> list[DoctorCheck]: 

255 """The cluster cannot be described: destroyed, renamed, or unqueryable.""" 

256 checks: list[DoctorCheck] = [] 

257 not_found = "ResourceNotFoundException" in (probe.describe_error or "") 

258 if not not_found: 

259 checks.append( 

260 DoctorCheck( 

261 layer="cluster", 

262 status="unknown", 

263 finding=( 

264 f"could not describe cluster {probe.cluster!r} in {probe.region}: " 

265 f"{probe.describe_error}" 

266 ), 

267 remedy=("Check AWS credentials/region (aws sts get-caller-identity) and retry."), 

268 ) 

269 ) 

270 return checks 

271 

272 checks.append( 

273 DoctorCheck( 

274 layer="cluster", 

275 status="fail", 

276 finding=f"cluster {probe.cluster!r} does not exist in {probe.region}", 

277 remedy=( 

278 f"Deploy it (gco stacks deploy {probe.cluster} -y) or check " 

279 "`gco stacks list` for where this deployment actually runs." 

280 ), 

281 ) 

282 ) 

283 if probe.kubeconfig_server and not probe.kubeconfig_tunnel_pinned: 

284 checks.append( 

285 DoctorCheck( 

286 layer="kubeconfig", 

287 status="fail", 

288 finding=( 

289 f"kubeconfig still has a context for {probe.cluster!r} pointing at " 

290 f"{_host(probe.kubeconfig_server)} — kubectl against it fails with " 

291 f"'{NO_SUCH_HOST}'. That is a stale context for a destroyed cluster, " 

292 "NOT a private-endpoint problem." 

293 ), 

294 remedy=( 

295 "Remove the stale context (kubectl config delete-context) or refresh " 

296 "it after redeploying (aws eks update-kubeconfig " 

297 f"--name {probe.cluster} --region {probe.region})." 

298 ), 

299 ) 

300 ) 

301 return checks 

302 

303 

304def _diagnose_reachability(probe: ClusterProbe) -> DoctorCheck: 

305 if probe.public: 

306 if probe.public_cidrs and "0.0.0.0/0" not in probe.public_cidrs: 

307 remedy = ( 

308 "If kubectl times out from this machine, confirm your egress IP is " 

309 "inside the allowlist; adjust with gco stacks eks endpoint set " 

310 "PUBLIC_AND_PRIVATE --cidr <your-ip>/32 and redeploy." 

311 ) 

312 if probe.private: 

313 # A rotating egress pool can't be allowlisted; the private 

314 # endpoint needs no allowlist at all. 

315 remedy += ( 

316 " Or bypass the allowlist: private access is on too, so " 

317 "--via-ssm reaches the private endpoint " 

318 "(gco cluster tunnel --via-ssm auto)." 

319 ) 

320 return DoctorCheck( 

321 layer="reachability", 

322 status="ok", 

323 finding=( 

324 f"public endpoint restricted to CIDR allowlist: {', '.join(probe.public_cidrs)}" 

325 ), 

326 remedy=remedy, 

327 ) 

328 return DoctorCheck( 

329 layer="reachability", 

330 status="ok", 

331 finding="public endpoint reachable from the internet (0.0.0.0/0; IAM still gates use)", 

332 remedy=( 

333 "Consider restricting it: gco stacks eks endpoint set PUBLIC_AND_PRIVATE " 

334 "--cidr <your-ip>/32, or PRIVATE with gco cluster tunnel." 

335 ), 

336 ) 

337 if probe.kubeconfig_tunnel_pinned: 

338 return DoctorCheck( 

339 layer="reachability", 

340 status="ok", 

341 finding=( 

342 "PRIVATE endpoint with an SSM tunnel pin in kubeconfig " 

343 f"({probe.kubeconfig_server}) — kubectl works while that tunnel is open" 

344 ), 

345 remedy="If kubectl fails, reopen the tunnel: gco cluster tunnel --via-ssm auto.", 

346 ) 

347 return DoctorCheck( 

348 layer="reachability", 

349 status="warn", 

350 finding=( 

351 "PRIVATE endpoint — kubectl from outside the VPC cannot connect " 

352 "(connection timeouts, NOT an authentication problem)" 

353 ), 

354 remedy=( 

355 "Open a tunnel: gco cluster tunnel --via-ssm auto (self-terminating bastion), " 

356 "or use a VPN/bastion. Note the tunnel does not replace an access entry." 

357 ), 

358 ) 

359 

360 

361def _diagnose_authentication(probe: ClusterProbe) -> DoctorCheck: 

362 if not probe.caller_arn: 

363 return DoctorCheck( 

364 layer="authentication", 

365 status="unknown", 

366 finding="could not resolve the caller's IAM principal", 

367 remedy="Configure AWS credentials (aws sts get-caller-identity must succeed).", 

368 ) 

369 if probe.access_entries is None: 

370 return DoctorCheck( 

371 layer="authentication", 

372 status="unknown", 

373 finding="could not list the cluster's EKS access entries", 

374 remedy=( 

375 "The caller needs eks:ListAccessEntries to run this check; " 

376 "try gco stacks access -r " + probe.region + " which also creates the entry." 

377 ), 

378 ) 

379 if probe.caller_arn in probe.access_entries: 

380 return DoctorCheck( 

381 layer="authentication", 

382 status="ok", 

383 finding=f"access entry exists for {probe.caller_arn}", 

384 ) 

385 return DoctorCheck( 

386 layer="authentication", 

387 status="fail", 

388 finding=( 

389 f"no EKS access entry for {probe.caller_arn} — kubectl fails with " 

390 "'Unauthorized' even over a working tunnel or public endpoint" 

391 ), 

392 remedy=( 

393 f"Run gco stacks access -r {probe.region} (one-shot entry + admin policy), " 

394 "or declare the principal in cdk.json eks_cluster.developer_access and " 

395 "redeploy for a namespace-scoped grant." 

396 ), 

397 ) 

398 

399 

400def _diagnose_authorization(probe: ClusterProbe) -> DoctorCheck | None: 

401 if not probe.caller_arn or probe.access_entries is None: 

402 return None 

403 if probe.caller_arn not in probe.access_entries: 

404 return None 

405 if probe.associated_policies is None: 

406 return DoctorCheck( 

407 layer="authorization", 

408 status="unknown", 

409 finding="could not list associated access policies for the caller's entry", 

410 remedy="The caller needs eks:ListAssociatedAccessPolicies to run this check.", 

411 ) 

412 if not probe.associated_policies: 

413 return DoctorCheck( 

414 layer="authorization", 

415 status="fail", 

416 finding=( 

417 "the access entry has no associated access policies — kubectl " 

418 "authenticates but every verb is Forbidden" 

419 ), 

420 remedy=( 

421 f"Run gco stacks access -r {probe.region} to associate " 

422 "AmazonEKSClusterAdminPolicy, or set a namespace-scoped policy via " 

423 "cdk.json eks_cluster.developer_access." 

424 ), 

425 ) 

426 names = ", ".join(arn.rsplit("/", 1)[-1] for arn in probe.associated_policies) 

427 return DoctorCheck( 

428 layer="authorization", 

429 status="ok", 

430 finding=f"associated access policies: {names}", 

431 ) 

432 

433 

434def _diagnose_kubeconfig(probe: ClusterProbe) -> DoctorCheck: 

435 if probe.kubeconfig_server is None: 

436 return DoctorCheck( 

437 layer="kubeconfig", 

438 status="warn", 

439 finding=f"no kubeconfig context for {probe.cluster!r}", 

440 remedy=( 

441 f"aws eks update-kubeconfig --name {probe.cluster} --region {probe.region} " 

442 f"(gco stacks access -r {probe.region} also does this)." 

443 ), 

444 ) 

445 if probe.kubeconfig_tunnel_pinned: 

446 return DoctorCheck( 

447 layer="kubeconfig", 

448 status="ok", 

449 finding=f"context pinned to a local SSM tunnel ({probe.kubeconfig_server})", 

450 remedy="Deliberate tunnel pin; gco commands preserve it while the tunnel is open.", 

451 ) 

452 if _host(probe.kubeconfig_server) == _host(probe.endpoint): 

453 return DoctorCheck( 

454 layer="kubeconfig", 

455 status="ok", 

456 finding="context points at the live cluster endpoint", 

457 ) 

458 return DoctorCheck( 

459 layer="kubeconfig", 

460 status="fail", 

461 finding=( 

462 f"context points at {_host(probe.kubeconfig_server)} but the live endpoint is " 

463 f"{_host(probe.endpoint)} — kubectl errors like '{NO_SUCH_HOST}' come from this " 

464 "stale entry, not from the endpoint access mode" 

465 ), 

466 remedy=( 

467 f"aws eks update-kubeconfig --name {probe.cluster} --region {probe.region} " 

468 "to repoint the context." 

469 ), 

470 ) 

471 

472 

473def diagnose(probe: ClusterProbe) -> list[DoctorCheck]: 

474 """Turn one probe into per-layer findings with remedies (pure).""" 

475 if not probe.exists: 

476 return _diagnose_missing_cluster(probe) 

477 checks = [ 

478 _diagnose_reachability(probe), 

479 _diagnose_authentication(probe), 

480 ] 

481 authorization = _diagnose_authorization(probe) 

482 if authorization is not None: 

483 checks.append(authorization) 

484 checks.append(_diagnose_kubeconfig(probe)) 

485 return checks 

486 

487 

488def endpoint_drift( 

489 configured_mode: str, 

490 configured_cidrs: list[str], 

491 live: dict[str, Any], 

492) -> str | None: 

493 """Describe cdk.json-vs-live endpoint drift, or ``None`` when converged. 

494 

495 Used by ``gco stacks status`` so an endpoint flip that was configured 

496 (``gco stacks eks endpoint set``) but not yet deployed — or applied 

497 out-of-band and never written back — shows up as explicit drift. 

498 """ 

499 live_public = bool(live.get("public")) 

500 live_cidrs = sorted(str(cidr) for cidr in live.get("public_cidrs") or []) 

501 configured_public = configured_mode == "PUBLIC_AND_PRIVATE" 

502 if configured_public != live_public: 

503 live_mode = "PUBLIC_AND_PRIVATE" if live_public else "PRIVATE" 

504 return ( 

505 f"cdk.json eks_cluster.endpoint_access={configured_mode} but the live " 

506 f"endpoint is {live_mode}" 

507 ) 

508 if not configured_public: 

509 return None 

510 expected = sorted(str(cidr) for cidr in configured_cidrs) or ["0.0.0.0/0"] 

511 if expected != live_cidrs: 

512 return ( 

513 "cdk.json eks_cluster.public_access_cidrs=" 

514 f"[{', '.join(expected)}] but the live allowlist is " 

515 f"[{', '.join(live_cidrs) or '0.0.0.0/0'}]" 

516 ) 

517 return None