spinuptui 1.3.0-alpha.4 → 1.3.0-alpha.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -1
- package/README.md +8 -2
- package/package.json +1 -1
- package/src/config.ts +29 -0
- package/src/index.tsx +8 -0
- package/src/lib/beacon.test.ts +14 -0
- package/src/lib/beacon.ts +4 -1
- package/src/lib/beaconBoard.test.ts +69 -0
- package/src/lib/beaconBoard.ts +197 -0
- package/src/lib/beaconListen.test.ts +30 -1
- package/src/lib/beaconListen.ts +40 -17
- package/src/lib/beaconListenInstall.ts +13 -4
- package/src/lib/cliBeacon.ts +25 -1
- package/src/lib/cliIncidents.ts +13 -6
- package/src/lib/cliSentinel.ts +451 -0
- package/src/lib/heartbeat.ts +5 -0
- package/src/lib/schedule.ts +4 -1
- package/src/lib/scheduler.test.ts +88 -1
- package/src/lib/schedulerJobs.ts +4 -2
- package/src/lib/schedulerRun.ts +61 -15
- package/src/lib/schedulerTick.ts +35 -13
- package/src/lib/sentinelAttach.test.ts +45 -0
- package/src/lib/sentinelAttach.ts +98 -0
- package/src/lib/sentinelDigest.test.ts +82 -0
- package/src/lib/sentinelDigest.ts +261 -0
- package/src/lib/sentinelExpect.test.ts +82 -0
- package/src/lib/sentinelExpect.ts +197 -0
- package/src/lib/sentinelIncidents.test.ts +145 -0
- package/src/lib/sentinelIncidents.ts +194 -0
- package/src/lib/sentinelPrompt.ts +89 -0
- package/src/lib/sentinelReply.ts +36 -0
- package/src/lib/sentinelState.ts +246 -0
- package/src/lib/sentinelTick.test.ts +214 -0
- package/src/lib/sentinelTick.ts +265 -0
- package/src/lib/sentinelWorker.test.ts +170 -0
- package/src/lib/sentinelWorker.ts +291 -0
- package/src/modules/beacon.ts +3 -0
- package/src/modules/index.ts +2 -7
- package/src/modules/scheduler.ts +31 -8
- package/src/modules/sentinel.ts +90 -0
- package/src/ui/App.tsx +4 -0
- package/src/ui/store.tsx +5 -0
- package/src/ui/views/Modules.tsx +56 -3
- package/src/ui/views/SentinelSetup.tsx +171 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// The daily digest: the place a slow-moving problem gets said out loud. It always
|
|
2
|
+
// sends, and it's silent unless something is new, because a header that's amber
|
|
3
|
+
// every day stops being read. A flag is under New on the day it trips, then under
|
|
4
|
+
// Ongoing with its age (docs/2026-10-30_sentinel-spec.md, "The digest").
|
|
5
|
+
|
|
6
|
+
import type { AppConfig } from "../config.ts"
|
|
7
|
+
import type { SpinupWPClientLike } from "../api/client.ts"
|
|
8
|
+
import { schedulerModule } from "../modules/scheduler.ts"
|
|
9
|
+
import { boardLogin } from "./beaconBoard.ts"
|
|
10
|
+
import { listenerHealth } from "./beaconListenInstall.ts"
|
|
11
|
+
import { agentCommand, AUTH_FAIL_RE, jobEnv, runProcess } from "./schedulerRun.ts"
|
|
12
|
+
import { APT_COMMAND, appendTrends, loadExpectations, readApt, runExpectation, validateExpectation, type ExpectDeps, type ExpectResult } from "./sentinelExpect.ts"
|
|
13
|
+
import { isWaiting, loadFleet, loadIncidents, loadPause, readTickLog, saveFleet, saveFlags, readJson, writeJson, sentinelDir, type Fleet, type Flags, type Incident, type SentinelSettings, type TickLine } from "./sentinelState.ts"
|
|
14
|
+
import { refreshFleetFrom } from "./sentinelTick.ts"
|
|
15
|
+
import { execSshCommand } from "./sshExec.ts"
|
|
16
|
+
import { hasIncidentMonitors } from "./cliIncidents.ts"
|
|
17
|
+
import { withKuma } from "./uptimeKuma.ts"
|
|
18
|
+
import { join } from "node:path"
|
|
19
|
+
|
|
20
|
+
const CHECK_TIMEOUT_MS = 30_000
|
|
21
|
+
const CONCURRENCY = 4
|
|
22
|
+
const AGENT_PROBE_MS = 60_000
|
|
23
|
+
const DAY_MS = 24 * 3_600_000
|
|
24
|
+
const STUCK_MS = DAY_MS
|
|
25
|
+
|
|
26
|
+
export interface HealthCheck {
|
|
27
|
+
name: string
|
|
28
|
+
ok: boolean
|
|
29
|
+
detail: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface DigestData {
|
|
33
|
+
when: Date
|
|
34
|
+
health: HealthCheck[]
|
|
35
|
+
ticks: TickLine[]
|
|
36
|
+
incidents: Incident[]
|
|
37
|
+
results: ExpectResult[]
|
|
38
|
+
aptTotal: number
|
|
39
|
+
apt: ExpectResult[]
|
|
40
|
+
notes: string[]
|
|
41
|
+
paused?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface Digest {
|
|
45
|
+
text: string
|
|
46
|
+
silent: boolean
|
|
47
|
+
flags: Flags
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const localDay = (d: Date) => d.toLocaleDateString("en-CA")
|
|
51
|
+
const dayCount = (from: string, to: string) => Math.round((Date.parse(to) - Date.parse(from)) / DAY_MS)
|
|
52
|
+
const plural = (n: number, s: string) => `${n} ${s}${n === 1 ? "" : "s"}`
|
|
53
|
+
|
|
54
|
+
export function composeDigest(d: DigestData, flags: Flags): Digest {
|
|
55
|
+
const today = localDay(d.when)
|
|
56
|
+
const since = d.when.getTime() - DAY_MS
|
|
57
|
+
const checks = [...d.results, ...d.apt]
|
|
58
|
+
const next: Flags = {}
|
|
59
|
+
const fresh: string[] = []
|
|
60
|
+
const ongoing: string[] = []
|
|
61
|
+
let acked = 0
|
|
62
|
+
for (const r of checks) {
|
|
63
|
+
if (r.state !== "flag") continue
|
|
64
|
+
const line = `${r.label}: ${r.text}`
|
|
65
|
+
const prior = flags[r.id]
|
|
66
|
+
next[r.id] = { firstSeen: prior?.firstSeen ?? today, text: line, ...(prior?.acked ? { acked: true } : {}) }
|
|
67
|
+
if (!prior || prior.firstSeen === today) fresh.push(line)
|
|
68
|
+
else if (prior.acked) acked++
|
|
69
|
+
else ongoing.push(`${line} (${plural(dayCount(prior.firstSeen, today), "day")})`)
|
|
70
|
+
}
|
|
71
|
+
// A check that couldn't run says nothing either way, so its flag keeps its age.
|
|
72
|
+
for (const r of checks) if (r.state === "unverified" && flags[r.id]) next[r.id] = flags[r.id]!
|
|
73
|
+
|
|
74
|
+
const recent = d.incidents.filter((i) => Date.parse(i.createdAt) >= since)
|
|
75
|
+
const stuck = d.incidents.filter((i) => isWaiting(i) && d.when.getTime() - Date.parse(i.createdAt) > STUCK_MS)
|
|
76
|
+
const failing = d.health.filter((h) => !h.ok)
|
|
77
|
+
const reports = recent.filter((i) => i.state === "reported").length
|
|
78
|
+
const recurrences = recent.filter((i) => i.state === "recurrence")
|
|
79
|
+
const blind = d.ticks.filter((t) => !t.ok).length
|
|
80
|
+
const retried = d.ticks.filter((t) => t.ok && t.retries > 0).length
|
|
81
|
+
|
|
82
|
+
const date = d.when.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" })
|
|
83
|
+
const attention = fresh.length > 0 || failing.length > 0 || stuck.length > 0
|
|
84
|
+
const header = !attention ? `✅ Fleet OK — ${date}` : fresh.length ? `⚠️ ${fresh.length} new — ${date}` : `⚠️ Needs attention — ${date}`
|
|
85
|
+
|
|
86
|
+
const lines = [header, ""]
|
|
87
|
+
if (d.paused) lines.push(`Paused until ${d.paused}.`, "")
|
|
88
|
+
lines.push(d.health.map((h) => `${h.name}: ${h.ok ? h.detail : `⚠️ ${h.detail}`}`).join("\n"))
|
|
89
|
+
lines.push(`Last 24h: ${plural(d.ticks.length, "check")} · ${plural(recent.length - recurrences.length, "incident")} · ${plural(reports, "report")} · ${plural(recurrences.length, "recurrence")} · ${blind} blind${retried ? ` · ${retried} cold start${retried === 1 ? "" : "s"}` : ""}`)
|
|
90
|
+
if (recurrences.length) {
|
|
91
|
+
const bySite = new Map<string, number>()
|
|
92
|
+
for (const i of recurrences) bySite.set(i.events[0]!.domain, (bySite.get(i.events[0]!.domain) ?? 0) + 1)
|
|
93
|
+
lines.push(`Recurring: ${[...bySite].map(([s, n]) => `${s} ×${n}`).join(", ")}`)
|
|
94
|
+
}
|
|
95
|
+
if (stuck.length) lines.push("", "Stuck:", ...stuck.map((i) => ` ${i.id}: ${i.state}${i.why ? ` (${i.why})` : ""}`))
|
|
96
|
+
if (fresh.length) lines.push("", "New:", ...fresh.map((l) => ` ${l}`))
|
|
97
|
+
if (ongoing.length) lines.push("", "Ongoing:", ...ongoing.map((l) => ` ${l}`))
|
|
98
|
+
if (acked) lines.push("", `${plural(acked, "acknowledged flag")} still set.`)
|
|
99
|
+
const quiet = d.results.filter((r) => r.state !== "flag")
|
|
100
|
+
if (quiet.length) lines.push("", "Checks:", ...quiet.map((r) => ` ${r.label}: ${r.state === "unverified" ? `unverified (${r.text})` : r.text}`))
|
|
101
|
+
if (d.aptTotal) {
|
|
102
|
+
const unverified = d.apt.filter((r) => r.state === "unverified")
|
|
103
|
+
const normal = d.apt.filter((r) => r.state === "ok").length
|
|
104
|
+
lines.push("", `Server updates: ${normal} of ${d.aptTotal} normal${unverified.length ? `, unverified: ${unverified.map((r) => r.label).join(", ")}` : ""}`)
|
|
105
|
+
}
|
|
106
|
+
if (d.notes.length) lines.push("", ...d.notes)
|
|
107
|
+
return { text: lines.join("\n"), silent: !attention, flags: next }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---- gathering --------------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
async function bounded<T>(fn: () => Promise<T>, fallback: T, ms = CHECK_TIMEOUT_MS): Promise<T> {
|
|
113
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
114
|
+
try {
|
|
115
|
+
return await Promise.race([fn(), new Promise<T>((r) => (timer = setTimeout(() => r(fallback), ms)))])
|
|
116
|
+
} catch {
|
|
117
|
+
return fallback
|
|
118
|
+
} finally {
|
|
119
|
+
clearTimeout(timer)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function mapLimit<T, R>(items: T[], limit: number, fn: (x: T) => Promise<R>): Promise<R[]> {
|
|
124
|
+
const out: R[] = new Array(items.length)
|
|
125
|
+
let next = 0
|
|
126
|
+
await Promise.all(
|
|
127
|
+
Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
128
|
+
while (next < items.length) {
|
|
129
|
+
const i = next++
|
|
130
|
+
out[i] = await fn(items[i]!)
|
|
131
|
+
}
|
|
132
|
+
}),
|
|
133
|
+
)
|
|
134
|
+
return out
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function expectDeps(cfg: AppConfig, client: SpinupWPClientLike): ExpectDeps {
|
|
138
|
+
return {
|
|
139
|
+
exec: async (domain, command, server) => {
|
|
140
|
+
const r = await execSshCommand(domain, command, client, cfg, { server: server ?? null })
|
|
141
|
+
return r.ok ? r.stdout : null
|
|
142
|
+
},
|
|
143
|
+
status: async (url) => {
|
|
144
|
+
try {
|
|
145
|
+
return (await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(20_000) })).status
|
|
146
|
+
} catch {
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// One attempt per server per day, on the site that worked last: a server with no
|
|
154
|
+
// reachable site reads "unverified", and fail2ban never hears about it.
|
|
155
|
+
const aptSitesPath = () => join(sentinelDir(), "apt-sites.json")
|
|
156
|
+
|
|
157
|
+
export function aptTargets(fleet: Fleet, lastGood: Record<string, string>, monitored: Set<string>): { server: string; domain: string }[] {
|
|
158
|
+
const byServer = new Map<string, string[]>()
|
|
159
|
+
for (const [domain, e] of Object.entries(fleet.domains)) {
|
|
160
|
+
if (domain !== e.site) continue
|
|
161
|
+
byServer.set(e.server, [...(byServer.get(e.server) ?? []), domain])
|
|
162
|
+
}
|
|
163
|
+
return [...byServer].sort().map(([server, domains]) => {
|
|
164
|
+
const rank = (d: string) => (d === lastGood[server] ? 0 : monitored.has(d) ? 1 : fleet.domains[d]?.vanity ? 2 : 3)
|
|
165
|
+
return { server, domain: [...domains].sort((a, b) => rank(a) - rank(b) || a.localeCompare(b))[0]! }
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function checkApt(fleet: Fleet, cfg: AppConfig, deps: ExpectDeps, dryRun: boolean): Promise<ExpectResult[]> {
|
|
170
|
+
const lastGood = readJson<Record<string, string>>(aptSitesPath(), {})
|
|
171
|
+
const monitored = new Set(Object.entries(cfg.kumaMonitors).filter(([, r]) => hasIncidentMonitors(r)).map(([d]) => d))
|
|
172
|
+
const results = await mapLimit(aptTargets(fleet, lastGood, monitored), CONCURRENCY, async ({ server, domain }) => {
|
|
173
|
+
const out = await bounded(() => deps.exec(domain, APT_COMMAND), null)
|
|
174
|
+
const read = readApt(out)
|
|
175
|
+
if (read) lastGood[server] = domain
|
|
176
|
+
const base = { id: `apt-${server}`, label: server }
|
|
177
|
+
if (!read) return { ...base, state: "unverified" as const, text: "probe failed" }
|
|
178
|
+
return read.flag ? { ...base, state: "flag" as const, text: read.flag } : { ...base, state: "ok" as const, text: "updating" }
|
|
179
|
+
})
|
|
180
|
+
if (!dryRun) writeJson(aptSitesPath(), lastGood)
|
|
181
|
+
return results
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function probeAgent(s: SentinelSettings): Promise<HealthCheck> {
|
|
185
|
+
const env = jobEnv()
|
|
186
|
+
const cmd = agentCommand({ agent: s.agent!, prompt: "Reply with exactly: AUTH_OK", workdir: s.workspace }, env)
|
|
187
|
+
if (typeof cmd === "string") return { name: "Agent", ok: false, detail: cmd }
|
|
188
|
+
const r = await runProcess(cmd, { cwd: sentinelDir(), env, timeoutMs: AGENT_PROBE_MS }).catch((e: Error) => ({ code: 1, output: e.message, timedOut: false }))
|
|
189
|
+
if (r.output.includes("AUTH_OK")) return { name: "Agent", ok: true, detail: "OK" }
|
|
190
|
+
return { name: "Agent", ok: false, detail: AUTH_FAIL_RE.test(r.output) ? `the ${s.agent} login has lapsed` : `unverified (probe ${r.timedOut ? "timed out" : `exited ${r.code}`})` }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function gatherDigest(s: SentinelSettings, cfg: AppConfig, client: SpinupWPClientLike, opts: { dryRun?: boolean; now?: Date } = {}): Promise<DigestData> {
|
|
194
|
+
const when = opts.now ?? new Date()
|
|
195
|
+
const notes: string[] = []
|
|
196
|
+
let fleet = loadFleet()
|
|
197
|
+
try {
|
|
198
|
+
fleet = await refreshFleetFrom(client, when)
|
|
199
|
+
if (!opts.dryRun) saveFleet(fleet)
|
|
200
|
+
} catch (err) {
|
|
201
|
+
notes.push(`Couldn't refresh the fleet list: ${(err as Error).message}`)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const health: HealthCheck[] = []
|
|
205
|
+
const sched = await schedulerModule.status()
|
|
206
|
+
const bad = sched.checks.find((c) => !c.ok && (c.name === "last tick" || c.name === "dispatcher" || c.name === "heartbeat"))
|
|
207
|
+
health.push({ name: "Station", ok: !bad, detail: bad ? `${bad.name}: ${bad.detail}` : "ticking" })
|
|
208
|
+
if (s.agent) health.push(await probeAgent(s))
|
|
209
|
+
if (s.reportTo) {
|
|
210
|
+
const b = await bounded(() => boardLogin(), { ok: false, detail: "timed out" })
|
|
211
|
+
health.push({ name: "Board", ok: b.ok, detail: b.ok ? "OK" : b.detail })
|
|
212
|
+
}
|
|
213
|
+
const listener = listenerHealth()
|
|
214
|
+
if (listener) health.push({ name: "Listener", ok: listener.ok, detail: listener.ok ? "OK" : listener.detail })
|
|
215
|
+
if (cfg.uptimeKuma) {
|
|
216
|
+
const ids = await bounded(async () => new Set((await withKuma(cfg.uptimeKuma!, (k) => k.getMonitors())).result.map((m) => m.id)), null)
|
|
217
|
+
if (!ids) health.push({ name: "Kuma", ok: false, detail: "couldn't list monitors" })
|
|
218
|
+
else {
|
|
219
|
+
const gone = Object.entries(cfg.kumaMonitors)
|
|
220
|
+
.filter(([, r]) => Object.entries(r).some(([k, v]) => k.endsWith("Id") && typeof v === "number" && !ids.has(v)))
|
|
221
|
+
.map(([d]) => d)
|
|
222
|
+
health.push({ name: "Kuma", ok: gone.length === 0, detail: gone.length ? `monitors deleted for ${gone.join(", ")}` : "OK" })
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const deps = expectDeps(cfg, client)
|
|
227
|
+
const today = when.getTime()
|
|
228
|
+
const active = loadExpectations().filter((e) => {
|
|
229
|
+
if (validateExpectation(e).length) {
|
|
230
|
+
notes.push(`Skipped invalid check ${e.id}: ${validateExpectation(e).join("; ")}`)
|
|
231
|
+
return false
|
|
232
|
+
}
|
|
233
|
+
if (e.expires && Date.parse(`${e.expires}T23:59:59`) < today) {
|
|
234
|
+
notes.push(`Check ${e.id} expired on ${e.expires} and no longer runs.`)
|
|
235
|
+
return false
|
|
236
|
+
}
|
|
237
|
+
return true
|
|
238
|
+
})
|
|
239
|
+
const results = await mapLimit(active, CONCURRENCY, (e) =>
|
|
240
|
+
bounded(() => runExpectation(e, deps), { id: e.id, label: e.label ?? e.id, state: "unverified" as const, text: "timed out" }, 90_000),
|
|
241
|
+
)
|
|
242
|
+
const apt = await checkApt(fleet, cfg, deps, !!opts.dryRun)
|
|
243
|
+
const pause = loadPause()
|
|
244
|
+
return {
|
|
245
|
+
when,
|
|
246
|
+
health,
|
|
247
|
+
ticks: readTickLog(when.getTime() - DAY_MS),
|
|
248
|
+
incidents: loadIncidents().filter((i) => !i.closedAt || Date.parse(i.createdAt) > when.getTime() - DAY_MS || isWaiting(i)),
|
|
249
|
+
results,
|
|
250
|
+
aptTotal: apt.length,
|
|
251
|
+
apt,
|
|
252
|
+
notes,
|
|
253
|
+
...(pause && Date.parse(pause.until) > when.getTime() ? { paused: new Date(pause.until).toLocaleString() } : {}),
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function recordDigest(digest: Digest, data: DigestData): void {
|
|
258
|
+
saveFlags(digest.flags)
|
|
259
|
+
appendTrends(data.results, localDay(new Date(data.when.getTime() - DAY_MS)))
|
|
260
|
+
}
|
|
261
|
+
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { APT_COMMAND, cacheProbeCommand, loadShapeCommand, logCountCommand, readApt, runExpectation, validateExpectation, warmerCommand, type ExpectDeps, type Expectation } from "./sentinelExpect.ts"
|
|
3
|
+
import { classifySshCommand } from "./sshExecPolicy.ts"
|
|
4
|
+
|
|
5
|
+
const answering = (stdout: string | null, status: number | null = 200): ExpectDeps => ({ exec: async () => stdout, status: async () => status })
|
|
6
|
+
|
|
7
|
+
describe("every remote command", () => {
|
|
8
|
+
test("gets past the ssh-exec policy", () => {
|
|
9
|
+
const commands = [logCountCommand("SomeCrawler/1.1"), logCountCommand("/old_tax/"), loadShapeCommand(8), cacheProbeCommand("www.example.net", "/?gbraid=probe"), warmerCommand("~/files/cache-warm/logs/warm-*.log"), APT_COMMAND]
|
|
10
|
+
for (const c of commands) expect([c, classifySshCommand(c).decision]).toEqual([c, "allow"])
|
|
11
|
+
})
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
describe("validation", () => {
|
|
15
|
+
const valid = (e: unknown) => validateExpectation(e as Expectation)
|
|
16
|
+
|
|
17
|
+
test("accepts each type", () => {
|
|
18
|
+
expect(valid({ id: "a-b", type: "log-count", domain: "example.com", match: "Bot/1.1", min: 10 })).toEqual([])
|
|
19
|
+
expect(valid({ id: "a-b", type: "load-shape", domain: "example.com", threshold: 8 })).toEqual([])
|
|
20
|
+
expect(valid({ id: "a-b", type: "cache-hit", domain: "example.com", host: "www.example.com", paths: ["/", "/?a=b"] })).toEqual([])
|
|
21
|
+
expect(valid({ id: "a-b", type: "warmer", domain: "example.com", logGlob: "~/files/logs/warm-*.log" })).toEqual([])
|
|
22
|
+
expect(valid({ id: "a-b", type: "status", url: "https://example.com/x", expect: 410 })).toEqual([])
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test("keeps shell out of what reaches a remote command", () => {
|
|
26
|
+
expect(valid({ id: "a-b", type: "log-count", domain: "example.com", match: 'x"; rm -rf ~; "' }).length).toBe(1)
|
|
27
|
+
expect(valid({ id: "a-b", type: "log-count", domain: "example.com", match: "$(id)" }).length).toBe(1)
|
|
28
|
+
expect(valid({ id: "a-b", type: "cache-hit", domain: "example.com", host: "h.example.com", paths: ["/?a=1&b=2"] }).length).toBe(1)
|
|
29
|
+
expect(valid({ id: "a-b", type: "warmer", domain: "example.com", logGlob: "x; id" }).length).toBe(1)
|
|
30
|
+
expect(valid({ id: "a-b", type: "cache-hit", domain: "example.com", host: "$(id)", paths: ["/"] }).length).toBe(1)
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe("running a check", () => {
|
|
35
|
+
const count: Expectation = { id: "c", type: "log-count", domain: "example.com", match: "Bot", min: 100, trend: true, label: "Bot" }
|
|
36
|
+
|
|
37
|
+
test("log-count", async () => {
|
|
38
|
+
expect(await runExpectation(count, answering("150\n"))).toMatchObject({ state: "ok", trend: "150" })
|
|
39
|
+
expect(await runExpectation(count, answering("0\n"))).toMatchObject({ state: "flag", text: "LOW: 0 yesterday (expected ≥100)" })
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test("a failed probe is unverified, never a pass or a flag", async () => {
|
|
43
|
+
expect((await runExpectation(count, answering(null))).state).toBe("unverified")
|
|
44
|
+
expect((await runExpectation(count, answering(""))).state).toBe("unverified")
|
|
45
|
+
expect((await runExpectation({ id: "l", type: "load-shape", domain: "e.com", threshold: 8 }, answering(""))).state).toBe("unverified")
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test("load-shape flags a latch, not a spike", async () => {
|
|
49
|
+
const e: Expectation = { id: "l", type: "load-shape", domain: "e.com", threshold: 8 }
|
|
50
|
+
expect(await runExpectation(e, answering("9.5 2 2\n"))).toMatchObject({ state: "ok", text: "load max 9.5, 2 over 8, longest 20 min" })
|
|
51
|
+
expect((await runExpectation(e, answering("9.5 3 3\n"))).state).toBe("flag")
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test("cache-hit", async () => {
|
|
55
|
+
const e: Expectation = { id: "h", type: "cache-hit", domain: "e.com", host: "www.e.com", paths: ["/", "/a"] }
|
|
56
|
+
expect((await runExpectation(e, answering("HIT\n"))).state).toBe("ok")
|
|
57
|
+
expect(await runExpectation(e, answering("MISS\n"))).toMatchObject({ state: "flag" })
|
|
58
|
+
expect((await runExpectation(e, answering(null))).state).toBe("unverified")
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test("warmer", async () => {
|
|
62
|
+
const e: Expectation = { id: "w", type: "warmer", domain: "e.com", logGlob: "~/w-*.log" }
|
|
63
|
+
expect((await runExpectation(e, answering("12\n"))).state).toBe("ok")
|
|
64
|
+
expect(await runExpectation(e, answering("0\n"))).toMatchObject({ state: "flag", text: "WARMER IDLE: 0 queued" })
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test("status", async () => {
|
|
68
|
+
const e: Expectation = { id: "s", type: "status", url: "https://e.com/x", expect: 410 }
|
|
69
|
+
expect((await runExpectation(e, answering(null, 410))).state).toBe("ok")
|
|
70
|
+
expect((await runExpectation(e, answering(null, 200))).state).toBe("flag")
|
|
71
|
+
expect((await runExpectation(e, answering(null, null))).state).toBe("unverified")
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe("readApt", () => {
|
|
76
|
+
const now = 1_800_000_000
|
|
77
|
+
test("healthy", () => expect(readApt(`${now - 3600} ${now} inactive 0`)).toEqual({ flag: null }))
|
|
78
|
+
test("stale", () => expect(readApt(`${now - 10 * 86_400} ${now} inactive 0`)!.flag).toContain("STALE: last successful update 10d ago"))
|
|
79
|
+
test("no stamp", () => expect(readApt(`none ${now} inactive 0`)!.flag).toContain("no successful update"))
|
|
80
|
+
test("stuck", () => expect(readApt(`${now} ${now} activating ${now - 3 * 86_400}`)!.flag).toContain("STUCK: apt-daily running 3d"))
|
|
81
|
+
test("unreadable", () => expect(readApt(null)).toBeNull())
|
|
82
|
+
})
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Expectation checks: what uptime monitoring can't see. A crawler that stopped
|
|
2
|
+
// arriving, a cache that stopped hitting, a box that latches under load. They run
|
|
3
|
+
// with the digest, read-only through ssh-exec, and report on the previous full
|
|
4
|
+
// day: at 08:00 today's log is only a few hours old and would read as a collapse.
|
|
5
|
+
//
|
|
6
|
+
// A failed probe is "unverified", never a pass and never a flag. Commands avoid a
|
|
7
|
+
// bare `>` and multi-parameter URLs, which the ssh-exec policy reads as a redirect
|
|
8
|
+
// and a shell operator (docs/2026-10-30_sentinel-spec.md, "Expectations").
|
|
9
|
+
|
|
10
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"
|
|
11
|
+
import { dirname } from "node:path"
|
|
12
|
+
import { parseDate } from "./schedule.ts"
|
|
13
|
+
import { expectationsPath, trendsPath, writeJson } from "./sentinelState.ts"
|
|
14
|
+
|
|
15
|
+
interface Base {
|
|
16
|
+
id: string
|
|
17
|
+
domain?: string
|
|
18
|
+
server?: string // when the domain exists on more than one server
|
|
19
|
+
label?: string
|
|
20
|
+
note?: string
|
|
21
|
+
expires?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type Expectation = Base &
|
|
25
|
+
(
|
|
26
|
+
| { type: "log-count"; domain: string; match: string; min?: number; trend?: boolean }
|
|
27
|
+
| { type: "load-shape"; domain: string; threshold: number }
|
|
28
|
+
| { type: "cache-hit"; domain: string; host: string; paths: string[] }
|
|
29
|
+
| { type: "warmer"; domain: string; logGlob: string }
|
|
30
|
+
| { type: "status"; url: string; expect: number }
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
export const EXPECT_TYPES = ["log-count", "load-shape", "cache-hit", "warmer", "status"] as const
|
|
34
|
+
const ID_RE = /^[a-z0-9][a-z0-9-]{1,79}$/
|
|
35
|
+
const DOMAIN_RE = /^[a-z0-9.-]+$/i
|
|
36
|
+
const LATCHED_SAMPLES = 3
|
|
37
|
+
// `sar` writes a sample every 10 minutes.
|
|
38
|
+
const SAMPLE_MIN = 10
|
|
39
|
+
|
|
40
|
+
export function loadExpectations(): Expectation[] {
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(readFileSync(expectationsPath(), "utf8")) as Expectation[]
|
|
43
|
+
} catch {
|
|
44
|
+
return []
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const saveExpectations = (list: Expectation[]) => writeJson(expectationsPath(), list)
|
|
49
|
+
|
|
50
|
+
// Everything here ends up inside a remote shell string, so each value is held to
|
|
51
|
+
// what it needs to be rather than escaped and hoped for.
|
|
52
|
+
export function validateExpectation(e: Expectation): string[] {
|
|
53
|
+
const p: string[] = []
|
|
54
|
+
if (!ID_RE.test(e.id ?? "")) p.push("id must be lowercase letters, digits and hyphens")
|
|
55
|
+
if (!(EXPECT_TYPES as readonly string[]).includes(e.type)) p.push(`type must be one of ${EXPECT_TYPES.join(", ")}`)
|
|
56
|
+
if (e.expires) {
|
|
57
|
+
try {
|
|
58
|
+
parseDate(e.expires)
|
|
59
|
+
} catch (err) {
|
|
60
|
+
p.push((err as Error).message)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (e.type !== "status" && !DOMAIN_RE.test(e.domain ?? "")) p.push("domain is required")
|
|
64
|
+
if (e.type === "log-count") {
|
|
65
|
+
if (!e.match) p.push("match is required")
|
|
66
|
+
if (/["$`\\\n>]/.test(e.match ?? "")) p.push('match can\'t contain " $ ` \\ or >')
|
|
67
|
+
if (e.min !== undefined && !(e.min >= 0)) p.push("min must be a number")
|
|
68
|
+
}
|
|
69
|
+
if (e.type === "load-shape" && !(e.threshold > 0)) p.push("threshold must be a positive number")
|
|
70
|
+
if (e.type === "cache-hit") {
|
|
71
|
+
if (!DOMAIN_RE.test(e.host ?? "")) p.push("host is required: the site's canonical host")
|
|
72
|
+
if (!e.paths?.length) p.push("paths is required")
|
|
73
|
+
for (const path of e.paths ?? []) if (!/^\/[\w./?=%-]*$/.test(path) || path.includes("&")) p.push(`${path}: a path may carry one query parameter and no shell characters`)
|
|
74
|
+
}
|
|
75
|
+
if (e.type === "warmer" && !/^[\w./~*-]+$/.test(e.logGlob ?? "")) p.push("logGlob is required, as a plain path with * wildcards")
|
|
76
|
+
if (e.type === "status") {
|
|
77
|
+
if (!/^https?:\/\//.test(e.url ?? "")) p.push("url is required")
|
|
78
|
+
if (!(e.expect >= 100)) p.push("expect is required: the status code")
|
|
79
|
+
}
|
|
80
|
+
return p
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---- the remote commands ----------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
// grep -c exits 1 on a zero count, so callers read stdout rather than the exit code.
|
|
86
|
+
export const logCountCommand = (match: string) => `zcat ~/logs/access.log-$(date +%Y%m%d).gz 2>/dev/null | grep -cF "${match}"`
|
|
87
|
+
|
|
88
|
+
// `!(x < T)` and `if (mx < x)` rather than `x >= T`: the guard reads a bare > as a
|
|
89
|
+
// redirect and denies the whole command.
|
|
90
|
+
export const loadShapeCommand = (threshold: number) =>
|
|
91
|
+
String.raw`LC_ALL=C sar -q -f /var/log/sysstat/sa$(date -d yesterday +%d) 2>/dev/null | awk -v T=${threshold} "\$4 ~ /^[0-9.]+\$/ {n++; if (!(\$4+0 < T)) {run++; over++; if (mxrun < run) mxrun=run} else {run=0}; if (mx < \$4+0) mx=\$4} END {printf \"%s %d %d\\n\", (mx==\"\"?0:mx), over+0, mxrun+0}"`
|
|
92
|
+
|
|
93
|
+
// No `-o /dev/null`: the guard reads an output flag as a redirect, so the body
|
|
94
|
+
// falls through and grep picks the header out of it.
|
|
95
|
+
export const cacheProbeCommand = (host: string, path: string) =>
|
|
96
|
+
String.raw`for i in 1 2; do curl -s -D - --max-time 15 --resolve ${host}:443:127.0.0.1 "https://${host}${path}"; done | grep -i "^fastcgi-cache:" | tail -1 | awk "{print \$2}"`
|
|
97
|
+
|
|
98
|
+
export const warmerCommand = (glob: string) => `L=$(ls -t ${glob} 2>/dev/null | head -1); [ -n "$L" ] && grep -hoE "starting: [0-9]+ URLs queued" "$L" | grep -oE "[0-9]+" | head -1`
|
|
99
|
+
|
|
100
|
+
export const APT_COMMAND =
|
|
101
|
+
'echo $(stat -c %Y /var/lib/apt/periodic/update-success-stamp 2>/dev/null || echo none) $(date +%s) $(systemctl show apt-daily.service -p ActiveState --value) $(date -d "$(systemctl show apt-daily.service -p StateChangeTimestamp --value)" +%s 2>/dev/null || echo 0)'
|
|
102
|
+
|
|
103
|
+
// ---- running one ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
export type ExpectState = "ok" | "flag" | "unverified"
|
|
106
|
+
|
|
107
|
+
export interface ExpectResult {
|
|
108
|
+
id: string
|
|
109
|
+
label: string
|
|
110
|
+
state: ExpectState
|
|
111
|
+
text: string
|
|
112
|
+
trend?: string
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface ExpectDeps {
|
|
116
|
+
// stdout, or null when the probe itself failed.
|
|
117
|
+
exec(domain: string, command: string, server?: string): Promise<string | null>
|
|
118
|
+
status(url: string): Promise<number | null>
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const labelOf = (e: Expectation) => e.label ?? e.id
|
|
122
|
+
const num = (s: string | null) => (s !== null && /^\d+$/.test(s.trim()) ? Number(s.trim()) : null)
|
|
123
|
+
|
|
124
|
+
export async function runExpectation(e: Expectation, deps: ExpectDeps): Promise<ExpectResult> {
|
|
125
|
+
const r = (state: ExpectState, text: string, trend?: string): ExpectResult => ({ id: e.id, label: labelOf(e), state, text, ...(trend !== undefined ? { trend } : {}) })
|
|
126
|
+
switch (e.type) {
|
|
127
|
+
case "log-count": {
|
|
128
|
+
const n = num(await deps.exec(e.domain, logCountCommand(e.match), e.server))
|
|
129
|
+
if (n === null) return r("unverified", "no count returned")
|
|
130
|
+
const trend = e.trend ? String(n) : undefined
|
|
131
|
+
if (e.min !== undefined && n < e.min) return r("flag", `LOW: ${n} yesterday (expected ≥${e.min})`, trend)
|
|
132
|
+
return r("ok", String(n), trend)
|
|
133
|
+
}
|
|
134
|
+
case "load-shape": {
|
|
135
|
+
const out = (await deps.exec(e.domain, loadShapeCommand(e.threshold), e.server))?.trim().split(/\s+/)
|
|
136
|
+
if (out?.length !== 3 || !/^[\d.]+$/.test(out[0]!) || !/^\d+$/.test(out[1]!) || !/^\d+$/.test(out[2]!)) return r("unverified", "no sar data")
|
|
137
|
+
const [max, over, run] = [out[0]!, Number(out[1]), Number(out[2])]
|
|
138
|
+
const shape = `load max ${max}, ${over} over ${e.threshold}, longest ${run * SAMPLE_MIN} min`
|
|
139
|
+
return run >= LATCHED_SAMPLES ? r("flag", `LATCHED: ${shape}`) : r("ok", shape)
|
|
140
|
+
}
|
|
141
|
+
case "cache-hit": {
|
|
142
|
+
const bad: string[] = []
|
|
143
|
+
let probes = 0
|
|
144
|
+
for (const path of e.paths) {
|
|
145
|
+
const got = (await deps.exec(e.domain, cacheProbeCommand(e.host, path), e.server))?.trim()
|
|
146
|
+
if (got) probes++
|
|
147
|
+
if (got?.toUpperCase() !== "HIT") bad.push(`${path}=${got || "no answer"}`)
|
|
148
|
+
}
|
|
149
|
+
if (!probes) return r("unverified", "no answer from any path")
|
|
150
|
+
return bad.length ? r("flag", `CACHE REGRESSED: ${bad.join(", ")}`) : r("ok", `${e.paths.length} path(s) HIT`)
|
|
151
|
+
}
|
|
152
|
+
case "warmer": {
|
|
153
|
+
const n = num(await deps.exec(e.domain, warmerCommand(e.logGlob), e.server))
|
|
154
|
+
if (n === null) return r("unverified", "no warmer log found")
|
|
155
|
+
return n === 0 ? r("flag", "WARMER IDLE: 0 queued") : r("ok", `${n} queued`)
|
|
156
|
+
}
|
|
157
|
+
case "status": {
|
|
158
|
+
const code = await deps.status(e.url)
|
|
159
|
+
if (code === null) return r("unverified", "no response")
|
|
160
|
+
return code === e.expect ? r("ok", String(code)) : r("flag", `${e.url} returned ${code} (expected ${e.expect})`)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function appendTrends(results: ExpectResult[], date: string): void {
|
|
166
|
+
const rows = results.filter((r) => r.trend !== undefined).map((r) => `${date},${r.id},${r.trend}`)
|
|
167
|
+
if (!rows.length) return
|
|
168
|
+
const path = trendsPath()
|
|
169
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
170
|
+
if (!existsSync(path)) appendFileSync(path, "date,id,value\n")
|
|
171
|
+
appendFileSync(path, rows.join("\n") + "\n")
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---- apt freshness, for every server -----------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
const APT_STALE_DAYS = 3
|
|
177
|
+
const APT_STUCK_HOURS = 6
|
|
178
|
+
|
|
179
|
+
// Catches a server whose updates quietly stopped: a daily `apt-get update` that
|
|
180
|
+
// hung and held the lock for over a year once, with the sites up and nothing
|
|
181
|
+
// paging. null means the output didn't parse.
|
|
182
|
+
export function readApt(output: string | null): { flag: string | null } | null {
|
|
183
|
+
const [stamp, nowS, state, since] = (output ?? "").trim().split(/\s+/)
|
|
184
|
+
if (!/^\d+$/.test(nowS ?? "")) return null
|
|
185
|
+
const now = Number(nowS)
|
|
186
|
+
const flags: string[] = []
|
|
187
|
+
if (!stamp || stamp === "none") flags.push("STALE: no successful update on record")
|
|
188
|
+
else {
|
|
189
|
+
const days = Math.floor((now - Number(stamp)) / 86_400)
|
|
190
|
+
if (days > APT_STALE_DAYS) flags.push(`STALE: last successful update ${days}d ago`)
|
|
191
|
+
}
|
|
192
|
+
if (state === "activating" && /^[1-9]\d*$/.test(since ?? "")) {
|
|
193
|
+
const hours = Math.floor((now - Number(since)) / 3600)
|
|
194
|
+
if (hours > APT_STUCK_HOURS) flags.push(`STUCK: apt-daily running ${hours > 48 ? `${Math.floor(hours / 24)}d` : `${hours}h`}`)
|
|
195
|
+
}
|
|
196
|
+
return { flag: flags.length ? flags.join("; ") : null }
|
|
197
|
+
}
|