@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
package/README.md
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
Shared control-plane library for OpenPalm.
|
|
4
4
|
CLI, admin, and scheduler use this package so stack behavior stays consistent.
|
|
5
5
|
|
|
6
|
+
> **Bun required.** This package ships TypeScript source and relies on Bun's native TS execution. It does not compile to JavaScript and is not compatible with Node.js.
|
|
7
|
+
|
|
6
8
|
The current model is direct-write over `~/.openpalm/` plus native Docker Compose.
|
|
7
9
|
Compose files in `stack/` and env files in `vault/` are the live runtime inputs.
|
|
8
10
|
|
package/package.json
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openpalm/lib",
|
|
3
|
-
"version": "0.11.0-beta.
|
|
3
|
+
"version": "0.11.0-beta.7",
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Shared control-plane library for OpenPalm — lifecycle, staging, secrets, channels, connections, scheduler",
|
|
7
|
+
"engines": {
|
|
8
|
+
"bun": ">=1.0.0"
|
|
9
|
+
},
|
|
7
10
|
"scripts": {
|
|
8
11
|
"test": "bun test"
|
|
9
12
|
},
|
|
@@ -244,7 +244,11 @@ export async function deleteAkmVaultKey(
|
|
|
244
244
|
const vaultPath = await ensureAkmUserVault(state, env);
|
|
245
245
|
if (!vaultPath) return false;
|
|
246
246
|
try {
|
|
247
|
-
|
|
247
|
+
// --yes: newer akm versions require explicit confirmation for any
|
|
248
|
+
// destructive operation in non-interactive mode. Without this flag
|
|
249
|
+
// the command exits with NON_INTERACTIVE_REQUIRES_YES and our
|
|
250
|
+
// delete looks like a hard failure instead of an idempotent unset.
|
|
251
|
+
await execAkm(["vault", "unset", "--yes", AKM_USER_VAULT_REF, key], env);
|
|
248
252
|
} catch (err) {
|
|
249
253
|
// `unset` of a missing key is a benign no-op; many akm versions exit 0
|
|
250
254
|
// anyway. If akm hard-fails (non-zero, non-empty stderr) we surface it.
|
|
@@ -19,9 +19,11 @@ function isValidChannelName(name: string): boolean {
|
|
|
19
19
|
// ── Channel Discovery ─────────────────────────────────────────────────
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
* Check if a compose file defines a channel service (has CHANNEL_NAME
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* Check if a compose file defines a channel service (has CHANNEL_NAME).
|
|
23
|
+
* Compose-derived: we parse the actual compose content rather than rely on
|
|
24
|
+
* filename or directory naming conventions. (GUARDIAN_URL used to be a
|
|
25
|
+
* fallback signal — it's been removed since channels-sdk now hardcodes the
|
|
26
|
+
* in-network guardian URL.)
|
|
25
27
|
*/
|
|
26
28
|
export function isChannelAddon(composePath: string): boolean {
|
|
27
29
|
try {
|
|
@@ -36,9 +38,9 @@ export function isChannelAddon(composePath: string): boolean {
|
|
|
36
38
|
const env = (svcDef as Record<string, unknown>).environment;
|
|
37
39
|
if (typeof env === "object" && env !== null) {
|
|
38
40
|
if (Array.isArray(env)) {
|
|
39
|
-
if (env.some((e: unknown) => typeof e === "string" &&
|
|
41
|
+
if (env.some((e: unknown) => typeof e === "string" && e.startsWith("CHANNEL_NAME="))) return true;
|
|
40
42
|
} else {
|
|
41
|
-
if ("CHANNEL_NAME" in (env as Record<string, unknown>)
|
|
43
|
+
if ("CHANNEL_NAME" in (env as Record<string, unknown>)) return true;
|
|
42
44
|
}
|
|
43
45
|
}
|
|
44
46
|
}
|
|
@@ -51,7 +53,7 @@ export function isChannelAddon(composePath: string): boolean {
|
|
|
51
53
|
/**
|
|
52
54
|
* Discover installed channels by scanning stack/addons/ for channel addons.
|
|
53
55
|
* A channel addon is identified by compose-derived truth: its compose.yml
|
|
54
|
-
* defines services with CHANNEL_NAME
|
|
56
|
+
* defines services with a CHANNEL_NAME environment variable.
|
|
55
57
|
*
|
|
56
58
|
* Non-channel addons (admin, ollama, etc.) are excluded.
|
|
57
59
|
*
|
|
@@ -6,7 +6,6 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { tmpdir } from "node:os";
|
|
8
8
|
import {
|
|
9
|
-
COMPOSE_PROJECT_NAME,
|
|
10
9
|
buildComposeOptions,
|
|
11
10
|
buildComposeCliArgs,
|
|
12
11
|
} from "./compose-args.js";
|
|
@@ -63,14 +62,6 @@ afterEach(() => {
|
|
|
63
62
|
rmSync(tempDir, { recursive: true, force: true });
|
|
64
63
|
});
|
|
65
64
|
|
|
66
|
-
// ── COMPOSE_PROJECT_NAME ─────────────────────────────────────────────────
|
|
67
|
-
|
|
68
|
-
describe("COMPOSE_PROJECT_NAME", () => {
|
|
69
|
-
it("is 'openpalm'", () => {
|
|
70
|
-
expect(COMPOSE_PROJECT_NAME).toBe("openpalm");
|
|
71
|
-
});
|
|
72
|
-
});
|
|
73
|
-
|
|
74
65
|
// ── buildComposeOptions ──────────────────────────────────────────────────
|
|
75
66
|
|
|
76
67
|
describe("buildComposeOptions", () => {
|
|
@@ -11,10 +11,6 @@ import { buildComposeFileList } from "./lifecycle.js";
|
|
|
11
11
|
import { buildEnvFiles } from "./config-persistence.js";
|
|
12
12
|
import { resolveComposeProjectName } from "./docker.js";
|
|
13
13
|
|
|
14
|
-
// ── Constants ────────────────────────────────────────────────────────────
|
|
15
|
-
|
|
16
|
-
export const COMPOSE_PROJECT_NAME = "openpalm";
|
|
17
|
-
|
|
18
14
|
// ── Types ────────────────────────────────────────────────────────────────
|
|
19
15
|
|
|
20
16
|
export type ComposeOptions = {
|
|
@@ -12,6 +12,7 @@ import { parseEnvFile, mergeEnvContent, expandEnvVars } from './env.js';
|
|
|
12
12
|
import type { ControlPlaneState, ArtifactMeta } from "./types.js";
|
|
13
13
|
import { isChannelAddon } from "./channels.js";
|
|
14
14
|
import { listEnabledAddonIds } from "./registry.js";
|
|
15
|
+
import { resolveOperatorIds, hasUsableOperatorId } from "./operator-ids.js";
|
|
15
16
|
|
|
16
17
|
import {
|
|
17
18
|
readCoreCompose,
|
|
@@ -69,16 +70,35 @@ export function writeSystemEnv(state: ControlPlaneState): void {
|
|
|
69
70
|
OP_SETUP_COMPLETE: alreadyComplete ? "true" : "false"
|
|
70
71
|
};
|
|
71
72
|
|
|
73
|
+
// Backfill OP_UID/OP_GID when the existing stack.env was written by an
|
|
74
|
+
// older code path that hard-coded 1000, or when the file was created
|
|
75
|
+
// with missing/zero values. We only override when the current value is
|
|
76
|
+
// missing or zero — an operator who manually set OP_UID=2000 (e.g.
|
|
77
|
+
// because they're running on a host with a non-1000 service account)
|
|
78
|
+
// must not be silently changed.
|
|
79
|
+
const parsed = parseEnvFile(systemEnvPath);
|
|
80
|
+
const ids = resolveOperatorIds(state.homeDir);
|
|
81
|
+
if (ids) {
|
|
82
|
+
if (!hasUsableOperatorId(parsed, "OP_UID")) adminManaged.OP_UID = String(ids.uid);
|
|
83
|
+
if (!hasUsableOperatorId(parsed, "OP_GID")) adminManaged.OP_GID = String(ids.gid);
|
|
84
|
+
}
|
|
85
|
+
|
|
72
86
|
const content = mergeEnvContent(base, adminManaged, {
|
|
73
87
|
sectionHeader: "# ── Admin-managed ──────────────────────────────────────────────────"
|
|
74
88
|
});
|
|
75
89
|
|
|
76
|
-
writeFileSync(systemEnvPath, content);
|
|
90
|
+
writeFileSync(systemEnvPath, content, { mode: 0o600 });
|
|
91
|
+
chmodSync(systemEnvPath, 0o600);
|
|
77
92
|
}
|
|
78
93
|
|
|
79
94
|
function generateFallbackSystemEnv(state: ControlPlaneState): string {
|
|
80
|
-
|
|
81
|
-
|
|
95
|
+
// Operator UID/GID — auto-detect from OP_HOME owner (or process UID).
|
|
96
|
+
// Skipped on Windows where containers run in WSL2 and OP_UID has no
|
|
97
|
+
// meaning on the host process.
|
|
98
|
+
const ids = resolveOperatorIds(state.homeDir);
|
|
99
|
+
const idLines: string[] = ids
|
|
100
|
+
? [`OP_UID=${ids.uid}`, `OP_GID=${ids.gid}`]
|
|
101
|
+
: [];
|
|
82
102
|
|
|
83
103
|
return [
|
|
84
104
|
"# OpenPalm — System Configuration (managed by CLI/admin)",
|
|
@@ -92,18 +112,18 @@ function generateFallbackSystemEnv(state: ControlPlaneState): string {
|
|
|
92
112
|
"",
|
|
93
113
|
"# ── Paths ──────────────────────────────────────────────────────────",
|
|
94
114
|
`OP_HOME=${state.homeDir}`,
|
|
95
|
-
|
|
96
|
-
`OP_GID=${gid}`,
|
|
115
|
+
...idLines,
|
|
97
116
|
"",
|
|
98
117
|
"# ── Images ──────────────────────────────────────────────────────────",
|
|
99
118
|
`OP_IMAGE_NAMESPACE=${process.env.OP_IMAGE_NAMESPACE ?? "openpalm"}`,
|
|
100
119
|
`OP_IMAGE_TAG=${DEFAULT_IMAGE_TAG}`,
|
|
101
120
|
"",
|
|
102
121
|
"# ── Ports (38XX range) ──────────────────────────────────────────────",
|
|
122
|
+
"# Guardian is network-only (no host port) — channels reach it via",
|
|
123
|
+
"# http://guardian:8080 over the channel_lan Docker network.",
|
|
103
124
|
`OP_ASSISTANT_PORT=3800`,
|
|
104
125
|
`OP_ADMIN_PORT=3880`,
|
|
105
126
|
`OP_ADMIN_OPENCODE_PORT=3881`,
|
|
106
|
-
`OP_GUARDIAN_PORT=3899`,
|
|
107
127
|
""
|
|
108
128
|
].join("\n");
|
|
109
129
|
}
|
|
@@ -126,8 +146,21 @@ export function discoverStackOverlays(stackDir: string): string[] {
|
|
|
126
146
|
.filter((e) => e.isDirectory())
|
|
127
147
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
128
148
|
for (const entry of entries) {
|
|
129
|
-
const
|
|
130
|
-
|
|
149
|
+
const dir = `${addonsDir}/${entry.name}`;
|
|
150
|
+
// Pick up compose.yml plus any compose.<variant>.yml sibling
|
|
151
|
+
// overlays (e.g. compose.cdi.yml generated by /admin/voice on
|
|
152
|
+
// CDI hosts). Stable sort: compose.yml first, then siblings
|
|
153
|
+
// alphabetically, so the base file's keys are the defaults and
|
|
154
|
+
// overlays merge on top in deterministic order.
|
|
155
|
+
const overlays = readdirSync(dir, { withFileTypes: true })
|
|
156
|
+
.filter((e) => e.isFile() && /^compose(\.[A-Za-z0-9_-]+)?\.ya?ml$/.test(e.name))
|
|
157
|
+
.map((e) => e.name)
|
|
158
|
+
.sort((a, b) => {
|
|
159
|
+
if (a === "compose.yml" || a === "compose.yaml") return -1;
|
|
160
|
+
if (b === "compose.yml" || b === "compose.yaml") return 1;
|
|
161
|
+
return a.localeCompare(b);
|
|
162
|
+
});
|
|
163
|
+
for (const name of overlays) files.push(`${dir}/${name}`);
|
|
131
164
|
}
|
|
132
165
|
}
|
|
133
166
|
|
|
@@ -225,7 +258,7 @@ export function writeChannelSecrets(stackDir: string, secrets: Record<string, st
|
|
|
225
258
|
* (e.g. `/var/run/docker.sock`) are left alone.
|
|
226
259
|
*/
|
|
227
260
|
export function ensureComposeVolumeTargets(state: ControlPlaneState): void {
|
|
228
|
-
const composeFiles = discoverStackOverlays(
|
|
261
|
+
const composeFiles = discoverStackOverlays(state.stackDir);
|
|
229
262
|
if (composeFiles.length === 0) return;
|
|
230
263
|
|
|
231
264
|
const envVars: Record<string, string> = {
|
|
@@ -281,9 +314,13 @@ export function ensureComposeVolumeTargets(state: ControlPlaneState): void {
|
|
|
281
314
|
export function writeRuntimeFiles(
|
|
282
315
|
state: ControlPlaneState
|
|
283
316
|
): void {
|
|
284
|
-
// Write core compose to config/stack/
|
|
317
|
+
// Write core compose to config/stack/ only on first install —
|
|
318
|
+
// refreshCoreAssets() is the canonical writer on update.
|
|
285
319
|
mkdirSync(state.stackDir, { recursive: true });
|
|
286
|
-
|
|
320
|
+
const composePath = `${state.stackDir}/core.compose.yml`;
|
|
321
|
+
if (!existsSync(composePath)) {
|
|
322
|
+
writeFileSync(composePath, state.artifacts.compose);
|
|
323
|
+
}
|
|
287
324
|
|
|
288
325
|
// Load persisted channel HMAC secrets from guardian.env,
|
|
289
326
|
// then generate new ones for new channel addons.
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { mkdirSync, writeFileSync, readFileSync, existsSync, copyFileSync } from "node:fs";
|
|
13
13
|
import { dirname, join, resolve, sep } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
14
15
|
import { resolveStateDir, resolveOpenPalmHome, resolveBackupsDir, resolveStashDir } from "./home.js";
|
|
15
16
|
import { createLogger } from "../logger.js";
|
|
16
17
|
import { sha256 } from "./crypto.js";
|
|
@@ -80,15 +81,31 @@ export function seedStashAssets(seeds: Record<string, string>): string[] {
|
|
|
80
81
|
// ── Asset Refresh (GitHub download) ──────────────────────────────────
|
|
81
82
|
|
|
82
83
|
const REPO = "itlackey/openpalm";
|
|
83
|
-
const VERSION = process.env.OP_ASSET_VERSION ?? "main";
|
|
84
84
|
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
function resolveAssetVersion(): string {
|
|
86
|
+
if (process.env.OP_ASSET_VERSION) return process.env.OP_ASSET_VERSION;
|
|
87
|
+
try {
|
|
88
|
+
const pkgJson = JSON.parse(
|
|
89
|
+
readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../../package.json"), "utf-8")
|
|
90
|
+
);
|
|
91
|
+
return `v${pkgJson.version}`;
|
|
92
|
+
} catch {
|
|
93
|
+
return "main";
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const VERSION = resolveAssetVersion();
|
|
97
|
+
|
|
98
|
+
// Persona files (openpalm.md, system.md), stash seeds, and user-editable config
|
|
99
|
+
// files are intentionally NOT in this list. They are seeded once (never
|
|
100
|
+
// overwritten) via seedOpenPalmDir (skipExisting) or SEEDED_ASSETS below.
|
|
87
101
|
const MANAGED_ASSETS: { relPath: string; githubFilename: string }[] = [
|
|
88
|
-
{ relPath: "config/stack/core.compose.yml",
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
102
|
+
{ relPath: "config/stack/core.compose.yml", githubFilename: ".openpalm/config/stack/core.compose.yml" },
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
// Seeded once — written only when the file does not exist yet.
|
|
106
|
+
// User edits always win; upgrade never touches these files.
|
|
107
|
+
const SEEDED_ASSETS: { relPath: string; githubFilename: string }[] = [
|
|
108
|
+
{ relPath: "config/assistant/opencode.jsonc", githubFilename: ".openpalm/config/assistant/opencode.jsonc" },
|
|
92
109
|
];
|
|
93
110
|
|
|
94
111
|
async function downloadAsset(filename: string): Promise<string> {
|
|
@@ -137,5 +154,44 @@ export async function refreshCoreAssets(): Promise<{
|
|
|
137
154
|
updated.push(asset.relPath);
|
|
138
155
|
}
|
|
139
156
|
|
|
157
|
+
// Seed user-editable assets only when missing — never overwrite.
|
|
158
|
+
for (const asset of SEEDED_ASSETS) {
|
|
159
|
+
const targetPath = join(homeDir, asset.relPath);
|
|
160
|
+
if (existsSync(targetPath)) continue;
|
|
161
|
+
const freshContent = await downloadAsset(asset.githubFilename);
|
|
162
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
163
|
+
writeFileSync(targetPath, freshContent);
|
|
164
|
+
updated.push(asset.relPath);
|
|
165
|
+
}
|
|
166
|
+
|
|
140
167
|
return { backupDir, updated };
|
|
141
168
|
}
|
|
169
|
+
|
|
170
|
+
// ── Assistant Persona File Seeding ────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Seed assistant persona files (openpalm.md, system.md) into OP_HOME.
|
|
174
|
+
*
|
|
175
|
+
* Idempotent: **never overwrites** an existing file — user edits always
|
|
176
|
+
* win. This preserves the "config/ is user-owned" contract: persona files
|
|
177
|
+
* are seeded once on first install and never touched again on update.
|
|
178
|
+
*
|
|
179
|
+
* `seeds` maps relative path keys (e.g. `"config/assistant/openpalm.md"`)
|
|
180
|
+
* to file content. Each file is written to `resolveOpenPalmHome()/<relPath>`
|
|
181
|
+
* only if the file does not already exist.
|
|
182
|
+
*
|
|
183
|
+
* Returns the list of relative paths that were actually written (empty on
|
|
184
|
+
* re-run when every seed already exists on disk).
|
|
185
|
+
*/
|
|
186
|
+
export function seedAssistantPersonaFiles(seeds: Record<string, string>): string[] {
|
|
187
|
+
const homeDir = resolveOpenPalmHome();
|
|
188
|
+
const written: string[] = [];
|
|
189
|
+
for (const [relPath, content] of Object.entries(seeds)) {
|
|
190
|
+
const targetPath = join(homeDir, relPath);
|
|
191
|
+
if (existsSync(targetPath)) continue;
|
|
192
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
193
|
+
writeFileSync(targetPath, content);
|
|
194
|
+
written.push(relPath);
|
|
195
|
+
}
|
|
196
|
+
return written;
|
|
197
|
+
}
|
|
@@ -295,26 +295,37 @@ export async function composeLogs(
|
|
|
295
295
|
return run(args, undefined);
|
|
296
296
|
}
|
|
297
297
|
|
|
298
|
+
// 60-minute pull timeout. Voice addon ships a ~2.4 GB image (CPU) /
|
|
299
|
+
// ~7.6 GB (CUDA); on a 1-2 Mbps home connection these legitimately take
|
|
300
|
+
// 30+ minutes. The previous 5-min cap silently killed pulls mid-stream
|
|
301
|
+
// on first install, surfacing as an opaque "pull failed". The wizard's
|
|
302
|
+
// retry layer wraps this, so an actually-hung pull is bounded by the
|
|
303
|
+
// outer retry budget; this just gives any progressing pull room to
|
|
304
|
+
// finish on slow connections.
|
|
305
|
+
const PULL_TIMEOUT_MS = 60 * 60_000;
|
|
306
|
+
|
|
298
307
|
/**
|
|
299
308
|
* Pull image for a single service.
|
|
300
309
|
*/
|
|
301
310
|
export async function composePullService(
|
|
302
311
|
service: string,
|
|
303
|
-
options: { files: string[]; envFiles?: string[] }
|
|
312
|
+
options: { files: string[]; envFiles?: string[]; profiles?: string[] }
|
|
304
313
|
): Promise<DockerResult> {
|
|
305
314
|
await runPreflight(options);
|
|
306
315
|
const args = buildComposeArgs(options);
|
|
316
|
+
for (const p of options.profiles ?? []) args.push("--profile", p);
|
|
307
317
|
args.push("pull", service);
|
|
308
|
-
return run(args, undefined,
|
|
318
|
+
return run(args, undefined, PULL_TIMEOUT_MS, collectEnvOverrides(options.envFiles));
|
|
309
319
|
}
|
|
310
320
|
|
|
311
321
|
export async function composePull(
|
|
312
|
-
options: { files: string[]; envFiles?: string[] }
|
|
322
|
+
options: { files: string[]; envFiles?: string[]; profiles?: string[] }
|
|
313
323
|
): Promise<DockerResult> {
|
|
314
324
|
await runPreflight(options);
|
|
315
325
|
const args = buildComposeArgs(options);
|
|
326
|
+
for (const p of options.profiles ?? []) args.push("--profile", p);
|
|
316
327
|
args.push("pull");
|
|
317
|
-
return run(args, undefined,
|
|
328
|
+
return run(args, undefined, PULL_TIMEOUT_MS, collectEnvOverrides(options.envFiles));
|
|
318
329
|
}
|
|
319
330
|
|
|
320
331
|
/**
|
package/src/control-plane/env.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { parse as dotenvParse } from 'dotenv';
|
|
2
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { readFileSync, existsSync, copyFileSync } from 'node:fs';
|
|
3
3
|
|
|
4
4
|
export function parseEnvContent(content: string): Record<string, string> {
|
|
5
5
|
return dotenvParse(content);
|
|
@@ -10,6 +10,9 @@ export function parseEnvFile(filePath: string): Record<string, string> {
|
|
|
10
10
|
try {
|
|
11
11
|
return dotenvParse(readFileSync(filePath, 'utf-8'));
|
|
12
12
|
} catch {
|
|
13
|
+
// File is unreadable or malformed — back it up before returning empty so
|
|
14
|
+
// the next write doesn't silently discard all existing values.
|
|
15
|
+
try { copyFileSync(filePath, `${filePath}.corrupt-${Date.now()}`); } catch { /* best-effort */ }
|
|
13
16
|
return {};
|
|
14
17
|
}
|
|
15
18
|
}
|
|
@@ -142,8 +142,8 @@ function seedMinimalEnvFiles(): void {
|
|
|
142
142
|
"GROQ_API_KEY=",
|
|
143
143
|
"MISTRAL_API_KEY=",
|
|
144
144
|
"GOOGLE_API_KEY=",
|
|
145
|
-
"
|
|
146
|
-
"
|
|
145
|
+
"OP_OWNER_NAME=",
|
|
146
|
+
"OP_OWNER_EMAIL=",
|
|
147
147
|
"",
|
|
148
148
|
].join("\n")
|
|
149
149
|
);
|
|
@@ -187,7 +187,7 @@ describe("Fresh Install", () => {
|
|
|
187
187
|
// API keys and owner info are seeded in state/stack.env.
|
|
188
188
|
const stackContent = readFileSync(join(stackDir, "stack.env"), "utf-8");
|
|
189
189
|
expect(stackContent).toContain("OPENAI_API_KEY=");
|
|
190
|
-
expect(stackContent).toContain("
|
|
190
|
+
expect(stackContent).toContain("OP_OWNER_NAME=");
|
|
191
191
|
});
|
|
192
192
|
|
|
193
193
|
// Scenario 2: isSetupComplete returns false before setup
|
|
@@ -25,7 +25,7 @@ import { refreshCoreAssets } from "./core-assets.js";
|
|
|
25
25
|
import { isSetupComplete } from "./setup-status.js";
|
|
26
26
|
import { snapshotCurrentState } from "./rollback.js";
|
|
27
27
|
import { checkDocker, composePreflight, composePull, composeUp, composeConfigServices, resolveComposeProjectName } from "./docker.js";
|
|
28
|
-
import {
|
|
28
|
+
import { acquireInstallLock, releaseInstallLock } from "./install-lock.js";
|
|
29
29
|
import { listEnabledAddonIds } from "./registry.js";
|
|
30
30
|
|
|
31
31
|
const IMAGE_NAMESPACE_RE = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;
|
|
@@ -125,7 +125,8 @@ async function reconcileCore(
|
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
export async function applyInstall(state: ControlPlaneState): Promise<void> {
|
|
128
|
-
const lock =
|
|
128
|
+
const lock = acquireInstallLock(state.stateDir);
|
|
129
|
+
if (!lock) throw new Error("Another install is already in progress");
|
|
129
130
|
try {
|
|
130
131
|
await reconcileCore(state, { activateServices: true });
|
|
131
132
|
// Pre-create host-side volume mount targets as the current user so
|
|
@@ -133,25 +134,27 @@ export async function applyInstall(state: ControlPlaneState): Promise<void> {
|
|
|
133
134
|
// non-root containers).
|
|
134
135
|
ensureComposeVolumeTargets(state);
|
|
135
136
|
} finally {
|
|
136
|
-
|
|
137
|
+
releaseInstallLock(lock);
|
|
137
138
|
}
|
|
138
139
|
}
|
|
139
140
|
|
|
140
141
|
export async function applyUpdate(state: ControlPlaneState): Promise<{ restarted: string[] }> {
|
|
141
|
-
const lock =
|
|
142
|
+
const lock = acquireInstallLock(state.stateDir);
|
|
143
|
+
if (!lock) throw new Error("Another install is already in progress");
|
|
142
144
|
try {
|
|
143
145
|
return { restarted: await reconcileCore(state, {}) };
|
|
144
146
|
} finally {
|
|
145
|
-
|
|
147
|
+
releaseInstallLock(lock);
|
|
146
148
|
}
|
|
147
149
|
}
|
|
148
150
|
|
|
149
151
|
export async function applyUninstall(state: ControlPlaneState): Promise<{ stopped: string[] }> {
|
|
150
|
-
const lock =
|
|
152
|
+
const lock = acquireInstallLock(state.stateDir);
|
|
153
|
+
if (!lock) throw new Error("Another install is already in progress");
|
|
151
154
|
try {
|
|
152
155
|
return { stopped: await reconcileCore(state, { deactivateServices: true }) };
|
|
153
156
|
} finally {
|
|
154
|
-
|
|
157
|
+
releaseInstallLock(lock);
|
|
155
158
|
}
|
|
156
159
|
}
|
|
157
160
|
|
|
@@ -218,13 +221,14 @@ export async function applyUpgrade(
|
|
|
218
221
|
updated: string[];
|
|
219
222
|
restarted: string[];
|
|
220
223
|
}> {
|
|
221
|
-
const lock =
|
|
224
|
+
const lock = acquireInstallLock(state.stateDir);
|
|
225
|
+
if (!lock) throw new Error("Another install is already in progress");
|
|
222
226
|
try {
|
|
223
227
|
const { backupDir, updated } = await refreshCoreAssets();
|
|
224
228
|
const restarted = await reconcileCore(state, {});
|
|
225
229
|
return { backupDir, updated, restarted };
|
|
226
230
|
} finally {
|
|
227
|
-
|
|
231
|
+
releaseInstallLock(lock);
|
|
228
232
|
}
|
|
229
233
|
}
|
|
230
234
|
|
|
@@ -296,6 +300,24 @@ export async function performUpgrade(state: ControlPlaneState): Promise<UpgradeR
|
|
|
296
300
|
};
|
|
297
301
|
}
|
|
298
302
|
|
|
303
|
+
/**
|
|
304
|
+
* Set a specific image tag in stack.env then pull images and restart containers.
|
|
305
|
+
* Used by the admin "set version" action — skips the auto-detect step in performUpgrade.
|
|
306
|
+
*/
|
|
307
|
+
export async function applyTagChange(state: ControlPlaneState, tag: string): Promise<UpgradeResult> {
|
|
308
|
+
const stackEnvPath = `${state.stackDir}/stack.env`;
|
|
309
|
+
const currentContent = existsSync(stackEnvPath) ? readFileSync(stackEnvPath, "utf-8") : "";
|
|
310
|
+
writeFileSync(stackEnvPath, mergeEnvContent(currentContent, { OP_IMAGE_TAG: tag }, { uncomment: true }));
|
|
311
|
+
const upgradeResult = await applyUpgrade(state);
|
|
312
|
+
return {
|
|
313
|
+
imageTag: tag,
|
|
314
|
+
namespace: "openpalm",
|
|
315
|
+
backupDir: upgradeResult.backupDir,
|
|
316
|
+
assetsUpdated: upgradeResult.updated,
|
|
317
|
+
restarted: upgradeResult.restarted,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
299
321
|
export function buildComposeFileList(state: ControlPlaneState): string[] {
|
|
300
322
|
return discoverStackOverlays(state.stackDir);
|
|
301
323
|
}
|
|
@@ -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
|
+
});
|