@remit/doctor 0.0.1
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/package.json +29 -0
- package/src/attempt.ts +34 -0
- package/src/check.test.ts +81 -0
- package/src/check.ts +36 -0
- package/src/cli.ts +43 -0
- package/src/config.test.ts +115 -0
- package/src/config.ts +241 -0
- package/src/deadman.test.ts +31 -0
- package/src/deadman.ts +34 -0
- package/src/dwell.test.ts +120 -0
- package/src/dwell.ts +53 -0
- package/src/heartbeats.test.ts +68 -0
- package/src/heartbeats.ts +68 -0
- package/src/index.ts +18 -0
- package/src/log.ts +71 -0
- package/src/loop.test.ts +301 -0
- package/src/loop.ts +154 -0
- package/src/main.ts +69 -0
- package/src/prometheus.test.ts +105 -0
- package/src/prometheus.ts +136 -0
- package/src/report.test.ts +165 -0
- package/src/report.ts +106 -0
- package/src/scrape.test.ts +71 -0
- package/src/scrape.ts +63 -0
- package/src/state.test.ts +111 -0
- package/src/state.ts +127 -0
- package/src/verdict.test.ts +554 -0
- package/src/verdict.ts +386 -0
- package/src/webhook.test.ts +241 -0
- package/src/webhook.ts +155 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { advance } from "./dwell.js";
|
|
4
|
+
import { type DoctorState, initialState } from "./state.js";
|
|
5
|
+
import type { Verdict } from "./verdict.js";
|
|
6
|
+
|
|
7
|
+
const NOW = new Date("2026-07-27T10:00:00.000Z");
|
|
8
|
+
|
|
9
|
+
/** Feed a run of verdicts through the machine and collect what it announced. */
|
|
10
|
+
const run = (
|
|
11
|
+
verdicts: readonly Verdict[],
|
|
12
|
+
dwell = 3,
|
|
13
|
+
from: DoctorState = initialState,
|
|
14
|
+
): { fired: (Verdict | undefined)[]; state: DoctorState } => {
|
|
15
|
+
let state = from;
|
|
16
|
+
const fired: (Verdict | undefined)[] = [];
|
|
17
|
+
for (const verdict of verdicts) {
|
|
18
|
+
const transition = advance(state, verdict, dwell, NOW);
|
|
19
|
+
state = transition.state;
|
|
20
|
+
fired.push(transition.fires);
|
|
21
|
+
}
|
|
22
|
+
return { fired, state };
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe("the dwell rule", () => {
|
|
26
|
+
it("says nothing on a healthy stack, however long it stays healthy", () => {
|
|
27
|
+
const { fired } = run([
|
|
28
|
+
"healthy",
|
|
29
|
+
"healthy",
|
|
30
|
+
"healthy",
|
|
31
|
+
"healthy",
|
|
32
|
+
"healthy",
|
|
33
|
+
]);
|
|
34
|
+
assert.deepEqual(fired, [
|
|
35
|
+
undefined,
|
|
36
|
+
undefined,
|
|
37
|
+
undefined,
|
|
38
|
+
undefined,
|
|
39
|
+
undefined,
|
|
40
|
+
]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("announces a degraded verdict once, on the third agreeing check", () => {
|
|
44
|
+
const { fired } = run(["degraded", "degraded", "degraded", "degraded"]);
|
|
45
|
+
assert.deepEqual(fired, [undefined, undefined, "degraded", undefined]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("announces the recovery once, three checks later", () => {
|
|
49
|
+
const { fired } = run([
|
|
50
|
+
"degraded",
|
|
51
|
+
"degraded",
|
|
52
|
+
"degraded",
|
|
53
|
+
"healthy",
|
|
54
|
+
"healthy",
|
|
55
|
+
"healthy",
|
|
56
|
+
"healthy",
|
|
57
|
+
]);
|
|
58
|
+
assert.deepEqual(fired, [
|
|
59
|
+
undefined,
|
|
60
|
+
undefined,
|
|
61
|
+
"degraded",
|
|
62
|
+
undefined,
|
|
63
|
+
undefined,
|
|
64
|
+
"healthy",
|
|
65
|
+
undefined,
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("stays silent through a flap that never settles", () => {
|
|
70
|
+
const { fired } = run([
|
|
71
|
+
"degraded",
|
|
72
|
+
"healthy",
|
|
73
|
+
"degraded",
|
|
74
|
+
"healthy",
|
|
75
|
+
"degraded",
|
|
76
|
+
"healthy",
|
|
77
|
+
"degraded",
|
|
78
|
+
"healthy",
|
|
79
|
+
]);
|
|
80
|
+
assert.deepEqual(new Set(fired), new Set([undefined]));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("stays silent when a degraded run is broken one check short", () => {
|
|
84
|
+
const { fired } = run(["degraded", "degraded", "healthy", "degraded"]);
|
|
85
|
+
assert.deepEqual(fired, [undefined, undefined, undefined, undefined]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("does not re-announce a condition already reported", () => {
|
|
89
|
+
const first = run(["degraded", "degraded", "degraded"]);
|
|
90
|
+
const second = run(
|
|
91
|
+
["degraded", "degraded", "degraded", "degraded"],
|
|
92
|
+
3,
|
|
93
|
+
first.state,
|
|
94
|
+
);
|
|
95
|
+
assert.deepEqual(new Set(second.fired), new Set([undefined]));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("survives a restart without re-announcing, because the state is the input", () => {
|
|
99
|
+
const before = run(["degraded", "degraded", "degraded"]).state;
|
|
100
|
+
// A restart reloads exactly this state from the volume.
|
|
101
|
+
const after = run(["degraded", "degraded", "degraded"], 3, before);
|
|
102
|
+
assert.deepEqual(new Set(after.fired), new Set([undefined]));
|
|
103
|
+
assert.equal(after.state.firedVerdict, "degraded");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("holds the run at the dwell count rather than counting up forever", () => {
|
|
107
|
+
const { state } = run(new Array(50).fill("healthy"));
|
|
108
|
+
assert.equal(state.candidateRuns, 3);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("honours a shorter dwell", () => {
|
|
112
|
+
const { fired } = run(["degraded", "degraded"], 1);
|
|
113
|
+
assert.deepEqual(fired, ["degraded", undefined]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("stamps when it last decided", () => {
|
|
117
|
+
const { state } = run(["healthy"]);
|
|
118
|
+
assert.equal(state.updatedAt, NOW.toISOString());
|
|
119
|
+
});
|
|
120
|
+
});
|
package/src/dwell.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { DoctorState } from "./state.js";
|
|
2
|
+
import type { Verdict } from "./verdict.js";
|
|
3
|
+
|
|
4
|
+
export interface Transition {
|
|
5
|
+
readonly state: DoctorState;
|
|
6
|
+
/** The verdict to announce, or `undefined` for the silence that is normal. */
|
|
7
|
+
readonly fires: Verdict | undefined;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* D8. A verdict is announced when it changes AND has held for `dwellChecks`
|
|
12
|
+
* consecutive checks. Never on an unchanged verdict, however long it persists.
|
|
13
|
+
*
|
|
14
|
+
* Transition-only firing without the dwell is the loudest possible response to
|
|
15
|
+
* a flapping signal: a verdict that oscillates every check sends two messages
|
|
16
|
+
* per cycle, which is worse than the periodic posting this rejects. A
|
|
17
|
+
* dead-letter message an operator replays and that fails again, and an account
|
|
18
|
+
* sitting on the sync-age threshold, both produce that shape.
|
|
19
|
+
*
|
|
20
|
+
* The cost is detection latency: at the default 60 s interval and three checks,
|
|
21
|
+
* an outage is announced up to three minutes after it starts and a recovery up
|
|
22
|
+
* to three minutes after it clears. Nobody acts inside three minutes on a
|
|
23
|
+
* mailbox that stopped syncing, and the dead-man's switch — which pings on
|
|
24
|
+
* every completed check, settled or not — is unaffected.
|
|
25
|
+
*
|
|
26
|
+
* `fires` is what the caller should send. It is the caller's job to record the
|
|
27
|
+
* returned state whether or not delivery succeeded: a transition is spent when
|
|
28
|
+
* it is decided, so a webhook that rejects the payload does not turn into an
|
|
29
|
+
* announcement on every subsequent check.
|
|
30
|
+
*/
|
|
31
|
+
export const advance = (
|
|
32
|
+
state: DoctorState,
|
|
33
|
+
verdict: Verdict,
|
|
34
|
+
dwellChecks: number,
|
|
35
|
+
now: Date,
|
|
36
|
+
): Transition => {
|
|
37
|
+
const runs = state.candidateVerdict === verdict ? state.candidateRuns + 1 : 1;
|
|
38
|
+
const settled = runs >= dwellChecks;
|
|
39
|
+
const fires = settled && verdict !== state.firedVerdict ? verdict : undefined;
|
|
40
|
+
return {
|
|
41
|
+
state: {
|
|
42
|
+
...state,
|
|
43
|
+
candidateVerdict: verdict,
|
|
44
|
+
// Held at the dwell count rather than counting up forever: the run only
|
|
45
|
+
// ever answers "has it settled", and an unbounded integer in a file that
|
|
46
|
+
// lives for years is a number with nothing to say.
|
|
47
|
+
candidateRuns: Math.min(runs, dwellChecks),
|
|
48
|
+
firedVerdict: fires ?? state.firedVerdict,
|
|
49
|
+
updatedAt: now.toISOString(),
|
|
50
|
+
},
|
|
51
|
+
fires,
|
|
52
|
+
};
|
|
53
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, utimes, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { describe, it } from "node:test";
|
|
6
|
+
import { readHeartbeats } from "./heartbeats.js";
|
|
7
|
+
|
|
8
|
+
const SERVICES = ["imap-worker", "smtp-worker"];
|
|
9
|
+
const NOW = Date.parse("2026-07-27T10:00:00.000Z");
|
|
10
|
+
|
|
11
|
+
const withFiles = async (
|
|
12
|
+
files: Readonly<Record<string, number>>,
|
|
13
|
+
): Promise<string> => {
|
|
14
|
+
const directory = await mkdtemp(join(tmpdir(), "remit-doctor-hb-"));
|
|
15
|
+
for (const [name, ageSeconds] of Object.entries(files)) {
|
|
16
|
+
const path = join(directory, name);
|
|
17
|
+
await writeFile(path, "2026-07-27T09:00:00.000Z\n");
|
|
18
|
+
const when = new Date(NOW - ageSeconds * 1000);
|
|
19
|
+
await utimes(path, when, when);
|
|
20
|
+
}
|
|
21
|
+
return directory;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
describe("readHeartbeats", () => {
|
|
25
|
+
it("reports the age of a service's stalest loop, not its freshest", async () => {
|
|
26
|
+
const directory = await withFiles({
|
|
27
|
+
"imap-worker.imap-sync": 5,
|
|
28
|
+
"imap-worker.imap-flag-push": 900,
|
|
29
|
+
"smtp-worker.smtp-send": 10,
|
|
30
|
+
});
|
|
31
|
+
const readings = await readHeartbeats(directory, SERVICES, NOW);
|
|
32
|
+
assert.equal(readings[0].ageSeconds, 900);
|
|
33
|
+
assert.equal(readings[1].ageSeconds, 10);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("reports a service with no file as unreadable, never as age zero", async () => {
|
|
37
|
+
const directory = await withFiles({ "imap-worker.imap-sync": 5 });
|
|
38
|
+
const [, smtp] = await readHeartbeats(directory, SERVICES, NOW);
|
|
39
|
+
assert.equal(smtp.ageSeconds, undefined);
|
|
40
|
+
assert.equal(smtp.error, "no heartbeat file");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("reports every service as unreadable when the directory is not there", async () => {
|
|
44
|
+
const readings = await readHeartbeats(
|
|
45
|
+
"/nonexistent/heartbeat",
|
|
46
|
+
SERVICES,
|
|
47
|
+
NOW,
|
|
48
|
+
);
|
|
49
|
+
assert.deepEqual(
|
|
50
|
+
readings.map((reading) => reading.ageSeconds),
|
|
51
|
+
[undefined, undefined],
|
|
52
|
+
);
|
|
53
|
+
assert.match(readings[0].error ?? "", /cannot read/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("does not mistake one service's file for another's prefix", async () => {
|
|
57
|
+
const directory = await withFiles({
|
|
58
|
+
"imap-worker-extra.queue": 5,
|
|
59
|
+
"imap-worker.imap-sync": 20,
|
|
60
|
+
});
|
|
61
|
+
const [imap] = await readHeartbeats(directory, ["imap-worker"], NOW);
|
|
62
|
+
assert.equal(imap.ageSeconds, 20);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("reads no services as no readings", async () => {
|
|
66
|
+
assert.deepEqual(await readHeartbeats(await withFiles({}), [], NOW), []);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { readdir, stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { attempt } from "./attempt.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The age of a worker's stalest poll loop, read off the shared `heartbeat`
|
|
7
|
+
* volume the workers write and this container mounts read-only (D1, D9).
|
|
8
|
+
*
|
|
9
|
+
* A worker runs one loop per queue and each rewrites its own
|
|
10
|
+
* `<service>.<queue>` file, so the oldest of a service's files is the signal:
|
|
11
|
+
* one loop wedged in a socket read while its siblings long-poll is exactly the
|
|
12
|
+
* failure a container-wide timestamp would hide.
|
|
13
|
+
*/
|
|
14
|
+
export interface HeartbeatReading {
|
|
15
|
+
readonly service: string;
|
|
16
|
+
/** Seconds since the stalest of this service's files was written. */
|
|
17
|
+
readonly ageSeconds: number | undefined;
|
|
18
|
+
/** Why no age could be read. `undefined` when one was. */
|
|
19
|
+
readonly error: string | undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A service with no file at all reads as an error, never as age zero. The whole
|
|
24
|
+
* point of the volume is that the checker can look; a verdict computed from a
|
|
25
|
+
* directory it could not read, or from a worker that has written nothing since
|
|
26
|
+
* it started, must not come back healthy.
|
|
27
|
+
*/
|
|
28
|
+
export const readHeartbeats = async (
|
|
29
|
+
directory: string,
|
|
30
|
+
services: readonly string[],
|
|
31
|
+
now: number = Date.now(),
|
|
32
|
+
): Promise<HeartbeatReading[]> => {
|
|
33
|
+
const listing = await attempt(readdir(directory));
|
|
34
|
+
if (!listing.ok) {
|
|
35
|
+
const message = `cannot read ${directory}: ${listing.error}`;
|
|
36
|
+
return services.map((service) => ({
|
|
37
|
+
service,
|
|
38
|
+
ageSeconds: undefined,
|
|
39
|
+
error: message,
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return Promise.all(
|
|
44
|
+
services.map(async (service) => {
|
|
45
|
+
const owned = listing.value.filter((name) =>
|
|
46
|
+
name.startsWith(`${service}.`),
|
|
47
|
+
);
|
|
48
|
+
if (owned.length === 0) {
|
|
49
|
+
return { service, ageSeconds: undefined, error: "no heartbeat file" };
|
|
50
|
+
}
|
|
51
|
+
const times = await attempt(
|
|
52
|
+
Promise.all(
|
|
53
|
+
owned.map(
|
|
54
|
+
async (name) => (await stat(join(directory, name))).mtimeMs,
|
|
55
|
+
),
|
|
56
|
+
),
|
|
57
|
+
);
|
|
58
|
+
if (!times.ok) {
|
|
59
|
+
return { service, ageSeconds: undefined, error: times.error };
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
service,
|
|
63
|
+
ageSeconds: (now - Math.min(...times.value)) / 1000,
|
|
64
|
+
error: undefined,
|
|
65
|
+
};
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export { runCheck } from "./check.js";
|
|
2
|
+
export type { DoctorConfig, ScrapeTarget } from "./config.js";
|
|
3
|
+
export { loadConfig } from "./config.js";
|
|
4
|
+
export { pingDeadMan } from "./deadman.js";
|
|
5
|
+
export { advance } from "./dwell.js";
|
|
6
|
+
export { readHeartbeats } from "./heartbeats.js";
|
|
7
|
+
export { parseMetrics } from "./prometheus.js";
|
|
8
|
+
export {
|
|
9
|
+
exitCodeFor,
|
|
10
|
+
NO_VERDICT_EXIT_CODE,
|
|
11
|
+
renderJson,
|
|
12
|
+
renderLines,
|
|
13
|
+
} from "./report.js";
|
|
14
|
+
export type { DoctorState } from "./state.js";
|
|
15
|
+
export { initialState, readState, writeState } from "./state.js";
|
|
16
|
+
export type { CheckResult, Reason, ReasonCode, Verdict } from "./verdict.js";
|
|
17
|
+
export { evaluate } from "./verdict.js";
|
|
18
|
+
export { buildBody, postWebhook } from "./webhook.js";
|
package/src/log.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The same JSON-lines field shape every other service writes (see
|
|
3
|
+
* deploy/vps/README.md, "Logs"), written by hand.
|
|
4
|
+
*
|
|
5
|
+
* The checker depends on nothing, which is what makes it the container most
|
|
6
|
+
* likely to still be running when the rest of the stack is not, and a logger is
|
|
7
|
+
* not the reason to change that. The queue sidecar makes the same trade for the
|
|
8
|
+
* same reason.
|
|
9
|
+
*
|
|
10
|
+
* Everything goes to stderr, including `info`. Stdout is the exec seam's
|
|
11
|
+
* output (see report.ts): a log line landing in it would corrupt what
|
|
12
|
+
* `remit doctor` parses.
|
|
13
|
+
*
|
|
14
|
+
* The threshold is set once at startup from `DOCTOR_LOG_LEVEL`, not read from
|
|
15
|
+
* the environment here. The compose service passes `DOCTOR_*` variables and
|
|
16
|
+
* nothing else — deliberately, so no application secret can arrive in this
|
|
17
|
+
* container — which means a plain `LOG_LEVEL` could never reach it and the
|
|
18
|
+
* per-check verdict line could never be turned on.
|
|
19
|
+
*/
|
|
20
|
+
export interface Log {
|
|
21
|
+
/** Routine traces — the per-check verdict line. Dropped at the default level. */
|
|
22
|
+
debug(fields: Record<string, unknown>, msg: string): void;
|
|
23
|
+
/** Startup, and an alert actually sent. Rare and worth a line. */
|
|
24
|
+
info(fields: Record<string, unknown>, msg: string): void;
|
|
25
|
+
error(fields: Record<string, unknown>, msg: string): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type Level = "debug" | "info" | "error";
|
|
29
|
+
|
|
30
|
+
const service = process.env.REMIT_SERVICE_NAME ?? "doctor";
|
|
31
|
+
|
|
32
|
+
const ORDER: readonly Level[] = ["debug", "info", "error"];
|
|
33
|
+
|
|
34
|
+
const DEFAULT_THRESHOLD = ORDER.indexOf("info");
|
|
35
|
+
|
|
36
|
+
let threshold = DEFAULT_THRESHOLD;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A value that is not a level name leaves the threshold at `info` rather than
|
|
40
|
+
* silencing the container: a typo must not turn the log off.
|
|
41
|
+
*/
|
|
42
|
+
export const setLogLevel = (level: string | undefined): void => {
|
|
43
|
+
const wanted = ORDER.indexOf(level?.trim().toLowerCase() as Level);
|
|
44
|
+
threshold = wanted === -1 ? DEFAULT_THRESHOLD : wanted;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const write = (
|
|
48
|
+
level: Level,
|
|
49
|
+
fields: Record<string, unknown>,
|
|
50
|
+
msg: string,
|
|
51
|
+
): void => {
|
|
52
|
+
if (ORDER.indexOf(level) < threshold) return;
|
|
53
|
+
process.stderr.write(
|
|
54
|
+
`${JSON.stringify({
|
|
55
|
+
level,
|
|
56
|
+
time: new Date().toISOString(),
|
|
57
|
+
service,
|
|
58
|
+
...fields,
|
|
59
|
+
msg,
|
|
60
|
+
})}\n`,
|
|
61
|
+
);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const log: Log = {
|
|
65
|
+
debug: (fields, msg) => write("debug", fields, msg),
|
|
66
|
+
info: (fields, msg) => write("info", fields, msg),
|
|
67
|
+
error: (fields, msg) => write("error", fields, msg),
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const describeError = (error: unknown): string =>
|
|
71
|
+
error instanceof Error ? (error.stack ?? error.message) : String(error);
|
package/src/loop.test.ts
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { loadConfig } from "./config.js";
|
|
4
|
+
import type { Log } from "./log.js";
|
|
5
|
+
import { type LoopDependencies, runLoop, runOnce, sleep } from "./loop.js";
|
|
6
|
+
import { type DoctorState, initialState } from "./state.js";
|
|
7
|
+
import type { CheckResult, Verdict } from "./verdict.js";
|
|
8
|
+
|
|
9
|
+
const NOW = new Date("2026-07-27T10:00:00.000Z");
|
|
10
|
+
|
|
11
|
+
const silent: Log = { debug: () => {}, info: () => {}, error: () => {} };
|
|
12
|
+
|
|
13
|
+
const result = (verdict: Verdict): CheckResult => ({
|
|
14
|
+
verdict,
|
|
15
|
+
checkedAt: NOW.toISOString(),
|
|
16
|
+
summary: `remit is ${verdict}`,
|
|
17
|
+
reasons:
|
|
18
|
+
verdict === "healthy"
|
|
19
|
+
? []
|
|
20
|
+
: [
|
|
21
|
+
{
|
|
22
|
+
code: "dead_letter_queue_not_empty",
|
|
23
|
+
summary: "1 message is quarantined on 1 dead-letter queue (dlq)",
|
|
24
|
+
detail: undefined,
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
counters: {
|
|
28
|
+
"imap-worker:imap_auth_failures": { total: 1, lastRoseAt: null },
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
interface Recorder {
|
|
33
|
+
readonly deps: LoopDependencies;
|
|
34
|
+
readonly posted: Verdict[];
|
|
35
|
+
readonly pings: number[];
|
|
36
|
+
readonly saved: DoctorState[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const recorder = (
|
|
40
|
+
verdicts: readonly Verdict[],
|
|
41
|
+
overrides: Partial<LoopDependencies> = {},
|
|
42
|
+
): Recorder => {
|
|
43
|
+
const posted: Verdict[] = [];
|
|
44
|
+
const pings: number[] = [];
|
|
45
|
+
const saved: DoctorState[] = [];
|
|
46
|
+
let index = 0;
|
|
47
|
+
return {
|
|
48
|
+
posted,
|
|
49
|
+
pings,
|
|
50
|
+
saved,
|
|
51
|
+
deps: {
|
|
52
|
+
runCheck: async () =>
|
|
53
|
+
result(verdicts[Math.min(index++, verdicts.length - 1)]),
|
|
54
|
+
saveState: async (state) => {
|
|
55
|
+
saved.push(state);
|
|
56
|
+
},
|
|
57
|
+
postWebhook: async (check) => {
|
|
58
|
+
posted.push(check.verdict);
|
|
59
|
+
return { kind: "sent" as const };
|
|
60
|
+
},
|
|
61
|
+
pingDeadMan: async () => {
|
|
62
|
+
pings.push(pings.length);
|
|
63
|
+
},
|
|
64
|
+
now: () => NOW,
|
|
65
|
+
log: silent,
|
|
66
|
+
...overrides,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const configured = loadConfig({
|
|
72
|
+
DOCTOR_WEBHOOK_URL: "https://hooks.example/x",
|
|
73
|
+
DOCTOR_HEARTBEAT_URL: "https://hc.example/x",
|
|
74
|
+
DOCTOR_INTERVAL_SECONDS: "1",
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("runOnce", () => {
|
|
78
|
+
it("sends nothing until the verdict settles, then sends it once", async () => {
|
|
79
|
+
const rec = recorder(["degraded", "degraded", "degraded", "degraded"]);
|
|
80
|
+
let state = initialState;
|
|
81
|
+
for (let turn = 0; turn < 4; turn += 1) {
|
|
82
|
+
state = await runOnce(rec.deps, configured, state);
|
|
83
|
+
}
|
|
84
|
+
assert.deepEqual(rec.posted, ["degraded"]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("pings the dead-man on every completed check, settled or not", async () => {
|
|
88
|
+
const rec = recorder(["healthy", "degraded", "degraded"]);
|
|
89
|
+
let state = initialState;
|
|
90
|
+
for (let turn = 0; turn < 3; turn += 1) {
|
|
91
|
+
state = await runOnce(rec.deps, configured, state);
|
|
92
|
+
}
|
|
93
|
+
assert.equal(rec.pings.length, 3);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("pings even when the verdict is degraded because a scrape failed", async () => {
|
|
97
|
+
const rec = recorder(["degraded"]);
|
|
98
|
+
await runOnce(rec.deps, configured, initialState);
|
|
99
|
+
assert.equal(rec.pings.length, 1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("carries the counter baseline forward into the persisted state", async () => {
|
|
103
|
+
const rec = recorder(["healthy"]);
|
|
104
|
+
const state = await runOnce(rec.deps, configured, initialState);
|
|
105
|
+
assert.deepEqual(state.counters, {
|
|
106
|
+
"imap-worker:imap_auth_failures": { total: 1, lastRoseAt: null },
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("spends the transition on a payload the endpoint refused", async () => {
|
|
111
|
+
// A 4xx is the endpoint deciding about this body — a template written
|
|
112
|
+
// wrong, a revoked URL. Repeating it produces the same answer forever.
|
|
113
|
+
let attempts = 0;
|
|
114
|
+
const rec = recorder(new Array(6).fill("degraded"), {
|
|
115
|
+
postWebhook: async () => {
|
|
116
|
+
attempts += 1;
|
|
117
|
+
return { kind: "rejected" as const, detail: "HTTP 400" };
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
let state = initialState;
|
|
121
|
+
for (let turn = 0; turn < 6; turn += 1) {
|
|
122
|
+
state = await runOnce(rec.deps, configured, state);
|
|
123
|
+
}
|
|
124
|
+
assert.equal(attempts, 1);
|
|
125
|
+
assert.equal(state.firedVerdict, "degraded");
|
|
126
|
+
assert.equal(rec.pings.length, 6);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("retries a transition the endpoint never received, on the next check", async () => {
|
|
130
|
+
// One transient 5xx on the firing check used to lose the outage alert
|
|
131
|
+
// outright: the dead-man's switch is a different URL at a different
|
|
132
|
+
// provider and keeps answering 200 while the webhook is down.
|
|
133
|
+
let attempts = 0;
|
|
134
|
+
const rec = recorder(new Array(6).fill("degraded"), {
|
|
135
|
+
postWebhook: async () => {
|
|
136
|
+
attempts += 1;
|
|
137
|
+
return attempts < 3
|
|
138
|
+
? { kind: "unreachable" as const, detail: "HTTP 503" }
|
|
139
|
+
: { kind: "sent" as const };
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
let state = initialState;
|
|
143
|
+
for (let turn = 0; turn < 6; turn += 1) {
|
|
144
|
+
state = await runOnce(rec.deps, configured, state);
|
|
145
|
+
if (turn < 2) assert.equal(state.firedVerdict, "healthy", `turn ${turn}`);
|
|
146
|
+
}
|
|
147
|
+
// Tried on the settling check and on each of the two after it, then landed
|
|
148
|
+
// and stopped.
|
|
149
|
+
assert.equal(attempts, 3);
|
|
150
|
+
assert.equal(state.firedVerdict, "degraded");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("treats a thrown delivery as unreachable, not as a decision", async () => {
|
|
154
|
+
let attempts = 0;
|
|
155
|
+
const rec = recorder(new Array(5).fill("degraded"), {
|
|
156
|
+
postWebhook: async () => {
|
|
157
|
+
attempts += 1;
|
|
158
|
+
throw new Error("socket hang up");
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
let state = initialState;
|
|
162
|
+
for (let turn = 0; turn < 5; turn += 1) {
|
|
163
|
+
state = await runOnce(rec.deps, configured, state);
|
|
164
|
+
}
|
|
165
|
+
assert.equal(attempts, 3);
|
|
166
|
+
assert.equal(state.firedVerdict, "healthy");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("keeps the settled run while retrying, so the retry is not another dwell away", async () => {
|
|
170
|
+
const rec = recorder(new Array(4).fill("degraded"), {
|
|
171
|
+
postWebhook: async () => ({
|
|
172
|
+
kind: "unreachable" as const,
|
|
173
|
+
detail: "HTTP 503",
|
|
174
|
+
}),
|
|
175
|
+
});
|
|
176
|
+
let state = initialState;
|
|
177
|
+
for (let turn = 0; turn < 4; turn += 1) {
|
|
178
|
+
state = await runOnce(rec.deps, configured, state);
|
|
179
|
+
}
|
|
180
|
+
assert.equal(state.candidateRuns, 3);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("still pings when the webhook cannot be delivered", async () => {
|
|
184
|
+
const rec = recorder(["degraded", "degraded", "degraded"], {
|
|
185
|
+
postWebhook: async () => {
|
|
186
|
+
throw new Error("HTTP 400");
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
let state = initialState;
|
|
190
|
+
for (let turn = 0; turn < 3; turn += 1) {
|
|
191
|
+
state = await runOnce(rec.deps, configured, state);
|
|
192
|
+
}
|
|
193
|
+
assert.equal(rec.pings.length, 3);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("keeps running when the dead-man ping fails", async () => {
|
|
197
|
+
const rec = recorder(["healthy"], {
|
|
198
|
+
pingDeadMan: async () => {
|
|
199
|
+
throw new Error("unreachable");
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
await assert.doesNotReject(runOnce(rec.deps, configured, initialState));
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("records the decision only after the endpoint has answered about it", async () => {
|
|
206
|
+
const order: string[] = [];
|
|
207
|
+
const rec = recorder(["degraded", "degraded", "degraded"], {
|
|
208
|
+
saveState: async () => {
|
|
209
|
+
order.push("save");
|
|
210
|
+
},
|
|
211
|
+
postWebhook: async () => {
|
|
212
|
+
order.push("post");
|
|
213
|
+
return { kind: "sent" as const };
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
let state = initialState;
|
|
217
|
+
for (let turn = 0; turn < 3; turn += 1) {
|
|
218
|
+
state = await runOnce(rec.deps, configured, state);
|
|
219
|
+
}
|
|
220
|
+
// The write follows the post, so a crash in the gap re-announces once.
|
|
221
|
+
// A duplicate is noise; a dropped outage alert is the failure this exists
|
|
222
|
+
// to prevent.
|
|
223
|
+
assert.deepEqual(order, ["save", "save", "post", "save"]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("sends nothing at all when no webhook is configured", async () => {
|
|
227
|
+
const rec = recorder(["degraded", "degraded", "degraded"]);
|
|
228
|
+
const quiet = loadConfig({});
|
|
229
|
+
let state = initialState;
|
|
230
|
+
for (let turn = 0; turn < 3; turn += 1) {
|
|
231
|
+
state = await runOnce(rec.deps, quiet, state);
|
|
232
|
+
}
|
|
233
|
+
assert.deepEqual(rec.posted, []);
|
|
234
|
+
assert.deepEqual(rec.pings, []);
|
|
235
|
+
// The verdict is still computed and recorded — `remit doctor` needs it.
|
|
236
|
+
assert.equal(state.firedVerdict, "degraded");
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe("runLoop", () => {
|
|
241
|
+
it("keeps going after a check that throws, and does not ping for it", async () => {
|
|
242
|
+
let calls = 0;
|
|
243
|
+
const rec = recorder(["healthy"], {
|
|
244
|
+
runCheck: async () => {
|
|
245
|
+
calls += 1;
|
|
246
|
+
if (calls === 1) throw new Error("scrape blew up");
|
|
247
|
+
return result("healthy");
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
const controller = new AbortController();
|
|
251
|
+
let turns = 0;
|
|
252
|
+
await runLoop({
|
|
253
|
+
config: configured,
|
|
254
|
+
initial: initialState,
|
|
255
|
+
signal: controller.signal,
|
|
256
|
+
deps: rec.deps,
|
|
257
|
+
sleep: async () => {
|
|
258
|
+
turns += 1;
|
|
259
|
+
if (turns >= 3) controller.abort();
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
assert.equal(calls, 3);
|
|
263
|
+
// Two of the three checks produced a verdict; the one that threw did not.
|
|
264
|
+
assert.equal(rec.pings.length, 2);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("stops on abort", async () => {
|
|
268
|
+
const rec = recorder(["healthy"]);
|
|
269
|
+
const controller = new AbortController();
|
|
270
|
+
controller.abort();
|
|
271
|
+
const state = await runLoop({
|
|
272
|
+
config: configured,
|
|
273
|
+
initial: initialState,
|
|
274
|
+
signal: controller.signal,
|
|
275
|
+
deps: rec.deps,
|
|
276
|
+
sleep: async () => {},
|
|
277
|
+
});
|
|
278
|
+
assert.deepEqual(state, initialState);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe("sleep", () => {
|
|
283
|
+
it("returns early on abort", async () => {
|
|
284
|
+
const controller = new AbortController();
|
|
285
|
+
const started = Date.now();
|
|
286
|
+
const waiting = sleep(60_000, controller.signal);
|
|
287
|
+
controller.abort();
|
|
288
|
+
await waiting;
|
|
289
|
+
assert.ok(Date.now() - started < 1000);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("returns immediately when already aborted", async () => {
|
|
293
|
+
const controller = new AbortController();
|
|
294
|
+
controller.abort();
|
|
295
|
+
await sleep(60_000, controller.signal);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("returns when the timer fires", async () => {
|
|
299
|
+
await sleep(1, new AbortController().signal);
|
|
300
|
+
});
|
|
301
|
+
});
|