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.
Files changed (44) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/README.md +8 -2
  3. package/package.json +1 -1
  4. package/src/config.ts +29 -0
  5. package/src/index.tsx +8 -0
  6. package/src/lib/beacon.test.ts +14 -0
  7. package/src/lib/beacon.ts +4 -1
  8. package/src/lib/beaconBoard.test.ts +69 -0
  9. package/src/lib/beaconBoard.ts +197 -0
  10. package/src/lib/beaconListen.test.ts +30 -1
  11. package/src/lib/beaconListen.ts +40 -17
  12. package/src/lib/beaconListenInstall.ts +13 -4
  13. package/src/lib/cliBeacon.ts +25 -1
  14. package/src/lib/cliIncidents.ts +13 -6
  15. package/src/lib/cliSentinel.ts +451 -0
  16. package/src/lib/heartbeat.ts +5 -0
  17. package/src/lib/schedule.ts +4 -1
  18. package/src/lib/scheduler.test.ts +88 -1
  19. package/src/lib/schedulerJobs.ts +4 -2
  20. package/src/lib/schedulerRun.ts +61 -15
  21. package/src/lib/schedulerTick.ts +35 -13
  22. package/src/lib/sentinelAttach.test.ts +45 -0
  23. package/src/lib/sentinelAttach.ts +98 -0
  24. package/src/lib/sentinelDigest.test.ts +82 -0
  25. package/src/lib/sentinelDigest.ts +261 -0
  26. package/src/lib/sentinelExpect.test.ts +82 -0
  27. package/src/lib/sentinelExpect.ts +197 -0
  28. package/src/lib/sentinelIncidents.test.ts +145 -0
  29. package/src/lib/sentinelIncidents.ts +194 -0
  30. package/src/lib/sentinelPrompt.ts +89 -0
  31. package/src/lib/sentinelReply.ts +36 -0
  32. package/src/lib/sentinelState.ts +246 -0
  33. package/src/lib/sentinelTick.test.ts +214 -0
  34. package/src/lib/sentinelTick.ts +265 -0
  35. package/src/lib/sentinelWorker.test.ts +170 -0
  36. package/src/lib/sentinelWorker.ts +291 -0
  37. package/src/modules/beacon.ts +3 -0
  38. package/src/modules/index.ts +2 -7
  39. package/src/modules/scheduler.ts +31 -8
  40. package/src/modules/sentinel.ts +90 -0
  41. package/src/ui/App.tsx +4 -0
  42. package/src/ui/store.tsx +5 -0
  43. package/src/ui/views/Modules.tsx +56 -3
  44. 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
+ }