@checkstack/backend 0.8.1 → 0.9.0
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/CHANGELOG.md +280 -0
- package/drizzle/0001_slim_mordo.sql +34 -0
- package/drizzle/meta/0001_snapshot.json +444 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +14 -9
- package/src/index.ts +460 -23
- package/src/plugin-deregistration.test.ts +137 -0
- package/src/plugin-manager/api-router.ts +35 -11
- package/src/plugin-manager/core-services.ts +21 -2
- package/src/plugin-manager/plugin-loader.ts +94 -0
- package/src/plugin-manager.ts +324 -105
- package/src/router-incremental.test.ts +49 -0
- package/src/schema.ts +79 -1
- package/src/services/compatibility-checker.test.ts +146 -0
- package/src/services/compatibility-checker.ts +137 -0
- package/src/services/dev-auth.test.ts +87 -0
- package/src/services/dev-auth.ts +56 -0
- package/src/services/plugin-artifact-store.ts +131 -0
- package/src/services/plugin-bundle-resolver.ts +76 -0
- package/src/services/plugin-event-recorder.ts +87 -0
- package/src/services/plugin-installers/catalog-installer.ts +33 -0
- package/src/services/plugin-installers/github-installer.ts +207 -0
- package/src/services/plugin-installers/install-from-tarball.ts +69 -0
- package/src/services/plugin-installers/installer-registry.ts +51 -0
- package/src/services/plugin-installers/npm-installer.ts +156 -0
- package/src/services/plugin-installers/plugin-install-error.ts +37 -0
- package/src/services/plugin-installers/tarball-installer.ts +80 -0
- package/src/services/plugin-installers/tarball-utils.test.ts +200 -0
- package/src/services/plugin-installers/tarball-utils.ts +172 -0
- package/src/services/plugin-manager-orchestrator.ts +522 -0
- package/src/services/plugin-manager-router.ts +219 -0
- package/src/services/readiness-registry.test.ts +124 -0
- package/src/services/readiness-registry.ts +103 -0
- package/src/utils/plugin-discovery.test.ts +6 -0
- package/src/utils/plugin-discovery.ts +6 -1
- package/tsconfig.json +36 -1
- package/src/plugin-lifecycle.test.ts +0 -276
- package/src/plugin-manager/plugin-admin-router.ts +0 -89
- package/src/services/plugin-installer.test.ts +0 -90
- package/src/services/plugin-installer.ts +0 -70
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
|
2
|
+
import { PluginManager } from "./plugin-manager";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Integration test for the originator/in-process split introduced in the
|
|
6
|
+
* runtime plugin system.
|
|
7
|
+
*
|
|
8
|
+
* Key contract: `deregisterPluginInProcess` must NOT touch any persistent
|
|
9
|
+
* state. It runs on every instance via the broadcast hook; the destructive
|
|
10
|
+
* cleanup (drop schema, delete plugin_configs, delete plugin_artifacts,
|
|
11
|
+
* delete plugins rows) only runs on the originator via `deletePluginData`.
|
|
12
|
+
*
|
|
13
|
+
* If a future refactor accidentally pushes destructive ops back into the
|
|
14
|
+
* in-process path, these tests fire.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
describe("deregisterPluginInProcess", () => {
|
|
18
|
+
let pluginManager: PluginManager;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
pluginManager = new PluginManager();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("clears in-memory cleanup handlers and runs them in LIFO order", async () => {
|
|
25
|
+
const order: string[] = [];
|
|
26
|
+
const handlerA = mock(async () => {
|
|
27
|
+
order.push("a");
|
|
28
|
+
});
|
|
29
|
+
const handlerB = mock(async () => {
|
|
30
|
+
order.push("b");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Inject cleanup handlers using the same mechanism plugins use during
|
|
34
|
+
// registration. We poke the private map because the public path (full
|
|
35
|
+
// backendPlugin.register) needs a real plugin module.
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
37
|
+
const handlers = (pluginManager as any).cleanupHandlers as Map<
|
|
38
|
+
string,
|
|
39
|
+
Array<() => Promise<void>>
|
|
40
|
+
>;
|
|
41
|
+
handlers.set("test-plugin", [handlerA, handlerB]);
|
|
42
|
+
|
|
43
|
+
await pluginManager.deregisterPluginInProcess("test-plugin");
|
|
44
|
+
|
|
45
|
+
// LIFO: handlerB ran before handlerA
|
|
46
|
+
expect(order).toEqual(["b", "a"]);
|
|
47
|
+
expect(handlers.has("test-plugin")).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("removes router, contract, metadata and access-rule entries", async () => {
|
|
51
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
52
|
+
const pm = pluginManager as any;
|
|
53
|
+
pm.pluginRpcRouters.set("foo", { router: true });
|
|
54
|
+
pm.pluginContractRegistry.set("foo", { contract: true });
|
|
55
|
+
pm.pluginMetadataRegistry.set("foo", { pluginId: "foo" });
|
|
56
|
+
pm.registeredAccessRules.push({
|
|
57
|
+
id: "foo.something",
|
|
58
|
+
pluginId: "foo",
|
|
59
|
+
action: "read",
|
|
60
|
+
resourceType: "thing",
|
|
61
|
+
description: "test",
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await pluginManager.deregisterPluginInProcess("foo");
|
|
65
|
+
|
|
66
|
+
expect(pm.pluginRpcRouters.has("foo")).toBe(false);
|
|
67
|
+
expect(pm.pluginContractRegistry.has("foo")).toBe(false);
|
|
68
|
+
expect(pm.pluginMetadataRegistry.has("foo")).toBe(false);
|
|
69
|
+
expect(
|
|
70
|
+
pm.registeredAccessRules.some(
|
|
71
|
+
(r: { pluginId: string }) => r.pluginId === "foo",
|
|
72
|
+
),
|
|
73
|
+
).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("does not invoke any artifact-store or destructive ops", async () => {
|
|
77
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
78
|
+
const pm = pluginManager as any;
|
|
79
|
+
|
|
80
|
+
const artifactStoreCalls: string[] = [];
|
|
81
|
+
const fakeArtifactStore = {
|
|
82
|
+
maxArtifactSize: 1,
|
|
83
|
+
store: () => {
|
|
84
|
+
artifactStoreCalls.push("store");
|
|
85
|
+
return Promise.resolve({ artifactId: "x", contentHash: "x" });
|
|
86
|
+
},
|
|
87
|
+
fetch: () => {
|
|
88
|
+
artifactStoreCalls.push("fetch");
|
|
89
|
+
return Promise.resolve(undefined);
|
|
90
|
+
},
|
|
91
|
+
fetchById: () => {
|
|
92
|
+
artifactStoreCalls.push("fetchById");
|
|
93
|
+
return Promise.resolve(undefined);
|
|
94
|
+
},
|
|
95
|
+
delete: () => {
|
|
96
|
+
artifactStoreCalls.push("delete");
|
|
97
|
+
return Promise.resolve();
|
|
98
|
+
},
|
|
99
|
+
deleteByBundle: () => {
|
|
100
|
+
artifactStoreCalls.push("deleteByBundle");
|
|
101
|
+
return Promise.resolve();
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
// The artifact store is registered via coreServices.pluginArtifactStore.
|
|
105
|
+
// Even when present, deregisterPluginInProcess should not consult it.
|
|
106
|
+
pm.registry.register(
|
|
107
|
+
{ id: "core.pluginArtifactStore" },
|
|
108
|
+
fakeArtifactStore,
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
pm.cleanupHandlers.set("foo", []);
|
|
112
|
+
await pluginManager.deregisterPluginInProcess("foo");
|
|
113
|
+
|
|
114
|
+
expect(artifactStoreCalls).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("handler errors do not block subsequent cleanup or remove-from-registry", async () => {
|
|
118
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
119
|
+
const pm = pluginManager as any;
|
|
120
|
+
|
|
121
|
+
const goodHandler = mock(async () => {});
|
|
122
|
+
const badHandler = mock(async () => {
|
|
123
|
+
throw new Error("intentional");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
pm.cleanupHandlers.set("plugin-a", [goodHandler, badHandler]);
|
|
127
|
+
pm.pluginRpcRouters.set("plugin-a", {});
|
|
128
|
+
|
|
129
|
+
await pluginManager.deregisterPluginInProcess("plugin-a");
|
|
130
|
+
|
|
131
|
+
expect(badHandler).toHaveBeenCalled();
|
|
132
|
+
expect(goodHandler).toHaveBeenCalled();
|
|
133
|
+
// Even though one handler threw, the in-memory state was cleared:
|
|
134
|
+
expect(pm.pluginRpcRouters.has("plugin-a")).toBe(false);
|
|
135
|
+
expect(pm.cleanupHandlers.has("plugin-a")).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -20,6 +20,8 @@ import type {
|
|
|
20
20
|
import type { ServiceRegistry } from "../services/service-registry";
|
|
21
21
|
import type { EventBus } from "@checkstack/backend-api";
|
|
22
22
|
import type { PluginMetadata } from "@checkstack/common";
|
|
23
|
+
import { rootLogger } from "../logger";
|
|
24
|
+
import { extractErrorMessage } from "@checkstack/common";
|
|
23
25
|
|
|
24
26
|
/**
|
|
25
27
|
* Creates the API route handler for Hono.
|
|
@@ -71,17 +73,18 @@ export function createApiRouteHandler({
|
|
|
71
73
|
try {
|
|
72
74
|
return await next(rest);
|
|
73
75
|
} catch (error) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
76
|
+
const target = (logger ?? rootLogger) as Logger;
|
|
77
|
+
target.error(
|
|
78
|
+
`RPC ${pathname} failed: ${extractErrorMessage(error)}`,
|
|
79
|
+
);
|
|
80
|
+
const stack =
|
|
81
|
+
error !== null &&
|
|
82
|
+
typeof error === "object" &&
|
|
83
|
+
"stack" in error
|
|
84
|
+
? (error as { stack: string }).stack
|
|
85
|
+
: undefined;
|
|
86
|
+
if (stack) {
|
|
87
|
+
target.error(`Stack trace: ${stack}`);
|
|
85
88
|
}
|
|
86
89
|
throw error;
|
|
87
90
|
}
|
|
@@ -121,6 +124,22 @@ export function createApiRouteHandler({
|
|
|
121
124
|
!cacheManager ||
|
|
122
125
|
!eventBus
|
|
123
126
|
) {
|
|
127
|
+
const missing = [
|
|
128
|
+
!auth && "auth",
|
|
129
|
+
!logger && "logger",
|
|
130
|
+
!db && "db",
|
|
131
|
+
!fetch && "fetch",
|
|
132
|
+
!healthCheckRegistry && "healthCheckRegistry",
|
|
133
|
+
!collectorRegistry && "collectorRegistry",
|
|
134
|
+
!queuePluginRegistry && "queuePluginRegistry",
|
|
135
|
+
!queueManager && "queueManager",
|
|
136
|
+
!cachePluginRegistry && "cachePluginRegistry",
|
|
137
|
+
!cacheManager && "cacheManager",
|
|
138
|
+
!eventBus && "eventBus",
|
|
139
|
+
].filter(Boolean).join(", ");
|
|
140
|
+
(logger ?? rootLogger).error(
|
|
141
|
+
`${pathname}: core services not initialized — missing: ${missing}`,
|
|
142
|
+
);
|
|
124
143
|
return c.json({ error: "Core services not initialized" }, 500);
|
|
125
144
|
}
|
|
126
145
|
|
|
@@ -136,6 +155,11 @@ export function createApiRouteHandler({
|
|
|
136
155
|
pluginMetadataRegistry.get(pluginId);
|
|
137
156
|
|
|
138
157
|
if (!pluginMetadata) {
|
|
158
|
+
(logger as Logger).error(
|
|
159
|
+
`${pathname}: no plugin metadata registered for pluginId='${pluginId}'. ` +
|
|
160
|
+
`Regular plugins populate this during register(); core routers must call ` +
|
|
161
|
+
`pluginManager.registerCorePluginMetadata().`,
|
|
162
|
+
);
|
|
139
163
|
return c.json({ error: "Plugin metadata not found in registry" }, 500);
|
|
140
164
|
}
|
|
141
165
|
|
|
@@ -30,6 +30,10 @@ import {
|
|
|
30
30
|
WebSocketRouteStoreImpl,
|
|
31
31
|
createScopedWsRegistry,
|
|
32
32
|
} from "../services/ws-route-registry";
|
|
33
|
+
import {
|
|
34
|
+
CoreReadinessRegistry,
|
|
35
|
+
createScopedReadinessRegistry,
|
|
36
|
+
} from "../services/readiness-registry";
|
|
33
37
|
|
|
34
38
|
/**
|
|
35
39
|
* Check if a PostgreSQL schema exists.
|
|
@@ -59,7 +63,11 @@ export function registerCoreServices({
|
|
|
59
63
|
pluginRpcRouters: Map<string, unknown>;
|
|
60
64
|
pluginHttpHandlers: Map<string, (req: Request) => Promise<Response>>;
|
|
61
65
|
pluginContractRegistry: Map<string, unknown>;
|
|
62
|
-
}): {
|
|
66
|
+
}): {
|
|
67
|
+
collectorRegistry: CoreCollectorRegistry;
|
|
68
|
+
wsStore: WebSocketRouteStoreImpl;
|
|
69
|
+
readinessRegistry: CoreReadinessRegistry;
|
|
70
|
+
} {
|
|
63
71
|
// 1. Database Factory (Scoped)
|
|
64
72
|
registry.registerFactory(coreServices.database, async (metadata) => {
|
|
65
73
|
const { pluginId, previousPluginIds } = metadata;
|
|
@@ -356,6 +364,17 @@ export function registerCoreServices({
|
|
|
356
364
|
createScopedWsRegistry(globalWsStore, metadata.pluginId),
|
|
357
365
|
);
|
|
358
366
|
|
|
367
|
+
// 11. Readiness Registry (Scoped Factory)
|
|
368
|
+
// Plugins contribute probes that are aggregated by the /ready endpoint.
|
|
369
|
+
const globalReadinessRegistry = new CoreReadinessRegistry();
|
|
370
|
+
registry.registerFactory(coreServices.readinessRegistry, () =>
|
|
371
|
+
createScopedReadinessRegistry(globalReadinessRegistry),
|
|
372
|
+
);
|
|
373
|
+
|
|
359
374
|
// Return global registries for lifecycle cleanup
|
|
360
|
-
return {
|
|
375
|
+
return {
|
|
376
|
+
collectorRegistry: globalCollectorRegistry,
|
|
377
|
+
wsStore: globalWsStore,
|
|
378
|
+
readinessRegistry: globalReadinessRegistry,
|
|
379
|
+
};
|
|
361
380
|
}
|
|
@@ -56,6 +56,14 @@ export interface PluginLoaderDeps {
|
|
|
56
56
|
* Map of pluginId -> contract for OpenAPI generation.
|
|
57
57
|
*/
|
|
58
58
|
pluginContractRegistry: Map<string, AnyContractRouter>;
|
|
59
|
+
/**
|
|
60
|
+
* Called once `/api/:pluginId/*` is added to the root router and Phase 2
|
|
61
|
+
* (per-plugin init) is about to start. From this point on, plugin RPC
|
|
62
|
+
* routers come online incrementally as each plugin initializes — so
|
|
63
|
+
* self-referencing HTTP calls (e.g. RPC made from `afterPluginsReady`)
|
|
64
|
+
* can be allowed through the boot-time request gate without deadlocking.
|
|
65
|
+
*/
|
|
66
|
+
onApiRouteRegistered?: () => void;
|
|
59
67
|
}
|
|
60
68
|
|
|
61
69
|
/**
|
|
@@ -216,6 +224,14 @@ export async function loadPlugins({
|
|
|
216
224
|
// 2. Sync local plugins to database
|
|
217
225
|
await syncPluginsToDatabase({ localPlugins, db: deps.db });
|
|
218
226
|
|
|
227
|
+
// 2.5 Bootstrap runtime-installed plugins missing from node_modules.
|
|
228
|
+
// For every `is_uninstallable=true` row in `plugins`, ensure the
|
|
229
|
+
// package is installed in the runtime dir. If not, fetch its tarball
|
|
230
|
+
// from `plugin_artifacts` and run `bun install` so the import below
|
|
231
|
+
// resolves. This is what lets a freshly spun replica recover the
|
|
232
|
+
// full plugin set from Postgres alone.
|
|
233
|
+
await bootstrapRuntimePlugins({ db: deps.db, registry: deps.registry });
|
|
234
|
+
|
|
219
235
|
// 3. Load all enabled BACKEND plugins from database
|
|
220
236
|
allPlugins = await deps.db
|
|
221
237
|
.select()
|
|
@@ -295,6 +311,19 @@ export async function loadPlugins({
|
|
|
295
311
|
});
|
|
296
312
|
registerApiRoute(rootRouter, apiHandler);
|
|
297
313
|
|
|
314
|
+
// Routes are now registered on the root router. Signal readiness so the
|
|
315
|
+
// server can stop blocking incoming requests in `waitForInit()`. We open
|
|
316
|
+
// the gate here (BEFORE Phase 2 / Phase 3) so that:
|
|
317
|
+
// - the static module-load endpoints (/api/plugins, /api/about, …) stop
|
|
318
|
+
// hanging behind the boot gate;
|
|
319
|
+
// - cross-plugin RPC calls made from `afterPluginsReady` can self-loop
|
|
320
|
+
// through the HTTP server without deadlocking on init completion.
|
|
321
|
+
// Plugin RPC routers come online incrementally as each plugin's Phase 2
|
|
322
|
+
// init runs; requests targeting a not-yet-initialized plugin fall through
|
|
323
|
+
// to the api-router's "Plugin metadata not found" 500, which is the
|
|
324
|
+
// pre-existing behavior and is preferable to a multi-second hang.
|
|
325
|
+
deps.onApiRouteRegistered?.();
|
|
326
|
+
|
|
298
327
|
for (const id of sortedIds) {
|
|
299
328
|
const p = pendingInits.find((x) => x.metadata.pluginId === id)!;
|
|
300
329
|
rootLogger.info(`🚀 Initializing ${p.metadata.pluginId}...`);
|
|
@@ -620,3 +649,68 @@ function validateContractAccessRules({
|
|
|
620
649
|
}
|
|
621
650
|
}
|
|
622
651
|
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Fresh-instance bootstrap.
|
|
655
|
+
*
|
|
656
|
+
* Walks every `is_uninstallable=true` row in the `plugins` table and ensures
|
|
657
|
+
* the corresponding package is installed under the runtime dir. Missing
|
|
658
|
+
* packages are recovered from `plugin_artifacts` (bytea) and installed with
|
|
659
|
+
* `bun install --no-save --ignore-scripts`. Once this returns, the normal
|
|
660
|
+
* `pluginModule = await import(...)` in Phase 1 below resolves.
|
|
661
|
+
*/
|
|
662
|
+
async function bootstrapRuntimePlugins({
|
|
663
|
+
db,
|
|
664
|
+
registry,
|
|
665
|
+
}: {
|
|
666
|
+
db: SafeDatabase<Record<string, unknown>>;
|
|
667
|
+
registry: ServiceRegistry;
|
|
668
|
+
}): Promise<void> {
|
|
669
|
+
const installerRegistry = await registry
|
|
670
|
+
.get(coreServices.pluginInstallerRegistry, { pluginId: "core" })
|
|
671
|
+
.catch(() => {});
|
|
672
|
+
const artifactStore = await registry
|
|
673
|
+
.get(coreServices.pluginArtifactStore, { pluginId: "core" })
|
|
674
|
+
.catch(() => {});
|
|
675
|
+
if (!installerRegistry || !artifactStore) {
|
|
676
|
+
rootLogger.debug(
|
|
677
|
+
" -> Skipping runtime plugin bootstrap (services not registered)",
|
|
678
|
+
);
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const remoteRows = await db
|
|
683
|
+
.select()
|
|
684
|
+
.from(plugins)
|
|
685
|
+
.where(eq(plugins.isUninstallable, true));
|
|
686
|
+
|
|
687
|
+
for (const row of remoteRows) {
|
|
688
|
+
if (!row.path) continue;
|
|
689
|
+
const pkgJsonPath = path.join(row.path, "package.json");
|
|
690
|
+
if (fs.existsSync(pkgJsonPath)) {
|
|
691
|
+
rootLogger.debug(` -> ${row.name} already present, skipping bootstrap`);
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
rootLogger.info(
|
|
695
|
+
`🔌 Bootstrapping runtime plugin from artifact store: ${row.name}@${row.version}`,
|
|
696
|
+
);
|
|
697
|
+
const artifact = await artifactStore.fetch({
|
|
698
|
+
pluginName: row.name,
|
|
699
|
+
version: row.version,
|
|
700
|
+
});
|
|
701
|
+
if (!artifact) {
|
|
702
|
+
rootLogger.warn(
|
|
703
|
+
` -> No artifact for ${row.name}@${row.version} — skipping. Re-install from the original source.`,
|
|
704
|
+
);
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
const allowInstallScripts =
|
|
708
|
+
(row.metadata as { checkstack?: { allowInstallScripts?: boolean } })
|
|
709
|
+
?.checkstack?.allowInstallScripts === true;
|
|
710
|
+
await installerRegistry.forSource("npm").installFromArtifact({
|
|
711
|
+
tarball: artifact.tarball,
|
|
712
|
+
pluginName: row.name,
|
|
713
|
+
allowInstallScripts,
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
}
|