@celilo/cli 0.5.0-alpha.3 → 0.5.0-alpha.5
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/drizzle/0010_dns_internal_records.sql +12 -0
- package/drizzle/0011_backups_name.sql +1 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +1 -1
- package/src/cli/command-registry.ts +18 -2
- package/src/cli/commands/events.ts +28 -0
- package/src/cli/commands/module-publish.ts +24 -0
- package/src/cli/commands/restore.ts +29 -0
- package/src/cli/commands/system-doctor.ts +135 -40
- package/src/cli/commands/system-migrate.test.ts +40 -0
- package/src/cli/commands/system-migrate.ts +65 -0
- package/src/cli/completion.ts +2 -0
- package/src/cli/index.ts +9 -0
- package/src/db/client.ts +15 -146
- package/src/db/migrate.ts +14 -6
- package/src/db/schema-introspection.ts +88 -0
- package/src/db/schema.ts +38 -0
- package/src/hooks/capability-loader.ts +28 -15
- package/src/services/deploy-preflight.ts +25 -0
- package/src/services/dns-internal-records.test.ts +126 -0
- package/src/services/dns-internal-records.ts +119 -0
- package/src/services/fleet-checks.test.ts +495 -0
- package/src/services/fleet-checks.ts +663 -0
- package/src/services/module-subscriptions.test.ts +88 -0
- package/src/services/module-subscriptions.ts +50 -1
- package/src/services/module-validator/bundled-deps.test.ts +55 -0
- package/src/services/module-validator/bundled-deps.ts +115 -0
- package/src/templates/generator.ts +21 -0
|
@@ -3,11 +3,14 @@ import { mkdtempSync, rmSync } from 'node:fs';
|
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { defineEvents, openBus } from '@celilo/event-bus';
|
|
6
|
+
import { closeDb, getDb } from '../db/client';
|
|
6
7
|
import { ModuleSubscriptionSchema } from '../manifest/schema';
|
|
7
8
|
import type { ModuleManifest } from '../manifest/schema';
|
|
9
|
+
import { setupTestDatabase as migrateDbFile } from '../test-utils/setup-test-db';
|
|
8
10
|
import {
|
|
9
11
|
registerModuleSubscriptions,
|
|
10
12
|
resolveSubscription,
|
|
13
|
+
resyncAllSubscriptions,
|
|
11
14
|
unregisterModuleSubscriptions,
|
|
12
15
|
} from './module-subscriptions';
|
|
13
16
|
|
|
@@ -253,3 +256,88 @@ describe('register / unregister roundtrip', () => {
|
|
|
253
256
|
}
|
|
254
257
|
});
|
|
255
258
|
});
|
|
259
|
+
|
|
260
|
+
describe('resyncAllSubscriptions (ISS-0088)', () => {
|
|
261
|
+
let dir: string;
|
|
262
|
+
let busPath: string;
|
|
263
|
+
|
|
264
|
+
beforeEach(async () => {
|
|
265
|
+
closeDb();
|
|
266
|
+
dir = mkdtempSync(join(tmpdir(), 'resync-test-'));
|
|
267
|
+
busPath = join(dir, 'events.db');
|
|
268
|
+
process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
|
|
269
|
+
process.env.EVENT_BUS_DB = busPath;
|
|
270
|
+
// Migrate the celilo.db file getDb() will open (resyncAllSubscriptions reads it).
|
|
271
|
+
const setupDb = await migrateDbFile(join(dir, 'celilo.db'));
|
|
272
|
+
setupDb.$client.close();
|
|
273
|
+
});
|
|
274
|
+
afterEach(() => {
|
|
275
|
+
closeDb();
|
|
276
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
277
|
+
process.env.EVENT_BUS_DB = undefined;
|
|
278
|
+
try {
|
|
279
|
+
rmSync(dir, { recursive: true, force: true });
|
|
280
|
+
} catch {
|
|
281
|
+
/* ignore */
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
function seedModule(
|
|
286
|
+
id: string,
|
|
287
|
+
state: string,
|
|
288
|
+
subs: Array<{ name: string; pattern: string; handler: string }>,
|
|
289
|
+
): void {
|
|
290
|
+
const manifest = JSON.stringify({
|
|
291
|
+
celilo_contract: '1.0',
|
|
292
|
+
id,
|
|
293
|
+
name: id,
|
|
294
|
+
version: '1.0.0',
|
|
295
|
+
requires: { capabilities: [] },
|
|
296
|
+
provides: { capabilities: [] },
|
|
297
|
+
variables: { owns: [], imports: [] },
|
|
298
|
+
subscriptions: subs,
|
|
299
|
+
});
|
|
300
|
+
getDb().$client.run(
|
|
301
|
+
'INSERT INTO modules (id, name, version, source_path, manifest_data, state) VALUES (?, ?, ?, ?, ?, ?)',
|
|
302
|
+
[id, id, '1.0.0', '/p', manifest, state],
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function subscriberNames(): string[] {
|
|
307
|
+
const bus = openBus({ dbPath: busPath, events: defineEvents({}) });
|
|
308
|
+
try {
|
|
309
|
+
return bus.db
|
|
310
|
+
.query<{ name: string }, []>('SELECT name FROM subscribers ORDER BY name')
|
|
311
|
+
.all()
|
|
312
|
+
.map((r) => r.name);
|
|
313
|
+
} finally {
|
|
314
|
+
bus.close();
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
it('registers subs for DEPLOYED modules only, skipping imported/undeployed', () => {
|
|
319
|
+
seedModule('caddy', 'VERIFIED', [
|
|
320
|
+
{ name: 'reconcile', pattern: 'public_web.routes_changed', handler: 'echo' },
|
|
321
|
+
]);
|
|
322
|
+
seedModule('technitium', 'INSTALLED', [
|
|
323
|
+
{ name: 'dns', pattern: 'system.created.*', handler: 'echo' },
|
|
324
|
+
]);
|
|
325
|
+
seedModule('forgejo', 'IMPORTED', [{ name: 'x', pattern: 'y', handler: 'echo' }]);
|
|
326
|
+
seedModule('greenwave', 'VERIFIED', []); // deployed but declares no subs
|
|
327
|
+
|
|
328
|
+
const result = resyncAllSubscriptions();
|
|
329
|
+
|
|
330
|
+
expect(result.modules).toBe(2); // caddy + technitium (forgejo not deployed, greenwave has none)
|
|
331
|
+
expect(result.registered).toBe(2);
|
|
332
|
+
expect(result.failures).toEqual([]);
|
|
333
|
+
// forgejo.x absent — it was IMPORTED, not deployed.
|
|
334
|
+
expect(subscriberNames()).toEqual(['caddy.reconcile', 'technitium.dns']);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('is idempotent — a second resync does not duplicate rows', () => {
|
|
338
|
+
seedModule('caddy', 'VERIFIED', [{ name: 'reconcile', pattern: 'a', handler: 'echo' }]);
|
|
339
|
+
resyncAllSubscriptions();
|
|
340
|
+
resyncAllSubscriptions();
|
|
341
|
+
expect(subscriberNames()).toEqual(['caddy.reconcile']);
|
|
342
|
+
});
|
|
343
|
+
});
|
|
@@ -14,8 +14,12 @@
|
|
|
14
14
|
* colliding.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import { join } from 'node:path';
|
|
17
18
|
import { defineEvents, openBus } from '@celilo/event-bus';
|
|
18
|
-
import {
|
|
19
|
+
import { inArray } from 'drizzle-orm';
|
|
20
|
+
import { getEventBusPath, getModuleStoragePath } from '../config/paths';
|
|
21
|
+
import { getDb } from '../db/client';
|
|
22
|
+
import { modules } from '../db/schema';
|
|
19
23
|
import type { ModuleManifest, ModuleSubscription } from '../manifest/schema';
|
|
20
24
|
|
|
21
25
|
/**
|
|
@@ -126,6 +130,51 @@ export function unregisterModuleSubscriptions(moduleId: string): {
|
|
|
126
130
|
}
|
|
127
131
|
}
|
|
128
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Rebuild the event-bus `subscribers` table from every deployed module's
|
|
135
|
+
* manifest `subscriptions:`. The reactive layer (who-reacts-to-what) is
|
|
136
|
+
* registered at module DEPLOY time and lives in the bus (events.db), which a
|
|
137
|
+
* restore/migration starts EMPTY — so after a cutover, event-driven reconciles
|
|
138
|
+
* (caddy public_web, dns register, …) are dead until every module redeploys
|
|
139
|
+
* (ISS-0088). This reconstructs them from durable celilo.db state instead.
|
|
140
|
+
*
|
|
141
|
+
* Idempotent (registerModuleSubscriptions upserts by subscriber name). Per-module
|
|
142
|
+
* failures are collected, not thrown, so one bad manifest doesn't abort the rest.
|
|
143
|
+
*/
|
|
144
|
+
export function resyncAllSubscriptions(): {
|
|
145
|
+
modules: number;
|
|
146
|
+
registered: number;
|
|
147
|
+
failures: Array<{ moduleId: string; error: string }>;
|
|
148
|
+
} {
|
|
149
|
+
const db = getDb();
|
|
150
|
+
const deployed = db
|
|
151
|
+
.select()
|
|
152
|
+
.from(modules)
|
|
153
|
+
.where(inArray(modules.state, ['INSTALLED', 'VERIFIED']))
|
|
154
|
+
.all();
|
|
155
|
+
|
|
156
|
+
let modulesWithSubs = 0;
|
|
157
|
+
let registered = 0;
|
|
158
|
+
const failures: Array<{ moduleId: string; error: string }> = [];
|
|
159
|
+
|
|
160
|
+
for (const mod of deployed) {
|
|
161
|
+
const manifest = mod.manifestData as unknown as ModuleManifest;
|
|
162
|
+
if (!manifest?.subscriptions?.length) continue;
|
|
163
|
+
// Code always lives at ${getModuleStoragePath()}/<id> by construction
|
|
164
|
+
// (import.ts) — the same path module-upgrade re-registers from (ISS-0091).
|
|
165
|
+
const modulePath = join(getModuleStoragePath(), mod.id);
|
|
166
|
+
try {
|
|
167
|
+
const result = registerModuleSubscriptions(manifest, modulePath);
|
|
168
|
+
registered += result.registered;
|
|
169
|
+
modulesWithSubs += 1;
|
|
170
|
+
} catch (err) {
|
|
171
|
+
failures.push({ moduleId: mod.id, error: err instanceof Error ? err.message : String(err) });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return { modules: modulesWithSubs, registered, failures };
|
|
176
|
+
}
|
|
177
|
+
|
|
129
178
|
function scopedName(moduleId: string, subName: string): string {
|
|
130
179
|
return `${moduleId}.${subName}`;
|
|
131
180
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { findStaleBundledDeps, refreshAndVerifyBundledDeps } from './bundled-deps';
|
|
3
|
+
|
|
4
|
+
describe('findStaleBundledDeps (ISS-0104)', () => {
|
|
5
|
+
test('flags a bundled @celilo dep older than the workspace', () => {
|
|
6
|
+
const mismatches = findStaleBundledDeps(
|
|
7
|
+
{ '@celilo/capabilities': '0.1.8' },
|
|
8
|
+
{ '@celilo/capabilities': '0.4.1' },
|
|
9
|
+
);
|
|
10
|
+
expect(mismatches).toEqual([
|
|
11
|
+
{ pkg: '@celilo/capabilities', bundled: '0.1.8', workspace: '0.4.1' },
|
|
12
|
+
]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('no mismatch when bundled matches workspace', () => {
|
|
16
|
+
expect(
|
|
17
|
+
findStaleBundledDeps(
|
|
18
|
+
{ '@celilo/capabilities': '0.4.1', '@celilo/event-bus': '0.1.6' },
|
|
19
|
+
{ '@celilo/capabilities': '0.4.1', '@celilo/event-bus': '0.1.6' },
|
|
20
|
+
),
|
|
21
|
+
).toEqual([]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('ignores workspace packages the module does not bundle', () => {
|
|
25
|
+
// Module bundles only capabilities; cli-display is a workspace package but
|
|
26
|
+
// not in this module's closure — must not be flagged.
|
|
27
|
+
expect(
|
|
28
|
+
findStaleBundledDeps(
|
|
29
|
+
{ '@celilo/capabilities': '0.4.1' },
|
|
30
|
+
{ '@celilo/capabilities': '0.4.1', '@celilo/cli-display': '0.1.9' },
|
|
31
|
+
),
|
|
32
|
+
).toEqual([]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('flags each stale dep independently', () => {
|
|
36
|
+
const mismatches = findStaleBundledDeps(
|
|
37
|
+
{ '@celilo/capabilities': '0.1.8', '@celilo/event-bus': '0.1.6' },
|
|
38
|
+
{ '@celilo/capabilities': '0.4.1', '@celilo/event-bus': '0.1.6' },
|
|
39
|
+
);
|
|
40
|
+
expect(mismatches).toEqual([
|
|
41
|
+
{ pkg: '@celilo/capabilities', bundled: '0.1.8', workspace: '0.4.1' },
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('refreshAndVerifyBundledDeps (ISS-0104)', () => {
|
|
47
|
+
test('no-op for a module without scripts/package.json', () => {
|
|
48
|
+
let installs = 0;
|
|
49
|
+
const result = refreshAndVerifyBundledDeps('/tmp/does-not-exist-module-xyz', () => {
|
|
50
|
+
installs++;
|
|
51
|
+
});
|
|
52
|
+
expect(result).toEqual({ refreshed: false, mismatches: [] });
|
|
53
|
+
expect(installs).toBe(0);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ISS-0104: a module's hooks resolve `@celilo/*` from the module's OWN
|
|
3
|
+
* `scripts/node_modules` (nearest-wins), and those are gitignored local
|
|
4
|
+
* installs that go stale. The publish bundles them as-is, so a deployed module
|
|
5
|
+
* can silently run capability code months older than what was just published
|
|
6
|
+
* (e.g. caddy shipped capabilities@0.1.8 while the workspace was at 0.4.1, so
|
|
7
|
+
* ISS-0102 never reached the rendered Caddyfile).
|
|
8
|
+
*
|
|
9
|
+
* Before packing, the publish refreshes a module's `scripts/` deps and verifies
|
|
10
|
+
* the bundled `@celilo/*` versions match the workspace — the versions being
|
|
11
|
+
* co-published. A mismatch fails the publish (unless --allow-stale).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { execSync } from 'node:child_process';
|
|
15
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
16
|
+
import { dirname, join } from 'node:path';
|
|
17
|
+
|
|
18
|
+
/** The @celilo workspace packages a module may bundle (by published name). */
|
|
19
|
+
const CELILO_WORKSPACE_PACKAGES = ['capabilities', 'cli-display', 'event-bus'] as const;
|
|
20
|
+
|
|
21
|
+
export interface DepMismatch {
|
|
22
|
+
pkg: string;
|
|
23
|
+
bundled: string;
|
|
24
|
+
workspace: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Pure: which bundled `@celilo/*` versions differ from the workspace. Only deps
|
|
29
|
+
* the module actually bundles AND the workspace owns are compared — a module
|
|
30
|
+
* that doesn't bundle a given package, or a package the workspace doesn't own,
|
|
31
|
+
* is ignored.
|
|
32
|
+
*/
|
|
33
|
+
export function findStaleBundledDeps(
|
|
34
|
+
bundled: Record<string, string>,
|
|
35
|
+
workspace: Record<string, string>,
|
|
36
|
+
): DepMismatch[] {
|
|
37
|
+
const mismatches: DepMismatch[] = [];
|
|
38
|
+
for (const [pkg, wsVersion] of Object.entries(workspace)) {
|
|
39
|
+
const bundledVersion = bundled[pkg];
|
|
40
|
+
if (bundledVersion && bundledVersion !== wsVersion) {
|
|
41
|
+
mismatches.push({ pkg, bundled: bundledVersion, workspace: wsVersion });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return mismatches;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readVersion(packageJsonPath: string): string | undefined {
|
|
48
|
+
try {
|
|
49
|
+
const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as { version?: string };
|
|
50
|
+
return parsed.version;
|
|
51
|
+
} catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Find the monorepo root (the dir with `packages/capabilities`) by walking up. */
|
|
57
|
+
function findWorkspaceRoot(start: string): string | undefined {
|
|
58
|
+
let dir = start;
|
|
59
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
60
|
+
if (existsSync(join(dir, 'packages', 'capabilities', 'package.json'))) return dir;
|
|
61
|
+
const parent = dirname(dir);
|
|
62
|
+
if (parent === dir) break;
|
|
63
|
+
dir = parent;
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface RefreshResult {
|
|
69
|
+
/** Whether the module had a scripts/ package to refresh. */
|
|
70
|
+
refreshed: boolean;
|
|
71
|
+
/** Bundled @celilo/* that don't match the workspace (empty = clean). */
|
|
72
|
+
mismatches: DepMismatch[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Refresh a module's `scripts/` deps (so the bundled hook runtime is current)
|
|
77
|
+
* and verify the bundled `@celilo/*` versions match the workspace. No-op for a
|
|
78
|
+
* module without `scripts/package.json`, or when run outside the monorepo (a
|
|
79
|
+
* standalone module has no workspace to compare against — refresh only).
|
|
80
|
+
*
|
|
81
|
+
* `run` is injected so tests can stub the `bun install` side effect.
|
|
82
|
+
*/
|
|
83
|
+
export function refreshAndVerifyBundledDeps(
|
|
84
|
+
moduleDir: string,
|
|
85
|
+
run: (cmd: string, cwd: string) => void = (cmd, cwd) =>
|
|
86
|
+
void execSync(cmd, { cwd, stdio: 'pipe' }),
|
|
87
|
+
): RefreshResult {
|
|
88
|
+
const scriptsDir = join(moduleDir, 'scripts');
|
|
89
|
+
if (!existsSync(join(scriptsDir, 'package.json'))) {
|
|
90
|
+
return { refreshed: false, mismatches: [] };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Reconcile node_modules with the lockfile + package.json pins so the pack
|
|
94
|
+
// bundles a CURRENT closure, not whatever the operator last installed.
|
|
95
|
+
run('bun install', scriptsDir);
|
|
96
|
+
|
|
97
|
+
const workspaceRoot = findWorkspaceRoot(moduleDir);
|
|
98
|
+
if (!workspaceRoot) {
|
|
99
|
+
return { refreshed: true, mismatches: [] };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const workspace: Record<string, string> = {};
|
|
103
|
+
for (const name of CELILO_WORKSPACE_PACKAGES) {
|
|
104
|
+
const version = readVersion(join(workspaceRoot, 'packages', name, 'package.json'));
|
|
105
|
+
if (version) workspace[`@celilo/${name}`] = version;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const bundled: Record<string, string> = {};
|
|
109
|
+
for (const pkg of Object.keys(workspace)) {
|
|
110
|
+
const version = readVersion(join(scriptsDir, 'node_modules', pkg, 'package.json'));
|
|
111
|
+
if (version) bundled[pkg] = version;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { refreshed: true, mismatches: findStaleBundledDeps(bundled, workspace) };
|
|
115
|
+
}
|
|
@@ -19,6 +19,10 @@ import {
|
|
|
19
19
|
import { getSingularSystemSpec } from '../manifest/schema';
|
|
20
20
|
import type { AnsibleCollection, ModuleManifest } from '../manifest/schema';
|
|
21
21
|
import { validateZoneRequirements } from '../manifest/validate';
|
|
22
|
+
import {
|
|
23
|
+
describeCapabilityProblem,
|
|
24
|
+
findBrokenCapabilityDerivations,
|
|
25
|
+
} from '../services/fleet-checks';
|
|
22
26
|
import { selectInfrastructure } from '../services/infrastructure-selector';
|
|
23
27
|
import { upsertModuleConfig } from '../services/module-config';
|
|
24
28
|
import type { InfrastructureSelection } from '../types/infrastructure';
|
|
@@ -780,6 +784,23 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
|
|
|
780
784
|
};
|
|
781
785
|
}
|
|
782
786
|
|
|
787
|
+
// Policy: fail loud at the source on a broken capability chain. A
|
|
788
|
+
// required `source: capability` var whose chain doesn't resolve is
|
|
789
|
+
// silently dropped during derivation, then surfaces downstream as a
|
|
790
|
+
// cryptic `$self:<x> not found` in some template. Assert it here against
|
|
791
|
+
// the resolved capabilities map so generate names the broken link and
|
|
792
|
+
// the provider to redeploy (ISS-0115; the data-plane sibling of ISS-0088).
|
|
793
|
+
const capProblems = findBrokenCapabilityDerivations(moduleId, manifest, context.capabilities);
|
|
794
|
+
if (capProblems.length > 0) {
|
|
795
|
+
return {
|
|
796
|
+
success: false,
|
|
797
|
+
error: `Cannot generate '${moduleId}': ${capProblems.length} capability-derived variable(s) won't resolve:\n${capProblems
|
|
798
|
+
.map((p) => ` - ${describeCapabilityProblem(p)}`)
|
|
799
|
+
.join('\n')}`,
|
|
800
|
+
details: capProblems,
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
|
|
783
804
|
// Execution: Store derived variables in module_configs
|
|
784
805
|
// Variables with derive_from are resolved in the context but not stored in module_configs.
|
|
785
806
|
// Store them so hooks and host_vars can access them.
|