@celilo/e2e 0.9.3 → 0.10.2
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/config/cpanel-host/cpanel-host-ca.crt +20 -0
- package/config/cpanel-host/docroot/.htaccess +14 -0
- package/config/cpanel-host/docroot/cms.html +4 -0
- package/config/cpanel-host/site-tls.crt +20 -0
- package/config/cpanel-host/site-tls.key +28 -0
- package/config/cpanel-host/site.conf +27 -0
- package/config/dns/com.zone +7 -0
- package/config/dns/knot-namecheap.conf +6 -0
- package/config/dns/tangohost.com.zone +14 -0
- package/config/resolver/unbound.conf +26 -0
- package/config/routing/fw-ext-routes.sh +16 -1
- package/config/routing/management-routes.sh +14 -4
- package/config/routing/minio-startup.sh +9 -0
- package/config/routing/public-sim-entrypoint.sh +24 -0
- package/config/routing/resolver-internal-routes.sh +10 -0
- package/docker/Dockerfile.apt-repo-sim +8 -0
- package/docker/Dockerfile.cpanel-host-sim +75 -0
- package/docker/Dockerfile.firewall +12 -0
- package/docker/Dockerfile.management +6 -0
- package/docker/Dockerfile.minio +7 -0
- package/docker/Dockerfile.npm-registry-sim +8 -0
- package/docker/Dockerfile.observer +11 -0
- package/docker/Dockerfile.registry +8 -0
- package/docker/Dockerfile.signal-cli +60 -0
- package/docker/Dockerfile.signal-release +44 -0
- package/docker/Dockerfile.signal-sim +17 -0
- package/docker/Dockerfile.target-machine +9 -0
- package/package.json +3 -3
- package/registry-server/package.json +17 -0
- package/registry-server/src/auth.test.ts +76 -0
- package/registry-server/src/auth.ts +105 -0
- package/registry-server/src/bootstrap-packaging.test.ts +71 -0
- package/registry-server/src/bootstrap.ts +250 -0
- package/registry-server/src/index.ts +16 -0
- package/registry-server/src/introspection.test.ts +247 -0
- package/registry-server/src/introspection.ts +204 -0
- package/registry-server/src/landing.ts +90 -0
- package/registry-server/src/module-owner-store.test.ts +85 -0
- package/registry-server/src/module-owner-store.ts +108 -0
- package/registry-server/src/rate-limit.test.ts +62 -0
- package/registry-server/src/rate-limit.ts +83 -0
- package/registry-server/src/scoped-token-store.test.ts +93 -0
- package/registry-server/src/scoped-token-store.ts +110 -0
- package/registry-server/src/server.test.ts +771 -0
- package/registry-server/src/server.ts +701 -0
- package/registry-server/src/storage.test.ts +148 -0
- package/registry-server/src/storage.ts +150 -0
- package/registry-server/src/validation.test.ts +86 -0
- package/registry-server/src/validation.ts +60 -0
- package/registry-server/tsconfig.json +15 -0
- package/scripts/stage-libsignal.ts +116 -0
- package/simulators/signal-cli/server.ts +328 -0
- package/src/cli/build.ts +7 -0
- package/src/cli/index.ts +6 -0
- package/src/container-manager.ts +47 -10
- package/src/docker-compose-generator.ts +120 -16
- package/src/exit-cleanup.test.ts +56 -0
- package/src/index.ts +7 -0
- package/src/network-builder.ts +66 -0
- package/src/public-sim-routes.test.ts +117 -0
- package/src/simulator-ips.ts +9 -0
- package/src/types.ts +71 -1
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* signal-cli JSON-RPC daemon simulator.
|
|
3
|
+
*
|
|
4
|
+
* Mimics `signal-cli daemon --http <addr>`: JSON-RPC 2.0 over POST, with the
|
|
5
|
+
* behaviours that make the real thing awkward, because those are the ones
|
|
6
|
+
* celilo has to survive:
|
|
7
|
+
*
|
|
8
|
+
* - errors arrive as a JSON-RPC `error` member with HTTP **200**, so code
|
|
9
|
+
* that checks `response.ok` believes a rejected send succeeded;
|
|
10
|
+
* - an unlinked/revoked account answers HTTP perfectly and can send nothing;
|
|
11
|
+
* - `receive` returns receipts and typing indicators alongside real
|
|
12
|
+
* messages, and those are not replies;
|
|
13
|
+
* - `receive` is REFUSED unless the daemon was started with
|
|
14
|
+
* `--receive-mode=manual`, which is the real default and the reason the
|
|
15
|
+
* ack path shipped broken.
|
|
16
|
+
*
|
|
17
|
+
* It pushes back rather than saying yes. Sending to a number that has not been
|
|
18
|
+
* registered with the simulator fails the way Signal fails.
|
|
19
|
+
*
|
|
20
|
+
* ── FIDELITY ───────────────────────────────────────────────────────────────
|
|
21
|
+
* Every shape below is verified against signal-cli 0.14.6 running in a
|
|
22
|
+
* container, plus its published JSON schemas — not against recollection. The
|
|
23
|
+
* contract test in e2e re-checks the real daemon so this file cannot silently
|
|
24
|
+
* drift from it. See design.md D16 for the verified table.
|
|
25
|
+
*
|
|
26
|
+
* Still true: this cannot prove that a LINKED account delivers to a phone.
|
|
27
|
+
* That needs Signal's real network, and CDSI/SVR2 attestation makes a local
|
|
28
|
+
* substitute impossible. Everything short of delivery is verified.
|
|
29
|
+
* ───────────────────────────────────────────────────────────────────────────
|
|
30
|
+
*
|
|
31
|
+
* Control surface (simulator-only, not part of signal-cli):
|
|
32
|
+
* POST /_control/inbound {from, body} queue a reply as if a human sent it
|
|
33
|
+
* POST /_control/soft-fail {recipient} make a recipient fail INSIDE a success
|
|
34
|
+
* POST /_control/unlink revoke the device link
|
|
35
|
+
* POST /_control/relink restore it
|
|
36
|
+
* GET /_control/sent every message sent, for assertions
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
const ACCOUNT = process.env.SIGNAL_ACCOUNT ?? '+15551234567';
|
|
40
|
+
const PORT = Number(process.env.SIGNAL_RPC_PORT ?? 8080);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Mirrors `signal-cli daemon --receive-mode=<mode>`, and DEFAULTS TO THE REAL
|
|
44
|
+
* DEFAULT (`on-start`) rather than to the convenient one.
|
|
45
|
+
*
|
|
46
|
+
* In on-start the daemon registers a strong receive handler, drains every
|
|
47
|
+
* inbound message into its own SSE stream, and REFUSES the `receive` JSON-RPC
|
|
48
|
+
* call outright. A simulator that answers `receive` regardless is a placebo
|
|
49
|
+
* for the whole ack path: that is precisely how the deployed module shipped
|
|
50
|
+
* with an unreadable transport while this suite stayed green. Only `manual`
|
|
51
|
+
* lets `receive` work — so a caller has to be started the way it must be
|
|
52
|
+
* deployed.
|
|
53
|
+
*
|
|
54
|
+
* Verified in signal-cli 0.14.6: DaemonCommand.java:78, :114;
|
|
55
|
+
* ManagerImpl.java:1494-1501, :1605; ReceiveCommand.java:125.
|
|
56
|
+
*/
|
|
57
|
+
const RECEIVE_MODE = process.env.SIGNAL_RECEIVE_MODE ?? 'on-start';
|
|
58
|
+
|
|
59
|
+
/** Numbers the simulator will accept as recipients. Anything else is rejected. */
|
|
60
|
+
const KNOWN_RECIPIENTS = new Set(
|
|
61
|
+
(process.env.SIGNAL_KNOWN_RECIPIENTS ?? '')
|
|
62
|
+
.split(',')
|
|
63
|
+
.map((n) => n.trim())
|
|
64
|
+
.filter(Boolean),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
interface SentMessage {
|
|
68
|
+
recipient: string;
|
|
69
|
+
message: string;
|
|
70
|
+
timestamp: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface QueuedInbound {
|
|
74
|
+
from: string;
|
|
75
|
+
body: string;
|
|
76
|
+
timestamp: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const sent: SentMessage[] = [];
|
|
80
|
+
/**
|
|
81
|
+
* Recipients that fail INSIDE a successful response, with a non-SUCCESS
|
|
82
|
+
* `type`. signal-cli only raises a JSON-RPC error when nothing succeeded, so
|
|
83
|
+
* this is how a partial failure looks — and how a client that checks only
|
|
84
|
+
* `error` ends up recording an undelivered page as sent.
|
|
85
|
+
*/
|
|
86
|
+
const unregisteredRecipients = new Set<string>();
|
|
87
|
+
const inboundQueue: QueuedInbound[] = [];
|
|
88
|
+
let linked = true;
|
|
89
|
+
let clock = 1_722_200_000_000;
|
|
90
|
+
|
|
91
|
+
const nextTimestamp = () => ++clock;
|
|
92
|
+
|
|
93
|
+
function rpcResult(id: unknown, result: unknown): Response {
|
|
94
|
+
return Response.json({ jsonrpc: '2.0', id, result });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A JSON-RPC error, returned with HTTP 200 exactly as signal-cli does. This is
|
|
99
|
+
* the single most important fidelity detail in the simulator.
|
|
100
|
+
*/
|
|
101
|
+
function rpcError(id: unknown, code: number, message: string): Response {
|
|
102
|
+
return Response.json({ jsonrpc: '2.0', id, error: { code, message } }, { status: 200 });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function handleSend(id: unknown, params: Record<string, unknown>): Response {
|
|
106
|
+
if (!linked) {
|
|
107
|
+
return rpcError(id, -32000, 'Account is not registered or the device link was removed');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const recipients = Array.isArray(params.recipient) ? (params.recipient as string[]) : [];
|
|
111
|
+
const message = typeof params.message === 'string' ? params.message : '';
|
|
112
|
+
|
|
113
|
+
if (recipients.length === 0) return rpcError(id, -32602, 'No recipient given');
|
|
114
|
+
|
|
115
|
+
for (const recipient of recipients) {
|
|
116
|
+
// Push back like Signal: an unregistered number is a hard failure, not a
|
|
117
|
+
// silent no-op.
|
|
118
|
+
if (KNOWN_RECIPIENTS.size > 0 && !KNOWN_RECIPIENTS.has(recipient)) {
|
|
119
|
+
return rpcError(id, -32000, `Unregistered user: ${recipient}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const timestamp = nextTimestamp();
|
|
124
|
+
for (const recipient of recipients) {
|
|
125
|
+
sent.push({ recipient, message, timestamp });
|
|
126
|
+
console.log(`[signal-sim] -> ${recipient}: ${message.split('\n')[0]}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Verified shape: { timestamp, results: [ { type, recipientAddress } ] }.
|
|
130
|
+
// The per-recipient `type` is the THIRD failure mode — a call can succeed
|
|
131
|
+
// at the JSON-RPC level and still not have delivered.
|
|
132
|
+
return rpcResult(id, {
|
|
133
|
+
timestamp,
|
|
134
|
+
results: recipients.map((recipient) => ({
|
|
135
|
+
type: unregisteredRecipients.has(recipient) ? 'UNREGISTERED_FAILURE' : 'SUCCESS',
|
|
136
|
+
recipientAddress: { number: recipient },
|
|
137
|
+
})),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Drain the inbound queue.
|
|
143
|
+
*
|
|
144
|
+
* Interleaves a delivery receipt and a typing indicator with real messages —
|
|
145
|
+
* the real daemon does, and a consumer that treats every envelope as a reply
|
|
146
|
+
* will misbehave on them.
|
|
147
|
+
*/
|
|
148
|
+
function handleReceive(id: unknown): Response {
|
|
149
|
+
// The refusal comes BEFORE the link check, as it does in the real daemon:
|
|
150
|
+
// the receive thread is started per account at daemon start, so an
|
|
151
|
+
// already-receiving manager rejects the call without ever looking at what
|
|
152
|
+
// is queued. Verbatim message from ReceiveCommand.java:125.
|
|
153
|
+
if (RECEIVE_MODE !== 'manual') {
|
|
154
|
+
return rpcError(
|
|
155
|
+
id,
|
|
156
|
+
-32001,
|
|
157
|
+
'Receive command cannot be used if messages are already being received.',
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!linked) {
|
|
162
|
+
return rpcError(id, -32000, 'Account is not registered or the device link was removed');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const envelopes: unknown[] = [
|
|
166
|
+
{
|
|
167
|
+
envelope: {
|
|
168
|
+
sourceNumber: ACCOUNT,
|
|
169
|
+
timestamp: nextTimestamp(),
|
|
170
|
+
receiptMessage: { isDelivery: true },
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
while (inboundQueue.length > 0) {
|
|
176
|
+
const item = inboundQueue.shift() as QueuedInbound;
|
|
177
|
+
// A reply from the linked account itself — the default single-operator
|
|
178
|
+
// setup, where the route points at the number the transport is a
|
|
179
|
+
// secondary device of — is a NOTE-TO-SELF, and the real daemon delivers
|
|
180
|
+
// it as a sync transcript, never as a dataMessage. Emitting it as a
|
|
181
|
+
// dataMessage would let a client that only reads dataMessage look
|
|
182
|
+
// correct here while dropping every reply in production (#460).
|
|
183
|
+
envelopes.push(
|
|
184
|
+
item.from === ACCOUNT
|
|
185
|
+
? {
|
|
186
|
+
envelope: {
|
|
187
|
+
sourceNumber: ACCOUNT,
|
|
188
|
+
timestamp: nextTimestamp(),
|
|
189
|
+
syncMessage: {
|
|
190
|
+
sentMessage: {
|
|
191
|
+
destinationNumber: ACCOUNT,
|
|
192
|
+
timestamp: item.timestamp,
|
|
193
|
+
message: item.body,
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
}
|
|
198
|
+
: {
|
|
199
|
+
envelope: {
|
|
200
|
+
sourceNumber: item.from,
|
|
201
|
+
timestamp: item.timestamp,
|
|
202
|
+
dataMessage: { message: item.body },
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
);
|
|
206
|
+
console.log(`[signal-sim] <- ${item.from}: ${item.body}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
envelopes.push({
|
|
210
|
+
envelope: {
|
|
211
|
+
sourceNumber: ACCOUNT,
|
|
212
|
+
timestamp: nextTimestamp(),
|
|
213
|
+
typingMessage: { action: 'STARTED' },
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
return rpcResult(id, envelopes);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function handleListAccounts(id: unknown): Response {
|
|
221
|
+
// A revoked link answers happily with an empty account list — reachability
|
|
222
|
+
// is not health.
|
|
223
|
+
return rpcResult(id, linked ? [{ number: ACCOUNT }] : []);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function handleControl(url: URL, req: Request): Promise<Response> {
|
|
227
|
+
switch (url.pathname) {
|
|
228
|
+
case '/_control/inbound': {
|
|
229
|
+
const body = (await req.json()) as { from?: string; body?: string };
|
|
230
|
+
if (!body.from || !body.body) {
|
|
231
|
+
return Response.json({ error: 'from and body required' }, { status: 400 });
|
|
232
|
+
}
|
|
233
|
+
inboundQueue.push({ from: body.from, body: body.body, timestamp: nextTimestamp() });
|
|
234
|
+
return Response.json({ queued: inboundQueue.length });
|
|
235
|
+
}
|
|
236
|
+
case '/_control/soft-fail': {
|
|
237
|
+
const body = (await req.json()) as { recipient?: string };
|
|
238
|
+
if (!body.recipient) {
|
|
239
|
+
return Response.json({ error: 'recipient required' }, { status: 400 });
|
|
240
|
+
}
|
|
241
|
+
unregisteredRecipients.add(body.recipient);
|
|
242
|
+
return Response.json({ softFailing: [...unregisteredRecipients] });
|
|
243
|
+
}
|
|
244
|
+
case '/_control/unlink':
|
|
245
|
+
linked = false;
|
|
246
|
+
console.log('[signal-sim] device link revoked');
|
|
247
|
+
return Response.json({ linked });
|
|
248
|
+
case '/_control/relink':
|
|
249
|
+
linked = true;
|
|
250
|
+
console.log('[signal-sim] device link restored');
|
|
251
|
+
return Response.json({ linked });
|
|
252
|
+
case '/_control/sent':
|
|
253
|
+
return Response.json({ sent });
|
|
254
|
+
case '/_control/reset':
|
|
255
|
+
sent.length = 0;
|
|
256
|
+
inboundQueue.length = 0;
|
|
257
|
+
unregisteredRecipients.clear();
|
|
258
|
+
linked = true;
|
|
259
|
+
return Response.json({ ok: true });
|
|
260
|
+
default:
|
|
261
|
+
return new Response('Not found', { status: 404 });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
Bun.serve({
|
|
266
|
+
port: PORT,
|
|
267
|
+
hostname: '0.0.0.0',
|
|
268
|
+
async fetch(req) {
|
|
269
|
+
const url = new URL(req.url);
|
|
270
|
+
|
|
271
|
+
if (url.pathname.startsWith('/_control/')) return handleControl(url, req);
|
|
272
|
+
|
|
273
|
+
// Verified endpoints on the real daemon.
|
|
274
|
+
if (url.pathname === '/api/v1/check') {
|
|
275
|
+
return req.method === 'GET' ? new Response(null, { status: 200 }) : new Response(null, { status: 405 });
|
|
276
|
+
}
|
|
277
|
+
if (url.pathname === '/api/v1/events') {
|
|
278
|
+
return new Response('', { status: 200, headers: { 'content-type': 'text/event-stream' } });
|
|
279
|
+
}
|
|
280
|
+
if (url.pathname !== '/api/v1/rpc') return new Response('Not found', { status: 404 });
|
|
281
|
+
|
|
282
|
+
// Verified: the real daemon returns 415 when Content-Type is not JSON.
|
|
283
|
+
const contentType = req.headers.get('content-type');
|
|
284
|
+
if (!contentType?.startsWith('application/json')) {
|
|
285
|
+
return new Response(null, { status: 415 });
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
289
|
+
|
|
290
|
+
let payload: { id?: unknown; method?: string; params?: Record<string, unknown> };
|
|
291
|
+
try {
|
|
292
|
+
payload = (await req.json()) as typeof payload;
|
|
293
|
+
} catch {
|
|
294
|
+
return Response.json(
|
|
295
|
+
{ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } },
|
|
296
|
+
{ status: 200 },
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const { id = null, method, params = {} } = payload;
|
|
301
|
+
|
|
302
|
+
switch (method) {
|
|
303
|
+
case 'send':
|
|
304
|
+
return handleSend(id, params);
|
|
305
|
+
case 'receive':
|
|
306
|
+
return handleReceive(id);
|
|
307
|
+
case 'listAccounts':
|
|
308
|
+
return handleListAccounts(id);
|
|
309
|
+
case 'version':
|
|
310
|
+
// Verified: { "version": "0.14.6" }
|
|
311
|
+
return rpcResult(id, { version: '0.14.6' });
|
|
312
|
+
default:
|
|
313
|
+
return rpcError(id, -32601, `Method not found: ${method}`);
|
|
314
|
+
}
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
console.log(
|
|
319
|
+
`[signal-sim] signal-cli JSON-RPC simulator on :${PORT} as ${ACCOUNT} (receive-mode=${RECEIVE_MODE})`,
|
|
320
|
+
);
|
|
321
|
+
if (KNOWN_RECIPIENTS.size > 0) {
|
|
322
|
+
console.log(`[signal-sim] known recipients: ${[...KNOWN_RECIPIENTS].join(', ')}`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Marks this file a module so its top-level constants get their own scope.
|
|
326
|
+
// Without it, `PORT` here collides with the same name in the isitup simulator,
|
|
327
|
+
// which shares a tsconfig.
|
|
328
|
+
export {};
|
package/src/cli/build.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } fro
|
|
|
23
23
|
import { basename, join, resolve } from 'node:path';
|
|
24
24
|
import { gunzipSync } from 'node:zlib';
|
|
25
25
|
import { stageAptRepo } from '../../scripts/stage-apt-repo';
|
|
26
|
+
import { stageLibsignal } from '../../scripts/stage-libsignal';
|
|
26
27
|
import { ensureRegistryServerBundle } from '../registry-bundle';
|
|
27
28
|
|
|
28
29
|
/**
|
|
@@ -176,6 +177,12 @@ async function stageSimulatorInputs(pkgDir: string): Promise<void> {
|
|
|
176
177
|
// time, so this must run before buildDockerImages().
|
|
177
178
|
stageAptRepo(repoRoot, pkgDir);
|
|
178
179
|
|
|
180
|
+
// (d) Build + stage the libsignal aarch64 native as a .deb, into the SAME
|
|
181
|
+
// pool stageAptRepo just populated (so it must run after it — that function
|
|
182
|
+
// recreates the pool empty). Compiled here on the host's own network rather
|
|
183
|
+
// than inside the sealed e2e network; see stage-libsignal.ts.
|
|
184
|
+
stageLibsignal(repoRoot, pkgDir);
|
|
185
|
+
|
|
179
186
|
console.log('');
|
|
180
187
|
}
|
|
181
188
|
|
package/src/cli/index.ts
CHANGED
|
@@ -61,6 +61,12 @@ function detectTestDir(): { stateDir: string; testsPath: string } {
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
if (existsSync(join(cwd, 'tests'))) return { stateDir: cwd, testsPath: join(cwd, 'tests') };
|
|
64
|
+
// The repo's own top-level suite lives at e2e/tests/, which is what `list`
|
|
65
|
+
// enumerates. Without this, `cele2e list` shows a test that `cele2e run
|
|
66
|
+
// <name>` then cannot find — the run/list disagreement in #412.
|
|
67
|
+
if (existsSync(join(cwd, 'e2e', 'tests'))) {
|
|
68
|
+
return { stateDir: join(cwd, 'e2e'), testsPath: join(cwd, 'e2e', 'tests') };
|
|
69
|
+
}
|
|
64
70
|
return { stateDir: cwd, testsPath: join(cwd, 'tests') };
|
|
65
71
|
}
|
|
66
72
|
|
package/src/container-manager.ts
CHANGED
|
@@ -4,7 +4,11 @@ import { tmpdir } from 'node:os';
|
|
|
4
4
|
import { basename, join, resolve } from 'node:path';
|
|
5
5
|
import { startBrowser } from './browser';
|
|
6
6
|
import { MIN_CLI_VERSION, checkCliVersion } from './cli-version-contract';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
SHARED_PROJECT_NAME,
|
|
9
|
+
generateTestComposeYaml,
|
|
10
|
+
getAllMachines,
|
|
11
|
+
} from './docker-compose-generator';
|
|
8
12
|
import { ensureSharedInfra } from './shared-infra';
|
|
9
13
|
import { SIMULATOR_IPS } from './simulator-ips';
|
|
10
14
|
import { startSocksProxy } from './socks-proxy';
|
|
@@ -67,8 +71,16 @@ const activeProjects: Set<string> = new Set();
|
|
|
67
71
|
*
|
|
68
72
|
* NOTE: only tears down per-test projects. Shared infra is managed
|
|
69
73
|
* separately (by the test runner or explicit stopSharedInfra call).
|
|
74
|
+
*
|
|
75
|
+
* MUST stay fully synchronous: it runs from an `exit` handler, where the
|
|
76
|
+
* event loop is already closed and any async work is silently dropped.
|
|
70
77
|
*/
|
|
71
78
|
function cleanupOnExit() {
|
|
79
|
+
// `--keep` / `--reuse` mean the operator wants the stack to survive for
|
|
80
|
+
// debugging — and they want it MOST after a failure, which is exactly the
|
|
81
|
+
// path this handler covers. Tearing down here would delete the evidence.
|
|
82
|
+
if (process.env.CELILO_E2E_KEEP === '1' || process.env.CELILO_E2E_REUSE === '1') return;
|
|
83
|
+
|
|
72
84
|
for (const project of activeProjects) {
|
|
73
85
|
try {
|
|
74
86
|
execSync(`docker compose -p ${project} down --volumes --remove-orphans`, {
|
|
@@ -77,6 +89,7 @@ function cleanupOnExit() {
|
|
|
77
89
|
});
|
|
78
90
|
} catch {}
|
|
79
91
|
}
|
|
92
|
+
activeProjects.clear();
|
|
80
93
|
try {
|
|
81
94
|
execSync('docker network prune -f', { timeout: 10_000, stdio: 'pipe' });
|
|
82
95
|
} catch {}
|
|
@@ -90,7 +103,25 @@ process.on('SIGINT', () => {
|
|
|
90
103
|
cleanupOnExit();
|
|
91
104
|
process.exit(1);
|
|
92
105
|
});
|
|
93
|
-
process.
|
|
106
|
+
// `exit`, NOT `beforeExit`: the runner always terminates via `process.exit()`
|
|
107
|
+
// (runner.ts ends with `process.exit(failed > 0 ? 1 : 0)`), and `beforeExit`
|
|
108
|
+
// does not fire for an explicit exit — nor after an uncaught throw. So on the
|
|
109
|
+
// FAILURE path, where the test never reaches `handle.stop()`, teardown was
|
|
110
|
+
// never running and the per-test networks leaked.
|
|
111
|
+
//
|
|
112
|
+
// That leak is not confined to the run that caused it. The sim's networks use
|
|
113
|
+
// the same subnets as a real celilo fleet (dmz 10.0.10.0/24, app 10.0.20.0/24,
|
|
114
|
+
// …), so on a host that lives in those subnets — celilo's own forgejo-builder
|
|
115
|
+
// does — a leaked bridge leaves a DUPLICATE route for a production prefix.
|
|
116
|
+
// Traffic from any later container to the real host at that address is routed
|
|
117
|
+
// into the dead simulated network and blackholed. That took out three
|
|
118
|
+
// consecutive release runs, each dying in `git fetch` after a ~132s connect
|
|
119
|
+
// timeout, long after the e2e run that stranded the network had ended.
|
|
120
|
+
//
|
|
121
|
+
// The start-of-run prefix sweep stays as the crash-recovery backstop (it is
|
|
122
|
+
// the only thing that can clean up after SIGKILL); this makes the common case
|
|
123
|
+
// clean up after itself instead of poisoning the next job. See #508.
|
|
124
|
+
process.on('exit', () => {
|
|
94
125
|
cleanupOnExit();
|
|
95
126
|
});
|
|
96
127
|
|
|
@@ -829,13 +860,11 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
|
|
|
829
860
|
);
|
|
830
861
|
}
|
|
831
862
|
|
|
832
|
-
// Wait for all target machines to complete setup (systemd boot + routing)
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
...config.internalMachines,
|
|
838
|
-
];
|
|
863
|
+
// Wait for all target machines to complete setup (systemd boot + routing).
|
|
864
|
+
// Via getAllMachines so a newly-supported zone cannot be silently skipped —
|
|
865
|
+
// this list was hand-maintained and omitted `secure-mgmt`, so the first
|
|
866
|
+
// machine placed there raced `machine add` and failed on SSH intermittently.
|
|
867
|
+
const allMachines = getAllMachines(config);
|
|
839
868
|
if (allMachines.length > 0) {
|
|
840
869
|
console.log('[progress:start] waiting for target machines | target machines ready');
|
|
841
870
|
}
|
|
@@ -935,7 +964,15 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
|
|
|
935
964
|
`celilo system init --accept-defaults \
|
|
936
965
|
network.internal.subnet=${ZONE_SUBNETS.internal} \
|
|
937
966
|
network.internal.gateway=${ZONE_GATEWAYS.internal} \
|
|
938
|
-
|
|
967
|
+
${
|
|
968
|
+
// The control plane needs its subnet declared if celilo-mgr lives
|
|
969
|
+
// there OR if a machine does — infrastructure selection for a module
|
|
970
|
+
// declaring `zone: secure-mgmt` reads it either way (#436).
|
|
971
|
+
config.managementZone === 'secure-mgmt' ||
|
|
972
|
+
(config.secureMgmtMachines ?? []).length > 0
|
|
973
|
+
? `network.secure-mgmt.subnet=${ZONE_SUBNETS['secure-mgmt']} network.secure-mgmt.gateway=${ZONE_GATEWAYS['secure-mgmt']} `
|
|
974
|
+
: ''
|
|
975
|
+
}dns.primary=100.100.0.1 \
|
|
939
976
|
dns.fallback=1.0.0.1,8.8.8.8`,
|
|
940
977
|
);
|
|
941
978
|
if (initResult.exitCode !== 0) {
|