@celilo/e2e 0.15.0 → 0.16.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/package.json +2 -2
- package/src/container-manager.ts +115 -0
- package/src/module-host.test.ts +71 -0
- package/src/module-host.ts +73 -0
- package/src/types.ts +50 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/e2e",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"README.md"
|
|
38
38
|
],
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@celilo/capabilities": "^1.
|
|
40
|
+
"@celilo/capabilities": "^1.5.0",
|
|
41
41
|
"@celilo/cli-display": "^0.2.0",
|
|
42
42
|
"@celilo/event-bus": "^0.6.0",
|
|
43
43
|
"@celilo/terraform-fake": "^0.3.0",
|
package/src/container-manager.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
getAllMachines,
|
|
13
13
|
} from './docker-compose-generator';
|
|
14
14
|
import { explainBuildFailure } from './doctor';
|
|
15
|
+
import { type ModuleHost, parseModuleHost } from './module-host';
|
|
15
16
|
import { ensureSharedInfra } from './shared-infra';
|
|
16
17
|
import { SIMULATOR_IPS } from './simulator-ips';
|
|
17
18
|
import { startSocksProxy } from './socks-proxy';
|
|
@@ -369,6 +370,29 @@ function assertCliVersion(projectName: string, composeDir: string): void {
|
|
|
369
370
|
if (problem) throw new Error(problem);
|
|
370
371
|
}
|
|
371
372
|
|
|
373
|
+
/**
|
|
374
|
+
* Exec into a container that is NOT part of the compose project.
|
|
375
|
+
*
|
|
376
|
+
* A guest the Proxmox simulator provisioned is a real container, but compose
|
|
377
|
+
* knows nothing about it, so `docker compose exec` cannot see it. Same wrapping
|
|
378
|
+
* as `dockerExec` so both behave identically from a test's point of view.
|
|
379
|
+
*/
|
|
380
|
+
function plainDockerExec(container: string, cmd: string, timeoutMs = 60_000): ExecResult {
|
|
381
|
+
try {
|
|
382
|
+
const stdout = run(`docker exec ${container} bash -c ${JSON.stringify(cmd)}`, {
|
|
383
|
+
timeout: timeoutMs,
|
|
384
|
+
});
|
|
385
|
+
return { stdout, stderr: '', exitCode: 0 };
|
|
386
|
+
} catch (err: unknown) {
|
|
387
|
+
const e = err as { stdout?: string; stderr?: string; status?: number };
|
|
388
|
+
return {
|
|
389
|
+
stdout: e.stdout ?? '',
|
|
390
|
+
stderr: e.stderr ?? String(err),
|
|
391
|
+
exitCode: e.status ?? 1,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
372
396
|
function dockerExecAsync(
|
|
373
397
|
projectName: string,
|
|
374
398
|
composeDir: string,
|
|
@@ -569,6 +593,97 @@ function buildNetworkHandle(
|
|
|
569
593
|
return Promise.resolve(dockerExec(projectName, composeDir, container, cmd, timeoutMs));
|
|
570
594
|
},
|
|
571
595
|
|
|
596
|
+
async registerControlPlane(options = {}): Promise<void> {
|
|
597
|
+
const moduleDir =
|
|
598
|
+
options.moduleDir ?? join(celiloRoot ?? PACKAGE_ROOT, 'modules', 'celilo-mgmt');
|
|
599
|
+
|
|
600
|
+
await handle.publishModule(moduleDir);
|
|
601
|
+
// Tolerant: this box may already be in the pool depending on how the
|
|
602
|
+
// network came up, and "already there" satisfies the point of the step.
|
|
603
|
+
await handle.celilo('machine add 127.0.0.1 --zone secure-mgmt --earmark celilo-mgmt', {
|
|
604
|
+
check: false,
|
|
605
|
+
});
|
|
606
|
+
await handle.celilo('module import celilo-mgmt');
|
|
607
|
+
// Docker and terraform are already in the management image; installing
|
|
608
|
+
// them again costs minutes and proves nothing here.
|
|
609
|
+
await handle.celilo('module config set celilo-mgmt install_docker false');
|
|
610
|
+
await handle.celilo('module config set celilo-mgmt install_terraform false');
|
|
611
|
+
await handle.celilo(
|
|
612
|
+
'module config set celilo-mgmt db_path /root/.local/share/celilo/celilo.db',
|
|
613
|
+
);
|
|
614
|
+
// Give the box its fleet key BEFORE deploying, using the harness keypair.
|
|
615
|
+
//
|
|
616
|
+
// `ensureFleetKey` (modules/celilo-mgmt/scripts/discovery.ts) mints a new
|
|
617
|
+
// `celilo-fleet` key under the state dir when none exists, and the deploy
|
|
618
|
+
// then sets `ssh.public_key` to it. In a real fleet that is right. In the
|
|
619
|
+
// rig it is not survivable: every target container trusts the HARNESS key,
|
|
620
|
+
// baked into authorized_keys at boot, and a freshly minted key is trusted
|
|
621
|
+
// by nothing. celilo would hold a key no machine accepts, and the next
|
|
622
|
+
// `machine add` fails with "no matching private key was found in ~/.ssh/"
|
|
623
|
+
// — which reads as a missing fixture and is actually a rotated key.
|
|
624
|
+
//
|
|
625
|
+
// `ensureFleetKey` is idempotent and REUSES an existing key, so seeding it
|
|
626
|
+
// with the harness pair means the deploy runs its real code path and
|
|
627
|
+
// arrives at the key the fleet already trusts. This models an operator
|
|
628
|
+
// whose management box already has its fleet key, which is the ordinary
|
|
629
|
+
// case after the first deploy.
|
|
630
|
+
const stateSshDir = '/root/.local/share/celilo/.ssh';
|
|
631
|
+
dockerExec(
|
|
632
|
+
projectName,
|
|
633
|
+
composeDir,
|
|
634
|
+
'management',
|
|
635
|
+
`mkdir -p ${stateSshDir} && chmod 700 ${stateSshDir} && ` +
|
|
636
|
+
`cp /ssh-keys/id_ed25519 ${stateSshDir}/id_ed25519 && ` +
|
|
637
|
+
`cp /ssh-keys/id_ed25519.pub ${stateSshDir}/id_ed25519.pub && ` +
|
|
638
|
+
`chmod 600 ${stateSshDir}/id_ed25519`,
|
|
639
|
+
);
|
|
640
|
+
|
|
641
|
+
await handle.celilo('module deploy celilo-mgmt', 300_000);
|
|
642
|
+
|
|
643
|
+
// Deploying celilo-mgmt onto the management box REWRITES that box's own
|
|
644
|
+
// ~/.ssh. A `machine add` landing in that window fails with "Cannot find
|
|
645
|
+
// SSH private key", which reads as a missing fixture rather than as a
|
|
646
|
+
// race against the deploy that just ran. Wait for the key to be back and
|
|
647
|
+
// readable before handing control to the caller, so no suite has to know
|
|
648
|
+
// this happens.
|
|
649
|
+
await waitFor(
|
|
650
|
+
async () => {
|
|
651
|
+
const probe = dockerExec(
|
|
652
|
+
projectName,
|
|
653
|
+
composeDir,
|
|
654
|
+
'management',
|
|
655
|
+
'test -r /root/.ssh/id_ed25519 && echo ok',
|
|
656
|
+
);
|
|
657
|
+
return probe.stdout.trim() === 'ok';
|
|
658
|
+
},
|
|
659
|
+
60_000,
|
|
660
|
+
"the management box's SSH private key to be readable after celilo-mgmt deploy",
|
|
661
|
+
);
|
|
662
|
+
},
|
|
663
|
+
|
|
664
|
+
async moduleHost(moduleId: string): Promise<ModuleHost> {
|
|
665
|
+
const status = dockerExec(
|
|
666
|
+
projectName,
|
|
667
|
+
composeDir,
|
|
668
|
+
'management',
|
|
669
|
+
`celilo module status ${moduleId}`,
|
|
670
|
+
);
|
|
671
|
+
const host = parseModuleHost(status.stdout);
|
|
672
|
+
if (!host) {
|
|
673
|
+
throw new Error(
|
|
674
|
+
`Could not find where '${moduleId}' is deployed. Is it deployed yet? \`module status\` reported no placement:\n${status.stdout.slice(0, 400)}`,
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
return host;
|
|
678
|
+
},
|
|
679
|
+
|
|
680
|
+
async execOnModuleHost(moduleId, cmd, timeoutMs = 60_000): Promise<ExecResult> {
|
|
681
|
+
const host = await this.moduleHost(moduleId);
|
|
682
|
+
return host.reach === 'plain'
|
|
683
|
+
? plainDockerExec(host.container, cmd, timeoutMs)
|
|
684
|
+
: dockerExec(projectName, composeDir, host.container, cmd, timeoutMs);
|
|
685
|
+
},
|
|
686
|
+
|
|
572
687
|
async respondWith(values): Promise<void> {
|
|
573
688
|
// Write the values JSON inside the management container at a
|
|
574
689
|
// stable path, then start a detached `celilo events respond
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolving a module's host from `module status`.
|
|
3
|
+
*
|
|
4
|
+
* The parsing is the risky half and needs no rig, so it is tested here rather
|
|
5
|
+
* than discovered during a 3-minute e2e run. The input shapes come from
|
|
6
|
+
* `formatPlacementLine` in `apps/celilo/src/services/placement-reconcile.ts`;
|
|
7
|
+
* if that function grows a case, one of these should fail.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test } from 'bun:test';
|
|
11
|
+
import { parseModuleHost } from './module-host';
|
|
12
|
+
|
|
13
|
+
/** How the CLI really prints it — clack gutter and ANSI included. */
|
|
14
|
+
const withGutter = (line: string) => `\x1b[1mModule: caddy\x1b[0m\n│ Placement:\n│ ${line}\n`;
|
|
15
|
+
|
|
16
|
+
describe('parseModuleHost', () => {
|
|
17
|
+
test('a provisioned guest resolves to the container the simulator created', () => {
|
|
18
|
+
const host = parseModuleHost(withGutter('iot (vmid 2100) → pve1 (zone app)'));
|
|
19
|
+
|
|
20
|
+
expect(host).toEqual({
|
|
21
|
+
hostname: 'iot',
|
|
22
|
+
vmid: 2100,
|
|
23
|
+
container: 'celilo-e2e-lxc-2100',
|
|
24
|
+
reach: 'plain',
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('a pool machine resolves to the compose service of the same name', () => {
|
|
29
|
+
const host = parseModuleHost(withGutter('caddy — machine (zone dmz)'));
|
|
30
|
+
|
|
31
|
+
expect(host).toEqual({ hostname: 'caddy', container: 'caddy', reach: 'compose' });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('the two are reached DIFFERENTLY, which is the whole point', () => {
|
|
35
|
+
// A guest the simulator created is a real container but not a compose
|
|
36
|
+
// service, so `docker compose exec` cannot see it. Getting this backwards
|
|
37
|
+
// fails in a way that reads as the module breaking.
|
|
38
|
+
const guest = parseModuleHost(withGutter('iot (vmid 2100) → pve1 (zone app)'));
|
|
39
|
+
const machine = parseModuleHost(withGutter('caddy — machine (zone dmz)'));
|
|
40
|
+
|
|
41
|
+
expect(guest?.reach).toBe('plain');
|
|
42
|
+
expect(machine?.reach).toBe('compose');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('a module that is not deployed yet resolves to nothing, not a guess', () => {
|
|
46
|
+
// A real state — imported but never deployed — and distinguishable from a
|
|
47
|
+
// parse failure, because a test should react differently to each.
|
|
48
|
+
expect(parseModuleHost('Module: caddy\nState: CONFIGURED\n')).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('reads placement even when Proxmox could not be reached', () => {
|
|
52
|
+
// `unreachable` still names the host and vmid, and the container exists
|
|
53
|
+
// regardless of whether celilo could confirm which node holds it.
|
|
54
|
+
const host = parseModuleHost(
|
|
55
|
+
withGutter('iot (vmid 2100) → node unknown — Proxmox unreachable (zone app)'),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
expect(host?.container).toBe('celilo-e2e-lxc-2100');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('is not fooled by the module id appearing elsewhere in the output', () => {
|
|
62
|
+
const output = [
|
|
63
|
+
'Module: caddy',
|
|
64
|
+
'Source: /root/.local/share/celilo/modules/caddy',
|
|
65
|
+
'Placement (live from Proxmox):',
|
|
66
|
+
' web (vmid 305) → pve1 (zone dmz)',
|
|
67
|
+
].join('\n');
|
|
68
|
+
|
|
69
|
+
expect(parseModuleHost(output)?.hostname).toBe('web');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reach a module's deployed host without knowing how it came to exist.
|
|
3
|
+
*
|
|
4
|
+
* A suite that says `net.exec('caddy', …)` is not naming a host — it is naming a
|
|
5
|
+
* COMPOSE SERVICE, which only exists because the test declared a machine in the
|
|
6
|
+
* topology. The moment the same module is placed on a container service, celilo
|
|
7
|
+
* provisions the host itself, there is no compose service by that name, and the
|
|
8
|
+
* call fails in a way that looks like the module broke.
|
|
9
|
+
*
|
|
10
|
+
* That coupling is the single thing standing between the suite and the
|
|
11
|
+
* conversions in celilo#815: every candidate suite execs on its module's host by
|
|
12
|
+
* compose-service name. `execOnModuleHost` asks celilo where the module actually
|
|
13
|
+
* landed and reaches it accordingly, so a suite stops caring which path placed
|
|
14
|
+
* it — which is what lets the SAME suite cover both.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Where a module's host lives, as celilo reports it. */
|
|
18
|
+
export interface ModuleHost {
|
|
19
|
+
hostname: string;
|
|
20
|
+
/** Present only for a guest celilo provisioned; absent for a pool machine. */
|
|
21
|
+
vmid?: number;
|
|
22
|
+
/**
|
|
23
|
+
* The docker container to exec into, and how to reach it.
|
|
24
|
+
*
|
|
25
|
+
* `compose` for a machine the topology declared — it is a service in the
|
|
26
|
+
* project. `plain` for a guest the simulator created, which is a real
|
|
27
|
+
* container but NOT part of the compose project, so `docker compose exec`
|
|
28
|
+
* cannot see it.
|
|
29
|
+
*/
|
|
30
|
+
container: string;
|
|
31
|
+
reach: 'compose' | 'plain';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parse `celilo module status <id>`'s placement section.
|
|
36
|
+
*
|
|
37
|
+
* The two shapes come from `formatPlacementLine`:
|
|
38
|
+
* `<hostname> — machine (zone X)` ← machine pool
|
|
39
|
+
* `<hostname> (vmid N) → <node> (zone X)` ← container service
|
|
40
|
+
*
|
|
41
|
+
* Returns null when no placement line is present, which is a real state — the
|
|
42
|
+
* module is imported but not deployed — and is worth distinguishing from a parse
|
|
43
|
+
* failure, because "not deployed yet" and "I could not read the output" call for
|
|
44
|
+
* different reactions from a test.
|
|
45
|
+
*/
|
|
46
|
+
export function parseModuleHost(statusOutput: string): ModuleHost | null {
|
|
47
|
+
// Strip clack's `│ ` gutter and any ANSI before matching; CLI output carries
|
|
48
|
+
// both, and a raw match silently finds nothing.
|
|
49
|
+
const lines = statusOutput
|
|
50
|
+
.split('\n')
|
|
51
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping ANSI is the point
|
|
52
|
+
.map((line) => line.replace(/\x1b\[[0-9;]*m/g, '').replace(/^[\s│|]+/, ''));
|
|
53
|
+
|
|
54
|
+
for (const line of lines) {
|
|
55
|
+
const container = /^(\S+)\s+\(vmid\s+(\d+)\)\s+→/.exec(line);
|
|
56
|
+
if (container?.[1] && container[2]) {
|
|
57
|
+
const vmid = Number(container[2]);
|
|
58
|
+
return {
|
|
59
|
+
hostname: container[1],
|
|
60
|
+
vmid,
|
|
61
|
+
container: `celilo-e2e-lxc-${vmid}`,
|
|
62
|
+
reach: 'plain',
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const machine = /^(\S+)\s+—\s+machine\s+\(/.exec(line);
|
|
67
|
+
if (machine?.[1]) {
|
|
68
|
+
return { hostname: machine[1], container: machine[1], reach: 'compose' };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return null;
|
|
73
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* selection had nowhere to put it — so a module that named the zone correctly
|
|
8
8
|
* could not be deployed by any test.
|
|
9
9
|
*/
|
|
10
|
+
|
|
11
|
+
import type { ModuleHost } from './module-host';
|
|
10
12
|
export type Zone = 'dmz' | 'app' | 'secure' | 'internal' | 'secure-mgmt';
|
|
11
13
|
|
|
12
14
|
/**
|
|
@@ -252,6 +254,54 @@ export interface NetworkHandle {
|
|
|
252
254
|
/** Execute an arbitrary command in any container */
|
|
253
255
|
exec(container: string, cmd: string, timeoutMs?: number): Promise<ExecResult>;
|
|
254
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Where celilo actually put a module, read from `module status`.
|
|
259
|
+
*
|
|
260
|
+
* Throws if the module is not deployed — "not deployed" and "I could not read
|
|
261
|
+
* the placement" are different problems and a test should not have to tell
|
|
262
|
+
* them apart from a silent empty result.
|
|
263
|
+
*/
|
|
264
|
+
moduleHost(moduleId: string): Promise<ModuleHost>;
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Tell celilo where its own control plane lives, and wait until the box is
|
|
268
|
+
* usable again.
|
|
269
|
+
*
|
|
270
|
+
* Required by any suite that runs celilo-mgr on `secure-mgmt` AND deploys a
|
|
271
|
+
* firewall — which is every container-service conversion, because the Proxmox
|
|
272
|
+
* simulator lives on `secure-mgmt` and `service add proxmox` has to reach it.
|
|
273
|
+
*
|
|
274
|
+
* The firewall's trusted sources are DERIVED from where celilo-mgmt is
|
|
275
|
+
* deployed, not hardcoded, and `secure-mgmt` is deliberately not a tier in the
|
|
276
|
+
* rule matrix — it reaches every tier by trust. So this must run BEFORE
|
|
277
|
+
* `deployFirewall()`, which is when those rules are computed. Skip it and
|
|
278
|
+
* fw-main's default-DROP FORWARD chain has no rule for the control-plane
|
|
279
|
+
* subnet: every SSH from celilo-mgr into a segmented zone times out, and it
|
|
280
|
+
* surfaces as the deployed module's hook failing rather than as a firewall
|
|
281
|
+
* policy gap.
|
|
282
|
+
*
|
|
283
|
+
* Also absorbs a race that is not the caller's business: the deploy rewrites
|
|
284
|
+
* the management box's own `~/.ssh`, so this does not return until the private
|
|
285
|
+
* key is readable again.
|
|
286
|
+
*/
|
|
287
|
+
registerControlPlane(options?: { moduleDir?: string }): Promise<void>;
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Execute a command on the host a module is deployed on, WITHOUT knowing how
|
|
291
|
+
* that host came to exist.
|
|
292
|
+
*
|
|
293
|
+
* `exec('caddy', …)` names a compose service, which exists only because the
|
|
294
|
+
* topology declared a machine. Place the same module on a container service
|
|
295
|
+
* and celilo provisions the host itself: no compose service by that name, and
|
|
296
|
+
* the call fails in a way that reads as the module breaking.
|
|
297
|
+
*
|
|
298
|
+
* This resolves the host from celilo and reaches it the right way — compose
|
|
299
|
+
* exec for a declared machine, plain docker exec for a provisioned guest,
|
|
300
|
+
* which is a real container the compose project knows nothing about. Use it
|
|
301
|
+
* in any suite that could run either way.
|
|
302
|
+
*/
|
|
303
|
+
execOnModuleHost(moduleId: string, cmd: string, timeoutMs?: number): Promise<ExecResult>;
|
|
304
|
+
|
|
255
305
|
/**
|
|
256
306
|
* Pause the test and drop into an interactive shell for debugging.
|
|
257
307
|
* The test resumes when you exit the shell.
|