@checkstack/satellite-backend 0.4.0 → 0.5.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 +153 -0
- package/drizzle/0001_tiresome_terror.sql +3 -0
- package/drizzle/0002_graceful_mac_gargan.sql +2 -0
- package/drizzle/meta/0001_snapshot.json +102 -0
- package/drizzle/meta/0002_snapshot.json +89 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +20 -13
- package/src/automations.ts +65 -24
- package/src/entity.test.ts +313 -0
- package/src/entity.ts +221 -0
- package/src/heartbeat-monitor.it.test.ts +232 -0
- package/src/heartbeat-monitor.test.ts +156 -83
- package/src/heartbeat-monitor.ts +102 -71
- package/src/hooks.ts +9 -39
- package/src/index.ts +168 -9
- package/src/run-secret-resolver.test.ts +121 -0
- package/src/run-secret-resolver.ts +66 -0
- package/src/satellite-ws-handler.test.ts +267 -0
- package/src/satellite-ws-handler.ts +242 -49
- package/src/schema.ts +22 -1
- package/src/service.test.ts +274 -0
- package/src/service.ts +133 -15
- package/src/status.ts +18 -0
- package/tsconfig.json +15 -0
- package/src/automations.test.ts +0 -54
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { secretEnvMappingSchema } from "@checkstack/secrets-common";
|
|
2
|
+
import type { SecretResolverService } from "@checkstack/secrets-backend";
|
|
3
|
+
import type { SatelliteAssignment } from "@checkstack/satellite-common";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolve a satellite collector run's secrets just-in-time.
|
|
7
|
+
*
|
|
8
|
+
* Security model (least-privilege, decision 5): the satellite asks by
|
|
9
|
+
* `configId` + `collectorId` only. Core reads the `secretEnv` mapping from
|
|
10
|
+
* the satellite's OWN persisted assignment for that collector — the
|
|
11
|
+
* satellite does not get to choose which secrets — and resolves ONLY those
|
|
12
|
+
* refs via the central resolver. So a compromised satellite cannot request
|
|
13
|
+
* arbitrary secrets; it can only obtain what its assignment already
|
|
14
|
+
* declares it needs.
|
|
15
|
+
*
|
|
16
|
+
* Returns the resolved env map. Throws a clear error when the collector
|
|
17
|
+
* isn't in the satellite's assignments, when the collector declares no
|
|
18
|
+
* `secretEnv` (nothing to resolve — caller should not have asked), or when
|
|
19
|
+
* a referenced secret can't be resolved. The values are never persisted.
|
|
20
|
+
*/
|
|
21
|
+
export async function resolveSatelliteRunSecrets({
|
|
22
|
+
satelliteId,
|
|
23
|
+
configId,
|
|
24
|
+
collectorId,
|
|
25
|
+
getAssignmentsForSatellite,
|
|
26
|
+
resolver,
|
|
27
|
+
}: {
|
|
28
|
+
satelliteId: string;
|
|
29
|
+
configId: string;
|
|
30
|
+
collectorId: string;
|
|
31
|
+
getAssignmentsForSatellite: (
|
|
32
|
+
satelliteId: string,
|
|
33
|
+
) => Promise<SatelliteAssignment[]>;
|
|
34
|
+
resolver: SecretResolverService;
|
|
35
|
+
}): Promise<Record<string, string>> {
|
|
36
|
+
const assignments = await getAssignmentsForSatellite(satelliteId);
|
|
37
|
+
const assignment = assignments.find((a) => a.configId === configId);
|
|
38
|
+
if (!assignment) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`No assignment "${configId}" for this satellite; cannot deliver secrets.`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const collector = (assignment.collectors ?? []).find(
|
|
45
|
+
(c) => c.id === collectorId || c.collectorId === collectorId,
|
|
46
|
+
);
|
|
47
|
+
if (!collector) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`Collector "${collectorId}" not found in assignment "${configId}".`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The declared mapping lives inside the collector's config. Validate it so
|
|
54
|
+
// a malformed config can't smuggle non-template values through.
|
|
55
|
+
const parsed = secretEnvMappingSchema.safeParse(
|
|
56
|
+
(collector.config as { secretEnv?: unknown }).secretEnv,
|
|
57
|
+
);
|
|
58
|
+
if (!parsed.success || Object.keys(parsed.data).length === 0) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Collector "${collectorId}" declares no secretEnv; nothing to resolve.`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const { env } = await resolver.resolveForRun({ secretEnv: parsed.data });
|
|
65
|
+
return env;
|
|
66
|
+
}
|
|
@@ -2,10 +2,12 @@ import { describe, it, expect, mock, beforeEach } from "bun:test";
|
|
|
2
2
|
import {
|
|
3
3
|
SatelliteWsHandler,
|
|
4
4
|
type SatelliteResultHandler,
|
|
5
|
+
type SatelliteScriptPackageSink,
|
|
5
6
|
} from "./satellite-ws-handler";
|
|
6
7
|
import { createMockLogger } from "@checkstack/test-utils-backend";
|
|
7
8
|
import type { SatelliteService } from "./service";
|
|
8
9
|
import type { ConfigRelay } from "./config-relay";
|
|
10
|
+
import type { SatelliteConnectionEvent } from "./entity";
|
|
9
11
|
import type { SatelliteWithStatus } from "@checkstack/satellite-common";
|
|
10
12
|
|
|
11
13
|
const MOCK_SATELLITE: SatelliteWithStatus = {
|
|
@@ -262,4 +264,269 @@ describe("SatelliteWsHandler", () => {
|
|
|
262
264
|
await handler.pushConfigUpdate("non-existent");
|
|
263
265
|
});
|
|
264
266
|
});
|
|
267
|
+
|
|
268
|
+
describe("script-package distribution", () => {
|
|
269
|
+
function makeSink(
|
|
270
|
+
lockfileHash: string | null,
|
|
271
|
+
): {
|
|
272
|
+
sink: SatelliteScriptPackageSink;
|
|
273
|
+
reports: Parameters<SatelliteScriptPackageSink["reportSyncState"]>[0][];
|
|
274
|
+
} {
|
|
275
|
+
const reports: Parameters<
|
|
276
|
+
SatelliteScriptPackageSink["reportSyncState"]
|
|
277
|
+
>[0][] = [];
|
|
278
|
+
return {
|
|
279
|
+
reports,
|
|
280
|
+
sink: {
|
|
281
|
+
getDesiredLockfileHash: mock(async () => lockfileHash),
|
|
282
|
+
reportSyncState: mock(async (input) => {
|
|
283
|
+
reports.push(input);
|
|
284
|
+
}),
|
|
285
|
+
getManifest: mock(async () => [
|
|
286
|
+
{ name: "leftpad", version: "0.0.1", integrity: "sha-1" },
|
|
287
|
+
]),
|
|
288
|
+
getBlobBase64: mock(async () => "YmxvYg=="),
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function authedHandlerWithSink(lockfileHash: string | null) {
|
|
294
|
+
const { sink, reports } = makeSink(lockfileHash);
|
|
295
|
+
const h = new SatelliteWsHandler(
|
|
296
|
+
service,
|
|
297
|
+
configRelay,
|
|
298
|
+
resultHandler,
|
|
299
|
+
logger,
|
|
300
|
+
undefined,
|
|
301
|
+
sink,
|
|
302
|
+
);
|
|
303
|
+
const ws = createMockWs();
|
|
304
|
+
const { onMessage } = h.onConnection(ws);
|
|
305
|
+
await onMessage(
|
|
306
|
+
JSON.stringify({
|
|
307
|
+
type: "authenticate",
|
|
308
|
+
clientId: "sat-1",
|
|
309
|
+
token: "csat_valid-token",
|
|
310
|
+
}),
|
|
311
|
+
);
|
|
312
|
+
return { h, ws, onMessage, reports };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
it("carries the desired lockfile hash in the authenticated payload", async () => {
|
|
316
|
+
const { ws } = await authedHandlerWithSink("hash-123");
|
|
317
|
+
const auth = JSON.parse(ws.messages[0]);
|
|
318
|
+
expect(auth.type).toBe("authenticated");
|
|
319
|
+
expect(auth.scriptPackagesLockfileHash).toBe("hash-123");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("omits the hash entirely when no sink is wired (version-skew safe)", async () => {
|
|
323
|
+
// Default `handler` has no script-package sink.
|
|
324
|
+
const ws = createMockWs();
|
|
325
|
+
const { onMessage } = handler.onConnection(ws);
|
|
326
|
+
await onMessage(
|
|
327
|
+
JSON.stringify({
|
|
328
|
+
type: "authenticate",
|
|
329
|
+
clientId: "sat-1",
|
|
330
|
+
token: "csat_valid-token",
|
|
331
|
+
}),
|
|
332
|
+
);
|
|
333
|
+
const auth = JSON.parse(ws.messages[0]);
|
|
334
|
+
expect("scriptPackagesLockfileHash" in auth).toBe(false);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("fans refresh_script_packages out to every connected satellite", async () => {
|
|
338
|
+
const { h, ws } = await authedHandlerWithSink("hash-123");
|
|
339
|
+
ws.messages.length = 0;
|
|
340
|
+
|
|
341
|
+
h.pushRefreshScriptPackagesToAll("hash-456");
|
|
342
|
+
|
|
343
|
+
expect(ws.messages).toHaveLength(1);
|
|
344
|
+
const msg = JSON.parse(ws.messages[0]);
|
|
345
|
+
expect(msg.type).toBe("refresh_script_packages");
|
|
346
|
+
expect(msg.lockfileHash).toBe("hash-456");
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("persists a satellite's reported sync state", async () => {
|
|
350
|
+
const { onMessage, reports } = await authedHandlerWithSink("hash-123");
|
|
351
|
+
await onMessage(
|
|
352
|
+
JSON.stringify({
|
|
353
|
+
type: "script_package_sync_state",
|
|
354
|
+
lockfileHash: "hash-123",
|
|
355
|
+
status: "ready",
|
|
356
|
+
}),
|
|
357
|
+
);
|
|
358
|
+
expect(reports).toHaveLength(1);
|
|
359
|
+
expect(reports[0]).toMatchObject({
|
|
360
|
+
satelliteId: "sat-1",
|
|
361
|
+
lockfileHash: "hash-123",
|
|
362
|
+
status: "ready",
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("answers a manifest request over the WS channel", async () => {
|
|
367
|
+
const { ws, onMessage } = await authedHandlerWithSink("hash-123");
|
|
368
|
+
ws.messages.length = 0;
|
|
369
|
+
await onMessage(
|
|
370
|
+
JSON.stringify({
|
|
371
|
+
type: "request_script_package_manifest",
|
|
372
|
+
lockfileHash: "hash-123",
|
|
373
|
+
}),
|
|
374
|
+
);
|
|
375
|
+
const reply = JSON.parse(ws.messages[0]);
|
|
376
|
+
expect(reply.type).toBe("script_package_manifest");
|
|
377
|
+
expect(reply.entries[0].name).toBe("leftpad");
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("answers a blob request over the WS channel", async () => {
|
|
381
|
+
const { ws, onMessage } = await authedHandlerWithSink("hash-123");
|
|
382
|
+
ws.messages.length = 0;
|
|
383
|
+
await onMessage(
|
|
384
|
+
JSON.stringify({
|
|
385
|
+
type: "request_script_package_blob",
|
|
386
|
+
integrity: "sha-1",
|
|
387
|
+
}),
|
|
388
|
+
);
|
|
389
|
+
const reply = JSON.parse(ws.messages[0]);
|
|
390
|
+
expect(reply.type).toBe("script_package_blob");
|
|
391
|
+
expect(reply.data).toBe("YmxvYg==");
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
describe("connection-state entity mirror", () => {
|
|
396
|
+
function makeEntitySink() {
|
|
397
|
+
const mirrors: Array<{
|
|
398
|
+
satelliteId: string;
|
|
399
|
+
lastEvent: SatelliteConnectionEvent;
|
|
400
|
+
lastHeartbeatAt: Date | null;
|
|
401
|
+
}> = [];
|
|
402
|
+
return {
|
|
403
|
+
sink: {
|
|
404
|
+
mirror: mock(
|
|
405
|
+
async (input: {
|
|
406
|
+
satelliteId: string;
|
|
407
|
+
lastEvent: SatelliteConnectionEvent;
|
|
408
|
+
lastHeartbeatAt: Date | null;
|
|
409
|
+
}) => {
|
|
410
|
+
mirrors.push(input);
|
|
411
|
+
},
|
|
412
|
+
),
|
|
413
|
+
},
|
|
414
|
+
mirrors,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
it("drives the connected edge with lastHeartbeatAt=now on successful authentication", async () => {
|
|
419
|
+
const { sink, mirrors } = makeEntitySink();
|
|
420
|
+
const h = new SatelliteWsHandler(
|
|
421
|
+
service,
|
|
422
|
+
configRelay,
|
|
423
|
+
resultHandler,
|
|
424
|
+
logger,
|
|
425
|
+
sink,
|
|
426
|
+
);
|
|
427
|
+
const ws = createMockWs();
|
|
428
|
+
const { onMessage } = h.onConnection(ws);
|
|
429
|
+
await onMessage(
|
|
430
|
+
JSON.stringify({
|
|
431
|
+
type: "authenticate",
|
|
432
|
+
clientId: "sat-1",
|
|
433
|
+
token: "csat_valid-token",
|
|
434
|
+
}),
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
expect(mirrors).toHaveLength(1);
|
|
438
|
+
expect(mirrors[0]!.satelliteId).toBe("sat-1");
|
|
439
|
+
expect(mirrors[0]!.lastEvent).toBe("connected");
|
|
440
|
+
// lastHeartbeatAt = now (non-null) so the computed status reads online.
|
|
441
|
+
expect(mirrors[0]!.lastHeartbeatAt).toBeInstanceOf(Date);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("does NOT also call updateHeartbeat on connect when a sink is wired (the mirror writes the heartbeat)", async () => {
|
|
445
|
+
const { sink } = makeEntitySink();
|
|
446
|
+
const h = new SatelliteWsHandler(
|
|
447
|
+
service,
|
|
448
|
+
configRelay,
|
|
449
|
+
resultHandler,
|
|
450
|
+
logger,
|
|
451
|
+
sink,
|
|
452
|
+
);
|
|
453
|
+
const ws = createMockWs();
|
|
454
|
+
const { onMessage } = h.onConnection(ws);
|
|
455
|
+
await onMessage(
|
|
456
|
+
JSON.stringify({
|
|
457
|
+
type: "authenticate",
|
|
458
|
+
clientId: "sat-1",
|
|
459
|
+
token: "csat_valid-token",
|
|
460
|
+
}),
|
|
461
|
+
);
|
|
462
|
+
// The connect-time heartbeat is written by the mirror's apply, not by a
|
|
463
|
+
// separate updateHeartbeat call.
|
|
464
|
+
expect(service.updateHeartbeat).not.toHaveBeenCalled();
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("writes the connect heartbeat directly when NO sink is wired", async () => {
|
|
468
|
+
// The default `handler` has no sink: it must still record the heartbeat.
|
|
469
|
+
const ws = createMockWs();
|
|
470
|
+
const { onMessage } = handler.onConnection(ws);
|
|
471
|
+
await onMessage(
|
|
472
|
+
JSON.stringify({
|
|
473
|
+
type: "authenticate",
|
|
474
|
+
clientId: "sat-1",
|
|
475
|
+
token: "csat_valid-token",
|
|
476
|
+
}),
|
|
477
|
+
);
|
|
478
|
+
expect(service.updateHeartbeat).toHaveBeenCalledWith("sat-1", {});
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it("drives the disconnected edge with lastHeartbeatAt=null (immediate offline) when the socket closes", async () => {
|
|
482
|
+
const { sink, mirrors } = makeEntitySink();
|
|
483
|
+
const h = new SatelliteWsHandler(
|
|
484
|
+
service,
|
|
485
|
+
configRelay,
|
|
486
|
+
resultHandler,
|
|
487
|
+
logger,
|
|
488
|
+
sink,
|
|
489
|
+
);
|
|
490
|
+
const ws = createMockWs();
|
|
491
|
+
const { onMessage, onClose } = h.onConnection(ws);
|
|
492
|
+
await onMessage(
|
|
493
|
+
JSON.stringify({
|
|
494
|
+
type: "authenticate",
|
|
495
|
+
clientId: "sat-1",
|
|
496
|
+
token: "csat_valid-token",
|
|
497
|
+
}),
|
|
498
|
+
);
|
|
499
|
+
onClose?.();
|
|
500
|
+
// onClose fires the mirror fire-and-forget; flush the microtask queue.
|
|
501
|
+
await Promise.resolve();
|
|
502
|
+
await Promise.resolve();
|
|
503
|
+
|
|
504
|
+
const disconnected = mirrors.find((m) => m.lastEvent === "disconnected");
|
|
505
|
+
expect(disconnected).toBeDefined();
|
|
506
|
+
// Clearing lastHeartbeatAt makes the computed status flip offline at once.
|
|
507
|
+
expect(disconnected!.lastHeartbeatAt).toBeNull();
|
|
508
|
+
expect(disconnected!.satelliteId).toBe("sat-1");
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it("does not mirror on a failed authentication", async () => {
|
|
512
|
+
const { sink, mirrors } = makeEntitySink();
|
|
513
|
+
const h = new SatelliteWsHandler(
|
|
514
|
+
service,
|
|
515
|
+
configRelay,
|
|
516
|
+
resultHandler,
|
|
517
|
+
logger,
|
|
518
|
+
sink,
|
|
519
|
+
);
|
|
520
|
+
const ws = createMockWs();
|
|
521
|
+
const { onMessage } = h.onConnection(ws);
|
|
522
|
+
await onMessage(
|
|
523
|
+
JSON.stringify({
|
|
524
|
+
type: "authenticate",
|
|
525
|
+
clientId: "sat-1",
|
|
526
|
+
token: "csat_invalid-token",
|
|
527
|
+
}),
|
|
528
|
+
);
|
|
529
|
+
expect(mirrors).toHaveLength(0);
|
|
530
|
+
});
|
|
531
|
+
});
|
|
265
532
|
});
|