@openpalm/lib 0.9.4

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.
@@ -0,0 +1,373 @@
1
+ /**
2
+ * Lifecycle helpers for the OpenPalm control plane.
3
+ *
4
+ * State factory, apply* lifecycle transitions, compose file list builders,
5
+ * and caller/action validation.
6
+ *
7
+ * All asset operations are delegated via CoreAssetProvider (injected).
8
+ */
9
+ import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync, copyFileSync, mkdtempSync, rmSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { tmpdir } from "node:os";
12
+ import { execFile } from "node:child_process";
13
+ import { promisify } from "node:util";
14
+ import { parseEnvFile, mergeEnvContent } from "./env.js";
15
+ import type { ControlPlaneState, CallerType } from "./types.js";
16
+ import { CORE_SERVICES } from "./types.js";
17
+ import { resolveConfigHome, resolveStateHome, resolveDataHome } from "./paths.js";
18
+ import { loadSecretsEnvFile } from "./secrets.js";
19
+ import { stageArtifacts, persistArtifacts, discoverStagedChannelYmls, randomHex, isOllamaEnabled } from "./staging.js";
20
+ import { refreshCoreAssets, ensureMemoryDir, ensureCoreAutomations } from "./core-assets.js";
21
+ import { ensureMemoryConfig } from "./memory-config.js";
22
+ import { isSetupComplete } from "./setup-status.js";
23
+ import type { CoreAssetProvider } from "./core-asset-provider.js";
24
+
25
+ const execFileAsync = promisify(execFile);
26
+
27
+ /** Resolve the varlock binary path — honours VARLOCK_BIN for dev environments. */
28
+ const envVarlockBin = process.env.VARLOCK_BIN;
29
+ let VARLOCK_BIN = "varlock";
30
+ if (envVarlockBin) {
31
+ if (envVarlockBin === "varlock" || envVarlockBin.startsWith("/")) {
32
+ VARLOCK_BIN = envVarlockBin;
33
+ } else {
34
+ console.warn(
35
+ `Unsafe VARLOCK_BIN value: ${envVarlockBin}. Falling back to "varlock". ` +
36
+ "Must be \"varlock\" or an absolute path.",
37
+ );
38
+ }
39
+ }
40
+
41
+ const IMAGE_NAMESPACE_RE = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;
42
+ const SEMVER_TAG_RE = /^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
43
+
44
+ // ── State Factory ──────────────────────────────────────────────────────
45
+
46
+ export function createState(
47
+ adminToken?: string
48
+ ): ControlPlaneState {
49
+ const stateDir = resolveStateHome();
50
+ const configDir = resolveConfigHome();
51
+ const fileEnv = loadSecretsEnvFile(configDir);
52
+ const resolvedAdminToken =
53
+ adminToken ?? fileEnv.OPENPALM_ADMIN_TOKEN ?? fileEnv.ADMIN_TOKEN ?? process.env.OPENPALM_ADMIN_TOKEN ?? process.env.ADMIN_TOKEN ?? "";
54
+
55
+ const services: Record<string, "running" | "stopped"> = {};
56
+ for (const name of CORE_SERVICES) {
57
+ services[name] = "stopped";
58
+ }
59
+
60
+ const dataDir = resolveDataHome();
61
+
62
+ const persistedSecrets = loadPersistedChannelSecrets(dataDir);
63
+ const channelSecrets: Record<string, string> = { ...persistedSecrets };
64
+
65
+ const setupToken = randomHex(16);
66
+ const state: ControlPlaneState = {
67
+ adminToken: resolvedAdminToken,
68
+ setupToken,
69
+ stateDir,
70
+ configDir,
71
+ dataDir,
72
+ services,
73
+ artifacts: { compose: "", caddyfile: "" },
74
+ artifactMeta: [],
75
+ audit: [],
76
+ channelSecrets
77
+ };
78
+
79
+ writeSetupTokenFile(state);
80
+
81
+ return state;
82
+ }
83
+
84
+ /**
85
+ * Write or remove the setup-token.txt file based on setup completion state.
86
+ */
87
+ export function writeSetupTokenFile(state: ControlPlaneState): void {
88
+ const tokenPath = `${state.stateDir}/setup-token.txt`;
89
+ const setupComplete = isSetupComplete(state.stateDir, state.configDir);
90
+
91
+ if (setupComplete) {
92
+ try { unlinkSync(tokenPath); } catch { /* already gone */ }
93
+ } else {
94
+ mkdirSync(state.stateDir, { recursive: true });
95
+ writeFileSync(tokenPath, state.setupToken + "\n", { mode: 0o600 });
96
+ }
97
+ }
98
+
99
+ // ── Private Loaders ───────────────────────────────────────────────────
100
+
101
+ function loadPersistedChannelSecrets(dataDir: string): Record<string, string> {
102
+ const parsed = parseEnvFile(`${dataDir}/stack.env`);
103
+ const result: Record<string, string> = {};
104
+ for (const [key, value] of Object.entries(parsed)) {
105
+ const match = key.match(/^CHANNEL_([A-Z0-9_]+)_SECRET$/);
106
+ if (match?.[1] && value) result[match[1].toLowerCase()] = value;
107
+ }
108
+ return result;
109
+ }
110
+
111
+ // ── Lifecycle Helpers ──────────────────────────────────────────────────
112
+
113
+ function reconcileCore(
114
+ state: ControlPlaneState,
115
+ assets: CoreAssetProvider,
116
+ opts: { activateServices?: boolean; deactivateServices?: boolean; seedMemoryConfig?: boolean },
117
+ ): string[] {
118
+ if (opts.activateServices) {
119
+ for (const s of CORE_SERVICES) state.services[s] = "running";
120
+ }
121
+ ensureMemoryDir();
122
+ ensureCoreAutomations(assets);
123
+ if (opts.seedMemoryConfig) ensureMemoryConfig(state.dataDir);
124
+
125
+ const active: string[] = [];
126
+ for (const [name, status] of Object.entries(state.services)) {
127
+ if (status === "running") active.push(name);
128
+ }
129
+
130
+ if (opts.deactivateServices) {
131
+ for (const name of Object.keys(state.services)) state.services[name] = "stopped";
132
+ }
133
+
134
+ state.artifacts = stageArtifacts(state, assets);
135
+ persistArtifacts(state, assets);
136
+ return active;
137
+ }
138
+
139
+ export function applyInstall(state: ControlPlaneState, assets: CoreAssetProvider): void {
140
+ reconcileCore(state, assets, { activateServices: true, seedMemoryConfig: true });
141
+ }
142
+
143
+ export function applyUpdate(state: ControlPlaneState, assets: CoreAssetProvider): { restarted: string[] } {
144
+ return { restarted: reconcileCore(state, assets, {}) };
145
+ }
146
+
147
+ export function applyUninstall(state: ControlPlaneState, assets: CoreAssetProvider): { stopped: string[] } {
148
+ return { stopped: reconcileCore(state, assets, { deactivateServices: true }) };
149
+ }
150
+
151
+ type DockerTagEntry = { name?: unknown };
152
+ type DockerTagsResponse = { results?: unknown };
153
+
154
+ function resolveNewestDockerTag(payload: unknown): string | null {
155
+ const results = (payload as DockerTagsResponse)?.results;
156
+ if (!Array.isArray(results)) return null;
157
+
158
+ let fallback: string | null = null;
159
+ for (const entry of results as DockerTagEntry[]) {
160
+ const name = typeof entry?.name === "string" ? entry.name.trim() : "";
161
+ if (!name || name === "latest") continue;
162
+ if (SEMVER_TAG_RE.test(name)) return name;
163
+ if (!fallback) fallback = name;
164
+ }
165
+ return fallback;
166
+ }
167
+
168
+ export async function updateStackEnvToLatestImageTag(state: ControlPlaneState): Promise<{
169
+ namespace: string;
170
+ tag: string;
171
+ }> {
172
+ const stackEnvPath = `${state.dataDir}/stack.env`;
173
+ const parsed = parseEnvFile(stackEnvPath);
174
+ const namespace = (parsed.OPENPALM_IMAGE_NAMESPACE ?? process.env.OPENPALM_IMAGE_NAMESPACE ?? "openpalm").trim().toLowerCase();
175
+
176
+ if (!IMAGE_NAMESPACE_RE.test(namespace)) {
177
+ throw new Error(`Invalid image namespace in stack.env: ${namespace}`);
178
+ }
179
+
180
+ let response: Response;
181
+ try {
182
+ response = await fetch(
183
+ `https://registry.hub.docker.com/v2/repositories/${namespace}/admin/tags?page_size=25&ordering=last_updated`,
184
+ { headers: { Accept: "application/json" } }
185
+ );
186
+ } catch (e) {
187
+ throw new Error(`Failed to query Docker tags: ${e instanceof Error ? e.message : String(e)}`);
188
+ }
189
+
190
+ if (!response.ok) {
191
+ throw new Error(`Docker tag lookup failed (${response.status})`);
192
+ }
193
+
194
+ const payload = await response.json();
195
+ const latestTag = resolveNewestDockerTag(payload);
196
+ if (!latestTag) {
197
+ throw new Error("No usable Docker image tag found");
198
+ }
199
+
200
+ const currentContent = existsSync(stackEnvPath) ? readFileSync(stackEnvPath, "utf-8") : "";
201
+ const updatedContent = mergeEnvContent(currentContent, { OPENPALM_IMAGE_TAG: latestTag }, { uncomment: true });
202
+ writeFileSync(stackEnvPath, updatedContent);
203
+
204
+ return { namespace, tag: latestTag };
205
+ }
206
+
207
+ export async function applyUpgrade(
208
+ state: ControlPlaneState,
209
+ assets: CoreAssetProvider
210
+ ): Promise<{
211
+ backupDir: string | null;
212
+ updated: string[];
213
+ restarted: string[];
214
+ }> {
215
+ const { backupDir, updated } = await refreshCoreAssets();
216
+ const restarted = reconcileCore(state, assets, {});
217
+ return { backupDir, updated, restarted };
218
+ }
219
+
220
+ // ── Compose File List Builder ────────────────────────────────────────────
221
+
222
+ export function buildComposeFileList(state: ControlPlaneState): string[] {
223
+ const files = [`${state.stateDir}/artifacts/docker-compose.yml`];
224
+
225
+ if (isOllamaEnabled(state)) {
226
+ const ollamaYml = `${state.stateDir}/artifacts/ollama.yml`;
227
+ files.push(ollamaYml);
228
+ }
229
+
230
+ const stagedYmls = discoverStagedChannelYmls(state.stateDir);
231
+ files.push(...stagedYmls);
232
+
233
+ return files;
234
+ }
235
+
236
+ /**
237
+ * Build the list of services that `docker compose up` should manage.
238
+ * Core services only — admin/docker-socket-proxy are in the "admin" profile.
239
+ */
240
+ export function buildManagedServices(state: ControlPlaneState): string[] {
241
+ const services: string[] = [...CORE_SERVICES];
242
+
243
+ if (isOllamaEnabled(state)) {
244
+ services.push("ollama");
245
+ }
246
+
247
+ const stagedYmls = discoverStagedChannelYmls(state.stateDir);
248
+ for (const p of stagedYmls) {
249
+ const filename = p.split("/").pop() ?? "";
250
+ const name = filename.replace(/\.yml$/, "");
251
+ if (name) services.push(`channel-${name}`);
252
+ }
253
+ return services;
254
+ }
255
+
256
+ // ── Caller Normalization ───────────────────────────────────────────────
257
+
258
+ const VALID_CALLERS = new Set<CallerType>([
259
+ "assistant",
260
+ "cli",
261
+ "ui",
262
+ "system",
263
+ "test"
264
+ ]);
265
+
266
+ export function normalizeCaller(headerValue: string | null): CallerType {
267
+ const v = (headerValue ?? "").trim().toLowerCase() as CallerType;
268
+ return VALID_CALLERS.has(v) ? v : "unknown";
269
+ }
270
+
271
+ // ── Action Validation ──────────────────────────────────────────────────
272
+
273
+ const ALLOWED_ACTIONS = new Set([
274
+ "install",
275
+ "update",
276
+ "upgrade",
277
+ "uninstall",
278
+ "containers.list",
279
+ "containers.up",
280
+ "containers.down",
281
+ "containers.restart",
282
+ "channels.list",
283
+ "channels.install",
284
+ "channels.uninstall",
285
+
286
+ "extensions.list",
287
+ "artifacts.list",
288
+ "artifacts.get",
289
+ "artifacts.manifest",
290
+ "audit.list",
291
+ "accessScope.get",
292
+ "accessScope.set",
293
+ "connections.get",
294
+ "connections.patch",
295
+ "connections.status"
296
+ ]);
297
+
298
+ export function isAllowedAction(action: string): boolean {
299
+ return ALLOWED_ACTIONS.has(action);
300
+ }
301
+
302
+ // ── Environment Validation ─────────────────────────────────────────────
303
+
304
+ export async function validateEnvironment(state: ControlPlaneState): Promise<{
305
+ ok: boolean;
306
+ errors: string[];
307
+ warnings: string[];
308
+ }> {
309
+ const schemaPath = `${state.dataDir}/secrets.env.schema`;
310
+ const envPath = `${state.configDir}/secrets.env`;
311
+
312
+ function sanitizeVarlockMessage(msg: string): string {
313
+ return msg
314
+ .replace(/sk-[A-Za-z0-9]{20,}/g, "[REDACTED]")
315
+ .replace(/gsk_[A-Za-z0-9]{30,}/g, "[REDACTED]")
316
+ .replace(/AIza[A-Za-z0-9_\-]{35}/g, "[REDACTED]")
317
+ .replace(/[0-9a-f]{32,}/gi, "[REDACTED]")
318
+ .replace(/value '([^']*)'/g, "value '[REDACTED]'");
319
+ }
320
+
321
+ function collectVarlockOutput(stderr: string, errors: string[], warnings: string[]): void {
322
+ for (const line of stderr.split("\n")) {
323
+ const trimmed = sanitizeVarlockMessage(line.trim());
324
+ if (!trimmed) continue;
325
+ if (trimmed.includes("ERROR")) errors.push(trimmed);
326
+ else if (trimmed.includes("WARN")) warnings.push(trimmed);
327
+ }
328
+ }
329
+
330
+ async function runVarlockLoad(
331
+ schemaFile: string,
332
+ envFile: string,
333
+ ): Promise<void> {
334
+ const tmpDir = mkdtempSync(join(tmpdir(), "varlock-"));
335
+ try {
336
+ copyFileSync(schemaFile, join(tmpDir, ".env.schema"));
337
+ copyFileSync(envFile, join(tmpDir, ".env"));
338
+ await execFileAsync(
339
+ VARLOCK_BIN,
340
+ ["load", "--path", `${tmpDir}/`],
341
+ { timeout: 10000 }
342
+ );
343
+ } finally {
344
+ rmSync(tmpDir, { recursive: true, force: true });
345
+ }
346
+ }
347
+
348
+ const errors: string[] = [];
349
+ const warnings: string[] = [];
350
+ let anyFailed = false;
351
+
352
+ try {
353
+ await runVarlockLoad(schemaPath, envPath);
354
+ } catch (err: unknown) {
355
+ anyFailed = true;
356
+ if (err && typeof err === "object" && "stderr" in err) {
357
+ collectVarlockOutput(String((err as { stderr: string }).stderr), errors, warnings);
358
+ }
359
+ }
360
+
361
+ const stackSchemaPath = `${state.dataDir}/stack.env.schema`;
362
+ const stackEnvPath = `${state.stateDir}/artifacts/stack.env`;
363
+ try {
364
+ await runVarlockLoad(stackSchemaPath, stackEnvPath);
365
+ } catch (err: unknown) {
366
+ anyFailed = true;
367
+ if (err && typeof err === "object" && "stderr" in err) {
368
+ collectVarlockOutput(String((err as { stderr: string }).stderr), errors, warnings);
369
+ }
370
+ }
371
+
372
+ return { ok: !anyFailed && errors.length === 0, errors, warnings };
373
+ }