@openpalm/lib 0.11.0-beta.1 → 0.11.0-beta.3
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/package.json +1 -1
- package/src/control-plane/akm-vault.test.ts +1 -4
- package/src/control-plane/compose-args.test.ts +0 -3
- 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 +1 -2
- package/src/control-plane/host-opencode.test.ts +0 -3
- package/src/control-plane/install-edge-cases.test.ts +26 -66
- package/src/control-plane/lifecycle.ts +8 -40
- package/src/control-plane/migrate-0110.test.ts +177 -0
- package/src/control-plane/migrate-0110.ts +99 -0
- package/src/control-plane/paths.ts +8 -1
- package/src/control-plane/registry-components.test.ts +3 -2
- package/src/control-plane/registry.test.ts +64 -0
- package/src/control-plane/registry.ts +113 -0
- package/src/control-plane/secret-backend.test.ts +5 -8
- package/src/control-plane/secret-mappings.ts +2 -3
- package/src/control-plane/secrets.ts +13 -7
- 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 +18 -14
- package/src/control-plane/setup.ts +22 -36
- package/src/control-plane/spec-to-env.ts +12 -1
- package/src/control-plane/types.ts +0 -18
- package/src/control-plane/validate.ts +1 -1
- package/src/index.ts +13 -4
- package/src/logger.ts +1 -1
- package/src/control-plane/audit.ts +0 -41
package/package.json
CHANGED
|
@@ -21,19 +21,16 @@ import type { ControlPlaneState } from "./types.js";
|
|
|
21
21
|
|
|
22
22
|
function makeState(homeDir: string): ControlPlaneState {
|
|
23
23
|
return {
|
|
24
|
-
adminToken: "test-admin",
|
|
25
|
-
assistantToken: "test-assistant",
|
|
26
24
|
homeDir,
|
|
27
25
|
configDir: join(homeDir, "config"),
|
|
28
26
|
stashDir: join(homeDir, "stash"),
|
|
29
27
|
workspaceDir: join(homeDir, "workspace"),
|
|
30
|
-
|
|
28
|
+
cacheDir: join(homeDir, "cache"),
|
|
31
29
|
stateDir: join(homeDir, "state"),
|
|
32
30
|
stackDir: join(homeDir, "stack"),
|
|
33
31
|
services: {},
|
|
34
32
|
artifacts: { compose: "" },
|
|
35
33
|
artifactMeta: [],
|
|
36
|
-
audit: [],
|
|
37
34
|
};
|
|
38
35
|
}
|
|
39
36
|
|
|
@@ -17,8 +17,6 @@ let tempDir: string;
|
|
|
17
17
|
function makeState(overrides: Partial<ControlPlaneState> = {}): ControlPlaneState {
|
|
18
18
|
const configDir = join(tempDir, "config");
|
|
19
19
|
return {
|
|
20
|
-
adminToken: "test",
|
|
21
|
-
assistantToken: "test",
|
|
22
20
|
homeDir: tempDir,
|
|
23
21
|
configDir,
|
|
24
22
|
stashDir: join(tempDir, "stash"),
|
|
@@ -29,7 +27,6 @@ function makeState(overrides: Partial<ControlPlaneState> = {}): ControlPlaneStat
|
|
|
29
27
|
services: {},
|
|
30
28
|
artifacts: { compose: "" },
|
|
31
29
|
artifactMeta: [],
|
|
32
|
-
audit: [],
|
|
33
30
|
...overrides,
|
|
34
31
|
};
|
|
35
32
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
parseComposeStderr,
|
|
4
|
+
summarizeComposeStderr,
|
|
5
|
+
} from "./compose-errors.js";
|
|
6
|
+
|
|
7
|
+
describe("parseComposeStderr", () => {
|
|
8
|
+
it("returns empty for empty input", () => {
|
|
9
|
+
expect(parseComposeStderr("")).toEqual([]);
|
|
10
|
+
expect(parseComposeStderr("\n\n")).toEqual([]);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("extracts pull access denied for a single service", () => {
|
|
14
|
+
const stderr = [
|
|
15
|
+
" Network openpalm_default Created",
|
|
16
|
+
" voice Pulling",
|
|
17
|
+
" voice Error pull access denied for openpalm/voice, repository does not exist or may require 'docker login'",
|
|
18
|
+
"Error response from daemon: pull access denied for openpalm/voice, repository does not exist or may require 'docker login': denied: requested access to the resource is denied",
|
|
19
|
+
].join("\n");
|
|
20
|
+
|
|
21
|
+
const failures = parseComposeStderr(stderr);
|
|
22
|
+
expect(failures.length).toBeGreaterThanOrEqual(1);
|
|
23
|
+
expect(failures[0].service).toBe("voice");
|
|
24
|
+
expect(failures[0].reason).toMatch(/pull access denied/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("handles spinner / status prefix glyphs", () => {
|
|
28
|
+
const stderr = " ⠿ voice Error pull access denied for openpalm/voice";
|
|
29
|
+
const failures = parseComposeStderr(stderr);
|
|
30
|
+
expect(failures).toHaveLength(1);
|
|
31
|
+
expect(failures[0].service).toBe("voice");
|
|
32
|
+
expect(failures[0].reason).toMatch(/pull access denied/);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("captures quoted Service failed lines", () => {
|
|
36
|
+
const stderr =
|
|
37
|
+
'Service "discord" failed to build: failed to solve: process did not complete';
|
|
38
|
+
const failures = parseComposeStderr(stderr);
|
|
39
|
+
expect(failures).toHaveLength(1);
|
|
40
|
+
expect(failures[0].service).toBe("discord");
|
|
41
|
+
expect(failures[0].reason).toMatch(/failed to solve/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("deduplicates identical (service, reason) pairs", () => {
|
|
45
|
+
const stderr = [
|
|
46
|
+
"voice Error pull access denied for openpalm/voice",
|
|
47
|
+
"voice Error pull access denied for openpalm/voice",
|
|
48
|
+
].join("\n");
|
|
49
|
+
const failures = parseComposeStderr(stderr);
|
|
50
|
+
expect(failures).toHaveLength(1);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("returns multiple distinct failures", () => {
|
|
54
|
+
const stderr = [
|
|
55
|
+
"voice Error pull access denied for openpalm/voice",
|
|
56
|
+
"discord Error no such image: openpalm/discord:latest",
|
|
57
|
+
].join("\n");
|
|
58
|
+
const failures = parseComposeStderr(stderr);
|
|
59
|
+
expect(failures).toHaveLength(2);
|
|
60
|
+
expect(failures.map((f) => f.service).sort()).toEqual(["discord", "voice"]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("falls back to image name when only daemon error is present", () => {
|
|
64
|
+
const stderr =
|
|
65
|
+
"Error response from daemon: pull access denied for openpalm/voice, repository does not exist";
|
|
66
|
+
const failures = parseComposeStderr(stderr);
|
|
67
|
+
expect(failures).toHaveLength(1);
|
|
68
|
+
expect(failures[0].service).toBe("openpalm/voice");
|
|
69
|
+
expect(failures[0].reason).toMatch(/pull access denied/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("ignores non-error noise (Pulling/Created/Started)", () => {
|
|
73
|
+
const stderr = [
|
|
74
|
+
" Network openpalm_default Created",
|
|
75
|
+
" Container openpalm-guardian-1 Started",
|
|
76
|
+
" assistant Pulling",
|
|
77
|
+
].join("\n");
|
|
78
|
+
expect(parseComposeStderr(stderr)).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("does not treat 'Error response from daemon' as a service name", () => {
|
|
82
|
+
const stderr = "Error response from daemon: something bad happened";
|
|
83
|
+
// No service-prefixed line, no pull access denied, no quoted service —
|
|
84
|
+
// parser should NOT invent a service called "Error".
|
|
85
|
+
expect(parseComposeStderr(stderr)).toEqual([]);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("summarizeComposeStderr", () => {
|
|
90
|
+
it("returns first non-empty line", () => {
|
|
91
|
+
expect(summarizeComposeStderr("\n\n hello world \nnext line")).toBe(
|
|
92
|
+
"hello world"
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("truncates long lines", () => {
|
|
97
|
+
const long = "x".repeat(800);
|
|
98
|
+
const out = summarizeComposeStderr(long, 100);
|
|
99
|
+
expect(out.length).toBe(100);
|
|
100
|
+
expect(out.endsWith("…")).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("returns empty string for empty input", () => {
|
|
104
|
+
expect(summarizeComposeStderr("")).toBe("");
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse `docker compose` stderr for per-service failures.
|
|
3
|
+
*
|
|
4
|
+
* `docker compose up -d` reports its progress on stderr — one or more
|
|
5
|
+
* status lines per service, plus a daemon-level "Error response from daemon"
|
|
6
|
+
* summary. When a single addon service fails to pull or start, the rest of
|
|
7
|
+
* the stack often comes up fine, so the only signal that anything is wrong
|
|
8
|
+
* is whatever appears on stderr. This helper extracts the per-service
|
|
9
|
+
* failure messages so callers can surface them to operators.
|
|
10
|
+
*/
|
|
11
|
+
export type ComposeServiceFailure = {
|
|
12
|
+
service: string;
|
|
13
|
+
reason: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Lines we recognise as per-service failure indicators. The compose CLI
|
|
18
|
+
* has rendered these in a few different shapes across versions:
|
|
19
|
+
*
|
|
20
|
+
* "voice Error pull access denied for openpalm/voice ..."
|
|
21
|
+
* " ⠿ voice Error pull access denied for openpalm/voice ..."
|
|
22
|
+
* "Service \"voice\" failed to build: ..."
|
|
23
|
+
*
|
|
24
|
+
* We also pick up the bare daemon error and attribute it to the service
|
|
25
|
+
* named in nearby lines when no service-prefixed line is present.
|
|
26
|
+
*/
|
|
27
|
+
const SERVICE_ERROR_RE = /^[\s⠦⠧⠇⠏⠋⠙⠹⠸⠼⠴⠿✔✘×]*\s*([A-Za-z0-9._-]+)\s+(Error|Failed|failed)\s+(.+)$/;
|
|
28
|
+
const SERVICE_FAILED_QUOTED_RE = /Service\s+["']([A-Za-z0-9._-]+)["']\s+failed[^:]*:\s*(.+)$/i;
|
|
29
|
+
const SERVICE_NOT_FOUND_RE = /no such service:\s*([A-Za-z0-9._-]+)/i;
|
|
30
|
+
const PULL_ACCESS_DENIED_RE = /pull access denied for\s+([^\s,]+)/i;
|
|
31
|
+
|
|
32
|
+
function pushUnique(
|
|
33
|
+
failures: ComposeServiceFailure[],
|
|
34
|
+
entry: ComposeServiceFailure
|
|
35
|
+
): void {
|
|
36
|
+
const trimmed = { service: entry.service.trim(), reason: entry.reason.trim() };
|
|
37
|
+
if (!trimmed.service || !trimmed.reason) return;
|
|
38
|
+
const dup = failures.find(
|
|
39
|
+
(f) => f.service === trimmed.service && f.reason === trimmed.reason
|
|
40
|
+
);
|
|
41
|
+
if (!dup) failures.push(trimmed);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Best-effort extraction of failures from compose stderr.
|
|
46
|
+
*
|
|
47
|
+
* - Returns one entry per (service, reason) pair, in stderr order.
|
|
48
|
+
* - Does NOT fabricate service names: if a daemon error appears without
|
|
49
|
+
* any nearby service-prefixed line, the caller's intended-services list
|
|
50
|
+
* is used by the route, not this parser.
|
|
51
|
+
*/
|
|
52
|
+
export function parseComposeStderr(stderr: string): ComposeServiceFailure[] {
|
|
53
|
+
const failures: ComposeServiceFailure[] = [];
|
|
54
|
+
if (!stderr) return failures;
|
|
55
|
+
|
|
56
|
+
const lines = stderr.split(/\r?\n/);
|
|
57
|
+
|
|
58
|
+
for (const raw of lines) {
|
|
59
|
+
const line = raw.replace(/\s+$/, "");
|
|
60
|
+
if (!line.trim()) continue;
|
|
61
|
+
|
|
62
|
+
const quoted = SERVICE_FAILED_QUOTED_RE.exec(line);
|
|
63
|
+
if (quoted) {
|
|
64
|
+
pushUnique(failures, { service: quoted[1], reason: quoted[2] });
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const m = SERVICE_ERROR_RE.exec(line);
|
|
69
|
+
if (m) {
|
|
70
|
+
// Skip generic prefixes that look like services but aren't
|
|
71
|
+
// (e.g. "Error response from daemon ..." would match if the parser
|
|
72
|
+
// is too lenient — the verb word would be the second token).
|
|
73
|
+
const candidate = m[1];
|
|
74
|
+
if (candidate.toLowerCase() === "error") continue;
|
|
75
|
+
pushUnique(failures, { service: candidate, reason: m[3] });
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const notFound = SERVICE_NOT_FOUND_RE.exec(line);
|
|
80
|
+
if (notFound) {
|
|
81
|
+
pushUnique(failures, {
|
|
82
|
+
service: notFound[1],
|
|
83
|
+
reason: `no such service: ${notFound[1]}`,
|
|
84
|
+
});
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// If we still found nothing but the stderr clearly mentions a pull
|
|
90
|
+
// access denied, surface the offending image as the "service" identifier
|
|
91
|
+
// — better than swallowing the failure entirely.
|
|
92
|
+
if (failures.length === 0) {
|
|
93
|
+
const denied = PULL_ACCESS_DENIED_RE.exec(stderr);
|
|
94
|
+
if (denied) {
|
|
95
|
+
pushUnique(failures, {
|
|
96
|
+
service: denied[1],
|
|
97
|
+
reason: `pull access denied for ${denied[1]}`,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return failures;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Summarise compose stderr in a single short line, suitable for log
|
|
107
|
+
* envelopes / API error messages when no per-service parse succeeded.
|
|
108
|
+
* Returns the first non-empty stderr line, capped.
|
|
109
|
+
*/
|
|
110
|
+
export function summarizeComposeStderr(stderr: string, maxLen = 500): string {
|
|
111
|
+
if (!stderr) return "";
|
|
112
|
+
const first = stderr
|
|
113
|
+
.split(/\r?\n/)
|
|
114
|
+
.map((l) => l.trim())
|
|
115
|
+
.find((l) => l.length > 0) ?? "";
|
|
116
|
+
return first.length > maxLen ? first.slice(0, maxLen - 1) + "…" : first;
|
|
117
|
+
}
|
|
@@ -85,8 +85,7 @@ function generateFallbackSystemEnv(state: ControlPlaneState): string {
|
|
|
85
85
|
"# Auto-generated fallback.",
|
|
86
86
|
"",
|
|
87
87
|
"# ── Authentication ──────────────────────────────────────────────────",
|
|
88
|
-
`
|
|
89
|
-
`OP_ASSISTANT_TOKEN=\${OP_ASSISTANT_TOKEN}`,
|
|
88
|
+
`OP_UI_LOGIN_PASSWORD=\${OP_UI_LOGIN_PASSWORD}`,
|
|
90
89
|
"",
|
|
91
90
|
"# ── Service Auth ─────────────────────────────────────────────────────",
|
|
92
91
|
"OP_OPENCODE_PASSWORD=",
|
|
@@ -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,8 +135,7 @@ 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=",
|
|
@@ -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);
|
|
@@ -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,
|
|
@@ -26,16 +26,13 @@ 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
28
|
import { acquireLock, releaseLock } from "./lock.js";
|
|
29
|
-
import { appendAudit } from "./audit.js";
|
|
30
29
|
import { listEnabledAddonIds } from "./registry.js";
|
|
31
30
|
|
|
32
31
|
const IMAGE_NAMESPACE_RE = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;
|
|
33
32
|
const SEMVER_TAG_RE = /^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
|
|
34
33
|
|
|
35
34
|
|
|
36
|
-
export function createState(
|
|
37
|
-
adminToken?: string
|
|
38
|
-
): ControlPlaneState {
|
|
35
|
+
export function createState(): ControlPlaneState {
|
|
39
36
|
const homeDir = resolveOpenPalmHome();
|
|
40
37
|
const configDir = resolveConfigDir();
|
|
41
38
|
const stashDir = resolveStashDir();
|
|
@@ -50,8 +47,6 @@ export function createState(
|
|
|
50
47
|
}
|
|
51
48
|
|
|
52
49
|
const bootstrapState: ControlPlaneState = {
|
|
53
|
-
adminToken: adminToken ?? process.env.OP_UI_TOKEN ?? "",
|
|
54
|
-
assistantToken: "",
|
|
55
50
|
homeDir,
|
|
56
51
|
configDir,
|
|
57
52
|
stashDir,
|
|
@@ -62,23 +57,10 @@ export function createState(
|
|
|
62
57
|
services,
|
|
63
58
|
artifacts: { compose: "" },
|
|
64
59
|
artifactMeta: [],
|
|
65
|
-
audit: [],
|
|
66
60
|
};
|
|
67
61
|
|
|
68
62
|
ensureSecrets(bootstrapState);
|
|
69
63
|
|
|
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
64
|
return bootstrapState;
|
|
83
65
|
}
|
|
84
66
|
|
|
@@ -142,7 +124,7 @@ async function reconcileCore(
|
|
|
142
124
|
return active;
|
|
143
125
|
}
|
|
144
126
|
|
|
145
|
-
export async function applyInstall(state: ControlPlaneState
|
|
127
|
+
export async function applyInstall(state: ControlPlaneState): Promise<void> {
|
|
146
128
|
const lock = acquireLock(state.homeDir, "install");
|
|
147
129
|
try {
|
|
148
130
|
await reconcileCore(state, { activateServices: true });
|
|
@@ -150,38 +132,24 @@ export async function applyInstall(state: ControlPlaneState, ctx?: AuditContext)
|
|
|
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
|
releaseLock(lock);
|
|
159
137
|
}
|
|
160
138
|
}
|
|
161
139
|
|
|
162
|
-
export async function applyUpdate(state: ControlPlaneState
|
|
140
|
+
export async function applyUpdate(state: ControlPlaneState): Promise<{ restarted: string[] }> {
|
|
163
141
|
const lock = acquireLock(state.homeDir, "update");
|
|
164
142
|
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;
|
|
143
|
+
return { restarted: await reconcileCore(state, {}) };
|
|
171
144
|
} finally {
|
|
172
145
|
releaseLock(lock);
|
|
173
146
|
}
|
|
174
147
|
}
|
|
175
148
|
|
|
176
|
-
export async function applyUninstall(state: ControlPlaneState
|
|
149
|
+
export async function applyUninstall(state: ControlPlaneState): Promise<{ stopped: string[] }> {
|
|
177
150
|
const lock = acquireLock(state.homeDir, "uninstall");
|
|
178
151
|
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;
|
|
152
|
+
return { stopped: await reconcileCore(state, { deactivateServices: true }) };
|
|
185
153
|
} finally {
|
|
186
154
|
releaseLock(lock);
|
|
187
155
|
}
|