@openpalm/lib 0.11.0-beta.2 → 0.11.0-beta.6
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 -0
- package/package.json +4 -1
- package/src/control-plane/akm-vault.ts +5 -1
- package/src/control-plane/channels.ts +8 -6
- package/src/control-plane/compose-args.test.ts +0 -9
- package/src/control-plane/compose-args.ts +0 -4
- package/src/control-plane/config-persistence.ts +46 -10
- package/src/control-plane/core-assets.ts +48 -7
- package/src/control-plane/docker.ts +15 -4
- package/src/control-plane/install-edge-cases.test.ts +3 -3
- package/src/control-plane/lifecycle.ts +13 -9
- package/src/control-plane/operator-ids.test.ts +130 -0
- package/src/control-plane/operator-ids.ts +89 -0
- package/src/control-plane/registry.test.ts +198 -4
- package/src/control-plane/registry.ts +333 -4
- package/src/control-plane/secrets.ts +4 -4
- package/src/control-plane/setup.test.ts +6 -6
- package/src/control-plane/setup.ts +4 -6
- package/src/control-plane/spec-to-env.test.ts +25 -9
- package/src/control-plane/spec-to-env.ts +28 -17
- package/src/control-plane/types.ts +0 -4
- package/src/control-plane/ui-assets.ts +34 -7
- package/src/index.ts +15 -9
- package/src/logger.test.ts +12 -12
- package/src/control-plane/admin-token.ts +0 -73
- package/src/control-plane/lock.test.ts +0 -194
- package/src/control-plane/lock.ts +0 -176
- package/src/control-plane/provider-config.ts +0 -34
- package/src/control-plane/secret-backend.test.ts +0 -346
- package/src/control-plane/secret-backend.ts +0 -362
- package/src/control-plane/spec-validator.ts +0 -62
|
@@ -1,194 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for orchestrator lock — acquisition, contention, stale cleanup,
|
|
3
|
-
* corrupt file handling, release, and idempotent release.
|
|
4
|
-
*/
|
|
5
|
-
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
6
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync } from "node:fs";
|
|
7
|
-
import { join } from "node:path";
|
|
8
|
-
import { tmpdir } from "node:os";
|
|
9
|
-
import {
|
|
10
|
-
acquireLock,
|
|
11
|
-
releaseLock,
|
|
12
|
-
lockPath,
|
|
13
|
-
LockAcquisitionError,
|
|
14
|
-
} from "./lock.js";
|
|
15
|
-
import type { LockHandle, LockInfo } from "./lock.js";
|
|
16
|
-
|
|
17
|
-
let opHome: string;
|
|
18
|
-
|
|
19
|
-
beforeEach(() => {
|
|
20
|
-
opHome = mkdtempSync(join(tmpdir(), "lock-test-"));
|
|
21
|
-
mkdirSync(join(opHome, "data"), { recursive: true });
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
afterEach(() => {
|
|
25
|
-
rmSync(opHome, { recursive: true, force: true });
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
// ── Acquisition ──────────────────────────────────────────────────────────
|
|
29
|
-
|
|
30
|
-
describe("acquireLock", () => {
|
|
31
|
-
it("creates a lock file with correct JSON content", () => {
|
|
32
|
-
const handle = acquireLock(opHome, "install");
|
|
33
|
-
expect(existsSync(handle.path)).toBe(true);
|
|
34
|
-
|
|
35
|
-
const content = JSON.parse(readFileSync(handle.path, "utf-8"));
|
|
36
|
-
expect(content.pid).toBe(process.pid);
|
|
37
|
-
expect(content.operation).toBe("install");
|
|
38
|
-
expect(typeof content.acquiredAt).toBe("string");
|
|
39
|
-
|
|
40
|
-
releaseLock(handle);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it("returns a handle with correct info", () => {
|
|
44
|
-
const handle = acquireLock(opHome, "update");
|
|
45
|
-
expect(handle.info.pid).toBe(process.pid);
|
|
46
|
-
expect(handle.info.operation).toBe("update");
|
|
47
|
-
expect(handle.path).toBe(lockPath(opHome));
|
|
48
|
-
|
|
49
|
-
releaseLock(handle);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it("places lock at {opHome}/data/.openpalm.lock", () => {
|
|
53
|
-
const handle = acquireLock(opHome, "test");
|
|
54
|
-
expect(handle.path).toBe(join(opHome, "data", ".openpalm.lock"));
|
|
55
|
-
releaseLock(handle);
|
|
56
|
-
});
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
// ── Contention ───────────────────────────────────────────────────────────
|
|
60
|
-
|
|
61
|
-
describe("contention", () => {
|
|
62
|
-
it("throws LockAcquisitionError when lock is already held by this process", () => {
|
|
63
|
-
const handle = acquireLock(opHome, "install");
|
|
64
|
-
|
|
65
|
-
try {
|
|
66
|
-
expect(() => acquireLock(opHome, "update")).toThrow(LockAcquisitionError);
|
|
67
|
-
} finally {
|
|
68
|
-
releaseLock(handle);
|
|
69
|
-
}
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it("error includes holder details", () => {
|
|
73
|
-
const handle = acquireLock(opHome, "install");
|
|
74
|
-
|
|
75
|
-
try {
|
|
76
|
-
acquireLock(opHome, "update");
|
|
77
|
-
expect.unreachable("should have thrown");
|
|
78
|
-
} catch (err) {
|
|
79
|
-
expect(err).toBeInstanceOf(LockAcquisitionError);
|
|
80
|
-
const lockErr = err as LockAcquisitionError;
|
|
81
|
-
expect(lockErr.holder.pid).toBe(process.pid);
|
|
82
|
-
expect(lockErr.holder.operation).toBe("install");
|
|
83
|
-
} finally {
|
|
84
|
-
releaseLock(handle);
|
|
85
|
-
}
|
|
86
|
-
});
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
// ── Stale PID cleanup ────────────────────────────────────────────────────
|
|
90
|
-
|
|
91
|
-
describe("stale PID cleanup", () => {
|
|
92
|
-
it("cleans up stale lock from a dead PID and acquires", () => {
|
|
93
|
-
// Write a lock file with a PID that does not exist
|
|
94
|
-
const stalePid = 99999999; // Very unlikely to be a real process
|
|
95
|
-
const staleInfo: LockInfo = {
|
|
96
|
-
pid: stalePid,
|
|
97
|
-
operation: "old-install",
|
|
98
|
-
acquiredAt: "2020-01-01T00:00:00.000Z",
|
|
99
|
-
};
|
|
100
|
-
writeFileSync(lockPath(opHome), JSON.stringify(staleInfo) + "\n");
|
|
101
|
-
|
|
102
|
-
// Should succeed because the PID is dead
|
|
103
|
-
const handle = acquireLock(opHome, "new-install");
|
|
104
|
-
expect(handle.info.pid).toBe(process.pid);
|
|
105
|
-
expect(handle.info.operation).toBe("new-install");
|
|
106
|
-
|
|
107
|
-
releaseLock(handle);
|
|
108
|
-
});
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
// ── Corrupt file handling ────────────────────────────────────────────────
|
|
112
|
-
|
|
113
|
-
describe("corrupt lock file", () => {
|
|
114
|
-
it("recovers from a corrupt lock file", () => {
|
|
115
|
-
writeFileSync(lockPath(opHome), "not valid json{{{");
|
|
116
|
-
|
|
117
|
-
const handle = acquireLock(opHome, "install");
|
|
118
|
-
expect(handle.info.pid).toBe(process.pid);
|
|
119
|
-
|
|
120
|
-
releaseLock(handle);
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it("recovers from an empty lock file", () => {
|
|
124
|
-
writeFileSync(lockPath(opHome), "");
|
|
125
|
-
|
|
126
|
-
const handle = acquireLock(opHome, "install");
|
|
127
|
-
expect(handle.info.pid).toBe(process.pid);
|
|
128
|
-
|
|
129
|
-
releaseLock(handle);
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
it("recovers from a lock file with missing fields", () => {
|
|
133
|
-
writeFileSync(lockPath(opHome), JSON.stringify({ pid: 1 }));
|
|
134
|
-
|
|
135
|
-
const handle = acquireLock(opHome, "install");
|
|
136
|
-
expect(handle.info.pid).toBe(process.pid);
|
|
137
|
-
|
|
138
|
-
releaseLock(handle);
|
|
139
|
-
});
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
// ── Release ──────────────────────────────────────────────────────────────
|
|
143
|
-
|
|
144
|
-
describe("releaseLock", () => {
|
|
145
|
-
it("removes the lock file", () => {
|
|
146
|
-
const handle = acquireLock(opHome, "install");
|
|
147
|
-
expect(existsSync(handle.path)).toBe(true);
|
|
148
|
-
|
|
149
|
-
releaseLock(handle);
|
|
150
|
-
expect(existsSync(handle.path)).toBe(false);
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it("is idempotent — second release is a no-op", () => {
|
|
154
|
-
const handle = acquireLock(opHome, "install");
|
|
155
|
-
releaseLock(handle);
|
|
156
|
-
expect(existsSync(handle.path)).toBe(false);
|
|
157
|
-
|
|
158
|
-
// Second release should not throw
|
|
159
|
-
releaseLock(handle);
|
|
160
|
-
expect(existsSync(handle.path)).toBe(false);
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it("does not remove lock owned by a different PID", () => {
|
|
164
|
-
// Simulate a lock file owned by someone else
|
|
165
|
-
const otherInfo: LockInfo = {
|
|
166
|
-
pid: 99999999,
|
|
167
|
-
operation: "other",
|
|
168
|
-
acquiredAt: new Date().toISOString(),
|
|
169
|
-
};
|
|
170
|
-
writeFileSync(lockPath(opHome), JSON.stringify(otherInfo) + "\n");
|
|
171
|
-
|
|
172
|
-
// Create a handle that claims to own the lock
|
|
173
|
-
const fakeHandle: LockHandle = {
|
|
174
|
-
path: lockPath(opHome),
|
|
175
|
-
info: {
|
|
176
|
-
pid: process.pid, // Different from file content
|
|
177
|
-
operation: "mine",
|
|
178
|
-
acquiredAt: new Date().toISOString(),
|
|
179
|
-
},
|
|
180
|
-
};
|
|
181
|
-
|
|
182
|
-
releaseLock(fakeHandle);
|
|
183
|
-
// Lock file should still exist because PID doesn't match
|
|
184
|
-
expect(existsSync(lockPath(opHome))).toBe(true);
|
|
185
|
-
});
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
// ── lockPath ─────────────────────────────────────────────────────────────
|
|
189
|
-
|
|
190
|
-
describe("lockPath", () => {
|
|
191
|
-
it("returns the correct path", () => {
|
|
192
|
-
expect(lockPath("/home/user/.openpalm")).toBe("/home/user/.openpalm/data/.openpalm.lock");
|
|
193
|
-
});
|
|
194
|
-
});
|
|
@@ -1,176 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Orchestrator lock — prevents concurrent mutating operations.
|
|
3
|
-
*
|
|
4
|
-
* Uses O_CREAT | O_EXCL for atomic exclusive file creation.
|
|
5
|
-
* Lock file lives at {dataDir}/.openpalm.lock containing JSON
|
|
6
|
-
* with { pid, operation, acquiredAt }.
|
|
7
|
-
*
|
|
8
|
-
* Uses node:fs (not Bun) since lib must be Node-compatible for SvelteKit admin.
|
|
9
|
-
*/
|
|
10
|
-
import { openSync, writeSync, closeSync, readFileSync, unlinkSync, mkdirSync, constants } from "node:fs";
|
|
11
|
-
import { dirname } from "node:path";
|
|
12
|
-
|
|
13
|
-
// ── Types ────────────────────────────────────────────────────────────────
|
|
14
|
-
|
|
15
|
-
export type LockInfo = {
|
|
16
|
-
pid: number;
|
|
17
|
-
operation: string;
|
|
18
|
-
acquiredAt: string;
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
export type LockHandle = {
|
|
22
|
-
path: string;
|
|
23
|
-
info: LockInfo;
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
// ── Error ────────────────────────────────────────────────────────────────
|
|
27
|
-
|
|
28
|
-
export class LockAcquisitionError extends Error {
|
|
29
|
-
public readonly holder: LockInfo;
|
|
30
|
-
|
|
31
|
-
constructor(holder: LockInfo) {
|
|
32
|
-
super(
|
|
33
|
-
`Cannot acquire lock: already held by PID ${holder.pid} ` +
|
|
34
|
-
`for "${holder.operation}" since ${holder.acquiredAt}`
|
|
35
|
-
);
|
|
36
|
-
this.name = "LockAcquisitionError";
|
|
37
|
-
this.holder = holder;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// ── Path ─────────────────────────────────────────────────────────────────
|
|
42
|
-
|
|
43
|
-
export function lockPath(opHome: string): string {
|
|
44
|
-
return `${opHome}/data/.openpalm.lock`;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// ── Stale PID Detection ──────────────────────────────────────────────────
|
|
48
|
-
|
|
49
|
-
function isProcessAlive(pid: number): boolean {
|
|
50
|
-
try {
|
|
51
|
-
process.kill(pid, 0);
|
|
52
|
-
return true;
|
|
53
|
-
} catch {
|
|
54
|
-
return false;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// ── Read existing lock info ──────────────────────────────────────────────
|
|
59
|
-
|
|
60
|
-
function readLockInfo(path: string): LockInfo | null {
|
|
61
|
-
try {
|
|
62
|
-
const content = readFileSync(path, "utf-8");
|
|
63
|
-
const parsed = JSON.parse(content);
|
|
64
|
-
if (
|
|
65
|
-
typeof parsed.pid === "number" &&
|
|
66
|
-
typeof parsed.operation === "string" &&
|
|
67
|
-
typeof parsed.acquiredAt === "string"
|
|
68
|
-
) {
|
|
69
|
-
return parsed as LockInfo;
|
|
70
|
-
}
|
|
71
|
-
return null;
|
|
72
|
-
} catch {
|
|
73
|
-
return null;
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// ── Acquire / Release ────────────────────────────────────────────────────
|
|
78
|
-
|
|
79
|
-
export function acquireLock(opHome: string, operation: string): LockHandle {
|
|
80
|
-
const path = lockPath(opHome);
|
|
81
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
82
|
-
const info: LockInfo = {
|
|
83
|
-
pid: process.pid,
|
|
84
|
-
operation,
|
|
85
|
-
acquiredAt: new Date().toISOString(),
|
|
86
|
-
};
|
|
87
|
-
const content = JSON.stringify(info) + "\n";
|
|
88
|
-
|
|
89
|
-
try {
|
|
90
|
-
// Atomic exclusive create — fails if file already exists
|
|
91
|
-
const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o644);
|
|
92
|
-
try {
|
|
93
|
-
writeSync(fd, content);
|
|
94
|
-
} finally {
|
|
95
|
-
closeSync(fd);
|
|
96
|
-
}
|
|
97
|
-
return { path, info };
|
|
98
|
-
} catch (err: unknown) {
|
|
99
|
-
// File already exists — check if it's stale
|
|
100
|
-
if ((err as NodeJS.ErrnoException).code === "EEXIST") {
|
|
101
|
-
const existing = readLockInfo(path);
|
|
102
|
-
|
|
103
|
-
if (existing && !isProcessAlive(existing.pid)) {
|
|
104
|
-
// Stale lock — remove and retry once
|
|
105
|
-
try {
|
|
106
|
-
unlinkSync(path);
|
|
107
|
-
} catch {
|
|
108
|
-
// Race: another process already removed it; fall through to retry
|
|
109
|
-
}
|
|
110
|
-
// Retry acquisition
|
|
111
|
-
try {
|
|
112
|
-
const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o644);
|
|
113
|
-
try {
|
|
114
|
-
writeSync(fd, content);
|
|
115
|
-
} finally {
|
|
116
|
-
closeSync(fd);
|
|
117
|
-
}
|
|
118
|
-
return { path, info };
|
|
119
|
-
} catch (retryErr: unknown) {
|
|
120
|
-
if ((retryErr as NodeJS.ErrnoException).code === "EEXIST") {
|
|
121
|
-
// Another process won the race — read the new holder
|
|
122
|
-
const newHolder = readLockInfo(path);
|
|
123
|
-
throw new LockAcquisitionError(
|
|
124
|
-
newHolder ?? { pid: 0, operation: "unknown", acquiredAt: "unknown" }
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
throw retryErr;
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// Lock is held by a live process (or corrupt file — treat as held)
|
|
132
|
-
if (existing) {
|
|
133
|
-
throw new LockAcquisitionError(existing);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// Corrupt lock file — remove and retry
|
|
137
|
-
try {
|
|
138
|
-
unlinkSync(path);
|
|
139
|
-
} catch {
|
|
140
|
-
// ignore
|
|
141
|
-
}
|
|
142
|
-
try {
|
|
143
|
-
const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o644);
|
|
144
|
-
try {
|
|
145
|
-
writeSync(fd, content);
|
|
146
|
-
} finally {
|
|
147
|
-
closeSync(fd);
|
|
148
|
-
}
|
|
149
|
-
return { path, info };
|
|
150
|
-
} catch (retryErr: unknown) {
|
|
151
|
-
if ((retryErr as NodeJS.ErrnoException).code === "EEXIST") {
|
|
152
|
-
const newHolder = readLockInfo(path);
|
|
153
|
-
throw new LockAcquisitionError(
|
|
154
|
-
newHolder ?? { pid: 0, operation: "unknown", acquiredAt: "unknown" }
|
|
155
|
-
);
|
|
156
|
-
}
|
|
157
|
-
throw retryErr;
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
throw err;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
export function releaseLock(handle: LockHandle): void {
|
|
166
|
-
// Verify ownership before deleting — only remove if we still own it
|
|
167
|
-
const existing = readLockInfo(handle.path);
|
|
168
|
-
if (!existing) return; // Already gone — idempotent
|
|
169
|
-
if (existing.pid !== handle.info.pid) return; // Not ours — don't touch
|
|
170
|
-
|
|
171
|
-
try {
|
|
172
|
-
unlinkSync(handle.path);
|
|
173
|
-
} catch {
|
|
174
|
-
// Already removed — idempotent
|
|
175
|
-
}
|
|
176
|
-
}
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import type { ControlPlaneState } from './types.js';
|
|
3
|
-
|
|
4
|
-
export type SecretProviderConfig = {
|
|
5
|
-
provider: 'plaintext' | 'pass';
|
|
6
|
-
passwordStoreDir?: string;
|
|
7
|
-
passPrefix?: string;
|
|
8
|
-
};
|
|
9
|
-
|
|
10
|
-
function providerConfigPath(state: ControlPlaneState): string {
|
|
11
|
-
return `${state.stateDir}/secrets/provider.json`;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function readSecretProviderConfig(state: ControlPlaneState): SecretProviderConfig | null {
|
|
15
|
-
const path = providerConfigPath(state);
|
|
16
|
-
if (!existsSync(path)) return null;
|
|
17
|
-
|
|
18
|
-
try {
|
|
19
|
-
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as SecretProviderConfig;
|
|
20
|
-
if (parsed?.provider === 'plaintext' || parsed?.provider === 'pass') {
|
|
21
|
-
return parsed;
|
|
22
|
-
}
|
|
23
|
-
} catch {
|
|
24
|
-
// ignore malformed provider config and fall back to schema detection
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
return null;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function writeSecretProviderConfig(state: ControlPlaneState, config: SecretProviderConfig): void {
|
|
31
|
-
const dir = `${state.stateDir}/secrets`;
|
|
32
|
-
mkdirSync(dir, { recursive: true });
|
|
33
|
-
writeFileSync(providerConfigPath(state), JSON.stringify(config, null, 2) + '\n');
|
|
34
|
-
}
|