@openpalm/lib 0.9.9 → 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.
Files changed (56) hide show
  1. package/README.md +31 -71
  2. package/package.json +1 -1
  3. package/src/control-plane/audit.ts +4 -4
  4. package/src/control-plane/backup.ts +31 -0
  5. package/src/control-plane/channels.ts +88 -156
  6. package/src/control-plane/cleanup-guardrails.test.ts +289 -0
  7. package/src/control-plane/compose-args.test.ts +170 -0
  8. package/src/control-plane/compose-args.ts +57 -0
  9. package/src/control-plane/config-persistence.ts +270 -0
  10. package/src/control-plane/core-assets.ts +58 -234
  11. package/src/control-plane/crypto.ts +14 -0
  12. package/src/control-plane/docker.ts +94 -204
  13. package/src/control-plane/env-schema-validation.test.ts +118 -0
  14. package/src/control-plane/extends-support.test.ts +105 -0
  15. package/src/control-plane/home.ts +133 -0
  16. package/src/control-plane/install-edge-cases.test.ts +314 -717
  17. package/src/control-plane/lifecycle.ts +215 -233
  18. package/src/control-plane/lock.test.ts +194 -0
  19. package/src/control-plane/lock.ts +176 -0
  20. package/src/control-plane/memory-config.ts +34 -160
  21. package/src/control-plane/opencode-client.test.ts +154 -0
  22. package/src/control-plane/opencode-client.ts +113 -0
  23. package/src/control-plane/provider-config.ts +34 -0
  24. package/src/control-plane/redact-schema.ts +50 -0
  25. package/src/control-plane/registry-components.test.ts +313 -0
  26. package/src/control-plane/registry.test.ts +414 -0
  27. package/src/control-plane/registry.ts +418 -0
  28. package/src/control-plane/rollback.ts +128 -0
  29. package/src/control-plane/scheduler.ts +18 -190
  30. package/src/control-plane/secret-backend.test.ts +359 -0
  31. package/src/control-plane/secret-backend.ts +322 -0
  32. package/src/control-plane/secret-mappings.ts +185 -0
  33. package/src/control-plane/secrets.ts +186 -112
  34. package/src/control-plane/setup-config.schema.json +306 -0
  35. package/src/control-plane/setup-status.ts +15 -8
  36. package/src/control-plane/setup-validation.ts +90 -0
  37. package/src/control-plane/setup.test.ts +336 -929
  38. package/src/control-plane/setup.ts +158 -886
  39. package/src/control-plane/spec-to-env.test.ts +100 -0
  40. package/src/control-plane/spec-to-env.ts +195 -0
  41. package/src/control-plane/spec-validator.ts +159 -0
  42. package/src/control-plane/stack-spec.test.ts +150 -0
  43. package/src/control-plane/stack-spec.ts +101 -22
  44. package/src/control-plane/types.ts +6 -99
  45. package/src/control-plane/validate.ts +107 -0
  46. package/src/index.ts +101 -159
  47. package/src/provider-constants.ts +2 -31
  48. package/src/control-plane/connection-mapping.ts +0 -191
  49. package/src/control-plane/connection-migration-flags.ts +0 -40
  50. package/src/control-plane/connection-profiles.ts +0 -317
  51. package/src/control-plane/core-asset-provider.ts +0 -21
  52. package/src/control-plane/fs-asset-provider.ts +0 -65
  53. package/src/control-plane/fs-registry-provider.ts +0 -46
  54. package/src/control-plane/paths.ts +0 -77
  55. package/src/control-plane/registry-provider.ts +0 -19
  56. package/src/control-plane/staging.ts +0 -399
@@ -1,399 +0,0 @@
1
- /**
2
- * Artifact staging pipeline for the OpenPalm control plane.
3
- *
4
- * Stages artifacts from CONFIG_HOME/DATA_HOME into STATE_HOME:
5
- * Caddyfile/compose staging, env staging, channel/automation file staging,
6
- * and artifact persistence.
7
- *
8
- * All asset content is provided by a CoreAssetProvider (injected).
9
- */
10
- import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync, copyFileSync } from "node:fs";
11
- import { join } from "node:path";
12
- import { createHash, randomBytes } from "node:crypto";
13
- import { mergeEnvContent } from './env.js';
14
- import type { ControlPlaneState, ArtifactMeta } from "./types.js";
15
- import { discoverChannels } from "./channels.js";
16
- import { appendAudit } from "./audit.js";
17
- import { parseAutomationYaml } from "./scheduler.js";
18
- import type { CoreAssetProvider } from "./core-asset-provider.js";
19
- import {
20
- readCoreCaddyfile,
21
- readCoreCompose,
22
- readOllamaCompose,
23
- readAdminCompose,
24
- ensureSecretsSchema,
25
- ensureStackSchema,
26
- PUBLIC_ACCESS_IMPORT,
27
- LAN_ONLY_IMPORT
28
- } from "./core-assets.js";
29
-
30
- const DEFAULT_IMAGE_TAG = process.env.OPENPALM_IMAGE_TAG ?? "latest";
31
-
32
- // ── Crypto Utilities ──────────────────────────────────────────────────
33
-
34
- export function sha256(content: string): string {
35
- return createHash("sha256").update(content).digest("hex");
36
- }
37
-
38
- /** Generate a hex string using Node's crypto.randomBytes (CSPRNG). */
39
- export function randomHex(bytes: number): string {
40
- return randomBytes(bytes).toString("hex");
41
- }
42
-
43
- // ── Ollama State ─────────────────────────────────────────────────────
44
-
45
- /**
46
- * Check whether Ollama is enabled in the stack by reading the
47
- * OPENPALM_OLLAMA_ENABLED flag from DATA_HOME/stack.env.
48
- */
49
- export function isOllamaEnabled(state: ControlPlaneState): boolean {
50
- const stackEnvPath = `${state.dataDir}/stack.env`;
51
- if (!existsSync(stackEnvPath)) return false;
52
- const content = readFileSync(stackEnvPath, "utf-8");
53
- const match = content.match(/^OPENPALM_OLLAMA_ENABLED=(.+)$/m);
54
- return match?.[1]?.trim().toLowerCase() === "true";
55
- }
56
-
57
- // ── Admin State ──────────────────────────────────────────────────────
58
-
59
- /**
60
- * Check whether admin is enabled in the stack by reading the
61
- * OPENPALM_ADMIN_ENABLED flag from DATA_HOME/stack.env.
62
- */
63
- export function isAdminEnabled(state: ControlPlaneState): boolean {
64
- const stackEnvPath = `${state.dataDir}/stack.env`;
65
- if (!existsSync(stackEnvPath)) return false;
66
- const content = readFileSync(stackEnvPath, "utf-8");
67
- const match = content.match(/^OPENPALM_ADMIN_ENABLED=(.+)$/m);
68
- return match?.[1]?.trim().toLowerCase() === "true";
69
- }
70
-
71
- // ── Caddyfile Staging ─────────────────────────────────────────────────
72
-
73
- export function withDefaultLanOnly(rawCaddy: string): string | null {
74
- if (rawCaddy.includes(PUBLIC_ACCESS_IMPORT) || rawCaddy.includes(LAN_ONLY_IMPORT)) {
75
- return rawCaddy;
76
- }
77
-
78
- const blockStarts = [
79
- /(handle_path\s+[^\n{]+\{\s*\n?)/,
80
- /(handle\s+[^\n{]+\{\s*\n?)/,
81
- /(route\s+[^\n{]+\{\s*\n?)/
82
- ];
83
-
84
- for (const pattern of blockStarts) {
85
- if (pattern.test(rawCaddy)) {
86
- return rawCaddy.replace(pattern, "$1\timport lan_only\n");
87
- }
88
- }
89
-
90
- return null;
91
- }
92
-
93
- export function stageChannelCaddyfiles(state: ControlPlaneState): void {
94
- const stagedChannelsDir = `${state.stateDir}/artifacts/channels`;
95
- const stagedPublicDir = `${stagedChannelsDir}/public`;
96
- const stagedLanDir = `${stagedChannelsDir}/lan`;
97
- rmSync(stagedPublicDir, { recursive: true, force: true });
98
- rmSync(stagedLanDir, { recursive: true, force: true });
99
- mkdirSync(stagedPublicDir, { recursive: true });
100
- mkdirSync(stagedLanDir, { recursive: true });
101
-
102
- const channels = discoverChannels(state.configDir);
103
- for (const ch of channels) {
104
- if (!ch.caddyPath) continue;
105
-
106
- const raw = readFileSync(ch.caddyPath, "utf-8");
107
- if (raw.includes(PUBLIC_ACCESS_IMPORT)) {
108
- writeFileSync(`${stagedPublicDir}/${ch.name}.caddy`, raw);
109
- continue;
110
- }
111
-
112
- const lanScoped = withDefaultLanOnly(raw);
113
- if (!lanScoped) {
114
- appendAudit(
115
- state,
116
- "system",
117
- "channels.route.skip",
118
- {
119
- channel: ch.name,
120
- reason: "Unable to infer route block for default LAN scoping"
121
- },
122
- false,
123
- "",
124
- "system"
125
- );
126
- continue;
127
- }
128
- writeFileSync(`${stagedLanDir}/${ch.name}.caddy`, lanScoped);
129
- }
130
- }
131
-
132
- function stageCaddyfile(_state: ControlPlaneState, assets: CoreAssetProvider): string {
133
- return readCoreCaddyfile(assets);
134
- }
135
-
136
- // ── Compose Staging ───────────────────────────────────────────────────
137
-
138
- function stageCompose(_state: ControlPlaneState, assets: CoreAssetProvider): string {
139
- return readCoreCompose(assets);
140
- }
141
-
142
- // ── Env Staging ───────────────────────────────────────────────────────
143
-
144
- export function stageSecretsEnv(state: ControlPlaneState): void {
145
- const artifactDir = `${state.stateDir}/artifacts`;
146
- mkdirSync(artifactDir, { recursive: true });
147
-
148
- const source = `${state.configDir}/secrets.env`;
149
- const content = existsSync(source) ? readFileSync(source, "utf-8") : "";
150
- writeFileSync(`${artifactDir}/secrets.env`, content);
151
- }
152
-
153
- /** Return the path to the staged secrets.env in STATE_HOME. */
154
- export function stagedEnvFile(state: ControlPlaneState): string {
155
- return `${state.stateDir}/artifacts/secrets.env`;
156
- }
157
-
158
- /** Return the path to the staged stack.env in STATE_HOME. */
159
- export function stagedStackEnvFile(state: ControlPlaneState): string {
160
- return `${state.stateDir}/artifacts/stack.env`;
161
- }
162
-
163
- /**
164
- * Return both staged env files in load order: [stack.env, secrets.env].
165
- * Non-existent files are omitted so docker compose does not error.
166
- */
167
- export function buildEnvFiles(state: ControlPlaneState): string[] {
168
- return [stagedStackEnvFile(state), stagedEnvFile(state)].filter(existsSync);
169
- }
170
-
171
- function stageStackEnv(state: ControlPlaneState): void {
172
- const artifactDir = `${state.stateDir}/artifacts`;
173
- mkdirSync(artifactDir, { recursive: true });
174
-
175
- const dataStackEnv = `${state.dataDir}/stack.env`;
176
-
177
- let base = "";
178
- if (existsSync(dataStackEnv)) {
179
- base = readFileSync(dataStackEnv, "utf-8");
180
- } else {
181
- base = generateFallbackStackEnv(state);
182
- mkdirSync(state.dataDir, { recursive: true });
183
- writeFileSync(dataStackEnv, base);
184
- }
185
-
186
- // Preserve existing OPENPALM_SETUP_COMPLETE=true from stack.env;
187
- // only mark complete if it was already true (not inferred from token presence).
188
- const alreadyComplete = /^OPENPALM_SETUP_COMPLETE=true$/mi.test(base);
189
-
190
- const adminManaged: Record<string, string> = {
191
- OPENPALM_SETUP_COMPLETE: alreadyComplete ? "true" : "false"
192
- };
193
- for (const [ch, secret] of Object.entries(state.channelSecrets)) {
194
- adminManaged[`CHANNEL_${ch.toUpperCase()}_SECRET`] = secret;
195
- }
196
-
197
- const content = mergeEnvContent(base, adminManaged, {
198
- sectionHeader: "# ── Admin-managed ──────────────────────────────────────────────────"
199
- });
200
-
201
- writeFileSync(dataStackEnv, content);
202
- writeFileSync(`${artifactDir}/stack.env`, content);
203
- }
204
-
205
- function generateFallbackStackEnv(state: ControlPlaneState): string {
206
- const uid = typeof process.getuid === "function" ? (process.getuid() ?? 1000) : 1000;
207
- const gid = typeof process.getgid === "function" ? (process.getgid() ?? 1000) : 1000;
208
-
209
- const home = process.env.HOME ?? "/home/node";
210
- const workDir = process.env.OPENPALM_WORK_DIR ?? `${home}/openpalm`;
211
-
212
- return [
213
- "# OpenPalm Stack Configuration — system-managed, do not edit",
214
- "# Auto-generated fallback (setup.sh has not run yet).",
215
- "",
216
- "# ── XDG Paths ──────────────────────────────────────────────────────",
217
- `OPENPALM_CONFIG_HOME=${state.configDir}`,
218
- `OPENPALM_DATA_HOME=${state.dataDir}`,
219
- `OPENPALM_STATE_HOME=${state.stateDir}`,
220
- `OPENPALM_WORK_DIR=${workDir}`,
221
- "",
222
- "# ── User/Group ──────────────────────────────────────────────────────",
223
- `OPENPALM_UID=${uid}`,
224
- `OPENPALM_GID=${gid}`,
225
- "",
226
- "# ── Docker Socket ───────────────────────────────────────────────────",
227
- `OPENPALM_DOCKER_SOCK=${process.env.OPENPALM_DOCKER_SOCK ?? "/var/run/docker.sock"}`,
228
- "",
229
- "# ── Images ──────────────────────────────────────────────────────────",
230
- `OPENPALM_IMAGE_NAMESPACE=${process.env.OPENPALM_IMAGE_NAMESPACE ?? "openpalm"}`,
231
- `OPENPALM_IMAGE_TAG=${DEFAULT_IMAGE_TAG}`,
232
- "",
233
- "# ── Networking ──────────────────────────────────────────────────────",
234
- "# SECURITY: Bind addresses default to 127.0.0.1. Changing to 0.0.0.0 exposes services publicly.",
235
- `OPENPALM_INGRESS_BIND_ADDRESS=${process.env.OPENPALM_INGRESS_BIND_ADDRESS ?? "127.0.0.1"}`,
236
- `OPENPALM_INGRESS_PORT=${process.env.OPENPALM_INGRESS_PORT ?? "8080"}`,
237
- "",
238
- "# ── Channel HMAC Secrets ────────────────────────────────────────────",
239
- ""
240
- ].join("\n");
241
- }
242
-
243
- // ── Channel YML Staging ───────────────────────────────────────────────
244
-
245
- export function stageChannelYmlFiles(state: ControlPlaneState): void {
246
- const stagedChannelsDir = `${state.stateDir}/artifacts/channels`;
247
- mkdirSync(stagedChannelsDir, { recursive: true });
248
-
249
- for (const f of readdirSync(stagedChannelsDir)) {
250
- if (f.endsWith(".yml")) {
251
- rmSync(`${stagedChannelsDir}/${f}`, { force: true });
252
- }
253
- }
254
-
255
- const channels = discoverChannels(state.configDir);
256
- for (const ch of channels) {
257
- const content = readFileSync(ch.ymlPath, "utf-8");
258
- writeFileSync(`${stagedChannelsDir}/${ch.name}.yml`, content);
259
- }
260
- }
261
-
262
- /**
263
- * Discover staged channel .yml overlays from STATE_HOME/artifacts/channels/.
264
- */
265
- export function discoverStagedChannelYmls(stateDir: string): string[] {
266
- const channelsDir = `${stateDir}/artifacts/channels`;
267
- if (!existsSync(channelsDir)) return [];
268
-
269
- return readdirSync(channelsDir, { withFileTypes: true })
270
- .filter((entry) => entry.isFile() && entry.name.endsWith(".yml"))
271
- .map((entry) => `${channelsDir}/${entry.name}`);
272
- }
273
-
274
- // ── Automation Staging ───────────────────────────────────────────────
275
-
276
- const AUTOMATION_FILE_NAME_RE = /^[a-z0-9][a-z0-9-]{0,62}\.yml$/;
277
-
278
- function discoverAutomationFiles(dir: string): { name: string; path: string }[] {
279
- if (!existsSync(dir)) return [];
280
- return readdirSync(dir, { withFileTypes: true })
281
- .filter((entry) => entry.isFile() && !entry.name.startsWith("."))
282
- .map((entry) => ({ name: entry.name, path: join(dir, entry.name) }))
283
- .filter((entry) => AUTOMATION_FILE_NAME_RE.test(entry.name));
284
- }
285
-
286
- function validateAutomationContent(content: string, fileName: string): boolean {
287
- return parseAutomationYaml(content, fileName) !== null;
288
- }
289
-
290
- export function stageAutomationFiles(state: ControlPlaneState): void {
291
- const stagedDir = `${state.stateDir}/automations`;
292
- mkdirSync(stagedDir, { recursive: true });
293
-
294
- for (const f of readdirSync(stagedDir)) {
295
- const fullPath = `${stagedDir}/${f}`;
296
- if (!f.startsWith(".")) {
297
- rmSync(fullPath, { force: true });
298
- }
299
- }
300
-
301
- const systemDir = `${state.dataDir}/automations`;
302
- for (const entry of discoverAutomationFiles(systemDir)) {
303
- const content = readFileSync(entry.path, "utf-8");
304
- if (!validateAutomationContent(content, entry.name)) continue;
305
- writeFileSync(`${stagedDir}/${entry.name}`, content);
306
- }
307
-
308
- const userDir = `${state.configDir}/automations`;
309
- for (const entry of discoverAutomationFiles(userDir)) {
310
- const content = readFileSync(entry.path, "utf-8");
311
- if (!validateAutomationContent(content, entry.name)) continue;
312
- writeFileSync(`${stagedDir}/${entry.name}`, content);
313
- }
314
- }
315
-
316
- // ── Env Schema Staging ────────────────────────────────────────────────
317
-
318
- function stageEnvSchemas(state: ControlPlaneState, assets: CoreAssetProvider): void {
319
- const destDir = `${state.dataDir}/assistant/env-schema`;
320
- mkdirSync(destDir, { recursive: true });
321
-
322
- const secretsSchemaPath = ensureSecretsSchema(assets);
323
- const stackSchemaPath = ensureStackSchema(assets);
324
-
325
- copyFileSync(secretsSchemaPath, `${destDir}/secrets.env.schema`);
326
- copyFileSync(stackSchemaPath, `${destDir}/stack.env.schema`);
327
- }
328
-
329
- // ── Top-Level Staging ─────────────────────────────────────────────────
330
-
331
- export function stageArtifacts(
332
- state: ControlPlaneState,
333
- assets: CoreAssetProvider
334
- ): {
335
- compose: string;
336
- caddyfile: string;
337
- } {
338
- return {
339
- compose: stageCompose(state, assets),
340
- caddyfile: stageCaddyfile(state, assets)
341
- };
342
- }
343
-
344
- // ── Artifact Metadata ──────────────────────────────────────────────────
345
-
346
- export function buildArtifactMeta(artifacts: {
347
- compose: string;
348
- caddyfile: string;
349
- }): ArtifactMeta[] {
350
- const now = new Date().toISOString();
351
- return (["compose", "caddyfile"] as const).map((name) => ({
352
- name,
353
- sha256: sha256(artifacts[name]),
354
- generatedAt: now,
355
- bytes: Buffer.byteLength(artifacts[name])
356
- }));
357
- }
358
-
359
- // ── Persistence ────────────────────────────────────────────────────────
360
-
361
- export function persistArtifacts(
362
- state: ControlPlaneState,
363
- assets: CoreAssetProvider
364
- ): void {
365
- const artifactDir = `${state.stateDir}/artifacts`;
366
- const channelsDir = `${state.configDir}/channels`;
367
- mkdirSync(artifactDir, { recursive: true });
368
- mkdirSync(channelsDir, { recursive: true });
369
-
370
- writeFileSync(`${artifactDir}/docker-compose.yml`, state.artifacts.compose);
371
- writeFileSync(`${artifactDir}/Caddyfile`, state.artifacts.caddyfile);
372
-
373
- if (isOllamaEnabled(state)) {
374
- writeFileSync(`${artifactDir}/ollama.yml`, readOllamaCompose(assets));
375
- }
376
-
377
- if (isAdminEnabled(state)) {
378
- writeFileSync(`${artifactDir}/admin.yml`, readAdminCompose(assets));
379
- }
380
-
381
- const allChannels = discoverChannels(state.configDir);
382
- for (const ch of allChannels) {
383
- if (!state.channelSecrets[ch.name]) {
384
- state.channelSecrets[ch.name] = randomHex(16);
385
- }
386
- }
387
- stageStackEnv(state);
388
- stageSecretsEnv(state);
389
- stageChannelYmlFiles(state);
390
- stageChannelCaddyfiles(state);
391
- stageAutomationFiles(state);
392
- stageEnvSchemas(state, assets);
393
-
394
- state.artifactMeta = buildArtifactMeta(state.artifacts);
395
- writeFileSync(
396
- `${artifactDir}/manifest.json`,
397
- JSON.stringify(state.artifactMeta, null, 2)
398
- );
399
- }