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:
2026-11-03 15:45:58 -06:00
parent 09882833a0
commit 8ae43905dc
13 changed files with 695 additions and 63 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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
View 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

View File

@@ -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()

View File

@@ -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:

View 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

View 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"}

View 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
View 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
View 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

View 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

View 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