@openpalm/lib 0.10.2 → 0.11.0-beta.10
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 +4 -2
- package/package.json +11 -3
- package/src/control-plane/akm-vault.test.ts +105 -0
- package/src/control-plane/akm-vault.ts +311 -0
- package/src/control-plane/channels.ts +11 -9
- package/src/control-plane/cleanup-guardrails.test.ts +8 -9
- package/src/control-plane/compose-args.test.ts +25 -33
- package/src/control-plane/compose-args.ts +0 -4
- package/src/control-plane/compose-errors.test.ts +106 -0
- package/src/control-plane/compose-errors.ts +117 -0
- package/src/control-plane/config-persistence.ts +148 -73
- package/src/control-plane/core-assets.test.ts +104 -0
- package/src/control-plane/core-assets.ts +111 -58
- package/src/control-plane/docker.ts +70 -25
- package/src/control-plane/env.test.ts +25 -1
- package/src/control-plane/env.ts +84 -1
- package/src/control-plane/home.ts +66 -69
- package/src/control-plane/host-opencode.test.ts +260 -0
- package/src/control-plane/host-opencode.ts +229 -0
- package/src/control-plane/install-edge-cases.test.ts +190 -292
- package/src/control-plane/install-lock.ts +157 -0
- package/src/control-plane/lifecycle.ts +65 -75
- package/src/control-plane/markdown-task.ts +200 -0
- package/src/control-plane/migrate-0110.test.ts +177 -0
- package/src/control-plane/migrate-0110.ts +99 -0
- package/src/control-plane/operator-ids.test.ts +130 -0
- package/src/control-plane/operator-ids.ts +89 -0
- package/src/control-plane/paths.ts +80 -0
- package/src/control-plane/provider-models.ts +154 -0
- package/src/control-plane/registry-components.test.ts +105 -27
- package/src/control-plane/registry.test.ts +247 -51
- package/src/control-plane/registry.ts +404 -54
- package/src/control-plane/rollback.ts +17 -16
- package/src/control-plane/scheduler.ts +75 -262
- package/src/control-plane/secret-mappings.ts +4 -8
- package/src/control-plane/secrets.ts +97 -55
- package/src/control-plane/setup-config.schema.json +5 -17
- package/src/control-plane/setup-status.ts +9 -29
- package/src/control-plane/setup-validation.ts +23 -23
- package/src/control-plane/setup.test.ts +143 -244
- package/src/control-plane/setup.ts +216 -133
- package/src/control-plane/skeleton-guardrail.test.ts +151 -0
- package/src/control-plane/spec-to-env.test.ts +75 -60
- package/src/control-plane/spec-to-env.ts +68 -153
- package/src/control-plane/stack-spec.test.ts +22 -84
- package/src/control-plane/stack-spec.ts +9 -89
- package/src/control-plane/types.ts +9 -29
- package/src/control-plane/ui-assets.ts +385 -0
- package/src/control-plane/validate.ts +44 -79
- package/src/index.ts +102 -56
- package/src/logger.test.ts +228 -0
- package/src/logger.ts +71 -1
- package/src/provider-constants.ts +22 -1
- package/src/control-plane/audit.ts +0 -40
- package/src/control-plane/env-schema-validation.test.ts +0 -118
- package/src/control-plane/lock.test.ts +0 -194
- package/src/control-plane/lock.ts +0 -176
- package/src/control-plane/memory-config.ts +0 -298
- package/src/control-plane/provider-config.ts +0 -34
- package/src/control-plane/redact-schema.ts +0 -50
- package/src/control-plane/secret-backend.test.ts +0 -359
- package/src/control-plane/secret-backend.ts +0 -322
- package/src/control-plane/spec-validator.ts +0 -159
|
@@ -21,11 +21,15 @@ import {
|
|
|
21
21
|
getRegistryAddonConfig,
|
|
22
22
|
listAvailableAddonIds,
|
|
23
23
|
getAddonServiceNames,
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
getAddonProfiles,
|
|
25
|
+
getAddonProfileSelection,
|
|
26
|
+
setAddonProfileSelection,
|
|
26
27
|
setAddonEnabled,
|
|
27
28
|
installAutomationFromRegistry,
|
|
28
29
|
uninstallAutomation,
|
|
30
|
+
getAddonProfileAvailability,
|
|
31
|
+
annotateAddonProfileAvailability,
|
|
32
|
+
__addonAvailabilityTestHooks,
|
|
29
33
|
} from "./registry.js";
|
|
30
34
|
|
|
31
35
|
// ── Validation Tests ─────────────────────────────────────────────────
|
|
@@ -201,75 +205,76 @@ describe("materialized registry catalog", () => {
|
|
|
201
205
|
|
|
202
206
|
it("materializes addons and automations into OP_HOME/registry", () => {
|
|
203
207
|
const sourceRoot = join(tmpDir, 'repo');
|
|
204
|
-
const addonDir = join(sourceRoot, '.openpalm', 'registry', 'addons', 'chat');
|
|
205
|
-
const automationsDir = join(sourceRoot, '.openpalm', 'registry', 'automations');
|
|
208
|
+
const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
|
|
209
|
+
const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
|
|
206
210
|
|
|
207
211
|
mkdirSync(addonDir, { recursive: true });
|
|
208
212
|
mkdirSync(automationsDir, { recursive: true });
|
|
209
213
|
writeFileSync(join(addonDir, 'compose.yml'), 'services: {}\n');
|
|
210
214
|
writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
|
|
211
|
-
writeFileSync(join(automationsDir, 'cleanup.
|
|
215
|
+
writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
|
|
212
216
|
|
|
213
217
|
const root = materializeRegistryCatalog(sourceRoot);
|
|
214
218
|
|
|
215
|
-
expect(root).toBe(join(process.env.OP_HOME!, 'registry'));
|
|
219
|
+
expect(root).toBe(join(process.env.OP_HOME!, 'state', 'registry'));
|
|
216
220
|
expect(existsSync(join(root, 'addons', 'chat', 'compose.yml'))).toBe(true);
|
|
217
221
|
expect(existsSync(join(root, 'addons', 'chat', '.env.schema'))).toBe(true);
|
|
218
|
-
expect(readFileSync(join(root, 'automations', 'cleanup.
|
|
222
|
+
expect(readFileSync(join(root, 'automations', 'cleanup.md'), 'utf-8')).toContain('Cleanup');
|
|
219
223
|
});
|
|
220
224
|
|
|
221
225
|
it("discovers materialized registry entries", () => {
|
|
222
226
|
const sourceRoot = join(tmpDir, 'repo');
|
|
223
|
-
const addonDir = join(sourceRoot, '.openpalm', 'registry', 'addons', 'chat');
|
|
224
|
-
const automationsDir = join(sourceRoot, '.openpalm', 'registry', 'automations');
|
|
227
|
+
const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
|
|
228
|
+
const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
|
|
225
229
|
|
|
226
230
|
mkdirSync(addonDir, { recursive: true });
|
|
227
231
|
mkdirSync(automationsDir, { recursive: true });
|
|
228
232
|
writeFileSync(join(addonDir, 'compose.yml'), 'services: {}\n');
|
|
229
233
|
writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
|
|
230
|
-
writeFileSync(join(automationsDir, 'cleanup.
|
|
234
|
+
writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
|
|
231
235
|
|
|
232
236
|
materializeRegistryCatalog(sourceRoot);
|
|
233
237
|
|
|
234
238
|
const components = discoverRegistryComponents();
|
|
235
|
-
const
|
|
239
|
+
const stashDir = join(process.env.OP_HOME!, 'stash');
|
|
240
|
+
const automations = discoverRegistryAutomations(stashDir);
|
|
236
241
|
|
|
237
242
|
expect(Object.keys(components)).toEqual(['chat']);
|
|
238
243
|
expect(components.chat?.schema).toContain('CHANNEL_CHAT_SECRET');
|
|
239
244
|
expect(automations.map((entry) => entry.name)).toEqual(['cleanup']);
|
|
240
|
-
expect(getRegistryAutomation('cleanup')).toContain('schedule:
|
|
245
|
+
expect(getRegistryAutomation('cleanup')).toContain('schedule: "0 3 * * *"');
|
|
241
246
|
});
|
|
242
247
|
|
|
243
248
|
it("returns addon config metadata from the materialized registry", () => {
|
|
244
249
|
const sourceRoot = join(tmpDir, 'repo');
|
|
245
|
-
const addonDir = join(sourceRoot, '.openpalm', 'registry', 'addons', 'chat');
|
|
246
|
-
const automationsDir = join(sourceRoot, '.openpalm', 'registry', 'automations');
|
|
250
|
+
const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
|
|
251
|
+
const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
|
|
247
252
|
|
|
248
253
|
mkdirSync(addonDir, { recursive: true });
|
|
249
254
|
mkdirSync(automationsDir, { recursive: true });
|
|
250
255
|
writeFileSync(join(addonDir, 'compose.yml'), 'services: {}\n');
|
|
251
256
|
writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
|
|
252
|
-
writeFileSync(join(automationsDir, 'cleanup.
|
|
257
|
+
writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
|
|
253
258
|
|
|
254
259
|
materializeRegistryCatalog(sourceRoot);
|
|
255
260
|
|
|
256
261
|
expect(getRegistryAddonConfig(process.env.OP_HOME!, 'chat')).toEqual({
|
|
257
|
-
schemaPath: 'registry/addons/chat/.env.schema',
|
|
258
|
-
userEnvPath: '
|
|
262
|
+
schemaPath: 'state/registry/addons/chat/.env.schema',
|
|
263
|
+
userEnvPath: 'config/stack/stack.env',
|
|
259
264
|
envSchema: 'CHANNEL_CHAT_SECRET=\n',
|
|
260
265
|
});
|
|
261
266
|
});
|
|
262
267
|
|
|
263
268
|
it("verifies the materialized registry and returns counts", () => {
|
|
264
269
|
const sourceRoot = join(tmpDir, 'repo');
|
|
265
|
-
const addonDir = join(sourceRoot, '.openpalm', 'registry', 'addons', 'chat');
|
|
266
|
-
const automationsDir = join(sourceRoot, '.openpalm', 'registry', 'automations');
|
|
270
|
+
const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
|
|
271
|
+
const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
|
|
267
272
|
|
|
268
273
|
mkdirSync(addonDir, { recursive: true });
|
|
269
274
|
mkdirSync(automationsDir, { recursive: true });
|
|
270
275
|
writeFileSync(join(addonDir, 'compose.yml'), 'services: {}\n');
|
|
271
276
|
writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
|
|
272
|
-
writeFileSync(join(automationsDir, 'cleanup.
|
|
277
|
+
writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
|
|
273
278
|
|
|
274
279
|
const root = materializeRegistryCatalog(sourceRoot);
|
|
275
280
|
|
|
@@ -286,77 +291,78 @@ describe("materialized registry catalog", () => {
|
|
|
286
291
|
|
|
287
292
|
it("fails when source catalog is incomplete", () => {
|
|
288
293
|
const sourceRoot = join(tmpDir, 'repo');
|
|
289
|
-
mkdirSync(join(sourceRoot, '.openpalm', 'registry', 'addons'), { recursive: true });
|
|
290
|
-
mkdirSync(join(sourceRoot, '.openpalm', 'registry', 'automations'), { recursive: true });
|
|
294
|
+
mkdirSync(join(sourceRoot, '.openpalm', 'state', 'registry', 'addons'), { recursive: true });
|
|
295
|
+
mkdirSync(join(sourceRoot, '.openpalm', 'state', 'registry', 'automations'), { recursive: true });
|
|
291
296
|
|
|
292
297
|
expect(() => materializeRegistryCatalog(sourceRoot)).toThrow('Registry catalog is incomplete');
|
|
293
298
|
});
|
|
294
299
|
|
|
295
300
|
it("enables and disables addons through the runtime stack directory", () => {
|
|
296
301
|
const sourceRoot = join(tmpDir, 'repo');
|
|
297
|
-
const addonDir = join(sourceRoot, '.openpalm', 'registry', 'addons', 'chat');
|
|
298
|
-
const automationsDir = join(sourceRoot, '.openpalm', 'registry', 'automations');
|
|
302
|
+
const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
|
|
303
|
+
const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
|
|
299
304
|
|
|
300
305
|
mkdirSync(addonDir, { recursive: true });
|
|
301
306
|
mkdirSync(automationsDir, { recursive: true });
|
|
302
307
|
writeFileSync(join(addonDir, 'compose.yml'), 'services: {}\n');
|
|
303
308
|
writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
|
|
304
|
-
writeFileSync(join(automationsDir, 'cleanup.
|
|
309
|
+
writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
|
|
305
310
|
|
|
306
311
|
materializeRegistryCatalog(sourceRoot);
|
|
307
312
|
|
|
308
|
-
|
|
309
|
-
expect(
|
|
313
|
+
const stackDir = join(process.env.OP_HOME!, 'config', 'stack');
|
|
314
|
+
expect(setAddonEnabled(process.env.OP_HOME!, stackDir, 'chat', true)).toMatchObject({ ok: true });
|
|
315
|
+
expect(existsSync(join(process.env.OP_HOME!, 'config', 'stack', 'addons', 'chat', 'compose.yml'))).toBe(true);
|
|
310
316
|
|
|
311
|
-
expect(
|
|
312
|
-
expect(existsSync(join(process.env.OP_HOME!, 'stack', 'addons', 'chat'))).toBe(false);
|
|
317
|
+
expect(setAddonEnabled(process.env.OP_HOME!, stackDir, 'chat', false)).toMatchObject({ ok: true });
|
|
318
|
+
expect(existsSync(join(process.env.OP_HOME!, 'config', 'stack', 'addons', 'chat'))).toBe(false);
|
|
313
319
|
});
|
|
314
320
|
|
|
315
321
|
it("returns addon service names from stack or registry compose files", () => {
|
|
316
322
|
const sourceRoot = join(tmpDir, 'repo');
|
|
317
|
-
const addonDir = join(sourceRoot, '.openpalm', 'registry', 'addons', '
|
|
318
|
-
const automationsDir = join(sourceRoot, '.openpalm', 'registry', 'automations');
|
|
323
|
+
const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'proxy-test');
|
|
324
|
+
const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
|
|
319
325
|
|
|
320
326
|
mkdirSync(addonDir, { recursive: true });
|
|
321
327
|
mkdirSync(automationsDir, { recursive: true });
|
|
322
|
-
writeFileSync(join(addonDir, 'compose.yml'), 'services:\n
|
|
323
|
-
writeFileSync(join(addonDir, '.env.schema'), '
|
|
324
|
-
writeFileSync(join(automationsDir, 'cleanup.
|
|
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');
|
|
325
331
|
|
|
326
332
|
materializeRegistryCatalog(sourceRoot);
|
|
327
333
|
|
|
328
|
-
expect(getAddonServiceNames(process.env.OP_HOME!, '
|
|
334
|
+
expect(getAddonServiceNames(process.env.OP_HOME!, 'proxy-test')).toEqual(['svc-a', 'svc-b']);
|
|
329
335
|
});
|
|
330
336
|
|
|
331
337
|
it("toggles addons and generates channel secrets when enabling channel addons", () => {
|
|
332
338
|
const sourceRoot = join(tmpDir, 'repo');
|
|
333
|
-
const addonDir = join(sourceRoot, '.openpalm', 'registry', 'addons', 'chat');
|
|
334
|
-
const automationsDir = join(sourceRoot, '.openpalm', 'registry', 'automations');
|
|
339
|
+
const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
|
|
340
|
+
const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
|
|
335
341
|
|
|
336
342
|
mkdirSync(addonDir, { recursive: true });
|
|
337
343
|
mkdirSync(automationsDir, { recursive: true });
|
|
338
344
|
writeFileSync(join(addonDir, 'compose.yml'), 'services:\n chat:\n image: test\n environment:\n CHANNEL_NAME: "Chat"\n CHANNEL_ID: "chat"\n');
|
|
339
345
|
writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
|
|
340
|
-
writeFileSync(join(automationsDir, 'cleanup.
|
|
346
|
+
writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
|
|
341
347
|
|
|
342
348
|
materializeRegistryCatalog(sourceRoot);
|
|
343
349
|
|
|
344
|
-
expect(setAddonEnabled(process.env.OP_HOME!, join(process.env.OP_HOME!, '
|
|
350
|
+
expect(setAddonEnabled(process.env.OP_HOME!, join(process.env.OP_HOME!, 'config', 'stack'), 'chat', true)).toEqual({
|
|
345
351
|
ok: true,
|
|
346
352
|
enabled: true,
|
|
347
353
|
changed: true,
|
|
348
354
|
services: ['chat'],
|
|
349
355
|
});
|
|
350
|
-
expect(existsSync(join(process.env.OP_HOME!, 'stack', 'addons', 'chat', 'compose.yml'))).toBe(true);
|
|
351
|
-
expect(readFileSync(join(process.env.OP_HOME!, '
|
|
356
|
+
expect(existsSync(join(process.env.OP_HOME!, 'config', 'stack', 'addons', 'chat', 'compose.yml'))).toBe(true);
|
|
357
|
+
expect(readFileSync(join(process.env.OP_HOME!, 'config', 'stack', 'guardian.env'), 'utf-8')).toMatch(/CHANNEL_CHAT_SECRET=/);
|
|
352
358
|
|
|
353
|
-
expect(setAddonEnabled(process.env.OP_HOME!, join(process.env.OP_HOME!, '
|
|
359
|
+
expect(setAddonEnabled(process.env.OP_HOME!, join(process.env.OP_HOME!, 'config', 'stack'), 'chat', false)).toEqual({
|
|
354
360
|
ok: true,
|
|
355
361
|
enabled: false,
|
|
356
362
|
changed: true,
|
|
357
363
|
services: ['chat'],
|
|
358
364
|
});
|
|
359
|
-
expect(existsSync(join(process.env.OP_HOME!, 'stack', 'addons', 'chat'))).toBe(false);
|
|
365
|
+
expect(existsSync(join(process.env.OP_HOME!, 'config', 'stack', 'addons', 'chat'))).toBe(false);
|
|
360
366
|
});
|
|
361
367
|
|
|
362
368
|
it("backs up OP_HOME without recursively copying backups", () => {
|
|
@@ -390,10 +396,71 @@ describe("materialized registry catalog", () => {
|
|
|
390
396
|
expect(existsSync(join(otherHome, 'backups', 'config', 'stack.yml'))).toBe(false);
|
|
391
397
|
});
|
|
392
398
|
|
|
393
|
-
it("
|
|
399
|
+
it("parses compose profiles + openpalm.profile.* labels per addon", () => {
|
|
394
400
|
const sourceRoot = join(tmpDir, 'repo');
|
|
395
|
-
const addonDir = join(sourceRoot, '.openpalm', 'registry', 'addons', '
|
|
396
|
-
const automationsDir = join(sourceRoot, '.openpalm', 'registry', 'automations');
|
|
401
|
+
const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'voice');
|
|
402
|
+
const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
|
|
403
|
+
|
|
404
|
+
mkdirSync(addonDir, { recursive: true });
|
|
405
|
+
mkdirSync(automationsDir, { recursive: true });
|
|
406
|
+
writeFileSync(
|
|
407
|
+
join(addonDir, 'compose.yml'),
|
|
408
|
+
[
|
|
409
|
+
'services:',
|
|
410
|
+
' voice:',
|
|
411
|
+
' profiles: [cpu]',
|
|
412
|
+
' image: openpalm/voice:cpu',
|
|
413
|
+
' labels:',
|
|
414
|
+
' openpalm.profile.label: CPU',
|
|
415
|
+
' openpalm.profile.default: "true"',
|
|
416
|
+
' voice-cuda:',
|
|
417
|
+
' profiles: [cuda]',
|
|
418
|
+
' image: openpalm/voice:cuda',
|
|
419
|
+
' labels:',
|
|
420
|
+
' openpalm.profile.label: NVIDIA',
|
|
421
|
+
' openpalm.profile.requires: nvidia-container-toolkit',
|
|
422
|
+
'',
|
|
423
|
+
].join('\n'),
|
|
424
|
+
);
|
|
425
|
+
writeFileSync(join(addonDir, '.env.schema'), 'VOICE=\n');
|
|
426
|
+
writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
|
|
427
|
+
|
|
428
|
+
materializeRegistryCatalog(sourceRoot);
|
|
429
|
+
|
|
430
|
+
const profiles = getAddonProfiles(process.env.OP_HOME!, 'voice');
|
|
431
|
+
expect(profiles).toEqual([
|
|
432
|
+
{ id: 'cpu', services: ['voice'], label: 'CPU', default: true },
|
|
433
|
+
{ id: 'cuda', services: ['voice-cuda'], label: 'NVIDIA', requires: 'nvidia-container-toolkit' },
|
|
434
|
+
]);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("round-trips addon profile selection through stack.env", () => {
|
|
438
|
+
const sourceRoot = join(tmpDir, 'repo');
|
|
439
|
+
const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'voice');
|
|
440
|
+
const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
|
|
441
|
+
|
|
442
|
+
mkdirSync(addonDir, { recursive: true });
|
|
443
|
+
mkdirSync(automationsDir, { recursive: true });
|
|
444
|
+
writeFileSync(join(addonDir, 'compose.yml'), 'services:\n voice:\n profiles: [cpu]\n image: x\n');
|
|
445
|
+
writeFileSync(join(addonDir, '.env.schema'), 'VOICE=\n');
|
|
446
|
+
writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
|
|
447
|
+
|
|
448
|
+
materializeRegistryCatalog(sourceRoot);
|
|
449
|
+
|
|
450
|
+
const stackDir = join(process.env.OP_HOME!, 'config', 'stack');
|
|
451
|
+
mkdirSync(stackDir, { recursive: true });
|
|
452
|
+
writeFileSync(join(stackDir, 'stack.env'), '');
|
|
453
|
+
|
|
454
|
+
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
|
+
});
|
|
459
|
+
|
|
460
|
+
it("installs and uninstalls automations through stash/tasks", () => {
|
|
461
|
+
const sourceRoot = join(tmpDir, 'repo');
|
|
462
|
+
const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
|
|
463
|
+
const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
|
|
397
464
|
const configDir = join(process.env.OP_HOME!, 'config');
|
|
398
465
|
|
|
399
466
|
mkdirSync(addonDir, { recursive: true });
|
|
@@ -401,14 +468,143 @@ describe("materialized registry catalog", () => {
|
|
|
401
468
|
mkdirSync(configDir, { recursive: true });
|
|
402
469
|
writeFileSync(join(addonDir, 'compose.yml'), 'services: {}\n');
|
|
403
470
|
writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
|
|
404
|
-
writeFileSync(join(automationsDir, 'cleanup.
|
|
471
|
+
writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
|
|
405
472
|
|
|
406
473
|
materializeRegistryCatalog(sourceRoot);
|
|
407
474
|
|
|
408
|
-
|
|
409
|
-
expect(
|
|
475
|
+
const stashDir = join(process.env.OP_HOME!, 'stash');
|
|
476
|
+
expect(installAutomationFromRegistry('cleanup', stashDir)).toEqual({ ok: true });
|
|
477
|
+
expect(readFileSync(join(stashDir, 'tasks', 'cleanup.md'), 'utf-8')).toContain('Cleanup');
|
|
410
478
|
|
|
411
|
-
expect(uninstallAutomation('cleanup',
|
|
412
|
-
expect(existsSync(join(
|
|
479
|
+
expect(uninstallAutomation('cleanup', stashDir)).toEqual({ ok: true });
|
|
480
|
+
expect(existsSync(join(stashDir, 'tasks', 'cleanup.md'))).toBe(false);
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// ── Host capability probes ───────────────────────────────────────────
|
|
485
|
+
|
|
486
|
+
describe("getAddonProfileAvailability", () => {
|
|
487
|
+
beforeEach(() => {
|
|
488
|
+
__addonAvailabilityTestHooks.reset();
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
afterEach(() => {
|
|
492
|
+
__addonAvailabilityTestHooks.reset();
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it("returns available:true for the cpu profile (no host requirements)", async () => {
|
|
496
|
+
const result = await getAddonProfileAvailability({ id: 'cpu' });
|
|
497
|
+
expect(result.available).toBe(true);
|
|
498
|
+
expect(result.reason).toBeUndefined();
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it("returns available:true for unknown profile ids (no host-side gating)", async () => {
|
|
502
|
+
const result = await getAddonProfileAvailability({ id: 'something-else' });
|
|
503
|
+
expect(result.available).toBe(true);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
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' });
|
|
509
|
+
expect(a).toBe(b); // same reference — cached
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it("probes cuda: returns available:false on a host with no NVIDIA runtime / CDI", async () => {
|
|
513
|
+
// This test runs on CI/dev machines without GPUs. We don't mock execFile;
|
|
514
|
+
// we just assert the contract: when neither signal is present, the
|
|
515
|
+
// reason mentions nvidia-container-toolkit. If a future GPU host runs
|
|
516
|
+
// this test, the assertion still tolerates the success case.
|
|
517
|
+
const result = await getAddonProfileAvailability({ id: 'cuda' });
|
|
518
|
+
if (!result.available) {
|
|
519
|
+
expect(result.reason).toContain('NVIDIA');
|
|
520
|
+
} else {
|
|
521
|
+
// Host genuinely has the runtime registered — accept it.
|
|
522
|
+
expect(result.reason).toBeUndefined();
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it("probes rocm: returns available:false when /dev/kfd is missing", async () => {
|
|
527
|
+
const result = await getAddonProfileAvailability({ id: 'rocm' });
|
|
528
|
+
if (!result.available) {
|
|
529
|
+
expect(result.reason).toContain('ROCm');
|
|
530
|
+
} else {
|
|
531
|
+
expect(result.reason).toBeUndefined();
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it("probes rocm: when devices exist, reports unpublished image distinctly from missing-device case", async () => {
|
|
536
|
+
// On a host without /dev/kfd, we hit the device-missing branch and
|
|
537
|
+
// get the "devices not present" copy. On a ROCm host, we'd fall
|
|
538
|
+
// through to the manifest-inspect probe and (until 0.11.0-rocm6
|
|
539
|
+
// ships) get the "image not published yet" copy. Both must mention
|
|
540
|
+
// ROCm so operator-facing copy stays consistent.
|
|
541
|
+
const result = await getAddonProfileAvailability({ id: 'rocm' });
|
|
542
|
+
if (!result.available && existsSync('/dev/kfd') && existsSync('/dev/dri')) {
|
|
543
|
+
expect(result.reason).toMatch(/image not published|CPU profile/i);
|
|
544
|
+
}
|
|
545
|
+
if (!result.available && !(existsSync('/dev/kfd') && existsSync('/dev/dri'))) {
|
|
546
|
+
expect(result.reason).toMatch(/devices not present/i);
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
describe("execFileNoThrow (ENOENT capture)", () => {
|
|
552
|
+
it("captures ENOENT for a missing binary as 'spawn <cmd> ENOENT' stderr", async () => {
|
|
553
|
+
const result = await __addonAvailabilityTestHooks.execFileNoThrow(
|
|
554
|
+
'/nonexistent/path/to/openpalm-test-no-such-binary-zzz',
|
|
555
|
+
['--help'],
|
|
556
|
+
2_000,
|
|
557
|
+
);
|
|
558
|
+
expect(result.ok).toBe(false);
|
|
559
|
+
expect(result.stderr).toMatch(/ENOENT/);
|
|
560
|
+
// When the binary is "docker", the synthetic stderr becomes
|
|
561
|
+
// `spawn docker ENOENT: command not found` — that string matches the
|
|
562
|
+
// translateDockerError regex `/spawn .*docker.*ENOENT/i` so the
|
|
563
|
+
// operator gets actionable copy instead of "unknown error (no stderr)".
|
|
564
|
+
expect(result.stderr).toMatch(/spawn\s+\S*\s*ENOENT/);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
it("formats ENOENT for `docker` so translateDockerError can match it", async () => {
|
|
568
|
+
// Use an absolute path that we know doesn't exist so the test is
|
|
569
|
+
// deterministic regardless of whether docker is installed on the host.
|
|
570
|
+
const result = await __addonAvailabilityTestHooks.execFileNoThrow(
|
|
571
|
+
'docker-not-installed-zzz',
|
|
572
|
+
['info'],
|
|
573
|
+
2_000,
|
|
574
|
+
);
|
|
575
|
+
expect(result.ok).toBe(false);
|
|
576
|
+
expect(result.stderr).toBe('spawn docker-not-installed-zzz ENOENT: command not found');
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
describe("annotateAddonProfileAvailability", () => {
|
|
581
|
+
beforeEach(() => {
|
|
582
|
+
__addonAvailabilityTestHooks.reset();
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
afterEach(() => {
|
|
586
|
+
__addonAvailabilityTestHooks.reset();
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it("decorates each profile with available + optional reason", async () => {
|
|
590
|
+
const out = await annotateAddonProfileAvailability([
|
|
591
|
+
{ id: 'cpu', services: ['voice'], label: 'CPU', default: true },
|
|
592
|
+
{ id: 'rocm', services: ['voice-rocm'], label: 'AMD' },
|
|
593
|
+
]);
|
|
594
|
+
expect(out).toHaveLength(2);
|
|
595
|
+
expect(out[0]?.id).toBe('cpu');
|
|
596
|
+
expect(out[0]?.available).toBe(true);
|
|
597
|
+
// Preserves original fields.
|
|
598
|
+
expect(out[0]?.label).toBe('CPU');
|
|
599
|
+
expect(out[0]?.default).toBe(true);
|
|
600
|
+
expect(out[1]?.id).toBe('rocm');
|
|
601
|
+
expect(typeof out[1]?.available).toBe('boolean');
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it("does not mutate the input array", async () => {
|
|
605
|
+
const input = [{ id: 'cpu', services: ['voice'] }];
|
|
606
|
+
const before = JSON.parse(JSON.stringify(input));
|
|
607
|
+
await annotateAddonProfileAvailability(input);
|
|
608
|
+
expect(input).toEqual(before);
|
|
413
609
|
});
|
|
414
610
|
});
|