@remit/doctor 0.0.2 → 0.0.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/doctor",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
package/src/check.test.ts CHANGED
@@ -21,6 +21,31 @@ const stack = (overrides: Readonly<Record<string, string>> = {}) =>
21
21
  return new Response(body, { status: 200 });
22
22
  }) as unknown as typeof fetch;
23
23
 
24
+ /**
25
+ * The stack plus a tunnel agent, recording every URL the check asks for. A
26
+ * `status` of `undefined` is an agent that is not there to answer at all.
27
+ */
28
+ const withTunnel = (
29
+ inner: typeof fetch,
30
+ status: number | undefined,
31
+ seen: string[],
32
+ ) =>
33
+ (async (url: string, init?: RequestInit) => {
34
+ seen.push(url);
35
+ if (new URL(url).hostname !== "tunnel") return inner(url, init);
36
+ if (status === undefined) {
37
+ throw new Error("connect ECONNREFUSED 172.18.0.9:2000");
38
+ }
39
+ return new Response("", { status });
40
+ }) as unknown as typeof fetch;
41
+
42
+ const ALL_WORKERS = [
43
+ "imap-worker",
44
+ "smtp-worker",
45
+ "account-worker",
46
+ "search-index-worker",
47
+ ];
48
+
24
49
  const heartbeatDir = async (services: readonly string[]): Promise<string> => {
25
50
  const directory = await mkdtemp(join(tmpdir(), "remit-doctor-check-"));
26
51
  for (const service of services) {
@@ -79,3 +104,79 @@ describe("runCheck", () => {
79
104
  );
80
105
  });
81
106
  });
107
+
108
+ describe("runCheck in tunnel mode", () => {
109
+ const tunnelConfig = async () =>
110
+ loadConfig({
111
+ DOCTOR_HEARTBEAT_DIR: await heartbeatDir(ALL_WORKERS),
112
+ DOCTOR_TLS_MODE: "tunnel",
113
+ });
114
+
115
+ it("degrades when the readiness endpoint does not answer 200", async () => {
116
+ const seen: string[] = [];
117
+ const result = await runCheck(
118
+ await tunnelConfig(),
119
+ {},
120
+ new Date(),
121
+ withTunnel(stack(), 503, seen),
122
+ );
123
+ assert.equal(result.verdict, "degraded");
124
+ assert.deepEqual(
125
+ result.reasons.map((reason) => reason.code),
126
+ ["tunnel_disconnected"],
127
+ );
128
+ assert.ok(seen.includes("http://tunnel:2000/ready"));
129
+ });
130
+
131
+ it("degrades when the agent does not answer at all", async () => {
132
+ const result = await runCheck(
133
+ await tunnelConfig(),
134
+ {},
135
+ new Date(),
136
+ withTunnel(stack(), undefined, []),
137
+ );
138
+ assert.deepEqual(
139
+ result.reasons.map((reason) => reason.code),
140
+ ["tunnel_disconnected"],
141
+ );
142
+ });
143
+
144
+ it("stays silent while the readiness endpoint answers 200", async () => {
145
+ const result = await runCheck(
146
+ await tunnelConfig(),
147
+ {},
148
+ new Date(),
149
+ withTunnel(stack(), 200, []),
150
+ );
151
+ assert.equal(result.verdict, "healthy");
152
+ });
153
+
154
+ it("asks the endpoint the deployment named", async () => {
155
+ const seen: string[] = [];
156
+ await runCheck(
157
+ loadConfig({
158
+ DOCTOR_HEARTBEAT_DIR: await heartbeatDir(ALL_WORKERS),
159
+ DOCTOR_TLS_MODE: "tunnel",
160
+ DOCTOR_TUNNEL_READY_URL: "http://tunnel:2000/healthz",
161
+ }),
162
+ {},
163
+ new Date(),
164
+ withTunnel(stack(), 200, seen),
165
+ );
166
+ assert.ok(seen.includes("http://tunnel:2000/healthz"));
167
+ });
168
+
169
+ it("never looks for a tunnel on a deployment that does not serve through one", async () => {
170
+ const seen: string[] = [];
171
+ const result = await runCheck(
172
+ loadConfig({ DOCTOR_HEARTBEAT_DIR: await heartbeatDir(ALL_WORKERS) }),
173
+ {},
174
+ new Date(),
175
+ // The agent is absent, as it is on every other mode. Probing it anyway
176
+ // would be the check degrading a healthy deployment.
177
+ withTunnel(stack(), undefined, seen),
178
+ );
179
+ assert.equal(result.verdict, "healthy");
180
+ assert.ok(!seen.some((url) => new URL(url).hostname === "tunnel"));
181
+ });
182
+ });
package/src/check.ts CHANGED
@@ -1,9 +1,23 @@
1
- import type { DoctorConfig } from "./config.js";
1
+ import { type DoctorConfig, TUNNEL_TLS_MODE } from "./config.js";
2
2
  import { readHeartbeats } from "./heartbeats.js";
3
3
  import { type Fetcher, scrapeAll } from "./scrape.js";
4
4
  import type { CounterState } from "./state.js";
5
+ import { probeTunnel, type TunnelReading } from "./tunnel.js";
5
6
  import { type CheckResult, evaluate } from "./verdict.js";
6
7
 
8
+ /**
9
+ * The tunnel is only a signal on a deployment that serves through one. A probe
10
+ * on any other mode would report a service that is not in the stack, which is
11
+ * the check being degraded by its own configuration.
12
+ */
13
+ const readTunnel = (
14
+ config: DoctorConfig,
15
+ fetcher: Fetcher,
16
+ ): Promise<TunnelReading | undefined> =>
17
+ config.tlsMode === TUNNEL_TLS_MODE
18
+ ? probeTunnel(config.tunnelReadyUrl, config.scrapeTimeoutMs, fetcher)
19
+ : Promise.resolve(undefined);
20
+
7
21
  /**
8
22
  * One check: scrape the endpoints that carry a signal, read the heartbeat
9
23
  * volume, and evaluate. The same function the loop runs on its interval and the
@@ -16,17 +30,19 @@ export const runCheck = async (
16
30
  now: Date = new Date(),
17
31
  fetcher: Fetcher = fetch,
18
32
  ): Promise<CheckResult> => {
19
- const [scrapes, heartbeats] = await Promise.all([
33
+ const [scrapes, heartbeats, tunnel] = await Promise.all([
20
34
  scrapeAll(config.targets, config.scrapeTimeoutMs, fetcher),
21
35
  readHeartbeats(
22
36
  config.heartbeatDir,
23
37
  config.heartbeatServices,
24
38
  now.getTime(),
25
39
  ),
40
+ readTunnel(config, fetcher),
26
41
  ]);
27
42
  return evaluate({
28
43
  scrapes,
29
44
  heartbeats,
45
+ tunnel,
30
46
  previousCounters,
31
47
  heartbeatMaxAgeSeconds: config.heartbeatMaxAgeSeconds,
32
48
  syncAgeMaxSeconds: config.syncAgeMaxSeconds,
@@ -19,6 +19,17 @@ describe("loadConfig", () => {
19
19
  "account-worker",
20
20
  "search-index-worker",
21
21
  ]);
22
+ assert.equal(config.tlsMode, "off");
23
+ assert.equal(config.tunnelReadyUrl, "http://tunnel:2000/ready");
24
+ });
25
+
26
+ it("takes the deployment's serving mode and the edge's readiness endpoint", () => {
27
+ const config = loadConfig({
28
+ DOCTOR_TLS_MODE: "tunnel",
29
+ DOCTOR_TUNNEL_READY_URL: "http://edge:9000/healthz",
30
+ });
31
+ assert.equal(config.tlsMode, "tunnel");
32
+ assert.equal(config.tunnelReadyUrl, "http://edge:9000/healthz");
22
33
  });
23
34
 
24
35
  it("refuses a webhook with no dead-man's switch, naming both variables", () => {
package/src/config.ts CHANGED
@@ -31,6 +31,16 @@ export interface DoctorConfig {
31
31
  readonly deadManUrl: string | undefined;
32
32
  readonly requestTimeoutMs: number;
33
33
  readonly logLevel: string | undefined;
34
+ /**
35
+ * The deployment's `TLS_MODE`, verbatim: the compose service hands it
36
+ * through as `DOCTOR_TLS_MODE`, so which signals apply stays a property of
37
+ * how the deployment serves rather than a second value that can disagree
38
+ * with it. The mode is carried, not a predicate derived from it: the reason
39
+ * the tunnel signal exists is the mode, and a `tunnelEnabled` boolean here
40
+ * would put that derivation somewhere no other consumer can see.
41
+ */
42
+ readonly tlsMode: string;
43
+ readonly tunnelReadyUrl: string;
34
44
  }
35
45
 
36
46
  /**
@@ -58,26 +68,35 @@ const DEFAULT_HEARTBEAT_SERVICES = [
58
68
  const DEFAULT_HEARTBEAT_MAX_AGE_SECONDS = 420;
59
69
 
60
70
  /**
61
- * Three hours. `remit_account_sync_age_seconds` sawtooths: the scheduler ticks
62
- * hourly by default (`MAILBOX_SYNC_TICK_INTERVAL_SECONDS`) and a tick skips a
63
- * mailbox stamped inside the freshness window, so a perfectly healthy account
64
- * climbs past an hour every cycle. A threshold at or near the tick fires on
65
- * accounts that are fine; raise this if you raised the tick.
71
+ * One hour. `remit_account_sync_age_seconds` sawtooths, and the height of the
72
+ * tooth is the scheduler's OFFLINE interval the age at which an account
73
+ * becomes due plus one TICK of sampling lag before a tick picks it up, plus
74
+ * the round itself. At the standalone stack's 15-minute offline interval and
75
+ * 5-minute tick (deploy/vps/docker-compose.sqlite.yml) a healthy account peaks
76
+ * around 25 minutes, so an hour clears it with room for several consecutive
77
+ * failed rounds and still names a genuinely stalled account inside the hour.
78
+ *
79
+ * A threshold below that peak fires on accounts that are fine, which is the
80
+ * failure that matters most here: this is the signal that says mail stopped
81
+ * arriving, and a signal that cries wolf is one an operator learns to ignore.
82
+ * Raise this if you raised either scheduler interval.
66
83
  */
67
- const DEFAULT_SYNC_AGE_MAX_SECONDS = 3 * 60 * 60;
84
+ const DEFAULT_SYNC_AGE_MAX_SECONDS = 60 * 60;
68
85
 
69
86
  /**
70
87
  * How long after the last authentication failure the condition still counts as
71
- * failing. Three hours, for the same reason the sync-age threshold is three
72
- * hours: authentication is retried on the sync tick, so the failures arrive in
73
- * one burst per tick and the gaps between bursts are not recoveries.
88
+ * failing. One hour, held equal to the sync-age threshold: authentication is
89
+ * retried on the sync cycle, so the failures arrive in one burst per cycle and
90
+ * the gaps between bursts are not recoveries, and two windows of the same width
91
+ * mean a deployment that fixes its password gets one recovery message rather
92
+ * than two.
74
93
  *
75
94
  * The signal is a counter delta, which is true for exactly one check. Without a
76
95
  * hold the reason appears on one check in sixty and the three-check dwell never
77
96
  * settles, so the one class of failure that never resolves itself would be the
78
97
  * one that never alerts.
79
98
  */
80
- const DEFAULT_AUTH_FAILURE_HOLD_SECONDS = 3 * 60 * 60;
99
+ const DEFAULT_AUTH_FAILURE_HOLD_SECONDS = 60 * 60;
81
100
 
82
101
  /** D8's number. Configurable so an operator can trade latency for quiet. */
83
102
  const DEFAULT_DWELL_CHECKS = 3;
@@ -95,6 +114,18 @@ const DEFAULT_REQUEST_TIMEOUT_SECONDS = 10;
95
114
 
96
115
  const DEFAULT_CONTENT_TYPE = "application/json";
97
116
 
117
+ /** The mode under which the tunnel signal exists at all. */
118
+ export const TUNNEL_TLS_MODE = "tunnel";
119
+
120
+ const DEFAULT_TLS_MODE = "off";
121
+
122
+ /**
123
+ * `cloudflared`'s readiness endpoint, on the metrics port the compose service
124
+ * gives it. Overridable because the provider contract is generic: another edge
125
+ * supplies its own image, credential variable and readiness endpoint.
126
+ */
127
+ const DEFAULT_TUNNEL_READY_URL = "http://tunnel:2000/ready";
128
+
98
129
  export type Env = Record<string, string | undefined>;
99
130
 
100
131
  const text = (env: Env, name: string): string | undefined => {
@@ -237,5 +268,8 @@ export const loadConfig = (env: Env = process.env): DoctorConfig => {
237
268
  "DOCTOR_REQUEST_TIMEOUT_SECONDS",
238
269
  DEFAULT_REQUEST_TIMEOUT_SECONDS,
239
270
  ) * 1000,
271
+ tlsMode: text(env, "DOCTOR_TLS_MODE") ?? DEFAULT_TLS_MODE,
272
+ tunnelReadyUrl:
273
+ text(env, "DOCTOR_TUNNEL_READY_URL") ?? DEFAULT_TUNNEL_READY_URL,
240
274
  };
241
275
  };
@@ -0,0 +1,44 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { probeTunnel } from "./tunnel.js";
4
+
5
+ const READY_URL = "http://tunnel:2000/ready";
6
+
7
+ const answering = (status: number) =>
8
+ (async () => new Response("", { status })) as unknown as typeof fetch;
9
+
10
+ const refusing = (message: string) =>
11
+ (async () => {
12
+ throw new Error(message);
13
+ }) as unknown as typeof fetch;
14
+
15
+ describe("probeTunnel", () => {
16
+ it("reads a connected agent off its 200", async () => {
17
+ const reading = await probeTunnel(READY_URL, 1000, answering(200));
18
+ assert.equal(reading.error, undefined);
19
+ });
20
+
21
+ it("reads a disconnected agent off anything else, and says the status", async () => {
22
+ const reading = await probeTunnel(READY_URL, 1000, answering(503));
23
+ assert.equal(reading.error, "HTTP 503");
24
+ });
25
+
26
+ it("turns a refused connection into a reading rather than a throw", async () => {
27
+ const reading = await probeTunnel(
28
+ READY_URL,
29
+ 1000,
30
+ refusing("connect ECONNREFUSED 172.18.0.9:2000"),
31
+ );
32
+ assert.match(reading.error ?? "", /ECONNREFUSED/);
33
+ });
34
+
35
+ it("asks the URL it was given", async () => {
36
+ const seen: string[] = [];
37
+ const recording = (async (url: string) => {
38
+ seen.push(url);
39
+ return new Response("", { status: 200 });
40
+ }) as unknown as typeof fetch;
41
+ await probeTunnel("http://edge:9000/healthz", 1000, recording);
42
+ assert.deepEqual(seen, ["http://edge:9000/healthz"]);
43
+ });
44
+ });
package/src/tunnel.ts ADDED
@@ -0,0 +1,31 @@
1
+ import { attempt } from "./attempt.js";
2
+ import type { Fetcher } from "./scrape.js";
3
+
4
+ /**
5
+ * Whether the reverse-tunnel agent still holds a connection to the provider's
6
+ * edge (D12 of the tunnel design). `cloudflared` answers 200 on its metrics
7
+ * port's `/ready` only while at least one edge connection is established, so a
8
+ * refused connection, a timeout and a non-200 all say the same thing: nothing
9
+ * on the internet can reach this deployment right now.
10
+ *
11
+ * A failure is a value, for the reason every other reading here is one — the
12
+ * condition is what the verdict exists to report, and a throw would take the
13
+ * rest of the check down with it.
14
+ */
15
+ export interface TunnelReading {
16
+ /** Why the readiness endpoint did not answer 200. `undefined` when it did. */
17
+ readonly error: string | undefined;
18
+ }
19
+
20
+ export const probeTunnel = async (
21
+ url: string,
22
+ timeoutMs: number,
23
+ fetcher: Fetcher = fetch,
24
+ ): Promise<TunnelReading> => {
25
+ const response = await attempt(
26
+ fetcher(url, { signal: AbortSignal.timeout(timeoutMs) }),
27
+ );
28
+ if (!response.ok) return { error: response.error };
29
+ if (!response.value.ok) return { error: `HTTP ${response.value.status}` };
30
+ return { error: undefined };
31
+ };
@@ -55,6 +55,7 @@ const counter = (total: number, roseMsAgo = 4 * 60 * 60 * 1000) => ({
55
55
  const input = (overrides: Partial<VerdictInput> = {}): VerdictInput => ({
56
56
  scrapes: HEALTHY_SCRAPES,
57
57
  heartbeats: HEALTHY_HEARTBEATS,
58
+ tunnel: undefined,
58
59
  previousCounters: {},
59
60
  heartbeatMaxAgeSeconds: 420,
60
61
  syncAgeMaxSeconds: 10_800,
@@ -444,6 +445,38 @@ describe("evaluate", () => {
444
445
  assert.equal(result.verdict, "healthy");
445
446
  });
446
447
 
448
+ it("degrades when the tunnel is not connected, while everything else reads clean", () => {
449
+ const result = evaluate(input({ tunnel: { error: "HTTP 503" } }));
450
+ assert.equal(result.verdict, "degraded");
451
+ assert.deepEqual(
452
+ result.reasons.map((reason) => reason.code),
453
+ ["tunnel_disconnected"],
454
+ );
455
+ assert.match(result.reasons[0].summary, /not connected to its edge/);
456
+ assert.equal(result.reasons[0].detail, "HTTP 503");
457
+ });
458
+
459
+ it("stays healthy when the tunnel answered", () => {
460
+ const result = evaluate(input({ tunnel: { error: undefined } }));
461
+ assert.equal(result.verdict, "healthy");
462
+ });
463
+
464
+ it("puts the tunnel ahead of the conditions a dropped tunnel does not cause", () => {
465
+ const result = evaluate(
466
+ input({
467
+ tunnel: { error: "connect ECONNREFUSED" },
468
+ heartbeats: [
469
+ { service: "imap-worker", ageSeconds: 900, error: undefined },
470
+ ...HEALTHY_HEARTBEATS.slice(1),
471
+ ],
472
+ }),
473
+ );
474
+ assert.deepEqual(
475
+ result.reasons.map((reason) => reason.code),
476
+ ["tunnel_disconnected", "worker_heartbeat_stale"],
477
+ );
478
+ });
479
+
447
480
  it("keeps every address, subject and folder name out of every summary", () => {
448
481
  const result = evaluate(
449
482
  input({
@@ -504,6 +537,7 @@ describe("no reason summary may carry a value D10 forbids", () => {
504
537
  { service: "imap-worker", ageSeconds: undefined, error: SENTINEL },
505
538
  ...HEALTHY_HEARTBEATS.slice(1),
506
539
  ],
540
+ tunnel: { error: SENTINEL },
507
541
  previousCounters: {
508
542
  "imap-worker:imap_auth_failures": counter(0),
509
543
  "smtp-worker:smtp_auth_failures": counter(0),
@@ -517,6 +551,7 @@ describe("no reason summary may carry a value D10 forbids", () => {
517
551
  "account_sync_stalled",
518
552
  "dead_letter_queue_not_empty",
519
553
  "mail_auth_failing",
554
+ "tunnel_disconnected",
520
555
  "worker_heartbeat_stale",
521
556
  ]);
522
557
  for (const reason of result.reasons) {
package/src/verdict.ts CHANGED
@@ -2,6 +2,7 @@ import type { HeartbeatReading } from "./heartbeats.js";
2
2
  import { seriesNamed } from "./prometheus.js";
3
3
  import type { ScrapeResult } from "./scrape.js";
4
4
  import type { CounterState } from "./state.js";
5
+ import type { TunnelReading } from "./tunnel.js";
5
6
 
6
7
  export type Verdict = "healthy" | "degraded";
7
8
 
@@ -11,7 +12,8 @@ export type ReasonCode =
11
12
  | "dead_letter_queue_not_empty"
12
13
  | "account_sync_stalled"
13
14
  | "mail_auth_failing"
14
- | "signal_missing";
15
+ | "signal_missing"
16
+ | "tunnel_disconnected";
15
17
 
16
18
  /**
17
19
  * One thing that is wrong.
@@ -68,6 +70,13 @@ export interface VerdictInput extends VerdictThresholds {
68
70
  readonly scrapes: readonly ScrapeResult[];
69
71
  readonly heartbeats: readonly HeartbeatReading[];
70
72
  readonly previousCounters: Readonly<Record<string, CounterState>>;
73
+ /**
74
+ * The tunnel's readiness, or `undefined` on a deployment that does not serve
75
+ * through one. Absent is not-applicable here, unlike every other signal,
76
+ * where absent is degraded: a deployment with no tunnel has no tunnel to be
77
+ * disconnected from.
78
+ */
79
+ readonly tunnel: TunnelReading | undefined;
71
80
  readonly now: Date;
72
81
  }
73
82
 
@@ -319,9 +328,32 @@ const missingSeries = (
319
328
  };
320
329
  };
321
330
 
331
+ /**
332
+ * The stack can be entirely healthy and still be serving nobody: in `tunnel`
333
+ * mode the only route in is the agent's connection to the edge, and when it
334
+ * drops every other signal here stays green while the browser gets the
335
+ * provider's error page.
336
+ *
337
+ * The checker's own two outbound calls — the webhook and the dead-man ping —
338
+ * dial straight out and do not pass through the agent, so this is the one
339
+ * reason that is still delivered while the condition it reports holds.
340
+ */
341
+ const disconnectedTunnel = (
342
+ tunnel: TunnelReading | undefined,
343
+ ): Reason | undefined => {
344
+ if (tunnel === undefined || tunnel.error === undefined) return undefined;
345
+ return {
346
+ code: "tunnel_disconnected",
347
+ summary:
348
+ "the tunnel agent is not connected to its edge, so the public address serves nobody",
349
+ detail: tunnel.error,
350
+ };
351
+ };
352
+
322
353
  const ORDER: readonly ReasonCode[] = [
323
354
  "scrape_failed",
324
355
  "signal_missing",
356
+ "tunnel_disconnected",
325
357
  "worker_heartbeat_stale",
326
358
  "account_sync_stalled",
327
359
  "mail_auth_failing",
@@ -360,6 +392,7 @@ export const evaluate = (input: VerdictInput): CheckResult => {
360
392
  const found = [
361
393
  scrapeFailures(input.scrapes),
362
394
  missingSeries(input.scrapes),
395
+ disconnectedTunnel(input.tunnel),
363
396
  staleHeartbeats(input.heartbeats, input.heartbeatMaxAgeSeconds),
364
397
  stalledSync(input.scrapes, input.syncAgeMaxSeconds),
365
398
  authFailures(counters, input.authFailureHoldSeconds, now),