Files
PFVCluster/scripts/kuma-maintenance.py
T
mrcharles 66def2e311 feat(cm): auto-open Kuma maintenance windows on rollback (OQ5 YES) [#769]
kuma-maintenance.py (open/list/delete, single-strategy windows,
version-tolerant addMaintenance probing) + pve-snapshot.sh rollback
opens a 30m window before touching a VM when ~/.creds/uptime-kuma.env
exists. TZ pinned America/Chicago. Verified: open->list(active=True)->
delete live on the fleet Kuma.

https://projects.knownelement.com/issues/769#note-4152
2026-09-04 06:05:27 -05:00

163 lines
5.0 KiB
Python

#!/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 <maintenance-id>
Prints the maintenance id on open (last line: "id=<n>") 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()