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
|
# vanguard:live
|
||||||
live.example.com {
|
live.example.com {
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
|
|||||||
@@ -320,7 +320,53 @@ permanent reference is needed for long sessions, not just a start burst.
|
|||||||
export at 2560×1440)
|
export at 2560×1440)
|
||||||
- rejected: LTC audio (SRT multitrack + per-PC generator); clock inside the picture; cropping at remux (re-encode)
|
- 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
|
- 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;
|
- `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`)
|
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`.
|
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
|
- [ ] 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
|
## 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
|
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;
|
/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".
|
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,
|
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.
|
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
|
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)
|
log("bot_env_applied", keys=",".join(applied), refused=",".join(refused) or None)
|
||||||
return applied, refused
|
return applied, refused
|
||||||
|
|
||||||
|
HEARTBEAT_FILE = Path(os.environ.get("ALERT_HEARTBEAT_FILE", "/srv/media/.markers/alert-heartbeat"))
|
||||||
|
|
||||||
_bot_deploy_lock = threading.Lock()
|
_bot_deploy_lock = threading.Lock()
|
||||||
BOT_DEPLOY_MAX_BYTES = 300 * 1024 * 1024
|
BOT_DEPLOY_MAX_BYTES = 300 * 1024 * 1024
|
||||||
BOT_DEPLOY_INSTALLER = os.environ.get("BOT_DEPLOY_INSTALLER", "/usr/local/bin/vanguard-deploy-bot")
|
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],
|
"files": [str(f.relative_to(ROOT)) for f in files],
|
||||||
"gb": round(sum(f.stat().st_size for f in files) / 1e9, 2)})
|
"gb": round(sum(f.stat().st_size for f in files) / 1e9, 2)})
|
||||||
def do_POST(self):
|
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"):
|
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 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.
|
# 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
|
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()
|
env = read_env()
|
||||||
gate_any = gate("")
|
gate_any = gate("")
|
||||||
extra = ""
|
extra = ""
|
||||||
@@ -223,6 +223,43 @@ def write_caddy(domain, auth_host="", watch_host="", git_host="", bot_host="", l
|
|||||||
file_server
|
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:
|
if live_host:
|
||||||
extra += f"""
|
extra += f"""
|
||||||
@@ -333,11 +370,12 @@ name: {{http.request.header.X-Authentik-Name}}"
|
|||||||
}}
|
}}
|
||||||
""" + extra)
|
""" + 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()
|
env = read_env()
|
||||||
if files_host: env["FILES_HOST"] = files_host
|
if files_host: env["FILES_HOST"] = files_host
|
||||||
if sync_host: env["SYNC_HOST"] = sync_host
|
if sync_host: env["SYNC_HOST"] = sync_host
|
||||||
if clips_host: env["CLIPS_HOST"] = clips_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")
|
env["LIVE_HOST"] = live_host or env.get("LIVE_HOST", "live.example.com")
|
||||||
if main_host: env["MAIN_HOST"] = main_host
|
if main_host: env["MAIN_HOST"] = main_host
|
||||||
if live_webhook is not None: env["LIVE_WEBHOOK_URL"] = live_webhook
|
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_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_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("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_BOT_TOKEN", "") # fill in: Discord dev portal -> the Authentik app -> Bot
|
||||||
env.setdefault("DISCORD_GUILD_ID", "")
|
env.setdefault("DISCORD_GUILD_ID", "")
|
||||||
env["WATCH_HOST"] = watch_host or "watch.example.com"
|
env["WATCH_HOST"] = watch_host or "watch.example.com"
|
||||||
@@ -512,15 +552,15 @@ def run(cmd, **kw):
|
|||||||
if DRY: return 0
|
if DRY: return 0
|
||||||
return subprocess.call(cmd, **kw)
|
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
|
# 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():
|
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("\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).")
|
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)
|
if not yes(" continue anyway?", False): print("aborted; add streamers first ([s] in the menu)"); sys.exit(1)
|
||||||
print("\n== writing configs")
|
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_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); write_mediamtx(domain); write_copyparty()
|
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)
|
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"])
|
run(["sudo", "install", "-d", "-o", os.environ.get("SUDO_USER") or os.environ.get("USER") or "debian", "/srv/live"])
|
||||||
print("== app data folders")
|
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
|
run(["sudo", "install", "-m", "0644", "syncthing/stignore", "/etc/vanguard/stignore"]) # dumpd copies it into new clips folders
|
||||||
e = read_env()
|
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("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
|
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")):
|
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"])
|
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)")
|
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", "-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", "-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"])
|
run(["sudo", "systemctl", "restart", "dumpd"])
|
||||||
print("== containers")
|
print("== containers")
|
||||||
dc = ["docker", "compose"] if (DRY or subprocess.call(["docker", "info"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0) else ["sudo", "docker", "compose"]
|
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")
|
if DRY: print(" (dry) installJellyfinPlugin.sh")
|
||||||
else: subprocess.run(["bash", "installJellyfinPlugin.sh"], capture_output=True, text=True); print(" jellyfin plugin: ensured")
|
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"\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"== 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)")
|
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()
|
_e = read_env()
|
||||||
if _e.get("APEX_HOST") and _e.get("APEX_REDIRECT"): print(f"== apex : https://{_e['APEX_HOST']}/ -> {_e['APEX_REDIRECT']}")
|
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"== 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"== 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"== 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)")
|
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:
|
else:
|
||||||
users[n] = ask_pw(n, users[n])
|
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")
|
print("== FULL SETUP (enter keeps the shown default)\n")
|
||||||
while True:
|
while True:
|
||||||
domain = ask("main hostname = the gateway page (DNS A record must point here)", domain)
|
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"))
|
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"))
|
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"))
|
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"))
|
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"))
|
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"))
|
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
|
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():
|
if not Path(".env").exists():
|
||||||
Path(".env").write_text(f"APEX_HOST={apex}\nAPEX_REDIRECT={apex_to}\n"); os.chmod(".env", 0o600)
|
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():
|
def ask_discord():
|
||||||
env = read_env()
|
env = read_env()
|
||||||
@@ -715,15 +764,16 @@ def main():
|
|||||||
hosts = read_hosts()
|
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", "")
|
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", "")
|
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()
|
streamers, accounts = read_streamers(), read_accounts()
|
||||||
admin_pw = accounts.pop("admin", "")
|
admin_pw = accounts.pop("admin", "")
|
||||||
editors = accounts
|
editors = accounts
|
||||||
if mode == "--show":
|
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 mode == "--apply":
|
||||||
if not domain: print("domain not set yet — run ./configure.py --full first"); sys.exit(1)
|
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)
|
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); 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 == "--hosts":
|
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 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)
|
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]
|
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)
|
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)
|
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)
|
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); 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
|
||||||
discord_token = discord_guild = live_webhook = None
|
discord_token = discord_guild = live_webhook = None
|
||||||
if mode == "--full":
|
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
|
if not yes("\napply now (write configs, restart services)?"): print("nothing written"); return
|
||||||
elif mode == "":
|
elif mode == "":
|
||||||
r = normal(domain, admin_pw, streamers, editors)
|
r = normal(domain, admin_pw, streamers, editors)
|
||||||
@@ -743,8 +793,8 @@ def main():
|
|||||||
discord_token, discord_guild = r
|
discord_token, discord_guild = r
|
||||||
else:
|
else:
|
||||||
print(__doc__); sys.exit(2)
|
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)
|
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)
|
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__":
|
if __name__ == "__main__":
|
||||||
try: main()
|
try: main()
|
||||||
|
|||||||
@@ -345,10 +345,135 @@ services:
|
|||||||
privileged: true
|
privileged: true
|
||||||
restart: unless-stopped
|
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:
|
networks:
|
||||||
botnet: # isolates discord-bot from the shared default network (see above)
|
botnet: # isolates discord-bot from the shared default network (see above)
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
copyparty_hist:
|
copyparty_hist:
|
||||||
|
vector_data:
|
||||||
caddy_data:
|
caddy_data:
|
||||||
caddy_config:
|
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