@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.
- package/CELILO_CORE_MODULES.md +3 -0
- package/CELILO_SUBSYSTEMS.md +4 -2
- package/drizzle/0025_port_forward_owner.sql +29 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +3 -3
- package/src/__integration__/container-services-cli.integration.test.ts +0 -4
- package/src/ansible/dependencies.test.ts +233 -289
- package/src/ansible/dependencies.ts +151 -83
- package/src/cli/commands/alerts-sweep.ts +14 -3
- package/src/cli/commands/machine-add.ts +0 -1
- package/src/cli/commands/machine-list.ts +10 -4
- package/src/cli/commands/machine-remove.ts +13 -7
- package/src/cli/commands/machine-status.ts +9 -11
- package/src/cli/commands/module-remove.ts +26 -23
- package/src/cli/commands/system-audit.ts +5 -1
- package/src/cli/commands/system-update.ts +10 -2
- package/src/db/schema.ts +30 -10
- package/src/hooks/capability-loader.ts +65 -13
- package/src/hooks/define-hook.test.ts +4 -6
- package/src/hooks/executor.ts +2 -1
- package/src/hooks/types.ts +9 -17
- package/src/infrastructure/property-extractor.test.ts +0 -2
- package/src/manifest/contracts/index.ts +20 -0
- package/src/manifest/contracts/v1.ts +33 -1
- package/src/manifest/schema.ts +48 -58
- package/src/services/alerting/sweep-runner.test.ts +5 -1
- package/src/services/alerting/sweep-runner.ts +14 -8
- package/src/services/aspect-runner.test.ts +0 -1
- package/src/services/audit/undeployed-modules.ts +18 -1
- package/src/services/consumer-cleanup.test.ts +347 -0
- package/src/services/consumer-cleanup.ts +244 -0
- package/src/services/infrastructure-selector.test.ts +0 -7
- package/src/services/infrastructure-selector.ts +24 -25
- package/src/services/infrastructure-variable-resolver.test.ts +0 -6
- package/src/services/infrastructure-variable-resolver.ts +0 -3
- package/src/services/machine-pool.test.ts +53 -85
- package/src/services/machine-pool.ts +68 -84
- package/src/services/module-deploy.ts +17 -39
- package/src/services/module-validator/index.test.ts +9 -0
- package/src/services/port-forwards.test.ts +93 -40
- package/src/services/port-forwards.ts +74 -48
- package/src/services/ssh-key-manager.test.ts +0 -10
- package/src/services/trusted-sources.test.ts +52 -13
- package/src/services/trusted-sources.ts +25 -15
- package/src/test-utils/cli-context.ts +15 -2
- package/src/types/infrastructure.ts +11 -1
- package/src/services/web-route-cleanup.test.ts +0 -250
- 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);
|