@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 +1 -1
- package/src/check.test.ts +101 -0
- package/src/check.ts +18 -2
- package/src/config.test.ts +11 -0
- package/src/config.ts +25 -0
- package/src/tunnel.test.ts +44 -0
- package/src/tunnel.ts +31 -0
- package/src/verdict.test.ts +35 -0
- package/src/verdict.ts +34 -1
package/package.json
CHANGED
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
|
|
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,
|
package/src/config.test.ts
CHANGED
|
@@ -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
|
+
};
|
package/src/verdict.test.ts
CHANGED
|
@@ -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),
|