@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
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Registry catalog discovery and refresh.
|
|
3
3
|
*
|
|
4
|
-
* `OP_HOME/registry` is the only persistent catalog location.
|
|
4
|
+
* `OP_HOME/state/registry` is the only persistent catalog location.
|
|
5
5
|
* Install seeds it once; refresh replaces it explicitly.
|
|
6
6
|
*/
|
|
7
7
|
import { cpSync, existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
|
-
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import { execFile, execFileSync } from 'node:child_process';
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
import { tmpdir } from 'node:os';
|
|
11
11
|
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,
|
|
@@ -46,10 +47,10 @@ export function isValidComponentName(name: string): boolean {
|
|
|
46
47
|
|
|
47
48
|
const DEFAULT_REPO = 'itlackey/openpalm';
|
|
48
49
|
|
|
49
|
-
export
|
|
50
|
+
export type RegistryConfig = {
|
|
50
51
|
repoUrl: string;
|
|
51
52
|
branch: string;
|
|
52
|
-
}
|
|
53
|
+
};
|
|
53
54
|
|
|
54
55
|
export function getRegistryConfig(): RegistryConfig {
|
|
55
56
|
return {
|
|
@@ -63,7 +64,7 @@ export type RegistryAutomationEntry = {
|
|
|
63
64
|
type: 'automation';
|
|
64
65
|
description: string;
|
|
65
66
|
schedule: string;
|
|
66
|
-
|
|
67
|
+
content: string;
|
|
67
68
|
};
|
|
68
69
|
|
|
69
70
|
export type RegistryComponentEntry = {
|
|
@@ -83,7 +84,7 @@ export type RegistryCatalogVerification = {
|
|
|
83
84
|
automationCount: number;
|
|
84
85
|
};
|
|
85
86
|
|
|
86
|
-
|
|
87
|
+
type MutationResult = { ok: true } | { ok: false; error: string };
|
|
87
88
|
export type AddonMutationResult = (
|
|
88
89
|
| { ok: true; enabled: boolean; changed: boolean; services: string[] }
|
|
89
90
|
| { ok: false; error: string }
|
|
@@ -95,7 +96,10 @@ function countValidAddons(rootDir: string): number {
|
|
|
95
96
|
return readdirSync(addonsDir, { withFileTypes: true }).filter((entry) => {
|
|
96
97
|
if (!entry.isDirectory() || !isValidComponentName(entry.name)) return false;
|
|
97
98
|
const addonDir = join(addonsDir, entry.name);
|
|
98
|
-
|
|
99
|
+
// An addon is valid if it has a compose.yml. Overlay-only addons that only
|
|
100
|
+
// patch existing services (ports, env, volumes) do not need an .env.schema;
|
|
101
|
+
// full addons that introduce services and env vars do.
|
|
102
|
+
return existsSync(join(addonDir, 'compose.yml'));
|
|
99
103
|
}).length;
|
|
100
104
|
}
|
|
101
105
|
|
|
@@ -103,8 +107,8 @@ function countValidAutomations(rootDir: string): number {
|
|
|
103
107
|
const automationsDir = join(rootDir, 'automations');
|
|
104
108
|
if (!existsSync(automationsDir)) return 0;
|
|
105
109
|
return readdirSync(automationsDir).filter((file) => {
|
|
106
|
-
if (!file.endsWith('.
|
|
107
|
-
return isValidComponentName(file.replace(/\.
|
|
110
|
+
if (!file.endsWith('.md')) return false;
|
|
111
|
+
return isValidComponentName(file.replace(/\.md$/, ''));
|
|
108
112
|
}).length;
|
|
109
113
|
}
|
|
110
114
|
|
|
@@ -123,8 +127,8 @@ export function verifyRegistryCatalog(rootDir = resolveRegistryDir()): RegistryC
|
|
|
123
127
|
}
|
|
124
128
|
|
|
125
129
|
export function materializeRegistryCatalog(sourceRoot: string): string {
|
|
126
|
-
const sourceAddonsDir = join(sourceRoot, '.openpalm', 'registry', 'addons');
|
|
127
|
-
const sourceAutomationsDir = join(sourceRoot, '.openpalm', 'registry', 'automations');
|
|
130
|
+
const sourceAddonsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons');
|
|
131
|
+
const sourceAutomationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
|
|
128
132
|
const tempRoot = mkdtempSync(join(tmpdir(), 'openpalm-registry-materialize-'));
|
|
129
133
|
|
|
130
134
|
try {
|
|
@@ -184,37 +188,46 @@ export function discoverRegistryComponents(): Record<string, RegistryComponentEn
|
|
|
184
188
|
if (!entry.isDirectory() || !VALID_NAME_RE.test(entry.name)) continue;
|
|
185
189
|
const addonDir = join(addonsDir, entry.name);
|
|
186
190
|
const composeFile = join(addonDir, 'compose.yml');
|
|
191
|
+
if (!existsSync(composeFile)) continue;
|
|
192
|
+
|
|
193
|
+
// .env.schema is optional: overlay-only addons (e.g. a port toggle) do
|
|
194
|
+
// not introduce new env vars, so they ship just compose.yml.
|
|
187
195
|
const schemaFile = join(addonDir, '.env.schema');
|
|
188
|
-
|
|
196
|
+
const schema = existsSync(schemaFile) ? readFileSync(schemaFile, 'utf-8') : '';
|
|
189
197
|
|
|
190
198
|
result[entry.name] = {
|
|
191
199
|
compose: readFileSync(composeFile, 'utf-8'),
|
|
192
|
-
schema
|
|
200
|
+
schema,
|
|
193
201
|
};
|
|
194
202
|
}
|
|
195
203
|
|
|
196
204
|
return result;
|
|
197
205
|
}
|
|
198
206
|
|
|
199
|
-
export function discoverRegistryAutomations(): RegistryAutomationEntry[] {
|
|
207
|
+
export function discoverRegistryAutomations(stashDir: string): RegistryAutomationEntry[] {
|
|
200
208
|
const automationsDir = resolveRegistryAutomationsDir();
|
|
201
209
|
if (!existsSync(automationsDir)) return [];
|
|
202
210
|
|
|
203
211
|
return readdirSync(automationsDir)
|
|
204
|
-
.filter((file) => file.endsWith('.
|
|
212
|
+
.filter((file) => file.endsWith('.md'))
|
|
205
213
|
.map((file) => {
|
|
206
|
-
const name = file.replace(/\.
|
|
214
|
+
const name = file.replace(/\.md$/, '');
|
|
207
215
|
if (!VALID_NAME_RE.test(name)) return null;
|
|
208
216
|
|
|
209
|
-
const
|
|
217
|
+
const content = readFileSync(join(automationsDir, file), 'utf-8');
|
|
210
218
|
let description = '';
|
|
211
219
|
let schedule = '';
|
|
212
220
|
|
|
221
|
+
// Extract frontmatter metadata (between --- delimiters)
|
|
213
222
|
try {
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
223
|
+
const after = content.startsWith('---') ? content.slice(3) : '';
|
|
224
|
+
const end = after.indexOf('\n---');
|
|
225
|
+
if (end !== -1) {
|
|
226
|
+
const parsed = parseYaml(after.slice(0, end));
|
|
227
|
+
if (parsed && typeof parsed === 'object') {
|
|
228
|
+
description = (parsed as Record<string, unknown>).description as string ?? '';
|
|
229
|
+
schedule = (parsed as Record<string, unknown>).schedule as string ?? '';
|
|
230
|
+
}
|
|
218
231
|
}
|
|
219
232
|
} catch {
|
|
220
233
|
// best-effort metadata extraction
|
|
@@ -225,7 +238,7 @@ export function discoverRegistryAutomations(): RegistryAutomationEntry[] {
|
|
|
225
238
|
type: 'automation' as const,
|
|
226
239
|
description,
|
|
227
240
|
schedule,
|
|
228
|
-
|
|
241
|
+
content,
|
|
229
242
|
};
|
|
230
243
|
})
|
|
231
244
|
.filter((entry): entry is RegistryAutomationEntry => entry !== null);
|
|
@@ -233,9 +246,9 @@ export function discoverRegistryAutomations(): RegistryAutomationEntry[] {
|
|
|
233
246
|
|
|
234
247
|
export function getRegistryAutomation(name: string): string | null {
|
|
235
248
|
if (!VALID_NAME_RE.test(name)) return null;
|
|
236
|
-
const
|
|
237
|
-
if (!existsSync(
|
|
238
|
-
return readFileSync(
|
|
249
|
+
const mdPath = join(resolveRegistryAutomationsDir(), `${name}.md`);
|
|
250
|
+
if (!existsSync(mdPath)) return null;
|
|
251
|
+
return readFileSync(mdPath, 'utf-8');
|
|
239
252
|
}
|
|
240
253
|
|
|
241
254
|
export function getRegistryAddonConfig(homeDir: string, name: string): RegistryAddonConfig {
|
|
@@ -243,11 +256,14 @@ export function getRegistryAddonConfig(homeDir: string, name: string): RegistryA
|
|
|
243
256
|
throw new Error(`Invalid addon name: ${name}`);
|
|
244
257
|
}
|
|
245
258
|
|
|
246
|
-
|
|
259
|
+
// Overlay-only addons (compose.yml only, no .env.schema) have no env vars
|
|
260
|
+
// to render, so the schema reads as an empty string.
|
|
261
|
+
const schemaPath = `state/registry/addons/${name}/.env.schema`;
|
|
262
|
+
const schemaFile = join(homeDir, schemaPath);
|
|
247
263
|
return {
|
|
248
264
|
schemaPath,
|
|
249
|
-
userEnvPath: '
|
|
250
|
-
envSchema: readFileSync(
|
|
265
|
+
userEnvPath: 'config/stack/stack.env',
|
|
266
|
+
envSchema: existsSync(schemaFile) ? readFileSync(schemaFile, 'utf-8') : '',
|
|
251
267
|
};
|
|
252
268
|
}
|
|
253
269
|
|
|
@@ -261,7 +277,7 @@ export function listAvailableAddonIds(): string[] {
|
|
|
261
277
|
}
|
|
262
278
|
|
|
263
279
|
export function listEnabledAddonIds(homeDir: string): string[] {
|
|
264
|
-
const addonsDir = join(homeDir, 'stack', 'addons');
|
|
280
|
+
const addonsDir = join(homeDir, 'config', 'stack', 'addons');
|
|
265
281
|
if (!existsSync(addonsDir)) return [];
|
|
266
282
|
|
|
267
283
|
return readdirSync(addonsDir, { withFileTypes: true })
|
|
@@ -274,19 +290,21 @@ function copyAddonFromRegistry(homeDir: string, name: string): void {
|
|
|
274
290
|
if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
|
|
275
291
|
|
|
276
292
|
const sourceDir = join(resolveRegistryAddonsDir(), name);
|
|
277
|
-
|
|
293
|
+
// compose.yml is the only required file. Overlay-only addons may omit
|
|
294
|
+
// .env.schema entirely.
|
|
295
|
+
if (!existsSync(join(sourceDir, 'compose.yml'))) {
|
|
278
296
|
throw new Error(`Addon "${name}" not found in registry`);
|
|
279
297
|
}
|
|
280
298
|
|
|
281
|
-
const targetDir = join(homeDir, 'stack', 'addons', name);
|
|
299
|
+
const targetDir = join(homeDir, 'config', 'stack', 'addons', name);
|
|
282
300
|
rmSync(targetDir, { recursive: true, force: true });
|
|
283
|
-
mkdirSync(join(homeDir, 'stack', 'addons'), { recursive: true });
|
|
301
|
+
mkdirSync(join(homeDir, 'config', 'stack', 'addons'), { recursive: true });
|
|
284
302
|
cpSync(sourceDir, targetDir, { recursive: true });
|
|
285
303
|
}
|
|
286
304
|
|
|
287
305
|
function removeEnabledAddon(homeDir: string, name: string): void {
|
|
288
306
|
if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
|
|
289
|
-
rmSync(join(homeDir, 'stack', 'addons', name), { recursive: true, force: true });
|
|
307
|
+
rmSync(join(homeDir, 'config', 'stack', 'addons', name), { recursive: true, force: true });
|
|
290
308
|
}
|
|
291
309
|
|
|
292
310
|
function readAddonServiceNames(composePath: string): string[] {
|
|
@@ -310,8 +328,8 @@ export function getAddonServiceNames(homeDir: string, name: string): string[] {
|
|
|
310
328
|
if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
|
|
311
329
|
|
|
312
330
|
const composeCandidates = [
|
|
313
|
-
join(homeDir, "stack", "addons", name, "compose.yml"),
|
|
314
|
-
join(homeDir, "registry", "addons", name, "compose.yml"),
|
|
331
|
+
join(homeDir, "config", "stack", "addons", name, "compose.yml"),
|
|
332
|
+
join(homeDir, "state", "registry", "addons", name, "compose.yml"),
|
|
315
333
|
];
|
|
316
334
|
|
|
317
335
|
for (const composePath of composeCandidates) {
|
|
@@ -322,18 +340,346 @@ export function getAddonServiceNames(homeDir: string, name: string): string[] {
|
|
|
322
340
|
return [];
|
|
323
341
|
}
|
|
324
342
|
|
|
325
|
-
export
|
|
343
|
+
export type AddonProfile = {
|
|
344
|
+
id: string;
|
|
345
|
+
services: string[];
|
|
346
|
+
label?: string;
|
|
347
|
+
requires?: string;
|
|
348
|
+
default?: boolean;
|
|
349
|
+
/**
|
|
350
|
+
* Whether the host can run this profile.
|
|
351
|
+
*
|
|
352
|
+
* Populated by `getAddonProfileAvailability()`. When the value is missing
|
|
353
|
+
* (e.g. older catalogs), callers should treat the profile as available.
|
|
354
|
+
*/
|
|
355
|
+
available?: boolean;
|
|
356
|
+
/** Human-readable reason when `available === false`. */
|
|
357
|
+
reason?: string;
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
// ── Host capability probes ─────────────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
export type AddonProfileAvailability = { available: boolean; reason?: string };
|
|
363
|
+
|
|
364
|
+
const HOST_PROBE_TIMEOUT_MS = 2_000;
|
|
365
|
+
|
|
366
|
+
// Process-lifetime cache. Hardware presence does not change while the UI
|
|
367
|
+
// server is running, so probing once is enough.
|
|
368
|
+
const availabilityCache = new Map<string, AddonProfileAvailability>();
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Reset the host-capability cache. Test-only — not exported.
|
|
372
|
+
*/
|
|
373
|
+
function _resetAvailabilityCacheForTests(): void {
|
|
374
|
+
availabilityCache.clear();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Exported under a deliberately ugly name so test files can reach it.
|
|
378
|
+
export const __addonAvailabilityTestHooks = {
|
|
379
|
+
reset: _resetAvailabilityCacheForTests,
|
|
380
|
+
/**
|
|
381
|
+
* Test-only: exposes the internal exec wrapper so tests can verify
|
|
382
|
+
* ENOENT (missing binary) is surfaced as actionable stderr that the
|
|
383
|
+
* docker-error translator can recognise.
|
|
384
|
+
*/
|
|
385
|
+
execFileNoThrow: (cmd: string, args: string[], timeoutMs: number) =>
|
|
386
|
+
execFileNoThrow(cmd, args, timeoutMs),
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
function execFileNoThrow(
|
|
390
|
+
cmd: string,
|
|
391
|
+
args: string[],
|
|
392
|
+
timeoutMs: number,
|
|
393
|
+
): Promise<{ ok: boolean; stdout: string; stderr: string }> {
|
|
394
|
+
return new Promise((resolve) => {
|
|
395
|
+
execFile(cmd, args, { timeout: timeoutMs }, (error, stdout, stderr) => {
|
|
396
|
+
// ENOENT (binary missing) surfaces here with no stderr — child_process
|
|
397
|
+
// never gets to exec the program. Inject a synthetic stderr that
|
|
398
|
+
// matches the translateDockerError ENOENT regex so callers get
|
|
399
|
+
// actionable copy instead of "unknown error (no stderr)".
|
|
400
|
+
let mergedStderr = stderr?.toString() ?? '';
|
|
401
|
+
const code = (error as NodeJS.ErrnoException | null)?.code;
|
|
402
|
+
if (code && !mergedStderr) {
|
|
403
|
+
if (code === 'ENOENT') {
|
|
404
|
+
mergedStderr = `spawn ${cmd} ENOENT: command not found`;
|
|
405
|
+
} else {
|
|
406
|
+
mergedStderr = `spawn ${cmd} ${code}`;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
resolve({
|
|
410
|
+
ok: !error,
|
|
411
|
+
stdout: stdout?.toString() ?? '',
|
|
412
|
+
stderr: mergedStderr,
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Compute the openpalm/voice image ref for a given GPU variant, matching
|
|
420
|
+
* the substitution chain in the addon compose file:
|
|
421
|
+
* ${OP_IMAGE_NAMESPACE:-openpalm}/voice:${OP_VOICE_IMAGE_TAG:-${OP_IMAGE_TAG:-v0.11.0}-<variant>}
|
|
422
|
+
*/
|
|
423
|
+
function voiceImageRef(variant: 'cpu' | 'cu121' | 'rocm6'): string {
|
|
424
|
+
const namespace = process.env.OP_IMAGE_NAMESPACE?.trim() || 'openpalm';
|
|
425
|
+
const explicit = process.env.OP_VOICE_IMAGE_TAG?.trim();
|
|
426
|
+
if (explicit) return `${namespace}/voice:${explicit}`;
|
|
427
|
+
const baseTag = process.env.OP_IMAGE_TAG?.trim() || 'v0.11.0';
|
|
428
|
+
return `${namespace}/voice:${baseTag}-${variant}`;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* `docker manifest inspect <ref>` returns 0 only when the registry can
|
|
433
|
+
* resolve a manifest for that ref. We use it as the cheap "is this image
|
|
434
|
+
* actually published?" check — no pull required. The retry handles
|
|
435
|
+
* transient registry hiccups. Timeout is short because the manifest blob
|
|
436
|
+
* is a few KB.
|
|
437
|
+
*/
|
|
438
|
+
async function dockerManifestExists(imageRef: string): Promise<boolean> {
|
|
439
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
440
|
+
const res = await execFileNoThrow(
|
|
441
|
+
'docker',
|
|
442
|
+
['manifest', 'inspect', imageRef],
|
|
443
|
+
5_000,
|
|
444
|
+
);
|
|
445
|
+
if (res.ok) return true;
|
|
446
|
+
// If docker itself is missing (ENOENT), retrying won't help.
|
|
447
|
+
if (/ENOENT/.test(res.stderr)) return false;
|
|
448
|
+
}
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function probeCuda(): Promise<AddonProfileAvailability> {
|
|
453
|
+
// Two acceptance signals:
|
|
454
|
+
// 1. `docker info` reports an `nvidia` runtime (toolkit installed +
|
|
455
|
+
// `nvidia-ctk runtime configure --runtime=docker` was run).
|
|
456
|
+
// 2. `/etc/cdi/nvidia.yaml` exists (CDI-mode daemon with a generated
|
|
457
|
+
// spec). We don't require the runtime in this case — the route's
|
|
458
|
+
// CDI fallback can switch the compose to driver:cdi.
|
|
459
|
+
try {
|
|
460
|
+
if (existsSync('/etc/cdi/nvidia.yaml')) return { available: true };
|
|
461
|
+
} catch {
|
|
462
|
+
// existsSync only throws on path-syntax issues; ignore and probe docker.
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const result = await execFileNoThrow(
|
|
466
|
+
'docker',
|
|
467
|
+
['info', '--format', '{{json .Runtimes}}'],
|
|
468
|
+
HOST_PROBE_TIMEOUT_MS,
|
|
469
|
+
);
|
|
470
|
+
if (result.ok && result.stdout.includes('"nvidia"')) {
|
|
471
|
+
return { available: true };
|
|
472
|
+
}
|
|
473
|
+
return {
|
|
474
|
+
available: false,
|
|
475
|
+
reason: 'NVIDIA runtime not registered. Install nvidia-container-toolkit or enable CDI.',
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async function probeRocm(): Promise<AddonProfileAvailability> {
|
|
480
|
+
// Hardware gate: ROCm needs both the KFD char device and the GPU DRI nodes.
|
|
481
|
+
let devicesPresent = false;
|
|
482
|
+
try {
|
|
483
|
+
devicesPresent = existsSync('/dev/kfd') && existsSync('/dev/dri');
|
|
484
|
+
} catch {
|
|
485
|
+
devicesPresent = false;
|
|
486
|
+
}
|
|
487
|
+
if (!devicesPresent) {
|
|
488
|
+
return {
|
|
489
|
+
available: false,
|
|
490
|
+
reason: 'AMD ROCm devices not present on this host.',
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Image gate: the openpalm/voice:*-rocm6 image isn't published yet, so
|
|
495
|
+
// even on a fully-functional ROCm host the compose-up would fail with a
|
|
496
|
+
// manifest-unknown pull error. Refuse the profile until the image lands.
|
|
497
|
+
const imageRef = voiceImageRef('rocm6');
|
|
498
|
+
const published = await dockerManifestExists(imageRef);
|
|
499
|
+
if (!published) {
|
|
500
|
+
return {
|
|
501
|
+
available: false,
|
|
502
|
+
reason: 'AMD ROCm image not published yet. Check back in a future release or use the CPU profile.',
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
return { available: true };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Probe the host for the capabilities required by an addon profile.
|
|
510
|
+
*
|
|
511
|
+
* Results are cached for the lifetime of the process — hardware doesn't
|
|
512
|
+
* change while the UI server runs. All probes use execFile (no shell)
|
|
513
|
+
* and never throw: errors collapse to `{ available: false, reason }`.
|
|
514
|
+
*
|
|
515
|
+
* Unknown profile ids default to `available: true` so unrelated addons
|
|
516
|
+
* (e.g. a future "high-mem" profile that doesn't probe hardware) keep
|
|
517
|
+
* working without code changes here.
|
|
518
|
+
*/
|
|
519
|
+
export async function getAddonProfileAvailability(
|
|
520
|
+
profile: Pick<AddonProfile, 'id'>,
|
|
521
|
+
): Promise<AddonProfileAvailability> {
|
|
522
|
+
const cacheKey = profile.id;
|
|
523
|
+
const cached = availabilityCache.get(cacheKey);
|
|
524
|
+
if (cached) return cached;
|
|
525
|
+
|
|
526
|
+
let result: AddonProfileAvailability;
|
|
527
|
+
try {
|
|
528
|
+
if (profile.id === 'cpu') {
|
|
529
|
+
result = { available: true };
|
|
530
|
+
} else if (profile.id === 'cuda') {
|
|
531
|
+
result = await probeCuda();
|
|
532
|
+
} else if (profile.id === 'rocm') {
|
|
533
|
+
result = await probeRocm();
|
|
534
|
+
} else {
|
|
535
|
+
// Unknown profile id — assume available; caller is responsible for
|
|
536
|
+
// labelling profiles that need host capability gating.
|
|
537
|
+
result = { available: true };
|
|
538
|
+
}
|
|
539
|
+
} catch (err) {
|
|
540
|
+
// Belt-and-braces: any unexpected throw collapses to unavailable.
|
|
541
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
542
|
+
result = { available: false, reason: `probe failed: ${reason}` };
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
availabilityCache.set(cacheKey, result);
|
|
546
|
+
return result;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Decorate a list of profiles with `available`/`reason` based on the host
|
|
551
|
+
* capability probes. Returns a fresh array; does not mutate inputs.
|
|
552
|
+
*/
|
|
553
|
+
export async function annotateAddonProfileAvailability(
|
|
554
|
+
profiles: AddonProfile[],
|
|
555
|
+
): Promise<AddonProfile[]> {
|
|
556
|
+
const results = await Promise.all(
|
|
557
|
+
profiles.map(async (p) => {
|
|
558
|
+
const a = await getAddonProfileAvailability(p);
|
|
559
|
+
const annotated: AddonProfile = { ...p, available: a.available };
|
|
560
|
+
if (a.reason) annotated.reason = a.reason;
|
|
561
|
+
return annotated;
|
|
562
|
+
}),
|
|
563
|
+
);
|
|
564
|
+
return results;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function readAddonProfiles(composePath: string): AddonProfile[] {
|
|
568
|
+
if (!existsSync(composePath)) return [];
|
|
569
|
+
|
|
570
|
+
let parsed: unknown;
|
|
571
|
+
try {
|
|
572
|
+
parsed = parseYaml(readFileSync(composePath, "utf-8"));
|
|
573
|
+
} catch (error) {
|
|
574
|
+
logger.warn("failed to parse addon compose profiles", {
|
|
575
|
+
composePath,
|
|
576
|
+
error: error instanceof Error ? error.message : String(error),
|
|
577
|
+
});
|
|
578
|
+
return [];
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const services = parsed && typeof parsed === "object"
|
|
582
|
+
? (parsed as { services?: unknown }).services
|
|
583
|
+
: undefined;
|
|
584
|
+
if (!services || typeof services !== "object" || Array.isArray(services)) return [];
|
|
585
|
+
|
|
586
|
+
const byProfile = new Map<string, AddonProfile>();
|
|
587
|
+
for (const [svcName, svcRaw] of Object.entries(services as Record<string, unknown>)) {
|
|
588
|
+
if (!svcRaw || typeof svcRaw !== "object") continue;
|
|
589
|
+
const svc = svcRaw as { profiles?: unknown; labels?: unknown };
|
|
590
|
+
if (!Array.isArray(svc.profiles)) continue;
|
|
591
|
+
const profileIds = svc.profiles.filter((p): p is string => typeof p === "string");
|
|
592
|
+
if (profileIds.length === 0) continue;
|
|
593
|
+
|
|
594
|
+
const labels = readServiceLabels(svc.labels);
|
|
595
|
+
const label = labels["openpalm.profile.label"];
|
|
596
|
+
const requires = labels["openpalm.profile.requires"];
|
|
597
|
+
const isDefault = labels["openpalm.profile.default"] === "true";
|
|
598
|
+
|
|
599
|
+
for (const id of profileIds) {
|
|
600
|
+
const existing = byProfile.get(id);
|
|
601
|
+
if (existing) {
|
|
602
|
+
existing.services.push(svcName);
|
|
603
|
+
if (!existing.label && label) existing.label = label;
|
|
604
|
+
if (!existing.requires && requires) existing.requires = requires;
|
|
605
|
+
if (!existing.default && isDefault) existing.default = true;
|
|
606
|
+
} else {
|
|
607
|
+
const profile: AddonProfile = { id, services: [svcName] };
|
|
608
|
+
if (label) profile.label = label;
|
|
609
|
+
if (requires) profile.requires = requires;
|
|
610
|
+
if (isDefault) profile.default = true;
|
|
611
|
+
byProfile.set(id, profile);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
return [...byProfile.values()];
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function readServiceLabels(raw: unknown): Record<string, string> {
|
|
620
|
+
if (!raw) return {};
|
|
621
|
+
const out: Record<string, string> = {};
|
|
622
|
+
if (Array.isArray(raw)) {
|
|
623
|
+
for (const entry of raw) {
|
|
624
|
+
if (typeof entry !== "string") continue;
|
|
625
|
+
const eq = entry.indexOf("=");
|
|
626
|
+
if (eq < 0) continue;
|
|
627
|
+
out[entry.slice(0, eq)] = entry.slice(eq + 1);
|
|
628
|
+
}
|
|
629
|
+
} else if (typeof raw === "object") {
|
|
630
|
+
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
|
|
631
|
+
if (v == null) continue;
|
|
632
|
+
out[k] = String(v);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
return out;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
export function getAddonProfiles(homeDir: string, name: string): AddonProfile[] {
|
|
639
|
+
if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
|
|
640
|
+
|
|
641
|
+
const composeCandidates = [
|
|
642
|
+
join(homeDir, "config", "stack", "addons", name, "compose.yml"),
|
|
643
|
+
join(homeDir, "state", "registry", "addons", name, "compose.yml"),
|
|
644
|
+
];
|
|
645
|
+
|
|
646
|
+
for (const composePath of composeCandidates) {
|
|
647
|
+
const profiles = readAddonProfiles(composePath);
|
|
648
|
+
if (profiles.length > 0) return profiles;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
return [];
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function profileEnvKey(name: string): string {
|
|
655
|
+
if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
|
|
656
|
+
return `OP_${name.replace(/-/g, '_').toUpperCase()}_PROFILE`;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
export function getAddonProfileSelection(stackDir: string, name: string): string | null {
|
|
660
|
+
const env = readStackEnv(stackDir);
|
|
661
|
+
const value = env[profileEnvKey(name)];
|
|
662
|
+
return value && value.trim() ? value.trim() : null;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
export function setAddonProfileSelection(stackDir: string, name: string, profile: string): void {
|
|
666
|
+
const trimmed = profile.trim();
|
|
667
|
+
if (!trimmed) throw new Error('Profile id cannot be empty');
|
|
668
|
+
patchSecretsEnvFile(stackDir, { [profileEnvKey(name)]: trimmed });
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function enableAddon(homeDir: string, name: string): MutationResult {
|
|
326
672
|
try {
|
|
327
673
|
copyAddonFromRegistry(homeDir, name);
|
|
328
|
-
// Pre-create the addon
|
|
329
|
-
mkdirSync(join(homeDir, '
|
|
674
|
+
// Pre-create the addon services directory so Docker doesn't create it as root
|
|
675
|
+
mkdirSync(join(homeDir, 'services', name), { recursive: true });
|
|
330
676
|
return { ok: true };
|
|
331
677
|
} catch (error) {
|
|
332
678
|
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
333
679
|
}
|
|
334
680
|
}
|
|
335
681
|
|
|
336
|
-
|
|
682
|
+
function disableAddonByName(homeDir: string, name: string): MutationResult {
|
|
337
683
|
try {
|
|
338
684
|
removeEnabledAddon(homeDir, name);
|
|
339
685
|
return { ok: true };
|
|
@@ -342,7 +688,7 @@ export function disableAddonByName(homeDir: string, name: string): MutationResul
|
|
|
342
688
|
}
|
|
343
689
|
}
|
|
344
690
|
|
|
345
|
-
export function setAddonEnabled(homeDir: string,
|
|
691
|
+
export function setAddonEnabled(homeDir: string, stackDir: string, name: string, enabled: boolean): AddonMutationResult {
|
|
346
692
|
if (!VALID_NAME_RE.test(name)) {
|
|
347
693
|
return { ok: false, error: `Invalid addon name: ${name}` };
|
|
348
694
|
}
|
|
@@ -367,9 +713,9 @@ export function setAddonEnabled(homeDir: string, vaultDir: string, name: string,
|
|
|
367
713
|
if (!mutation.ok) return mutation;
|
|
368
714
|
|
|
369
715
|
if (enabled) {
|
|
370
|
-
const composePath = join(homeDir, "stack", "addons", name, "compose.yml");
|
|
716
|
+
const composePath = join(homeDir, "config", "stack", "addons", name, "compose.yml");
|
|
371
717
|
if (isChannelAddon(composePath)) {
|
|
372
|
-
writeChannelSecrets(
|
|
718
|
+
writeChannelSecrets(stackDir, { [name]: randomHex(16) });
|
|
373
719
|
}
|
|
374
720
|
}
|
|
375
721
|
|
|
@@ -381,38 +727,42 @@ export function setAddonEnabled(homeDir: string, vaultDir: string, name: string,
|
|
|
381
727
|
};
|
|
382
728
|
}
|
|
383
729
|
|
|
384
|
-
export function installAutomationFromRegistry(name: string,
|
|
730
|
+
export function installAutomationFromRegistry(name: string, stashDir: string): MutationResult {
|
|
385
731
|
if (!VALID_NAME_RE.test(name)) {
|
|
386
732
|
return { ok: false, error: `Invalid automation name: ${name}` };
|
|
387
733
|
}
|
|
388
734
|
|
|
389
|
-
const
|
|
390
|
-
if (!
|
|
735
|
+
const markdownContent = getRegistryAutomation(name);
|
|
736
|
+
if (!markdownContent) {
|
|
391
737
|
return { ok: false, error: `Automation "${name}" not found in registry` };
|
|
392
738
|
}
|
|
393
739
|
|
|
394
|
-
const
|
|
395
|
-
mkdirSync(
|
|
740
|
+
const tasksDir = join(stashDir, 'tasks');
|
|
741
|
+
mkdirSync(tasksDir, { recursive: true });
|
|
396
742
|
|
|
397
|
-
const
|
|
398
|
-
if (existsSync(
|
|
743
|
+
const mdPath = join(tasksDir, `${name}.md`);
|
|
744
|
+
if (existsSync(mdPath)) {
|
|
399
745
|
return { ok: false, error: `Automation "${name}" is already installed` };
|
|
400
746
|
}
|
|
401
747
|
|
|
402
|
-
writeFileSync(
|
|
748
|
+
writeFileSync(mdPath, markdownContent);
|
|
749
|
+
// The assistant container's 60-second akm tasks sync loop picks up the new
|
|
750
|
+
// file from the shared stash mount and registers it with OS cron.
|
|
403
751
|
return { ok: true };
|
|
404
752
|
}
|
|
405
753
|
|
|
406
|
-
export function uninstallAutomation(name: string,
|
|
754
|
+
export function uninstallAutomation(name: string, stashDir: string): MutationResult {
|
|
407
755
|
if (!VALID_NAME_RE.test(name)) {
|
|
408
756
|
return { ok: false, error: `Invalid automation name: ${name}` };
|
|
409
757
|
}
|
|
410
758
|
|
|
411
|
-
const
|
|
412
|
-
if (!existsSync(
|
|
759
|
+
const mdPath = join(stashDir, 'tasks', `${name}.md`);
|
|
760
|
+
if (!existsSync(mdPath)) {
|
|
413
761
|
return { ok: false, error: `Automation "${name}" is not installed` };
|
|
414
762
|
}
|
|
415
763
|
|
|
416
|
-
rmSync(
|
|
764
|
+
rmSync(mdPath, { force: true });
|
|
765
|
+
// The assistant container's 60-second akm tasks sync will notice the file
|
|
766
|
+
// is gone and deregister it from OS cron on next sync.
|
|
417
767
|
return { ok: true };
|
|
418
768
|
}
|