spinuptui 1.3.0-alpha.3 → 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 +32 -1
- package/README.md +12 -3
- package/package.json +1 -1
- package/src/config.ts +32 -0
- package/src/index.tsx +11 -0
- package/src/lib/beacon.test.ts +14 -0
- package/src/lib/beacon.ts +14 -1
- package/src/lib/beaconBoard.test.ts +69 -0
- package/src/lib/beaconBoard.ts +197 -0
- package/src/lib/beaconListen.test.ts +190 -0
- package/src/lib/beaconListen.ts +355 -0
- package/src/lib/beaconListenInstall.ts +251 -0
- package/src/lib/cliBeacon.ts +115 -3
- package/src/lib/cliIncidents.ts +13 -6
- package/src/lib/cliScheduler.ts +30 -1
- 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 +189 -1
- package/src/lib/schedulerExtend.ts +105 -0
- package/src/lib/schedulerInstall.ts +36 -15
- 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 +9 -1
- package/src/modules/contract.ts +34 -0
- package/src/modules/index.ts +2 -7
- package/src/modules/scheduler.ts +147 -6
- package/src/modules/sentinel.ts +90 -0
- package/src/ui/App.tsx +8 -0
- package/src/ui/store.tsx +11 -0
- package/src/ui/views/BeaconSetup.tsx +11 -7
- package/src/ui/views/ListenerSetup.tsx +205 -0
- package/src/ui/views/Modules.tsx +70 -5
- package/src/ui/views/SentinelSetup.tsx +171 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import type { IncidentEvent, IncidentKind, IncidentStatus } from "./cliIncidents.ts"
|
|
3
|
+
import { applyEvents, closeIncidents, eventMs, markSeen, newEvents, startedToday, windowHours, type ApplyInput } from "./sentinelIncidents.ts"
|
|
4
|
+
import { seenKey, type Fleet, type Incident } from "./sentinelState.ts"
|
|
5
|
+
|
|
6
|
+
const fleet: Fleet = { domains: { "a.example.com": { server: "web1", site: "a.example.com" }, "b.example.com": { server: "web1", site: "b.example.com" }, "c.example.com": { server: "web2", site: "c.example.com" } } }
|
|
7
|
+
const now = new Date("2026-10-30T14:00:00Z")
|
|
8
|
+
|
|
9
|
+
let nextId = 1
|
|
10
|
+
function ev(domain: string, status: IncidentStatus = "down", kind: IncidentKind = "health", time = "2026-10-30 13:58:00.000", monitorId?: number): IncidentEvent {
|
|
11
|
+
return { domain, kind, status, time, message: "", monitorId: monitorId ?? nextId++ }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const apply = (over: Partial<ApplyInput> & { events: IncidentEvent[] }) =>
|
|
15
|
+
applyEvents({ incidents: [], fleet, now, recurrenceHours: 6, hasAgent: true, shadow: false, kumaAlerted: new Set(), ...over })
|
|
16
|
+
|
|
17
|
+
describe("the seen-set", () => {
|
|
18
|
+
test("finds an old event a newer one would have hidden from a timestamp", () => {
|
|
19
|
+
const newer = ev("a.example.com", "down", "health", "2026-10-30 13:50:00.000", 1)
|
|
20
|
+
const older = ev("b.example.com", "down", "health", "2026-10-30 13:40:00.000", 2)
|
|
21
|
+
const seen = markSeen({}, [newer], now)
|
|
22
|
+
expect(newEvents([older, newer], seen)).toEqual([older])
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test("two monitors can share a timestamp", () => {
|
|
26
|
+
const a = ev("a.example.com", "down", "health", "2026-10-30 13:50:00.000", 1)
|
|
27
|
+
const b = ev("a.example.com", "down", "redis", "2026-10-30 13:50:00.000", 2)
|
|
28
|
+
expect(newEvents([a, b], markSeen({}, [a], now))).toEqual([b])
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test("drops what's past the longest window", () => {
|
|
32
|
+
const old = ev("a.example.com", "down", "health", "2026-10-20 13:50:00.000", 1)
|
|
33
|
+
expect(markSeen({ [seenKey(old)]: old.time }, [], now)).toEqual({})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test("the window reaches back to the last clean tick, within limits", () => {
|
|
37
|
+
expect(windowHours(undefined, now)).toBe(1)
|
|
38
|
+
expect(windowHours("2026-10-30T13:55:00Z", now)).toBe(2)
|
|
39
|
+
expect(windowHours("2026-10-29T13:55:00Z", now)).toBe(26)
|
|
40
|
+
expect(windowHours("2026-09-01T00:00:00Z", now)).toBe(168)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test("reads Kuma's zoneless times as UTC", () => {
|
|
44
|
+
expect(eventMs("2026-10-30 13:34:05.123")).toBe(Date.parse("2026-10-30T13:34:05.123Z"))
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe("grouping", () => {
|
|
49
|
+
test("down events on one server are one incident, with one page", () => {
|
|
50
|
+
const r = apply({ events: [ev("a.example.com"), ev("b.example.com"), ev("c.example.com")] })
|
|
51
|
+
expect(r.created.map((i) => i.key).sort()).toEqual(["web1", "web2"])
|
|
52
|
+
expect(r.pages.length).toBe(2)
|
|
53
|
+
expect(r.pages[0]!.text).toContain("a.example.com, b.example.com")
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test("a domain outside the fleet is its own incident, and asks for a refresh", () => {
|
|
57
|
+
const r = apply({ events: [ev("other.example.org")] })
|
|
58
|
+
expect(r.created[0]!.key).toBe("other.example.org")
|
|
59
|
+
expect(r.unknownDomains).toEqual(["other.example.org"])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test("joining an open incident pages once silently, and queues nothing new", () => {
|
|
63
|
+
const first = apply({ events: [ev("a.example.com")] })
|
|
64
|
+
const later = apply({ incidents: first.created, events: [ev("b.example.com")] })
|
|
65
|
+
expect(later.created).toEqual([])
|
|
66
|
+
expect(later.pages).toMatchObject([{ silent: true }])
|
|
67
|
+
expect(later.pages[0]!.text).toContain("joined")
|
|
68
|
+
const again = apply({ incidents: first.created, events: [ev("b.example.com", "down", "redis")] })
|
|
69
|
+
expect(again.pages).toEqual([])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test("the first page is silent only when Kuma alerted for every monitor", () => {
|
|
73
|
+
const a = ev("a.example.com", "down", "health", undefined, 7)
|
|
74
|
+
const b = ev("b.example.com", "down", "health", undefined, 8)
|
|
75
|
+
expect(apply({ events: [a, b], kumaAlerted: new Set([7, 8]) }).pages[0]!.silent).toBe(true)
|
|
76
|
+
expect(apply({ events: [a, b], kumaAlerted: new Set([7]) }).pages[0]!.silent).toBe(false)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test("without an agent the page says so", () => {
|
|
80
|
+
expect(apply({ events: [ev("a.example.com")], hasAgent: false }).pages[0]!.text).toContain("no agent")
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe("closing", () => {
|
|
85
|
+
test("every monitor back, then 30 quiet minutes", () => {
|
|
86
|
+
const down = ev("a.example.com", "down", "health", undefined, 1)
|
|
87
|
+
const [i] = apply({ events: [down] }).created
|
|
88
|
+
expect(closeIncidents([i!], new Date(now.getTime() + 3_600_000))).toEqual([])
|
|
89
|
+
apply({ incidents: [i!], events: [ev("a.example.com", "up", "health", "2026-10-30 14:00:00.000", 1)] })
|
|
90
|
+
expect(i!.down).toEqual([])
|
|
91
|
+
expect(closeIncidents([i!], new Date(now.getTime() + 60_000))).toEqual([])
|
|
92
|
+
expect(closeIncidents([i!], new Date(now.getTime() + 31 * 60_000)).length).toBe(1)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test("a monitor that never comes back can't hold it open for ever", () => {
|
|
96
|
+
const [i] = apply({ events: [ev("a.example.com")] }).created
|
|
97
|
+
expect(closeIncidents([i!], new Date(now.getTime() + 8 * 24 * 3_600_000)).length).toBe(1)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe("recurrence", () => {
|
|
102
|
+
const reported = (at: Date): Incident => {
|
|
103
|
+
const [i] = apply({ events: [ev("a.example.com")], now: at }).created
|
|
104
|
+
return { ...i!, state: "reported", reportedAt: at.toISOString(), reportPath: "sites/a/incidents/x.md", closedAt: at.toISOString() }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
test("a report in the window means no new diagnosis, and a way to ask for one", () => {
|
|
108
|
+
const r = apply({ incidents: [reported(new Date(now.getTime() - 3_600_000))], events: [ev("a.example.com")] })
|
|
109
|
+
expect(r.created[0]!.state).toBe("recurrence")
|
|
110
|
+
expect(r.pages[0]).toMatchObject({ silent: true })
|
|
111
|
+
expect(r.pages[0]!.text).toContain("sites/a/incidents/x.md")
|
|
112
|
+
expect(r.pages[0]!.buttons![0]![0]).toMatchObject({ action: "sentinel.diagnose" })
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test("outside the window, or for another kind, it's a new incident", () => {
|
|
116
|
+
const old = reported(new Date(now.getTime() - 7 * 3_600_000))
|
|
117
|
+
expect(apply({ incidents: [old], events: [ev("a.example.com")] }).created[0]!.state).toBe("queued")
|
|
118
|
+
const recent = reported(new Date(now.getTime() - 3_600_000))
|
|
119
|
+
expect(apply({ incidents: [recent], events: [ev("a.example.com", "down", "redis")] }).created[0]!.state).toBe("queued")
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test("a failed diagnosis doesn't cover anything", () => {
|
|
123
|
+
const failed: Incident = { ...reported(new Date(now.getTime() - 3_600_000)), state: "failed" }
|
|
124
|
+
expect(apply({ incidents: [failed], events: [ev("a.example.com")] }).created[0]!.state).toBe("queued")
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test("the third in 24 hours makes a sound", () => {
|
|
128
|
+
const base = reported(new Date(now.getTime() - 3_600_000))
|
|
129
|
+
const rec = (n: number): Incident => ({ ...base, id: `r${n}`, state: "recurrence", refersTo: base.id, createdAt: new Date(now.getTime() - n * 3_600_000).toISOString() })
|
|
130
|
+
expect(apply({ incidents: [base, rec(1)], events: [ev("a.example.com")] }).pages[0]!.silent).toBe(true)
|
|
131
|
+
expect(apply({ incidents: [base, rec(1), rec(2)], events: [ev("a.example.com")] }).pages[0]!.silent).toBe(false)
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
describe("shadow mode", () => {
|
|
136
|
+
test("records what would have been queued", () => {
|
|
137
|
+
expect(apply({ events: [ev("a.example.com")], shadow: true }).created[0]!.state).toBe("shadow")
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test("the cap counts today's starts", () => {
|
|
142
|
+
const i = apply({ events: [ev("a.example.com")] }).created[0]!
|
|
143
|
+
expect(startedToday([{ ...i, startedAt: now.toISOString() }, i], now)).toBe(1)
|
|
144
|
+
expect(startedToday([{ ...i, startedAt: "2026-10-01T00:00:00Z" }], now)).toBe(0)
|
|
145
|
+
})
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// What a tick decides, with no I/O: which events are new, how they group into
|
|
2
|
+
// incidents, what's a recurrence, and what to page. The tick feeds it state and
|
|
3
|
+
// carries out what it returns (docs/2026-10-30_sentinel-spec.md, "Incidents").
|
|
4
|
+
|
|
5
|
+
import type { IncidentEvent as KumaEvent } from "./cliIncidents.ts"
|
|
6
|
+
import { seenKey, SEEN_KEEP_DAYS, type Fleet, type Incident, type Seen } from "./sentinelState.ts"
|
|
7
|
+
import type { BeaconButton } from "../modules/contract.ts"
|
|
8
|
+
|
|
9
|
+
const HOUR = 3_600_000
|
|
10
|
+
export const CLOSE_QUIET_MS = 30 * 60_000
|
|
11
|
+
const CLOSE_ANYWAY_MS = 7 * 24 * HOUR
|
|
12
|
+
const LOUD_RECURRENCE = 3
|
|
13
|
+
|
|
14
|
+
export interface Page {
|
|
15
|
+
text: string
|
|
16
|
+
silent: boolean
|
|
17
|
+
buttons?: BeaconButton[][]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// A Kuma time ("2026-10-30 13:34:05.123") is UTC with no zone marker.
|
|
21
|
+
export const eventMs = (time: string) => Date.parse(`${time.replace(" ", "T").slice(0, 23)}Z`)
|
|
22
|
+
|
|
23
|
+
// The events not handled yet. A seen-set rather than a timestamp: a monitor whose
|
|
24
|
+
// history failed to read last time can still deliver an event older than one
|
|
25
|
+
// already handled, and two events can share a timestamp.
|
|
26
|
+
export function newEvents(events: KumaEvent[], seen: Seen): KumaEvent[] {
|
|
27
|
+
return events.filter((e) => !seen[seenKey(e)])
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function markSeen(seen: Seen, events: KumaEvent[], now: Date): Seen {
|
|
31
|
+
const out = { ...seen }
|
|
32
|
+
for (const e of events) out[seenKey(e)] = e.time
|
|
33
|
+
const cutoff = now.getTime() - SEEN_KEEP_DAYS * 24 * HOUR
|
|
34
|
+
for (const [k, t] of Object.entries(out)) if (eventMs(t) < cutoff) delete out[k]
|
|
35
|
+
return out
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// How far back to read: from the last tick that read every monitor.
|
|
39
|
+
export function windowHours(lastCleanAt: string | undefined, now: Date): number {
|
|
40
|
+
if (!lastCleanAt) return 1
|
|
41
|
+
const hours = Math.ceil((now.getTime() - Date.parse(lastCleanAt)) / HOUR) + 1
|
|
42
|
+
return Math.min(Math.max(hours, 1), 168)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const stamp = (d: Date) => d.toISOString().slice(0, 16).replace(/[-:]/g, "").replace("T", "-")
|
|
46
|
+
const slug = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")
|
|
47
|
+
|
|
48
|
+
export interface ApplyInput {
|
|
49
|
+
incidents: Incident[]
|
|
50
|
+
events: KumaEvent[]
|
|
51
|
+
fleet: Fleet
|
|
52
|
+
now: Date
|
|
53
|
+
recurrenceHours: number
|
|
54
|
+
hasAgent: boolean
|
|
55
|
+
shadow: boolean
|
|
56
|
+
kumaAlerted: Set<number>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ApplyOutput {
|
|
60
|
+
changed: Incident[]
|
|
61
|
+
created: Incident[]
|
|
62
|
+
pages: Page[]
|
|
63
|
+
unknownDomains: string[]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function covering(all: Incident[], e: KumaEvent, sinceMs: number): Incident | null {
|
|
67
|
+
const live = new Set(["reported", "queued", "diagnosing", "waiting_agent", "shadow"])
|
|
68
|
+
for (const i of [...all].reverse()) {
|
|
69
|
+
if (!live.has(i.state)) continue
|
|
70
|
+
if (Date.parse(i.reportedAt ?? i.createdAt) < sinceMs) continue
|
|
71
|
+
if (i.events.some((x) => x.domain === e.domain && x.kind === e.kind)) return i
|
|
72
|
+
}
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const domainsOf = (i: Incident) => [...new Set(i.events.map((e) => e.domain))]
|
|
77
|
+
const kindsOf = (i: Incident) => [...new Set(i.events.map((e) => e.kind))]
|
|
78
|
+
|
|
79
|
+
export function diagnoseButton(id: string, text = "Diagnose anyway"): BeaconButton {
|
|
80
|
+
return { text, action: "sentinel.diagnose", payload: { id } }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function newIncidentPage(i: Incident, hasAgent: boolean, kumaAlerted: Set<number>): Page {
|
|
84
|
+
const where = i.server ? ` on ${i.server}` : ""
|
|
85
|
+
const next = hasAgent ? "diagnosing" : "writing it up (no agent configured)"
|
|
86
|
+
return {
|
|
87
|
+
text: `🔴 ${domainsOf(i).join(", ")} down (${kindsOf(i).join(", ")})${where}: ${next}.`,
|
|
88
|
+
// Kuma already made a sound for this; Sentinel's job is the "why" that follows.
|
|
89
|
+
silent: i.events.every((e) => kumaAlerted.has(e.monitorId)),
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function recurrencePage(i: Incident, earlier: Incident, count: number): Page {
|
|
94
|
+
const first = i.events[0]!
|
|
95
|
+
return {
|
|
96
|
+
text: `🔁 ${first.domain} ${first.kind} again${count > 1 ? ` (${count} in 24h)` : ""}. Covered by ${earlier.reportPath ?? earlier.id}, so no new diagnosis.`,
|
|
97
|
+
silent: count < LOUD_RECURRENCE,
|
|
98
|
+
buttons: [[diagnoseButton(i.id)]],
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Applies new events to the incidents. A down event joins its server's open
|
|
103
|
+
// incident, or starts one, unless a recent report already covers it. An up event
|
|
104
|
+
// clears its monitor.
|
|
105
|
+
export function applyEvents(input: ApplyInput): ApplyOutput {
|
|
106
|
+
const { fleet, now, recurrenceHours, hasAgent, shadow, kumaAlerted } = input
|
|
107
|
+
const all = [...input.incidents]
|
|
108
|
+
const changed = new Map<string, Incident>()
|
|
109
|
+
const created: Incident[] = []
|
|
110
|
+
const fresh = new Set<string>()
|
|
111
|
+
const unknownDomains = new Set<string>()
|
|
112
|
+
const pages: Page[] = []
|
|
113
|
+
const nowIso = now.toISOString()
|
|
114
|
+
|
|
115
|
+
for (const e of [...input.events].sort((a, b) => a.time.localeCompare(b.time))) {
|
|
116
|
+
if (e.status === "up") {
|
|
117
|
+
for (const i of all) {
|
|
118
|
+
if (!i.closedAt && i.down.includes(e.monitorId)) {
|
|
119
|
+
i.down = i.down.filter((m) => m !== e.monitorId)
|
|
120
|
+
changed.set(i.id, i)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
if (e.status !== "down") continue
|
|
126
|
+
const entry = fleet.domains[e.domain]
|
|
127
|
+
if (!entry) unknownDomains.add(e.domain)
|
|
128
|
+
const key = entry?.server ?? e.domain
|
|
129
|
+
const event = { domain: e.domain, kind: e.kind, time: e.time, message: e.message, monitorId: e.monitorId }
|
|
130
|
+
const open = all.find((i) => !i.closedAt && i.key === key)
|
|
131
|
+
if (open) {
|
|
132
|
+
const known = open.events.some((x) => x.domain === e.domain)
|
|
133
|
+
open.events.push(event)
|
|
134
|
+
if (!open.down.includes(e.monitorId)) open.down.push(e.monitorId)
|
|
135
|
+
open.lastDownAt = nowIso
|
|
136
|
+
changed.set(open.id, open)
|
|
137
|
+
if (!known && !fresh.has(open.id)) pages.push({ text: `➕ ${e.domain} (${e.kind}) joined the incident on ${key}.`, silent: true })
|
|
138
|
+
continue
|
|
139
|
+
}
|
|
140
|
+
const earlier = covering(all, e, now.getTime() - recurrenceHours * HOUR)
|
|
141
|
+
const base = `${stamp(now)}-${slug(key)}`
|
|
142
|
+
let id = base
|
|
143
|
+
for (let n = 2; all.some((i) => i.id === id); n++) id = `${base}-${n}`
|
|
144
|
+
const incident: Incident = {
|
|
145
|
+
id,
|
|
146
|
+
key,
|
|
147
|
+
server: entry?.server ?? null,
|
|
148
|
+
state: earlier ? "recurrence" : shadow ? "shadow" : "queued",
|
|
149
|
+
createdAt: nowIso,
|
|
150
|
+
events: [event],
|
|
151
|
+
down: [e.monitorId],
|
|
152
|
+
lastDownAt: nowIso,
|
|
153
|
+
attempts: 0,
|
|
154
|
+
...(earlier ? { refersTo: earlier.id } : {}),
|
|
155
|
+
}
|
|
156
|
+
all.push(incident)
|
|
157
|
+
created.push(incident)
|
|
158
|
+
changed.set(id, incident)
|
|
159
|
+
fresh.add(id)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
for (const i of created) {
|
|
163
|
+
if (i.state === "recurrence") {
|
|
164
|
+
const earlier = all.find((x) => x.id === i.refersTo)!
|
|
165
|
+
const domain = i.events[0]!.domain
|
|
166
|
+
const count = all.filter((x) => x.state === "recurrence" && Date.parse(x.createdAt) > now.getTime() - 24 * HOUR && x.events.some((y) => y.domain === domain)).length
|
|
167
|
+
pages.push(recurrencePage(i, earlier, count))
|
|
168
|
+
} else {
|
|
169
|
+
pages.push(newIncidentPage(i, hasAgent, kumaAlerted))
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return { changed: [...changed.values()], created, pages, unknownDomains: [...unknownDomains] }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Closes what's over: every monitor back and quiet for 30 minutes. A monitor that
|
|
176
|
+
// never reports back (deleted in Kuma, say) can't hold an incident open forever.
|
|
177
|
+
export function closeIncidents(incidents: Incident[], now: Date): Incident[] {
|
|
178
|
+
const out: Incident[] = []
|
|
179
|
+
for (const i of incidents) {
|
|
180
|
+
if (i.closedAt) continue
|
|
181
|
+
const quiet = now.getTime() - Date.parse(i.lastDownAt)
|
|
182
|
+
if ((i.down.length === 0 && quiet >= CLOSE_QUIET_MS) || quiet >= CLOSE_ANYWAY_MS) {
|
|
183
|
+
i.closedAt = now.toISOString()
|
|
184
|
+
out.push(i)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return out
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// How many diagnoses have started today, for the daily cap.
|
|
191
|
+
export function startedToday(incidents: Incident[], now: Date): number {
|
|
192
|
+
const day = now.toLocaleDateString("en-CA")
|
|
193
|
+
return incidents.filter((i) => i.startedAt && new Date(i.startedAt).toLocaleDateString("en-CA") === day).length
|
|
194
|
+
}
|