feat(kuma): Cloudron HTTP-200 coverage + group discipline [#435]

55 http monitors added under "Cloud Systems" (blue/green: canary first,
verified green); 9 root-level strays re-homed into founder's groups
(root now zero). New tools: inventory (read-only dump), cloudron-sync
(idempotent diff/add from committed app list), regroup (lib-based moves;
raw editMonitor times out on this build). fleet-sync now requires
--group-id so it can never place monitors at root again.

Results: https://projects.knownelement.com/issues/435#note-4
This commit is contained in:
2026-09-02 13:23:27 -05:00
parent 53b847985e
commit 8bc1dc6612
6 changed files with 348 additions and 16 deletions
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""kuma-cloudron-sync.py — Uptime Kuma Cloudron app coverage sync [#435]
Compares the committed Cloudron app list (oam/kuma/cloudron-apps.txt) against
live Kuma http monitors, reports coverage gaps, and (with --add) creates the
missing monitors INSIDE the "Cloud Systems" group (never root — founder
ruling 2026-09-02: monitors go in groups).
App list provenance: CT-log enumeration + DNS resolve + HTTPS probe across
all 23 Kuma-monaged domains (2026-09-02, see #435 note). Re-derive with
/tmp-style probe pipeline or a read-only Cloudron API diff when a token
lands. Two apps (forms/learn) return 500 today — monitored red on purpose
(#681).
Env (from ~/.creds/uptime-kuma.env):
UPTIME_KUMA_URL, UPTIME_KUMA_TOKEN
Usage:
python3 kuma-cloudron-sync.py [--add] [--limit N] [--group-id ID]
--add create missing monitors
--limit N add at most N (canary mode)
--group-id ID parent group (default: 48 = "Cloud Systems")
"""
import argparse
import os
import sys
import time
from pathlib import Path
import socketio
HERE = Path(__file__).resolve().parent.parent / "oam" / "kuma" / "cloudron-apps.txt"
DEFAULT_GROUP = 48
def load_apps():
apps = []
for line in HERE.read_text().splitlines():
line = line.split("#", 1)[0].strip()
if line:
apps.append(line)
return apps
def kuma_connect():
sio = socketio.Client(reconnection=False)
state = {}
@sio.on("monitorList")
def _ml(d):
state["ml"] = d
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}")
sio.emit("monitorList")
for _ in range(20):
if "ml" in state:
break
time.sleep(1)
return sio, state.get("ml", {})
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--add", action="store_true")
ap.add_argument("--limit", type=int, default=0)
ap.add_argument("--group-id", type=int, default=DEFAULT_GROUP)
args = ap.parse_args()
apps = load_apps()
sio, mons = kuma_connect()
try:
covered = set()
for m in mons.values():
name = (m.get("name") or "").strip().lower()
url = (m.get("url") or "").strip().lower()
for fqdn in apps:
if fqdn == name or url.rstrip("/") in (
f"https://{fqdn}".rstrip("/"),
f"https://{fqdn}/".rstrip("/"),
):
covered.add(fqdn)
missing = [a for a in apps if a not in covered]
print(f"apps in list: {len(apps)} covered: {len(covered)} missing: {len(missing)}")
for fqdn in missing:
print(f" MISSING {fqdn}")
if args.add and missing:
batch = missing if not args.limit else missing[: args.limit]
for fqdn in batch:
payload = {
"type": "http",
"name": fqdn,
"url": f"https://{fqdn}/",
"interval": 60,
"retryInterval": 60,
"resendInterval": 0,
"maxretries": 2,
"notificationIDList": {"1": True, "2": True},
"upsideDown": False,
"description": "Cloudron app HTTP 200 (#435)",
"httpBodyEncoding": "json",
"accepted_statuscodes": ["200-299"],
"conditions": [],
"active": True,
"parent": args.group_id,
"maxredirects": 10,
}
r = sio.call("add", payload, timeout=30)
ok = isinstance(r, dict) and r.get("ok")
print(f" ADD {fqdn}: {'id=' + str(r.get('monitorID')) if ok else r}")
finally:
sio.disconnect()
if __name__ == "__main__":
main()
+10
View File
@@ -106,7 +106,16 @@ def kuma_connect():
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--add", action="store_true", help="create missing monitors")
ap.add_argument(
"--group-id",
type=int,
default=0,
help="parent group id for new monitors (REQUIRED — root placement "
"is banned per founder ruling 2026-09-02; use groups)",
)
args = ap.parse_args()
if args.add and not args.group_id:
raise SystemExit("--group-id is required with --add (no root monitors)")
targets = fleet_targets()
sio, mons = kuma_connect()
@@ -140,6 +149,7 @@ def main():
"conditions": [],
"active": host not in PAUSED_BY_DEFAULT,
"packetSize": 56,
"parent": args.group_id,
}
r = sio.call("add", payload, timeout=30)
ok = isinstance(r, dict) and r.get("ok")
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""kuma-inventory.py — read-only Uptime Kuma monitor inventory dump [#435]
Connects via the socket.io API (same pattern as kuma-fleet-sync.py) and
prints every monitor with id, type, parent (group), name, target, active
state. Read-only: emits nothing back to the server. Used to plan group
placement and coverage gaps; never mutates.
Env (from ~/.creds/uptime-kuma.env):
UPTIME_KUMA_URL, UPTIME_KUMA_TOKEN
Usage:
python3 kuma-inventory.py # TSV dump: id<TAB>parent<TAB>type<TAB>active<TAB>name<TAB>target
python3 kuma-inventory.py --summary # group counts + root-level offenders only
"""
import argparse
import os
import time
import socketio
def kuma_connect():
sio = socketio.Client(reconnection=False)
state = {}
@sio.on("monitorList")
def _ml(d):
state["ml"] = d
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}")
sio.emit("monitorList")
for _ in range(20):
if "ml" in state:
break
time.sleep(1)
return sio, state.get("ml", {})
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--summary", action="store_true")
args = ap.parse_args()
sio, mons = kuma_connect()
try:
by_id = {str(m.get("id")): m for m in mons.values()}
def name_of(mid):
m = by_id.get(str(mid))
return (m.get("name") or "?") if m else f"id{mid}"
rows = []
for m in mons.values():
mid = m.get("id")
parent = m.get("parent") or m.get("parent_id") or ""
parent = name_of(parent) if parent not in ("", None) else "-"
mtype = m.get("type") or "?"
active = "on" if m.get("active", True) else "PAUSED"
name = (m.get("name") or "?").replace("\t", " ")
target = (m.get("hostname") if m.get("type") == "dns" else None) or m.get("url") or m.get("hostname") or ""
target = str(target).replace("\t", " ")
rows.append((mid, parent, mtype, active, name, target))
rows.sort(key=lambda r: (r[1] == "-", r[1], r[4].lower()))
if args.summary:
groups = [r for r in rows if r[2] == "group"]
root = [r for r in rows if r[1] == "-" and r[2] != "group"]
print(f"total monitors: {len(rows)} groups: {len(groups)} root-level: {len(root)}")
print("\ngroups:")
for r in groups:
kids = sum(1 for x in rows if x[1] == r[4])
print(f" {r[4]} ({kids} children, id={r[0]})")
print("\nroot-level monitors (must move into groups):")
for r in root:
print(f" id={r[0]} [{r[2]}] {r[3]} {r[4]} {r[5]}")
else:
for r in rows:
print("\t".join(str(c) for c in r))
finally:
sio.disconnect()
if __name__ == "__main__":
main()
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""kuma-regroup.py — move Uptime Kuma monitors into groups [#435]
Founder ruling 2026-09-02: no monitors at the root; use the existing
groups. Uses the uptime-kuma-api library — raw socket.io editMonitor with
a hand-built payload times out on this Kuma build (missing form fields
the server requires before acking; the lib sends the full UI-shaped
payload). dns_resolve_type="A" is passed to satisfy lib-side validation
for monitors where the field is None (harmless for non-dns types).
Env (from ~/.creds/uptime-kuma.env):
UPTIME_KUMA_URL, UPTIME_KUMA_TOKEN
Usage (host — needs python-socketio-compatible env via lib):
python3 kuma-regroup.py --dry-move 211:48 213:13
python3 kuma-regroup.py --move 211:48 213:13
id:groupid pairs. --move executes; --dry-move prints the plan only.
Verify afterwards with kuma-inventory.py (the lib's get_monitors does
not map the parent field — trust the raw monitorList view).
"""
import argparse
import os
import sys
from uptime_kuma_api import UptimeKumaApi
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--move", nargs="+", default=[], metavar="ID:GROUPID")
ap.add_argument("--dry-move", nargs="+", default=[], metavar="ID:GROUPID")
args = ap.parse_args()
pairs = [(int(a), int(b)) for a, b in (p.split(":") for p in (args.move or args.dry_move))]
dry = bool(args.dry_move)
api = UptimeKumaApi(os.environ["UPTIME_KUMA_URL"], timeout=30)
rc = 0
try:
api.login_by_token(os.environ["UPTIME_KUMA_TOKEN"])
monitors = {m["id"]: m for m in api.get_monitors()}
for mid, gid in pairs:
m = monitors.get(mid)
if not m:
print(f"SKIP {mid}: not found")
rc = 1
continue
g = monitors.get(gid)
gname = g["name"] if g else "?"
if dry:
print(f"DRY {mid} '{m['name']}' -> group {gid} '{gname}'")
continue
try:
r = api.edit_monitor(mid, parent=gid, dns_resolve_type="A")
ok = isinstance(r, dict) and ("Saved" in str(r.get("msg", "")))
print(f"MOVE {mid} '{m['name']}' -> {gid} '{gname}': {r.get('msg') if ok else r}")
if not ok:
rc = 1
except Exception as e: # noqa: BLE001 — per-move isolation, batch continues
print(f"MOVE {mid} FAIL: {str(e)[:120]}")
rc = 1
finally:
api.disconnect()
return rc
if __name__ == "__main__":
sys.exit(main())