@openpalm/lib 0.11.0-beta.9 → 0.11.0-rc.18
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/README.md +1 -1
- package/package.json +1 -1
- package/src/control-plane/akm-sources.test.ts +206 -0
- package/src/control-plane/akm-sources.ts +234 -0
- package/src/control-plane/akm-user-env.test.ts +142 -0
- package/src/control-plane/akm-user-env.ts +167 -0
- package/src/control-plane/backup.ts +14 -5
- package/src/control-plane/channels.ts +48 -29
- package/src/control-plane/cleanup-guardrails.test.ts +1 -1
- package/src/control-plane/compose-args.test.ts +69 -30
- package/src/control-plane/compose-args.ts +62 -8
- package/src/control-plane/config-persistence.ts +102 -136
- package/src/control-plane/core-assets.ts +45 -60
- package/src/control-plane/defaults.ts +16 -0
- package/src/control-plane/docker.ts +15 -14
- package/src/control-plane/env.test.ts +10 -10
- package/src/control-plane/env.ts +16 -1
- package/src/control-plane/extends-support.test.ts +8 -8
- package/src/control-plane/fs-atomic.ts +15 -0
- package/src/control-plane/home.ts +34 -46
- package/src/control-plane/host-akm-sharing.test.ts +145 -0
- package/src/control-plane/host-akm-sharing.ts +129 -0
- package/src/control-plane/host-opencode.test.ts +82 -10
- package/src/control-plane/host-opencode.ts +42 -13
- package/src/control-plane/install-edge-cases.test.ts +100 -136
- package/src/control-plane/install-lock.ts +7 -7
- package/src/control-plane/lifecycle.ts +45 -40
- package/src/control-plane/markdown-task.ts +30 -50
- package/src/control-plane/migrations.test.ts +272 -0
- package/src/control-plane/migrations.ts +423 -0
- package/src/control-plane/opencode-client.ts +1 -1
- package/src/control-plane/paths.ts +61 -46
- package/src/control-plane/profile-ids.ts +21 -0
- package/src/control-plane/provider-models.ts +3 -3
- package/src/control-plane/registry.test.ts +107 -90
- package/src/control-plane/registry.ts +301 -110
- package/src/control-plane/rollback.ts +8 -38
- package/src/control-plane/scheduler.ts +10 -7
- package/src/control-plane/secret-audit.test.ts +159 -0
- package/src/control-plane/secret-audit.ts +255 -0
- package/src/control-plane/secret-mappings.ts +2 -2
- package/src/control-plane/secrets-files.test.ts +99 -0
- package/src/control-plane/secrets-files.ts +113 -0
- package/src/control-plane/secrets.ts +113 -86
- package/src/control-plane/setup-config.schema.json +1 -1
- package/src/control-plane/setup-status.ts +6 -11
- package/src/control-plane/setup.test.ts +137 -61
- package/src/control-plane/setup.ts +82 -63
- package/src/control-plane/skeleton-guardrail.test.ts +66 -56
- package/src/control-plane/spec-to-env.test.ts +63 -26
- package/src/control-plane/spec-to-env.ts +51 -14
- package/src/control-plane/task-files.test.ts +45 -0
- package/src/control-plane/task-files.ts +51 -0
- package/src/control-plane/types.ts +2 -4
- package/src/control-plane/ui-assets.test.ts +333 -0
- package/src/control-plane/ui-assets.ts +290 -142
- package/src/control-plane/validate.ts +13 -15
- package/src/index.ts +96 -26
- package/src/control-plane/akm-vault.test.ts +0 -105
- package/src/control-plane/akm-vault.ts +0 -311
- package/src/control-plane/core-assets.test.ts +0 -104
- package/src/control-plane/migrate-0110.test.ts +0 -177
- package/src/control-plane/migrate-0110.ts +0 -99
- package/src/control-plane/registry-components.test.ts +0 -391
- package/src/control-plane/stack-spec.test.ts +0 -94
- package/src/control-plane/stack-spec.ts +0 -67
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
getRegistryAutomation,
|
|
21
21
|
getRegistryAddonConfig,
|
|
22
22
|
listAvailableAddonIds,
|
|
23
|
+
listEnabledAddonIds,
|
|
23
24
|
getAddonServiceNames,
|
|
24
25
|
getAddonProfiles,
|
|
25
26
|
getAddonProfileSelection,
|
|
@@ -31,6 +32,7 @@ import {
|
|
|
31
32
|
annotateAddonProfileAvailability,
|
|
32
33
|
__addonAvailabilityTestHooks,
|
|
33
34
|
} from "./registry.js";
|
|
35
|
+
import { readSecret } from './secrets-files.js';
|
|
34
36
|
|
|
35
37
|
// ── Validation Tests ─────────────────────────────────────────────────
|
|
36
38
|
|
|
@@ -205,76 +207,84 @@ describe("materialized registry catalog", () => {
|
|
|
205
207
|
|
|
206
208
|
it("materializes addons and automations into OP_HOME/registry", () => {
|
|
207
209
|
const sourceRoot = join(tmpDir, 'repo');
|
|
208
|
-
const addonDir = join(sourceRoot, '.openpalm', '
|
|
209
|
-
const automationsDir = join(sourceRoot, '.openpalm', '
|
|
210
|
+
const addonDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'addons', 'chat');
|
|
211
|
+
const automationsDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'automations');
|
|
210
212
|
|
|
211
213
|
mkdirSync(addonDir, { recursive: true });
|
|
212
214
|
mkdirSync(automationsDir, { recursive: true });
|
|
213
215
|
writeFileSync(join(addonDir, 'compose.yml'), 'services: {}\n');
|
|
214
216
|
writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
|
|
215
|
-
writeFileSync(join(automationsDir, 'cleanup.
|
|
217
|
+
writeFileSync(join(automationsDir, 'cleanup.yml'), 'description: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n');
|
|
216
218
|
|
|
217
219
|
const root = materializeRegistryCatalog(sourceRoot);
|
|
218
220
|
|
|
219
|
-
expect(root).toBe(join(process.env.OP_HOME!, '
|
|
221
|
+
expect(root).toBe(join(process.env.OP_HOME!, 'data', 'registry'));
|
|
220
222
|
expect(existsSync(join(root, 'addons', 'chat', 'compose.yml'))).toBe(true);
|
|
221
223
|
expect(existsSync(join(root, 'addons', 'chat', '.env.schema'))).toBe(true);
|
|
222
|
-
expect(readFileSync(join(root, 'automations', 'cleanup.
|
|
224
|
+
expect(readFileSync(join(root, 'automations', 'cleanup.yml'), 'utf-8')).toContain('Cleanup');
|
|
223
225
|
});
|
|
224
226
|
|
|
225
227
|
it("discovers materialized registry entries", () => {
|
|
226
228
|
const sourceRoot = join(tmpDir, 'repo');
|
|
227
|
-
const addonDir = join(sourceRoot, '.openpalm', '
|
|
228
|
-
const automationsDir = join(sourceRoot, '.openpalm', '
|
|
229
|
+
const addonDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'addons', 'chat');
|
|
230
|
+
const automationsDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'automations');
|
|
229
231
|
|
|
230
232
|
mkdirSync(addonDir, { recursive: true });
|
|
231
233
|
mkdirSync(automationsDir, { recursive: true });
|
|
232
234
|
writeFileSync(join(addonDir, 'compose.yml'), 'services: {}\n');
|
|
233
235
|
writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
|
|
234
|
-
writeFileSync(join(automationsDir, 'cleanup.
|
|
236
|
+
writeFileSync(join(automationsDir, 'cleanup.yml'), 'description: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n');
|
|
235
237
|
|
|
236
238
|
materializeRegistryCatalog(sourceRoot);
|
|
237
239
|
|
|
238
240
|
const components = discoverRegistryComponents();
|
|
239
|
-
const stashDir = join(process.env.OP_HOME!, '
|
|
241
|
+
const stashDir = join(process.env.OP_HOME!, 'knowledge');
|
|
240
242
|
const automations = discoverRegistryAutomations(stashDir);
|
|
241
243
|
|
|
242
244
|
expect(Object.keys(components)).toEqual(['chat']);
|
|
243
245
|
expect(components.chat?.schema).toContain('CHANNEL_CHAT_SECRET');
|
|
244
|
-
expect(automations.map((entry) => entry.name)).
|
|
245
|
-
expect(getRegistryAutomation('
|
|
246
|
+
expect(automations.map((entry) => entry.name)).toContain('akm-improve');
|
|
247
|
+
expect(getRegistryAutomation('akm-improve')).toContain('akm improve');
|
|
246
248
|
});
|
|
247
249
|
|
|
248
250
|
it("returns addon config metadata from the materialized registry", () => {
|
|
249
251
|
const sourceRoot = join(tmpDir, 'repo');
|
|
250
|
-
const addonDir = join(sourceRoot, '.openpalm', '
|
|
251
|
-
const automationsDir = join(sourceRoot, '.openpalm', '
|
|
252
|
+
const addonDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'addons', 'chat');
|
|
253
|
+
const automationsDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'automations');
|
|
252
254
|
|
|
253
255
|
mkdirSync(addonDir, { recursive: true });
|
|
254
256
|
mkdirSync(automationsDir, { recursive: true });
|
|
255
257
|
writeFileSync(join(addonDir, 'compose.yml'), 'services: {}\n');
|
|
256
258
|
writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
|
|
257
|
-
writeFileSync(join(automationsDir, 'cleanup.
|
|
259
|
+
writeFileSync(join(automationsDir, 'cleanup.yml'), 'description: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n');
|
|
258
260
|
|
|
259
261
|
materializeRegistryCatalog(sourceRoot);
|
|
260
262
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
263
|
+
const cfg = getRegistryAddonConfig(process.env.OP_HOME!, 'chat');
|
|
264
|
+
expect(cfg.userEnvPath).toBe('knowledge/env/stack.env');
|
|
265
|
+
expect(cfg.envSchema).toBe('CHANNEL_CHAT_SECRET=\n');
|
|
266
|
+
expect(cfg.schemaPath.endsWith('/data/registry/addons/chat/.env.schema')).toBe(true);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("falls back to the built-in addon schema when no registry is materialized", () => {
|
|
270
|
+
// No materialized registry → built-in schema for first-party addons.
|
|
271
|
+
const discord = getRegistryAddonConfig(process.env.OP_HOME!, 'discord');
|
|
272
|
+
expect(discord.envSchema).toContain('DISCORD_BOT_TOKEN');
|
|
273
|
+
expect(discord.schemaPath).toBe('');
|
|
274
|
+
// ssh is compose/profile-only — no configurable env.
|
|
275
|
+
expect(getRegistryAddonConfig(process.env.OP_HOME!, 'ssh').envSchema).toBe('');
|
|
266
276
|
});
|
|
267
277
|
|
|
268
278
|
it("verifies the materialized registry and returns counts", () => {
|
|
269
279
|
const sourceRoot = join(tmpDir, 'repo');
|
|
270
|
-
const addonDir = join(sourceRoot, '.openpalm', '
|
|
271
|
-
const automationsDir = join(sourceRoot, '.openpalm', '
|
|
280
|
+
const addonDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'addons', 'chat');
|
|
281
|
+
const automationsDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'automations');
|
|
272
282
|
|
|
273
283
|
mkdirSync(addonDir, { recursive: true });
|
|
274
284
|
mkdirSync(automationsDir, { recursive: true });
|
|
275
285
|
writeFileSync(join(addonDir, 'compose.yml'), 'services: {}\n');
|
|
276
286
|
writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
|
|
277
|
-
writeFileSync(join(automationsDir, 'cleanup.
|
|
287
|
+
writeFileSync(join(automationsDir, 'cleanup.yml'), 'description: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n');
|
|
278
288
|
|
|
279
289
|
const root = materializeRegistryCatalog(sourceRoot);
|
|
280
290
|
|
|
@@ -285,65 +295,58 @@ describe("materialized registry catalog", () => {
|
|
|
285
295
|
});
|
|
286
296
|
});
|
|
287
297
|
|
|
288
|
-
it("returns
|
|
289
|
-
expect(listAvailableAddonIds()).toEqual([]);
|
|
298
|
+
it("returns static built-in addons without requiring a registry directory", () => {
|
|
299
|
+
expect(listAvailableAddonIds()).toEqual(['api', 'chat', 'discord', 'ollama', 'slack', 'ssh', 'voice']);
|
|
290
300
|
});
|
|
291
301
|
|
|
292
302
|
it("fails when source catalog is incomplete", () => {
|
|
293
303
|
const sourceRoot = join(tmpDir, 'repo');
|
|
294
|
-
mkdirSync(join(sourceRoot, '.openpalm', '
|
|
295
|
-
mkdirSync(join(sourceRoot, '.openpalm', '
|
|
304
|
+
mkdirSync(join(sourceRoot, '.openpalm', 'data', 'registry', 'addons'), { recursive: true });
|
|
305
|
+
mkdirSync(join(sourceRoot, '.openpalm', 'data', 'registry', 'automations'), { recursive: true });
|
|
296
306
|
|
|
297
307
|
expect(() => materializeRegistryCatalog(sourceRoot)).toThrow('Registry catalog is incomplete');
|
|
298
308
|
});
|
|
299
309
|
|
|
300
|
-
it("enables and disables addons through
|
|
310
|
+
it("enables and disables addons through explicit runtime state", () => {
|
|
301
311
|
const sourceRoot = join(tmpDir, 'repo');
|
|
302
|
-
const addonDir = join(sourceRoot, '.openpalm', '
|
|
303
|
-
const automationsDir = join(sourceRoot, '.openpalm', '
|
|
312
|
+
const addonDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'addons', 'chat');
|
|
313
|
+
const automationsDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'automations');
|
|
304
314
|
|
|
305
315
|
mkdirSync(addonDir, { recursive: true });
|
|
306
316
|
mkdirSync(automationsDir, { recursive: true });
|
|
307
317
|
writeFileSync(join(addonDir, 'compose.yml'), 'services: {}\n');
|
|
308
318
|
writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
|
|
309
|
-
writeFileSync(join(automationsDir, 'cleanup.
|
|
319
|
+
writeFileSync(join(automationsDir, 'cleanup.yml'), 'description: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n');
|
|
310
320
|
|
|
311
321
|
materializeRegistryCatalog(sourceRoot);
|
|
312
322
|
|
|
313
323
|
const stackDir = join(process.env.OP_HOME!, 'config', 'stack');
|
|
314
324
|
expect(setAddonEnabled(process.env.OP_HOME!, stackDir, 'chat', true)).toMatchObject({ ok: true });
|
|
315
|
-
expect(
|
|
325
|
+
expect(listEnabledAddonIds(process.env.OP_HOME!)).toEqual(['chat']);
|
|
326
|
+
expect(existsSync(join(process.env.OP_HOME!, 'config', 'stack', 'addons', 'chat', 'compose.yml'))).toBe(false);
|
|
316
327
|
|
|
317
328
|
expect(setAddonEnabled(process.env.OP_HOME!, stackDir, 'chat', false)).toMatchObject({ ok: true });
|
|
318
|
-
expect(
|
|
329
|
+
expect(listEnabledAddonIds(process.env.OP_HOME!)).toEqual([]);
|
|
319
330
|
});
|
|
320
331
|
|
|
321
|
-
it("returns addon service names from
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
mkdirSync(addonDir, { recursive: true });
|
|
327
|
-
mkdirSync(automationsDir, { recursive: true });
|
|
328
|
-
writeFileSync(join(addonDir, 'compose.yml'), 'services:\n svc-a:\n image: image-a\n svc-b:\n image: image-b\n');
|
|
329
|
-
writeFileSync(join(addonDir, '.env.schema'), 'PROXY_TOKEN=\n');
|
|
330
|
-
writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
|
|
331
|
-
|
|
332
|
-
materializeRegistryCatalog(sourceRoot);
|
|
332
|
+
it("returns addon service names from fixed compose files", () => {
|
|
333
|
+
const stackDir = join(process.env.OP_HOME!, 'config', 'stack');
|
|
334
|
+
mkdirSync(stackDir, { recursive: true });
|
|
335
|
+
writeFileSync(join(stackDir, 'custom.compose.yml'), 'services:\n proxy-test:\n profiles: ["addon.proxy-test"]\n image: image-a\n proxy-test-worker:\n profiles: ["addon.proxy-test"]\n image: image-b\n');
|
|
333
336
|
|
|
334
|
-
expect(getAddonServiceNames(process.env.OP_HOME!, 'proxy-test')).toEqual(['
|
|
337
|
+
expect(getAddonServiceNames(process.env.OP_HOME!, 'proxy-test')).toEqual(['proxy-test', 'proxy-test-worker']);
|
|
335
338
|
});
|
|
336
339
|
|
|
337
340
|
it("toggles addons and generates channel secrets when enabling channel addons", () => {
|
|
338
341
|
const sourceRoot = join(tmpDir, 'repo');
|
|
339
|
-
const addonDir = join(sourceRoot, '.openpalm', '
|
|
340
|
-
const automationsDir = join(sourceRoot, '.openpalm', '
|
|
342
|
+
const addonDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'addons', 'chat');
|
|
343
|
+
const automationsDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'automations');
|
|
341
344
|
|
|
342
345
|
mkdirSync(addonDir, { recursive: true });
|
|
343
346
|
mkdirSync(automationsDir, { recursive: true });
|
|
344
347
|
writeFileSync(join(addonDir, 'compose.yml'), 'services:\n chat:\n image: test\n environment:\n CHANNEL_NAME: "Chat"\n CHANNEL_ID: "chat"\n');
|
|
345
348
|
writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
|
|
346
|
-
writeFileSync(join(automationsDir, 'cleanup.
|
|
349
|
+
writeFileSync(join(automationsDir, 'cleanup.yml'), 'description: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n');
|
|
347
350
|
|
|
348
351
|
materializeRegistryCatalog(sourceRoot);
|
|
349
352
|
|
|
@@ -353,8 +356,8 @@ describe("materialized registry catalog", () => {
|
|
|
353
356
|
changed: true,
|
|
354
357
|
services: ['chat'],
|
|
355
358
|
});
|
|
356
|
-
expect(
|
|
357
|
-
expect(
|
|
359
|
+
expect(listEnabledAddonIds(process.env.OP_HOME!)).toEqual(['chat']);
|
|
360
|
+
expect(readSecret(join(process.env.OP_HOME!, 'config', 'stack'), 'channel_chat_secret')).toBeTruthy();
|
|
358
361
|
|
|
359
362
|
expect(setAddonEnabled(process.env.OP_HOME!, join(process.env.OP_HOME!, 'config', 'stack'), 'chat', false)).toEqual({
|
|
360
363
|
ok: true,
|
|
@@ -362,20 +365,21 @@ describe("materialized registry catalog", () => {
|
|
|
362
365
|
changed: true,
|
|
363
366
|
services: ['chat'],
|
|
364
367
|
});
|
|
365
|
-
expect(
|
|
368
|
+
expect(listEnabledAddonIds(process.env.OP_HOME!)).toEqual([]);
|
|
366
369
|
});
|
|
367
370
|
|
|
368
371
|
it("backs up OP_HOME without recursively copying backups", () => {
|
|
369
372
|
mkdirSync(join(process.env.OP_HOME!, 'config'), { recursive: true });
|
|
370
|
-
mkdirSync(join(process.env.OP_HOME!, 'backups', 'old-backup'), { recursive: true });
|
|
373
|
+
mkdirSync(join(process.env.OP_HOME!, 'data', 'backups', 'old-backup'), { recursive: true });
|
|
371
374
|
writeFileSync(join(process.env.OP_HOME!, 'config', 'stack.yml'), 'llm: test\n');
|
|
372
|
-
writeFileSync(join(process.env.OP_HOME!, 'backups', 'old-backup', 'marker.txt'), 'old\n');
|
|
375
|
+
writeFileSync(join(process.env.OP_HOME!, 'data', 'backups', 'old-backup', 'marker.txt'), 'old\n');
|
|
373
376
|
|
|
374
377
|
const backupDir = backupOpenPalmHome(process.env.OP_HOME!);
|
|
375
378
|
|
|
376
379
|
expect(backupDir).not.toBeNull();
|
|
377
380
|
expect(existsSync(join(backupDir!, 'config', 'stack.yml'))).toBe(true);
|
|
378
|
-
expect(existsSync(join(backupDir!, '
|
|
381
|
+
expect(existsSync(join(backupDir!, 'cache'))).toBe(false);
|
|
382
|
+
expect(existsSync(join(backupDir!, 'data', 'backups'))).toBe(false);
|
|
379
383
|
});
|
|
380
384
|
|
|
381
385
|
it("writes backups under the provided homeDir even when OP_HOME points elsewhere", () => {
|
|
@@ -383,7 +387,7 @@ describe("materialized registry catalog", () => {
|
|
|
383
387
|
const otherHome = join(tmpDir, 'other-home');
|
|
384
388
|
|
|
385
389
|
mkdirSync(join(actualHome, 'config'), { recursive: true });
|
|
386
|
-
mkdirSync(join(otherHome, 'backups'), { recursive: true });
|
|
390
|
+
mkdirSync(join(otherHome, 'data', 'backups'), { recursive: true });
|
|
387
391
|
writeFileSync(join(actualHome, 'config', 'stack.yml'), 'llm: local\n');
|
|
388
392
|
|
|
389
393
|
process.env.OP_HOME = otherHome;
|
|
@@ -391,15 +395,15 @@ describe("materialized registry catalog", () => {
|
|
|
391
395
|
const backupDir = backupOpenPalmHome(actualHome);
|
|
392
396
|
|
|
393
397
|
expect(backupDir).not.toBeNull();
|
|
394
|
-
expect(backupDir!.startsWith(join(actualHome, 'backups'))).toBe(true);
|
|
398
|
+
expect(backupDir!.startsWith(join(actualHome, 'data', 'backups'))).toBe(true);
|
|
395
399
|
expect(existsSync(join(backupDir!, 'config', 'stack.yml'))).toBe(true);
|
|
396
|
-
expect(existsSync(join(otherHome, 'backups', 'config', 'stack.yml'))).toBe(false);
|
|
400
|
+
expect(existsSync(join(otherHome, 'data', 'backups', 'config', 'stack.yml'))).toBe(false);
|
|
397
401
|
});
|
|
398
402
|
|
|
399
403
|
it("parses compose profiles + openpalm.profile.* labels per addon", () => {
|
|
400
404
|
const sourceRoot = join(tmpDir, 'repo');
|
|
401
|
-
const addonDir = join(sourceRoot, '.openpalm', '
|
|
402
|
-
const automationsDir = join(sourceRoot, '.openpalm', '
|
|
405
|
+
const addonDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'addons', 'voice');
|
|
406
|
+
const automationsDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'automations');
|
|
403
407
|
|
|
404
408
|
mkdirSync(addonDir, { recursive: true });
|
|
405
409
|
mkdirSync(automationsDir, { recursive: true });
|
|
@@ -408,13 +412,13 @@ describe("materialized registry catalog", () => {
|
|
|
408
412
|
[
|
|
409
413
|
'services:',
|
|
410
414
|
' voice:',
|
|
411
|
-
' profiles: [cpu]',
|
|
415
|
+
' profiles: ["addon.voice.cpu"]',
|
|
412
416
|
' image: openpalm/voice:cpu',
|
|
413
417
|
' labels:',
|
|
414
418
|
' openpalm.profile.label: CPU',
|
|
415
419
|
' openpalm.profile.default: "true"',
|
|
416
420
|
' voice-cuda:',
|
|
417
|
-
' profiles: [cuda]',
|
|
421
|
+
' profiles: ["addon.voice.cuda"]',
|
|
418
422
|
' image: openpalm/voice:cuda',
|
|
419
423
|
' labels:',
|
|
420
424
|
' openpalm.profile.label: NVIDIA',
|
|
@@ -423,44 +427,57 @@ describe("materialized registry catalog", () => {
|
|
|
423
427
|
].join('\n'),
|
|
424
428
|
);
|
|
425
429
|
writeFileSync(join(addonDir, '.env.schema'), 'VOICE=\n');
|
|
426
|
-
writeFileSync(join(automationsDir, 'cleanup.
|
|
430
|
+
writeFileSync(join(automationsDir, 'cleanup.yml'), 'description: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n');
|
|
427
431
|
|
|
428
432
|
materializeRegistryCatalog(sourceRoot);
|
|
429
433
|
|
|
430
434
|
const profiles = getAddonProfiles(process.env.OP_HOME!, 'voice');
|
|
431
435
|
expect(profiles).toEqual([
|
|
432
|
-
{ id: 'cpu', services: ['voice'], label: 'CPU', default: true },
|
|
433
|
-
{ id: 'cuda', services: ['voice-cuda'], label: 'NVIDIA', requires: 'nvidia-container-toolkit' },
|
|
436
|
+
{ id: 'addon.voice.cpu', services: ['voice'], label: 'CPU', default: true },
|
|
437
|
+
{ id: 'addon.voice.cuda', services: ['voice-cuda'], label: 'NVIDIA (CUDA 12.1)', requires: 'nvidia-container-toolkit' },
|
|
438
|
+
{ id: 'addon.voice.rocm', services: ['voice-rocm'], label: 'AMD (ROCm 6.x)', requires: 'amdgpu kernel module' },
|
|
434
439
|
]);
|
|
435
440
|
});
|
|
436
441
|
|
|
437
442
|
it("round-trips addon profile selection through stack.env", () => {
|
|
438
443
|
const sourceRoot = join(tmpDir, 'repo');
|
|
439
|
-
const addonDir = join(sourceRoot, '.openpalm', '
|
|
440
|
-
const automationsDir = join(sourceRoot, '.openpalm', '
|
|
444
|
+
const addonDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'addons', 'voice');
|
|
445
|
+
const automationsDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'automations');
|
|
441
446
|
|
|
442
447
|
mkdirSync(addonDir, { recursive: true });
|
|
443
448
|
mkdirSync(automationsDir, { recursive: true });
|
|
444
|
-
writeFileSync(join(addonDir, 'compose.yml'), 'services:\n voice:\n profiles: [cpu]\n image: x\n');
|
|
449
|
+
writeFileSync(join(addonDir, 'compose.yml'), 'services:\n voice:\n profiles: ["addon.voice.cpu"]\n image: x\n');
|
|
445
450
|
writeFileSync(join(addonDir, '.env.schema'), 'VOICE=\n');
|
|
446
|
-
writeFileSync(join(automationsDir, 'cleanup.
|
|
451
|
+
writeFileSync(join(automationsDir, 'cleanup.yml'), 'description: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n');
|
|
447
452
|
|
|
448
453
|
materializeRegistryCatalog(sourceRoot);
|
|
449
454
|
|
|
450
455
|
const stackDir = join(process.env.OP_HOME!, 'config', 'stack');
|
|
456
|
+
const stackEnv = join(process.env.OP_HOME!, 'knowledge', 'env', 'stack.env');
|
|
457
|
+
mkdirSync(stackDir, { recursive: true });
|
|
458
|
+
mkdirSync(join(process.env.OP_HOME!, 'knowledge', 'env'), { recursive: true });
|
|
459
|
+
writeFileSync(stackEnv, '');
|
|
460
|
+
|
|
461
|
+
expect(getAddonProfileSelection(stackDir, 'voice')).toBeNull();
|
|
462
|
+
setAddonProfileSelection(stackDir, 'voice', 'addon.voice.cuda');
|
|
463
|
+
expect(getAddonProfileSelection(stackDir, 'voice')).toBe('addon.voice.cuda');
|
|
464
|
+
expect(readFileSync(stackEnv, 'utf-8')).toContain('OP_VOICE_PROFILE=addon.voice.cuda');
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("ignores non-canonical addon profile values when reading stack.env", () => {
|
|
468
|
+
const stackDir = join(process.env.OP_HOME!, 'config', 'stack');
|
|
469
|
+
const stackEnv = join(process.env.OP_HOME!, 'knowledge', 'env', 'stack.env');
|
|
451
470
|
mkdirSync(stackDir, { recursive: true });
|
|
452
|
-
|
|
471
|
+
mkdirSync(join(process.env.OP_HOME!, 'knowledge', 'env'), { recursive: true });
|
|
472
|
+
writeFileSync(stackEnv, 'OP_VOICE_PROFILE=not-canonical\n');
|
|
453
473
|
|
|
454
474
|
expect(getAddonProfileSelection(stackDir, 'voice')).toBeNull();
|
|
455
|
-
setAddonProfileSelection(stackDir, 'voice', 'cuda');
|
|
456
|
-
expect(getAddonProfileSelection(stackDir, 'voice')).toBe('cuda');
|
|
457
|
-
expect(readFileSync(join(stackDir, 'stack.env'), 'utf-8')).toContain('OP_VOICE_PROFILE=cuda');
|
|
458
475
|
});
|
|
459
476
|
|
|
460
|
-
it("installs and uninstalls automations through
|
|
477
|
+
it("installs and uninstalls automations through knowledge/tasks", () => {
|
|
461
478
|
const sourceRoot = join(tmpDir, 'repo');
|
|
462
|
-
const addonDir = join(sourceRoot, '.openpalm', '
|
|
463
|
-
const automationsDir = join(sourceRoot, '.openpalm', '
|
|
479
|
+
const addonDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'addons', 'chat');
|
|
480
|
+
const automationsDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'automations');
|
|
464
481
|
const configDir = join(process.env.OP_HOME!, 'config');
|
|
465
482
|
|
|
466
483
|
mkdirSync(addonDir, { recursive: true });
|
|
@@ -468,16 +485,16 @@ describe("materialized registry catalog", () => {
|
|
|
468
485
|
mkdirSync(configDir, { recursive: true });
|
|
469
486
|
writeFileSync(join(addonDir, 'compose.yml'), 'services: {}\n');
|
|
470
487
|
writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
|
|
471
|
-
writeFileSync(join(automationsDir, 'cleanup.
|
|
488
|
+
writeFileSync(join(automationsDir, 'cleanup.yml'), 'description: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n');
|
|
472
489
|
|
|
473
490
|
materializeRegistryCatalog(sourceRoot);
|
|
474
491
|
|
|
475
|
-
const stashDir = join(process.env.OP_HOME!, '
|
|
492
|
+
const stashDir = join(process.env.OP_HOME!, 'knowledge');
|
|
476
493
|
expect(installAutomationFromRegistry('cleanup', stashDir)).toEqual({ ok: true });
|
|
477
|
-
expect(readFileSync(join(stashDir, 'tasks', 'cleanup.
|
|
494
|
+
expect(readFileSync(join(stashDir, 'tasks', 'cleanup.yml'), 'utf-8')).toContain('Cleanup');
|
|
478
495
|
|
|
479
496
|
expect(uninstallAutomation('cleanup', stashDir)).toEqual({ ok: true });
|
|
480
|
-
expect(existsSync(join(stashDir, 'tasks', 'cleanup.
|
|
497
|
+
expect(existsSync(join(stashDir, 'tasks', 'cleanup.yml'))).toBe(false);
|
|
481
498
|
});
|
|
482
499
|
});
|
|
483
500
|
|
|
@@ -493,7 +510,7 @@ describe("getAddonProfileAvailability", () => {
|
|
|
493
510
|
});
|
|
494
511
|
|
|
495
512
|
it("returns available:true for the cpu profile (no host requirements)", async () => {
|
|
496
|
-
const result = await getAddonProfileAvailability({ id: 'cpu' });
|
|
513
|
+
const result = await getAddonProfileAvailability({ id: 'addon.voice.cpu' });
|
|
497
514
|
expect(result.available).toBe(true);
|
|
498
515
|
expect(result.reason).toBeUndefined();
|
|
499
516
|
});
|
|
@@ -504,8 +521,8 @@ describe("getAddonProfileAvailability", () => {
|
|
|
504
521
|
});
|
|
505
522
|
|
|
506
523
|
it("caches the result across calls (probe runs only once)", async () => {
|
|
507
|
-
const a = await getAddonProfileAvailability({ id: 'cpu' });
|
|
508
|
-
const b = await getAddonProfileAvailability({ id: 'cpu' });
|
|
524
|
+
const a = await getAddonProfileAvailability({ id: 'addon.voice.cpu' });
|
|
525
|
+
const b = await getAddonProfileAvailability({ id: 'addon.voice.cpu' });
|
|
509
526
|
expect(a).toBe(b); // same reference — cached
|
|
510
527
|
});
|
|
511
528
|
|
|
@@ -514,7 +531,7 @@ describe("getAddonProfileAvailability", () => {
|
|
|
514
531
|
// we just assert the contract: when neither signal is present, the
|
|
515
532
|
// reason mentions nvidia-container-toolkit. If a future GPU host runs
|
|
516
533
|
// this test, the assertion still tolerates the success case.
|
|
517
|
-
const result = await getAddonProfileAvailability({ id: 'cuda' });
|
|
534
|
+
const result = await getAddonProfileAvailability({ id: 'addon.voice.cuda' });
|
|
518
535
|
if (!result.available) {
|
|
519
536
|
expect(result.reason).toContain('NVIDIA');
|
|
520
537
|
} else {
|
|
@@ -524,7 +541,7 @@ describe("getAddonProfileAvailability", () => {
|
|
|
524
541
|
});
|
|
525
542
|
|
|
526
543
|
it("probes rocm: returns available:false when /dev/kfd is missing", async () => {
|
|
527
|
-
const result = await getAddonProfileAvailability({ id: 'rocm' });
|
|
544
|
+
const result = await getAddonProfileAvailability({ id: 'addon.voice.rocm' });
|
|
528
545
|
if (!result.available) {
|
|
529
546
|
expect(result.reason).toContain('ROCm');
|
|
530
547
|
} else {
|
|
@@ -538,7 +555,7 @@ describe("getAddonProfileAvailability", () => {
|
|
|
538
555
|
// through to the manifest-inspect probe and (until 0.11.0-rocm6
|
|
539
556
|
// ships) get the "image not published yet" copy. Both must mention
|
|
540
557
|
// ROCm so operator-facing copy stays consistent.
|
|
541
|
-
const result = await getAddonProfileAvailability({ id: 'rocm' });
|
|
558
|
+
const result = await getAddonProfileAvailability({ id: 'addon.voice.rocm' });
|
|
542
559
|
if (!result.available && existsSync('/dev/kfd') && existsSync('/dev/dri')) {
|
|
543
560
|
expect(result.reason).toMatch(/image not published|CPU profile/i);
|
|
544
561
|
}
|
|
@@ -588,21 +605,21 @@ describe("annotateAddonProfileAvailability", () => {
|
|
|
588
605
|
|
|
589
606
|
it("decorates each profile with available + optional reason", async () => {
|
|
590
607
|
const out = await annotateAddonProfileAvailability([
|
|
591
|
-
{ id: 'cpu', services: ['voice'], label: 'CPU', default: true },
|
|
592
|
-
{ id: 'rocm', services: ['voice-rocm'], label: 'AMD' },
|
|
608
|
+
{ id: 'addon.voice.cpu', services: ['voice'], label: 'CPU', default: true },
|
|
609
|
+
{ id: 'addon.voice.rocm', services: ['voice-rocm'], label: 'AMD' },
|
|
593
610
|
]);
|
|
594
611
|
expect(out).toHaveLength(2);
|
|
595
|
-
expect(out[0]?.id).toBe('cpu');
|
|
612
|
+
expect(out[0]?.id).toBe('addon.voice.cpu');
|
|
596
613
|
expect(out[0]?.available).toBe(true);
|
|
597
614
|
// Preserves original fields.
|
|
598
615
|
expect(out[0]?.label).toBe('CPU');
|
|
599
616
|
expect(out[0]?.default).toBe(true);
|
|
600
|
-
expect(out[1]?.id).toBe('rocm');
|
|
617
|
+
expect(out[1]?.id).toBe('addon.voice.rocm');
|
|
601
618
|
expect(typeof out[1]?.available).toBe('boolean');
|
|
602
619
|
});
|
|
603
620
|
|
|
604
621
|
it("does not mutate the input array", async () => {
|
|
605
|
-
const input = [{ id: 'cpu', services: ['voice'] }];
|
|
622
|
+
const input = [{ id: 'addon.voice.cpu', services: ['voice'] }];
|
|
606
623
|
const before = JSON.parse(JSON.stringify(input));
|
|
607
624
|
await annotateAddonProfileAvailability(input);
|
|
608
625
|
expect(input).toEqual(before);
|