@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
@@ -0,0 +1,347 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import type { DbClient } from '../db/client';
6
+ import type { ModuleManifest } from '../manifest/schema';
7
+ import { setupTestDatabase } from '../test-utils/setup-test-db';
8
+ import {
9
+ type CleanupTarget,
10
+ type ProviderRow,
11
+ type ProviderState,
12
+ planConsumerCleanup,
13
+ runConsumerCleanup,
14
+ } from './consumer-cleanup';
15
+
16
+ /**
17
+ * The pure half of the removal dispatch
18
+ * (openspec/changes/consumer-removal-cleanup). Which providers get told, and
19
+ * which are skipped and why, is all decided here — so it is all assertable
20
+ * without a database, a hook runner, or a deployed anything.
21
+ */
22
+
23
+ function manifest(input: { requires?: string[]; optional?: string[] }): ModuleManifest {
24
+ return {
25
+ requires: { capabilities: (input.requires ?? []).map((name) => ({ name, version: '1.0.0' })) },
26
+ optional: { capabilities: (input.optional ?? []).map((name) => ({ name, version: '1.0.0' })) },
27
+ } as unknown as ModuleManifest;
28
+ }
29
+
30
+ const deployed = (...ids: string[]): ProviderState[] =>
31
+ ids.map((moduleId) => ({ moduleId, state: 'VERIFIED' }));
32
+
33
+ const provides = (...pairs: Array<[string, string]>): ProviderRow[] =>
34
+ pairs.map(([moduleId, capabilityName]) => ({ moduleId, capabilityName }));
35
+
36
+ describe('planConsumerCleanup', () => {
37
+ it('collects providers of BOTH requires and optional capabilities', () => {
38
+ // The same set `remove-guard.ts` counts as a dependency edge. A capability
39
+ // consumed optionally still had state minted for it — `technitium` consumes
40
+ // `dhcp_server` under `optional:`, and the two definitions disagreeing has
41
+ // already been silently harmful once.
42
+ const plan = planConsumerCleanup(
43
+ 'consumer',
44
+ manifest({ requires: ['public_web'], optional: ['firewall'] }),
45
+ provides(['caddy', 'public_web'], ['iptables', 'firewall']),
46
+ deployed('caddy', 'iptables'),
47
+ );
48
+
49
+ expect(plan.map((t) => t.providerId)).toEqual(['caddy', 'iptables']);
50
+ });
51
+
52
+ it('tells EVERY provider of one capability (the chained-firewall case)', () => {
53
+ // `capabilities` has no uniqueness on the name: `firewall` deliberately has
54
+ // several rows, an edge provider plus inner layers, and each holds its own
55
+ // rules for the departing consumer (D3).
56
+ const plan = planConsumerCleanup(
57
+ 'consumer',
58
+ manifest({ requires: ['firewall'] }),
59
+ provides(['iptables', 'firewall'], ['greenwave', 'firewall']),
60
+ deployed('iptables', 'greenwave'),
61
+ );
62
+
63
+ expect(plan.map((t) => t.providerId)).toEqual(['greenwave', 'iptables']);
64
+ });
65
+
66
+ it('tells a provider of two consumed capabilities exactly ONCE', () => {
67
+ // Dispatching per capability would run the same withdrawal twice (D1) — the
68
+ // hook is a full converge, so the second run is at best wasted and at worst
69
+ // re-applies a ruleset over a half-applied one.
70
+ const plan = planConsumerCleanup(
71
+ 'consumer',
72
+ manifest({ requires: ['source_forge', 'registry_publish'] }),
73
+ provides(['forgejo', 'source_forge'], ['forgejo', 'registry_publish']),
74
+ deployed('forgejo'),
75
+ );
76
+
77
+ expect(plan).toHaveLength(1);
78
+ expect(plan[0].providerId).toBe('forgejo');
79
+ expect(plan[0].capabilityNames).toEqual(['registry_publish', 'source_forge']);
80
+ });
81
+
82
+ it('sorts by provider id, so dispatch order is deterministic', () => {
83
+ const plan = planConsumerCleanup(
84
+ 'consumer',
85
+ manifest({ requires: ['firewall'] }),
86
+ provides(['zebra', 'firewall'], ['axon', 'firewall'], ['iptables', 'firewall']),
87
+ deployed('zebra', 'axon', 'iptables'),
88
+ );
89
+
90
+ expect(plan.map((t) => t.providerId)).toEqual(['axon', 'iptables', 'zebra']);
91
+ });
92
+
93
+ it('marks a PAUSED provider skipped rather than dispatching to it', () => {
94
+ // A paused module does not run non-lifecycle hooks (D7). Recording the skip
95
+ // is what lets the caller say WHICH provider is still holding state.
96
+ const plan = planConsumerCleanup(
97
+ 'consumer',
98
+ manifest({ requires: ['public_web'] }),
99
+ provides(['caddy', 'public_web']),
100
+ [{ moduleId: 'caddy', state: 'PAUSED' }],
101
+ );
102
+
103
+ expect(plan).toEqual([
104
+ { providerId: 'caddy', capabilityNames: ['public_web'], skip: 'paused' },
105
+ ]);
106
+ });
107
+
108
+ it('marks a never-deployed provider skipped', () => {
109
+ // Capabilities are registered at IMPORT, so the table routinely names
110
+ // providers that have never resolved anything and hold nothing (D8).
111
+ for (const state of ['IMPORTED', 'VALIDATED', 'CONFIGURED']) {
112
+ const plan = planConsumerCleanup(
113
+ 'consumer',
114
+ manifest({ requires: ['public_web'] }),
115
+ provides(['caddy', 'public_web']),
116
+ [{ moduleId: 'caddy', state }],
117
+ );
118
+ expect(plan[0].skip).toBe('not-deployed');
119
+ }
120
+ });
121
+
122
+ it('skips a provider with no module row at all rather than dispatching blind', () => {
123
+ const plan = planConsumerCleanup(
124
+ 'consumer',
125
+ manifest({ requires: ['public_web'] }),
126
+ provides(['caddy', 'public_web']),
127
+ [],
128
+ );
129
+
130
+ expect(plan[0].skip).toBe('not-deployed');
131
+ });
132
+
133
+ it('is empty for a module that consumes nothing', () => {
134
+ expect(
135
+ planConsumerCleanup(
136
+ 'consumer',
137
+ manifest({}),
138
+ provides(['caddy', 'public_web']),
139
+ deployed('caddy'),
140
+ ),
141
+ ).toEqual([]);
142
+ });
143
+
144
+ it('never asks the departing module to withdraw its own state', () => {
145
+ // A module can both provide and require a capability. Its own `on_uninstall`
146
+ // owns its teardown; being handed itself as a provider would run a converge
147
+ // on a module that is mid-removal.
148
+ const plan = planConsumerCleanup(
149
+ 'caddy',
150
+ manifest({ requires: ['firewall'] }),
151
+ provides(['caddy', 'firewall'], ['iptables', 'firewall']),
152
+ deployed('caddy', 'iptables'),
153
+ );
154
+
155
+ expect(plan.map((t) => t.providerId)).toEqual(['iptables']);
156
+ });
157
+
158
+ it('ignores providers of capabilities the consumer never declared', () => {
159
+ const plan = planConsumerCleanup(
160
+ 'consumer',
161
+ manifest({ requires: ['public_web'] }),
162
+ provides(['caddy', 'public_web'], ['namecheap', 'dns_registrar']),
163
+ deployed('caddy', 'namecheap'),
164
+ );
165
+
166
+ expect(plan.map((t) => t.providerId)).toEqual(['caddy']);
167
+ });
168
+ });
169
+
170
+ describe('runConsumerCleanup — the paused skip is reported, not silent', () => {
171
+ let dir: string;
172
+ let db: DbClient;
173
+
174
+ beforeEach(async () => {
175
+ dir = mkdtempSync(join(tmpdir(), 'cc-'));
176
+ const dbPath = join(dir, 'celilo.db');
177
+ process.env.CELILO_DB_PATH = dbPath;
178
+ db = await setupTestDatabase(dbPath);
179
+ });
180
+ afterEach(() => {
181
+ db.$client.close();
182
+ process.env.CELILO_DB_PATH = undefined;
183
+ rmSync(dir, { recursive: true, force: true });
184
+ });
185
+
186
+ /**
187
+ * Asserted here rather than through the CLI: `module remove`'s output is
188
+ * clack-formatted and its stderr is discarded by the integration harness, so
189
+ * a substring match there would prove nothing about the message. This is the
190
+ * layer where the text actually exists.
191
+ */
192
+ it('warns, naming the provider AND the consumer whose state it keeps (D7)', async () => {
193
+ const messages: string[] = [];
194
+ const logger = {
195
+ info: () => {},
196
+ warn: (m: string) => messages.push(m),
197
+ error: () => {},
198
+ success: () => {},
199
+ };
200
+
201
+ const result = await runConsumerCleanup(
202
+ 'departing',
203
+ [{ providerId: 'caddy', capabilityNames: ['public_web'], skip: 'paused' }],
204
+ db,
205
+ logger,
206
+ );
207
+
208
+ expect(result.skipped).toEqual([{ providerId: 'caddy', reason: 'paused' }]);
209
+ expect(result.failures).toEqual([]);
210
+ expect(messages).toHaveLength(1);
211
+ expect(messages[0]).toContain('caddy');
212
+ expect(messages[0]).toContain('departing');
213
+ expect(messages[0]).toContain('public_web');
214
+ });
215
+
216
+ it('says nothing about a provider that was never deployed', async () => {
217
+ // Unlike a pause, this is not a state an operator chose and can undo — the
218
+ // provider holds nothing, so there is nothing to report.
219
+ const messages: string[] = [];
220
+ const logger = {
221
+ info: () => {},
222
+ warn: (m: string) => messages.push(m),
223
+ error: () => {},
224
+ success: () => {},
225
+ };
226
+
227
+ const result = await runConsumerCleanup(
228
+ 'departing',
229
+ [{ providerId: 'caddy', capabilityNames: ['public_web'], skip: 'not-deployed' }],
230
+ db,
231
+ logger,
232
+ );
233
+
234
+ expect(result.skipped).toEqual([{ providerId: 'caddy', reason: 'not-deployed' }]);
235
+ expect(messages).toEqual([]);
236
+ });
237
+ });
238
+
239
+ describe('runConsumerCleanup — dispatch', () => {
240
+ let dir: string;
241
+ let db: DbClient;
242
+
243
+ beforeEach(async () => {
244
+ dir = mkdtempSync(join(tmpdir(), 'ccd-'));
245
+ const dbPath = join(dir, 'celilo.db');
246
+ process.env.CELILO_DB_PATH = dbPath;
247
+ db = await setupTestDatabase(dbPath);
248
+ });
249
+ afterEach(() => {
250
+ db.$client.close();
251
+ process.env.CELILO_DB_PATH = undefined;
252
+ rmSync(dir, { recursive: true, force: true });
253
+ });
254
+
255
+ const capture = () => {
256
+ const warnings: string[] = [];
257
+ return {
258
+ warnings,
259
+ logger: {
260
+ info: () => {},
261
+ warn: (m: string) => warnings.push(m),
262
+ error: () => {},
263
+ success: () => {},
264
+ },
265
+ };
266
+ };
267
+
268
+ const target = (providerId: string): CleanupTarget => ({
269
+ providerId,
270
+ capabilityNames: ['firewall'],
271
+ });
272
+
273
+ /**
274
+ * `runNamedHook` reports a paused module as SUCCESS. Taking that at face value
275
+ * would log a withdrawal that never happened — the silence this change exists
276
+ * to end — so the flag is read rather than assumed. Reachable when a module is
277
+ * paused between the plan and this dispatch.
278
+ */
279
+ it('a provider paused between plan and dispatch is skipped, not counted as withdrawn', async () => {
280
+ const { warnings, logger } = capture();
281
+
282
+ const result = await runConsumerCleanup(
283
+ 'departing',
284
+ [target('iptables')],
285
+ db,
286
+ logger,
287
+ async () => ({
288
+ success: true,
289
+ outputs: {},
290
+ duration: 0,
291
+ skippedPaused: true,
292
+ }),
293
+ );
294
+
295
+ expect(result.notified).toEqual([]);
296
+ expect(result.skipped).toEqual([{ providerId: 'iptables', reason: 'paused' }]);
297
+ expect(result.failures).toEqual([]);
298
+ expect(warnings[0]).toContain('iptables');
299
+ expect(warnings[0]).toContain('departing');
300
+ });
301
+
302
+ it('a provider with no such hook is not reported as having withdrawn anything', async () => {
303
+ const { logger } = capture();
304
+
305
+ const result = await runConsumerCleanup(
306
+ 'departing',
307
+ [target('namecheap')],
308
+ db,
309
+ logger,
310
+ async () => ({
311
+ success: true,
312
+ outputs: {},
313
+ duration: 0,
314
+ notDefined: true,
315
+ }),
316
+ );
317
+
318
+ // It mints nothing per consumer, so succeeding IS the right answer — but it
319
+ // did not withdraw anything, and must not claim to.
320
+ expect(result.notified).toEqual([]);
321
+ expect(result.failures).toEqual([]);
322
+ });
323
+
324
+ it('continues past a failure, and records it on the failing provider only (D13)', async () => {
325
+ const { logger } = capture();
326
+ const told: string[] = [];
327
+
328
+ const result = await runConsumerCleanup(
329
+ 'departing',
330
+ [target('axon'), target('iptables')],
331
+ db,
332
+ logger,
333
+ async (providerId) => {
334
+ told.push(providerId);
335
+ return providerId === 'axon'
336
+ ? { success: false, outputs: {}, duration: 0, error: 'router said no' }
337
+ : { success: true, outputs: {}, duration: 0 };
338
+ },
339
+ );
340
+
341
+ // Both were told, in plan order — stopping early would leave MORE providers
342
+ // holding state for a module about to disappear.
343
+ expect(told).toEqual(['axon', 'iptables']);
344
+ expect(result.failures).toEqual([{ providerId: 'axon', error: 'router said no' }]);
345
+ expect(result.notified).toEqual(['iptables']);
346
+ });
347
+ });
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Telling every provider that one of its consumers is leaving
3
+ * (openspec/changes/consumer-removal-cleanup).
4
+ *
5
+ * A capability is two-sided. The consumer asks, the provider mints something in
6
+ * its own world — a site block in caddy's Caddyfile, a DNAT rule in a ruleset,
7
+ * an OIDC client at authentik — and removal only ever touched one side of it.
8
+ * The FK cascade made that worse rather than better: the registry row
9
+ * disappeared, so the provider's next converge had no way to learn the thing
10
+ * had ever existed. The registry went quiet and the machine kept serving.
11
+ *
12
+ * This is the generic path that replaces `web-route-cleanup.ts`, which did the
13
+ * same job for exactly one capability, called by name from core.
14
+ *
15
+ * Split plan/execute (Rule 10.4) because the interesting decisions — which
16
+ * providers, which are skipped and why — are pure, and the part that isn't is
17
+ * just "run each hook and record what happened".
18
+ */
19
+
20
+ import { eq } from 'drizzle-orm';
21
+ import type { DbClient } from '../db/client';
22
+ import { capabilities, modules } from '../db/schema';
23
+ import { type RunNamedHookResult, runNamedHook } from '../hooks/run-named-hook';
24
+ import type { HookLogger } from '../hooks/types';
25
+ import type { ModuleManifest } from '../manifest/schema';
26
+ import { deletePortForwardsForModule } from './port-forwards';
27
+ import { deleteTrustedSourcesForModule } from './trusted-sources';
28
+
29
+ /**
30
+ * States in which a module has never resolved a capability and therefore holds
31
+ * nothing minted on anyone's behalf. Capabilities are registered at IMPORT, not
32
+ * deploy, so the `capabilities` table routinely names providers that were never
33
+ * deployed. The same predicate `remove-guard.ts` uses to decide a module is not
34
+ * a dependent — the guard and the cleanup must keep ONE definition of a live
35
+ * provider (D8).
36
+ */
37
+ const PRE_DEPLOY_STATES = new Set(['IMPORTED', 'VALIDATED', 'CONFIGURED']);
38
+
39
+ export type CleanupSkipReason = 'paused' | 'not-deployed';
40
+
41
+ export interface CleanupTarget {
42
+ /** The provider module to notify. */
43
+ providerId: string;
44
+ /** Which of its capabilities the departing consumer used — for the log line. */
45
+ capabilityNames: string[];
46
+ /** Set when the provider will NOT be notified. */
47
+ skip?: CleanupSkipReason;
48
+ }
49
+
50
+ export interface ProviderRow {
51
+ moduleId: string;
52
+ capabilityName: string;
53
+ }
54
+
55
+ export interface ProviderState {
56
+ moduleId: string;
57
+ state: string;
58
+ }
59
+
60
+ /**
61
+ * Which providers must be told that `consumer` is going away.
62
+ *
63
+ * Pure. Sorted by provider id so dispatch order is deterministic and a failure
64
+ * is reproducible.
65
+ *
66
+ * ONE ENTRY PER PROVIDER, not per capability (D1): a provider can hold two
67
+ * capabilities the same consumer used, and dispatching per capability would run
68
+ * the same withdrawal twice. But MANY providers per capability (D3) —
69
+ * `capabilities` has no uniqueness on the name, and `firewall` deliberately has
70
+ * several rows (an edge provider plus inner layers). Every one is told.
71
+ *
72
+ * The provider is never its own consumer: a module that both provides and
73
+ * requires a capability would otherwise be asked to withdraw its own state as
74
+ * it is being removed, which its `on_uninstall` already owns.
75
+ */
76
+ export function planConsumerCleanup(
77
+ consumer: string,
78
+ manifest: ModuleManifest,
79
+ providerRows: ProviderRow[],
80
+ providerStates: ProviderState[],
81
+ ): CleanupTarget[] {
82
+ // `requires` AND `optional` — the same set `remove-guard.ts` counts as a
83
+ // dependency edge. A capability consumed optionally still minted state.
84
+ const consumed = new Set([
85
+ ...(manifest.requires?.capabilities ?? []).map((c) => c.name),
86
+ ...(manifest.optional?.capabilities ?? []).map((c) => c.name),
87
+ ]);
88
+ if (consumed.size === 0) return [];
89
+
90
+ const stateOf = new Map(providerStates.map((s) => [s.moduleId, s.state]));
91
+ const byProvider = new Map<string, Set<string>>();
92
+
93
+ for (const row of providerRows) {
94
+ if (!consumed.has(row.capabilityName)) continue;
95
+ if (row.moduleId === consumer) continue;
96
+ const names = byProvider.get(row.moduleId) ?? new Set<string>();
97
+ names.add(row.capabilityName);
98
+ byProvider.set(row.moduleId, names);
99
+ }
100
+
101
+ return [...byProvider.entries()]
102
+ .map(([providerId, names]): CleanupTarget => {
103
+ const state = stateOf.get(providerId);
104
+ const capabilityNames = [...names].sort();
105
+ if (state === 'PAUSED') return { providerId, capabilityNames, skip: 'paused' };
106
+ if (state === undefined || PRE_DEPLOY_STATES.has(state)) {
107
+ return { providerId, capabilityNames, skip: 'not-deployed' };
108
+ }
109
+ return { providerId, capabilityNames };
110
+ })
111
+ .sort((a, b) => a.providerId.localeCompare(b.providerId));
112
+ }
113
+
114
+ /** Read the plan's inputs out of the DB. */
115
+ export function loadConsumerCleanupPlan(
116
+ consumer: string,
117
+ manifest: ModuleManifest,
118
+ db: DbClient,
119
+ ): CleanupTarget[] {
120
+ const providerRows = db
121
+ .select({ moduleId: capabilities.moduleId, capabilityName: capabilities.capabilityName })
122
+ .from(capabilities)
123
+ .all();
124
+ const providerStates = db
125
+ .select({ moduleId: modules.id, state: modules.state })
126
+ .from(modules)
127
+ .all();
128
+ return planConsumerCleanup(consumer, manifest, providerRows, providerStates);
129
+ }
130
+
131
+ export interface CleanupFailure {
132
+ providerId: string;
133
+ error: string;
134
+ }
135
+
136
+ export interface ConsumerCleanupResult {
137
+ notified: string[];
138
+ skipped: Array<{ providerId: string; reason: CleanupSkipReason }>;
139
+ failures: CleanupFailure[];
140
+ }
141
+
142
+ /**
143
+ * Run the plan. Sequential, deterministic, and it CONTINUES PAST A FAILURE
144
+ * (D13) — every provider is told even if an earlier one threw. Stopping early
145
+ * would leave MORE providers holding state for a module that is about to
146
+ * disappear, which is the failure this exists to fix.
147
+ *
148
+ * A failed withdrawal NEVER blocks the removal (D6). The record is an ERRORed
149
+ * PROVIDER, not a refused removal: the hook is a full converge, so a failure
150
+ * does not mean "it failed to forget one thing" — the provider may have
151
+ * re-rendered its state without the departing consumer AND without everything
152
+ * else. ERROR is the honest label for a provider whose state is now unknown,
153
+ * and `audit/undeployed-modules.ts` already turns it into a `blocked` finding.
154
+ * Nothing refuses to USE an ERRORed module, so the provider keeps serving while
155
+ * carrying the flag.
156
+ */
157
+ export async function runConsumerCleanup(
158
+ consumer: string,
159
+ plan: CleanupTarget[],
160
+ db: DbClient,
161
+ logger: HookLogger,
162
+ /** Injectable hook runner — tests drive the failure and paused paths through it. */
163
+ runHook: (providerId: string) => Promise<RunNamedHookResult> = (providerId) =>
164
+ runNamedHook(providerId, 'on_consumer_removed', db, logger, { inputs: { consumer } }),
165
+ ): Promise<ConsumerCleanupResult> {
166
+ const result: ConsumerCleanupResult = { notified: [], skipped: [], failures: [] };
167
+
168
+ for (const target of plan) {
169
+ if (target.skip) {
170
+ result.skipped.push({ providerId: target.providerId, reason: target.skip });
171
+ // Reported, not silent (D7/D8). Nothing was attempted, so nothing is
172
+ // unknown and the provider is NOT marked ERROR.
173
+ if (target.skip === 'paused') {
174
+ logger.warn(
175
+ `${target.providerId} is paused, so it was not told that '${consumer}' is gone — it is still holding whatever it minted for it (${target.capabilityNames.join(', ')}). Unpause and redeploy it to reconcile.`,
176
+ );
177
+ }
178
+ continue;
179
+ }
180
+
181
+ const hookResult = await runHook(target.providerId);
182
+
183
+ // A module paused BETWEEN the plan and this dispatch. `runNamedHook` reports
184
+ // that as success, so taking it at face value would log a withdrawal that
185
+ // did not happen — which is the silence this whole change exists to end.
186
+ // The window is small (one CLI process) and the consequence of trusting it
187
+ // is not, so it is read rather than assumed.
188
+ if (hookResult.skippedPaused) {
189
+ result.skipped.push({ providerId: target.providerId, reason: 'paused' });
190
+ logger.warn(
191
+ `${target.providerId} was paused while '${consumer}' was being removed, so it was not told — it is still holding whatever it minted for it (${target.capabilityNames.join(', ')}). Unpause and redeploy it to reconcile.`,
192
+ );
193
+ continue;
194
+ }
195
+
196
+ if (hookResult.success) {
197
+ // `notDefined` means the provider declares no such hook — it mints
198
+ // nothing per consumer, and the dispatch succeeding is the right answer.
199
+ if (!hookResult.notDefined) {
200
+ result.notified.push(target.providerId);
201
+ logger.info(`${target.providerId} withdrew what it held for '${consumer}'`);
202
+ }
203
+ continue;
204
+ }
205
+
206
+ const error = hookResult.error ?? 'unknown error';
207
+ result.failures.push({ providerId: target.providerId, error });
208
+ markProviderErrored(target.providerId, consumer, error, db);
209
+ logger.warn(
210
+ `${target.providerId} failed to withdraw what it held for '${consumer}' and is now marked ERROR: ${error}`,
211
+ );
212
+ }
213
+
214
+ // The rows, once every provider has converged without them (D4). `web_routes`
215
+ // dies with the `modules` row via its FK cascade; these two carry a plain
216
+ // `registered_by` text column and do not.
217
+ deletePortForwardsForModule(db, consumer);
218
+ deleteTrustedSourcesForModule(db, consumer);
219
+
220
+ return result;
221
+ }
222
+
223
+ /**
224
+ * Mark the PROVIDER — not the module being removed — as ERROR, naming the
225
+ * departing consumer and the raw hook error.
226
+ *
227
+ * The consumer is in `errorMessage` because the audit finding is read long
228
+ * after the removal, by someone with no reason to connect the two.
229
+ */
230
+ function markProviderErrored(
231
+ providerId: string,
232
+ consumer: string,
233
+ error: string,
234
+ db: DbClient,
235
+ ): void {
236
+ db.update(modules)
237
+ .set({
238
+ state: 'ERROR',
239
+ errorMessage: `Failed to withdraw state held for removed consumer '${consumer}': ${error}`,
240
+ updatedAt: new Date(),
241
+ })
242
+ .where(eq(modules.id, providerId))
243
+ .run();
244
+ }
@@ -100,7 +100,6 @@ describe('infrastructure-selector', () => {
100
100
  hardware: { cpu_cores: 4, memory_mb: 4096, disk_gb: 128 },
101
101
  role: 'host',
102
102
  interfaces: [],
103
- assignedModuleIds: [],
104
103
  });
105
104
 
106
105
  const result = await selectInfrastructure(module);
@@ -143,7 +142,6 @@ describe('infrastructure-selector', () => {
143
142
  hardware: { cpu_cores: 4, memory_mb: 4096, disk_gb: 128 },
144
143
  role: 'host',
145
144
  interfaces: [],
146
- assignedModuleIds: [],
147
145
  });
148
146
 
149
147
  const result = await selectInfrastructure(module);
@@ -308,7 +306,6 @@ describe('infrastructure-selector', () => {
308
306
  hardware: { cpu_cores: 1, memory_mb: 4096, disk_gb: 128 },
309
307
  role: 'host',
310
308
  interfaces: [],
311
- assignedModuleIds: [],
312
309
  });
313
310
 
314
311
  // Should throw InfrastructureError with resource details
@@ -350,7 +347,6 @@ describe('infrastructure-selector', () => {
350
347
  hardware: { cpu_cores: 4, memory_mb: 1024, disk_gb: 128 },
351
348
  role: 'host',
352
349
  interfaces: [],
353
- assignedModuleIds: [],
354
350
  });
355
351
 
356
352
  await expect(selectInfrastructure(module)).rejects.toThrow(InfrastructureError);
@@ -390,7 +386,6 @@ describe('infrastructure-selector', () => {
390
386
  hardware: { cpu_cores: 4, memory_mb: 4096, disk_gb: 10 },
391
387
  role: 'host',
392
388
  interfaces: [],
393
- assignedModuleIds: [],
394
389
  });
395
390
 
396
391
  await expect(selectInfrastructure(module)).rejects.toThrow(InfrastructureError);
@@ -430,7 +425,6 @@ describe('infrastructure-selector', () => {
430
425
  hardware: { cpu_cores: 8, memory_mb: 16384, disk_gb: 256 },
431
426
  role: 'host',
432
427
  interfaces: [],
433
- assignedModuleIds: [],
434
428
  });
435
429
 
436
430
  const result = await selectInfrastructure(module);
@@ -473,7 +467,6 @@ describe('infrastructure-selector', () => {
473
467
  hardware: { cpu_cores: 4, memory_mb: 4096, disk_gb: 128 },
474
468
  role: 'host',
475
469
  interfaces: [],
476
- assignedModuleIds: [],
477
470
  });
478
471
 
479
472
  const result = await selectInfrastructure(module);