@celilo/cli 0.27.0 → 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 +2 -2
- 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 +24 -6
- package/src/hooks/capability-loader.ts +59 -13
- package/src/hooks/define-hook.test.ts +0 -6
- package/src/hooks/executor.ts +2 -1
- package/src/hooks/types.ts +9 -17
- 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/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/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/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/services/web-route-cleanup.test.ts +0 -250
- package/src/services/web-route-cleanup.ts +0 -144
|
@@ -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.
|
|
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('
|
|
50
|
+
it('replace is idempotent — declaring the same set twice yields one row', () => {
|
|
51
51
|
const store = buildTrustedSourceStore(db, 'wireguard');
|
|
52
|
-
store.
|
|
53
|
-
store.
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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').
|
|
73
|
-
buildTrustedSourceStore(db, 'other').
|
|
74
|
-
|
|
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').
|
|
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').
|
|
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
|
-
|
|
42
|
-
//
|
|
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(
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
|
@@ -351,9 +351,22 @@ export class CLIContext {
|
|
|
351
351
|
new Promise<CommandResponse>((resolve, reject) => {
|
|
352
352
|
this.pendingResponses.set(id, { resolve, reject });
|
|
353
353
|
}),
|
|
354
|
-
// Timeout promise
|
|
354
|
+
// Timeout promise. Name the command and the actual elapsed time, not
|
|
355
|
+
// just the budget — a bare "timed out after 30000ms" reads as a hang
|
|
356
|
+
// in the command under test, indistinguishable from a real deploy
|
|
357
|
+
// defect. `Date.now() - startTime` at fire time is normally ~= timeout,
|
|
358
|
+
// but under CI load the event loop can be too busy to run this
|
|
359
|
+
// callback promptly, so a MUCH larger elapsed-than-budget is itself a
|
|
360
|
+
// load signal, not a fluke to explain away (celilo#804).
|
|
355
361
|
new Promise<CommandResponse>((_, reject) =>
|
|
356
|
-
setTimeout(() =>
|
|
362
|
+
setTimeout(() => {
|
|
363
|
+
const elapsed = Date.now() - startTime;
|
|
364
|
+
reject(
|
|
365
|
+
new Error(
|
|
366
|
+
`Command #${id} "${command}" timed out after ${timeout}ms (elapsed ${elapsed}ms)`,
|
|
367
|
+
),
|
|
368
|
+
);
|
|
369
|
+
}, timeout),
|
|
357
370
|
),
|
|
358
371
|
]);
|
|
359
372
|
|
|
@@ -1,250 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, test } 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 { RunResult, Runner } from '@celilo/capabilities';
|
|
6
|
-
import { eq } from 'drizzle-orm';
|
|
7
|
-
import type { DbClient } from '../db/client';
|
|
8
|
-
import { capabilities, moduleConfigs, modules, webRoutes } from '../db/schema';
|
|
9
|
-
import { setupTestDatabase } from '../test-utils/setup-test-db';
|
|
10
|
-
import type { RouteReconcileWaitResult } from './celilo-events';
|
|
11
|
-
import { cleanupWebRoutesForModule } from './web-route-cleanup';
|
|
12
|
-
|
|
13
|
-
const CADDY_IP = '10.0.10.10';
|
|
14
|
-
|
|
15
|
-
const CLEAN_RECONCILE: RouteReconcileWaitResult = {
|
|
16
|
-
events: 1,
|
|
17
|
-
succeeded: 1,
|
|
18
|
-
failed: 0,
|
|
19
|
-
timedOut: false,
|
|
20
|
-
noDispatcher: false,
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
function createRecordingRunner(result: Partial<RunResult> = {}): Runner & { calls: string[] } {
|
|
24
|
-
const calls: string[] = [];
|
|
25
|
-
const runner = ((cmd: string) => {
|
|
26
|
-
calls.push(cmd);
|
|
27
|
-
return { ok: true, stdout: '', stderr: '', ...result } as RunResult;
|
|
28
|
-
}) as Runner & { calls: string[] };
|
|
29
|
-
runner.calls = calls;
|
|
30
|
-
return runner;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
describe('cleanupWebRoutesForModule', () => {
|
|
34
|
-
let dir: string;
|
|
35
|
-
let db: DbClient;
|
|
36
|
-
|
|
37
|
-
async function seedModule(id: string): Promise<void> {
|
|
38
|
-
db.insert(modules)
|
|
39
|
-
.values({
|
|
40
|
-
id,
|
|
41
|
-
name: id,
|
|
42
|
-
version: '1.0.0',
|
|
43
|
-
manifestData: {},
|
|
44
|
-
sourcePath: `/tmp/${id}`,
|
|
45
|
-
})
|
|
46
|
-
.run();
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
async function seedCaddyProvider(): Promise<void> {
|
|
50
|
-
await seedModule('caddy');
|
|
51
|
-
db.insert(capabilities)
|
|
52
|
-
.values({ moduleId: 'caddy', capabilityName: 'public_web', version: '3.0.0', data: {} })
|
|
53
|
-
.run();
|
|
54
|
-
db.insert(moduleConfigs)
|
|
55
|
-
.values({
|
|
56
|
-
moduleId: 'caddy',
|
|
57
|
-
key: 'target_ip',
|
|
58
|
-
value: `${CADDY_IP}/24`,
|
|
59
|
-
valueJson: JSON.stringify(`${CADDY_IP}/24`),
|
|
60
|
-
})
|
|
61
|
-
.run();
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function seedRoute(
|
|
65
|
-
moduleId: string,
|
|
66
|
-
slug: string,
|
|
67
|
-
path: string,
|
|
68
|
-
type: 'static' | 'reverse_proxy',
|
|
69
|
-
) {
|
|
70
|
-
db.insert(webRoutes)
|
|
71
|
-
.values({
|
|
72
|
-
slug,
|
|
73
|
-
moduleId,
|
|
74
|
-
type,
|
|
75
|
-
path,
|
|
76
|
-
hostname: 'iamtheinternet.org',
|
|
77
|
-
targetHost: type === 'reverse_proxy' ? '10.0.20.5' : null,
|
|
78
|
-
targetPort: type === 'reverse_proxy' ? 8080 : null,
|
|
79
|
-
})
|
|
80
|
-
.run();
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
beforeEach(async () => {
|
|
84
|
-
dir = mkdtempSync(join(tmpdir(), 'wrc-'));
|
|
85
|
-
const dbPath = join(dir, 'celilo.db');
|
|
86
|
-
process.env.CELILO_DB_PATH = dbPath;
|
|
87
|
-
db = await setupTestDatabase(dbPath);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
afterEach(() => {
|
|
91
|
-
db.$client.close();
|
|
92
|
-
process.env.CELILO_DB_PATH = undefined;
|
|
93
|
-
try {
|
|
94
|
-
rmSync(dir, { recursive: true, force: true });
|
|
95
|
-
} catch {
|
|
96
|
-
/* ignore */
|
|
97
|
-
}
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
test('deletes routes, reclaims the asset root, and emits the reconcile', async () => {
|
|
101
|
-
await seedCaddyProvider();
|
|
102
|
-
await seedModule('hello-foo');
|
|
103
|
-
seedRoute('hello-foo', 'foo', '/foo', 'static');
|
|
104
|
-
|
|
105
|
-
const run = createRecordingRunner();
|
|
106
|
-
let emittedFor: string | undefined;
|
|
107
|
-
|
|
108
|
-
const result = await cleanupWebRoutesForModule({
|
|
109
|
-
moduleId: 'hello-foo',
|
|
110
|
-
db,
|
|
111
|
-
run,
|
|
112
|
-
emitAndWait: async (triggeredBy) => {
|
|
113
|
-
emittedFor = triggeredBy;
|
|
114
|
-
return CLEAN_RECONCILE;
|
|
115
|
-
},
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
expect(result.routesRemoved).toBe(1);
|
|
119
|
-
expect(result.assetDirsRemoved).toEqual(['/srv/www/foo']);
|
|
120
|
-
expect(result.warnings).toEqual([]);
|
|
121
|
-
expect(emittedFor).toBe('hello-foo');
|
|
122
|
-
|
|
123
|
-
expect(run.calls).toHaveLength(1);
|
|
124
|
-
expect(run.calls[0]).toContain(`root@${CADDY_IP}`);
|
|
125
|
-
expect(run.calls[0]).toContain('rm -rf');
|
|
126
|
-
expect(run.calls[0]).toContain('/srv/www/foo');
|
|
127
|
-
|
|
128
|
-
const remaining = db.select().from(webRoutes).where(eq(webRoutes.moduleId, 'hello-foo')).all();
|
|
129
|
-
expect(remaining).toHaveLength(0);
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
test('leaves other modules routes alone', async () => {
|
|
133
|
-
await seedCaddyProvider();
|
|
134
|
-
await seedModule('hello-foo');
|
|
135
|
-
await seedModule('hello-bar');
|
|
136
|
-
seedRoute('hello-foo', 'foo', '/foo', 'static');
|
|
137
|
-
seedRoute('hello-bar', 'bar', '/bar', 'static');
|
|
138
|
-
|
|
139
|
-
const run = createRecordingRunner();
|
|
140
|
-
await cleanupWebRoutesForModule({
|
|
141
|
-
moduleId: 'hello-foo',
|
|
142
|
-
db,
|
|
143
|
-
run,
|
|
144
|
-
emitAndWait: async () => CLEAN_RECONCILE,
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
const survivors = db.select().from(webRoutes).all();
|
|
148
|
-
expect(survivors.map((r) => r.moduleId)).toEqual(['hello-bar']);
|
|
149
|
-
// Only /srv/www/foo is touched — the sibling's asset root must survive.
|
|
150
|
-
expect(run.calls.join('\n')).not.toContain('/srv/www/bar');
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
test('no-ops when the module owns no routes (its on_uninstall already ran)', async () => {
|
|
154
|
-
await seedCaddyProvider();
|
|
155
|
-
await seedModule('celilo-website');
|
|
156
|
-
|
|
157
|
-
const run = createRecordingRunner();
|
|
158
|
-
let emitted = false;
|
|
159
|
-
|
|
160
|
-
const result = await cleanupWebRoutesForModule({
|
|
161
|
-
moduleId: 'celilo-website',
|
|
162
|
-
db,
|
|
163
|
-
run,
|
|
164
|
-
emitAndWait: async () => {
|
|
165
|
-
emitted = true;
|
|
166
|
-
return CLEAN_RECONCILE;
|
|
167
|
-
},
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
expect(result).toEqual({ routesRemoved: 0, assetDirsRemoved: [], warnings: [] });
|
|
171
|
-
expect(run.calls).toHaveLength(0);
|
|
172
|
-
// No redundant reconcile on every module removal.
|
|
173
|
-
expect(emitted).toBe(false);
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
test('skips the remote rm for reverse-proxy routes (no assets on the host)', async () => {
|
|
177
|
-
await seedCaddyProvider();
|
|
178
|
-
await seedModule('forgejo');
|
|
179
|
-
seedRoute('forgejo', 'forgejo', '/git', 'reverse_proxy');
|
|
180
|
-
|
|
181
|
-
const run = createRecordingRunner();
|
|
182
|
-
const result = await cleanupWebRoutesForModule({
|
|
183
|
-
moduleId: 'forgejo',
|
|
184
|
-
db,
|
|
185
|
-
run,
|
|
186
|
-
emitAndWait: async () => CLEAN_RECONCILE,
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
expect(result.routesRemoved).toBe(1);
|
|
190
|
-
expect(run.calls).toHaveLength(0);
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
test('still deletes routes when the asset rm fails', async () => {
|
|
194
|
-
await seedCaddyProvider();
|
|
195
|
-
await seedModule('hello-foo');
|
|
196
|
-
seedRoute('hello-foo', 'foo', '/foo', 'static');
|
|
197
|
-
|
|
198
|
-
const run = createRecordingRunner({ ok: false, stderr: 'host unreachable' });
|
|
199
|
-
const result = await cleanupWebRoutesForModule({
|
|
200
|
-
moduleId: 'hello-foo',
|
|
201
|
-
db,
|
|
202
|
-
run,
|
|
203
|
-
emitAndWait: async () => CLEAN_RECONCILE,
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
expect(result.routesRemoved).toBe(1);
|
|
207
|
-
expect(result.assetDirsRemoved).toEqual([]);
|
|
208
|
-
expect(result.warnings.join(' ')).toMatch(/Failed to remove \/srv\/www\/foo/);
|
|
209
|
-
expect(db.select().from(webRoutes).all()).toHaveLength(0);
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
test('warns when no provider is deployed to clean assets from', async () => {
|
|
213
|
-
await seedModule('hello-foo');
|
|
214
|
-
seedRoute('hello-foo', 'foo', '/foo', 'static');
|
|
215
|
-
|
|
216
|
-
const run = createRecordingRunner();
|
|
217
|
-
const result = await cleanupWebRoutesForModule({
|
|
218
|
-
moduleId: 'hello-foo',
|
|
219
|
-
db,
|
|
220
|
-
run,
|
|
221
|
-
emitAndWait: async () => CLEAN_RECONCILE,
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
expect(result.routesRemoved).toBe(1);
|
|
225
|
-
expect(run.calls).toHaveLength(0);
|
|
226
|
-
expect(result.warnings.join(' ')).toMatch(/No public_web provider host found/);
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
test('warns — but does not throw — when the provider never reconciles', async () => {
|
|
230
|
-
await seedCaddyProvider();
|
|
231
|
-
await seedModule('hello-foo');
|
|
232
|
-
seedRoute('hello-foo', 'foo', '/foo', 'static');
|
|
233
|
-
|
|
234
|
-
const result = await cleanupWebRoutesForModule({
|
|
235
|
-
moduleId: 'hello-foo',
|
|
236
|
-
db,
|
|
237
|
-
run: createRecordingRunner(),
|
|
238
|
-
emitAndWait: async () => ({
|
|
239
|
-
events: 1,
|
|
240
|
-
succeeded: 0,
|
|
241
|
-
failed: 0,
|
|
242
|
-
timedOut: false,
|
|
243
|
-
noDispatcher: true,
|
|
244
|
-
}),
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
expect(result.routesRemoved).toBe(1);
|
|
248
|
-
expect(result.warnings.join(' ')).toMatch(/no event dispatcher is running/);
|
|
249
|
-
});
|
|
250
|
-
});
|
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Route cleanup on module removal — the consumer half of ISS-0035.
|
|
3
|
-
*
|
|
4
|
-
* `register_route` / `unregister_routes` emit `public_web.routes_changed`, and
|
|
5
|
-
* the provider's `reconcile_routes` subscription re-renders its config from
|
|
6
|
-
* `web_routes`. But `unregister_routes` is only reachable from a module's own
|
|
7
|
-
* `on_uninstall` teardown script, and most consumers don't define one —
|
|
8
|
-
* celilo-website, celilo-registry, celilo-apt-repo and celilo-mgmt all publish
|
|
9
|
-
* a site and have no teardown. For those, `module remove` deleted the route
|
|
10
|
-
* rows through the FK cascade on `modules.id` and emitted NOTHING, so the
|
|
11
|
-
* provider never learned the route was gone: the rendered Caddyfile kept a site
|
|
12
|
-
* block for a module that no longer exists, and `/srv/www/<slug>` was orphaned.
|
|
13
|
-
*
|
|
14
|
-
* The fix lives here rather than in each module's teardown script for the usual
|
|
15
|
-
* reason: one call site in `performModuleRemove` covers every consumer and
|
|
16
|
-
* can't be forgotten by the next one. Modules that DO define an `on_uninstall`
|
|
17
|
-
* calling `unregister_routes` still win — their hook runs first and this
|
|
18
|
-
* becomes a no-op (zero owned routes).
|
|
19
|
-
*
|
|
20
|
-
* Everything here is best-effort. A failed asset `rm` or an undelivered
|
|
21
|
-
* reconcile is reported as a warning, never a thrown error: a stuck provider is
|
|
22
|
-
* no reason to strand a module in a half-removed state.
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import { execRunner, runAppCommand, shellEscape } from '@celilo/capabilities';
|
|
26
|
-
import type { Runner } from '@celilo/capabilities';
|
|
27
|
-
import { eq } from 'drizzle-orm';
|
|
28
|
-
import type { DbClient } from '../db/client';
|
|
29
|
-
import { capabilities, webRoutes } from '../db/schema';
|
|
30
|
-
import { loadHookConfigMap } from '../hooks/load-hook-config';
|
|
31
|
-
import { emitWebRoutesChangedAndWait } from './celilo-events';
|
|
32
|
-
|
|
33
|
-
export interface WebRouteCleanupResult {
|
|
34
|
-
routesRemoved: number;
|
|
35
|
-
/** Asset roots actually deleted from the provider host. */
|
|
36
|
-
assetDirsRemoved: string[];
|
|
37
|
-
/** Non-fatal problems, for the caller to log. Never thrown. */
|
|
38
|
-
warnings: string[];
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export interface WebRouteCleanupDeps {
|
|
42
|
-
moduleId: string;
|
|
43
|
-
db: DbClient;
|
|
44
|
-
/** Injectable remote runner — tests pass a mock. */
|
|
45
|
-
run?: Runner;
|
|
46
|
-
/** Injectable event emit + wait — tests pass a stub. */
|
|
47
|
-
emitAndWait?: typeof emitWebRoutesChangedAndWait;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* The provider host that owns `/srv/www` — the same `config.target_ip` the
|
|
52
|
-
* public_web capability uses for its asset uploads.
|
|
53
|
-
*/
|
|
54
|
-
async function resolvePublicWebHostIp(db: DbClient): Promise<string | undefined> {
|
|
55
|
-
const providers = db
|
|
56
|
-
.select()
|
|
57
|
-
.from(capabilities)
|
|
58
|
-
.where(eq(capabilities.capabilityName, 'public_web'))
|
|
59
|
-
.all();
|
|
60
|
-
const provider = providers[0];
|
|
61
|
-
if (!provider) return undefined;
|
|
62
|
-
|
|
63
|
-
const config = await loadHookConfigMap(provider.moduleId, db);
|
|
64
|
-
// target_ip may carry a CIDR suffix; the capability strips it the same way.
|
|
65
|
-
return String(config.target_ip ?? '').split('/')[0] || undefined;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Delete a module's web routes, reclaim its static asset roots, and tell the
|
|
70
|
-
* provider to reconcile. Returns counts + warnings; never throws.
|
|
71
|
-
*/
|
|
72
|
-
export async function cleanupWebRoutesForModule(
|
|
73
|
-
deps: WebRouteCleanupDeps,
|
|
74
|
-
): Promise<WebRouteCleanupResult> {
|
|
75
|
-
const { moduleId, db, run = execRunner, emitAndWait = emitWebRoutesChangedAndWait } = deps;
|
|
76
|
-
|
|
77
|
-
const owned = db.select().from(webRoutes).where(eq(webRoutes.moduleId, moduleId)).all();
|
|
78
|
-
if (owned.length === 0) {
|
|
79
|
-
// Either the module never registered a route, or its own on_uninstall
|
|
80
|
-
// already called unregister_routes (which emitted and reconciled). Nothing
|
|
81
|
-
// to do — and importantly, no event, so we don't trigger a redundant
|
|
82
|
-
// reconcile on every module removal.
|
|
83
|
-
return { routesRemoved: 0, assetDirsRemoved: [], warnings: [] };
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const warnings: string[] = [];
|
|
87
|
-
const assetDirsRemoved: string[] = [];
|
|
88
|
-
|
|
89
|
-
const staticSlugs = owned.filter((r) => r.type === 'static').map((r) => r.slug);
|
|
90
|
-
if (staticSlugs.length > 0) {
|
|
91
|
-
const providerIp = await resolvePublicWebHostIp(db);
|
|
92
|
-
if (!providerIp) {
|
|
93
|
-
warnings.push(
|
|
94
|
-
`No public_web provider host found — left ${staticSlugs.length} asset dir(s) in /srv/www on disk`,
|
|
95
|
-
);
|
|
96
|
-
} else {
|
|
97
|
-
for (const slug of staticSlugs) {
|
|
98
|
-
const dir = `/srv/www/${slug}`;
|
|
99
|
-
// escape-hatch: reclaiming a consumer's static web root on the provider
|
|
100
|
-
// host. No capability owns asset-root teardown, and it is neither
|
|
101
|
-
// desired-state nor a service action.
|
|
102
|
-
const result = runAppCommand(
|
|
103
|
-
{ ipv4_address: providerIp },
|
|
104
|
-
`rm -rf ${shellEscape(dir)}`,
|
|
105
|
-
run,
|
|
106
|
-
{ timeoutMs: 30_000 },
|
|
107
|
-
);
|
|
108
|
-
if (result.ok) {
|
|
109
|
-
assetDirsRemoved.push(dir);
|
|
110
|
-
} else {
|
|
111
|
-
warnings.push(
|
|
112
|
-
`Failed to remove ${dir} on ${providerIp} (continuing): ${result.stderr || result.stdout || 'unknown'}`,
|
|
113
|
-
);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
db.delete(webRoutes).where(eq(webRoutes.moduleId, moduleId)).run();
|
|
120
|
-
|
|
121
|
-
// Same signal a consumer's own unregister_routes emits. Unlike the deploy
|
|
122
|
-
// path (ISS-0081), an undelivered reconcile here is a warning, not a
|
|
123
|
-
// failure — removal must complete regardless.
|
|
124
|
-
const reconcile = await emitAndWait(moduleId);
|
|
125
|
-
if (reconcile.noDispatcher) {
|
|
126
|
-
warnings.push(
|
|
127
|
-
`Routes for ${moduleId} were deleted but no event dispatcher is running, so the public_web provider has not reconciled — it is still serving the removed route(s) until its next reconcile.`,
|
|
128
|
-
);
|
|
129
|
-
} else if (reconcile.timedOut) {
|
|
130
|
-
warnings.push(
|
|
131
|
-
`public_web provider did not confirm the route removal within the deadline (${reconcile.succeeded} ok, ${reconcile.failed} failed of ${reconcile.events} event(s)).`,
|
|
132
|
-
);
|
|
133
|
-
} else if (reconcile.failed > 0) {
|
|
134
|
-
warnings.push(
|
|
135
|
-
`public_web provider failed to apply the route removal (${reconcile.failed} delivery failure(s)) — it may still be serving the removed route(s).`,
|
|
136
|
-
);
|
|
137
|
-
} else if (reconcile.events > 0 && reconcile.succeeded === 0) {
|
|
138
|
-
warnings.push(
|
|
139
|
-
`Routes for ${moduleId} were deleted but NO provider reconciled — the provider has no reconcile_routes subscription, so the removed route(s) may still be served.`,
|
|
140
|
-
);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
return { routesRemoved: owned.length, assetDirsRemoved, warnings };
|
|
144
|
-
}
|