Phase 13 build: logs, metrics, alerting and a deadman on a diag. host
Six containers, all loopback-published, reachable only through the gated diag. host: victorialogs 30d retention, -http.pathPrefix=/logs so Caddy passes the prefix through unchanged vector docker_logs + journald -> VictoriaLogs (elasticsearch bulk, the documented path) victoriametrics 90d, scrapes the four taps that were already live plus the new services vmalert-metrics 11 metric rules: disk, load, memory, RAID, restart loops, a core container gone vmalert-logs 5 LogsQL rules: sweep failures, publish-deny bursts, failed bot deploys, cert trouble alertmanager groups and inhibits, posts to Discord, webhook read from a 0600 file not the config The deadman (bin/vanguard-deadman.sh, host timer, 5 min) is the part that does not trust any of the above: curl only, it checks each component answers, that the always-firing Watchdog alert is still arriving through vmalert -> Alertmanager -> dumpd /heartbeat, and the disks. It reports on change and announces recovery once. Verified here: failures listed, silence when nothing changes, disk threshold, recovery fired once. Collection uses the Docker socket (Phase 13 option A, chosen explicitly). That is root-equivalent on this host and is recorded as such in vector.yaml with the two upgrade paths. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -153,6 +153,67 @@ clips.example.com {
|
||||
}
|
||||
}
|
||||
|
||||
# vanguard:diag
|
||||
diag.example.com {
|
||||
encode zstd gzip
|
||||
log {
|
||||
output stderr
|
||||
format filter {
|
||||
wrap json
|
||||
fields {
|
||||
request>headers>Cookie delete
|
||||
request>headers>Authorization delete
|
||||
request>headers>X-Stream-Auth delete
|
||||
}
|
||||
}
|
||||
}
|
||||
# /logout on every gated host = sign out of the Discord login (domain-wide session)
|
||||
redir /logout /outpost.goauthentik.io/sign_out 302
|
||||
# Authentik forward-auth (embedded outpost). Needs a Proxy Provider in Authentik — domain-level for the
|
||||
# whole domain (recommended) or single-application for this host — assigned to the embedded outpost.
|
||||
# Until then: 4xx, never open. Per-host group requirements are enforced below from X-Authentik-Groups.
|
||||
reverse_proxy /outpost.goauthentik.io/* 127.0.0.1:9000
|
||||
forward_auth 127.0.0.1:9000 {
|
||||
uri /outpost.goauthentik.io/auth/caddy
|
||||
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Entitlements X-Authentik-Email X-Authentik-Name X-Authentik-Uid X-Authentik-Jwt X-Authentik-Meta-Jwks X-Authentik-Meta-Outpost X-Authentik-Meta-Provider X-Authentik-Meta-App X-Authentik-Meta-Version
|
||||
trusted_proxies private_ranges
|
||||
}
|
||||
@lacks_group not header_regexp X-Authentik-Groups (^|\|)media\-admins(\||$)
|
||||
handle @lacks_group {
|
||||
respond "This page is for members of the 'media-admins' group." 403
|
||||
}
|
||||
# VictoriaLogs runs with -http.pathPrefix=/logs and emits /logs/... URLs itself, so the prefix is
|
||||
# passed through unchanged. Stripping it would break the UI's own asset paths.
|
||||
redir /logs /logs/select/vmui/ 302
|
||||
redir /logs/ /logs/select/vmui/ 302
|
||||
handle /logs* {
|
||||
reverse_proxy 127.0.0.1:9428
|
||||
}
|
||||
handle /metrics-db* {
|
||||
reverse_proxy 127.0.0.1:8428 # VictoriaMetrics UI + query API
|
||||
}
|
||||
handle /alerts* {
|
||||
reverse_proxy 127.0.0.1:9093 # Alertmanager: what is firing, silences
|
||||
}
|
||||
handle /rules-metrics* {
|
||||
reverse_proxy 127.0.0.1:8880 # vmalert over VictoriaMetrics
|
||||
}
|
||||
handle /rules-logs* {
|
||||
reverse_proxy 127.0.0.1:8881 # vmalert over VictoriaLogs
|
||||
}
|
||||
handle {
|
||||
respond "Vanguard diagnostics
|
||||
|
||||
/logs logs (VictoriaLogs)
|
||||
/metrics-db metrics (VictoriaMetrics)
|
||||
/alerts what is firing (Alertmanager)
|
||||
/rules-metrics metric rules (vmalert)
|
||||
/rules-logs log rules (vmalert)
|
||||
|
||||
Alerts go to Discord. The deadman runs on the host and watches this stack." 200
|
||||
}
|
||||
}
|
||||
|
||||
# vanguard:live
|
||||
live.example.com {
|
||||
encode zstd gzip
|
||||
|
||||
@@ -320,7 +320,53 @@ permanent reference is needed for long sessions, not just a start burst.
|
||||
export at 2560×1440)
|
||||
- rejected: LTC audio (SRT multitrack + per-PC generator); clock inside the picture; cropping at remux (re-encode)
|
||||
|
||||
### Phase 13 — push-to-deploy (built; OFF — `DEPLOY_AUTO=0`, CI workflow in draft-workflows/)
|
||||
### Phase 13 — `diag.` host: logs, metrics, alerting, deadman (NEXT)
|
||||
|
||||
**Decided 2026-11-03: VictoriaLogs**, on its own hostname `diag.BASE`, with the log UI at `diag.BASE/logs`.
|
||||
Rejected: ELK (a JVM heap and a growing index on a box that spends its nights running x264, to search one
|
||||
host's logs), and Grafana + Loki (two services and a dashboard tool we would only use as a log viewer).
|
||||
VictoriaLogs is one container with its own UI and the same `/metrics` endpoint style as the taps we already
|
||||
expose. The framework everything feeds from is `LOGGING.md`; this phase only adds the store and the collector.
|
||||
|
||||
- [ ] CNAME `diag` → the main host
|
||||
- [ ] `configure.py`: new host role `diag`, Caddy block gated by `DIAG_REQUIRE_GROUP` (default `media-admins`)
|
||||
- [ ] `victoria-logs` container, `127.0.0.1:9428`, data in `/srv/appdata/victorialogs`, run with
|
||||
`-http.pathPrefix=/logs` so its own UI emits `/logs/...` URLs; Caddy uses `handle /logs*` and does **not**
|
||||
strip the prefix (stripping breaks vmui's asset paths). `redir /logs /logs/select/vmui/ 302` for humans.
|
||||
- [ ] collector: **Vector**, one container, two sources — `journald` for our units (dumpd, normalize-sweep,
|
||||
trash-purge, deploy-pending, vgdeband) and Docker logs for the 15 containers. The
|
||||
`vanguard.service` / `vanguard.category` labels already on every container become stream fields, so
|
||||
filtering by category works without parsing. Parse stages per source as `LOGGING.md` lists them
|
||||
(JSON for caddy/authentik, logfmt for syncthing and our own scripts, one regex for mediamtx,
|
||||
multiline + regex for jellyfin).
|
||||
- [ ] **Decide at build time how Vector reads container logs.** Its `docker_logs` source needs the Docker API,
|
||||
and mounting the socket is root-equivalent on this host — the same exposure act_runner already has. The
|
||||
alternative is a read-only socket proxy in front of it, one more container but no root. Reading the
|
||||
json files directly avoids both and loses the container labels, which is most of the value.
|
||||
- [ ] retention: one global `-retentionPeriod` (start at 30d and watch the disk). Keeping `auth` and `deploy`
|
||||
longer than the rest would mean separate tenants — not worth it unless an audit needs it.
|
||||
- [ ] switch on the metrics scrape at the same time if wanted: the four taps are already live
|
||||
(node_exporter 9100, cAdvisor 9101, MediaMTX 9998, Caddy 2019) and VictoriaMetrics beside VictoriaLogs
|
||||
would consume them. Not required for logs.
|
||||
- [ ] **alerts — VictoriaLogs does NOT alert.** It stores and queries, and exposes `/select/logsql/stats_query`
|
||||
(+ `_range`) returning Prometheus-compatible results. Alerting is **vmalert** evaluating rules against
|
||||
those endpoints, and **Alertmanager** receiving what fires (it has a native Discord receiver). That chain
|
||||
covers *log* conditions only — a sweep `event=failed`, a burst of `publish_deny`, a failed bot deploy.
|
||||
Disk, load and restart loops are *metrics*, so alerting on them additionally needs VictoriaMetrics
|
||||
scraping the four taps that are already live. Full picture: four containers.
|
||||
- [ ] **Do this first, instead, and keep it either way**: `bin/vanguard-watch.sh` on a timer — disk on `/srv`
|
||||
over 85 %, any `*.normalize-failed.log`, a container missing from `docker compose ps`, dumpd `level=error`
|
||||
in the last interval → one Discord webhook post. About forty lines, no new services, and it covers the
|
||||
failure modes that actually take the platform down. The vmalert/Alertmanager stack is the grown-up
|
||||
version for thresholds, grouping and silencing, not a prerequisite for being warned.
|
||||
|
||||
**Later on the same host — ops controls.** Restart buttons and similar, so `diag.` becomes the one place an
|
||||
admin looks and acts. Design note so it is not built the easy wrong way: the page must not get Docker access.
|
||||
Put each action behind a dumpd endpoint with a fixed allowlist (restart *this* named service, run the sweep now,
|
||||
purge trash now), authorise from the Authentik headers as everything else does, and log every invocation as a
|
||||
logfmt event so the action lands in the same place as the logs it is diagnosing. No generic "run a command".
|
||||
|
||||
### Phase 14 — push-to-deploy (built; OFF — `DEPLOY_AUTO=0`, CI workflow in draft-workflows/)
|
||||
- server folder = git clone (`switchToClone.sh`); generated files gitignored and preserved
|
||||
- `vanguard-deploy pull|apply|rollback|pending|status` applies only the changed parts; `check.sh` gates it;
|
||||
MediaMTX restarts are deferred while anything is live (`deploy-pending.timer`)
|
||||
@@ -384,52 +430,6 @@ Checklist if adopted:
|
||||
Swap, not redesign: same compose, same `/srv` layout; restore `/srv/appdata` + `/srv/media`.
|
||||
- [ ] home archive puller (`home/rclone-archive.sh`) on the home box, nightly cron
|
||||
|
||||
### Phase 14 — `diag.` host: logs now, ops controls later
|
||||
|
||||
**Decided 2026-11-03: VictoriaLogs**, on its own hostname `diag.BASE`, with the log UI at `diag.BASE/logs`.
|
||||
Rejected: ELK (a JVM heap and a growing index on a box that spends its nights running x264, to search one
|
||||
host's logs), and Grafana + Loki (two services and a dashboard tool we would only use as a log viewer).
|
||||
VictoriaLogs is one container with its own UI and the same `/metrics` endpoint style as the taps we already
|
||||
expose. The framework everything feeds from is `LOGGING.md`; this phase only adds the store and the collector.
|
||||
|
||||
- [ ] CNAME `diag` → the main host
|
||||
- [ ] `configure.py`: new host role `diag`, Caddy block gated by `DIAG_REQUIRE_GROUP` (default `media-admins`)
|
||||
- [ ] `victoria-logs` container, `127.0.0.1:9428`, data in `/srv/appdata/victorialogs`, run with
|
||||
`-http.pathPrefix=/logs` so its own UI emits `/logs/...` URLs; Caddy uses `handle /logs*` and does **not**
|
||||
strip the prefix (stripping breaks vmui's asset paths). `redir /logs /logs/select/vmui/ 302` for humans.
|
||||
- [ ] collector: **Vector**, one container, two sources — `journald` for our units (dumpd, normalize-sweep,
|
||||
trash-purge, deploy-pending, vgdeband) and Docker logs for the 15 containers. The
|
||||
`vanguard.service` / `vanguard.category` labels already on every container become stream fields, so
|
||||
filtering by category works without parsing. Parse stages per source as `LOGGING.md` lists them
|
||||
(JSON for caddy/authentik, logfmt for syncthing and our own scripts, one regex for mediamtx,
|
||||
multiline + regex for jellyfin).
|
||||
- [ ] **Decide at build time how Vector reads container logs.** Its `docker_logs` source needs the Docker API,
|
||||
and mounting the socket is root-equivalent on this host — the same exposure act_runner already has. The
|
||||
alternative is a read-only socket proxy in front of it, one more container but no root. Reading the
|
||||
json files directly avoids both and loses the container labels, which is most of the value.
|
||||
- [ ] retention: one global `-retentionPeriod` (start at 30d and watch the disk). Keeping `auth` and `deploy`
|
||||
longer than the rest would mean separate tenants — not worth it unless an audit needs it.
|
||||
- [ ] switch on the metrics scrape at the same time if wanted: the four taps are already live
|
||||
(node_exporter 9100, cAdvisor 9101, MediaMTX 9998, Caddy 2019) and VictoriaMetrics beside VictoriaLogs
|
||||
would consume them. Not required for logs.
|
||||
- [ ] **alerts — VictoriaLogs does NOT alert.** It stores and queries, and exposes `/select/logsql/stats_query`
|
||||
(+ `_range`) returning Prometheus-compatible results. Alerting is **vmalert** evaluating rules against
|
||||
those endpoints, and **Alertmanager** receiving what fires (it has a native Discord receiver). That chain
|
||||
covers *log* conditions only — a sweep `event=failed`, a burst of `publish_deny`, a failed bot deploy.
|
||||
Disk, load and restart loops are *metrics*, so alerting on them additionally needs VictoriaMetrics
|
||||
scraping the four taps that are already live. Full picture: four containers.
|
||||
- [ ] **Do this first, instead, and keep it either way**: `bin/vanguard-watch.sh` on a timer — disk on `/srv`
|
||||
over 85 %, any `*.normalize-failed.log`, a container missing from `docker compose ps`, dumpd `level=error`
|
||||
in the last interval → one Discord webhook post. About forty lines, no new services, and it covers the
|
||||
failure modes that actually take the platform down. The vmalert/Alertmanager stack is the grown-up
|
||||
version for thresholds, grouping and silencing, not a prerequisite for being warned.
|
||||
|
||||
**Later on the same host — ops controls.** Restart buttons and similar, so `diag.` becomes the one place an
|
||||
admin looks and acts. Design note so it is not built the easy wrong way: the page must not get Docker access.
|
||||
Put each action behind a dumpd endpoint with a fixed allowlist (restart *this* named service, run the sweep now,
|
||||
purge trash now), authorise from the Authentik headers as everything else does, and log every invocation as a
|
||||
logfmt event so the action lands in the same place as the logs it is diagnosing. No generic "run a command".
|
||||
|
||||
|
||||
## Risks / gotchas
|
||||
|
||||
|
||||
15
bin/dumpd.py
15
bin/dumpd.py
@@ -12,6 +12,8 @@ POST /me/clips -> {"device_id": "..."}: create clips/<user> (+ .stignore), add
|
||||
POST /deploy -> Gitea push webhook (bridge port only, HMAC-verified with DEPLOY_WEBHOOK_SECRET from
|
||||
/etc/vanguard/deploy.env). Runs /usr/local/bin/vanguard-deploy pull ONLY when DEPLOY_AUTO=1;
|
||||
otherwise logs the push and answers 202 "auto-deploy disabled".
|
||||
POST /heartbeat -> Alertmanager's Watchdog route: touches a file the deadman timer checks, so a dead
|
||||
alerting pipeline becomes visible (bin/vanguard-deadman.sh).
|
||||
POST /deploy-bot -> Gitea Actions (job container -> host gateway) hands over a Discord-bot release bundle,
|
||||
HMAC-SHA256 signed with BOT_DEPLOY_SECRET; installs it with vanguard-deploy-bot.
|
||||
May carry X-Vanguard-Env (base64 JSON): the bot's own settings, kept in Gitea, written
|
||||
@@ -326,6 +328,8 @@ def apply_bot_env(values):
|
||||
log("bot_env_applied", keys=",".join(applied), refused=",".join(refused) or None)
|
||||
return applied, refused
|
||||
|
||||
HEARTBEAT_FILE = Path(os.environ.get("ALERT_HEARTBEAT_FILE", "/srv/media/.markers/alert-heartbeat"))
|
||||
|
||||
_bot_deploy_lock = threading.Lock()
|
||||
BOT_DEPLOY_MAX_BYTES = 300 * 1024 * 1024
|
||||
BOT_DEPLOY_INSTALLER = os.environ.get("BOT_DEPLOY_INSTALLER", "/usr/local/bin/vanguard-deploy-bot")
|
||||
@@ -493,6 +497,17 @@ class H(BaseHTTPRequestHandler):
|
||||
"files": [str(f.relative_to(ROOT)) for f in files],
|
||||
"gb": round(sum(f.stat().st_size for f in files) / 1e9, 2)})
|
||||
def do_POST(self):
|
||||
if self.server.server_port == AUTH_LISTEN[1] and self.path.startswith("/heartbeat"):
|
||||
# Alertmanager posts here every minute because the Watchdog alert always fires. Nothing is read from
|
||||
# the body and nothing is authenticated: the port only accepts the Docker bridge, and the only thing
|
||||
# this grants is keeping a timestamp fresh. The deadman treats a STALE file as the alarm.
|
||||
try:
|
||||
self.rfile.read(int(self.headers.get("Content-Length") or 0))
|
||||
HEARTBEAT_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
HEARTBEAT_FILE.write_text(f"{int(_time.time())}\n")
|
||||
except OSError as error:
|
||||
return self._json(500, {"error": str(error)})
|
||||
return self._json(200, {"ok": True})
|
||||
if self.server.server_port == AUTH_LISTEN[1] and self.path.startswith("/deploy-bot"):
|
||||
# The bot's CI posts its release bundle here (bridge-only port; the firewall allows docker0/br-* alone).
|
||||
# The body is the artifact; the signature proves it came from a runner holding BOT_DEPLOY_SECRET.
|
||||
|
||||
76
bin/vanguard-deadman.sh
Executable file
76
bin/vanguard-deadman.sh
Executable file
@@ -0,0 +1,76 @@
|
||||
#!/bin/bash
|
||||
# The deadman: the one check that does not depend on the observability stack being alive.
|
||||
#
|
||||
# vmalert and Alertmanager tell you when something on the box is wrong. Nothing inside that chain can tell you
|
||||
# the chain itself is down, and silence looks exactly like health. So this runs on the HOST from a systemd
|
||||
# timer, uses nothing but curl, and posts straight to a Discord webhook.
|
||||
#
|
||||
# It checks three things:
|
||||
# 1. every piece of the stack answers its health endpoint
|
||||
# 2. Alertmanager's Watchdog heartbeat is fresh (proves rule evaluation AND routing work end to end,
|
||||
# not merely that the processes are up)
|
||||
# 3. the disks, because that is the failure this box is most likely to die of, and it must be reported
|
||||
# even when the thing that would normally report it is the thing that broke
|
||||
#
|
||||
# State in /var/lib/vanguard/deadman.state: it announces a change, not the same thing every five minutes,
|
||||
# and says so once when everything recovers.
|
||||
set -uo pipefail
|
||||
|
||||
WEBHOOK_FILE="${ALERT_WEBHOOK_FILE:-/etc/vanguard/alert-webhook}"
|
||||
HEARTBEAT="${ALERT_HEARTBEAT_FILE:-/srv/media/.markers/alert-heartbeat}"
|
||||
HEARTBEAT_MAX_S="${HEARTBEAT_MAX_S:-600}"
|
||||
STATE="${DEADMAN_STATE:-/var/lib/vanguard/deadman.state}"
|
||||
DISK_PCT="${DEADMAN_DISK_PCT:-90}"
|
||||
|
||||
problems=()
|
||||
note() { problems+=("$1"); }
|
||||
|
||||
alive() { # alive <label> <url>
|
||||
local code
|
||||
code=$(curl -s -o /dev/null -m 5 -w '%{http_code}' "$2" || true)
|
||||
[[ $code == 2* || $code == 3* ]] || note "$1 is not answering (HTTP ${code:-no response})"
|
||||
}
|
||||
|
||||
alive "VictoriaLogs" "http://127.0.0.1:9428/logs/health"
|
||||
alive "VictoriaMetrics" "http://127.0.0.1:8428/health"
|
||||
alive "vmalert (metrics)" "http://127.0.0.1:8880/health"
|
||||
alive "vmalert (logs)" "http://127.0.0.1:8881/health"
|
||||
alive "Alertmanager" "http://127.0.0.1:9093/-/healthy"
|
||||
|
||||
# 2. the heartbeat. A missing file right after a restart is not yet a fault, so only age counts.
|
||||
if [[ -f $HEARTBEAT ]]; then
|
||||
age=$(( $(date +%s) - $(stat -c %Y "$HEARTBEAT") ))
|
||||
(( age <= HEARTBEAT_MAX_S )) || note "no alert heartbeat for ${age}s (vmalert → Alertmanager → dumpd path is broken)"
|
||||
else
|
||||
note "no alert heartbeat has ever arrived (is the Watchdog rule loaded and routed?)"
|
||||
fi
|
||||
|
||||
# 3. disks, independently of any exporter
|
||||
while read -r pct mount; do
|
||||
(( pct >= DISK_PCT )) && note "$mount is ${pct}% full"
|
||||
done < <(df --output=pcent,target /srv / 2>/dev/null | tail -n +2 | tr -d '%' | awk '{print $1, $2}')
|
||||
|
||||
# ---- report only on change ---------------------------------------------------------------------------
|
||||
now="$(IFS='|'; echo "${problems[*]-}")"
|
||||
mkdir -p "$(dirname "$STATE")"
|
||||
was="$(cat "$STATE" 2>/dev/null || true)"
|
||||
printf '%s' "$now" > "$STATE"
|
||||
[[ $now == "$was" ]] && exit 0 # nothing changed since the last run: stay quiet
|
||||
|
||||
post() { # post <text>
|
||||
[[ -s $WEBHOOK_FILE ]] || { echo "deadman: no webhook in $WEBHOOK_FILE; not sent: $1" >&2; return; }
|
||||
curl -s -m 15 -H 'Content-Type: application/json' \
|
||||
--data "$(python3 -c 'import json,sys; print(json.dumps({"content": sys.argv[1][:1900]}))' "$1")" \
|
||||
"$(cat "$WEBHOOK_FILE")" >/dev/null || echo "deadman: webhook post failed" >&2
|
||||
}
|
||||
|
||||
if [[ -z $now ]]; then
|
||||
echo "deadman: recovered"
|
||||
post "✅ **Vanguard monitoring recovered** — the alerting stack and disks are healthy again."
|
||||
else
|
||||
printf 'deadman: %s\n' "${problems[@]}"
|
||||
body="⚠️ **Vanguard monitoring is not healthy** (this message comes from the host, outside the stack):"
|
||||
for item in "${problems[@]}"; do body+=$'\n'"• $item"; done
|
||||
body+=$'\n'"Until this clears, assume no other alert can reach you."
|
||||
post "$body"
|
||||
fi
|
||||
82
configure.py
82
configure.py
@@ -172,7 +172,7 @@ def gate(required_group=""):
|
||||
"""
|
||||
return block
|
||||
|
||||
def write_caddy(domain, auth_host="", watch_host="", git_host="", bot_host="", live_host="", files_host="", sync_host="", clips_host=""):
|
||||
def write_caddy(domain, auth_host="", watch_host="", git_host="", bot_host="", live_host="", files_host="", sync_host="", clips_host="", diag_host=""):
|
||||
env = read_env()
|
||||
gate_any = gate("")
|
||||
extra = ""
|
||||
@@ -223,6 +223,43 @@ def write_caddy(domain, auth_host="", watch_host="", git_host="", bot_host="", l
|
||||
file_server
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
if diag_host:
|
||||
extra += f"""
|
||||
# vanguard:diag
|
||||
{diag_host} {{
|
||||
encode zstd gzip
|
||||
{gate(env.get("DIAG_REQUIRE_GROUP", "media-admins"))} # VictoriaLogs runs with -http.pathPrefix=/logs and emits /logs/... URLs itself, so the prefix is
|
||||
# passed through unchanged. Stripping it would break the UI's own asset paths.
|
||||
redir /logs /logs/select/vmui/ 302
|
||||
redir /logs/ /logs/select/vmui/ 302
|
||||
handle /logs* {{
|
||||
reverse_proxy 127.0.0.1:9428
|
||||
}}
|
||||
handle /metrics-db* {{
|
||||
reverse_proxy 127.0.0.1:8428 # VictoriaMetrics UI + query API
|
||||
}}
|
||||
handle /alerts* {{
|
||||
reverse_proxy 127.0.0.1:9093 # Alertmanager: what is firing, silences
|
||||
}}
|
||||
handle /rules-metrics* {{
|
||||
reverse_proxy 127.0.0.1:8880 # vmalert over VictoriaMetrics
|
||||
}}
|
||||
handle /rules-logs* {{
|
||||
reverse_proxy 127.0.0.1:8881 # vmalert over VictoriaLogs
|
||||
}}
|
||||
handle {{
|
||||
respond "Vanguard diagnostics
|
||||
|
||||
/logs logs (VictoriaLogs)
|
||||
/metrics-db metrics (VictoriaMetrics)
|
||||
/alerts what is firing (Alertmanager)
|
||||
/rules-metrics metric rules (vmalert)
|
||||
/rules-logs log rules (vmalert)
|
||||
|
||||
Alerts go to Discord. The deadman runs on the host and watches this stack." 200
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
if live_host:
|
||||
extra += f"""
|
||||
@@ -333,11 +370,12 @@ name: {{http.request.header.X-Authentik-Name}}"
|
||||
}}
|
||||
""" + extra)
|
||||
|
||||
def write_env(watch_host, discord_token=None, discord_guild=None, git_host="", bot_host="", live_host="", main_host="", live_webhook=None, files_host="", sync_host="", clips_host=""):
|
||||
def write_env(watch_host, discord_token=None, discord_guild=None, git_host="", bot_host="", live_host="", main_host="", live_webhook=None, files_host="", sync_host="", clips_host="", diag_host=""):
|
||||
env = read_env()
|
||||
if files_host: env["FILES_HOST"] = files_host
|
||||
if sync_host: env["SYNC_HOST"] = sync_host
|
||||
if clips_host: env["CLIPS_HOST"] = clips_host
|
||||
if diag_host: env["DIAG_HOST"] = diag_host
|
||||
env["LIVE_HOST"] = live_host or env.get("LIVE_HOST", "live.example.com")
|
||||
if main_host: env["MAIN_HOST"] = main_host
|
||||
if live_webhook is not None: env["LIVE_WEBHOOK_URL"] = live_webhook
|
||||
@@ -373,6 +411,8 @@ def write_env(watch_host, discord_token=None, discord_guild=None, git_host="", b
|
||||
env.setdefault("BOT_DEPLOY_SECRET", secrets.token_hex(32)) # Gitea repo secret BOT_DEPLOY_SECRET: signs the bot's release bundle
|
||||
env.setdefault("BOT_DEPLOY_AUTO", "1") # 0 = refuse bundles posted by CI; install by hand instead
|
||||
env.setdefault("BOT_ENV_KEYS", "DISCORD_BOT_TOKEN,DISCORD_GUILD_ID") # the only .env keys the bot's CI may set
|
||||
env.setdefault("DIAG_REQUIRE_GROUP", "media-admins") # who may see logs, metrics and alerts
|
||||
env.setdefault("ALERT_WEBHOOK_URL", "") # Discord webhook for Alertmanager AND the deadman
|
||||
env.setdefault("DISCORD_BOT_TOKEN", "") # fill in: Discord dev portal -> the Authentik app -> Bot
|
||||
env.setdefault("DISCORD_GUILD_ID", "")
|
||||
env["WATCH_HOST"] = watch_host or "watch.example.com"
|
||||
@@ -512,15 +552,15 @@ def run(cmd, **kw):
|
||||
if DRY: return 0
|
||||
return subprocess.call(cmd, **kw)
|
||||
|
||||
def apply(domain, admin_pw, streamers, editors, auth_host="", watch_host="", discord_token=None, discord_guild=None, git_host="", bot_host="", live_host="", live_webhook=None, files_host="", sync_host="", clips_host=""):
|
||||
def apply(domain, admin_pw, streamers, editors, auth_host="", watch_host="", discord_token=None, discord_guild=None, git_host="", bot_host="", live_host="", live_webhook=None, files_host="", sync_host="", clips_host="", diag_host=""):
|
||||
# admin_pw / editors are legacy no-ops: access is by Authentik group now
|
||||
if not streamers and Path("mediamtx.yml").exists() and "CHANGEME" in Path("mediamtx.yml").read_text():
|
||||
print("\n!! mediamtx.yml contains CHANGEME placeholders — it looks like the repo template was copied over the")
|
||||
print(" generated config. Applying now would leave NO streamers (OBS ingest and the dump button stop working).")
|
||||
if not yes(" continue anyway?", False): print("aborted; add streamers first ([s] in the menu)"); sys.exit(1)
|
||||
print("\n== writing configs")
|
||||
write_env(watch_host, discord_token, discord_guild, git_host, bot_host, live_host, domain, live_webhook, files_host, sync_host, clips_host)
|
||||
write_caddy(domain, auth_host, watch_host, git_host, bot_host, live_host, files_host, sync_host, clips_host); write_mediamtx(domain); write_copyparty()
|
||||
write_env(watch_host, discord_token, discord_guild, git_host, bot_host, live_host, domain, live_webhook, files_host, sync_host, clips_host, diag_host)
|
||||
write_caddy(domain, auth_host, watch_host, git_host, bot_host, live_host, files_host, sync_host, clips_host, diag_host); write_mediamtx(domain); write_copyparty()
|
||||
Path("stream-token.secret").write_text(read_env()["STREAM_TOKEN_SECRET"] + "\n"); os.chmod("stream-token.secret", 0o600)
|
||||
run(["sudo", "install", "-d", "-o", os.environ.get("SUDO_USER") or os.environ.get("USER") or "debian", "/srv/live"])
|
||||
print("== app data folders")
|
||||
@@ -539,6 +579,8 @@ def apply(domain, admin_pw, streamers, editors, auth_host="", watch_host="", dis
|
||||
run(["sudo", "install", "-m", "0644", "syncthing/stignore", "/etc/vanguard/stignore"]) # dumpd copies it into new clips folders
|
||||
e = read_env()
|
||||
Path("deploy.env").write_text("".join(f"{k}={e.get(k, '')}\n" for k in ("DEPLOY_AUTO", "DEPLOY_WEBHOOK_SECRET", "DEPLOY_BRANCH", "DEPLOY_NOTIFY_WEBHOOK_URL", "BOT_DEPLOY_AUTO", "BOT_DEPLOY_SECRET", "BOT_ENV_KEYS")) + f"DEPLOY_REPO_DIR={Path.cwd()}\n"); os.chmod("deploy.env", 0o600)
|
||||
Path("alert-webhook").write_text((e.get("ALERT_WEBHOOK_URL") or "") + "\n"); os.chmod("alert-webhook", 0o600)
|
||||
run(["sudo", "install", "-m", "0644", "alert-webhook", "/etc/vanguard/alert-webhook"]) # read by alertmanager (uid 65534) and the deadman
|
||||
run(["sudo", "install", "-m", "0600", "deploy.env", "/etc/vanguard/deploy.env"]) # read by dumpd (/deploy webhook) and deploy.sh
|
||||
if all(Path(f).exists() for f in ("deploy.sh", "systemd/deploy-pending.service", "systemd/deploy-pending.timer")):
|
||||
run(["sudo", "install", "-m", "0755", "deploy.sh", "/usr/local/bin/vanguard-deploy"])
|
||||
@@ -548,6 +590,11 @@ def apply(domain, admin_pw, streamers, editors, auth_host="", watch_host="", dis
|
||||
print(" (deploy pipeline files missing — deploy.sh / systemd/deploy-pending.* not uploaded; skipping vanguard-deploy install)")
|
||||
run(["sudo", "install", "-m", "0755", "deployBotBundle.sh", "/usr/local/bin/vanguard-deploy-bot"]) # dumpd runs this for POST /deploy-bot
|
||||
run(["sudo", "install", "-d", "-o", "1000", "-g", "1000", "/srv/bot", "/srv/bot/app"]) # the bot's released bundle -> /app
|
||||
run(["sudo", "install", "-d", "/var/lib/vanguard"]) # deadman state
|
||||
if all(Path(f).exists() for f in ("bin/vanguard-deadman.sh", "systemd/vanguard-deadman.timer")):
|
||||
run(["sudo", "install", "-m", "0755", "bin/vanguard-deadman.sh", "/usr/local/bin/"])
|
||||
run(["sudo", "install", "-m", "0644", "systemd/vanguard-deadman.service", "systemd/vanguard-deadman.timer", "/etc/systemd/system/"])
|
||||
run(["sudo", "systemctl", "daemon-reload"]); run(["sudo", "systemctl", "enable", "--now", "vanguard-deadman.timer"])
|
||||
run(["sudo", "systemctl", "restart", "dumpd"])
|
||||
print("== containers")
|
||||
dc = ["docker", "compose"] if (DRY or subprocess.call(["docker", "info"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0) else ["sudo", "docker", "compose"]
|
||||
@@ -597,7 +644,7 @@ def post_start_fixups(dc):
|
||||
if DRY: print(" (dry) installJellyfinPlugin.sh")
|
||||
else: subprocess.run(["bash", "installJellyfinPlugin.sh"], capture_output=True, text=True); print(" jellyfin plugin: ensured")
|
||||
|
||||
def show(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_host="", bot_host="", live_host="", files_host="", sync_host="", clips_host=""):
|
||||
def show(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_host="", bot_host="", live_host="", files_host="", sync_host="", clips_host="", diag_host=""):
|
||||
print(f"\n== gateway : https://{domain or 'HOST'}/ (/whoami, /logout)")
|
||||
print(f"== files : https://{files_host}/ (copyparty; editors / media-admins)" if files_host else "== files : (not set — run --full)")
|
||||
print(f"== clips : https://{clips_host}/ (self-service clip intake, any member)" if clips_host else "== clips : (not set — run --full)")
|
||||
@@ -605,6 +652,7 @@ def show(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_
|
||||
_e = read_env()
|
||||
if _e.get("APEX_HOST") and _e.get("APEX_REDIRECT"): print(f"== apex : https://{_e['APEX_HOST']}/ -> {_e['APEX_REDIRECT']}")
|
||||
print(f"== live : https://{live_host}/ (portal; /dock/ for OBS; WebRTC needs 8189 open)" if live_host else "== live : (not set — run --full)")
|
||||
print(f"== diag : https://{diag_host}/logs (logs, metrics, alerts; group '{read_env().get('DIAG_REQUIRE_GROUP','media-admins')}')" if diag_host else "== diag : (not set — run --full)")
|
||||
print(f"== bot web : https://{bot_host}/ (Authentik forward-auth; Caddy requires group '{read_env().get('BOT_REQUIRE_GROUP','bot-users') or 'any'}')" if bot_host else "== bot web : (not set — run --full)")
|
||||
print(f"== gitea : https://{git_host}/ ssh port 2222 admin {read_env().get('GITEA_ADMIN_USER','vanguard')} / {read_env().get('GITEA_ADMIN_PASSWORD','?')} (runner: ./registerGiteaRunner.sh)" if git_host else "== gitea : (not set — run --full)")
|
||||
print(f"== authentik: https://{auth_host}/ akadmin password = AUTHENTIK_BOOTSTRAP_PASSWORD in .env (set on first start; change it in the UI)" if auth_host else "== authentik: (not set — run --full)")
|
||||
@@ -644,7 +692,7 @@ def manage_users(kind, users):
|
||||
else:
|
||||
users[n] = ask_pw(n, users[n])
|
||||
|
||||
def full(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_host="", bot_host="", live_host="", files_host="", sync_host="", clips_host=""):
|
||||
def full(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_host="", bot_host="", live_host="", files_host="", sync_host="", clips_host="", diag_host=""):
|
||||
print("== FULL SETUP (enter keeps the shown default)\n")
|
||||
while True:
|
||||
domain = ask("main hostname = the gateway page (DNS A record must point here)", domain)
|
||||
@@ -653,6 +701,7 @@ def full(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_
|
||||
files_host = ask("Files (copyparty) hostname (CNAME to the above)", files_host or default_sub(domain, "files"))
|
||||
clips_host = ask("Clips self-service hostname (CNAME to the above)", clips_host or default_sub(domain, "clips"))
|
||||
sync_host = ask("Syncthing UI hostname (CNAME to the above)", sync_host or default_sub(domain, "sync"))
|
||||
diag_host = ask("Diagnostics hostname: logs, metrics, alerts (CNAME to the above)", diag_host or default_sub(domain, "diag"))
|
||||
auth_host = ask("Authentik hostname (CNAME to the above)", auth_host or default_sub(domain, "auth"))
|
||||
watch_host = ask("Jellyfin hostname (CNAME to the above)", watch_host or default_sub(domain, "watch"))
|
||||
git_host = ask("Gitea hostname (CNAME to the above)", git_host or default_sub(domain, "git"))
|
||||
@@ -687,7 +736,7 @@ def full(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_
|
||||
Path(".env").write_text("".join(f"{k}={v}\n" for k, v in e.items())) if Path(".env").exists() else None
|
||||
if not Path(".env").exists():
|
||||
Path(".env").write_text(f"APEX_HOST={apex}\nAPEX_REDIRECT={apex_to}\n"); os.chmod(".env", 0o600)
|
||||
return domain, admin_pw, streamers, editors, auth_host, watch_host, discord_token, discord_guild, git_host, bot_host, live_host, live_webhook, files_host, sync_host, clips_host
|
||||
return domain, admin_pw, streamers, editors, auth_host, watch_host, discord_token, discord_guild, git_host, bot_host, live_host, live_webhook, files_host, sync_host, clips_host, diag_host
|
||||
|
||||
def ask_discord():
|
||||
env = read_env()
|
||||
@@ -715,15 +764,16 @@ def main():
|
||||
hosts = read_hosts()
|
||||
domain, auth_host, watch_host, git_host, bot_host, live_host = hosts.get("main", ""), hosts.get("auth", ""), hosts.get("watch", ""), hosts.get("git", ""), hosts.get("bot", ""), hosts.get("live", "")
|
||||
files_host, sync_host, clips_host = hosts.get("files", ""), hosts.get("sync", ""), hosts.get("clips", "")
|
||||
diag_host = hosts.get("diag", "")
|
||||
streamers, accounts = read_streamers(), read_accounts()
|
||||
admin_pw = accounts.pop("admin", "")
|
||||
editors = accounts
|
||||
if mode == "--show":
|
||||
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host, files_host, sync_host, clips_host); return
|
||||
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host, files_host, sync_host, clips_host, diag_host); return
|
||||
if mode == "--apply":
|
||||
if not domain: print("domain not set yet — run ./configure.py --full first"); sys.exit(1)
|
||||
apply(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host=git_host, bot_host=bot_host, live_host=live_host, files_host=files_host, sync_host=sync_host, clips_host=clips_host)
|
||||
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host, files_host, sync_host, clips_host); return
|
||||
apply(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host=git_host, bot_host=bot_host, live_host=live_host, files_host=files_host, sync_host=sync_host, clips_host=clips_host, diag_host=diag_host)
|
||||
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host, files_host, sync_host, clips_host, diag_host); return
|
||||
if mode == "--hosts":
|
||||
if not 4 <= len(sys.argv) <= 10: print("usage: configure.py --hosts AUTH WATCH [GIT [BOT [LIVE [FILES [SYNC [CLIPS]]]]]]"); sys.exit(2)
|
||||
if not domain: print("domain not set yet — run ./configure.py --full first"); sys.exit(1)
|
||||
@@ -731,11 +781,11 @@ def main():
|
||||
auth_host, watch_host = a[0], a[1]
|
||||
git_host, bot_host, live_host = (a[2] if a[2] is not None else git_host), (a[3] if a[3] is not None else bot_host), (a[4] if a[4] is not None else live_host)
|
||||
files_host, sync_host, clips_host = (a[5] if a[5] is not None else files_host), (a[6] if a[6] is not None else sync_host), (a[7] if a[7] is not None else clips_host)
|
||||
apply(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host=git_host, bot_host=bot_host, live_host=live_host, files_host=files_host, sync_host=sync_host, clips_host=clips_host)
|
||||
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host, files_host, sync_host, clips_host); return
|
||||
apply(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host=git_host, bot_host=bot_host, live_host=live_host, files_host=files_host, sync_host=sync_host, clips_host=clips_host, diag_host=diag_host)
|
||||
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host, files_host, sync_host, clips_host, diag_host); return
|
||||
discord_token = discord_guild = live_webhook = None
|
||||
if mode == "--full":
|
||||
domain, admin_pw, streamers, editors, auth_host, watch_host, discord_token, discord_guild, git_host, bot_host, live_host, live_webhook, files_host, sync_host, clips_host = full(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host, files_host, sync_host, clips_host)
|
||||
domain, admin_pw, streamers, editors, auth_host, watch_host, discord_token, discord_guild, git_host, bot_host, live_host, live_webhook, files_host, sync_host, clips_host, diag_host = full(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host, files_host, sync_host, clips_host, diag_host)
|
||||
if not yes("\napply now (write configs, restart services)?"): print("nothing written"); return
|
||||
elif mode == "":
|
||||
r = normal(domain, admin_pw, streamers, editors)
|
||||
@@ -743,8 +793,8 @@ def main():
|
||||
discord_token, discord_guild = r
|
||||
else:
|
||||
print(__doc__); sys.exit(2)
|
||||
apply(domain, admin_pw, streamers, editors, auth_host, watch_host, discord_token, discord_guild, git_host, bot_host, live_host, live_webhook, files_host, sync_host, clips_host)
|
||||
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host, files_host, sync_host, clips_host)
|
||||
apply(domain, admin_pw, streamers, editors, auth_host, watch_host, discord_token, discord_guild, git_host, bot_host, live_host, live_webhook, files_host, sync_host, clips_host, diag_host)
|
||||
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host, files_host, sync_host, clips_host, diag_host)
|
||||
|
||||
if __name__ == "__main__":
|
||||
try: main()
|
||||
|
||||
@@ -345,10 +345,135 @@ services:
|
||||
privileged: true
|
||||
restart: unless-stopped
|
||||
|
||||
# ================================================================ observability (Phase 13)
|
||||
# Logs: Vector -> VictoriaLogs. Metrics: VictoriaMetrics scrapes the four taps.
|
||||
# Alerting: a vmalert per datasource -> Alertmanager -> Discord. All loopback; published via diag.HOST.
|
||||
# The deadman (bin/vanguard-deadman.sh, a host timer) watches THIS stack, because nothing in it can.
|
||||
victorialogs:
|
||||
logging: *vglog
|
||||
labels: { vanguard.service: victorialogs, vanguard.category: infra }
|
||||
image: docker.io/victoriametrics/victoria-logs:latest
|
||||
container_name: victorialogs
|
||||
user: "1000:1000"
|
||||
command:
|
||||
- -storageDataPath=/data
|
||||
- -retentionPeriod=30d
|
||||
- -httpListenAddr=:9428
|
||||
- -http.pathPrefix=/logs # its own UI then emits /logs/... so Caddy must NOT strip the prefix
|
||||
ports:
|
||||
- "127.0.0.1:9428:9428"
|
||||
volumes:
|
||||
- /srv/appdata/victorialogs:/data
|
||||
restart: unless-stopped
|
||||
|
||||
vector:
|
||||
logging: *vglog
|
||||
labels: { vanguard.service: vector, vanguard.category: infra }
|
||||
image: docker.io/timberio/vector:latest-debian
|
||||
container_name: vector
|
||||
environment:
|
||||
VECTOR_CONFIG: /etc/vector/vector.yaml
|
||||
volumes:
|
||||
- ./observability/vector.yaml:/etc/vector/vector.yaml:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock # Phase 13 decision "A": container logs need the Docker
|
||||
# API, so this is root-equivalent on the host. Upgrade
|
||||
# paths: journald log driver, or a read-only socket proxy.
|
||||
- /var/log/journal:/var/log/journal:ro # our own systemd units
|
||||
- /etc/machine-id:/etc/machine-id:ro # journald needs it to find the right journal
|
||||
- vector_data:/var/lib/vector
|
||||
depends_on:
|
||||
- victorialogs
|
||||
restart: unless-stopped
|
||||
|
||||
victoriametrics:
|
||||
logging: *vglog
|
||||
labels: { vanguard.service: victoriametrics, vanguard.category: infra }
|
||||
image: docker.io/victoriametrics/victoria-metrics:latest
|
||||
container_name: victoriametrics
|
||||
user: "1000:1000"
|
||||
command:
|
||||
- -storageDataPath=/data
|
||||
- -retentionPeriod=90d
|
||||
- -httpListenAddr=:8428
|
||||
- -promscrape.config=/etc/vm/scrape.yml
|
||||
- -promscrape.config.strictParse=false
|
||||
ports:
|
||||
- "127.0.0.1:8428:8428"
|
||||
volumes:
|
||||
- /srv/appdata/victoriametrics:/data
|
||||
- ./observability/scrape.yml:/etc/vm/scrape.yml:ro
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway" # the four taps bind the host's loopback
|
||||
restart: unless-stopped
|
||||
|
||||
vmalert-metrics:
|
||||
logging: *vglog
|
||||
labels: { vanguard.service: vmalert-metrics, vanguard.category: infra }
|
||||
image: docker.io/victoriametrics/vmalert:latest
|
||||
container_name: vmalert-metrics
|
||||
command:
|
||||
- -datasource.url=http://victoriametrics:8428
|
||||
- -notifier.url=http://alertmanager:9093
|
||||
- -remoteWrite.url=http://victoriametrics:8428
|
||||
- -rule=/etc/alerts/alerts-metrics.yml
|
||||
- -httpListenAddr=:8880
|
||||
- -evaluationInterval=1m
|
||||
ports:
|
||||
- "127.0.0.1:8880:8880"
|
||||
volumes:
|
||||
- ./observability/alerts-metrics.yml:/etc/alerts/alerts-metrics.yml:ro
|
||||
depends_on:
|
||||
- victoriametrics
|
||||
- alertmanager
|
||||
restart: unless-stopped
|
||||
|
||||
vmalert-logs:
|
||||
logging: *vglog
|
||||
labels: { vanguard.service: vmalert-logs, vanguard.category: infra }
|
||||
image: docker.io/victoriametrics/vmalert:latest
|
||||
container_name: vmalert-logs
|
||||
# A vmalert instance has one datasource, so log rules need their own: this one queries VictoriaLogs'
|
||||
# LogsQL stats endpoints, which answer in the Prometheus format vmalert expects.
|
||||
command:
|
||||
- -datasource.url=http://victorialogs:9428/logs
|
||||
- -notifier.url=http://alertmanager:9093
|
||||
- -remoteWrite.url=http://victoriametrics:8428
|
||||
- -rule=/etc/alerts/alerts-logs.yml
|
||||
- -httpListenAddr=:8881
|
||||
- -evaluationInterval=2m
|
||||
ports:
|
||||
- "127.0.0.1:8881:8881"
|
||||
volumes:
|
||||
- ./observability/alerts-logs.yml:/etc/alerts/alerts-logs.yml:ro
|
||||
depends_on:
|
||||
- victorialogs
|
||||
- alertmanager
|
||||
restart: unless-stopped
|
||||
|
||||
alertmanager:
|
||||
logging: *vglog
|
||||
labels: { vanguard.service: alertmanager, vanguard.category: infra }
|
||||
image: docker.io/prom/alertmanager:latest
|
||||
container_name: alertmanager
|
||||
command:
|
||||
- --config.file=/etc/alertmanager/alertmanager.yml
|
||||
- --storage.path=/data
|
||||
- --web.listen-address=:9093
|
||||
ports:
|
||||
- "127.0.0.1:9093:9093"
|
||||
volumes:
|
||||
- ./observability/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
|
||||
- /etc/vanguard/alert-webhook:/etc/vanguard/alert-webhook:ro # written by configure.py, mode 0600
|
||||
- /srv/appdata/alertmanager:/data
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway" # the Watchdog heartbeat goes to dumpd on the bridge port
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
botnet: # isolates discord-bot from the shared default network (see above)
|
||||
|
||||
volumes:
|
||||
copyparty_hist:
|
||||
vector_data:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
|
||||
50
observability/alertmanager.yml
Normal file
50
observability/alertmanager.yml
Normal file
@@ -0,0 +1,50 @@
|
||||
# Alertmanager: groups what vmalert fires and posts it to Discord. The webhook URL is NOT in here — it is a
|
||||
# secret, so configure.py writes it to /etc/vanguard/alert-webhook with mode 0600 and this reads it from there.
|
||||
#
|
||||
# The "heartbeat" route is the other half of the deadman. The Watchdog alert in alerts-metrics.yml always fires,
|
||||
# so Alertmanager posts a heartbeat to a file-watching receiver every interval; bin/vanguard-deadman.sh on the
|
||||
# host notices when that stops and tells Discord itself. Nothing inside the stack can report its own death.
|
||||
|
||||
global:
|
||||
resolve_timeout: 5m
|
||||
|
||||
route:
|
||||
receiver: discord
|
||||
group_by: [alertname, service]
|
||||
group_wait: 30s
|
||||
group_interval: 5m
|
||||
repeat_interval: 12h
|
||||
routes:
|
||||
- matchers: [severity="none"]
|
||||
receiver: heartbeat
|
||||
group_wait: 0s
|
||||
group_interval: 1m
|
||||
repeat_interval: 1m
|
||||
- matchers: [severity="critical"]
|
||||
receiver: discord
|
||||
group_wait: 10s
|
||||
repeat_interval: 1h
|
||||
|
||||
inhibit_rules:
|
||||
# While the disk is critical, do not also shout that it is merely filling.
|
||||
- source_matchers: [alertname="MediaDiskCritical"]
|
||||
target_matchers: [alertname="MediaDiskFilling"]
|
||||
# If a core container is gone, its restart-loop alert is noise.
|
||||
- source_matchers: [alertname="ContainerGone"]
|
||||
target_matchers: [alertname="ContainerRestartLoop"]
|
||||
|
||||
receivers:
|
||||
- name: discord
|
||||
discord_configs:
|
||||
- webhook_url_file: /etc/vanguard/alert-webhook
|
||||
title: '{{ if eq .Status "firing" }}🔴{{ else }}✅{{ end }} {{ .CommonLabels.alertname }}'
|
||||
message: |
|
||||
{{ range .Alerts }}{{ .Annotations.summary }}
|
||||
{{ if .Annotations.detail }}{{ .Annotations.detail }}
|
||||
{{ end }}{{ end }}
|
||||
- name: heartbeat
|
||||
# Goes to dumpd on the host (bridge-only port), which touches a file. bin/vanguard-deadman.sh checks that
|
||||
# file is fresh, so a heartbeat never reaches Discord but its ABSENCE does.
|
||||
webhook_configs:
|
||||
- url: http://127.0.0.1:9095/heartbeat
|
||||
send_resolved: false
|
||||
33
observability/alerts-logs.yml
Normal file
33
observability/alerts-logs.yml
Normal file
@@ -0,0 +1,33 @@
|
||||
# Rules vmalert evaluates against VictoriaLogs' /select/logsql/stats_query endpoints (LogsQL, not PromQL).
|
||||
groups:
|
||||
- name: logs
|
||||
interval: 2m
|
||||
type: vlogs
|
||||
rules:
|
||||
- alert: SweepFailing
|
||||
# normalize-sweep writes event=failed and leaves a .normalize-failed.log beside the file.
|
||||
expr: 'component:=sweep level:=error event:=failed | stats count() as hits'
|
||||
for: 1m
|
||||
labels: {severity: warning}
|
||||
annotations:
|
||||
summary: "the normalize sweep failed {{ $value }} time(s) in the last 2m"
|
||||
detail: "A recording or clip did not convert. Look for *.normalize-failed.log under /srv/media."
|
||||
- alert: PublishDenied
|
||||
# A few are normal (expired token, wrong prefix). A burst means a streamer is locked out.
|
||||
expr: 'component:=dumpd event:=publish_deny | stats count() as hits'
|
||||
for: 5m
|
||||
labels: {severity: warning}
|
||||
annotations: {summary: "{{ $value }} publish denials in 2m — a streamer may be unable to go live"}
|
||||
- alert: BotDeployFailed
|
||||
expr: 'component:=dumpd event:=bot_deploy_exit level:=error | stats count() as hits'
|
||||
labels: {severity: warning}
|
||||
annotations: {summary: "a Discord bot release failed to install"}
|
||||
- alert: ServiceErrors
|
||||
expr: 'level:=error | stats by (service) count() as hits'
|
||||
for: 5m
|
||||
labels: {severity: warning}
|
||||
annotations: {summary: "{{ $labels.service }} logged {{ $value }} errors in 2m"}
|
||||
- alert: CertificateTrouble
|
||||
expr: 'service:=caddy _msg:"obtain" _msg:"error" | stats count() as hits'
|
||||
labels: {severity: warning}
|
||||
annotations: {summary: "Caddy could not obtain or renew a certificate"}
|
||||
84
observability/alerts-metrics.yml
Normal file
84
observability/alerts-metrics.yml
Normal file
@@ -0,0 +1,84 @@
|
||||
# Rules vmalert evaluates against VictoriaMetrics. Metric conditions only; log conditions are in alerts-logs.yml.
|
||||
groups:
|
||||
- name: disk
|
||||
interval: 1m
|
||||
rules:
|
||||
- alert: MediaDiskFilling
|
||||
# The most likely quiet death on this box: recordings fill /srv and the sweep starts failing.
|
||||
expr: 100 - (node_filesystem_avail_bytes{mountpoint="/srv"} / node_filesystem_size_bytes{mountpoint="/srv"} * 100) > 85
|
||||
for: 10m
|
||||
labels: {severity: warning}
|
||||
annotations:
|
||||
summary: "/srv is {{ $value | printf \"%.0f\" }}% full"
|
||||
detail: "Recordings and clips live here. Check /srv/media/streams and the 15-day archive window."
|
||||
- alert: MediaDiskCritical
|
||||
expr: 100 - (node_filesystem_avail_bytes{mountpoint="/srv"} / node_filesystem_size_bytes{mountpoint="/srv"} * 100) > 94
|
||||
for: 2m
|
||||
labels: {severity: critical}
|
||||
annotations:
|
||||
summary: "/srv is {{ $value | printf \"%.0f\" }}% full — recording will fail"
|
||||
- alert: RootDiskFilling
|
||||
expr: 100 - (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"} * 100) > 85
|
||||
for: 10m
|
||||
labels: {severity: warning}
|
||||
annotations: {summary: "/ is {{ $value | printf \"%.0f\" }}% full (docker images, journal, appdata)"}
|
||||
|
||||
- name: host
|
||||
interval: 1m
|
||||
rules:
|
||||
- alert: LoadSustained
|
||||
# The sweep is niced, so a sustained load above core count means something is not yielding.
|
||||
expr: node_load15 > count(count by (cpu) (node_cpu_seconds_total))
|
||||
for: 20m
|
||||
labels: {severity: warning}
|
||||
annotations: {summary: "load15 {{ $value | printf \"%.1f\" }} above core count for 20m"}
|
||||
- alert: MemoryLow
|
||||
expr: node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes * 100 < 10
|
||||
for: 10m
|
||||
labels: {severity: warning}
|
||||
annotations: {summary: "only {{ $value | printf \"%.0f\" }}% memory available"}
|
||||
- alert: RaidDegraded
|
||||
expr: node_md_disks{state!="active"} > 0
|
||||
for: 5m
|
||||
labels: {severity: critical}
|
||||
annotations: {summary: "md RAID has a disk that is not active"}
|
||||
|
||||
- name: containers
|
||||
interval: 1m
|
||||
rules:
|
||||
- alert: ContainerRestartLoop
|
||||
expr: increase(container_start_time_seconds{name!=""}[15m]) > 0 and changes(container_start_time_seconds{name!=""}[1h]) > 2
|
||||
for: 5m
|
||||
labels: {severity: warning}
|
||||
annotations: {summary: "{{ $labels.name }} restarted more than twice in an hour"}
|
||||
- alert: ContainerGone
|
||||
# cAdvisor stops reporting a container that is not running at all.
|
||||
expr: absent(container_last_seen{name="mediamtx"}) or absent(container_last_seen{name="caddy"}) or absent(container_last_seen{name="discord-bot"})
|
||||
for: 5m
|
||||
labels: {severity: critical}
|
||||
annotations: {summary: "a core container is not running (mediamtx, caddy or discord-bot)"}
|
||||
|
||||
- name: streaming
|
||||
interval: 1m
|
||||
rules:
|
||||
- alert: StreamDroppingPackets
|
||||
expr: increase(mediamtx_paths_bytes_received{}[5m]) == 0 and mediamtx_paths{state="ready"} > 0
|
||||
for: 10m
|
||||
labels: {severity: warning}
|
||||
annotations: {summary: "a MediaMTX path is ready but has received nothing for 10m"}
|
||||
|
||||
- name: observability
|
||||
interval: 1m
|
||||
rules:
|
||||
- alert: LogIngestionStalled
|
||||
# If this fires, the log half is blind — and if it cannot fire, the deadman covers it.
|
||||
expr: increase(vl_rows_ingested_total[15m]) == 0
|
||||
for: 15m
|
||||
labels: {severity: warning}
|
||||
annotations: {summary: "VictoriaLogs has ingested nothing for 15m — is Vector running?"}
|
||||
- alert: Watchdog
|
||||
# Always firing on purpose: Alertmanager routes it to the heartbeat receiver, and the deadman script
|
||||
# on the host notices when it stops arriving. This is how the monitoring stack reports its own death.
|
||||
expr: vector(1)
|
||||
labels: {severity: none}
|
||||
annotations: {summary: "alerting pipeline is alive"}
|
||||
22
observability/scrape.yml
Normal file
22
observability/scrape.yml
Normal file
@@ -0,0 +1,22 @@
|
||||
# What VictoriaMetrics scrapes. Every target is loopback-only on this host (LOGGING.md "Metrics taps").
|
||||
global:
|
||||
scrape_interval: 30s
|
||||
external_labels:
|
||||
host: vanguard
|
||||
|
||||
scrape_configs:
|
||||
- job_name: host # CPU, RAM, disk space + I/O, network, load, md RAID
|
||||
static_configs: [{targets: ["host.docker.internal:9100"]}]
|
||||
- job_name: containers # per container CPU/memory/network/block IO, labelled vanguard.*
|
||||
static_configs: [{targets: ["host.docker.internal:9101"]}]
|
||||
- job_name: mediamtx # per path bytes/s, readers, publishers, dropped packets
|
||||
static_configs: [{targets: ["host.docker.internal:9998"]}]
|
||||
- job_name: caddy # requests by host and status, durations, TLS
|
||||
static_configs: [{targets: ["host.docker.internal:2019"]}]
|
||||
- job_name: victorialogs
|
||||
static_configs: [{targets: ["victorialogs:9428"]}]
|
||||
metrics_path: /logs/metrics # it runs behind -http.pathPrefix=/logs
|
||||
- job_name: victoriametrics
|
||||
static_configs: [{targets: ["127.0.0.1:8428"]}]
|
||||
- job_name: alertmanager
|
||||
static_configs: [{targets: ["alertmanager:9093"]}]
|
||||
99
observability/vector.yaml
Normal file
99
observability/vector.yaml
Normal file
@@ -0,0 +1,99 @@
|
||||
# Vector: collects from the two taps in LOGGING.md and ships to VictoriaLogs.
|
||||
#
|
||||
# docker_logs every container's stdout/stderr, with the vanguard.service / vanguard.category labels
|
||||
# we put on each one, so filtering by category needs no parsing
|
||||
# journald our own systemd units (dumpd, the sweep, trash purge, deploy pending, vgdeband)
|
||||
#
|
||||
# TRADE-OFF, chosen deliberately (CommunityDiscordMedia.md Phase 13): docker_logs needs the Docker API, so this
|
||||
# container holds the socket and therefore root-equivalent access to the host — the same exposure act_runner
|
||||
# already has. Accepted "for now". The upgrade paths, in order of preference, are the journald log driver for
|
||||
# every container (one tap, no socket, labels survive as journal fields) or a read-only socket proxy in front
|
||||
# of this container. Neither changes anything downstream of here.
|
||||
#
|
||||
# Every event ends up with: service, category, source_kind, message, level where it could be determined.
|
||||
|
||||
data_dir: /var/lib/vector
|
||||
|
||||
sources:
|
||||
containers:
|
||||
type: docker_logs
|
||||
docker_host: unix:///var/run/docker.sock
|
||||
exclude_containers: [vector] # never ship our own output: it would feed itself
|
||||
units:
|
||||
type: journald
|
||||
current_boot_only: false
|
||||
journal_directory: /var/log/journal
|
||||
include_units: [dumpd, normalize-sweep, trash-purge, deploy-pending, vgdeband]
|
||||
|
||||
transforms:
|
||||
# ---- one shape, whichever tap it came from
|
||||
normalised:
|
||||
type: remap
|
||||
inputs: [containers, units]
|
||||
source: |
|
||||
if exists(.container_name) {
|
||||
.service = .label."vanguard.service" || .container_name
|
||||
.category = .label."vanguard.category" || "infra"
|
||||
.source_kind = "container"
|
||||
del(.label); del(.container_id); del(.image); del(.container_created_at); del(.stream)
|
||||
} else {
|
||||
unit = ._SYSTEMD_UNIT || .unit || "host"
|
||||
.service = replace(string!(unit), ".service", "")
|
||||
.category = if .service == "dumpd" { "stream" } else { "media" }
|
||||
.source_kind = "unit"
|
||||
if !exists(.message) && exists(.MESSAGE) { .message = del(.MESSAGE) }
|
||||
# the journal carries a lot of bookkeeping; keep what a query or a human needs
|
||||
del(.PRIORITY); del(.SYSLOG_IDENTIFIER); del(.SYSLOG_FACILITY); del(._TRANSPORT)
|
||||
del(._PID); del(._UID); del(._GID); del(._COMM); del(._EXE); del(._CMDLINE)
|
||||
del(._CAP_EFFECTIVE); del(._SELINUX_CONTEXT); del(._AUDIT_SESSION); del(._AUDIT_LOGINUID)
|
||||
del(._SYSTEMD_CGROUP); del(._SYSTEMD_SLICE); del(._SYSTEMD_INVOCATION_ID)
|
||||
del(._BOOT_ID); del(._MACHINE_ID); del(._SOURCE_REALTIME_TIMESTAMP); del(._SYSTEMD_UNIT)
|
||||
}
|
||||
|
||||
# ---- parse the shapes LOGGING.md documents; anything else stays a plain message
|
||||
parsed:
|
||||
type: remap
|
||||
inputs: [normalised]
|
||||
source: |
|
||||
text = string(.message) ?? ""
|
||||
if starts_with(text, "{") {
|
||||
# caddy and authentik emit JSON
|
||||
fields, err = parse_json(text)
|
||||
if err == null && is_object(fields) {
|
||||
.json = fields
|
||||
.level = get(object!(fields), ["level"]) ?? get(object!(fields), ["severity"]) ?? null
|
||||
.message = get(object!(fields), ["msg"]) ?? get(object!(fields), ["event"]) ?? text
|
||||
}
|
||||
} else if match(text, r'(^|\s)(ts|level|component|event)=') {
|
||||
# our own scripts, and syncthing's key=value tail
|
||||
fields, err = parse_logfmt(text)
|
||||
if err == null {
|
||||
.kv = fields
|
||||
.level = get(object!(fields), ["level"]) ?? null
|
||||
.event = get(object!(fields), ["event"]) ?? null
|
||||
}
|
||||
} else if match(text, r'^\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2} ') {
|
||||
# mediamtx: 2026/11/03 05:00:00 INF [component] message
|
||||
fields, err = parse_regex(text, r'^\S+ \S+ (?P<lvl>\w+) (?P<rest>.*)$')
|
||||
if err == null { .level = fields.lvl; .message = fields.rest }
|
||||
} else if match(text, r'^\[\d{2}:\d{2}:\d{2}') {
|
||||
# jellyfin: [00:00:00.000] [LVL] [thread] Logger: message (stack traces arrive as their own events)
|
||||
fields, err = parse_regex(text, r'^\[[\d:.]+\] \[(?P<lvl>\w+)\] \[\d+\] (?P<rest>.*)$')
|
||||
if err == null { .level = fields.lvl; .message = fields.rest }
|
||||
}
|
||||
if exists(.level) { .level = downcase(string!(.level)) }
|
||||
|
||||
sinks:
|
||||
victorialogs:
|
||||
type: elasticsearch
|
||||
inputs: [parsed]
|
||||
endpoints: ["http://victorialogs:9428/insert/elasticsearch/"]
|
||||
mode: bulk
|
||||
api_version: v8
|
||||
compression: gzip
|
||||
healthcheck:
|
||||
enabled: false
|
||||
query:
|
||||
_msg_field: message
|
||||
_time_field: timestamp
|
||||
_stream_fields: service,category,source_kind
|
||||
7
systemd/vanguard-deadman.service
Normal file
7
systemd/vanguard-deadman.service
Normal file
@@ -0,0 +1,7 @@
|
||||
[Unit]
|
||||
Description=Vanguard deadman: is the alerting stack itself alive, and are the disks ok
|
||||
After=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/vanguard-deadman.sh
|
||||
10
systemd/vanguard-deadman.timer
Normal file
10
systemd/vanguard-deadman.timer
Normal file
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Run the Vanguard deadman every 5 minutes
|
||||
|
||||
[Timer]
|
||||
OnBootSec=2min
|
||||
OnUnitActiveSec=5min
|
||||
AccuracySec=30s
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
Reference in New Issue
Block a user