#!/usr/bin/env python3 """kuma-maintenance.py — Uptime Kuma maintenance windows from the CLI [#769] Companion to kuma-add-ping.py (same connect pattern). Creates "single" strategy maintenance windows so change work has a Kuma record (founder golden rule: any VM reboot ships behind a maintenance window). Env (from ~/.creds/uptime-kuma.env): UPTIME_KUMA_URL, UPTIME_KUMA_TOKEN Usage: python3 kuma-maintenance.py open --title "cm rollback 5109" --minutes 30 \ [--desc "ticket #769"] [--monitor "monitor name" ...] python3 kuma-maintenance.py list python3 kuma-maintenance.py delete Prints the maintenance id on open (last line: "id=") for scripting. """ import argparse import datetime import os import sys import socketio def kuma_connect(): sio = socketio.Client(reconnection=False) sio.connect( os.environ["UPTIME_KUMA_URL"], transports=["websocket"], wait_timeout=30 ) r = sio.call("loginByToken", os.environ["UPTIME_KUMA_TOKEN"], timeout=30) if not (isinstance(r, dict) and r.get("ok")): raise SystemExit(f"login failed: {r}") return sio def fmt(dt): return dt.strftime("%Y-%m-%d %H:%M") def open_window(args): sio = kuma_connect() start = datetime.datetime.now() - datetime.timedelta(minutes=1) end = start + datetime.timedelta(minutes=args.minutes + 1) obj = { "title": args.title, "description": args.desc or "", "strategy": "single", "active": True, "intervalDay": 1, "dateRange": [fmt(start), fmt(end)], "timeRange": [], "weekdays": [], "daysOfMonth": [], "timezone": "LOCAL", } attempts = [ ("obj", lambda: sio.call("addMaintenance", obj, timeout=30)), ("obj+monitors", lambda: sio.call( "addMaintenance", obj, args.monitor or [], timeout=30)), ] result = None for label, fn in attempts: try: result = fn() except Exception as exc: # noqa: BLE001 - probe different API shapes result = (False, str(exc)) if isinstance(result, dict) and result.get("ok"): break if isinstance(result, (list, tuple)) and result and result[0] is True: break else: print(f"FAIL addMaintenance ({label}): {result}", file=sys.stderr) sys.exit(1) mid = None if isinstance(result, dict): mid = result.get("maintenanceID") or result.get("id") or ( result.get("maintenance") or {}).get("id") elif isinstance(result, (list, tuple)): body = result[1] if len(result) > 1 else None if isinstance(body, dict): mid = body.get("maintenanceID") or body.get("id") or ( body.get("maintenance") or {}).get("id") if mid is None and args.monitor: # best-effort attach for API shapes that create-then-attach try: sio.call("addMonitorMaintenance", mid, args.monitor, timeout=30) except Exception: # noqa: BLE001 - attach is best-effort pass print(f"opened '{args.title}' {fmt(start)} -> {fmt(end)}") print(f"id={mid}") sio.disconnect() def list_windows(_args): sio = kuma_connect() state = {} @sio.on("maintenanceList") def _ml(d): state["ml"] = d sio.emit("getMaintenanceList") for _ in range(20): if "ml" in state: break sio.sleep(0.5) items = state.get("ml") or [] if isinstance(items, dict): items = list(items.values()) for m in items: print(f"id={m.get('id')} active={m.get('active')} " f"strategy={m.get('strategy')} title={m.get('title')!r}") sio.disconnect() def delete_window(args): sio = kuma_connect() attempts = [ lambda: sio.call("deleteMaintenance", int(args.mid), timeout=30), lambda: sio.call("deleteMaintenance", {"id": int(args.mid)}, timeout=30), ] for fn in attempts: try: r = fn() except Exception as exc: # noqa: BLE001 - probe different API shapes r = (False, str(exc)) if (isinstance(r, dict) and r.get("ok")) or ( isinstance(r, (list, tuple)) and r and r[0] is True ): print(f"deleted {args.mid}") sio.disconnect() return print(f"FAIL delete: {r}", file=sys.stderr) sys.exit(1) def main(): p = argparse.ArgumentParser(description=__doc__) sub = p.add_subparsers(dest="cmd", required=True) po = sub.add_parser("open", help="open a single-strategy window") po.add_argument("--title", required=True) po.add_argument("--minutes", type=int, default=30) po.add_argument("--desc", default="") po.add_argument("--monitor", action="append", default=[]) po.set_defaults(fn=open_window) pl = sub.add_parser("list", help="list maintenance windows") pl.set_defaults(fn=list_windows) pd = sub.add_parser("delete", help="delete a window by id") pd.add_argument("mid") pd.set_defaults(fn=delete_window) args = p.parse_args() args.fn(args) if __name__ == "__main__": main()