@openpalm/lib 0.10.2 → 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 +4 -2
- package/package.json +11 -3
- package/src/control-plane/akm-vault.test.ts +105 -0
- package/src/control-plane/akm-vault.ts +311 -0
- package/src/control-plane/channels.ts +11 -9
- package/src/control-plane/cleanup-guardrails.test.ts +8 -9
- package/src/control-plane/compose-args.test.ts +25 -33
- 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 +148 -73
- package/src/control-plane/core-assets.test.ts +104 -0
- package/src/control-plane/core-assets.ts +111 -58
- package/src/control-plane/docker.ts +70 -25
- package/src/control-plane/env.test.ts +25 -1
- package/src/control-plane/env.ts +84 -1
- package/src/control-plane/home.ts +66 -69
- package/src/control-plane/host-opencode.test.ts +260 -0
- package/src/control-plane/host-opencode.ts +229 -0
- package/src/control-plane/install-edge-cases.test.ts +190 -292
- package/src/control-plane/install-lock.ts +157 -0
- package/src/control-plane/lifecycle.ts +65 -75
- package/src/control-plane/markdown-task.ts +200 -0
- 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 +80 -0
- package/src/control-plane/provider-models.ts +154 -0
- package/src/control-plane/registry-components.test.ts +105 -27
- package/src/control-plane/registry.test.ts +247 -51
- package/src/control-plane/registry.ts +404 -54
- package/src/control-plane/rollback.ts +17 -16
- package/src/control-plane/scheduler.ts +75 -262
- package/src/control-plane/secret-mappings.ts +4 -8
- package/src/control-plane/secrets.ts +97 -55
- package/src/control-plane/setup-config.schema.json +5 -17
- package/src/control-plane/setup-status.ts +9 -29
- package/src/control-plane/setup-validation.ts +23 -23
- package/src/control-plane/setup.test.ts +143 -244
- package/src/control-plane/setup.ts +216 -133
- package/src/control-plane/skeleton-guardrail.test.ts +151 -0
- package/src/control-plane/spec-to-env.test.ts +75 -60
- package/src/control-plane/spec-to-env.ts +68 -153
- package/src/control-plane/stack-spec.test.ts +22 -84
- package/src/control-plane/stack-spec.ts +9 -89
- package/src/control-plane/types.ts +9 -29
- package/src/control-plane/ui-assets.ts +385 -0
- package/src/control-plane/validate.ts +44 -79
- package/src/index.ts +102 -56
- package/src/logger.test.ts +228 -0
- package/src/logger.ts +71 -1
- package/src/provider-constants.ts +22 -1
- package/src/control-plane/audit.ts +0 -40
- package/src/control-plane/env-schema-validation.test.ts +0 -118
- package/src/control-plane/lock.test.ts +0 -194
- package/src/control-plane/lock.ts +0 -176
- package/src/control-plane/memory-config.ts +0 -298
- package/src/control-plane/provider-config.ts +0 -34
- package/src/control-plane/redact-schema.ts +0 -50
- package/src/control-plane/secret-backend.test.ts +0 -359
- package/src/control-plane/secret-backend.ts +0 -322
- package/src/control-plane/spec-validator.ts +0 -159
|
@@ -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";
|
|
@@ -15,47 +14,42 @@ import type { ControlPlaneState } from "./types.js";
|
|
|
15
14
|
let tempDir: string;
|
|
16
15
|
|
|
17
16
|
function makeState(overrides: Partial<ControlPlaneState> = {}): ControlPlaneState {
|
|
17
|
+
const configDir = join(tempDir, "config");
|
|
18
18
|
return {
|
|
19
|
-
adminToken: "test",
|
|
20
|
-
assistantToken: "test",
|
|
21
|
-
setupToken: "test",
|
|
22
19
|
homeDir: tempDir,
|
|
23
|
-
configDir
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
logsDir: join(tempDir, "logs"),
|
|
20
|
+
configDir,
|
|
21
|
+
stashDir: join(tempDir, "stash"),
|
|
22
|
+
workspaceDir: join(tempDir, "workspace"),
|
|
27
23
|
cacheDir: join(tempDir, "cache"),
|
|
24
|
+
stateDir: join(tempDir, "state"),
|
|
25
|
+
stackDir: join(configDir, "stack"),
|
|
28
26
|
services: {},
|
|
29
27
|
artifacts: { compose: "" },
|
|
30
28
|
artifactMeta: [],
|
|
31
|
-
audit: [],
|
|
32
29
|
...overrides,
|
|
33
30
|
};
|
|
34
31
|
}
|
|
35
32
|
|
|
36
33
|
function seedCoreCompose(): void {
|
|
37
|
-
const stackDir = join(tempDir, "stack");
|
|
34
|
+
const stackDir = join(tempDir, "config", "stack");
|
|
38
35
|
mkdirSync(stackDir, { recursive: true });
|
|
39
36
|
writeFileSync(join(stackDir, "core.compose.yml"), "services: {}");
|
|
40
37
|
}
|
|
41
38
|
|
|
42
|
-
function seedEnvFiles(files: { stack?: boolean;
|
|
39
|
+
function seedEnvFiles(files: { stack?: boolean; guardian?: boolean } = {}): void {
|
|
40
|
+
const stackDir = join(tempDir, "config", "stack");
|
|
43
41
|
if (files.stack) {
|
|
44
|
-
mkdirSync(
|
|
45
|
-
writeFileSync(join(
|
|
46
|
-
}
|
|
47
|
-
if (files.user) {
|
|
48
|
-
mkdirSync(join(tempDir, "vault", "user"), { recursive: true });
|
|
49
|
-
writeFileSync(join(tempDir, "vault", "user", "user.env"), "SECRET=val");
|
|
42
|
+
mkdirSync(stackDir, { recursive: true });
|
|
43
|
+
writeFileSync(join(stackDir, "stack.env"), "KEY=val");
|
|
50
44
|
}
|
|
51
45
|
if (files.guardian) {
|
|
52
|
-
mkdirSync(
|
|
53
|
-
writeFileSync(join(
|
|
46
|
+
mkdirSync(stackDir, { recursive: true });
|
|
47
|
+
writeFileSync(join(stackDir, "guardian.env"), "CHANNEL_CHAT_SECRET=abc");
|
|
54
48
|
}
|
|
55
49
|
}
|
|
56
50
|
|
|
57
51
|
function seedAddon(name: string): void {
|
|
58
|
-
const addonDir = join(tempDir, "stack", "addons", name);
|
|
52
|
+
const addonDir = join(tempDir, "config", "stack", "addons", name);
|
|
59
53
|
mkdirSync(addonDir, { recursive: true });
|
|
60
54
|
writeFileSync(join(addonDir, "compose.yml"), "services: {}");
|
|
61
55
|
}
|
|
@@ -68,14 +62,6 @@ afterEach(() => {
|
|
|
68
62
|
rmSync(tempDir, { recursive: true, force: true });
|
|
69
63
|
});
|
|
70
64
|
|
|
71
|
-
// ── COMPOSE_PROJECT_NAME ─────────────────────────────────────────────────
|
|
72
|
-
|
|
73
|
-
describe("COMPOSE_PROJECT_NAME", () => {
|
|
74
|
-
it("is 'openpalm'", () => {
|
|
75
|
-
expect(COMPOSE_PROJECT_NAME).toBe("openpalm");
|
|
76
|
-
});
|
|
77
|
-
});
|
|
78
|
-
|
|
79
65
|
// ── buildComposeOptions ──────────────────────────────────────────────────
|
|
80
66
|
|
|
81
67
|
describe("buildComposeOptions", () => {
|
|
@@ -98,13 +84,16 @@ describe("buildComposeOptions", () => {
|
|
|
98
84
|
});
|
|
99
85
|
|
|
100
86
|
it("returns env files in correct order", () => {
|
|
101
|
-
|
|
87
|
+
// Note: vault/user/user.env is no longer a
|
|
88
|
+
// compose env_file. The runtime env file list is: stack.env, guardian.env.
|
|
89
|
+
// Even when a legacy user.env is present on disk, it is intentionally
|
|
90
|
+
// excluded from the compose args.
|
|
91
|
+
seedEnvFiles({ stack: true, guardian: true });
|
|
102
92
|
const state = makeState();
|
|
103
93
|
const opts = buildComposeOptions(state);
|
|
104
|
-
expect(opts.envFiles).toHaveLength(
|
|
94
|
+
expect(opts.envFiles).toHaveLength(2);
|
|
105
95
|
expect(opts.envFiles[0]).toContain("stack.env");
|
|
106
|
-
expect(opts.envFiles[1]).toContain("
|
|
107
|
-
expect(opts.envFiles[2]).toContain("guardian.env");
|
|
96
|
+
expect(opts.envFiles[1]).toContain("guardian.env");
|
|
108
97
|
});
|
|
109
98
|
|
|
110
99
|
it("excludes missing env files", () => {
|
|
@@ -136,8 +125,11 @@ describe("buildComposeCliArgs", () => {
|
|
|
136
125
|
});
|
|
137
126
|
|
|
138
127
|
it("includes --env-file flags for env files that exist", () => {
|
|
128
|
+
// Note: vault/user/user.env is no longer
|
|
129
|
+
// listed in the compose env_file set. Only stack.env and guardian.env
|
|
130
|
+
// (when present) are passed via --env-file.
|
|
139
131
|
seedCoreCompose();
|
|
140
|
-
seedEnvFiles({ stack: true,
|
|
132
|
+
seedEnvFiles({ stack: true, guardian: true });
|
|
141
133
|
const state = makeState();
|
|
142
134
|
const args = buildComposeCliArgs(state);
|
|
143
135
|
const envFileIndices = args.reduce<number[]>((acc, arg, i) => {
|
|
@@ -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 = {
|
|
@@ -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
|
+
}
|