mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
240 lines
7.3 KiB
Python
240 lines
7.3 KiB
Python
"""Service status and rollout management for Hanzo K8s CLI.
|
|
|
|
Uses kubectl to query pod and deployment status and displays results
|
|
in rich tables.
|
|
|
|
Usage:
|
|
hanzo k8s services # List pods in default namespace
|
|
hanzo k8s services --namespace kube-system
|
|
hanzo k8s rollout <deployment> # Restart a deployment
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
|
|
import click
|
|
from rich.console import Console
|
|
from rich.table import Table
|
|
|
|
console = Console()
|
|
|
|
|
|
def _find_kubectl() -> str:
|
|
"""Locate kubectl or exit."""
|
|
path = shutil.which("kubectl")
|
|
if not path:
|
|
console.print(
|
|
"[red]kubectl not found.[/red] "
|
|
"Install: https://kubernetes.io/docs/tasks/tools/"
|
|
)
|
|
sys.exit(1)
|
|
return path
|
|
|
|
|
|
def _kubectl_json(args: list[str]) -> dict | list:
|
|
"""Run kubectl with -o json and return parsed output."""
|
|
kubectl = _find_kubectl()
|
|
cmd = [kubectl] + args + ["-o", "json"]
|
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
if result.returncode != 0:
|
|
err = result.stderr.strip()
|
|
raise click.ClickException(f"kubectl failed: {err}")
|
|
if not result.stdout.strip():
|
|
return {}
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
def _parse_age(timestamp: str) -> str:
|
|
"""Convert an ISO timestamp to a human-readable age string."""
|
|
try:
|
|
created = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
|
|
delta = datetime.now(timezone.utc) - created
|
|
total_seconds = int(delta.total_seconds())
|
|
|
|
if total_seconds < 60:
|
|
return f"{total_seconds}s"
|
|
if total_seconds < 3600:
|
|
return f"{total_seconds // 60}m"
|
|
if total_seconds < 86400:
|
|
return f"{total_seconds // 3600}h"
|
|
days = total_seconds // 86400
|
|
if days < 30:
|
|
return f"{days}d"
|
|
return f"{days // 30}mo"
|
|
except (ValueError, TypeError):
|
|
return "?"
|
|
|
|
|
|
def _status_style(phase: str) -> str:
|
|
"""Return rich style for a pod phase."""
|
|
phase_lower = phase.lower()
|
|
if phase_lower == "running":
|
|
return "green"
|
|
if phase_lower in ("pending", "containercreating"):
|
|
return "yellow"
|
|
if "error" in phase_lower or "crash" in phase_lower or "backoff" in phase_lower:
|
|
return "red"
|
|
if phase_lower in ("succeeded", "completed"):
|
|
return "cyan"
|
|
return "white"
|
|
|
|
|
|
@click.command("services")
|
|
@click.option(
|
|
"--namespace",
|
|
"-n",
|
|
default=None,
|
|
help="Kubernetes namespace (default: current cluster namespace).",
|
|
)
|
|
def services(namespace: str | None) -> None:
|
|
"""Show service status from pod information.
|
|
|
|
Lists pods grouped by service (app label), showing replica counts,
|
|
status, age, and restart counts.
|
|
"""
|
|
if namespace is None:
|
|
from hanzo_cli.k8s.clusters import get_current_cluster
|
|
|
|
_, cluster_info = get_current_cluster()
|
|
namespace = cluster_info.get("namespace", "hanzo")
|
|
|
|
try:
|
|
data = _kubectl_json(["get", "pods", "-n", namespace])
|
|
except click.ClickException as e:
|
|
console.print(f"[red]{e.message}[/red]")
|
|
return
|
|
|
|
items = data.get("items", [])
|
|
if not items:
|
|
console.print(f"[yellow]No pods found in namespace '{namespace}'.[/yellow]")
|
|
return
|
|
|
|
# Group pods by service name (app label)
|
|
svc_map: dict[str, list[dict]] = {}
|
|
for pod in items:
|
|
labels = pod.get("metadata", {}).get("labels", {})
|
|
svc_name = (
|
|
labels.get("app")
|
|
or labels.get("app.kubernetes.io/name")
|
|
or labels.get("name")
|
|
or pod.get("metadata", {}).get("name", "unknown")
|
|
)
|
|
svc_map.setdefault(svc_name, []).append(pod)
|
|
|
|
table = Table(title=f"Services — {namespace}")
|
|
table.add_column("Service", style="cyan", min_width=20)
|
|
table.add_column("Ready", justify="center", min_width=8)
|
|
table.add_column("Status", justify="center", min_width=14)
|
|
table.add_column("Age", justify="right", min_width=6)
|
|
table.add_column("Restarts", justify="right", min_width=9)
|
|
|
|
for svc_name in sorted(svc_map.keys()):
|
|
pods = svc_map[svc_name]
|
|
total = len(pods)
|
|
ready = 0
|
|
total_restarts = 0
|
|
worst_phase = "Running"
|
|
|
|
for pod in pods:
|
|
status = pod.get("status", {})
|
|
phase = status.get("phase", "Unknown")
|
|
|
|
# Check container statuses for more detail
|
|
container_statuses = status.get("containerStatuses", [])
|
|
pod_ready = True
|
|
for cs in container_statuses:
|
|
total_restarts += cs.get("restartCount", 0)
|
|
if not cs.get("ready", False):
|
|
pod_ready = False
|
|
# Detect CrashLoopBackOff
|
|
waiting = cs.get("state", {}).get("waiting", {})
|
|
reason = waiting.get("reason", "")
|
|
if reason:
|
|
phase = reason
|
|
|
|
if pod_ready and phase == "Running":
|
|
ready += 1
|
|
|
|
# Track worst phase for display
|
|
if phase != "Running":
|
|
worst_phase = phase
|
|
|
|
# Pick display phase
|
|
if ready == total:
|
|
display_phase = "Running"
|
|
elif ready > 0:
|
|
display_phase = f"{worst_phase}" if worst_phase != "Running" else "Partial"
|
|
else:
|
|
display_phase = worst_phase
|
|
|
|
style = _status_style(display_phase)
|
|
ready_str = f"{ready}/{total}"
|
|
|
|
# Age from oldest pod
|
|
oldest = min(
|
|
(
|
|
p.get("metadata", {}).get("creationTimestamp", "")
|
|
for p in pods
|
|
),
|
|
default="",
|
|
)
|
|
age_str = _parse_age(oldest) if oldest else "?"
|
|
|
|
restart_style = "red" if total_restarts > 10 else "yellow" if total_restarts > 0 else "dim"
|
|
|
|
table.add_row(
|
|
svc_name,
|
|
ready_str,
|
|
f"[{style}]{display_phase}[/{style}]",
|
|
age_str,
|
|
f"[{restart_style}]{total_restarts}[/{restart_style}]",
|
|
)
|
|
|
|
console.print(table)
|
|
console.print(f"\n{len(svc_map)} service(s), {len(items)} pod(s)")
|
|
|
|
|
|
@click.command("rollout")
|
|
@click.argument("deployment")
|
|
@click.option(
|
|
"--namespace",
|
|
"-n",
|
|
default=None,
|
|
help="Kubernetes namespace (default: current cluster namespace).",
|
|
)
|
|
def rollout(deployment: str, namespace: str | None) -> None:
|
|
"""Trigger a rolling restart of a deployment.
|
|
|
|
Runs `kubectl rollout restart deployment/<name>` in the specified
|
|
namespace.
|
|
"""
|
|
if namespace is None:
|
|
from hanzo_cli.k8s.clusters import get_current_cluster
|
|
|
|
_, cluster_info = get_current_cluster()
|
|
namespace = cluster_info.get("namespace", "hanzo")
|
|
|
|
kubectl = _find_kubectl()
|
|
resource = deployment if "/" in deployment else f"deployment/{deployment}"
|
|
|
|
console.print(
|
|
f"Restarting [cyan]{resource}[/cyan] in namespace [cyan]{namespace}[/cyan]..."
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[kubectl, "rollout", "restart", resource, "-n", namespace],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
console.print(f"[red]Failed:[/red] {result.stderr.strip()}")
|
|
raise SystemExit(1)
|
|
|
|
console.print(f"[green]{result.stdout.strip()}[/green]")
|