@o3co/create-auth-provider 0.15.0 → 0.17.0-rc.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/README.ja.md +139 -48
- package/README.md +138 -47
- package/dist/index.d.mts +33 -10
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +118 -59
- package/dist/internal/template-filter.d.mts +7 -10
- package/dist/internal/template-filter.d.mts.map +1 -1
- package/dist/internal/template-filter.mjs +7 -10
- package/package.json +6 -4
- package/templates/standalone/.dockerignore +15 -2
- package/templates/standalone/.env.example +69 -24
- package/templates/standalone/Dockerfile +7 -3
- package/templates/standalone/README.ja.md +677 -184
- package/templates/standalone/README.md +699 -238
- package/templates/standalone/config/application.conf +203 -325
- package/templates/standalone/config/clients.yaml +2 -0
- package/templates/standalone/config/clients.yaml.example +4 -0
- package/templates/standalone/config/development.conf +13 -4
- package/templates/standalone/config/mailpit.conf +8 -0
- package/templates/standalone/config/production.conf +5 -6
- package/templates/standalone/config/reference.conf +399 -0
- package/templates/standalone/docker-compose.mailpit.yml +36 -0
- package/templates/standalone/docker-compose.production.yml +58 -30
- package/templates/standalone/docker-compose.yml +13 -3
- package/templates/standalone/gitignore +4 -2
- package/templates/standalone/package.json +9 -6
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +65 -32
- package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +1178 -0
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +256 -0
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +1119 -0
- package/templates/standalone/src/__tests__/audit-sink.test.mts +78 -76
- package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
- package/templates/standalone/src/__tests__/core-federations.test.mts +226 -0
- package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +9 -14
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +672 -271
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +480 -78
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +102 -66
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +135 -0
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +49 -18
- package/templates/standalone/src/__tests__/google-session.fixture.mts +315 -0
- package/templates/standalone/src/__tests__/host-routes.test.mts +134 -0
- package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
- package/templates/standalone/src/__tests__/listen.test.mts +121 -0
- package/templates/standalone/src/__tests__/logger-projection.test.mts +77 -0
- package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
- package/templates/standalone/src/__tests__/mailpit-compose.test.mts +328 -0
- package/templates/standalone/src/__tests__/metrics.test.mts +17 -7
- package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +780 -0
- package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +52 -46
- package/templates/standalone/src/__tests__/own-modules.test.mts +1457 -0
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +183 -0
- package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +135 -0
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +112 -81
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
- package/templates/standalone/src/__tests__/replica-safety.test.mts +166 -145
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +146 -0
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +275 -95
- package/templates/standalone/src/__tests__/sections.test.mts +480 -0
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +67 -55
- package/templates/standalone/src/__tests__/session-requirements.test.mts +299 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +319 -115
- package/templates/standalone/src/__tests__/smoke.test.mts +179 -233
- package/templates/standalone/src/__tests__/store-credential.test.mts +317 -0
- package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +108 -95
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +438 -0
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +390 -0
- package/templates/standalone/src/adapters.mts +149 -0
- package/templates/standalone/src/app.mts +71 -115
- package/templates/standalone/src/buildModules.mts +229 -251
- package/templates/standalone/src/configPath.mts +422 -29
- package/templates/standalone/src/listen.mts +60 -0
- package/templates/standalone/src/logger.mts +31 -57
- package/templates/standalone/src/metrics.mts +24 -47
- package/templates/standalone/src/mfaSwitch.mts +262 -0
- package/templates/standalone/src/modules.mts +480 -464
- package/templates/standalone/src/rootRenames.mts +79 -0
- package/templates/standalone/src/routes.mts +89 -0
- package/templates/standalone/src/sections.mts +366 -0
- package/templates/standalone/src/shutdown.mts +54 -126
- package/templates/standalone/tests/index.test.js +2 -2
- package/templates/standalone/tests/package-lock.json +1615 -2223
- package/templates/standalone/tests/package.json +2 -2
- package/templates/standalone/tests/vitest.config.mjs +26 -0
- package/templates/standalone/vitest.config.mts +8 -7
- package/templates/standalone/vitest.supertest-loopback.mts +97 -69
- package/templates/versions.json +18 -15
- package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
- package/templates/standalone/src/terminalError.mts +0 -60
|
@@ -15,31 +15,24 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
*
|
|
19
|
-
* guarantees
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* waits for in-flight requests indefinitely, so one stuck request meant the
|
|
24
|
-
* process never exited on its own and the orchestrator's SIGKILL took it down
|
|
25
|
-
* mid-flight — the opposite of a graceful shutdown, and invisible until it
|
|
26
|
-
* happened. The cleanup-failure path also wrote to `console.error`, a bare
|
|
27
|
-
* line in a service whose every other line is NDJSON.
|
|
28
|
-
*
|
|
29
|
-
* So the behaviour lives here now, with a deadline and the app's own logger,
|
|
30
|
-
* and these tests are the contract the issue said was missing.
|
|
18
|
+
* The contract of the template's graceful shutdown (README, "Shutdown
|
|
19
|
+
* guarantees"): a force-close deadline on the drain, a bounded cleanup, and
|
|
20
|
+
* every stage logged through the app's own logger. `server.close()` alone
|
|
21
|
+
* waits for in-flight requests indefinitely, so one stuck request would keep
|
|
22
|
+
* the process up until the orchestrator's SIGKILL took it down mid-flight.
|
|
31
23
|
*/
|
|
32
24
|
|
|
33
|
-
import type
|
|
34
|
-
import type { Logger } from "@o3co/auth-provider-core";
|
|
35
|
-
import { describe, expect, it, vi } from "vitest";
|
|
25
|
+
import { createServer, type Server } from "node:http";
|
|
36
26
|
import {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
} from "
|
|
27
|
+
type BootstrapMap,
|
|
28
|
+
createApp,
|
|
29
|
+
createMemoryReplaySeenSet,
|
|
30
|
+
defineModule,
|
|
31
|
+
type Logger,
|
|
32
|
+
} from "@o3co/auth-provider-core";
|
|
33
|
+
import { makeValidCoreConfig } from "@o3co/auth-provider-core/testing";
|
|
34
|
+
import { describe, expect, it, vi } from "vitest";
|
|
35
|
+
import { deferExit, installGracefulShutdown } from "../shutdown.mjs";
|
|
43
36
|
|
|
44
37
|
/** A `Server` double whose `close` callback fires only when we say so. */
|
|
45
38
|
function makeServer() {
|
|
@@ -67,8 +60,7 @@ function makeServer() {
|
|
|
67
60
|
|
|
68
61
|
/**
|
|
69
62
|
* A `Logger`-shaped spy. Typed rather than cast: `as never` would hide a real
|
|
70
|
-
* mismatch the day the port gains a method
|
|
71
|
-
* shutdown path logs through the app logger instead of `console`.
|
|
63
|
+
* mismatch the day the port gains a method.
|
|
72
64
|
*/
|
|
73
65
|
const makeLogger = () => {
|
|
74
66
|
const spy = {
|
|
@@ -85,16 +77,54 @@ const makeLogger = () => {
|
|
|
85
77
|
return spy satisfies Logger;
|
|
86
78
|
};
|
|
87
79
|
|
|
80
|
+
/**
|
|
81
|
+
* A logger that serialises every own property of what it is handed, `cause`
|
|
82
|
+
* and non-enumerable fields included — a deployment is free to install one.
|
|
83
|
+
* `lines` is what it wrote; its levels are spies.
|
|
84
|
+
*/
|
|
85
|
+
const serialiseEverythingLogger = () => {
|
|
86
|
+
const lines: string[] = [];
|
|
87
|
+
const walk = (value: unknown, seen = new WeakSet<object>()): unknown => {
|
|
88
|
+
if (typeof value !== "object" || value === null) return value;
|
|
89
|
+
if (seen.has(value)) return "[circular]";
|
|
90
|
+
seen.add(value);
|
|
91
|
+
const out: Record<string, unknown> = {};
|
|
92
|
+
for (const key of Object.getOwnPropertyNames(value)) {
|
|
93
|
+
out[key] = walk((value as Record<string, unknown>)[key], seen);
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
};
|
|
97
|
+
const record = (level: string) =>
|
|
98
|
+
vi.fn((...args: unknown[]): void => {
|
|
99
|
+
lines.push(JSON.stringify({ level, args: walk(args) }));
|
|
100
|
+
});
|
|
101
|
+
const logger = {
|
|
102
|
+
trace: record("trace"),
|
|
103
|
+
debug: record("debug"),
|
|
104
|
+
info: record("info"),
|
|
105
|
+
warn: record("warn"),
|
|
106
|
+
error: record("error"),
|
|
107
|
+
fatal: record("fatal"),
|
|
108
|
+
child: vi.fn(() => logger as unknown as Logger),
|
|
109
|
+
};
|
|
110
|
+
return { logger: logger satisfies Logger, lines };
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/** A logged projection's `stack`: frames only, from the first. */
|
|
114
|
+
const FRAMES = expect.stringMatching(/^ {4}at /);
|
|
115
|
+
|
|
88
116
|
/** Drive one shutdown without touching the real `process` or exiting. */
|
|
89
117
|
function install(
|
|
90
118
|
opts: {
|
|
91
119
|
cleanup?: () => void | Promise<void>;
|
|
92
120
|
drainTimeoutMs?: number;
|
|
93
121
|
cleanupTimeoutMs?: number;
|
|
122
|
+
cleanupAllowanceMs?: () => number | undefined;
|
|
123
|
+
logger?: ReturnType<typeof makeLogger>;
|
|
94
124
|
} = {},
|
|
95
125
|
) {
|
|
96
126
|
const { server, spies, finishDraining, failClose } = makeServer();
|
|
97
|
-
const logger = makeLogger();
|
|
127
|
+
const logger = opts.logger ?? makeLogger();
|
|
98
128
|
const exit = vi.fn();
|
|
99
129
|
const signals = new Map<string, () => void>();
|
|
100
130
|
|
|
@@ -103,6 +133,9 @@ function install(
|
|
|
103
133
|
cleanup: opts.cleanup ?? (() => {}),
|
|
104
134
|
...(opts.drainTimeoutMs === undefined ? {} : { drainTimeoutMs: opts.drainTimeoutMs }),
|
|
105
135
|
...(opts.cleanupTimeoutMs === undefined ? {} : { cleanupTimeoutMs: opts.cleanupTimeoutMs }),
|
|
136
|
+
...(opts.cleanupAllowanceMs === undefined
|
|
137
|
+
? {}
|
|
138
|
+
: { cleanupAllowanceMs: opts.cleanupAllowanceMs }),
|
|
106
139
|
exit,
|
|
107
140
|
onSignal: (name, handler) => signals.set(name, handler),
|
|
108
141
|
offSignal: (name) => signals.delete(name),
|
|
@@ -111,7 +144,7 @@ function install(
|
|
|
111
144
|
return { spies, logger, exit, signals, finishDraining, failClose };
|
|
112
145
|
}
|
|
113
146
|
|
|
114
|
-
describe("installGracefulShutdown
|
|
147
|
+
describe("installGracefulShutdown", () => {
|
|
115
148
|
it("listens for both SIGTERM and SIGINT", async () => {
|
|
116
149
|
const { signals } = install();
|
|
117
150
|
expect([...signals.keys()].sort()).toEqual(["SIGINT", "SIGTERM"]);
|
|
@@ -148,7 +181,6 @@ describe("installGracefulShutdown (#290)", () => {
|
|
|
148
181
|
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
149
182
|
});
|
|
150
183
|
|
|
151
|
-
// The gap the audit was pointing at.
|
|
152
184
|
it("forces the remaining connections closed when draining outruns the deadline", async () => {
|
|
153
185
|
vi.useFakeTimers();
|
|
154
186
|
try {
|
|
@@ -172,7 +204,15 @@ describe("installGracefulShutdown (#290)", () => {
|
|
|
172
204
|
signals.get("SIGTERM")?.();
|
|
173
205
|
await vi.advanceTimersByTimeAsync(5_000);
|
|
174
206
|
expect(exit).toHaveBeenCalledWith(1);
|
|
175
|
-
|
|
207
|
+
// Each stage is an event: object-first, a snake_case name.
|
|
208
|
+
expect(logger.info).toHaveBeenCalledWith(
|
|
209
|
+
{ drainTimeoutMs: 5_000, cleanupTimeoutMs: 5_000 },
|
|
210
|
+
"shutdown_draining",
|
|
211
|
+
);
|
|
212
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
213
|
+
{ drainTimeoutMs: 5_000 },
|
|
214
|
+
"shutdown_drain_deadline_exceeded",
|
|
215
|
+
);
|
|
176
216
|
} finally {
|
|
177
217
|
vi.useRealTimers();
|
|
178
218
|
}
|
|
@@ -193,21 +233,91 @@ describe("installGracefulShutdown (#290)", () => {
|
|
|
193
233
|
}
|
|
194
234
|
});
|
|
195
235
|
|
|
196
|
-
it("reports a cleanup failure through the app logger, not console", async () => {
|
|
236
|
+
it("reports a cleanup failure through the app logger, not console, as loggableError's projection", async () => {
|
|
197
237
|
// Every other line this service emits is NDJSON through pino; a bare
|
|
198
238
|
// console.error on the shutdown path is the one a log pipeline drops.
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
239
|
+
// What a real `handle.dispose()` rejects with is an AggregateError of
|
|
240
|
+
// every cleanup's own error. The line names each member and its code,
|
|
241
|
+
// and carries nothing of what they hold: an OAuth library's refusal
|
|
242
|
+
// keeps the refresh answer it refused on a non-Error cause, an ioredis
|
|
243
|
+
// reply the write it refused.
|
|
244
|
+
const refusedRefresh = Object.assign(
|
|
245
|
+
new Error("invalid response encountered", {
|
|
246
|
+
cause: { body: { refresh_token: "1//0g-UPSTREAM-S3CRET" } },
|
|
247
|
+
}),
|
|
248
|
+
{ name: "ClientError", code: "OAUTH_INVALID_RESPONSE" },
|
|
249
|
+
);
|
|
250
|
+
const refusedWrite = Object.assign(
|
|
251
|
+
new Error("READONLY You can't write against a read only replica."),
|
|
252
|
+
{
|
|
253
|
+
name: "ReplyError",
|
|
254
|
+
command: { name: "set", args: ["fg:credential:grant-1", "1//0g-ARGS-S3CRET"] },
|
|
202
255
|
},
|
|
256
|
+
);
|
|
257
|
+
const failingCleanups = defineModule({
|
|
258
|
+
name: "test:failing-cleanups",
|
|
259
|
+
provides: {
|
|
260
|
+
replaySeenSet: () => createMemoryReplaySeenSet(),
|
|
261
|
+
challengeStore: () => ({}) as never,
|
|
262
|
+
},
|
|
263
|
+
lifecycle: {
|
|
264
|
+
replaySeenSet: {
|
|
265
|
+
eager: true,
|
|
266
|
+
cleanup: () => {
|
|
267
|
+
throw refusedRefresh;
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
challengeStore: {
|
|
271
|
+
eager: true,
|
|
272
|
+
cleanup: () => {
|
|
273
|
+
throw refusedWrite;
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
const handle = await createApp({
|
|
279
|
+
modules: [failingCleanups],
|
|
280
|
+
bootstrapComponents: {
|
|
281
|
+
config: makeValidCoreConfig(),
|
|
282
|
+
pathResolver: (path: string) => path,
|
|
283
|
+
} as unknown as BootstrapMap,
|
|
284
|
+
});
|
|
285
|
+
const { logger, lines } = serialiseEverythingLogger();
|
|
286
|
+
const { exit, signals, finishDraining } = install({
|
|
287
|
+
logger,
|
|
288
|
+
cleanup: () => handle.dispose(),
|
|
203
289
|
});
|
|
204
290
|
signals.get("SIGTERM")?.();
|
|
205
291
|
finishDraining();
|
|
206
292
|
await vi.waitFor(() => expect(exit).toHaveBeenCalled());
|
|
207
293
|
expect(logger.error).toHaveBeenCalledWith(
|
|
208
|
-
|
|
209
|
-
|
|
294
|
+
{
|
|
295
|
+
err: {
|
|
296
|
+
name: "AggregateError",
|
|
297
|
+
detail: expect.stringMatching(/^AppHandle\.dispose: 2 cleanup errors /),
|
|
298
|
+
stack: FRAMES,
|
|
299
|
+
aggregateErrors: expect.arrayContaining([
|
|
300
|
+
{
|
|
301
|
+
name: "ClientError",
|
|
302
|
+
detail: "invalid response encountered",
|
|
303
|
+
code: "OAUTH_INVALID_RESPONSE",
|
|
304
|
+
stack: FRAMES,
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
name: "ReplyError",
|
|
308
|
+
detail: "READONLY You can't write against a read only replica.",
|
|
309
|
+
command: { name: "set" },
|
|
310
|
+
stack: FRAMES,
|
|
311
|
+
},
|
|
312
|
+
]),
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
"shutdown_cleanup_failed",
|
|
210
316
|
);
|
|
317
|
+
for (const line of lines) {
|
|
318
|
+
expect(line).not.toContain("S3CRET");
|
|
319
|
+
expect(line).not.toContain("refresh_token");
|
|
320
|
+
}
|
|
211
321
|
});
|
|
212
322
|
|
|
213
323
|
it("still exits when cleanup throws — a failed dispose must not wedge the process", async () => {
|
|
@@ -221,19 +331,37 @@ describe("installGracefulShutdown (#290)", () => {
|
|
|
221
331
|
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1));
|
|
222
332
|
});
|
|
223
333
|
|
|
224
|
-
it("does not report a failed close as a clean drain", async () => {
|
|
334
|
+
it("does not report a failed close as a clean drain, and logs the failure as loggableError's projection", async () => {
|
|
225
335
|
// `server.close` reports through its callback -- "Server is not running"
|
|
226
336
|
// is the common one, but any listener teardown failure lands there.
|
|
227
337
|
// Exiting 0 on it would tell an orchestrator the listener came down
|
|
228
|
-
// when it did not.
|
|
229
|
-
|
|
338
|
+
// when it did not. A real server that is not listening gives Node's
|
|
339
|
+
// own error; the line keeps its name, message, code and frames.
|
|
340
|
+
const server = createServer();
|
|
341
|
+
const { logger, lines } = serialiseEverythingLogger();
|
|
342
|
+
const exit = vi.fn();
|
|
343
|
+
const signals = new Map<string, () => void>();
|
|
344
|
+
installGracefulShutdown(server, {
|
|
345
|
+
logger,
|
|
346
|
+
exit,
|
|
347
|
+
onSignal: (name, handler) => signals.set(name, handler),
|
|
348
|
+
offSignal: (name) => signals.delete(name),
|
|
349
|
+
});
|
|
230
350
|
signals.get("SIGTERM")?.();
|
|
231
|
-
failClose(new Error("Server is not running"));
|
|
232
351
|
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1));
|
|
233
352
|
expect(logger.error).toHaveBeenCalledWith(
|
|
234
|
-
|
|
235
|
-
|
|
353
|
+
{
|
|
354
|
+
err: {
|
|
355
|
+
name: "Error",
|
|
356
|
+
detail: "Server is not running.",
|
|
357
|
+
code: "ERR_SERVER_NOT_RUNNING",
|
|
358
|
+
stack: FRAMES,
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
"shutdown_server_close_failed",
|
|
236
362
|
);
|
|
363
|
+
// The frames, never the header line that repeats the message.
|
|
364
|
+
for (const line of lines) expect(line).not.toContain("Error [ERR_SERVER_NOT_RUNNING]");
|
|
237
365
|
});
|
|
238
366
|
|
|
239
367
|
it("still runs cleanup when close reports a failure", async () => {
|
|
@@ -248,11 +376,9 @@ describe("installGracefulShutdown (#290)", () => {
|
|
|
248
376
|
});
|
|
249
377
|
|
|
250
378
|
it("bounds cleanup so a hanging dispose cannot wedge the process", async () => {
|
|
251
|
-
// The
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
// The same defect was found in auth.proxy#81 and auth.policy-verifier#210,
|
|
255
|
-
// both of which took this file as their starting point.
|
|
379
|
+
// The drain deadline is already cleared when cleanup runs, so without a
|
|
380
|
+
// deadline of its own a dispose that never settles would keep `exit`
|
|
381
|
+
// from ever being reached.
|
|
256
382
|
vi.useFakeTimers();
|
|
257
383
|
try {
|
|
258
384
|
const { signals, finishDraining, exit, logger } = install({
|
|
@@ -263,8 +389,8 @@ describe("installGracefulShutdown (#290)", () => {
|
|
|
263
389
|
finishDraining();
|
|
264
390
|
await vi.advanceTimersByTimeAsync(5_000);
|
|
265
391
|
expect(logger.error).toHaveBeenCalledWith(
|
|
266
|
-
|
|
267
|
-
|
|
392
|
+
{ cleanupTimeoutMs: 5_000 },
|
|
393
|
+
"shutdown_cleanup_timed_out",
|
|
268
394
|
);
|
|
269
395
|
expect(exit).toHaveBeenCalledWith(1);
|
|
270
396
|
} finally {
|
|
@@ -300,9 +426,9 @@ describe("installGracefulShutdown (#290)", () => {
|
|
|
300
426
|
});
|
|
301
427
|
|
|
302
428
|
it("reports the cleanup outcome as the reason, not the drain that preceded it", async () => {
|
|
303
|
-
// `
|
|
304
|
-
//
|
|
305
|
-
//
|
|
429
|
+
// `reason` must agree with `exitCode` on the one line an operator alerts
|
|
430
|
+
// on. The drain outcome is carried under its own key, so neither fact is
|
|
431
|
+
// lost.
|
|
306
432
|
const { signals, finishDraining, logger, exit } = install({
|
|
307
433
|
cleanup: () => Promise.reject(new Error("teardown failed")),
|
|
308
434
|
});
|
|
@@ -311,7 +437,7 @@ describe("installGracefulShutdown (#290)", () => {
|
|
|
311
437
|
await new Promise((resolve) => setImmediate(resolve));
|
|
312
438
|
expect(logger.info).toHaveBeenCalledWith(
|
|
313
439
|
{ reason: "cleanup-failed", drain: "drained", exitCode: 1 },
|
|
314
|
-
"
|
|
440
|
+
"shutdown_complete",
|
|
315
441
|
);
|
|
316
442
|
expect(exit).toHaveBeenCalledWith(1);
|
|
317
443
|
});
|
|
@@ -323,7 +449,7 @@ describe("installGracefulShutdown (#290)", () => {
|
|
|
323
449
|
await new Promise((resolve) => setImmediate(resolve));
|
|
324
450
|
expect(logger.info).toHaveBeenCalledWith(
|
|
325
451
|
{ reason: "drained", drain: "drained", exitCode: 0 },
|
|
326
|
-
"
|
|
452
|
+
"shutdown_complete",
|
|
327
453
|
);
|
|
328
454
|
});
|
|
329
455
|
|
|
@@ -336,81 +462,159 @@ describe("installGracefulShutdown (#290)", () => {
|
|
|
336
462
|
});
|
|
337
463
|
});
|
|
338
464
|
|
|
339
|
-
describe("
|
|
340
|
-
it("
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
465
|
+
describe("the cleanup allowance the handle reports", () => {
|
|
466
|
+
it("gives cleanup the allowance when it is longer than the drain", async () => {
|
|
467
|
+
vi.useFakeTimers();
|
|
468
|
+
try {
|
|
469
|
+
const { signals, finishDraining, exit } = install({
|
|
470
|
+
cleanup: () => new Promise<void>((resolve) => setTimeout(resolve, 30_000)),
|
|
471
|
+
drainTimeoutMs: 5_000,
|
|
472
|
+
cleanupAllowanceMs: () => 45_000,
|
|
473
|
+
});
|
|
474
|
+
signals.get("SIGTERM")?.();
|
|
475
|
+
finishDraining();
|
|
476
|
+
await vi.advanceTimersByTimeAsync(31_000);
|
|
477
|
+
expect(exit).toHaveBeenCalledExactlyOnceWith(0);
|
|
478
|
+
} finally {
|
|
479
|
+
vi.useRealTimers();
|
|
480
|
+
}
|
|
352
481
|
});
|
|
353
482
|
|
|
354
|
-
it("
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
).toEqual({ cleanupTimeoutMs: 60_000 + 3_000 + 5_000 + FEDERATION_GRANTS_CLEANUP_MARGIN_MS });
|
|
376
|
-
// Lowered budgets never go below the documented minimum, and a config
|
|
377
|
-
// without budgets (built by hand) gets the floor rather than a guess.
|
|
378
|
-
expect(
|
|
379
|
-
cleanupAllowanceFor({
|
|
380
|
-
federationGrants: { enabled: true, ...shipped, upstreamHardTimeoutMs: 1_000 },
|
|
381
|
-
}),
|
|
382
|
-
).toEqual({ cleanupTimeoutMs: FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS });
|
|
383
|
-
expect(
|
|
384
|
-
cleanupAllowanceFor({ federationGrants: { enabled: true, upstreamHardTimeoutMs: 90_000 } }),
|
|
385
|
-
).toEqual({ cleanupTimeoutMs: FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS });
|
|
483
|
+
it("gives up on cleanup at the allowance, and logs it as the budget", async () => {
|
|
484
|
+
vi.useFakeTimers();
|
|
485
|
+
try {
|
|
486
|
+
const { signals, finishDraining, exit, logger } = install({
|
|
487
|
+
cleanup: () => new Promise<void>(() => {}),
|
|
488
|
+
drainTimeoutMs: 5_000,
|
|
489
|
+
cleanupAllowanceMs: () => 45_000,
|
|
490
|
+
});
|
|
491
|
+
signals.get("SIGTERM")?.();
|
|
492
|
+
finishDraining();
|
|
493
|
+
await vi.advanceTimersByTimeAsync(44_999);
|
|
494
|
+
expect(exit).not.toHaveBeenCalled();
|
|
495
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
496
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
497
|
+
{ cleanupTimeoutMs: 45_000 },
|
|
498
|
+
"shutdown_cleanup_timed_out",
|
|
499
|
+
);
|
|
500
|
+
expect(exit).toHaveBeenCalledExactlyOnceWith(1);
|
|
501
|
+
} finally {
|
|
502
|
+
vi.useRealTimers();
|
|
503
|
+
}
|
|
386
504
|
});
|
|
387
505
|
|
|
388
|
-
it("
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
506
|
+
it("keeps the longer budget when the allowance is shorter", async () => {
|
|
507
|
+
vi.useFakeTimers();
|
|
508
|
+
try {
|
|
509
|
+
const { signals, finishDraining, exit, logger } = install({
|
|
510
|
+
cleanup: () => new Promise<void>(() => {}),
|
|
511
|
+
drainTimeoutMs: 5_000,
|
|
512
|
+
cleanupAllowanceMs: () => 2_000,
|
|
513
|
+
});
|
|
514
|
+
signals.get("SIGTERM")?.();
|
|
515
|
+
finishDraining();
|
|
516
|
+
await vi.advanceTimersByTimeAsync(4_999);
|
|
517
|
+
expect(exit).not.toHaveBeenCalled();
|
|
518
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
519
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
520
|
+
{ cleanupTimeoutMs: 5_000 },
|
|
521
|
+
"shutdown_cleanup_timed_out",
|
|
522
|
+
);
|
|
523
|
+
} finally {
|
|
524
|
+
vi.useRealTimers();
|
|
525
|
+
}
|
|
400
526
|
});
|
|
401
527
|
|
|
402
|
-
it("
|
|
528
|
+
it("leaves cleanup its own budget when the handle reports no allowance", async () => {
|
|
403
529
|
vi.useFakeTimers();
|
|
404
530
|
try {
|
|
405
|
-
const { signals, finishDraining,
|
|
406
|
-
cleanup: () => new Promise<void>((
|
|
531
|
+
const { signals, finishDraining, logger } = install({
|
|
532
|
+
cleanup: () => new Promise<void>(() => {}),
|
|
407
533
|
drainTimeoutMs: 5_000,
|
|
408
|
-
|
|
534
|
+
cleanupAllowanceMs: () => undefined,
|
|
409
535
|
});
|
|
410
536
|
signals.get("SIGTERM")?.();
|
|
411
537
|
finishDraining();
|
|
412
|
-
await vi.advanceTimersByTimeAsync(
|
|
413
|
-
expect(
|
|
538
|
+
await vi.advanceTimersByTimeAsync(5_000);
|
|
539
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
540
|
+
{ cleanupTimeoutMs: 5_000 },
|
|
541
|
+
"shutdown_cleanup_timed_out",
|
|
542
|
+
);
|
|
543
|
+
} finally {
|
|
544
|
+
vi.useRealTimers();
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it("reads the allowance when the signal arrives, so a tail registered after install counts", async () => {
|
|
549
|
+
vi.useFakeTimers();
|
|
550
|
+
try {
|
|
551
|
+
let allowance: number | undefined;
|
|
552
|
+
const { signals, finishDraining, exit, logger } = install({
|
|
553
|
+
cleanup: () => new Promise<void>(() => {}),
|
|
554
|
+
drainTimeoutMs: 5_000,
|
|
555
|
+
cleanupAllowanceMs: () => allowance,
|
|
556
|
+
});
|
|
557
|
+
allowance = 60_000;
|
|
558
|
+
signals.get("SIGTERM")?.();
|
|
559
|
+
expect(logger.info).toHaveBeenCalledWith(
|
|
560
|
+
{ drainTimeoutMs: 5_000, cleanupTimeoutMs: 60_000 },
|
|
561
|
+
"shutdown_draining",
|
|
562
|
+
);
|
|
563
|
+
finishDraining();
|
|
564
|
+
await vi.advanceTimersByTimeAsync(59_999);
|
|
565
|
+
expect(exit).not.toHaveBeenCalled();
|
|
566
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
567
|
+
expect(exit).toHaveBeenCalledExactlyOnceWith(1);
|
|
568
|
+
} finally {
|
|
569
|
+
vi.useRealTimers();
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it.each([Number.NaN, Number.POSITIVE_INFINITY, -1, 0, 1.5, 2_147_483_648])(
|
|
574
|
+
"treats an allowance of %s as absent: cleanup keeps its own budget",
|
|
575
|
+
async (allowance) => {
|
|
576
|
+
// Handed to a timer, NaN or a delay past 2147483647 fires after about a
|
|
577
|
+
// millisecond, which would turn the floor into no budget at all.
|
|
578
|
+
vi.useFakeTimers();
|
|
579
|
+
try {
|
|
580
|
+
const { signals, finishDraining, exit, logger } = install({
|
|
581
|
+
cleanup: () => new Promise<void>(() => {}),
|
|
582
|
+
drainTimeoutMs: 5_000,
|
|
583
|
+
cleanupAllowanceMs: () => allowance,
|
|
584
|
+
});
|
|
585
|
+
signals.get("SIGTERM")?.();
|
|
586
|
+
finishDraining();
|
|
587
|
+
await vi.advanceTimersByTimeAsync(4_999);
|
|
588
|
+
expect(exit).not.toHaveBeenCalled();
|
|
589
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
590
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
591
|
+
{ cleanupTimeoutMs: 5_000 },
|
|
592
|
+
"shutdown_cleanup_timed_out",
|
|
593
|
+
);
|
|
594
|
+
} finally {
|
|
595
|
+
vi.useRealTimers();
|
|
596
|
+
}
|
|
597
|
+
},
|
|
598
|
+
);
|
|
599
|
+
|
|
600
|
+
it("treats an allowance that cannot be read as absent, and still shuts down", async () => {
|
|
601
|
+
vi.useFakeTimers();
|
|
602
|
+
try {
|
|
603
|
+
const { signals, finishDraining, exit, logger } = install({
|
|
604
|
+
cleanup: () => new Promise<void>(() => {}),
|
|
605
|
+
drainTimeoutMs: 5_000,
|
|
606
|
+
cleanupAllowanceMs: () => {
|
|
607
|
+
throw new Error("allowance unavailable");
|
|
608
|
+
},
|
|
609
|
+
});
|
|
610
|
+
signals.get("SIGTERM")?.();
|
|
611
|
+
finishDraining();
|
|
612
|
+
await vi.advanceTimersByTimeAsync(5_000);
|
|
613
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
614
|
+
{ cleanupTimeoutMs: 5_000 },
|
|
615
|
+
"shutdown_cleanup_timed_out",
|
|
616
|
+
);
|
|
617
|
+
expect(exit).toHaveBeenCalledExactlyOnceWith(1);
|
|
414
618
|
} finally {
|
|
415
619
|
vi.useRealTimers();
|
|
416
620
|
}
|