#!/usr/bin/env python3 """ web.py: FastAPI web dashboard for Kubernetes image version tracking. Routes: GET / — image inventory grouped by namespace → app → container GET /upgrade — upgrade trigger page with outdated images and job history POST /api/refresh — kick off a background version-check refresh POST /api/upgrade/trigger — create a Job from the upgrade-requester CronJob GET /api/upgrade/jobs — recent upgrade job history (JSON) GET /api/images — full inventory JSON """ import logging from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timezone from fastapi import BackgroundTasks, FastAPI, Request from fastapi.responses import HTMLResponse, JSONResponse from fastapi.templating import Jinja2Templates from kubernetes import client, config # type: ignore[attr-defined] from packaging.version import InvalidVersion, Version from check_versions import get_latest_version, parse_image_ref logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") logger = logging.getLogger(__name__) app = FastAPI(title="K8s Image Dashboard") templates = Jinja2Templates(directory="templates") _pool = ThreadPoolExecutor(max_workers=20) _cache: dict = {"inventory": None, "last_updated": None, "is_checking": False} # ── Kubernetes helpers ──────────────────────────────────────────────────────── def _load_k8s() -> None: try: config.load_incluster_config() except config.ConfigException: config.load_kube_config() def _build_owner_maps() -> tuple[dict, dict]: apps_v1 = client.AppsV1Api() batch_v1 = client.BatchV1Api() rs_map: dict[str, str] = {} for rs in apps_v1.list_replica_set_for_all_namespaces().items: ns = rs.metadata.namespace for ref in rs.metadata.owner_references or []: if ref.kind == "Deployment": rs_map[f"{ns}/{rs.metadata.name}"] = ref.name job_map: dict[str, str] = {} for job in batch_v1.list_job_for_all_namespaces().items: ns = job.metadata.namespace for ref in job.metadata.owner_references or []: if ref.kind == "CronJob": job_map[f"{ns}/{job.metadata.name}"] = ref.name return rs_map, job_map def _resolve_owner(pod, rs_map: dict, job_map: dict) -> tuple[str, str]: ns = pod.metadata.namespace for ref in pod.metadata.owner_references or []: if ref.kind == "ReplicaSet": deploy = rs_map.get(f"{ns}/{ref.name}") return (deploy, "Deployment") if deploy else (ref.name, "ReplicaSet") if ref.kind == "StatefulSet": return ref.name, "StatefulSet" if ref.kind == "DaemonSet": return ref.name, "DaemonSet" if ref.kind == "Job": cj = job_map.get(f"{ns}/{ref.name}") return (cj, "CronJob") if cj else (ref.name, "Job") return pod.metadata.name, "Pod" def _build_inventory() -> list[dict]: """Query K8s and build an app-grouped inventory. Version statuses start as 'pending'.""" _load_k8s() v1 = client.CoreV1Api() pods = v1.list_pod_for_all_namespaces(watch=False) rs_map, job_map = _build_owner_maps() apps: dict[tuple, dict] = {} for pod in pods.items: ns = pod.metadata.namespace app_name, kind = _resolve_owner(pod, rs_map, job_map) key = (ns, app_name) if key not in apps: apps[key] = {"namespace": ns, "app": app_name, "kind": kind, "pod_count": 0, "containers": {}} apps[key]["pod_count"] += 1 for c in pod.spec.containers or []: if not c.image or c.name in apps[key]["containers"]: continue img = parse_image_ref(c.image) apps[key]["containers"][c.name] = { "name": c.name, "image": c.image, "image_short": img.display_name, "tag": img.tag, "registry": img.registry, "is_local": img.is_local, "is_floating": img.is_floating, "skip_check": img.skip_check, "latest_tag": None, "status": "pending", } return sorted( [{**a, "containers": list(a["containers"].values())} for a in apps.values()], key=lambda x: (x["namespace"], x["app"]), ) # ── Version checking ────────────────────────────────────────────────────────── def _check_container(container: dict) -> None: if container["is_local"]: container["status"] = "local" return if container["skip_check"]: container["status"] = "skipped" return if container["is_floating"]: container["status"] = "floating" return img = parse_image_ref(container["image"]) latest = get_latest_version(img) container["latest_tag"] = latest if latest is None: container["status"] = "unknown" return try: outdated = Version(latest.lstrip("v")) > Version(container["tag"].lstrip("v")) except InvalidVersion: outdated = container["tag"] != latest container["status"] = "outdated" if outdated else "current" def _run_checks(inventory: list[dict]) -> None: if _cache["is_checking"]: return _cache["is_checking"] = True try: containers = [c for a in inventory for c in a["containers"]] futures = {_pool.submit(_check_container, c): c for c in containers} for f in as_completed(futures): try: f.result() except Exception as e: logger.warning("Version check error: %s", e) _cache["last_updated"] = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") logger.info( "Version check complete: %d outdated, %d current, %d floating", sum(1 for a in inventory for c in a["containers"] if c["status"] == "outdated"), sum(1 for a in inventory for c in a["containers"] if c["status"] == "current"), sum(1 for a in inventory for c in a["containers"] if c["status"] == "floating"), ) finally: _cache["is_checking"] = False def _get_inventory(background_tasks: BackgroundTasks) -> list[dict]: """Return cached inventory, building it and kicking off version checks if empty.""" if _cache["inventory"] is None: inventory = _build_inventory() _cache["inventory"] = inventory background_tasks.add_task(_run_checks, inventory) return _cache["inventory"] # ── Upgrade job helpers ─────────────────────────────────────────────────────── def _list_upgrade_jobs() -> list[dict]: try: _load_k8s() batch_v1 = client.BatchV1Api() jobs = batch_v1.list_namespaced_job( "k8s-version-tracker", label_selector="app=upgrade-requester" ) result = [] for j in sorted( jobs.items, key=lambda x: x.metadata.creation_timestamp or datetime.min, reverse=True, )[:5]: s = j.status status = "running" if (s.active or 0) else ("succeeded" if (s.succeeded or 0) else "failed") result.append({ "name": j.metadata.name, "created": j.metadata.creation_timestamp.isoformat() if j.metadata.creation_timestamp else "", "status": status, "active": s.active or 0, "succeeded": s.succeeded or 0, "failed": s.failed or 0, }) return result except Exception as e: logger.error("Failed to list upgrade jobs: %s", e) return [] # ── Stats helper ───────────────────────────────────────────────────────────── def _stats(inventory: list[dict]) -> dict: all_c = [c for a in inventory for c in a["containers"]] return { "total": len(all_c), "outdated": sum(1 for c in all_c if c["status"] == "outdated"), "current": sum(1 for c in all_c if c["status"] == "current"), "floating": sum(1 for c in all_c if c["status"] == "floating"), "local": sum(1 for c in all_c if c["status"] in ("local", "skipped")), "pending": sum(1 for c in all_c if c["status"] in ("pending", "unknown")), } # ── Routes ──────────────────────────────────────────────────────────────────── @app.get("/", response_class=HTMLResponse) async def images_page(request: Request, background_tasks: BackgroundTasks): inventory = _get_inventory(background_tasks) ns_map: dict[str, list] = {} for app_data in inventory: ns_map.setdefault(app_data["namespace"], []).append(app_data) return templates.TemplateResponse("images.html", { "request": request, "namespaces": dict(sorted(ns_map.items())), "stats": _stats(inventory), "last_updated": _cache["last_updated"], "is_checking": _cache["is_checking"], }) @app.get("/upgrade", response_class=HTMLResponse) async def upgrade_page(request: Request): inventory = _cache["inventory"] or [] outdated_apps = [ {**a, "containers": [c for c in a["containers"] if c["status"] == "outdated"]} for a in inventory if any(c["status"] == "outdated" for c in a["containers"]) ] return templates.TemplateResponse("upgrade.html", { "request": request, "outdated_apps": outdated_apps, "jobs": _list_upgrade_jobs(), "last_updated": _cache["last_updated"], "is_checking": _cache["is_checking"], }) @app.post("/api/refresh") async def api_refresh(background_tasks: BackgroundTasks): if _cache["is_checking"]: return {"status": "already_running"} inventory = _build_inventory() _cache["inventory"] = inventory background_tasks.add_task(_run_checks, inventory) return {"status": "started"} @app.get("/api/images") async def api_images(): return {"inventory": _cache["inventory"] or [], "last_updated": _cache["last_updated"]} @app.post("/api/upgrade/trigger") async def api_trigger_upgrade(): try: _load_k8s() batch_v1 = client.BatchV1Api() cj = batch_v1.read_namespaced_cron_job("upgrade-requester", "k8s-version-tracker") stamp = datetime.now(timezone.utc).strftime("%m%d%H%M") job_name = f"upgrade-manual-{stamp}" job = client.V1Job( api_version="batch/v1", kind="Job", metadata=client.V1ObjectMeta( name=job_name, namespace="k8s-version-tracker", labels={"app": "upgrade-requester", "triggered-by": "dashboard"}, ), spec=cj.spec.job_template.spec, ) batch_v1.create_namespaced_job("k8s-version-tracker", job) logger.info("Triggered upgrade job: %s", job_name) return {"status": "created", "job_name": job_name} except Exception as e: logger.error("Trigger failed: %s", e) return JSONResponse(status_code=500, content={"error": str(e)}) @app.get("/api/upgrade/jobs") async def api_upgrade_jobs(): return {"jobs": _list_upgrade_jobs()} if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8080, log_level="info")