@celilo/e2e 0.10.4 → 0.11.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.md +37 -28
- package/bin/e2e-status +1 -1
- package/bin/e2e-up +14 -14
- package/config/dhcp/dnsmasq.conf +3 -3
- package/config/resolver/unbound.conf +2 -2
- package/config/routing/fw-ext-routes.sh +4 -4
- package/config/routing/fw-isp-routes.sh +3 -3
- package/config/routing/fw-main-routes.sh +15 -4
- package/config/routing/management-routes.sh +11 -11
- package/config/routing/observer-setup.sh +3 -3
- package/config/routing/resolver-internal-routes.sh +5 -5
- package/config/routing/target-routes.sh +1 -1
- package/config/routing/target-setup.sh +18 -16
- package/config/socks/startup.sh +3 -3
- package/docker/Dockerfile.target-machine +15 -1
- package/docker/Dockerfile.target-machine-docker +6 -0
- package/docker/Dockerfile.target-technitium +6 -0
- package/npm-registry-server/src/packument.test.ts +2 -5
- package/npm-registry-server/src/packument.ts +3 -3
- package/npm-registry-server/src/server.ts +1 -1
- package/package.json +3 -3
- package/registry-server/src/bootstrap.ts +1 -1
- package/registry-server/src/introspection.test.ts +8 -12
- package/registry-server/src/server.test.ts +2 -2
- package/registry-server/src/server.ts +1 -1
- package/registry-server/src/storage.test.ts +8 -4
- package/registry-server/src/storage.ts +1 -1
- package/registry-server/src/validation.ts +2 -4
- package/scripts/pack-celilo-packages.ts +1 -4
- package/scripts/stage-apt-repo.ts +1 -1
- package/scripts/stage-libsignal.ts +1 -3
- package/simulators/greenwave/server.ts +4 -2
- package/simulators/greenwave/state.ts +24 -17
- package/simulators/isitup/server.ts +1 -5
- package/simulators/namecheap-ddns/server.ts +2 -4
- package/simulators/namecheap-ddns/zone-updater.test.ts +3 -1
- package/simulators/namecheap-ddns/zone-updater.ts +6 -10
- package/simulators/signal-cli/server.ts +3 -1
- package/src/address-plan.test.ts +95 -0
- package/src/browser.ts +3 -3
- package/src/bus-events.ts +2 -2
- package/src/cli/command-tree-parser.ts +8 -2
- package/src/cli/completion.ts +5 -8
- package/src/cli/index.ts +9 -2
- package/src/cli/module-discovery.ts +3 -1
- package/src/cli/scaffold.ts +2 -2
- package/src/cli-version-contract.ts +5 -6
- package/src/container-manager.ts +25 -8
- package/src/docker-compose-generator.ts +67 -39
- package/src/extract-failure.ts +92 -0
- package/src/fixtures.ts +10 -17
- package/src/index.ts +2 -2
- package/src/network-builder.ts +14 -9
- package/src/observer.test.ts +50 -19
- package/src/observer.ts +15 -8
- package/src/registry-bundle.ts +1 -3
- package/src/run-lock.test.ts +2 -2
- package/src/run-lock.ts +19 -5
- package/src/runner.ts +9 -67
- package/src/shared-infra.ts +50 -22
- package/src/socks-proxy.ts +16 -19
- package/src/types.ts +67 -17
- package/src/vantage-helpers.ts +6 -2
- package/src/vantage.test.ts +94 -40
- package/src/vantage.ts +24 -17
- package/src/wait-for-run.ts +1 -1
- package/src/zone-classifier.test.ts +32 -21
- package/src/zone-classifier.ts +7 -6
package/src/observer.ts
CHANGED
|
@@ -45,8 +45,8 @@ export interface ObserverPlacement {
|
|
|
45
45
|
// — the firewall to the segmented zones — NOT the LAN's default route, which is the home
|
|
46
46
|
// router below. A faithful internalDevice routes its default via the home router and has
|
|
47
47
|
// NO route to the segmented zones, so a dmz/app/secure container IP is unreachable.
|
|
48
|
-
const HOME_ROUTER = '
|
|
49
|
-
const INTERNAL_RESOLVER = '
|
|
48
|
+
const HOME_ROUTER = '10.226.1.1';
|
|
49
|
+
const INTERNAL_RESOLVER = '10.226.1.10';
|
|
50
50
|
const PUBLIC_RESOLVER = '100.100.0.1';
|
|
51
51
|
const INTERNET_GATEWAY = '100.64.0.1'; // fw-ext, on internet-external
|
|
52
52
|
|
|
@@ -64,7 +64,7 @@ export const OBSERVER_PLACEMENTS: Record<Exclude<Vantage, 'management'>, Observe
|
|
|
64
64
|
internalDevice: {
|
|
65
65
|
service: 'observer-internal',
|
|
66
66
|
network: 'internal',
|
|
67
|
-
ip: '
|
|
67
|
+
ip: '10.226.1.211', // a LAN host (dhcp range is .200-.220; dhcp-client is .210)
|
|
68
68
|
gateway: HOME_ROUTER,
|
|
69
69
|
interZoneRoutes: false,
|
|
70
70
|
// Identical DNS to the management box on purpose: the only difference vs management
|
|
@@ -109,7 +109,9 @@ export const OBSERVER_PLACEMENTS: Record<Exclude<Vantage, 'management'>, Observe
|
|
|
109
109
|
/** The placement for an injectable vantage; throws for `management` (not injectable). */
|
|
110
110
|
export function observerPlacement(vantage: Vantage): ObserverPlacement {
|
|
111
111
|
if (vantage === 'management') {
|
|
112
|
-
throw new Error(
|
|
112
|
+
throw new Error(
|
|
113
|
+
'management has no observer placement — it reuses the real management container.',
|
|
114
|
+
);
|
|
113
115
|
}
|
|
114
116
|
return OBSERVER_PLACEMENTS[vantage];
|
|
115
117
|
}
|
|
@@ -209,7 +211,10 @@ export function createObserverTransport(handle: NetworkHandle): ProbeTransport {
|
|
|
209
211
|
`timeout 4 bash -c 'exec 3<>/dev/tcp/${opts.host}/${opts.port}'`,
|
|
210
212
|
8_000,
|
|
211
213
|
);
|
|
212
|
-
return {
|
|
214
|
+
return {
|
|
215
|
+
connected: res.exitCode === 0,
|
|
216
|
+
detail: res.exitCode === 0 ? undefined : 'connect failed/timed out',
|
|
217
|
+
};
|
|
213
218
|
},
|
|
214
219
|
|
|
215
220
|
async ping(opts) {
|
|
@@ -223,8 +228,7 @@ export function createObserverTransport(handle: NetworkHandle): ProbeTransport {
|
|
|
223
228
|
const { host, port } = hostPort(opts.url);
|
|
224
229
|
const certRes = await handle.exec(
|
|
225
230
|
c,
|
|
226
|
-
`echo | openssl s_client -connect ${host}:${port} -servername ${host} 2>/dev/null |
|
|
227
|
-
'openssl x509 -noout -subject -issuer -ext subjectAltName',
|
|
231
|
+
`echo | openssl s_client -connect ${host}:${port} -servername ${host} 2>/dev/null | openssl x509 -noout -subject -issuer -ext subjectAltName`,
|
|
228
232
|
10_000,
|
|
229
233
|
);
|
|
230
234
|
// -k gathers the status even for an untrusted chain; the cert is reported truthfully
|
|
@@ -249,7 +253,10 @@ export function createObserverTransport(handle: NetworkHandle): ProbeTransport {
|
|
|
249
253
|
const hosts = await handle.exec(c, `grep -F ${opts.name} /etc/hosts`, 5_000);
|
|
250
254
|
return {
|
|
251
255
|
resolvConf: resolv.stdout.trim(),
|
|
252
|
-
hostsLines: hosts.stdout
|
|
256
|
+
hostsLines: hosts.stdout
|
|
257
|
+
.split('\n')
|
|
258
|
+
.map((l) => l.trim())
|
|
259
|
+
.filter(Boolean),
|
|
253
260
|
};
|
|
254
261
|
},
|
|
255
262
|
};
|
package/src/registry-bundle.ts
CHANGED
|
@@ -39,9 +39,7 @@ export function ensureRegistryServerBundle(pkgDir: string): void {
|
|
|
39
39
|
if (!existsSync(upstream)) {
|
|
40
40
|
if (!existsSync(bundle)) {
|
|
41
41
|
throw new Error(
|
|
42
|
-
`cele2e: ${bundle} is missing and ../registry-server doesn't exist either.\
|
|
43
|
-
'The @celilo/e2e package was published without its registry-server bundle. ' +
|
|
44
|
-
'Re-publish from infra/scripts/publish.ts so the bundle is recreated.',
|
|
42
|
+
`cele2e: ${bundle} is missing and ../registry-server doesn't exist either.\nThe @celilo/e2e package was published without its registry-server bundle. Re-publish from infra/scripts/publish.ts so the bundle is recreated.`,
|
|
45
43
|
);
|
|
46
44
|
}
|
|
47
45
|
return; // npm-installed; bundle ships in tarball
|
package/src/run-lock.test.ts
CHANGED
|
@@ -3,10 +3,10 @@ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
|
3
3
|
import { hostname, tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import {
|
|
6
|
-
acquireRunLock,
|
|
7
|
-
clearLock,
|
|
8
6
|
E2eBusyError,
|
|
9
7
|
type LockHolder,
|
|
8
|
+
acquireRunLock,
|
|
9
|
+
clearLock,
|
|
10
10
|
lockStatus,
|
|
11
11
|
markKept,
|
|
12
12
|
readHolder,
|
package/src/run-lock.ts
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import { execFileSync } from 'node:child_process';
|
|
27
|
-
import { mkdirSync, openSync,
|
|
27
|
+
import { closeSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
28
28
|
import { homedir, hostname } from 'node:os';
|
|
29
29
|
import { basename, dirname, join } from 'node:path';
|
|
30
30
|
|
|
@@ -71,7 +71,10 @@ export class E2eBusyError extends Error {
|
|
|
71
71
|
export function formatBusy(h: LockHolder): string {
|
|
72
72
|
const age = ageString(Date.parse(h.startedAt));
|
|
73
73
|
const verb = h.state === 'kept' ? 'holds a kept stack from' : 'running';
|
|
74
|
-
const what =
|
|
74
|
+
const what =
|
|
75
|
+
h.state === 'kept'
|
|
76
|
+
? '(run `cele2e release` to free it)'
|
|
77
|
+
: `${h.test}, started ${age} ago (pid ${h.pid})`;
|
|
75
78
|
return `e2e busy: ${h.session} ${verb} ${what}`;
|
|
76
79
|
}
|
|
77
80
|
|
|
@@ -88,7 +91,11 @@ function deriveSession(): string {
|
|
|
88
91
|
const cwd = process.cwd();
|
|
89
92
|
const git = (args: string[]): string => {
|
|
90
93
|
try {
|
|
91
|
-
return execFileSync('git', args, {
|
|
94
|
+
return execFileSync('git', args, {
|
|
95
|
+
cwd,
|
|
96
|
+
encoding: 'utf-8',
|
|
97
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
98
|
+
}).trim();
|
|
92
99
|
} catch {
|
|
93
100
|
return '';
|
|
94
101
|
}
|
|
@@ -164,7 +171,9 @@ export function acquireRunLock(opts: { test: string; runId: string; allowKept?:
|
|
|
164
171
|
// Reclaim when: corrupt/unreadable, the holder is stale, OR this is a
|
|
165
172
|
// `--reuse` run taking over a `kept` stack it's deliberately reusing.
|
|
166
173
|
if (!existing || isStale(existing) || (opts.allowKept && existing.state === 'kept')) {
|
|
167
|
-
try {
|
|
174
|
+
try {
|
|
175
|
+
unlinkSync(lockPath());
|
|
176
|
+
} catch {}
|
|
168
177
|
continue;
|
|
169
178
|
}
|
|
170
179
|
throw new E2eBusyError(existing);
|
|
@@ -224,7 +233,12 @@ export function releaseRunLock(): void {
|
|
|
224
233
|
if (held.keepOnRelease) {
|
|
225
234
|
const cur = readHolder();
|
|
226
235
|
if (cur && cur.pid === process.pid) {
|
|
227
|
-
try {
|
|
236
|
+
try {
|
|
237
|
+
writeFileSync(
|
|
238
|
+
lockPath(),
|
|
239
|
+
JSON.stringify({ ...cur, state: 'kept', beatAt: Date.now() }, null, 2),
|
|
240
|
+
);
|
|
241
|
+
} catch {}
|
|
228
242
|
}
|
|
229
243
|
return;
|
|
230
244
|
}
|
package/src/runner.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
emitTestCompleted,
|
|
30
30
|
emitTestStarted,
|
|
31
31
|
} from './bus-events';
|
|
32
|
+
import { extractFailureMessage, stripAnsi } from './extract-failure';
|
|
32
33
|
import { parseLine } from './parse-line';
|
|
33
34
|
import { E2eBusyError, acquireRunLock, markKept } from './run-lock';
|
|
34
35
|
import { SIMULATOR_IPS } from './simulator-ips';
|
|
@@ -176,7 +177,7 @@ function loadTiming(): TimingHistory {
|
|
|
176
177
|
}
|
|
177
178
|
|
|
178
179
|
function saveTiming(history: TimingHistory): void {
|
|
179
|
-
writeFileSync(timingFile, JSON.stringify(history, Object.keys(history).sort(), 2)
|
|
180
|
+
writeFileSync(timingFile, `${JSON.stringify(history, Object.keys(history).sort(), 2)}\n`);
|
|
180
181
|
}
|
|
181
182
|
|
|
182
183
|
function formatDuration(secs: number): string {
|
|
@@ -185,67 +186,8 @@ function formatDuration(secs: number): string {
|
|
|
185
186
|
}
|
|
186
187
|
|
|
187
188
|
// ─── Error Extraction ────────────────────────────────────────────────
|
|
188
|
-
|
|
189
|
-
//
|
|
190
|
-
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
191
|
-
function stripAnsi(s: string): string {
|
|
192
|
-
return s.replace(ANSI_RE, '');
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
function extractFailureMessage(lines: string[], exitCode: number): string {
|
|
196
|
-
const plain = lines.map(stripAnsi);
|
|
197
|
-
|
|
198
|
-
// bun's own test failure: "✗ test name [Xms]" — extract the failing test name
|
|
199
|
-
const bunFailIdx = plain.findIndex((l) => /^\s*✗\s+\S/.test(l) && !l.includes('failed ('));
|
|
200
|
-
if (bunFailIdx >= 0) {
|
|
201
|
-
const testName = plain[bunFailIdx]
|
|
202
|
-
.replace(/^\s*✗\s+/, '')
|
|
203
|
-
.replace(/\s*\[\d+ms\].*$/, '')
|
|
204
|
-
.trim();
|
|
205
|
-
// Grab the error detail on the next few lines (bun indents it under the test)
|
|
206
|
-
const detail = plain
|
|
207
|
-
.slice(bunFailIdx + 1, bunFailIdx + 6)
|
|
208
|
-
.map((l) => l.trim())
|
|
209
|
-
.filter((l) => l.length > 0 && !l.startsWith('at ') && !l.startsWith('│') && l !== '└─')
|
|
210
|
-
.slice(0, 2)
|
|
211
|
-
.join(' — ');
|
|
212
|
-
return detail ? `${testName}: ${detail}` : testName;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// "error: ..." lines (may be indented in bun output)
|
|
216
|
-
const errorIdx = plain.findIndex(
|
|
217
|
-
(l) => l.trimStart().startsWith('error:') && !l.includes('bun test'),
|
|
218
|
-
);
|
|
219
|
-
if (errorIdx >= 0) {
|
|
220
|
-
const context = plain
|
|
221
|
-
.slice(errorIdx, errorIdx + 5)
|
|
222
|
-
.map((l) => l.trim())
|
|
223
|
-
.filter((l) => l.length > 0 && !l.startsWith('at '));
|
|
224
|
-
return context.join('\n ');
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
const celiloError = plain.find(
|
|
228
|
-
(l) => l.includes('CeliloCommandError') || (l.includes('celilo') && l.includes('failed')),
|
|
229
|
-
);
|
|
230
|
-
if (celiloError) return celiloError.slice(0, 120);
|
|
231
|
-
|
|
232
|
-
const timeout = plain.find((l) => l.includes('Timeout') || l.includes('timed out'));
|
|
233
|
-
if (timeout) return timeout.slice(0, 120);
|
|
234
|
-
|
|
235
|
-
// Last resort: print the last few non-empty, non-progress lines from the output
|
|
236
|
-
const tail = plain
|
|
237
|
-
.filter(
|
|
238
|
-
(l) =>
|
|
239
|
-
l.length > 0 &&
|
|
240
|
-
!l.startsWith('[progress') &&
|
|
241
|
-
!l.startsWith('[e2e:') &&
|
|
242
|
-
!l.startsWith('[ansible:'),
|
|
243
|
-
)
|
|
244
|
-
.slice(-3)
|
|
245
|
-
.map((l) => l.trim())
|
|
246
|
-
.join(' | ');
|
|
247
|
-
return tail || `exit code ${exitCode}`;
|
|
248
|
-
}
|
|
189
|
+
// extractFailureMessage / stripAnsi live in extract-failure.ts: this file
|
|
190
|
+
// calls main() at import time, so nothing declared here is testable.
|
|
249
191
|
|
|
250
192
|
// ─── Test Execution ──────────────────────────────────────────────────
|
|
251
193
|
|
|
@@ -409,7 +351,7 @@ async function runTest(
|
|
|
409
351
|
display.abandonFailed();
|
|
410
352
|
}
|
|
411
353
|
|
|
412
|
-
writeFileSync(logFile, stdout
|
|
354
|
+
writeFileSync(logFile, `${stdout}\n---STDERR---\n${stderr}`);
|
|
413
355
|
|
|
414
356
|
const expectMatch = stdout.match(/(\d+) expect\(\) calls/);
|
|
415
357
|
const expectCount = expectMatch ? Number.parseInt(expectMatch[1], 10) : undefined;
|
|
@@ -816,7 +758,7 @@ async function main() {
|
|
|
816
758
|
if (flagKeep && result.projectName) {
|
|
817
759
|
writeFileSync(
|
|
818
760
|
persistentFile,
|
|
819
|
-
JSON.stringify(
|
|
761
|
+
`${JSON.stringify(
|
|
820
762
|
{
|
|
821
763
|
projectName: result.projectName,
|
|
822
764
|
testFile: file,
|
|
@@ -824,7 +766,7 @@ async function main() {
|
|
|
824
766
|
},
|
|
825
767
|
null,
|
|
826
768
|
2,
|
|
827
|
-
)
|
|
769
|
+
)}\n`,
|
|
828
770
|
);
|
|
829
771
|
console.log(`${dim} Persistent network: ${result.projectName}${reset}`);
|
|
830
772
|
console.log(`${dim} Reuse: ${basename(process.argv[1])} --reuse${reset}`);
|
|
@@ -886,7 +828,7 @@ async function main() {
|
|
|
886
828
|
const icon = r.status === 'pass' ? '✓' : '✗';
|
|
887
829
|
const color = r.status === 'pass' ? green : red;
|
|
888
830
|
console.log(
|
|
889
|
-
`${bold}║${reset} ${color}${icon}${reset} ${r.name.padEnd(28)} ${dim}${(r.duration
|
|
831
|
+
`${bold}║${reset} ${color}${icon}${reset} ${r.name.padEnd(28)} ${dim}${(`${r.duration}s`).padStart(6)}${reset}`,
|
|
890
832
|
);
|
|
891
833
|
if (r.error) {
|
|
892
834
|
console.log(`${bold}║${reset} ${red}└─ ${r.error}${reset}`);
|
|
@@ -911,7 +853,7 @@ async function main() {
|
|
|
911
853
|
const adds: string[] = [];
|
|
912
854
|
if (!content.includes('results/')) adds.push('results/');
|
|
913
855
|
if (!content.includes('.e2e-timing.json')) adds.push('.e2e-timing.json');
|
|
914
|
-
if (adds.length) writeFileSync(gitignore, content.trimEnd()
|
|
856
|
+
if (adds.length) writeFileSync(gitignore, `${content.trimEnd()}\n${adds.join('\n')}\n`);
|
|
915
857
|
|
|
916
858
|
emitRunCompleted({
|
|
917
859
|
runId,
|
package/src/shared-infra.ts
CHANGED
|
@@ -11,9 +11,13 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { execSync } from 'node:child_process';
|
|
14
|
-
import { join } from 'node:path';
|
|
15
14
|
import { copyFileSync, existsSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
16
|
-
import {
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import {
|
|
17
|
+
SHARED_NETWORKS,
|
|
18
|
+
SHARED_PROJECT_NAME,
|
|
19
|
+
generateSharedInfraYaml,
|
|
20
|
+
} from './docker-compose-generator';
|
|
17
21
|
import { ensureRegistryServerBundle } from './registry-bundle';
|
|
18
22
|
|
|
19
23
|
const SHARED_COMPOSE_FILE = 'docker-compose.shared.yml';
|
|
@@ -37,8 +41,10 @@ function run(cmd: string, opts?: { cwd?: string; timeout?: number }): string {
|
|
|
37
41
|
*/
|
|
38
42
|
function nukeE2eResources(e2eDir: string): void {
|
|
39
43
|
try {
|
|
40
|
-
run(
|
|
41
|
-
{
|
|
44
|
+
run(
|
|
45
|
+
`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} down --volumes --remove-orphans`,
|
|
46
|
+
{ cwd: e2eDir, timeout: 30_000 },
|
|
47
|
+
);
|
|
42
48
|
} catch {}
|
|
43
49
|
try {
|
|
44
50
|
const ids = run('docker ps -aq --filter name=celilo-e2e', { timeout: 10_000 });
|
|
@@ -49,11 +55,17 @@ function nukeE2eResources(e2eDir: string): void {
|
|
|
49
55
|
.split('\n')
|
|
50
56
|
.filter((n) => n.startsWith('celilo-e2e'));
|
|
51
57
|
for (const net of nets) {
|
|
52
|
-
try {
|
|
58
|
+
try {
|
|
59
|
+
run(`docker network rm ${net}`, { timeout: 5_000 });
|
|
60
|
+
} catch {}
|
|
53
61
|
}
|
|
54
62
|
} catch {}
|
|
55
|
-
try {
|
|
56
|
-
|
|
63
|
+
try {
|
|
64
|
+
run('docker network prune -f', { timeout: 10_000 });
|
|
65
|
+
} catch {}
|
|
66
|
+
try {
|
|
67
|
+
run('docker volume prune -f', { timeout: 10_000 });
|
|
68
|
+
} catch {}
|
|
57
69
|
}
|
|
58
70
|
|
|
59
71
|
/**
|
|
@@ -117,7 +129,9 @@ export async function ensureSharedInfra(): Promise<void> {
|
|
|
117
129
|
}
|
|
118
130
|
} catch {
|
|
119
131
|
// DNS check failed — restart shared infra
|
|
120
|
-
console.log(
|
|
132
|
+
console.log(
|
|
133
|
+
'[progress:start] shared infra DNS check failed, restarting | shared infra restarted',
|
|
134
|
+
);
|
|
121
135
|
await stopSharedInfra();
|
|
122
136
|
}
|
|
123
137
|
}
|
|
@@ -130,7 +144,9 @@ export async function ensureSharedInfra(): Promise<void> {
|
|
|
130
144
|
// This self-heals orphans left by a crashed / cross-path / cross-checkout
|
|
131
145
|
// prior run (e.g. a botched-mount container that wedges the next `up`),
|
|
132
146
|
// which the old compose-project-scoped `down` couldn't see (#212).
|
|
133
|
-
console.log(
|
|
147
|
+
console.log(
|
|
148
|
+
'[progress:start] cleaning up stale shared infrastructure | shared infra cleanup complete',
|
|
149
|
+
);
|
|
134
150
|
nukeE2eResources(e2eDir);
|
|
135
151
|
|
|
136
152
|
// Refresh the bundled registry-server source so Dockerfile.registry's
|
|
@@ -161,12 +177,16 @@ export async function ensureSharedInfra(): Promise<void> {
|
|
|
161
177
|
|
|
162
178
|
// Build and start
|
|
163
179
|
console.log('[progress:start] building shared infrastructure images | shared images built');
|
|
164
|
-
run(`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} build`,
|
|
165
|
-
|
|
180
|
+
run(`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} build`, {
|
|
181
|
+
cwd: e2eDir,
|
|
182
|
+
timeout: 300_000,
|
|
183
|
+
});
|
|
166
184
|
|
|
167
185
|
console.log('[progress:start] starting shared infrastructure | shared infra started');
|
|
168
|
-
run(`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} up -d`,
|
|
169
|
-
|
|
186
|
+
run(`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} up -d`, {
|
|
187
|
+
cwd: e2eDir,
|
|
188
|
+
timeout: 120_000,
|
|
189
|
+
});
|
|
170
190
|
|
|
171
191
|
// Wait for DNS convergence — robustly. The old single 60s window with no
|
|
172
192
|
// recovery was an intermittent build-infra failure on the builder (#319:
|
|
@@ -196,23 +216,29 @@ export async function ensureSharedInfra(): Promise<void> {
|
|
|
196
216
|
if (Date.now() - lastRestart > 45_000) {
|
|
197
217
|
console.log('[progress:sub] DNS not converged yet — restarting namecheap-dns');
|
|
198
218
|
try {
|
|
199
|
-
run(
|
|
200
|
-
|
|
219
|
+
run(
|
|
220
|
+
`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} restart namecheap-dns`,
|
|
221
|
+
{ cwd: e2eDir, timeout: 30_000 },
|
|
222
|
+
);
|
|
201
223
|
} catch {}
|
|
202
224
|
lastRestart = Date.now();
|
|
203
225
|
}
|
|
204
|
-
await new Promise(r => setTimeout(r, 2000));
|
|
226
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
205
227
|
}
|
|
206
228
|
// Diagnostics before failing, so the log shows WHY it didn't converge.
|
|
207
229
|
let psOut = '';
|
|
208
230
|
try {
|
|
209
|
-
psOut = run(`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} ps`,
|
|
210
|
-
|
|
231
|
+
psOut = run(`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} ps`, {
|
|
232
|
+
cwd: e2eDir,
|
|
233
|
+
timeout: 10_000,
|
|
234
|
+
});
|
|
211
235
|
} catch {}
|
|
212
236
|
let dnsLog = '';
|
|
213
237
|
try {
|
|
214
|
-
dnsLog = run(
|
|
215
|
-
{
|
|
238
|
+
dnsLog = run(
|
|
239
|
+
`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} logs --tail 40 namecheap-dns`,
|
|
240
|
+
{ cwd: e2eDir, timeout: 10_000 },
|
|
241
|
+
);
|
|
216
242
|
} catch {}
|
|
217
243
|
throw new Error(
|
|
218
244
|
`Timeout waiting for shared infrastructure DNS convergence (${dnsTimeout / 1000}s)\n` +
|
|
@@ -227,8 +253,10 @@ export async function stopSharedInfra(): Promise<void> {
|
|
|
227
253
|
console.log('[progress:start] stopping shared infrastructure | shared infra stopped');
|
|
228
254
|
const e2eDir = getE2eDir();
|
|
229
255
|
try {
|
|
230
|
-
run(
|
|
231
|
-
{
|
|
256
|
+
run(
|
|
257
|
+
`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} down --volumes --remove-orphans`,
|
|
258
|
+
{ cwd: e2eDir, timeout: 60_000 },
|
|
259
|
+
);
|
|
232
260
|
} catch {
|
|
233
261
|
// Best effort
|
|
234
262
|
}
|
package/src/socks-proxy.ts
CHANGED
|
@@ -17,7 +17,7 @@ export interface SocksProxyOptions {
|
|
|
17
17
|
* ACME, Caddy host routing). Recommended for tests that need to
|
|
18
18
|
* simulate what a real user sees.
|
|
19
19
|
* - 'internal': operator-on-LAN view, attaches to the internal zone,
|
|
20
|
-
* uses split-horizon DNS (
|
|
20
|
+
* uses split-horizon DNS (10.226.1.10) so iamtheinternet.org resolves
|
|
21
21
|
* to caddy's internal IP directly. Useful for debugging — reaches all
|
|
22
22
|
* zones via the firewall.
|
|
23
23
|
*/
|
|
@@ -65,7 +65,7 @@ const VANTAGE_CONFIGS: Record<SocksProxyVantage, VantageConfig> = {
|
|
|
65
65
|
},
|
|
66
66
|
internal: {
|
|
67
67
|
network: 'internal',
|
|
68
|
-
ip: '
|
|
68
|
+
ip: '10.226.1.150',
|
|
69
69
|
},
|
|
70
70
|
};
|
|
71
71
|
|
|
@@ -165,8 +165,7 @@ async function waitForTcpAccept(port: number, timeoutMs = 10_000): Promise<void>
|
|
|
165
165
|
await new Promise((r) => setTimeout(r, 100));
|
|
166
166
|
}
|
|
167
167
|
throw new Error(
|
|
168
|
-
`SOCKS proxy on 127.0.0.1:${port} never accepted connections within ${timeoutMs}ms`
|
|
169
|
-
(lastErr ? ` (last error: ${lastErr})` : ''),
|
|
168
|
+
`SOCKS proxy on 127.0.0.1:${port} never accepted connections within ${timeoutMs}ms${lastErr ? ` (last error: ${lastErr})` : ''}`,
|
|
170
169
|
);
|
|
171
170
|
}
|
|
172
171
|
|
|
@@ -233,8 +232,7 @@ function ensureProxyImage(): void {
|
|
|
233
232
|
const dockerfilePath = join(PACKAGE_ROOT, PROXY_DOCKERFILE);
|
|
234
233
|
if (!existsSync(dockerfilePath)) {
|
|
235
234
|
throw new Error(
|
|
236
|
-
`SOCKS proxy Dockerfile not found at ${dockerfilePath}.
|
|
237
|
-
`Expected it to ship with the @celilo/e2e package.`,
|
|
235
|
+
`SOCKS proxy Dockerfile not found at ${dockerfilePath}. Expected it to ship with the @celilo/e2e package.`,
|
|
238
236
|
);
|
|
239
237
|
}
|
|
240
238
|
|
|
@@ -247,18 +245,17 @@ function ensureProxyImage(): void {
|
|
|
247
245
|
});
|
|
248
246
|
} catch (err) {
|
|
249
247
|
const msg = err instanceof Error ? err.message : String(err);
|
|
250
|
-
throw new Error(
|
|
251
|
-
`Failed to build ${PROXY_IMAGE} from ${PROXY_DOCKERFILE}: ${msg}`,
|
|
252
|
-
);
|
|
248
|
+
throw new Error(`Failed to build ${PROXY_IMAGE} from ${PROXY_DOCKERFILE}: ${msg}`);
|
|
253
249
|
}
|
|
254
250
|
}
|
|
255
251
|
|
|
256
252
|
function inspectContainer(name: string): boolean {
|
|
257
253
|
try {
|
|
258
|
-
const result = execSync(
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
254
|
+
const result = execSync(`docker inspect -f '{{.State.Running}}' ${name} 2>/dev/null`, {
|
|
255
|
+
timeout: 5_000,
|
|
256
|
+
encoding: 'utf-8',
|
|
257
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
258
|
+
});
|
|
262
259
|
return result.trim() === 'true';
|
|
263
260
|
} catch {
|
|
264
261
|
return false;
|
|
@@ -276,15 +273,15 @@ async function waitForContainerListening(name: string): Promise<void> {
|
|
|
276
273
|
let lastInspect = '';
|
|
277
274
|
while (Date.now() < deadline) {
|
|
278
275
|
try {
|
|
279
|
-
lastInspect = execSync(
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
276
|
+
lastInspect = execSync(`docker inspect -f '{{.State.Status}} {{.State.ExitCode}}' ${name}`, {
|
|
277
|
+
timeout: 3_000,
|
|
278
|
+
encoding: 'utf-8',
|
|
279
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
280
|
+
}).trim();
|
|
283
281
|
} catch {
|
|
284
282
|
// Container may have already self-removed (--rm) after exiting
|
|
285
283
|
throw new Error(
|
|
286
|
-
`SOCKS proxy container '${name}' disappeared during startup.
|
|
287
|
-
`It likely exited immediately. Check the image's required env vars.`,
|
|
284
|
+
`SOCKS proxy container '${name}' disappeared during startup. It likely exited immediately. Check the image's required env vars.`,
|
|
288
285
|
);
|
|
289
286
|
}
|
|
290
287
|
const [status] = lastInspect.split(/\s+/);
|
package/src/types.ts
CHANGED
|
@@ -123,7 +123,7 @@ export interface NetworkConfig {
|
|
|
123
123
|
|
|
124
124
|
/**
|
|
125
125
|
* When set, brings up a SECOND management-class box named `celilo-mgr-2`
|
|
126
|
-
* (at
|
|
126
|
+
* (at 10.226.1.101 on `internal`) using the given image variant. Used by
|
|
127
127
|
* the migration restore e2e: the primary box is the backup source, the
|
|
128
128
|
* second box is a fresh target that `apt install celilo` + `celilo restore`s
|
|
129
129
|
* into. The second box is NOT auto-init'd by container-manager (which keys
|
|
@@ -355,7 +355,7 @@ export interface NetworkHandle {
|
|
|
355
355
|
* subnets from ZONE_SUBNETS / ZONE_GATEWAYS.
|
|
356
356
|
*
|
|
357
357
|
* @param opts.zones - zones to provide (default: dmz, app, secure)
|
|
358
|
-
* @param opts.natIp - NAT IP for port-forwarded traffic (default
|
|
358
|
+
* @param opts.natIp - NAT IP for port-forwarded traffic (default 10.226.1.253)
|
|
359
359
|
*/
|
|
360
360
|
deployFirewall(opts?: {
|
|
361
361
|
zones?: Array<'dmz' | 'app' | 'secure'>;
|
|
@@ -407,22 +407,72 @@ export interface ResponderValues {
|
|
|
407
407
|
aspects?: Record<string, boolean>;
|
|
408
408
|
}
|
|
409
409
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
410
|
+
/**
|
|
411
|
+
* The parent block every simulated PRIVATE zone lives inside (#539).
|
|
412
|
+
*
|
|
413
|
+
* The sim used to number its zones exactly like a real celilo fleet: its dmz,
|
|
414
|
+
* app and secure /24s WERE the fleet's, and its internal LAN was the operator's.
|
|
415
|
+
* That is not a cosmetic overlap — celilo's own forgejo-builder lives in the
|
|
416
|
+
* REAL dmz, so a leaked stack put a second interface on the builder's own /24
|
|
417
|
+
* and blackholed every containerized CI job's route to the forge for ~135s.
|
|
418
|
+
* Twice in one afternoon. Teardown can't be made reliable enough to fix that (a
|
|
419
|
+
* SIGKILL runs no handler), so the fix is that a leak stops MATTERING.
|
|
420
|
+
*
|
|
421
|
+
* `10.226.0.0/16` — mnemonic `0xE2` = "e2", the e2e supernet. It must be
|
|
422
|
+
* RFC-1918 (the private zones model private networks — CLAUDE.md inviolable
|
|
423
|
+
* rules #1/#3), and this block collides with nothing plausible:
|
|
424
|
+
* - the fleet numbers its zones out of the bottom of 10/8 → far away
|
|
425
|
+
* - the operator's VPN sits at the very top of 10/8 → far away
|
|
426
|
+
* - the operator's LAN is in 192.168/16 → different block
|
|
427
|
+
* - Docker's default address pools are 172.16/12 and
|
|
428
|
+
* 192.168/16 → different block
|
|
429
|
+
*
|
|
430
|
+
* The second reason matters more than the collision: there must be nothing
|
|
431
|
+
* magic about the octets production happens to use. A suite that only passes on
|
|
432
|
+
* those exact numbers is asserting one site's address plan rather than celilo's
|
|
433
|
+
* behaviour. Renumbering proves address-independence.
|
|
434
|
+
*
|
|
435
|
+
* Do not move a zone out of this block. `address-plan.test.ts` fails if you do,
|
|
436
|
+
* and fails if the retired fleet-colliding prefixes reappear anywhere in the
|
|
437
|
+
* suite.
|
|
438
|
+
*/
|
|
439
|
+
export const SIM_PRIVATE_SUPERNET = '10.226.0.0/16';
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Third-octet plan inside {@link SIM_PRIVATE_SUPERNET}. Host octets are
|
|
443
|
+
* unchanged from the pre-#539 numbering, so `caddy` is still `…10.10` and
|
|
444
|
+
* fw-main is still `…1.254` — only the network part moved.
|
|
445
|
+
*/
|
|
446
|
+
const ZONE_PREFIXES: Record<Zone, string> = {
|
|
447
|
+
internal: '10.226.1',
|
|
448
|
+
dmz: '10.226.10',
|
|
449
|
+
app: '10.226.20',
|
|
450
|
+
secure: '10.226.30',
|
|
451
|
+
'secure-mgmt': '10.226.120',
|
|
416
452
|
};
|
|
417
453
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
454
|
+
/**
|
|
455
|
+
* An address in a zone, by host octet. Reference this instead of writing an
|
|
456
|
+
* octet: it is what makes the whole plan a one-line change, and what makes a
|
|
457
|
+
* stray hardcoded address stand out as the anomaly it is.
|
|
458
|
+
*/
|
|
459
|
+
export function zoneIp(zone: Zone, host: number): string {
|
|
460
|
+
return `${ZONE_PREFIXES[zone]}.${host}`;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** The router/firewall leg in each zone. `internal`'s is fw-main's LAN side. */
|
|
464
|
+
export const ZONE_GATEWAYS: Record<Zone, string> = {
|
|
465
|
+
dmz: zoneIp('dmz', 1),
|
|
466
|
+
app: zoneIp('app', 1),
|
|
467
|
+
secure: zoneIp('secure', 1),
|
|
468
|
+
internal: zoneIp('internal', 254),
|
|
469
|
+
'secure-mgmt': zoneIp('secure-mgmt', 1),
|
|
424
470
|
};
|
|
425
471
|
|
|
472
|
+
export const ZONE_SUBNETS: Record<Zone, string> = Object.fromEntries(
|
|
473
|
+
(Object.keys(ZONE_PREFIXES) as Zone[]).map((zone) => [zone, `${ZONE_PREFIXES[zone]}.0/24`]),
|
|
474
|
+
) as Record<Zone, string>;
|
|
475
|
+
|
|
426
476
|
/**
|
|
427
477
|
* The firewall's internal-side DNAT ingress (the natIp) in the e2e topology — the role by
|
|
428
478
|
* which an internal device reaches a service that lives in a segmented zone. Single source
|
|
@@ -431,7 +481,7 @@ export const ZONE_SUBNETS: Record<Zone, string> = {
|
|
|
431
481
|
* hardcoding the address in a test.
|
|
432
482
|
*/
|
|
433
483
|
export function internalNatIp(): string {
|
|
434
|
-
return '
|
|
484
|
+
return zoneIp('internal', 253);
|
|
435
485
|
}
|
|
436
486
|
|
|
437
487
|
/**
|
|
@@ -441,7 +491,7 @@ export function internalNatIp(): string {
|
|
|
441
491
|
* this instead of hardcoding the address in a test.
|
|
442
492
|
*/
|
|
443
493
|
export function greenwaveRouterIp(): string {
|
|
444
|
-
return '
|
|
494
|
+
return zoneIp('internal', 1);
|
|
445
495
|
}
|
|
446
496
|
|
|
447
497
|
/**
|
|
@@ -466,7 +516,7 @@ export function externalWanSubnet(): string {
|
|
|
466
516
|
|
|
467
517
|
/** The internal split-horizon DNS resolver (dns-int) in the e2e topology. */
|
|
468
518
|
export function internalResolverIp(): string {
|
|
469
|
-
return '
|
|
519
|
+
return zoneIp('internal', 10);
|
|
470
520
|
}
|
|
471
521
|
|
|
472
522
|
/** The public upstream recursive resolver (the comcast-resolver sim). */
|
package/src/vantage-helpers.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* here — they wait on real firewall isolation (ISS-0118).
|
|
9
9
|
*
|
|
10
10
|
* Topology prerequisite: inject the observers the assertion needs, e.g.
|
|
11
|
-
* network().dmz({ caddy: '10.
|
|
11
|
+
* network().dmz({ caddy: '10.226.10.10' }).observe('internalDevice', 'publicInternet')
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { createObserverTransport } from './observer';
|
|
@@ -52,7 +52,11 @@ export async function assertFrontedServicePositives(
|
|
|
52
52
|
const url = `https://${expectation.fqdn}${expectation.path ?? ''}`;
|
|
53
53
|
|
|
54
54
|
// The internet sees the service at a public IP and a valid HTTPS endpoint.
|
|
55
|
-
await probe.assertResolvesInZone({
|
|
55
|
+
await probe.assertResolvesInZone({
|
|
56
|
+
from: 'publicInternet',
|
|
57
|
+
name: expectation.fqdn,
|
|
58
|
+
zone: 'public',
|
|
59
|
+
});
|
|
56
60
|
await probe.assertHttps({
|
|
57
61
|
from: 'publicInternet',
|
|
58
62
|
url,
|