@checkstack/satellite 0.2.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 +40 -0
- package/package.json +23 -0
- package/src/index.ts +298 -0
- package/src/result-buffer.test.ts +74 -0
- package/src/result-buffer.ts +49 -0
- package/src/satellite-client.ts +224 -0
- package/src/scheduler.ts +88 -0
- package/src/strategy-loader.ts +215 -0
- package/tsconfig.json +6 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# @checkstack/satellite
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 26d8bae: Distributed satellite health checks and Assignment IDE page
|
|
8
|
+
|
|
9
|
+
**Satellite System**
|
|
10
|
+
|
|
11
|
+
- New `satellite-backend`, `satellite-common`, `satellite-frontend`, and `satellite` agent packages for distributed health check execution
|
|
12
|
+
- WebSocket-based satellite connectivity with authentication, heartbeats, and live configuration push
|
|
13
|
+
- Satellite management UI with create dialog, status badges, and list page
|
|
14
|
+
|
|
15
|
+
**Live Configuration Updates**
|
|
16
|
+
|
|
17
|
+
- Added `assignmentChanged` hook to `healthcheck-backend` for cross-plugin communication
|
|
18
|
+
- `satellite-backend` subscribes to assignment changes and pushes config updates to connected satellites in real-time
|
|
19
|
+
|
|
20
|
+
**Assignment IDE Page**
|
|
21
|
+
|
|
22
|
+
- Replaced the 1028-line modal-based `SystemHealthCheckAssignment` component with a full-page IDE layout
|
|
23
|
+
- New modular components: `AssignmentTree`, `GeneralPanel`, `ThresholdsPanel`, `RetentionPanel`, `ExecutionPanel`
|
|
24
|
+
- Added unassign capability and sorted assignment lists for stable ordering
|
|
25
|
+
|
|
26
|
+
**Shared IDE Primitives**
|
|
27
|
+
|
|
28
|
+
- Extracted `IDETreeNode`, `IDETreeSection`, `IDEStatusBar`, `IDELayout` to `@checkstack/ui` for cross-plugin reuse
|
|
29
|
+
- Migrated existing health check IDE editor to use shared primitives
|
|
30
|
+
|
|
31
|
+
**Infrastructure**
|
|
32
|
+
|
|
33
|
+
- Added `Dockerfile.satellite` for containerized satellite deployment
|
|
34
|
+
- WebSocket route registry in `@checkstack/backend` and `@checkstack/backend-api`
|
|
35
|
+
|
|
36
|
+
### Patch Changes
|
|
37
|
+
|
|
38
|
+
- Updated dependencies [26d8bae]
|
|
39
|
+
- @checkstack/satellite-common@0.2.0
|
|
40
|
+
- @checkstack/backend-api@0.12.0
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@checkstack/satellite",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"typecheck": "tsc --noEmit",
|
|
8
|
+
"start": "bun run src/index.ts",
|
|
9
|
+
"lint": "bun run lint:code",
|
|
10
|
+
"lint:code": "eslint . --max-warnings 0"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@checkstack/satellite-common": "0.1.0",
|
|
14
|
+
"@checkstack/backend-api": "0.11.1",
|
|
15
|
+
"@checkstack/common": "0.6.5"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@checkstack/tsconfig": "0.0.5",
|
|
19
|
+
"@checkstack/scripts": "0.1.2",
|
|
20
|
+
"@types/bun": "^1.0.0",
|
|
21
|
+
"typescript": "^5.0.0"
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SatelliteAssignment,
|
|
3
|
+
ResultMessage,
|
|
4
|
+
} from "@checkstack/satellite-common";
|
|
5
|
+
import type {
|
|
6
|
+
ConnectedClient,
|
|
7
|
+
TransportClient,
|
|
8
|
+
} from "@checkstack/backend-api";
|
|
9
|
+
import { SatelliteClient } from "./satellite-client";
|
|
10
|
+
import { Scheduler } from "./scheduler";
|
|
11
|
+
import { loadStrategies } from "./strategy-loader";
|
|
12
|
+
|
|
13
|
+
// =============================================================================
|
|
14
|
+
// Environment validation — fail fast if required vars are missing
|
|
15
|
+
// =============================================================================
|
|
16
|
+
|
|
17
|
+
const CORE_URL = process.env["CHECKSTACK_CORE_URL"];
|
|
18
|
+
const CLIENT_ID = process.env["CHECKSTACK_SATELLITE_CLIENT_ID"];
|
|
19
|
+
const TOKEN = process.env["CHECKSTACK_SATELLITE_TOKEN"];
|
|
20
|
+
|
|
21
|
+
if (!CORE_URL) {
|
|
22
|
+
throw new Error("CHECKSTACK_CORE_URL environment variable is required");
|
|
23
|
+
}
|
|
24
|
+
if (!CLIENT_ID) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
"CHECKSTACK_SATELLITE_CLIENT_ID environment variable is required",
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
if (!TOKEN) {
|
|
30
|
+
throw new Error("CHECKSTACK_SATELLITE_TOKEN environment variable is required");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Read version from package.json
|
|
34
|
+
const pkg = await import("../package.json");
|
|
35
|
+
const VERSION = (pkg as { version?: string }).version ?? "unknown";
|
|
36
|
+
|
|
37
|
+
// =============================================================================
|
|
38
|
+
// Logger
|
|
39
|
+
// =============================================================================
|
|
40
|
+
|
|
41
|
+
const logger = {
|
|
42
|
+
info: (msg: string) => console.log(`[satellite] ${msg}`),
|
|
43
|
+
warn: (msg: string) => console.warn(`[satellite] ${msg}`),
|
|
44
|
+
error: (msg: string) => console.error(`[satellite] ${msg}`),
|
|
45
|
+
debug: (msg: string) => {
|
|
46
|
+
if (process.env["DEBUG"]) {
|
|
47
|
+
console.log(`[satellite:debug] ${msg}`);
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// =============================================================================
|
|
53
|
+
// Strategy loading — dynamically discovers healthcheck-*-backend plugins
|
|
54
|
+
// =============================================================================
|
|
55
|
+
|
|
56
|
+
logger.info(`Starting Checkstack Satellite v${VERSION}`);
|
|
57
|
+
logger.info("Loading health check strategies...");
|
|
58
|
+
|
|
59
|
+
const { healthCheckRegistry, collectorRegistry } = await loadStrategies({
|
|
60
|
+
logger,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// =============================================================================
|
|
64
|
+
// Health check executor — mirrors core queue-executor pattern:
|
|
65
|
+
// 1. Look up strategy by ID
|
|
66
|
+
// 2. createClient(config) to establish connection + measure latency
|
|
67
|
+
// 3. Execute collectors against the connected client
|
|
68
|
+
// 4. Close client and report result
|
|
69
|
+
// =============================================================================
|
|
70
|
+
|
|
71
|
+
async function executeAssignment(
|
|
72
|
+
assignment: SatelliteAssignment,
|
|
73
|
+
): Promise<ResultMessage> {
|
|
74
|
+
const strategy = healthCheckRegistry.getStrategy(assignment.strategyId);
|
|
75
|
+
if (!strategy) {
|
|
76
|
+
return {
|
|
77
|
+
type: "result",
|
|
78
|
+
configId: assignment.configId,
|
|
79
|
+
systemId: assignment.systemId,
|
|
80
|
+
status: "unhealthy",
|
|
81
|
+
latencyMs: 0,
|
|
82
|
+
executedAt: new Date().toISOString(),
|
|
83
|
+
result: {
|
|
84
|
+
status: "unhealthy",
|
|
85
|
+
latencyMs: 0,
|
|
86
|
+
message: `Strategy ${assignment.strategyId} not found in satellite`,
|
|
87
|
+
metadata: {
|
|
88
|
+
connected: false,
|
|
89
|
+
error: `Strategy ${assignment.strategyId} not found in satellite`,
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const start = performance.now();
|
|
96
|
+
let connectedClient:
|
|
97
|
+
| ConnectedClient<TransportClient<never, unknown>>
|
|
98
|
+
| undefined;
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
// 1. Establish connection (measures connectivity + latency)
|
|
102
|
+
connectedClient = await strategy.createClient(assignment.config);
|
|
103
|
+
const connectionTimeMs = Math.round(performance.now() - start);
|
|
104
|
+
|
|
105
|
+
// 2. Execute collectors if configured
|
|
106
|
+
const collectors = assignment.collectors ?? [];
|
|
107
|
+
const collectorResults: Record<string, unknown> = {};
|
|
108
|
+
let hasCollectorError = false;
|
|
109
|
+
let errorMessage: string | undefined;
|
|
110
|
+
|
|
111
|
+
if (collectors.length > 0) {
|
|
112
|
+
const collectorPromises = collectors.map(async (collectorEntry) => {
|
|
113
|
+
const registered = collectorRegistry.getCollector(
|
|
114
|
+
collectorEntry.collectorId,
|
|
115
|
+
);
|
|
116
|
+
if (!registered) {
|
|
117
|
+
logger.warn(
|
|
118
|
+
`Collector ${collectorEntry.collectorId} not found, skipping`,
|
|
119
|
+
);
|
|
120
|
+
return { storageKey: collectorEntry.id, skipped: true };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const collectorResult = await registered.collector.execute({
|
|
125
|
+
config: collectorEntry.config,
|
|
126
|
+
client: connectedClient!.client,
|
|
127
|
+
pluginId: assignment.strategyId,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
storageKey: collectorEntry.id,
|
|
132
|
+
skipped: false,
|
|
133
|
+
success: !collectorResult.error,
|
|
134
|
+
error: collectorResult.error,
|
|
135
|
+
result: {
|
|
136
|
+
_collectorId: collectorEntry.collectorId,
|
|
137
|
+
...collectorResult.result,
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
} catch (error) {
|
|
141
|
+
return {
|
|
142
|
+
storageKey: collectorEntry.id,
|
|
143
|
+
skipped: false,
|
|
144
|
+
success: false,
|
|
145
|
+
error: String(error),
|
|
146
|
+
result: {
|
|
147
|
+
_collectorId: collectorEntry.collectorId,
|
|
148
|
+
error: String(error),
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const settledResults = await Promise.allSettled(collectorPromises);
|
|
155
|
+
|
|
156
|
+
for (const settled of settledResults) {
|
|
157
|
+
if (settled.status === "rejected") {
|
|
158
|
+
hasCollectorError = true;
|
|
159
|
+
if (!errorMessage) errorMessage = String(settled.reason);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const result = settled.value;
|
|
164
|
+
if (result.skipped) continue;
|
|
165
|
+
|
|
166
|
+
collectorResults[result.storageKey] = result.result;
|
|
167
|
+
|
|
168
|
+
if (!result.success) {
|
|
169
|
+
hasCollectorError = true;
|
|
170
|
+
if (!errorMessage) errorMessage = result.error;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const latencyMs = Math.round(performance.now() - start);
|
|
176
|
+
|
|
177
|
+
// 3. Build result — matches local queue-executor structure so
|
|
178
|
+
// frontend auto-charts and history detail page work identically.
|
|
179
|
+
const status = hasCollectorError ? "unhealthy" : "healthy";
|
|
180
|
+
const result: ResultMessage = {
|
|
181
|
+
type: "result",
|
|
182
|
+
configId: assignment.configId,
|
|
183
|
+
systemId: assignment.systemId,
|
|
184
|
+
status,
|
|
185
|
+
latencyMs,
|
|
186
|
+
executedAt: new Date().toISOString(),
|
|
187
|
+
result: {
|
|
188
|
+
status,
|
|
189
|
+
latencyMs,
|
|
190
|
+
message: errorMessage
|
|
191
|
+
? `Check failed: ${errorMessage}`
|
|
192
|
+
: `Completed in ${latencyMs}ms`,
|
|
193
|
+
metadata: {
|
|
194
|
+
connected: true,
|
|
195
|
+
connectionTimeMs,
|
|
196
|
+
collectors: collectorResults,
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
return result;
|
|
202
|
+
} catch (error) {
|
|
203
|
+
const latencyMs = Math.round(performance.now() - start);
|
|
204
|
+
return {
|
|
205
|
+
type: "result",
|
|
206
|
+
configId: assignment.configId,
|
|
207
|
+
systemId: assignment.systemId,
|
|
208
|
+
status: "unhealthy",
|
|
209
|
+
latencyMs,
|
|
210
|
+
executedAt: new Date().toISOString(),
|
|
211
|
+
result: {
|
|
212
|
+
status: "unhealthy",
|
|
213
|
+
latencyMs,
|
|
214
|
+
message: String(error),
|
|
215
|
+
metadata: {
|
|
216
|
+
connected: !!connectedClient,
|
|
217
|
+
error: String(error),
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
} finally {
|
|
222
|
+
try {
|
|
223
|
+
connectedClient?.close();
|
|
224
|
+
} catch {
|
|
225
|
+
// Ignore close errors
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// =============================================================================
|
|
231
|
+
// Bootstrap
|
|
232
|
+
// =============================================================================
|
|
233
|
+
|
|
234
|
+
logger.info(`Core URL: ${CORE_URL}`);
|
|
235
|
+
logger.info(`Client ID: ${CLIENT_ID}`);
|
|
236
|
+
|
|
237
|
+
const client = new SatelliteClient({
|
|
238
|
+
coreUrl: CORE_URL,
|
|
239
|
+
clientId: CLIENT_ID,
|
|
240
|
+
token: TOKEN,
|
|
241
|
+
version: VERSION,
|
|
242
|
+
logger,
|
|
243
|
+
onAssignments: (assignments: SatelliteAssignment[]) => {
|
|
244
|
+
scheduler.updateAssignments(assignments);
|
|
245
|
+
},
|
|
246
|
+
onDisconnect: () => {
|
|
247
|
+
scheduler.stop();
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const scheduler = new Scheduler({
|
|
252
|
+
logger,
|
|
253
|
+
onExecute: async (assignment: SatelliteAssignment) => {
|
|
254
|
+
try {
|
|
255
|
+
const result = await executeAssignment(assignment);
|
|
256
|
+
client.sendResult(result);
|
|
257
|
+
} catch (error) {
|
|
258
|
+
logger.error(
|
|
259
|
+
`Failed to execute ${assignment.configId}: ${String(error)}`,
|
|
260
|
+
);
|
|
261
|
+
client.sendResult({
|
|
262
|
+
type: "result",
|
|
263
|
+
configId: assignment.configId,
|
|
264
|
+
systemId: assignment.systemId,
|
|
265
|
+
status: "unhealthy",
|
|
266
|
+
latencyMs: 0,
|
|
267
|
+
executedAt: new Date().toISOString(),
|
|
268
|
+
result: {
|
|
269
|
+
status: "unhealthy",
|
|
270
|
+
latencyMs: 0,
|
|
271
|
+
message: String(error),
|
|
272
|
+
metadata: {
|
|
273
|
+
connected: false,
|
|
274
|
+
error: String(error),
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// Start the connection
|
|
283
|
+
void client.connect();
|
|
284
|
+
|
|
285
|
+
// Graceful shutdown
|
|
286
|
+
process.on("SIGTERM", () => {
|
|
287
|
+
logger.info("Received SIGTERM, shutting down...");
|
|
288
|
+
scheduler.stop();
|
|
289
|
+
client.disconnect();
|
|
290
|
+
process.exit(0);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
process.on("SIGINT", () => {
|
|
294
|
+
logger.info("Received SIGINT, shutting down...");
|
|
295
|
+
scheduler.stop();
|
|
296
|
+
client.disconnect();
|
|
297
|
+
process.exit(0);
|
|
298
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { ResultBuffer } from "./result-buffer";
|
|
3
|
+
import type { ResultMessage } from "@checkstack/satellite-common";
|
|
4
|
+
|
|
5
|
+
function makeResult(overrides?: Partial<ResultMessage>): ResultMessage {
|
|
6
|
+
return {
|
|
7
|
+
type: "result",
|
|
8
|
+
configId: "config-1",
|
|
9
|
+
systemId: "system-1",
|
|
10
|
+
status: "healthy",
|
|
11
|
+
latencyMs: 42,
|
|
12
|
+
executedAt: new Date().toISOString(),
|
|
13
|
+
...overrides,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("ResultBuffer", () => {
|
|
18
|
+
test("starts empty", () => {
|
|
19
|
+
const buffer = new ResultBuffer();
|
|
20
|
+
expect(buffer.size).toBe(0);
|
|
21
|
+
expect(buffer.isEmpty).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("push adds results", () => {
|
|
25
|
+
const buffer = new ResultBuffer();
|
|
26
|
+
buffer.push(makeResult());
|
|
27
|
+
expect(buffer.size).toBe(1);
|
|
28
|
+
expect(buffer.isEmpty).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("flush returns all results and clears buffer", () => {
|
|
32
|
+
const buffer = new ResultBuffer();
|
|
33
|
+
const r1 = makeResult({ configId: "a" });
|
|
34
|
+
const r2 = makeResult({ configId: "b" });
|
|
35
|
+
buffer.push(r1);
|
|
36
|
+
buffer.push(r2);
|
|
37
|
+
|
|
38
|
+
const flushed = buffer.flush();
|
|
39
|
+
expect(flushed).toHaveLength(2);
|
|
40
|
+
expect(flushed[0].configId).toBe("a");
|
|
41
|
+
expect(flushed[1].configId).toBe("b");
|
|
42
|
+
expect(buffer.size).toBe(0);
|
|
43
|
+
expect(buffer.isEmpty).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("flush returns empty array when buffer is empty", () => {
|
|
47
|
+
const buffer = new ResultBuffer();
|
|
48
|
+
expect(buffer.flush()).toEqual([]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("drops oldest when capacity is exceeded", () => {
|
|
52
|
+
const buffer = new ResultBuffer(3);
|
|
53
|
+
buffer.push(makeResult({ configId: "1" }));
|
|
54
|
+
buffer.push(makeResult({ configId: "2" }));
|
|
55
|
+
buffer.push(makeResult({ configId: "3" }));
|
|
56
|
+
|
|
57
|
+
// Buffer is full, next push should drop "1"
|
|
58
|
+
buffer.push(makeResult({ configId: "4" }));
|
|
59
|
+
expect(buffer.size).toBe(3);
|
|
60
|
+
|
|
61
|
+
const flushed = buffer.flush();
|
|
62
|
+
expect(flushed.map((r) => r.configId)).toEqual(["2", "3", "4"]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("respects custom capacity", () => {
|
|
66
|
+
const buffer = new ResultBuffer(1);
|
|
67
|
+
buffer.push(makeResult({ configId: "first" }));
|
|
68
|
+
buffer.push(makeResult({ configId: "second" }));
|
|
69
|
+
|
|
70
|
+
expect(buffer.size).toBe(1);
|
|
71
|
+
const flushed = buffer.flush();
|
|
72
|
+
expect(flushed[0].configId).toBe("second");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { RESULT_BUFFER_CAPACITY } from "@checkstack/satellite-common";
|
|
2
|
+
import type { ResultMessage } from "@checkstack/satellite-common";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* In-memory FIFO ring buffer for health check results.
|
|
6
|
+
* When the WebSocket connection is lost, results are queued here.
|
|
7
|
+
* On reconnection, all buffered results are flushed to the core.
|
|
8
|
+
* Oldest results are dropped when the buffer is full.
|
|
9
|
+
*/
|
|
10
|
+
export class ResultBuffer {
|
|
11
|
+
private buffer: ResultMessage[] = [];
|
|
12
|
+
private readonly capacity: number;
|
|
13
|
+
|
|
14
|
+
constructor(capacity = RESULT_BUFFER_CAPACITY) {
|
|
15
|
+
this.capacity = capacity;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Push a result into the buffer.
|
|
20
|
+
* If the buffer is full, the oldest result is dropped.
|
|
21
|
+
*/
|
|
22
|
+
push(result: ResultMessage): void {
|
|
23
|
+
if (this.buffer.length >= this.capacity) {
|
|
24
|
+
// Drop oldest (FIFO)
|
|
25
|
+
this.buffer.shift();
|
|
26
|
+
}
|
|
27
|
+
this.buffer.push(result);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Flush all buffered results and clear the buffer.
|
|
32
|
+
* Returns the results in chronological order (oldest first).
|
|
33
|
+
*/
|
|
34
|
+
flush(): ResultMessage[] {
|
|
35
|
+
const results = [...this.buffer];
|
|
36
|
+
this.buffer = [];
|
|
37
|
+
return results;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Current number of buffered results */
|
|
41
|
+
get size(): number {
|
|
42
|
+
return this.buffer.length;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Whether the buffer is empty */
|
|
46
|
+
get isEmpty(): boolean {
|
|
47
|
+
return this.buffer.length === 0;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import {
|
|
2
|
+
HEARTBEAT_INTERVAL_MS,
|
|
3
|
+
RECONNECT_BASE_MS,
|
|
4
|
+
RECONNECT_MAX_MS,
|
|
5
|
+
} from "@checkstack/satellite-common";
|
|
6
|
+
import type {
|
|
7
|
+
SatelliteAssignment,
|
|
8
|
+
CoreToSatelliteMessage,
|
|
9
|
+
SatelliteToCoreMessage,
|
|
10
|
+
ResultMessage,
|
|
11
|
+
} from "@checkstack/satellite-common";
|
|
12
|
+
import { ResultBuffer } from "./result-buffer";
|
|
13
|
+
|
|
14
|
+
interface SatelliteClientConfig {
|
|
15
|
+
coreUrl: string;
|
|
16
|
+
clientId: string;
|
|
17
|
+
token: string;
|
|
18
|
+
version: string;
|
|
19
|
+
onAssignments: (assignments: SatelliteAssignment[]) => void;
|
|
20
|
+
onDisconnect?: () => void;
|
|
21
|
+
logger?: {
|
|
22
|
+
info: (msg: string) => void;
|
|
23
|
+
warn: (msg: string) => void;
|
|
24
|
+
error: (msg: string) => void;
|
|
25
|
+
debug: (msg: string) => void;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* WebSocket client for connecting a satellite to the core.
|
|
31
|
+
* Handles authentication, heartbeats, result delivery, and reconnection.
|
|
32
|
+
*/
|
|
33
|
+
export class SatelliteClient {
|
|
34
|
+
private ws: WebSocket | undefined;
|
|
35
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | undefined;
|
|
36
|
+
private reconnectAttempt = 0;
|
|
37
|
+
private startTime = Date.now();
|
|
38
|
+
private connected = false;
|
|
39
|
+
private readonly resultBuffer = new ResultBuffer();
|
|
40
|
+
private readonly config: SatelliteClientConfig;
|
|
41
|
+
|
|
42
|
+
constructor(config: SatelliteClientConfig) {
|
|
43
|
+
this.config = config;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Start the connection loop. Connects and automatically reconnects on failure.
|
|
48
|
+
*/
|
|
49
|
+
async connect(): Promise<void> {
|
|
50
|
+
const wsUrl = this.buildWsUrl();
|
|
51
|
+
this.config.logger?.info(`Connecting to core at ${wsUrl}...`);
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
this.ws = new WebSocket(wsUrl);
|
|
55
|
+
|
|
56
|
+
this.ws.addEventListener("open", () => {
|
|
57
|
+
this.config.logger?.info("WebSocket connection established");
|
|
58
|
+
this.sendMessage({
|
|
59
|
+
type: "authenticate",
|
|
60
|
+
clientId: this.config.clientId,
|
|
61
|
+
token: this.config.token,
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
this.ws.addEventListener("message", (event) => {
|
|
66
|
+
this.handleMessage(String(event.data));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
this.ws.addEventListener("close", (event) => {
|
|
70
|
+
this.config.logger?.warn(
|
|
71
|
+
`WebSocket closed: ${event.code} ${event.reason}`,
|
|
72
|
+
);
|
|
73
|
+
this.handleDisconnect();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
this.ws.addEventListener("error", () => {
|
|
77
|
+
this.config.logger?.error("WebSocket error");
|
|
78
|
+
});
|
|
79
|
+
} catch (error) {
|
|
80
|
+
this.config.logger?.error(`Connection failed: ${String(error)}`);
|
|
81
|
+
this.scheduleReconnect();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Send a health check result to the core.
|
|
87
|
+
* If disconnected, the result is buffered for later delivery.
|
|
88
|
+
*/
|
|
89
|
+
sendResult(result: ResultMessage): void {
|
|
90
|
+
if (this.connected && this.ws?.readyState === WebSocket.OPEN) {
|
|
91
|
+
this.sendMessage(result);
|
|
92
|
+
} else {
|
|
93
|
+
this.resultBuffer.push(result);
|
|
94
|
+
this.config.logger?.debug(
|
|
95
|
+
`Buffered result (${this.resultBuffer.size} total)`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Gracefully disconnect */
|
|
101
|
+
disconnect(): void {
|
|
102
|
+
this.stopHeartbeat();
|
|
103
|
+
this.connected = false;
|
|
104
|
+
this.ws?.close(1000, "Client shutdown");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private handleMessage(raw: string): void {
|
|
108
|
+
let msg: CoreToSatelliteMessage;
|
|
109
|
+
try {
|
|
110
|
+
msg = JSON.parse(raw) as CoreToSatelliteMessage;
|
|
111
|
+
} catch {
|
|
112
|
+
this.config.logger?.warn(`Invalid message from core: ${raw}`);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
switch (msg.type) {
|
|
117
|
+
case "authenticated": {
|
|
118
|
+
this.config.logger?.info(
|
|
119
|
+
`Authenticated as ${msg.satelliteId}, received ${msg.assignments.length} assignments`,
|
|
120
|
+
);
|
|
121
|
+
this.connected = true;
|
|
122
|
+
this.reconnectAttempt = 0;
|
|
123
|
+
this.startHeartbeat();
|
|
124
|
+
this.flushBuffer();
|
|
125
|
+
this.config.onAssignments(msg.assignments);
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
case "auth_failed": {
|
|
130
|
+
this.config.logger?.error(`Authentication failed: ${msg.reason}`);
|
|
131
|
+
// Don't reconnect on auth failure — credentials are wrong
|
|
132
|
+
this.ws?.close(4001, "Auth failed");
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
case "config_updated": {
|
|
137
|
+
this.config.logger?.info(
|
|
138
|
+
`Config updated: ${msg.assignments.length} assignments`,
|
|
139
|
+
);
|
|
140
|
+
this.config.onAssignments(msg.assignments);
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
case "shutdown": {
|
|
145
|
+
this.config.logger?.warn(`Shutdown requested: ${msg.reason}`);
|
|
146
|
+
this.disconnect();
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private handleDisconnect(): void {
|
|
153
|
+
this.connected = false;
|
|
154
|
+
this.stopHeartbeat();
|
|
155
|
+
this.config.onDisconnect?.();
|
|
156
|
+
this.scheduleReconnect();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private scheduleReconnect(): void {
|
|
160
|
+
const delay = this.calculateBackoff();
|
|
161
|
+
this.reconnectAttempt++;
|
|
162
|
+
this.config.logger?.info(
|
|
163
|
+
`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempt})...`,
|
|
164
|
+
);
|
|
165
|
+
setTimeout(() => void this.connect(), delay);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Exponential backoff with jitter.
|
|
170
|
+
* Delay = min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2^attempt) ± jitter
|
|
171
|
+
*/
|
|
172
|
+
private calculateBackoff(): number {
|
|
173
|
+
const exponential = RECONNECT_BASE_MS * 2 ** this.reconnectAttempt;
|
|
174
|
+
const capped = Math.min(exponential, RECONNECT_MAX_MS);
|
|
175
|
+
// Add ±25% jitter
|
|
176
|
+
const jitter = capped * 0.25 * (Math.random() * 2 - 1);
|
|
177
|
+
return Math.round(capped + jitter);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private startHeartbeat(): void {
|
|
181
|
+
this.stopHeartbeat();
|
|
182
|
+
this.heartbeatTimer = setInterval(() => {
|
|
183
|
+
if (this.connected && this.ws?.readyState === WebSocket.OPEN) {
|
|
184
|
+
this.sendMessage({
|
|
185
|
+
type: "heartbeat",
|
|
186
|
+
version: this.config.version,
|
|
187
|
+
uptimeSeconds: Math.round((Date.now() - this.startTime) / 1000),
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private stopHeartbeat(): void {
|
|
194
|
+
if (this.heartbeatTimer) {
|
|
195
|
+
clearInterval(this.heartbeatTimer);
|
|
196
|
+
this.heartbeatTimer = undefined;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private flushBuffer(): void {
|
|
201
|
+
if (this.resultBuffer.isEmpty) return;
|
|
202
|
+
|
|
203
|
+
const buffered = this.resultBuffer.flush();
|
|
204
|
+
this.config.logger?.info(
|
|
205
|
+
`Flushing ${buffered.length} buffered results to core`,
|
|
206
|
+
);
|
|
207
|
+
for (const result of buffered) {
|
|
208
|
+
this.sendMessage(result);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private sendMessage(msg: SatelliteToCoreMessage): void {
|
|
213
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
214
|
+
this.ws.send(JSON.stringify(msg));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private buildWsUrl(): string {
|
|
219
|
+
const base = this.config.coreUrl
|
|
220
|
+
.replace(/^http:/, "ws:")
|
|
221
|
+
.replace(/^https:/, "wss:");
|
|
222
|
+
return `${base.replace(/\/$/, "")}/api/ws/satellite`;
|
|
223
|
+
}
|
|
224
|
+
}
|
package/src/scheduler.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { SatelliteAssignment } from "@checkstack/satellite-common";
|
|
2
|
+
|
|
3
|
+
interface SchedulerConfig {
|
|
4
|
+
onExecute: (assignment: SatelliteAssignment) => Promise<void>;
|
|
5
|
+
logger?: {
|
|
6
|
+
info: (msg: string) => void;
|
|
7
|
+
debug: (msg: string) => void;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Local scheduler for satellite health check execution.
|
|
13
|
+
* Manages interval timers for each assignment. When assignments are updated,
|
|
14
|
+
* existing timers are reconciled: unchanged assignments keep running,
|
|
15
|
+
* removed assignments are stopped, and new assignments are started.
|
|
16
|
+
*/
|
|
17
|
+
export class Scheduler {
|
|
18
|
+
private timers = new Map<string, ReturnType<typeof setInterval>>();
|
|
19
|
+
private readonly config: SchedulerConfig;
|
|
20
|
+
|
|
21
|
+
constructor(config: SchedulerConfig) {
|
|
22
|
+
this.config = config;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Update the set of assignments. Reconciles timers with the new set.
|
|
27
|
+
*/
|
|
28
|
+
updateAssignments(assignments: SatelliteAssignment[]): void {
|
|
29
|
+
// Build a set of current assignment keys
|
|
30
|
+
const newKeys = new Set(
|
|
31
|
+
assignments.map((a) => this.makeKey(a)),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
// Stop timers for removed assignments
|
|
35
|
+
for (const [key, timer] of this.timers) {
|
|
36
|
+
if (!newKeys.has(key)) {
|
|
37
|
+
clearInterval(timer);
|
|
38
|
+
this.timers.delete(key);
|
|
39
|
+
this.config.logger?.debug(`Stopped scheduler for ${key}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Start timers for new/updated assignments
|
|
44
|
+
for (const assignment of assignments) {
|
|
45
|
+
const key = this.makeKey(assignment);
|
|
46
|
+
if (this.timers.has(key)) {
|
|
47
|
+
// Already running — keep existing timer
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
this.config.logger?.debug(
|
|
52
|
+
`Starting scheduler for ${key} (every ${assignment.intervalSeconds}s)`,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
// Execute immediately, then at interval
|
|
56
|
+
void this.config.onExecute(assignment);
|
|
57
|
+
|
|
58
|
+
const timer = setInterval(
|
|
59
|
+
() => void this.config.onExecute(assignment),
|
|
60
|
+
assignment.intervalSeconds * 1000,
|
|
61
|
+
);
|
|
62
|
+
this.timers.set(key, timer);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
this.config.logger?.info(
|
|
66
|
+
`Scheduler updated: ${this.timers.size} active check(s)`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Stop all timers */
|
|
71
|
+
stop(): void {
|
|
72
|
+
for (const [key, timer] of this.timers) {
|
|
73
|
+
clearInterval(timer);
|
|
74
|
+
this.config.logger?.debug(`Stopped scheduler for ${key}`);
|
|
75
|
+
}
|
|
76
|
+
this.timers.clear();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Number of active timers */
|
|
80
|
+
get activeCount(): number {
|
|
81
|
+
return this.timers.size;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Unique key for an assignment (config+system, since a satellite only runs each once) */
|
|
85
|
+
private makeKey(assignment: SatelliteAssignment): string {
|
|
86
|
+
return `${assignment.configId}:${assignment.systemId}`;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
HealthCheckStrategy,
|
|
3
|
+
HealthCheckRegistry,
|
|
4
|
+
CollectorRegistry,
|
|
5
|
+
RegisteredCollector,
|
|
6
|
+
CollectorStrategy,
|
|
7
|
+
TransportClient,
|
|
8
|
+
BackendPluginRegistry,
|
|
9
|
+
} from "@checkstack/backend-api";
|
|
10
|
+
import type { PluginMetadata } from "@checkstack/common";
|
|
11
|
+
import { readdir } from "node:fs/promises";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Simple in-memory HealthCheckRegistry for the satellite.
|
|
16
|
+
* Captures strategy registrations from plugins without needing the full backend.
|
|
17
|
+
*/
|
|
18
|
+
class SatelliteHealthCheckRegistry implements HealthCheckRegistry {
|
|
19
|
+
private strategies = new Map<string, HealthCheckStrategy>();
|
|
20
|
+
private pluginId = "";
|
|
21
|
+
|
|
22
|
+
setPluginId(id: string) {
|
|
23
|
+
this.pluginId = id;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
register<S extends HealthCheckStrategy>(strategy: S): void {
|
|
27
|
+
const qualifiedId = `${this.pluginId}.${strategy.id}`;
|
|
28
|
+
this.strategies.set(qualifiedId, strategy);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
getStrategy(id: string): HealthCheckStrategy | undefined {
|
|
32
|
+
return this.strategies.get(id);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
getStrategies(): HealthCheckStrategy[] {
|
|
36
|
+
return [...this.strategies.values()];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
getStrategiesWithMeta() {
|
|
40
|
+
return [...this.strategies.entries()].map(([qualifiedId, strategy]) => ({
|
|
41
|
+
qualifiedId,
|
|
42
|
+
strategy,
|
|
43
|
+
ownerPluginId: qualifiedId.split(".")[0] ?? qualifiedId,
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Simple in-memory CollectorRegistry for the satellite.
|
|
50
|
+
*/
|
|
51
|
+
class SatelliteCollectorRegistry implements CollectorRegistry {
|
|
52
|
+
private collectors = new Map<string, RegisteredCollector>();
|
|
53
|
+
private pluginMeta: PluginMetadata = { pluginId: "" };
|
|
54
|
+
|
|
55
|
+
setPlugin(meta: PluginMetadata) {
|
|
56
|
+
this.pluginMeta = meta;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
register(
|
|
60
|
+
collector: CollectorStrategy<TransportClient<unknown, unknown>>,
|
|
61
|
+
): void {
|
|
62
|
+
const qualifiedId = `${this.pluginMeta.pluginId}.${collector.id}`;
|
|
63
|
+
this.collectors.set(qualifiedId, {
|
|
64
|
+
qualifiedId,
|
|
65
|
+
collector,
|
|
66
|
+
ownerPlugin: this.pluginMeta,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
getCollector(id: string): RegisteredCollector | undefined {
|
|
71
|
+
return this.collectors.get(id);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
getCollectorsForPlugin(meta: PluginMetadata): RegisteredCollector[] {
|
|
75
|
+
return [...this.collectors.values()].filter(
|
|
76
|
+
(c) => c.ownerPlugin.pluginId === meta.pluginId,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
getCollectors(): RegisteredCollector[] {
|
|
81
|
+
return [...this.collectors.values()];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Discovers and loads all healthcheck strategy plugins from the plugins directory.
|
|
87
|
+
* Returns in-memory registries populated with the strategies and collectors.
|
|
88
|
+
*/
|
|
89
|
+
export async function loadStrategies(opts: {
|
|
90
|
+
logger: { info: (msg: string) => void; debug: (msg: string) => void; warn: (msg: string) => void };
|
|
91
|
+
}): Promise<{
|
|
92
|
+
healthCheckRegistry: SatelliteHealthCheckRegistry;
|
|
93
|
+
collectorRegistry: SatelliteCollectorRegistry;
|
|
94
|
+
}> {
|
|
95
|
+
const { logger } = opts;
|
|
96
|
+
const healthCheckRegistry = new SatelliteHealthCheckRegistry();
|
|
97
|
+
const collectorRegistry = new SatelliteCollectorRegistry();
|
|
98
|
+
|
|
99
|
+
// Discover plugins by scanning for healthcheck-*-backend directories
|
|
100
|
+
// In Docker, plugins are at /app/plugins/; locally, relative to this file
|
|
101
|
+
const pluginsDir = path.resolve(import.meta.dir, "../../../plugins");
|
|
102
|
+
let entries: string[];
|
|
103
|
+
try {
|
|
104
|
+
entries = await readdir(pluginsDir);
|
|
105
|
+
} catch {
|
|
106
|
+
// Try Docker path
|
|
107
|
+
try {
|
|
108
|
+
const dockerPluginsDir = "/app/plugins";
|
|
109
|
+
entries = await readdir(dockerPluginsDir);
|
|
110
|
+
return loadPluginsFromDir({
|
|
111
|
+
pluginsDir: dockerPluginsDir,
|
|
112
|
+
entries,
|
|
113
|
+
healthCheckRegistry,
|
|
114
|
+
collectorRegistry,
|
|
115
|
+
logger,
|
|
116
|
+
});
|
|
117
|
+
} catch {
|
|
118
|
+
logger.warn("No plugins directory found, no strategies loaded");
|
|
119
|
+
return { healthCheckRegistry, collectorRegistry };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return loadPluginsFromDir({
|
|
124
|
+
pluginsDir,
|
|
125
|
+
entries,
|
|
126
|
+
healthCheckRegistry,
|
|
127
|
+
collectorRegistry,
|
|
128
|
+
logger,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function loadPluginsFromDir(opts: {
|
|
133
|
+
pluginsDir: string;
|
|
134
|
+
entries: string[];
|
|
135
|
+
healthCheckRegistry: SatelliteHealthCheckRegistry;
|
|
136
|
+
collectorRegistry: SatelliteCollectorRegistry;
|
|
137
|
+
logger: { info: (msg: string) => void; debug: (msg: string) => void; warn: (msg: string) => void };
|
|
138
|
+
}): Promise<{
|
|
139
|
+
healthCheckRegistry: SatelliteHealthCheckRegistry;
|
|
140
|
+
collectorRegistry: SatelliteCollectorRegistry;
|
|
141
|
+
}> {
|
|
142
|
+
const { pluginsDir, entries, healthCheckRegistry, collectorRegistry, logger } = opts;
|
|
143
|
+
|
|
144
|
+
// Include both healthcheck strategy plugins and standalone collector plugins
|
|
145
|
+
const pluginDirs = entries.filter(
|
|
146
|
+
(e) =>
|
|
147
|
+
(e.startsWith("healthcheck-") && e.endsWith("-backend")) ||
|
|
148
|
+
(e.startsWith("collector-") && e.endsWith("-backend")),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
logger.info(`Discovered ${pluginDirs.length} strategy plugins`);
|
|
152
|
+
|
|
153
|
+
for (const dir of pluginDirs) {
|
|
154
|
+
try {
|
|
155
|
+
const pluginPath = path.join(pluginsDir, dir, "src", "index.ts");
|
|
156
|
+
const mod = await import(pluginPath);
|
|
157
|
+
const plugin = mod.default;
|
|
158
|
+
|
|
159
|
+
if (!plugin?.metadata?.pluginId || !plugin?.register) {
|
|
160
|
+
logger.warn(`Plugin ${dir} has no valid default export, skipping`);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const pluginId = plugin.metadata.pluginId as string;
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
// Create a mock env that captures registrations
|
|
169
|
+
healthCheckRegistry.setPluginId(pluginId);
|
|
170
|
+
collectorRegistry.setPlugin(plugin.metadata as PluginMetadata);
|
|
171
|
+
|
|
172
|
+
const mockEnv: Partial<BackendPluginRegistry> = {
|
|
173
|
+
registerInit: ({ init }) => {
|
|
174
|
+
// We'll call init synchronously after register
|
|
175
|
+
// Store it for deferred execution
|
|
176
|
+
(mockEnv as Record<string, unknown>)._init = init;
|
|
177
|
+
},
|
|
178
|
+
registerAccessRules: () => {
|
|
179
|
+
// No-op in satellite
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
plugin.register(mockEnv);
|
|
184
|
+
|
|
185
|
+
// Call the stored init function with our registries
|
|
186
|
+
const storedInit = (mockEnv as Record<string, unknown>)._init as (
|
|
187
|
+
deps: Record<string, unknown>,
|
|
188
|
+
) => Promise<void>;
|
|
189
|
+
|
|
190
|
+
if (storedInit) {
|
|
191
|
+
await storedInit({
|
|
192
|
+
healthCheckRegistry,
|
|
193
|
+
collectorRegistry,
|
|
194
|
+
logger: {
|
|
195
|
+
debug: (msg: string) => logger.debug(`[${pluginId}] ${msg}`),
|
|
196
|
+
info: (msg: string) => logger.info(`[${pluginId}] ${msg}`),
|
|
197
|
+
warn: (msg: string) => logger.warn(`[${pluginId}] ${msg}`),
|
|
198
|
+
error: (msg: string) => logger.warn(`[${pluginId}] ${msg}`),
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
logger.info(`✓ Loaded strategy plugin: ${pluginId}`);
|
|
204
|
+
} catch (error) {
|
|
205
|
+
logger.warn(`Failed to load plugin ${dir}: ${String(error)}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
logger.info(
|
|
210
|
+
`Loaded ${healthCheckRegistry.getStrategies().length} strategies, ` +
|
|
211
|
+
`${collectorRegistry.getCollectors().length} collectors`,
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
return { healthCheckRegistry, collectorRegistry };
|
|
215
|
+
}
|