@celilo/e2e 0.13.0 → 0.13.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.
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/e2e",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
4
4
  "description": "E2E test infrastructure for Celilo-deployed applications. Provides a simulated internet with DNS hierarchy, ACME server, firewalls, and target machines in Docker.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -37,9 +37,9 @@
37
37
  "README.md"
38
38
  ],
39
39
  "dependencies": {
40
- "@celilo/capabilities": "^1.1.0",
40
+ "@celilo/capabilities": "^1.2.0",
41
41
  "@celilo/cli-display": "^0.2.0",
42
- "@celilo/event-bus": "^0.4.0",
42
+ "@celilo/event-bus": "^0.5.0",
43
43
  "yaml": "^2.8.0",
44
44
  "zod": "^3.24.1"
45
45
  },
@@ -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,19 +615,32 @@ function buildNetworkHandle(
612
615
  await new Promise((r) => setTimeout(r, 500));
613
616
  },
614
617
 
615
- async deployFirewall(opts = {}): Promise<void> {
616
- // `opts.zones` says which zones get `provided_networks` written to system
617
- // config. It does NOT say which NICs the box has — the compose topology
618
- // decides that, and it wires fw-main to every segmented zone regardless of
619
- // what a test passed. Roughly twenty call sites passed `['dmz']` against a
620
- // four-leg firewall, so every one of them under-declared the hardware.
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
- // Deriving the declared list from the topology makes under-declaring
623
- // structurally impossible rather than something each future test author has
624
- // to remember, and removes one more hand-maintained copy of a list the
625
- // compose generator already owns.
626
- const providedZones = opts.zones ?? ['dmz', 'app', 'secure'];
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 from provided_networks: it is the residual, has
641
+ // no subnet, and is not a 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
646
  const providedNetworks = providedZones.map((zone) => ({
@@ -667,7 +683,105 @@ function buildNetworkHandle(
667
683
  await handle.celilo(
668
684
  `module config set iptables provided_networks '${JSON.stringify(providedNetworks)}'`,
669
685
  );
670
- await handle.celilo('module deploy iptables', 180_000);
686
+ // `check: false` returns the failed deploy instead of throwing, so a test
687
+ // can assert on WHAT the refusal said. Interface classification refuses
688
+ // by design (D12 onboarding), and a refusal is only useful if it names
689
+ // the interface — which is an assertion about output, not about an
690
+ // exception having been raised.
691
+ return opts.check === false
692
+ ? await handle.celilo('module deploy iptables', { check: false, timeoutMs: 180_000 })
693
+ : await handle.celilo('module deploy iptables', 180_000);
694
+ },
695
+
696
+ async attachAlienSegment(opts: {
697
+ subnet: string;
698
+ containers: string[];
699
+ }): Promise<Record<string, string>> {
700
+ // A REAL cable into a REAL port. Interface classification is about what
701
+ // celilo finds on the box, so a test that fakes the finding — a dummy
702
+ // link, a stubbed interface table — is testing its own fixture. This
703
+ // creates an actual docker network celilo has no subnet for and plugs
704
+ // real containers into it, which is what an operator hanging an
705
+ // unmanaged switch off a spare port produces: a new interface, a global
706
+ // address, and something live on the other side to prove reachability
707
+ // with.
708
+ //
709
+ // The subnet is the caller's, and it must not be one of ZONE_SUBNETS —
710
+ // the whole point is that celilo cannot attribute it.
711
+ const netName = `${projectName}_alien-seg`;
712
+ alienSegmentSubnet = opts.subnet;
713
+ run(`docker network create --driver bridge --subnet ${opts.subnet} ${netName}`, {
714
+ timeout: 20_000,
715
+ });
716
+
717
+ const assigned: Record<string, string> = {};
718
+ opts.containers.forEach((service, index) => {
719
+ // .2 upward: .1 is the bridge itself.
720
+ const ip = opts.subnet.replace(/\.0\/\d+$/, `.${index + 2}`);
721
+ const id = run(`docker compose -p ${projectName} -f ${COMPOSE_FILE} ps -q ${service}`, {
722
+ cwd: composeDir,
723
+ timeout: 20_000,
724
+ });
725
+ if (!id) throw new Error(`attachAlienSegment: no container for service '${service}'`);
726
+
727
+ // `docker network connect` EXITS NONZERO on a container that already has
728
+ // a default route — "failed to set gateway while updating gateway: file
729
+ // exists" — while attaching the interface perfectly well: the address
730
+ // lands, the veth is up, and traffic flows both ways. Every container in
731
+ // this topology routes through the firewall, so every attach hits it.
732
+ // `docker network inspect` is no help either; it lists no containers for
733
+ // this network whether the attach worked or not.
734
+ //
735
+ // So neither the exit code nor docker's own view can be believed, and
736
+ // the address on the box is the only trustworthy signal. Verified below
737
+ // rather than assumed — a half-completed attach really does leave an
738
+ // address behind with no working wire, which is precisely the state that
739
+ // would make a classification test pass for the wrong reason.
740
+ try {
741
+ run(`docker network connect --ip ${ip} ${netName} ${id}`, { timeout: 20_000 });
742
+ } catch {}
743
+
744
+ const check = dockerExec(projectName, composeDir, service, 'ip -o addr show scope global');
745
+ if (!check.stdout.includes(ip)) {
746
+ throw new Error(
747
+ `attachAlienSegment: '${service}' has no ${ip} after connecting to ${netName}:\n${check.stdout}`,
748
+ );
749
+ }
750
+ assigned[service] = ip;
751
+ attachedToAlienSegment.add(service);
752
+ });
753
+ return assigned;
754
+ },
755
+
756
+ async detachAlienSegment(): Promise<void> {
757
+ const netName = `${projectName}_alien-seg`;
758
+ for (const service of attachedToAlienSegment) {
759
+ const id = run(`docker compose -p ${projectName} -f ${COMPOSE_FILE} ps -q ${service}`, {
760
+ cwd: composeDir,
761
+ timeout: 20_000,
762
+ });
763
+ if (id) {
764
+ try {
765
+ run(`docker network disconnect -f ${netName} ${id}`, { timeout: 20_000 });
766
+ } catch {}
767
+ }
768
+ }
769
+ try {
770
+ run(`docker network rm ${netName}`, { timeout: 20_000 });
771
+ } catch {}
772
+
773
+ // VERIFY, for the same reason attach does. A lingering address on a dead
774
+ // wire is still an interface celilo cannot attribute, so a test that goes
775
+ // on to expect a clean converge would fail somewhere far from the cause.
776
+ for (const service of attachedToAlienSegment) {
777
+ const check = dockerExec(projectName, composeDir, service, 'ip -o addr show scope global');
778
+ const prefix = alienSegmentSubnet.replace(/\.\d+\/\d+$/, '').replace(/\./g, '\\.');
779
+ const orphan = new RegExp(`(\\S+)\\s+inet\\s+${prefix}\\.\\d+`).exec(check.stdout);
780
+ if (orphan) {
781
+ dockerExec(projectName, composeDir, service, `ip link del ${orphan[1]}`);
782
+ }
783
+ }
784
+ attachedToAlienSegment.clear();
671
785
  },
672
786
 
673
787
  async deployGreenwave(): Promise<void> {
package/src/types.ts CHANGED
@@ -363,18 +363,16 @@ export interface NetworkHandle {
363
363
  * Targets fw-main (the customer firewall) using the standard topology
364
364
  * subnets from ZONE_SUBNETS / ZONE_GATEWAYS.
365
365
  *
366
- * `opts.zones` says which zones get `provided_networks` written to system
367
- * config. It does NOT declare which NICs the firewall has — the topology
368
- * decides that, and `deployFirewall` derives the declared list from it, so a
369
- * test cannot under-declare the box's hardware by omission.
366
+ * Both the declared zone list AND `provided_networks` are derived from the
367
+ * topology this network was built with. There is no `zones` option: a firewall
368
+ * holding an address on a segment celilo has no subnet for classifies `alien`
369
+ * and the converge refuses, so the wired legs and the declared zones must be
370
+ * the same list. A test that wants a firewall with fewer legs changes the
371
+ * topology, which is the honest way to say it (design D8).
370
372
  *
371
- * @param opts.zones - zones to PROVIDE (default: dmz, app, secure)
372
373
  * @param opts.natIp - NAT IP for port-forwarded traffic (default 10.226.1.253)
373
374
  */
374
- deployFirewall(opts?: {
375
- zones?: Array<'dmz' | 'app' | 'secure'>;
376
- natIp?: string;
377
- }): Promise<void>;
375
+ deployFirewall(opts?: { natIp?: string; check?: boolean }): Promise<ExecResult>;
378
376
 
379
377
  /**
380
378
  * Deploy the `greenwave` module against the ISP router (`fw-isp`) — the box
@@ -390,6 +388,27 @@ export interface NetworkHandle {
390
388
  *
391
389
  * Not needed under `direct-internet`, where fw-main owns the edge itself.
392
390
  */
391
+ /**
392
+ * Plug the named containers into a NEW network celilo has no subnet for, and
393
+ * return the address each was given.
394
+ *
395
+ * For interface-classification tests: the firewall gains a real interface
396
+ * with a real global address that matches no declared zone, and a peer on the
397
+ * same segment gives the isolation assertion a real signal to measure — can
398
+ * that segment still reach a service on the firewall — rather than a rule
399
+ * string in `iptables-save`.
400
+ *
401
+ * `subnet` must NOT be one of `ZONE_SUBNETS`; an attributable segment defeats
402
+ * the purpose.
403
+ */
404
+ attachAlienSegment(opts: {
405
+ subnet: string;
406
+ containers: string[];
407
+ }): Promise<Record<string, string>>;
408
+
409
+ /** Unplug and remove the segment `attachAlienSegment` created. */
410
+ detachAlienSegment(): Promise<void>;
411
+
393
412
  deployGreenwave(): Promise<void>;
394
413
 
395
414
  /** Tear down the entire network */