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
+2 -16
View File
@@ -5,19 +5,5 @@ A commit is blocked while any task below remains unchecked.
## Current Tasks
(session state persisted to Redmine #367/#368, Discourse audit #298 reply 51,
Discourse k8s wiki #305 reply 8, and git PFVCluster d3c5ae8/1f44a19)
- [x] k3s HA control plane rebuilt: 3/3 cnodes Ready, etcd quorum, taints [#367]
- [x] Proxmox tuning applied (ssd/discard, queues=2, cpuunits, startup order) [#367]
- [x] Founder moved cnode disks to TS5-SSD; etcd latency 4-5s → <1s [#367]
- [x] Workers joined: tsys3/6/7/9 + ultix-streaming (replaces tsys5 slot) [#368]
- [x] Serial bounce wave completed; test pod schedules on worker only [#368]
- [x] post-setup.sh kubectl bug + join-workers.sh timeouts fixed; tuning codified [#367][#368]
- [x] ultix-offstage excluded (unreachable) — join when back on tailnet [#368]
- [x] hap-bridge removed from pfv-bms host (unsupported_system_software fixed; supported=true) [#619]
- [x] hap-bridge.py merge fix + 5 unit tests (babd6d2) [#619]
- [x] Core watchdog re-enabled (founder-approved); community SSH add-on confirmed gone [#344]
- [x] Lock DHCP-table verdict: no lock OUIs, BLE-only likely — answered in needfromcharles §3 [#619]
- [x] iDRAC probes: both live, TLS1.0-only, no Redfish (hardware gen), SNMP off — #625 note + creds ask §9
- [x] HA backup pre-cleanup-2026-09-02 verified (692MB); image prune 0B (nothing dangling; 4GB optional via -a, left to founder)
(none — sessions track their own state in .crush/active-ticket*; last
completed: Kuma Cloudron coverage + root cleanup, #435)
+55
View File
@@ -0,0 +1,55 @@
ap4ap.org
bi.knownelement.com
bookmarks.knownelement.com
broadcast.thepeernet.com
broadcast.turnsys.com
building-management.knownelement.com
chat.knownelement.com
community.turnsys.com
erp.knownelement.com
esign.knownelement.com
ezeda.org
ezpodstack.org
finance.turnsys.com
forms.knownelement.com
git.knownelement.com
grafana.knownelement.com
helpdesk.knownelement.com
hfnfc.net
hfnoc.net
ipam.knownelement.com
jupyter.knownelement.com
knownelement.com
learn.turnsys.com
marketing.turnsys.com
matrix.turnsys.com
meetmorse.com
merchantsofhope.org
my.knownelement.com
nextcloud.knownelement.com
paperless.knownelement.com
photos.knownelement.com
phplist.knownelement.com
projects.knownelement.com
pwvault.turnsys.com
rackrental.net
reachableceo.com
readlater.knownelement.com
recordings.thepeernet.com
redwfo.com
rss.knownelement.com
rwscp.net
sidedoorgroup.org
sol-calc.com
startinglineproductions.com
staticbits.knownelement.com
status.knownelement.com
suborbital-systems.com
surveys.knownelement.com
thepeernet.com
turnsys.com
uksrs.org
url.knownelement.com
webmail.knownelement.com
webstats.knownelement.com
whiteboard.knownelement.com
+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())