@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/e2e",
3
- "version": "0.15.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.3.0",
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",
@@ -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.