Files
Media-Server-Setup/observability/kibana-alarms.py
Brandyn / Techy b38d40ab0c Elastic security, our own metrics, and an alert pipeline that can actually reach Discord
Observability was running but could not report anything. This closes that end to end and
makes every piece of it reproducible from the installers instead of from a shell history.

Logs
- Filebeat 9 REMOVED the container input; it refuses the config per container and then runs
  with zero harvesters and no index. Container logs now use filestream + the container parser
  with a stable id each, and add_docker_metadata is told where the id is.
- Every line gets event.dataset, or Kibana's Logs rate chart is one "unknown" bucket.
- Beats pin replicas to 0: a single node cannot place one, so the cluster sat yellow forever.

Security
- xpack.security.enabled=true, because Kibana's Observability apps ask Elasticsearch whether
  the user holds a privilege and that API does not exist when security is off (the
  Infrastructure page failed on every view). Nobody sees a login: Kibana's anonymous provider
  signs every visitor in as one native user, and Caddy + Authentik + DIAG_REQUIRE_GROUP stays
  the only gate. configure.py generates the service passwords, creates the accounts and writes
  a root-only netrc for the deadman; --es-security retries just that step.

Alerting
- A basic licence has no webhook connector (403, Gold+), so Kibana's rules write documents into
  a vanguard-alerts index through the free .index connector and vanguard-alerts.timer delivers
  them to Discord. Alerts are therefore searchable beside the logs that caused them.
- Six rules in observability/kibana-alarms.py: disk and memory below 15% free, bot not running,
  bot unhealthy, recordings stuck over an hour, normalise failures parked. Each repeats every
  12 h while unresolved.
- The deadman watches both halves of that pipeline, since nothing inside the stack can report
  that the stack's own alerting died.

Our own metrics
- bin/vgmetrics.sh counters from the sweep and vgdeband, plus live gauges on dumpd /metrics
  (loopback only, unrouted by Caddy), scraped by Metricbeat. vanguard_media_pending is the
  measured answer to whether normalising keeps up, which was previously only a benchmark guess.
- The sweep now captures ffmpeg's stderr: rc= alone was not diagnosable.
- Its file loop reads a process substitution, not a pipe, or the subshell discards every counter.

Housekeeping
- configure.py --apply installs the media scripts (it never did, so applies left stale binaries),
  creates the alert index, injects an ILM delete phase and runs Kibana's first-run setup.
- The discord-bot container asks the bot about itself via /health rather than probing a port.
- Removed the dead diag landing page: Kibana occupies the root of that host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-03 19:58:06 -06:00

151 lines
7.7 KiB
Python
Executable File

#!/usr/bin/env python3
"""The real alarm rules. Idempotent: creates what is missing, leaves what exists.
Every rule is an Elasticsearch query rule that counts matching documents in a window and writes one document
into the alert bus through the "Vanguard alert index" connector, which bin/vanguard-alerts.sh delivers to
Discord. Run observability/kibana-setup.py first — it creates that connector.
FIRING CADENCE: notify_when=onThrottleInterval with throttle=12h, so an alarm that stays true writes again
every 12 hours and stops when the condition clears. The delivery script's own 15-minute per-rule suppression
sits well inside that and never interferes.
WHAT IS DELIBERATELY NOT HERE
Elasticsearch, Kibana and Beat liveness, and the disks, are bin/vanguard-deadman.sh's job. Nothing that
depends on Kibana being alive can report that Kibana is dead, so those checks stay outside the stack and
duplicating them here would be worse than useless: they would go quiet in exactly the case that matters.
pw=$(grep -m1 '^ES_ELASTIC_PASSWORD=' .env | cut -d= -f2-)
KEY=$(curl -s -XPOST -u "elastic:$pw" localhost:9200/_security/api_key \
-H 'Content-Type: application/json' -d '{"name":"vanguard-cli"}' \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["encoded"])')
KEY="$KEY" python3 observability/kibana-alarms.py
To change a rule, edit it in Kibana or delete it there and re-run this. Thresholds here are the defaults:
FREE_PCT 15 alarm when a real filesystem or memory has less than this percent free
STUCK_SECONDS 3600 alarm when a finished recording has waited this long to be normalised
THROTTLE 12h how often an unresolved alarm repeats
"""
import json, os, urllib.error, urllib.request
KIBANA = "http://127.0.0.1:5601"
HEADERS = {"Content-Type": "application/json", "kbn-xsrf": "1",
"Authorization": "ApiKey " + os.environ["KEY"]}
CONNECTOR_NAME = "Vanguard alert index"
FREE_PCT = float(os.environ.get("FREE_PCT", "15"))
STUCK_SECONDS = int(os.environ.get("STUCK_SECONDS", "3600"))
THROTTLE = os.environ.get("THROTTLE", "12h")
USED_MAX = round(1 - FREE_PCT / 100, 4) # 15% free -> used.pct >= 0.85
# Filesystems that are not disks. Without this, every tmpfs and overlay mount reports ~100% used and the
# disk alarm would fire permanently on something that cannot fill up.
PSEUDO_FS = ["tmpfs", "devtmpfs", "overlay", "squashfs", "ramfs", "autofs", "nsfs", "tracefs", "debugfs",
"configfs", "mqueue", "hugetlbfs", "pstore", "bpf", "devpts", "proc", "sysfs", "cgroup",
"cgroup2", "fusectl", "securityfs", "efivarfs", "binfmt_misc", "fuse.lxcfs", "iso9660"]
def rule(name, level, why, query, *, index="metricbeat-*", window=10, comparator=">", threshold=0):
return {"name": name, "level": level, "why": why, "query": query,
"index": index, "window": window, "comparator": comparator, "threshold": threshold}
ALARMS = [
rule("Disk free below %g%%" % FREE_PCT, "error",
"A filesystem is nearly full. Elasticsearch turns its indices READ-ONLY at the flood watermark and "
"does not undo that by itself, and recordings stop being published.",
{"query": {"bool": {
"filter": [{"range": {"system.filesystem.used.pct": {"gte": USED_MAX}}}],
"must_not": [{"terms": {"system.filesystem.type": PSEUDO_FS}}]}}}),
rule("Memory free below %g%%" % FREE_PCT, "warn",
"Memory is nearly exhausted. Elasticsearch has a pinned 2 GB heap, so the pressure is usually "
"ffmpeg or Jellyfin, and the kernel will start killing processes rather than queueing.",
{"query": {"range": {"system.memory.actual.used.pct": {"gte": USED_MAX}}}}),
rule("Discord bot not running", "error",
"No metrics from the discord-bot container for 5 minutes: it is stopped, removed, or restarting too "
"fast to be sampled. Nothing it does is happening.",
{"query": {"term": {"container.name": "discord-bot"}}},
window=5, comparator="<", threshold=1),
rule("Discord bot unhealthy", "error",
"The bot is running but reports itself degraded: its /health says the Discord gateway is "
"disconnected or the database does not answer. It is up and doing nothing.",
{"query": {"bool": {"filter": [{"term": {"container.name": "discord-bot"}},
{"term": {"docker.healthcheck.status": "unhealthy"}}]}}},
window=5),
rule("Recordings stuck waiting to be normalised", "warn",
"A finished recording has waited over %d minutes in .live/. It is not in Jellyfin, not in copyparty "
"and not announced, so to everyone else it does not exist." % (STUCK_SECONDS // 60),
{"query": {"range": {"prometheus.vanguard_media_pending_oldest_seconds.value": {"gt": STUCK_SECONDS}}}}),
rule("Normalise failures parked", "warn",
"One or more files have a normalize-failed log, so the sweep skips them forever until it is deleted. "
"The log now contains ffmpeg's stderr, which says why.",
{"query": {"range": {"prometheus.vanguard_media_failed_files.value": {"gt": 0}}}}),
]
def req(method, path, body=None):
request = urllib.request.Request(KIBANA + path, method=method,
data=None if body is None else json.dumps(body).encode())
for key, value in HEADERS.items():
request.add_header(key, value)
try:
with urllib.request.urlopen(request, timeout=30) as response:
raw = response.read()
return json.loads(raw) if raw else {}
except urllib.error.HTTPError as error:
print(f" {method} {path} -> HTTP {error.code}")
print(" " + error.read().decode(errors="replace")[:600])
raise SystemExit(1)
connectors = {c["name"]: c["id"] for c in req("GET", "/api/actions/connectors")}
if CONNECTOR_NAME not in connectors:
raise SystemExit(f"connector {CONNECTOR_NAME!r} is missing — run observability/kibana-setup.py first")
connector_id = connectors[CONNECTOR_NAME]
present = {r["name"] for r in req("GET", "/api/alerting/rules/_find?per_page=100").get("data", [])}
created = skipped = 0
for alarm in ALARMS:
if alarm["name"] in present:
print("already present:", alarm["name"])
skipped += 1
continue
made = req("POST", "/api/alerting/rule", {
"name": alarm["name"],
"rule_type_id": ".es-query",
"consumer": "stackAlerts",
"enabled": True,
"schedule": {"interval": "1m"},
"params": {
"searchType": "esQuery",
"index": [alarm["index"]],
"timeField": "@timestamp",
"esQuery": json.dumps(alarm["query"]),
"size": 1,
"aggType": "count",
"groupBy": "all",
"thresholdComparator": alarm["comparator"],
"threshold": [alarm["threshold"]],
"timeWindowSize": alarm["window"],
"timeWindowUnit": "m",
"excludeHitsFromPreviousRun": False,
},
"actions": [{
"group": "query matched",
"id": connector_id,
"params": {"documents": [{
"kind": "alarm",
"rule": "{{rule.name}}",
"level": alarm["level"],
"message": alarm["why"] + " [{{context.message}}]",
}]},
# every 12 h for as long as it stays true, then silence when it clears
"frequency": {"summary": False, "notify_when": "onThrottleInterval", "throttle": THROTTLE},
}],
})
print("created:", made["name"], "|", alarm["level"], "| repeats every", THROTTLE)
created += 1
print(f"\n{created} created, {skipped} already there. Disk/memory threshold {FREE_PCT:g}% free; "
f"stuck after {STUCK_SECONDS}s.")