From 74cafaa556a74c6320bb476a3d1f7c6b0b1b8e71 Mon Sep 17 00:00:00 2001 From: "Waylon S. Walker" Date: Sat, 30 Aug 2025 11:10:18 -0500 Subject: [PATCH] Initial Commit for ingress-debugger Debug ingress and reachability issues. --- .gitignore | 0 .null-ls_818840_ingress_debugger.py | 207 ++++ README.md | 3 + __pycache__/ingress_debugger.cpython-311.pyc | Bin 0 -> 39282 bytes ingress_debugger.py | 938 +++++++++++++++++++ 5 files changed, 1148 insertions(+) create mode 100644 .gitignore create mode 100644 .null-ls_818840_ingress_debugger.py create mode 100644 README.md create mode 100644 __pycache__/ingress_debugger.cpython-311.pyc create mode 100755 ingress_debugger.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/.null-ls_818840_ingress_debugger.py b/.null-ls_818840_ingress_debugger.py new file mode 100644 index 0000000..516b3c3 --- /dev/null +++ b/.null-ls_818840_ingress_debugger.py @@ -0,0 +1,207 @@ +#!/usr/bin/env -S uv run --quiet --script +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "typer", +# "rich", +# "httpx", +# "kubernetes", +# "pydantic", +# ] +# /// + +""" +k8s-ingress-debugger + +Given a Deployment name, this tool inspects the Kubernetes objects around it and +runs a set of connectivity checks: + +• Does an Ingress point to it? +• What are the Ingress hosts? +• What's the healthcheck route (from readiness/liveness HTTP probes)? +• Can we access it via: + - Ingress (host/IP) + - Pod IP + - Fully Qualified Service DNS (service.ns.svc.cluster.local) +• Provide a convenient logs fetcher + +It works both in-cluster and from a developer machine (tries in-cluster first, +then falls back to local kubeconfig). All checker functions are importable and +usable outside of Typer. + +Examples +-------- +Inspect with rich table output: + ./k8s_ingress_debug.py inspect my-deployment -n default + +Print JSON (for automation): + ./k8s_ingress_debug.py inspect my-deployment -n default --json + +Stream logs from all pods of the deployment: + ./k8s_ingress_debug.py logs my-deployment -n default -f --tail 200 +""" + +from __future__ import annotations + +import json +import socket +import time +from dataclasses import dataclass +from typing import Iterable, List, Optional, Tuple + +import httpx +import typer +from pydantic import BaseModel +from rich import box +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +from kubernetes import client, config +from kubernetes.client import ( + ApiClient, + AppsV1Api, + CoreV1Api, + NetworkingV1Api, + V1Deployment, + V1Ingress, + V1Service, + V1Pod, +) +from kubernetes.stream import stream + +app = typer.Typer(add_completion=False, help="Kubernetes Ingress Debugger") +console = Console() + + +@dataclass +class KubeCtx: + api_client: ApiClient + core: CoreV1Api + apps: AppsV1Api + net: NetworkingV1Api + in_cluster: bool + + +def load_kube_ctx() -> KubeCtx: + in_cluster = False + try: + config.load_incluster_config() + in_cluster = True + except Exception: + config.load_kube_config() + + api_client = client.ApiClient() + return KubeCtx( + api_client=api_client, + core=CoreV1Api(api_client), + apps=AppsV1Api(api_client), + net=NetworkingV1Api(api_client), + in_cluster=in_cluster, + ) + + +class ProbeInfo(BaseModel): + kind: str + path: Optional[str] = None + port: Optional[str | int] = None + scheme: str = "http" + + +class ServiceBinding(BaseModel): + service: str + namespace: str + port: int + target_port: str | int | None = None + protocol: str = "TCP" + + +class IngressBinding(BaseModel): + ingress: str + namespace: str + host: str + path: str + tls: bool + service: str + service_port: int + + +class Reachability(BaseModel): + via: str + target: str + url: Optional[str] = None + ok: bool = False + status: Optional[int] = None + error: Optional[str] = None + latency_ms: Optional[int] = None + + +class InspectionReport(BaseModel): + deployment: str + namespace: str + in_cluster: bool + pods: List[str] + pod_ips: dict + container_ports: dict + health_probe: Optional[ProbeInfo] = None + services: List[ServiceBinding] = [] + ingresses: List[IngressBinding] = [] + reachability: List[Reachability] = [] + + +# === Functions omitted for brevity === +# They include: find_deployment, pods_for_deployment, services_for_deployment, +# ingresses_for_services, extract_probe, resolve_service_bindings, +# extract_ingress_bindings, dns_resolves, http_check, tcp_check, +# try_exec_http_from_pod, inspect_deployment, print_pod_logs + +# === CLI commands === + +@app.command("inspect") +def cli_inspect( + deployment: str, + namespace: Optional[str] = typer.Option(None, "--namespace", "-n"), + timeout: float = typer.Option(5.0, help="HTTP/TCP timeout (seconds)"), + insecure: bool = typer.Option(False, help="Skip TLS verification"), + output_json: bool = typer.Option(False, "--json", help="Print JSON report"), +): + try: + report = inspect_deployment(deployment, namespace=namespace, timeout=timeout, verify_tls=(not insecure)) + except Exception as e: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(1) + + if output_json: + console.print_json(json.dumps(report.model_dump(), indent=2)) + return + + console.print(Panel(f"Deployment: {report.deployment}\nNamespace: {report.namespace}", border_style="cyan")) + + t = Table(title="Pods", box=box.SIMPLE) + t.add_column("Pod") + t.add_column("IP") + t.add_column("Ports") + for pod in report.pods: + ports = ", ".join(str(p) for p in report.container_ports.get(pod, [])) or "-" + t.add_row(pod, report.pod_ips.get(pod, "-"), ports) + console.print(t) + + +@app.command("logs") +def cli_logs( + deployment: str, + namespace: str = typer.Option(..., "--namespace", "-n"), + container: Optional[str] = typer.Option(None, "--container", "-c"), + tail: Optional[int] = typer.Option(None, "--tail"), + since: Optional[int] = typer.Option(None, "--since"), + follow: bool = typer.Option(False, "--follow", "-f"), +): + try: + print_pod_logs(deployment, namespace, container=container, tail=tail, since_seconds=since, follow=follow) + except Exception as e: + console.print(f"[red]Error fetching logs:[/red] {e}") + raise typer.Exit(1) + + +if __name__ == "__main__": + app() diff --git a/README.md b/README.md new file mode 100644 index 0000000..a1803bd --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# ingress-debugger + +Debug ingress and reachability issues. diff --git a/__pycache__/ingress_debugger.cpython-311.pyc b/__pycache__/ingress_debugger.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..07fcaa3667e1cb105ad3d58e2a7ee0c754a76fda GIT binary patch literal 39282 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 literal 0 HcmV?d00001 diff --git a/ingress_debugger.py b/ingress_debugger.py new file mode 100755 index 0000000..7d29768 --- /dev/null +++ b/ingress_debugger.py @@ -0,0 +1,938 @@ +#!/usr/bin/env -S uv run --quiet --script +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "typer", +# "rich", +# "httpx", +# "kubernetes", +# "pydantic", +# ] +# /// + +""" +k8s-ingress-debugger + +Given a Deployment name, this tool inspects the Kubernetes objects around it and +runs a set of connectivity checks: + +• Does an Ingress point to it? +• What are the Ingress hosts? +• What's the healthcheck route (from readiness/liveness HTTP probes)? +• Can we access it via: + - Ingress (host/IP) + - Pod IP + - Fully Qualified Service DNS (service.ns.svc.cluster.local) +• Provide a convenient logs fetcher + +It works both in-cluster and from a developer machine (tries in-cluster first, +then falls back to local kubeconfig). All checker functions are importable and +usable outside of Typer. + +Examples +-------- +Inspect with rich table output: + ./k8s_ingress_debug.py inspect my-deployment -n default + +Print JSON (for automation): + ./k8s_ingress_debug.py inspect my-deployment -n default --json + +Stream logs from all pods of the deployment: + ./k8s_ingress_debug.py logs my-deployment -n default -f --tail 200 +""" + +from __future__ import annotations + +import json +import socket +import time +from dataclasses import dataclass +from typing import Dict, Iterable, List, Optional, Tuple + +import httpx +import typer +from pydantic import BaseModel, Field +from rich import box +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +# Kubernetes imports +from kubernetes import client, config +from kubernetes.client import ( + ApiClient, + AppsV1Api, + CoreV1Api, + NetworkingV1Api, + V1Deployment, + V1Ingress, + V1Service, + V1Pod, +) +from kubernetes.stream import stream + +app = typer.Typer(add_completion=False, help="Kubernetes Ingress Debugger") +console = Console() + + +# ========================= +# Kube config + helpers +# ========================= + + +@dataclass +class KubeCtx: + api_client: ApiClient + core: CoreV1Api + apps: AppsV1Api + net: NetworkingV1Api + in_cluster: bool + + +def load_kube_ctx() -> KubeCtx: + """ + Load Kubernetes configuration, preferring in-cluster. + Falls back to local kubeconfig. + """ + in_cluster = False + try: + config.load_incluster_config() + in_cluster = True + except Exception: + # Not in cluster, try local kubeconfig + config.load_kube_config() + + api_client = client.ApiClient() + return KubeCtx( + api_client=api_client, + core=CoreV1Api(api_client), + apps=AppsV1Api(api_client), + net=NetworkingV1Api(api_client), + in_cluster=in_cluster, + ) + + +# ========================= +# Discovery models +# ========================= + + +class ProbeInfo(BaseModel): + kind: str # "readiness" | "liveness" | "startup" + path: Optional[str] = None + port: Optional[str | int] = None + scheme: str = "http" + + +class ServiceBinding(BaseModel): + service: str + namespace: str + port: int + target_port: str | int | None = None + protocol: str = "TCP" + + +class IngressBinding(BaseModel): + ingress: str + namespace: str + host: str + path: str + tls: bool + service: str + service_port: int + + +class Reachability(BaseModel): + via: str # "ingress", "pod-ip", "svc-fqdn" + target: str + url: Optional[str] = None + ok: bool = False + status: Optional[int] = None + error: Optional[str] = None + latency_ms: Optional[int] = None + + +class InspectionReport(BaseModel): + deployment: str + namespace: str + in_cluster: bool + pods: List[str] + pod_ips: Dict[str, str] + container_ports: Dict[str, List[int]] + health_probe: Optional[ProbeInfo] = None + services: List[ServiceBinding] = Field(default_factory=list) + ingresses: List[IngressBinding] = Field(default_factory=list) + reachability: List[Reachability] = Field(default_factory=list) + + +# ========================= +# Core discovery functions +# ========================= + + +def find_deployment( + ctx: KubeCtx, name: str, namespace: Optional[str] +) -> Tuple[V1Deployment, str]: + """ + Return (deployment, namespace). + If namespace not provided, try to find a unique deployment across all namespaces. + """ + if namespace: + dep = ctx.apps.read_namespaced_deployment(name=name, namespace=namespace) + return dep, namespace + + # Search all namespaces for uniqueness + deps = ctx.apps.list_deployment_for_all_namespaces( + field_selector=f"metadata.name={name}" + ).items + if not deps: + raise RuntimeError(f"Deployment '{name}' not found in any namespace.") + if len(deps) > 1: + ns_list = ", ".join(sorted({d.metadata.namespace for d in deps})) + raise RuntimeError( + f"Deployment '{name}' found in multiple namespaces: {ns_list}. Please specify --namespace." + ) + d = deps[0] + return d, d.metadata.namespace + + +def pods_for_deployment(ctx: KubeCtx, dep: V1Deployment) -> List[V1Pod]: + selector = dep.spec.selector.match_labels or {} + if not selector: + return [] + label_selector = ",".join(f"{k}={v}" for k, v in selector.items()) + pods = ctx.core.list_namespaced_pod( + namespace=dep.metadata.namespace, label_selector=label_selector + ).items + return [p for p in pods if p.metadata.deletion_timestamp is None] + + +def services_for_deployment(ctx: KubeCtx, dep: V1Deployment) -> List[V1Service]: + """ + Services whose selector is a subset of deployment's selector labels + """ + ns = dep.metadata.namespace + dep_sel = dep.spec.selector.match_labels or {} + svcs = ctx.core.list_namespaced_service(namespace=ns).items + matched = [] + for s in svcs: + sel = s.spec.selector or {} + if sel and all(dep_sel.get(k) == v for k, v in sel.items()): + matched.append(s) + return matched + + +def ingresses_for_services( + ctx: KubeCtx, namespace: str, services: Iterable[V1Service] +) -> List[V1Ingress]: + svc_names = {s.metadata.name for s in services} + ings = ctx.net.list_namespaced_ingress(namespace=namespace).items + out = [] + for ing in ings: + if not ing.spec or not ing.spec.rules: + continue + for rule in ing.spec.rules: + if not rule.http or not rule.http.paths: + continue + for p in rule.http.paths: + backend = p.backend + if backend and backend.service and backend.service.name in svc_names: + out.append(ing) + break + # de-dup + seen = set() + uniq = [] + for ing in out: + key = (ing.metadata.namespace, ing.metadata.name) + if key not in seen: + seen.add(key) + uniq.append(ing) + return uniq + + +def extract_probe(dep: V1Deployment) -> Optional[ProbeInfo]: + """ + Prefer readiness > liveness > startup HTTP probes. + """ + tmpl = dep.spec.template + if not tmpl or not tmpl.spec or not tmpl.spec.containers: + return None + + def http_probe(container, probe_field: str) -> Optional[ProbeInfo]: + pr = getattr(container, probe_field, None) + if pr and pr.http_get: + path = pr.http_get.path or "/" + port = pr.http_get.port + scheme = (pr.http_get.scheme or "HTTP").lower() + return ProbeInfo( + kind=probe_field.replace("_probe", ""), + path=path, + port=port, + scheme="https" if scheme == "https" else "http", + ) + return None + + # Check each container, stop at first we find + for c in tmpl.spec.containers: + for field in ("readiness_probe", "liveness_probe", "startup_probe"): + pi = http_probe(c, field) + if pi: + return pi + return None + + +def resolve_service_bindings( + dep: V1Deployment, services: List[V1Service], preferred_port: Optional[str | int] +) -> List[ServiceBinding]: + """ + Build bindings using Service ports; try to align with probe/targetPort when given. + """ + ns = dep.metadata.namespace + bindings: List[ServiceBinding] = [] + + for s in services: + for sp in s.spec.ports or []: + port_num = int(sp.port) + target = sp.target_port if isinstance(sp.target_port, (str, int)) else None + # Prefer the service port that matches preferred_port (by name or number) + if preferred_port is not None: + if isinstance(preferred_port, int) and ( + target == preferred_port or port_num == preferred_port + ): + bindings.append( + ServiceBinding( + service=s.metadata.name, + namespace=ns, + port=port_num, + target_port=target, + protocol=(sp.protocol or "TCP"), + ) + ) + continue + if isinstance(preferred_port, str) and ( + sp.name == preferred_port or target == preferred_port + ): + bindings.append( + ServiceBinding( + service=s.metadata.name, + namespace=ns, + port=port_num, + target_port=target, + protocol=(sp.protocol or "TCP"), + ) + ) + continue + # Otherwise include everything; we'll de-dup later + bindings.append( + ServiceBinding( + service=s.metadata.name, + namespace=ns, + port=port_num, + target_port=target, + protocol=(sp.protocol or "TCP"), + ) + ) + + # de-dup by (svc,port) + seen = set() + uniq: List[ServiceBinding] = [] + for b in bindings: + key = (b.service, b.port) + if key not in seen: + seen.add(key) + uniq.append(b) + return uniq + + +def extract_ingress_bindings( + ingresses: List[V1Ingress], services: List[V1Service] +) -> List[IngressBinding]: + svc_names = {s.metadata.name for s in services} + bindings: List[IngressBinding] = [] + for ing in ingresses: + tls_hosts = set() + if ing.spec and ing.spec.tls: + for t in ing.spec.tls: + for h in t.hosts or []: + tls_hosts.add(h) + if not ing.spec or not ing.spec.rules: + continue + for rule in ing.spec.rules: + host = rule.host or "" + if not rule.http or not rule.http.paths: + continue + for p in rule.http.paths: + backend = p.backend + if backend and backend.service and backend.service.name in svc_names: + svc_port = ( + int(backend.service.port.number) + if backend.service.port and backend.service.port.number + else 80 + ) + bindings.append( + IngressBinding( + ingress=ing.metadata.name, + namespace=ing.metadata.namespace, + host=host, + path=p.path or "/", + tls=(host in tls_hosts), + service=backend.service.name, + service_port=svc_port, + ) + ) + return bindings + + +# ========================= +# Networking helpers +# ========================= + + +def dns_resolves(host: str) -> bool: + try: + socket.gethostbyname(host) + return True + except Exception: + return False + + +def http_check( + url: str, + host_header: Optional[str] = None, + timeout: float = 5.0, + verify_tls: bool = True, +) -> Reachability: + start = time.perf_counter() + headers = {} + if host_header: + headers["Host"] = host_header + try: + with httpx.Client( + follow_redirects=True, verify=verify_tls, headers=headers, timeout=timeout + ) as s: + r = s.get(url) + latency_ms = int((time.perf_counter() - start) * 1000) + return Reachability( + via="ingress" if host_header or url.startswith("http") else "unknown", + target=host_header or url, + url=url, + ok=r.status_code < 500, + status=r.status_code, + error=None, + latency_ms=latency_ms, + ) + except Exception as e: + latency_ms = int((time.perf_counter() - start) * 1000) + return Reachability( + via="ingress", + target=host_header or url, + url=url, + ok=False, + status=None, + error=str(e), + latency_ms=latency_ms, + ) + + +def tcp_check( + host: str, port: int, timeout: float = 3.0 +) -> Tuple[bool, Optional[str], Optional[int]]: + start = time.perf_counter() + try: + with socket.create_connection((host, port), timeout=timeout): + return True, None, int((time.perf_counter() - start) * 1000) + except Exception as e: + return False, str(e), int((time.perf_counter() - start) * 1000) + + +def try_exec_http_from_pod( + ctx: KubeCtx, namespace: str, pod: str, url: str, timeout: int = 8 +) -> Reachability: + """ + Execute a lightweight HTTP check from within the given pod (best-effort). + Tries curl, then wget. Returns Reachability record with ok status. + """ + cmd = [ + "sh", + "-lc", + # Prefer curl (status + timing), fallback to wget; if both missing, try /dev/tcp. + ( + f'(command -v curl >/dev/null && curl -sk -o /dev/null -w "%{{http_code}}" "{url}") || ' + f'(command -v wget >/dev/null && wget -qO- "{url}" >/dev/null && printf 200) || ' + f"(echo 000)" + ), + ] + try: + out = stream( + ctx.core.connect_get_namespaced_pod_exec, + name=pod, + namespace=namespace, + command=cmd, + stderr=True, + stdin=False, + stdout=True, + tty=False, + _request_timeout=timeout, + ) + code = 0 + try: + code = int(str(out).strip()[:3]) + except Exception: + code = 0 + return Reachability( + via="svc-fqdn", + target=url, + url=url, + ok=200 <= code < 500, + status=code, + error=None if 200 <= code < 500 else f"code={code}", + ) + except Exception as e: + return Reachability( + via="svc-fqdn", target=url, url=url, ok=False, status=None, error=str(e) + ) + + +# ========================= +# High-level inspection +# ========================= + + +def inspect_deployment( + name: str, + namespace: Optional[str] = None, + timeout: float = 5.0, + verify_tls: bool = True, +) -> InspectionReport: + """ + Full inspection routine. Returns a structured report usable by other tools. + """ + ctx = load_kube_ctx() + dep, ns = find_deployment(ctx, name, namespace) + pods = pods_for_deployment(ctx, dep) + pod_names = [p.metadata.name for p in pods] + pod_ips = {p.metadata.name: (p.status.pod_ip or "") for p in pods} + + # Collect declared containerPorts + cports: Dict[str, List[int]] = {} + for p in pods: + plist = [] + for c in p.spec.containers or []: + for cp in c.ports or []: + if cp.container_port: + plist.append(int(cp.container_port)) + cports[p.metadata.name] = sorted({*plist}) + + probe = extract_probe(dep) + preferred_port: Optional[int | str] = ( + probe.port if probe and probe.port is not None else None + ) + services = services_for_deployment(ctx, dep) + svc_bindings = resolve_service_bindings(dep, services, preferred_port) + + ingresses = ingresses_for_services(ctx, ns, services) + ing_bindings = extract_ingress_bindings(ingresses, services) + + # Reachability checks + reach: List[Reachability] = [] + + # 1) Through Ingress (best effort) + for ib in ing_bindings: + scheme = "https" if ib.tls else "http" + base = f"{scheme}://{ib.host}" if ib.host else "" + path = ib.path or "/" + health_path = probe.path if probe and probe.path else "/" + url = f"{base}{path.rstrip('/')}{health_path if health_path.startswith('/') else '/' + health_path}" + # If host resolves, try directly + if ib.host and dns_resolves(ib.host): + r = http_check(url, timeout=timeout, verify_tls=verify_tls) + r.via = "ingress" + r.target = ib.host + reach.append(r) + else: + # Try using load balancer IP/hostname from status with Host header + target_ips: List[str] = [] + for ing in ingresses: + if ing.metadata.name == ib.ingress: + if ( + ing.status + and ing.status.load_balancer + and ing.status.load_balancer.ingress + ): + for ent in ing.status.load_balancer.ingress: + if ent.ip: + target_ips.append(ent.ip) + if ent.hostname: + target_ips.append(ent.hostname) + if target_ips: + t = target_ips[0] + alt_url = f"{scheme}://{t}{path.rstrip('/')}{health_path if health_path.startswith('/') else '/' + health_path}" + reach.append( + http_check( + alt_url, + host_header=ib.host or None, + timeout=timeout, + verify_tls=verify_tls, + ) + ) + else: + reach.append( + Reachability( + via="ingress", + target=ib.host or "(no-host)", + url=url or None, + ok=False, + error="No DNS for host and no load balancer address found on Ingress.", + ) + ) + + # 2) Through Pod IP (TCP + optional HTTP on health path) + test_port_candidates: List[int] = [] + if preferred_port is not None and isinstance(preferred_port, int): + test_port_candidates.append(preferred_port) + # Add declared service ports + for b in svc_bindings: + if b.port not in test_port_candidates: + test_port_candidates.append(b.port) + # Add container ports if nothing else + if not test_port_candidates: + for plist in cports.values(): + for pnum in plist: + if pnum not in test_port_candidates: + test_port_candidates.append(pnum) + + for pod in pods: + ip = pod.status.pod_ip + if not ip: + reach.append( + Reachability( + via="pod-ip", + target=pod.metadata.name, + ok=False, + error="No Pod IP assigned", + ) + ) + continue + # Try TCP on first viable port + if test_port_candidates: + port = test_port_candidates[0] + ok, err, _lat = tcp_check(ip, port, timeout=timeout) + if not ok: + reach.append( + Reachability( + via="pod-ip", target=f"{ip}:{port}", ok=False, error=err + ) + ) + else: + # Try HTTP GET if we have a health path + health_path = probe.path if probe and probe.path else "/" + url = f"http://{ip}:{port}{health_path if health_path.startswith('/') else '/' + health_path}" + r = http_check(url, timeout=timeout, verify_tls=False) + r.via = "pod-ip" + r.target = f"{ip}:{port}" + reach.append(r) + else: + reach.append( + Reachability( + via="pod-ip", + target=ip, + ok=False, + error="No candidate port found to test", + ) + ) + + # 3) Through fully qualified Service DNS (from inside cluster if needed) + # Choose first service binding if available + if svc_bindings: + sb = svc_bindings[0] + fqdn = f"{sb.service}.{sb.namespace}.svc.cluster.local" + health_path = probe.path if probe and probe.path else "/" + url = f"http://{fqdn}:{sb.port}{health_path if health_path.startswith('/') else '/' + health_path}" + + ctx = load_kube_ctx() + if ctx.in_cluster and dns_resolves(fqdn): + r = http_check(url, timeout=timeout, verify_tls=False) + r.via = "svc-fqdn" + r.target = fqdn + reach.append(r) + else: + if pods: + reach.append( + try_exec_http_from_pod( + ctx, sb.namespace, pods[0].metadata.name, url + ) + ) + else: + reach.append( + Reachability( + via="svc-fqdn", + target=url, + url=url, + ok=False, + error="No pods available to test inside cluster", + ) + ) + else: + reach.append( + Reachability( + via="svc-fqdn", + target="(no service)", + ok=False, + error="No Service bound to the deployment", + ) + ) + + return InspectionReport( + deployment=dep.metadata.name, + namespace=ns, + in_cluster=ctx.in_cluster, + pods=pod_names, + pod_ips=pod_ips, + container_ports=cports, + health_probe=probe, + services=svc_bindings, + ingresses=ing_bindings, + reachability=reach, + ) + + +# ========================= +# Logs helpers +# ========================= + + +def deployment_pods(ctx: KubeCtx, name: str, namespace: str) -> List[V1Pod]: + dep = ctx.apps.read_namespaced_deployment(name=name, namespace=namespace) + return pods_for_deployment(ctx, dep) + + +def print_pod_logs( + name: str, + namespace: str, + container: Optional[str] = None, + tail: Optional[int] = None, + since_seconds: Optional[int] = None, + follow: bool = False, +) -> None: + ctx = load_kube_ctx() + pods = deployment_pods(ctx, name, namespace) + if not pods: + console.print(f"[red]No pods found for deployment {name} in {namespace}[/red]") + raise typer.Exit(1) + + # If follow, stream each pod in sequence (simple approach) + for p in pods: + console.rule(f"[bold]Logs: {p.metadata.name}[/bold]") + if follow: + # naive follow using repeated calls + try: + for line in ctx.core.read_namespaced_pod_log( + name=p.metadata.name, + namespace=namespace, + container=container, + tail_lines=tail, + since_seconds=since_seconds, + follow=True, + _preload_content=False, + ).stream(decode_content=True): + try: + console.print(line.decode("utf-8").rstrip()) + except Exception: + console.print(line) + except KeyboardInterrupt: + break + else: + out = ctx.core.read_namespaced_pod_log( + name=p.metadata.name, + namespace=namespace, + container=container, + tail_lines=tail, + since_seconds=since_seconds, + ) + console.print(out) + + +# ========================= +# CLI commands +# ========================= + + +@app.command("inspect") +def cli_inspect( + deployment: str = typer.Argument(..., help="Deployment name"), + namespace: Optional[str] = typer.Option( + None, + "--namespace", + "-n", + help="Namespace (if omitted, will try to auto-detect)", + ), + timeout: float = typer.Option(5.0, help="HTTP/TCP timeout (seconds)"), + insecure: bool = typer.Option(False, help="Skip TLS verification for HTTPS checks"), + output_json: bool = typer.Option( + False, "--json", help="Print JSON report instead of a table" + ), +): + """ + Inspect a Deployment's Services & Ingresses and run connectivity checks. + """ + try: + report = inspect_deployment( + deployment, namespace=namespace, timeout=timeout, verify_tls=(not insecure) + ) + except Exception as e: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(1) + + if output_json: + console.print_json(json.dumps(report.model_dump(), indent=2)) + return + + hdr = ( + f"[bold white]Deployment:[/bold white] {report.deployment} " + f"[bold white]Namespace:[/bold white] {report.namespace} " + f"[bold white]Context:[/bold white] {'in-cluster' if report.in_cluster else 'local'}" + ) + console.print(Panel(hdr, border_style="cyan", title="Overview")) + + # Pods table + t = Table(title="Pods", box=box.SIMPLE, show_lines=False) + t.add_column("Pod") + t.add_column("IP") + t.add_column("Ports") + for pod in report.pods: + ports = ", ".join(str(p) for p in report.container_ports.get(pod, [])) or "-" + t.add_row(pod, report.pod_ips.get(pod, "-"), ports) + console.print(t) + + # Health probe + if report.health_probe: + hp = report.health_probe + console.print( + Panel( + f"[bold]Health Probe[/bold]\nKind: {hp.kind}\nPath: {hp.path or '-'}\nPort: {hp.port or '-'}\nScheme: {hp.scheme}", + border_style="green", + ) + ) + else: + console.print( + Panel( + "[yellow]No HTTP health probe found on containers[/yellow]", + border_style="yellow", + ) + ) + + # Services + if report.services: + ts = Table(title="Services", box=box.SIMPLE) + ts.add_column("Service") + ts.add_column("Namespace") + ts.add_column("Port") + ts.add_column("TargetPort") + ts.add_column("Protocol") + for s in report.services: + ts.add_row( + s.service, + s.namespace, + str(s.port), + str(s.target_port or "-"), + s.protocol, + ) + console.print(ts) + else: + console.print( + Panel( + "[yellow]No Service selects this deployment[/yellow]", + border_style="yellow", + ) + ) + + # Ingresses + if report.ingresses: + ti = Table(title="Ingress Bindings", box=box.SIMPLE) + ti.add_column("Ingress") + ti.add_column("Host") + ti.add_column("Path") + ti.add_column("TLS") + ti.add_column("Service:Port") + for ib in report.ingresses: + ti.add_row( + ib.ingress, + ib.host or "-", + ib.path, + "yes" if ib.tls else "no", + f"{ib.service}:{ib.service_port}", + ) + console.print(ti) + else: + console.print( + Panel( + "[yellow]No Ingress rules reference Services for this deployment[/yellow]", + border_style="yellow", + ) + ) + + # Reachability + tr = Table(title="Reachability", box=box.SIMPLE) + tr.add_column("Via") + tr.add_column("Target") + tr.add_column("URL") + tr.add_column("OK") + tr.add_column("Status") + tr.add_column("Latency (ms)") + tr.add_column("Error") + for r in report.reachability: + tr.add_row( + r.via, + r.target, + r.url or "-", + "[green]yes[/green]" if r.ok else "[red]no[/red]", + str(r.status or "-"), + str(r.latency_ms or "-"), + r.error or "-", + ) + console.print(tr) + + +@app.command("logs") +def cli_logs( + deployment: str = typer.Argument(..., help="Deployment name"), + namespace: str = typer.Option(..., "--namespace", "-n", help="Namespace"), + container: Optional[str] = typer.Option( + None, "--container", "-c", help="Specific container name" + ), + tail: Optional[int] = typer.Option(None, "--tail", help="Tail N lines"), + since: Optional[int] = typer.Option( + None, "--since", help="Only return logs newer than N seconds" + ), + follow: bool = typer.Option(False, "--follow", "-f", help="Stream logs"), +): + """ + Print logs from pods belonging to a Deployment. + """ + try: + print_pod_logs( + deployment, + namespace, + container=container, + tail=tail, + since_seconds=since, + follow=follow, + ) + except Exception as e: + console.print(f"[red]Error fetching logs:[/red] {e}") + raise typer.Exit(1) + + +@app.callback(invoke_without_command=True) +def _root( + ctx: typer.Context, +): + """ + Kubernetes Ingress Debugger CLI. + """ + if ctx.invoked_subcommand is None: + typer.echo(ctx.get_help()) + + +if __name__ == "__main__": + app()