diff --git a/scripts/kuma-maintenance.py b/scripts/kuma-maintenance.py new file mode 100644 index 0000000..6e39a73 --- /dev/null +++ b/scripts/kuma-maintenance.py @@ -0,0 +1,162 @@ +#!/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() diff --git a/scripts/pve-snapshot.sh b/scripts/pve-snapshot.sh index 981f58e..b48fea4 100755 --- a/scripts/pve-snapshot.sh +++ b/scripts/pve-snapshot.sh @@ -96,6 +96,18 @@ rollback) done guard_protected "$vmid" "rollback" "$force" [ -n "$yes" ] || die "rollback STOPS the VM. Set an Uptime Kuma maintenance window for monitored VMs, then pass --yes" + # OQ5 approved (questions-v10): auto-open a Kuma window when creds exist. + if [ -f "$HOME/.creds/uptime-kuma.env" ]; then + win="$(timeout 240 docker run --rm -v "$SCRIPT_DIR/..:/app" -w /app \ + --env-file "$HOME/.creds/uptime-kuma.env" -e TZ=America/Chicago \ + python:3.12-slim sh -c 'pip install -q --no-cache-dir "python-socketio[client]" websocket-client >/dev/null 2>&1; python3 scripts/kuma-maintenance.py open --title "cm rollback vmid='"$vmid"'" --minutes 30 --desc "pve-snapshot.sh '"$snap"'"' 2>/dev/null \ + | grep -o 'id=[0-9]*' | head -1 | cut -d= -f2 || true)" + if [ -n "$win" ]; then + log "Kuma maintenance window id=$win opened (30m)" + else + log "WARN: could not open Kuma window (creds present) — create it manually before proceeding" + fi + fi log "rolling back $vmid to $snap (VM will be stopped)" prox "qm rollback $vmid '$snap'" log_action "rollback vmid=$vmid snap=$snap"