Skip to content

Commit 9fc5493

Browse files
author
S. K. Rotwang MMMMMMMMM
committed
Ops watchdog: catch silent service failures within 15 minutes
Background: during the previous deploy sweep we caught three seneschal-* systemd units that had been failing every tick for ~36 hours because /opt/seneschal-data-api/scripts/*.mjs went missing (rsync regression). Nothing was watching systemd state, so the income-poller lost ~36 hourly snapshots before anyone noticed. * seneschal-ops-monitor.{mjs,service,timer}: 10-minute watchdog that parses `systemctl show` for every seneschal-* unit + paired timer, stats the expected scripts under /opt/.../scripts, classifies state (ok/failed/stale/missing/unknown), and posts state-change deltas to Telegram (silent no-op when creds unconfigured). * ops-monitor.js: pure helpers (parser, classifier, report builder, diff, Telegram POST) — fully unit-tested without systemd or network round-trips. * /v1/ops/health endpoint exposes the watchdog state file. 200 when ok, 503 when degraded/stale/unknown so external monitors can alert on the meta-failure of the watchdog itself. * stats.seneschal.space: new "Ops watchdog" tile with overall state + coloured-dot strip showing every unit + script tracked. Hover tooltips spell out the systemd reason. * 32 new tests (28 ops-monitor + 4 /v1/ops/health), full suite 523/523 green.
1 parent 2b35c91 commit 9fc5493

9 files changed

Lines changed: 990 additions & 0 deletions

docs/stats.html

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,22 @@ <h2 style="color:#9d6cff;margin:0">Private Watch &mdash; view-key payment monito
280280
<h2 style="color:var(--accent);margin:0">Seneschal activity — last 24h</h2>
281281
<span class="kpi-sub">counts only — no profit figures exposed</span>
282282
</header>
283+
284+
<!-- Ops health strip — populated by /v1/ops/health on every
285+
stats refresh. Each unit gets a coloured dot so a regression
286+
is visible at a glance from the public dashboard. -->
287+
<section id="ops-health" class="kpi-grid" style="margin-bottom:1.4em">
288+
<div class="op-tile" style="grid-column:span 2">
289+
<div class="op-label">Ops watchdog</div>
290+
<div class="op-value mono-sm" id="ops-overall"></div>
291+
<div class="op-sub" id="ops-summary">checked every 10 min · post-mortem alerts via @OrknetP</div>
292+
</div>
293+
<div class="op-tile" style="grid-column:span 4">
294+
<div class="op-label">Per-unit status</div>
295+
<div class="op-value" id="ops-dots" style="display:flex;flex-wrap:wrap;gap:0.45em;font-size:0.85em;line-height:1.4;align-items:center"></div>
296+
<div class="op-sub" id="ops-changes">last change: none recorded since boot</div>
297+
</div>
298+
</section>
283299
<div class="operator-tiles">
284300
<div class="op-tile">
285301
<div class="op-label">Liquidation attempts</div>
@@ -1119,6 +1135,53 @@ <h2>Support development</h2>
11191135
}[c]));
11201136
}
11211137

1138+
// Render the ops-health strip. Splits the watchdog report into a
1139+
// summary tile + a per-unit dot row. We tolerate any failure mode
1140+
// of the endpoint (404, 503, network) by colouring the dot row to
1141+
// match — `degraded`/`stale`/`unknown` all show as their own dot.
1142+
async function renderOpsHealth() {
1143+
const overallEl = document.getElementById('ops-overall');
1144+
const summaryEl = document.getElementById('ops-summary');
1145+
const dotsEl = document.getElementById('ops-dots');
1146+
const changesEl = document.getElementById('ops-changes');
1147+
if (!overallEl) return;
1148+
let report = null;
1149+
let httpStatus = 0;
1150+
try {
1151+
const r = await fetch(`${API_BASE}/v1/ops/health`, { headers: { accept: 'application/json' } });
1152+
httpStatus = r.status;
1153+
report = await r.json();
1154+
}
1155+
catch (e) {
1156+
overallEl.textContent = 'UNREACHABLE';
1157+
overallEl.className = 'op-value mono-sm warn';
1158+
summaryEl.textContent = `couldn't reach /v1/ops/health: ${e.message}`;
1159+
dotsEl.textContent = '—';
1160+
return;
1161+
}
1162+
const overall = report?.overall ?? 'unknown';
1163+
const colour = { ok: 'good', degraded: 'warn', stale: 'warn', unknown: 'warn' }[overall] ?? 'warn';
1164+
overallEl.className = 'op-value mono-sm ' + colour;
1165+
overallEl.textContent = overall.toUpperCase();
1166+
const ageMin = report?.ageMs ? Math.round(report.ageMs / 60000) : null;
1167+
summaryEl.textContent = `${report?.summary ?? '(no summary)'}${ageMin != null ? ` · last tick ${ageMin}m ago` : ''}`;
1168+
const units = report?.units ?? {};
1169+
const scripts = report?.scripts ?? {};
1170+
const dotFor = (status) => {
1171+
const c = { ok: 'var(--good)', failed: 'var(--danger)', stale: 'var(--warn)', missing: 'var(--danger)', unknown: 'var(--muted)' }[status] ?? 'var(--muted)';
1172+
return `<span style="display:inline-block;width:0.65em;height:0.65em;border-radius:50%;background:${c}"></span>`;
1173+
};
1174+
const shortName = (n) => n.replace(/^seneschal-/, '').replace(/\.service$/, '');
1175+
const unitBadges = Object.entries(units).map(([name, v]) =>
1176+
`<span style="display:inline-flex;gap:0.35em;align-items:center" title="${escapeHtml(v.reason ?? v.status)}">${dotFor(v.status)} ${escapeHtml(shortName(name))}</span>`
1177+
);
1178+
const scriptBadges = Object.entries(scripts).map(([path, v]) => {
1179+
const base = path.split('/').pop();
1180+
return `<span style="display:inline-flex;gap:0.35em;align-items:center" title="${escapeHtml(v.reason ?? v.status)}">${dotFor(v.status)} ${escapeHtml(base)}</span>`;
1181+
});
1182+
dotsEl.innerHTML = unitBadges.concat(scriptBadges).join(' · ');
1183+
}
1184+
11221185
// ── data fetch + render orchestration ─────────────────────
11231186
async function refresh() {
11241187
setStatus('loading', 'refreshing…');
@@ -1138,12 +1201,18 @@ <h2>Support development</h2>
11381201
renderRecentLiq(d);
11391202
renderPremium(d);
11401203
renderSupport(d);
1204+
// Ops-health is its own endpoint so a slow stats overview
1205+
// doesn't gate the operational view (or vice-versa).
1206+
renderOpsHealth().catch(() => { /* already painted error state */ });
11411207
clearError();
11421208
const ts = new Date(d.as_of_ms);
11431209
setStatus('', `updated ${ts.toLocaleTimeString()}`);
11441210
} catch (e) {
11451211
setStatus('error', `error: ${e.message}`);
11461212
showError(`Could not load stats: ${e.message}. Retrying in 30s.`);
1213+
// Still paint ops-health independently — if the stats DB
1214+
// is down we still want to know whether the watchdog is up.
1215+
renderOpsHealth().catch(() => {});
11471216
}
11481217
}
11491218

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Seneschal ops watchdog config — operator-managed env.
2+
#
3+
# Install location on fin4: /etc/seneschal/ops-monitor.env
4+
# Mode: 600 root:root
5+
#
6+
# Everything below is optional. With nothing set the watchdog still
7+
# runs (every 10 min via seneschal-ops-monitor.timer), writes its
8+
# state to /var/lib/seneschal-ops-monitor/state.json (consumed by
9+
# /v1/ops/health and the stats page) and logs to journalctl. The
10+
# only thing you lose is push notifications.
11+
12+
# ── Telegram alerting (recommended) ─────────────────────────────
13+
# 1. Talk to @BotFather, /newbot, follow prompts. Save the token.
14+
# 2. Start a chat with your new bot from @OrknetP (the bot has to
15+
# have spoken to you first before it can DM you). Send any
16+
# message.
17+
# 3. curl "https://api.telegram.org/bot$BOT_TOKEN/getUpdates" — find
18+
# your chat in the `result[*].message.chat.id` field. Numeric
19+
# integer for private chats, negative for group chats.
20+
# 4. Drop them in here, chmod 600, then `systemctl start
21+
# seneschal-ops-monitor.service` to force a fresh tick — you'll
22+
# get an "Initial watchdog state" message if anything's degraded,
23+
# nothing otherwise.
24+
#
25+
# TELEGRAM_BOT_TOKEN=123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11
26+
# TELEGRAM_CHAT_ID=123456789
27+
28+
# ── Optional: override the units / scripts that are checked ─────
29+
# Comma-separated. For unit names append ":cadenceSec" to enable
30+
# the timer-staleness check (omit the colon for long-running
31+
# services like the REST server).
32+
#
33+
# SENESCHAL_OPS_UNITS=seneschal-data-rest.service,seneschal-private-watch-poller.service:180,seneschal-income-poller.service:3600
34+
# SENESCHAL_OPS_SCRIPTS=/opt/seneschal-data-api/scripts/private-watch-poller.mjs,/opt/seneschal-data-api/scripts/income-poller.mjs
35+
36+
# ── Optional: state file path ───────────────────────────────────
37+
# SENESCHAL_OPS_STATE_FILE=/var/lib/seneschal-ops-monitor/state.json

scripts/seneschal-ops-monitor.mjs

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
#!/usr/bin/env node
2+
// Seneschal ops watchdog.
3+
//
4+
// Inspects the seneschal-* systemd units + the data-api script
5+
// directory, builds a health report, persists it, and alerts on
6+
// state changes via:
7+
// * Always: writes the JSON report to STATE_FILE
8+
// (default /var/lib/seneschal-ops-monitor/state.json),
9+
// and logs a one-line summary to stdout (captured by
10+
// systemd-journald).
11+
// * Optional: posts state-change deltas to Telegram if both
12+
// TELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID env vars are set.
13+
//
14+
// Reads its config from env so the same binary runs in dev + prod:
15+
// SENESCHAL_OPS_UNITS — comma list of "service[:cadenceSec]"
16+
// pairs. Cadence is the expected
17+
// timer interval; omit (no colon) for
18+
// long-running services.
19+
// SENESCHAL_OPS_SCRIPTS — comma list of absolute script
20+
// paths to verify exist + are
21+
// executable. Default is the four
22+
// .mjs/.sh scripts the data-api
23+
// relies on.
24+
// SENESCHAL_OPS_STATE_FILE — where to persist the last report
25+
// (default /var/lib/seneschal-ops-monitor/state.json)
26+
// TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID — optional alert sink.
27+
//
28+
// Exit codes:
29+
// 0 — overall ok
30+
// 2 — degraded (one or more units / scripts unhealthy)
31+
// 1 — watchdog itself crashed (uncaught error)
32+
33+
import { execFileSync } from 'node:child_process';
34+
import { readFile, writeFile, mkdir, stat, access } from 'node:fs/promises';
35+
import { constants as FS } from 'node:fs';
36+
import { dirname } from 'node:path';
37+
import { hostname } from 'node:os';
38+
39+
import {
40+
parseShowOutput,
41+
classifyUnit,
42+
buildReport,
43+
diffReports,
44+
renderTelegramMessage,
45+
sendTelegram
46+
} from '../src/ops-monitor.js';
47+
48+
const DEFAULTS = Object.freeze({
49+
UNITS: [
50+
// timer-driven oneshots (cadenceSec → interval grace)
51+
{ name: 'seneschal-private-watch-poller.service', timerName: 'seneschal-private-watch-poller.timer', intervalSec: 180 },
52+
{ name: 'seneschal-income-poller.service', timerName: 'seneschal-income-poller.timer', intervalSec: 3600 },
53+
{ name: 'seneschal-paymaster-sweep-check.service', timerName: 'seneschal-paymaster-sweep-check.timer', intervalSec: 86_400 },
54+
{ name: 'seneschal-backup.service', timerName: 'seneschal-backup.timer', intervalSec: 86_400 },
55+
// long-running services (no timer pair)
56+
{ name: 'seneschal-data-rest.service' },
57+
{ name: 'seneschal-data-mcp.service' }
58+
],
59+
SCRIPTS: [
60+
'/opt/seneschal-data-api/scripts/private-watch-poller.mjs',
61+
'/opt/seneschal-data-api/scripts/income-poller.mjs',
62+
'/opt/seneschal-data-api/scripts/paymaster-sweep-check.mjs',
63+
'/opt/seneschal-data-api/scripts/publish-docs.sh'
64+
],
65+
STATE_FILE: '/var/lib/seneschal-ops-monitor/state.json'
66+
});
67+
68+
function parseUnitList(envValue) {
69+
if (!envValue) return DEFAULTS.UNITS;
70+
return envValue.split(',').map(s => s.trim()).filter(Boolean).map(s => {
71+
const [name, intervalSec] = s.split(':');
72+
// Convention: if there's a paired timer with the same stem,
73+
// the operator should add `:cadenceSec`; otherwise we treat
74+
// it as a long-running service.
75+
const out = { name };
76+
if (intervalSec) {
77+
out.timerName = name.replace(/\.service$/, '.timer');
78+
out.intervalSec = parseInt(intervalSec, 10);
79+
}
80+
return out;
81+
});
82+
}
83+
84+
function parseScriptList(envValue) {
85+
if (!envValue) return DEFAULTS.SCRIPTS;
86+
return envValue.split(',').map(s => s.trim()).filter(Boolean);
87+
}
88+
89+
function systemctlShow(unit) {
90+
// We deliberately do NOT use --no-pager — `systemctl show`
91+
// doesn't paginate. We also tolerate exit != 0 (e.g. unit not
92+
// found) by returning an empty parse, so the classifier sees
93+
// status=unknown and the operator gets a clear delta.
94+
try {
95+
const out = execFileSync('/usr/bin/systemctl', ['show', unit,
96+
'-p', 'ActiveState',
97+
'-p', 'SubState',
98+
'-p', 'Result',
99+
'-p', 'LastTriggerUSec',
100+
'-p', 'NRestarts',
101+
'-p', 'UnitFileState'
102+
], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
103+
return parseShowOutput(out);
104+
}
105+
catch {
106+
return {};
107+
}
108+
}
109+
110+
async function checkScript(path) {
111+
try {
112+
const s = await stat(path);
113+
if (!s.isFile()) return { status: 'missing', reason: 'not a regular file' };
114+
try {
115+
await access(path, FS.X_OK);
116+
}
117+
catch {
118+
return { status: 'missing', reason: 'not executable' };
119+
}
120+
return { status: 'ok', sizeBytes: s.size, mtimeMs: s.mtimeMs };
121+
}
122+
catch (err) {
123+
return { status: 'missing', reason: err?.code ?? String(err) };
124+
}
125+
}
126+
127+
async function loadPriorReport(path) {
128+
try {
129+
const buf = await readFile(path, 'utf8');
130+
return JSON.parse(buf);
131+
}
132+
catch {
133+
return null;
134+
}
135+
}
136+
137+
async function savePriorReport(path, report) {
138+
await mkdir(dirname(path), { recursive: true });
139+
await writeFile(path, JSON.stringify(report, null, 2));
140+
}
141+
142+
async function main() {
143+
const nowMs = Date.now();
144+
const units = parseUnitList(process.env.SENESCHAL_OPS_UNITS);
145+
const scripts = parseScriptList(process.env.SENESCHAL_OPS_SCRIPTS);
146+
const stateFile = process.env.SENESCHAL_OPS_STATE_FILE ?? DEFAULTS.STATE_FILE;
147+
148+
const unitClassifications = {};
149+
for (const u of units) {
150+
const serviceShow = systemctlShow(u.name);
151+
const timerShow = u.timerName ? systemctlShow(u.timerName) : null;
152+
unitClassifications[u.name] = classifyUnit({
153+
service: serviceShow,
154+
timer: timerShow,
155+
expected: u.intervalSec ? { intervalMs: u.intervalSec * 1000 } : null,
156+
nowMs
157+
});
158+
}
159+
160+
const scriptChecks = {};
161+
for (const p of scripts) {
162+
scriptChecks[p] = await checkScript(p);
163+
}
164+
165+
const report = buildReport({
166+
units: unitClassifications,
167+
scripts: scriptChecks,
168+
nowMs
169+
});
170+
171+
const prior = await loadPriorReport(stateFile);
172+
const changes = diffReports(prior, report);
173+
174+
// Always log the summary so journalctl tells a continuous
175+
// story even when nothing changed.
176+
console.log(JSON.stringify({
177+
t: new Date(nowMs).toISOString(),
178+
event: 'ops_watchdog_tick',
179+
overall: report.overall,
180+
summary: report.summary,
181+
changes
182+
}));
183+
184+
if (changes.length > 0) {
185+
console.warn(JSON.stringify({
186+
t: new Date(nowMs).toISOString(),
187+
event: 'ops_watchdog_change',
188+
level: report.overall === 'ok' ? 'recovery' : 'alert',
189+
changes
190+
}));
191+
const text = renderTelegramMessage({
192+
changes,
193+
report,
194+
host: hostname()
195+
});
196+
const tg = await sendTelegram({
197+
botToken: process.env.TELEGRAM_BOT_TOKEN,
198+
chatId: process.env.TELEGRAM_CHAT_ID,
199+
text
200+
});
201+
console.log(JSON.stringify({
202+
t: new Date(nowMs).toISOString(),
203+
event: 'ops_watchdog_alert',
204+
telegram: tg
205+
}));
206+
}
207+
208+
await savePriorReport(stateFile, report);
209+
process.exit(report.overall === 'ok' ? 0 : 2);
210+
}
211+
212+
main().catch(err => {
213+
console.error(JSON.stringify({
214+
t: new Date().toISOString(),
215+
event: 'ops_watchdog_crash',
216+
error: err?.stack ?? err?.message ?? String(err)
217+
}));
218+
process.exit(1);
219+
});

0 commit comments

Comments
 (0)