@openpalm/lib 0.11.0-beta.3 → 0.11.0-beta.7
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 +2 -0
- package/package.json +4 -1
- package/src/control-plane/akm-vault.ts +5 -1
- package/src/control-plane/channels.ts +8 -6
- package/src/control-plane/compose-args.test.ts +0 -9
- package/src/control-plane/compose-args.ts +0 -4
- package/src/control-plane/config-persistence.ts +48 -11
- package/src/control-plane/core-assets.ts +63 -7
- package/src/control-plane/docker.ts +15 -4
- package/src/control-plane/env.ts +4 -1
- package/src/control-plane/install-edge-cases.test.ts +3 -3
- package/src/control-plane/lifecycle.ts +31 -9
- package/src/control-plane/operator-ids.test.ts +130 -0
- package/src/control-plane/operator-ids.ts +89 -0
- package/src/control-plane/registry.test.ts +134 -4
- package/src/control-plane/registry.ts +220 -4
- package/src/control-plane/secrets.ts +4 -4
- package/src/control-plane/setup.test.ts +6 -6
- package/src/control-plane/setup.ts +4 -6
- package/src/control-plane/spec-to-env.test.ts +25 -9
- package/src/control-plane/spec-to-env.ts +28 -17
- package/src/control-plane/types.ts +0 -4
- package/src/control-plane/ui-assets.ts +45 -9
- package/src/index.ts +13 -9
- package/src/logger.test.ts +12 -12
- package/src/control-plane/admin-token.ts +0 -73
- package/src/control-plane/lock.test.ts +0 -194
- package/src/control-plane/lock.ts +0 -176
- package/src/control-plane/provider-config.ts +0 -34
- package/src/control-plane/secret-backend.test.ts +0 -346
- package/src/control-plane/secret-backend.ts +0 -362
- package/src/control-plane/spec-validator.ts +0 -62
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator UID/GID detection for stack.env.
|
|
3
|
+
*
|
|
4
|
+
* Container processes that bind-mount host paths (voice models, addon
|
|
5
|
+
* caches, etc.) run as `${OP_UID}:${OP_GID}`. If those values are wrong,
|
|
6
|
+
* the container can't write to the mounted volume and the install
|
|
7
|
+
* silently degrades (model downloads stall, healthchecks time out).
|
|
8
|
+
*
|
|
9
|
+
* Detection strategy (Linux/macOS):
|
|
10
|
+
* 1. Stat OP_HOME. If it exists and is owned by a non-root user,
|
|
11
|
+
* prefer that owner — operator may have created OP_HOME under a
|
|
12
|
+
* different account than the one running install (e.g. sudo
|
|
13
|
+
* install for a service user).
|
|
14
|
+
* 2. Otherwise fall back to the process's real UID/GID.
|
|
15
|
+
* 3. Never return 0 (root). Running install as root is allowed but
|
|
16
|
+
* the container must run as the operator, not root.
|
|
17
|
+
*
|
|
18
|
+
* Returns `null` on Windows (containers run in WSL2's Linux; OP_UID
|
|
19
|
+
* has no meaning on the win32 host process itself).
|
|
20
|
+
*/
|
|
21
|
+
import { statSync } from "node:fs";
|
|
22
|
+
|
|
23
|
+
export type OperatorIds = { uid: number; gid: number };
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve the operator's UID/GID for stack.env.
|
|
27
|
+
* Returns null on Windows or when neither homeDir owner nor process
|
|
28
|
+
* UID/GID is available (e.g. process.getuid undefined on some runtimes).
|
|
29
|
+
*/
|
|
30
|
+
export function resolveOperatorIds(homeDir: string): OperatorIds | null {
|
|
31
|
+
if (process.platform === "win32") return null;
|
|
32
|
+
|
|
33
|
+
const processUid = typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
34
|
+
const processGid = typeof process.getgid === "function" ? process.getgid() : undefined;
|
|
35
|
+
|
|
36
|
+
let ownerUid: number | undefined;
|
|
37
|
+
let ownerGid: number | undefined;
|
|
38
|
+
try {
|
|
39
|
+
const st = statSync(homeDir);
|
|
40
|
+
ownerUid = st.uid;
|
|
41
|
+
ownerGid = st.gid;
|
|
42
|
+
} catch {
|
|
43
|
+
// homeDir may not exist yet during a first-time install — that's fine,
|
|
44
|
+
// we fall through to the process IDs below.
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Prefer the homeDir owner when it's a non-root user (the operator may
|
|
48
|
+
// have created OP_HOME under a different account than the one running
|
|
49
|
+
// install — e.g. an admin running `sudo openpalm install` on behalf of
|
|
50
|
+
// a service account).
|
|
51
|
+
const uid =
|
|
52
|
+
ownerUid !== undefined && ownerUid !== 0
|
|
53
|
+
? ownerUid
|
|
54
|
+
: processUid !== undefined && processUid !== 0
|
|
55
|
+
? processUid
|
|
56
|
+
: ownerUid; // last resort: homeDir owner even if 0, or undefined
|
|
57
|
+
|
|
58
|
+
const gid =
|
|
59
|
+
ownerGid !== undefined && ownerGid !== 0
|
|
60
|
+
? ownerGid
|
|
61
|
+
: processGid !== undefined && processGid !== 0
|
|
62
|
+
? processGid
|
|
63
|
+
: ownerGid;
|
|
64
|
+
|
|
65
|
+
if (uid === undefined || gid === undefined) return null;
|
|
66
|
+
|
|
67
|
+
// Final guard: never return 0 (root). This happens when BOTH the OP_HOME
|
|
68
|
+
// owner AND the process UID are root (e.g. `sudo openpalm install` on a
|
|
69
|
+
// freshly-created root-owned OP_HOME, common in CI builds and Docker-based
|
|
70
|
+
// installer flows). Returning null causes the caller to skip writing
|
|
71
|
+
// OP_UID/OP_GID to stack.env, and compose's `${OP_UID:-1000}` default
|
|
72
|
+
// kicks in — container runs as 1000:1000, which is the sane fallback
|
|
73
|
+
// when no real operator can be detected.
|
|
74
|
+
if (uid === 0 || gid === 0) return null;
|
|
75
|
+
|
|
76
|
+
return { uid, gid };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Returns true if the parsed stack.env already has a usable
|
|
81
|
+
* (non-zero, numeric) operator ID for the given key.
|
|
82
|
+
* Operator may have hand-set OP_UID/OP_GID; respect that.
|
|
83
|
+
*/
|
|
84
|
+
export function hasUsableOperatorId(parsed: Record<string, string>, key: "OP_UID" | "OP_GID"): boolean {
|
|
85
|
+
const raw = parsed[key];
|
|
86
|
+
if (!raw) return false;
|
|
87
|
+
const n = Number(raw);
|
|
88
|
+
return Number.isFinite(n) && n > 0;
|
|
89
|
+
}
|
|
@@ -24,11 +24,12 @@ import {
|
|
|
24
24
|
getAddonProfiles,
|
|
25
25
|
getAddonProfileSelection,
|
|
26
26
|
setAddonProfileSelection,
|
|
27
|
-
enableAddon,
|
|
28
|
-
disableAddonByName,
|
|
29
27
|
setAddonEnabled,
|
|
30
28
|
installAutomationFromRegistry,
|
|
31
29
|
uninstallAutomation,
|
|
30
|
+
getAddonProfileAvailability,
|
|
31
|
+
annotateAddonProfileAvailability,
|
|
32
|
+
__addonAvailabilityTestHooks,
|
|
32
33
|
} from "./registry.js";
|
|
33
34
|
|
|
34
35
|
// ── Validation Tests ─────────────────────────────────────────────────
|
|
@@ -309,10 +310,11 @@ describe("materialized registry catalog", () => {
|
|
|
309
310
|
|
|
310
311
|
materializeRegistryCatalog(sourceRoot);
|
|
311
312
|
|
|
312
|
-
|
|
313
|
+
const stackDir = join(process.env.OP_HOME!, 'config', 'stack');
|
|
314
|
+
expect(setAddonEnabled(process.env.OP_HOME!, stackDir, 'chat', true)).toMatchObject({ ok: true });
|
|
313
315
|
expect(existsSync(join(process.env.OP_HOME!, 'config', 'stack', 'addons', 'chat', 'compose.yml'))).toBe(true);
|
|
314
316
|
|
|
315
|
-
expect(
|
|
317
|
+
expect(setAddonEnabled(process.env.OP_HOME!, stackDir, 'chat', false)).toMatchObject({ ok: true });
|
|
316
318
|
expect(existsSync(join(process.env.OP_HOME!, 'config', 'stack', 'addons', 'chat'))).toBe(false);
|
|
317
319
|
});
|
|
318
320
|
|
|
@@ -478,3 +480,131 @@ describe("materialized registry catalog", () => {
|
|
|
478
480
|
expect(existsSync(join(stashDir, 'tasks', 'cleanup.md'))).toBe(false);
|
|
479
481
|
});
|
|
480
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);
|
|
609
|
+
});
|
|
610
|
+
});
|
|
@@ -5,7 +5,7 @@
|
|
|
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';
|
|
@@ -84,7 +84,7 @@ export type RegistryCatalogVerification = {
|
|
|
84
84
|
automationCount: number;
|
|
85
85
|
};
|
|
86
86
|
|
|
87
|
-
|
|
87
|
+
type MutationResult = { ok: true } | { ok: false; error: string };
|
|
88
88
|
export type AddonMutationResult = (
|
|
89
89
|
| { ok: true; enabled: boolean; changed: boolean; services: string[] }
|
|
90
90
|
| { ok: false; error: string }
|
|
@@ -346,8 +346,224 @@ export type AddonProfile = {
|
|
|
346
346
|
label?: string;
|
|
347
347
|
requires?: string;
|
|
348
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;
|
|
349
358
|
};
|
|
350
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
|
+
|
|
351
567
|
function readAddonProfiles(composePath: string): AddonProfile[] {
|
|
352
568
|
if (!existsSync(composePath)) return [];
|
|
353
569
|
|
|
@@ -452,7 +668,7 @@ export function setAddonProfileSelection(stackDir: string, name: string, profile
|
|
|
452
668
|
patchSecretsEnvFile(stackDir, { [profileEnvKey(name)]: trimmed });
|
|
453
669
|
}
|
|
454
670
|
|
|
455
|
-
|
|
671
|
+
function enableAddon(homeDir: string, name: string): MutationResult {
|
|
456
672
|
try {
|
|
457
673
|
copyAddonFromRegistry(homeDir, name);
|
|
458
674
|
// Pre-create the addon services directory so Docker doesn't create it as root
|
|
@@ -463,7 +679,7 @@ export function enableAddon(homeDir: string, name: string): MutationResult {
|
|
|
463
679
|
}
|
|
464
680
|
}
|
|
465
681
|
|
|
466
|
-
|
|
682
|
+
function disableAddonByName(homeDir: string, name: string): MutationResult {
|
|
467
683
|
try {
|
|
468
684
|
removeEnabledAddon(homeDir, name);
|
|
469
685
|
return { ok: true };
|
|
@@ -14,8 +14,8 @@ const logger = createLogger("secrets");
|
|
|
14
14
|
/** Keys whose values are shown unmasked in the UI (not secrets). */
|
|
15
15
|
export const PLAIN_CONFIG_KEYS = new Set([
|
|
16
16
|
"OPENAI_BASE_URL",
|
|
17
|
-
"
|
|
18
|
-
"
|
|
17
|
+
"OP_OWNER_NAME",
|
|
18
|
+
"OP_OWNER_EMAIL",
|
|
19
19
|
]);
|
|
20
20
|
|
|
21
21
|
|
|
@@ -91,8 +91,8 @@ function ensureSystemSecrets(state: ControlPlaneState): void {
|
|
|
91
91
|
"LMSTUDIO_API_KEY=",
|
|
92
92
|
"",
|
|
93
93
|
"# ── Owner ────────────────────────────────────────────────────────────",
|
|
94
|
-
`
|
|
95
|
-
`
|
|
94
|
+
`OP_OWNER_NAME=${process.env.OP_OWNER_NAME ?? ""}`,
|
|
95
|
+
`OP_OWNER_EMAIL=${process.env.OP_OWNER_EMAIL ?? ""}`,
|
|
96
96
|
"",
|
|
97
97
|
].join("\n");
|
|
98
98
|
const content = mergeEnvContent(header, updates);
|
|
@@ -218,15 +218,15 @@ describe("buildSecretsFromSetup", () => {
|
|
|
218
218
|
it("sets owner info when provided", () => {
|
|
219
219
|
const spec = makeValidSpec();
|
|
220
220
|
const secrets = buildSecretsFromSetup(spec.connections, spec.owner);
|
|
221
|
-
expect(secrets.
|
|
222
|
-
expect(secrets.
|
|
221
|
+
expect(secrets.OP_OWNER_NAME).toBe("Test User");
|
|
222
|
+
expect(secrets.OP_OWNER_EMAIL).toBe("test@example.com");
|
|
223
223
|
});
|
|
224
224
|
|
|
225
225
|
it("omits owner info when empty", () => {
|
|
226
226
|
const spec = makeValidSpec({ owner: { name: "", email: "" } });
|
|
227
227
|
const secrets = buildSecretsFromSetup(spec.connections, spec.owner);
|
|
228
|
-
expect(secrets.
|
|
229
|
-
expect(secrets.
|
|
228
|
+
expect(secrets.OP_OWNER_NAME).toBeUndefined();
|
|
229
|
+
expect(secrets.OP_OWNER_EMAIL).toBeUndefined();
|
|
230
230
|
});
|
|
231
231
|
|
|
232
232
|
it("does NOT include provider API keys in stack.env updates", () => {
|
|
@@ -367,8 +367,8 @@ describe("performSetup", () => {
|
|
|
367
367
|
"GROQ_API_KEY=",
|
|
368
368
|
"MISTRAL_API_KEY=",
|
|
369
369
|
"GOOGLE_API_KEY=",
|
|
370
|
-
"
|
|
371
|
-
"
|
|
370
|
+
"OP_OWNER_NAME=",
|
|
371
|
+
"OP_OWNER_EMAIL=",
|
|
372
372
|
"",
|
|
373
373
|
].join("\n")
|
|
374
374
|
);
|
|
@@ -17,12 +17,11 @@ import { acquireInstallLock, releaseInstallLock, type InstallLockHandle } from "
|
|
|
17
17
|
import {
|
|
18
18
|
ensureSecrets,
|
|
19
19
|
updateSecretsEnv,
|
|
20
|
-
|
|
20
|
+
patchSecretsEnvFile,
|
|
21
21
|
ensureOpenCodeConfig,
|
|
22
22
|
readStackEnv,
|
|
23
23
|
writeAuthJsonProviderKeys,
|
|
24
24
|
} from "./secrets.js";
|
|
25
|
-
import { ensureOpenCodeSystemConfig } from "./core-assets.js";
|
|
26
25
|
import { createState } from "./lifecycle.js";
|
|
27
26
|
import { writeStackSpec } from "./stack-spec.js";
|
|
28
27
|
import { writeVoiceVars } from "./spec-to-env.js";
|
|
@@ -98,8 +97,8 @@ export function buildSecretsFromSetup(
|
|
|
98
97
|
const updates: Record<string, string> = {};
|
|
99
98
|
const ownerName = (owner?.name?.trim() ?? "").replace(/[\r\n\0]/g, "").slice(0, 200);
|
|
100
99
|
const ownerEmail = (owner?.email?.trim() ?? "").replace(/[\r\n\0]/g, "").slice(0, 200);
|
|
101
|
-
if (ownerName) updates.
|
|
102
|
-
if (ownerEmail) updates.
|
|
100
|
+
if (ownerName) updates.OP_OWNER_NAME = ownerName;
|
|
101
|
+
if (ownerEmail) updates.OP_OWNER_EMAIL = ownerEmail;
|
|
103
102
|
void connections;
|
|
104
103
|
return updates;
|
|
105
104
|
}
|
|
@@ -227,7 +226,7 @@ export async function performSetup(
|
|
|
227
226
|
}
|
|
228
227
|
}
|
|
229
228
|
updateSecretsEnv(state, updates);
|
|
230
|
-
|
|
229
|
+
patchSecretsEnvFile(state.stackDir, buildSystemSecretsFromSetup(security.uiLoginPassword, existingSystemEnv));
|
|
231
230
|
// Provider API keys land in OpenCode's auth.json (bind-mounted into
|
|
232
231
|
// the assistant container) — never in stack.env.
|
|
233
232
|
writeAuthJsonProviderKeys(state, providerKeys);
|
|
@@ -311,7 +310,6 @@ export async function performSetup(
|
|
|
311
310
|
}
|
|
312
311
|
|
|
313
312
|
ensureOpenCodeConfig();
|
|
314
|
-
ensureOpenCodeSystemConfig();
|
|
315
313
|
|
|
316
314
|
// Seed default automation into the AKM stash. Idempotent — existing files
|
|
317
315
|
// are left alone so user edits survive re-install and upgrade.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
-
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { deriveSystemEnvFromSpec, writeVoiceVars } from "./spec-to-env.js";
|
|
@@ -25,7 +25,11 @@ describe("deriveSystemEnvFromSpec", () => {
|
|
|
25
25
|
test("produces default port values", () => {
|
|
26
26
|
const result = deriveSystemEnvFromSpec(MINIMAL_SPEC, "/home/op");
|
|
27
27
|
expect(result.OP_ASSISTANT_PORT).toBe("3800");
|
|
28
|
-
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("does not emit OP_GUARDIAN_PORT (guardian is network-only, no host mapping)", () => {
|
|
31
|
+
const result = deriveSystemEnvFromSpec(MINIMAL_SPEC, "/home/op");
|
|
32
|
+
expect(result.OP_GUARDIAN_PORT).toBeUndefined();
|
|
29
33
|
});
|
|
30
34
|
|
|
31
35
|
test("does not include the retired memory service port", () => {
|
|
@@ -45,6 +49,18 @@ describe("deriveSystemEnvFromSpec", () => {
|
|
|
45
49
|
expect(result.OP_OLLAMA_ENABLED).toBeUndefined();
|
|
46
50
|
expect(result.OP_ADMIN_ENABLED).toBeUndefined();
|
|
47
51
|
});
|
|
52
|
+
|
|
53
|
+
test("auto-detects OP_UID/OP_GID from the homeDir owner (not hard-coded 1000)", () => {
|
|
54
|
+
// tempDir is owned by the test process, which on a CI runner or
|
|
55
|
+
// dev box is typically NOT root and NOT necessarily UID 1000. The
|
|
56
|
+
// assertion that matters: we read the value off statSync, not a
|
|
57
|
+
// hard-coded constant.
|
|
58
|
+
if (process.platform === "win32") return;
|
|
59
|
+
const expected = statSync(tempDir);
|
|
60
|
+
const result = deriveSystemEnvFromSpec(MINIMAL_SPEC, tempDir);
|
|
61
|
+
expect(result.OP_UID).toBe(String(expected.uid));
|
|
62
|
+
expect(result.OP_GID).toBe(String(expected.gid));
|
|
63
|
+
});
|
|
48
64
|
});
|
|
49
65
|
|
|
50
66
|
describe("writeVoiceVars", () => {
|
|
@@ -57,9 +73,9 @@ describe("writeVoiceVars", () => {
|
|
|
57
73
|
}, tempDir);
|
|
58
74
|
|
|
59
75
|
const content = readFileSync(join(tempDir, "stack.env"), "utf-8");
|
|
60
|
-
expect(content).toContain("
|
|
61
|
-
expect(content).toContain("
|
|
62
|
-
expect(content).toContain("
|
|
76
|
+
expect(content).toContain("OP_TTS_BASE_URL=https://tts.example.com/v1");
|
|
77
|
+
expect(content).toContain("OP_TTS_MODEL=tts-1");
|
|
78
|
+
expect(content).toContain("OP_TTS_VOICE=alloy");
|
|
63
79
|
});
|
|
64
80
|
|
|
65
81
|
test("writes STT vars to stack.env", () => {
|
|
@@ -71,9 +87,9 @@ describe("writeVoiceVars", () => {
|
|
|
71
87
|
}, tempDir);
|
|
72
88
|
|
|
73
89
|
const content = readFileSync(join(tempDir, "stack.env"), "utf-8");
|
|
74
|
-
expect(content).toContain("
|
|
75
|
-
expect(content).toContain("
|
|
76
|
-
expect(content).toContain("
|
|
90
|
+
expect(content).toContain("OP_STT_BASE_URL=https://stt.example.com/v1");
|
|
91
|
+
expect(content).toContain("OP_STT_MODEL=whisper-1");
|
|
92
|
+
expect(content).toContain("OP_STT_LANGUAGE=en");
|
|
77
93
|
});
|
|
78
94
|
|
|
79
95
|
test("creates stack.env if it does not exist", () => {
|
|
@@ -84,7 +100,7 @@ describe("writeVoiceVars", () => {
|
|
|
84
100
|
}, tempDir);
|
|
85
101
|
|
|
86
102
|
const content = readFileSync(join(tempDir, "stack.env"), "utf-8");
|
|
87
|
-
expect(content).toContain("
|
|
103
|
+
expect(content).toContain("OP_TTS_BASE_URL=https://tts.example.com/v1");
|
|
88
104
|
});
|
|
89
105
|
|
|
90
106
|
test("is a no-op when no vars are provided", () => {
|