alerting: Alertmanager + Signal relay deliver firing rules to Frank's phone

- dankclaw/observability/quadlet/alertmanager.container: loopback :9093,
  config from shared/prometheus/alertmanager.yml, family conventions
- shared/prometheus/alertmanager.yml: single signal receiver, group_by
  alertname, 30s/5m/4h intervals, webhook to host.containers.internal:18093
- scripts/signal-relay.py + .service: host-side user unit, loopback :18093,
  15-min dedupe, sends via signal-cli account 318311
- prometheus.yml: alerting block to alertmanager:9093; stale comments fixed
- llm-fleet.yml: delivery header; LlmSpecDecodeOff annotation now drafter-agnostic
- deploy.sh/deploy-remote.sh/deploy-plan.py: alertmanager service, config
  copy, relay install, planner routing for config + relay files
- homepage: alertmanager status card (loopback probe)
- tests: alertmanager in UNITS/loopback/health/PAIRS/planner + five new
  delivery-path seams (webhook<->relay contract, prometheus target, relay
  unit, signal-cli account, planner routing)
- docs: Alertmanager.md component page, Observability.md, Prometheus.md,
  index.md, AGENTS.md delivery line
This commit is contained in:
Frank
2026-09-17 13:09:59 -07:00
parent 17450dd172
commit 590853c133
17 changed files with 513 additions and 51 deletions
+12 -1
View File
@@ -38,6 +38,7 @@ SERVICE_ORDER = (
"loki",
"alloy",
"node-exporter",
"alertmanager",
"authentik-postgres",
"authentik-server",
"authentik-worker",
@@ -83,6 +84,7 @@ DANKCLAW_SERVICES = {
"loki",
"alloy",
"node-exporter",
"alertmanager",
"authentik-postgres",
"authentik-server",
"authentik-worker",
@@ -246,6 +248,10 @@ def services_for_path(path: str, target: str) -> set[str]:
_add(services, "searxng")
elif group == "prometheus":
_add(services, "prometheus")
if len(parts) >= 3 and parts[2] == "alertmanager.yml":
# The Alertmanager config ships from this directory: a change
# to it must redeploy the service that installs it.
_add(services, "alertmanager")
elif group == "loki":
_add(services, "loki")
elif group == "alloy":
@@ -320,7 +326,7 @@ def services_for_path(path: str, target: str) -> set[str]:
# consumer (prometheus before grafana, loki before alloy).
if len(parts) >= 4 and parts[2] == "quadlet":
name = parts[3].removesuffix(".container")
if name in {"prometheus", "grafana", "loki", "alloy", "node-exporter"}:
if name in {"prometheus", "grafana", "loki", "alloy", "node-exporter", "alertmanager"}:
_add(services, name)
return services
if len(parts) >= 2 and parts[1] == "gitea":
@@ -391,6 +397,11 @@ def services_for_path(path: str, target: str) -> set[str]:
name = p.name
if name in {"homepage-status.py", "homepage-status.service", "homepage-status.timer"}:
_add(services, "homepage")
if name in {"signal-relay.py", "signal-relay.service"}:
# The Signal relay is installed by the alertmanager deploy, so a
# change to it redeploying alertmanager is the only path that
# reaches it on the host.
_add(services, "alertmanager")
if name in {"frank-small.env", "frank-small.service", "serve-frank-small.sh"}:
# llama.cpp frank-small (2026-09-16) keeps its unit, env and serve
# files here; each one selects frank-small only. The ik_llama.cpp
+33 -3
View File
@@ -86,7 +86,7 @@ service_is_allowed() {
dankclaw:open-webui|dankclaw:gitea-postgres|dankclaw:gitea-server|\
dankclaw:comfyui|dankclaw:hermes|\
dankclaw:hermes-dashboard|dankclaw:hermes-webui|dankclaw:prometheus|dankclaw:grafana|\
dankclaw:loki|dankclaw:alloy|dankclaw:node-exporter|\
dankclaw:loki|dankclaw:alloy|dankclaw:node-exporter|dankclaw:alertmanager|\
dankclaw:authentik-postgres|dankclaw:authentik-server|\
dankclaw:authentik-worker|dankclaw:authentik-outpost|dankclaw:authentik-outpost-ldap|dankclaw:anubis|\
dankclaw:caddy|dankclaw:homepage|dankclaw:seerr|dankclaw:marreta|\
@@ -210,6 +210,31 @@ copy_prometheus_config() {
/opt/prometheus/config/rules/llm-fleet.yml
}
# Alertmanager config ships beside the Prometheus config: the two halves of
# the alerting path change together, and a receiver with no route (or a
# route with no receiver) fails silently.
copy_alertmanager_config() {
run_privileged mkdir -p /opt/alertmanager/config /opt/alertmanager/data
run_privileged cp shared/prometheus/alertmanager.yml /opt/alertmanager/config/alertmanager.yml
}
# The Signal relay is Alertmanager's only webhook consumer and the only
# thing on this host that talks to signal-cli. It is a plain systemd USER
# unit (not a quadlet): it runs python3 from the checkout, so a deploy
# updates it without a copy step, and — unlike generated quadlet units,
# which this host's podman 5.x v1 generator rejects — a hand-written user
# unit can be enabled, and Linger=yes for dank starts it at boot.
install_signal_relay() {
local unit_dir="$HOME/.config/systemd/user"
mkdir -p "$unit_dir"
cp scripts/signal-relay.service "$unit_dir/signal-relay.service"
systemctl --user daemon-reload
systemctl --user enable signal-relay.service
systemctl --user restart signal-relay.service
curl -fsS -m 5 http://127.0.0.1:18093/health >/dev/null \
|| { echo "signal-relay is not answering on 127.0.0.1:18093" >&2; return 1; }
}
copy_loki_config() {
run_privileged mkdir -p /opt/loki/config
run_privileged cp shared/loki/loki-config.yaml /opt/loki/config/loki-config.yaml
@@ -426,13 +451,17 @@ seed_grafana_env() {
deploy_observability() {
local service="$1"
# Observability units live under <target>/observability/quadlet: Dankclaw
# has five, Dankserver one (node-exporter, since 2026-09-16). The config
# has six, Dankserver one (node-exporter, since 2026-09-16). The config
# copies below are Dankclaw-owned state and run only there.
local unit_dir="$TARGET/observability/quadlet"
case "$service" in
prometheus)
copy_prometheus_config
;;
alertmanager)
copy_alertmanager_config
install_signal_relay
;;
loki)
copy_loki_config
build_compose_image loki
@@ -456,6 +485,7 @@ deploy_observability() {
systemctl --user restart "$service.service"
case "$service" in
prometheus) wait_for_health prometheus http://127.0.0.1:9090/-/healthy 60 ;;
alertmanager) wait_for_health alertmanager http://127.0.0.1:9093/-/healthy 60 ;;
grafana) wait_for_health grafana http://127.0.0.1:3002/api/health 90 ;;
loki) wait_for_health loki http://127.0.0.1:3100/ready 60 ;;
alloy) wait_for_health alloy http://127.0.0.1:12345/-/healthy 60 ;;
@@ -1378,7 +1408,7 @@ deploy_service() {
copyparty) deploy_copyparty ;;
plex) deploy_plex ;;
tautulli) deploy_tautulli ;;
prometheus|grafana|loki|alloy|node-exporter) deploy_observability "$service" ;;
prometheus|grafana|loki|alloy|node-exporter|alertmanager) deploy_observability "$service" ;;
comfyui) deploy_comfyui ;;
*) deploy_compose_service "$service" ;;
esac
+1
View File
@@ -138,6 +138,7 @@ valid_service() {
dankclaw:gitea-server|dankclaw:homepage|\
dankclaw:comfyui|dankclaw:hermes|dankclaw:hermes-dashboard|\
dankclaw:hermes-webui|dankclaw:prometheus|dankclaw:grafana|dankclaw:loki|dankclaw:alloy|\
dankclaw:alertmanager|\
dankclaw:node-exporter|dankclaw:authentik-postgres|dankclaw:authentik-server|\
dankclaw:authentik-worker|dankclaw:authentik-outpost|dankclaw:authentik-outpost-ldap|dankclaw:anubis|dankclaw:seerr|\
dankclaw:frank-large|dankclaw:frank-small|\
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""Alertmanager-to-Signal relay for the homelab (Dankclaw).
Listens on 127.0.0.1:18093, receives Alertmanager's webhook payload, formats
each alert group into a short message and hands it to signal-cli under the
existing account on this host. Runs as a systemd user unit
(scripts/signal-relay.service); logs go to the journal.
The phone number and account name are numbers, not credentials — the
credential is the signal-cli account state under
~/.local/share/signal-cli, which never leaves the host.
Stdlib only: this box's user units run the system python3.
"""
import json
import shutil
import subprocess
import sys
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
LISTEN_ADDR = ("127.0.0.1", 18093)
SIGNAL_CLI = "/usr/local/bin/signal-cli"
SIGNAL_ACCOUNT = "318311"
SIGNAL_TO = "+12062476336"
# The relay is a safety net, not the rate limiter: Alertmanager already
# repeats on repeat_interval. This suppresses exact duplicates that arrive
# inside the window (config reloads, double-fires) so a misconfiguration
# cannot fill the phone.
DEDUPE_SECONDS = 15 * 60
_last_sent = {}
# Cap on the instances listed per group; the count is always stated.
MAX_INSTANCES = 3
def log(msg):
print(f"signal-relay: {msg}", file=sys.stderr, flush=True)
def format_group(alerts):
"""One message per webhook delivery (Alertmanager groups by alertname)."""
if not alerts:
return None
first = alerts[0]
name = first.get("labels", {}).get("alertname", "unknown")
state = "firing" if first.get("status") == "firing" else "resolved"
severity = first.get("labels", {}).get("severity", "info").upper()
lines = [f"[{severity}] {name} ({len(alerts)} {state})"]
for a in alerts[:MAX_INSTANCES]:
labels = a.get("labels", {})
ident = labels.get("instance") or labels.get("job") or "?"
summary = (a.get("annotations") or {}).get("summary")
line = f" {ident}"
if summary:
line += f": {summary}"
lines.append(line)
if len(alerts) > MAX_INSTANCES:
lines.append(f" … and {len(alerts) - MAX_INSTANCES} more")
return "\n".join(lines)
def dedupe_key(alerts):
first = alerts[0]
labels = tuple(sorted(first.get("labels", {}).items()))
return (first.get("status"),) + labels
def send_signal(text):
if shutil.which(SIGNAL_CLI) is None:
log(f"signal-cli not found at {SIGNAL_CLI}")
return False
try:
proc = subprocess.run(
[SIGNAL_CLI, "-a", SIGNAL_ACCOUNT, "-o", "json", "send", SIGNAL_TO, text],
capture_output=True, text=True, timeout=30,
)
except subprocess.TimeoutExpired:
log("signal-cli timed out after 30s")
return False
if proc.returncode != 0:
log(f"signal-cli failed rc={proc.returncode}: {proc.stderr.strip()[:300]}")
return False
return True
def handle_alertmanager(payload):
alerts = payload.get("alerts", [])
text = format_group(alerts)
if text is None:
log("empty alert group, nothing sent")
return
now = time.monotonic()
key = dedupe_key(alerts)
_last_sent = {k: v for k, v in _last_sent.items() if now - v < DEDUPE_SECONDS}
if key in _last_sent:
log(f"suppressed duplicate: {key[0]}")
return
if send_signal(text):
_last_sent[key] = now
log(f"sent to {SIGNAL_TO}: {text.splitlines()[0]}")
else:
# Do not mark a failed send: the next repeat should retry.
log("send failed; will retry on the next delivery")
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/health":
self.send_response(200)
self.end_headers()
self.wfile.write(b"ok")
else:
self.send_response(404)
self.end_headers()
def do_POST(self):
if self.path != "/alertmanager":
self.send_response(404)
self.end_headers()
return
length = int(self.headers.get("Content-Length", 0))
try:
payload = json.loads(self.rfile.read(length))
except (ValueError, UnicodeDecodeError) as e:
log(f"bad payload: {e}")
self.send_response(400)
self.end_headers()
return
try:
handle_alertmanager(payload)
except Exception as e: # a relay bug must not 500 Alertmanager
log(f"handler error: {e!r}")
self.send_response(200)
self.end_headers()
self.wfile.write(b"ok")
def log_message(self, fmt, *args):
# Default access log is per-request noise; the journal keeps the
# send/suppress lines, which are the ones that matter.
pass
def main():
server = ThreadingHTTPServer(LISTEN_ADDR, Handler)
log(f"listening on {'.'.join(map(str, LISTEN_ADDR))}, account {SIGNAL_ACCOUNT}")
server.serve_forever()
if __name__ == "__main__":
main()
+37
View File
@@ -0,0 +1,37 @@
# Systemd USER unit: signal-relay (Dankclaw).
#
# Install path: ~/.config/systemd/user/signal-relay.service
# Installed and enabled by install_signal_relay in scripts/deploy-remote.sh
# (part of the alertmanager deploy).
#
# The webhook consumer for Alertmanager: it listens on loopback only and is
# the only thing on this host that talks to signal-cli. It is a plain user
# unit, not a quadlet, on purpose:
#
# * it runs python3 straight from the checkout, so a deploy updates the
# relay without a copy step (the homepage timer copies to
# /usr/local/bin; this one has no root side);
# * generated quadlet units cannot be enabled on this host's podman 5.x
# v1 generator — a hand-written user unit can, and with Linger=yes for
# dank it starts at boot;
# * it needs no container: its only dependencies are /usr/bin/python3
# (system) and /usr/local/bin/signal-cli (host install, account state
# under ~/.local/share/signal-cli).
#
# Loopback-only for the same reason every observability port is: an
# unauthenticated message-sending endpoint has no business on a host
# interface. Alertmanager reaches it from its container via
# host.containers.internal.
[Unit]
Description=Alertmanager-to-Signal relay (Dankclaw)
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/bin/python3 /home/dank/serverAppConfigs/scripts/signal-relay.py
Restart=always
RestartSec=5
[Install]
WantedBy=default.target