@lifeaitools/clauth 1.30.14 → 1.30.15
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/cli/commands/serve.js
CHANGED
|
@@ -41,8 +41,9 @@ import {
|
|
|
41
41
|
listTunnels,
|
|
42
42
|
readSupervisorEvents,
|
|
43
43
|
addTunnelRoute,
|
|
44
|
-
removeTunnelRoute,
|
|
45
|
-
|
|
44
|
+
removeTunnelRoute,
|
|
45
|
+
reconcileSurfaceHealth,
|
|
46
|
+
runPluginAction,
|
|
46
47
|
runSurfaceAction,
|
|
47
48
|
setPluginEnabled,
|
|
48
49
|
supervisorHealth,
|
|
@@ -7819,7 +7820,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
7819
7820
|
|
|
7820
7821
|
// If vault is already unlocked at startup (auto-unlock from boot.key skips POST /unlock),
|
|
7821
7822
|
// mirror the tunnel-start logic that the unlock handler would have run.
|
|
7822
|
-
if (initPassword) {
|
|
7823
|
+
if (initPassword) {
|
|
7823
7824
|
setImmediate(() => {
|
|
7824
7825
|
if (tunnelHostname) {
|
|
7825
7826
|
tunnelStatus = "starting";
|
|
@@ -7835,11 +7836,32 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
7835
7836
|
}
|
|
7836
7837
|
}).catch(() => { tunnelStatus = "error"; });
|
|
7837
7838
|
}
|
|
7838
|
-
});
|
|
7839
|
-
}
|
|
7840
|
-
|
|
7841
|
-
|
|
7842
|
-
|
|
7839
|
+
});
|
|
7840
|
+
}
|
|
7841
|
+
|
|
7842
|
+
// The localhost supervisor is the only process allowed to repair clauth-owned
|
|
7843
|
+
// local surfaces. Keep this loop out of the vault/staged instances and make
|
|
7844
|
+
// the cadence configurable for deterministic tests.
|
|
7845
|
+
if (port === getSupervisorPort() && process.env.CLAUTH_SUPERVISOR_HEALTH_RECONCILE !== "0") {
|
|
7846
|
+
const configuredInterval = Number(process.env.CLAUTH_SUPERVISOR_HEALTH_INTERVAL_MS || 10000);
|
|
7847
|
+
const intervalMs = Number.isFinite(configuredInterval) ? Math.max(1000, Math.min(configuredInterval, 300000)) : 10000;
|
|
7848
|
+
let healthReconcileInFlight = false;
|
|
7849
|
+
const runHealthReconcile = () => {
|
|
7850
|
+
if (healthReconcileInFlight) return;
|
|
7851
|
+
healthReconcileInFlight = true;
|
|
7852
|
+
reconcileSurfaceHealth().catch((err) => {
|
|
7853
|
+
try { fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] supervisor health reconcile failed: ${err.message}\n`); } catch {}
|
|
7854
|
+
}).finally(() => { healthReconcileInFlight = false; });
|
|
7855
|
+
};
|
|
7856
|
+
const healthTimer = setInterval(runHealthReconcile, intervalMs);
|
|
7857
|
+
healthTimer.unref?.();
|
|
7858
|
+
server.__supervisorHealthTimer = healthTimer;
|
|
7859
|
+
server.on("close", () => clearInterval(healthTimer));
|
|
7860
|
+
setImmediate(runHealthReconcile);
|
|
7861
|
+
}
|
|
7862
|
+
|
|
7863
|
+
return server;
|
|
7864
|
+
}
|
|
7843
7865
|
|
|
7844
7866
|
// ── Actions ──────────────────────────────────────────────────
|
|
7845
7867
|
|
|
@@ -10,6 +10,9 @@ const DEFAULT_SUPERVISOR_PORT = 52439;
|
|
|
10
10
|
const DESTINATIONS = new Set(["local/clauth/pm2", "vultr/clauth/pm2", "coolify/clauth/docker"]);
|
|
11
11
|
const OWNERS = new Set(["clauth", "plugin", "external"]);
|
|
12
12
|
const ACTIONS = new Set(["start", "stop", "restart", "reconcile", "test", "promote", "rollback"]);
|
|
13
|
+
const DEFAULT_HEALTH_RECONCILE_INTERVAL_MS = 10000;
|
|
14
|
+
const DEFAULT_HEALTH_TIMEOUT_MS = 2500;
|
|
15
|
+
const HEALTH_RECONCILE_COOLDOWN_MS = 15000;
|
|
13
16
|
|
|
14
17
|
export function getSupervisorPort() {
|
|
15
18
|
return Number(process.env.CLAUTH_SUPERVISOR_PORT || DEFAULT_SUPERVISOR_PORT);
|
|
@@ -57,6 +60,86 @@ function now() {
|
|
|
57
60
|
return new Date().toISOString();
|
|
58
61
|
}
|
|
59
62
|
|
|
63
|
+
function healthUrlForSurface(surface) {
|
|
64
|
+
if (!surface?.health) return null;
|
|
65
|
+
if (/^https?:\/\//i.test(surface.health)) return surface.health;
|
|
66
|
+
if (!surface.port || surface.port === "auto") return null;
|
|
67
|
+
return `http://127.0.0.1:${surface.port}${String(surface.health).startsWith("/") ? surface.health : `/${surface.health}`}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function updateSurfaceState(surfaceId, patch) {
|
|
71
|
+
const state = loadSupervisorState();
|
|
72
|
+
state.surfaces = (state.surfaces || []).map((surface) => (
|
|
73
|
+
`${surface.plugin_id}:${surface.id}` === surfaceId || surface.id === surfaceId
|
|
74
|
+
? { ...surface, ...patch }
|
|
75
|
+
: surface
|
|
76
|
+
));
|
|
77
|
+
saveSupervisorState(state);
|
|
78
|
+
return state.surfaces.find((surface) => `${surface.plugin_id}:${surface.id}` === surfaceId || surface.id === surfaceId) || null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function appendSupervisorEvent(event) {
|
|
82
|
+
appendJsonl(file("events.jsonl"), { ts: now(), ...event });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Probe enabled local clauth-owned surfaces and repair an unavailable one via
|
|
87
|
+
* the same governed action path exposed to Dev Center. External/plugin-owned
|
|
88
|
+
* surfaces are intentionally observe-only and are never restarted here.
|
|
89
|
+
*/
|
|
90
|
+
export async function reconcileSurfaceHealth({ fetchImpl = globalThis.fetch, timeoutMs = DEFAULT_HEALTH_TIMEOUT_MS } = {}) {
|
|
91
|
+
const inspected = [];
|
|
92
|
+
for (const surface of listSurfaces()) {
|
|
93
|
+
if (!surface.enabled || surface.lifecycle_owner !== "clauth" || surface.destination !== "local/clauth/pm2") continue;
|
|
94
|
+
const id = `${surface.plugin_id}:${surface.id}`;
|
|
95
|
+
const url = healthUrlForSurface(surface);
|
|
96
|
+
if (!url) continue;
|
|
97
|
+
const observedAt = now();
|
|
98
|
+
let healthy = false;
|
|
99
|
+
let error = null;
|
|
100
|
+
try {
|
|
101
|
+
const controller = new AbortController();
|
|
102
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
103
|
+
try {
|
|
104
|
+
const response = await fetchImpl(url, { signal: controller.signal });
|
|
105
|
+
healthy = Boolean(response?.ok);
|
|
106
|
+
if (!healthy) error = `HTTP ${response?.status ?? "unknown"}`;
|
|
107
|
+
} finally {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
}
|
|
110
|
+
} catch (err) {
|
|
111
|
+
error = err?.name === "AbortError" ? "health timeout" : String(err?.message || err);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (healthy) {
|
|
115
|
+
updateSurfaceState(id, { state: "current", last_health_at: observedAt, last_health_ok: true, last_health_error: null });
|
|
116
|
+
inspected.push({ surface_id: id, state: "healthy", observed_at: observedAt });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const lastAttempt = Date.parse(surface.last_reconcile_at || "") || 0;
|
|
121
|
+
if (Date.now() - lastAttempt < HEALTH_RECONCILE_COOLDOWN_MS) {
|
|
122
|
+
updateSurfaceState(id, { state: "unavailable", last_health_at: observedAt, last_health_ok: false, last_health_error: error });
|
|
123
|
+
inspected.push({ surface_id: id, state: "unavailable", error, cooldown: true, observed_at: observedAt });
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
updateSurfaceState(id, {
|
|
128
|
+
state: "unavailable",
|
|
129
|
+
last_health_at: observedAt,
|
|
130
|
+
last_health_ok: false,
|
|
131
|
+
last_health_error: error,
|
|
132
|
+
last_reconcile_at: observedAt,
|
|
133
|
+
});
|
|
134
|
+
appendSupervisorEvent({ kind: "surface_health_failed", surface_id: id, error, health: url });
|
|
135
|
+
const receipt = runSurfaceAction(id, "reconcile", "supervisor-health-loop");
|
|
136
|
+
const repaired = receipt?.resulting_state?.ok === true;
|
|
137
|
+
updateSurfaceState(id, { state: repaired ? "current" : "unavailable", last_reconcile_operation_id: receipt?.operationId || null });
|
|
138
|
+
inspected.push({ surface_id: id, state: repaired ? "reconciled" : "reconcile_failed", error, operation_id: receipt?.operationId || null, observed_at: observedAt });
|
|
139
|
+
}
|
|
140
|
+
return { inspected };
|
|
141
|
+
}
|
|
142
|
+
|
|
60
143
|
function normalizeCommand(command, field) {
|
|
61
144
|
if (!command) return [];
|
|
62
145
|
if (!Array.isArray(command)) throw new Error(`${field} must be a command array`);
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
getClauthPm2Home,
|
|
11
11
|
listPlugins,
|
|
12
12
|
listSurfaces,
|
|
13
|
+
reconcileSurfaceHealth,
|
|
13
14
|
runPluginAction,
|
|
14
15
|
runSurfaceAction,
|
|
15
16
|
removeTunnelRoute,
|
|
@@ -195,6 +196,78 @@ test("surface promote and rollback never fall through to restart commands", () =
|
|
|
195
196
|
}
|
|
196
197
|
}));
|
|
197
198
|
|
|
199
|
+
test("health reconciliation marks a failed clauth surface and repairs it through reconcile", async () => {
|
|
200
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-supervisor-health-"));
|
|
201
|
+
const oldDir = process.env.CLAUTH_SUPERVISOR_DIR;
|
|
202
|
+
const oldManaged = process.env.CLAUTH_MANAGED_PLUGIN_ROOTS;
|
|
203
|
+
const oldUser = process.env.CLAUTH_USER_PLUGIN_ROOTS;
|
|
204
|
+
process.env.CLAUTH_SUPERVISOR_DIR = root;
|
|
205
|
+
process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = path.join(root, "managed");
|
|
206
|
+
process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user");
|
|
207
|
+
try {
|
|
208
|
+
writePlugin(root, "managed", "health-demo", baseManifest("health-demo", {
|
|
209
|
+
core: true,
|
|
210
|
+
enable_default: true,
|
|
211
|
+
surfaces: [{
|
|
212
|
+
id: "primary",
|
|
213
|
+
destination: "local/clauth/pm2",
|
|
214
|
+
lifecycle_owner: "clauth",
|
|
215
|
+
port: 39111,
|
|
216
|
+
health: "/health",
|
|
217
|
+
restart: [process.execPath, "--version"],
|
|
218
|
+
}],
|
|
219
|
+
}));
|
|
220
|
+
discoverPlugins();
|
|
221
|
+
const result = await reconcileSurfaceHealth({
|
|
222
|
+
fetchImpl: async () => ({ ok: false, status: 503 }),
|
|
223
|
+
});
|
|
224
|
+
assert.equal(result.inspected[0].surface_id, "health-demo:primary");
|
|
225
|
+
assert.equal(result.inspected[0].state, "reconciled");
|
|
226
|
+
const surface = listSurfaces().find((item) => item.plugin_id === "health-demo");
|
|
227
|
+
assert.equal(surface.state, "current");
|
|
228
|
+
assert.ok(surface.last_reconcile_operation_id);
|
|
229
|
+
} finally {
|
|
230
|
+
if (oldDir === undefined) delete process.env.CLAUTH_SUPERVISOR_DIR;
|
|
231
|
+
else process.env.CLAUTH_SUPERVISOR_DIR = oldDir;
|
|
232
|
+
if (oldManaged === undefined) delete process.env.CLAUTH_MANAGED_PLUGIN_ROOTS;
|
|
233
|
+
else process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = oldManaged;
|
|
234
|
+
if (oldUser === undefined) delete process.env.CLAUTH_USER_PLUGIN_ROOTS;
|
|
235
|
+
else process.env.CLAUTH_USER_PLUGIN_ROOTS = oldUser;
|
|
236
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("health reconciliation never restarts external or plugin-owned surfaces", async () => {
|
|
241
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-supervisor-observe-"));
|
|
242
|
+
const oldDir = process.env.CLAUTH_SUPERVISOR_DIR;
|
|
243
|
+
const oldManaged = process.env.CLAUTH_MANAGED_PLUGIN_ROOTS;
|
|
244
|
+
const oldUser = process.env.CLAUTH_USER_PLUGIN_ROOTS;
|
|
245
|
+
process.env.CLAUTH_SUPERVISOR_DIR = root;
|
|
246
|
+
process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = path.join(root, "managed");
|
|
247
|
+
process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user");
|
|
248
|
+
try {
|
|
249
|
+
writePlugin(root, "managed", "external-demo", baseManifest("external-demo", {
|
|
250
|
+
core: true,
|
|
251
|
+
enable_default: true,
|
|
252
|
+
destination: "vultr/clauth/pm2",
|
|
253
|
+
lifecycle_owner: "external",
|
|
254
|
+
surfaces: [{ id: "primary", destination: "vultr/clauth/pm2", lifecycle_owner: "external", port: 39112, health: "/health" }],
|
|
255
|
+
}));
|
|
256
|
+
discoverPlugins();
|
|
257
|
+
const result = await reconcileSurfaceHealth({ fetchImpl: async () => ({ ok: false, status: 503 }) });
|
|
258
|
+
assert.deepEqual(result.inspected, []);
|
|
259
|
+
assert.equal(listSurfaces()[0].state, "current");
|
|
260
|
+
} finally {
|
|
261
|
+
if (oldDir === undefined) delete process.env.CLAUTH_SUPERVISOR_DIR;
|
|
262
|
+
else process.env.CLAUTH_SUPERVISOR_DIR = oldDir;
|
|
263
|
+
if (oldManaged === undefined) delete process.env.CLAUTH_MANAGED_PLUGIN_ROOTS;
|
|
264
|
+
else process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = oldManaged;
|
|
265
|
+
if (oldUser === undefined) delete process.env.CLAUTH_USER_PLUGIN_ROOTS;
|
|
266
|
+
else process.env.CLAUTH_USER_PLUGIN_ROOTS = oldUser;
|
|
267
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
198
271
|
test("supervisor write-token policy is temporarily relaxed only for the localhost supervisor port", () => {
|
|
199
272
|
assert.equal(supervisorRequiresWriteToken(52439, {}), false);
|
|
200
273
|
assert.equal(supervisorRequiresWriteToken(52439, { CLAUTH_SUPERVISOR_REQUIRE_WRITE_TOKEN: "1" }), true);
|