@openpalm/lib 0.10.1 → 0.11.0-beta.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 +2 -2
- package/package.json +7 -3
- package/src/control-plane/admin-token.ts +73 -0
- package/src/control-plane/akm-vault.test.ts +108 -0
- package/src/control-plane/akm-vault.ts +307 -0
- package/src/control-plane/audit.ts +3 -2
- package/src/control-plane/channels.ts +3 -3
- package/src/control-plane/cleanup-guardrails.test.ts +8 -9
- package/src/control-plane/compose-args.test.ts +25 -21
- package/src/control-plane/config-persistence.ts +103 -64
- package/src/control-plane/core-assets.test.ts +104 -0
- package/src/control-plane/core-assets.ts +54 -57
- package/src/control-plane/docker.ts +55 -21
- package/src/control-plane/env.test.ts +25 -1
- package/src/control-plane/env.ts +80 -0
- package/src/control-plane/home.ts +66 -69
- package/src/control-plane/host-opencode.test.ts +263 -0
- package/src/control-plane/host-opencode.ts +229 -0
- package/src/control-plane/install-edge-cases.test.ts +182 -244
- package/src/control-plane/install-lock.ts +157 -0
- package/src/control-plane/lifecycle.ts +57 -56
- package/src/control-plane/markdown-task.ts +200 -0
- package/src/control-plane/paths.ts +75 -0
- package/src/control-plane/provider-config.ts +2 -2
- package/src/control-plane/provider-models.ts +154 -0
- package/src/control-plane/registry-components.test.ts +102 -25
- package/src/control-plane/registry.test.ts +49 -47
- package/src/control-plane/registry.ts +71 -50
- package/src/control-plane/rollback.ts +17 -16
- package/src/control-plane/scheduler.ts +75 -262
- package/src/control-plane/secret-backend.test.ts +98 -108
- package/src/control-plane/secret-backend.ts +221 -181
- package/src/control-plane/secret-mappings.ts +3 -6
- package/src/control-plane/secrets.ts +83 -47
- package/src/control-plane/setup-config.schema.json +2 -14
- package/src/control-plane/setup-status.ts +4 -29
- package/src/control-plane/setup-validation.ts +21 -21
- package/src/control-plane/setup.test.ts +122 -227
- package/src/control-plane/setup.ts +224 -125
- package/src/control-plane/skeleton-guardrail.test.ts +151 -0
- package/src/control-plane/spec-to-env.test.ts +59 -58
- package/src/control-plane/spec-to-env.ts +39 -140
- package/src/control-plane/spec-validator.ts +2 -99
- package/src/control-plane/stack-spec.test.ts +21 -77
- package/src/control-plane/stack-spec.ts +7 -83
- package/src/control-plane/types.ts +17 -15
- package/src/control-plane/ui-assets.ts +349 -0
- package/src/control-plane/validate.ts +44 -79
- package/src/index.ts +77 -44
- package/src/logger.test.ts +228 -0
- package/src/logger.ts +71 -1
- package/src/provider-constants.ts +22 -1
- package/src/control-plane/env-schema-validation.test.ts +0 -118
- package/src/control-plane/memory-config.ts +0 -298
- package/src/control-plane/redact-schema.ts +0 -50
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-healing install lock for the setup wizard phase.
|
|
3
|
+
*
|
|
4
|
+
* Both `performSetup` (config writes) and `startDeploy` (Docker work) need an
|
|
5
|
+
* exclusive lock against concurrent installs. The lock file lives at
|
|
6
|
+
* `<stateDir>/.install.lock` and contains `<pid>\n<timestamp>\n`.
|
|
7
|
+
*
|
|
8
|
+
* Self-healing rules:
|
|
9
|
+
* - On EEXIST, parse the holder PID. If the process is gone (`process.kill(pid, 0)`
|
|
10
|
+
* throws ESRCH) the lock is stale and we remove + retry once.
|
|
11
|
+
* - If the timestamp is older than STALE_AFTER_MS the lock is stale and we
|
|
12
|
+
* remove + retry once.
|
|
13
|
+
* - If the file is unparseable (e.g. written by an older version) fall back to
|
|
14
|
+
* mtime > STALE_AFTER_MS.
|
|
15
|
+
*
|
|
16
|
+
* On any unexpected error (permissions, ENOSPC, etc.) we return null so the
|
|
17
|
+
* caller surfaces "install_in_progress" rather than silently fake-acquiring.
|
|
18
|
+
*/
|
|
19
|
+
import { openSync, writeSync, closeSync, readFileSync, statSync, rmSync, mkdirSync, constants } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { createLogger } from "../logger.js";
|
|
22
|
+
|
|
23
|
+
const logger = createLogger("install-lock");
|
|
24
|
+
|
|
25
|
+
const STALE_AFTER_MS = 30 * 60 * 1000; // 30 minutes
|
|
26
|
+
|
|
27
|
+
export type InstallLockHandle = {
|
|
28
|
+
path: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function isProcessAlive(pid: number): boolean {
|
|
32
|
+
try {
|
|
33
|
+
process.kill(pid, 0);
|
|
34
|
+
return true;
|
|
35
|
+
} catch (err) {
|
|
36
|
+
// ESRCH = no such process. EPERM = process exists but we don't own it.
|
|
37
|
+
return (err as NodeJS.ErrnoException).code === "EPERM";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseLockContent(content: string): { pid: number | null; timestamp: number | null } {
|
|
42
|
+
const lines = content.split("\n");
|
|
43
|
+
const pid = Number.parseInt(lines[0] ?? "", 10);
|
|
44
|
+
const timestamp = Number.parseInt(lines[1] ?? "", 10);
|
|
45
|
+
return {
|
|
46
|
+
pid: Number.isFinite(pid) && pid > 0 ? pid : null,
|
|
47
|
+
timestamp: Number.isFinite(timestamp) && timestamp > 0 ? timestamp : null,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isStale(path: string): boolean {
|
|
52
|
+
let content = "";
|
|
53
|
+
try {
|
|
54
|
+
content = readFileSync(path, "utf-8");
|
|
55
|
+
} catch {
|
|
56
|
+
// Can't read — assume held; caller will surface error.
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
const { pid, timestamp } = parseLockContent(content);
|
|
60
|
+
if (pid !== null) {
|
|
61
|
+
if (!isProcessAlive(pid)) return true;
|
|
62
|
+
if (timestamp !== null && Date.now() - timestamp > STALE_AFTER_MS) return true;
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
// Unparseable — fall back to mtime.
|
|
66
|
+
try {
|
|
67
|
+
const stat = statSync(path);
|
|
68
|
+
return Date.now() - stat.mtimeMs > STALE_AFTER_MS;
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function tryCreate(path: string): boolean {
|
|
75
|
+
const content = `${process.pid}\n${Date.now()}\n`;
|
|
76
|
+
try {
|
|
77
|
+
const fd = openSync(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o644);
|
|
78
|
+
try {
|
|
79
|
+
writeSync(fd, content);
|
|
80
|
+
} finally {
|
|
81
|
+
try { closeSync(fd); } catch { /* best-effort */ }
|
|
82
|
+
}
|
|
83
|
+
return true;
|
|
84
|
+
} catch (err: unknown) {
|
|
85
|
+
if ((err as NodeJS.ErrnoException).code === "EEXIST") return false;
|
|
86
|
+
// Unexpected error — propagate so caller returns null.
|
|
87
|
+
throw err;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Try to acquire the install lock under `stateDir`. Returns a handle on
|
|
93
|
+
* success or null if the lock is held by a live, recent install (or on any
|
|
94
|
+
* unexpected filesystem error — caller should surface "install_in_progress").
|
|
95
|
+
*
|
|
96
|
+
* Callers MUST call `releaseInstallLock()` in a finally block when done.
|
|
97
|
+
*/
|
|
98
|
+
export function acquireInstallLock(stateDir: string): InstallLockHandle | null {
|
|
99
|
+
try {
|
|
100
|
+
mkdirSync(stateDir, { recursive: true });
|
|
101
|
+
} catch (err) {
|
|
102
|
+
logger.warn("failed to ensure state dir for install lock", {
|
|
103
|
+
stateDir,
|
|
104
|
+
error: err instanceof Error ? err.message : String(err),
|
|
105
|
+
});
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
const path = join(stateDir, ".install.lock");
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
if (tryCreate(path)) return { path };
|
|
112
|
+
} catch (err) {
|
|
113
|
+
logger.warn("unexpected error acquiring install lock", {
|
|
114
|
+
path,
|
|
115
|
+
error: err instanceof Error ? err.message : String(err),
|
|
116
|
+
});
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// EEXIST — check whether the existing lock is stale.
|
|
121
|
+
if (!isStale(path)) return null;
|
|
122
|
+
|
|
123
|
+
logger.info("removing stale install lock and retrying acquire", { path });
|
|
124
|
+
try {
|
|
125
|
+
rmSync(path, { force: true });
|
|
126
|
+
} catch (err) {
|
|
127
|
+
logger.warn("failed to remove stale install lock", {
|
|
128
|
+
path,
|
|
129
|
+
error: err instanceof Error ? err.message : String(err),
|
|
130
|
+
});
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
if (tryCreate(path)) return { path };
|
|
136
|
+
} catch (err) {
|
|
137
|
+
logger.warn("unexpected error re-acquiring install lock", {
|
|
138
|
+
path,
|
|
139
|
+
error: err instanceof Error ? err.message : String(err),
|
|
140
|
+
});
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
// Lost the race with another acquirer.
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function releaseInstallLock(handle: InstallLockHandle | null): void {
|
|
148
|
+
if (!handle) return;
|
|
149
|
+
try {
|
|
150
|
+
rmSync(handle.path, { force: true });
|
|
151
|
+
} catch (err) {
|
|
152
|
+
logger.warn("failed to release install lock", {
|
|
153
|
+
path: handle.path,
|
|
154
|
+
error: err instanceof Error ? err.message : String(err),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -1,30 +1,32 @@
|
|
|
1
1
|
/** Lifecycle helpers — state factory, apply transitions, compose file list. */
|
|
2
|
-
import { readFileSync, writeFileSync, existsSync,
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
3
3
|
import { parseEnvFile, mergeEnvContent } from "./env.js";
|
|
4
|
-
import type { ControlPlaneState, CallerType } from "./types.js";
|
|
4
|
+
import type { ControlPlaneState, CallerType, AuditContext } from "./types.js";
|
|
5
5
|
import { CORE_SERVICES } from "./types.js";
|
|
6
6
|
import {
|
|
7
7
|
resolveOpenPalmHome,
|
|
8
8
|
resolveConfigDir,
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
resolveStashDir,
|
|
10
|
+
resolveWorkspaceDir,
|
|
11
|
+
resolveCacheDir,
|
|
12
|
+
resolveStateDir,
|
|
13
|
+
resolveStackDir,
|
|
13
14
|
} from "./home.js";
|
|
14
15
|
import { ensureSecrets, readStackEnv, updateSystemSecretsEnv } from "./secrets.js";
|
|
15
16
|
import {
|
|
16
17
|
resolveRuntimeFiles,
|
|
17
18
|
writeRuntimeFiles,
|
|
18
|
-
randomHex,
|
|
19
19
|
buildEnvFiles,
|
|
20
20
|
discoverStackOverlays,
|
|
21
|
+
ensureComposeVolumeTargets,
|
|
21
22
|
} from "./config-persistence.js";
|
|
22
23
|
import { readStackSpec } from "./stack-spec.js";
|
|
23
|
-
import { refreshCoreAssets
|
|
24
|
+
import { refreshCoreAssets } from "./core-assets.js";
|
|
24
25
|
import { isSetupComplete } from "./setup-status.js";
|
|
25
26
|
import { snapshotCurrentState } from "./rollback.js";
|
|
26
27
|
import { checkDocker, composePreflight, composePull, composeUp, composeConfigServices, resolveComposeProjectName } from "./docker.js";
|
|
27
28
|
import { acquireLock, releaseLock } from "./lock.js";
|
|
29
|
+
import { appendAudit } from "./audit.js";
|
|
28
30
|
import { listEnabledAddonIds } from "./registry.js";
|
|
29
31
|
|
|
30
32
|
const IMAGE_NAMESPACE_RE = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;
|
|
@@ -36,27 +38,27 @@ export function createState(
|
|
|
36
38
|
): ControlPlaneState {
|
|
37
39
|
const homeDir = resolveOpenPalmHome();
|
|
38
40
|
const configDir = resolveConfigDir();
|
|
39
|
-
const
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
const
|
|
41
|
+
const stashDir = resolveStashDir();
|
|
42
|
+
const workspaceDir = resolveWorkspaceDir();
|
|
43
|
+
const cacheDir = resolveCacheDir();
|
|
44
|
+
const stateDir = resolveStateDir();
|
|
45
|
+
const stackDir = resolveStackDir();
|
|
43
46
|
|
|
44
47
|
const services: Record<string, "running" | "stopped"> = {};
|
|
45
48
|
for (const name of CORE_SERVICES) {
|
|
46
49
|
services[name] = "stopped";
|
|
47
50
|
}
|
|
48
51
|
|
|
49
|
-
const setupToken = randomHex(16);
|
|
50
52
|
const bootstrapState: ControlPlaneState = {
|
|
51
|
-
adminToken: adminToken ?? process.env.
|
|
53
|
+
adminToken: adminToken ?? process.env.OP_UI_TOKEN ?? "",
|
|
52
54
|
assistantToken: "",
|
|
53
|
-
setupToken,
|
|
54
55
|
homeDir,
|
|
55
56
|
configDir,
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
logsDir,
|
|
57
|
+
stashDir,
|
|
58
|
+
workspaceDir,
|
|
59
59
|
cacheDir,
|
|
60
|
+
stateDir,
|
|
61
|
+
stackDir,
|
|
60
62
|
services,
|
|
61
63
|
artifacts: { compose: "" },
|
|
62
64
|
artifactMeta: [],
|
|
@@ -65,35 +67,21 @@ export function createState(
|
|
|
65
67
|
|
|
66
68
|
ensureSecrets(bootstrapState);
|
|
67
69
|
|
|
68
|
-
const stackEnv = readStackEnv(
|
|
70
|
+
const stackEnv = readStackEnv(stackDir);
|
|
69
71
|
// Precedence: explicit parameter > stack.env > process.env.
|
|
70
72
|
bootstrapState.adminToken =
|
|
71
73
|
adminToken
|
|
72
|
-
?? stackEnv.
|
|
73
|
-
?? process.env.
|
|
74
|
+
?? stackEnv.OP_UI_TOKEN
|
|
75
|
+
?? process.env.OP_UI_TOKEN
|
|
74
76
|
?? "";
|
|
75
77
|
bootstrapState.assistantToken =
|
|
76
78
|
stackEnv.OP_ASSISTANT_TOKEN
|
|
77
79
|
?? process.env.OP_ASSISTANT_TOKEN
|
|
78
80
|
?? "";
|
|
79
81
|
|
|
80
|
-
writeSetupTokenFile(bootstrapState);
|
|
81
|
-
|
|
82
82
|
return bootstrapState;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
export function writeSetupTokenFile(state: ControlPlaneState): void {
|
|
86
|
-
const tokenPath = `${state.dataDir}/setup-token.txt`;
|
|
87
|
-
const setupComplete = isSetupComplete(state.vaultDir);
|
|
88
|
-
|
|
89
|
-
if (setupComplete) {
|
|
90
|
-
try { unlinkSync(tokenPath); } catch { /* already gone */ }
|
|
91
|
-
} else {
|
|
92
|
-
mkdirSync(state.dataDir, { recursive: true });
|
|
93
|
-
writeFileSync(tokenPath, state.setupToken + "\n", { mode: 0o600 });
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
85
|
|
|
98
86
|
async function reconcileCore(
|
|
99
87
|
state: ControlPlaneState,
|
|
@@ -102,10 +90,9 @@ async function reconcileCore(
|
|
|
102
90
|
if (opts.activateServices) {
|
|
103
91
|
for (const s of CORE_SERVICES) state.services[s] = "running";
|
|
104
92
|
}
|
|
105
|
-
ensureMemoryDir(state.dataDir);
|
|
106
93
|
|
|
107
94
|
for (const addonName of listEnabledAddonIds(state.homeDir)) {
|
|
108
|
-
mkdirSync(`${state.
|
|
95
|
+
mkdirSync(`${state.stateDir}/${addonName}`, { recursive: true });
|
|
109
96
|
}
|
|
110
97
|
|
|
111
98
|
const active: string[] = [];
|
|
@@ -155,28 +142,46 @@ async function reconcileCore(
|
|
|
155
142
|
return active;
|
|
156
143
|
}
|
|
157
144
|
|
|
158
|
-
export async function applyInstall(state: ControlPlaneState): Promise<void> {
|
|
145
|
+
export async function applyInstall(state: ControlPlaneState, ctx?: AuditContext): Promise<void> {
|
|
159
146
|
const lock = acquireLock(state.homeDir, "install");
|
|
160
147
|
try {
|
|
161
148
|
await reconcileCore(state, { activateServices: true });
|
|
149
|
+
// Pre-create host-side volume mount targets as the current user so
|
|
150
|
+
// Docker doesn't create them root-owned (which causes EACCES inside
|
|
151
|
+
// non-root containers).
|
|
152
|
+
ensureComposeVolumeTargets(state);
|
|
153
|
+
if (ctx) appendAudit(state, ctx.actor, "install", {}, true, ctx.requestId ?? "", ctx.callerType ?? "unknown");
|
|
154
|
+
} catch (err) {
|
|
155
|
+
if (ctx) appendAudit(state, ctx.actor, "install", { error: String(err) }, false, ctx.requestId ?? "", ctx.callerType ?? "unknown");
|
|
156
|
+
throw err;
|
|
162
157
|
} finally {
|
|
163
158
|
releaseLock(lock);
|
|
164
159
|
}
|
|
165
160
|
}
|
|
166
161
|
|
|
167
|
-
export async function applyUpdate(state: ControlPlaneState): Promise<{ restarted: string[] }> {
|
|
162
|
+
export async function applyUpdate(state: ControlPlaneState, ctx?: AuditContext): Promise<{ restarted: string[] }> {
|
|
168
163
|
const lock = acquireLock(state.homeDir, "update");
|
|
169
164
|
try {
|
|
170
|
-
|
|
165
|
+
const result = { restarted: await reconcileCore(state, {}) };
|
|
166
|
+
if (ctx) appendAudit(state, ctx.actor, "update", { restarted: result.restarted }, true, ctx.requestId ?? "", ctx.callerType ?? "unknown");
|
|
167
|
+
return result;
|
|
168
|
+
} catch (err) {
|
|
169
|
+
if (ctx) appendAudit(state, ctx.actor, "update", { error: String(err) }, false, ctx.requestId ?? "", ctx.callerType ?? "unknown");
|
|
170
|
+
throw err;
|
|
171
171
|
} finally {
|
|
172
172
|
releaseLock(lock);
|
|
173
173
|
}
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
export async function applyUninstall(state: ControlPlaneState): Promise<{ stopped: string[] }> {
|
|
176
|
+
export async function applyUninstall(state: ControlPlaneState, ctx?: AuditContext): Promise<{ stopped: string[] }> {
|
|
177
177
|
const lock = acquireLock(state.homeDir, "uninstall");
|
|
178
178
|
try {
|
|
179
|
-
|
|
179
|
+
const result = { stopped: await reconcileCore(state, { deactivateServices: true }) };
|
|
180
|
+
if (ctx) appendAudit(state, ctx.actor, "uninstall", { stopped: result.stopped }, true, ctx.requestId ?? "", ctx.callerType ?? "unknown");
|
|
181
|
+
return result;
|
|
182
|
+
} catch (err) {
|
|
183
|
+
if (ctx) appendAudit(state, ctx.actor, "uninstall", { error: String(err) }, false, ctx.requestId ?? "", ctx.callerType ?? "unknown");
|
|
184
|
+
throw err;
|
|
180
185
|
} finally {
|
|
181
186
|
releaseLock(lock);
|
|
182
187
|
}
|
|
@@ -203,7 +208,7 @@ export async function updateStackEnvToLatestImageTag(state: ControlPlaneState):
|
|
|
203
208
|
namespace: string;
|
|
204
209
|
tag: string;
|
|
205
210
|
}> {
|
|
206
|
-
const systemEnvPath = `${state.
|
|
211
|
+
const systemEnvPath = `${state.stackDir}/stack.env`;
|
|
207
212
|
const parsed = parseEnvFile(systemEnvPath);
|
|
208
213
|
const namespace = (parsed.OP_IMAGE_NAMESPACE ?? process.env.OP_IMAGE_NAMESPACE ?? "openpalm").trim().toLowerCase();
|
|
209
214
|
|
|
@@ -273,22 +278,18 @@ export async function performUpgrade(state: ControlPlaneState): Promise<UpgradeR
|
|
|
273
278
|
const files = buildComposeFileList(state);
|
|
274
279
|
const envFiles = buildEnvFiles(state);
|
|
275
280
|
|
|
276
|
-
//
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
if (!preflight.ok) {
|
|
280
|
-
throw new Error(`Compose preflight failed: ${preflight.stderr}`);
|
|
281
|
-
}
|
|
282
|
-
}
|
|
281
|
+
// Compose preflight runs inside `applyUpgrade` -> `reconcileCore`, so we
|
|
282
|
+
// skip the redundant top-level call. Any merge failure aborts before
|
|
283
|
+
// mutation just the same.
|
|
283
284
|
|
|
284
|
-
//
|
|
285
|
-
const stackEnvPath = `${state.
|
|
285
|
+
// 1. Snapshot stack.env for rollback on failure
|
|
286
|
+
const stackEnvPath = `${state.stackDir}/stack.env`;
|
|
286
287
|
let originalStackEnv: string | null = null;
|
|
287
288
|
try {
|
|
288
289
|
originalStackEnv = readFileSync(stackEnvPath, "utf-8");
|
|
289
290
|
} catch { /* stack.env may not exist yet */ }
|
|
290
291
|
|
|
291
|
-
//
|
|
292
|
+
// 2. Update image tag + refresh core assets
|
|
292
293
|
let imageTag: string;
|
|
293
294
|
let namespace: string;
|
|
294
295
|
let upgradeResult: { backupDir: string | null; updated: string[]; restarted: string[] };
|
|
@@ -305,13 +306,13 @@ export async function performUpgrade(state: ControlPlaneState): Promise<UpgradeR
|
|
|
305
306
|
throw e;
|
|
306
307
|
}
|
|
307
308
|
|
|
308
|
-
//
|
|
309
|
+
// 3. Pull images
|
|
309
310
|
const pullResult = await composePull({ files, envFiles });
|
|
310
311
|
if (!pullResult.ok) {
|
|
311
312
|
throw new Error(`Failed to pull images: ${pullResult.stderr}`);
|
|
312
313
|
}
|
|
313
314
|
|
|
314
|
-
//
|
|
315
|
+
// 4. Recreate containers
|
|
315
316
|
const services = await buildManagedServices(state);
|
|
316
317
|
const upResult = await composeUp({ files, envFiles, services, removeOrphans: true });
|
|
317
318
|
if (!upResult.ok) {
|
|
@@ -328,7 +329,7 @@ export async function performUpgrade(state: ControlPlaneState): Promise<UpgradeR
|
|
|
328
329
|
}
|
|
329
330
|
|
|
330
331
|
export function buildComposeFileList(state: ControlPlaneState): string[] {
|
|
331
|
-
return discoverStackOverlays(
|
|
332
|
+
return discoverStackOverlays(state.stackDir);
|
|
332
333
|
}
|
|
333
334
|
|
|
334
335
|
export async function buildManagedServices(state: ControlPlaneState): Promise<string[]> {
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AKM markdown task parser.
|
|
3
|
+
*
|
|
4
|
+
* Task files are markdown with YAML frontmatter. The frontmatter defines the
|
|
5
|
+
* schedule and target; for inline-prompt tasks the markdown body is the prompt.
|
|
6
|
+
*
|
|
7
|
+
* Supported target types:
|
|
8
|
+
* command — `command: [...]` YAML array (argv), run via Bun.spawn / akm tasks run
|
|
9
|
+
* prompt — `prompt: inline` + markdown body as the prompt text
|
|
10
|
+
* workflow — `workflow: workflow:<ref>` + optional `params` map
|
|
11
|
+
*/
|
|
12
|
+
import { parse as parseYaml } from "yaml";
|
|
13
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import type { AutomationConfig } from "./scheduler.js";
|
|
16
|
+
import { createLogger } from "../logger.js";
|
|
17
|
+
|
|
18
|
+
const logger = createLogger("markdown-task");
|
|
19
|
+
|
|
20
|
+
// ── Types ─────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
export interface MarkdownTask {
|
|
23
|
+
id: string;
|
|
24
|
+
schedule: string;
|
|
25
|
+
enabled: boolean;
|
|
26
|
+
description?: string;
|
|
27
|
+
tags?: string[];
|
|
28
|
+
timeoutMs?: number;
|
|
29
|
+
target: MarkdownTaskTarget;
|
|
30
|
+
source: { path: string };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type MarkdownTaskTarget =
|
|
34
|
+
| { kind: "command"; cmd: string[] }
|
|
35
|
+
| { kind: "prompt"; profile?: string; body: string }
|
|
36
|
+
| { kind: "workflow"; ref: string; params: Record<string, unknown> };
|
|
37
|
+
|
|
38
|
+
// ── Frontmatter splitter ──────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
interface ParsedFile {
|
|
41
|
+
frontmatter: string;
|
|
42
|
+
body: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function splitFrontmatter(content: string): ParsedFile | null {
|
|
46
|
+
// Must start with ---
|
|
47
|
+
if (!content.startsWith("---")) return null;
|
|
48
|
+
const after = content.slice(3);
|
|
49
|
+
const end = after.indexOf("\n---");
|
|
50
|
+
if (end === -1) return null;
|
|
51
|
+
return {
|
|
52
|
+
frontmatter: after.slice(0, end).trim(),
|
|
53
|
+
body: after.slice(end + 4).trim(),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Parser ────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
export function parseMarkdownTask(filePath: string): MarkdownTask | null {
|
|
60
|
+
const id = filePath.replace(/.*[\\/]/, "").replace(/\.md$/, "");
|
|
61
|
+
let raw: string;
|
|
62
|
+
try {
|
|
63
|
+
raw = readFileSync(filePath, "utf-8");
|
|
64
|
+
} catch (err) {
|
|
65
|
+
logger.warn("failed to read task file", { filePath, error: String(err) });
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const parts = splitFrontmatter(raw);
|
|
70
|
+
if (!parts) {
|
|
71
|
+
logger.warn("task file missing frontmatter delimiters", { filePath });
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let fm: Record<string, unknown>;
|
|
76
|
+
try {
|
|
77
|
+
fm = parseYaml(parts.frontmatter) as Record<string, unknown>;
|
|
78
|
+
} catch (err) {
|
|
79
|
+
logger.warn("failed to parse task frontmatter", { filePath, error: String(err) });
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!fm || typeof fm !== "object") {
|
|
84
|
+
logger.warn("task frontmatter is not an object", { filePath });
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const schedule = fm.schedule;
|
|
89
|
+
if (typeof schedule !== "string" || !schedule.trim()) {
|
|
90
|
+
logger.warn("task missing or empty 'schedule'", { filePath });
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Resolve target type from frontmatter
|
|
95
|
+
let target: MarkdownTaskTarget;
|
|
96
|
+
|
|
97
|
+
if (fm.command !== undefined) {
|
|
98
|
+
const cmd = Array.isArray(fm.command)
|
|
99
|
+
? fm.command.map(String)
|
|
100
|
+
: typeof fm.command === "string"
|
|
101
|
+
? [fm.command]
|
|
102
|
+
: null;
|
|
103
|
+
if (!cmd || cmd.length === 0) {
|
|
104
|
+
logger.warn("task 'command' must be a non-empty array", { filePath });
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
target = { kind: "command", cmd };
|
|
108
|
+
} else if (fm.prompt !== undefined) {
|
|
109
|
+
if (fm.prompt !== "inline") {
|
|
110
|
+
// Future: handle asset-ref and file-path prompt sources
|
|
111
|
+
logger.warn("task 'prompt' supports only 'inline' currently", { filePath });
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
if (!parts.body) {
|
|
115
|
+
logger.warn("prompt:inline task has no markdown body", { filePath });
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
target = {
|
|
119
|
+
kind: "prompt",
|
|
120
|
+
profile: typeof fm.profile === "string" ? fm.profile : undefined,
|
|
121
|
+
body: parts.body,
|
|
122
|
+
};
|
|
123
|
+
} else if (fm.workflow !== undefined) {
|
|
124
|
+
if (typeof fm.workflow !== "string") {
|
|
125
|
+
logger.warn("task 'workflow' must be a string ref", { filePath });
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
target = {
|
|
129
|
+
kind: "workflow",
|
|
130
|
+
ref: fm.workflow,
|
|
131
|
+
params: (fm.params && typeof fm.params === "object" && !Array.isArray(fm.params))
|
|
132
|
+
? fm.params as Record<string, unknown>
|
|
133
|
+
: {},
|
|
134
|
+
};
|
|
135
|
+
} else {
|
|
136
|
+
logger.warn("task must have one of: command, prompt, workflow", { filePath });
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
id,
|
|
142
|
+
schedule: schedule.trim(),
|
|
143
|
+
enabled: fm.enabled !== false,
|
|
144
|
+
description: typeof fm.description === "string" ? fm.description : undefined,
|
|
145
|
+
tags: Array.isArray(fm.tags) ? fm.tags.map(String) : undefined,
|
|
146
|
+
timeoutMs: typeof fm.timeoutMs === "number" ? fm.timeoutMs : undefined,
|
|
147
|
+
target,
|
|
148
|
+
source: { path: filePath },
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function loadMarkdownTasks(stashDir: string): MarkdownTask[] {
|
|
153
|
+
const dir = join(stashDir, "tasks");
|
|
154
|
+
if (!existsSync(dir)) return [];
|
|
155
|
+
|
|
156
|
+
const tasks: MarkdownTask[] = [];
|
|
157
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
158
|
+
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
|
|
159
|
+
const task = parseMarkdownTask(join(dir, entry.name));
|
|
160
|
+
if (task) tasks.push(task);
|
|
161
|
+
}
|
|
162
|
+
return tasks;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── AutomationConfig adapter ──────────────────────────────────────────────
|
|
166
|
+
// Keeps the GET /admin/automations response shape compatible so the existing
|
|
167
|
+
// UI does not require changes.
|
|
168
|
+
|
|
169
|
+
export function taskToAutomationConfig(task: MarkdownTask): AutomationConfig {
|
|
170
|
+
const { target } = task;
|
|
171
|
+
|
|
172
|
+
let actionType: "shell" | "assistant" | "workflow" | "api" | "http";
|
|
173
|
+
let content: string | undefined;
|
|
174
|
+
let agent: string | undefined;
|
|
175
|
+
|
|
176
|
+
if (target.kind === "command") {
|
|
177
|
+
actionType = "shell";
|
|
178
|
+
} else if (target.kind === "prompt") {
|
|
179
|
+
actionType = "assistant";
|
|
180
|
+
content = target.body;
|
|
181
|
+
agent = target.profile;
|
|
182
|
+
} else {
|
|
183
|
+
actionType = "workflow";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
name: task.id,
|
|
188
|
+
description: task.description ?? "",
|
|
189
|
+
schedule: task.schedule,
|
|
190
|
+
timezone: "",
|
|
191
|
+
enabled: task.enabled,
|
|
192
|
+
action: {
|
|
193
|
+
type: actionType,
|
|
194
|
+
content,
|
|
195
|
+
agent,
|
|
196
|
+
},
|
|
197
|
+
on_failure: "log",
|
|
198
|
+
fileName: `${task.id}.md`,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authoritative path resolution for the OpenPalm control plane.
|
|
3
|
+
*
|
|
4
|
+
* Every consumer imports from here instead of concatenating paths inline.
|
|
5
|
+
* When the directory layout changes, update this file only.
|
|
6
|
+
*
|
|
7
|
+
* Layout:
|
|
8
|
+
* config/ — user-editable config + system config files (auth.json, akm/)
|
|
9
|
+
* config/stack/ — compose runtime + stack config (stack.env, guardian.env, stack.yml, addons/)
|
|
10
|
+
* cache/ — regenerable/semi-persistent data (akm cache, guardian cache, rollback)
|
|
11
|
+
* state/ — persistent service data (assistant, admin, guardian, logs, backups, registry)
|
|
12
|
+
* stash/ — akm knowledge (skills, vaults, agents)
|
|
13
|
+
* workspace/ — shared work area
|
|
14
|
+
*/
|
|
15
|
+
import type { ControlPlaneState } from "./types.js";
|
|
16
|
+
|
|
17
|
+
// ── Config directory — user + system config ─────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/** OpenCode auth token store */
|
|
20
|
+
export const authJsonPath = (s: ControlPlaneState): string => `${s.configDir}/auth.json`;
|
|
21
|
+
/** akm setup config directory (AKM_CONFIG_DIR) */
|
|
22
|
+
export const akmConfigDir = (s: ControlPlaneState): string => `${s.configDir}/akm`;
|
|
23
|
+
/** akm setup config file (written by admin on capability save) */
|
|
24
|
+
export const akmConfigPath = (s: ControlPlaneState): string => `${s.configDir}/akm/config.json`;
|
|
25
|
+
export const tasksDir = (s: ControlPlaneState): string => `${s.stashDir}/tasks`;
|
|
26
|
+
export const assistantConfigDir = (s: ControlPlaneState): string => `${s.configDir}/assistant`;
|
|
27
|
+
|
|
28
|
+
// ── Config/stack directory — compose runtime + stack config ─────────────────
|
|
29
|
+
|
|
30
|
+
/** System env: capabilities, secrets, tokens */
|
|
31
|
+
export const stackEnvPath = (s: ControlPlaneState): string => `${s.stackDir}/stack.env`;
|
|
32
|
+
/** Guardian HMAC channel secrets */
|
|
33
|
+
export const guardianEnvPath = (s: ControlPlaneState): string => `${s.stackDir}/guardian.env`;
|
|
34
|
+
/** Stack spec: capability assignments */
|
|
35
|
+
export const stackSpecFilePath = (s: ControlPlaneState): string => `${s.stackDir}/stack.yml`;
|
|
36
|
+
|
|
37
|
+
// ── Cache directory — regenerable/semi-persistent ───────────────────────────
|
|
38
|
+
|
|
39
|
+
export const akmCacheDir = (s: ControlPlaneState): string => `${s.cacheDir}/akm`;
|
|
40
|
+
export const guardianCacheDir = (s: ControlPlaneState): string => `${s.cacheDir}/guardian`;
|
|
41
|
+
export const rollbackDir = (s: ControlPlaneState): string => `${s.cacheDir}/rollback`;
|
|
42
|
+
|
|
43
|
+
// ── State directory — persistent service data ───────────────────────────────
|
|
44
|
+
|
|
45
|
+
export const assistantServiceDir = (s: ControlPlaneState): string => `${s.stateDir}/assistant`;
|
|
46
|
+
export const adminServiceDir = (s: ControlPlaneState): string => `${s.stateDir}/admin`;
|
|
47
|
+
export const guardianServiceDir = (s: ControlPlaneState): string => `${s.stateDir}/guardian`;
|
|
48
|
+
export const guardianStashDir = (s: ControlPlaneState): string => `${s.stateDir}/guardian/stash`;
|
|
49
|
+
export const guardianAkmDir = (s: ControlPlaneState): string => `${s.stateDir}/guardian/akm`;
|
|
50
|
+
/** Shared akm operational data (data/, state/ — NOT config, which lives in config/akm/) */
|
|
51
|
+
export const akmStateDir = (s: ControlPlaneState): string => `${s.stateDir}/akm`;
|
|
52
|
+
export const taskLogDir = (s: ControlPlaneState, id: string): string => `${s.cacheDir}/akm/tasks/logs/${id}`;
|
|
53
|
+
export const taskLogsRootDir = (s: ControlPlaneState): string => `${s.cacheDir}/akm/tasks/logs`;
|
|
54
|
+
export const logsDir = (s: ControlPlaneState): string => `${s.stateDir}/logs`;
|
|
55
|
+
export const adminAuditPath = (s: ControlPlaneState): string => `${s.stateDir}/logs/admin-audit.jsonl`;
|
|
56
|
+
export const guardianAuditPath = (s: ControlPlaneState): string => `${s.stateDir}/logs/guardian-audit.log`;
|
|
57
|
+
export const backupsDir = (s: ControlPlaneState): string => `${s.stateDir}/backups`;
|
|
58
|
+
export const registryDir = (s: ControlPlaneState): string => `${s.stateDir}/registry`;
|
|
59
|
+
export const registryAddonsDir = (s: ControlPlaneState): string => `${s.stateDir}/registry/addons`;
|
|
60
|
+
export const registryAutomationsDir = (s: ControlPlaneState): string => `${s.stateDir}/registry/automations`;
|
|
61
|
+
export const secretsDir = (s: ControlPlaneState): string => `${s.stateDir}/secrets`;
|
|
62
|
+
export const secretProviderPath = (s: ControlPlaneState): string => `${s.stateDir}/secrets/provider.json`;
|
|
63
|
+
export const secretsIndexPath = (s: ControlPlaneState): string => `${s.stateDir}/secrets/plaintext-index.json`;
|
|
64
|
+
export const passStoreDir = (s: ControlPlaneState): string => `${s.stateDir}/secrets/pass-store`;
|
|
65
|
+
|
|
66
|
+
// ── Stash directory ─────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/** akm vault:user file — lives in the stash */
|
|
69
|
+
export const akmUserVaultPath = (s: ControlPlaneState): string => `${s.stashDir}/vaults/user.env`;
|
|
70
|
+
|
|
71
|
+
// ── Stack directory ─────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
export const coreComposePath = (s: ControlPlaneState): string => `${s.stackDir}/core.compose.yml`;
|
|
74
|
+
export const addonsStackDir = (s: ControlPlaneState): string => `${s.stackDir}/addons`;
|
|
75
|
+
export const addonComposePath = (s: ControlPlaneState, name: string): string => `${s.stackDir}/addons/${name}/compose.yml`;
|