@celilo/cli 0.5.0-alpha.1 → 0.5.0-alpha.11
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/0009_dns_registrations.sql +13 -0
- package/drizzle/0010_dns_internal_records.sql +12 -0
- package/drizzle/0011_backups_name.sql +1 -0
- package/drizzle/meta/_journal.json +22 -1
- package/package.json +3 -3
- package/src/ansible/inventory.test.ts +10 -10
- package/src/ansible/validation.test.ts +25 -15
- package/src/api-clients/proxmox.test.ts +211 -1
- package/src/api-clients/proxmox.ts +399 -8
- package/src/cli/command-registry.ts +83 -6
- package/src/cli/commands/backup-delete.ts +10 -7
- package/src/cli/commands/backup-import.ts +11 -8
- package/src/cli/commands/backup-restore.ts +11 -8
- package/src/cli/commands/dns.ts +57 -0
- package/src/cli/commands/events.test.ts +4 -4
- package/src/cli/commands/events.ts +89 -24
- package/src/cli/commands/machine-add.ts +178 -163
- package/src/cli/commands/machine-remove.ts +10 -7
- package/src/cli/commands/module-config.test.ts +78 -0
- package/src/cli/commands/module-config.ts +18 -3
- package/src/cli/commands/module-import.ts +9 -5
- package/src/cli/commands/module-publish.ts +24 -0
- package/src/cli/commands/module-remove.ts +20 -9
- package/src/cli/commands/module-status.ts +15 -0
- package/src/cli/commands/module-upgrade.test.ts +37 -0
- package/src/cli/commands/module-upgrade.ts +26 -6
- package/src/cli/commands/proxmox-node-list.ts +101 -0
- package/src/cli/commands/proxmox-template-selection.ts +16 -15
- package/src/cli/commands/proxmox-vm-template-build.ts +171 -0
- package/src/cli/commands/publish/alpha.test.ts +26 -0
- package/src/cli/commands/publish/alpha.ts +23 -0
- package/src/cli/commands/publish/types.ts +7 -2
- package/src/cli/commands/publish/workspace.ts +11 -1
- package/src/cli/commands/restore.ts +29 -0
- package/src/cli/commands/service-add-digitalocean.ts +120 -109
- package/src/cli/commands/service-add-proxmox.ts +283 -209
- package/src/cli/commands/service-reconfigure.test.ts +115 -0
- package/src/cli/commands/service-reconfigure.ts +252 -129
- package/src/cli/commands/service-remove.ts +19 -13
- package/src/cli/commands/service-verify.ts +9 -10
- package/src/cli/commands/storage-add-local.ts +120 -107
- package/src/cli/commands/storage-add-s3.ts +145 -131
- package/src/cli/commands/storage-remove.ts +11 -8
- package/src/cli/commands/system-doctor.ts +135 -40
- package/src/cli/commands/system-init.ts +119 -128
- package/src/cli/commands/system-migrate.test.ts +40 -0
- package/src/cli/commands/system-migrate.ts +65 -0
- package/src/cli/completion.ts +23 -0
- package/src/cli/index.ts +91 -7
- package/src/cli/service-credential.ts +54 -0
- package/src/config/paths.test.ts +61 -48
- 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 +74 -0
- package/src/hooks/capability-loader-firewall.test.ts +3 -3
- package/src/hooks/capability-loader.ts +43 -2
- package/src/hooks/run-named-hook.ts +28 -2
- package/src/hooks/types.ts +2 -1
- package/src/infrastructure/property-extractor.test.ts +15 -0
- package/src/infrastructure/property-extractor.ts +12 -0
- package/src/manifest/contracts/v1.ts +16 -0
- package/src/manifest/schema.ts +17 -0
- package/src/manifest/validate.test.ts +53 -0
- package/src/services/bus-interview.test.ts +2 -2
- package/src/services/bus-interview.ts +232 -0
- package/src/services/bus-secret-flow.test.ts +2 -2
- package/src/services/celilo-mgmt-hooks.test.ts +3 -2
- package/src/services/deploy-preflight.ts +25 -0
- package/src/services/deploy-validation.test.ts +54 -4
- package/src/services/deploy-validation.ts +27 -36
- package/src/services/dns-internal-records.test.ts +126 -0
- package/src/services/dns-internal-records.ts +119 -0
- package/src/services/dns-provider-backfill.test.ts +2 -2
- package/src/services/dns-provider-backfill.ts +14 -2
- package/src/services/dns-registrations.test.ts +120 -0
- package/src/services/dns-registrations.ts +108 -0
- package/src/services/events-daemon.test.ts +59 -0
- package/src/services/events-daemon.ts +191 -57
- package/src/services/fleet-checks.test.ts +508 -0
- package/src/services/fleet-checks.ts +678 -0
- package/src/services/module-build.test.ts +43 -38
- package/src/services/module-config.ts +12 -0
- package/src/services/module-deploy.ts +7 -6
- 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/services/module-validator/capability-versions.test.ts +1 -1
- package/src/services/placement-reconcile.test.ts +86 -0
- package/src/services/placement-reconcile.ts +108 -0
- package/src/services/programmatic-responder.ts +34 -0
- package/src/services/terminal-responder.ts +113 -0
- package/src/templates/generator.test.ts +92 -12
- package/src/templates/generator.ts +165 -80
- package/src/test-utils/fixtures.test.ts +1 -1
- package/src/test-utils/integration-guard.ts +33 -0
- package/src/types/infrastructure.ts +6 -0
- package/src/variables/computed/computed-integration.test.ts +3 -3
- package/src/variables/computed/computed.test.ts +5 -5
- package/src/variables/declarative-derivation.test.ts +6 -6
|
@@ -5,6 +5,7 @@ import { eq } from 'drizzle-orm';
|
|
|
5
5
|
import { log } from '../cli/prompts';
|
|
6
6
|
import { type DbClient, createDbClient } from '../db/client';
|
|
7
7
|
import { moduleBuilds, modules } from '../db/schema';
|
|
8
|
+
import { skipIntegration } from '../test-utils/integration-guard';
|
|
8
9
|
import { buildModuleFromSource, getModuleBuildStatus, verifyArtifactsExist } from './module-build';
|
|
9
10
|
|
|
10
11
|
const TEST_DB_PATH = './test-module-build.db';
|
|
@@ -156,12 +157,14 @@ describe('Module Build Service', () => {
|
|
|
156
157
|
// We're just testing that detection happens (message logged)
|
|
157
158
|
}, 10000); // 10 second timeout
|
|
158
159
|
|
|
159
|
-
test(
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
`${TEST_MODULE_DIR}/build
|
|
164
|
-
|
|
160
|
+
test.skipIf(skipIntegration({ tools: ['ansible'] }))(
|
|
161
|
+
'should record build metadata in database',
|
|
162
|
+
async () => {
|
|
163
|
+
await mkdir(TEST_MODULE_DIR, { recursive: true });
|
|
164
|
+
await mkdir(`${TEST_MODULE_DIR}/build`, { recursive: true });
|
|
165
|
+
await writeFile(
|
|
166
|
+
`${TEST_MODULE_DIR}/build/playbook.yml`,
|
|
167
|
+
`---
|
|
165
168
|
- name: Quick test build
|
|
166
169
|
hosts: localhost
|
|
167
170
|
gather_facts: false
|
|
@@ -170,43 +173,45 @@ describe('Module Build Service', () => {
|
|
|
170
173
|
ansible.builtin.debug:
|
|
171
174
|
msg: "Build test"
|
|
172
175
|
`,
|
|
173
|
-
|
|
176
|
+
);
|
|
174
177
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
id: 'record-test',
|
|
178
|
-
name: 'Record Test',
|
|
179
|
-
version: '1.0.0',
|
|
180
|
-
sourcePath: TEST_MODULE_DIR,
|
|
181
|
-
manifestData: {
|
|
178
|
+
db.insert(modules)
|
|
179
|
+
.values({
|
|
182
180
|
id: 'record-test',
|
|
183
181
|
name: 'Record Test',
|
|
184
182
|
version: '1.0.0',
|
|
185
|
-
|
|
186
|
-
|
|
183
|
+
sourcePath: TEST_MODULE_DIR,
|
|
184
|
+
manifestData: {
|
|
185
|
+
id: 'record-test',
|
|
186
|
+
name: 'Record Test',
|
|
187
|
+
version: '1.0.0',
|
|
188
|
+
build: {
|
|
189
|
+
script: 'build/playbook.yml',
|
|
190
|
+
},
|
|
187
191
|
},
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
192
|
+
})
|
|
193
|
+
.run();
|
|
194
|
+
|
|
195
|
+
const result = await buildModuleFromSource('record-test', db);
|
|
196
|
+
|
|
197
|
+
// Build should succeed (simple debug task)
|
|
198
|
+
expect(result.success).toBe(true);
|
|
199
|
+
|
|
200
|
+
// Verify build metadata was recorded
|
|
201
|
+
const buildRecord = await db
|
|
202
|
+
.select()
|
|
203
|
+
.from(moduleBuilds)
|
|
204
|
+
.where(eq(moduleBuilds.moduleId, 'record-test'))
|
|
205
|
+
.get();
|
|
206
|
+
|
|
207
|
+
expect(buildRecord).toBeDefined();
|
|
208
|
+
expect(buildRecord?.moduleId).toBe('record-test');
|
|
209
|
+
expect(buildRecord?.version).toBe('1.0.0');
|
|
210
|
+
expect(buildRecord?.status).toBe('success');
|
|
211
|
+
expect(buildRecord?.environment).toBe('system'); // No flake.nix
|
|
212
|
+
},
|
|
213
|
+
10000,
|
|
214
|
+
); // 10 second timeout for ansible execution
|
|
210
215
|
|
|
211
216
|
// Regression: a build.command that references $CELILO_MODULE_SOURCE_DIR
|
|
212
217
|
// (the variable celilo-registry's manifest uses to find sibling packages
|
|
@@ -106,6 +106,18 @@ export function upsertModuleConfig(
|
|
|
106
106
|
.run();
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Delete a module config row if present (no-op if absent). Used to drop a
|
|
111
|
+
* derived/cached key that should no longer persist — e.g. `__infra_target_node`,
|
|
112
|
+
* now resolved live from Proxmox each generate rather than cached in the DB
|
|
113
|
+
* (ISS-0090: "the DB stores intent, Proxmox reports reality").
|
|
114
|
+
*/
|
|
115
|
+
export function deleteModuleConfig(db: DbClient, moduleId: string, key: string): void {
|
|
116
|
+
db.delete(moduleConfigs)
|
|
117
|
+
.where(and(eq(moduleConfigs.moduleId, moduleId), eq(moduleConfigs.key, key)))
|
|
118
|
+
.run();
|
|
119
|
+
}
|
|
120
|
+
|
|
109
121
|
/**
|
|
110
122
|
* Parse a stored module_configs row into its canonical typed value.
|
|
111
123
|
* Throws if valueJson is null — that's a row written before the
|
|
@@ -48,7 +48,6 @@ export interface DeployResult {
|
|
|
48
48
|
phases: {
|
|
49
49
|
validation?: boolean;
|
|
50
50
|
autoGenerated?: boolean;
|
|
51
|
-
autoBuilt?: boolean;
|
|
52
51
|
planning?: boolean;
|
|
53
52
|
terraformInit?: boolean;
|
|
54
53
|
terraformPlan?: boolean;
|
|
@@ -353,7 +352,6 @@ async function deployModuleImpl(
|
|
|
353
352
|
const validation = await validateAndPrepareDeployment(moduleId, db);
|
|
354
353
|
phases.validation = validation.success;
|
|
355
354
|
phases.autoGenerated = validation.autoGenerated;
|
|
356
|
-
phases.autoBuilt = validation.autoBuilt;
|
|
357
355
|
if (!validation.success) {
|
|
358
356
|
return {
|
|
359
357
|
success: false,
|
|
@@ -532,9 +530,7 @@ async function deployModuleImpl(
|
|
|
532
530
|
}
|
|
533
531
|
}
|
|
534
532
|
|
|
535
|
-
log.success(
|
|
536
|
-
validation.autoBuilt ? 'Templates generated and module built' : 'Templates generated',
|
|
537
|
-
);
|
|
533
|
+
log.success('Templates generated');
|
|
538
534
|
|
|
539
535
|
// Run validate_config hook if defined (e.g., credential validation via Playwright)
|
|
540
536
|
if (manifest.hooks?.validate_config) {
|
|
@@ -911,7 +907,12 @@ async function deployModuleImpl(
|
|
|
911
907
|
lines.push(` ${key} = ${value}`);
|
|
912
908
|
}
|
|
913
909
|
if (resolution.skipped.length > 0) {
|
|
914
|
-
|
|
910
|
+
// These are infrastructure-managed vars with no value this deploy (e.g.
|
|
911
|
+
// vmid/target_node on a machine deploy). NOT honored operator overrides —
|
|
912
|
+
// such keys are rejected at `config set` (ISS-0069); don't imply otherwise.
|
|
913
|
+
lines.push(
|
|
914
|
+
` (auto-managed, not applicable this deploy: ${resolution.skipped.join(', ')})`,
|
|
915
|
+
);
|
|
915
916
|
}
|
|
916
917
|
log.success(lines.join('\n'));
|
|
917
918
|
|
|
@@ -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
|
+
}
|
|
@@ -31,7 +31,7 @@ describe('validateCapabilityVersions', () => {
|
|
|
31
31
|
expect(errors).toHaveLength(1);
|
|
32
32
|
expect(errors[0]).toContain('provides[public_web]');
|
|
33
33
|
expect(errors[0]).toContain('1.0.0');
|
|
34
|
-
expect(errors[0]).toContain('3.
|
|
34
|
+
expect(errors[0]).toContain('3.1.0');
|
|
35
35
|
});
|
|
36
36
|
|
|
37
37
|
test('error when provides[X].version is newer than runtime', () => {
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { DeployedSystem } from '@celilo/capabilities';
|
|
3
|
+
import { formatPlacementLine, resolveOnePlacement } from './placement-reconcile';
|
|
4
|
+
|
|
5
|
+
function sys(
|
|
6
|
+
overrides: Partial<DeployedSystem> & { infrastructure: DeployedSystem['infrastructure'] },
|
|
7
|
+
): DeployedSystem {
|
|
8
|
+
return {
|
|
9
|
+
name: 'main',
|
|
10
|
+
hostname: 'caddy',
|
|
11
|
+
ipv4_address: '10.0.10.10',
|
|
12
|
+
zone: 'dmz',
|
|
13
|
+
...overrides,
|
|
14
|
+
} as DeployedSystem;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const SVC = 'svc-1';
|
|
18
|
+
const cs = (vmid?: number) =>
|
|
19
|
+
sys({
|
|
20
|
+
infrastructure: {
|
|
21
|
+
type: 'container_service',
|
|
22
|
+
serviceId: SVC,
|
|
23
|
+
...(vmid != null ? { vmid } : {}),
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('resolveOnePlacement (ISS-0060 — node reconciled from Proxmox, not a cached DB value)', () => {
|
|
28
|
+
test('machine-pool system → machine (no Proxmox node)', () => {
|
|
29
|
+
const s = sys({ infrastructure: { type: 'machine', machineId: 'm1' } });
|
|
30
|
+
expect(resolveOnePlacement(s, new Map(), new Set(), new Set())).toEqual({ kind: 'machine' });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('non-Proxmox container service → other', () => {
|
|
34
|
+
expect(resolveOnePlacement(cs(200), new Map(), new Set(), new Set([SVC]))).toEqual({
|
|
35
|
+
kind: 'other',
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('container_service without a vmid → uncreated', () => {
|
|
40
|
+
expect(resolveOnePlacement(cs(undefined), new Map(), new Set(), new Set())).toEqual({
|
|
41
|
+
kind: 'uncreated',
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('Proxmox unreachable for the service → unreachable (never a stale fallback)', () => {
|
|
46
|
+
expect(resolveOnePlacement(cs(200), new Map(), new Set([SVC]), new Set())).toEqual({
|
|
47
|
+
kind: 'unreachable',
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('vmid present in the cluster → node (the reconciled reality)', () => {
|
|
52
|
+
expect(resolveOnePlacement(cs(200), new Map([[200, 'node2']]), new Set(), new Set())).toEqual({
|
|
53
|
+
kind: 'node',
|
|
54
|
+
node: 'node2',
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('vmid absent from the cluster → absent', () => {
|
|
59
|
+
expect(resolveOnePlacement(cs(200), new Map([[999, 'node2']]), new Set(), new Set())).toEqual({
|
|
60
|
+
kind: 'absent',
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('formatPlacementLine', () => {
|
|
66
|
+
const s = cs(200);
|
|
67
|
+
|
|
68
|
+
test('node resolution shows the real node + vmid', () => {
|
|
69
|
+
expect(formatPlacementLine(s, { kind: 'node', node: 'node2' })).toBe(
|
|
70
|
+
'caddy (vmid 200) → node2 (zone dmz)',
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('unreachable is explicit — no silent stale value', () => {
|
|
75
|
+
expect(formatPlacementLine(s, { kind: 'unreachable' })).toContain('Proxmox unreachable');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('absent flags a vmid not in the cluster', () => {
|
|
79
|
+
expect(formatPlacementLine(s, { kind: 'absent' })).toContain('not found in Proxmox');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('machine line omits vmid/node', () => {
|
|
83
|
+
const m = sys({ hostname: 'iot', zone: 'internal', infrastructure: { type: 'machine' } });
|
|
84
|
+
expect(formatPlacementLine(m, { kind: 'machine' })).toBe('iot — machine (zone internal)');
|
|
85
|
+
});
|
|
86
|
+
});
|