@openpalm/lib 0.11.0-beta.1 → 0.11.0-beta.11
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 +5 -1
- package/src/control-plane/akm-vault.test.ts +1 -4
- 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 -12
- 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 +49 -13
- 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/host-opencode.test.ts +0 -3
- package/src/control-plane/install-edge-cases.test.ts +29 -69
- package/src/control-plane/lifecycle.ts +39 -50
- 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 +8 -3
- package/src/control-plane/registry-components.test.ts +3 -2
- package/src/control-plane/registry.test.ts +198 -4
- package/src/control-plane/registry.ts +333 -4
- package/src/control-plane/secret-mappings.ts +2 -3
- package/src/control-plane/secrets.ts +17 -11
- package/src/control-plane/setup-config.schema.json +3 -3
- package/src/control-plane/setup-status.ts +6 -1
- package/src/control-plane/setup-validation.ts +2 -2
- package/src/control-plane/setup.test.ts +42 -20
- package/src/control-plane/setup.ts +25 -41
- package/src/control-plane/spec-to-env.test.ts +30 -16
- package/src/control-plane/spec-to-env.ts +37 -21
- package/src/control-plane/stack-spec.test.ts +5 -11
- package/src/control-plane/stack-spec.ts +2 -6
- package/src/control-plane/types.ts +0 -22
- package/src/control-plane/ui-assets.ts +45 -9
- package/src/control-plane/validate.ts +1 -1
- package/src/index.ts +26 -13
- package/src/logger.test.ts +12 -12
- package/src/logger.ts +1 -1
- package/src/control-plane/admin-token.ts +0 -73
- package/src/control-plane/audit.ts +0 -41
- 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 -349
- package/src/control-plane/secret-backend.ts +0 -362
- package/src/control-plane/spec-validator.ts +0 -62
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, statSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { resolveOperatorIds, hasUsableOperatorId } from "./operator-ids.js";
|
|
6
|
+
|
|
7
|
+
let tempDir = "";
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
tempDir = mkdtempSync(join(tmpdir(), "openpalm-opids-"));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("resolveOperatorIds", () => {
|
|
18
|
+
test("returns the homeDir's owner when it exists and is non-root", () => {
|
|
19
|
+
// A mkdtemp directory is owned by the current process — neither
|
|
20
|
+
// root (in any reasonable test env) nor a hard-coded 1000.
|
|
21
|
+
const expected = statSync(tempDir);
|
|
22
|
+
const ids = resolveOperatorIds(tempDir);
|
|
23
|
+
if (process.platform === "win32") {
|
|
24
|
+
expect(ids).toBeNull();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
expect(ids).not.toBeNull();
|
|
28
|
+
expect(ids!.uid).toBe(expected.uid);
|
|
29
|
+
expect(ids!.gid).toBe(expected.gid);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("falls back to process UID when homeDir does not exist", () => {
|
|
33
|
+
const missing = join(tempDir, "does-not-exist");
|
|
34
|
+
const ids = resolveOperatorIds(missing);
|
|
35
|
+
if (process.platform === "win32") {
|
|
36
|
+
expect(ids).toBeNull();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
expect(ids).not.toBeNull();
|
|
40
|
+
// process.getuid is guaranteed on POSIX runtimes used by this test
|
|
41
|
+
expect(ids!.uid).toBe(process.getuid!());
|
|
42
|
+
expect(ids!.gid).toBe(process.getgid!());
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("never returns 0 (root) — falls back to process UID when homeDir is root-owned", () => {
|
|
46
|
+
// We can't easily chown a dir to root without root. Instead, exercise
|
|
47
|
+
// the branch via a faked statSync output: build a path that triggers
|
|
48
|
+
// the "owner is 0, prefer process UID" code path by ensuring real
|
|
49
|
+
// tempDir owner is the process UID and asserting the result for a
|
|
50
|
+
// missing path matches process UID (already covered above). The
|
|
51
|
+
// explicit 0-check is enforced by the implementation; this test
|
|
52
|
+
// documents that the function never *returns* 0 for any of the
|
|
53
|
+
// exercised inputs in a non-root test process.
|
|
54
|
+
const ids = resolveOperatorIds(tempDir);
|
|
55
|
+
if (process.platform === "win32") {
|
|
56
|
+
expect(ids).toBeNull();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
expect(ids).not.toBeNull();
|
|
60
|
+
expect(ids!.uid).toBeGreaterThan(0);
|
|
61
|
+
expect(ids!.gid).toBeGreaterThan(0);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("returns null when BOTH homeDir owner and process UID/GID are 0 (root install on root-owned OP_HOME)", () => {
|
|
65
|
+
if (process.platform === "win32") {
|
|
66
|
+
// win32 short-circuits before any of this logic
|
|
67
|
+
expect(resolveOperatorIds(tempDir)).toBeNull();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Stub process.getuid / getgid to simulate running as root. On Linux,
|
|
72
|
+
// `/` is owned by uid=0 gid=0, so passing "/" gives us a root-owned
|
|
73
|
+
// homeDir. Combined with the stubbed process IDs, this hits the
|
|
74
|
+
// "both signals are root" branch that previously returned {0,0}.
|
|
75
|
+
const origGetuid = process.getuid;
|
|
76
|
+
const origGetgid = process.getgid;
|
|
77
|
+
try {
|
|
78
|
+
(process as unknown as { getuid: () => number }).getuid = () => 0;
|
|
79
|
+
(process as unknown as { getgid: () => number }).getgid = () => 0;
|
|
80
|
+
// Sanity-check the assumption that "/" is root-owned in this env
|
|
81
|
+
// before relying on it as a fixture. On macOS / Linux CI runners
|
|
82
|
+
// this holds; if a future weird env breaks it, the assertion
|
|
83
|
+
// surfaces clearly rather than producing a confusing pass.
|
|
84
|
+
const rootStat = statSync("/");
|
|
85
|
+
expect(rootStat.uid).toBe(0);
|
|
86
|
+
expect(rootStat.gid).toBe(0);
|
|
87
|
+
|
|
88
|
+
const ids = resolveOperatorIds("/");
|
|
89
|
+
expect(ids).toBeNull();
|
|
90
|
+
} finally {
|
|
91
|
+
(process as unknown as { getuid: typeof origGetuid }).getuid = origGetuid;
|
|
92
|
+
(process as unknown as { getgid: typeof origGetgid }).getgid = origGetgid;
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("returns null on win32", () => {
|
|
97
|
+
// This test is informational; on non-win32 it doesn't run the win32
|
|
98
|
+
// branch. The check is left here for documentation and runs as a
|
|
99
|
+
// no-op assertion on POSIX.
|
|
100
|
+
if (process.platform === "win32") {
|
|
101
|
+
expect(resolveOperatorIds(tempDir)).toBeNull();
|
|
102
|
+
} else {
|
|
103
|
+
// No-op: confirms the test compiles and the helper is callable.
|
|
104
|
+
expect(typeof resolveOperatorIds).toBe("function");
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("hasUsableOperatorId", () => {
|
|
110
|
+
test("returns true for positive numeric values", () => {
|
|
111
|
+
expect(hasUsableOperatorId({ OP_UID: "1000" }, "OP_UID")).toBe(true);
|
|
112
|
+
expect(hasUsableOperatorId({ OP_GID: "501" }, "OP_GID")).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("returns false for missing key", () => {
|
|
116
|
+
expect(hasUsableOperatorId({}, "OP_UID")).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("returns false for empty string", () => {
|
|
120
|
+
expect(hasUsableOperatorId({ OP_UID: "" }, "OP_UID")).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("returns false for zero", () => {
|
|
124
|
+
expect(hasUsableOperatorId({ OP_UID: "0" }, "OP_UID")).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("returns false for non-numeric garbage", () => {
|
|
128
|
+
expect(hasUsableOperatorId({ OP_UID: "abc" }, "OP_UID")).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -31,8 +31,6 @@ export const assistantConfigDir = (s: ControlPlaneState): string => `${s.conf
|
|
|
31
31
|
export const stackEnvPath = (s: ControlPlaneState): string => `${s.stackDir}/stack.env`;
|
|
32
32
|
/** Guardian HMAC channel secrets */
|
|
33
33
|
export const guardianEnvPath = (s: ControlPlaneState): string => `${s.stackDir}/guardian.env`;
|
|
34
|
-
/** Stack spec: capability assignments */
|
|
35
|
-
export const stackSpecFilePath = (s: ControlPlaneState): string => `${s.stackDir}/stack.yml`;
|
|
36
34
|
|
|
37
35
|
// ── Cache directory — regenerable/semi-persistent ───────────────────────────
|
|
38
36
|
|
|
@@ -52,8 +50,15 @@ export const akmStateDir = (s: ControlPlaneState): string => `${s.stat
|
|
|
52
50
|
export const taskLogDir = (s: ControlPlaneState, id: string): string => `${s.cacheDir}/akm/tasks/logs/${id}`;
|
|
53
51
|
export const taskLogsRootDir = (s: ControlPlaneState): string => `${s.cacheDir}/akm/tasks/logs`;
|
|
54
52
|
export const logsDir = (s: ControlPlaneState): string => `${s.stateDir}/logs`;
|
|
55
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Guardian's own audit log of channel ingress (HMAC verify, replay, rate
|
|
55
|
+
* limit). Phase 6 of the auth/proxy refactor removed the OpenPalm-side
|
|
56
|
+
* `admin-audit.jsonl` — OpenCode session logs are the audit trail for
|
|
57
|
+
* chat + tool activity.
|
|
58
|
+
*/
|
|
56
59
|
export const guardianAuditPath = (s: ControlPlaneState): string => `${s.stateDir}/logs/guardian-audit.log`;
|
|
60
|
+
/** One-shot 0.11.0 migration log (OP_UI_TOKEN → OPENCODE_SERVER_PASSWORD, endpoints.json move) */
|
|
61
|
+
export const migration0110LogPath = (s: ControlPlaneState): string => `${s.stateDir}/logs/migration-0.11.0.log`;
|
|
57
62
|
export const backupsDir = (s: ControlPlaneState): string => `${s.stateDir}/backups`;
|
|
58
63
|
export const registryDir = (s: ControlPlaneState): string => `${s.stateDir}/registry`;
|
|
59
64
|
export const registryAddonsDir = (s: ControlPlaneState): string => `${s.stateDir}/registry/addons`;
|
|
@@ -343,8 +343,9 @@ describe("registry component sensitive fields", () => {
|
|
|
343
343
|
|
|
344
344
|
for (const id of fullAddonIds) {
|
|
345
345
|
it(`${id}: has at least one @sensitive field (channel secret)`, () => {
|
|
346
|
-
// ollama
|
|
347
|
-
|
|
346
|
+
// ollama and voice are local inference servers — no channel secret
|
|
347
|
+
// or upstream API key needed (LAN-only, no auth by design).
|
|
348
|
+
if (id === "ollama" || id === "voice") return;
|
|
348
349
|
const schema = readComponentFile(id, ".env.schema");
|
|
349
350
|
const entries = parseEnvSchema(schema);
|
|
350
351
|
const sensitiveEntries = entries.filter((e) =>
|
|
@@ -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 ─────────────────────────────────────────────────
|
|
@@ -306,10 +310,11 @@ describe("materialized registry catalog", () => {
|
|
|
306
310
|
|
|
307
311
|
materializeRegistryCatalog(sourceRoot);
|
|
308
312
|
|
|
309
|
-
|
|
313
|
+
const stackDir = join(process.env.OP_HOME!, 'config', 'stack');
|
|
314
|
+
expect(setAddonEnabled(process.env.OP_HOME!, stackDir, 'chat', true)).toMatchObject({ ok: true });
|
|
310
315
|
expect(existsSync(join(process.env.OP_HOME!, 'config', 'stack', 'addons', 'chat', 'compose.yml'))).toBe(true);
|
|
311
316
|
|
|
312
|
-
expect(
|
|
317
|
+
expect(setAddonEnabled(process.env.OP_HOME!, stackDir, 'chat', false)).toMatchObject({ ok: true });
|
|
313
318
|
expect(existsSync(join(process.env.OP_HOME!, 'config', 'stack', 'addons', 'chat'))).toBe(false);
|
|
314
319
|
});
|
|
315
320
|
|
|
@@ -391,6 +396,67 @@ describe("materialized registry catalog", () => {
|
|
|
391
396
|
expect(existsSync(join(otherHome, 'backups', 'config', 'stack.yml'))).toBe(false);
|
|
392
397
|
});
|
|
393
398
|
|
|
399
|
+
it("parses compose profiles + openpalm.profile.* labels per addon", () => {
|
|
400
|
+
const sourceRoot = join(tmpDir, 'repo');
|
|
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
|
+
|
|
394
460
|
it("installs and uninstalls automations through stash/tasks", () => {
|
|
395
461
|
const sourceRoot = join(tmpDir, 'repo');
|
|
396
462
|
const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
|
|
@@ -414,3 +480,131 @@ describe("materialized registry catalog", () => {
|
|
|
414
480
|
expect(existsSync(join(stashDir, 'tasks', 'cleanup.md'))).toBe(false);
|
|
415
481
|
});
|
|
416
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
|
+
});
|