@celilo/e2e 0.7.16 → 0.8.0
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/bin/e2e-build +20 -0
- package/bin/e2e-cache-images +13 -6
- package/config/dns/example.net.zone +16 -0
- package/config/dns/iamtheinternet.org.zone +16 -0
- package/config/routing/fw-main-routes.sh +26 -9
- package/package.json +3 -2
- package/scripts/stage-apt-repo.ts +14 -2
- package/src/cli/build.ts +29 -0
- package/src/cli/command-registry.ts +10 -2
- package/src/cli/completion.ts +9 -2
- package/src/cli/index.ts +71 -5
- package/src/container-manager.ts +32 -1
- package/src/docker-compose-generator.ts +11 -0
- package/src/run-lock.test.ts +105 -0
- package/src/run-lock.ts +252 -0
- package/src/runner.ts +109 -18
- package/src/shared-infra.ts +69 -35
package/bin/e2e-build
CHANGED
|
@@ -22,6 +22,7 @@ NETAPPS_DIR="$E2E_DIR/netapps"
|
|
|
22
22
|
cd "$E2E_DIR"
|
|
23
23
|
|
|
24
24
|
GREEN='\033[0;32m'
|
|
25
|
+
YELLOW='\033[0;33m'
|
|
25
26
|
DIM='\033[2m'
|
|
26
27
|
BOLD='\033[1m'
|
|
27
28
|
NC='\033[0m'
|
|
@@ -160,6 +161,25 @@ BAKE_END=$(date +%s)
|
|
|
160
161
|
echo -e "${GREEN}Baked in $((BAKE_END - BAKE_START))s${NC}"
|
|
161
162
|
echo ""
|
|
162
163
|
|
|
164
|
+
# ── Step 2d: Pre-seed heavy app images into the app-zone preload cache ─────────
|
|
165
|
+
#
|
|
166
|
+
# Heavy application images (authentik server, postgres, redis) are pulled ONCE
|
|
167
|
+
# on the host here and saved to docker-image-cache/, which app-zone target
|
|
168
|
+
# machines bind-mount and load at startup via docker-image-preload.service.
|
|
169
|
+
# Without this the cache is empty on a fresh checkout / the builder, so every
|
|
170
|
+
# app-module deploy pulls GB through the simulated internet — slow enough to
|
|
171
|
+
# time the authentik deploy out (exit 124) and take down forgejo-deploy +
|
|
172
|
+
# full-stack-pipeline (ISS-0154). Best-effort: e2e-cache-images is per-image
|
|
173
|
+
# resilient and the preload service skips a missing tarball, so a transient
|
|
174
|
+
# registry hiccup degrades to a slow pull rather than failing build-infra.
|
|
175
|
+
echo -e "${BOLD}Pre-seeding app-zone image cache...${NC}"
|
|
176
|
+
if bash "$E2E_DIR/bin/e2e-cache-images"; then
|
|
177
|
+
:
|
|
178
|
+
else
|
|
179
|
+
echo -e "${YELLOW}⚠ image cache step failed — app-zone deploys may pull over the sim${NC}" >&2
|
|
180
|
+
fi
|
|
181
|
+
echo ""
|
|
182
|
+
|
|
163
183
|
# ── Step 3: Optionally save images to tarball ─────────────────────────────────
|
|
164
184
|
|
|
165
185
|
if [ "$1" = "--save" ]; then
|
package/bin/e2e-cache-images
CHANGED
|
@@ -23,6 +23,7 @@ mkdir -p "$CACHE_DIR"
|
|
|
23
23
|
|
|
24
24
|
BOLD='\033[1m'
|
|
25
25
|
GREEN='\033[0;32m'
|
|
26
|
+
YELLOW='\033[0;33m'
|
|
26
27
|
DIM='\033[2m'
|
|
27
28
|
NC='\033[0m'
|
|
28
29
|
|
|
@@ -44,12 +45,18 @@ for filename in "${!IMAGES[@]}"; do
|
|
|
44
45
|
printf " %-50s " "$image"
|
|
45
46
|
START=$(date +%s)
|
|
46
47
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
48
|
+
# Per-image best-effort: a transient registry failure for one image must not
|
|
49
|
+
# abort caching the others (this runs inside build-infra now). A guarded
|
|
50
|
+
# command does not trip `set -e`. The preload service skips a missing tarball
|
|
51
|
+
# gracefully — that module just pulls over the sim at deploy time.
|
|
52
|
+
if docker pull "$image" > /dev/null 2>&1 && docker save "$image" -o "$tarball"; then
|
|
53
|
+
END=$(date +%s)
|
|
54
|
+
SIZE=$(du -h "$tarball" | cut -f1)
|
|
55
|
+
echo -e "${GREEN}✔${NC} ${DIM}$((END - START))s ${SIZE}${NC}"
|
|
56
|
+
else
|
|
57
|
+
rm -f "$tarball"
|
|
58
|
+
echo -e "${YELLOW}✘ failed — will pull over the sim at deploy time${NC}"
|
|
59
|
+
fi
|
|
53
60
|
done
|
|
54
61
|
|
|
55
62
|
echo ""
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
$ORIGIN example.net.
|
|
2
|
+
$TTL 300
|
|
3
|
+
|
|
4
|
+
@ IN SOA ns1.example.net. admin.example.net. (
|
|
5
|
+
2024020108 300 60 604800 300
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
; Public DNS — RFC 1918 addresses NEVER appear here. The whole point of
|
|
9
|
+
; the e2e simulation is to model real internet connectivity, where
|
|
10
|
+
; private IPs are unreachable from outside. All public-facing names
|
|
11
|
+
; resolve to the firewall's external IP (100.100.0.100), which DNATs
|
|
12
|
+
; inbound to the right internal host.
|
|
13
|
+
@ IN NS ns1.example.net.
|
|
14
|
+
ns1 IN A 100.64.0.55
|
|
15
|
+
@ IN A 100.100.0.100
|
|
16
|
+
www IN A 100.100.0.100
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
$ORIGIN iamtheinternet.org.
|
|
2
|
+
$TTL 300
|
|
3
|
+
|
|
4
|
+
@ IN SOA ns1.iamtheinternet.org. admin.iamtheinternet.org. (
|
|
5
|
+
2024020247 300 60 604800 300
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
; Public DNS — RFC 1918 addresses NEVER appear here. The whole point of
|
|
9
|
+
; the e2e simulation is to model real internet connectivity, where
|
|
10
|
+
; private IPs are unreachable from outside. All public-facing names
|
|
11
|
+
; resolve to the firewall's external IP (100.100.0.100), which DNATs
|
|
12
|
+
; inbound to the right internal host.
|
|
13
|
+
@ IN NS ns1.iamtheinternet.org.
|
|
14
|
+
ns1 IN A 100.64.0.55
|
|
15
|
+
@ IN A 0.0.0.0
|
|
16
|
+
www IN A 100.100.0.100
|
|
@@ -8,22 +8,39 @@ chmod 700 /root/.ssh
|
|
|
8
8
|
chmod 600 /root/.ssh/authorized_keys
|
|
9
9
|
/usr/sbin/sshd
|
|
10
10
|
|
|
11
|
-
#
|
|
11
|
+
# IP forwarding is set via sysctls in docker-compose
|
|
12
|
+
iptables -P FORWARD ACCEPT
|
|
13
|
+
|
|
14
|
+
# Routing + egress NAT — depends on topology (detected by the presence of an
|
|
15
|
+
# isp-external interface).
|
|
12
16
|
ip route del default 2>/dev/null || true
|
|
13
17
|
if ip -o addr show | grep -q '100.100.0'; then
|
|
14
|
-
# Direct
|
|
18
|
+
# Direct-internet: fw-main has its own external (WAN) interface.
|
|
15
19
|
ip route add default via 100.100.0.101
|
|
20
|
+
# Egress NAT SCOPED to the external/WAN interface (the one with the public
|
|
21
|
+
# 100.100.0.x address) — NOT a blanket MASQUERADE (ISS-0156). Scoping to the WAN
|
|
22
|
+
# means inter-zone traffic (protected↔protected, protected↔internal) keeps its
|
|
23
|
+
# source, so the dmz-resident resolver sees each client's real zone for
|
|
24
|
+
# source-based DNS views. This boot-time rule is what target machines need for
|
|
25
|
+
# egress/DNS BEFORE any module deploys; celilo's iptables module applies the
|
|
26
|
+
# same WAN-scoped MASQUERADE on deploy (idempotent — the production code path).
|
|
27
|
+
EXT_IFACE=$(ip -o addr show | awk '/100\.100\.0\./{print $2; exit}')
|
|
28
|
+
iptables -t nat -A POSTROUTING -o "$EXT_IFACE" -j MASQUERADE
|
|
16
29
|
else
|
|
17
|
-
#
|
|
30
|
+
# Two-layer: no external interface on fw-main; it routes outbound to the
|
|
31
|
+
# upstream firewall (fw-isp) and MASQUERADEs toward it (egress leaves via the
|
|
32
|
+
# internal-facing interface, so there is no single WAN interface to scope to).
|
|
18
33
|
ip route add default via 192.168.0.1
|
|
34
|
+
# Protected↔protected (dmz/app/secure ↔ each other, all in 10/8) is NOT NAT'd —
|
|
35
|
+
# a real firewall routes between its segmented zones without NAT, preserving the
|
|
36
|
+
# client source so the dmz-resident resolver can serve source-based split-horizon
|
|
37
|
+
# (ISS-0156). Only protected↔internal and egress get MASQUERADE'd below. Without
|
|
38
|
+
# this RETURN, app→dmz and internal→dmz would both be SNAT'd to the dmz gateway
|
|
39
|
+
# and become indistinguishable (confirmed via live probe).
|
|
40
|
+
iptables -t nat -A POSTROUTING -s 10.0.0.0/8 -d 10.0.0.0/8 -j RETURN
|
|
41
|
+
iptables -t nat -A POSTROUTING -j MASQUERADE
|
|
19
42
|
fi
|
|
20
43
|
|
|
21
|
-
# IP forwarding is set via sysctls in docker-compose
|
|
22
|
-
|
|
23
|
-
# NAT all outbound traffic from dmz/app/secure
|
|
24
|
-
iptables -t nat -A POSTROUTING -j MASQUERADE
|
|
25
|
-
iptables -P FORWARD ACCEPT
|
|
26
|
-
|
|
27
44
|
# DNS
|
|
28
45
|
echo "nameserver 100.100.0.1" > /etc/resolv.conf
|
|
29
46
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/e2e",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "E2E test infrastructure for Celilo-deployed applications. Provides a simulated internet with DNS hierarchy, ACME server, firewalls, and target machines in Docker.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|
|
21
21
|
"test": "bun test tests/",
|
|
22
|
-
"test:completion": "bun test tests/completion"
|
|
22
|
+
"test:completion": "bun test tests/completion",
|
|
23
|
+
"test:integration": "bun test tests-integration/"
|
|
23
24
|
},
|
|
24
25
|
"files": [
|
|
25
26
|
"src/",
|
|
@@ -65,11 +65,24 @@ export function stageAptRepo(repoRoot: string, pkgDir: string): boolean {
|
|
|
65
65
|
process.stdout.write(` ${'apt-repo (build debs)'.padEnd(28)} `);
|
|
66
66
|
const t0 = Date.now();
|
|
67
67
|
|
|
68
|
+
// The deb must install the EXACT version the npm-registry-sim serves locally
|
|
69
|
+
// (= apps/celilo/package.json). Pin CELILO_VERSION so the postinst runs
|
|
70
|
+
// `bun add @celilo/cli@<version>` rather than `@celilo/cli@alpha` (build-deb.sh's
|
|
71
|
+
// default): the sim has no local `alpha` dist-tag, so an `@alpha` install leaks
|
|
72
|
+
// to the real npm uplink and pulls a stale published alpha (ISS-0153). Pinning
|
|
73
|
+
// makes the deb version, the installed npm version, and the test expectation a
|
|
74
|
+
// single source of truth.
|
|
75
|
+
const version = currentCeliloVersion(repoRoot);
|
|
76
|
+
|
|
68
77
|
// Reuse the canonical build-deb scripts so versioning/contents match a
|
|
69
78
|
// real release exactly. build:deb builds both arches; build:deb:bootstrap
|
|
70
79
|
// builds the arch-all meta-package.
|
|
71
80
|
for (const script of ['build:deb', 'build:deb:bootstrap']) {
|
|
72
|
-
const result = spawnSync('bun', ['run', script], {
|
|
81
|
+
const result = spawnSync('bun', ['run', script], {
|
|
82
|
+
cwd: repoRoot,
|
|
83
|
+
stdio: 'pipe',
|
|
84
|
+
env: { ...process.env, CELILO_VERSION: version },
|
|
85
|
+
});
|
|
73
86
|
if (result.status !== 0) {
|
|
74
87
|
console.log(`${red}✗${reset}`);
|
|
75
88
|
console.error(result.stderr?.toString());
|
|
@@ -81,7 +94,6 @@ export function stageAptRepo(repoRoot: string, pkgDir: string): boolean {
|
|
|
81
94
|
// Stage every freshly-built .deb into the pool. dist/ may also hold older
|
|
82
95
|
// versions; copy only the current ones so the repo doesn't advertise
|
|
83
96
|
// stale celilo versions the npm-registry-sim no longer serves.
|
|
84
|
-
const version = currentCeliloVersion(repoRoot);
|
|
85
97
|
const distDir = join(repoRoot, 'dist');
|
|
86
98
|
|
|
87
99
|
const debs = readdirSync(distDir).filter((f) => f.endsWith('.deb') && f.includes(version));
|
package/src/cli/build.ts
CHANGED
|
@@ -48,6 +48,7 @@ const bold = '\x1b[1m';
|
|
|
48
48
|
const dim = '\x1b[2m';
|
|
49
49
|
const green = '\x1b[32m';
|
|
50
50
|
const red = '\x1b[31m';
|
|
51
|
+
const yellow = '\x1b[33m';
|
|
51
52
|
const reset = '\x1b[0m';
|
|
52
53
|
|
|
53
54
|
interface BuildOptions {
|
|
@@ -320,6 +321,30 @@ function bakeManagement(pkgDir: string): void {
|
|
|
320
321
|
console.log(`\n${green}Baked in ${elapsed}s${reset}\n`);
|
|
321
322
|
}
|
|
322
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Pre-seed heavy application images (authentik server, postgres, redis) into
|
|
326
|
+
* the app-zone preload cache. Without this the `docker-image-cache` is empty on
|
|
327
|
+
* a fresh checkout / the builder, so every app-module deploy pulls GB through
|
|
328
|
+
* the simulated internet — slow enough to time the authentik deploy out
|
|
329
|
+
* (exit 124) and take down forgejo-deploy + full-stack-pipeline (ISS-0154).
|
|
330
|
+
* Best-effort: `e2e-cache-images` is per-image resilient and the preload
|
|
331
|
+
* service skips a missing tarball, so a transient registry hiccup degrades to a
|
|
332
|
+
* slow pull rather than failing build-infra.
|
|
333
|
+
*/
|
|
334
|
+
function cacheAppImages(pkgDir: string): void {
|
|
335
|
+
const cacheScript = join(pkgDir, 'bin', 'e2e-cache-images');
|
|
336
|
+
if (!existsSync(cacheScript)) return;
|
|
337
|
+
|
|
338
|
+
console.log(`${bold}Pre-seeding app-zone image cache...${reset}\n`);
|
|
339
|
+
const result = spawnSync('bash', [cacheScript], { stdio: 'inherit' });
|
|
340
|
+
if (result.status !== 0) {
|
|
341
|
+
console.error(
|
|
342
|
+
`${yellow}⚠ image cache step failed — app-zone deploys may pull over the sim${reset}`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
console.log('');
|
|
346
|
+
}
|
|
347
|
+
|
|
323
348
|
function saveImages(pkgDir: string): void {
|
|
324
349
|
const cacheDir = join(pkgDir, '.docker-cache');
|
|
325
350
|
mkdirSync(cacheDir, { recursive: true });
|
|
@@ -406,6 +431,10 @@ export function runBuild(options: BuildOptions): void {
|
|
|
406
431
|
// install.sh, rather than a parallel `bun add -g` pinning.
|
|
407
432
|
bakeManagement(pkgDir);
|
|
408
433
|
|
|
434
|
+
// Pre-seed heavy app images so app-zone deploys load them from the cache
|
|
435
|
+
// instead of pulling GB through the sim (ISS-0154).
|
|
436
|
+
cacheAppImages(pkgDir);
|
|
437
|
+
|
|
409
438
|
if (save) {
|
|
410
439
|
saveImages(pkgDir);
|
|
411
440
|
}
|
|
@@ -26,11 +26,14 @@ export const COMMANDS: CommandDef[] = [
|
|
|
26
26
|
description: 'Run e2e tests — pass a module path or pattern, or --all / --all-modules',
|
|
27
27
|
flags: [
|
|
28
28
|
{ name: '--all', description: 'Run the full regression: every module suite + top-level e2e/tests/' },
|
|
29
|
+
{ name: '--complete', description: 'Alias of --all: run every test, including ci-unsafe (quarantined) ones' },
|
|
30
|
+
{ name: '--ci-safe', description: 'Full regression EXCEPT tests marked cele2e-ci-unsafe (used by the nightly)' },
|
|
29
31
|
{ name: '--all-modules', description: 'Run e2e tests for all modules with e2e/ directories' },
|
|
30
32
|
{ name: '--keep', description: 'Keep network running after tests' },
|
|
31
33
|
{ name: '--reuse', description: 'Reuse existing network if running' },
|
|
32
34
|
{ name: '--live', description: 'Use live (non-simulated) internet' },
|
|
33
35
|
{ name: '--published', description: 'Use published .netapp packages' },
|
|
36
|
+
{ name: '--notify', description: 'Desktop notification when the run finishes' },
|
|
34
37
|
],
|
|
35
38
|
},
|
|
36
39
|
{
|
|
@@ -73,7 +76,12 @@ export const COMMANDS: CommandDef[] = [
|
|
|
73
76
|
},
|
|
74
77
|
{
|
|
75
78
|
name: 'status',
|
|
76
|
-
description: 'Show network status
|
|
79
|
+
description: 'Show run-lock holder + network status (exit 3 if busy)',
|
|
80
|
+
flags: [{ name: '--json', description: 'Emit a machine-readable lock snapshot for polling' }],
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: 'release',
|
|
84
|
+
description: 'Free a run-lock left by --keep/up (does not tear down)',
|
|
77
85
|
},
|
|
78
86
|
{
|
|
79
87
|
name: 'load',
|
|
@@ -99,7 +107,7 @@ export const COMMANDS: CommandDef[] = [
|
|
|
99
107
|
|
|
100
108
|
export const UP_PRESETS = ['--caddy', '--full-stack', '--infrastructure'];
|
|
101
109
|
|
|
102
|
-
export const RUN_FLAGS = ['--all', '--all-modules', '--keep', '--reuse', '--live', '--published'];
|
|
110
|
+
export const RUN_FLAGS = ['--all', '--complete', '--ci-safe', '--all-modules', '--keep', '--reuse', '--live', '--published', '--notify'];
|
|
103
111
|
|
|
104
112
|
export const DOWN_FLAGS = ['--keep', '--all'];
|
|
105
113
|
|
package/src/cli/completion.ts
CHANGED
|
@@ -119,6 +119,9 @@ _cele2e() {
|
|
|
119
119
|
'--reuse[Reuse existing network if running]' \\
|
|
120
120
|
'--live[Use live (non-simulated) internet]' \\
|
|
121
121
|
'--published[Use published .netapp packages]' \\
|
|
122
|
+
'--notify[Desktop notification when the run finishes]' \\
|
|
123
|
+
'--ci[Plain log output: one line per step, no spinner]' \\
|
|
124
|
+
'--no-ci[Force spinner even when a CI env var is set]' \\
|
|
122
125
|
'*::test name or module path:_cele2e_run_args'
|
|
123
126
|
;;
|
|
124
127
|
list)
|
|
@@ -146,7 +149,10 @@ _cele2e() {
|
|
|
146
149
|
completion)
|
|
147
150
|
_arguments '1: :_cele2e_completion_shells'
|
|
148
151
|
;;
|
|
149
|
-
status
|
|
152
|
+
status)
|
|
153
|
+
_arguments '--json[Emit a machine-readable lock snapshot for polling]'
|
|
154
|
+
;;
|
|
155
|
+
release|load|clear-timing)
|
|
150
156
|
;;
|
|
151
157
|
esac
|
|
152
158
|
;;
|
|
@@ -162,7 +168,8 @@ _cele2e_commands() {
|
|
|
162
168
|
'build-infra:Rebuild @celilo/e2e Docker images and standard module .netapps'
|
|
163
169
|
'clear-timing:Clear saved timing history for test ETAs'
|
|
164
170
|
'shell:Shell into a running container (default: management)'
|
|
165
|
-
'status:Show network status
|
|
171
|
+
'status:Show run-lock holder + network status (exit 3 if busy)'
|
|
172
|
+
'release:Free a run-lock left by --keep/up (does not tear down)'
|
|
166
173
|
'load:Load cached Docker images from tarball'
|
|
167
174
|
'scaffold:Generate a new test file from template'
|
|
168
175
|
'version:Show cele2e version'
|
package/src/cli/index.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { runBuild } from './build';
|
|
|
23
23
|
import { runScaffold } from './scaffold';
|
|
24
24
|
import { generateBashCompletion, generateZshCompletion, getCompletions } from './completion';
|
|
25
25
|
import { findAllModules, findModulesWithMatchingTest } from './module-discovery';
|
|
26
|
+
import { acquireRunLock, clearLock, E2eBusyError, formatBusy, lockStatus, markKept } from '../run-lock';
|
|
26
27
|
|
|
27
28
|
const PKG_DIR = resolve(import.meta.dir, '../..');
|
|
28
29
|
const BIN_DIR = join(PKG_DIR, 'bin');
|
|
@@ -127,7 +128,8 @@ Commands:
|
|
|
127
128
|
clear-timing Clear saved timing history used for test ETAs
|
|
128
129
|
version Show cele2e version
|
|
129
130
|
shell [container] Shell into a running container (default: management)
|
|
130
|
-
status Show network status
|
|
131
|
+
status Show run-lock holder + network status (exit 3 if busy)
|
|
132
|
+
release Free a run-lock left by --keep/up (does not tear down)
|
|
131
133
|
load Load cached Docker images from tarball
|
|
132
134
|
scaffold <name> Generate a new test file from template
|
|
133
135
|
completion <shell> Generate shell completion script (zsh|bash)
|
|
@@ -139,6 +141,9 @@ Options for \`run\`:
|
|
|
139
141
|
--reuse Reuse existing network if running
|
|
140
142
|
--live Use live (non-simulated) internet
|
|
141
143
|
--published Use published .netapp packages
|
|
144
|
+
--notify Desktop notification when the run finishes (best-effort)
|
|
145
|
+
--ci Plain log output: one ✔/✗ line per step, no spinner
|
|
146
|
+
(auto-on when a CI env var is set; --no-ci forces off)
|
|
142
147
|
|
|
143
148
|
Options for \`up\`:
|
|
144
149
|
--caddy Start with caddy machine in DMZ
|
|
@@ -187,8 +192,17 @@ switch (command) {
|
|
|
187
192
|
// repo's top-level e2e/tests/ in one invocation; `--all-modules` runs just
|
|
188
193
|
// the module suites. Both resolve the repo root via resolveSuiteRoot() so
|
|
189
194
|
// they work through the `./cele2e` wrapper's cd into packages/e2e.
|
|
190
|
-
if (
|
|
191
|
-
|
|
195
|
+
if (
|
|
196
|
+
args.includes('--all') ||
|
|
197
|
+
args.includes('--all-modules') ||
|
|
198
|
+
args.includes('--complete') ||
|
|
199
|
+
args.includes('--ci-safe')
|
|
200
|
+
) {
|
|
201
|
+
// --complete and --ci-safe both run the full discovery (every module +
|
|
202
|
+
// the repo's top-level e2e/tests/). --ci-safe additionally has the runner
|
|
203
|
+
// skip tests marked `cele2e-ci-unsafe`. --all-modules stays modules-only.
|
|
204
|
+
const wantTopLevel =
|
|
205
|
+
args.includes('--all') || args.includes('--complete') || args.includes('--ci-safe');
|
|
192
206
|
const suiteRoot = resolveSuiteRoot();
|
|
193
207
|
const allModules = findAllModules(suiteRoot);
|
|
194
208
|
const env: Record<string, string> = {
|
|
@@ -206,7 +220,9 @@ switch (command) {
|
|
|
206
220
|
}
|
|
207
221
|
runScript(
|
|
208
222
|
'e2e-run',
|
|
209
|
-
|
|
223
|
+
// Strip the discovery aliases; KEEP --ci-safe so the runner applies the
|
|
224
|
+
// `cele2e-ci-unsafe` quarantine exclusion.
|
|
225
|
+
args.filter((a) => a !== '--all' && a !== '--all-modules' && a !== '--complete'),
|
|
210
226
|
env,
|
|
211
227
|
);
|
|
212
228
|
}
|
|
@@ -284,16 +300,55 @@ switch (command) {
|
|
|
284
300
|
process.exit(0);
|
|
285
301
|
}
|
|
286
302
|
|
|
287
|
-
case 'up':
|
|
303
|
+
case 'up': {
|
|
304
|
+
// `up` leaves an interactive network running, so it holds the run-lock in a
|
|
305
|
+
// kept state (cleared by `cele2e down`/`release`) to stop a concurrent run
|
|
306
|
+
// from wiping it.
|
|
307
|
+
try {
|
|
308
|
+
acquireRunLock({ test: 'up', runId: process.env.CELE2E_RUN_ID ?? crypto.randomUUID() });
|
|
309
|
+
markKept();
|
|
310
|
+
} catch (err) {
|
|
311
|
+
if (err instanceof E2eBusyError) {
|
|
312
|
+
console.error(err.message);
|
|
313
|
+
process.exit(3);
|
|
314
|
+
}
|
|
315
|
+
throw err;
|
|
316
|
+
}
|
|
288
317
|
runScript('e2e-up', args);
|
|
318
|
+
}
|
|
289
319
|
|
|
290
320
|
case 'down':
|
|
321
|
+
// Tearing the network down frees the (possibly kept) lock.
|
|
322
|
+
clearLock();
|
|
291
323
|
runScript('e2e-down', args, { E2E_TEST_DIR: stateDir });
|
|
292
324
|
|
|
293
325
|
case 'shell':
|
|
294
326
|
runScript('e2e-shell', args);
|
|
295
327
|
|
|
328
|
+
case 'release': {
|
|
329
|
+
// Free a lock left by `--keep`/`up` (a `kept` stack) so the next run can
|
|
330
|
+
// proceed. Does not tear the stack down — use `cele2e down` for that.
|
|
331
|
+
const had = clearLock();
|
|
332
|
+
console.log(had ? 'Released the e2e run-lock.' : 'No e2e run-lock held.');
|
|
333
|
+
process.exit(0);
|
|
334
|
+
}
|
|
335
|
+
|
|
296
336
|
case 'status': {
|
|
337
|
+
// Run-lock is the contention surface other sessions poll. Report it first;
|
|
338
|
+
// exit 0 = free, 3 = busy. `--json` emits a machine-readable snapshot.
|
|
339
|
+
const lock = lockStatus();
|
|
340
|
+
if (args.includes('--json')) {
|
|
341
|
+
console.log(JSON.stringify(lock));
|
|
342
|
+
process.exit(lock.free ? 0 : 3);
|
|
343
|
+
}
|
|
344
|
+
console.log('=== Run Lock ===');
|
|
345
|
+
if (lock.free) {
|
|
346
|
+
console.log(' free');
|
|
347
|
+
} else if (lock.holder) {
|
|
348
|
+
console.log(` BUSY — ${formatBusy(lock.holder)}`);
|
|
349
|
+
}
|
|
350
|
+
console.log('');
|
|
351
|
+
|
|
297
352
|
const persistentFile = join(stateDir, '.e2e-persistent.json');
|
|
298
353
|
let projectName: string | undefined;
|
|
299
354
|
if (existsSync(persistentFile)) {
|
|
@@ -319,6 +374,17 @@ switch (command) {
|
|
|
319
374
|
const save = args.includes('--save');
|
|
320
375
|
const skipModules = args.includes('--skip-modules');
|
|
321
376
|
const moduleDirs = args.filter((a) => !a.startsWith('--'));
|
|
377
|
+
// build-infra mutates the shared Docker images — serialize against runs.
|
|
378
|
+
// Released via the lock's exit handler (runBuild may process.exit on failure).
|
|
379
|
+
try {
|
|
380
|
+
acquireRunLock({ test: 'build-infra', runId: process.env.CELE2E_RUN_ID ?? crypto.randomUUID() });
|
|
381
|
+
} catch (err) {
|
|
382
|
+
if (err instanceof E2eBusyError) {
|
|
383
|
+
console.error(err.message);
|
|
384
|
+
process.exit(3);
|
|
385
|
+
}
|
|
386
|
+
throw err;
|
|
387
|
+
}
|
|
322
388
|
runBuild({ pkgDir: PKG_DIR, moduleDirs, save, skipModules });
|
|
323
389
|
break;
|
|
324
390
|
}
|
package/src/container-manager.ts
CHANGED
|
@@ -434,7 +434,17 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
|
|
|
434
434
|
// until its module deploys, so this wait would always time out. Management
|
|
435
435
|
// can still resolve names via the fallback `nameserver 100.100.0.1` in
|
|
436
436
|
// its resolv.conf during deploy.
|
|
437
|
-
|
|
437
|
+
//
|
|
438
|
+
// The resolver-under-test may sit in ANY zone, not just `internal`: ISS-0156
|
|
439
|
+
// places the dns_internal provider in a PROTECTED zone (dmz) so it can see
|
|
440
|
+
// protected-zone query sources for split-horizon views. So check every zone's
|
|
441
|
+
// machines for a `dns-int`, not only internalMachines.
|
|
442
|
+
const dnsIntReplaced = [
|
|
443
|
+
...config.internalMachines,
|
|
444
|
+
...config.dmzMachines,
|
|
445
|
+
...config.appMachines,
|
|
446
|
+
...config.secureMachines,
|
|
447
|
+
].some((m) => m.name === 'dns-int');
|
|
438
448
|
if (!dnsIntReplaced) {
|
|
439
449
|
console.log('[progress:start] waiting for internal resolver | internal resolver ready');
|
|
440
450
|
await waitFor(
|
|
@@ -682,6 +692,27 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
|
|
|
682
692
|
gateway: ZONE_GATEWAYS[zone],
|
|
683
693
|
}));
|
|
684
694
|
|
|
695
|
+
// fw-main is a firewall container, not a target machine, so the
|
|
696
|
+
// network readiness wait (target-setup) does NOT cover its sshd. Poll
|
|
697
|
+
// until the firewall accepts SSH before `machine add` — otherwise a
|
|
698
|
+
// transient first-connect times out (spawnSync ETIMEDOUT) and aborts the
|
|
699
|
+
// whole deploy, cascading into unrelated "module not found" failures
|
|
700
|
+
// (#222). This is a readiness wait on the real prerequisite, not a sleep.
|
|
701
|
+
await waitFor(
|
|
702
|
+
async () => {
|
|
703
|
+
const probe = dockerExec(
|
|
704
|
+
projectName,
|
|
705
|
+
composeDir,
|
|
706
|
+
'management',
|
|
707
|
+
`ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=5 -i /root/.ssh/id_ed25519 root@${firewallIp} hostname`,
|
|
708
|
+
15_000,
|
|
709
|
+
);
|
|
710
|
+
return probe.exitCode === 0;
|
|
711
|
+
},
|
|
712
|
+
60_000,
|
|
713
|
+
`firewall ${firewallIp} sshd`,
|
|
714
|
+
);
|
|
715
|
+
|
|
685
716
|
// fw-main is registered as an internal-zone machine; iptables
|
|
686
717
|
// deploys to it and (Phase 2) writes the provided zones to system
|
|
687
718
|
// config from its on_install hook.
|
|
@@ -396,6 +396,17 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
|
|
|
396
396
|
variant === 'vanilla' ? 'celilo-e2e/management:vanilla' : 'celilo-e2e/management:latest',
|
|
397
397
|
networks: { internal: { ipv4_address: ip } },
|
|
398
398
|
cap_add: ['NET_ADMIN'],
|
|
399
|
+
// ISS-0157: `module import`'s `bun install` hard-links packages from the
|
|
400
|
+
// bun cache into node_modules, which on overlayfs forces a copy-up + fsync
|
|
401
|
+
// per file. On a slow/contended builder disk that storm blows past the
|
|
402
|
+
// 120s import timeout (intermittently). Put the celilo data dir (the
|
|
403
|
+
// node_modules install target) on tmpfs (RAM): bun then copies cache→tmpfs
|
|
404
|
+
// (cross-fs, no overlay copy-up) at RAM speed, immune to host disk. NOT
|
|
405
|
+
// /root/.bun itself — that holds the baked `celilo` CLI (`bun add -g`).
|
|
406
|
+
// Tmpfs the bun *cache* subdir too: the copy-up + fsync storm is the
|
|
407
|
+
// overlay upper layer under /root/.bun/install/cache. RAM-backing it (cold
|
|
408
|
+
// → re-fetch over the fast sim/net) keeps the CLI intact. Ephemeral e2e.
|
|
409
|
+
tmpfs: ['/root/.local/share/celilo', '/root/.bun/install/cache'],
|
|
399
410
|
volumes: [
|
|
400
411
|
...(celiloRoot ? [`${celiloRoot}:/celilo`] : []),
|
|
401
412
|
'ssh-keys:/ssh-keys',
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test } from 'bun:test';
|
|
2
|
+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { hostname, tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import {
|
|
6
|
+
acquireRunLock,
|
|
7
|
+
clearLock,
|
|
8
|
+
E2eBusyError,
|
|
9
|
+
type LockHolder,
|
|
10
|
+
lockStatus,
|
|
11
|
+
markKept,
|
|
12
|
+
readHolder,
|
|
13
|
+
releaseRunLock,
|
|
14
|
+
} from './run-lock';
|
|
15
|
+
|
|
16
|
+
// Isolate every test on its own lock file (machine-global path is overridable).
|
|
17
|
+
let dir: string;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
dir = mkdtempSync(join(tmpdir(), 'e2e-lock-'));
|
|
21
|
+
process.env.CELILO_E2E_LOCK_PATH = join(dir, 'run.lock');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
clearLock();
|
|
26
|
+
rmSync(dir, { recursive: true, force: true });
|
|
27
|
+
delete process.env.CELILO_E2E_LOCK_PATH;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function writeRawHolder(h: Partial<LockHolder>): void {
|
|
31
|
+
const full: LockHolder = {
|
|
32
|
+
pid: 123,
|
|
33
|
+
hostname: hostname(),
|
|
34
|
+
session: 'other',
|
|
35
|
+
test: 'foo',
|
|
36
|
+
runId: 'r',
|
|
37
|
+
startedAt: new Date().toISOString(),
|
|
38
|
+
beatAt: Date.now(),
|
|
39
|
+
state: 'running',
|
|
40
|
+
...h,
|
|
41
|
+
};
|
|
42
|
+
writeFileSync(process.env.CELILO_E2E_LOCK_PATH as string, JSON.stringify(full));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test('acquire writes a holder for this process; release removes it', () => {
|
|
46
|
+
acquireRunLock({ test: 'mytest', runId: 'run-1' });
|
|
47
|
+
const h = readHolder();
|
|
48
|
+
expect(h?.pid).toBe(process.pid);
|
|
49
|
+
expect(h?.test).toBe('mytest');
|
|
50
|
+
expect(lockStatus().free).toBe(false);
|
|
51
|
+
|
|
52
|
+
releaseRunLock();
|
|
53
|
+
expect(readHolder()).toBeNull();
|
|
54
|
+
expect(lockStatus().free).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('a live holder makes a second acquire throw E2eBusyError', () => {
|
|
58
|
+
acquireRunLock({ test: 't1', runId: 'r1' });
|
|
59
|
+
// The on-disk holder is us (a live pid) → busy.
|
|
60
|
+
expect(() => acquireRunLock({ test: 't2', runId: 'r2' })).toThrow(E2eBusyError);
|
|
61
|
+
releaseRunLock();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('a dead-pid holder on the same host is stale and reclaimed', () => {
|
|
65
|
+
// 2e9 is far above any real pid → process.kill(pid, 0) throws ESRCH → dead.
|
|
66
|
+
writeRawHolder({ pid: 2_000_000_000 });
|
|
67
|
+
expect(lockStatus().free).toBe(true); // stale → reported free
|
|
68
|
+
|
|
69
|
+
acquireRunLock({ test: 'reclaimer', runId: 'r' }); // reclaims the stale lock
|
|
70
|
+
expect(readHolder()?.pid).toBe(process.pid);
|
|
71
|
+
releaseRunLock();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('--keep leaves a kept lock that survives release and blocks the next run', () => {
|
|
75
|
+
acquireRunLock({ test: 'kept-test', runId: 'r' });
|
|
76
|
+
markKept();
|
|
77
|
+
releaseRunLock();
|
|
78
|
+
|
|
79
|
+
const h = readHolder();
|
|
80
|
+
expect(h?.state).toBe('kept');
|
|
81
|
+
expect(lockStatus().free).toBe(false); // kept is never stale
|
|
82
|
+
|
|
83
|
+
// A plain run is refused...
|
|
84
|
+
expect(() => acquireRunLock({ test: 'next', runId: 'r2' })).toThrow(E2eBusyError);
|
|
85
|
+
// ...but a --reuse run (allowKept) takes it over.
|
|
86
|
+
acquireRunLock({ test: 'reuse', runId: 'r3', allowKept: true });
|
|
87
|
+
expect(readHolder()?.pid).toBe(process.pid);
|
|
88
|
+
releaseRunLock();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('clearLock frees a kept lock (the `cele2e release` path)', () => {
|
|
92
|
+
writeRawHolder({ state: 'kept', pid: 2_000_000_000 });
|
|
93
|
+
expect(lockStatus().free).toBe(false);
|
|
94
|
+
expect(clearLock()).toBe(true);
|
|
95
|
+
expect(existsSync(process.env.CELILO_E2E_LOCK_PATH as string)).toBe(false);
|
|
96
|
+
expect(lockStatus().free).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('a corrupt lock file is reclaimed, not fatal', () => {
|
|
100
|
+
writeFileSync(process.env.CELILO_E2E_LOCK_PATH as string, 'not json{');
|
|
101
|
+
expect(readHolder()).toBeNull();
|
|
102
|
+
acquireRunLock({ test: 'survivor', runId: 'r' });
|
|
103
|
+
expect(readHolder()?.pid).toBe(process.pid);
|
|
104
|
+
releaseRunLock();
|
|
105
|
+
});
|
package/src/run-lock.ts
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cele2e run-lock — cross-platform mutual exclusion for the shared e2e infra.
|
|
3
|
+
*
|
|
4
|
+
* cele2e is a single shared resource: the shared infra is one global compose
|
|
5
|
+
* project (celilo-e2e-shared) and start-of-run cleanup tears down every
|
|
6
|
+
* celilo-e2e-* container regardless of who started it. So two actors at once
|
|
7
|
+
* (two Claude sessions, or a session + an operator at the terminal) clobber
|
|
8
|
+
* each other. This lock serializes runs across processes — no external tools,
|
|
9
|
+
* identical on macOS and Linux.
|
|
10
|
+
*
|
|
11
|
+
* Design (Forgejo #243):
|
|
12
|
+
* - Acquire via fs.openSync(path, 'wx') — POSIX-atomic exclusive create,
|
|
13
|
+
* same behavior on mac+linux. The lockfile lives at a MACHINE-GLOBAL path
|
|
14
|
+
* (~/.cache/celilo-e2e/run.lock), NOT under any repo/worktree, because the
|
|
15
|
+
* Docker infra is global regardless of which checkout started it.
|
|
16
|
+
* - Fail fast on contention with a message naming the holder. Sessions poll
|
|
17
|
+
* `cele2e status` and decide for themselves whether to wait (no --wait).
|
|
18
|
+
* - Staleness: on the SAME host, PID-liveness is authoritative (process.kill
|
|
19
|
+
* (pid, 0)); a dead holder PID → reclaim. Cross-host we can't check the PID,
|
|
20
|
+
* so fall back to a heartbeat TTL (beatAt older than STALE_TTL_MS).
|
|
21
|
+
* - A `kept` lock (left by `--keep` / `up`) is NEVER auto-reclaimed — it
|
|
22
|
+
* guards a stack that outlives the process and is cleared only by
|
|
23
|
+
* `cele2e release` / `cele2e down`. This enforces the honor-system rule.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { execFileSync } from 'node:child_process';
|
|
27
|
+
import { mkdirSync, openSync, closeSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
28
|
+
import { homedir, hostname } from 'node:os';
|
|
29
|
+
import { basename, dirname, join } from 'node:path';
|
|
30
|
+
|
|
31
|
+
const STALE_TTL_MS = 90_000;
|
|
32
|
+
const HEARTBEAT_MS = 30_000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Machine-global lock path — NOT under any repo/worktree, because the Docker
|
|
36
|
+
* infra is global regardless of which checkout started it. Overridable via
|
|
37
|
+
* CELILO_E2E_LOCK_PATH (used by tests; also a handy operator escape hatch).
|
|
38
|
+
*/
|
|
39
|
+
export function lockPath(): string {
|
|
40
|
+
return process.env.CELILO_E2E_LOCK_PATH || join(homedir(), '.cache', 'celilo-e2e', 'run.lock');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface LockHolder {
|
|
44
|
+
pid: number;
|
|
45
|
+
hostname: string;
|
|
46
|
+
session: string;
|
|
47
|
+
test: string;
|
|
48
|
+
runId: string;
|
|
49
|
+
startedAt: string;
|
|
50
|
+
beatAt: number;
|
|
51
|
+
state: 'running' | 'kept';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface Held {
|
|
55
|
+
keepOnRelease: boolean;
|
|
56
|
+
heartbeat: ReturnType<typeof setInterval> | null;
|
|
57
|
+
released: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Set once this process owns the lock; null otherwise. */
|
|
61
|
+
let held: Held | null = null;
|
|
62
|
+
|
|
63
|
+
export class E2eBusyError extends Error {
|
|
64
|
+
constructor(public holder: LockHolder) {
|
|
65
|
+
super(formatBusy(holder));
|
|
66
|
+
this.name = 'E2eBusyError';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Human label for who's running, e.g. "e2e busy: <session> running <test>, started 3m ago (pid 1234)". */
|
|
71
|
+
export function formatBusy(h: LockHolder): string {
|
|
72
|
+
const age = ageString(Date.parse(h.startedAt));
|
|
73
|
+
const verb = h.state === 'kept' ? 'holds a kept stack from' : 'running';
|
|
74
|
+
const what = h.state === 'kept' ? `(run \`cele2e release\` to free it)` : `${h.test}, started ${age} ago (pid ${h.pid})`;
|
|
75
|
+
return `e2e busy: ${h.session} ${verb} ${what}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function ageString(since: number): string {
|
|
79
|
+
const s = Math.max(0, Math.floor((Date.now() - since) / 1000));
|
|
80
|
+
if (s < 60) return `${s}s`;
|
|
81
|
+
if (s < 3600) return `${Math.floor(s / 60)}m`;
|
|
82
|
+
return `${Math.floor(s / 3600)}h${Math.floor((s % 3600) / 60)}m`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Branch + worktree path so a holder maps back to a specific session/thread. */
|
|
86
|
+
function deriveSession(): string {
|
|
87
|
+
if (process.env.CELILO_E2E_SESSION) return process.env.CELILO_E2E_SESSION;
|
|
88
|
+
const cwd = process.cwd();
|
|
89
|
+
const git = (args: string[]): string => {
|
|
90
|
+
try {
|
|
91
|
+
return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
92
|
+
} catch {
|
|
93
|
+
return '';
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
const branch = git(['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
97
|
+
const top = git(['rev-parse', '--show-toplevel']);
|
|
98
|
+
const base = top ? basename(top) : basename(cwd);
|
|
99
|
+
if (branch && top) return `${branch} (${top})`;
|
|
100
|
+
return branch || base;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function pidAlive(pid: number): boolean {
|
|
104
|
+
try {
|
|
105
|
+
process.kill(pid, 0);
|
|
106
|
+
return true;
|
|
107
|
+
} catch (err) {
|
|
108
|
+
// ESRCH = no such process (dead). EPERM = alive but not ours (still alive).
|
|
109
|
+
return (err as NodeJS.ErrnoException).code === 'EPERM';
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function readHolder(): LockHolder | null {
|
|
114
|
+
try {
|
|
115
|
+
return JSON.parse(readFileSync(lockPath(), 'utf-8')) as LockHolder;
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Is the on-disk holder stale (safe to reclaim)? A `kept` lock is never stale —
|
|
123
|
+
* it deliberately outlives its process. On the same host the PID is the source
|
|
124
|
+
* of truth; cross-host we can only use the heartbeat TTL.
|
|
125
|
+
*/
|
|
126
|
+
function isStale(h: LockHolder): boolean {
|
|
127
|
+
if (h.state === 'kept') return false;
|
|
128
|
+
if (h.hostname === hostname()) return !pidAlive(h.pid);
|
|
129
|
+
return Date.now() - h.beatAt > STALE_TTL_MS;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function writeHolder(fd: number, h: LockHolder): void {
|
|
133
|
+
writeFileSync(fd, JSON.stringify(h, null, 2));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Acquire the run lock for this process. Throws E2eBusyError if another live
|
|
138
|
+
* (non-stale) holder owns it. Registers an exit handler so the lock is released
|
|
139
|
+
* on any process.exit() path (normal, exception, or a SIGINT handler that
|
|
140
|
+
* exits). A signal-killed process with no exit handler leaks the lock, but the
|
|
141
|
+
* next run reclaims it via PID-liveness — that's exactly what staleness is for.
|
|
142
|
+
*/
|
|
143
|
+
export function acquireRunLock(opts: { test: string; runId: string; allowKept?: boolean }): void {
|
|
144
|
+
mkdirSync(dirname(lockPath()), { recursive: true });
|
|
145
|
+
|
|
146
|
+
const holder: LockHolder = {
|
|
147
|
+
pid: process.pid,
|
|
148
|
+
hostname: hostname(),
|
|
149
|
+
session: deriveSession(),
|
|
150
|
+
test: opts.test,
|
|
151
|
+
runId: opts.runId,
|
|
152
|
+
startedAt: new Date().toISOString(),
|
|
153
|
+
beatAt: Date.now(),
|
|
154
|
+
state: 'running',
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
158
|
+
let fd: number;
|
|
159
|
+
try {
|
|
160
|
+
fd = openSync(lockPath(), 'wx');
|
|
161
|
+
} catch (err) {
|
|
162
|
+
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err;
|
|
163
|
+
const existing = readHolder();
|
|
164
|
+
// Reclaim when: corrupt/unreadable, the holder is stale, OR this is a
|
|
165
|
+
// `--reuse` run taking over a `kept` stack it's deliberately reusing.
|
|
166
|
+
if (!existing || isStale(existing) || (opts.allowKept && existing.state === 'kept')) {
|
|
167
|
+
try { unlinkSync(lockPath()); } catch {}
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
throw new E2eBusyError(existing);
|
|
171
|
+
}
|
|
172
|
+
writeHolder(fd, holder);
|
|
173
|
+
closeSync(fd);
|
|
174
|
+
|
|
175
|
+
const heartbeat = setInterval(() => {
|
|
176
|
+
// Only refresh while running; a kept lock's beatAt is frozen by design.
|
|
177
|
+
if (!held || held.released || held.keepOnRelease) return;
|
|
178
|
+
try {
|
|
179
|
+
const cur = readHolder();
|
|
180
|
+
if (cur && cur.pid === process.pid) {
|
|
181
|
+
writeFileSync(lockPath(), JSON.stringify({ ...cur, beatAt: Date.now() }, null, 2));
|
|
182
|
+
}
|
|
183
|
+
} catch {}
|
|
184
|
+
}, HEARTBEAT_MS);
|
|
185
|
+
heartbeat.unref();
|
|
186
|
+
|
|
187
|
+
held = { keepOnRelease: false, heartbeat, released: false };
|
|
188
|
+
process.on('exit', releaseRunLock);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
// Two reclaim attempts both lost the race → someone else won fair and square.
|
|
192
|
+
const existing = readHolder();
|
|
193
|
+
throw new E2eBusyError(existing ?? holderUnknown());
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function holderUnknown(): LockHolder {
|
|
197
|
+
return {
|
|
198
|
+
pid: 0,
|
|
199
|
+
hostname: hostname(),
|
|
200
|
+
session: 'unknown',
|
|
201
|
+
test: '?',
|
|
202
|
+
runId: '?',
|
|
203
|
+
startedAt: new Date().toISOString(),
|
|
204
|
+
beatAt: Date.now(),
|
|
205
|
+
state: 'running',
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Mark the lock to survive process exit in a `kept` state — call when a stack
|
|
211
|
+
* (network/containers) will outlive this process (`--keep`, `up`). The next run
|
|
212
|
+
* then refuses to clobber it until `cele2e release` / `cele2e down`.
|
|
213
|
+
*/
|
|
214
|
+
export function markKept(): void {
|
|
215
|
+
if (held) held.keepOnRelease = true;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Release the lock. Idempotent. Honors markKept() (kept-state) vs delete. */
|
|
219
|
+
export function releaseRunLock(): void {
|
|
220
|
+
if (!held || held.released) return;
|
|
221
|
+
held.released = true;
|
|
222
|
+
if (held.heartbeat) clearInterval(held.heartbeat);
|
|
223
|
+
|
|
224
|
+
if (held.keepOnRelease) {
|
|
225
|
+
const cur = readHolder();
|
|
226
|
+
if (cur && cur.pid === process.pid) {
|
|
227
|
+
try { writeFileSync(lockPath(), JSON.stringify({ ...cur, state: 'kept', beatAt: Date.now() }, null, 2)); } catch {}
|
|
228
|
+
}
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
const cur = readHolder();
|
|
233
|
+
if (!cur || cur.pid === process.pid) unlinkSync(lockPath());
|
|
234
|
+
} catch {}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Forcibly clear the lock regardless of holder — used by `cele2e release`/`down`. */
|
|
238
|
+
export function clearLock(): boolean {
|
|
239
|
+
try {
|
|
240
|
+
unlinkSync(lockPath());
|
|
241
|
+
return true;
|
|
242
|
+
} catch {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Current lock state for `cele2e status` (and any poller). */
|
|
248
|
+
export function lockStatus(): { free: boolean; holder: LockHolder | null } {
|
|
249
|
+
const h = readHolder();
|
|
250
|
+
if (!h || isStale(h)) return { free: true, holder: null };
|
|
251
|
+
return { free: false, holder: h };
|
|
252
|
+
}
|
package/src/runner.ts
CHANGED
|
@@ -19,9 +19,9 @@
|
|
|
19
19
|
|
|
20
20
|
import { spawn, spawnSync, execSync } from 'node:child_process';
|
|
21
21
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
22
|
-
import { basename, join, resolve } from 'node:path';
|
|
22
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
23
23
|
import { parse as parseYaml } from 'yaml';
|
|
24
|
-
import { ProgressDisplay } from '@celilo/cli-display';
|
|
24
|
+
import { type DisplayMode, ProgressDisplay } from '@celilo/cli-display';
|
|
25
25
|
import {
|
|
26
26
|
emitRunCompleted,
|
|
27
27
|
emitRunFailed,
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
emitTestStarted,
|
|
31
31
|
} from './bus-events';
|
|
32
32
|
import { parseLine } from './parse-line';
|
|
33
|
+
import { acquireRunLock, E2eBusyError, markKept } from './run-lock';
|
|
33
34
|
|
|
34
35
|
// ─── Config ──────────────────────────────────────────────────────────
|
|
35
36
|
|
|
@@ -70,7 +71,43 @@ const flagReuse = rawArgs.includes('--reuse');
|
|
|
70
71
|
const flagLive = rawArgs.includes('--live');
|
|
71
72
|
const flagPublished = rawArgs.includes('--published');
|
|
72
73
|
const flagVerbose = rawArgs.includes('--verbose');
|
|
74
|
+
// CI mode: no animated spinner, no [progress:*] markers — just one clean
|
|
75
|
+
// ✔/✗ line per step (plus sub-events) written straight to the log. Forgejo's
|
|
76
|
+
// runner allocates a PTY, so process.stdout.isTTY is truthy there and the
|
|
77
|
+
// interactive footer (redrawn via `\r` on every spinner tick) gets linearized
|
|
78
|
+
// into one log line per tick — dozens of identical braille lines per step.
|
|
79
|
+
// `--ci` (or any standard `CI` env var, e.g. Forgejo/GitHub Actions) forces
|
|
80
|
+
// the non-interactive render path that sidesteps that entirely. `--no-ci`
|
|
81
|
+
// opts back out when a CI env var is set but an operator wants the spinner.
|
|
82
|
+
const flagCi = rawArgs.includes('--ci');
|
|
83
|
+
const flagNoCi = rawArgs.includes('--no-ci');
|
|
84
|
+
const ci = flagCi || (!flagNoCi && !!process.env.CI);
|
|
73
85
|
const patterns = rawArgs.filter((a) => !a.startsWith('--'));
|
|
86
|
+
// --ci-safe: skip tests marked `cele2e-ci-unsafe` in their file (quarantined —
|
|
87
|
+
// known-failing or builder-flaky, each tracked by an ISS). `--complete`/`--all`
|
|
88
|
+
// run everything. Passed through from the CLI (not stripped) so the runner can
|
|
89
|
+
// apply the exclusion.
|
|
90
|
+
const flagCiSafe = rawArgs.includes('--ci-safe');
|
|
91
|
+
// --notify: fire a desktop notification when the run finishes (handy for long
|
|
92
|
+
// detached/background runs). Best-effort — never affects the exit code.
|
|
93
|
+
const flagNotify = rawArgs.includes('--notify');
|
|
94
|
+
|
|
95
|
+
function notify(title: string, message: string): void {
|
|
96
|
+
if (!flagNotify) return;
|
|
97
|
+
try {
|
|
98
|
+
if (process.platform === 'darwin') {
|
|
99
|
+
const r = spawnSync('terminal-notifier', ['-title', title, '-message', message, '-sound', 'Glass'], { stdio: 'ignore' });
|
|
100
|
+
if (r.error) {
|
|
101
|
+
spawnSync('osascript', ['-e', `display notification ${JSON.stringify(message)} with title ${JSON.stringify(title)} sound name "Glass"`], { stdio: 'ignore' });
|
|
102
|
+
}
|
|
103
|
+
spawnSync('say', [message], { stdio: 'ignore' });
|
|
104
|
+
} else if (process.platform === 'linux') {
|
|
105
|
+
spawnSync('notify-send', [title, message], { stdio: 'ignore' });
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
// ponytail: notification is a nicety; its failure must never break a run.
|
|
109
|
+
}
|
|
110
|
+
}
|
|
74
111
|
|
|
75
112
|
// ─── Colors ──────────────────────────────────────────────────────────
|
|
76
113
|
|
|
@@ -78,10 +115,10 @@ const bold = '\x1b[1m';
|
|
|
78
115
|
const dim = '\x1b[2m';
|
|
79
116
|
const green = '\x1b[32m';
|
|
80
117
|
const red = '\x1b[31m';
|
|
81
|
-
const cyan = '\x1b[36m';
|
|
82
118
|
const reset = '\x1b[0m';
|
|
83
119
|
|
|
84
|
-
const interactive =
|
|
120
|
+
const interactive =
|
|
121
|
+
process.stdout.isTTY && !process.argv.includes('--no-interactive') && !flagVerbose && !ci;
|
|
85
122
|
|
|
86
123
|
// ─── Timing ──────────────────────────────────────────────────────────
|
|
87
124
|
|
|
@@ -369,14 +406,6 @@ function dockerComposeShared(cmd: string): string {
|
|
|
369
406
|
}
|
|
370
407
|
}
|
|
371
408
|
|
|
372
|
-
function isSharedInfraHealthy(): boolean {
|
|
373
|
-
const containers = dockerComposeShared('ps -q');
|
|
374
|
-
if (!containers) return false;
|
|
375
|
-
return dockerComposeShared(
|
|
376
|
-
'exec -T namecheap-dns dig @127.0.0.1 iamtheinternet.org SOA +short +timeout=2',
|
|
377
|
-
).length > 0;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
409
|
function stopSharedInfra(): void {
|
|
381
410
|
dockerComposeShared('down --volumes --remove-orphans');
|
|
382
411
|
try { execSync('docker network prune -f', { stdio: 'pipe', timeout: 10_000 }); } catch {}
|
|
@@ -400,6 +429,24 @@ function assertDockerAvailable(): void {
|
|
|
400
429
|
async function main() {
|
|
401
430
|
assertDockerAvailable();
|
|
402
431
|
|
|
432
|
+
// Serialize against other cele2e runs sharing the global Docker infra. Fail
|
|
433
|
+
// fast naming the holder; pollers use `cele2e status`. Acquire BEFORE any
|
|
434
|
+
// docker mutation so a competing run can't wipe our shared infra mid-setup.
|
|
435
|
+
const lockLabel = patterns.length ? patterns.join(',') : moduleDirs.length ? 'modules' : 'all';
|
|
436
|
+
try {
|
|
437
|
+
acquireRunLock({ test: lockLabel, runId: process.env.CELE2E_RUN_ID ?? ambientRunId, allowKept: flagReuse });
|
|
438
|
+
// A --reuse run keeps using a kept stack that stays up afterwards — hold the
|
|
439
|
+
// lock in kept state so it isn't freed out from under the reused network.
|
|
440
|
+
if (flagReuse) markKept();
|
|
441
|
+
} catch (err) {
|
|
442
|
+
if (err instanceof E2eBusyError) {
|
|
443
|
+
console.error(`\n${red}${err.message}${reset}`);
|
|
444
|
+
console.error(`${dim}Poll with: cele2e status (machine-readable: cele2e status --json)${reset}\n`);
|
|
445
|
+
process.exit(3);
|
|
446
|
+
}
|
|
447
|
+
throw err;
|
|
448
|
+
}
|
|
449
|
+
|
|
403
450
|
let testFiles: string[] = [];
|
|
404
451
|
|
|
405
452
|
if (moduleDirs.length > 0) {
|
|
@@ -454,12 +501,45 @@ async function main() {
|
|
|
454
501
|
.filter((f) => f.endsWith('.test.ts'))
|
|
455
502
|
.sort()
|
|
456
503
|
.map((f) => resolve(testsPath, f));
|
|
504
|
+
// Docker-backed integration tests live in a sibling `tests-integration/`
|
|
505
|
+
// so they stay OUT of the hermetic `bun test tests/` gate. cele2e is the
|
|
506
|
+
// heavy runner (Docker + build-infra present), so it covers BOTH tiers —
|
|
507
|
+
// this is what keeps the documented `cele2e run ansible-output` working
|
|
508
|
+
// after the test moved out of tests/.
|
|
509
|
+
const integrationPath = join(dirname(testsPath), 'tests-integration');
|
|
510
|
+
if (existsSync(integrationPath)) {
|
|
511
|
+
testFiles.push(
|
|
512
|
+
...readdirSync(integrationPath)
|
|
513
|
+
.filter((f) => f.endsWith('.test.ts'))
|
|
514
|
+
.sort()
|
|
515
|
+
.map((f) => resolve(integrationPath, f)),
|
|
516
|
+
);
|
|
517
|
+
}
|
|
457
518
|
}
|
|
458
519
|
|
|
459
520
|
if (patterns.length > 0) {
|
|
460
521
|
testFiles = testFiles.filter((f) => patterns.some((p) => basename(f, '.test.ts').includes(p)));
|
|
461
522
|
}
|
|
462
523
|
|
|
524
|
+
// --ci-safe: drop quarantined tests — those whose file contains the marker
|
|
525
|
+
// `cele2e-ci-unsafe` (a comment naming the reason + ISS). Keeps the nightly
|
|
526
|
+
// green on the CI-safe subset while the quarantined tests are tracked + fixed
|
|
527
|
+
// separately. `--complete`/`--all` run everything (no exclusion).
|
|
528
|
+
if (flagCiSafe) {
|
|
529
|
+
const before = testFiles.length;
|
|
530
|
+
testFiles = testFiles.filter((f) => {
|
|
531
|
+
try {
|
|
532
|
+
return !readFileSync(f, 'utf-8').includes('cele2e-ci-unsafe');
|
|
533
|
+
} catch {
|
|
534
|
+
return true;
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
const skipped = before - testFiles.length;
|
|
538
|
+
if (skipped > 0) {
|
|
539
|
+
console.log(`${dim}--ci-safe: skipped ${skipped} quarantined (cele2e-ci-unsafe) test(s)${reset}`);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
463
543
|
if (testFiles.length === 0) {
|
|
464
544
|
console.error(`${red}No test files matched${reset}`);
|
|
465
545
|
process.exit(1);
|
|
@@ -507,9 +587,8 @@ async function main() {
|
|
|
507
587
|
mkdirSync(resultsDir, { recursive: true });
|
|
508
588
|
|
|
509
589
|
// runId scopes every event in this suite invocation. Read from env to
|
|
510
|
-
// let callers (CI
|
|
511
|
-
//
|
|
512
|
-
// print it so the operator can correlate.
|
|
590
|
+
// let callers (CI) inject a known id and subscribe to it before launching
|
|
591
|
+
// cele2e; otherwise generate one and print it so the operator can correlate.
|
|
513
592
|
const runId = process.env.CELE2E_RUN_ID || crypto.randomUUID();
|
|
514
593
|
if (!process.env.CELE2E_RUN_ID) {
|
|
515
594
|
console.log(`${dim}runId: ${runId}${reset}`);
|
|
@@ -524,14 +603,21 @@ async function main() {
|
|
|
524
603
|
|
|
525
604
|
// 'auto' resolves to 'render' when isTTY is true (operator at a
|
|
526
605
|
// terminal sees animated spinners) and 'protocol' when isTTY is
|
|
527
|
-
// false (
|
|
528
|
-
//
|
|
606
|
+
// false (a backgrounded run / any redirected stdout gets ASCII
|
|
607
|
+
// `[progress:*]` markers — grep-friendly, no braille noise).
|
|
529
608
|
// Prior to this change the mode was hardcoded 'render', so non-TTY
|
|
530
609
|
// callers got `⣾ phase started` / `✔ phase done` lines per phase
|
|
531
610
|
// (e.g. ~29 braille codepoints in a typical test log) — cosmetic
|
|
532
611
|
// but actively unhelpful for log scanning and tooling.
|
|
612
|
+
//
|
|
613
|
+
// CI mode forces 'render' with isTTY:false (non-interactive render):
|
|
614
|
+
// one ✔/✗ line per step, sub-events emitted inline, NO spinner and
|
|
615
|
+
// NO `[progress:*]` markers. We can't rely on 'auto' here because
|
|
616
|
+
// Forgejo's PTY makes isTTY truthy — which would pick the animated
|
|
617
|
+
// footer and spam the log with one braille line per spinner tick.
|
|
618
|
+
const displayMode: DisplayMode = ci ? 'render' : 'auto';
|
|
533
619
|
const display = new ProgressDisplay({
|
|
534
|
-
mode:
|
|
620
|
+
mode: displayMode,
|
|
535
621
|
out: { write: process.stdout.write.bind(process.stdout), isTTY: interactive },
|
|
536
622
|
});
|
|
537
623
|
let passed = 0;
|
|
@@ -606,6 +692,9 @@ async function main() {
|
|
|
606
692
|
console.log(`${dim} Persistent network: ${result.projectName}${reset}`);
|
|
607
693
|
console.log(`${dim} Reuse: ${basename(process.argv[1])} --reuse${reset}`);
|
|
608
694
|
console.log(`${dim} Tear down: cele2e down${reset}`);
|
|
695
|
+
// The kept network outlives this process — hold the lock in a `kept`
|
|
696
|
+
// state so the next run refuses to clobber it (cleared by `cele2e down`).
|
|
697
|
+
markKept();
|
|
609
698
|
}
|
|
610
699
|
|
|
611
700
|
if (result.status === 'pass') {
|
|
@@ -688,6 +777,7 @@ async function main() {
|
|
|
688
777
|
resultsDir,
|
|
689
778
|
});
|
|
690
779
|
|
|
780
|
+
notify('cele2e', `${passed} passed, ${failed} failed — ${formatDuration(suiteDuration)}`);
|
|
691
781
|
process.exit(failed > 0 ? 1 : 0);
|
|
692
782
|
}
|
|
693
783
|
|
|
@@ -705,5 +795,6 @@ main().catch((err) => {
|
|
|
705
795
|
error: err instanceof Error ? err.message : String(err),
|
|
706
796
|
});
|
|
707
797
|
console.error(err);
|
|
798
|
+
notify('cele2e', 'run failed');
|
|
708
799
|
process.exit(1);
|
|
709
800
|
});
|
package/src/shared-infra.ts
CHANGED
|
@@ -30,6 +30,34 @@ function run(cmd: string, opts?: { cwd?: string; timeout?: number }): string {
|
|
|
30
30
|
|
|
31
31
|
let sharedInfraRunning = false;
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Remove ALL celilo-e2e-* Docker resources by name prefix (#212). Used at
|
|
35
|
+
* start-of-run, where the run-lock guarantees no other session's stack is live.
|
|
36
|
+
* Graceful compose-down of the shared project first (clean network detach),
|
|
37
|
+
* then a force sweep that catches orphans from any prior invocation regardless
|
|
38
|
+
* of compose-project name or the path that created them.
|
|
39
|
+
*/
|
|
40
|
+
function nukeE2eResources(e2eDir: string): void {
|
|
41
|
+
try {
|
|
42
|
+
run(`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} down --volumes --remove-orphans`,
|
|
43
|
+
{ cwd: e2eDir, timeout: 30_000 });
|
|
44
|
+
} catch {}
|
|
45
|
+
try {
|
|
46
|
+
const ids = run('docker ps -aq --filter name=celilo-e2e', { timeout: 10_000 });
|
|
47
|
+
if (ids.trim()) run(`docker rm -f ${ids.replace(/\n/g, ' ')}`, { timeout: 60_000 });
|
|
48
|
+
} catch {}
|
|
49
|
+
try {
|
|
50
|
+
const nets = run('docker network ls --format "{{.Name}}"')
|
|
51
|
+
.split('\n')
|
|
52
|
+
.filter((n) => n.startsWith('celilo-e2e'));
|
|
53
|
+
for (const net of nets) {
|
|
54
|
+
try { run(`docker network rm ${net}`, { timeout: 5_000 }); } catch {}
|
|
55
|
+
}
|
|
56
|
+
} catch {}
|
|
57
|
+
try { run('docker network prune -f', { timeout: 10_000 }); } catch {}
|
|
58
|
+
try { run('docker volume prune -f', { timeout: 10_000 }); } catch {}
|
|
59
|
+
}
|
|
60
|
+
|
|
33
61
|
/**
|
|
34
62
|
* Check if the shared infrastructure is already running and healthy.
|
|
35
63
|
*
|
|
@@ -104,38 +132,14 @@ export async function ensureSharedInfra(): Promise<void> {
|
|
|
104
132
|
|
|
105
133
|
const e2eDir = getE2eDir();
|
|
106
134
|
|
|
107
|
-
//
|
|
135
|
+
// Start-of-run cleanup. We hold the run-lock here (acquired in the runner /
|
|
136
|
+
// build path before any docker mutation), so NO other session's stack is
|
|
137
|
+
// live — it is safe to remove EVERY celilo-e2e-* resource by name prefix.
|
|
138
|
+
// This self-heals orphans left by a crashed / cross-path / cross-checkout
|
|
139
|
+
// prior run (e.g. a botched-mount container that wedges the next `up`),
|
|
140
|
+
// which the old compose-project-scoped `down` couldn't see (#212).
|
|
108
141
|
console.log('[progress:start] cleaning up stale shared infrastructure | shared infra cleanup complete');
|
|
109
|
-
|
|
110
|
-
run(`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} down --volumes --remove-orphans`,
|
|
111
|
-
{ cwd: e2eDir, timeout: 30_000 });
|
|
112
|
-
} catch {}
|
|
113
|
-
|
|
114
|
-
// Also clean up any per-test projects that reference our networks
|
|
115
|
-
try {
|
|
116
|
-
const networks = run('docker network ls --format "{{.Name}}"')
|
|
117
|
-
.split('\n')
|
|
118
|
-
.filter(n => n.startsWith('celilo-e2e-') && !n.startsWith(`${SHARED_PROJECT_NAME}_`));
|
|
119
|
-
if (networks.length > 0) {
|
|
120
|
-
const projects = [...new Set(networks.map(n => n.replace(/_[^_]+$/, '')))];
|
|
121
|
-
for (const project of projects) {
|
|
122
|
-
try { run(`docker compose -p ${project} down --volumes --remove-orphans`, { timeout: 30_000 }); } catch {}
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
} catch {}
|
|
126
|
-
|
|
127
|
-
try { run('docker network prune -f', { timeout: 10_000 }); } catch {}
|
|
128
|
-
try { run('docker volume prune -f', { timeout: 10_000 }); } catch {}
|
|
129
|
-
|
|
130
|
-
// Force-remove any leftover networks by name
|
|
131
|
-
try {
|
|
132
|
-
const staleNets = run('docker network ls --format "{{.Name}}"')
|
|
133
|
-
.split('\n')
|
|
134
|
-
.filter(n => n.startsWith('celilo-e2e'));
|
|
135
|
-
for (const net of staleNets) {
|
|
136
|
-
try { run(`docker network rm ${net}`, { timeout: 5_000 }); } catch {}
|
|
137
|
-
}
|
|
138
|
-
} catch {}
|
|
142
|
+
nukeE2eResources(e2eDir);
|
|
139
143
|
|
|
140
144
|
// Refresh the bundled registry-server source so Dockerfile.registry's
|
|
141
145
|
// COPY resolves whether we're in the monorepo (regenerated from the
|
|
@@ -172,11 +176,16 @@ export async function ensureSharedInfra(): Promise<void> {
|
|
|
172
176
|
run(`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} up -d`,
|
|
173
177
|
{ cwd: e2eDir, timeout: 120_000 });
|
|
174
178
|
|
|
175
|
-
// Wait for DNS convergence
|
|
179
|
+
// Wait for DNS convergence — robustly. The old single 60s window with no
|
|
180
|
+
// recovery was an intermittent build-infra failure on the builder (#319:
|
|
181
|
+
// namecheap-dns hadn't served its zone within 60s, and the bare throw gave
|
|
182
|
+
// no clue why). Now: a longer window, RESTART the DNS container if it's
|
|
183
|
+
// stuck (re-triggers the zone load), and DUMP diagnostics before giving up.
|
|
176
184
|
console.log('[progress:start] waiting for DNS convergence | DNS converged');
|
|
185
|
+
const dnsTimeout = 180_000;
|
|
177
186
|
const start = Date.now();
|
|
178
|
-
|
|
179
|
-
while (Date.now() - start <
|
|
187
|
+
let lastRestart = start;
|
|
188
|
+
while (Date.now() - start < dnsTimeout) {
|
|
180
189
|
try {
|
|
181
190
|
const result = run(
|
|
182
191
|
`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} exec -T namecheap-dns dig @127.0.0.1 iamtheinternet.org SOA +short +timeout=2`,
|
|
@@ -190,9 +199,34 @@ export async function ensureSharedInfra(): Promise<void> {
|
|
|
190
199
|
} catch {
|
|
191
200
|
// retry
|
|
192
201
|
}
|
|
202
|
+
// Recovery: if ~45s have passed since the last (re)start without
|
|
203
|
+
// converging, the DNS container likely didn't load its zone — restart
|
|
204
|
+
// it to re-trigger the load instead of waiting out the whole window.
|
|
205
|
+
if (Date.now() - lastRestart > 45_000) {
|
|
206
|
+
console.log('[progress:sub] DNS not converged yet — restarting namecheap-dns');
|
|
207
|
+
try {
|
|
208
|
+
run(`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} restart namecheap-dns`,
|
|
209
|
+
{ cwd: e2eDir, timeout: 30_000 });
|
|
210
|
+
} catch {}
|
|
211
|
+
lastRestart = Date.now();
|
|
212
|
+
}
|
|
193
213
|
await new Promise(r => setTimeout(r, 2000));
|
|
194
214
|
}
|
|
195
|
-
|
|
215
|
+
// Diagnostics before failing, so the log shows WHY it didn't converge.
|
|
216
|
+
let psOut = '';
|
|
217
|
+
try {
|
|
218
|
+
psOut = run(`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} ps`,
|
|
219
|
+
{ cwd: e2eDir, timeout: 10_000 });
|
|
220
|
+
} catch {}
|
|
221
|
+
let dnsLog = '';
|
|
222
|
+
try {
|
|
223
|
+
dnsLog = run(`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} logs --tail 40 namecheap-dns`,
|
|
224
|
+
{ cwd: e2eDir, timeout: 10_000 });
|
|
225
|
+
} catch {}
|
|
226
|
+
throw new Error(
|
|
227
|
+
`Timeout waiting for shared infrastructure DNS convergence (${dnsTimeout / 1000}s)\n` +
|
|
228
|
+
`--- compose ps ---\n${psOut}\n--- namecheap-dns logs (tail 40) ---\n${dnsLog}`,
|
|
229
|
+
);
|
|
196
230
|
}
|
|
197
231
|
|
|
198
232
|
/**
|