@celilo/e2e 0.13.1 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,48 @@
1
+ FROM oven/bun:1
2
+
3
+ WORKDIR /app
4
+
5
+ # The Docker CLI is the whole point of this image: creating an LXC means
6
+ # starting a real container on the host daemon, via the socket mounted at run
7
+ # time (D2). `docker-cli` alone — no daemon, nothing to run in here.
8
+ RUN apt-get update && apt-get install -y --no-install-recommends \
9
+ docker.io \
10
+ openssl \
11
+ ca-certificates \
12
+ && rm -rf /var/lib/apt/lists/*
13
+
14
+ # The fake comes from THIS checkout, bundled next to us at build-infra time
15
+ # (src/registry-bundle.ts), not from npm.
16
+ #
17
+ # Installing `@celilo/terraform-fake` here instead would mean the simulator runs
18
+ # whatever npm last published, so a PR that changes the fake would be tested
19
+ # against code it does not touch — the "modules run their bundled copy" trap
20
+ # (celilo#173) in a new place, and silent. Before the first release it cannot
21
+ # work at all: `bun install` resolved `@celilo/terraform-fake@*` against
22
+ # registry.npmjs.org and got a 404.
23
+ # Placed AS the package name, so the entrypoint's import specifier is the same
24
+ # one that resolves against the workspace at typecheck time. A relative path
25
+ # into the bundle would work at runtime and drag a dependency-less copy into
26
+ # `tsc`, which then cannot resolve msw.
27
+ COPY terraform-fake/package.json ./node_modules/@celilo/terraform-fake/package.json
28
+ COPY terraform-fake/src ./node_modules/@celilo/terraform-fake/src
29
+
30
+ # Only the fake's own third-party deps (msw, express, @mswjs/http-middleware),
31
+ # which are ordinary public packages.
32
+ RUN cd node_modules/@celilo/terraform-fake && bun install --production
33
+
34
+ # The rig's half: the provisioner and the zone plan it resolves against.
35
+ COPY src/proxmox-provisioner.ts ./src/proxmox-provisioner.ts
36
+ COPY src/types.ts ./src/types.ts
37
+ COPY simulators/proxmox/entrypoint.ts ./simulators/proxmox/entrypoint.ts
38
+
39
+ # Proxmox is HTTPS-only on 8006 and every consumer skips verification
40
+ # (`pm_tls_insecure`), so a self-signed pair generated at build time is exactly
41
+ # as good as a real one here and needs no fixture to keep in sync.
42
+ RUN mkdir -p /certs && openssl req -x509 -newkey rsa:2048 \
43
+ -keyout /certs/proxmox-sim.key -out /certs/proxmox-sim.crt \
44
+ -days 3650 -nodes -subj "/CN=proxmox.sim" 2>/dev/null
45
+
46
+ EXPOSE 8006
47
+
48
+ ENTRYPOINT ["bun", "run", "simulators/proxmox/entrypoint.ts"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/e2e",
3
- "version": "0.13.1",
3
+ "version": "0.14.1",
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",
@@ -39,7 +39,8 @@
39
39
  "dependencies": {
40
40
  "@celilo/capabilities": "^1.2.0",
41
41
  "@celilo/cli-display": "^0.2.0",
42
- "@celilo/event-bus": "^0.5.0",
42
+ "@celilo/event-bus": "^0.6.0",
43
+ "@celilo/terraform-fake": "^0.2.0",
43
44
  "yaml": "^2.8.0",
44
45
  "zod": "^3.24.1"
45
46
  },
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The Proxmox simulator, as the rig runs it.
3
+ *
4
+ * Everything here is the join between two halves that are deliberately kept
5
+ * apart: `@celilo/terraform-fake` knows the Proxmox API and nothing about this
6
+ * rig, and `proxmox-provisioner` knows this rig and nothing about Proxmox. This
7
+ * file is the only place that holds both, and it is also the only place that
8
+ * holds the host Docker socket (D2) — which is why it is an image in the rig
9
+ * rather than anything the published package ships.
10
+ */
11
+ // In the image this specifier resolves to the BUNDLED copy from this checkout
12
+ // (Dockerfile.proxmox-sim places it at node_modules/@celilo/terraform-fake),
13
+ // never to whatever npm last published — a PR that changes the fake must be
14
+ // tested against the fake it changed.
15
+ import { createProxmoxFake } from '@celilo/terraform-fake';
16
+ import { createDockerProvisioner } from '../../src/proxmox-provisioner';
17
+
18
+ const port = Number(process.env.PROXMOX_SIM_PORT ?? 8006);
19
+
20
+ /**
21
+ * The compose project name, which prefixes the networks and volumes the
22
+ * provisioner attaches containers to.
23
+ *
24
+ * Required rather than defaulted: a wrong guess here does not fail, it starts
25
+ * containers on a network no test is looking at, and the deploy failure that
26
+ * follows points at DNS or the firewall instead.
27
+ */
28
+ const project = process.env.CELILO_E2E_PROJECT;
29
+ if (!project) {
30
+ throw new Error('CELILO_E2E_PROJECT is required — it names the compose project to attach to');
31
+ }
32
+
33
+ const fake = createProxmoxFake({
34
+ tls: {
35
+ key: await Bun.file('/certs/proxmox-sim.key').text(),
36
+ cert: await Bun.file('/certs/proxmox-sim.crt').text(),
37
+ },
38
+ // One node, named to match what a test sets as `target_node`.
39
+ nodes: [
40
+ {
41
+ name: process.env.PROXMOX_SIM_NODE ?? 'pve1',
42
+ cores: 8,
43
+ memoryBytes: 16 * 1024 ** 3,
44
+ diskBytes: 500 * 1024 ** 3,
45
+ },
46
+ ],
47
+ provisioner: createDockerProvisioner({ project }),
48
+ });
49
+
50
+ await fake.listen(port);
51
+ console.log(`[proxmox-sim] listening on https://0.0.0.0:${port}/api2/json (project ${project})`);
package/src/cli/build.ts CHANGED
@@ -25,7 +25,7 @@ import { gunzipSync } from 'node:zlib';
25
25
  import { stageAptRepo } from '../../scripts/stage-apt-repo';
26
26
  import { stageLibsignal } from '../../scripts/stage-libsignal';
27
27
  import { explainBuildFailure } from '../doctor';
28
- import { ensureRegistryServerBundle } from '../registry-bundle';
28
+ import { ensureRegistryServerBundle, ensureTerraformFakeBundle } from '../registry-bundle';
29
29
 
30
30
  /**
31
31
  * Ceiling on a single `docker build`. The slowest image here is a few minutes
@@ -213,6 +213,7 @@ export const PUBLISHED_CELILO_PACKAGES = [
213
213
  '@celilo/cli-display',
214
214
  '@celilo/core',
215
215
  '@celilo/event-bus',
216
+ '@celilo/terraform-fake',
216
217
  '@celilo/e2e',
217
218
  '@celilo/cli',
218
219
  ] as const;
@@ -501,6 +502,7 @@ function buildDockerImages(pkgDir: string): void {
501
502
  // Dockerfile.registry copies from <pkgDir>/registry-server, which is
502
503
  // .gitignored and recreated from the canonical source each build.
503
504
  ensureRegistryServerBundle(pkgDir);
505
+ ensureTerraformFakeBundle(pkgDir);
504
506
 
505
507
  const dockerDir = join(pkgDir, 'docker');
506
508
  if (!existsSync(dockerDir)) {
@@ -637,17 +637,12 @@ function buildNetworkHandle(
637
637
  // remember, and removes a hand-maintained copy of a list the compose
638
638
  // generator already owns.
639
639
  //
640
- // `external` is excluded from provided_networks: it is the residual, has
641
- // no subnet, and is not a zone modules are placed in.
640
+ // `external` is excluded: it is the residual, has no subnet, and is not a
641
+ // zone modules are placed in.
642
642
  const declaredZones = firewallZoneLegs(topology);
643
643
  const providedZones = declaredZones.filter((zone) => zone !== 'external');
644
644
  const firewallIp = ZONE_GATEWAYS.internal; // fw-main on internal
645
645
  const natIp = opts.natIp ?? internalNatIp();
646
- const providedNetworks = providedZones.map((zone) => ({
647
- zone,
648
- subnet: ZONE_SUBNETS[zone],
649
- gateway: ZONE_GATEWAYS[zone],
650
- }));
651
646
 
652
647
  // fw-main is a firewall container, not a target machine, so the
653
648
  // network readiness wait (target-setup) does NOT cover its sshd. Poll
@@ -670,9 +665,24 @@ function buildNetworkHandle(
670
665
  `firewall ${firewallIp} sshd`,
671
666
  );
672
667
 
673
- // fw-main is registered as an internal-zone machine; iptables
674
- // deploys to it and (Phase 2) writes the provided zones to system
675
- // config from its on_install hook.
668
+ // The addressing of each segmented zone, supplied the way an operator
669
+ // supplies it — `system config set`, celilo's own surface.
670
+ //
671
+ // This used to ride in on `module config set iptables provided_networks`,
672
+ // and the module wrote it to system config from its install hook. Modules
673
+ // no longer define networks
674
+ // (openspec/changes/networks-are-declared-not-written/specs/network-declaration/spec.md);
675
+ // the manifest REQUIRES them, and celilo asks whoever is attached for any
676
+ // it does not already hold. The harness answers in advance rather than
677
+ // through the interview, because these values are the simulator's own
678
+ // address plan (ZONE_SUBNETS) — it is not a stand-in for an operator's
679
+ // judgement, it is the topology this rig physically wired.
680
+ for (const zone of providedZones) {
681
+ await handle.celilo(`system config set network.${zone}.subnet ${ZONE_SUBNETS[zone]}`);
682
+ await handle.celilo(`system config set network.${zone}.gateway ${ZONE_GATEWAYS[zone]}`);
683
+ }
684
+
685
+ // fw-main is registered as an internal-zone machine; iptables deploys to it.
676
686
  await handle.celilo(
677
687
  `machine add ${firewallIp} --ssh-user root --ssh-key-file /root/.ssh/id_ed25519 --zone internal`,
678
688
  );
@@ -680,9 +690,6 @@ function buildNetworkHandle(
680
690
  await handle.celilo(`module config set iptables firewall_ip ${firewallIp}`);
681
691
  await handle.celilo(`module config set iptables nat_ip ${natIp}`);
682
692
  await handle.celilo(`module config set iptables zones '${JSON.stringify(declaredZones)}'`);
683
- await handle.celilo(
684
- `module config set iptables provided_networks '${JSON.stringify(providedNetworks)}'`,
685
- );
686
693
  // `check: false` returns the failed deploy instead of throwing, so a test
687
694
  // can assert on WHAT the refusal said. Interface classification refuses
688
695
  // by design (D12 onboarding), and a refusal is only useful if it names
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { stringify } from 'yaml';
4
4
  import { normalizeObservers, observerEnv, observerPlacement } from './observer';
5
- import { SIMULATOR_IPS } from './simulator-ips';
5
+ import { PROXMOX_SIM_IP, SIMULATOR_IPS } from './simulator-ips';
6
6
  import type { MachineSpec, NetworkConfig, TopologyPreset, Zone } from './types';
7
7
  import {
8
8
  ZONE_GATEWAYS,
@@ -462,6 +462,31 @@ export function generateSharedInfraYaml(): string {
462
462
  // Per-test compose generation
463
463
  // ---------------------------------------------------------------------------
464
464
 
465
+ /**
466
+ * The Proxmox API simulator, on `secure-mgmt` with celilo-mgr (D6).
467
+ *
468
+ * Defined once and emitted from BOTH compose blocks. Adding a simulator to
469
+ * only one of them is the standard way to end up with something that works in
470
+ * exactly half the suites, and the half it fails in is whichever one nobody
471
+ * ran first.
472
+ *
473
+ * It holds the host Docker socket because creating an LXC has to produce a
474
+ * real container (D2). The containment for that is in the provisioner: every
475
+ * container it creates carries the `celilo-e2e-` prefix and it refuses to
476
+ * touch one that does not.
477
+ */
478
+ function proxmoxSimService() {
479
+ return baseService({
480
+ build: { context: '.', dockerfile: 'docker/Dockerfile.proxmox-sim' },
481
+ networks: { 'secure-mgmt': { ipv4_address: PROXMOX_SIM_IP } },
482
+ volumes: ['/var/run/docker.sock:/var/run/docker.sock'],
483
+ // Compose substitutes this at up-time from `-p`, so the generator does not
484
+ // have to know the project name (it is not a property of the YAML).
485
+ environment: { CELILO_E2E_PROJECT: '${COMPOSE_PROJECT_NAME}' },
486
+ cap_add: ['NET_ADMIN'],
487
+ });
488
+ }
489
+
465
490
  /**
466
491
  * Generate the compose YAML for a single test's containers.
467
492
  * References the shared infrastructure's isp-external network as external.
@@ -472,7 +497,10 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
472
497
  // The control-plane network exists if celilo-mgr lives there OR if any
473
498
  // machine does — a module declaring `zone: secure-mgmt` needs somewhere to
474
499
  // land whether or not the management box shares the network (#436).
475
- const needsSecureMgmt = mgmtOnOwnNetwork || (config.secureMgmtMachines ?? []).length > 0;
500
+ // The proxmox simulator lives here too, so enabling it must bring the network
501
+ // up even in a topology where celilo-mgr sits on the internal LAN.
502
+ const needsSecureMgmt =
503
+ mgmtOnOwnNetwork || (config.secureMgmtMachines ?? []).length > 0 || config.proxmoxSim === true;
476
504
  const networks: Record<string, unknown> = {
477
505
  internal: zoneNetworkDef('internal'),
478
506
  dmz: zoneNetworkDef('dmz'),
@@ -635,6 +663,10 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
635
663
  });
636
664
 
637
665
  // --- Dynamic test machines ---
666
+ if (config.proxmoxSim) {
667
+ services['proxmox-sim'] = proxmoxSimService();
668
+ }
669
+
638
670
  const allMachines = getAllMachines(config);
639
671
 
640
672
  for (const machine of allMachines) {
@@ -749,6 +781,10 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
749
781
  dmz: zoneNetworkDef('dmz'),
750
782
  app: zoneNetworkDef('app'),
751
783
  secure: zoneNetworkDef('secure'),
784
+ // The proxmox simulator lives on secure-mgmt, so enabling it has to bring
785
+ // the network with it — a service on a network the file never declares is
786
+ // a compose error at `up`, not a missing feature at deploy.
787
+ ...(config.proxmoxSim ? { 'secure-mgmt': zoneNetworkDef('secure-mgmt') } : {}),
752
788
  'isp-external': networkDef('203.0.113.0/24', '203.0.113.250'),
753
789
  'internet-external': networkDef('100.64.0.0/24', '100.64.0.250'),
754
790
  'real-internet': {
@@ -895,6 +931,10 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
895
931
  volumes: getRegistryVolumes(),
896
932
  });
897
933
 
934
+ if (config.proxmoxSim) {
935
+ services['proxmox-sim'] = proxmoxSimService();
936
+ }
937
+
898
938
  const allMachines = getAllMachines(config);
899
939
 
900
940
  for (const machine of allMachines) {
package/src/index.ts CHANGED
@@ -88,7 +88,7 @@ export {
88
88
 
89
89
  // Simulator addresses on `internet-external` — canonical for code, so a test
90
90
  // asserting against the authoritative DNS server never hardcodes its IP.
91
- export { SIMULATOR_IPS, SIMULATOR_IP_ENTRIES } from './simulator-ips';
91
+ export { PROXMOX_SIM_IP, SIMULATOR_IPS, SIMULATOR_IP_ENTRIES } from './simulator-ips';
92
92
  export type { SimulatorIpName } from './simulator-ips';
93
93
 
94
94
  // Shared infrastructure management (for test runners)
@@ -140,6 +140,20 @@ export class NetworkBuilder {
140
140
  return this;
141
141
  }
142
142
 
143
+ /**
144
+ * Add the Proxmox API simulator on `secure-mgmt`.
145
+ *
146
+ * Turns on the only path in the suite that deploys a module through a
147
+ * `container_service` rather than the machine pool: IPAM allocation, the
148
+ * deployed-system recording, the infrastructure-variable resolver and the
149
+ * DNS-ingress reservation. Creating an LXC against it starts a real
150
+ * container, so what Ansible then configures is a genuinely reachable host.
151
+ */
152
+ withProxmoxSim(): this {
153
+ this.config.proxmoxSim = true;
154
+ return this;
155
+ }
156
+
143
157
  /**
144
158
  * Add a real, UNLINKED signal-cli daemon on the internal network.
145
159
  *
@@ -0,0 +1,196 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ /**
3
+ * The provisioner's logic, with `docker` stubbed — no daemon involved.
4
+ *
5
+ * What is worth testing here is everything BEFORE the shell-out: which zone
6
+ * network a guest lands on, that the container is named so the cleanup sweep
7
+ * can find it, and that readiness is waited on rather than assumed.
8
+ */
9
+ import type { GuestRecord } from '@celilo/terraform-fake';
10
+ import {
11
+ CONTAINER_PREFIX,
12
+ containerNameFor,
13
+ createDockerProvisioner,
14
+ parseNet,
15
+ zoneForGateway,
16
+ } from './proxmox-provisioner';
17
+ import { ZONE_GATEWAYS, zoneIp } from './types';
18
+
19
+ /**
20
+ * Addresses are DERIVED from the zone plan, never written out. A literal here
21
+ * pins an address to a zone role, and the two drift the moment the plan moves:
22
+ * an earlier draft of this file paired dmz's gateway with an address from the
23
+ * app range, and every assertion still passed because they were only ever
24
+ * compared against each other.
25
+ */
26
+ const DMZ_ADDRESS = zoneIp('dmz', 13);
27
+ const NET0 = `name=eth0,bridge=vmbr0,gw=${ZONE_GATEWAYS.dmz},ip=${DMZ_ADDRESS}/24,tag=20`;
28
+
29
+ const guest = (overrides: Record<string, string> = {}): GuestRecord => ({
30
+ vmid: 203,
31
+ node: 'pve1',
32
+ kind: 'lxc',
33
+ hostname: 'test-host',
34
+ status: 'running',
35
+ config: { net0: NET0, ...overrides },
36
+ });
37
+
38
+ /** Records argv instead of running docker; replies `active` to the readiness poll. */
39
+ function stubDocker() {
40
+ const calls: string[][] = [];
41
+ const runner = (args: string[]): string => {
42
+ calls.push(args);
43
+ return args.includes('is-active') ? 'active\n' : '';
44
+ };
45
+ return { calls, runner, find: (verb: string) => calls.find((c) => c[0] === verb) };
46
+ }
47
+
48
+ const provisionerWith = (docker: (args: string[]) => string) =>
49
+ createDockerProvisioner({ project: 'celilo-e2e-test', docker, sleep: async () => {} });
50
+
51
+ describe('createGuest', () => {
52
+ test('runs the container on the zone network at the address terraform was given', async () => {
53
+ const { runner, find } = stubDocker();
54
+ await provisionerWith(runner).createGuest(guest());
55
+
56
+ const run = find('run') ?? [];
57
+ expect(run).toContain('celilo-e2e-test_dmz');
58
+ expect(run[run.indexOf('--ip') + 1]).toBe(DMZ_ADDRESS);
59
+ expect(run.at(-1)).toBe('celilo-e2e/target-machine');
60
+ });
61
+
62
+ test('names the container so the debris sweep can find it', async () => {
63
+ // cele2e doctor and the by-name cleanup both key off this prefix; an
64
+ // unprefixed container leaks silently between runs.
65
+ const { runner, find } = stubDocker();
66
+ await provisionerWith(runner).createGuest(guest());
67
+
68
+ const name = (find('run') ?? [])[(find('run') ?? []).indexOf('--name') + 1];
69
+ expect(name).toStartWith(CONTAINER_PREFIX);
70
+ expect(name).toBe('celilo-e2e-lxc-203');
71
+ });
72
+
73
+ test('passes the gateway through, since target-setup routes from it', async () => {
74
+ const { runner, find } = stubDocker();
75
+ await provisionerWith(runner).createGuest(guest());
76
+
77
+ expect(find('run')).toContain(`GATEWAY=${ZONE_GATEWAYS.dmz}`);
78
+ });
79
+
80
+ test('mounts the ssh-keys volume the machine pool already uses', async () => {
81
+ const { runner, find } = stubDocker();
82
+ await provisionerWith(runner).createGuest(guest());
83
+
84
+ expect(find('run')).toContain('celilo-e2e-test_ssh-keys:/ssh-keys:ro');
85
+ });
86
+
87
+ test('the app zone gets the dockerd-capable image', async () => {
88
+ const { runner, find } = stubDocker();
89
+ const net0 = `name=eth0,gw=${ZONE_GATEWAYS.app},ip=${zoneIp('app', 13)}/24`;
90
+ await provisionerWith(runner).createGuest(guest({ net0 }));
91
+
92
+ expect((find('run') ?? []).at(-1)).toBe('celilo-e2e/target-machine-docker');
93
+ });
94
+
95
+ test('waits for target-setup before returning', async () => {
96
+ // Returning early lets Ansible race sshd, which surfaces as an
97
+ // intermittent connection failure several steps later.
98
+ let ready = false;
99
+ const calls: string[][] = [];
100
+ const runner = (args: string[]): string => {
101
+ calls.push(args);
102
+ if (!args.includes('is-active')) return '';
103
+ const answer = ready ? 'active\n' : 'activating\n';
104
+ ready = true;
105
+ return answer;
106
+ };
107
+ await provisionerWith(runner).createGuest(guest());
108
+
109
+ expect(calls.filter((c) => c.includes('is-active')).length).toBe(2);
110
+ });
111
+
112
+ test('gives up rather than hanging when the container never boots', async () => {
113
+ const runner = (args: string[]): string => {
114
+ if (args.includes('is-active')) throw new Error('container not running');
115
+ return '';
116
+ };
117
+ const provisioner = createDockerProvisioner({
118
+ project: 'p',
119
+ docker: runner,
120
+ readyTimeoutMs: 0,
121
+ sleep: async () => {},
122
+ });
123
+
124
+ expect(provisioner.createGuest(guest())).rejects.toThrow('did not go active');
125
+ });
126
+
127
+ test('appends ssh_public_keys AFTER boot, since target-setup overwrites the file', async () => {
128
+ const { runner, calls } = stubDocker();
129
+ await provisionerWith(runner).createGuest(
130
+ guest({ ssh_public_keys: 'ssh-ed25519 AAAAC3 test' }),
131
+ );
132
+
133
+ const append = calls.findIndex((c) => c.join(' ').includes('authorized_keys'));
134
+ const ready = calls.findIndex((c) => c.includes('is-active'));
135
+ expect(append).toBeGreaterThan(ready);
136
+ });
137
+
138
+ test('refuses a gateway belonging to no zone rather than guessing one', async () => {
139
+ const { runner } = stubDocker();
140
+ const net0 = 'name=eth0,gw=192.0.2.1,ip=192.0.2.10/24';
141
+
142
+ expect(provisionerWith(runner).createGuest(guest({ net0 }))).rejects.toThrow('no rig zone');
143
+ });
144
+
145
+ test('refuses a net0 with no address rather than starting an unreachable box', async () => {
146
+ const { runner } = stubDocker();
147
+
148
+ expect(provisionerWith(runner).createGuest(guest({ net0: 'name=eth0' }))).rejects.toThrow(
149
+ 'no gw/ip',
150
+ );
151
+ });
152
+ });
153
+
154
+ describe('destroyGuest', () => {
155
+ test('removes the container', () => {
156
+ const { runner, find } = stubDocker();
157
+ provisionerWith(runner).destroyGuest(guest());
158
+
159
+ expect(find('rm')).toEqual(['rm', '-f', 'celilo-e2e-lxc-203']);
160
+ });
161
+ });
162
+
163
+ describe('the containment that makes the mounted Docker socket acceptable', () => {
164
+ test('nothing without the prefix is ever touched', () => {
165
+ // The simulator can reach every container on the developer's machine. This
166
+ // is the guard that keeps it to its own (D2).
167
+ expect(containerNameFor(203)).toStartWith(CONTAINER_PREFIX);
168
+ });
169
+ });
170
+
171
+ describe('parseNet', () => {
172
+ test('unpacks the comma-packed net0 Proxmox uses', () => {
173
+ expect(parseNet(NET0)).toMatchObject({
174
+ name: 'eth0',
175
+ bridge: 'vmbr0',
176
+ ip: `${DMZ_ADDRESS}/24`,
177
+ tag: '20',
178
+ });
179
+ });
180
+
181
+ test('an empty net0 yields nothing rather than throwing', () => {
182
+ expect(parseNet('')).toEqual({});
183
+ });
184
+ });
185
+
186
+ describe('zoneForGateway', () => {
187
+ test('every rig zone resolves from its own gateway', () => {
188
+ for (const zone of Object.keys(ZONE_GATEWAYS) as Array<keyof typeof ZONE_GATEWAYS>) {
189
+ expect(zoneForGateway(ZONE_GATEWAYS[zone])).toBe(zone);
190
+ }
191
+ });
192
+
193
+ test('an unknown gateway resolves to nothing', () => {
194
+ expect(zoneForGateway('192.0.2.1')).toBeUndefined();
195
+ });
196
+ });
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Makes a fake Proxmox LXC into a real, SSH-able Docker container.
3
+ *
4
+ * This is the callback `@celilo/terraform-fake` calls on create and destroy.
5
+ * It lives here rather than in the package because everything it knows —
6
+ * zone networks, the `target-machine` image, the compose project name — is
7
+ * rig-specific (D9).
8
+ *
9
+ * The container is the SAME image and the SAME boot contract the machine pool
10
+ * already uses, so a container-service deploy and a machine-pool deploy differ
11
+ * only in how the host came to exist. `target-setup.service` inside the image
12
+ * already installs the fleet key from the mounted `ssh-keys` volume, sets the
13
+ * default route from `GATEWAY`, and points DNS at the resolver — so this
14
+ * reuses that rather than reimplementing any of it, and readiness is the same
15
+ * `systemctl is-active target-setup` the harness waits on for machines.
16
+ */
17
+ import { execFileSync } from 'node:child_process';
18
+ import type { GuestRecord } from '@celilo/terraform-fake';
19
+ import { ZONE_GATEWAYS, type Zone } from './types';
20
+
21
+ /**
22
+ * Every container this creates carries the prefix, and it refuses to touch one
23
+ * that does not.
24
+ *
25
+ * Both halves matter. The prefix is what `cele2e doctor` and the by-name
26
+ * cleanup sweep use to find debris, so an unprefixed container leaks silently
27
+ * between runs. The refusal is the containment that made mounting the host
28
+ * Docker socket acceptable (D2): the simulator can reach every container on
29
+ * the developer's machine, and must only ever act on its own.
30
+ */
31
+ export const CONTAINER_PREFIX = 'celilo-e2e-';
32
+
33
+ /** Shells out to `docker`. Injected so the logic is testable without a daemon. */
34
+ export type DockerRunner = (args: string[]) => string;
35
+
36
+ export const realDocker: DockerRunner = (args) =>
37
+ execFileSync('docker', args, { encoding: 'utf-8', timeout: 60_000 });
38
+
39
+ export interface DockerProvisionerOptions {
40
+ /** Compose project name — networks and volumes are prefixed with it. */
41
+ project: string;
42
+ docker?: DockerRunner;
43
+ /** Poll budget for `target-setup` to go active. Matches the machine-pool wait. */
44
+ readyTimeoutMs?: number;
45
+ sleep?: (ms: number) => Promise<void>;
46
+ }
47
+
48
+ export const containerNameFor = (vmid: number): string => `${CONTAINER_PREFIX}lxc-${vmid}`;
49
+
50
+ /** Parse Proxmox's comma-packed `net0` into its parts. */
51
+ export function parseNet(net0: string): Record<string, string> {
52
+ const parts: Record<string, string> = {};
53
+ for (const pair of net0.split(',')) {
54
+ const [key, ...rest] = pair.split('=');
55
+ if (key && rest.length > 0) parts[key] = rest.join('=');
56
+ }
57
+ return parts;
58
+ }
59
+
60
+ /**
61
+ * Which rig zone a guest belongs to, from the gateway Terraform was given.
62
+ *
63
+ * Deliberately keyed off the gateway rather than the VLAN tag: `ZONE_GATEWAYS`
64
+ * is already the rig's source of truth for zone addressing, so this cannot
65
+ * drift from it the way a second tag→zone table would.
66
+ */
67
+ export function zoneForGateway(gateway: string): Zone | undefined {
68
+ return (Object.keys(ZONE_GATEWAYS) as Zone[]).find((zone) => ZONE_GATEWAYS[zone] === gateway);
69
+ }
70
+
71
+ export function createDockerProvisioner(options: DockerProvisionerOptions) {
72
+ const docker = options.docker ?? realDocker;
73
+ const readyTimeoutMs = options.readyTimeoutMs ?? 60_000;
74
+ const sleep = options.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms)));
75
+
76
+ const assertOurs = (name: string): void => {
77
+ if (!name.startsWith(CONTAINER_PREFIX)) {
78
+ throw new Error(`refusing to act on ${name}: not a ${CONTAINER_PREFIX} container`);
79
+ }
80
+ };
81
+
82
+ return {
83
+ async createGuest(guest: GuestRecord): Promise<void> {
84
+ const net = parseNet(guest.config.net0 ?? '');
85
+ const gateway = net.gw;
86
+ const cidr = net.ip;
87
+ if (!gateway || !cidr) {
88
+ throw new Error(`guest ${guest.vmid} has no gw/ip in net0: ${guest.config.net0}`);
89
+ }
90
+
91
+ const zone = zoneForGateway(gateway);
92
+ if (!zone) {
93
+ throw new Error(`no rig zone has gateway ${gateway} (guest ${guest.vmid})`);
94
+ }
95
+
96
+ const name = containerNameFor(guest.vmid);
97
+ assertOurs(name);
98
+
99
+ // Same image selection rule the compose generator uses for machines: the
100
+ // app zone needs a dockerd-capable box.
101
+ const image =
102
+ zone === 'app' ? 'celilo-e2e/target-machine-docker' : 'celilo-e2e/target-machine';
103
+
104
+ docker([
105
+ 'run',
106
+ '-d',
107
+ '--name',
108
+ name,
109
+ '--hostname',
110
+ guest.hostname,
111
+ '--network',
112
+ `${options.project}_${zone}`,
113
+ '--ip',
114
+ cidr.split('/')[0] ?? '',
115
+ '--privileged',
116
+ '--tmpfs',
117
+ '/run',
118
+ '--tmpfs',
119
+ '/run/lock',
120
+ '--tmpfs',
121
+ '/tmp',
122
+ '-v',
123
+ `${options.project}_ssh-keys:/ssh-keys:ro`,
124
+ '-e',
125
+ `GATEWAY=${gateway}`,
126
+ image,
127
+ ]);
128
+
129
+ await this.waitUntilReady(name);
130
+
131
+ // Proxmox really does honour `ssh_public_keys`, and a module may pass a
132
+ // key that is not the fleet key in the mounted volume. Appended AFTER
133
+ // target-setup has run, because that script *copies* authorized_keys
134
+ // over and would otherwise clobber this.
135
+ const key = guest.config.ssh_public_keys?.trim();
136
+ if (key) {
137
+ docker([
138
+ 'exec',
139
+ name,
140
+ 'bash',
141
+ '-c',
142
+ `mkdir -p /root/.ssh && printf '%s\\n' ${JSON.stringify(key)} >> /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys`,
143
+ ]);
144
+ }
145
+ },
146
+
147
+ /**
148
+ * Block until the container's own boot contract says it is ready.
149
+ *
150
+ * Reporting the create task OK before this would let Ansible race sshd,
151
+ * which surfaces as an intermittent connection failure several steps later.
152
+ */
153
+ async waitUntilReady(name: string): Promise<void> {
154
+ const deadline = Date.now() + readyTimeoutMs;
155
+ while (Date.now() < deadline) {
156
+ try {
157
+ const out = docker(['exec', name, 'systemctl', 'is-active', 'target-setup']);
158
+ if (out.trim() === 'active') return;
159
+ } catch {
160
+ // Still booting: `docker exec` fails until systemd is up.
161
+ }
162
+ await sleep(500);
163
+ }
164
+ throw new Error(`${name} target-setup did not go active within ${readyTimeoutMs}ms`);
165
+ },
166
+
167
+ destroyGuest(guest: GuestRecord): void {
168
+ const name = containerNameFor(guest.vmid);
169
+ assertOurs(name);
170
+ docker(['rm', '-f', name]);
171
+ },
172
+ };
173
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The simulator reaches BOTH generated compose files, or neither.
3
+ *
4
+ * Wiring a simulator into one generator and not the other is the standard way
5
+ * to produce something that works in exactly half the suites — and the half it
6
+ * fails in is whichever one nobody ran first. These tests are cheap precisely
7
+ * because that failure is expensive.
8
+ */
9
+ import { describe, expect, test } from 'bun:test';
10
+ import { generateComposeYaml, generateTestComposeYaml } from './docker-compose-generator';
11
+ import { PROXMOX_SIM_IP } from './simulator-ips';
12
+ import type { NetworkConfig } from './types';
13
+
14
+ const config = (overrides: Partial<NetworkConfig> = {}): NetworkConfig => ({
15
+ topology: 'default',
16
+ dmzMachines: [],
17
+ appMachines: [],
18
+ secureMachines: [],
19
+ internalMachines: [],
20
+ secureMgmtMachines: [],
21
+ domain: 'iamtheinternet.org',
22
+ ddnsPassword: 'test123',
23
+ verifyRouting: false,
24
+ managementVolumes: [],
25
+ dhcpClient: false,
26
+ signalCli: false,
27
+ signalSim: false,
28
+ signalRelease: false,
29
+ ...overrides,
30
+ });
31
+
32
+ const GENERATORS: Array<[string, (c: NetworkConfig) => string]> = [
33
+ ['generateTestComposeYaml', (c) => generateTestComposeYaml(c)],
34
+ ['generateComposeYaml', (c) => generateComposeYaml(c)],
35
+ ];
36
+
37
+ describe.each(GENERATORS)('%s', (_name, generate) => {
38
+ test('omits the simulator entirely when it is not asked for', () => {
39
+ // Every existing suite deploys onto the machine pool. None of them should
40
+ // pay for a container they do not use.
41
+ expect(generate(config())).not.toContain('proxmox-sim');
42
+ });
43
+
44
+ test('emits the simulator when enabled', () => {
45
+ expect(generate(config({ proxmoxSim: true }))).toContain('proxmox-sim');
46
+ });
47
+
48
+ test('puts it on secure-mgmt at the address celilo will be pointed at', () => {
49
+ // secure-mgmt with celilo-mgr, not a data-plane tier: Proxmox creates the
50
+ // fleet rather than being consumed by it (D6).
51
+ const yaml = generate(config({ proxmoxSim: true }));
52
+
53
+ expect(yaml).toContain(PROXMOX_SIM_IP);
54
+ expect(yaml).toContain('secure-mgmt');
55
+ });
56
+
57
+ test('declares the secure-mgmt network it puts the simulator on', () => {
58
+ // A service on a network the file never declares is a compose error at
59
+ // `up`, and it reads as a topology problem rather than a missing line.
60
+ const yaml = generate(config({ proxmoxSim: true }));
61
+ const networksBlock = yaml.slice(yaml.indexOf('networks:'), yaml.indexOf('services:'));
62
+
63
+ expect(networksBlock).toContain('secure-mgmt');
64
+ });
65
+
66
+ test('mounts the Docker socket, since creating an LXC must make a real box', () => {
67
+ expect(generate(config({ proxmoxSim: true }))).toContain('/var/run/docker.sock');
68
+ });
69
+
70
+ test('passes the compose project through, which names the networks to attach to', () => {
71
+ // Left to a default, the provisioner would start containers on a network
72
+ // no test is watching, and the deploy failure would point at DNS.
73
+ expect(generate(config({ proxmoxSim: true }))).toContain('CELILO_E2E_PROJECT');
74
+ });
75
+ });
@@ -54,6 +54,7 @@ const EVERYTHING: NetworkConfig = {
54
54
  signalCli: true,
55
55
  signalSim: true,
56
56
  signalRelease: true,
57
+ proxmoxSim: true,
57
58
  };
58
59
 
59
60
  function publicSimulators(yaml: string): Array<{ name: string; dockerfile: string }> {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Bundle helper for the @celilo/registry-server source.
2
+ * Bundle helpers for sibling-package sources a Dockerfile has to COPY.
3
3
  *
4
4
  * Why this exists: Dockerfile.registry COPYs from a `registry-server/`
5
5
  * directory in its build context. When cele2e runs from inside the
@@ -16,6 +16,15 @@
16
16
  *
17
17
  * The bundled directory is .gitignored — it's a build artifact, not
18
18
  * canonical source. The canonical source remains packages/registry-server.
19
+ *
20
+ * The same reasoning applies to @celilo/terraform-fake, which
21
+ * Dockerfile.proxmox-sim needs, with one addition that is not merely
22
+ * convenience: the simulator MUST run the workspace copy, not the published
23
+ * one. Installing it from npm inside the image would mean a PR that changes the
24
+ * fake is tested against whatever was last released — the "modules run their
25
+ * bundled copy" trap (celilo#173) in a new place, and silent. It also cannot
26
+ * work before the first release: `bun install` in the image resolved
27
+ * `@celilo/terraform-fake@*` against registry.npmjs.org and got a 404.
19
28
  */
20
29
 
21
30
  import { cpSync, existsSync, rmSync } from 'node:fs';
@@ -33,22 +42,40 @@ import { join } from 'node:path';
33
42
  * published incorrectly.
34
43
  */
35
44
  export function ensureRegistryServerBundle(pkgDir: string): void {
36
- const bundle = join(pkgDir, 'registry-server');
37
- const upstream = join(pkgDir, '..', 'registry-server');
45
+ ensureSiblingBundle(pkgDir, 'registry-server', ['package.json', 'tsconfig.json', 'src']);
46
+ }
47
+
48
+ /**
49
+ * Ensure <pkgDir>/terraform-fake/ holds the source Dockerfile.proxmox-sim
50
+ * COPYs, so the simulator runs the fake from THIS checkout.
51
+ */
52
+ export function ensureTerraformFakeBundle(pkgDir: string): void {
53
+ ensureSiblingBundle(pkgDir, 'terraform-fake', ['package.json', 'src']);
54
+ }
55
+
56
+ /**
57
+ * Copy `entries` from the sibling package `name` into `<pkgDir>/<name>`.
58
+ *
59
+ * In the monorepo the bundle is wiped and recopied every time — cheap, and it
60
+ * means the image can never build from a stale copy. When npm-installed there
61
+ * is no sibling and the bundle must already have shipped in the tarball; a
62
+ * missing one is a publish defect, not something to paper over.
63
+ */
64
+ function ensureSiblingBundle(pkgDir: string, name: string, entries: string[]): void {
65
+ const bundle = join(pkgDir, name);
66
+ const upstream = join(pkgDir, '..', name);
38
67
 
39
68
  if (!existsSync(upstream)) {
40
69
  if (!existsSync(bundle)) {
41
70
  throw new Error(
42
- `cele2e: ${bundle} is missing and ../registry-server doesn't exist either.\nThe @celilo/e2e package was published without its registry-server bundle. Re-publish from infra/scripts/publish.ts so the bundle is recreated.`,
71
+ `cele2e: ${bundle} is missing and ../${name} doesn't exist either.\nThe @celilo/e2e package was published without its ${name} bundle. Re-publish from infra/scripts/publish.ts so the bundle is recreated.`,
43
72
  );
44
73
  }
45
74
  return; // npm-installed; bundle ships in tarball
46
75
  }
47
76
 
48
- // Monorepo: wipe + copy the canonical source so the bundle is always
49
- // fresh. We only need three things — package.json, tsconfig.json, src/.
50
77
  rmSync(bundle, { recursive: true, force: true });
51
- cpSync(join(upstream, 'package.json'), join(bundle, 'package.json'));
52
- cpSync(join(upstream, 'tsconfig.json'), join(bundle, 'tsconfig.json'));
53
- cpSync(join(upstream, 'src'), join(bundle, 'src'), { recursive: true });
78
+ for (const entry of entries) {
79
+ cpSync(join(upstream, entry), join(bundle, entry), { recursive: true });
80
+ }
54
81
  }
@@ -18,7 +18,7 @@ import {
18
18
  SHARED_PROJECT_NAME,
19
19
  generateSharedInfraYaml,
20
20
  } from './docker-compose-generator';
21
- import { ensureRegistryServerBundle } from './registry-bundle';
21
+ import { ensureRegistryServerBundle, ensureTerraformFakeBundle } from './registry-bundle';
22
22
 
23
23
  const SHARED_COMPOSE_FILE = 'docker-compose.shared.yml';
24
24
  const COMPOSE_TIMEOUT = 120_000;
@@ -153,6 +153,9 @@ export async function ensureSharedInfra(): Promise<void> {
153
153
  // COPY resolves whether we're in the monorepo (regenerated from the
154
154
  // sibling) or installed from npm (already bundled in the tarball).
155
155
  ensureRegistryServerBundle(e2eDir);
156
+ // Same reason, for Dockerfile.proxmox-sim: the simulator runs the fake from
157
+ // this checkout rather than whatever npm last published.
158
+ ensureTerraformFakeBundle(e2eDir);
156
159
 
157
160
  // Generate and write compose file
158
161
  const yaml = generateSharedInfraYaml();
@@ -1,3 +1,5 @@
1
+ import { zoneIp } from './types';
2
+
1
3
  /**
2
4
  * Single source of truth for the IPs of e2e simulator containers on
3
5
  * the `internet-external` Docker network.
@@ -71,6 +73,22 @@ export const SIMULATOR_IPS = {
71
73
 
72
74
  export type SimulatorIpName = keyof typeof SIMULATOR_IPS;
73
75
 
76
+ /**
77
+ * Proxmox API simulator — the one simulator NOT on `internet-external`, and so
78
+ * deliberately not a member of `SIMULATOR_IPS` above.
79
+ *
80
+ * It lives on `secure-mgmt` with celilo-mgr (D6): Proxmox *creates* the fleet
81
+ * rather than being consumed by it, which makes it control-plane, not a public
82
+ * internet service. Putting it in the map would also hand it to
83
+ * `check-simulator-ips.sh`, which scans for the internet-external address
84
+ * space and would be checking the wrong thing.
85
+ *
86
+ * Derived from `zoneIp` rather than written out, so it cannot drift from the
87
+ * zone plan. `.1` is the gateway and `.100` is celilo-mgr; `90` follows the
88
+ * convention the other zone-resident simulators use.
89
+ */
90
+ export const PROXMOX_SIM_IP = zoneIp('secure-mgmt', 90);
91
+
74
92
  /**
75
93
  * Iterable list of `[name, ip]` pairs — useful for the sanity check
76
94
  * script and any future consumer that wants to enumerate the set.
package/src/types.ts CHANGED
@@ -68,6 +68,19 @@ export interface NetworkConfig {
68
68
  managementVolumes: string[];
69
69
  /** Include a DHCP client container on the internal network */
70
70
  dhcpClient: boolean;
71
+ /**
72
+ * Include the Proxmox API simulator on `secure-mgmt`.
73
+ *
74
+ * Turns on the ONLY path in the suite that deploys a module through a
75
+ * `container_service` rather than the machine pool — IPAM allocation, the
76
+ * deployed-system recording, the infrastructure-variable resolver and the
77
+ * DNS-ingress reservation. Optional because it costs a container and a
78
+ * `service add`, and every existing suite deploys onto the machine pool.
79
+ *
80
+ * On `secure-mgmt` with celilo-mgr rather than a data-plane tier: Proxmox
81
+ * creates the fleet rather than being consumed by it (D6).
82
+ */
83
+ proxmoxSim?: boolean;
71
84
  /**
72
85
  * Include a REAL signal-cli daemon on the internal network.
73
86
  *
@@ -454,6 +467,17 @@ export interface ResponderValues {
454
467
  * silently approved). Passed straight through to `celilo events respond`.
455
468
  */
456
469
  aspects?: Record<string, boolean>;
470
+ /**
471
+ * Generic interview answers, keyed `<scope>.<key>` (ISS-0127).
472
+ *
473
+ * The value's shape follows the question: string for text/select, string[]
474
+ * for multiselect, boolean for confirm. This is how a command that is neither
475
+ * a deploy nor a module — `service add proxmox`, say — gets driven headlessly.
476
+ *
477
+ * The responder has supported this family since ISS-0127; only this type was
478
+ * missing it, which made the capability unreachable from a test.
479
+ */
480
+ interview?: Record<string, unknown>;
457
481
  }
458
482
 
459
483
  /**