@remit/doctor 0.0.2 → 0.0.3

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.3",
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
  /**
@@ -95,6 +105,18 @@ const DEFAULT_REQUEST_TIMEOUT_SECONDS = 10;
95
105
 
96
106
  const DEFAULT_CONTENT_TYPE = "application/json";
97
107
 
108
+ /** The mode under which the tunnel signal exists at all. */
109
+ export const TUNNEL_TLS_MODE = "tunnel";
110
+
111
+ const DEFAULT_TLS_MODE = "off";
112
+
113
+ /**
114
+ * `cloudflared`'s readiness endpoint, on the metrics port the compose service
115
+ * gives it. Overridable because the provider contract is generic: another edge
116
+ * supplies its own image, credential variable and readiness endpoint.
117
+ */
118
+ const DEFAULT_TUNNEL_READY_URL = "http://tunnel:2000/ready";
119
+
98
120
  export type Env = Record<string, string | undefined>;
99
121
 
100
122
  const text = (env: Env, name: string): string | undefined => {
@@ -237,5 +259,8 @@ export const loadConfig = (env: Env = process.env): DoctorConfig => {
237
259
  "DOCTOR_REQUEST_TIMEOUT_SECONDS",
238
260
  DEFAULT_REQUEST_TIMEOUT_SECONDS,
239
261
  ) * 1000,
262
+ tlsMode: text(env, "DOCTOR_TLS_MODE") ?? DEFAULT_TLS_MODE,
263
+ tunnelReadyUrl:
264
+ text(env, "DOCTOR_TUNNEL_READY_URL") ?? DEFAULT_TUNNEL_READY_URL,
240
265
  };
241
266
  };
@@ -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),