@celilo/e2e 0.13.0 → 0.14.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/docker/Dockerfile.firewall +12 -1
- package/docker/Dockerfile.proxmox-sim +48 -0
- package/package.json +4 -3
- package/simulators/proxmox/entrypoint.ts +51 -0
- package/src/cli/build.ts +3 -1
- package/src/container-manager.ts +144 -23
- package/src/docker-compose-generator.ts +42 -2
- package/src/index.ts +1 -1
- package/src/network-builder.ts +14 -0
- package/src/proxmox-provisioner.test.ts +196 -0
- package/src/proxmox-provisioner.ts +173 -0
- package/src/proxmox-sim-compose.test.ts +75 -0
- package/src/public-sim-routes.test.ts +1 -0
- package/src/registry-bundle.ts +36 -9
- package/src/shared-infra.ts +4 -1
- package/src/simulator-ips.ts +18 -0
- package/src/types.ts +52 -9
|
@@ -12,10 +12,21 @@ RUN apt-get update && apt-get install -y \
|
|
|
12
12
|
dnsutils \
|
|
13
13
|
procps \
|
|
14
14
|
python3 \
|
|
15
|
-
wireguard-tools \
|
|
16
15
|
wireguard-go \
|
|
17
16
|
&& rm -rf /var/lib/apt/lists/*
|
|
18
17
|
|
|
18
|
+
# `wireguard-tools` is DELIBERATELY not installed here. The wireguard module's
|
|
19
|
+
# job includes putting it on the host, and baking it in meant the rig proved the
|
|
20
|
+
# tunnel worked on the one kind of host that could never exercise that: the
|
|
21
|
+
# module only ever ran `wg --version`, found it, and moved on. On a real firewall
|
|
22
|
+
# the deploy failed and told the operator to install wireguard by hand — over the
|
|
23
|
+
# access the VPN was being deployed to provide.
|
|
24
|
+
#
|
|
25
|
+
# `wireguard-go` stays, and is a different kind of thing. A container cannot load
|
|
26
|
+
# a kernel module, so wg-quick needs a userspace implementation to fall back to;
|
|
27
|
+
# that is the simulator standing in for hardware it does not have, not a step
|
|
28
|
+
# celilo is supposed to perform.
|
|
29
|
+
|
|
19
30
|
# Containers have no wireguard kernel module to load, and a test must not depend
|
|
20
31
|
# on whether the Docker host happens to have one. wg-quick falls back to this
|
|
21
32
|
# userspace implementation when `ip link add type wireguard` fails, so the tunnel
|
|
@@ -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.
|
|
3
|
+
"version": "0.14.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,9 +37,10 @@
|
|
|
37
37
|
"README.md"
|
|
38
38
|
],
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@celilo/capabilities": "^1.
|
|
40
|
+
"@celilo/capabilities": "^1.2.0",
|
|
41
41
|
"@celilo/cli-display": "^0.2.0",
|
|
42
|
-
"@celilo/event-bus": "^0.
|
|
42
|
+
"@celilo/event-bus": "^0.5.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)) {
|
package/src/container-manager.ts
CHANGED
|
@@ -535,6 +535,9 @@ function buildNetworkHandle(
|
|
|
535
535
|
// Playwright browsers). stop() tears these down before the docker
|
|
536
536
|
// network itself.
|
|
537
537
|
const activeProxies: SocksProxyHandle[] = [];
|
|
538
|
+
/** Services plugged into the undeclared segment, and the subnet it uses. */
|
|
539
|
+
const attachedToAlienSegment = new Set<string>();
|
|
540
|
+
let alienSegmentSubnet = '';
|
|
538
541
|
const activeBrowsers: BrowserHandle[] = [];
|
|
539
542
|
|
|
540
543
|
const handle: NetworkHandle = {
|
|
@@ -612,26 +615,34 @@ function buildNetworkHandle(
|
|
|
612
615
|
await new Promise((r) => setTimeout(r, 500));
|
|
613
616
|
},
|
|
614
617
|
|
|
615
|
-
async deployFirewall(opts = {}): Promise<
|
|
616
|
-
//
|
|
617
|
-
//
|
|
618
|
-
//
|
|
619
|
-
//
|
|
620
|
-
//
|
|
618
|
+
async deployFirewall(opts = {}): Promise<ExecResult> {
|
|
619
|
+
// Everything the firewall declares is derived from the topology it is
|
|
620
|
+
// wired for. `opts.zones` used to select which zones got
|
|
621
|
+
// `provided_networks`, and that cannot survive interface classification:
|
|
622
|
+
// `provided_networks` IS what writes `network.<zone>.subnet`, so a leg
|
|
623
|
+
// left out of it has no declared subnet, classifies `alien`, and the
|
|
624
|
+
// converge refuses on a firewall celilo has never understood.
|
|
621
625
|
//
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
//
|
|
625
|
-
//
|
|
626
|
-
|
|
626
|
+
// That is the real rule, not a harness quirk. A firewall holding an
|
|
627
|
+
// address on a segment celilo has no subnet for is one celilo cannot
|
|
628
|
+
// describe. So the wired legs and the declared zones are ONE list (D8),
|
|
629
|
+
// and a test wanting fewer legs changes the topology.
|
|
630
|
+
//
|
|
631
|
+
// The old `opts.zones` never said which NICs the box has — the compose
|
|
632
|
+
// topology decides that, and it wires fw-main to every segmented zone
|
|
633
|
+
// regardless of what a test passed. Roughly twenty call sites passed
|
|
634
|
+
// `['dmz']` against a four-leg firewall, so every one of them
|
|
635
|
+
// under-declared the hardware. Deriving from the topology makes that
|
|
636
|
+
// structurally impossible rather than something each test author has to
|
|
637
|
+
// remember, and removes a hand-maintained copy of a list the compose
|
|
638
|
+
// generator already owns.
|
|
639
|
+
//
|
|
640
|
+
// `external` is excluded: it is the residual, has no subnet, and is not a
|
|
641
|
+
// zone modules are placed in.
|
|
627
642
|
const declaredZones = firewallZoneLegs(topology);
|
|
643
|
+
const providedZones = declaredZones.filter((zone) => zone !== 'external');
|
|
628
644
|
const firewallIp = ZONE_GATEWAYS.internal; // fw-main on internal
|
|
629
645
|
const natIp = opts.natIp ?? internalNatIp();
|
|
630
|
-
const providedNetworks = providedZones.map((zone) => ({
|
|
631
|
-
zone,
|
|
632
|
-
subnet: ZONE_SUBNETS[zone],
|
|
633
|
-
gateway: ZONE_GATEWAYS[zone],
|
|
634
|
-
}));
|
|
635
646
|
|
|
636
647
|
// fw-main is a firewall container, not a target machine, so the
|
|
637
648
|
// network readiness wait (target-setup) does NOT cover its sshd. Poll
|
|
@@ -654,9 +665,24 @@ function buildNetworkHandle(
|
|
|
654
665
|
`firewall ${firewallIp} sshd`,
|
|
655
666
|
);
|
|
656
667
|
|
|
657
|
-
//
|
|
658
|
-
//
|
|
659
|
-
//
|
|
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.
|
|
660
686
|
await handle.celilo(
|
|
661
687
|
`machine add ${firewallIp} --ssh-user root --ssh-key-file /root/.ssh/id_ed25519 --zone internal`,
|
|
662
688
|
);
|
|
@@ -664,10 +690,105 @@ function buildNetworkHandle(
|
|
|
664
690
|
await handle.celilo(`module config set iptables firewall_ip ${firewallIp}`);
|
|
665
691
|
await handle.celilo(`module config set iptables nat_ip ${natIp}`);
|
|
666
692
|
await handle.celilo(`module config set iptables zones '${JSON.stringify(declaredZones)}'`);
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
)
|
|
670
|
-
|
|
693
|
+
// `check: false` returns the failed deploy instead of throwing, so a test
|
|
694
|
+
// can assert on WHAT the refusal said. Interface classification refuses
|
|
695
|
+
// by design (D12 onboarding), and a refusal is only useful if it names
|
|
696
|
+
// the interface — which is an assertion about output, not about an
|
|
697
|
+
// exception having been raised.
|
|
698
|
+
return opts.check === false
|
|
699
|
+
? await handle.celilo('module deploy iptables', { check: false, timeoutMs: 180_000 })
|
|
700
|
+
: await handle.celilo('module deploy iptables', 180_000);
|
|
701
|
+
},
|
|
702
|
+
|
|
703
|
+
async attachAlienSegment(opts: {
|
|
704
|
+
subnet: string;
|
|
705
|
+
containers: string[];
|
|
706
|
+
}): Promise<Record<string, string>> {
|
|
707
|
+
// A REAL cable into a REAL port. Interface classification is about what
|
|
708
|
+
// celilo finds on the box, so a test that fakes the finding — a dummy
|
|
709
|
+
// link, a stubbed interface table — is testing its own fixture. This
|
|
710
|
+
// creates an actual docker network celilo has no subnet for and plugs
|
|
711
|
+
// real containers into it, which is what an operator hanging an
|
|
712
|
+
// unmanaged switch off a spare port produces: a new interface, a global
|
|
713
|
+
// address, and something live on the other side to prove reachability
|
|
714
|
+
// with.
|
|
715
|
+
//
|
|
716
|
+
// The subnet is the caller's, and it must not be one of ZONE_SUBNETS —
|
|
717
|
+
// the whole point is that celilo cannot attribute it.
|
|
718
|
+
const netName = `${projectName}_alien-seg`;
|
|
719
|
+
alienSegmentSubnet = opts.subnet;
|
|
720
|
+
run(`docker network create --driver bridge --subnet ${opts.subnet} ${netName}`, {
|
|
721
|
+
timeout: 20_000,
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
const assigned: Record<string, string> = {};
|
|
725
|
+
opts.containers.forEach((service, index) => {
|
|
726
|
+
// .2 upward: .1 is the bridge itself.
|
|
727
|
+
const ip = opts.subnet.replace(/\.0\/\d+$/, `.${index + 2}`);
|
|
728
|
+
const id = run(`docker compose -p ${projectName} -f ${COMPOSE_FILE} ps -q ${service}`, {
|
|
729
|
+
cwd: composeDir,
|
|
730
|
+
timeout: 20_000,
|
|
731
|
+
});
|
|
732
|
+
if (!id) throw new Error(`attachAlienSegment: no container for service '${service}'`);
|
|
733
|
+
|
|
734
|
+
// `docker network connect` EXITS NONZERO on a container that already has
|
|
735
|
+
// a default route — "failed to set gateway while updating gateway: file
|
|
736
|
+
// exists" — while attaching the interface perfectly well: the address
|
|
737
|
+
// lands, the veth is up, and traffic flows both ways. Every container in
|
|
738
|
+
// this topology routes through the firewall, so every attach hits it.
|
|
739
|
+
// `docker network inspect` is no help either; it lists no containers for
|
|
740
|
+
// this network whether the attach worked or not.
|
|
741
|
+
//
|
|
742
|
+
// So neither the exit code nor docker's own view can be believed, and
|
|
743
|
+
// the address on the box is the only trustworthy signal. Verified below
|
|
744
|
+
// rather than assumed — a half-completed attach really does leave an
|
|
745
|
+
// address behind with no working wire, which is precisely the state that
|
|
746
|
+
// would make a classification test pass for the wrong reason.
|
|
747
|
+
try {
|
|
748
|
+
run(`docker network connect --ip ${ip} ${netName} ${id}`, { timeout: 20_000 });
|
|
749
|
+
} catch {}
|
|
750
|
+
|
|
751
|
+
const check = dockerExec(projectName, composeDir, service, 'ip -o addr show scope global');
|
|
752
|
+
if (!check.stdout.includes(ip)) {
|
|
753
|
+
throw new Error(
|
|
754
|
+
`attachAlienSegment: '${service}' has no ${ip} after connecting to ${netName}:\n${check.stdout}`,
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
assigned[service] = ip;
|
|
758
|
+
attachedToAlienSegment.add(service);
|
|
759
|
+
});
|
|
760
|
+
return assigned;
|
|
761
|
+
},
|
|
762
|
+
|
|
763
|
+
async detachAlienSegment(): Promise<void> {
|
|
764
|
+
const netName = `${projectName}_alien-seg`;
|
|
765
|
+
for (const service of attachedToAlienSegment) {
|
|
766
|
+
const id = run(`docker compose -p ${projectName} -f ${COMPOSE_FILE} ps -q ${service}`, {
|
|
767
|
+
cwd: composeDir,
|
|
768
|
+
timeout: 20_000,
|
|
769
|
+
});
|
|
770
|
+
if (id) {
|
|
771
|
+
try {
|
|
772
|
+
run(`docker network disconnect -f ${netName} ${id}`, { timeout: 20_000 });
|
|
773
|
+
} catch {}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
try {
|
|
777
|
+
run(`docker network rm ${netName}`, { timeout: 20_000 });
|
|
778
|
+
} catch {}
|
|
779
|
+
|
|
780
|
+
// VERIFY, for the same reason attach does. A lingering address on a dead
|
|
781
|
+
// wire is still an interface celilo cannot attribute, so a test that goes
|
|
782
|
+
// on to expect a clean converge would fail somewhere far from the cause.
|
|
783
|
+
for (const service of attachedToAlienSegment) {
|
|
784
|
+
const check = dockerExec(projectName, composeDir, service, 'ip -o addr show scope global');
|
|
785
|
+
const prefix = alienSegmentSubnet.replace(/\.\d+\/\d+$/, '').replace(/\./g, '\\.');
|
|
786
|
+
const orphan = new RegExp(`(\\S+)\\s+inet\\s+${prefix}\\.\\d+`).exec(check.stdout);
|
|
787
|
+
if (orphan) {
|
|
788
|
+
dockerExec(projectName, composeDir, service, `ip link del ${orphan[1]}`);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
attachedToAlienSegment.clear();
|
|
671
792
|
},
|
|
672
793
|
|
|
673
794
|
async deployGreenwave(): Promise<void> {
|
|
@@ -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
|
-
|
|
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)
|
package/src/network-builder.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/registry-bundle.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Bundle
|
|
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
|
-
|
|
37
|
-
|
|
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
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
cpSync(join(upstream, entry), join(bundle, entry), { recursive: true });
|
|
80
|
+
}
|
|
54
81
|
}
|
package/src/shared-infra.ts
CHANGED
|
@@ -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();
|
package/src/simulator-ips.ts
CHANGED
|
@@ -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
|
*
|
|
@@ -363,18 +376,16 @@ export interface NetworkHandle {
|
|
|
363
376
|
* Targets fw-main (the customer firewall) using the standard topology
|
|
364
377
|
* subnets from ZONE_SUBNETS / ZONE_GATEWAYS.
|
|
365
378
|
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
379
|
+
* Both the declared zone list AND `provided_networks` are derived from the
|
|
380
|
+
* topology this network was built with. There is no `zones` option: a firewall
|
|
381
|
+
* holding an address on a segment celilo has no subnet for classifies `alien`
|
|
382
|
+
* and the converge refuses, so the wired legs and the declared zones must be
|
|
383
|
+
* the same list. A test that wants a firewall with fewer legs changes the
|
|
384
|
+
* topology, which is the honest way to say it (design D8).
|
|
370
385
|
*
|
|
371
|
-
* @param opts.zones - zones to PROVIDE (default: dmz, app, secure)
|
|
372
386
|
* @param opts.natIp - NAT IP for port-forwarded traffic (default 10.226.1.253)
|
|
373
387
|
*/
|
|
374
|
-
deployFirewall(opts?: {
|
|
375
|
-
zones?: Array<'dmz' | 'app' | 'secure'>;
|
|
376
|
-
natIp?: string;
|
|
377
|
-
}): Promise<void>;
|
|
388
|
+
deployFirewall(opts?: { natIp?: string; check?: boolean }): Promise<ExecResult>;
|
|
378
389
|
|
|
379
390
|
/**
|
|
380
391
|
* Deploy the `greenwave` module against the ISP router (`fw-isp`) — the box
|
|
@@ -390,6 +401,27 @@ export interface NetworkHandle {
|
|
|
390
401
|
*
|
|
391
402
|
* Not needed under `direct-internet`, where fw-main owns the edge itself.
|
|
392
403
|
*/
|
|
404
|
+
/**
|
|
405
|
+
* Plug the named containers into a NEW network celilo has no subnet for, and
|
|
406
|
+
* return the address each was given.
|
|
407
|
+
*
|
|
408
|
+
* For interface-classification tests: the firewall gains a real interface
|
|
409
|
+
* with a real global address that matches no declared zone, and a peer on the
|
|
410
|
+
* same segment gives the isolation assertion a real signal to measure — can
|
|
411
|
+
* that segment still reach a service on the firewall — rather than a rule
|
|
412
|
+
* string in `iptables-save`.
|
|
413
|
+
*
|
|
414
|
+
* `subnet` must NOT be one of `ZONE_SUBNETS`; an attributable segment defeats
|
|
415
|
+
* the purpose.
|
|
416
|
+
*/
|
|
417
|
+
attachAlienSegment(opts: {
|
|
418
|
+
subnet: string;
|
|
419
|
+
containers: string[];
|
|
420
|
+
}): Promise<Record<string, string>>;
|
|
421
|
+
|
|
422
|
+
/** Unplug and remove the segment `attachAlienSegment` created. */
|
|
423
|
+
detachAlienSegment(): Promise<void>;
|
|
424
|
+
|
|
393
425
|
deployGreenwave(): Promise<void>;
|
|
394
426
|
|
|
395
427
|
/** Tear down the entire network */
|
|
@@ -435,6 +467,17 @@ export interface ResponderValues {
|
|
|
435
467
|
* silently approved). Passed straight through to `celilo events respond`.
|
|
436
468
|
*/
|
|
437
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>;
|
|
438
481
|
}
|
|
439
482
|
|
|
440
483
|
/**
|