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:
+2
-16
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user