@celilo/e2e 0.19.3 → 0.20.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/README.md +30 -13
- package/bin/e2e-bake-management +171 -12
- package/bin/e2e-infra +0 -1
- package/bin/e2e-up +14 -3
- package/docker/Dockerfile.observer +12 -1
- package/docker/Dockerfile.target-machine +22 -1
- package/npm-registry-server/package.json +1 -1
- package/package.json +3 -3
- package/registry-server/package.json +1 -1
- package/scripts/pack-celilo-packages.ts +15 -0
- package/src/block-timing.test.ts +559 -0
- package/src/block-timing.ts +366 -0
- package/src/cli/build.test.ts +54 -4
- package/src/cli/build.ts +204 -88
- package/src/cli/command-registry.ts +21 -0
- package/src/cli/command-tree-parser.ts +11 -2
- package/src/cli/completion.ts +9 -0
- package/src/cli/host.ts +252 -0
- package/src/cli/index.ts +78 -51
- package/src/cli/module-discovery.ts +108 -13
- package/src/cli/scaffold.ts +18 -26
- package/src/container-manager.cleanup.test.ts +284 -0
- package/src/container-manager.runner.test.ts +351 -0
- package/src/container-manager.test.ts +84 -0
- package/src/container-manager.ts +721 -185
- package/src/docker-compose-generator.ts +135 -61
- package/src/doctor.test.ts +259 -4
- package/src/doctor.ts +276 -3
- package/src/exit-cleanup.test.ts +83 -1
- package/src/fleet-nameserver-gate.test.ts +45 -0
- package/src/host-vm.test.ts +156 -0
- package/src/host-vm.ts +230 -0
- package/src/index.ts +11 -0
- package/src/live-stack.test.ts +184 -0
- package/src/live-stack.ts +145 -0
- package/src/no-unjustified-sleep.test.ts +90 -0
- package/src/proxmox-provisioner.test.ts +18 -2
- package/src/proxmox-provisioner.ts +22 -0
- package/src/public-sim-routes.test.ts +9 -2
- package/src/repo-root.ts +33 -0
- package/src/run-args.test.ts +76 -0
- package/src/run-args.ts +89 -0
- package/src/runner.ts +213 -8
- package/src/shared-infra.ts +83 -32
- package/src/socks-proxy.ts +2 -0
- package/src/source-fingerprint.test.ts +213 -0
- package/src/source-fingerprint.ts +201 -0
- package/src/stage-simulator-inputs.ts +93 -0
- package/src/stages.ts +133 -0
- package/src/wait-for-run.ts +1 -0
package/src/container-manager.ts
CHANGED
|
@@ -1,19 +1,29 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type ChildProcessWithoutNullStreams,
|
|
3
|
+
type ExecSyncOptions,
|
|
4
|
+
type SpawnOptionsWithoutStdio,
|
|
5
|
+
execSync,
|
|
6
|
+
spawn,
|
|
7
|
+
} from 'node:child_process';
|
|
2
8
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
9
|
import { tmpdir } from 'node:os';
|
|
4
10
|
import { basename, join, resolve } from 'node:path';
|
|
11
|
+
import { parseIpv4, subnetContains } from '@celilo/capabilities';
|
|
5
12
|
import { parse as parseYaml } from 'yaml';
|
|
6
13
|
import { startBrowser } from './browser';
|
|
7
14
|
import { MIN_CLI_VERSION, checkCliVersion } from './cli-version-contract';
|
|
8
15
|
import {
|
|
16
|
+
type FirewallLeg,
|
|
9
17
|
SHARED_PROJECT_NAME,
|
|
10
18
|
firewallZoneLegs,
|
|
19
|
+
generateSharedInfraYaml,
|
|
11
20
|
generateTestComposeYaml,
|
|
12
21
|
getAllMachines,
|
|
22
|
+
referencedImages,
|
|
13
23
|
registryUploadsHostDir,
|
|
14
24
|
} from './docker-compose-generator';
|
|
15
|
-
import { explainBuildFailure } from './doctor';
|
|
16
25
|
import { type ModuleHost, parseModuleHost } from './module-host';
|
|
26
|
+
import { CONTAINER_PREFIX, GUEST_PROJECT_LABEL } from './proxmox-provisioner';
|
|
17
27
|
import { ensureSharedInfra } from './shared-infra';
|
|
18
28
|
import { SIMULATOR_IPS } from './simulator-ips';
|
|
19
29
|
import { startSocksProxy } from './socks-proxy';
|
|
@@ -35,9 +45,114 @@ import {
|
|
|
35
45
|
internalNatIp,
|
|
36
46
|
} from './types';
|
|
37
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Every way this module reaches the docker CLI, injectable so the cleanup
|
|
50
|
+
* sweeps, the exec wrappers and the image accounting are unit-testable
|
|
51
|
+
* without a daemon (ce-h4no, lane A1 of openspec/changes/e2e-suite-recovery).
|
|
52
|
+
*
|
|
53
|
+
* Follows the DoctorProbe pattern in doctor.ts: an interface of typed
|
|
54
|
+
* operations with the real implementation as the default, so no caller
|
|
55
|
+
* changes. proxmox-provisioner.ts owns the narrower `DockerRunner` name
|
|
56
|
+
* (argv-only, sync, no streaming), so this one is named for what it wraps:
|
|
57
|
+
* the docker CLI itself. Tests substitute a fake via `withDockerCli`.
|
|
58
|
+
*/
|
|
59
|
+
export interface DockerCli {
|
|
60
|
+
/** Synchronous `docker ...` shell command. Throws on non-zero exit, like execSync. */
|
|
61
|
+
exec(command: string, opts?: ExecSyncOptions): string;
|
|
62
|
+
/**
|
|
63
|
+
* Streaming `docker ...` for long-running compose build and exec. Callers
|
|
64
|
+
* never pass stdio (pipe default), so the streams are non-null.
|
|
65
|
+
*/
|
|
66
|
+
spawn(args: string[], opts?: SpawnOptionsWithoutStdio): ChildProcessWithoutNullStreams;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const realDockerCli: DockerCli = {
|
|
70
|
+
exec(command, opts) {
|
|
71
|
+
// Encoding is pinned last so TS narrows the return to `string` even if
|
|
72
|
+
// a caller passes an opts object that could in principle override it.
|
|
73
|
+
return execSync(command, { ...opts, encoding: 'utf-8' });
|
|
74
|
+
},
|
|
75
|
+
spawn(args, opts) {
|
|
76
|
+
return spawn('docker', args, opts);
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
let dockerCli: DockerCli = realDockerCli;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Run `fn` with `runner` as this module's docker access, restoring the
|
|
84
|
+
* previous runner afterwards — even when `fn` throws. A sync callback runs
|
|
85
|
+
* and restores synchronously; a promise callback keeps the override in force
|
|
86
|
+
* across its awaits and restores it when it settles. Concurrent async scopes
|
|
87
|
+
* would restore out of order, so tests await one scope at a time.
|
|
88
|
+
*/
|
|
89
|
+
export function withDockerCli<T>(runner: DockerCli, fn: () => T): T;
|
|
90
|
+
export function withDockerCli<T>(runner: DockerCli, fn: () => Promise<T>): Promise<T>;
|
|
91
|
+
export function withDockerCli<T>(runner: DockerCli, fn: () => T | Promise<T>): T | Promise<T> {
|
|
92
|
+
const previous = dockerCli;
|
|
93
|
+
dockerCli = runner;
|
|
94
|
+
try {
|
|
95
|
+
const result = fn();
|
|
96
|
+
if (result instanceof Promise) {
|
|
97
|
+
return result.finally(() => {
|
|
98
|
+
dockerCli = previous;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
dockerCli = previous;
|
|
102
|
+
return result;
|
|
103
|
+
} catch (err) {
|
|
104
|
+
dockerCli = previous;
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
38
109
|
/** Package root — where docker/, config/, simulators/ live */
|
|
39
110
|
const PACKAGE_ROOT = join(__dirname, '..');
|
|
40
111
|
|
|
112
|
+
/**
|
|
113
|
+
* The `.netapp` build-infra already staged for this module, if nothing in the
|
|
114
|
+
* source has changed since.
|
|
115
|
+
*
|
|
116
|
+
* `publishModule` used to repackage from source unconditionally, inside a
|
|
117
|
+
* running stack, under a 60s cap. Measured 2026-09-04: build-infra packaged
|
|
118
|
+
* `celilo-registry` (an 82MB netapp) in 11 seconds before any stack existed,
|
|
119
|
+
* then publishModule exceeded 60 seconds on identical work because the stack was
|
|
120
|
+
* up and eating the host. `registry-pipeline` failed on exactly that, and it is
|
|
121
|
+
* not one of the three flakes that suite is quarantined for (celilo#1258).
|
|
122
|
+
*
|
|
123
|
+
* `find -newer -quit` asks the filesystem instead of walking in JS and stops at
|
|
124
|
+
* the first newer file. A staleness check that cannot answer repackages: reusing
|
|
125
|
+
* on an inconclusive result is how a test silently runs against a stale module.
|
|
126
|
+
*/
|
|
127
|
+
function stagedNetappIfCurrent(moduleDir: string, moduleId: string): string | null {
|
|
128
|
+
const staged = join(PACKAGE_ROOT, 'netapps', `${moduleId}.netapp`);
|
|
129
|
+
if (!existsSync(staged)) return null;
|
|
130
|
+
|
|
131
|
+
// Only paths the package actually contains can make it stale. `e2e/` is
|
|
132
|
+
// excluded from a module package wholesale and `node_modules/.bin` is excluded
|
|
133
|
+
// from the hook runtime closure — see `classifyModulePath` and
|
|
134
|
+
// `includeNodeModulesPath` in apps/celilo/src/module/packaging/, which are the
|
|
135
|
+
// authority. They are restated rather than imported because packages/e2e has
|
|
136
|
+
// no import path into apps/celilo, and registry-server's bootstrap.ts already
|
|
137
|
+
// carries the same duplication for the same reason.
|
|
138
|
+
//
|
|
139
|
+
// Getting these wrong is safe in one direction only, and it is this one:
|
|
140
|
+
// counting a non-packaged path makes us repackage needlessly (slow), while
|
|
141
|
+
// MISSING a packaged path would reuse a stale netapp (wrong). Every exclusion
|
|
142
|
+
// here is a path the packager does not ship, so it cannot cause the latter.
|
|
143
|
+
// Without them nothing is ever reused: `bun install` touches
|
|
144
|
+
// `e2e/node_modules/.bin/*` and every module looks permanently dirty.
|
|
145
|
+
try {
|
|
146
|
+
const newer = execSync(
|
|
147
|
+
`find ${JSON.stringify(moduleDir)} -newer ${JSON.stringify(staged)} -not -path '*/e2e/*' -not -path '*/node_modules/.bin/*' -print -quit`,
|
|
148
|
+
{ encoding: 'utf-8', timeout: 30_000 },
|
|
149
|
+
).trim();
|
|
150
|
+
return newer === '' ? staged : null;
|
|
151
|
+
} catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
41
156
|
/**
|
|
42
157
|
* Find the Celilo project root by walking up from the package directory.
|
|
43
158
|
* Looks for apps/celilo/ as the marker.
|
|
@@ -104,11 +219,14 @@ const activeProjects: Set<string> = new Set();
|
|
|
104
219
|
*/
|
|
105
220
|
export function projectTeardownCommands(project: string): {
|
|
106
221
|
listContainers: string;
|
|
222
|
+
/** Sim-created LXC guests of this project — by label, since their names carry no timestamp. */
|
|
223
|
+
listGuests: string;
|
|
107
224
|
listNetworks: string;
|
|
108
225
|
listVolumes: string;
|
|
109
226
|
} {
|
|
110
227
|
return {
|
|
111
228
|
listContainers: `docker ps -aq --filter name=${project}`,
|
|
229
|
+
listGuests: `docker ps -aq --filter label=${GUEST_PROJECT_LABEL}=${project}`,
|
|
112
230
|
listNetworks: `docker network ls --format {{.Name}} --filter name=${project}`,
|
|
113
231
|
// Volumes were missing here, and the compose path that would have removed
|
|
114
232
|
// them (`down --volumes`) is the same best-effort call that silently does
|
|
@@ -121,42 +239,220 @@ export function projectTeardownCommands(project: string): {
|
|
|
121
239
|
};
|
|
122
240
|
}
|
|
123
241
|
|
|
124
|
-
/**
|
|
125
|
-
|
|
242
|
+
/**
|
|
243
|
+
* Command listing the containers attached to one of the project's networks.
|
|
244
|
+
*
|
|
245
|
+
* A Proxmox guest is named `celilo-e2e-lxc-<vmid>` and carries NO project name
|
|
246
|
+
* (it is created by `docker run`, not compose), so the project-scoped container
|
|
247
|
+
* sweep never sees it. A live guest keeps `docker network rm` failing forever,
|
|
248
|
+
* and the leaked network holds the sim's subnet — every later run then dies at
|
|
249
|
+
* `docker compose up` with `invalid pool request: Pool overlaps` (ce-ywix:
|
|
250
|
+
* three full regressions lost to one crashed suite's guest).
|
|
251
|
+
*/
|
|
252
|
+
export function networkContainersInspectCommand(network: string): string {
|
|
253
|
+
return `docker network inspect ${network} --format {{json .Containers}}`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* One best-effort cleanup step that failed and was carried past. A sweep step
|
|
258
|
+
* never aborts its sweep (the next step's resources are no less leaked for
|
|
259
|
+
* it), but it is also never silent: each failure is logged as a
|
|
260
|
+
* `[cleanup:failed]` line and counted in the sweep's return value, so the
|
|
261
|
+
* progress line cannot claim "cleanup complete" over a sweep that did not
|
|
262
|
+
* complete (ce-yuzi, lane A2 of openspec/changes/e2e-suite-recovery).
|
|
263
|
+
*/
|
|
264
|
+
export interface CleanupFailure {
|
|
265
|
+
/** The step that failed, named the way the sweep runs it, e.g. `docker rm -f ...`. */
|
|
266
|
+
step: string;
|
|
267
|
+
/** The error's own message, not a restatement of it. */
|
|
268
|
+
detail: string;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Log one best-effort step that failed where there is no sweep to count it. */
|
|
272
|
+
function logCleanupFailure(step: string, error: unknown): void {
|
|
273
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
274
|
+
console.error(`[cleanup:failed] ${step} | ${detail}`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Record a carried-past failure: log it AND count it, so the caller's
|
|
278
|
+
* progress line reflects reality. */
|
|
279
|
+
function recordFailure(failures: CleanupFailure[], step: string, error: unknown): void {
|
|
280
|
+
failures.push({ step, detail: error instanceof Error ? error.message : String(error) });
|
|
281
|
+
logCleanupFailure(step, error);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* The done-side of the stale-resource sweep's progress line. Honest by
|
|
286
|
+
* construction: "complete" appears only when the sweep returned zero
|
|
287
|
+
* failures.
|
|
288
|
+
*/
|
|
289
|
+
export function cleanupProgress(failures: CleanupFailure[]): string {
|
|
290
|
+
if (failures.length === 0) return 'cleanup complete';
|
|
291
|
+
return `cleanup incomplete: ${failures.length} step(s) failed (see [cleanup:failed] lines above)`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Force-remove a project's containers, networks and volumes by name. Never
|
|
296
|
+
* throws. Returns one CleanupFailure per step that failed and was carried
|
|
297
|
+
* past, so callers can report what the sweep could not do.
|
|
298
|
+
*/
|
|
299
|
+
export function forceRemoveProject(project: string): CleanupFailure[] {
|
|
300
|
+
const failures: CleanupFailure[] = [];
|
|
126
301
|
const cmds = projectTeardownCommands(project);
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
302
|
+
const listed = (cmd: string, step: string): string[] => {
|
|
303
|
+
try {
|
|
304
|
+
return run(cmd, { timeout: 15_000, stdio: 'pipe' }).split('\n').filter(Boolean);
|
|
305
|
+
} catch (err) {
|
|
306
|
+
// A listing that fails must not look like an empty result: an empty
|
|
307
|
+
// answer and a broken docker are indistinguishable to the sweep, and
|
|
308
|
+
// the sweep would skip what it cannot see.
|
|
309
|
+
recordFailure(failures, step, err);
|
|
310
|
+
return [];
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
// Guests are listed by LABEL, not only by name: a sim-created guest is named
|
|
314
|
+
// celilo-e2e-lxc-<vmid>, which the name filter cannot match, and a guest left
|
|
315
|
+
// running holds its zone network's endpoint — the network rm below then fails
|
|
316
|
+
// with "has active endpoints" and the subnet stays allocated, colliding with
|
|
317
|
+
// every later suite in the run (celilo#1247).
|
|
318
|
+
const ids = [
|
|
319
|
+
...new Set([
|
|
320
|
+
...listed(cmds.listContainers, `docker ps name=${project}`),
|
|
321
|
+
...listed(cmds.listGuests, `docker ps label=${GUEST_PROJECT_LABEL}=${project}`),
|
|
322
|
+
]),
|
|
323
|
+
];
|
|
324
|
+
if (ids.length > 0) {
|
|
325
|
+
try {
|
|
326
|
+
run(`docker rm -f ${ids.join(' ')}`, { timeout: 30_000, stdio: 'pipe' });
|
|
327
|
+
} catch (err) {
|
|
328
|
+
recordFailure(failures, `docker rm -f ${project} containers`, err);
|
|
134
329
|
}
|
|
135
|
-
}
|
|
330
|
+
}
|
|
136
331
|
try {
|
|
137
|
-
const nets =
|
|
138
|
-
.toString()
|
|
332
|
+
const nets = run(cmds.listNetworks, { timeout: 15_000, stdio: 'pipe' })
|
|
139
333
|
.split('\n')
|
|
140
334
|
.filter(Boolean);
|
|
141
335
|
for (const net of nets) {
|
|
336
|
+
// Remove the e2e containers ON the network first, or the rm below fails
|
|
337
|
+
// while a guest is attached. Only ever touches containers carrying the
|
|
338
|
+
// provisioner's own CONTAINER_PREFIX containment contract, so anything
|
|
339
|
+
// else attached by hand is left alone and the network rm fails (the
|
|
340
|
+
// doctor's leaked-stacks check is what names that case).
|
|
341
|
+
try {
|
|
342
|
+
// Through the seam (run/dockerCli), not raw execSync, so the sweep is
|
|
343
|
+
// unit-testable and every failure lands in the same channel.
|
|
344
|
+
const attached = JSON.parse(
|
|
345
|
+
run(networkContainersInspectCommand(net), { timeout: 15_000, stdio: 'pipe' }) || '{}',
|
|
346
|
+
) as Record<string, { Name?: string }>;
|
|
347
|
+
const ours = Object.values(attached)
|
|
348
|
+
.map((c) => c.Name ?? '')
|
|
349
|
+
.filter((n) => n.startsWith(CONTAINER_PREFIX));
|
|
350
|
+
if (ours.length > 0) {
|
|
351
|
+
run(`docker rm -f ${ours.join(' ')}`, { timeout: 30_000, stdio: 'pipe' });
|
|
352
|
+
}
|
|
353
|
+
} catch (err) {
|
|
354
|
+
recordFailure(failures, `attached-container sweep on ${net}`, err);
|
|
355
|
+
}
|
|
142
356
|
try {
|
|
143
|
-
|
|
144
|
-
} catch {
|
|
357
|
+
run(`docker network rm ${net}`, { timeout: 10_000, stdio: 'pipe' });
|
|
358
|
+
} catch (err) {
|
|
359
|
+
// A network that fails to rm must not stop the volume sweep below.
|
|
360
|
+
recordFailure(failures, `docker network rm ${net}`, err);
|
|
361
|
+
}
|
|
145
362
|
}
|
|
146
|
-
} catch {
|
|
363
|
+
} catch (err) {
|
|
364
|
+
recordFailure(failures, `network sweep for ${project}`, err);
|
|
365
|
+
}
|
|
147
366
|
// Volumes last: a volume still attached to a container cannot be removed, so
|
|
148
367
|
// this must follow the container sweep above.
|
|
149
368
|
try {
|
|
150
|
-
const vols =
|
|
151
|
-
.
|
|
369
|
+
const vols = run(cmds.listVolumes, { timeout: 15_000, stdio: 'pipe' })
|
|
370
|
+
.split('\n')
|
|
371
|
+
.filter(Boolean);
|
|
372
|
+
for (const vol of vols) {
|
|
373
|
+
try {
|
|
374
|
+
run(`docker volume rm ${vol}`, { timeout: 10_000, stdio: 'pipe' });
|
|
375
|
+
} catch (err) {
|
|
376
|
+
recordFailure(failures, `docker volume rm ${vol}`, err);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
} catch (err) {
|
|
380
|
+
recordFailure(failures, `volume sweep for ${project}`, err);
|
|
381
|
+
}
|
|
382
|
+
return failures;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* The start-of-run stale-resource sweep: per-test containers, sim-created
|
|
387
|
+
* guests of ANY project (their names carry no timestamp, celilo#1247), whole
|
|
388
|
+
* per-test projects found via their networks, and stale volumes. Moved
|
|
389
|
+
* verbatim out of startNetwork so its reach and its failure behavior are
|
|
390
|
+
* unit-testable (ce-yuzi). Returns the failures it carried past; the caller
|
|
391
|
+
* turns that into the progress line.
|
|
392
|
+
*/
|
|
393
|
+
export function sweepStaleTestResources(): CleanupFailure[] {
|
|
394
|
+
const failures: CleanupFailure[] = [];
|
|
395
|
+
// Force-kill per-test containers (not shared infra)
|
|
396
|
+
try {
|
|
397
|
+
const containers = run('docker ps -aq --filter "name=celilo-e2e-1" 2>/dev/null');
|
|
398
|
+
if (containers.trim()) {
|
|
399
|
+
run(`docker rm -f ${containers.replace(/\n/g, ' ')}`, { timeout: 30_000 });
|
|
400
|
+
}
|
|
401
|
+
} catch (err) {
|
|
402
|
+
recordFailure(failures, 'stale container sweep', err);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Sim-created LXC guests. The name filter above cannot match them (their
|
|
406
|
+
// names are celilo-e2e-lxc-<vmid>, with no timestamp), and a guest left
|
|
407
|
+
// running holds its zone network's endpoint — so the network sweep below
|
|
408
|
+
// cannot free that subnet either, and every later suite in the run dies
|
|
409
|
+
// creating it (celilo#1247). Any container carrying the guest label is
|
|
410
|
+
// ours by construction: only the provisioner sets it.
|
|
411
|
+
try {
|
|
412
|
+
const guests = run(`docker ps -aq --filter label=${GUEST_PROJECT_LABEL}`);
|
|
413
|
+
if (guests.trim()) {
|
|
414
|
+
run(`docker rm -f ${guests.replace(/\n/g, ' ')}`, { timeout: 30_000 });
|
|
415
|
+
}
|
|
416
|
+
} catch (err) {
|
|
417
|
+
recordFailure(failures, 'stale guest sweep', err);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Remove stale per-test networks AND their volumes, via the same
|
|
421
|
+
// by-name teardown the exit handler uses. This used to lean on
|
|
422
|
+
// `docker compose -p <project> down --volumes` for the volume half, which
|
|
423
|
+
// has no `-f` and no reliable cwd here either — compose exits "no
|
|
424
|
+
// configuration file provided" and the catch swallows it, so volumes were
|
|
425
|
+
// never removed on this path either. Reusing forceRemoveProject keeps the
|
|
426
|
+
// crash-recovery sweep and the exit handler from drifting apart.
|
|
427
|
+
try {
|
|
428
|
+
const networks = run('docker network ls --format "{{.Name}}" 2>/dev/null')
|
|
429
|
+
.split('\n')
|
|
430
|
+
.filter((n) => n.startsWith('celilo-e2e-1')); // per-test projects start with timestamp
|
|
431
|
+
const projects = [...new Set(networks.map((n) => n.replace(/_[^_]+$/, '')))];
|
|
432
|
+
for (const project of projects) {
|
|
433
|
+
failures.push(...forceRemoveProject(project));
|
|
434
|
+
}
|
|
435
|
+
} catch (err) {
|
|
436
|
+
recordFailure(failures, 'stale project sweep', err);
|
|
437
|
+
}
|
|
438
|
+
// Volumes can outlive every container and network of their project — that
|
|
439
|
+
// is exactly the 58-volume pile — so sweep by prefix too, not only for
|
|
440
|
+
// projects that still have a network to be discovered by.
|
|
441
|
+
try {
|
|
442
|
+
const vols = run('docker volume ls -q --filter name=celilo-e2e-1 2>/dev/null')
|
|
152
443
|
.split('\n')
|
|
153
444
|
.filter(Boolean);
|
|
154
445
|
for (const vol of vols) {
|
|
155
446
|
try {
|
|
156
|
-
|
|
157
|
-
} catch {
|
|
447
|
+
run(`docker volume rm ${vol}`, { timeout: 5_000 });
|
|
448
|
+
} catch (err) {
|
|
449
|
+
recordFailure(failures, `docker volume rm ${vol}`, err);
|
|
450
|
+
}
|
|
158
451
|
}
|
|
159
|
-
} catch {
|
|
452
|
+
} catch (err) {
|
|
453
|
+
recordFailure(failures, 'stale volume sweep', err);
|
|
454
|
+
}
|
|
455
|
+
return failures;
|
|
160
456
|
}
|
|
161
457
|
|
|
162
458
|
function cleanupOnExit() {
|
|
@@ -169,19 +465,27 @@ function cleanupOnExit() {
|
|
|
169
465
|
// Compose first when it can work — it also drops volumes and orphans — but
|
|
170
466
|
// it is best-effort, so never rely on it having done anything.
|
|
171
467
|
try {
|
|
172
|
-
|
|
468
|
+
run(`docker compose -f ${COMPOSE_FILE} -p ${project} down --volumes --remove-orphans`, {
|
|
173
469
|
cwd: PACKAGE_ROOT,
|
|
174
470
|
timeout: 30_000,
|
|
175
471
|
stdio: 'pipe',
|
|
176
472
|
});
|
|
177
|
-
} catch {
|
|
473
|
+
} catch (err) {
|
|
474
|
+
// Best effort: an exit handler has no reliable cwd, so compose can fail
|
|
475
|
+
// to find the file. forceRemoveProject below is the authoritative,
|
|
476
|
+
// compose-free teardown, but the compose attempt still gets logged so a
|
|
477
|
+
// systematic failure is visible instead of absorbed.
|
|
478
|
+
logCleanupFailure(`compose down ${project}`, err);
|
|
479
|
+
}
|
|
178
480
|
// Authoritative: needs no compose file, no cwd, no working directory state.
|
|
179
481
|
forceRemoveProject(project);
|
|
180
482
|
}
|
|
181
483
|
activeProjects.clear();
|
|
182
484
|
try {
|
|
183
|
-
|
|
184
|
-
} catch {
|
|
485
|
+
run('docker network prune -f', { timeout: 10_000, stdio: 'pipe' });
|
|
486
|
+
} catch (err) {
|
|
487
|
+
logCleanupFailure('docker network prune', err);
|
|
488
|
+
}
|
|
185
489
|
}
|
|
186
490
|
|
|
187
491
|
process.on('SIGTERM', () => {
|
|
@@ -219,12 +523,14 @@ process.on('exit', () => {
|
|
|
219
523
|
function run(cmd: string, opts?: ExecSyncOptions): string {
|
|
220
524
|
// Encoding is pinned last so TS narrows the return to `string` even if
|
|
221
525
|
// a caller passes an opts object that could in principle override it.
|
|
222
|
-
return
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
526
|
+
return dockerCli
|
|
527
|
+
.exec(cmd, {
|
|
528
|
+
timeout: COMPOSE_TIMEOUT,
|
|
529
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
530
|
+
...opts,
|
|
531
|
+
encoding: 'utf-8',
|
|
532
|
+
})
|
|
533
|
+
.trim();
|
|
228
534
|
}
|
|
229
535
|
|
|
230
536
|
const COMPOSE_FILE = 'docker-compose.test.yml';
|
|
@@ -312,7 +618,8 @@ export async function scrubDnsZones(): Promise<void> {
|
|
|
312
618
|
}
|
|
313
619
|
}
|
|
314
620
|
|
|
315
|
-
|
|
621
|
+
/** Exported for the unit tests that drive it through a fake DockerCli. */
|
|
622
|
+
export function dockerExec(
|
|
316
623
|
projectName: string,
|
|
317
624
|
composeDir: string,
|
|
318
625
|
container: string,
|
|
@@ -349,11 +656,96 @@ function dockerExec(
|
|
|
349
656
|
return {
|
|
350
657
|
stdout: e.stdout?.toString() ?? '',
|
|
351
658
|
stderr,
|
|
352
|
-
|
|
659
|
+
// The timeout verdict wins over any status the dying client left behind.
|
|
660
|
+
// `docker compose exec` traps the SIGTERM execSync sends on timeout and
|
|
661
|
+
// exits 130 by its own convention (ce-013r, celilo#1293), so the real
|
|
662
|
+
// error carries status: 130 — the client's death code, not the command's
|
|
663
|
+
// verdict. Adopting it read every harness timeout as a mystery exit 130.
|
|
664
|
+
exitCode: timedOut ? 124 : (e.status ?? 1),
|
|
353
665
|
};
|
|
354
666
|
}
|
|
355
667
|
}
|
|
356
668
|
|
|
669
|
+
/**
|
|
670
|
+
* Subnets the simulated internet actually hosts. A nameserver outside all of
|
|
671
|
+
* them blackholes: nothing in the sim owns the address, so every query to it
|
|
672
|
+
* eats its full timeout. ZONE_SUBNETS covers machines a test deploys (a
|
|
673
|
+
* dns_internal provider among them); the three external networks cover the
|
|
674
|
+
* sim's public edge — isp-external (comcast-resolver, the fleet's
|
|
675
|
+
* `dns.primary`), internet-external (the public simulators), and the
|
|
676
|
+
* real-internet transit network behind fw-ext.
|
|
677
|
+
*/
|
|
678
|
+
const SIM_ROUTABLE_SUBNETS: ReadonlyArray<{ cidr: string; label: string }> = [
|
|
679
|
+
...Object.entries(ZONE_SUBNETS).map(([zone, cidr]) => ({ cidr, label: `${zone} zone` })),
|
|
680
|
+
{ cidr: '203.0.113.0/24', label: 'isp-external' },
|
|
681
|
+
{ cidr: '100.64.0.0/24', label: 'internet-external' },
|
|
682
|
+
{ cidr: '172.30.0.0/24', label: 'real-internet' },
|
|
683
|
+
];
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* The nameserver problems in `celilo system config get` output, one string
|
|
687
|
+
* each. Pure so the gate is unit-testable without a daemon. `dns.fallback` is
|
|
688
|
+
* comma-separated; `dns.primary` a single IP — both split on either separator.
|
|
689
|
+
* A missing `dns.primary` is itself a problem: the birth nameserver list would
|
|
690
|
+
* be empty.
|
|
691
|
+
*/
|
|
692
|
+
export function fleetNameserverProblems(configOutput: string): string[] {
|
|
693
|
+
const entries = [...configOutput.matchAll(/^dns\.(primary|fallback) = (.+)$/gm)].map(
|
|
694
|
+
([, key, value]) => ({ key: key as 'primary' | 'fallback', value: value.trim() }),
|
|
695
|
+
);
|
|
696
|
+
const problems: string[] = [];
|
|
697
|
+
if (!entries.some((e) => e.key === 'primary')) {
|
|
698
|
+
problems.push('dns.primary is not set — the birth nameserver list would be empty');
|
|
699
|
+
}
|
|
700
|
+
for (const { key, value } of entries) {
|
|
701
|
+
for (const ip of value.split(/[\s,]+/).filter(Boolean)) {
|
|
702
|
+
if (parseIpv4(ip) === null) {
|
|
703
|
+
problems.push(`dns.${key} entry "${ip}" is not an IPv4 address`);
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
706
|
+
const routable = SIM_ROUTABLE_SUBNETS.some((s) => subnetContains(s.cidr, ip));
|
|
707
|
+
if (!routable) {
|
|
708
|
+
problems.push(
|
|
709
|
+
`dns.${key} nameserver ${ip} is in no simulated subnet (${SIM_ROUTABLE_SUBNETS.map((s) => s.cidr).join(', ')}) — the sim cannot route to it, so every lookup to it blackholes`,
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
return problems;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Recurrence gate for the Gathering Facts hang (celilo#1290). A nameserver in
|
|
719
|
+
* fleet config that the sim cannot route blackholes exactly the lookups
|
|
720
|
+
* ansible's Gathering Facts performs, and the failure used to surface minutes
|
|
721
|
+
* later as an unrelated 600s command timeout. Read back what `system init`
|
|
722
|
+
* actually stored — core's own resolver discovery falls back to 1.1.1.1
|
|
723
|
+
* (dns-discovery.ts), which is unroutable here — and fail in seconds, naming
|
|
724
|
+
* the address.
|
|
725
|
+
*/
|
|
726
|
+
function assertFleetNameserversAreSimRoutable(projectName: string, composeDir: string): void {
|
|
727
|
+
const result = dockerExec(
|
|
728
|
+
projectName,
|
|
729
|
+
composeDir,
|
|
730
|
+
'management',
|
|
731
|
+
'celilo system config get dns.primary; celilo system config get dns.fallback',
|
|
732
|
+
30_000,
|
|
733
|
+
);
|
|
734
|
+
// A missing dns.fallback exits non-zero ("key not found"); that is fine and
|
|
735
|
+
// expected. Only treat the read as failed when even dns.primary is absent.
|
|
736
|
+
if (result.exitCode !== 0 && !result.stdout.includes('dns.primary = ')) {
|
|
737
|
+
throw new Error(
|
|
738
|
+
`Could not read fleet DNS config from the management container: ${result.stderr || result.stdout || '(no output)'}`,
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
const problems = fleetNameserverProblems(result.stdout);
|
|
742
|
+
if (problems.length > 0) {
|
|
743
|
+
throw new Error(
|
|
744
|
+
`Fleet DNS config names nameserver(s) the simulated internet cannot reach — the celilo#1290 failure shape (600s Gathering Facts hang):\n ${problems.join('\n ')}`,
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
357
749
|
/**
|
|
358
750
|
* Fail fast if the management image's baked celilo CLI is older than the
|
|
359
751
|
* harness needs (ce-5qp). Runs before `system init` so a version mismatch
|
|
@@ -378,22 +770,94 @@ function assertCliVersion(projectName: string, composeDir: string): void {
|
|
|
378
770
|
* knows nothing about it, so `docker compose exec` cannot see it. Same wrapping
|
|
379
771
|
* as `dockerExec` so both behave identically from a test's point of view.
|
|
380
772
|
*/
|
|
381
|
-
function plainDockerExec(container: string, cmd: string, timeoutMs = 60_000): ExecResult {
|
|
773
|
+
export function plainDockerExec(container: string, cmd: string, timeoutMs = 60_000): ExecResult {
|
|
382
774
|
try {
|
|
383
775
|
const stdout = run(`docker exec ${container} bash -c ${JSON.stringify(cmd)}`, {
|
|
384
776
|
timeout: timeoutMs,
|
|
385
777
|
});
|
|
386
778
|
return { stdout, stderr: '', exitCode: 0 };
|
|
387
779
|
} catch (err: unknown) {
|
|
388
|
-
const e = err as {
|
|
780
|
+
const e = err as {
|
|
781
|
+
stdout?: string;
|
|
782
|
+
stderr?: string;
|
|
783
|
+
status?: number;
|
|
784
|
+
code?: string;
|
|
785
|
+
signal?: string;
|
|
786
|
+
killed?: boolean;
|
|
787
|
+
};
|
|
788
|
+
// Same timeout mapping as dockerExec: the harness's clock ended the exec,
|
|
789
|
+
// so it reports 124 with the actionable stderr, never the client's own
|
|
790
|
+
// exit status (ce-013r).
|
|
791
|
+
const timedOut = e.code === 'ETIMEDOUT' || e.signal === 'SIGTERM' || e.killed === true;
|
|
389
792
|
return {
|
|
390
793
|
stdout: e.stdout ?? '',
|
|
391
|
-
stderr:
|
|
392
|
-
|
|
794
|
+
stderr: timedOut
|
|
795
|
+
? `timed out after ${Math.round(timeoutMs / 1000)}s running: ${cmd}`
|
|
796
|
+
: (e.stderr ?? String(err)),
|
|
797
|
+
exitCode: timedOut ? 124 : (e.status ?? 1),
|
|
393
798
|
};
|
|
394
799
|
}
|
|
395
800
|
}
|
|
396
801
|
|
|
802
|
+
/**
|
|
803
|
+
* Finds the interface still carrying an address in `subnet` (the alien
|
|
804
|
+
* segment's subnet) inside container `service`, deletes it, and verifies the
|
|
805
|
+
* deletion by re-reading the interface table. Throws naming the surviving
|
|
806
|
+
* interface and container when the interface is still there — the detach path
|
|
807
|
+
* of attachAlienSegment, which re-reads and throws for the same reason:
|
|
808
|
+
* neither docker's exit code nor its own view can be believed for veth
|
|
809
|
+
* surgery, and a surviving alien interface is exactly what makes a later
|
|
810
|
+
* converge refuse somewhere far from the cause (celilo#1261).
|
|
811
|
+
*
|
|
812
|
+
* `exec` is injected so unit tests can drive this against a faked interface
|
|
813
|
+
* table with no docker at all.
|
|
814
|
+
*/
|
|
815
|
+
export function removeAlienInterface(opts: {
|
|
816
|
+
service: string;
|
|
817
|
+
subnet: string;
|
|
818
|
+
exec: (cmd: string) => ExecResult;
|
|
819
|
+
/** Captured failure of `docker network disconnect -f`, carried into the
|
|
820
|
+
* throw's report when the repair does not hold. It is evidence, not a
|
|
821
|
+
* verdict: disconnect legitimately fails on a container docker already
|
|
822
|
+
* considers unattached. */
|
|
823
|
+
disconnectError?: string;
|
|
824
|
+
}): void {
|
|
825
|
+
const prefix = opts.subnet.replace(/\.\d+\/\d+$/, '').replace(/\./g, '\\.');
|
|
826
|
+
const orphanRegex = new RegExp(`(\\S+)\\s+inet\\s+${prefix}\\.\\d+`);
|
|
827
|
+
const orphanIn = (table: string) => orphanRegex.exec(table)?.[1];
|
|
828
|
+
|
|
829
|
+
const check = opts.exec('ip -o addr show scope global');
|
|
830
|
+
const orphan = orphanIn(check.stdout);
|
|
831
|
+
if (!orphan) return;
|
|
832
|
+
|
|
833
|
+
const del = opts.exec(`ip link del ${orphan}`);
|
|
834
|
+
const recheck = opts.exec('ip -o addr show scope global');
|
|
835
|
+
const survivor = orphanIn(recheck.stdout);
|
|
836
|
+
if (survivor) {
|
|
837
|
+
throw new Error(
|
|
838
|
+
[
|
|
839
|
+
`detachAlienSegment: '${opts.service}' still has interface ${survivor} on ${opts.subnet} after repair:`,
|
|
840
|
+
` ip link del ${orphan} exited ${del.exitCode}, stderr: ${del.stderr || '(empty)'}`,
|
|
841
|
+
opts.disconnectError
|
|
842
|
+
? ` docker network disconnect failed: ${opts.disconnectError}`
|
|
843
|
+
: undefined,
|
|
844
|
+
` interface table after repair:\n${recheck.stdout}`,
|
|
845
|
+
]
|
|
846
|
+
.filter((line) => line !== undefined)
|
|
847
|
+
.join('\n'),
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
// The delete reported failure but the interface is gone. docker's exit code
|
|
851
|
+
// is not to be believed for this operation (see attachAlienSegment), so this
|
|
852
|
+
// is surfaced rather than swallowed — and not fatal, since the actual goal
|
|
853
|
+
// state (no alien interface) holds.
|
|
854
|
+
if (del.exitCode !== 0) {
|
|
855
|
+
console.error(
|
|
856
|
+
`detachAlienSegment: 'ip link del ${orphan}' on '${opts.service}' exited ${del.exitCode} but the interface is gone. stderr: ${del.stderr || '(empty)'}`,
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
397
861
|
function dockerExecAsync(
|
|
398
862
|
projectName: string,
|
|
399
863
|
composeDir: string,
|
|
@@ -420,7 +884,7 @@ function dockerExecAsync(
|
|
|
420
884
|
'-c',
|
|
421
885
|
cmd,
|
|
422
886
|
];
|
|
423
|
-
const child = spawn(
|
|
887
|
+
const child = dockerCli.spawn(args, { cwd: composeDir });
|
|
424
888
|
|
|
425
889
|
let stdout = '';
|
|
426
890
|
let stderr = '';
|
|
@@ -462,6 +926,7 @@ async function waitFor(
|
|
|
462
926
|
} catch {
|
|
463
927
|
// retry
|
|
464
928
|
}
|
|
929
|
+
// e2e-sleep-ok: poll cadence inside waitFor; the loop re-checks the condition each iteration.
|
|
465
930
|
await new Promise((r) => setTimeout(r, 2000));
|
|
466
931
|
}
|
|
467
932
|
// Self-diagnosing timeout (e2e-confidence #255): a readiness wait must attach
|
|
@@ -481,67 +946,28 @@ async function waitFor(
|
|
|
481
946
|
}
|
|
482
947
|
|
|
483
948
|
/**
|
|
484
|
-
*
|
|
485
|
-
*
|
|
486
|
-
*
|
|
487
|
-
*
|
|
949
|
+
* Which of these image tags docker does not have locally.
|
|
950
|
+
*
|
|
951
|
+
* One `docker images` listing rather than a per-image `inspect`, because this
|
|
952
|
+
* runs on the startup path of every test and forty process spawns there is its
|
|
953
|
+
* own cost. A docker failure returns "all of them", so the caller builds —
|
|
954
|
+
* being wrong in the direction of doing the work.
|
|
488
955
|
*/
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
956
|
+
/** Exported for the unit tests that drive it through a fake DockerCli. */
|
|
957
|
+
export function missingImages(tags: string[]): string[] {
|
|
958
|
+
if (tags.length === 0) return [];
|
|
959
|
+
let present: Set<string>;
|
|
960
|
+
try {
|
|
961
|
+
present = new Set(
|
|
962
|
+
run('docker images --format "{{.Repository}}:{{.Tag}}"', { timeout: 15_000 })
|
|
963
|
+
.split('\n')
|
|
964
|
+
.map((line) => line.trim())
|
|
965
|
+
.filter(Boolean),
|
|
499
966
|
);
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
function handleLine(line: string) {
|
|
505
|
-
// --progress=plain lines look like: #5 [management 2/4] RUN apt-get ...
|
|
506
|
-
const svcMatch = line.match(/\[([a-z][a-z0-9-]*)\s+\d+\/\d+\]/);
|
|
507
|
-
if (svcMatch) {
|
|
508
|
-
const svc = svcMatch[1];
|
|
509
|
-
if (!seenServices.has(svc)) {
|
|
510
|
-
seenServices.add(svc);
|
|
511
|
-
// Each new service auto-finalizes the previous [progress:start] in the runner.
|
|
512
|
-
console.log(`[progress:start] building image: ${svc} | ${svc} image ready`);
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
let buf = '';
|
|
518
|
-
function onData(chunk: Buffer) {
|
|
519
|
-
buf += chunk.toString();
|
|
520
|
-
const lines = buf.split('\n');
|
|
521
|
-
buf = lines.pop() ?? '';
|
|
522
|
-
for (const line of lines) handleLine(line.trim());
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
proc.stdout.on('data', onData);
|
|
526
|
-
proc.stderr.on('data', (chunk: Buffer) => {
|
|
527
|
-
stderr += chunk.toString();
|
|
528
|
-
onData(chunk);
|
|
529
|
-
});
|
|
530
|
-
|
|
531
|
-
proc.on('close', (code) => {
|
|
532
|
-
if (buf.trim()) handleLine(buf.trim());
|
|
533
|
-
if (code === 0) {
|
|
534
|
-
console.log('[progress:done] images built');
|
|
535
|
-
resolve();
|
|
536
|
-
} else {
|
|
537
|
-
reject(
|
|
538
|
-
new Error(
|
|
539
|
-
`docker compose build failed (exit ${code}):\n${stderr.slice(-500)}${explainBuildFailure(stderr)}`,
|
|
540
|
-
),
|
|
541
|
-
);
|
|
542
|
-
}
|
|
543
|
-
});
|
|
544
|
-
});
|
|
967
|
+
} catch {
|
|
968
|
+
return tags;
|
|
969
|
+
}
|
|
970
|
+
return tags.filter((tag) => !present.has(tag.includes(':') ? tag : `${tag}:latest`));
|
|
545
971
|
}
|
|
546
972
|
|
|
547
973
|
/**
|
|
@@ -739,10 +1165,40 @@ function buildNetworkHandle(
|
|
|
739
1165
|
`respondWith: failed to spawn responder inside management container: ${spawnResult.stderr}`,
|
|
740
1166
|
);
|
|
741
1167
|
}
|
|
742
|
-
//
|
|
743
|
-
//
|
|
744
|
-
//
|
|
745
|
-
|
|
1168
|
+
// The spawn returns as soon as the shell backgrounds the process; bun
|
|
1169
|
+
// then has to boot inside the container and register the responder's
|
|
1170
|
+
// bus watches. A fixed sleep both wastes time on an idle host and is
|
|
1171
|
+
// not enough on a loaded one: dns-replication failed stage 1 when its
|
|
1172
|
+
// deploy's first interview fired before the responder polled, and the
|
|
1173
|
+
// missing-responder error read as a missing fixture value. The log
|
|
1174
|
+
// line below is printed only after startProgrammaticResponder has
|
|
1175
|
+
// registered every watch, so waiting for it is a readiness wait on the
|
|
1176
|
+
// real prerequisite, not a sleep (same pattern as deployFirewall).
|
|
1177
|
+
const responderLog = '/tmp/cele2e-responder.log';
|
|
1178
|
+
await waitFor(
|
|
1179
|
+
async () => {
|
|
1180
|
+
const probe = dockerExec(
|
|
1181
|
+
projectName,
|
|
1182
|
+
composeDir,
|
|
1183
|
+
'management',
|
|
1184
|
+
`grep -q "programmatic responder running" ${responderLog}`,
|
|
1185
|
+
5_000,
|
|
1186
|
+
);
|
|
1187
|
+
return probe.exitCode === 0;
|
|
1188
|
+
},
|
|
1189
|
+
60_000,
|
|
1190
|
+
'the e2e responder to register its bus watches in the management container',
|
|
1191
|
+
async () => {
|
|
1192
|
+
const log = dockerExec(
|
|
1193
|
+
projectName,
|
|
1194
|
+
composeDir,
|
|
1195
|
+
'management',
|
|
1196
|
+
`cat ${responderLog} 2>/dev/null || echo '(no responder log)'`,
|
|
1197
|
+
5_000,
|
|
1198
|
+
);
|
|
1199
|
+
return `responder log (${responderLog}):\n${log.stdout || log.stderr}`;
|
|
1200
|
+
},
|
|
1201
|
+
);
|
|
746
1202
|
},
|
|
747
1203
|
|
|
748
1204
|
async deployFirewall(opts = {}): Promise<ExecResult> {
|
|
@@ -769,7 +1225,19 @@ function buildNetworkHandle(
|
|
|
769
1225
|
//
|
|
770
1226
|
// `external` is excluded: it is the residual, has no subnet, and is not a
|
|
771
1227
|
// zone modules are placed in.
|
|
772
|
-
|
|
1228
|
+
//
|
|
1229
|
+
// `firewallZoneLegs` knows the topology's legs, but not the control-plane
|
|
1230
|
+
// one: the generator adds fw-main's `secure-mgmt` leg as a post-step
|
|
1231
|
+
// (when celilo-mgr lives off the LAN, when a machine declares the zone,
|
|
1232
|
+
// or when the proxmox sim is on), so it never appears in a
|
|
1233
|
+
// topology-derived list. A sim fleet's fw-main then held an address on a
|
|
1234
|
+
// segment with no declared subnet, eth4 classified alien, and the
|
|
1235
|
+
// iptables converge refused (ce-fvxd). The compose file is the wiring and
|
|
1236
|
+
// the wiring decides — the same artifact `topologyFromComposeFile` reads.
|
|
1237
|
+
const baseLegs = firewallZoneLegs(topology);
|
|
1238
|
+
const declaredZones: FirewallLeg[] = fwMainHasSecureMgmtLeg(join(composeDir, COMPOSE_FILE))
|
|
1239
|
+
? [...baseLegs, 'secure-mgmt']
|
|
1240
|
+
: baseLegs;
|
|
773
1241
|
const providedZones = declaredZones.filter((zone) => zone !== 'external');
|
|
774
1242
|
const firewallIp = ZONE_GATEWAYS.internal; // fw-main on internal
|
|
775
1243
|
const natIp = opts.natIp ?? internalNatIp();
|
|
@@ -881,7 +1349,11 @@ function buildNetworkHandle(
|
|
|
881
1349
|
// would make a classification test pass for the wrong reason.
|
|
882
1350
|
try {
|
|
883
1351
|
run(`docker network connect --ip ${ip} ${netName} ${id}`, { timeout: 20_000 });
|
|
884
|
-
} catch {
|
|
1352
|
+
} catch {
|
|
1353
|
+
// Ignorable: failure here is the COMMON outcome (the gateway-exists
|
|
1354
|
+
// case documented above, hit by every attach), and a connect that
|
|
1355
|
+
// genuinely failed cannot hide — the address check below throws.
|
|
1356
|
+
}
|
|
885
1357
|
|
|
886
1358
|
const check = dockerExec(projectName, composeDir, service, 'ip -o addr show scope global');
|
|
887
1359
|
if (!check.stdout.includes(ip)) {
|
|
@@ -902,33 +1374,44 @@ function buildNetworkHandle(
|
|
|
902
1374
|
cwd: composeDir,
|
|
903
1375
|
timeout: 20_000,
|
|
904
1376
|
});
|
|
905
|
-
if (id)
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
1377
|
+
if (!id) continue;
|
|
1378
|
+
// `-f` makes disconnect report failure on a container docker considers
|
|
1379
|
+
// unattached, which after a half-completed attach is exactly the state
|
|
1380
|
+
// being cleaned up here. So the failure is captured for the repair step
|
|
1381
|
+
// to carry in its report, not obeyed — whether the interface actually
|
|
1382
|
+
// left is decided by the interface table, not by docker's exit code.
|
|
1383
|
+
let disconnectError: string | undefined;
|
|
1384
|
+
try {
|
|
1385
|
+
run(`docker network disconnect -f ${netName} ${id}`, { timeout: 20_000 });
|
|
1386
|
+
} catch (err: unknown) {
|
|
1387
|
+
disconnectError = err instanceof Error ? err.message : String(err);
|
|
909
1388
|
}
|
|
1389
|
+
removeAlienInterface({
|
|
1390
|
+
service,
|
|
1391
|
+
subnet: alienSegmentSubnet,
|
|
1392
|
+
disconnectError,
|
|
1393
|
+
exec: (cmd) => dockerExec(projectName, composeDir, service, cmd),
|
|
1394
|
+
});
|
|
910
1395
|
}
|
|
911
1396
|
try {
|
|
912
1397
|
run(`docker network rm ${netName}`, { timeout: 20_000 });
|
|
913
|
-
} catch {
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
// on to expect a clean converge would fail somewhere far from the cause.
|
|
918
|
-
for (const service of attachedToAlienSegment) {
|
|
919
|
-
const check = dockerExec(projectName, composeDir, service, 'ip -o addr show scope global');
|
|
920
|
-
const prefix = alienSegmentSubnet.replace(/\.\d+\/\d+$/, '').replace(/\./g, '\\.');
|
|
921
|
-
const orphan = new RegExp(`(\\S+)\\s+inet\\s+${prefix}\\.\\d+`).exec(check.stdout);
|
|
922
|
-
if (orphan) {
|
|
923
|
-
dockerExec(projectName, composeDir, service, `ip link del ${orphan[1]}`);
|
|
924
|
-
}
|
|
1398
|
+
} catch (err: unknown) {
|
|
1399
|
+
console.error(
|
|
1400
|
+
`detachAlienSegment: docker network rm ${netName} failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1401
|
+
);
|
|
925
1402
|
}
|
|
926
1403
|
attachedToAlienSegment.clear();
|
|
927
1404
|
},
|
|
928
1405
|
|
|
929
1406
|
async deployGreenwave(): Promise<void> {
|
|
930
1407
|
const routerIp = greenwaveRouterIp();
|
|
931
|
-
|
|
1408
|
+
// No `machine add` for the router, and do not restore one: greenwave is an
|
|
1409
|
+
// appliance module (schema.ts `apiOnly`, ISP router) that talks to it over
|
|
1410
|
+
// HTTPS only (scripts/router-api.ts), deploys onto no system
|
|
1411
|
+
// (manifest `requires: capabilities: []`, no `system:`), and never enters
|
|
1412
|
+
// infrastructure selection. Adding the router as a root machine only ran
|
|
1413
|
+
// the fleet aspects against a simulated ISP router over SSH.
|
|
1414
|
+
// @psbanka - 2026-09: removed in ce-5b1v; see celilo#1263.
|
|
932
1415
|
await handle.celilo('module import greenwave');
|
|
933
1416
|
await handle.celilo(`module config set greenwave router_ip ${routerIp}`);
|
|
934
1417
|
await handle.celilo('module secret set greenwave router_username admin');
|
|
@@ -973,13 +1456,18 @@ function buildNetworkHandle(
|
|
|
973
1456
|
const start = Date.now();
|
|
974
1457
|
const timeout = 86_400_000; // 24 hour max debug session
|
|
975
1458
|
while (!existsSync(signalFile) && Date.now() - start < timeout) {
|
|
1459
|
+
// e2e-sleep-ok: waits on a human exiting the debug shell; the signal file is re-checked above.
|
|
976
1460
|
await new Promise((r) => setTimeout(r, 500));
|
|
977
1461
|
}
|
|
978
1462
|
|
|
979
|
-
// Clean up
|
|
1463
|
+
// Clean up. Ignorable: the file only ends THIS pause's wait loop, its
|
|
1464
|
+
// name is pid-unique so a leftover can never satisfy another session's
|
|
1465
|
+
// wait, and the next debug pause writes its own.
|
|
980
1466
|
try {
|
|
981
1467
|
require('node:fs').unlinkSync(signalFile);
|
|
982
|
-
} catch {
|
|
1468
|
+
} catch {
|
|
1469
|
+
// deliberately ignored, see above
|
|
1470
|
+
}
|
|
983
1471
|
|
|
984
1472
|
console.log('[debug:resumed]');
|
|
985
1473
|
},
|
|
@@ -997,17 +1485,29 @@ function buildNetworkHandle(
|
|
|
997
1485
|
if (isNetapp) {
|
|
998
1486
|
netappPath = absPath;
|
|
999
1487
|
} else {
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
);
|
|
1009
|
-
|
|
1010
|
-
|
|
1488
|
+
const staged = stagedNetappIfCurrent(absPath, moduleId);
|
|
1489
|
+
if (staged) {
|
|
1490
|
+
// build-infra already produced this, before the stack was competing
|
|
1491
|
+
// for the host. Repackaging it here is the work that times out.
|
|
1492
|
+
netappPath = staged;
|
|
1493
|
+
} else {
|
|
1494
|
+
netappPath = join(tmpdir(), `${moduleId}-${Date.now()}.netapp`);
|
|
1495
|
+
cleanup = true;
|
|
1496
|
+
const celiloCliPath = join(celiloRoot ?? PACKAGE_ROOT, 'apps/celilo/src/cli/index.ts');
|
|
1497
|
+
const startedAt = Date.now();
|
|
1498
|
+
try {
|
|
1499
|
+
// 180s, not 60s: this runs while the stack is up, and the same work
|
|
1500
|
+
// that takes 11s on an idle host went past 60s under a live stack.
|
|
1501
|
+
run(
|
|
1502
|
+
`bun run ${JSON.stringify(celiloCliPath)} package ${JSON.stringify(absPath)} --output ${JSON.stringify(netappPath)}`,
|
|
1503
|
+
{ timeout: 180_000 },
|
|
1504
|
+
);
|
|
1505
|
+
} catch (err) {
|
|
1506
|
+
const seconds = ((Date.now() - startedAt) / 1000).toFixed(1);
|
|
1507
|
+
throw new Error(
|
|
1508
|
+
`Failed to package module ${moduleId} at ${absPath} after ${seconds}s (no current staged netapp, so it was rebuilt inside a live stack): ${String(err)}`,
|
|
1509
|
+
);
|
|
1510
|
+
}
|
|
1011
1511
|
}
|
|
1012
1512
|
}
|
|
1013
1513
|
|
|
@@ -1029,7 +1529,9 @@ function buildNetworkHandle(
|
|
|
1029
1529
|
if (cleanup)
|
|
1030
1530
|
try {
|
|
1031
1531
|
execSync(`rm -f ${JSON.stringify(netappPath)}`, { stdio: 'pipe' });
|
|
1032
|
-
} catch {
|
|
1532
|
+
} catch (err) {
|
|
1533
|
+
logCleanupFailure(`rm staged netapp ${netappPath}`, err);
|
|
1534
|
+
}
|
|
1033
1535
|
}
|
|
1034
1536
|
},
|
|
1035
1537
|
|
|
@@ -1046,12 +1548,18 @@ function buildNetworkHandle(
|
|
|
1046
1548
|
for (const browser of activeBrowsers) {
|
|
1047
1549
|
try {
|
|
1048
1550
|
await browser.close();
|
|
1049
|
-
} catch {
|
|
1551
|
+
} catch (err) {
|
|
1552
|
+
// A browser that refuses to close must not stop the network teardown
|
|
1553
|
+
// below — the leaked resource is the network, not the browser.
|
|
1554
|
+
logCleanupFailure('browser close', err);
|
|
1555
|
+
}
|
|
1050
1556
|
}
|
|
1051
1557
|
for (const proxy of activeProxies) {
|
|
1052
1558
|
try {
|
|
1053
1559
|
await proxy.stop();
|
|
1054
|
-
} catch {
|
|
1560
|
+
} catch (err) {
|
|
1561
|
+
logCleanupFailure('proxy stop', err);
|
|
1562
|
+
}
|
|
1055
1563
|
}
|
|
1056
1564
|
|
|
1057
1565
|
// Respect --keep / --reuse: don't tear down the network
|
|
@@ -1061,13 +1569,30 @@ function buildNetworkHandle(
|
|
|
1061
1569
|
return;
|
|
1062
1570
|
}
|
|
1063
1571
|
console.log('[progress:start] stopping network | network stopped');
|
|
1572
|
+
// Kill this project's sim-created guests BEFORE compose down. A guest
|
|
1573
|
+
// holds its zone network's endpoint and the ssh-keys volume, so compose
|
|
1574
|
+
// down with the guest alive fails both removals, the error is swallowed
|
|
1575
|
+
// below, and the subnet stays allocated for every later suite
|
|
1576
|
+
// (celilo#1247).
|
|
1577
|
+
try {
|
|
1578
|
+
const guests = run(projectTeardownCommands(projectName).listGuests);
|
|
1579
|
+
if (guests.trim()) {
|
|
1580
|
+
run(`docker rm -f ${guests.replace(/\n/g, ' ')}`, { timeout: 30_000 });
|
|
1581
|
+
}
|
|
1582
|
+
} catch (err) {
|
|
1583
|
+
// Compose down below still runs; without the guest gone it fails both
|
|
1584
|
+
// removals (celilo#1247), so the failure is worth naming, not absorbing.
|
|
1585
|
+
logCleanupFailure(`guest sweep ${projectName}`, err);
|
|
1586
|
+
}
|
|
1064
1587
|
try {
|
|
1065
1588
|
run(`docker compose -f ${COMPOSE_FILE} -p ${projectName} down --volumes --remove-orphans`, {
|
|
1066
1589
|
cwd: composeDir,
|
|
1067
1590
|
timeout: 60_000,
|
|
1068
1591
|
});
|
|
1069
|
-
} catch {
|
|
1070
|
-
// Best effort cleanup
|
|
1592
|
+
} catch (err) {
|
|
1593
|
+
// Best effort cleanup: stop() must never fail the caller over teardown.
|
|
1594
|
+
// The failure is logged so a systematically broken compose is visible.
|
|
1595
|
+
logCleanupFailure(`compose down ${projectName}`, err);
|
|
1071
1596
|
}
|
|
1072
1597
|
activeProjects.delete(projectName);
|
|
1073
1598
|
},
|
|
@@ -1103,47 +1628,15 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
|
|
|
1103
1628
|
await scrubDnsZones();
|
|
1104
1629
|
}
|
|
1105
1630
|
|
|
1106
|
-
// Clean up stale per-test containers (self-healing from prior crashes)
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
} catch {}
|
|
1116
|
-
|
|
1117
|
-
// Remove stale per-test networks AND their volumes, via the same
|
|
1118
|
-
// by-name teardown the exit handler uses. This used to lean on
|
|
1119
|
-
// `docker compose -p <project> down --volumes` for the volume half, which
|
|
1120
|
-
// has no `-f` and no reliable cwd here either — compose exits "no
|
|
1121
|
-
// configuration file provided" and the catch swallows it, so volumes were
|
|
1122
|
-
// never removed on this path either. Reusing forceRemoveProject keeps the
|
|
1123
|
-
// crash-recovery sweep and the exit handler from drifting apart.
|
|
1124
|
-
try {
|
|
1125
|
-
const networks = run('docker network ls --format "{{.Name}}" 2>/dev/null')
|
|
1126
|
-
.split('\n')
|
|
1127
|
-
.filter((n) => n.startsWith('celilo-e2e-1')); // per-test projects start with timestamp
|
|
1128
|
-
const projects = [...new Set(networks.map((n) => n.replace(/_[^_]+$/, '')))];
|
|
1129
|
-
for (const project of projects) {
|
|
1130
|
-
forceRemoveProject(project);
|
|
1131
|
-
}
|
|
1132
|
-
} catch {}
|
|
1133
|
-
// Volumes can outlive every container and network of their project — that
|
|
1134
|
-
// is exactly the 58-volume pile — so sweep by prefix too, not only for
|
|
1135
|
-
// projects that still have a network to be discovered by.
|
|
1136
|
-
try {
|
|
1137
|
-
const vols = run('docker volume ls -q --filter name=celilo-e2e-1 2>/dev/null')
|
|
1138
|
-
.split('\n')
|
|
1139
|
-
.filter(Boolean);
|
|
1140
|
-
for (const vol of vols) {
|
|
1141
|
-
try {
|
|
1142
|
-
run(`docker volume rm ${vol}`, { timeout: 5_000 });
|
|
1143
|
-
} catch {}
|
|
1144
|
-
}
|
|
1145
|
-
} catch {}
|
|
1146
|
-
} catch {}
|
|
1631
|
+
// Clean up stale per-test containers (self-healing from prior crashes).
|
|
1632
|
+
// The sweep is extracted so it is testable; the progress line is honest by
|
|
1633
|
+
// construction: "cleanup complete" only when the sweep reported zero
|
|
1634
|
+
// failures (ce-yuzi).
|
|
1635
|
+
console.log('[progress:start] cleaning up stale test resources | sweeping');
|
|
1636
|
+
const cleanupFailures = sweepStaleTestResources();
|
|
1637
|
+
console.log(
|
|
1638
|
+
`[progress:start] cleaning up stale test resources | ${cleanupProgress(cleanupFailures)}`,
|
|
1639
|
+
);
|
|
1147
1640
|
|
|
1148
1641
|
const projectName = `celilo-e2e-${Date.now()}`;
|
|
1149
1642
|
activeProjects.add(projectName);
|
|
@@ -1155,9 +1648,21 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
|
|
|
1155
1648
|
const yaml = generateTestComposeYaml(config, celiloRoot);
|
|
1156
1649
|
writeFileSync(join(composeDir, COMPOSE_FILE), yaml);
|
|
1157
1650
|
|
|
1158
|
-
//
|
|
1159
|
-
//
|
|
1160
|
-
|
|
1651
|
+
// The compose carries `image:` only (never `build:`), so the run-time path
|
|
1652
|
+
// cannot build and cannot resolve a FROM from docker.io — the fetch that
|
|
1653
|
+
// made 30 outputs fail with `lookup auth.docker.io` (tasks.md 5b.2). A
|
|
1654
|
+
// missing baked tag is a loud failure naming the remedy. Checked for BOTH
|
|
1655
|
+
// compose files here, before shared infra comes up, so an unpopulated
|
|
1656
|
+
// machine is named in seconds rather than discovered mid-`up`.
|
|
1657
|
+
const missing = missingImages([
|
|
1658
|
+
...referencedImages(yaml),
|
|
1659
|
+
...referencedImages(generateSharedInfraYaml()),
|
|
1660
|
+
]);
|
|
1661
|
+
if (missing.length > 0) {
|
|
1662
|
+
throw new Error(
|
|
1663
|
+
`${missing.length} baked image(s) missing from the local store, and suite time never builds: ${missing.join(', ')}.\nRemedy: cele2e build-infra (it owns the network phase — docker base images, apt, caddy, docker-ce). The suite run stays hermetic.`,
|
|
1664
|
+
);
|
|
1665
|
+
}
|
|
1161
1666
|
|
|
1162
1667
|
console.log('[progress:start] starting containers | containers running');
|
|
1163
1668
|
run(`docker compose -f ${COMPOSE_FILE} -p ${projectName} up -d`, {
|
|
@@ -1346,6 +1851,15 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
|
|
|
1346
1851
|
if (config.managementVariant !== 'vanilla') {
|
|
1347
1852
|
assertCliVersion(projectName, composeDir);
|
|
1348
1853
|
console.log('[progress:start] initializing celilo | celilo initialized');
|
|
1854
|
+
// dns.fallback is deliberately NOT set. The sim hosts exactly one resolver
|
|
1855
|
+
// the fleet may name — comcast-resolver at 203.0.113.1 — so the
|
|
1856
|
+
// pre-resolver birth list is just that address. The former value
|
|
1857
|
+
// (1.0.0.1,8.8.8.8) named addresses that exist nowhere in the sim: every
|
|
1858
|
+
// lookup to them ate its full timeout, and ansible's Gathering Facts does
|
|
1859
|
+
// reverse lookups, which is the 600s hang in celilo#1290. The sim's
|
|
1860
|
+
// second public resolver (SIMULATOR_IPS.PUBLIC_RESOLVER) stays OUT of
|
|
1861
|
+
// fleet config on purpose: the public_dns check needs a resolver celilo
|
|
1862
|
+
// does not itself use.
|
|
1349
1863
|
// Honest harness (openspec/specs/progressive-zone-disclosure/spec.md): seed ONLY the
|
|
1350
1864
|
// `internal` zone — the network the management box is genuinely on —
|
|
1351
1865
|
// plus DNS. dmz/app/secure are NOT pre-seeded; they come into being
|
|
@@ -1366,12 +1880,12 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
|
|
|
1366
1880
|
config.managementZone === 'secure-mgmt' || (config.secureMgmtMachines ?? []).length > 0
|
|
1367
1881
|
? `network.secure-mgmt.subnet=${ZONE_SUBNETS['secure-mgmt']} network.secure-mgmt.gateway=${ZONE_GATEWAYS['secure-mgmt']} `
|
|
1368
1882
|
: ''
|
|
1369
|
-
}dns.primary=203.0.113.1
|
|
1370
|
-
dns.fallback=1.0.0.1,8.8.8.8`,
|
|
1883
|
+
}dns.primary=203.0.113.1`,
|
|
1371
1884
|
);
|
|
1372
1885
|
if (initResult.exitCode !== 0) {
|
|
1373
1886
|
throw new Error(`Celilo init failed: ${initResult.stderr}`);
|
|
1374
1887
|
}
|
|
1888
|
+
assertFleetNameserversAreSimRoutable(projectName, composeDir);
|
|
1375
1889
|
|
|
1376
1890
|
// Start the event-bus dispatcher (ISS-0035 / ISS-0042), now that `system
|
|
1377
1891
|
// init` has created the bus DB. Deploys emit bus events —
|
|
@@ -1429,6 +1943,28 @@ function topologyFromComposeFile(composePath: string): TopologyPreset {
|
|
|
1429
1943
|
}
|
|
1430
1944
|
}
|
|
1431
1945
|
|
|
1946
|
+
/**
|
|
1947
|
+
* Whether the generated compose wires fw-main to the secure-mgmt control-plane
|
|
1948
|
+
* network. The generator adds that leg as a post-step after building the
|
|
1949
|
+
* topology services, so it is invisible to `firewallZoneLegs(topology)` — which
|
|
1950
|
+
* is how a wired-but-undeclared eth4 reached the iptables converge and got
|
|
1951
|
+
* refused (ce-fvxd). Exported for the leg-coherence test.
|
|
1952
|
+
*/
|
|
1953
|
+
export function fwMainHasSecureMgmtLeg(composePath: string): boolean {
|
|
1954
|
+
try {
|
|
1955
|
+
const compose = parseYaml(readFileSync(composePath, 'utf-8')) as {
|
|
1956
|
+
services?: Record<string, { networks?: Record<string, unknown> }>;
|
|
1957
|
+
};
|
|
1958
|
+
return Boolean(compose.services?.['fw-main']?.networks?.['secure-mgmt']);
|
|
1959
|
+
} catch {
|
|
1960
|
+
// An unreadable compose file means the stack was not built by this
|
|
1961
|
+
// generator. Reporting "no leg" reproduces the under-declaration, but the
|
|
1962
|
+
// converge refuses loudly on the unaccounted interface, so the failure is
|
|
1963
|
+
// not silent — the same trade `topologyFromComposeFile` makes.
|
|
1964
|
+
return false;
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1432
1968
|
export function reconnectNetwork(projectName: string): NetworkHandle {
|
|
1433
1969
|
const composeDir = PACKAGE_ROOT;
|
|
1434
1970
|
|