@celilo/e2e 0.18.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import { type ExecSyncOptions, execSync, spawn } from 'node:child_process';
2
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { basename, join, resolve } from 'node:path';
5
5
  import { parse as parseYaml } from 'yaml';
@@ -10,6 +10,7 @@ import {
10
10
  firewallZoneLegs,
11
11
  generateTestComposeYaml,
12
12
  getAllMachines,
13
+ registryUploadsHostDir,
13
14
  } from './docker-compose-generator';
14
15
  import { explainBuildFailure } from './doctor';
15
16
  import { type ModuleHost, parseModuleHost } from './module-host';
@@ -1005,12 +1006,19 @@ function buildNetworkHandle(
1005
1006
  }
1006
1007
 
1007
1008
  try {
1008
- // Copy directly into the registry container's /uploads dir.
1009
- // The registry rebuilds its index on every request, so no HTTP handshake needed.
1010
- run(
1011
- `docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} cp ${JSON.stringify(netappPath)} registry:/uploads/${moduleId}.netapp`,
1012
- { cwd: composeDir, timeout: 120_000 },
1013
- );
1009
+ // Write HOST-side into the dir bound at the registry's /uploads. The
1010
+ // registry rescans that dir on every request, so it needs no HTTP
1011
+ // handshake and no restart.
1012
+ //
1013
+ // Deliberately not a `docker compose cp` into the container: /uploads
1014
+ // is a READ-ONLY bind in a consumer install, so the container write
1015
+ // failed for every npm-installed consumer while passing forever in the
1016
+ // monorepo, where the same path is not a mount at all (celilo#1142).
1017
+ // registryUploadsHostDir() is the same function the compose generator
1018
+ // builds the mount from, so the two cannot drift apart again.
1019
+ const uploadsDir = registryUploadsHostDir();
1020
+ mkdirSync(uploadsDir, { recursive: true });
1021
+ copyFileSync(netappPath, join(uploadsDir, `${moduleId}.netapp`));
1014
1022
  } finally {
1015
1023
  if (cleanup)
1016
1024
  try {
@@ -1264,8 +1272,14 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
1264
1272
  );
1265
1273
  }
1266
1274
 
1267
- // Wait for DHCP client lease
1268
- if (config.dhcpClient) {
1275
+ // Wait for DHCP client lease.
1276
+ //
1277
+ // Skipped when the router's DHCP is off, because then NOTHING is serving yet:
1278
+ // celilo's own DHCP server is a module, and modules deploy long after the
1279
+ // network is up. Waiting here would time out at 60s on every such suite and
1280
+ // report it as a network-start failure. Those suites drive the client
1281
+ // themselves once their server is deployed.
1282
+ if (config.dhcpClient && config.routerDhcp !== false) {
1269
1283
  console.log('[progress:start] waiting for DHCP client lease | DHCP lease acquired');
1270
1284
  // Self-diagnosing via waitFor's onTimeout (e2e-confidence #255): a DHCP-lease
1271
1285
  // timeout is an intermittent in-suite flake (passes solo). On timeout, dump
@@ -1,5 +1,5 @@
1
- import { existsSync } from 'node:fs';
2
- import { join } from 'node:path';
1
+ import { existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
3
  import { stringify } from 'yaml';
4
4
  import { normalizeObservers, observerEnv, observerPlacement } from './observer';
5
5
  import { PROXMOX_SIM_IP, SIMULATOR_IPS } from './simulator-ips';
@@ -13,32 +13,76 @@ import {
13
13
  zoneIp,
14
14
  } from './types';
15
15
 
16
+ const PACKAGE_DIR = join(import.meta.dir, '..');
17
+
18
+ /**
19
+ * Is this @celilo/e2e running from the monorepo, or from an npm install?
20
+ *
21
+ * `<pkgDir>/../../modules` is `<repo>/modules` in the monorepo and
22
+ * `<consumer>/node_modules/modules` (nonexistent) in a consumer install.
23
+ */
24
+ function isMonorepoInstall(): boolean {
25
+ return existsSync(join(PACKAGE_DIR, '..', '..', 'modules'));
26
+ }
27
+
16
28
  /**
17
- * Volumes for the registry container. Picks between source-bootstrap and
18
- * uploads-bootstrap based on where the @celilo/e2e package lives:
29
+ * The host dir bound read-only at the registry's `/uploads` — and therefore
30
+ * the dir `publishModule` writes a `.netapp` into. The registry rescans it on
31
+ * every request, so a host-side write is picked up with no container write and
32
+ * no HTTP handshake.
33
+ *
34
+ * Both install shapes bind one. They differ only in WHICH, because they differ
35
+ * in where standard modules come from:
36
+ *
37
+ * - **Monorepo dev** — modules are served live from `/modules`
38
+ * (`../../modules`, BOOTSTRAP_MODULES_DIR mode), so edits to a module's
39
+ * source flow through without a manual repack. `/uploads` is therefore a
40
+ * dedicated drop-zone, emptied on every shared-infra start (see
41
+ * `prepareRegistryDropZone`) — a leftover `.netapp` would shadow live source.
19
42
  *
20
- * - **Monorepo dev** — `../../modules` from the compose file (which lives
21
- * in `packages/e2e/`) resolves to `<repo>/modules`. The registry runs
22
- * in BOOTSTRAP_MODULES_DIR mode and packs source modules on the fly,
23
- * so edits to a module's source flow through without manual repack.
43
+ * - **npm-installed consumer** (lunacycle and friends) — `../../modules` does
44
+ * not exist, so there is no source to serve. `/uploads` IS the netapp cache
45
+ * `cele2e build-infra` fills from the public registry (see
46
+ * stageNetappsFromRegistry in cli/build.ts), and a published module lands
47
+ * beside the standard ones. Consumers vendor nothing.
48
+ *
49
+ * Read this through `registryUploadsHostDir()` rather than re-deriving it.
50
+ * publishModule used to `docker cp` into the container's `/uploads` instead,
51
+ * which works only where that path is NOT a mount — so it passed forever in
52
+ * the monorepo and failed for every consumer against the read-only bind
53
+ * (celilo#1142). One function now decides the mount and the write target
54
+ * together, so they cannot disagree again.
55
+ */
56
+ export function registryUploadsHostDir(): string {
57
+ return join(PACKAGE_DIR, isMonorepoInstall() ? 'uploads' : 'netapps');
58
+ }
59
+
60
+ /**
61
+ * Make the drop-zone ready for a shared-infra bring-up.
24
62
  *
25
- * - **npm-installed consumer** (lunacycle and friends) — `../../modules`
26
- * would resolve to `<consumer>/node_modules/modules`, which doesn't
27
- * exist. Docker fails the mount with "permission denied" trying to
28
- * create that path. Instead we bind the local `./netapps/` dir to
29
- * `/uploads`, and the registry's scanUploadsDir picks up the .netapp
30
- * files. That dir is populated at `cele2e build-infra` time by fetching
31
- * the standard-module netapps from the public registry (see
32
- * stageNetappsFromRegistry in cli/build.ts) — consumers vendor nothing.
63
+ * Creates it: Docker silently creates a MISSING bind source as an empty
64
+ * root-owned directory, which the host then cannot write into.
33
65
  *
34
- * Detection: check whether the would-be monorepo modules dir is real.
35
- * `import.meta.dir` is `<pkgDir>/src/`, so `../../../modules` from there
36
- * is `<repo>/modules` in the monorepo and `<consumer>/node_modules/modules`
37
- * (nonexistent) in a consumer install.
66
+ * Empties it, but only in the monorepo, where `/uploads` is dedicated — a
67
+ * `.netapp` left by a previous run would shadow the live module source it was
68
+ * packed from. In a consumer install the same dir IS the build-infra netapp
69
+ * cache, so clearing it would delete every standard module. (Before
70
+ * celilo#1142 published netapps lived in the registry's container layer and
71
+ * died with the container, so starting empty is the behaviour being kept, not
72
+ * a new one.)
38
73
  */
74
+ export function prepareRegistryDropZone(): void {
75
+ const dir = registryUploadsHostDir();
76
+ mkdirSync(dir, { recursive: true });
77
+ if (!isMonorepoInstall()) return;
78
+ for (const file of readdirSync(dir)) {
79
+ if (file.endsWith('.netapp')) rmSync(join(dir, file), { force: true });
80
+ }
81
+ }
82
+
39
83
  function getRegistryVolumes(): string[] {
40
- const monorepoModules = join(import.meta.dir, '..', '..', '..', 'modules');
41
- return existsSync(monorepoModules) ? ['../../modules:/modules:ro'] : ['./netapps:/uploads:ro'];
84
+ const uploads = `./${basename(registryUploadsHostDir())}:/uploads:ro`;
85
+ return isMonorepoInstall() ? ['../../modules:/modules:ro', uploads] : [uploads];
42
86
  }
43
87
 
44
88
  const NETWORK_DRIVER_OPTS = {
@@ -94,6 +138,12 @@ function baseService(opts: {
94
138
  environment?: Record<string, string>;
95
139
  tmpfs?: string[];
96
140
  privileged?: boolean;
141
+ /**
142
+ * Docker `security_opt` entries, e.g. `seccomp=unconfined`. Needed by the
143
+ * management box so the hook jail's bubblewrap can build a namespace inside
144
+ * a container — see the measured ladder at its call site.
145
+ */
146
+ security_opt?: string[];
97
147
  dns?: string[];
98
148
  /** Host devices to expose, e.g. `/dev/net/tun` for a userspace WireGuard tunnel. */
99
149
  devices?: string[];
@@ -215,7 +265,13 @@ function defaultTopologyServices(config: NetworkConfig): Record<string, unknown>
215
265
  volumes: ['ssh-keys:/ssh-keys:ro'],
216
266
  // Which real ISP router this sim stands in for. The protocol is identical
217
267
  // across the two; only the TR-181 vendor extension prefix differs.
218
- environment: { ROUTER_VENDOR_PREFIX: config.routerVendorPrefix ?? 'X_GWS_' },
268
+ environment: {
269
+ ROUTER_VENDOR_PREFIX: config.routerVendorPrefix ?? 'X_GWS_',
270
+ // The simulated router serves DHCP on `internal` by default, which is
271
+ // what an operator's router does. A suite testing celilo's OWN DHCP
272
+ // turns it off, because two servers on one broadcast domain race.
273
+ ROUTER_DHCP: config.routerDhcp === false ? 'off' : 'on',
274
+ },
219
275
  }),
220
276
  };
221
277
  }
@@ -568,7 +624,34 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
568
624
  image:
569
625
  variant === 'vanilla' ? 'celilo-e2e/management:vanilla' : 'celilo-e2e/management:latest',
570
626
  networks: { [zone]: { ipv4_address: ip } },
571
- cap_add: ['NET_ADMIN'],
627
+ // SYS_ADMIN is for the hook jail, NET_ADMIN for the routing the box does
628
+ // anyway. See the security_opt block below for why both are here.
629
+ cap_add: ['NET_ADMIN', 'SYS_ADMIN'],
630
+ // The hook jail runs each hook under bubblewrap, which has to build a
631
+ // user namespace and then mount inside it. Docker denies that four
632
+ // separate ways, and every one of these is load-bearing — measured on a
633
+ // colima Ubuntu 24.04.1 VM (kernel 6.8.0-50,
634
+ // kernel.apparmor_restrict_unprivileged_userns=1), by removing one at a
635
+ // time from the working set:
636
+ //
637
+ // drop seccomp=unconfined -> bwrap: pivot_root: Operation not permitted
638
+ // drop apparmor=unconfined -> bwrap: Failed to make / slave: Permission denied
639
+ // drop systempaths=unconfined -> bwrap: Can't mount proc on /newroot/proc
640
+ // drop SYS_ADMIN -> bwrap: setting up uid map: Permission denied
641
+ //
642
+ // Deliberately NOT `privileged: true`, which design D8 measured as
643
+ // working and task 4.1 asked to improve on. It would also grant every
644
+ // capability and every host device; this set grants four named things.
645
+ //
646
+ // `systempaths=unconfined` is the one D8's ladder was missing: it unmasks
647
+ // the /proc paths Docker hides, which is why "all three" of the earlier
648
+ // options still failed on the proc mount.
649
+ //
650
+ // NOTE this makes the container a WEAK place to test the AppArmor
651
+ // profile: apparmor=unconfined means the restriction the profile exists
652
+ // to satisfy is not in force here. That gate is the celilo-builder probe
653
+ // on a real kernel, never this container.
654
+ security_opt: ['seccomp=unconfined', 'apparmor=unconfined', 'systempaths=unconfined'],
572
655
  // ISS-0157: `module import`'s `bun install` hard-links packages from the
573
656
  // bun cache into node_modules, which on overlayfs forces a copy-up + fsync
574
657
  // per file. On a slow/contended builder disk that storm blows past the
@@ -140,6 +140,23 @@ export class NetworkBuilder {
140
140
  return this;
141
141
  }
142
142
 
143
+ /**
144
+ * Stop the simulated ISP router serving DHCP on `internal`.
145
+ *
146
+ * For suites where CELILO serves DHCP (`modules/dnsmasq-dhcp`). Two servers
147
+ * on one broadcast domain race, so without this a lease assertion is a coin
148
+ * toss. It is also what an operator does when moving DHCP to celilo, so the
149
+ * suite models the migration rather than dodging a conflict.
150
+ *
151
+ * NOTE: `withDhcpClient()`'s start-up lease wait expects SOMETHING to answer.
152
+ * Pair this with a suite that deploys celilo's DHCP server, or the client
153
+ * gets no lease and the network never comes up.
154
+ */
155
+ withoutRouterDhcp(): this {
156
+ this.config.routerDhcp = false;
157
+ return this;
158
+ }
159
+
143
160
  /**
144
161
  * Add the Proxmox API simulator on `secure-mgmt`.
145
162
  *
@@ -17,6 +17,7 @@ import {
17
17
  SHARED_NETWORKS,
18
18
  SHARED_PROJECT_NAME,
19
19
  generateSharedInfraYaml,
20
+ prepareRegistryDropZone,
20
21
  } from './docker-compose-generator';
21
22
  import { ensureRegistryServerBundle, ensureTerraformFakeBundle } from './registry-bundle';
22
23
 
@@ -161,6 +162,11 @@ export async function ensureSharedInfra(): Promise<void> {
161
162
  const yaml = generateSharedInfraYaml();
162
163
  writeFileSync(join(e2eDir, SHARED_COMPOSE_FILE), yaml);
163
164
 
165
+ // The registry's /uploads bind source must exist before `up` (a missing
166
+ // bind source becomes an empty root-owned dir), and in the monorepo it
167
+ // starts empty so no stale .netapp shadows live module source.
168
+ prepareRegistryDropZone();
169
+
164
170
  // Seed the live DNS zone files from their templates BEFORE the compose
165
171
  // mounts them. config/dns/{iamtheinternet.org,example.net}.zone are
166
172
  // gitignored runtime state (scrubDnsZones rewrites them per-test); on a
package/src/types.ts CHANGED
@@ -66,6 +66,19 @@ export interface NetworkConfig {
66
66
  * serves both rather than a near-duplicate second one.
67
67
  */
68
68
  routerVendorPrefix?: string;
69
+ /**
70
+ * Whether the simulated ISP router serves DHCP on `internal`. Defaults to
71
+ * true, which is what an operator's router does and what every existing suite
72
+ * relies on.
73
+ *
74
+ * Set false when celilo serves DHCP instead (`modules/dnsmasq-dhcp`). Two
75
+ * DHCP servers on one broadcast domain race — whichever answers a DISCOVER
76
+ * first wins — so a suite asserting WHICH server issued a lease would be
77
+ * asserting a coin toss. Turning the router's off is also what a real
78
+ * operator does when moving DHCP to celilo, so this models the migration
79
+ * rather than papering over a conflict.
80
+ */
81
+ routerDhcp?: boolean;
69
82
  /** Extra volume mounts for the management container (host:container format) */
70
83
  managementVolumes: string[];
71
84
  /** Include a DHCP client container on the internal network */