@openpalm/lib 0.11.0-beta.11 → 0.11.0-beta.14
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-user-env.test.ts +113 -0
- package/src/control-plane/akm-user-env.ts +144 -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 +90 -31
- package/src/control-plane/compose-args.ts +119 -9
- package/src/control-plane/config-persistence.ts +87 -133
- package/src/control-plane/core-assets.test.ts +9 -9
- package/src/control-plane/core-assets.ts +24 -8
- package/src/control-plane/docker.ts +15 -14
- package/src/control-plane/env.test.ts +10 -10
- package/src/control-plane/env.ts +1 -1
- package/src/control-plane/extends-support.test.ts +8 -8
- package/src/control-plane/home.ts +34 -46
- 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 +94 -102
- package/src/control-plane/install-lock.ts +7 -7
- package/src/control-plane/lifecycle.ts +36 -34
- package/src/control-plane/markdown-task.ts +30 -50
- package/src/control-plane/paths.ts +62 -42
- 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 +97 -88
- package/src/control-plane/registry.ts +142 -109
- package/src/control-plane/rollback.ts +8 -38
- package/src/control-plane/scheduler.ts +7 -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 +60 -0
- package/src/control-plane/secrets-files.ts +66 -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 +42 -40
- package/src/control-plane/setup.ts +36 -31
- package/src/control-plane/skeleton-guardrail.test.ts +64 -55
- package/src/control-plane/spec-to-env.test.ts +22 -17
- package/src/control-plane/spec-to-env.ts +7 -2
- package/src/control-plane/stack-spec.test.ts +10 -0
- package/src/control-plane/stack-spec.ts +28 -1
- package/src/control-plane/types.ts +2 -4
- package/src/control-plane/ui-assets.ts +60 -58
- package/src/control-plane/validate.ts +13 -15
- package/src/index.ts +47 -15
- package/src/control-plane/akm-vault.test.ts +0 -105
- package/src/control-plane/akm-vault.ts +0 -311
- 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
|
@@ -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,76 @@ 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
263
|
expect(getRegistryAddonConfig(process.env.OP_HOME!, 'chat')).toEqual({
|
|
262
|
-
schemaPath: '
|
|
263
|
-
userEnvPath: '
|
|
264
|
-
envSchema: '
|
|
264
|
+
schemaPath: '',
|
|
265
|
+
userEnvPath: 'knowledge/env/stack.env',
|
|
266
|
+
envSchema: '',
|
|
265
267
|
});
|
|
266
268
|
});
|
|
267
269
|
|
|
268
270
|
it("verifies the materialized registry and returns counts", () => {
|
|
269
271
|
const sourceRoot = join(tmpDir, 'repo');
|
|
270
|
-
const addonDir = join(sourceRoot, '.openpalm', '
|
|
271
|
-
const automationsDir = join(sourceRoot, '.openpalm', '
|
|
272
|
+
const addonDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'addons', 'chat');
|
|
273
|
+
const automationsDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'automations');
|
|
272
274
|
|
|
273
275
|
mkdirSync(addonDir, { recursive: true });
|
|
274
276
|
mkdirSync(automationsDir, { recursive: true });
|
|
275
277
|
writeFileSync(join(addonDir, 'compose.yml'), 'services: {}\n');
|
|
276
278
|
writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
|
|
277
|
-
writeFileSync(join(automationsDir, 'cleanup.
|
|
279
|
+
writeFileSync(join(automationsDir, 'cleanup.yml'), 'description: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n');
|
|
278
280
|
|
|
279
281
|
const root = materializeRegistryCatalog(sourceRoot);
|
|
280
282
|
|
|
@@ -285,65 +287,58 @@ describe("materialized registry catalog", () => {
|
|
|
285
287
|
});
|
|
286
288
|
});
|
|
287
289
|
|
|
288
|
-
it("returns
|
|
289
|
-
expect(listAvailableAddonIds()).toEqual([]);
|
|
290
|
+
it("returns static built-in addons without requiring a registry directory", () => {
|
|
291
|
+
expect(listAvailableAddonIds()).toEqual(['api', 'chat', 'discord', 'ollama', 'slack', 'ssh', 'voice']);
|
|
290
292
|
});
|
|
291
293
|
|
|
292
294
|
it("fails when source catalog is incomplete", () => {
|
|
293
295
|
const sourceRoot = join(tmpDir, 'repo');
|
|
294
|
-
mkdirSync(join(sourceRoot, '.openpalm', '
|
|
295
|
-
mkdirSync(join(sourceRoot, '.openpalm', '
|
|
296
|
+
mkdirSync(join(sourceRoot, '.openpalm', 'data', 'registry', 'addons'), { recursive: true });
|
|
297
|
+
mkdirSync(join(sourceRoot, '.openpalm', 'data', 'registry', 'automations'), { recursive: true });
|
|
296
298
|
|
|
297
299
|
expect(() => materializeRegistryCatalog(sourceRoot)).toThrow('Registry catalog is incomplete');
|
|
298
300
|
});
|
|
299
301
|
|
|
300
|
-
it("enables and disables addons through
|
|
302
|
+
it("enables and disables addons through explicit runtime state", () => {
|
|
301
303
|
const sourceRoot = join(tmpDir, 'repo');
|
|
302
|
-
const addonDir = join(sourceRoot, '.openpalm', '
|
|
303
|
-
const automationsDir = join(sourceRoot, '.openpalm', '
|
|
304
|
+
const addonDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'addons', 'chat');
|
|
305
|
+
const automationsDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'automations');
|
|
304
306
|
|
|
305
307
|
mkdirSync(addonDir, { recursive: true });
|
|
306
308
|
mkdirSync(automationsDir, { recursive: true });
|
|
307
309
|
writeFileSync(join(addonDir, 'compose.yml'), 'services: {}\n');
|
|
308
310
|
writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
|
|
309
|
-
writeFileSync(join(automationsDir, 'cleanup.
|
|
311
|
+
writeFileSync(join(automationsDir, 'cleanup.yml'), 'description: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n');
|
|
310
312
|
|
|
311
313
|
materializeRegistryCatalog(sourceRoot);
|
|
312
314
|
|
|
313
315
|
const stackDir = join(process.env.OP_HOME!, 'config', 'stack');
|
|
314
316
|
expect(setAddonEnabled(process.env.OP_HOME!, stackDir, 'chat', true)).toMatchObject({ ok: true });
|
|
315
|
-
expect(
|
|
317
|
+
expect(listEnabledAddonIds(process.env.OP_HOME!)).toEqual(['chat']);
|
|
318
|
+
expect(existsSync(join(process.env.OP_HOME!, 'config', 'stack', 'addons', 'chat', 'compose.yml'))).toBe(false);
|
|
316
319
|
|
|
317
320
|
expect(setAddonEnabled(process.env.OP_HOME!, stackDir, 'chat', false)).toMatchObject({ ok: true });
|
|
318
|
-
expect(
|
|
321
|
+
expect(listEnabledAddonIds(process.env.OP_HOME!)).toEqual([]);
|
|
319
322
|
});
|
|
320
323
|
|
|
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);
|
|
324
|
+
it("returns addon service names from fixed compose files", () => {
|
|
325
|
+
const stackDir = join(process.env.OP_HOME!, 'config', 'stack');
|
|
326
|
+
mkdirSync(stackDir, { recursive: true });
|
|
327
|
+
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
328
|
|
|
334
|
-
expect(getAddonServiceNames(process.env.OP_HOME!, 'proxy-test')).toEqual(['
|
|
329
|
+
expect(getAddonServiceNames(process.env.OP_HOME!, 'proxy-test')).toEqual(['proxy-test', 'proxy-test-worker']);
|
|
335
330
|
});
|
|
336
331
|
|
|
337
332
|
it("toggles addons and generates channel secrets when enabling channel addons", () => {
|
|
338
333
|
const sourceRoot = join(tmpDir, 'repo');
|
|
339
|
-
const addonDir = join(sourceRoot, '.openpalm', '
|
|
340
|
-
const automationsDir = join(sourceRoot, '.openpalm', '
|
|
334
|
+
const addonDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'addons', 'chat');
|
|
335
|
+
const automationsDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'automations');
|
|
341
336
|
|
|
342
337
|
mkdirSync(addonDir, { recursive: true });
|
|
343
338
|
mkdirSync(automationsDir, { recursive: true });
|
|
344
339
|
writeFileSync(join(addonDir, 'compose.yml'), 'services:\n chat:\n image: test\n environment:\n CHANNEL_NAME: "Chat"\n CHANNEL_ID: "chat"\n');
|
|
345
340
|
writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
|
|
346
|
-
writeFileSync(join(automationsDir, 'cleanup.
|
|
341
|
+
writeFileSync(join(automationsDir, 'cleanup.yml'), 'description: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n');
|
|
347
342
|
|
|
348
343
|
materializeRegistryCatalog(sourceRoot);
|
|
349
344
|
|
|
@@ -353,8 +348,8 @@ describe("materialized registry catalog", () => {
|
|
|
353
348
|
changed: true,
|
|
354
349
|
services: ['chat'],
|
|
355
350
|
});
|
|
356
|
-
expect(
|
|
357
|
-
expect(
|
|
351
|
+
expect(listEnabledAddonIds(process.env.OP_HOME!)).toEqual(['chat']);
|
|
352
|
+
expect(readSecret(join(process.env.OP_HOME!, 'config', 'stack'), 'channel_chat_secret')).toBeTruthy();
|
|
358
353
|
|
|
359
354
|
expect(setAddonEnabled(process.env.OP_HOME!, join(process.env.OP_HOME!, 'config', 'stack'), 'chat', false)).toEqual({
|
|
360
355
|
ok: true,
|
|
@@ -362,20 +357,21 @@ describe("materialized registry catalog", () => {
|
|
|
362
357
|
changed: true,
|
|
363
358
|
services: ['chat'],
|
|
364
359
|
});
|
|
365
|
-
expect(
|
|
360
|
+
expect(listEnabledAddonIds(process.env.OP_HOME!)).toEqual([]);
|
|
366
361
|
});
|
|
367
362
|
|
|
368
363
|
it("backs up OP_HOME without recursively copying backups", () => {
|
|
369
364
|
mkdirSync(join(process.env.OP_HOME!, 'config'), { recursive: true });
|
|
370
|
-
mkdirSync(join(process.env.OP_HOME!, 'backups', 'old-backup'), { recursive: true });
|
|
365
|
+
mkdirSync(join(process.env.OP_HOME!, 'data', 'backups', 'old-backup'), { recursive: true });
|
|
371
366
|
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');
|
|
367
|
+
writeFileSync(join(process.env.OP_HOME!, 'data', 'backups', 'old-backup', 'marker.txt'), 'old\n');
|
|
373
368
|
|
|
374
369
|
const backupDir = backupOpenPalmHome(process.env.OP_HOME!);
|
|
375
370
|
|
|
376
371
|
expect(backupDir).not.toBeNull();
|
|
377
372
|
expect(existsSync(join(backupDir!, 'config', 'stack.yml'))).toBe(true);
|
|
378
|
-
expect(existsSync(join(backupDir!, '
|
|
373
|
+
expect(existsSync(join(backupDir!, 'cache'))).toBe(false);
|
|
374
|
+
expect(existsSync(join(backupDir!, 'data', 'backups'))).toBe(false);
|
|
379
375
|
});
|
|
380
376
|
|
|
381
377
|
it("writes backups under the provided homeDir even when OP_HOME points elsewhere", () => {
|
|
@@ -383,7 +379,7 @@ describe("materialized registry catalog", () => {
|
|
|
383
379
|
const otherHome = join(tmpDir, 'other-home');
|
|
384
380
|
|
|
385
381
|
mkdirSync(join(actualHome, 'config'), { recursive: true });
|
|
386
|
-
mkdirSync(join(otherHome, 'backups'), { recursive: true });
|
|
382
|
+
mkdirSync(join(otherHome, 'data', 'backups'), { recursive: true });
|
|
387
383
|
writeFileSync(join(actualHome, 'config', 'stack.yml'), 'llm: local\n');
|
|
388
384
|
|
|
389
385
|
process.env.OP_HOME = otherHome;
|
|
@@ -391,15 +387,15 @@ describe("materialized registry catalog", () => {
|
|
|
391
387
|
const backupDir = backupOpenPalmHome(actualHome);
|
|
392
388
|
|
|
393
389
|
expect(backupDir).not.toBeNull();
|
|
394
|
-
expect(backupDir!.startsWith(join(actualHome, 'backups'))).toBe(true);
|
|
390
|
+
expect(backupDir!.startsWith(join(actualHome, 'data', 'backups'))).toBe(true);
|
|
395
391
|
expect(existsSync(join(backupDir!, 'config', 'stack.yml'))).toBe(true);
|
|
396
|
-
expect(existsSync(join(otherHome, 'backups', 'config', 'stack.yml'))).toBe(false);
|
|
392
|
+
expect(existsSync(join(otherHome, 'data', 'backups', 'config', 'stack.yml'))).toBe(false);
|
|
397
393
|
});
|
|
398
394
|
|
|
399
395
|
it("parses compose profiles + openpalm.profile.* labels per addon", () => {
|
|
400
396
|
const sourceRoot = join(tmpDir, 'repo');
|
|
401
|
-
const addonDir = join(sourceRoot, '.openpalm', '
|
|
402
|
-
const automationsDir = join(sourceRoot, '.openpalm', '
|
|
397
|
+
const addonDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'addons', 'voice');
|
|
398
|
+
const automationsDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'automations');
|
|
403
399
|
|
|
404
400
|
mkdirSync(addonDir, { recursive: true });
|
|
405
401
|
mkdirSync(automationsDir, { recursive: true });
|
|
@@ -408,13 +404,13 @@ describe("materialized registry catalog", () => {
|
|
|
408
404
|
[
|
|
409
405
|
'services:',
|
|
410
406
|
' voice:',
|
|
411
|
-
' profiles: [cpu]',
|
|
407
|
+
' profiles: ["addon.voice.cpu"]',
|
|
412
408
|
' image: openpalm/voice:cpu',
|
|
413
409
|
' labels:',
|
|
414
410
|
' openpalm.profile.label: CPU',
|
|
415
411
|
' openpalm.profile.default: "true"',
|
|
416
412
|
' voice-cuda:',
|
|
417
|
-
' profiles: [cuda]',
|
|
413
|
+
' profiles: ["addon.voice.cuda"]',
|
|
418
414
|
' image: openpalm/voice:cuda',
|
|
419
415
|
' labels:',
|
|
420
416
|
' openpalm.profile.label: NVIDIA',
|
|
@@ -423,44 +419,57 @@ describe("materialized registry catalog", () => {
|
|
|
423
419
|
].join('\n'),
|
|
424
420
|
);
|
|
425
421
|
writeFileSync(join(addonDir, '.env.schema'), 'VOICE=\n');
|
|
426
|
-
writeFileSync(join(automationsDir, 'cleanup.
|
|
422
|
+
writeFileSync(join(automationsDir, 'cleanup.yml'), 'description: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n');
|
|
427
423
|
|
|
428
424
|
materializeRegistryCatalog(sourceRoot);
|
|
429
425
|
|
|
430
426
|
const profiles = getAddonProfiles(process.env.OP_HOME!, 'voice');
|
|
431
427
|
expect(profiles).toEqual([
|
|
432
|
-
{ id: 'cpu', services: ['voice'], label: 'CPU', default: true },
|
|
433
|
-
{ id: 'cuda', services: ['voice-cuda'], label: 'NVIDIA', requires: 'nvidia-container-toolkit' },
|
|
428
|
+
{ id: 'addon.voice.cpu', services: ['voice'], label: 'CPU', default: true },
|
|
429
|
+
{ id: 'addon.voice.cuda', services: ['voice-cuda'], label: 'NVIDIA (CUDA 12.1)', requires: 'nvidia-container-toolkit' },
|
|
430
|
+
{ id: 'addon.voice.rocm', services: ['voice-rocm'], label: 'AMD (ROCm 6.x)', requires: 'amdgpu kernel module' },
|
|
434
431
|
]);
|
|
435
432
|
});
|
|
436
433
|
|
|
437
434
|
it("round-trips addon profile selection through stack.env", () => {
|
|
438
435
|
const sourceRoot = join(tmpDir, 'repo');
|
|
439
|
-
const addonDir = join(sourceRoot, '.openpalm', '
|
|
440
|
-
const automationsDir = join(sourceRoot, '.openpalm', '
|
|
436
|
+
const addonDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'addons', 'voice');
|
|
437
|
+
const automationsDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'automations');
|
|
441
438
|
|
|
442
439
|
mkdirSync(addonDir, { recursive: true });
|
|
443
440
|
mkdirSync(automationsDir, { recursive: true });
|
|
444
|
-
writeFileSync(join(addonDir, 'compose.yml'), 'services:\n voice:\n profiles: [cpu]\n image: x\n');
|
|
441
|
+
writeFileSync(join(addonDir, 'compose.yml'), 'services:\n voice:\n profiles: ["addon.voice.cpu"]\n image: x\n');
|
|
445
442
|
writeFileSync(join(addonDir, '.env.schema'), 'VOICE=\n');
|
|
446
|
-
writeFileSync(join(automationsDir, 'cleanup.
|
|
443
|
+
writeFileSync(join(automationsDir, 'cleanup.yml'), 'description: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n');
|
|
447
444
|
|
|
448
445
|
materializeRegistryCatalog(sourceRoot);
|
|
449
446
|
|
|
450
447
|
const stackDir = join(process.env.OP_HOME!, 'config', 'stack');
|
|
448
|
+
const stackEnv = join(process.env.OP_HOME!, 'knowledge', 'env', 'stack.env');
|
|
449
|
+
mkdirSync(stackDir, { recursive: true });
|
|
450
|
+
mkdirSync(join(process.env.OP_HOME!, 'knowledge', 'env'), { recursive: true });
|
|
451
|
+
writeFileSync(stackEnv, '');
|
|
452
|
+
|
|
453
|
+
expect(getAddonProfileSelection(stackDir, 'voice')).toBeNull();
|
|
454
|
+
setAddonProfileSelection(stackDir, 'voice', 'addon.voice.cuda');
|
|
455
|
+
expect(getAddonProfileSelection(stackDir, 'voice')).toBe('addon.voice.cuda');
|
|
456
|
+
expect(readFileSync(stackEnv, 'utf-8')).toContain('OP_VOICE_PROFILE=addon.voice.cuda');
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it("ignores non-canonical addon profile values when reading stack.env", () => {
|
|
460
|
+
const stackDir = join(process.env.OP_HOME!, 'config', 'stack');
|
|
461
|
+
const stackEnv = join(process.env.OP_HOME!, 'knowledge', 'env', 'stack.env');
|
|
451
462
|
mkdirSync(stackDir, { recursive: true });
|
|
452
|
-
|
|
463
|
+
mkdirSync(join(process.env.OP_HOME!, 'knowledge', 'env'), { recursive: true });
|
|
464
|
+
writeFileSync(stackEnv, 'OP_VOICE_PROFILE=not-canonical\n');
|
|
453
465
|
|
|
454
466
|
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
467
|
});
|
|
459
468
|
|
|
460
|
-
it("installs and uninstalls automations through
|
|
469
|
+
it("installs and uninstalls automations through knowledge/tasks", () => {
|
|
461
470
|
const sourceRoot = join(tmpDir, 'repo');
|
|
462
|
-
const addonDir = join(sourceRoot, '.openpalm', '
|
|
463
|
-
const automationsDir = join(sourceRoot, '.openpalm', '
|
|
471
|
+
const addonDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'addons', 'chat');
|
|
472
|
+
const automationsDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'automations');
|
|
464
473
|
const configDir = join(process.env.OP_HOME!, 'config');
|
|
465
474
|
|
|
466
475
|
mkdirSync(addonDir, { recursive: true });
|
|
@@ -468,16 +477,16 @@ describe("materialized registry catalog", () => {
|
|
|
468
477
|
mkdirSync(configDir, { recursive: true });
|
|
469
478
|
writeFileSync(join(addonDir, 'compose.yml'), 'services: {}\n');
|
|
470
479
|
writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
|
|
471
|
-
writeFileSync(join(automationsDir, 'cleanup.
|
|
480
|
+
writeFileSync(join(automationsDir, 'cleanup.yml'), 'description: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n');
|
|
472
481
|
|
|
473
482
|
materializeRegistryCatalog(sourceRoot);
|
|
474
483
|
|
|
475
|
-
const stashDir = join(process.env.OP_HOME!, '
|
|
484
|
+
const stashDir = join(process.env.OP_HOME!, 'knowledge');
|
|
476
485
|
expect(installAutomationFromRegistry('cleanup', stashDir)).toEqual({ ok: true });
|
|
477
|
-
expect(readFileSync(join(stashDir, 'tasks', 'cleanup.
|
|
486
|
+
expect(readFileSync(join(stashDir, 'tasks', 'cleanup.yml'), 'utf-8')).toContain('Cleanup');
|
|
478
487
|
|
|
479
488
|
expect(uninstallAutomation('cleanup', stashDir)).toEqual({ ok: true });
|
|
480
|
-
expect(existsSync(join(stashDir, 'tasks', 'cleanup.
|
|
489
|
+
expect(existsSync(join(stashDir, 'tasks', 'cleanup.yml'))).toBe(false);
|
|
481
490
|
});
|
|
482
491
|
});
|
|
483
492
|
|
|
@@ -493,7 +502,7 @@ describe("getAddonProfileAvailability", () => {
|
|
|
493
502
|
});
|
|
494
503
|
|
|
495
504
|
it("returns available:true for the cpu profile (no host requirements)", async () => {
|
|
496
|
-
const result = await getAddonProfileAvailability({ id: 'cpu' });
|
|
505
|
+
const result = await getAddonProfileAvailability({ id: 'addon.voice.cpu' });
|
|
497
506
|
expect(result.available).toBe(true);
|
|
498
507
|
expect(result.reason).toBeUndefined();
|
|
499
508
|
});
|
|
@@ -504,8 +513,8 @@ describe("getAddonProfileAvailability", () => {
|
|
|
504
513
|
});
|
|
505
514
|
|
|
506
515
|
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' });
|
|
516
|
+
const a = await getAddonProfileAvailability({ id: 'addon.voice.cpu' });
|
|
517
|
+
const b = await getAddonProfileAvailability({ id: 'addon.voice.cpu' });
|
|
509
518
|
expect(a).toBe(b); // same reference — cached
|
|
510
519
|
});
|
|
511
520
|
|
|
@@ -514,7 +523,7 @@ describe("getAddonProfileAvailability", () => {
|
|
|
514
523
|
// we just assert the contract: when neither signal is present, the
|
|
515
524
|
// reason mentions nvidia-container-toolkit. If a future GPU host runs
|
|
516
525
|
// this test, the assertion still tolerates the success case.
|
|
517
|
-
const result = await getAddonProfileAvailability({ id: 'cuda' });
|
|
526
|
+
const result = await getAddonProfileAvailability({ id: 'addon.voice.cuda' });
|
|
518
527
|
if (!result.available) {
|
|
519
528
|
expect(result.reason).toContain('NVIDIA');
|
|
520
529
|
} else {
|
|
@@ -524,7 +533,7 @@ describe("getAddonProfileAvailability", () => {
|
|
|
524
533
|
});
|
|
525
534
|
|
|
526
535
|
it("probes rocm: returns available:false when /dev/kfd is missing", async () => {
|
|
527
|
-
const result = await getAddonProfileAvailability({ id: 'rocm' });
|
|
536
|
+
const result = await getAddonProfileAvailability({ id: 'addon.voice.rocm' });
|
|
528
537
|
if (!result.available) {
|
|
529
538
|
expect(result.reason).toContain('ROCm');
|
|
530
539
|
} else {
|
|
@@ -538,7 +547,7 @@ describe("getAddonProfileAvailability", () => {
|
|
|
538
547
|
// through to the manifest-inspect probe and (until 0.11.0-rocm6
|
|
539
548
|
// ships) get the "image not published yet" copy. Both must mention
|
|
540
549
|
// ROCm so operator-facing copy stays consistent.
|
|
541
|
-
const result = await getAddonProfileAvailability({ id: 'rocm' });
|
|
550
|
+
const result = await getAddonProfileAvailability({ id: 'addon.voice.rocm' });
|
|
542
551
|
if (!result.available && existsSync('/dev/kfd') && existsSync('/dev/dri')) {
|
|
543
552
|
expect(result.reason).toMatch(/image not published|CPU profile/i);
|
|
544
553
|
}
|
|
@@ -588,21 +597,21 @@ describe("annotateAddonProfileAvailability", () => {
|
|
|
588
597
|
|
|
589
598
|
it("decorates each profile with available + optional reason", async () => {
|
|
590
599
|
const out = await annotateAddonProfileAvailability([
|
|
591
|
-
{ id: 'cpu', services: ['voice'], label: 'CPU', default: true },
|
|
592
|
-
{ id: 'rocm', services: ['voice-rocm'], label: 'AMD' },
|
|
600
|
+
{ id: 'addon.voice.cpu', services: ['voice'], label: 'CPU', default: true },
|
|
601
|
+
{ id: 'addon.voice.rocm', services: ['voice-rocm'], label: 'AMD' },
|
|
593
602
|
]);
|
|
594
603
|
expect(out).toHaveLength(2);
|
|
595
|
-
expect(out[0]?.id).toBe('cpu');
|
|
604
|
+
expect(out[0]?.id).toBe('addon.voice.cpu');
|
|
596
605
|
expect(out[0]?.available).toBe(true);
|
|
597
606
|
// Preserves original fields.
|
|
598
607
|
expect(out[0]?.label).toBe('CPU');
|
|
599
608
|
expect(out[0]?.default).toBe(true);
|
|
600
|
-
expect(out[1]?.id).toBe('rocm');
|
|
609
|
+
expect(out[1]?.id).toBe('addon.voice.rocm');
|
|
601
610
|
expect(typeof out[1]?.available).toBe('boolean');
|
|
602
611
|
});
|
|
603
612
|
|
|
604
613
|
it("does not mutate the input array", async () => {
|
|
605
|
-
const input = [{ id: 'cpu', services: ['voice'] }];
|
|
614
|
+
const input = [{ id: 'addon.voice.cpu', services: ['voice'] }];
|
|
606
615
|
const before = JSON.parse(JSON.stringify(input));
|
|
607
616
|
await annotateAddonProfileAvailability(input);
|
|
608
617
|
expect(input).toEqual(before);
|