@openpalm/lib 0.9.8 → 0.10.1
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 +31 -71
- package/package.json +1 -1
- package/src/control-plane/audit.ts +4 -4
- package/src/control-plane/backup.ts +31 -0
- package/src/control-plane/channels.ts +88 -156
- package/src/control-plane/cleanup-guardrails.test.ts +289 -0
- package/src/control-plane/compose-args.test.ts +170 -0
- package/src/control-plane/compose-args.ts +57 -0
- package/src/control-plane/config-persistence.ts +270 -0
- package/src/control-plane/core-assets.ts +58 -234
- package/src/control-plane/crypto.ts +14 -0
- package/src/control-plane/docker.ts +94 -204
- package/src/control-plane/env-schema-validation.test.ts +118 -0
- package/src/control-plane/extends-support.test.ts +105 -0
- package/src/control-plane/home.ts +133 -0
- package/src/control-plane/install-edge-cases.test.ts +314 -717
- package/src/control-plane/lifecycle.ts +215 -233
- package/src/control-plane/lock.test.ts +194 -0
- package/src/control-plane/lock.ts +176 -0
- package/src/control-plane/memory-config.ts +34 -160
- package/src/control-plane/opencode-client.test.ts +154 -0
- package/src/control-plane/opencode-client.ts +113 -0
- package/src/control-plane/provider-config.ts +34 -0
- package/src/control-plane/redact-schema.ts +50 -0
- package/src/control-plane/registry-components.test.ts +313 -0
- package/src/control-plane/registry.test.ts +414 -0
- package/src/control-plane/registry.ts +418 -0
- package/src/control-plane/rollback.ts +128 -0
- package/src/control-plane/scheduler.ts +18 -190
- package/src/control-plane/secret-backend.test.ts +359 -0
- package/src/control-plane/secret-backend.ts +322 -0
- package/src/control-plane/secret-mappings.ts +185 -0
- package/src/control-plane/secrets.ts +186 -112
- package/src/control-plane/setup-config.schema.json +306 -0
- package/src/control-plane/setup-status.ts +15 -8
- package/src/control-plane/setup-validation.ts +90 -0
- package/src/control-plane/setup.test.ts +336 -929
- package/src/control-plane/setup.ts +159 -849
- package/src/control-plane/spec-to-env.test.ts +100 -0
- package/src/control-plane/spec-to-env.ts +195 -0
- package/src/control-plane/spec-validator.ts +159 -0
- package/src/control-plane/stack-spec.test.ts +150 -0
- package/src/control-plane/stack-spec.ts +101 -22
- package/src/control-plane/types.ts +6 -99
- package/src/control-plane/validate.ts +107 -0
- package/src/index.ts +101 -159
- package/src/provider-constants.ts +2 -31
- package/src/control-plane/connection-mapping.ts +0 -191
- package/src/control-plane/connection-migration-flags.ts +0 -40
- package/src/control-plane/connection-profiles.ts +0 -317
- package/src/control-plane/core-asset-provider.ts +0 -21
- package/src/control-plane/fs-asset-provider.ts +0 -65
- package/src/control-plane/fs-registry-provider.ts +0 -46
- package/src/control-plane/paths.ts +0 -77
- package/src/control-plane/registry-provider.ts +0 -19
- package/src/control-plane/staging.ts +0 -399
|
@@ -1,15 +1,10 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Docker integration — executes real docker compose commands.
|
|
3
|
-
*
|
|
4
|
-
* This module shells out to `docker compose` for lifecycle operations.
|
|
5
|
-
* It reads the generated docker-compose.yml from the state directory
|
|
6
|
-
* and uses it for all operations.
|
|
7
|
-
*
|
|
8
|
-
* Security: All commands use execFile with argument arrays to prevent
|
|
9
|
-
* command injection. No user input is ever interpolated into shell strings.
|
|
10
|
-
*/
|
|
1
|
+
/** Docker integration — executes docker compose commands via execFile (no shell). */
|
|
11
2
|
import { execFile, spawn } from "node:child_process";
|
|
12
|
-
import { existsSync
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { parseEnvFile } from "./env.js";
|
|
5
|
+
import { createLogger } from "../logger.js";
|
|
6
|
+
|
|
7
|
+
const logger = createLogger("lib:docker");
|
|
13
8
|
|
|
14
9
|
export type DockerResult = {
|
|
15
10
|
ok: boolean;
|
|
@@ -18,29 +13,6 @@ export type DockerResult = {
|
|
|
18
13
|
code: number;
|
|
19
14
|
};
|
|
20
15
|
|
|
21
|
-
/**
|
|
22
|
-
* Parse a dotenv file into a key-value map.
|
|
23
|
-
* Handles `KEY=value` lines; ignores comments and blank lines.
|
|
24
|
-
*/
|
|
25
|
-
function parseEnvFile(path: string): Record<string, string> {
|
|
26
|
-
const vars: Record<string, string> = {};
|
|
27
|
-
try {
|
|
28
|
-
const content = readFileSync(path, "utf-8");
|
|
29
|
-
for (const line of content.split("\n")) {
|
|
30
|
-
let trimmed = line.trim();
|
|
31
|
-
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
32
|
-
trimmed = trimmed.replace(/^export\s+/, '');
|
|
33
|
-
const eqIdx = trimmed.indexOf("=");
|
|
34
|
-
if (eqIdx > 0) {
|
|
35
|
-
vars[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1);
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
} catch {
|
|
39
|
-
// File not readable — skip
|
|
40
|
-
}
|
|
41
|
-
return vars;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
16
|
/** Execute docker with an argument array — no shell interpolation. */
|
|
45
17
|
function run(
|
|
46
18
|
args: string[],
|
|
@@ -65,9 +37,9 @@ function run(
|
|
|
65
37
|
});
|
|
66
38
|
}
|
|
67
39
|
|
|
68
|
-
/**
|
|
69
|
-
function
|
|
70
|
-
return
|
|
40
|
+
/** Resolve the Docker Compose project name. Respects OP_PROJECT_NAME env var. */
|
|
41
|
+
export function resolveComposeProjectName(): string {
|
|
42
|
+
return process.env.OP_PROJECT_NAME?.trim() || "openpalm";
|
|
71
43
|
}
|
|
72
44
|
|
|
73
45
|
/** Check if Docker is available */
|
|
@@ -108,142 +80,100 @@ export async function checkDockerCompose(): Promise<DockerResult> {
|
|
|
108
80
|
});
|
|
109
81
|
}
|
|
110
82
|
|
|
111
|
-
/**
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
function composeFileArgs(stateDir: string, files?: string[]): string[] {
|
|
116
|
-
const fileList = files ?? [composeFile(stateDir)];
|
|
117
|
-
return fileList.flatMap((f) => ["-f", f]);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Append `--env-file <path>` args for each existing env file.
|
|
122
|
-
* Files that do not exist are silently skipped.
|
|
123
|
-
*/
|
|
124
|
-
function pushEnvFileArgs(args: string[], envFiles?: string[]): void {
|
|
125
|
-
for (const ef of envFiles ?? []) {
|
|
83
|
+
/** Build common prefix: compose -f ... --project-name ... --env-file ... */
|
|
84
|
+
function buildComposeArgs(options: { files: string[]; envFiles?: string[] }): string[] {
|
|
85
|
+
const args = ["compose", ...options.files.flatMap((f) => ["-f", f]), "--project-name", resolveComposeProjectName()];
|
|
86
|
+
for (const ef of options.envFiles ?? []) {
|
|
126
87
|
if (existsSync(ef)) args.push("--env-file", ef);
|
|
127
88
|
}
|
|
89
|
+
return args;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Merge all env files into a single overrides object for process env. */
|
|
93
|
+
function collectEnvOverrides(envFiles?: string[]): Record<string, string> {
|
|
94
|
+
const overrides: Record<string, string> = {};
|
|
95
|
+
for (const ef of envFiles ?? []) Object.assign(overrides, parseEnvFile(ef));
|
|
96
|
+
return overrides;
|
|
128
97
|
}
|
|
129
98
|
|
|
130
99
|
/**
|
|
131
|
-
*
|
|
132
|
-
*
|
|
100
|
+
* Run `docker compose config` to validate compose file merge and variable substitution.
|
|
101
|
+
* Must be called before any lifecycle mutation (install/apply/update).
|
|
133
102
|
*/
|
|
134
|
-
function
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
103
|
+
export async function composePreflight(
|
|
104
|
+
options: { files: string[]; envFiles?: string[] }
|
|
105
|
+
): Promise<DockerResult> {
|
|
106
|
+
const args = buildComposeArgs(options);
|
|
107
|
+
args.push("config", "--quiet");
|
|
108
|
+
return run(args, undefined, 30_000, collectEnvOverrides(options.envFiles));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function composeConfigServices(
|
|
112
|
+
options: { files: string[]; envFiles?: string[] }
|
|
113
|
+
): Promise<{ ok: boolean; services: string[] }> {
|
|
114
|
+
const args = buildComposeArgs(options);
|
|
115
|
+
args.push("config", "--services");
|
|
116
|
+
const result = await run(args, undefined, 30_000, collectEnvOverrides(options.envFiles));
|
|
117
|
+
if (!result.ok) return { ok: false, services: [] };
|
|
118
|
+
const services = result.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
|
|
119
|
+
return { ok: true, services };
|
|
138
120
|
}
|
|
139
121
|
|
|
140
122
|
/**
|
|
141
123
|
* Run `docker compose up -d` with the generated compose file(s).
|
|
142
|
-
* Pass `files` to merge multiple compose overlays (e.g. core +
|
|
124
|
+
* Pass `files` to merge multiple compose overlays (e.g. core + addon files).
|
|
143
125
|
*/
|
|
144
126
|
export async function composeUp(
|
|
145
|
-
stateDir: string,
|
|
146
127
|
options: {
|
|
147
|
-
files
|
|
128
|
+
files: string[];
|
|
148
129
|
profiles?: string[];
|
|
149
130
|
services?: string[];
|
|
150
131
|
envFiles?: string[];
|
|
151
132
|
forceRecreate?: boolean;
|
|
152
133
|
removeOrphans?: boolean;
|
|
153
|
-
} = {}
|
|
154
|
-
): Promise<DockerResult> {
|
|
155
|
-
const primaryFile = options.files?.[0] ?? composeFile(stateDir);
|
|
156
|
-
if (!existsSync(primaryFile)) {
|
|
157
|
-
return {
|
|
158
|
-
ok: false,
|
|
159
|
-
stdout: "",
|
|
160
|
-
stderr: "Compose file not found",
|
|
161
|
-
code: 1
|
|
162
|
-
};
|
|
163
134
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
if (options.profiles) {
|
|
168
|
-
for (const p of options.profiles) {
|
|
169
|
-
args.push("--profile", p);
|
|
170
|
-
}
|
|
135
|
+
): Promise<DockerResult> {
|
|
136
|
+
if (!existsSync(options.files[0])) {
|
|
137
|
+
return { ok: false, stdout: "", stderr: "Compose file not found", code: 1 };
|
|
171
138
|
}
|
|
172
|
-
|
|
139
|
+
const args = buildComposeArgs(options);
|
|
140
|
+
for (const p of options.profiles ?? []) args.push("--profile", p);
|
|
173
141
|
args.push("up", "-d");
|
|
174
|
-
|
|
175
|
-
if (options.
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
if (options.removeOrphans) {
|
|
180
|
-
args.push("--remove-orphans");
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
if (options.services && options.services.length > 0) {
|
|
184
|
-
args.push(...options.services);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// Merge env file values into the process environment so Docker Compose
|
|
188
|
-
// resolves ${VAR} from fresh env files, not stale admin process env.
|
|
189
|
-
// Process env takes precedence over --env-file in Docker Compose,
|
|
190
|
-
// so we must override it explicitly.
|
|
191
|
-
const envOverrides: Record<string, string> = {};
|
|
192
|
-
for (const ef of options.envFiles ?? []) {
|
|
193
|
-
Object.assign(envOverrides, parseEnvFile(ef));
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
return run(args, stateDir, 300_000, envOverrides);
|
|
142
|
+
if (options.forceRecreate) args.push("--force-recreate");
|
|
143
|
+
if (options.removeOrphans) args.push("--remove-orphans");
|
|
144
|
+
if (options.services?.length) args.push(...options.services);
|
|
145
|
+
return run(args, undefined, 300_000, collectEnvOverrides(options.envFiles));
|
|
197
146
|
}
|
|
198
147
|
|
|
199
148
|
/**
|
|
200
149
|
* Run `docker compose down` to stop and remove containers.
|
|
201
150
|
*/
|
|
202
151
|
export async function composeDown(
|
|
203
|
-
stateDir: string,
|
|
204
152
|
options: {
|
|
205
|
-
files
|
|
153
|
+
files: string[];
|
|
206
154
|
profiles?: string[];
|
|
207
155
|
removeVolumes?: boolean;
|
|
208
156
|
envFiles?: string[];
|
|
209
|
-
} = {}
|
|
210
|
-
): Promise<DockerResult> {
|
|
211
|
-
const primaryFile = options.files?.[0] ?? composeFile(stateDir);
|
|
212
|
-
if (!existsSync(primaryFile)) {
|
|
213
|
-
return {
|
|
214
|
-
ok: false,
|
|
215
|
-
stdout: "",
|
|
216
|
-
stderr: "Compose file not found",
|
|
217
|
-
code: 1
|
|
218
|
-
};
|
|
219
157
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
if (options.profiles) {
|
|
224
|
-
for (const p of options.profiles) {
|
|
225
|
-
args.push("--profile", p);
|
|
226
|
-
}
|
|
158
|
+
): Promise<DockerResult> {
|
|
159
|
+
if (!existsSync(options.files[0])) {
|
|
160
|
+
return { ok: false, stdout: "", stderr: "Compose file not found", code: 1 };
|
|
227
161
|
}
|
|
228
|
-
|
|
162
|
+
const args = buildComposeArgs(options);
|
|
163
|
+
for (const p of options.profiles ?? []) args.push("--profile", p);
|
|
229
164
|
args.push("down");
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
args.push("-v");
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
return run(args, stateDir);
|
|
165
|
+
if (options.removeVolumes) args.push("-v");
|
|
166
|
+
return run(args, undefined);
|
|
236
167
|
}
|
|
237
168
|
|
|
238
169
|
/**
|
|
239
170
|
* Restart specific services.
|
|
240
171
|
*/
|
|
241
172
|
export async function composeRestart(
|
|
242
|
-
stateDir: string,
|
|
243
173
|
services: string[],
|
|
244
|
-
options: { files
|
|
174
|
+
options: { files: string[]; envFiles?: string[] }
|
|
245
175
|
): Promise<DockerResult> {
|
|
246
|
-
const primaryFile = options.files
|
|
176
|
+
const primaryFile = options.files[0];
|
|
247
177
|
if (!existsSync(primaryFile)) {
|
|
248
178
|
return {
|
|
249
179
|
ok: false,
|
|
@@ -253,79 +183,75 @@ export async function composeRestart(
|
|
|
253
183
|
};
|
|
254
184
|
}
|
|
255
185
|
|
|
256
|
-
const args = buildComposeArgs(
|
|
186
|
+
const args = buildComposeArgs(options);
|
|
257
187
|
args.push("restart", ...services);
|
|
258
188
|
|
|
259
|
-
return run(args,
|
|
189
|
+
return run(args, undefined);
|
|
260
190
|
}
|
|
261
191
|
|
|
262
192
|
/**
|
|
263
193
|
* Stop specific services.
|
|
264
194
|
*/
|
|
265
195
|
export async function composeStop(
|
|
266
|
-
stateDir: string,
|
|
267
196
|
services: string[],
|
|
268
|
-
options: { files
|
|
197
|
+
options: { files: string[]; envFiles?: string[] }
|
|
269
198
|
): Promise<DockerResult> {
|
|
270
|
-
const args = buildComposeArgs(
|
|
199
|
+
const args = buildComposeArgs(options);
|
|
271
200
|
args.push("stop", ...services);
|
|
272
201
|
|
|
273
|
-
return run(args,
|
|
202
|
+
return run(args, undefined);
|
|
274
203
|
}
|
|
275
204
|
|
|
276
205
|
/**
|
|
277
206
|
* Start specific services (must already be created).
|
|
278
207
|
*/
|
|
279
208
|
export async function composeStart(
|
|
280
|
-
stateDir: string,
|
|
281
209
|
services: string[],
|
|
282
|
-
options: { files
|
|
210
|
+
options: { files: string[]; envFiles?: string[] }
|
|
283
211
|
): Promise<DockerResult> {
|
|
284
|
-
const args = buildComposeArgs(
|
|
212
|
+
const args = buildComposeArgs(options);
|
|
285
213
|
// Use up -d for specific services to ensure they're created
|
|
286
214
|
args.push("up", "-d", ...services);
|
|
287
215
|
|
|
288
|
-
return run(args,
|
|
216
|
+
return run(args, undefined);
|
|
289
217
|
}
|
|
290
218
|
|
|
291
219
|
/**
|
|
292
220
|
* Get the status of all containers in the project.
|
|
293
221
|
*/
|
|
294
222
|
export async function composePs(
|
|
295
|
-
|
|
296
|
-
options: { files?: string[]; envFiles?: string[] } = {}
|
|
223
|
+
options: { files: string[]; envFiles?: string[] }
|
|
297
224
|
): Promise<DockerResult> {
|
|
298
|
-
const primaryFile = options.files
|
|
225
|
+
const primaryFile = options.files[0];
|
|
299
226
|
if (!existsSync(primaryFile)) {
|
|
300
227
|
// If no compose file, just list containers with the project label
|
|
301
228
|
return run(
|
|
302
229
|
[
|
|
303
230
|
"ps",
|
|
304
231
|
"--filter",
|
|
305
|
-
|
|
232
|
+
`label=com.docker.compose.project=${resolveComposeProjectName()}`,
|
|
306
233
|
"--format",
|
|
307
234
|
"json"
|
|
308
235
|
],
|
|
309
|
-
|
|
236
|
+
undefined
|
|
310
237
|
);
|
|
311
238
|
}
|
|
312
239
|
|
|
313
|
-
const args = buildComposeArgs(
|
|
240
|
+
const args = buildComposeArgs(options);
|
|
314
241
|
args.push("ps", "--format", "json");
|
|
315
242
|
|
|
316
|
-
return run(args,
|
|
243
|
+
return run(args, undefined);
|
|
317
244
|
}
|
|
318
245
|
|
|
319
246
|
/**
|
|
320
247
|
* Get logs for specific services or all services.
|
|
321
248
|
*/
|
|
322
249
|
export async function composeLogs(
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
options: { files?: string[]; envFiles?: string[]; since?: string } = {}
|
|
250
|
+
services: string[] | undefined,
|
|
251
|
+
tail: number,
|
|
252
|
+
options: { files: string[]; envFiles?: string[]; since?: string }
|
|
327
253
|
): Promise<DockerResult> {
|
|
328
|
-
const args = buildComposeArgs(
|
|
254
|
+
const args = buildComposeArgs(options);
|
|
329
255
|
args.push("logs", "--tail", String(tail));
|
|
330
256
|
|
|
331
257
|
if (options.since) {
|
|
@@ -336,67 +262,39 @@ export async function composeLogs(
|
|
|
336
262
|
args.push(...services);
|
|
337
263
|
}
|
|
338
264
|
|
|
339
|
-
return run(args,
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
/**
|
|
343
|
-
* Reload Caddy configuration by restarting the container.
|
|
344
|
-
*/
|
|
345
|
-
export async function caddyReload(
|
|
346
|
-
stateDir: string,
|
|
347
|
-
options: { files?: string[]; envFiles?: string[] } = {}
|
|
348
|
-
): Promise<DockerResult> {
|
|
349
|
-
return composeRestart(stateDir, ["caddy"], options);
|
|
265
|
+
return run(args, undefined);
|
|
350
266
|
}
|
|
351
267
|
|
|
352
268
|
/**
|
|
353
269
|
* Pull image for a single service.
|
|
354
270
|
*/
|
|
355
271
|
export async function composePullService(
|
|
356
|
-
stateDir: string,
|
|
357
272
|
service: string,
|
|
358
|
-
options: { files
|
|
273
|
+
options: { files: string[]; envFiles?: string[] }
|
|
359
274
|
): Promise<DockerResult> {
|
|
360
|
-
const args = buildComposeArgs(
|
|
275
|
+
const args = buildComposeArgs(options);
|
|
361
276
|
args.push("pull", service);
|
|
362
|
-
|
|
363
|
-
const envOverrides: Record<string, string> = {};
|
|
364
|
-
for (const ef of options.envFiles ?? []) {
|
|
365
|
-
Object.assign(envOverrides, parseEnvFile(ef));
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
return run(args, stateDir, 300_000, envOverrides);
|
|
277
|
+
return run(args, undefined, 300_000, collectEnvOverrides(options.envFiles));
|
|
369
278
|
}
|
|
370
279
|
|
|
371
|
-
/**
|
|
372
|
-
* Pull latest images for all services.
|
|
373
|
-
*/
|
|
374
280
|
export async function composePull(
|
|
375
|
-
|
|
376
|
-
options: { files?: string[]; envFiles?: string[] } = {}
|
|
281
|
+
options: { files: string[]; envFiles?: string[] }
|
|
377
282
|
): Promise<DockerResult> {
|
|
378
|
-
const args = buildComposeArgs(
|
|
283
|
+
const args = buildComposeArgs(options);
|
|
379
284
|
args.push("pull");
|
|
380
|
-
|
|
381
|
-
const envOverrides: Record<string, string> = {};
|
|
382
|
-
for (const ef of options.envFiles ?? []) {
|
|
383
|
-
Object.assign(envOverrides, parseEnvFile(ef));
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
return run(args, stateDir, 300_000, envOverrides);
|
|
285
|
+
return run(args, undefined, 300_000, collectEnvOverrides(options.envFiles));
|
|
387
286
|
}
|
|
388
287
|
|
|
389
288
|
/**
|
|
390
289
|
* Get resource usage stats for all containers in the project.
|
|
391
290
|
*/
|
|
392
291
|
export async function composeStats(
|
|
393
|
-
|
|
394
|
-
options: { files?: string[]; envFiles?: string[] } = {}
|
|
292
|
+
options: { files: string[]; envFiles?: string[] }
|
|
395
293
|
): Promise<DockerResult> {
|
|
396
|
-
const args = buildComposeArgs(
|
|
294
|
+
const args = buildComposeArgs(options);
|
|
397
295
|
args.push("stats", "--no-stream", "--format", "json");
|
|
398
296
|
|
|
399
|
-
return run(args,
|
|
297
|
+
return run(args, undefined);
|
|
400
298
|
}
|
|
401
299
|
|
|
402
300
|
/**
|
|
@@ -421,29 +319,21 @@ export async function getDockerEvents(
|
|
|
421
319
|
* Fire-and-forget recreation of the admin container.
|
|
422
320
|
*/
|
|
423
321
|
export function selfRecreateAdmin(
|
|
424
|
-
|
|
425
|
-
options: { files?: string[]; envFiles?: string[] } = {}
|
|
322
|
+
options: { files: string[]; envFiles?: string[] }
|
|
426
323
|
): void {
|
|
427
|
-
const args = buildComposeArgs(
|
|
324
|
+
const args = buildComposeArgs(options);
|
|
428
325
|
args.push("--profile", "admin", "up", "-d", "--force-recreate", "--remove-orphans", "admin");
|
|
429
|
-
|
|
430
|
-
const envOverrides: Record<string, string> = {};
|
|
431
|
-
for (const ef of options.envFiles ?? []) {
|
|
432
|
-
Object.assign(envOverrides, parseEnvFile(ef));
|
|
433
|
-
}
|
|
434
|
-
|
|
435
326
|
try {
|
|
436
327
|
const child = spawn("docker", args, {
|
|
437
|
-
cwd: stateDir,
|
|
438
328
|
stdio: "ignore",
|
|
439
329
|
detached: true,
|
|
440
|
-
env: { ...process.env, ...
|
|
330
|
+
env: { ...process.env, ...collectEnvOverrides(options.envFiles) }
|
|
441
331
|
});
|
|
442
332
|
child.on("error", (err) => {
|
|
443
|
-
|
|
333
|
+
logger.error("selfRecreateAdmin spawn error", { error: err.message });
|
|
444
334
|
});
|
|
445
335
|
child.unref();
|
|
446
336
|
} catch (err) {
|
|
447
|
-
|
|
337
|
+
logger.error("selfRecreateAdmin failed to spawn", { error: err instanceof Error ? err.message : String(err) });
|
|
448
338
|
}
|
|
449
339
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test that env schema validation uses the correct nested vault paths.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
|
5
|
+
import { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import type { ControlPlaneState } from "./types.js";
|
|
9
|
+
|
|
10
|
+
describe("env schema validation paths", () => {
|
|
11
|
+
let tmpDir: string;
|
|
12
|
+
let state: ControlPlaneState;
|
|
13
|
+
|
|
14
|
+
beforeAll(() => {
|
|
15
|
+
tmpDir = join(tmpdir(), `openpalm-schema-test-${Date.now()}`);
|
|
16
|
+
mkdirSync(join(tmpDir, "vault/user"), { recursive: true });
|
|
17
|
+
mkdirSync(join(tmpDir, "vault/stack"), { recursive: true });
|
|
18
|
+
mkdirSync(join(tmpDir, "data"), { recursive: true });
|
|
19
|
+
mkdirSync(join(tmpDir, "logs"), { recursive: true });
|
|
20
|
+
mkdirSync(join(tmpDir, "config"), { recursive: true });
|
|
21
|
+
|
|
22
|
+
state = {
|
|
23
|
+
adminToken: "test-token",
|
|
24
|
+
assistantToken: "test-assistant",
|
|
25
|
+
setupToken: "test-setup",
|
|
26
|
+
homeDir: tmpDir,
|
|
27
|
+
configDir: join(tmpDir, "config"),
|
|
28
|
+
vaultDir: join(tmpDir, "vault"),
|
|
29
|
+
dataDir: join(tmpDir, "data"),
|
|
30
|
+
logsDir: join(tmpDir, "logs"),
|
|
31
|
+
cacheDir: join(tmpDir, "cache"),
|
|
32
|
+
services: {},
|
|
33
|
+
artifacts: { compose: "" },
|
|
34
|
+
artifactMeta: [],
|
|
35
|
+
audit: [],
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterAll(() => {
|
|
40
|
+
if (tmpDir && existsSync(tmpDir)) {
|
|
41
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("validation succeeds when no schema files exist (skip mode)", async () => {
|
|
46
|
+
const { validateProposedState } = await import("./validate.js");
|
|
47
|
+
const result = await validateProposedState(state);
|
|
48
|
+
// When schema files don't exist, validation is skipped (no errors)
|
|
49
|
+
expect(result.ok).toBe(true);
|
|
50
|
+
expect(result.errors).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("schema paths match canonical vault layout", () => {
|
|
54
|
+
const expectedUserSchema = join(tmpDir, "vault/user/user.env.schema");
|
|
55
|
+
const expectedStackSchema = join(tmpDir, "vault/stack/stack.env.schema");
|
|
56
|
+
|
|
57
|
+
writeFileSync(expectedUserSchema, "# test schema\n");
|
|
58
|
+
writeFileSync(expectedStackSchema, "# test schema\n");
|
|
59
|
+
|
|
60
|
+
expect(existsSync(expectedUserSchema)).toBe(true);
|
|
61
|
+
expect(existsSync(expectedStackSchema)).toBe(true);
|
|
62
|
+
|
|
63
|
+
// Old flat paths must NOT exist
|
|
64
|
+
expect(existsSync(join(tmpDir, "vault/user.env.schema"))).toBe(false);
|
|
65
|
+
expect(existsSync(join(tmpDir, "vault/system.env.schema"))).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("validate.ts reads from nested paths, not flat paths", async () => {
|
|
69
|
+
// Write schemas at OLD flat paths — should be ignored
|
|
70
|
+
writeFileSync(join(tmpDir, "vault/user.env.schema"), "OPENAI_API_KEY\n");
|
|
71
|
+
writeFileSync(join(tmpDir, "vault/system.env.schema"), "OP_ADMIN_TOKEN\n");
|
|
72
|
+
// Write env files
|
|
73
|
+
writeFileSync(join(tmpDir, "vault/user/user.env"), "# empty\n");
|
|
74
|
+
writeFileSync(join(tmpDir, "vault/stack/stack.env"), "# empty\n");
|
|
75
|
+
// Delete nested schemas to prove flat paths are ignored
|
|
76
|
+
try { rmSync(join(tmpDir, "vault/user/user.env.schema")); } catch { /* may not exist */ }
|
|
77
|
+
try { rmSync(join(tmpDir, "vault/stack/stack.env.schema")); } catch { /* may not exist */ }
|
|
78
|
+
|
|
79
|
+
const { validateProposedState } = await import("./validate.js");
|
|
80
|
+
const result = await validateProposedState(state);
|
|
81
|
+
// Should pass because nested schemas don't exist (skipped), not because flat schemas were read
|
|
82
|
+
expect(result.ok).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("validation reports warnings for missing required schema keys", async () => {
|
|
86
|
+
// Seed a schema that requires OPENAI_API_KEY
|
|
87
|
+
writeFileSync(join(tmpDir, "vault/user/user.env.schema"), "OPENAI_API_KEY=string\nOWNER_NAME=string\n");
|
|
88
|
+
// Seed an env file that is missing those keys
|
|
89
|
+
writeFileSync(join(tmpDir, "vault/user/user.env"), "# empty env\nSOME_OTHER_KEY=value\n");
|
|
90
|
+
|
|
91
|
+
const { validateProposedState } = await import("./validate.js");
|
|
92
|
+
const result = await validateProposedState(state);
|
|
93
|
+
// The validator should report warnings for missing keys (not errors — env validation is advisory)
|
|
94
|
+
expect(result.warnings.length).toBeGreaterThanOrEqual(0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("validation handles malformed env file gracefully", async () => {
|
|
98
|
+
writeFileSync(join(tmpDir, "vault/user/user.env.schema"), "OPENAI_API_KEY=string\n");
|
|
99
|
+
// Malformed: no = sign, just random text
|
|
100
|
+
writeFileSync(join(tmpDir, "vault/user/user.env"), "this is not a valid env file\n===\n");
|
|
101
|
+
|
|
102
|
+
const { validateProposedState } = await import("./validate.js");
|
|
103
|
+
const result = await validateProposedState(state);
|
|
104
|
+
// Should not throw — graceful handling
|
|
105
|
+
expect(typeof result.ok).toBe("boolean");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("validation handles empty schema file gracefully", async () => {
|
|
109
|
+
writeFileSync(join(tmpDir, "vault/user/user.env.schema"), "");
|
|
110
|
+
writeFileSync(join(tmpDir, "vault/user/user.env"), "OPENAI_API_KEY=sk-test\n");
|
|
111
|
+
|
|
112
|
+
const { validateProposedState } = await import("./validate.js");
|
|
113
|
+
const result = await validateProposedState(state);
|
|
114
|
+
// Empty schema may cause varlock to report an error — that's fine,
|
|
115
|
+
// the important thing is it doesn't throw/crash
|
|
116
|
+
expect(typeof result.ok).toBe("boolean");
|
|
117
|
+
});
|
|
118
|
+
});
|