@celilo/e2e 0.19.3 → 0.20.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.
Files changed (50) hide show
  1. package/README.md +30 -13
  2. package/bin/e2e-bake-management +196 -12
  3. package/bin/e2e-infra +0 -1
  4. package/bin/e2e-up +14 -3
  5. package/docker/Dockerfile.observer +12 -1
  6. package/docker/Dockerfile.target-machine +22 -1
  7. package/npm-registry-server/package.json +1 -1
  8. package/package.json +3 -3
  9. package/registry-server/package.json +1 -1
  10. package/scripts/pack-celilo-packages.ts +15 -0
  11. package/src/block-timing.test.ts +559 -0
  12. package/src/block-timing.ts +366 -0
  13. package/src/cli/build.test.ts +135 -4
  14. package/src/cli/build.ts +237 -94
  15. package/src/cli/command-registry.ts +21 -0
  16. package/src/cli/command-tree-parser.ts +11 -2
  17. package/src/cli/completion.ts +9 -0
  18. package/src/cli/host.ts +252 -0
  19. package/src/cli/index.ts +78 -51
  20. package/src/cli/module-discovery.ts +108 -13
  21. package/src/cli/scaffold.ts +18 -26
  22. package/src/container-manager.cleanup.test.ts +284 -0
  23. package/src/container-manager.runner.test.ts +351 -0
  24. package/src/container-manager.test.ts +84 -0
  25. package/src/container-manager.ts +721 -185
  26. package/src/docker-compose-generator.ts +135 -61
  27. package/src/doctor.test.ts +259 -4
  28. package/src/doctor.ts +276 -3
  29. package/src/exit-cleanup.test.ts +83 -1
  30. package/src/fleet-nameserver-gate.test.ts +45 -0
  31. package/src/host-vm.test.ts +156 -0
  32. package/src/host-vm.ts +230 -0
  33. package/src/index.ts +11 -0
  34. package/src/live-stack.test.ts +300 -0
  35. package/src/live-stack.ts +355 -0
  36. package/src/no-unjustified-sleep.test.ts +90 -0
  37. package/src/proxmox-provisioner.test.ts +18 -2
  38. package/src/proxmox-provisioner.ts +22 -0
  39. package/src/public-sim-routes.test.ts +9 -2
  40. package/src/repo-root.ts +33 -0
  41. package/src/run-args.test.ts +76 -0
  42. package/src/run-args.ts +89 -0
  43. package/src/runner.ts +213 -8
  44. package/src/shared-infra.ts +88 -32
  45. package/src/socks-proxy.ts +2 -0
  46. package/src/source-fingerprint.test.ts +213 -0
  47. package/src/source-fingerprint.ts +212 -0
  48. package/src/stage-simulator-inputs.ts +93 -0
  49. package/src/stages.ts +133 -0
  50. package/src/wait-for-run.ts +1 -0
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Recurrence gate for celilo#1267: readiness waits in packages/e2e must poll
3
+ * an observable condition (waitFor or a poll loop), never sleep a fixed
4
+ * duration.
5
+ *
6
+ * apps/celilo/CLAUDE.md prohibits sleeping to mask race conditions, but until
7
+ * now nothing enforced it in this package. respondWith slept 500ms for the
8
+ * responder to boot; on a loaded host that was not enough, the next command
9
+ * hit an unanswered interview, and the error read as a missing fixture value
10
+ * rather than as harness timing.
11
+ *
12
+ * The rule here: every fixed sleep in packages/e2e/src must carry an inline
13
+ * `e2e-sleep-ok:` justification on the same line or the line above. A poll
14
+ * loop's cadence sleep carries one honestly; a bare readiness gate cannot, so
15
+ * it fails this gate instead of failing five suites under load.
16
+ */
17
+
18
+ import { describe, expect, test } from 'bun:test';
19
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
20
+ import { join, resolve } from 'node:path';
21
+
22
+ const SLEEP_PATTERN = /new Promise\(\s*\(?r\)?\s*=>\s*setTimeout\(r\s*,/;
23
+
24
+ function srcDir(): string {
25
+ let dir = import.meta.dir;
26
+ for (let i = 0; i < 8; i++) {
27
+ if (existsSync(join(dir, 'apps')) && existsSync(join(dir, 'modules')))
28
+ return join(dir, 'packages', 'e2e', 'src');
29
+ dir = resolve(dir, '..');
30
+ }
31
+ throw new Error('could not locate repo root (no ancestor with apps/ + modules/)');
32
+ }
33
+
34
+ function tsFiles(dir: string): string[] {
35
+ const out: string[] = [];
36
+ for (const entry of readdirSync(dir)) {
37
+ const full = join(dir, entry);
38
+ if (statSync(full).isDirectory()) out.push(...tsFiles(full));
39
+ else if (entry.endsWith('.ts') && !entry.endsWith('.test.ts')) out.push(full);
40
+ }
41
+ return out;
42
+ }
43
+
44
+ interface Violation {
45
+ file: string;
46
+ line: number;
47
+ text: string;
48
+ }
49
+
50
+ function unjustifiedSleeps(): Violation[] {
51
+ const violations: Violation[] = [];
52
+ for (const file of tsFiles(srcDir())) {
53
+ const lines = readFileSync(file, 'utf-8').split('\n');
54
+ lines.forEach((text, i) => {
55
+ if (!SLEEP_PATTERN.test(text)) return;
56
+ const justified = text.includes('e2e-sleep-ok:') || lines[i - 1]?.includes('e2e-sleep-ok:');
57
+ if (!justified) violations.push({ file, line: i + 1, text: text.trim() });
58
+ });
59
+ }
60
+ return violations;
61
+ }
62
+
63
+ describe('recurrence gate: no bare readiness sleeps in packages/e2e (celilo#1267)', () => {
64
+ test('the scan reaches a non-trivial set of files (sanity — it actually ran)', () => {
65
+ const files = tsFiles(srcDir());
66
+ expect(files.length).toBeGreaterThan(10);
67
+ });
68
+
69
+ test('every fixed sleep carries an e2e-sleep-ok justification', () => {
70
+ const violations = unjustifiedSleeps();
71
+ const report = violations
72
+ .map(
73
+ (v) =>
74
+ `${v.file}:${v.line}: ${v.text}\n Add an inline \`e2e-sleep-ok: <reason>\` comment on this line or the one above, or poll an observable condition with waitFor.`,
75
+ )
76
+ .join('\n');
77
+ expect(violations, `Unjustified fixed sleeps:\n${report}`).toEqual([]);
78
+ });
79
+
80
+ test('the known poll-cadence sleeps are still present and justified (the rule engages)', () => {
81
+ // Reach probe, not reasoning: if the justified count drops to zero the
82
+ // scan is matching nothing and the gate above proves nothing.
83
+ const violations = unjustifiedSleeps();
84
+ const total = tsFiles(srcDir()).reduce((count, file) => {
85
+ const lines = readFileSync(file, 'utf-8').split('\n');
86
+ return count + lines.filter((text) => SLEEP_PATTERN.test(text)).length;
87
+ }, 0);
88
+ expect(total - violations.length).toBeGreaterThan(3);
89
+ });
90
+ });
@@ -9,6 +9,7 @@ import { describe, expect, test } from 'bun:test';
9
9
  import type { GuestRecord } from '@celilo/terraform-fake';
10
10
  import {
11
11
  CONTAINER_PREFIX,
12
+ GUEST_PROJECT_LABEL,
12
13
  containerNameFor,
13
14
  createDockerProvisioner,
14
15
  parseNet,
@@ -60,8 +61,10 @@ describe('createGuest', () => {
60
61
  });
61
62
 
62
63
  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.
64
+ // cele2e doctor keys off this prefix; an unprefixed container leaks
65
+ // silently between runs. Project membership is a separate concern — the
66
+ // per-test sweeps cannot match this name (no timestamp in it), so the
67
+ // label test below is what teardown actually keys off.
65
68
  const { runner, find } = stubDocker();
66
69
  await provisionerWith(runner).createGuest(guest());
67
70
 
@@ -70,6 +73,19 @@ describe('createGuest', () => {
70
73
  expect(name).toBe('celilo-e2e-lxc-203');
71
74
  });
72
75
 
76
+ test('labels the guest with its compose project so teardown can find it (celilo#1247)', async () => {
77
+ // The name cannot carry project membership (no timestamp), and compose's
78
+ // own labels cannot be borrowed (the guest is not a compose service, and
79
+ // compose down removes orphans only on the full label set — verified:
80
+ // a bare com.docker.compose.project label is not enough). The explicit
81
+ // label is what projectTeardownCommands lists guests by.
82
+ const { runner, find } = stubDocker();
83
+ await provisionerWith(runner).createGuest(guest());
84
+
85
+ const run = find('run') ?? [];
86
+ expect(run[run.indexOf('--label') + 1]).toBe(`${GUEST_PROJECT_LABEL}=celilo-e2e-test`);
87
+ });
88
+
73
89
  test('passes the gateway through, since target-setup routes from it', async () => {
74
90
  const { runner, find } = stubDocker();
75
91
  await provisionerWith(runner).createGuest(guest());
@@ -30,6 +30,20 @@ import { ZONE_GATEWAYS, type Zone } from './types';
30
30
  */
31
31
  export const CONTAINER_PREFIX = 'celilo-e2e-';
32
32
 
33
+ /**
34
+ * Marks a sim-created guest as belonging to a compose project.
35
+ *
36
+ * The name prefix alone is not enough for cleanup: a guest is named
37
+ * `celilo-e2e-lxc-<vmid>` with no timestamp in it, so every by-name filter the
38
+ * sweeps use (`celilo-e2e-1<ts>`, `celilo-e2e-<ts>`) misses it. A guest left
39
+ * behind keeps its zone network's endpoint alive, the network rm fails with
40
+ * "has active endpoints", and the subnet stays allocated — every later suite
41
+ * in the run then dies creating that network (celilo#1247). The label is what
42
+ * `projectTeardownCommands` filters on; it carries the project value, so
43
+ * teardown finds exactly the guests of the project it is tearing down.
44
+ */
45
+ export const GUEST_PROJECT_LABEL = 'celilo-e2e.project';
46
+
33
47
  /** Shells out to `docker`. Injected so the logic is testable without a daemon. */
34
48
  export type DockerRunner = (args: string[]) => string;
35
49
 
@@ -71,6 +85,7 @@ export function zoneForGateway(gateway: string): Zone | undefined {
71
85
  export function createDockerProvisioner(options: DockerProvisionerOptions) {
72
86
  const docker = options.docker ?? realDocker;
73
87
  const readyTimeoutMs = options.readyTimeoutMs ?? 60_000;
88
+ // e2e-sleep-ok: injectable test seam; real callers poll a condition between sleeps.
74
89
  const sleep = options.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms)));
75
90
 
76
91
  const assertOurs = (name: string): void => {
@@ -96,6 +111,12 @@ export function createDockerProvisioner(options: DockerProvisionerOptions) {
96
111
  const name = containerNameFor(guest.vmid);
97
112
  assertOurs(name);
98
113
 
114
+ // Project membership must be discoverable without the name: teardown
115
+ // matches on this label because the guest's name carries no timestamp.
116
+ // Added before the readiness wait, so even a guest that never finishes
117
+ // booting is still findable and removable.
118
+ const projectLabel = ['--label', `${GUEST_PROJECT_LABEL}=${options.project}`] as const;
119
+
99
120
  // Same image selection rule the compose generator uses for machines: the
100
121
  // app zone needs a dockerd-capable box.
101
122
  const image =
@@ -106,6 +127,7 @@ export function createDockerProvisioner(options: DockerProvisionerOptions) {
106
127
  '-d',
107
128
  '--name',
108
129
  name,
130
+ ...projectLabel,
109
131
  '--hostname',
110
132
  guest.hostname,
111
133
  '--network',
@@ -34,7 +34,7 @@ const ISP_EDGE = '100.64.0.1';
34
34
  const EDGE_SERVICE = 'fw-ext';
35
35
 
36
36
  interface ComposeService {
37
- build?: { dockerfile?: string };
37
+ image?: string;
38
38
  networks?: Record<string, unknown>;
39
39
  }
40
40
 
@@ -61,7 +61,14 @@ function publicSimulators(yaml: string): Array<{ name: string; dockerfile: strin
61
61
  const compose = parse(yaml) as { services?: Record<string, ComposeService> };
62
62
  return Object.entries(compose.services ?? {})
63
63
  .filter(([name, svc]) => name !== EDGE_SERVICE && svc.networks?.['internet-external'])
64
- .map(([name, svc]) => ({ name, dockerfile: svc.build?.dockerfile ?? '' }));
64
+ .map(([name, svc]) => ({
65
+ name,
66
+ // The compose carries the tag, not the Dockerfile: the bake convention
67
+ // is tag `celilo-e2e/<name>` <-> `docker/Dockerfile.<name>` (build-infra
68
+ // tags every Dockerfile exactly that way). A drift in the convention
69
+ // makes the reads below fail loudly, which is what we want.
70
+ dockerfile: `docker/Dockerfile.${(svc.image ?? '').replace('celilo-e2e/', '').replace(/:.*/, '')}`,
71
+ }));
65
72
  }
66
73
 
67
74
  /**
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Where the celilo monorepo is, if we are inside one.
3
+ *
4
+ * Its own module because two unrelated consumers need it and neither may
5
+ * import the other: `cli/build.ts` uses it to find modules to package, and
6
+ * `doctor.ts` uses it to fingerprint the source a baked image came from —
7
+ * and `build.ts` already imports `doctor.ts`, so the reverse edge would be a
8
+ * cycle. Re-exported from `cli/build.ts` so its existing callers and its
9
+ * recurrence test are unchanged.
10
+ */
11
+
12
+ import { existsSync } from 'node:fs';
13
+ import { join, resolve } from 'node:path';
14
+
15
+ /**
16
+ * Walk up from `startDir` to the celilo checkout, or null.
17
+ *
18
+ * The marker is `apps/celilo/package.json` and must stay that specific
19
+ * (ce-dbc): keying on a bare `modules/` directory made a stray empty
20
+ * `node_modules/modules` from a botched `bun add -g` read as the monorepo
21
+ * root, which skipped the published-staging path and crashed the packer for
22
+ * every npm consumer.
23
+ */
24
+ export function findMonorepoRoot(startDir: string): string | null {
25
+ let dir = startDir;
26
+ for (let i = 0; i < 10; i++) {
27
+ if (existsSync(join(dir, 'apps', 'celilo', 'package.json'))) return dir;
28
+ const parent = resolve(dir, '..');
29
+ if (parent === dir) break;
30
+ dir = parent;
31
+ }
32
+ return null;
33
+ }
@@ -0,0 +1,76 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { KNOWN_RUN_FLAGS, filterTestFilesByPatterns, parseRunArgs } from './run-args';
3
+
4
+ describe('parseRunArgs', () => {
5
+ test('--help is recognized as help, never discarded into run-everything', () => {
6
+ const parsed = parseRunArgs(['--help']);
7
+ expect(parsed.helpRequested).toBe(true);
8
+ expect(parsed.unknownFlags).toEqual([]);
9
+ expect(parsed.patterns).toEqual([]);
10
+ });
11
+
12
+ test('every known flag is accepted and yields no patterns', () => {
13
+ const parsed = parseRunArgs([...KNOWN_RUN_FLAGS, '--seed=42']);
14
+ expect(parsed.unknownFlags).toEqual([]);
15
+ expect(parsed.patterns).toEqual([]);
16
+ expect(parsed.helpRequested).toBe(false);
17
+ });
18
+
19
+ test('positional patterns survive flag parsing in order', () => {
20
+ const parsed = parseRunArgs(['deploy', '--keep', 'caddy', '--verbose']);
21
+ expect(parsed.patterns).toEqual(['deploy', 'caddy']);
22
+ expect(parsed.unknownFlags).toEqual([]);
23
+ });
24
+
25
+ test('an unrecognised --flag is reported, not silently discarded', () => {
26
+ // The old runner stripped every --arg it did not know, leaving zero
27
+ // patterns: run everything. `--al` must error, not launch the catalogue.
28
+ const parsed = parseRunArgs(['--al', '--dry-run']);
29
+ expect(parsed.unknownFlags).toEqual(['--al', '--dry-run']);
30
+ expect(parsed.patterns).toEqual([]);
31
+ expect(parsed.helpRequested).toBe(false);
32
+ });
33
+
34
+ test('the CLI discovery aliases are unknown to the runner', () => {
35
+ // `cele2e run` strips --all/--all-modules before exec. If one ever
36
+ // reaches the runner directly it must error, not run everything.
37
+ const parsed = parseRunArgs(['--all', '--all-modules']);
38
+ expect(parsed.unknownFlags).toEqual(['--all', '--all-modules']);
39
+ });
40
+ });
41
+
42
+ describe('filterTestFilesByPatterns', () => {
43
+ const files = [
44
+ '/mods/knot/e2e/aspect-fanout.test.ts',
45
+ '/top/tests/aspect-fanout-new-systems.test.ts',
46
+ '/top/tests/caddy-internal-private.test.ts',
47
+ ];
48
+
49
+ test('an exact pattern selects only its own file, not the sibling it prefixes', () => {
50
+ // The old substring-OR filter dragged aspect-fanout-new-systems into
51
+ // `cele2e run aspect-fanout caddy-internal-private` whenever the
52
+ // top-level dir was in the collected set.
53
+ const picked = filterTestFilesByPatterns(files, ['aspect-fanout', 'caddy-internal-private']);
54
+ expect(picked).toEqual([
55
+ '/mods/knot/e2e/aspect-fanout.test.ts',
56
+ '/top/tests/caddy-internal-private.test.ts',
57
+ ]);
58
+ });
59
+
60
+ test('a pattern with no exact match keeps substring semantics', () => {
61
+ const picked = filterTestFilesByPatterns(files, ['fanout-new']);
62
+ expect(picked).toEqual(['/top/tests/aspect-fanout-new-systems.test.ts']);
63
+ });
64
+
65
+ test('several patterns union their matches', () => {
66
+ const picked = filterTestFilesByPatterns(files, ['aspect-fanout-new-systems', 'aspect-fanout']);
67
+ expect(picked).toEqual([
68
+ '/mods/knot/e2e/aspect-fanout.test.ts',
69
+ '/top/tests/aspect-fanout-new-systems.test.ts',
70
+ ]);
71
+ });
72
+
73
+ test('no patterns selects everything', () => {
74
+ expect(filterTestFilesByPatterns(files, [])).toEqual(files);
75
+ });
76
+ });
@@ -0,0 +1,89 @@
1
+ import { basename } from 'node:path';
2
+
3
+ /**
4
+ * Argument parsing for the e2e run script (runner.ts). Pure and side-effect
5
+ * free, so the runner's argv contract is unit-testable without touching
6
+ * Docker, the run-lock, or anything else main() reaches at import time.
7
+ */
8
+
9
+ /**
10
+ * Flags `e2e-run` understands. `--seed=<n>` is value-carrying and recognized
11
+ * by prefix, not listed here.
12
+ */
13
+ export const KNOWN_RUN_FLAGS: readonly string[] = [
14
+ '--keep',
15
+ '--reuse',
16
+ '--live',
17
+ '--published',
18
+ '--source-cli',
19
+ '--verbose',
20
+ '--ci',
21
+ '--no-ci',
22
+ '--complete',
23
+ // Passed through by `cele2e run` (harmless/no-op there, see cli/index.ts).
24
+ '--ci-safe',
25
+ '--notify',
26
+ '--shuffle',
27
+ '--no-interactive',
28
+ ];
29
+
30
+ export interface ParsedRunArgs {
31
+ /** Positional suite-name patterns (substring match against test file names). */
32
+ patterns: string[];
33
+ /** `--`-prefixed arguments the runner does not know. Must be empty before a run starts. */
34
+ unknownFlags: string[];
35
+ /** True when `--help` was requested. */
36
+ helpRequested: boolean;
37
+ }
38
+
39
+ /**
40
+ * Split e2e-run argv into patterns and flags. Reports unrecognized `--` args
41
+ * in `unknownFlags` instead of discarding them: the old filter stripped every
42
+ * `--`-prefixed argument it did not know, so `cele2e run --help` (and any
43
+ * typo'd flag) left zero patterns, which is the runner's encoding of "run
44
+ * everything" — the one command an unsure operator types took the single most
45
+ * expensive resource in the city.
46
+ */
47
+ export function parseRunArgs(argv: readonly string[]): ParsedRunArgs {
48
+ const patterns: string[] = [];
49
+ const unknownFlags: string[] = [];
50
+ let helpRequested = false;
51
+ for (const arg of argv) {
52
+ if (!arg.startsWith('--')) {
53
+ patterns.push(arg);
54
+ continue;
55
+ }
56
+ if (arg === '--help') {
57
+ helpRequested = true;
58
+ } else if (!isKnownFlag(arg)) {
59
+ unknownFlags.push(arg);
60
+ }
61
+ }
62
+ return { patterns, unknownFlags, helpRequested };
63
+ }
64
+
65
+ /**
66
+ * Filter collected test files by the run's positional patterns. A pattern
67
+ * that exactly equals a test name selects only that file; otherwise the old
68
+ * substring semantics apply. Without the exact rule, `cele2e run
69
+ * aspect-fanout caddy-internal-private` also ran aspect-fanout-new-systems:
70
+ * the substring OR matched the prefix whenever the top-level dir was in the
71
+ * collected set.
72
+ */
73
+ export function filterTestFilesByPatterns(
74
+ files: readonly string[],
75
+ patterns: readonly string[],
76
+ ): string[] {
77
+ if (patterns.length === 0) return [...files];
78
+ const exactPatterns = new Set(
79
+ patterns.filter((p) => files.some((f) => basename(f, '.test.ts') === p)),
80
+ );
81
+ return files.filter((f) => {
82
+ const name = basename(f, '.test.ts');
83
+ return patterns.some((p) => (exactPatterns.has(p) ? name === p : name.includes(p)));
84
+ });
85
+ }
86
+
87
+ function isKnownFlag(arg: string): boolean {
88
+ return KNOWN_RUN_FLAGS.includes(arg) || arg.startsWith('--seed=');
89
+ }