@celilo/e2e 0.20.1 → 0.20.3

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.
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { describe, expect, test } from 'bun:test';
11
- import { parseModuleHost } from './module-host';
11
+ import { parseModuleHost, parseModuleWhere } from './module-host';
12
12
 
13
13
  /** How the CLI really prints it — clack gutter and ANSI included. */
14
14
  const withGutter = (line: string) => `\x1b[1mModule: caddy\x1b[0m\n│ Placement:\n│ ${line}\n`;
@@ -69,3 +69,57 @@ describe('parseModuleHost', () => {
69
69
  expect(parseModuleHost(output)?.hostname).toBe('web');
70
70
  });
71
71
  });
72
+
73
+ describe('parseModuleWhere', () => {
74
+ test('the inventory payload yields the recorded address', () => {
75
+ const payload = JSON.stringify({
76
+ module: 'caddy-internal',
77
+ systems: [
78
+ {
79
+ name: 'main',
80
+ hostname: 'caddy-internal',
81
+ ipv4_address: '10.226.30.14',
82
+ zone: 'dmz',
83
+ vmid: 2201,
84
+ infra_type: 'container_service',
85
+ placement: 'caddy-internal (vmid 2201) → pve1 (zone dmz)',
86
+ reachability: "firewall-segmented (dmz) — reach via the firewall's natIp DNAT",
87
+ },
88
+ ],
89
+ });
90
+
91
+ expect(parseModuleWhere(payload)).toEqual(['10.226.30.14']);
92
+ });
93
+
94
+ test('ANSI and whitespace around the payload do not break it', () => {
95
+ // The success path prints verbatim (celilo#698), but dev-mode shims have
96
+ // surprised us before, so the parse tolerates decoration.
97
+ const payload = `\x1b[1m${JSON.stringify({ systems: [{ ipv4_address: '10.226.30.15' }] })}\x1b[0m\n`;
98
+
99
+ expect(parseModuleWhere(payload)).toEqual(['10.226.30.15']);
100
+ });
101
+
102
+ test('a module with several systems yields them all, caller picks', () => {
103
+ const payload = JSON.stringify({
104
+ systems: [
105
+ { name: 'main', ipv4_address: '10.226.20.9' },
106
+ { name: 'replica', ipv4_address: '10.226.20.10' },
107
+ ],
108
+ });
109
+
110
+ expect(parseModuleWhere(payload)).toEqual(['10.226.20.9', '10.226.20.10']);
111
+ });
112
+
113
+ test('an API-only module yields an empty list, a real state', () => {
114
+ // namecheap has no host. Empty is not a parse failure — the caller decides
115
+ // whether "no recorded address" is expected for their module.
116
+ expect(parseModuleWhere(JSON.stringify({ systems: [] }))).toEqual([]);
117
+ });
118
+
119
+ test('a non-JSON answer yields an empty list, not a throw', () => {
120
+ // Crash text on stdout is a CLI failure and the harness method attaches
121
+ // the raw output to its error; the parser's contract is "addresses found",
122
+ // not "reasons why not".
123
+ expect(parseModuleWhere('Error: module not found: caddy\n')).toEqual([]);
124
+ });
125
+ });
@@ -70,3 +70,34 @@ export function parseModuleHost(statusOutput: string): ModuleHost | null {
70
70
 
71
71
  return null;
72
72
  }
73
+
74
+ /**
75
+ * The address(es) a module's deploy recorded, from `celilo module where --json`.
76
+ *
77
+ * The old source was `grep target_ip` over the module's `generated/` tree
78
+ * (celilo#1334). D4 of control-plane-stops-building-modules makes that tree
79
+ * ephemeral — a successful deploy deletes it — so the inventory is where the
80
+ * address lives now: `module_systems` is written by the deploy itself and
81
+ * survives it. Every current module declares exactly one system, so callers
82
+ * take the first address; a module with none is an API-only module or an
83
+ * undeployed one, and that distinction belongs to the caller.
84
+ */
85
+ export function parseModuleWhere(whereOutput: string): string[] {
86
+ // The success path prints the payload verbatim (celilo#698), but defensive
87
+ // ANSI stripping costs one line and dev-mode shims have surprised us before.
88
+ const stripped = whereOutput.replace(/\x1b\[[0-9;]*m/g, '').trim();
89
+
90
+ let parsed: { systems?: { ipv4_address?: string }[] };
91
+ try {
92
+ parsed = JSON.parse(stripped) as { systems?: { ipv4_address?: string }[] };
93
+ } catch {
94
+ // A non-JSON answer is a CLI failure (version drift, crash text) arriving
95
+ // on stdout. An empty list reads as "no systems", which is a real state a
96
+ // caller may legitimately handle — these are not the same problem.
97
+ return [];
98
+ }
99
+
100
+ return (parsed.systems ?? [])
101
+ .map((system) => system.ipv4_address ?? '')
102
+ .filter((address) => address !== '');
103
+ }
@@ -0,0 +1,139 @@
1
+ import { afterAll, beforeAll, expect, test } from 'bun:test';
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ rmSync,
7
+ symlinkSync,
8
+ utimesSync,
9
+ writeFileSync,
10
+ } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+ import { isNetappCurrent } from './netapp-staleness';
14
+
15
+ let dir: string;
16
+ let src: string;
17
+ let netapp: string;
18
+ const scratch: string[] = [];
19
+
20
+ beforeAll(() => {
21
+ dir = mkdtempSync(join(tmpdir(), 'netapp-stale-'));
22
+ scratch.push(dir);
23
+ src = join(dir, 'src');
24
+ netapp = join(dir, 'out.netapp');
25
+ mkdirSync(src);
26
+ });
27
+
28
+ afterAll(() => {
29
+ for (const path of scratch) rmSync(path, { recursive: true, force: true });
30
+ });
31
+
32
+ function writeSource(name: string, contents: string): string {
33
+ const target = join(src, name);
34
+ writeFileSync(target, contents);
35
+ return target;
36
+ }
37
+
38
+ test('no netapp yet: not current, even over an empty tree', () => {
39
+ expect(existsSync(netapp)).toBe(false);
40
+ expect(isNetappCurrent(netapp, dir)).toBe(false);
41
+ });
42
+
43
+ test('netapp newer than every source file: current (the skip case)', () => {
44
+ writeSource('main.sh', 'echo hello');
45
+ writeFileSync(netapp, 'netapp-bytes');
46
+ // The netapp was written after the source, so repackaging would produce
47
+ // the same bytes. build-infra must skip this module.
48
+ expect(isNetappCurrent(netapp, dir)).toBe(true);
49
+ });
50
+
51
+ test('recurrence gate (celilo#1258): touching a source file makes the netapp stale', () => {
52
+ // Touch, don't rewrite: proves the check reads mtimes, not contents.
53
+ const touched = writeSource('touched.txt', 'x');
54
+ const later = new Date(Date.now() + 60_000);
55
+ utimesSync(touched, later, later);
56
+ expect(isNetappCurrent(netapp, dir)).toBe(false);
57
+ });
58
+
59
+ test('netapp older than a source edit: stale', () => {
60
+ const older = new Date(Date.now() - 120_000);
61
+ utimesSync(netapp, older, older);
62
+ expect(isNetappCurrent(netapp, dir)).toBe(false);
63
+ });
64
+
65
+ test('a rebuilt binlink dir never marks the netapp stale (the bun install shape)', () => {
66
+ // Fresh tree: shared state above would leak src/'s own dir mtime into this
67
+ // assertion, and src/ is a shipped path whose mtime must count.
68
+ const tree = mkdtempSync(join(tmpdir(), 'netapp-binlinks-'));
69
+ scratch.push(tree);
70
+ const moduleDir = join(tree, 'mod');
71
+ const binDir = join(moduleDir, 'e2e', 'node_modules', '.bin');
72
+ mkdirSync(binDir, { recursive: true });
73
+ writeFileSync(join(moduleDir, 'main.sh'), 'echo hello');
74
+ symlinkSync('../real-tool', join(binDir, 'tool'));
75
+ const staged = join(tree, 'staged.netapp');
76
+ writeFileSync(staged, 'netapp-bytes');
77
+ expect(isNetappCurrent(staged, moduleDir)).toBe(true);
78
+
79
+ // bun install recreates the binlink: the symlink file AND the .bin
80
+ // directory move past the netapp. Neither ships (see classifyModulePath
81
+ // and includeNodeModulesPath), so the netapp stays current. This is the
82
+ // dir-level case the file-level exclusion alone misses, probed 2026-09-08:
83
+ // `-not -path '*/node_modules/.bin/*'` does not match `.bin` itself, and
84
+ // creating an entry moves the dir's mtime.
85
+ rmSync(join(binDir, 'tool'));
86
+ symlinkSync('../real-tool', join(binDir, 'tool'));
87
+ expect(isNetappCurrent(staged, moduleDir)).toBe(true);
88
+ });
89
+
90
+ test('a change confined to the module e2e/ tree never marks the netapp stale', () => {
91
+ const tree = mkdtempSync(join(tmpdir(), 'netapp-e2e-'));
92
+ scratch.push(tree);
93
+ const moduleDir = join(tree, 'mod');
94
+ mkdirSync(join(moduleDir, 'e2e'), { recursive: true });
95
+ writeFileSync(join(moduleDir, 'main.sh'), 'echo hello');
96
+ writeFileSync(join(moduleDir, 'e2e', 'suite.test.ts'), 'test("x", () => {});');
97
+ const staged = join(tree, 'staged.netapp');
98
+ writeFileSync(staged, 'netapp-bytes');
99
+ expect(isNetappCurrent(staged, moduleDir)).toBe(true);
100
+
101
+ // An e2e-only edit: the file and its parent dir are both unshipped, so
102
+ // the netapp stays current. A change to main.sh would NOT be excluded —
103
+ // covered by the recurrence gate above.
104
+ const later = new Date(Date.now() + 60_000);
105
+ const e2eFile = join(moduleDir, 'e2e', 'new.test.ts');
106
+ writeFileSync(e2eFile, 'test("y", () => {});');
107
+ utimesSync(e2eFile, later, later);
108
+ expect(isNetappCurrent(staged, moduleDir)).toBe(true);
109
+ });
110
+
111
+ test('a change to a SHIPPED bin-closure path marks the netapp stale', () => {
112
+ // scripts/node_modules minus .bin is the hook runtime closure the packager
113
+ // ships in full (ISS-0046). A change there must repackage, so the
114
+ // exclusion cannot be as broad as "anything under node_modules".
115
+ const tree = mkdtempSync(join(tmpdir(), 'netapp-hookrt-'));
116
+ scratch.push(tree);
117
+ const moduleDir = join(tree, 'mod');
118
+ const hookDep = join(moduleDir, 'scripts', 'node_modules', 'tldts');
119
+ mkdirSync(hookDep, { recursive: true });
120
+ writeFileSync(join(moduleDir, 'main.sh'), 'echo hello');
121
+ writeFileSync(join(hookDep, 'index.js'), 'module.exports = {};');
122
+ const staged = join(tree, 'staged.netapp');
123
+ writeFileSync(staged, 'netapp-bytes');
124
+ expect(isNetappCurrent(staged, moduleDir)).toBe(true);
125
+
126
+ const later = new Date(Date.now() + 60_000);
127
+ const depFile = join(hookDep, 'index.js');
128
+ writeFileSync(depFile, 'module.exports = { changed: true };');
129
+ utimesSync(depFile, later, later);
130
+ expect(isNetappCurrent(staged, moduleDir)).toBe(false);
131
+ });
132
+
133
+ test('a nonexistent source tree repackages (a check that cannot answer never reuses)', () => {
134
+ const tree = mkdtempSync(join(tmpdir(), 'netapp-missing-'));
135
+ scratch.push(tree);
136
+ const staged = join(tree, 'staged.netapp');
137
+ writeFileSync(staged, 'netapp-bytes');
138
+ expect(isNetappCurrent(staged, join(tree, 'does-not-exist'))).toBe(false);
139
+ });
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Staleness helper for build-infra's `packageNetapp` (cli/build.ts).
3
+ *
4
+ * Before celilo#1258 build-infra repackaged all 37 modules on every run
5
+ * (about 5 minutes to reach a 38 second test) because nothing reused a
6
+ * current .netapp. publishModule's reuse of the staged netapp is a separate
7
+ * fix that landed from ce-nvd8 (bf05c946) and keeps its own probe.
8
+ */
9
+
10
+ import { execSync } from 'node:child_process';
11
+ import { existsSync } from 'node:fs';
12
+
13
+ /**
14
+ * True when `netappPath` exists and nothing that ships in the package has
15
+ * changed under `sourceDir` since it was written.
16
+ *
17
+ * `find -newer -quit` asks the filesystem instead of walking in JS and stops
18
+ * at the first newer file. A staleness check that cannot answer repackages:
19
+ * reusing on an inconclusive result is how a test silently runs against a
20
+ * stale module.
21
+ *
22
+ * Only paths the package actually contains can make it stale. `e2e/` is
23
+ * excluded from a module package wholesale and `node_modules/.bin` is excluded
24
+ * from the hook runtime closure — see `classifyModulePath` and
25
+ * `includeNodeModulesPath` in apps/celilo/src/module/packaging/, which are the
26
+ * authority. They are restated rather than imported because packages/e2e has
27
+ * no import path into apps/celilo, and registry-server's bootstrap.ts already
28
+ * carries the same duplication for the same reason.
29
+ *
30
+ * Both subtrees are excluded at the DIRECTORY level as well as the file
31
+ * level (path suffixes `e2e` and `node_modules/.bin`, with and without a
32
+ * trailing component): a directory's mtime moves whenever an entry inside
33
+ * it is created or replaced, so `bun install` recreating the binlinks in a
34
+ * module's `e2e/node_modules/.bin` would otherwise mark every module
35
+ * permanently dirty through the dir entry alone.
36
+ *
37
+ * Getting these wrong is safe in one direction only, and it is this one:
38
+ * counting a non-packaged path makes us repackage needlessly (slow), while
39
+ * MISSING a packaged path would reuse a stale netapp (wrong). Every exclusion
40
+ * here is a path the packager does not ship, so it cannot cause the latter.
41
+ */
42
+ export function isNetappCurrent(netappPath: string, sourceDir: string): boolean {
43
+ if (!existsSync(netappPath)) return false;
44
+ // Each exclude needs both the bare dir and its contents: `find -newer`
45
+ // matches the directory's own mtime too, and creating or replacing an
46
+ // entry inside moves it.
47
+ const excludes = [
48
+ "-not -path '*/e2e'",
49
+ "-not -path '*/e2e/*'",
50
+ "-not -path '*/node_modules/.bin'",
51
+ "-not -path '*/node_modules/.bin/*'",
52
+ ];
53
+ try {
54
+ const newer = execSync(
55
+ `find ${JSON.stringify(sourceDir)} -newer ${JSON.stringify(netappPath)} ${excludes.join(' ')} -print -quit`,
56
+ { encoding: 'utf-8', timeout: 30_000 },
57
+ ).trim();
58
+ return newer === '';
59
+ } catch {
60
+ return false; // a check that cannot answer repackages
61
+ }
62
+ }
@@ -38,6 +38,10 @@ async function ensureModuleDeps(hostPath: string): Promise<void> {
38
38
  export class NetworkBuilder {
39
39
  private config: NetworkConfig = {
40
40
  topology: 'default',
41
+ // The production topology: celilo-mgr sits on its own control-plane
42
+ // network. A suite that genuinely needs the single-network legacy
43
+ // topology opts out with `.managementZone('internal')` and says why.
44
+ managementZone: 'secure-mgmt',
41
45
  dmzMachines: [],
42
46
  appMachines: [],
43
47
  secureMachines: [],
@@ -72,9 +76,11 @@ export class NetworkBuilder {
72
76
 
73
77
  /**
74
78
  * Place the celilo management container in a given zone. Defaults to
75
- * `internal`; `secure-mgmt` gives celilo-mgr its own control-plane network
76
- * (the production topology), so a test can exercise celilo reaching and
77
- * resolving for a fleet from OFF the internal LAN.
79
+ * `secure-mgmt` (the production topology, celilo-mgr on its own
80
+ * control-plane network). `internal` opts back into the legacy
81
+ * single-network topology, where "trusted subnet == network.internal.subnet"
82
+ * comes out true by construction — only for a suite that genuinely tests a
83
+ * behaviour on that topology, and says so.
78
84
  */
79
85
  managementZone(zone: 'internal' | 'secure-mgmt'): this {
80
86
  this.config.managementZone = zone;
@@ -278,6 +284,16 @@ export class NetworkBuilder {
278
284
  return this;
279
285
  }
280
286
 
287
+ /**
288
+ * The config this builder will hand to `startNetwork`, read back for tests
289
+ * that assert on the DEFAULTS rather than construct a literal. Without this
290
+ * a default is invisible to every gate (the literal-shaped configs in the
291
+ * unit tests never exercise it).
292
+ */
293
+ snapshotConfig(): Readonly<NetworkConfig> {
294
+ return { ...this.config };
295
+ }
296
+
281
297
  async start(): Promise<NetworkHandle> {
282
298
  // Pre-flight: ensure mounted module directories have deps installed.
283
299
  // Without this, `module import` tries to `bun install` inside Docker
@@ -28,7 +28,16 @@
28
28
  */
29
29
 
30
30
  import { cpSync, existsSync, rmSync } from 'node:fs';
31
- import { join } from 'node:path';
31
+ import { basename, join } from 'node:path';
32
+
33
+ // @psbanka - 2026-09: Test files are excluded from the bundle (ce-62u1). A
34
+ // bundled test copy is one directory deeper than its source, so
35
+ // workspace-relative imports like '../../../apps/...' resolve one level short
36
+ // and fail. The bundle exists to serve the sim image, which runs no tests, so
37
+ // the tests belong to the source package only.
38
+ function isTestFile(path: string): boolean {
39
+ return /\.test\.tsx?$/.test(basename(path));
40
+ }
32
41
 
33
42
  /**
34
43
  * Ensure <pkgDir>/registry-server/ contains a fresh copy of the
@@ -76,6 +85,9 @@ function ensureSiblingBundle(pkgDir: string, name: string, entries: string[]): v
76
85
 
77
86
  rmSync(bundle, { recursive: true, force: true });
78
87
  for (const entry of entries) {
79
- cpSync(join(upstream, entry), join(bundle, entry), { recursive: true });
88
+ cpSync(join(upstream, entry), join(bundle, entry), {
89
+ recursive: true,
90
+ filter: (src) => !isTestFile(src),
91
+ });
80
92
  }
81
93
  }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Gate on the wiring of the --keep exit path (celilo#1313).
3
+ *
4
+ * `cele2e run <suite> --keep` kept the per-test project but tore the SHARED
5
+ * infrastructure down anyway, so the kept stack had no DNS, no CA and no
6
+ * registry — half a system, in the one mode the debugging method prescribes.
7
+ * The mechanism was a wiring bug: the end-of-run `stopSharedInfra()` call
8
+ * carried no flagKeep guard, while the per-test path right above it was
9
+ * guarded. Like the sibling exit-cleanup gate, the unit gate is on the wiring;
10
+ * the behavioral proof (one cheap suite run with --keep, shared containers
11
+ * inspected afterwards) is an e2e run and needs the rig.
12
+ *
13
+ * Read the source instead of importing the runner: main() runs at import time
14
+ * (module top-level), so importing it would execute a docker run.
15
+ */
16
+
17
+ import { expect, test } from 'bun:test';
18
+ import { readFileSync } from 'node:fs';
19
+ import { dirname, join } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ const SRC = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'runner.ts'), 'utf-8');
23
+
24
+ /** The end-of-run block, from the results summary to the last-run write. */
25
+ function endOfRunBlock(): string {
26
+ return SRC.slice(SRC.indexOf('saveTiming(history);\n saveBlockTiming(blockTiming);'));
27
+ }
28
+
29
+ test('end-of-run stopSharedInfra is guarded by flagKeep (celilo#1313)', () => {
30
+ // Today the call ran unconditionally, so a --keep run destroyed exactly the
31
+ // shared infra the kept stack depends on.
32
+ const block = endOfRunBlock();
33
+ const guardAt = block.indexOf('if (flagKeep)');
34
+ const stopAt = block.indexOf('stopSharedInfra()');
35
+ expect(guardAt).toBeGreaterThan(-1);
36
+ expect(stopAt).toBeGreaterThan(-1);
37
+ // The unconditional call sat BEFORE any guard; after the fix the guard
38
+ // precedes it, so the call is inside the flagKeep branch.
39
+ expect(guardAt).toBeLessThan(stopAt);
40
+ });
41
+
42
+ test('a --keep exit names what was kept, not just what stopped (celilo#1313)', () => {
43
+ // "Stopping shared infrastructure..." printed while the user had asked to
44
+ // keep things, and nothing said DNS and certs would be missing. The kept
45
+ // path must name the shared pieces (DNS, CA, registry) and the one teardown
46
+ // command that reaps them.
47
+ const block = endOfRunBlock();
48
+ const keepPath = block.slice(block.indexOf('if (flagKeep)'), block.indexOf('stopSharedInfra()'));
49
+ expect(keepPath).toContain('DNS');
50
+ expect(keepPath).toContain('cele2e down');
51
+ });
52
+
53
+ test('the kept lock marks under --keep even when no project was captured', () => {
54
+ // With shared infra left up, a --keep run that failed to capture a project
55
+ // (startNetwork failure) still leaves a live stack. The kept lock is the
56
+ // instrument that says so: the next run's live-stack guard reads the same
57
+ // state, but the refusal reads as contention rather than a deliberate kept
58
+ // stack unless the lock marks. Idempotent with the per-test markKept().
59
+ const block = endOfRunBlock();
60
+ const keepPath = block.slice(block.indexOf('if (flagKeep)'), block.indexOf('} else'));
61
+ expect(keepPath).toContain('markKept()');
62
+ });
63
+
64
+ test('SIGINT still stops shared infra unconditionally (abort is not a kept stack)', () => {
65
+ // Pinned as intended behavior: Ctrl-C is an abort, the persistent project
66
+ // was never written and no kept lock was marked, so tearing the shared
67
+ // pieces down is right. The next run's live-stack guard names the remedy
68
+ // for anything the abort left live.
69
+ const sigint = SRC.slice(SRC.indexOf("process.on('SIGINT'"), SRC.indexOf('for (let i = 0;'));
70
+ expect(sigint).toContain('stopSharedInfra()');
71
+ expect(sigint).not.toContain('flagKeep');
72
+ });
package/src/runner.ts CHANGED
@@ -1080,8 +1080,25 @@ async function main() {
1080
1080
  saveTiming(history);
1081
1081
  saveBlockTiming(blockTiming);
1082
1082
 
1083
- console.log(`${dim}Stopping shared infrastructure...${reset}`);
1084
- stopSharedInfra();
1083
+ // A --keep run keeps the WHOLE stack: the per-test project (persistent file
1084
+ // written in the loop) and the shared infra here. Tearing the shared pieces
1085
+ // down under --keep gave a kept stack with no name resolution and no certs
1086
+ // (celilo#1313) — half a system, in the mode the debugging method
1087
+ // prescribes. The exit stays one command: `cele2e down` sweeps every
1088
+ // celilo-e2e-* container, network and volume by name filter (bin/e2e-down)
1089
+ // and releases the kept lock, so the kept shared infra costs nothing extra
1090
+ // to reap. markKept() here too: with shared infra left up, a run that
1091
+ // captured no project still leaves a live stack, and an unmarked lock makes
1092
+ // the next run's live-stack refusal read as contention instead of a
1093
+ // deliberate kept stack. Idempotent with the per-test markKept().
1094
+ if (flagKeep) {
1095
+ markKept();
1096
+ console.log(`${dim}Keeping shared infrastructure (DNS, CA, registry) for debugging.${reset}`);
1097
+ console.log(`${dim} Tear down everything (project + shared infra): cele2e down${reset}`);
1098
+ } else {
1099
+ console.log(`${dim}Stopping shared infrastructure...${reset}`);
1100
+ stopSharedInfra();
1101
+ }
1085
1102
 
1086
1103
  const suiteDuration = Math.floor((Date.now() - suiteStart) / 1000);
1087
1104
 
package/src/types.ts CHANGED
@@ -120,14 +120,16 @@ export interface NetworkConfig {
120
120
  */
121
121
  signalRelease: boolean;
122
122
  /**
123
- * Which zone the celilo management container sits in. Defaults to `internal`
124
- * — the single-network topology every existing test uses.
123
+ * Which zone the celilo management container sits in. Defaults to
124
+ * `secure-mgmt` — the production topology, celilo-mgr on its own
125
+ * control-plane network. (It used to default to `internal`, the
126
+ * single-network topology, and that default is why the control-plane-network
127
+ * bug reached production: with management always on `internal`, the hardcoded
128
+ * "trusted subnet == network.internal.subnet" assumption was accidentally
129
+ * true in every test.)
125
130
  *
126
- * `secure-mgmt` places celilo-mgr on its OWN control-plane network, which is
127
- * the production topology and the one the suite could not express before. That
128
- * gap is why the control-plane-network bug reached production: with management
129
- * always on `internal`, the hardcoded "trusted subnet == network.internal.subnet"
130
- * assumption was accidentally true in every test.
131
+ * A suite that genuinely needs the legacy single-network topology sets
132
+ * `internal` EXPLICITLY and says why in a comment.
131
133
  */
132
134
  managementZone?: 'internal' | 'secure-mgmt';
133
135
  /**
@@ -290,6 +292,18 @@ export interface NetworkHandle {
290
292
  */
291
293
  moduleHost(moduleId: string): Promise<ModuleHost>;
292
294
 
295
+ /**
296
+ * The IPv4 address a module's deploy recorded, from `celilo module where
297
+ * --json` (the `module_systems` inventory).
298
+ *
299
+ * Replaces `grep target_ip` over the module's `generated/` tree, which D4
300
+ * of control-plane-stops-building-modules deletes after every successful
301
+ * deploy (celilo#1334). The inventory is the deploy's own durable record of
302
+ * the address, so this works on the machine pool and the container service
303
+ * alike. Throws when the deploy recorded nothing.
304
+ */
305
+ targetIp(moduleId: string): Promise<string>;
306
+
293
307
  /**
294
308
  * Tell celilo where its own control plane lives, and wait until the box is
295
309
  * usable again.
@@ -1,76 +0,0 @@
1
- import { describe, expect, test } from 'bun:test';
2
- import { ADMIN_SCOPE, TokenAuth, hashToken } from './auth';
3
-
4
- describe('TokenAuth (package-scoped — ISS-0140)', () => {
5
- test('empty specs → no tokens; nothing authorizes', () => {
6
- const auth = new TokenAuth([]);
7
- expect(auth.hasTokens()).toBe(false);
8
- expect(auth.authorize('anything', 'caddy')).toBe(false);
9
- expect(auth.isAdmin('anything')).toBe(false);
10
- });
11
-
12
- test('admin-scoped token publishes ANY package', () => {
13
- const auth = new TokenAuth([{ token: 'admin-tok', scope: ADMIN_SCOPE }]);
14
- expect(auth.authorize('admin-tok', 'caddy')).toBe(true);
15
- expect(auth.authorize('admin-tok', 'lunacycle')).toBe(true);
16
- expect(auth.isAdmin('admin-tok')).toBe(true);
17
- });
18
-
19
- test('package-scoped token publishes ONLY its package', () => {
20
- const auth = new TokenAuth([{ token: 'luna-tok', scope: 'lunacycle' }]);
21
- expect(auth.authorize('luna-tok', 'lunacycle')).toBe(true);
22
- expect(auth.authorize('luna-tok', 'caddy')).toBe(false);
23
- // A scoped token is NOT an admin token — it cannot mint.
24
- expect(auth.isAdmin('luna-tok')).toBe(false);
25
- });
26
-
27
- test('unknown token never authorizes', () => {
28
- const auth = new TokenAuth([{ token: 'known', scope: 'lunacycle' }]);
29
- expect(auth.authorize('unknown', 'lunacycle')).toBe(false);
30
- });
31
-
32
- test('tokens are stored hashed, not in cleartext', () => {
33
- const auth = new TokenAuth([{ token: 'secret-token', scope: ADMIN_SCOPE }]);
34
- expect(JSON.stringify(auth)).not.toContain('secret-token');
35
- });
36
-
37
- test('tolerates a Bearer prefix and surrounding whitespace', () => {
38
- const auth = new TokenAuth([{ token: 'clean', scope: ADMIN_SCOPE }]);
39
- expect(auth.authorize(' clean ', 'caddy')).toBe(true);
40
- expect(auth.authorize('Bearer clean', 'caddy')).toBe(true);
41
- });
42
-
43
- test('empty header rejected', () => {
44
- const auth = new TokenAuth([{ token: 'secret', scope: ADMIN_SCOPE }]);
45
- expect(auth.authorize('', 'caddy')).toBe(false);
46
- expect(auth.scopeOf('')).toBe(null);
47
- });
48
-
49
- test('addHashed / removeHashed manage minted tokens at runtime', () => {
50
- const auth = new TokenAuth([]);
51
- const hash = hashToken('minted-tok');
52
- auth.addHashed(hash, 'lunacycle');
53
- expect(auth.authorize('minted-tok', 'lunacycle')).toBe(true);
54
- auth.removeHashed(hash);
55
- expect(auth.authorize('minted-tok', 'lunacycle')).toBe(false);
56
- });
57
- });
58
-
59
- describe('TokenAuth.fromEnv (PUBLISH_TOKENS format)', () => {
60
- test('bare line → admin scope; "token pkg" line → scoped', () => {
61
- process.env.PUBLISH_TOKENS = 'admin-tok\nluna-tok lunacycle\n';
62
- const auth = TokenAuth.fromEnv();
63
- expect(auth.isAdmin('admin-tok')).toBe(true);
64
- expect(auth.authorize('luna-tok', 'lunacycle')).toBe(true);
65
- expect(auth.authorize('luna-tok', 'caddy')).toBe(false);
66
- process.env.PUBLISH_TOKENS = '';
67
- });
68
-
69
- test('blank lines ignored', () => {
70
- process.env.PUBLISH_TOKENS = '\n \nt1\n';
71
- const auth = TokenAuth.fromEnv();
72
- expect(auth.hasTokens()).toBe(true);
73
- expect(auth.isAdmin('t1')).toBe(true);
74
- process.env.PUBLISH_TOKENS = '';
75
- });
76
- });
@@ -1,71 +0,0 @@
1
- /**
2
- * Lockstep gate (ISS-0046): the registry-server's on-demand `walkDir` packager
3
- * can't import the canonical node_modules-bundling rule at runtime (this server
4
- * ships standalone, with no @celilo deps), so it carries a structural copy.
5
- * This test imports the canonical rule directly (zero-dependency module, safe to
6
- * pull in at test time from the workspace) and asserts walkDir's actual output
7
- * conforms to it — so a drift in either copy fails here instead of shipping a
8
- * broken `.netapp`.
9
- */
10
-
11
- import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
12
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
13
- import { tmpdir } from 'node:os';
14
- import { join } from 'node:path';
15
- // Canonical rule lives in apps/celilo; importing it cross-package is fine in a
16
- // workspace test (it's not bundled into the standalone registry-sim image).
17
- import { includeNodeModulesPath } from '../../../apps/celilo/src/module/packaging/package-rules';
18
- import { walkDir } from './bootstrap';
19
-
20
- describe('bootstrap walkDir stays in lockstep with the canonical packaging rule', () => {
21
- let dir: string;
22
-
23
- // Representative node_modules paths spanning both branches of the rule.
24
- const nmFiles = [
25
- 'scripts/node_modules/@celilo/capabilities/src/dns-internal.ts',
26
- 'scripts/node_modules/tldts/index.js',
27
- 'scripts/node_modules/drizzle-orm/index.js',
28
- 'scripts/node_modules/.bin/tldts',
29
- 'node_modules/@celilo/capabilities/index.js',
30
- 'node_modules/lodash/index.js',
31
- 'node_modules/@celilo/e2e/index.js',
32
- ];
33
-
34
- function write(rel: string): void {
35
- const full = join(dir, rel);
36
- mkdirSync(join(full, '..'), { recursive: true });
37
- writeFileSync(full, 'x');
38
- }
39
-
40
- beforeEach(() => {
41
- dir = mkdtempSync(join(tmpdir(), 'celilo-bootstrap-rule-'));
42
- write('manifest.yml');
43
- write('scripts/on-install.ts');
44
- for (const f of nmFiles) write(f);
45
- });
46
-
47
- afterEach(() => {
48
- try {
49
- rmSync(dir, { recursive: true, force: true });
50
- } catch {
51
- /* ignore */
52
- }
53
- });
54
-
55
- it("walkDir's node_modules decisions match includeNodeModulesPath", () => {
56
- const bundled = new Set(walkDir(dir));
57
- for (const rel of nmFiles) {
58
- expect(bundled.has(rel)).toBe(includeNodeModulesPath(rel));
59
- }
60
- });
61
-
62
- it('bundles scripts deps (tldts), drops .bin shims and non-scripts cruft', () => {
63
- const bundled = new Set(walkDir(dir));
64
- expect(bundled.has('scripts/node_modules/tldts/index.js')).toBe(true);
65
- expect(bundled.has('scripts/node_modules/drizzle-orm/index.js')).toBe(true);
66
- expect(bundled.has('scripts/node_modules/.bin/tldts')).toBe(false);
67
- expect(bundled.has('node_modules/lodash/index.js')).toBe(false);
68
- expect(bundled.has('node_modules/@celilo/e2e/index.js')).toBe(false);
69
- expect(bundled.has('node_modules/@celilo/capabilities/index.js')).toBe(true);
70
- });
71
- });