#!/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 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")