@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,126 +1,112 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
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";
|
|
1
|
+
/** Lifecycle helpers — state factory, apply transitions, compose file list. */
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync } from "node:fs";
|
|
14
3
|
import { parseEnvFile, mergeEnvContent } from "./env.js";
|
|
15
4
|
import type { ControlPlaneState, CallerType } from "./types.js";
|
|
16
5
|
import { CORE_SERVICES } from "./types.js";
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
6
|
+
import {
|
|
7
|
+
resolveOpenPalmHome,
|
|
8
|
+
resolveConfigDir,
|
|
9
|
+
resolveVaultDir,
|
|
10
|
+
resolveDataDir,
|
|
11
|
+
resolveLogsDir,
|
|
12
|
+
resolveCacheHome,
|
|
13
|
+
} from "./home.js";
|
|
14
|
+
import { ensureSecrets, readStackEnv, updateSystemSecretsEnv } from "./secrets.js";
|
|
15
|
+
import {
|
|
16
|
+
resolveRuntimeFiles,
|
|
17
|
+
writeRuntimeFiles,
|
|
18
|
+
randomHex,
|
|
19
|
+
buildEnvFiles,
|
|
20
|
+
discoverStackOverlays,
|
|
21
|
+
} from "./config-persistence.js";
|
|
22
|
+
import { readStackSpec } from "./stack-spec.js";
|
|
23
|
+
import { refreshCoreAssets, ensureMemoryDir } from "./core-assets.js";
|
|
22
24
|
import { isSetupComplete } from "./setup-status.js";
|
|
23
|
-
import
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
}
|
|
25
|
+
import { snapshotCurrentState } from "./rollback.js";
|
|
26
|
+
import { checkDocker, composePreflight, composePull, composeUp, composeConfigServices, resolveComposeProjectName } from "./docker.js";
|
|
27
|
+
import { acquireLock, releaseLock } from "./lock.js";
|
|
28
|
+
import { listEnabledAddonIds } from "./registry.js";
|
|
40
29
|
|
|
41
30
|
const IMAGE_NAMESPACE_RE = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;
|
|
42
31
|
const SEMVER_TAG_RE = /^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
|
|
43
32
|
|
|
44
|
-
// ── State Factory ──────────────────────────────────────────────────────
|
|
45
33
|
|
|
46
34
|
export function createState(
|
|
47
35
|
adminToken?: string
|
|
48
36
|
): ControlPlaneState {
|
|
49
|
-
const
|
|
50
|
-
const configDir =
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
|
|
37
|
+
const homeDir = resolveOpenPalmHome();
|
|
38
|
+
const configDir = resolveConfigDir();
|
|
39
|
+
const vaultDir = resolveVaultDir();
|
|
40
|
+
const dataDir = resolveDataDir();
|
|
41
|
+
const logsDir = resolveLogsDir();
|
|
42
|
+
const cacheDir = resolveCacheHome();
|
|
54
43
|
|
|
55
44
|
const services: Record<string, "running" | "stopped"> = {};
|
|
56
45
|
for (const name of CORE_SERVICES) {
|
|
57
46
|
services[name] = "stopped";
|
|
58
47
|
}
|
|
59
48
|
|
|
60
|
-
const dataDir = resolveDataHome();
|
|
61
|
-
|
|
62
|
-
const persistedSecrets = loadPersistedChannelSecrets(dataDir);
|
|
63
|
-
const channelSecrets: Record<string, string> = { ...persistedSecrets };
|
|
64
|
-
|
|
65
49
|
const setupToken = randomHex(16);
|
|
66
|
-
const
|
|
67
|
-
adminToken:
|
|
50
|
+
const bootstrapState: ControlPlaneState = {
|
|
51
|
+
adminToken: adminToken ?? process.env.OP_ADMIN_TOKEN ?? "",
|
|
52
|
+
assistantToken: "",
|
|
68
53
|
setupToken,
|
|
69
|
-
|
|
54
|
+
homeDir,
|
|
70
55
|
configDir,
|
|
56
|
+
vaultDir,
|
|
71
57
|
dataDir,
|
|
58
|
+
logsDir,
|
|
59
|
+
cacheDir,
|
|
72
60
|
services,
|
|
73
|
-
artifacts: { compose: ""
|
|
61
|
+
artifacts: { compose: "" },
|
|
74
62
|
artifactMeta: [],
|
|
75
63
|
audit: [],
|
|
76
|
-
channelSecrets
|
|
77
64
|
};
|
|
78
65
|
|
|
79
|
-
|
|
66
|
+
ensureSecrets(bootstrapState);
|
|
80
67
|
|
|
81
|
-
|
|
68
|
+
const stackEnv = readStackEnv(vaultDir);
|
|
69
|
+
// Precedence: explicit parameter > stack.env > process.env.
|
|
70
|
+
bootstrapState.adminToken =
|
|
71
|
+
adminToken
|
|
72
|
+
?? stackEnv.OP_ADMIN_TOKEN
|
|
73
|
+
?? process.env.OP_ADMIN_TOKEN
|
|
74
|
+
?? "";
|
|
75
|
+
bootstrapState.assistantToken =
|
|
76
|
+
stackEnv.OP_ASSISTANT_TOKEN
|
|
77
|
+
?? process.env.OP_ASSISTANT_TOKEN
|
|
78
|
+
?? "";
|
|
79
|
+
|
|
80
|
+
writeSetupTokenFile(bootstrapState);
|
|
81
|
+
|
|
82
|
+
return bootstrapState;
|
|
82
83
|
}
|
|
83
84
|
|
|
84
|
-
/**
|
|
85
|
-
* Write or remove the setup-token.txt file based on setup completion state.
|
|
86
|
-
*/
|
|
87
85
|
export function writeSetupTokenFile(state: ControlPlaneState): void {
|
|
88
|
-
const tokenPath = `${state.
|
|
89
|
-
const setupComplete = isSetupComplete(state.
|
|
86
|
+
const tokenPath = `${state.dataDir}/setup-token.txt`;
|
|
87
|
+
const setupComplete = isSetupComplete(state.vaultDir);
|
|
90
88
|
|
|
91
89
|
if (setupComplete) {
|
|
92
90
|
try { unlinkSync(tokenPath); } catch { /* already gone */ }
|
|
93
91
|
} else {
|
|
94
|
-
mkdirSync(state.
|
|
92
|
+
mkdirSync(state.dataDir, { recursive: true });
|
|
95
93
|
writeFileSync(tokenPath, state.setupToken + "\n", { mode: 0o600 });
|
|
96
94
|
}
|
|
97
95
|
}
|
|
98
96
|
|
|
99
|
-
// ── Private Loaders ───────────────────────────────────────────────────
|
|
100
97
|
|
|
101
|
-
function
|
|
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(
|
|
98
|
+
async function reconcileCore(
|
|
114
99
|
state: ControlPlaneState,
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
): string[] {
|
|
100
|
+
opts: { activateServices?: boolean; deactivateServices?: boolean },
|
|
101
|
+
): Promise<string[]> {
|
|
118
102
|
if (opts.activateServices) {
|
|
119
103
|
for (const s of CORE_SERVICES) state.services[s] = "running";
|
|
120
104
|
}
|
|
121
|
-
ensureMemoryDir();
|
|
122
|
-
|
|
123
|
-
|
|
105
|
+
ensureMemoryDir(state.dataDir);
|
|
106
|
+
|
|
107
|
+
for (const addonName of listEnabledAddonIds(state.homeDir)) {
|
|
108
|
+
mkdirSync(`${state.dataDir}/${addonName}`, { recursive: true });
|
|
109
|
+
}
|
|
124
110
|
|
|
125
111
|
const active: string[] = [];
|
|
126
112
|
for (const [name, status] of Object.entries(state.services)) {
|
|
@@ -131,21 +117,69 @@ function reconcileCore(
|
|
|
131
117
|
for (const name of Object.keys(state.services)) state.services[name] = "stopped";
|
|
132
118
|
}
|
|
133
119
|
|
|
134
|
-
|
|
135
|
-
|
|
120
|
+
// Preflight: validate compose merge before mutation.
|
|
121
|
+
// Mandatory when compose files exist and OP_SKIP_COMPOSE_PREFLIGHT is not set.
|
|
122
|
+
// Fails if Docker is unavailable (Docker is required for any compose operation).
|
|
123
|
+
const files = buildComposeFileList(state);
|
|
124
|
+
const envFiles = buildEnvFiles(state);
|
|
125
|
+
if (files.length > 0 && !process.env.OP_SKIP_COMPOSE_PREFLIGHT) {
|
|
126
|
+
const dockerCheck = await checkDocker();
|
|
127
|
+
if (!dockerCheck.ok) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
"Compose preflight failed: Docker is not available.\n" +
|
|
130
|
+
"Docker must be running before install/update/apply operations."
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
const preflight = await composePreflight({ files, envFiles });
|
|
134
|
+
if (!preflight.ok) {
|
|
135
|
+
const projectName = resolveComposeProjectName();
|
|
136
|
+
const fileArgs = files.flatMap((f) => ["-f", f]).join(" ");
|
|
137
|
+
const envArgs = envFiles.filter(existsSync).flatMap((f) => ["--env-file", f]).join(" ");
|
|
138
|
+
const resolvedCmd = `docker compose ${fileArgs} --project-name ${projectName} ${envArgs} config --quiet`;
|
|
139
|
+
throw new Error(
|
|
140
|
+
`Compose preflight failed: ${preflight.stderr}\n` +
|
|
141
|
+
`Resolved command: ${resolvedCmd}\n` +
|
|
142
|
+
`Files: ${files.join(", ")}\n` +
|
|
143
|
+
`Env files: ${envFiles.join(", ")}\n` +
|
|
144
|
+
`Project: ${projectName}`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Snapshot before writing (for rollback on failure)
|
|
150
|
+
snapshotCurrentState(state);
|
|
151
|
+
|
|
152
|
+
// Resolve and write runtime files to live paths
|
|
153
|
+
state.artifacts = resolveRuntimeFiles();
|
|
154
|
+
writeRuntimeFiles(state);
|
|
136
155
|
return active;
|
|
137
156
|
}
|
|
138
157
|
|
|
139
|
-
export function applyInstall(state: ControlPlaneState
|
|
140
|
-
|
|
158
|
+
export async function applyInstall(state: ControlPlaneState): Promise<void> {
|
|
159
|
+
const lock = acquireLock(state.homeDir, "install");
|
|
160
|
+
try {
|
|
161
|
+
await reconcileCore(state, { activateServices: true });
|
|
162
|
+
} finally {
|
|
163
|
+
releaseLock(lock);
|
|
164
|
+
}
|
|
141
165
|
}
|
|
142
166
|
|
|
143
|
-
export function applyUpdate(state: ControlPlaneState
|
|
144
|
-
|
|
167
|
+
export async function applyUpdate(state: ControlPlaneState): Promise<{ restarted: string[] }> {
|
|
168
|
+
const lock = acquireLock(state.homeDir, "update");
|
|
169
|
+
try {
|
|
170
|
+
return { restarted: await reconcileCore(state, {}) };
|
|
171
|
+
} finally {
|
|
172
|
+
releaseLock(lock);
|
|
173
|
+
}
|
|
145
174
|
}
|
|
146
175
|
|
|
147
|
-
export function applyUninstall(state: ControlPlaneState
|
|
148
|
-
|
|
176
|
+
export async function applyUninstall(state: ControlPlaneState): Promise<{ stopped: string[] }> {
|
|
177
|
+
const lock = acquireLock(state.homeDir, "uninstall");
|
|
178
|
+
try {
|
|
179
|
+
return { stopped: await reconcileCore(state, { deactivateServices: true }) };
|
|
180
|
+
} finally {
|
|
181
|
+
releaseLock(lock);
|
|
182
|
+
}
|
|
149
183
|
}
|
|
150
184
|
|
|
151
185
|
type DockerTagEntry = { name?: unknown };
|
|
@@ -169,12 +203,12 @@ export async function updateStackEnvToLatestImageTag(state: ControlPlaneState):
|
|
|
169
203
|
namespace: string;
|
|
170
204
|
tag: string;
|
|
171
205
|
}> {
|
|
172
|
-
const
|
|
173
|
-
const parsed = parseEnvFile(
|
|
174
|
-
const namespace = (parsed.
|
|
206
|
+
const systemEnvPath = `${state.vaultDir}/stack/stack.env`;
|
|
207
|
+
const parsed = parseEnvFile(systemEnvPath);
|
|
208
|
+
const namespace = (parsed.OP_IMAGE_NAMESPACE ?? process.env.OP_IMAGE_NAMESPACE ?? "openpalm").trim().toLowerCase();
|
|
175
209
|
|
|
176
210
|
if (!IMAGE_NAMESPACE_RE.test(namespace)) {
|
|
177
|
-
throw new Error(`Invalid image namespace in
|
|
211
|
+
throw new Error(`Invalid image namespace in system.env: ${namespace}`);
|
|
178
212
|
}
|
|
179
213
|
|
|
180
214
|
let response: Response;
|
|
@@ -197,72 +231,124 @@ export async function updateStackEnvToLatestImageTag(state: ControlPlaneState):
|
|
|
197
231
|
throw new Error("No usable Docker image tag found");
|
|
198
232
|
}
|
|
199
233
|
|
|
200
|
-
const currentContent = existsSync(
|
|
201
|
-
const updatedContent = mergeEnvContent(currentContent, {
|
|
202
|
-
writeFileSync(
|
|
234
|
+
const currentContent = existsSync(systemEnvPath) ? readFileSync(systemEnvPath, "utf-8") : "";
|
|
235
|
+
const updatedContent = mergeEnvContent(currentContent, { OP_IMAGE_TAG: latestTag }, { uncomment: true });
|
|
236
|
+
writeFileSync(systemEnvPath, updatedContent);
|
|
203
237
|
|
|
204
238
|
return { namespace, tag: latestTag };
|
|
205
239
|
}
|
|
206
240
|
|
|
207
241
|
export async function applyUpgrade(
|
|
208
|
-
state: ControlPlaneState
|
|
209
|
-
assets: CoreAssetProvider
|
|
242
|
+
state: ControlPlaneState
|
|
210
243
|
): Promise<{
|
|
211
244
|
backupDir: string | null;
|
|
212
245
|
updated: string[];
|
|
213
246
|
restarted: string[];
|
|
214
247
|
}> {
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
248
|
+
const lock = acquireLock(state.homeDir, "upgrade");
|
|
249
|
+
try {
|
|
250
|
+
const { backupDir, updated } = await refreshCoreAssets();
|
|
251
|
+
const restarted = await reconcileCore(state, {});
|
|
252
|
+
return { backupDir, updated, restarted };
|
|
253
|
+
} finally {
|
|
254
|
+
releaseLock(lock);
|
|
255
|
+
}
|
|
218
256
|
}
|
|
219
257
|
|
|
220
|
-
|
|
258
|
+
export type UpgradeResult = {
|
|
259
|
+
imageTag: string;
|
|
260
|
+
namespace: string;
|
|
261
|
+
backupDir: string | null;
|
|
262
|
+
assetsUpdated: string[];
|
|
263
|
+
restarted: string[];
|
|
264
|
+
};
|
|
221
265
|
|
|
222
|
-
|
|
223
|
-
|
|
266
|
+
/**
|
|
267
|
+
* Full upgrade: resolve latest image tag, refresh assets, pull images,
|
|
268
|
+
* and recreate containers. Used by both the admin endpoint and CLI.
|
|
269
|
+
*
|
|
270
|
+
* Callers handle their own audit logging and admin self-recreation.
|
|
271
|
+
*/
|
|
272
|
+
export async function performUpgrade(state: ControlPlaneState): Promise<UpgradeResult> {
|
|
273
|
+
const files = buildComposeFileList(state);
|
|
274
|
+
const envFiles = buildEnvFiles(state);
|
|
275
|
+
|
|
276
|
+
// 1. Preflight: validate compose merge before any mutation
|
|
277
|
+
if (files.length > 0 && !process.env.OP_SKIP_COMPOSE_PREFLIGHT) {
|
|
278
|
+
const preflight = await composePreflight({ files, envFiles });
|
|
279
|
+
if (!preflight.ok) {
|
|
280
|
+
throw new Error(`Compose preflight failed: ${preflight.stderr}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// 2. Snapshot stack.env for rollback on failure
|
|
285
|
+
const stackEnvPath = `${state.vaultDir}/stack/stack.env`;
|
|
286
|
+
let originalStackEnv: string | null = null;
|
|
287
|
+
try {
|
|
288
|
+
originalStackEnv = readFileSync(stackEnvPath, "utf-8");
|
|
289
|
+
} catch { /* stack.env may not exist yet */ }
|
|
224
290
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
291
|
+
// 3. Update image tag + refresh core assets
|
|
292
|
+
let imageTag: string;
|
|
293
|
+
let namespace: string;
|
|
294
|
+
let upgradeResult: { backupDir: string | null; updated: string[]; restarted: string[] };
|
|
295
|
+
try {
|
|
296
|
+
const tagResult = await updateStackEnvToLatestImageTag(state);
|
|
297
|
+
imageTag = tagResult.tag;
|
|
298
|
+
namespace = tagResult.namespace;
|
|
299
|
+
upgradeResult = await applyUpgrade(state);
|
|
300
|
+
} catch (e) {
|
|
301
|
+
// Restore stack.env on failure
|
|
302
|
+
if (originalStackEnv !== null) {
|
|
303
|
+
try { writeFileSync(stackEnvPath, originalStackEnv); } catch { /* best effort */ }
|
|
304
|
+
}
|
|
305
|
+
throw e;
|
|
228
306
|
}
|
|
229
307
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
308
|
+
// 4. Pull images
|
|
309
|
+
const pullResult = await composePull({ files, envFiles });
|
|
310
|
+
if (!pullResult.ok) {
|
|
311
|
+
throw new Error(`Failed to pull images: ${pullResult.stderr}`);
|
|
233
312
|
}
|
|
234
313
|
|
|
235
|
-
|
|
236
|
-
|
|
314
|
+
// 5. Recreate containers
|
|
315
|
+
const services = await buildManagedServices(state);
|
|
316
|
+
const upResult = await composeUp({ files, envFiles, services, removeOrphans: true });
|
|
317
|
+
if (!upResult.ok) {
|
|
318
|
+
throw new Error(`Images pulled but failed to recreate containers: ${upResult.stderr}`);
|
|
319
|
+
}
|
|
237
320
|
|
|
238
|
-
return
|
|
321
|
+
return {
|
|
322
|
+
imageTag,
|
|
323
|
+
namespace,
|
|
324
|
+
backupDir: upgradeResult.backupDir,
|
|
325
|
+
assetsUpdated: upgradeResult.updated,
|
|
326
|
+
restarted: upgradeResult.restarted,
|
|
327
|
+
};
|
|
239
328
|
}
|
|
240
329
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
*/
|
|
245
|
-
export function buildManagedServices(state: ControlPlaneState): string[] {
|
|
246
|
-
const services: string[] = [...CORE_SERVICES];
|
|
330
|
+
export function buildComposeFileList(state: ControlPlaneState): string[] {
|
|
331
|
+
return discoverStackOverlays(`${state.homeDir}/stack`);
|
|
332
|
+
}
|
|
247
333
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
334
|
+
export async function buildManagedServices(state: ControlPlaneState): Promise<string[]> {
|
|
335
|
+
const files = buildComposeFileList(state);
|
|
336
|
+
const envFiles = buildEnvFiles(state);
|
|
251
337
|
|
|
252
|
-
|
|
253
|
-
|
|
338
|
+
// Prefer compose-derived service list when Docker is available
|
|
339
|
+
if (files.length > 0 && !process.env.OP_SKIP_COMPOSE_PREFLIGHT) {
|
|
340
|
+
const result = await composeConfigServices({ files, envFiles });
|
|
341
|
+
if (result.ok && result.services.length > 0) {
|
|
342
|
+
return result.services;
|
|
343
|
+
}
|
|
254
344
|
}
|
|
255
345
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
const name = filename.replace(/\.yml$/, "");
|
|
260
|
-
if (name) services.push(`channel-${name}`);
|
|
261
|
-
}
|
|
346
|
+
// Fallback: static inference from CORE_SERVICES + active addon overlays
|
|
347
|
+
const services: string[] = [...CORE_SERVICES];
|
|
348
|
+
services.push(...listEnabledAddonIds(state.homeDir));
|
|
262
349
|
return services;
|
|
263
350
|
}
|
|
264
351
|
|
|
265
|
-
// ── Caller Normalization ───────────────────────────────────────────────
|
|
266
352
|
|
|
267
353
|
const VALID_CALLERS = new Set<CallerType>([
|
|
268
354
|
"assistant",
|
|
@@ -276,107 +362,3 @@ export function normalizeCaller(headerValue: string | null): CallerType {
|
|
|
276
362
|
const v = (headerValue ?? "").trim().toLowerCase() as CallerType;
|
|
277
363
|
return VALID_CALLERS.has(v) ? v : "unknown";
|
|
278
364
|
}
|
|
279
|
-
|
|
280
|
-
// ── Action Validation ──────────────────────────────────────────────────
|
|
281
|
-
|
|
282
|
-
const ALLOWED_ACTIONS = new Set([
|
|
283
|
-
"install",
|
|
284
|
-
"update",
|
|
285
|
-
"upgrade",
|
|
286
|
-
"uninstall",
|
|
287
|
-
"containers.list",
|
|
288
|
-
"containers.up",
|
|
289
|
-
"containers.down",
|
|
290
|
-
"containers.restart",
|
|
291
|
-
"channels.list",
|
|
292
|
-
"channels.install",
|
|
293
|
-
"channels.uninstall",
|
|
294
|
-
|
|
295
|
-
"extensions.list",
|
|
296
|
-
"artifacts.list",
|
|
297
|
-
"artifacts.get",
|
|
298
|
-
"artifacts.manifest",
|
|
299
|
-
"audit.list",
|
|
300
|
-
"accessScope.get",
|
|
301
|
-
"accessScope.set",
|
|
302
|
-
"connections.get",
|
|
303
|
-
"connections.patch",
|
|
304
|
-
"connections.status"
|
|
305
|
-
]);
|
|
306
|
-
|
|
307
|
-
export function isAllowedAction(action: string): boolean {
|
|
308
|
-
return ALLOWED_ACTIONS.has(action);
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
// ── Environment Validation ─────────────────────────────────────────────
|
|
312
|
-
|
|
313
|
-
export async function validateEnvironment(state: ControlPlaneState): Promise<{
|
|
314
|
-
ok: boolean;
|
|
315
|
-
errors: string[];
|
|
316
|
-
warnings: string[];
|
|
317
|
-
}> {
|
|
318
|
-
const schemaPath = `${state.dataDir}/secrets.env.schema`;
|
|
319
|
-
const envPath = `${state.configDir}/secrets.env`;
|
|
320
|
-
|
|
321
|
-
function sanitizeVarlockMessage(msg: string): string {
|
|
322
|
-
return msg
|
|
323
|
-
.replace(/sk-[A-Za-z0-9]{20,}/g, "[REDACTED]")
|
|
324
|
-
.replace(/gsk_[A-Za-z0-9]{30,}/g, "[REDACTED]")
|
|
325
|
-
.replace(/AIza[A-Za-z0-9_\-]{35}/g, "[REDACTED]")
|
|
326
|
-
.replace(/[0-9a-f]{32,}/gi, "[REDACTED]")
|
|
327
|
-
.replace(/value '([^']*)'/g, "value '[REDACTED]'");
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
function collectVarlockOutput(stderr: string, errors: string[], warnings: string[]): void {
|
|
331
|
-
for (const line of stderr.split("\n")) {
|
|
332
|
-
const trimmed = sanitizeVarlockMessage(line.trim());
|
|
333
|
-
if (!trimmed) continue;
|
|
334
|
-
if (trimmed.includes("ERROR")) errors.push(trimmed);
|
|
335
|
-
else if (trimmed.includes("WARN")) warnings.push(trimmed);
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
async function runVarlockLoad(
|
|
340
|
-
schemaFile: string,
|
|
341
|
-
envFile: string,
|
|
342
|
-
): Promise<void> {
|
|
343
|
-
const tmpDir = mkdtempSync(join(tmpdir(), "varlock-"));
|
|
344
|
-
try {
|
|
345
|
-
copyFileSync(schemaFile, join(tmpDir, ".env.schema"));
|
|
346
|
-
copyFileSync(envFile, join(tmpDir, ".env"));
|
|
347
|
-
await execFileAsync(
|
|
348
|
-
VARLOCK_BIN,
|
|
349
|
-
["load", "--path", `${tmpDir}/`],
|
|
350
|
-
{ timeout: 10000 }
|
|
351
|
-
);
|
|
352
|
-
} finally {
|
|
353
|
-
rmSync(tmpDir, { recursive: true, force: true });
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
const errors: string[] = [];
|
|
358
|
-
const warnings: string[] = [];
|
|
359
|
-
let anyFailed = false;
|
|
360
|
-
|
|
361
|
-
try {
|
|
362
|
-
await runVarlockLoad(schemaPath, envPath);
|
|
363
|
-
} catch (err: unknown) {
|
|
364
|
-
anyFailed = true;
|
|
365
|
-
if (err && typeof err === "object" && "stderr" in err) {
|
|
366
|
-
collectVarlockOutput(String((err as { stderr: string }).stderr), errors, warnings);
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
const stackSchemaPath = `${state.dataDir}/stack.env.schema`;
|
|
371
|
-
const stackEnvPath = `${state.stateDir}/artifacts/stack.env`;
|
|
372
|
-
try {
|
|
373
|
-
await runVarlockLoad(stackSchemaPath, stackEnvPath);
|
|
374
|
-
} catch (err: unknown) {
|
|
375
|
-
anyFailed = true;
|
|
376
|
-
if (err && typeof err === "object" && "stderr" in err) {
|
|
377
|
-
collectVarlockOutput(String((err as { stderr: string }).stderr), errors, warnings);
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
return { ok: !anyFailed && errors.length === 0, errors, warnings };
|
|
382
|
-
}
|