@openpalm/lib 0.11.0-beta.1 → 0.11.0-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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 +24 -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
|
@@ -14,8 +14,6 @@ import type { ControlPlaneState } from "./types.js";
|
|
|
14
14
|
|
|
15
15
|
function makeState(homeDir: string): ControlPlaneState {
|
|
16
16
|
return {
|
|
17
|
-
adminToken: "test-admin",
|
|
18
|
-
assistantToken: "test-assistant",
|
|
19
17
|
homeDir,
|
|
20
18
|
configDir: join(homeDir, "config"),
|
|
21
19
|
stashDir: join(homeDir, "stash"),
|
|
@@ -26,7 +24,6 @@ function makeState(homeDir: string): ControlPlaneState {
|
|
|
26
24
|
services: {},
|
|
27
25
|
artifacts: { compose: "" },
|
|
28
26
|
artifactMeta: [],
|
|
29
|
-
audit: [],
|
|
30
27
|
};
|
|
31
28
|
}
|
|
32
29
|
|
|
@@ -36,7 +36,7 @@ function makeValidSpec(overrides?: Partial<SetupSpec>): SetupSpec {
|
|
|
36
36
|
version: 2,
|
|
37
37
|
llm: { provider: "openai", model: "gpt-4o", baseUrl: "https://api.openai.com/v1" },
|
|
38
38
|
embedding: { provider: "openai", model: "text-embedding-3-small", dims: 1536, baseUrl: "https://api.openai.com/v1" },
|
|
39
|
-
security: {
|
|
39
|
+
security: { uiLoginPassword: "test-admin-token-12345" },
|
|
40
40
|
owner: { name: "Test User", email: "test@example.com" },
|
|
41
41
|
connections: [
|
|
42
42
|
{
|
|
@@ -135,16 +135,15 @@ function seedMinimalEnvFiles(): void {
|
|
|
135
135
|
join(stackDir, "stack.env"),
|
|
136
136
|
[
|
|
137
137
|
"# OpenPalm — Stack Configuration",
|
|
138
|
-
"
|
|
139
|
-
"OP_ASSISTANT_TOKEN=",
|
|
138
|
+
"OP_UI_LOGIN_PASSWORD=",
|
|
140
139
|
"OPENAI_API_KEY=",
|
|
141
140
|
"OPENAI_BASE_URL=",
|
|
142
141
|
"ANTHROPIC_API_KEY=",
|
|
143
142
|
"GROQ_API_KEY=",
|
|
144
143
|
"MISTRAL_API_KEY=",
|
|
145
144
|
"GOOGLE_API_KEY=",
|
|
146
|
-
"
|
|
147
|
-
"
|
|
145
|
+
"OP_OWNER_NAME=",
|
|
146
|
+
"OP_OWNER_EMAIL=",
|
|
148
147
|
"",
|
|
149
148
|
].join("\n")
|
|
150
149
|
);
|
|
@@ -171,8 +170,6 @@ describe("Fresh Install", () => {
|
|
|
171
170
|
// does create stack.env with required keys when files do not exist.
|
|
172
171
|
it("ensureSecrets creates state/stack.env with required keys on fresh install", () => {
|
|
173
172
|
const state: ControlPlaneState = {
|
|
174
|
-
adminToken: "",
|
|
175
|
-
assistantToken: "",
|
|
176
173
|
homeDir,
|
|
177
174
|
configDir,
|
|
178
175
|
stashDir: join(homeDir, "stash"),
|
|
@@ -183,7 +180,6 @@ describe("Fresh Install", () => {
|
|
|
183
180
|
services: {},
|
|
184
181
|
artifacts: { compose: "" },
|
|
185
182
|
artifactMeta: [],
|
|
186
|
-
audit: [],
|
|
187
183
|
};
|
|
188
184
|
|
|
189
185
|
ensureSecrets(state);
|
|
@@ -191,7 +187,7 @@ describe("Fresh Install", () => {
|
|
|
191
187
|
// API keys and owner info are seeded in state/stack.env.
|
|
192
188
|
const stackContent = readFileSync(join(stackDir, "stack.env"), "utf-8");
|
|
193
189
|
expect(stackContent).toContain("OPENAI_API_KEY=");
|
|
194
|
-
expect(stackContent).toContain("
|
|
190
|
+
expect(stackContent).toContain("OP_OWNER_NAME=");
|
|
195
191
|
});
|
|
196
192
|
|
|
197
193
|
// Scenario 2: isSetupComplete returns false before setup
|
|
@@ -253,11 +249,9 @@ describe("Existing Install", () => {
|
|
|
253
249
|
// Scenario 5: ensureSecrets does NOT overwrite existing stack.env
|
|
254
250
|
it("ensureSecrets does not overwrite existing stack.env tokens", () => {
|
|
255
251
|
mkdirSync(stateDir, { recursive: true });
|
|
256
|
-
writeFileSync(join(stackDir, "stack.env"), "
|
|
252
|
+
writeFileSync(join(stackDir, "stack.env"), "OP_UI_LOGIN_PASSWORD=my-custom-password\n");
|
|
257
253
|
|
|
258
254
|
const state: ControlPlaneState = {
|
|
259
|
-
adminToken: "",
|
|
260
|
-
assistantToken: "",
|
|
261
255
|
homeDir,
|
|
262
256
|
configDir,
|
|
263
257
|
stashDir: join(homeDir, "stash"),
|
|
@@ -268,57 +262,29 @@ describe("Existing Install", () => {
|
|
|
268
262
|
services: {},
|
|
269
263
|
artifacts: { compose: "" },
|
|
270
264
|
artifactMeta: [],
|
|
271
|
-
audit: [],
|
|
272
265
|
};
|
|
273
266
|
|
|
274
267
|
ensureSecrets(state);
|
|
275
268
|
|
|
276
|
-
// Existing
|
|
269
|
+
// Existing password must be preserved
|
|
277
270
|
const afterContent = readFileSync(join(stackDir, "stack.env"), "utf-8");
|
|
278
|
-
expect(afterContent).toContain("
|
|
279
|
-
expect(afterContent).toContain("OP_ASSISTANT_TOKEN=existing-token");
|
|
271
|
+
expect(afterContent).toContain("OP_UI_LOGIN_PASSWORD=my-custom-password");
|
|
280
272
|
});
|
|
281
273
|
|
|
282
|
-
// Scenario 6: performSetup re-run
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
274
|
+
// Scenario 6: performSetup re-run rewrites OP_UI_LOGIN_PASSWORD when the
|
|
275
|
+
// operator supplies a new one in the spec. This is intentional — the
|
|
276
|
+
// wizard "rerun" path is how an operator rotates the password. The
|
|
277
|
+
// legacy OP_ASSISTANT_TOKEN preservation test was removed with the token.
|
|
278
|
+
it("performSetup re-run rewrites OP_UI_LOGIN_PASSWORD when spec changes", async () => {
|
|
279
|
+
await performSetup(makeValidSpec({ security: { uiLoginPassword: "first-password-12345" } }));
|
|
286
280
|
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
"utf-8"
|
|
290
|
-
);
|
|
291
|
-
const firstMatch = secretsAfterFirst.match(
|
|
292
|
-
/OP_ASSISTANT_TOKEN=([a-f0-9]+)/
|
|
293
|
-
);
|
|
294
|
-
expect(firstMatch).not.toBeNull();
|
|
295
|
-
const firstToken = firstMatch![1];
|
|
281
|
+
const afterFirst = readFileSync(join(stackDir, "stack.env"), "utf-8");
|
|
282
|
+
expect(afterFirst).toContain("OP_UI_LOGIN_PASSWORD=first-password-12345");
|
|
296
283
|
|
|
297
|
-
|
|
298
|
-
await performSetup(
|
|
299
|
-
makeValidSpec({
|
|
300
|
-
connections: [
|
|
301
|
-
{
|
|
302
|
-
id: "openai-main",
|
|
303
|
-
name: "OpenAI",
|
|
304
|
-
provider: "openai",
|
|
305
|
-
baseUrl: "https://api.openai.com",
|
|
306
|
-
apiKey: "sk-different-key-999",
|
|
307
|
-
},
|
|
308
|
-
],
|
|
309
|
-
})
|
|
310
|
-
);
|
|
284
|
+
await performSetup(makeValidSpec({ security: { uiLoginPassword: "second-password-12345" } }));
|
|
311
285
|
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
"utf-8"
|
|
315
|
-
);
|
|
316
|
-
const secondMatch = secretsAfterSecond.match(
|
|
317
|
-
/OP_ASSISTANT_TOKEN=([a-f0-9]+)/
|
|
318
|
-
);
|
|
319
|
-
expect(secondMatch).not.toBeNull();
|
|
320
|
-
// OP_ASSISTANT_TOKEN should be preserved across setups
|
|
321
|
-
expect(secondMatch![1]).toBe(firstToken);
|
|
286
|
+
const afterSecond = readFileSync(join(stackDir, "stack.env"), "utf-8");
|
|
287
|
+
expect(afterSecond).toContain("OP_UI_LOGIN_PASSWORD=second-password-12345");
|
|
322
288
|
});
|
|
323
289
|
|
|
324
290
|
// Scenario 7: performSetup must NOT mark OP_SETUP_COMPLETE — see scenario
|
|
@@ -386,11 +352,9 @@ describe("Broken/Corrupt State", () => {
|
|
|
386
352
|
// Scenario 9: ensureSecrets is idempotent on repeated calls
|
|
387
353
|
it("ensureSecrets is idempotent — second call does not overwrite existing stack.env", () => {
|
|
388
354
|
mkdirSync(stateDir, { recursive: true });
|
|
389
|
-
writeFileSync(join(stackDir, "stack.env"), "
|
|
355
|
+
writeFileSync(join(stackDir, "stack.env"), "OP_UI_LOGIN_PASSWORD=existing-password\n");
|
|
390
356
|
|
|
391
357
|
const state: ControlPlaneState = {
|
|
392
|
-
adminToken: "",
|
|
393
|
-
assistantToken: "",
|
|
394
358
|
homeDir,
|
|
395
359
|
configDir,
|
|
396
360
|
stashDir: join(homeDir, "stash"),
|
|
@@ -401,15 +365,13 @@ describe("Broken/Corrupt State", () => {
|
|
|
401
365
|
services: {},
|
|
402
366
|
artifacts: { compose: "" },
|
|
403
367
|
artifactMeta: [],
|
|
404
|
-
audit: [],
|
|
405
368
|
};
|
|
406
369
|
|
|
407
370
|
ensureSecrets(state);
|
|
408
371
|
|
|
409
|
-
// Existing
|
|
372
|
+
// Existing password must be preserved
|
|
410
373
|
const content = readFileSync(join(stackDir, "stack.env"), "utf-8");
|
|
411
|
-
expect(content).toContain("
|
|
412
|
-
expect(content).toContain("OP_ASSISTANT_TOKEN=existing-assistant");
|
|
374
|
+
expect(content).toContain("OP_UI_LOGIN_PASSWORD=existing-password");
|
|
413
375
|
});
|
|
414
376
|
|
|
415
377
|
// Scenario 10: env file with malformed lines
|
|
@@ -447,11 +409,11 @@ describe("Broken/Corrupt State", () => {
|
|
|
447
409
|
expect(isSetupComplete(stackDir)).toBe(false);
|
|
448
410
|
});
|
|
449
411
|
|
|
450
|
-
it("isSetupComplete falls back to true when
|
|
412
|
+
it("isSetupComplete falls back to true when UI login password is set but OP_SETUP_COMPLETE missing", () => {
|
|
451
413
|
mkdirSync(stateDir, { recursive: true });
|
|
452
414
|
writeFileSync(
|
|
453
415
|
join(stackDir, "stack.env"),
|
|
454
|
-
"OP_IMAGE_TAG=latest\nexport
|
|
416
|
+
"OP_IMAGE_TAG=latest\nexport OP_UI_LOGIN_PASSWORD=my-real-password\n"
|
|
455
417
|
);
|
|
456
418
|
|
|
457
419
|
expect(isSetupComplete(stackDir)).toBe(true);
|
|
@@ -527,12 +489,12 @@ describe("Environment Edge Cases", () => {
|
|
|
527
489
|
rmSync(homeDir, { recursive: true, force: true });
|
|
528
490
|
});
|
|
529
491
|
|
|
530
|
-
// Scenario 16:
|
|
531
|
-
it("isSetupComplete detects
|
|
492
|
+
// Scenario 16: isSetupComplete picks up OP_UI_LOGIN_PASSWORD when set
|
|
493
|
+
it("isSetupComplete detects OP_UI_LOGIN_PASSWORD", () => {
|
|
532
494
|
mkdirSync(stateDir, { recursive: true });
|
|
533
495
|
writeFileSync(
|
|
534
496
|
join(stackDir, "stack.env"),
|
|
535
|
-
"SOME_OTHER_KEY=value\nexport
|
|
497
|
+
"SOME_OTHER_KEY=value\nexport OP_UI_LOGIN_PASSWORD=real-password-here\n"
|
|
536
498
|
);
|
|
537
499
|
|
|
538
500
|
expect(isSetupComplete(stackDir)).toBe(true);
|
|
@@ -705,13 +667,11 @@ describe("performSetup end-to-end artifacts", () => {
|
|
|
705
667
|
).toBe(true);
|
|
706
668
|
});
|
|
707
669
|
|
|
708
|
-
it("writes
|
|
670
|
+
it("writes the UI login password to stack.env", async () => {
|
|
709
671
|
await performSetup(makeValidSpec());
|
|
710
672
|
|
|
711
673
|
const secrets = parseEnvFile(join(stackDir, "stack.env"));
|
|
712
|
-
expect(secrets.
|
|
713
|
-
expect(typeof secrets.OP_ASSISTANT_TOKEN).toBe("string");
|
|
714
|
-
expect(secrets.OP_ASSISTANT_TOKEN).not.toBe("test-admin-token-12345");
|
|
674
|
+
expect(secrets.OP_UI_LOGIN_PASSWORD).toBe("test-admin-token-12345");
|
|
715
675
|
});
|
|
716
676
|
|
|
717
677
|
it("writes akm config with llm provider and model", async () => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Lifecycle helpers — state factory, apply transitions, compose file list. */
|
|
2
2
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
3
3
|
import { parseEnvFile, mergeEnvContent } from "./env.js";
|
|
4
|
-
import type { ControlPlaneState, CallerType
|
|
4
|
+
import type { ControlPlaneState, CallerType } from "./types.js";
|
|
5
5
|
import { CORE_SERVICES } from "./types.js";
|
|
6
6
|
import {
|
|
7
7
|
resolveOpenPalmHome,
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
resolveStateDir,
|
|
13
13
|
resolveStackDir,
|
|
14
14
|
} from "./home.js";
|
|
15
|
-
import { ensureSecrets
|
|
15
|
+
import { ensureSecrets } from "./secrets.js";
|
|
16
16
|
import {
|
|
17
17
|
resolveRuntimeFiles,
|
|
18
18
|
writeRuntimeFiles,
|
|
@@ -20,22 +20,18 @@ import {
|
|
|
20
20
|
discoverStackOverlays,
|
|
21
21
|
ensureComposeVolumeTargets,
|
|
22
22
|
} from "./config-persistence.js";
|
|
23
|
-
import { readStackSpec } from "./stack-spec.js";
|
|
24
23
|
import { refreshCoreAssets } from "./core-assets.js";
|
|
25
24
|
import { isSetupComplete } from "./setup-status.js";
|
|
26
25
|
import { snapshotCurrentState } from "./rollback.js";
|
|
27
26
|
import { checkDocker, composePreflight, composePull, composeUp, composeConfigServices, resolveComposeProjectName } from "./docker.js";
|
|
28
|
-
import {
|
|
29
|
-
import { appendAudit } from "./audit.js";
|
|
27
|
+
import { acquireInstallLock, releaseInstallLock } from "./install-lock.js";
|
|
30
28
|
import { listEnabledAddonIds } from "./registry.js";
|
|
31
29
|
|
|
32
30
|
const IMAGE_NAMESPACE_RE = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;
|
|
33
31
|
const SEMVER_TAG_RE = /^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
|
|
34
32
|
|
|
35
33
|
|
|
36
|
-
export function createState(
|
|
37
|
-
adminToken?: string
|
|
38
|
-
): ControlPlaneState {
|
|
34
|
+
export function createState(): ControlPlaneState {
|
|
39
35
|
const homeDir = resolveOpenPalmHome();
|
|
40
36
|
const configDir = resolveConfigDir();
|
|
41
37
|
const stashDir = resolveStashDir();
|
|
@@ -50,8 +46,6 @@ export function createState(
|
|
|
50
46
|
}
|
|
51
47
|
|
|
52
48
|
const bootstrapState: ControlPlaneState = {
|
|
53
|
-
adminToken: adminToken ?? process.env.OP_UI_TOKEN ?? "",
|
|
54
|
-
assistantToken: "",
|
|
55
49
|
homeDir,
|
|
56
50
|
configDir,
|
|
57
51
|
stashDir,
|
|
@@ -62,23 +56,10 @@ export function createState(
|
|
|
62
56
|
services,
|
|
63
57
|
artifacts: { compose: "" },
|
|
64
58
|
artifactMeta: [],
|
|
65
|
-
audit: [],
|
|
66
59
|
};
|
|
67
60
|
|
|
68
61
|
ensureSecrets(bootstrapState);
|
|
69
62
|
|
|
70
|
-
const stackEnv = readStackEnv(stackDir);
|
|
71
|
-
// Precedence: explicit parameter > stack.env > process.env.
|
|
72
|
-
bootstrapState.adminToken =
|
|
73
|
-
adminToken
|
|
74
|
-
?? stackEnv.OP_UI_TOKEN
|
|
75
|
-
?? process.env.OP_UI_TOKEN
|
|
76
|
-
?? "";
|
|
77
|
-
bootstrapState.assistantToken =
|
|
78
|
-
stackEnv.OP_ASSISTANT_TOKEN
|
|
79
|
-
?? process.env.OP_ASSISTANT_TOKEN
|
|
80
|
-
?? "";
|
|
81
|
-
|
|
82
63
|
return bootstrapState;
|
|
83
64
|
}
|
|
84
65
|
|
|
@@ -142,48 +123,37 @@ async function reconcileCore(
|
|
|
142
123
|
return active;
|
|
143
124
|
}
|
|
144
125
|
|
|
145
|
-
export async function applyInstall(state: ControlPlaneState
|
|
146
|
-
const lock =
|
|
126
|
+
export async function applyInstall(state: ControlPlaneState): Promise<void> {
|
|
127
|
+
const lock = acquireInstallLock(state.stateDir);
|
|
128
|
+
if (!lock) throw new Error("Another install is already in progress");
|
|
147
129
|
try {
|
|
148
130
|
await reconcileCore(state, { activateServices: true });
|
|
149
131
|
// Pre-create host-side volume mount targets as the current user so
|
|
150
132
|
// Docker doesn't create them root-owned (which causes EACCES inside
|
|
151
133
|
// non-root containers).
|
|
152
134
|
ensureComposeVolumeTargets(state);
|
|
153
|
-
if (ctx) appendAudit(state, ctx.actor, "install", {}, true, ctx.requestId ?? "", ctx.callerType ?? "unknown");
|
|
154
|
-
} catch (err) {
|
|
155
|
-
if (ctx) appendAudit(state, ctx.actor, "install", { error: String(err) }, false, ctx.requestId ?? "", ctx.callerType ?? "unknown");
|
|
156
|
-
throw err;
|
|
157
135
|
} finally {
|
|
158
|
-
|
|
136
|
+
releaseInstallLock(lock);
|
|
159
137
|
}
|
|
160
138
|
}
|
|
161
139
|
|
|
162
|
-
export async function applyUpdate(state: ControlPlaneState
|
|
163
|
-
const lock =
|
|
140
|
+
export async function applyUpdate(state: ControlPlaneState): Promise<{ restarted: string[] }> {
|
|
141
|
+
const lock = acquireInstallLock(state.stateDir);
|
|
142
|
+
if (!lock) throw new Error("Another install is already in progress");
|
|
164
143
|
try {
|
|
165
|
-
|
|
166
|
-
if (ctx) appendAudit(state, ctx.actor, "update", { restarted: result.restarted }, true, ctx.requestId ?? "", ctx.callerType ?? "unknown");
|
|
167
|
-
return result;
|
|
168
|
-
} catch (err) {
|
|
169
|
-
if (ctx) appendAudit(state, ctx.actor, "update", { error: String(err) }, false, ctx.requestId ?? "", ctx.callerType ?? "unknown");
|
|
170
|
-
throw err;
|
|
144
|
+
return { restarted: await reconcileCore(state, {}) };
|
|
171
145
|
} finally {
|
|
172
|
-
|
|
146
|
+
releaseInstallLock(lock);
|
|
173
147
|
}
|
|
174
148
|
}
|
|
175
149
|
|
|
176
|
-
export async function applyUninstall(state: ControlPlaneState
|
|
177
|
-
const lock =
|
|
150
|
+
export async function applyUninstall(state: ControlPlaneState): Promise<{ stopped: string[] }> {
|
|
151
|
+
const lock = acquireInstallLock(state.stateDir);
|
|
152
|
+
if (!lock) throw new Error("Another install is already in progress");
|
|
178
153
|
try {
|
|
179
|
-
|
|
180
|
-
if (ctx) appendAudit(state, ctx.actor, "uninstall", { stopped: result.stopped }, true, ctx.requestId ?? "", ctx.callerType ?? "unknown");
|
|
181
|
-
return result;
|
|
182
|
-
} catch (err) {
|
|
183
|
-
if (ctx) appendAudit(state, ctx.actor, "uninstall", { error: String(err) }, false, ctx.requestId ?? "", ctx.callerType ?? "unknown");
|
|
184
|
-
throw err;
|
|
154
|
+
return { stopped: await reconcileCore(state, { deactivateServices: true }) };
|
|
185
155
|
} finally {
|
|
186
|
-
|
|
156
|
+
releaseInstallLock(lock);
|
|
187
157
|
}
|
|
188
158
|
}
|
|
189
159
|
|
|
@@ -250,13 +220,14 @@ export async function applyUpgrade(
|
|
|
250
220
|
updated: string[];
|
|
251
221
|
restarted: string[];
|
|
252
222
|
}> {
|
|
253
|
-
const lock =
|
|
223
|
+
const lock = acquireInstallLock(state.stateDir);
|
|
224
|
+
if (!lock) throw new Error("Another install is already in progress");
|
|
254
225
|
try {
|
|
255
226
|
const { backupDir, updated } = await refreshCoreAssets();
|
|
256
227
|
const restarted = await reconcileCore(state, {});
|
|
257
228
|
return { backupDir, updated, restarted };
|
|
258
229
|
} finally {
|
|
259
|
-
|
|
230
|
+
releaseInstallLock(lock);
|
|
260
231
|
}
|
|
261
232
|
}
|
|
262
233
|
|
|
@@ -328,6 +299,24 @@ export async function performUpgrade(state: ControlPlaneState): Promise<UpgradeR
|
|
|
328
299
|
};
|
|
329
300
|
}
|
|
330
301
|
|
|
302
|
+
/**
|
|
303
|
+
* Set a specific image tag in stack.env then pull images and restart containers.
|
|
304
|
+
* Used by the admin "set version" action — skips the auto-detect step in performUpgrade.
|
|
305
|
+
*/
|
|
306
|
+
export async function applyTagChange(state: ControlPlaneState, tag: string): Promise<UpgradeResult> {
|
|
307
|
+
const stackEnvPath = `${state.stackDir}/stack.env`;
|
|
308
|
+
const currentContent = existsSync(stackEnvPath) ? readFileSync(stackEnvPath, "utf-8") : "";
|
|
309
|
+
writeFileSync(stackEnvPath, mergeEnvContent(currentContent, { OP_IMAGE_TAG: tag }, { uncomment: true }));
|
|
310
|
+
const upgradeResult = await applyUpgrade(state);
|
|
311
|
+
return {
|
|
312
|
+
imageTag: tag,
|
|
313
|
+
namespace: "openpalm",
|
|
314
|
+
backupDir: upgradeResult.backupDir,
|
|
315
|
+
assetsUpdated: upgradeResult.updated,
|
|
316
|
+
restarted: upgradeResult.restarted,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
331
320
|
export function buildComposeFileList(state: ControlPlaneState): string[] {
|
|
332
321
|
return discoverStackOverlays(state.stackDir);
|
|
333
322
|
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the 0.11.0 auth-migration shim.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
|
|
5
|
+
import {
|
|
6
|
+
existsSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
mkdtempSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
statSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
chmodSync,
|
|
14
|
+
} from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { migrateAuth0110 } from "./migrate-0110.js";
|
|
18
|
+
import type { ControlPlaneState } from "./types.js";
|
|
19
|
+
|
|
20
|
+
function makeState(homeDir: string): ControlPlaneState {
|
|
21
|
+
return {
|
|
22
|
+
homeDir,
|
|
23
|
+
configDir: join(homeDir, "config"),
|
|
24
|
+
stashDir: join(homeDir, "stash"),
|
|
25
|
+
workspaceDir: join(homeDir, "workspace"),
|
|
26
|
+
cacheDir: join(homeDir, "cache"),
|
|
27
|
+
stateDir: join(homeDir, "state"),
|
|
28
|
+
stackDir: join(homeDir, "config", "stack"),
|
|
29
|
+
services: {},
|
|
30
|
+
artifacts: { compose: "" },
|
|
31
|
+
artifactMeta: [],
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function seedStackEnv(stackDir: string, content: string): string {
|
|
36
|
+
mkdirSync(stackDir, { recursive: true });
|
|
37
|
+
const path = join(stackDir, "stack.env");
|
|
38
|
+
writeFileSync(path, content, { encoding: "utf-8", mode: 0o600 });
|
|
39
|
+
chmodSync(path, 0o600);
|
|
40
|
+
return path;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("migrateAuth0110", () => {
|
|
44
|
+
let homeDir: string;
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
homeDir = mkdtempSync(join(tmpdir(), "openpalm-migrate-0110-"));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
rmSync(homeDir, { recursive: true, force: true });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("no-ops on a fresh install (no stack.env)", () => {
|
|
55
|
+
const state = makeState(homeDir);
|
|
56
|
+
const result = migrateAuth0110(state);
|
|
57
|
+
expect(result.migrated).toBe(false);
|
|
58
|
+
expect(result.reason).toContain("fresh install");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("promotes OP_UI_TOKEN → OP_UI_LOGIN_PASSWORD and removes legacy keys", () => {
|
|
62
|
+
const state = makeState(homeDir);
|
|
63
|
+
const stackEnvPath = seedStackEnv(
|
|
64
|
+
state.stackDir,
|
|
65
|
+
[
|
|
66
|
+
"# header",
|
|
67
|
+
"OP_UI_TOKEN=legacy-token-value",
|
|
68
|
+
"OP_ASSISTANT_TOKEN=some-assistant-token",
|
|
69
|
+
"OP_OPENCODE_PASSWORD=opencode-secret",
|
|
70
|
+
"",
|
|
71
|
+
].join("\n"),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const result = migrateAuth0110(state);
|
|
75
|
+
expect(result.migrated).toBe(true);
|
|
76
|
+
expect(result.reason).toContain("promoted OP_UI_TOKEN");
|
|
77
|
+
expect(result.reason).toContain("removed OP_UI_TOKEN");
|
|
78
|
+
expect(result.reason).toContain("removed OP_ASSISTANT_TOKEN");
|
|
79
|
+
|
|
80
|
+
const after = readFileSync(stackEnvPath, "utf-8");
|
|
81
|
+
expect(after).toContain("OP_UI_LOGIN_PASSWORD=legacy-token-value");
|
|
82
|
+
expect(after).not.toMatch(/^OP_UI_TOKEN=/m);
|
|
83
|
+
expect(after).not.toMatch(/^OP_ASSISTANT_TOKEN=/m);
|
|
84
|
+
// Unrelated keys preserved
|
|
85
|
+
expect(after).toContain("OP_OPENCODE_PASSWORD=opencode-secret");
|
|
86
|
+
|
|
87
|
+
// Perms preserved
|
|
88
|
+
expect(statSync(stackEnvPath).mode & 0o777).toBe(0o600);
|
|
89
|
+
|
|
90
|
+
// Migration log appended
|
|
91
|
+
const logPath = join(state.stateDir, "logs", "migration-0.11.0.log");
|
|
92
|
+
expect(existsSync(logPath)).toBe(true);
|
|
93
|
+
const log = readFileSync(logPath, "utf-8");
|
|
94
|
+
expect(log).toContain("migrate-auth-0110");
|
|
95
|
+
expect(log).toContain("promoted OP_UI_TOKEN");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("does not overwrite an existing OP_UI_LOGIN_PASSWORD", () => {
|
|
99
|
+
const state = makeState(homeDir);
|
|
100
|
+
const stackEnvPath = seedStackEnv(
|
|
101
|
+
state.stackDir,
|
|
102
|
+
[
|
|
103
|
+
"OP_UI_LOGIN_PASSWORD=new-password",
|
|
104
|
+
"OP_UI_TOKEN=legacy-value",
|
|
105
|
+
"",
|
|
106
|
+
].join("\n"),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const result = migrateAuth0110(state);
|
|
110
|
+
expect(result.migrated).toBe(true);
|
|
111
|
+
expect(result.reason).not.toContain("promoted");
|
|
112
|
+
expect(result.reason).toContain("removed OP_UI_TOKEN");
|
|
113
|
+
|
|
114
|
+
const after = readFileSync(stackEnvPath, "utf-8");
|
|
115
|
+
expect(after).toContain("OP_UI_LOGIN_PASSWORD=new-password");
|
|
116
|
+
expect(after).not.toMatch(/^OP_UI_TOKEN=/m);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("removes OP_ASSISTANT_TOKEN even when only it is present", () => {
|
|
120
|
+
const state = makeState(homeDir);
|
|
121
|
+
const stackEnvPath = seedStackEnv(
|
|
122
|
+
state.stackDir,
|
|
123
|
+
[
|
|
124
|
+
"OP_UI_LOGIN_PASSWORD=pw",
|
|
125
|
+
"OP_ASSISTANT_TOKEN=stale",
|
|
126
|
+
"",
|
|
127
|
+
].join("\n"),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const result = migrateAuth0110(state);
|
|
131
|
+
expect(result.migrated).toBe(true);
|
|
132
|
+
expect(result.reason).toContain("removed OP_ASSISTANT_TOKEN");
|
|
133
|
+
expect(readFileSync(stackEnvPath, "utf-8")).not.toMatch(/^OP_ASSISTANT_TOKEN=/m);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("is idempotent: second run reports already-migrated", () => {
|
|
137
|
+
const state = makeState(homeDir);
|
|
138
|
+
seedStackEnv(
|
|
139
|
+
state.stackDir,
|
|
140
|
+
[
|
|
141
|
+
"OP_UI_TOKEN=t",
|
|
142
|
+
"OP_ASSISTANT_TOKEN=t2",
|
|
143
|
+
"",
|
|
144
|
+
].join("\n"),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const first = migrateAuth0110(state);
|
|
148
|
+
expect(first.migrated).toBe(true);
|
|
149
|
+
|
|
150
|
+
const second = migrateAuth0110(state);
|
|
151
|
+
expect(second.migrated).toBe(false);
|
|
152
|
+
expect(second.reason).toContain("already migrated");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("treats an empty OP_UI_TOKEN value as not-set (no promotion)", () => {
|
|
156
|
+
const state = makeState(homeDir);
|
|
157
|
+
const stackEnvPath = seedStackEnv(
|
|
158
|
+
state.stackDir,
|
|
159
|
+
[
|
|
160
|
+
"OP_UI_TOKEN=",
|
|
161
|
+
"OP_ASSISTANT_TOKEN=foo",
|
|
162
|
+
"",
|
|
163
|
+
].join("\n"),
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const result = migrateAuth0110(state);
|
|
167
|
+
expect(result.migrated).toBe(true);
|
|
168
|
+
// Empty-string OP_UI_TOKEN should NOT be promoted as a password.
|
|
169
|
+
expect(result.reason).not.toContain("promoted");
|
|
170
|
+
|
|
171
|
+
const after = readFileSync(stackEnvPath, "utf-8");
|
|
172
|
+
// The empty OP_UI_TOKEN line is still removed.
|
|
173
|
+
expect(after).not.toMatch(/^OP_UI_TOKEN=/m);
|
|
174
|
+
// No OP_UI_LOGIN_PASSWORD added (would be an empty value).
|
|
175
|
+
expect(after).not.toMatch(/^OP_UI_LOGIN_PASSWORD=/m);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-shot migration for the 0.11.0 auth refactor.
|
|
3
|
+
*
|
|
4
|
+
* Existing installs have OP_UI_TOKEN and OP_ASSISTANT_TOKEN in
|
|
5
|
+
* config/stack/stack.env. The 0.11.0 refactor (auth-and-proxy-refactor-plan.md)
|
|
6
|
+
* replaces them with a single OP_UI_LOGIN_PASSWORD. If we don't migrate,
|
|
7
|
+
* operators get locked out the moment they run the new UI build because the
|
|
8
|
+
* login route compares the cookie against process.env.OP_UI_LOGIN_PASSWORD,
|
|
9
|
+
* which is empty on existing installs.
|
|
10
|
+
*
|
|
11
|
+
* Migration logic (idempotent):
|
|
12
|
+
* - If OP_UI_LOGIN_PASSWORD is unset AND OP_UI_TOKEN is set, copy
|
|
13
|
+
* OP_UI_TOKEN's value into OP_UI_LOGIN_PASSWORD.
|
|
14
|
+
* - Remove OP_UI_TOKEN and OP_ASSISTANT_TOKEN from stack.env (they're
|
|
15
|
+
* no longer used).
|
|
16
|
+
* - Append a one-line summary to state/logs/migration-0.11.0.log.
|
|
17
|
+
* - If OP_UI_LOGIN_PASSWORD is already set, leave it alone — the operator
|
|
18
|
+
* already migrated or set up fresh.
|
|
19
|
+
*
|
|
20
|
+
* Called from ensureSecrets so it runs before any auth-required code path
|
|
21
|
+
* gets a chance to see the half-migrated state.
|
|
22
|
+
*/
|
|
23
|
+
import {
|
|
24
|
+
existsSync,
|
|
25
|
+
readFileSync,
|
|
26
|
+
writeFileSync,
|
|
27
|
+
chmodSync,
|
|
28
|
+
appendFileSync,
|
|
29
|
+
mkdirSync,
|
|
30
|
+
} from "node:fs";
|
|
31
|
+
import { dirname } from "node:path";
|
|
32
|
+
import { parseEnvContent, removeEnvKey, upsertEnvValue } from "./env.js";
|
|
33
|
+
import { migration0110LogPath } from "./paths.js";
|
|
34
|
+
import type { ControlPlaneState } from "./types.js";
|
|
35
|
+
|
|
36
|
+
export type MigrateAuth0110Result = {
|
|
37
|
+
/** True if any change was written to stack.env. */
|
|
38
|
+
migrated: boolean;
|
|
39
|
+
/** Human-readable description of what changed (or why nothing did). */
|
|
40
|
+
reason: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function migrateAuth0110(state: ControlPlaneState): MigrateAuth0110Result {
|
|
44
|
+
const stackEnvPath = `${state.stackDir}/stack.env`;
|
|
45
|
+
if (!existsSync(stackEnvPath)) {
|
|
46
|
+
return { migrated: false, reason: "no stack.env yet (fresh install)" };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const before = readFileSync(stackEnvPath, "utf-8");
|
|
50
|
+
const parsed = parseEnvContent(before);
|
|
51
|
+
const hasLoginPw = typeof parsed.OP_UI_LOGIN_PASSWORD === "string" && parsed.OP_UI_LOGIN_PASSWORD.length > 0;
|
|
52
|
+
const hasUiToken = typeof parsed.OP_UI_TOKEN === "string" && parsed.OP_UI_TOKEN.length > 0;
|
|
53
|
+
const hasAssistantToken = "OP_ASSISTANT_TOKEN" in parsed;
|
|
54
|
+
const hasUiTokenLine = "OP_UI_TOKEN" in parsed;
|
|
55
|
+
|
|
56
|
+
if (hasLoginPw && !hasUiTokenLine && !hasAssistantToken) {
|
|
57
|
+
return { migrated: false, reason: "already migrated" };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let content = before;
|
|
61
|
+
const changes: string[] = [];
|
|
62
|
+
|
|
63
|
+
if (!hasLoginPw && hasUiToken) {
|
|
64
|
+
content = upsertEnvValue(content, "OP_UI_LOGIN_PASSWORD", parsed.OP_UI_TOKEN);
|
|
65
|
+
changes.push("promoted OP_UI_TOKEN → OP_UI_LOGIN_PASSWORD");
|
|
66
|
+
}
|
|
67
|
+
if (hasUiTokenLine) {
|
|
68
|
+
content = removeEnvKey(content, "OP_UI_TOKEN");
|
|
69
|
+
changes.push("removed OP_UI_TOKEN");
|
|
70
|
+
}
|
|
71
|
+
if (hasAssistantToken) {
|
|
72
|
+
content = removeEnvKey(content, "OP_ASSISTANT_TOKEN");
|
|
73
|
+
changes.push("removed OP_ASSISTANT_TOKEN");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (changes.length === 0) {
|
|
77
|
+
return { migrated: false, reason: "no changes needed" };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Preserve the 0600 mode the existing file should already have.
|
|
81
|
+
writeFileSync(stackEnvPath, content, { encoding: "utf-8", mode: 0o600 });
|
|
82
|
+
try { chmodSync(stackEnvPath, 0o600); } catch { /* best-effort */ }
|
|
83
|
+
|
|
84
|
+
// Best-effort audit line. The migration log is small and append-only;
|
|
85
|
+
// if it fails (perm error, fs full), we don't roll back the migration.
|
|
86
|
+
try {
|
|
87
|
+
const logPath = migration0110LogPath(state);
|
|
88
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
89
|
+
appendFileSync(
|
|
90
|
+
logPath,
|
|
91
|
+
`${new Date().toISOString()} migrate-auth-0110 ${changes.join("; ")}\n`,
|
|
92
|
+
"utf-8",
|
|
93
|
+
);
|
|
94
|
+
} catch {
|
|
95
|
+
/* best-effort */
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { migrated: true, reason: changes.join("; ") };
|
|
99
|
+
}
|