Coverage for scripts / live_release_validation / checks / network_posture.py: 100.00%

208 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-09-14 22:07 +0000

1"""Network posture probes: prove the shipped NetworkPolicies decide traffic live. 

2 

3``03-network-policies.yaml`` documents a zero-trust posture, and on EKS Auto 

4Mode that posture is only real once ``06-network-policy-controller.yaml`` has 

5switched the policy controller on. The ``network-posture`` action settles the 

6question on the deployed cluster by dialing real connections from throwaway 

7pods and reading the verdict from each probe's exit code: 

8 

9* ``gco-jobs`` -> ``gco-jobs``: **reachable** (``allow-same-namespace``, so a 

10 multi-pod job can talk to itself); 

11* ``default`` -> ``gco-jobs``: **blocked** (``default-deny-ingress``); 

12* ``gco-jobs`` -> ``gco-system``: **blocked** (``default-deny-ingress`` — the 

13 job namespace cannot reach the control plane's services); 

14* ``default`` -> the live inference-monitor's ``:9090/metrics``: **reachable** 

15 (``allow-metrics-to-inference-monitor`` admits exactly the metrics port, 

16 which is what lets Prometheus scrape it); 

17* ``gco-jobs`` -> ``https://checkip.amazonaws.com/``: **reachable** 

18 (``allow-dns`` plus ``allow-https-egress``); 

19* ``gco-jobs`` -> ``http://checkip.amazonaws.com/`` on port 80: **blocked** 

20 (no rule admits it — the same host over 443 answered, so this is the policy 

21 deciding, not the network); and 

22* ``gco-jobs`` -> ``http://169.254.170.23/v1/credentials``: **reachable** 

23 (``allow-pod-identity-agent`` — the node's EKS Pod Identity Agent, which is 

24 where a ``gco-service-account`` pod gets its AWS credentials; the probe 

25 carries no token, so the agent answers 4xx, and any HTTP answer counts). 

26 Port 80 to the internet is blocked by the previous probe, so this one shows 

27 the rule admitting exactly the link-local endpoint. 

28 

29Every probe is a digest-pinned BusyBox Job labelled with this run's token 

30(``manifests/netpol-probe-job.yaml``); the two listeners are the same image 

31behind ``httpd`` (``manifests/netpol-target-job.yaml``). All of them are 

32deleted before the action returns, and each carries ``activeDeadlineSeconds`` 

33plus a TTL so a harness that dies mid-probe still leaves nothing behind. 

34 

35A probe's verdict is its steady state, not its first dial. The VPC CNI 

36attaches a new pod's policies in parallel with the pod's start and admits 

37everything until they are in place (standard mode; Auto Mode's NodeClass 

38``networkPolicy: DefaultDeny`` is the strict alternative, which requires a 

39policy for every pod on the node), so a client that dials once at start can 

40read that window instead of the policy — the fourth live run of this branch 

41saw exactly that on the port-80 egress probe. The probe script therefore 

42samples until one answer has held for 30 seconds and at least 45 seconds have 

43passed, exits 43 if the answer is still changing after three minutes, and 

44prints every change of answer; the harness keeps those lines as ``samples``, 

45the closing line as ``settled``, and flags ``attach_window_observed`` when the 

46first answer differed from the steady state. 

47 

48When cdk.json turns the controller off (``eks_cluster.network_policy_enforcement: 

49false``) the deny verdicts are not promised and those probes are recorded as 

50skipped; the reachability probes still have to pass. 

51""" 

52 

53from __future__ import annotations 

54 

55import copy 

56import json 

57import time 

58from dataclasses import asdict, dataclass 

59from typing import Any 

60 

61from ..models import RunContext 

62from .cluster import KubectlRunner, kubectl_json 

63from .jobs import _load_manifest, _run_token 

64from .platform_workloads import network_policy_enforcement_enabled 

65 

66_TARGET_MANIFEST = "netpol-target-job.yaml" 

67_PROBE_MANIFEST = "netpol-probe-job.yaml" 

68_TARGET_PORT = 8080 

69_INFERENCE_MONITOR_METRICS_PORT = 9090 

70_EGRESS_HOST = "checkip.amazonaws.com" 

71#: The EKS Pod Identity Agent's link-local address on every node; the 

72#: cluster points AWS_CONTAINER_CREDENTIALS_FULL_URI at this path. 

73_POD_IDENTITY_AGENT_URL = "http://169.254.170.23/v1/credentials" 

74#: A namespace the manifests leave unpoliced, so a client there proves the 

75#: target namespace's ingress rules and nothing else. 

76_UNPOLICED_NAMESPACE = "default" 

77_REACHABLE_EXIT_CODE = 0 

78_BLOCKED_EXIT_CODE = 42 

79#: The answer kept changing for the probe's whole budget; never a verdict. 

80_UNSETTLED_EXIT_CODE = 43 

81#: Auto Mode may have to launch a node for the first pod; a probe then still 

82#: has the image pull and its own three-minute sampling budget ahead of it. 

83_POD_TIMEOUT_SECONDS = 600 

84#: One line per change of answer plus the closing lines; an answer that 

85#: flapped on every sample for the whole budget prints more, and the tail 

86#: keeps the end of that story. 

87_LOG_TAIL_LINES = 40 

88_LOG_LIMIT = 4_000 

89_SAMPLE_PREFIX = "NETPOL_SAMPLE " 

90_SETTLED_PREFIXES = ("NETPOL_SETTLED ", "NETPOL_UNSETTLED ") 

91 

92 

93class NetworkPostureValidationError(RuntimeError): 

94 """The live cluster does not enforce the documented network posture.""" 

95 

96 

97@dataclass(frozen=True) 

98class ProbeSpec: 

99 """One connection attempt and the verdict the shipped policies promise.""" 

100 

101 name: str 

102 client_namespace: str 

103 url: str 

104 expected: str 

105 rule: str 

106 #: A deny verdict exists only while the policy controller is on. 

107 enforcement_only: bool 

108 

109 

110def _probe_specs( 

111 system_target_ip: str, 

112 jobs_target_ip: str, 

113 inference_monitor_ip: str, 

114) -> tuple[ProbeSpec, ...]: 

115 return ( 

116 ProbeSpec( 

117 "same-namespace", 

118 "gco-jobs", 

119 f"http://{jobs_target_ip}:{_TARGET_PORT}/", 

120 "reachable", 

121 "gco-jobs/allow-same-namespace", 

122 enforcement_only=False, 

123 ), 

124 ProbeSpec( 

125 "cross-jobs", 

126 _UNPOLICED_NAMESPACE, 

127 f"http://{jobs_target_ip}:{_TARGET_PORT}/", 

128 "blocked", 

129 "gco-jobs/default-deny-ingress", 

130 enforcement_only=True, 

131 ), 

132 ProbeSpec( 

133 "cross-system", 

134 "gco-jobs", 

135 f"http://{system_target_ip}:{_TARGET_PORT}/", 

136 "blocked", 

137 "gco-system/default-deny-ingress", 

138 enforcement_only=True, 

139 ), 

140 ProbeSpec( 

141 "metrics-open", 

142 _UNPOLICED_NAMESPACE, 

143 f"http://{inference_monitor_ip}:{_INFERENCE_MONITOR_METRICS_PORT}/metrics", 

144 "reachable", 

145 "gco-system/allow-metrics-to-inference-monitor", 

146 enforcement_only=False, 

147 ), 

148 ProbeSpec( 

149 "https-egress", 

150 "gco-jobs", 

151 f"https://{_EGRESS_HOST}/", 

152 "reachable", 

153 "gco-jobs/allow-dns + gco-jobs/allow-https-egress", 

154 enforcement_only=False, 

155 ), 

156 ProbeSpec( 

157 "http-egress", 

158 "gco-jobs", 

159 f"http://{_EGRESS_HOST}/", 

160 "blocked", 

161 "gco-jobs egress (no rule admits port 80)", 

162 enforcement_only=True, 

163 ), 

164 ProbeSpec( 

165 "pod-identity-agent", 

166 "gco-jobs", 

167 _POD_IDENTITY_AGENT_URL, 

168 "reachable", 

169 "gco-jobs/allow-pod-identity-agent", 

170 enforcement_only=False, 

171 ), 

172 ) 

173 

174 

175def _dict(value: Any) -> dict[str, Any]: 

176 return value if isinstance(value, dict) else {} 

177 

178 

179def _list(value: Any) -> list[Any]: 

180 return value if isinstance(value, list) else [] 

181 

182 

183def _substitute(value: Any, replacements: dict[str, str]) -> Any: 

184 """Replace manifest placeholders (``__PROBE_URL__``) wherever they appear.""" 

185 if isinstance(value, str): 

186 for token, replacement in replacements.items(): 

187 value = value.replace(token, replacement) 

188 return value 

189 if isinstance(value, list): 

190 return [_substitute(item, replacements) for item in value] 

191 if isinstance(value, dict): 

192 return {key: _substitute(item, replacements) for key, item in value.items()} 

193 return value 

194 

195 

196class NetworkPostureProbe: 

197 """Run the probe matrix on one Region's cluster and clean up after it.""" 

198 

199 def __init__(self, ctx: RunContext, region: str, kubectl: KubectlRunner) -> None: 

200 self.ctx = ctx 

201 self.region = region 

202 self.kubectl = kubectl 

203 self.token = _run_token(ctx.settings.run_id) 

204 self.timeout = float(ctx.settings.command_timeout_seconds) 

205 with ctx.state_lock: 

206 self.record: dict[str, Any] = ctx.checkpoint.state.setdefault( 

207 "network_posture", {} 

208 ).setdefault(region, {}) 

209 self.record.setdefault("jobs", []) 

210 

211 # -- checkpointing ----------------------------------------------------- 

212 

213 def _persist(self) -> None: 

214 self.ctx.persist() 

215 

216 def _fail(self, message: str) -> NetworkPostureValidationError: 

217 self.record["failure"] = message 

218 self._persist() 

219 return NetworkPostureValidationError(f"network posture in {self.region}: {message}") 

220 

221 # -- Job lifecycle ----------------------------------------------------- 

222 

223 def _job_manifest( 

224 self, 

225 filename: str, 

226 *, 

227 name: str, 

228 namespace: str, 

229 replacements: dict[str, str] | None = None, 

230 ) -> dict[str, Any]: 

231 """Load one checked-in Job (run token applied) and give it this probe's identity.""" 

232 manifests, _name, _namespace = _load_manifest(self.ctx, filename) 

233 job = copy.deepcopy(next(item for item in manifests if item.get("kind") == "Job")) 

234 if replacements: 

235 job = _substitute(job, replacements) 

236 job["metadata"]["name"] = name 

237 job["metadata"]["namespace"] = namespace 

238 return job 

239 

240 def _run(self, *arguments: str, **kwargs: Any) -> tuple[int, str, str]: 

241 code, stdout, stderr = self.kubectl(*arguments, timeout=self.timeout, **kwargs) 

242 return code, stdout, stderr 

243 

244 def _create_job(self, job: dict[str, Any]) -> None: 

245 namespace = job["metadata"]["namespace"] 

246 name = job["metadata"]["name"] 

247 # Record before creating: a Job that exists without a record could not 

248 # be cleaned up, so the record is what authorizes the delete. 

249 self.record["jobs"].append({"namespace": namespace, "name": name, "deleted": False}) 

250 self._persist() 

251 # Start from a clean slate so a retry of this run never reads a stale 

252 # verdict; foreground cascading waits for the old pod to be gone. 

253 code, _stdout, stderr = self._run( 

254 "delete", 

255 "job", 

256 name, 

257 "--namespace", 

258 namespace, 

259 "--ignore-not-found", 

260 "--cascade=foreground", 

261 "--wait=true", 

262 "--timeout=120s", 

263 ) 

264 if code != 0: 

265 self.record["last_kubectl_error"] = { 

266 "argv": ["delete", "job", name], 

267 "returncode": code, 

268 "stderr": stderr[-_LOG_LIMIT:], 

269 } 

270 raise self._fail(f"could not clear a previous {namespace}/{name}") 

271 code, _stdout, stderr = self._run( 

272 "apply", "--namespace", namespace, "--filename", "-", input=json.dumps(job) 

273 ) 

274 if code != 0: 

275 self.record["last_kubectl_error"] = { 

276 "argv": ["apply", namespace, name], 

277 "returncode": code, 

278 "stderr": stderr[-_LOG_LIMIT:], 

279 } 

280 raise self._fail(f"could not create {namespace}/{name}") 

281 

282 def _job_pod(self, namespace: str, name: str) -> dict[str, Any] | None: 

283 payload = kubectl_json( 

284 self.kubectl, 

285 self.record, 

286 "get", 

287 "pods", 

288 "--namespace", 

289 namespace, 

290 "--selector", 

291 f"job-name={name}", 

292 timeout=self.timeout, 

293 ) 

294 for item in _list(_dict(payload).get("items")): 

295 if not _dict(_dict(item).get("metadata")).get("deletionTimestamp"): 

296 return _dict(item) 

297 return None 

298 

299 def _wait(self, deadline: float, what: str) -> None: 

300 if time.monotonic() >= deadline: 

301 raise self._fail(f"{what} did not happen within {_POD_TIMEOUT_SECONDS}s") 

302 time.sleep(self.ctx.settings.poll_interval_seconds) 

303 

304 def _wait_for_listener(self, namespace: str, name: str) -> tuple[str, str]: 

305 """Return ``(pod name, pod IP)`` once the listener pod is Running and Ready.""" 

306 deadline = time.monotonic() + _POD_TIMEOUT_SECONDS 

307 while True: 

308 pod = self._job_pod(namespace, name) 

309 status = _dict(pod.get("status")) if pod else {} 

310 containers = [_dict(entry) for entry in _list(status.get("containerStatuses"))] 

311 if status.get("phase") == "Failed": 

312 raise self._fail(f"listener {namespace}/{name} failed before serving") 

313 if ( 

314 pod is not None 

315 and status.get("phase") == "Running" 

316 and containers 

317 and all(entry.get("ready") is True for entry in containers) 

318 and status.get("podIP") 

319 ): 

320 return str(_dict(pod.get("metadata")).get("name")), str(status["podIP"]) 

321 self._wait(deadline, f"listener {namespace}/{name} readiness") 

322 

323 def _wait_for_verdict(self, namespace: str, name: str) -> dict[str, Any]: 

324 """Return the probe pod's terminal phase, exit code, and log tail.""" 

325 deadline = time.monotonic() + _POD_TIMEOUT_SECONDS 

326 while True: 

327 pod = self._job_pod(namespace, name) 

328 status = _dict(pod.get("status")) if pod else {} 

329 phase = status.get("phase") 

330 if phase in ("Succeeded", "Failed"): 

331 containers = [_dict(entry) for entry in _list(status.get("containerStatuses"))] 

332 terminated = ( 

333 _dict(_dict(containers[0].get("state")).get("terminated")) if containers else {} 

334 ) 

335 exit_code = terminated.get("exitCode") 

336 pod_name = str(_dict(_dict(pod).get("metadata")).get("name")) 

337 _code, stdout, stderr = self._run( 

338 "logs", 

339 pod_name, 

340 "--namespace", 

341 namespace, 

342 f"--tail={_LOG_TAIL_LINES}", 

343 ) 

344 return { 

345 "pod": pod_name, 

346 "phase": phase, 

347 "exit_code": exit_code, 

348 "output": (stdout or stderr)[-_LOG_LIMIT:], 

349 } 

350 self._wait(deadline, f"probe {namespace}/{name} completion") 

351 

352 def _delete_jobs(self) -> list[dict[str, Any]]: 

353 problems: list[dict[str, Any]] = [] 

354 for entry in self.record["jobs"]: 

355 if entry.get("deleted"): 

356 continue 

357 code, _stdout, stderr = self._run( 

358 "delete", 

359 "job", 

360 entry["name"], 

361 "--namespace", 

362 entry["namespace"], 

363 "--ignore-not-found", 

364 "--wait=false", 

365 ) 

366 if code == 0: 

367 entry["deleted"] = True 

368 else: 

369 problems.append({**entry, "returncode": code, "stderr": stderr[-_LOG_LIMIT:]}) 

370 self._persist() 

371 return problems 

372 

373 # -- the matrix --------------------------------------------------------- 

374 

375 def _inference_monitor_ip(self) -> tuple[str, str]: 

376 payload = kubectl_json( 

377 self.kubectl, 

378 self.record, 

379 "get", 

380 "pods", 

381 "--namespace", 

382 "gco-system", 

383 "--selector", 

384 "app=inference-monitor", 

385 timeout=self.timeout, 

386 ) 

387 for item in _list(_dict(payload).get("items")): 

388 metadata = _dict(_dict(item).get("metadata")) 

389 status = _dict(_dict(item).get("status")) 

390 containers = [_dict(entry) for entry in _list(status.get("containerStatuses"))] 

391 if ( 

392 not metadata.get("deletionTimestamp") 

393 and status.get("phase") == "Running" 

394 and containers 

395 and all(entry.get("ready") is True for entry in containers) 

396 and status.get("podIP") 

397 ): 

398 return str(metadata.get("name")), str(status["podIP"]) 

399 raise self._fail("no ready inference-monitor pod to probe") 

400 

401 @staticmethod 

402 def _observed(verdict: dict[str, Any]) -> str: 

403 if verdict["phase"] == "Succeeded" and verdict["exit_code"] == _REACHABLE_EXIT_CODE: 

404 return "reachable" 

405 if verdict["phase"] == "Failed" and verdict["exit_code"] == _BLOCKED_EXIT_CODE: 

406 return "blocked" 

407 if verdict["phase"] == "Failed" and verdict["exit_code"] == _UNSETTLED_EXIT_CODE: 

408 return "unsettled" 

409 return "error" 

410 

411 @staticmethod 

412 def _trace(output: str) -> dict[str, Any]: 

413 """Read the probe script's sampling trace out of its log tail. 

414 

415 ``samples`` holds every change of answer (``t=0s reachable``, 

416 ``t=3s blocked``), ``settled`` the closing line, and 

417 ``attach_window_observed`` is true when the first answer differed from 

418 the steady state — on the VPC CNI, the standard-mode window between the 

419 pod's start and its policies being attached. 

420 """ 

421 samples: list[str] = [] 

422 settled: str | None = None 

423 for line in output.splitlines(): 

424 if line.startswith(_SAMPLE_PREFIX): 

425 samples.append(line.removeprefix(_SAMPLE_PREFIX)) 

426 elif line.startswith(_SETTLED_PREFIXES): 

427 settled = line 

428 return { 

429 "samples": samples, 

430 "settled": settled, 

431 "attach_window_observed": len(samples) > 1, 

432 } 

433 

434 def run(self) -> dict[str, Any]: 

435 """Start the listeners, run every probe, delete everything, judge the matrix.""" 

436 enforcement = network_policy_enforcement_enabled(self.ctx) 

437 self.record["enforcement_configured"] = enforcement 

438 results: list[dict[str, Any]] = [] 

439 evidence: dict[str, Any] = {"enforcement_configured": enforcement, "probes": results} 

440 try: 

441 targets: dict[str, dict[str, str]] = {} 

442 for namespace in ("gco-system", "gco-jobs"): 

443 name = f"gco-live-netpol-target-{self.token}" 

444 self._create_job( 

445 self._job_manifest(_TARGET_MANIFEST, name=name, namespace=namespace) 

446 ) 

447 pod_name, pod_ip = self._wait_for_listener(namespace, name) 

448 targets[namespace] = {"job": name, "pod": pod_name, "ip": pod_ip} 

449 evidence["targets"] = targets 

450 monitor_pod, monitor_ip = self._inference_monitor_ip() 

451 evidence["inference_monitor"] = {"pod": monitor_pod, "ip": monitor_ip} 

452 self.record["targets"] = targets 

453 self._persist() 

454 

455 specs = _probe_specs(targets["gco-system"]["ip"], targets["gco-jobs"]["ip"], monitor_ip) 

456 launched: list[tuple[ProbeSpec, str]] = [] 

457 for spec in specs: 

458 if spec.enforcement_only and not enforcement: 

459 results.append( 

460 { 

461 **asdict(spec), 

462 "status": "skipped", 

463 "reason": ( 

464 "eks_cluster.network_policy_enforcement is false in cdk.json; " 

465 "no deny verdict is promised" 

466 ), 

467 } 

468 ) 

469 continue 

470 name = f"gco-live-netpol-{spec.name}-{self.token}" 

471 self._create_job( 

472 self._job_manifest( 

473 _PROBE_MANIFEST, 

474 name=name, 

475 namespace=spec.client_namespace, 

476 replacements={"__PROBE_URL__": spec.url}, 

477 ) 

478 ) 

479 launched.append((spec, name)) 

480 for spec, name in launched: 

481 verdict = self._wait_for_verdict(spec.client_namespace, name) 

482 observed = self._observed(verdict) 

483 results.append( 

484 { 

485 **asdict(spec), 

486 **verdict, 

487 **self._trace(verdict["output"]), 

488 "job": name, 

489 "observed": observed, 

490 "status": "matched" if observed == spec.expected else "mismatch", 

491 } 

492 ) 

493 self.record["probes"] = results 

494 self._persist() 

495 finally: 

496 try: 

497 evidence["cleanup_problems"] = self._delete_jobs() 

498 except Exception as exc: # a cleanup error must never mask the verdict 

499 evidence["cleanup_problems"] = [{"error": f"{type(exc).__name__}: {exc}"}] 

500 

501 mismatches = [ 

502 f"{item['name']} ({item['client_namespace']} -> {item['url']}) expected " 

503 f"{item['expected']}, observed {item['observed']} " 

504 f"[phase={item['phase']} exit={item['exit_code']}]" 

505 for item in results 

506 if item["status"] == "mismatch" 

507 ] 

508 if mismatches: 

509 raise self._fail("; ".join(mismatches)) 

510 if evidence["cleanup_problems"]: 

511 raise self._fail( 

512 f"{len(evidence['cleanup_problems'])} probe Job(s) could not be deleted" 

513 ) 

514 self.record["evidence"] = evidence 

515 self._persist() 

516 return evidence