@celilo/e2e 0.13.1 → 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.proxmox-sim +48 -0
- package/package.json +2 -1
- package/simulators/proxmox/entrypoint.ts +51 -0
- package/src/cli/build.ts +3 -1
- package/src/container-manager.ts +20 -13
- 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 +24 -0
|
@@ -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",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"@celilo/capabilities": "^1.2.0",
|
|
41
41
|
"@celilo/cli-display": "^0.2.0",
|
|
42
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
|
@@ -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
|
|
641
|
-
//
|
|
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
|
-
//
|
|
674
|
-
//
|
|
675
|
-
//
|
|
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
|
-
|
|
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
|
*
|
|
@@ -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
|
/**
|