@openpalm/lib 0.11.0-beta.2 → 0.11.0-beta.3
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/package.json +1 -1
- package/src/control-plane/registry.test.ts +64 -0
- package/src/control-plane/registry.ts +113 -0
- package/src/index.ts +4 -0
package/package.json
CHANGED
|
@@ -21,6 +21,9 @@ import {
|
|
|
21
21
|
getRegistryAddonConfig,
|
|
22
22
|
listAvailableAddonIds,
|
|
23
23
|
getAddonServiceNames,
|
|
24
|
+
getAddonProfiles,
|
|
25
|
+
getAddonProfileSelection,
|
|
26
|
+
setAddonProfileSelection,
|
|
24
27
|
enableAddon,
|
|
25
28
|
disableAddonByName,
|
|
26
29
|
setAddonEnabled,
|
|
@@ -391,6 +394,67 @@ describe("materialized registry catalog", () => {
|
|
|
391
394
|
expect(existsSync(join(otherHome, 'backups', 'config', 'stack.yml'))).toBe(false);
|
|
392
395
|
});
|
|
393
396
|
|
|
397
|
+
it("parses compose profiles + openpalm.profile.* labels per addon", () => {
|
|
398
|
+
const sourceRoot = join(tmpDir, 'repo');
|
|
399
|
+
const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'voice');
|
|
400
|
+
const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
|
|
401
|
+
|
|
402
|
+
mkdirSync(addonDir, { recursive: true });
|
|
403
|
+
mkdirSync(automationsDir, { recursive: true });
|
|
404
|
+
writeFileSync(
|
|
405
|
+
join(addonDir, 'compose.yml'),
|
|
406
|
+
[
|
|
407
|
+
'services:',
|
|
408
|
+
' voice:',
|
|
409
|
+
' profiles: [cpu]',
|
|
410
|
+
' image: openpalm/voice:cpu',
|
|
411
|
+
' labels:',
|
|
412
|
+
' openpalm.profile.label: CPU',
|
|
413
|
+
' openpalm.profile.default: "true"',
|
|
414
|
+
' voice-cuda:',
|
|
415
|
+
' profiles: [cuda]',
|
|
416
|
+
' image: openpalm/voice:cuda',
|
|
417
|
+
' labels:',
|
|
418
|
+
' openpalm.profile.label: NVIDIA',
|
|
419
|
+
' openpalm.profile.requires: nvidia-container-toolkit',
|
|
420
|
+
'',
|
|
421
|
+
].join('\n'),
|
|
422
|
+
);
|
|
423
|
+
writeFileSync(join(addonDir, '.env.schema'), 'VOICE=\n');
|
|
424
|
+
writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
|
|
425
|
+
|
|
426
|
+
materializeRegistryCatalog(sourceRoot);
|
|
427
|
+
|
|
428
|
+
const profiles = getAddonProfiles(process.env.OP_HOME!, 'voice');
|
|
429
|
+
expect(profiles).toEqual([
|
|
430
|
+
{ id: 'cpu', services: ['voice'], label: 'CPU', default: true },
|
|
431
|
+
{ id: 'cuda', services: ['voice-cuda'], label: 'NVIDIA', requires: 'nvidia-container-toolkit' },
|
|
432
|
+
]);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("round-trips addon profile selection through stack.env", () => {
|
|
436
|
+
const sourceRoot = join(tmpDir, 'repo');
|
|
437
|
+
const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'voice');
|
|
438
|
+
const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
|
|
439
|
+
|
|
440
|
+
mkdirSync(addonDir, { recursive: true });
|
|
441
|
+
mkdirSync(automationsDir, { recursive: true });
|
|
442
|
+
writeFileSync(join(addonDir, 'compose.yml'), 'services:\n voice:\n profiles: [cpu]\n image: x\n');
|
|
443
|
+
writeFileSync(join(addonDir, '.env.schema'), 'VOICE=\n');
|
|
444
|
+
writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
|
|
445
|
+
|
|
446
|
+
materializeRegistryCatalog(sourceRoot);
|
|
447
|
+
|
|
448
|
+
const stackDir = join(process.env.OP_HOME!, 'config', 'stack');
|
|
449
|
+
mkdirSync(stackDir, { recursive: true });
|
|
450
|
+
writeFileSync(join(stackDir, 'stack.env'), '');
|
|
451
|
+
|
|
452
|
+
expect(getAddonProfileSelection(stackDir, 'voice')).toBeNull();
|
|
453
|
+
setAddonProfileSelection(stackDir, 'voice', 'cuda');
|
|
454
|
+
expect(getAddonProfileSelection(stackDir, 'voice')).toBe('cuda');
|
|
455
|
+
expect(readFileSync(join(stackDir, 'stack.env'), 'utf-8')).toContain('OP_VOICE_PROFILE=cuda');
|
|
456
|
+
});
|
|
457
|
+
|
|
394
458
|
it("installs and uninstalls automations through stash/tasks", () => {
|
|
395
459
|
const sourceRoot = join(tmpDir, 'repo');
|
|
396
460
|
const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
|
|
@@ -12,6 +12,7 @@ import { parse as parseYaml } from 'yaml';
|
|
|
12
12
|
import { createLogger } from '../logger.js';
|
|
13
13
|
import { isChannelAddon } from './channels.js';
|
|
14
14
|
import { randomHex, writeChannelSecrets } from './config-persistence.js';
|
|
15
|
+
import { patchSecretsEnvFile, readStackEnv } from './secrets.js';
|
|
15
16
|
import {
|
|
16
17
|
resolveRegistryAddonsDir,
|
|
17
18
|
resolveRegistryAutomationsDir,
|
|
@@ -339,6 +340,118 @@ export function getAddonServiceNames(homeDir: string, name: string): string[] {
|
|
|
339
340
|
return [];
|
|
340
341
|
}
|
|
341
342
|
|
|
343
|
+
export type AddonProfile = {
|
|
344
|
+
id: string;
|
|
345
|
+
services: string[];
|
|
346
|
+
label?: string;
|
|
347
|
+
requires?: string;
|
|
348
|
+
default?: boolean;
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
function readAddonProfiles(composePath: string): AddonProfile[] {
|
|
352
|
+
if (!existsSync(composePath)) return [];
|
|
353
|
+
|
|
354
|
+
let parsed: unknown;
|
|
355
|
+
try {
|
|
356
|
+
parsed = parseYaml(readFileSync(composePath, "utf-8"));
|
|
357
|
+
} catch (error) {
|
|
358
|
+
logger.warn("failed to parse addon compose profiles", {
|
|
359
|
+
composePath,
|
|
360
|
+
error: error instanceof Error ? error.message : String(error),
|
|
361
|
+
});
|
|
362
|
+
return [];
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const services = parsed && typeof parsed === "object"
|
|
366
|
+
? (parsed as { services?: unknown }).services
|
|
367
|
+
: undefined;
|
|
368
|
+
if (!services || typeof services !== "object" || Array.isArray(services)) return [];
|
|
369
|
+
|
|
370
|
+
const byProfile = new Map<string, AddonProfile>();
|
|
371
|
+
for (const [svcName, svcRaw] of Object.entries(services as Record<string, unknown>)) {
|
|
372
|
+
if (!svcRaw || typeof svcRaw !== "object") continue;
|
|
373
|
+
const svc = svcRaw as { profiles?: unknown; labels?: unknown };
|
|
374
|
+
if (!Array.isArray(svc.profiles)) continue;
|
|
375
|
+
const profileIds = svc.profiles.filter((p): p is string => typeof p === "string");
|
|
376
|
+
if (profileIds.length === 0) continue;
|
|
377
|
+
|
|
378
|
+
const labels = readServiceLabels(svc.labels);
|
|
379
|
+
const label = labels["openpalm.profile.label"];
|
|
380
|
+
const requires = labels["openpalm.profile.requires"];
|
|
381
|
+
const isDefault = labels["openpalm.profile.default"] === "true";
|
|
382
|
+
|
|
383
|
+
for (const id of profileIds) {
|
|
384
|
+
const existing = byProfile.get(id);
|
|
385
|
+
if (existing) {
|
|
386
|
+
existing.services.push(svcName);
|
|
387
|
+
if (!existing.label && label) existing.label = label;
|
|
388
|
+
if (!existing.requires && requires) existing.requires = requires;
|
|
389
|
+
if (!existing.default && isDefault) existing.default = true;
|
|
390
|
+
} else {
|
|
391
|
+
const profile: AddonProfile = { id, services: [svcName] };
|
|
392
|
+
if (label) profile.label = label;
|
|
393
|
+
if (requires) profile.requires = requires;
|
|
394
|
+
if (isDefault) profile.default = true;
|
|
395
|
+
byProfile.set(id, profile);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return [...byProfile.values()];
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function readServiceLabels(raw: unknown): Record<string, string> {
|
|
404
|
+
if (!raw) return {};
|
|
405
|
+
const out: Record<string, string> = {};
|
|
406
|
+
if (Array.isArray(raw)) {
|
|
407
|
+
for (const entry of raw) {
|
|
408
|
+
if (typeof entry !== "string") continue;
|
|
409
|
+
const eq = entry.indexOf("=");
|
|
410
|
+
if (eq < 0) continue;
|
|
411
|
+
out[entry.slice(0, eq)] = entry.slice(eq + 1);
|
|
412
|
+
}
|
|
413
|
+
} else if (typeof raw === "object") {
|
|
414
|
+
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
|
|
415
|
+
if (v == null) continue;
|
|
416
|
+
out[k] = String(v);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return out;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export function getAddonProfiles(homeDir: string, name: string): AddonProfile[] {
|
|
423
|
+
if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
|
|
424
|
+
|
|
425
|
+
const composeCandidates = [
|
|
426
|
+
join(homeDir, "config", "stack", "addons", name, "compose.yml"),
|
|
427
|
+
join(homeDir, "state", "registry", "addons", name, "compose.yml"),
|
|
428
|
+
];
|
|
429
|
+
|
|
430
|
+
for (const composePath of composeCandidates) {
|
|
431
|
+
const profiles = readAddonProfiles(composePath);
|
|
432
|
+
if (profiles.length > 0) return profiles;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return [];
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function profileEnvKey(name: string): string {
|
|
439
|
+
if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
|
|
440
|
+
return `OP_${name.replace(/-/g, '_').toUpperCase()}_PROFILE`;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export function getAddonProfileSelection(stackDir: string, name: string): string | null {
|
|
444
|
+
const env = readStackEnv(stackDir);
|
|
445
|
+
const value = env[profileEnvKey(name)];
|
|
446
|
+
return value && value.trim() ? value.trim() : null;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export function setAddonProfileSelection(stackDir: string, name: string, profile: string): void {
|
|
450
|
+
const trimmed = profile.trim();
|
|
451
|
+
if (!trimmed) throw new Error('Profile id cannot be empty');
|
|
452
|
+
patchSecretsEnvFile(stackDir, { [profileEnvKey(name)]: trimmed });
|
|
453
|
+
}
|
|
454
|
+
|
|
342
455
|
export function enableAddon(homeDir: string, name: string): MutationResult {
|
|
343
456
|
try {
|
|
344
457
|
copyAddonFromRegistry(homeDir, name);
|
package/src/index.ts
CHANGED
|
@@ -43,6 +43,7 @@ export {
|
|
|
43
43
|
// ── Registry Catalog ─────────────────────────────────────────────────────
|
|
44
44
|
export type {
|
|
45
45
|
AddonMutationResult,
|
|
46
|
+
AddonProfile,
|
|
46
47
|
RegistryAutomationEntry,
|
|
47
48
|
RegistryComponentEntry,
|
|
48
49
|
RegistryAddonConfig,
|
|
@@ -57,6 +58,9 @@ export {
|
|
|
57
58
|
getRegistryAutomation,
|
|
58
59
|
getRegistryAddonConfig,
|
|
59
60
|
getAddonServiceNames,
|
|
61
|
+
getAddonProfiles,
|
|
62
|
+
getAddonProfileSelection,
|
|
63
|
+
setAddonProfileSelection,
|
|
60
64
|
listAvailableAddonIds,
|
|
61
65
|
listEnabledAddonIds,
|
|
62
66
|
enableAddon,
|