@celilo/cli 0.26.1 → 1.0.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.
Files changed (48) hide show
  1. package/CELILO_CORE_MODULES.md +3 -0
  2. package/CELILO_SUBSYSTEMS.md +4 -2
  3. package/drizzle/0025_port_forward_owner.sql +29 -0
  4. package/drizzle/meta/_journal.json +7 -0
  5. package/package.json +3 -3
  6. package/src/__integration__/container-services-cli.integration.test.ts +0 -4
  7. package/src/ansible/dependencies.test.ts +233 -289
  8. package/src/ansible/dependencies.ts +151 -83
  9. package/src/cli/commands/alerts-sweep.ts +14 -3
  10. package/src/cli/commands/machine-add.ts +0 -1
  11. package/src/cli/commands/machine-list.ts +10 -4
  12. package/src/cli/commands/machine-remove.ts +13 -7
  13. package/src/cli/commands/machine-status.ts +9 -11
  14. package/src/cli/commands/module-remove.ts +26 -23
  15. package/src/cli/commands/system-audit.ts +5 -1
  16. package/src/cli/commands/system-update.ts +10 -2
  17. package/src/db/schema.ts +30 -10
  18. package/src/hooks/capability-loader.ts +65 -13
  19. package/src/hooks/define-hook.test.ts +4 -6
  20. package/src/hooks/executor.ts +2 -1
  21. package/src/hooks/types.ts +9 -17
  22. package/src/infrastructure/property-extractor.test.ts +0 -2
  23. package/src/manifest/contracts/index.ts +20 -0
  24. package/src/manifest/contracts/v1.ts +33 -1
  25. package/src/manifest/schema.ts +48 -58
  26. package/src/services/alerting/sweep-runner.test.ts +5 -1
  27. package/src/services/alerting/sweep-runner.ts +14 -8
  28. package/src/services/aspect-runner.test.ts +0 -1
  29. package/src/services/audit/undeployed-modules.ts +18 -1
  30. package/src/services/consumer-cleanup.test.ts +347 -0
  31. package/src/services/consumer-cleanup.ts +244 -0
  32. package/src/services/infrastructure-selector.test.ts +0 -7
  33. package/src/services/infrastructure-selector.ts +24 -25
  34. package/src/services/infrastructure-variable-resolver.test.ts +0 -6
  35. package/src/services/infrastructure-variable-resolver.ts +0 -3
  36. package/src/services/machine-pool.test.ts +53 -85
  37. package/src/services/machine-pool.ts +68 -84
  38. package/src/services/module-deploy.ts +17 -39
  39. package/src/services/module-validator/index.test.ts +9 -0
  40. package/src/services/port-forwards.test.ts +93 -40
  41. package/src/services/port-forwards.ts +74 -48
  42. package/src/services/ssh-key-manager.test.ts +0 -10
  43. package/src/services/trusted-sources.test.ts +52 -13
  44. package/src/services/trusted-sources.ts +25 -15
  45. package/src/test-utils/cli-context.ts +15 -2
  46. package/src/types/infrastructure.ts +11 -1
  47. package/src/services/web-route-cleanup.test.ts +0 -250
  48. package/src/services/web-route-cleanup.ts +0 -144
@@ -59,38 +59,18 @@ export interface DeployResult {
59
59
  };
60
60
  }
61
61
 
62
- /**
63
- * Update machine's assigned module IDs after successful deployment
64
- * Execution function - updates database
65
- *
66
- * @param moduleId - Module identifier
67
- * @param machineId - Machine identifier
68
- * @param db - Database connection
69
- */
70
- async function updateMachineAssignment(
71
- moduleId: string,
72
- machineId: string,
73
- db: DbClient,
74
- ): Promise<void> {
75
- // Get current machine record
76
- const machine = await db.select().from(machines).where(eq(machines.id, machineId)).get();
77
-
78
- if (!machine) {
79
- throw new Error(`Machine not found: ${machineId}`);
80
- }
81
-
82
- // Add module ID if not already assigned
83
- const assignedIds = machine.assignedModuleIds || [];
84
- if (!assignedIds.includes(moduleId)) {
85
- const updatedIds = [...assignedIds, moduleId];
86
- await db
87
- .update(machines)
88
- .set({ assignedModuleIds: updatedIds })
89
- .where(eq(machines.id, machineId))
90
- .run();
91
- log.success(`Machine ${machineId} updated with module assignment`);
92
- }
93
- }
62
+ // `updateMachineAssignment` is deleted (celilo#773).
63
+ //
64
+ // It was the sole writer of `machines.assigned_module_ids`, it only ever
65
+ // APPENDED, and nothing ever removed an entry — `module remove` deletes the
66
+ // module row and never touches `machines`. So a machine accumulated the ids of
67
+ // modules that no longer existed and could never be freed except by editing the
68
+ // database.
69
+ //
70
+ // Nothing replaces it. Occupancy is now derived from `module_infrastructure`
71
+ // and `module_systems` at the point of use (`getModulesOnMachine`), both of
72
+ // which the deploy path already writes and both of which cascade on module
73
+ // removal — so the machine frees itself with no bookkeeping step to forget.
94
74
 
95
75
  export interface DeployOptions {
96
76
  debug?: boolean;
@@ -1203,13 +1183,11 @@ async function deployModuleImpl(
1203
1183
  };
1204
1184
  }
1205
1185
 
1206
- if (plan.infrastructure?.type === 'machine' && plan.infrastructure.machineId) {
1207
- // Update machine's assigned_module_ids
1208
- await updateMachineAssignment(moduleId, plan.infrastructure.machineId, db);
1209
- } else if (
1210
- plan.infrastructure?.type === 'container_service' &&
1211
- plan.infrastructure.serviceId
1212
- ) {
1186
+ if (plan.infrastructure?.type === 'container_service' && plan.infrastructure.serviceId) {
1187
+ // Placeholder branch retained below; the machine branch is gone because
1188
+ // occupancy is no longer a stored fact to update (celilo#773).
1189
+ }
1190
+ if (plan.infrastructure?.type === 'container_service' && plan.infrastructure.serviceId) {
1213
1191
  // TODO: extract Terraform outputs and persist them on
1214
1192
  // module_infrastructure.containerMetadata. Until that lands,
1215
1193
  // the deploy still succeeds — we just don't track which
@@ -15,6 +15,15 @@ const REPO_ROOT = resolve(__dirname, '../../../../..');
15
15
  const CADDY_MODULE_PATH = resolve(REPO_ROOT, 'modules/caddy');
16
16
 
17
17
  describe('runChecks (orchestrator)', () => {
18
+ // celilo#804: this test timed out at 60000ms on a docs-only CI run yet
19
+ // measures ~500ms end-to-end locally (all 3 tests in this file, combined).
20
+ // The work here is a handful of synchronous `git`/`spawnSync` calls
21
+ // (checkGitHygiene) with nothing to cut without dropping the git_hygiene
22
+ // check this test exists to exercise — so this was a saturated CI runner
23
+ // stalling those spawns, not this suite sitting close to its budget.
24
+ // Raising the timeout further wouldn't shrink that margin (a real stall
25
+ // would blow through any budget); left at the file's default (60000ms,
26
+ // ~100x the measured runtime) rather than bumped without evidence it helps.
18
27
  test('healthy in-tree module produces all-ok report', async () => {
19
28
  const checks = await runChecks(CADDY_MODULE_PATH, {
20
29
  noBuild: true,
@@ -3,11 +3,14 @@ import { mkdtempSync, rmSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import type { PortForwardStore } from '@celilo/capabilities';
6
+ import { eq } from 'drizzle-orm';
6
7
  import type { DbClient } from '../db/client';
8
+ import { portForwards } from '../db/schema';
7
9
  import { setupTestDatabase } from '../test-utils/setup-test-db';
8
- import { buildPortForwardStore } from './port-forwards';
10
+ import { buildPortForwardStore, deletePortForwardsForModule } from './port-forwards';
9
11
 
10
12
  const FW = '192.168.0.254';
13
+ const CADDY = { internalIp: '10.0.20.5', protocol: 'TCP' as const, description: 'caddy' };
11
14
 
12
15
  describe('port-forward store', () => {
13
16
  let dir: string;
@@ -19,7 +22,7 @@ describe('port-forward store', () => {
19
22
  const dbPath = join(dir, 'celilo.db');
20
23
  process.env.CELILO_DB_PATH = dbPath;
21
24
  db = await setupTestDatabase(dbPath);
22
- store = buildPortForwardStore(db);
25
+ store = buildPortForwardStore(db, 'caddy');
23
26
  });
24
27
  afterEach(() => {
25
28
  db.$client.close();
@@ -31,62 +34,112 @@ describe('port-forward store', () => {
31
34
  }
32
35
  });
33
36
 
34
- it('adds and lists forwards for a firewall', () => {
35
- store.add(FW, { internalIp: '10.0.20.5', port: 443, protocol: 'TCP', description: 'caddy' });
36
- store.add(FW, {
37
- internalIp: '10.0.20.42',
38
- port: 2222,
39
- protocol: 'TCP',
40
- description: 'forgejo',
41
- });
37
+ it('declares and lists forwards for a firewall', () => {
38
+ store.replace(FW, CADDY, [80, 443]);
42
39
  const forwards = store.list(FW);
43
40
  expect(forwards).toHaveLength(2);
44
- expect(forwards.map((f) => f.port).sort((a, b) => a - b)).toEqual([443, 2222]);
41
+ expect(forwards.map((f) => f.port).sort((a, b) => a - b)).toEqual([80, 443]);
45
42
  });
46
43
 
47
- it('add is an idempotent upsert same tuple one row, description updated', () => {
48
- store.add(FW, { internalIp: '10.0.20.5', port: 443, protocol: 'TCP', description: 'v1' });
49
- store.add(FW, { internalIp: '10.0.20.5', port: 443, protocol: 'TCP', description: 'v2' });
44
+ it('stamps registeredBy from the bound consumer, and no caller can supply it', () => {
45
+ store.replace(FW, CADDY, [443]);
46
+ expect(store.list(FW)[0].registeredBy).toBe('caddy');
47
+ // The write takes only (firewall, target, ports) — there is no parameter an
48
+ // owner could be passed through, which is the point rather than an omission.
49
+ expect(store.replace.length).toBe(3);
50
+ });
51
+
52
+ it('replace is idempotent — same declaration twice → one row per port', () => {
53
+ store.replace(FW, { ...CADDY, description: 'v1' }, [443]);
54
+ store.replace(FW, { ...CADDY, description: 'v2' }, [443]);
50
55
  const forwards = store.list(FW);
51
56
  expect(forwards).toHaveLength(1);
52
57
  expect(forwards[0].description).toBe('v2');
53
58
  });
54
59
 
55
60
  it('scopes forwards by firewallIp', () => {
56
- store.add(FW, { internalIp: '10.0.20.5', port: 443, protocol: 'TCP', description: 'a' });
57
- store.add('10.0.30.254', {
58
- internalIp: '10.0.30.5',
59
- port: 53,
60
- protocol: 'UDP',
61
- description: 'b',
62
- });
61
+ store.replace(FW, CADDY, [443]);
62
+ store.replace(
63
+ '10.0.30.254',
64
+ { internalIp: '10.0.30.5', protocol: 'UDP', description: 'b' },
65
+ [53],
66
+ );
63
67
  expect(store.list(FW)).toHaveLength(1);
64
68
  expect(store.list('10.0.30.254')).toHaveLength(1);
65
69
  });
66
70
 
67
- it('remove deletes exactly the tuple and leaves others', () => {
68
- store.add(FW, { internalIp: '10.0.20.5', port: 443, protocol: 'TCP', description: 'x' });
69
- store.add(FW, { internalIp: '10.0.20.5', port: 80, protocol: 'TCP', description: 'y' });
70
- store.remove(FW, '10.0.20.5', 443, 'TCP');
71
- const forwards = store.list(FW);
72
- expect(forwards).toHaveLength(1);
73
- expect(forwards[0].port).toBe(80);
74
- });
75
-
76
71
  it('ingressIp forwards are distinct from the public (NULL) ones', () => {
77
- store.add(FW, { internalIp: '10.0.20.5', port: 53, protocol: 'UDP', description: 'public' });
78
- store.add(FW, {
79
- internalIp: '10.0.20.5',
80
- port: 53,
81
- protocol: 'UDP',
82
- ingressIp: '10.0.10.53',
83
- description: 'ingress',
84
- });
72
+ const target = { internalIp: '10.0.20.5', protocol: 'UDP' as const, description: 'dns' };
73
+ store.replace(FW, target, [53]);
74
+ store.replace(FW, { ...target, ingressIp: '10.0.10.53' }, [53]);
85
75
  expect(store.list(FW)).toHaveLength(2);
86
- // removing the public (NULL-ingress) one leaves the ingress one intact
87
- store.remove(FW, '10.0.20.5', 53, 'UDP');
76
+ // Re-declaring the public (NULL-ingress) set as empty leaves the ingress one.
77
+ store.replace(FW, target, []);
88
78
  const forwards = store.list(FW);
89
79
  expect(forwards).toHaveLength(1);
90
80
  expect(forwards[0].ingressIp).toBe('10.0.10.53');
91
81
  });
82
+
83
+ // D5b / celilo#855. Before this, `exposeService` upserted per port and nothing
84
+ // ever removed a forward a consumer stopped wanting: a module that exposed
85
+ // :8080 and redeployed exposing :9090 kept both, forever.
86
+ it('re-declaring with a shorter port list drops the ports left out', () => {
87
+ store.replace(FW, CADDY, [80, 443, 8080]);
88
+ store.replace(FW, CADDY, [80, 443]);
89
+ expect(
90
+ store
91
+ .list(FW)
92
+ .map((f) => f.port)
93
+ .sort((a, b) => a - b),
94
+ ).toEqual([80, 443]);
95
+ });
96
+
97
+ it('a consumer narrowing its declaration withdraws no other module rows', () => {
98
+ const forgejo = buildPortForwardStore(db, 'forgejo');
99
+ store.replace(FW, CADDY, [80, 443]);
100
+ forgejo.replace(FW, { internalIp: '10.0.20.42', protocol: 'TCP', description: 'git' }, [2222]);
101
+
102
+ store.replace(FW, CADDY, [443]);
103
+
104
+ expect(store.list(FW).filter((f) => f.registeredBy === 'forgejo')).toHaveLength(1);
105
+ });
106
+
107
+ // D5a, the refcount case — the bug most likely to ship silently. The owner is
108
+ // IN the unique index, so two consumers of the same forward are two rows and
109
+ // one leaving does not delete a rule the other still needs.
110
+ it('two consumers of the SAME forward are two rows, and one leaving leaves the other', () => {
111
+ const other = buildPortForwardStore(db, 'greenwave-app');
112
+ store.replace(FW, CADDY, [443]);
113
+ other.replace(FW, { ...CADDY, description: 'also 443' }, [443]);
114
+ expect(store.list(FW)).toHaveLength(2);
115
+
116
+ deletePortForwardsForModule(db, 'caddy');
117
+
118
+ const left = store.list(FW);
119
+ expect(left).toHaveLength(1);
120
+ expect(left[0].registeredBy).toBe('greenwave-app');
121
+ expect(left[0].port).toBe(443);
122
+ });
123
+
124
+ // Migration 0025 leaves pre-existing rows with an empty owner so the fleet
125
+ // keeps serving. They are adopted the first time their owner re-declares the
126
+ // same target, which is the only moment we can safely say who owns them.
127
+ it('adopts an unattributed legacy row when its owner re-declares the target', () => {
128
+ db.insert(portForwards)
129
+ .values({
130
+ firewallIp: FW,
131
+ internalIp: CADDY.internalIp,
132
+ port: 443,
133
+ protocol: 'TCP',
134
+ description: 'legacy',
135
+ registeredBy: '',
136
+ })
137
+ .run();
138
+
139
+ store.replace(FW, CADDY, [443]);
140
+
141
+ const rows = db.select().from(portForwards).where(eq(portForwards.firewallIp, FW)).all();
142
+ expect(rows).toHaveLength(1);
143
+ expect(rows[0].registeredBy).toBe('caddy');
144
+ });
92
145
  });
@@ -2,15 +2,20 @@
2
2
  * Port-forward registry — the DB-backed `PortForwardStore` (openspec/changes/unified-management-no-ssh/proposal.md).
3
3
  *
4
4
  * The shared-core desired-state store for the `firewall` capability. The
5
- * capability-loader constructs one of these and injects it into the firewall
6
- * provider factory, so `exposeService`/`unexposeService` become register/
7
- * unregister and the provider's converge renders the complete ruleset from
8
- * `list(firewallIp)` and applies it atomically (`iptables-restore`). Replaces
9
- * "read the box back with `iptables -L`" as the source of truth.
5
+ * capability-loader constructs one of these bound to the CONSUMING module and
6
+ * injects it into the firewall provider factory, so `exposeService` becomes a
7
+ * declaration of that consumer's complete port set and the provider's converge
8
+ * renders the whole ruleset from `list(firewallIp)`, applying it atomically
9
+ * (`iptables-restore`). Replaces "read the box back with `iptables -L`" as the
10
+ * source of truth.
11
+ *
12
+ * `registeredBy` is stamped HERE, never accepted from a caller — the same rule
13
+ * `buildTrustedSourceStore` follows, so a forward cannot be attributed to the
14
+ * wrong module (openspec/changes/consumer-removal-cleanup, D2).
10
15
  */
11
16
 
12
- import type { PortForward, PortForwardStore, Protocol } from '@celilo/capabilities';
13
- import { and, eq, isNull } from 'drizzle-orm';
17
+ import type { PortForward, PortForwardStore, PortForwardTarget } from '@celilo/capabilities';
18
+ import { and, eq, isNull, or } from 'drizzle-orm';
14
19
  import type { DbClient } from '../db/client';
15
20
  import { portForwards } from '../db/schema';
16
21
 
@@ -19,7 +24,37 @@ function ingressMatch(ingressIp: string | undefined) {
19
24
  return ingressIp ? eq(portForwards.ingressIp, ingressIp) : isNull(portForwards.ingressIp);
20
25
  }
21
26
 
22
- export function buildPortForwardStore(db: DbClient): PortForwardStore {
27
+ /**
28
+ * Every row this consumer owns for one target, plus the UNATTRIBUTED rows for
29
+ * the same target.
30
+ *
31
+ * The unattributed half is the migration's other end: rows written before
32
+ * `registered_by` existed carry `''`, and a consumer re-declaring the target
33
+ * they describe is the one moment we can safely say who owns them — one module
34
+ * owns a backend IP. Without this they would render forever with no owner to
35
+ * withdraw them.
36
+ */
37
+ function ownedOrUnattributed(firewallIp: string, target: PortForwardTarget, consumer: string) {
38
+ return and(
39
+ eq(portForwards.firewallIp, firewallIp),
40
+ eq(portForwards.internalIp, target.internalIp),
41
+ eq(portForwards.protocol, target.protocol),
42
+ ingressMatch(target.ingressIp),
43
+ or(eq(portForwards.registeredBy, consumer), eq(portForwards.registeredBy, '')),
44
+ );
45
+ }
46
+
47
+ /**
48
+ * Build the store bound to the module that will call through the capability.
49
+ *
50
+ * ponytail: a consumer that stops exposing a target ENTIRELY — its backend IP
51
+ * changes, or it drops a host — leaves rows for the old target, because no call
52
+ * arrives to declare that target's set empty. Bounded: the rows die when the
53
+ * module is removed, and a redeploy onto a new host is the only way to reach
54
+ * it. Closing it needs a sweep against `getModuleSystems`, which is not
55
+ * obviously worth its own failure mode.
56
+ */
57
+ export function buildPortForwardStore(db: DbClient, registeredBy: string): PortForwardStore {
23
58
  return {
24
59
  list(firewallIp: string): PortForward[] {
25
60
  return db
@@ -33,54 +68,45 @@ export function buildPortForwardStore(db: DbClient): PortForwardStore {
33
68
  protocol: r.protocol,
34
69
  ingressIp: r.ingressIp ?? undefined,
35
70
  description: r.description,
71
+ registeredBy: r.registeredBy,
36
72
  }));
37
73
  },
38
74
 
39
- add(firewallIp: string, forward: PortForward): void {
40
- // Idempotent upsert: delete any existing row for this exact tuple, then
41
- // insert. Matches buildRouteOps' delete-then-insert (keeps the operation
42
- // atomic w.r.t. the unique index, and NULL-aware on ingress_ip).
75
+ replace(firewallIp: string, target: PortForwardTarget, ports: number[]): void {
76
+ // Delete-then-insert over the consumer's WHOLE set for this target, not
77
+ // per port: that is what makes a redeploy exposing a shorter port list
78
+ // withdraw the ports it left out (celilo#855). Scoped to this consumer,
79
+ // so another module's forward for the same target is untouched.
43
80
  db.delete(portForwards)
44
- .where(
45
- and(
46
- eq(portForwards.firewallIp, firewallIp),
47
- eq(portForwards.internalIp, forward.internalIp),
48
- eq(portForwards.port, forward.port),
49
- eq(portForwards.protocol, forward.protocol),
50
- ingressMatch(forward.ingressIp),
51
- ),
52
- )
81
+ .where(ownedOrUnattributed(firewallIp, target, registeredBy))
53
82
  .run();
54
- db.insert(portForwards)
55
- .values({
56
- firewallIp,
57
- internalIp: forward.internalIp,
58
- port: forward.port,
59
- protocol: forward.protocol,
60
- ingressIp: forward.ingressIp ?? null,
61
- description: forward.description,
62
- })
63
- .run();
64
- },
65
83
 
66
- remove(
67
- firewallIp: string,
68
- internalIp: string,
69
- port: number,
70
- protocol: Protocol,
71
- ingressIp?: string,
72
- ): void {
73
- db.delete(portForwards)
74
- .where(
75
- and(
76
- eq(portForwards.firewallIp, firewallIp),
77
- eq(portForwards.internalIp, internalIp),
78
- eq(portForwards.port, port),
79
- eq(portForwards.protocol, protocol),
80
- ingressMatch(ingressIp),
81
- ),
84
+ if (ports.length === 0) return;
85
+
86
+ db.insert(portForwards)
87
+ .values(
88
+ ports.map((port) => ({
89
+ firewallIp,
90
+ internalIp: target.internalIp,
91
+ port,
92
+ protocol: target.protocol,
93
+ ingressIp: target.ingressIp ?? null,
94
+ description: target.description,
95
+ registeredBy,
96
+ })),
82
97
  )
83
98
  .run();
84
99
  },
85
100
  };
86
101
  }
102
+
103
+ /**
104
+ * Drop every forward a departing module owns, across every firewall.
105
+ *
106
+ * `registered_by` is plain text rather than a FK, so unlike `web_routes` these
107
+ * rows do NOT die with the `modules` row — core deletes them explicitly, AFTER
108
+ * the provider has converged without them (D4).
109
+ */
110
+ export function deletePortForwardsForModule(db: DbClient, moduleId: string): void {
111
+ db.delete(portForwards).where(eq(portForwards.registeredBy, moduleId)).run();
112
+ }
@@ -55,7 +55,6 @@ describe('a machine with no stored SSH key fails loudly, not silently', () => {
55
55
  hardware: { cpu_cores: 4, memory_mb: 4096, disk_gb: 100 },
56
56
  role: 'host',
57
57
  interfaces: [],
58
- assignedModuleIds: [],
59
58
  });
60
59
 
61
60
  await expect(writeTemporarySshKey(machine.id)).rejects.toThrow(/has no SSH key stored/);
@@ -114,7 +113,6 @@ describe('ssh-key-manager', () => {
114
113
  hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
115
114
  role: 'host',
116
115
  interfaces: [],
117
- assignedModuleIds: [],
118
116
  });
119
117
 
120
118
  const keyPath = await writeTemporarySshKey(machine.id);
@@ -142,7 +140,6 @@ describe('ssh-key-manager', () => {
142
140
  hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
143
141
  role: 'host',
144
142
  interfaces: [],
145
- assignedModuleIds: [],
146
143
  });
147
144
 
148
145
  const keyPath = await writeTemporarySshKey(machine.id);
@@ -163,7 +160,6 @@ describe('ssh-key-manager', () => {
163
160
  hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
164
161
  role: 'host',
165
162
  interfaces: [],
166
- assignedModuleIds: [],
167
163
  });
168
164
 
169
165
  const keyPath = await writeTemporarySshKey(machine.id);
@@ -189,7 +185,6 @@ describe('ssh-key-manager', () => {
189
185
  hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
190
186
  role: 'host',
191
187
  interfaces: [],
192
- assignedModuleIds: [],
193
188
  });
194
189
 
195
190
  const machine2 = await addMachine({
@@ -201,7 +196,6 @@ describe('ssh-key-manager', () => {
201
196
  hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
202
197
  role: 'host',
203
198
  interfaces: [],
204
- assignedModuleIds: [],
205
199
  });
206
200
 
207
201
  const keyPath1 = await writeTemporarySshKey(machine1.id);
@@ -232,7 +226,6 @@ describe('ssh-key-manager', () => {
232
226
  hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
233
227
  role: 'host',
234
228
  interfaces: [],
235
- assignedModuleIds: [],
236
229
  });
237
230
 
238
231
  const managedKey = new ManagedSshKey(machine.id);
@@ -252,7 +245,6 @@ describe('ssh-key-manager', () => {
252
245
  hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
253
246
  role: 'host',
254
247
  interfaces: [],
255
- assignedModuleIds: [],
256
248
  });
257
249
 
258
250
  const managedKey = new ManagedSshKey(machine.id);
@@ -274,7 +266,6 @@ describe('ssh-key-manager', () => {
274
266
  hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
275
267
  role: 'host',
276
268
  interfaces: [],
277
- assignedModuleIds: [],
278
269
  });
279
270
 
280
271
  const managedKey = new ManagedSshKey(machine.id);
@@ -301,7 +292,6 @@ describe('ssh-key-manager', () => {
301
292
  hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
302
293
  role: 'host',
303
294
  interfaces: [],
304
- assignedModuleIds: [],
305
295
  });
306
296
 
307
297
  const managedKey = new ManagedSshKey(machine.id);
@@ -40,27 +40,63 @@ describe('trusted-source store', () => {
40
40
 
41
41
  it('stamps registeredBy from the binding, not the caller', () => {
42
42
  const store = buildTrustedSourceStore(db, 'wireguard');
43
- store.add(FW, { subnet: VPN, description: 'admin VPN clients' });
43
+ store.replace(FW, { subnets: [VPN], description: 'admin VPN clients' });
44
44
 
45
45
  expect(store.list(FW)).toEqual([
46
46
  { subnet: VPN, description: 'admin VPN clients', registeredBy: 'wireguard' },
47
47
  ]);
48
48
  });
49
49
 
50
- it('upserts by subnetregistering twice yields one row', () => {
50
+ it('replace is idempotentdeclaring the same set twice yields one row', () => {
51
51
  const store = buildTrustedSourceStore(db, 'wireguard');
52
- store.add(FW, { subnet: VPN, description: 'first' });
53
- store.add(FW, { subnet: VPN, description: 'second' });
52
+ store.replace(FW, { subnets: [VPN], description: 'first' });
53
+ store.replace(FW, { subnets: [VPN], description: 'second' });
54
54
 
55
55
  expect(store.list(FW)).toHaveLength(1);
56
56
  expect(store.list(FW)[0].description).toBe('second');
57
57
  });
58
58
 
59
- it('removes by subnet and leaves other firewalls alone', () => {
60
- buildTrustedSourceStore(db, 'wireguard').add(FW, { subnet: VPN, description: 'vpn' });
61
- buildTrustedSourceStore(db, 'other').add('10.0.0.1', { subnet: VPN, description: 'elsewhere' });
59
+ // D5b: the set is DECLARED, so changing an admin VPN's client subnet revokes
60
+ // the old one's reach. It used to keep reaching every zone forever.
61
+ it('a subnet left out of a later declaration loses its reach', () => {
62
+ const store = buildTrustedSourceStore(db, 'wireguard');
63
+ store.replace(FW, { subnets: [VPN, '10.9.9.0/24'], description: 'vpn' });
64
+ store.replace(FW, { subnets: [VPN], description: 'vpn' });
62
65
 
63
- buildTrustedSourceStore(db, 'wireguard').remove(FW, VPN);
66
+ expect(store.list(FW).map((s) => s.subnet)).toEqual([VPN]);
67
+ });
68
+
69
+ it('an empty declaration withdraws the consumer’s whole set', () => {
70
+ const store = buildTrustedSourceStore(db, 'wireguard');
71
+ store.replace(FW, { subnets: [VPN], description: 'vpn' });
72
+ store.replace(FW, { subnets: [], description: 'vpn' });
73
+
74
+ expect(store.list(FW)).toEqual([]);
75
+ });
76
+
77
+ // D5a, the refcount case: the owner is in the unique index, so two modules
78
+ // trusting the same subnet are two rows and one withdrawing does not revoke
79
+ // the other's reach.
80
+ it('two consumers trusting the same subnet are two rows; one withdrawing leaves the other', () => {
81
+ buildTrustedSourceStore(db, 'wireguard').replace(FW, { subnets: [VPN], description: 'vpn' });
82
+ buildTrustedSourceStore(db, 'other').replace(FW, { subnets: [VPN], description: 'also vpn' });
83
+ expect(listTrustedSourcesFor(db, FW)).toHaveLength(2);
84
+
85
+ buildTrustedSourceStore(db, 'wireguard').replace(FW, { subnets: [], description: 'vpn' });
86
+
87
+ expect(listTrustedSourcesFor(db, FW)).toEqual([
88
+ { subnet: VPN, description: 'also vpn', registeredBy: 'other' },
89
+ ]);
90
+ });
91
+
92
+ it('withdrawing on one firewall leaves other firewalls alone', () => {
93
+ buildTrustedSourceStore(db, 'wireguard').replace(FW, { subnets: [VPN], description: 'vpn' });
94
+ buildTrustedSourceStore(db, 'other').replace('10.0.0.1', {
95
+ subnets: [VPN],
96
+ description: 'elsewhere',
97
+ });
98
+
99
+ buildTrustedSourceStore(db, 'wireguard').replace(FW, { subnets: [], description: 'vpn' });
64
100
 
65
101
  expect(buildTrustedSourceStore(db, 'wireguard').list(FW)).toEqual([]);
66
102
  expect(buildTrustedSourceStore(db, 'other').list('10.0.0.1')).toHaveLength(1);
@@ -69,9 +105,9 @@ describe('trusted-source store', () => {
69
105
  it('reads one firewall’s registrations without a module binding', () => {
70
106
  // Trust registered against one firewall is not trust granted by another —
71
107
  // the read is scoped, and the reader needs no identity to look.
72
- buildTrustedSourceStore(db, 'wireguard').add(FW, { subnet: VPN, description: 'vpn' });
73
- buildTrustedSourceStore(db, 'other').add('10.0.0.1', {
74
- subnet: '172.16.9.0/24',
108
+ buildTrustedSourceStore(db, 'wireguard').replace(FW, { subnets: [VPN], description: 'vpn' });
109
+ buildTrustedSourceStore(db, 'other').replace('10.0.0.1', {
110
+ subnets: ['172.16.9.0/24'],
75
111
  description: 'elsewhere',
76
112
  });
77
113
 
@@ -80,7 +116,7 @@ describe('trusted-source store', () => {
80
116
  });
81
117
 
82
118
  it('reports every registration across firewalls, with who registered it', () => {
83
- buildTrustedSourceStore(db, 'wireguard').add(FW, { subnet: VPN, description: 'vpn' });
119
+ buildTrustedSourceStore(db, 'wireguard').replace(FW, { subnets: [VPN], description: 'vpn' });
84
120
 
85
121
  expect(listAllTrustedSources(db)).toEqual([
86
122
  { firewallIp: FW, subnet: VPN, description: 'vpn', registeredBy: 'wireguard' },
@@ -104,7 +140,10 @@ describe('the render input excludes registrations; the reporting view includes t
104
140
  process.env.CELILO_DB_PATH = dbPath;
105
141
  db = await setupTestDatabase(dbPath);
106
142
  db.insert(systemConfig).values({ key: 'network.internal.subnet', value: CONTROL_PLANE }).run();
107
- buildTrustedSourceStore(db, 'wireguard').add(FW, { subnet: VPN, description: 'admin VPN' });
143
+ buildTrustedSourceStore(db, 'wireguard').replace(FW, {
144
+ subnets: [VPN],
145
+ description: 'admin VPN',
146
+ });
108
147
  });
109
148
  afterEach(() => {
110
149
  db.$client.close();
@@ -38,31 +38,41 @@ export function buildTrustedSourceStore(db: DbClient, registeredBy: string): Tru
38
38
  }));
39
39
  },
40
40
 
41
- add(firewallIp: string, source: RegisterTrustedSourceRequest): void {
42
- // Idempotent upsert on (firewall, subnet) matches buildPortForwardStore.
41
+ replace(firewallIp: string, source: RegisterTrustedSourceRequest): void {
42
+ // The consumer's COMPLETE set for this firewall (D5b), scoped to its own
43
+ // rows: a subnet it trusted before and omits now loses its reach, and a
44
+ // subnet another module also trusts keeps it. Changing an admin VPN's
45
+ // client subnet used to leave the old one reaching every zone forever.
43
46
  db.delete(trustedSources)
44
47
  .where(
45
- and(eq(trustedSources.firewallIp, firewallIp), eq(trustedSources.subnet, source.subnet)),
48
+ and(
49
+ eq(trustedSources.firewallIp, firewallIp),
50
+ eq(trustedSources.registeredBy, registeredBy),
51
+ ),
46
52
  )
47
53
  .run();
48
- db.insert(trustedSources)
49
- .values({
50
- firewallIp,
51
- subnet: source.subnet,
52
- description: source.description,
53
- registeredBy,
54
- })
55
- .run();
56
- },
57
54
 
58
- remove(firewallIp: string, subnet: string): void {
59
- db.delete(trustedSources)
60
- .where(and(eq(trustedSources.firewallIp, firewallIp), eq(trustedSources.subnet, subnet)))
55
+ if (source.subnets.length === 0) return;
56
+
57
+ db.insert(trustedSources)
58
+ .values(
59
+ source.subnets.map((subnet) => ({
60
+ firewallIp,
61
+ subnet,
62
+ description: source.description,
63
+ registeredBy,
64
+ })),
65
+ )
61
66
  .run();
62
67
  },
63
68
  };
64
69
  }
65
70
 
71
+ /** Drop every trusted source a departing module owns, across every firewall. */
72
+ export function deleteTrustedSourcesForModule(db: DbClient, moduleId: string): void {
73
+ db.delete(trustedSources).where(eq(trustedSources.registeredBy, moduleId)).run();
74
+ }
75
+
66
76
  /** Where a trusted subnet came from — reach into every tier must be attributable. */
67
77
  export type TrustedSubnetOrigin = 'derived-control-plane' | 'registered' | 'operator-override';
68
78