Coverage for scripts / live_release_validation / checks / network_posture.py: 100.00%
304 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
1"""Network posture probes: prove the shipped NetworkPolicies decide traffic live.
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:
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 ``https://…:9443/metrics``:
15 **reachable** (``allow-metrics-to-inference-monitor`` admits exactly the
16 metrics TLS sidecar's port, which is what lets Prometheus scrape it);
17* ``default`` -> the same pod's plaintext ``:9090``: **blocked** (the monitor
18 binds it to loopback, so only its TLS sidecar can reach it);
19* ``default`` -> the live cost-monitor's ``https://…:8443``: **blocked**
20 (``allow-manifest-processor-to-cost-monitor-ingress`` admits only the
21 manifest processor), and its plaintext ``:8080``: **blocked** (loopback);
22* ``default`` and ``gco-jobs`` -> a stand-in model pod in ``gco-inference`` on
23 ``:8443``: **blocked** (``allow-inference-proxy-ingress`` admits only the
24 inference proxy; the stand-in carries the ``gco.io/type: inference`` label
25 those policies select);
26* ``gco-jobs`` -> ``https://checkip.amazonaws.com/``: **reachable**
27 (``allow-dns`` plus ``allow-https-egress``);
28* ``gco-jobs`` -> ``http://checkip.amazonaws.com/`` on port 80: **blocked**
29 (no rule admits it — the same host over 443 answered, so this is the policy
30 deciding, not the network); and
31* ``gco-jobs`` -> ``http://169.254.170.23/v1/credentials``: **reachable**
32 (``allow-pod-identity-agent`` — the node's EKS Pod Identity Agent, which is
33 where a ``gco-service-account`` pod gets its AWS credentials; the probe
34 carries no token, so the agent answers 4xx, and any HTTP answer counts).
35 Port 80 to the internet is blocked by the previous probe, so this one shows
36 the rule admitting exactly the link-local endpoint.
38The one allowed caller of the cost monitor cannot be impersonated by a probe
39pod without joining its Service, so that leg rides the service path instead:
40``GET /api/v1/cost/status`` through the Region's API must answer 200, which
41the manifest processor only does after reaching the cost monitor on 8443 over
42verified TLS. The model pods' one allowed caller, the inference proxy, is
43proved the same way by the ``inference`` action's served requests (their
44Services expose only 8443).
46Every probe is a digest-pinned BusyBox Job labelled with this run's token
47(``manifests/netpol-probe-job.yaml``); the listeners are the same image
48behind ``httpd`` (``manifests/netpol-target-job.yaml`` in ``gco-system`` and
49``gco-jobs``, ``manifests/netpol-model-target-job.yaml`` in ``gco-inference``).
50All of them are deleted before the action returns, and each carries
51``activeDeadlineSeconds`` plus a TTL so a harness that dies mid-probe still
52leaves nothing behind.
54A probe's verdict is its steady state, not its first dial. The VPC CNI
55attaches a new pod's policies in parallel with the pod's start and admits
56everything until they are in place (standard mode; Auto Mode's NodeClass
57``networkPolicy: DefaultDeny`` is the strict alternative, which requires a
58policy for every pod on the node), so a client that dials once at start can
59read that window instead of the policy — the fourth live run of this branch
60saw exactly that on the port-80 egress probe. The probe script therefore
61samples until one answer has held for 30 seconds and at least 45 seconds have
62passed, exits 43 if the answer is still changing after three minutes, and
63prints every change of answer; the harness keeps those lines as ``samples``,
64the closing line as ``settled``, and flags ``attach_window_observed`` when the
65first answer differed from the steady state.
67A disruption is not a verdict. Probe and listener pods ask Karpenter to leave
68their node alone (``karpenter.sh/do-not-disrupt``) and prefer on-demand
69capacity, but an interruption — a Spot reclaim, scheduled maintenance — can
70still evict one: a live run lost the HTTPS egress probe 46 seconds into its
71sampling and then waited out the whole pod timeout on a pod that no longer
72existed. A probe whose Job failed with no terminated pod left, or whose pod
73carries ``DisruptionTarget`` without a verdict exit code, is read at once and
74re-run once from a fresh Job; a second disruption is reported as one. A
75listener pod that did not last the matrix (any ``httpd`` target, or the
76inference-monitor or cost-monitor pod a probe dialed) voids every verdict
77dialed against it, so the action names it instead of reporting mismatches.
79When cdk.json turns the controller off (``eks_cluster.network_policy_enforcement:
80false``) the deny verdicts are not promised and those probes are recorded as
81skipped; the reachability probes and the two loopback probes (a bind address,
82not a policy) still have to pass. With cost monitoring off there is no cost
83monitor to dial and its probes are skipped with that reason.
84"""
86from __future__ import annotations
88import copy
89import json
90import time
91from dataclasses import asdict, dataclass
92from typing import Any
94from ..context import _job_transport_region
95from ..models import RunContext
96from .cluster import KubectlRunner, kubectl_json
97from .jobs import _load_manifest, _run_token
98from .opencost import _cost_monitoring_configured
99from .platform_workloads import network_policy_enforcement_enabled
101_TARGET_MANIFEST = "netpol-target-job.yaml"
102_MODEL_TARGET_MANIFEST = "netpol-model-target-job.yaml"
103_PROBE_MANIFEST = "netpol-probe-job.yaml"
104_TARGET_PORT = 8080
105#: The inference-monitor's metrics TLS sidecar; its app binds 9090 to loopback.
106_INFERENCE_MONITOR_METRICS_PORT = 9443
107_INFERENCE_MONITOR_LOOPBACK_PORT = 9090
108#: The cost-monitor's TLS sidecar; its app binds 8080 to loopback.
109_COST_MONITOR_PORT = 8443
110_COST_MONITOR_LOOPBACK_PORT = 8080
111#: The model pods' only Service port (their TLS sidecar).
112_MODEL_PORT = 8443
113_MODEL_NAMESPACE = "gco-inference"
114_COST_STATUS_PATH = "/api/v1/cost/status"
115_EGRESS_HOST = "checkip.amazonaws.com"
116#: The EKS Pod Identity Agent's link-local address on every node; the
117#: cluster points AWS_CONTAINER_CREDENTIALS_FULL_URI at this path.
118_POD_IDENTITY_AGENT_URL = "http://169.254.170.23/v1/credentials"
119#: A namespace the manifests leave unpoliced, so a client there proves the
120#: target namespace's ingress rules and nothing else.
121_UNPOLICED_NAMESPACE = "default"
122_REACHABLE_EXIT_CODE = 0
123_BLOCKED_EXIT_CODE = 42
124#: The answer kept changing for the probe's whole budget; never a verdict.
125_UNSETTLED_EXIT_CODE = 43
126#: Exit codes the probe script chose; anything else means it never finished.
127_SCRIPT_EXIT_CODES = frozenset({_REACHABLE_EXIT_CODE, _BLOCKED_EXIT_CODE, _UNSETTLED_EXIT_CODE})
128#: A probe a disruption voided is re-run from a fresh Job, once.
129_PROBE_ATTEMPTS = 2
130#: Auto Mode may have to launch a node for the first pod; a probe then still
131#: has the image pull and its own three-minute sampling budget ahead of it.
132_POD_TIMEOUT_SECONDS = 600
133#: One line per change of answer plus the closing lines; an answer that
134#: flapped on every sample for the whole budget prints more, and the tail
135#: keeps the end of that story.
136_LOG_TAIL_LINES = 40
137_LOG_LIMIT = 4_000
138_SAMPLE_PREFIX = "NETPOL_SAMPLE "
139_SETTLED_PREFIXES = ("NETPOL_SETTLED ", "NETPOL_UNSETTLED ")
140_COST_MONITORING_DISABLED = "cost monitoring is disabled in cdk.json; there is no cost monitor"
143class NetworkPostureValidationError(RuntimeError):
144 """The live cluster does not enforce the documented network posture."""
147@dataclass(frozen=True)
148class ProbeSpec:
149 """One connection attempt and the verdict the shipped policies promise."""
151 name: str
152 client_namespace: str
153 url: str
154 expected: str
155 rule: str
156 #: A deny verdict exists only while the policy controller is on.
157 enforcement_only: bool
158 #: Dials the cost monitor, which exists only with cost monitoring on.
159 requires_cost_monitor: bool = False
162def _probe_specs(
163 system_target_ip: str,
164 jobs_target_ip: str,
165 inference_monitor_ip: str,
166 *,
167 model_target_ip: str,
168 cost_monitor_ip: str | None,
169) -> tuple[ProbeSpec, ...]:
170 cost_host = cost_monitor_ip or "cost-monitor-not-deployed"
171 return (
172 ProbeSpec(
173 "same-namespace",
174 "gco-jobs",
175 f"http://{jobs_target_ip}:{_TARGET_PORT}/",
176 "reachable",
177 "gco-jobs/allow-same-namespace",
178 enforcement_only=False,
179 ),
180 ProbeSpec(
181 "cross-jobs",
182 _UNPOLICED_NAMESPACE,
183 f"http://{jobs_target_ip}:{_TARGET_PORT}/",
184 "blocked",
185 "gco-jobs/default-deny-ingress",
186 enforcement_only=True,
187 ),
188 ProbeSpec(
189 "cross-system",
190 "gco-jobs",
191 f"http://{system_target_ip}:{_TARGET_PORT}/",
192 "blocked",
193 "gco-system/default-deny-ingress",
194 enforcement_only=True,
195 ),
196 ProbeSpec(
197 "metrics-open",
198 _UNPOLICED_NAMESPACE,
199 f"https://{inference_monitor_ip}:{_INFERENCE_MONITOR_METRICS_PORT}/metrics",
200 "reachable",
201 "gco-system/allow-metrics-to-inference-monitor (metrics-tls-proxy, TCP 9443)",
202 enforcement_only=False,
203 ),
204 ProbeSpec(
205 "metrics-plaintext",
206 _UNPOLICED_NAMESPACE,
207 f"http://{inference_monitor_ip}:{_INFERENCE_MONITOR_LOOPBACK_PORT}/metrics",
208 "blocked",
209 "inference-monitor binds :9090 to 127.0.0.1 (METRICS_HOST)",
210 enforcement_only=False,
211 ),
212 ProbeSpec(
213 "cost-from-default",
214 _UNPOLICED_NAMESPACE,
215 f"https://{cost_host}:{_COST_MONITOR_PORT}/",
216 "blocked",
217 "gco-system/allow-manifest-processor-to-cost-monitor-ingress "
218 "(manifest-processor only, TCP 8443)",
219 enforcement_only=True,
220 requires_cost_monitor=True,
221 ),
222 ProbeSpec(
223 "cost-plaintext",
224 _UNPOLICED_NAMESPACE,
225 f"http://{cost_host}:{_COST_MONITOR_LOOPBACK_PORT}/",
226 "blocked",
227 "cost-monitor binds :8080 to 127.0.0.1 (HOST)",
228 enforcement_only=False,
229 requires_cost_monitor=True,
230 ),
231 ProbeSpec(
232 "model-from-default",
233 _UNPOLICED_NAMESPACE,
234 f"http://{model_target_ip}:{_MODEL_PORT}/",
235 "blocked",
236 "gco-inference/allow-inference-proxy-ingress (inference-proxy only, TCP 8443)",
237 enforcement_only=True,
238 ),
239 ProbeSpec(
240 "model-from-jobs",
241 "gco-jobs",
242 f"http://{model_target_ip}:{_MODEL_PORT}/",
243 "blocked",
244 "gco-inference/allow-inference-proxy-ingress (inference-proxy only, TCP 8443)",
245 enforcement_only=True,
246 ),
247 ProbeSpec(
248 "https-egress",
249 "gco-jobs",
250 f"https://{_EGRESS_HOST}/",
251 "reachable",
252 "gco-jobs/allow-dns + gco-jobs/allow-https-egress",
253 enforcement_only=False,
254 ),
255 ProbeSpec(
256 "http-egress",
257 "gco-jobs",
258 f"http://{_EGRESS_HOST}/",
259 "blocked",
260 "gco-jobs egress (no rule admits port 80)",
261 enforcement_only=True,
262 ),
263 ProbeSpec(
264 "pod-identity-agent",
265 "gco-jobs",
266 _POD_IDENTITY_AGENT_URL,
267 "reachable",
268 "gco-jobs/allow-pod-identity-agent",
269 enforcement_only=False,
270 ),
271 )
274#: The manifest processor's leg to the cost monitor, over the service path.
275_COST_VIA_MANIFEST_PROCESSOR = ProbeSpec(
276 "cost-via-manifest-processor",
277 "gco-system",
278 f"GET {_COST_STATUS_PATH} -> https://cost-monitor.gco-system.svc.cluster.local:8443",
279 "reachable",
280 "gco-system/allow-manifest-processor-to-cost-monitor-egress + -ingress (TCP 8443, "
281 "verified TLS)",
282 enforcement_only=False,
283 requires_cost_monitor=True,
284)
287def _dict(value: Any) -> dict[str, Any]:
288 return value if isinstance(value, dict) else {}
291def _list(value: Any) -> list[Any]:
292 return value if isinstance(value, list) else []
295def _substitute(value: Any, replacements: dict[str, str]) -> Any:
296 """Replace manifest placeholders (``__PROBE_URL__``) wherever they appear."""
297 if isinstance(value, str):
298 for token, replacement in replacements.items():
299 value = value.replace(token, replacement)
300 return value
301 if isinstance(value, list):
302 return [_substitute(item, replacements) for item in value]
303 if isinstance(value, dict):
304 return {key: _substitute(item, replacements) for key, item in value.items()}
305 return value
308def _mismatch(item: dict[str, Any]) -> str:
309 """One report line for a probe whose observed answer broke its promise."""
310 if item.get("via") == "api":
311 detail = f"[HTTP {item['status_code']}]"
312 else:
313 detail = f"[phase={item['phase']} exit={item['exit_code']}]"
314 line = (
315 f"{item['name']} ({item['client_namespace']} -> {item['url']}) expected "
316 f"{item['expected']}, observed {item['observed']} {detail}"
317 )
318 disruptions = item.get("disruptions") or []
319 if disruptions:
320 line += f" after {len(disruptions)} disruption(s), last: {disruptions[-1]['reason']}"
321 return line
324class NetworkPostureProbe:
325 """Run the probe matrix on one Region's cluster and clean up after it."""
327 def __init__(self, ctx: RunContext, region: str, kubectl: KubectlRunner) -> None:
328 self.ctx = ctx
329 self.region = region
330 self.kubectl = kubectl
331 self.token = _run_token(ctx.settings.run_id)
332 self.timeout = float(ctx.settings.command_timeout_seconds)
333 with ctx.state_lock:
334 self.record: dict[str, Any] = ctx.checkpoint.state.setdefault(
335 "network_posture", {}
336 ).setdefault(region, {})
337 self.record.setdefault("jobs", [])
339 # -- checkpointing -----------------------------------------------------
341 def _persist(self) -> None:
342 self.ctx.persist()
344 def _fail(self, message: str) -> NetworkPostureValidationError:
345 self.record["failure"] = message
346 self._persist()
347 return NetworkPostureValidationError(f"network posture in {self.region}: {message}")
349 # -- Job lifecycle -----------------------------------------------------
351 def _job_manifest(
352 self,
353 filename: str,
354 *,
355 name: str,
356 namespace: str,
357 replacements: dict[str, str] | None = None,
358 ) -> dict[str, Any]:
359 """Load one checked-in Job (run token applied) and give it this probe's identity."""
360 manifests, _name, _namespace = _load_manifest(self.ctx, filename)
361 job = copy.deepcopy(next(item for item in manifests if item.get("kind") == "Job"))
362 if replacements:
363 job = _substitute(job, replacements)
364 job["metadata"]["name"] = name
365 job["metadata"]["namespace"] = namespace
366 return job
368 def _run(self, *arguments: str, **kwargs: Any) -> tuple[int, str, str]:
369 code, stdout, stderr = self.kubectl(*arguments, timeout=self.timeout, **kwargs)
370 return code, stdout, stderr
372 def _create_job(self, job: dict[str, Any]) -> None:
373 namespace = job["metadata"]["namespace"]
374 name = job["metadata"]["name"]
375 # Record before creating: a Job that exists without a record could not
376 # be cleaned up, so the record is what authorizes the delete. A re-run
377 # of a disrupted probe reuses its Job's record.
378 entry = {"namespace": namespace, "name": name, "deleted": False}
379 if entry not in self.record["jobs"]:
380 self.record["jobs"].append(entry)
381 self._persist()
382 # Start from a clean slate so a retry of this run never reads a stale
383 # verdict; foreground cascading waits for the old pod to be gone.
384 code, _stdout, stderr = self._run(
385 "delete",
386 "job",
387 name,
388 "--namespace",
389 namespace,
390 "--ignore-not-found",
391 "--cascade=foreground",
392 "--wait=true",
393 "--timeout=120s",
394 )
395 if code != 0:
396 self.record["last_kubectl_error"] = {
397 "argv": ["delete", "job", name],
398 "returncode": code,
399 "stderr": stderr[-_LOG_LIMIT:],
400 }
401 raise self._fail(f"could not clear a previous {namespace}/{name}")
402 code, _stdout, stderr = self._run(
403 "apply", "--namespace", namespace, "--filename", "-", input=json.dumps(job)
404 )
405 if code != 0:
406 self.record["last_kubectl_error"] = {
407 "argv": ["apply", namespace, name],
408 "returncode": code,
409 "stderr": stderr[-_LOG_LIMIT:],
410 }
411 raise self._fail(f"could not create {namespace}/{name}")
413 def _live_pods(self, namespace: str, selector: str) -> list[dict[str, Any]]:
414 """The selector's pods that are not being deleted."""
415 payload = kubectl_json(
416 self.kubectl,
417 self.record,
418 "get",
419 "pods",
420 "--namespace",
421 namespace,
422 "--selector",
423 selector,
424 timeout=self.timeout,
425 )
426 return [
427 _dict(item)
428 for item in _list(_dict(payload).get("items"))
429 if not _dict(_dict(item).get("metadata")).get("deletionTimestamp")
430 ]
432 def _job_pod(self, namespace: str, name: str) -> dict[str, Any] | None:
433 pods = self._live_pods(namespace, f"job-name={name}")
434 return pods[0] if pods else None
436 def _job_failure(self, namespace: str, name: str) -> dict[str, str] | None:
437 """The Job's ``Failed`` condition as ``{reason, message}``, or ``None``."""
438 payload = kubectl_json(
439 self.kubectl,
440 self.record,
441 "get",
442 "job",
443 name,
444 "--namespace",
445 namespace,
446 timeout=self.timeout,
447 )
448 for entry in _list(_dict(_dict(payload).get("status")).get("conditions")):
449 condition = _dict(entry)
450 if condition.get("type") == "Failed" and condition.get("status") == "True":
451 return {
452 "reason": str(condition.get("reason") or "Failed"),
453 "message": str(condition.get("message") or "")[:_LOG_LIMIT],
454 }
455 return None
457 @staticmethod
458 def _disruption_condition(pod: dict[str, Any]) -> dict[str, str] | None:
459 """The pod's ``DisruptionTarget`` condition as ``{reason, message}``, or ``None``."""
460 for entry in _list(_dict(pod.get("status")).get("conditions")):
461 condition = _dict(entry)
462 if condition.get("type") == "DisruptionTarget" and condition.get("status") == "True":
463 return {
464 "reason": str(condition.get("reason") or "DisruptionTarget"),
465 "message": str(condition.get("message") or "")[:_LOG_LIMIT],
466 }
467 return None
469 def _wait(self, deadline: float, what: str) -> None:
470 if time.monotonic() >= deadline:
471 raise self._fail(f"{what} did not happen within {_POD_TIMEOUT_SECONDS}s")
472 time.sleep(self.ctx.settings.poll_interval_seconds)
474 def _wait_for_listener(self, namespace: str, name: str) -> tuple[str, str]:
475 """Return ``(pod name, pod IP)`` once the listener pod is Running and Ready."""
476 deadline = time.monotonic() + _POD_TIMEOUT_SECONDS
477 while True:
478 pod = self._job_pod(namespace, name)
479 status = _dict(pod.get("status")) if pod else {}
480 containers = [_dict(entry) for entry in _list(status.get("containerStatuses"))]
481 if status.get("phase") == "Failed":
482 raise self._fail(f"listener {namespace}/{name} failed before serving")
483 if (
484 pod is not None
485 and status.get("phase") == "Running"
486 and containers
487 and all(entry.get("ready") is True for entry in containers)
488 and status.get("podIP")
489 ):
490 return str(_dict(pod.get("metadata")).get("name")), str(status["podIP"])
491 self._wait(deadline, f"listener {namespace}/{name} readiness")
493 def _wait_for_verdict(self, namespace: str, name: str) -> dict[str, Any]:
494 """Return the probe pod's terminal phase, exit code, log tail, and disruption.
496 ``disruption`` is ``None`` for a verdict. It holds the reason when a
497 disruption took the verdict away: the Job failed with no terminated pod
498 left to read (the pod was evicted and deleted), or the pod stopped with
499 an exit code the script never chose while carrying ``DisruptionTarget``.
500 Both return at once instead of waiting out the deadline.
501 """
502 deadline = time.monotonic() + _POD_TIMEOUT_SECONDS
503 while True:
504 pod = self._job_pod(namespace, name)
505 status = _dict(pod.get("status")) if pod else {}
506 phase = status.get("phase")
507 if pod is not None and phase in ("Succeeded", "Failed"):
508 containers = [_dict(entry) for entry in _list(status.get("containerStatuses"))]
509 terminated = (
510 _dict(_dict(containers[0].get("state")).get("terminated")) if containers else {}
511 )
512 exit_code = terminated.get("exitCode")
513 pod_name = str(_dict(pod.get("metadata")).get("name"))
514 _code, stdout, stderr = self._run(
515 "logs",
516 pod_name,
517 "--namespace",
518 namespace,
519 f"--tail={_LOG_TAIL_LINES}",
520 )
521 disruption = (
522 None if exit_code in _SCRIPT_EXIT_CODES else self._disruption_condition(pod)
523 )
524 return {
525 "pod": pod_name,
526 "phase": phase,
527 "exit_code": exit_code,
528 "output": (stdout or stderr)[-_LOG_LIMIT:],
529 "disruption": disruption,
530 }
531 if pod is None and (failure := self._job_failure(namespace, name)) is not None:
532 return {
533 "pod": None,
534 "phase": "Failed",
535 "exit_code": None,
536 "output": "",
537 "disruption": failure,
538 }
539 self._wait(deadline, f"probe {namespace}/{name} completion")
541 def _probe_verdict(
542 self, spec: ProbeSpec, job: dict[str, Any]
543 ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
544 """Wait for one probe, re-running it from a fresh Job if a disruption voided it."""
545 namespace = job["metadata"]["namespace"]
546 name = job["metadata"]["name"]
547 disruptions: list[dict[str, Any]] = []
548 for attempt in range(1, _PROBE_ATTEMPTS + 1):
549 verdict = self._wait_for_verdict(namespace, name)
550 disruption = verdict.pop("disruption")
551 if disruption is None:
552 break
553 disruptions.append({"attempt": attempt, "pod": verdict["pod"], **disruption})
554 self.record.setdefault("disruptions", []).append(
555 {"probe": spec.name, "attempt": attempt, **disruption}
556 )
557 self._persist()
558 if attempt < _PROBE_ATTEMPTS:
559 self._create_job(job)
560 return verdict, disruptions
562 def _disrupted_listeners(
563 self,
564 targets: dict[str, dict[str, str]],
565 platform_pods: list[tuple[str, str]],
566 ) -> list[str]:
567 """Listeners whose pod did not last the matrix; verdicts dialed at them are void.
569 The inference-monitor and cost-monitor pods the probes dialed are among
570 them: a replaced pod answers on a different IP, so a probe that dialed
571 the old one would read ``blocked`` for a reason no policy chose.
572 """
573 dialed = [
574 (namespace, f"job-name={target['job']}", target["pod"], f"{namespace}/{target['job']}")
575 for namespace, target in targets.items()
576 ]
577 dialed.extend(
578 ("gco-system", f"app={app}", pod_name, f"gco-system/{pod_name}")
579 for app, pod_name in platform_pods
580 )
581 broken: list[str] = []
582 for namespace, selector, pod_name, label in dialed:
583 pod = next(
584 (
585 item
586 for item in self._live_pods(namespace, selector)
587 if _dict(item.get("metadata")).get("name") == pod_name
588 ),
589 None,
590 )
591 if (
592 pod is None
593 or _dict(pod.get("status")).get("phase") != "Running"
594 or self._disruption_condition(pod) is not None
595 ):
596 broken.append(label)
597 return broken
599 def _delete_jobs(self) -> list[dict[str, Any]]:
600 problems: list[dict[str, Any]] = []
601 for entry in self.record["jobs"]:
602 if entry.get("deleted"):
603 continue
604 code, _stdout, stderr = self._run(
605 "delete",
606 "job",
607 entry["name"],
608 "--namespace",
609 entry["namespace"],
610 "--ignore-not-found",
611 "--wait=false",
612 )
613 if code == 0:
614 entry["deleted"] = True
615 else:
616 problems.append({**entry, "returncode": code, "stderr": stderr[-_LOG_LIMIT:]})
617 self._persist()
618 return problems
620 # -- the matrix ---------------------------------------------------------
622 def _ready_platform_pod(self, app: str) -> tuple[str, str]:
623 """Return ``(pod name, pod IP)`` of one Running, Ready ``gco-system`` pod of ``app``."""
624 for item in self._live_pods("gco-system", f"app={app}"):
625 metadata = _dict(item.get("metadata"))
626 status = _dict(item.get("status"))
627 containers = [_dict(entry) for entry in _list(status.get("containerStatuses"))]
628 if (
629 status.get("phase") == "Running"
630 and containers
631 and all(entry.get("ready") is True for entry in containers)
632 and status.get("podIP")
633 ):
634 return str(metadata.get("name")), str(status["podIP"])
635 raise self._fail(f"no ready {app} pod to probe")
637 def _cost_via_manifest_processor(self) -> dict[str, Any]:
638 """Prove the manifest processor reaches the cost monitor through the API."""
639 spec = asdict(_COST_VIA_MANIFEST_PROCESSOR)
640 try:
641 transport = _job_transport_region(self.ctx, self.region)
642 except RuntimeError as exc:
643 return {**spec, "status": "skipped", "reason": str(exc)}
644 response = self.ctx.aws_client.make_authenticated_request(
645 method="GET",
646 path=_COST_STATUS_PATH,
647 target_region=transport,
648 )
649 observed = "reachable" if response.status_code == 200 else "error"
650 return {
651 **spec,
652 "via": "api",
653 "status_code": response.status_code,
654 "observed": observed,
655 "status": "matched" if observed == spec["expected"] else "mismatch",
656 }
658 @staticmethod
659 def _observed(verdict: dict[str, Any]) -> str:
660 if verdict["phase"] == "Succeeded" and verdict["exit_code"] == _REACHABLE_EXIT_CODE:
661 return "reachable"
662 if verdict["phase"] == "Failed" and verdict["exit_code"] == _BLOCKED_EXIT_CODE:
663 return "blocked"
664 if verdict["phase"] == "Failed" and verdict["exit_code"] == _UNSETTLED_EXIT_CODE:
665 return "unsettled"
666 return "error"
668 @staticmethod
669 def _trace(output: str) -> dict[str, Any]:
670 """Read the probe script's sampling trace out of its log tail.
672 ``samples`` holds every change of answer (``t=0s reachable``,
673 ``t=3s blocked``), ``settled`` the closing line, and
674 ``attach_window_observed`` is true when the first answer differed from
675 the steady state — on the VPC CNI, the standard-mode window between the
676 pod's start and its policies being attached.
677 """
678 samples: list[str] = []
679 settled: str | None = None
680 for line in output.splitlines():
681 if line.startswith(_SAMPLE_PREFIX):
682 samples.append(line.removeprefix(_SAMPLE_PREFIX))
683 elif line.startswith(_SETTLED_PREFIXES):
684 settled = line
685 return {
686 "samples": samples,
687 "settled": settled,
688 "attach_window_observed": len(samples) > 1,
689 }
691 def _start_listeners(self) -> dict[str, dict[str, str]]:
692 """Start every ``httpd`` target and return ``{namespace: {job, pod, ip}}``."""
693 listeners = (
694 ("gco-system", _TARGET_MANIFEST, f"gco-live-netpol-target-{self.token}"),
695 ("gco-jobs", _TARGET_MANIFEST, f"gco-live-netpol-target-{self.token}"),
696 (_MODEL_NAMESPACE, _MODEL_TARGET_MANIFEST, f"gco-live-netpol-model-{self.token}"),
697 )
698 targets: dict[str, dict[str, str]] = {}
699 for namespace, manifest, name in listeners:
700 self._create_job(self._job_manifest(manifest, name=name, namespace=namespace))
701 pod_name, pod_ip = self._wait_for_listener(namespace, name)
702 targets[namespace] = {"job": name, "pod": pod_name, "ip": pod_ip}
703 return targets
705 def run(self) -> dict[str, Any]:
706 """Start the listeners, run every probe, delete everything, judge the matrix."""
707 enforcement = network_policy_enforcement_enabled(self.ctx)
708 cost_monitoring = _cost_monitoring_configured(self.ctx)
709 self.record["enforcement_configured"] = enforcement
710 results: list[dict[str, Any]] = []
711 evidence: dict[str, Any] = {
712 "enforcement_configured": enforcement,
713 "cost_monitoring_configured": cost_monitoring,
714 "probes": results,
715 }
716 try:
717 targets = self._start_listeners()
718 evidence["targets"] = targets
719 self.record["targets"] = targets
720 monitor_pod, monitor_ip = self._ready_platform_pod("inference-monitor")
721 evidence["inference_monitor"] = {"pod": monitor_pod, "ip": monitor_ip}
722 platform_pods = [("inference-monitor", monitor_pod)]
723 cost_monitor_ip: str | None = None
724 if cost_monitoring:
725 cost_pod, cost_monitor_ip = self._ready_platform_pod("cost-monitor")
726 evidence["cost_monitor"] = {"pod": cost_pod, "ip": cost_monitor_ip}
727 platform_pods.append(("cost-monitor", cost_pod))
728 self._persist()
730 specs = _probe_specs(
731 targets["gco-system"]["ip"],
732 targets["gco-jobs"]["ip"],
733 monitor_ip,
734 model_target_ip=targets[_MODEL_NAMESPACE]["ip"],
735 cost_monitor_ip=cost_monitor_ip,
736 )
737 launched: list[tuple[ProbeSpec, dict[str, Any]]] = []
738 for spec in specs:
739 if spec.requires_cost_monitor and not cost_monitoring:
740 results.append(
741 {**asdict(spec), "status": "skipped", "reason": _COST_MONITORING_DISABLED}
742 )
743 continue
744 if spec.enforcement_only and not enforcement:
745 results.append(
746 {
747 **asdict(spec),
748 "status": "skipped",
749 "reason": (
750 "eks_cluster.network_policy_enforcement is false in cdk.json; "
751 "no deny verdict is promised"
752 ),
753 }
754 )
755 continue
756 job = self._job_manifest(
757 _PROBE_MANIFEST,
758 name=f"gco-live-netpol-{spec.name}-{self.token}",
759 namespace=spec.client_namespace,
760 replacements={"__PROBE_URL__": spec.url},
761 )
762 self._create_job(job)
763 launched.append((spec, job))
764 for spec, job in launched:
765 verdict, disruptions = self._probe_verdict(spec, job)
766 observed = self._observed(verdict)
767 results.append(
768 {
769 **asdict(spec),
770 **verdict,
771 **self._trace(verdict["output"]),
772 "job": job["metadata"]["name"],
773 "disruptions": disruptions,
774 "observed": observed,
775 "status": "matched" if observed == spec.expected else "mismatch",
776 }
777 )
778 self.record["probes"] = results
779 self._persist()
780 if cost_monitoring:
781 results.append(self._cost_via_manifest_processor())
782 else:
783 results.append(
784 {
785 **asdict(_COST_VIA_MANIFEST_PROCESSOR),
786 "status": "skipped",
787 "reason": _COST_MONITORING_DISABLED,
788 }
789 )
790 self.record["probes"] = results
791 self._persist()
792 if disrupted := self._disrupted_listeners(targets, platform_pods):
793 self.record["disrupted_listeners"] = disrupted
794 raise self._fail(
795 f"listener(s) {', '.join(disrupted)} did not last the probe matrix "
796 "(evicted or replaced), so the verdicts dialed against them are void"
797 )
798 finally:
799 try:
800 evidence["cleanup_problems"] = self._delete_jobs()
801 except Exception as exc: # a cleanup error must never mask the verdict
802 evidence["cleanup_problems"] = [{"error": f"{type(exc).__name__}: {exc}"}]
804 mismatches = [_mismatch(item) for item in results if item["status"] == "mismatch"]
805 if mismatches:
806 raise self._fail("; ".join(mismatches))
807 if evidence["cleanup_problems"]:
808 raise self._fail(
809 f"{len(evidence['cleanup_problems'])} probe Job(s) could not be deleted"
810 )
811 self.record["evidence"] = evidence
812 self._persist()
813 return evidence