@celilo/cli 0.11.0 → 0.12.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/AGENTS.md +7 -4
- package/CELILO_CORE_MODULES.md +7 -6
- package/CELILO_SUBSYSTEMS.md +52 -6
- package/MODULE_PRIMITIVES.md +164 -0
- package/README.md +5 -5
- package/drizzle/0015_port_forwards.sql +12 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +14 -6
- package/schemas/system_config.json +1 -1
- package/src/ansible/inventory.test.ts +1 -1
- package/src/ansible/inventory.ts +3 -3
- package/src/api/protocol.test.ts +1 -1
- package/src/api/remote-client.test.ts +1 -1
- package/src/api/serve.ts +5 -5
- package/src/capabilities/public-web-publish.test.ts +209 -0
- package/src/capabilities/validation.test.ts +1 -1
- package/src/capabilities/well-known.test.ts +1 -1
- package/src/capabilities/well-known.ts +2 -2
- package/src/cli/command-tree-parser.ts +3 -1
- package/src/cli/commands/api.ts +1 -1
- package/src/cli/commands/apt-upgrade.test.ts +33 -0
- package/src/cli/commands/apt-upgrade.ts +63 -0
- package/src/cli/commands/backup-pull.ts +1 -1
- package/src/cli/commands/commands-json.ts +29 -0
- package/src/cli/commands/completion.ts +1 -1
- package/src/cli/commands/events.ts +1 -1
- package/src/cli/commands/module-changeset.ts +1 -1
- package/src/cli/commands/module-import.ts +1 -1
- package/src/cli/commands/module-list.ts +16 -2
- package/src/cli/commands/module-publish.ts +1 -1
- package/src/cli/commands/module-remove.ts +26 -1
- package/src/cli/commands/module-types.ts +1 -1
- package/src/cli/commands/module-upgrade.ts +1 -1
- package/src/cli/commands/module-version.ts +1 -1
- package/src/cli/commands/module-where.test.ts +26 -0
- package/src/cli/commands/module-where.ts +130 -0
- package/src/cli/commands/proxmox-node-list.ts +1 -1
- package/src/cli/commands/publish/alpha.test.ts +1 -1
- package/src/cli/commands/publish/alpha.ts +3 -3
- package/src/cli/commands/publish/changesets.ts +1 -1
- package/src/cli/commands/publish/global-install.ts +6 -2
- package/src/cli/commands/publish/helpers.ts +21 -14
- package/src/cli/commands/publish/index.ts +4 -4
- package/src/cli/commands/publish/plan.ts +3 -3
- package/src/cli/commands/publish/preflight.ts +2 -2
- package/src/cli/commands/publish/types.ts +2 -2
- package/src/cli/commands/publish/workspace.test.ts +1 -1
- package/src/cli/commands/publish/workspace.ts +2 -2
- package/src/cli/commands/registry-owner.test.ts +166 -0
- package/src/cli/commands/registry-owner.ts +124 -0
- package/src/cli/commands/registry-token.test.ts +109 -0
- package/src/cli/commands/registry-token.ts +194 -0
- package/src/cli/commands/restore.ts +1 -1
- package/src/cli/commands/service-add-proxmox.ts +1 -1
- package/src/cli/commands/service-list.ts +15 -2
- package/src/cli/commands/subscribers-list.ts +1 -1
- package/src/cli/commands/system-apply-config-equivalence.test.ts +1 -1
- package/src/cli/commands/system-apply-config.ts +1 -1
- package/src/cli/commands/system-init-deprecation.test.ts +1 -1
- package/src/cli/commands/system-init.ts +2 -2
- package/src/cli/commands/token.test.ts +26 -0
- package/src/cli/commands/token.ts +160 -0
- package/src/cli/completion.ts +21 -0
- package/src/cli/generate-zsh-completion.test.ts +22 -4
- package/src/cli/generate-zsh-completion.ts +7 -3
- package/src/cli/index.ts +170 -7
- package/src/cli/parser.ts +1 -1
- package/src/cli/restore-command.test.ts +1 -1
- package/src/db/client.ts +1 -1
- package/src/db/schema.ts +50 -7
- package/src/hooks/capability-loader-firewall.test.ts +4 -3
- package/src/hooks/capability-loader.ts +100 -10
- package/src/hooks/define-hook.test.ts +24 -0
- package/src/hooks/executor.test.ts +1 -1
- package/src/hooks/executor.ts +2 -2
- package/src/hooks/types.ts +1 -1
- package/src/manifest/contracts/v1.ts +2 -2
- package/src/manifest/schema.ts +13 -13
- package/src/manifest/template-validator.ts +1 -1
- package/src/module/packaging/build.ts +19 -0
- package/src/module/packaging/workspace-deps.test.ts +94 -0
- package/src/module/packaging/workspace-deps.ts +185 -0
- package/src/module/versioning/changeset-version.ts +1 -1
- package/src/policy/no-hand-built-ssh.test.ts +90 -0
- package/src/registry/client.test.ts +86 -0
- package/src/registry/client.ts +67 -1
- package/src/services/api-access.ts +1 -1
- package/src/services/aspect-approvals.ts +1 -1
- package/src/services/aspect-runner.ts +3 -3
- package/src/services/aspect-template-resolver.test.ts +1 -1
- package/src/services/aspect-template-resolver.ts +1 -1
- package/src/services/build-bus/delivery-events.ts +1 -1
- package/src/services/build-bus/fan-out.ts +1 -1
- package/src/services/build-bus/hook-dispatch.ts +1 -1
- package/src/services/build-bus/receiver-server.ts +1 -1
- package/src/services/build-bus/status.test.ts +1 -1
- package/src/services/build-bus/status.ts +1 -1
- package/src/services/build-bus/subscriber-store.ts +1 -1
- package/src/services/bus-interview.ts +1 -1
- package/src/services/celilo-events.ts +1 -1
- package/src/services/celilo-mgmt-hooks.test.ts +1 -1
- package/src/services/cross-module-read.ts +1 -1
- package/src/services/deploy-posture.ts +1 -1
- package/src/services/deployed-systems.test.ts +1 -1
- package/src/services/deployed-systems.ts +4 -4
- package/src/services/dns-provider-backfill.ts +2 -2
- package/src/services/events-daemon.ts +1 -1
- package/src/services/machine-pool.ts +3 -3
- package/src/services/module-deploy.ts +93 -8
- package/src/services/module-subscriptions.ts +1 -1
- package/src/services/module-types-generator.ts +1 -1
- package/src/services/module-validator/git-hygiene.ts +1 -1
- package/src/services/port-forwards.test.ts +92 -0
- package/src/services/port-forwards.ts +86 -0
- package/src/services/proxmox-reconcile.ts +2 -2
- package/src/services/public-web-republish.test.ts +189 -0
- package/src/services/public-web-republish.ts +84 -0
- package/src/services/restore-from-file.ts +1 -1
- package/src/services/restore-preflight.ts +1 -1
- package/src/services/system-identity.ts +1 -1
- package/src/services/system-init.test.ts +2 -2
- package/src/services/system-init.ts +2 -2
- package/src/services/web-route-cleanup.test.ts +250 -0
- package/src/services/web-route-cleanup.ts +144 -0
- package/src/templates/generator.ts +2 -2
- package/src/types/infrastructure.ts +1 -1
- package/src/variables/computed/computed-integration.test.ts +1 -1
- package/src/variables/computed/evaluate.ts +1 -1
- package/src/variables/computed/parse.ts +1 -1
- package/src/variables/context.test.ts +1 -1
- package/src/variables/context.ts +5 -5
- package/src/variables/lxc-nameserver.test.ts +1 -1
- package/src/variables/resolver.ts +2 -2
- package/src/variables/types.ts +1 -1
- package/src/api/protocol.ts +0 -159
- package/src/api/remote-client.ts +0 -218
- package/src/cli/command-registry.ts +0 -1488
|
@@ -0,0 +1,189 @@
|
|
|
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 { DbClient } from '../db/client';
|
|
6
|
+
import { modules, webRoutes } from '../db/schema';
|
|
7
|
+
import type { ModuleManifest } from '../manifest/schema';
|
|
8
|
+
import { setupTestDatabase } from '../test-utils/setup-test-db';
|
|
9
|
+
import { providesPublicWeb, republishStaticWebConsumers } from './public-web-republish';
|
|
10
|
+
|
|
11
|
+
const caddyManifest = {
|
|
12
|
+
provides: { capabilities: [{ name: 'public_web', version: '3.0.0' }] },
|
|
13
|
+
} as unknown as ModuleManifest;
|
|
14
|
+
|
|
15
|
+
const plainManifest = { provides: { capabilities: [] } } as unknown as ModuleManifest;
|
|
16
|
+
|
|
17
|
+
describe('republishStaticWebConsumers', () => {
|
|
18
|
+
let dir: string;
|
|
19
|
+
let db: DbClient;
|
|
20
|
+
|
|
21
|
+
function seedModule(id: string) {
|
|
22
|
+
db.insert(modules)
|
|
23
|
+
.values({ id, name: id, version: '1.0.0', manifestData: {}, sourcePath: `/tmp/${id}` })
|
|
24
|
+
.run();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function seedRoute(
|
|
28
|
+
moduleId: string,
|
|
29
|
+
slug: string,
|
|
30
|
+
path: string,
|
|
31
|
+
type: 'static' | 'reverse_proxy',
|
|
32
|
+
) {
|
|
33
|
+
db.insert(webRoutes)
|
|
34
|
+
.values({
|
|
35
|
+
slug,
|
|
36
|
+
moduleId,
|
|
37
|
+
type,
|
|
38
|
+
path,
|
|
39
|
+
hostname: 'iamtheinternet.org',
|
|
40
|
+
targetHost: type === 'reverse_proxy' ? '10.0.20.5' : null,
|
|
41
|
+
targetPort: type === 'reverse_proxy' ? 8080 : null,
|
|
42
|
+
})
|
|
43
|
+
.run();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function recordingHook(failFor: string[] = []) {
|
|
47
|
+
const calls: string[] = [];
|
|
48
|
+
const runHook = (async (moduleId: string) => {
|
|
49
|
+
calls.push(moduleId);
|
|
50
|
+
return failFor.includes(moduleId)
|
|
51
|
+
? { success: false, outputs: {}, error: 'boom', duration: 1 }
|
|
52
|
+
: { success: true, outputs: {}, duration: 1 };
|
|
53
|
+
}) as unknown as Parameters<typeof republishStaticWebConsumers>[0]['runHook'];
|
|
54
|
+
return { runHook, calls };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
beforeEach(async () => {
|
|
58
|
+
dir = mkdtempSync(join(tmpdir(), 'pwr-'));
|
|
59
|
+
const dbPath = join(dir, 'celilo.db');
|
|
60
|
+
process.env.CELILO_DB_PATH = dbPath;
|
|
61
|
+
db = await setupTestDatabase(dbPath);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
db.$client.close();
|
|
66
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
67
|
+
try {
|
|
68
|
+
rmSync(dir, { recursive: true, force: true });
|
|
69
|
+
} catch {
|
|
70
|
+
/* ignore */
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('providesPublicWeb reads the manifest', () => {
|
|
75
|
+
expect(providesPublicWeb(caddyManifest)).toBe(true);
|
|
76
|
+
expect(providesPublicWeb(plainManifest)).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('does not run for a module that is not a public_web provider', async () => {
|
|
80
|
+
seedModule('hello-foo');
|
|
81
|
+
seedRoute('hello-foo', 'foo', '/foo', 'static');
|
|
82
|
+
const { runHook, calls } = recordingHook();
|
|
83
|
+
|
|
84
|
+
const result = await republishStaticWebConsumers({
|
|
85
|
+
providerModuleId: 'namecheap',
|
|
86
|
+
manifest: plainManifest,
|
|
87
|
+
db,
|
|
88
|
+
runHook,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
expect(result).toEqual({ ran: false, republished: [], failures: [] });
|
|
92
|
+
expect(calls).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('re-runs on_install for every static consumer', async () => {
|
|
96
|
+
seedModule('caddy');
|
|
97
|
+
seedModule('hello-foo');
|
|
98
|
+
seedModule('hello-bar');
|
|
99
|
+
seedModule('celilo-website');
|
|
100
|
+
seedRoute('hello-foo', 'foo', '/foo', 'static');
|
|
101
|
+
seedRoute('hello-bar', 'bar', '/bar', 'static');
|
|
102
|
+
seedRoute('celilo-website', 'celilo-website', '/', 'static');
|
|
103
|
+
const { runHook, calls } = recordingHook();
|
|
104
|
+
|
|
105
|
+
const result = await republishStaticWebConsumers({
|
|
106
|
+
providerModuleId: 'caddy',
|
|
107
|
+
manifest: caddyManifest,
|
|
108
|
+
db,
|
|
109
|
+
runHook,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(result.ran).toBe(true);
|
|
113
|
+
expect(calls.sort()).toEqual(['celilo-website', 'hello-bar', 'hello-foo']);
|
|
114
|
+
expect(result.republished.sort()).toEqual(['celilo-website', 'hello-bar', 'hello-foo']);
|
|
115
|
+
expect(result.failures).toEqual([]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('skips reverse-proxy-only consumers (no assets on the provider host)', async () => {
|
|
119
|
+
seedModule('caddy');
|
|
120
|
+
seedModule('forgejo');
|
|
121
|
+
seedModule('hello-foo');
|
|
122
|
+
seedRoute('forgejo', 'git', '/git', 'reverse_proxy');
|
|
123
|
+
seedRoute('hello-foo', 'foo', '/foo', 'static');
|
|
124
|
+
const { runHook, calls } = recordingHook();
|
|
125
|
+
|
|
126
|
+
await republishStaticWebConsumers({
|
|
127
|
+
providerModuleId: 'caddy',
|
|
128
|
+
manifest: caddyManifest,
|
|
129
|
+
db,
|
|
130
|
+
runHook,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(calls).toEqual(['hello-foo']);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('never re-runs the provider itself', async () => {
|
|
137
|
+
seedModule('caddy');
|
|
138
|
+
// A provider that also owns a static route (a landing page of its own).
|
|
139
|
+
seedRoute('caddy', 'caddy', '/', 'static');
|
|
140
|
+
const { runHook, calls } = recordingHook();
|
|
141
|
+
|
|
142
|
+
const result = await republishStaticWebConsumers({
|
|
143
|
+
providerModuleId: 'caddy',
|
|
144
|
+
manifest: caddyManifest,
|
|
145
|
+
db,
|
|
146
|
+
runHook,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
expect(calls).toEqual([]);
|
|
150
|
+
expect(result.republished).toEqual([]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('de-duplicates a consumer that owns several static routes', async () => {
|
|
154
|
+
seedModule('caddy');
|
|
155
|
+
seedModule('hello-foo');
|
|
156
|
+
seedRoute('hello-foo', 'foo', '/foo', 'static');
|
|
157
|
+
seedRoute('hello-foo', 'foo-docs', '/foo/docs', 'static');
|
|
158
|
+
const { runHook, calls } = recordingHook();
|
|
159
|
+
|
|
160
|
+
await republishStaticWebConsumers({
|
|
161
|
+
providerModuleId: 'caddy',
|
|
162
|
+
manifest: caddyManifest,
|
|
163
|
+
db,
|
|
164
|
+
runHook,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
expect(calls).toEqual(['hello-foo']);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('one consumer failing does not stop the others', async () => {
|
|
171
|
+
seedModule('caddy');
|
|
172
|
+
seedModule('hello-foo');
|
|
173
|
+
seedModule('hello-bar');
|
|
174
|
+
seedRoute('hello-foo', 'foo', '/foo', 'static');
|
|
175
|
+
seedRoute('hello-bar', 'bar', '/bar', 'static');
|
|
176
|
+
const { runHook, calls } = recordingHook(['hello-foo']);
|
|
177
|
+
|
|
178
|
+
const result = await republishStaticWebConsumers({
|
|
179
|
+
providerModuleId: 'caddy',
|
|
180
|
+
manifest: caddyManifest,
|
|
181
|
+
db,
|
|
182
|
+
runHook,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
expect(calls.sort()).toEqual(['hello-bar', 'hello-foo']);
|
|
186
|
+
expect(result.republished).toEqual(['hello-bar']);
|
|
187
|
+
expect(result.failures).toEqual([{ moduleId: 'hello-foo', error: 'boom' }]);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Restore consumer content after a public_web provider redeploy.
|
|
3
|
+
*
|
|
4
|
+
* A provider redeploy destroys the provider's host and rebuilds it, taking
|
|
5
|
+
* `/srv/www` with it. The consumers' `web_routes` rows survive — they belong to
|
|
6
|
+
* the consumer modules, not the provider — so the rebuilt provider renders
|
|
7
|
+
* `root * /srv/www/<slug>` for directories that no longer exist and every
|
|
8
|
+
* static site 404s. Nothing re-uploaded the assets; an operator had to redeploy
|
|
9
|
+
* each consumer by hand and there was no signal telling them to.
|
|
10
|
+
*
|
|
11
|
+
* The fix is to do exactly what that operator did: after a provider deploy
|
|
12
|
+
* lands, re-run `on_install` for every module holding a STATIC route.
|
|
13
|
+
* `publishStaticSite` is idempotent (it upserts the route and re-tars the
|
|
14
|
+
* source dir), so the re-run re-registers the route and refills the fresh
|
|
15
|
+
* `/srv/www/<slug>`.
|
|
16
|
+
*
|
|
17
|
+
* Reverse-proxy consumers are skipped: they keep no state on the provider host,
|
|
18
|
+
* so re-running them buys nothing and only widens the blast radius.
|
|
19
|
+
*
|
|
20
|
+
* ponytail: re-runs every static consumer unconditionally rather than probing
|
|
21
|
+
* which /srv/www/<slug> directories are actually missing — O(static consumers)
|
|
22
|
+
* per provider deploy, currently a handful. If a large fleet makes provider
|
|
23
|
+
* deploys slow, probe the provider host first and re-run only the misses.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { eq } from 'drizzle-orm';
|
|
27
|
+
import type { DbClient } from '../db/client';
|
|
28
|
+
import { webRoutes } from '../db/schema';
|
|
29
|
+
import { createConsoleLogger } from '../hooks/logger';
|
|
30
|
+
import { runNamedHook } from '../hooks/run-named-hook';
|
|
31
|
+
import type { ModuleManifest } from '../manifest/schema';
|
|
32
|
+
|
|
33
|
+
export interface RepublishFailure {
|
|
34
|
+
moduleId: string;
|
|
35
|
+
error: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface RepublishResult {
|
|
39
|
+
/** False when the deployed module isn't a public_web provider. */
|
|
40
|
+
ran: boolean;
|
|
41
|
+
/** Consumer modules whose on_install was re-run successfully. */
|
|
42
|
+
republished: string[];
|
|
43
|
+
failures: RepublishFailure[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function providesPublicWeb(manifest: ModuleManifest): boolean {
|
|
47
|
+
return (manifest.provides?.capabilities ?? []).some((c) => c.name === 'public_web');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface RepublishDeps {
|
|
51
|
+
providerModuleId: string;
|
|
52
|
+
manifest: ModuleManifest;
|
|
53
|
+
db: DbClient;
|
|
54
|
+
/** Injectable for tests. */
|
|
55
|
+
runHook?: typeof runNamedHook;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function republishStaticWebConsumers(deps: RepublishDeps): Promise<RepublishResult> {
|
|
59
|
+
const { providerModuleId, manifest, db, runHook = runNamedHook } = deps;
|
|
60
|
+
|
|
61
|
+
if (!providesPublicWeb(manifest)) {
|
|
62
|
+
return { ran: false, republished: [], failures: [] };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const staticRoutes = db.select().from(webRoutes).where(eq(webRoutes.type, 'static')).all();
|
|
66
|
+
const consumerIds = [
|
|
67
|
+
...new Set(staticRoutes.map((r) => r.moduleId).filter((id) => id !== providerModuleId)),
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
const republished: string[] = [];
|
|
71
|
+
const failures: RepublishFailure[] = [];
|
|
72
|
+
|
|
73
|
+
for (const moduleId of consumerIds) {
|
|
74
|
+
const logger = createConsoleLogger(moduleId, 'on_install');
|
|
75
|
+
const result = await runHook(moduleId, 'on_install', db, logger, {});
|
|
76
|
+
if (result.success) {
|
|
77
|
+
republished.push(moduleId);
|
|
78
|
+
} else {
|
|
79
|
+
failures.push({ moduleId, error: result.error ?? 'unknown error' });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { ran: true, republished, failures };
|
|
84
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* File-direct restore + system-file swap. Powers the `celilo restore`
|
|
3
|
-
* top-level convenience command (Phase 4 of
|
|
3
|
+
* top-level convenience command (Phase 4 of openspec/specs/management-server-backup/spec.md).
|
|
4
4
|
*
|
|
5
5
|
* The generic `restoreModuleBackup` reads from a configured storage
|
|
6
6
|
* destination — which on a fresh-bootstrap box doesn't exist yet.
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* Either signal is sufficient. Both are checked so a half-deployed
|
|
14
14
|
* target (DB cleared but disk artifacts left over) still gets caught.
|
|
15
15
|
*
|
|
16
|
-
* Phase 4 of
|
|
16
|
+
* Phase 4 of openspec/specs/management-server-backup/spec.md (Design Decision 9).
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
@@ -16,7 +16,7 @@ export interface SystemIdentity {
|
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* Get a module's hostname and IP from its recorded deployed systems
|
|
19
|
-
* (
|
|
19
|
+
* (openspec/specs/module-systems-addressing/spec.md). Returns null for a module with no host
|
|
20
20
|
* (config-only providers like namecheap). Single-system modules have one; this
|
|
21
21
|
* returns the first (Phase C iterates all systems for per-system events).
|
|
22
22
|
*/
|
|
@@ -29,7 +29,7 @@ describe('System Init Service', () => {
|
|
|
29
29
|
// a zone address).
|
|
30
30
|
expect(defaults['network.bridge']).toBe('vmbr0');
|
|
31
31
|
// Network/DNS addressing is no longer defaulted
|
|
32
|
-
// (
|
|
32
|
+
// (openspec/specs/progressive-zone-disclosure/spec.md): internal is discovered at
|
|
33
33
|
// celilo-mgmt install; dmz/app/secure appear only when a firewall
|
|
34
34
|
// module provides them.
|
|
35
35
|
expect(defaults['network.dmz.subnet']).toBeUndefined();
|
|
@@ -184,7 +184,7 @@ describe('System Init Service', () => {
|
|
|
184
184
|
test('keys off the sentinel, not config rows', async () => {
|
|
185
185
|
// Config rows present but no sentinel → NOT initialized. Initialization
|
|
186
186
|
// is tracked by the explicit `system.initialized` marker
|
|
187
|
-
// (
|
|
187
|
+
// (openspec/specs/progressive-zone-disclosure/spec.md), not by any network row.
|
|
188
188
|
db.insert(systemConfig).values({ key: 'network.dmz.subnet', value: '10.0.0.0/24' }).run();
|
|
189
189
|
db.insert(systemConfig).values({ key: 'ssh.public_key', value: 'ssh-rsa AAAA...' }).run();
|
|
190
190
|
expect(isSystemInitialized(db)).toBe(false);
|
|
@@ -199,7 +199,7 @@ export function initializeSystem(
|
|
|
199
199
|
}
|
|
200
200
|
|
|
201
201
|
// Explicit init marker. Network/DNS addressing is no longer seeded with
|
|
202
|
-
// defaults (
|
|
202
|
+
// defaults (openspec/specs/progressive-zone-disclosure/spec.md), so we can't key
|
|
203
203
|
// "is this system initialized?" off a network row anymore. Write a
|
|
204
204
|
// dedicated sentinel instead. additionalProperties:true in the schema
|
|
205
205
|
// permits this non-schema key.
|
|
@@ -238,7 +238,7 @@ export function loadExistingConfiguration(db: DbClient): Record<string, string>
|
|
|
238
238
|
* Keys off the explicit `system.initialized` sentinel written by
|
|
239
239
|
* initializeSystem(). Earlier this checked for a seeded network zone
|
|
240
240
|
* subnet, but network/DNS addressing is no longer defaulted
|
|
241
|
-
* (
|
|
241
|
+
* (openspec/specs/progressive-zone-disclosure/spec.md), so a fresh init leaves those
|
|
242
242
|
* rows absent.
|
|
243
243
|
*
|
|
244
244
|
* @param db - Database instance
|
|
@@ -0,0 +1,250 @@
|
|
|
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
|
+
});
|