@checkstack/satellite-backend 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 +44 -0
- package/drizzle/0000_melted_gargoyle.sql +10 -0
- package/drizzle/meta/0000_snapshot.json +83 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +7 -0
- package/package.json +35 -0
- package/src/config-relay.ts +31 -0
- package/src/heartbeat-monitor.test.ts +175 -0
- package/src/heartbeat-monitor.ts +70 -0
- package/src/hooks.ts +17 -0
- package/src/index.ts +159 -0
- package/src/router.ts +83 -0
- package/src/satellite-ws-handler.test.ts +265 -0
- package/src/satellite-ws-handler.ts +222 -0
- package/src/schema.ts +27 -0
- package/src/service.test.ts +292 -0
- package/src/service.ts +171 -0
- package/tsconfig.json +6 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# @checkstack/satellite-backend
|
|
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
|
+
- Updated dependencies [26d8bae]
|
|
40
|
+
- @checkstack/healthcheck-common@0.11.0
|
|
41
|
+
- @checkstack/healthcheck-backend@0.13.0
|
|
42
|
+
- @checkstack/satellite-common@0.2.0
|
|
43
|
+
- @checkstack/backend-api@0.12.0
|
|
44
|
+
- @checkstack/queue-api@0.2.13
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
CREATE TABLE "satellites" (
|
|
2
|
+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
3
|
+
"name" text NOT NULL,
|
|
4
|
+
"region" text NOT NULL,
|
|
5
|
+
"tags" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
|
6
|
+
"token_hash" text NOT NULL,
|
|
7
|
+
"last_heartbeat_at" timestamp,
|
|
8
|
+
"version" text,
|
|
9
|
+
"created_at" timestamp DEFAULT now() NOT NULL
|
|
10
|
+
);
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "8c89df94-7c41-44ec-9197-02edea2f23f2",
|
|
3
|
+
"prevId": "00000000-0000-0000-0000-000000000000",
|
|
4
|
+
"version": "7",
|
|
5
|
+
"dialect": "postgresql",
|
|
6
|
+
"tables": {
|
|
7
|
+
"public.satellites": {
|
|
8
|
+
"name": "satellites",
|
|
9
|
+
"schema": "",
|
|
10
|
+
"columns": {
|
|
11
|
+
"id": {
|
|
12
|
+
"name": "id",
|
|
13
|
+
"type": "uuid",
|
|
14
|
+
"primaryKey": true,
|
|
15
|
+
"notNull": true,
|
|
16
|
+
"default": "gen_random_uuid()"
|
|
17
|
+
},
|
|
18
|
+
"name": {
|
|
19
|
+
"name": "name",
|
|
20
|
+
"type": "text",
|
|
21
|
+
"primaryKey": false,
|
|
22
|
+
"notNull": true
|
|
23
|
+
},
|
|
24
|
+
"region": {
|
|
25
|
+
"name": "region",
|
|
26
|
+
"type": "text",
|
|
27
|
+
"primaryKey": false,
|
|
28
|
+
"notNull": true
|
|
29
|
+
},
|
|
30
|
+
"tags": {
|
|
31
|
+
"name": "tags",
|
|
32
|
+
"type": "jsonb",
|
|
33
|
+
"primaryKey": false,
|
|
34
|
+
"notNull": true,
|
|
35
|
+
"default": "'{}'::jsonb"
|
|
36
|
+
},
|
|
37
|
+
"token_hash": {
|
|
38
|
+
"name": "token_hash",
|
|
39
|
+
"type": "text",
|
|
40
|
+
"primaryKey": false,
|
|
41
|
+
"notNull": true
|
|
42
|
+
},
|
|
43
|
+
"last_heartbeat_at": {
|
|
44
|
+
"name": "last_heartbeat_at",
|
|
45
|
+
"type": "timestamp",
|
|
46
|
+
"primaryKey": false,
|
|
47
|
+
"notNull": false
|
|
48
|
+
},
|
|
49
|
+
"version": {
|
|
50
|
+
"name": "version",
|
|
51
|
+
"type": "text",
|
|
52
|
+
"primaryKey": false,
|
|
53
|
+
"notNull": false
|
|
54
|
+
},
|
|
55
|
+
"created_at": {
|
|
56
|
+
"name": "created_at",
|
|
57
|
+
"type": "timestamp",
|
|
58
|
+
"primaryKey": false,
|
|
59
|
+
"notNull": true,
|
|
60
|
+
"default": "now()"
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"indexes": {},
|
|
64
|
+
"foreignKeys": {},
|
|
65
|
+
"compositePrimaryKeys": {},
|
|
66
|
+
"uniqueConstraints": {},
|
|
67
|
+
"policies": {},
|
|
68
|
+
"checkConstraints": {},
|
|
69
|
+
"isRLSEnabled": false
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
"enums": {},
|
|
73
|
+
"schemas": {},
|
|
74
|
+
"sequences": {},
|
|
75
|
+
"roles": {},
|
|
76
|
+
"policies": {},
|
|
77
|
+
"views": {},
|
|
78
|
+
"_meta": {
|
|
79
|
+
"columns": {},
|
|
80
|
+
"schemas": {},
|
|
81
|
+
"tables": {}
|
|
82
|
+
}
|
|
83
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@checkstack/satellite-backend",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"checkstack": {
|
|
7
|
+
"type": "backend"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"typecheck": "tsc --noEmit",
|
|
11
|
+
"generate": "drizzle-kit generate",
|
|
12
|
+
"lint": "bun run lint:code",
|
|
13
|
+
"lint:code": "eslint . --max-warnings 0"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@checkstack/backend-api": "0.11.1",
|
|
17
|
+
"@checkstack/satellite-common": "0.1.0",
|
|
18
|
+
"@checkstack/healthcheck-common": "0.10.1",
|
|
19
|
+
"@checkstack/signal-common": "0.1.9",
|
|
20
|
+
"@checkstack/healthcheck-backend": "0.12.1",
|
|
21
|
+
"@checkstack/common": "0.6.5",
|
|
22
|
+
"@checkstack/queue-api": "0.2.12",
|
|
23
|
+
"drizzle-orm": "^0.45.0",
|
|
24
|
+
"zod": "^4.2.1",
|
|
25
|
+
"@orpc/server": "^1.13.2"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@checkstack/drizzle-helper": "0.0.4",
|
|
29
|
+
"@checkstack/scripts": "0.1.2",
|
|
30
|
+
"@checkstack/test-utils-backend": "0.1.18",
|
|
31
|
+
"@checkstack/tsconfig": "0.0.5",
|
|
32
|
+
"@types/bun": "^1.0.0",
|
|
33
|
+
"typescript": "^5.0.0"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { SatelliteAssignment } from "@checkstack/satellite-common";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Builds the full configuration payload for a satellite.
|
|
5
|
+
* Queries systemHealthChecks for associations that reference this satellite's ID,
|
|
6
|
+
* then joins with healthCheckConfigurations to build the full assignment list.
|
|
7
|
+
*/
|
|
8
|
+
export class ConfigRelay {
|
|
9
|
+
constructor(
|
|
10
|
+
/**
|
|
11
|
+
* The healthcheck database connection.
|
|
12
|
+
* This is passed in from the healthcheck-backend's schema context.
|
|
13
|
+
*/
|
|
14
|
+
private getHealthcheckDb: () => Promise<{
|
|
15
|
+
getAssignmentsForSatellite: (
|
|
16
|
+
satelliteId: string,
|
|
17
|
+
) => Promise<SatelliteAssignment[]>;
|
|
18
|
+
}>,
|
|
19
|
+
) {}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Get all health check assignments for a specific satellite.
|
|
23
|
+
* Returns the full configuration payload needed for the satellite to execute checks.
|
|
24
|
+
*/
|
|
25
|
+
async getAssignmentsForSatellite(
|
|
26
|
+
satelliteId: string,
|
|
27
|
+
): Promise<SatelliteAssignment[]> {
|
|
28
|
+
const db = await this.getHealthcheckDb();
|
|
29
|
+
return db.getAssignmentsForSatellite(satelliteId);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
|
2
|
+
import { HeartbeatMonitor } from "./heartbeat-monitor";
|
|
3
|
+
import {
|
|
4
|
+
createMockLogger,
|
|
5
|
+
createMockSignalService,
|
|
6
|
+
type MockSignalService,
|
|
7
|
+
} from "@checkstack/test-utils-backend";
|
|
8
|
+
import { SATELLITE_STATUS_CHANGED } from "@checkstack/satellite-common";
|
|
9
|
+
import type { SatelliteService } from "./service";
|
|
10
|
+
import type { SatelliteWithStatus } from "@checkstack/satellite-common";
|
|
11
|
+
|
|
12
|
+
const createMockSatelliteService = (
|
|
13
|
+
satellites: SatelliteWithStatus[],
|
|
14
|
+
): SatelliteService =>
|
|
15
|
+
({
|
|
16
|
+
listSatellites: mock(async () => satellites),
|
|
17
|
+
}) as unknown as SatelliteService;
|
|
18
|
+
|
|
19
|
+
describe("HeartbeatMonitor", () => {
|
|
20
|
+
let signalService: MockSignalService;
|
|
21
|
+
let logger: ReturnType<typeof createMockLogger>;
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
signalService = createMockSignalService();
|
|
25
|
+
logger = createMockLogger();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("should not broadcast signals on first check (initialization)", async () => {
|
|
29
|
+
const service = createMockSatelliteService([
|
|
30
|
+
{
|
|
31
|
+
id: "sat-1",
|
|
32
|
+
name: "eu-west",
|
|
33
|
+
region: "eu-west-1",
|
|
34
|
+
tags: {},
|
|
35
|
+
status: "online",
|
|
36
|
+
createdAt: new Date(),
|
|
37
|
+
},
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
const monitor = new HeartbeatMonitor(service, signalService, logger);
|
|
41
|
+
await monitor.checkHeartbeats();
|
|
42
|
+
|
|
43
|
+
// First check should NOT broadcast — it only initializes state
|
|
44
|
+
expect(signalService.getRecordedSignals()).toHaveLength(0);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("should broadcast when a satellite goes offline", async () => {
|
|
48
|
+
const satellites: SatelliteWithStatus[] = [
|
|
49
|
+
{
|
|
50
|
+
id: "sat-1",
|
|
51
|
+
name: "eu-west",
|
|
52
|
+
region: "eu-west-1",
|
|
53
|
+
tags: {},
|
|
54
|
+
status: "online",
|
|
55
|
+
createdAt: new Date(),
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
const service = createMockSatelliteService(satellites);
|
|
59
|
+
const monitor = new HeartbeatMonitor(service, signalService, logger);
|
|
60
|
+
|
|
61
|
+
// First check: initialize state
|
|
62
|
+
await monitor.checkHeartbeats();
|
|
63
|
+
|
|
64
|
+
// Simulate satellite going offline
|
|
65
|
+
satellites[0] = { ...satellites[0], status: "offline" };
|
|
66
|
+
await monitor.checkHeartbeats();
|
|
67
|
+
|
|
68
|
+
expect(
|
|
69
|
+
signalService.wasSignalEmitted(SATELLITE_STATUS_CHANGED.id),
|
|
70
|
+
).toBe(true);
|
|
71
|
+
|
|
72
|
+
const recorded = signalService.getRecordedSignalsById(
|
|
73
|
+
SATELLITE_STATUS_CHANGED.id,
|
|
74
|
+
);
|
|
75
|
+
expect(recorded).toHaveLength(1);
|
|
76
|
+
expect(recorded[0].payload).toEqual({
|
|
77
|
+
satelliteId: "sat-1",
|
|
78
|
+
status: "offline",
|
|
79
|
+
name: "eu-west",
|
|
80
|
+
region: "eu-west-1",
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("should broadcast when a satellite comes back online", async () => {
|
|
85
|
+
const satellites: SatelliteWithStatus[] = [
|
|
86
|
+
{
|
|
87
|
+
id: "sat-1",
|
|
88
|
+
name: "eu-west",
|
|
89
|
+
region: "eu-west-1",
|
|
90
|
+
tags: {},
|
|
91
|
+
status: "offline",
|
|
92
|
+
createdAt: new Date(),
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
const service = createMockSatelliteService(satellites);
|
|
96
|
+
const monitor = new HeartbeatMonitor(service, signalService, logger);
|
|
97
|
+
|
|
98
|
+
// First check: initialize state
|
|
99
|
+
await monitor.checkHeartbeats();
|
|
100
|
+
|
|
101
|
+
// Simulate satellite coming online
|
|
102
|
+
satellites[0] = { ...satellites[0], status: "online" };
|
|
103
|
+
await monitor.checkHeartbeats();
|
|
104
|
+
|
|
105
|
+
const recorded = signalService.getRecordedSignalsById(
|
|
106
|
+
SATELLITE_STATUS_CHANGED.id,
|
|
107
|
+
);
|
|
108
|
+
expect(recorded).toHaveLength(1);
|
|
109
|
+
expect(recorded[0].payload).toEqual({
|
|
110
|
+
satelliteId: "sat-1",
|
|
111
|
+
status: "online",
|
|
112
|
+
name: "eu-west",
|
|
113
|
+
region: "eu-west-1",
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("should not broadcast if status stays the same", async () => {
|
|
118
|
+
const satellites: SatelliteWithStatus[] = [
|
|
119
|
+
{
|
|
120
|
+
id: "sat-1",
|
|
121
|
+
name: "eu-west",
|
|
122
|
+
region: "eu-west-1",
|
|
123
|
+
tags: {},
|
|
124
|
+
status: "online",
|
|
125
|
+
createdAt: new Date(),
|
|
126
|
+
},
|
|
127
|
+
];
|
|
128
|
+
const service = createMockSatelliteService(satellites);
|
|
129
|
+
const monitor = new HeartbeatMonitor(service, signalService, logger);
|
|
130
|
+
|
|
131
|
+
// First check: initialize
|
|
132
|
+
await monitor.checkHeartbeats();
|
|
133
|
+
// Second check: same status
|
|
134
|
+
await monitor.checkHeartbeats();
|
|
135
|
+
|
|
136
|
+
expect(signalService.getRecordedSignals()).toHaveLength(0);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("should clean up tracked state for deleted satellites", async () => {
|
|
140
|
+
const satellites: SatelliteWithStatus[] = [
|
|
141
|
+
{
|
|
142
|
+
id: "sat-1",
|
|
143
|
+
name: "eu-west",
|
|
144
|
+
region: "eu-west-1",
|
|
145
|
+
tags: {},
|
|
146
|
+
status: "online",
|
|
147
|
+
createdAt: new Date(),
|
|
148
|
+
},
|
|
149
|
+
];
|
|
150
|
+
const service = createMockSatelliteService(satellites);
|
|
151
|
+
const monitor = new HeartbeatMonitor(service, signalService, logger);
|
|
152
|
+
|
|
153
|
+
// First check
|
|
154
|
+
await monitor.checkHeartbeats();
|
|
155
|
+
|
|
156
|
+
// Satellite removed (empty list returned)
|
|
157
|
+
satellites.length = 0;
|
|
158
|
+
await monitor.checkHeartbeats();
|
|
159
|
+
|
|
160
|
+
// Adding it back should not trigger a transition
|
|
161
|
+
// (it's treated as a new satellite on first check)
|
|
162
|
+
satellites.push({
|
|
163
|
+
id: "sat-1",
|
|
164
|
+
name: "eu-west",
|
|
165
|
+
region: "eu-west-1",
|
|
166
|
+
tags: {},
|
|
167
|
+
status: "online",
|
|
168
|
+
createdAt: new Date(),
|
|
169
|
+
});
|
|
170
|
+
await monitor.checkHeartbeats();
|
|
171
|
+
|
|
172
|
+
// No transition signal should have been emitted for the re-add
|
|
173
|
+
expect(signalService.getRecordedSignals()).toHaveLength(0);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Logger } from "@checkstack/backend-api";
|
|
2
|
+
import type { SignalService } from "@checkstack/signal-common";
|
|
3
|
+
import type { SatelliteService } from "./service";
|
|
4
|
+
import {
|
|
5
|
+
SATELLITE_STATUS_CHANGED,
|
|
6
|
+
OFFLINE_THRESHOLD_MS,
|
|
7
|
+
} from "@checkstack/satellite-common";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Monitors satellite heartbeats and broadcasts status change signals.
|
|
11
|
+
* Tracks previous state in-memory to detect transitions (online → offline, offline → online).
|
|
12
|
+
*/
|
|
13
|
+
export class HeartbeatMonitor {
|
|
14
|
+
/**
|
|
15
|
+
* In-memory tracking of each satellite's last known status.
|
|
16
|
+
* Used to detect transitions and avoid redundant signal broadcasts.
|
|
17
|
+
*/
|
|
18
|
+
private previousStatuses = new Map<string, "online" | "offline">();
|
|
19
|
+
|
|
20
|
+
constructor(
|
|
21
|
+
private service: SatelliteService,
|
|
22
|
+
private signalService: SignalService,
|
|
23
|
+
private logger: Logger,
|
|
24
|
+
) {}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Check all satellites and broadcast status change signals for any transitions.
|
|
28
|
+
* Called periodically by a recurring queue job.
|
|
29
|
+
*/
|
|
30
|
+
async checkHeartbeats(): Promise<void> {
|
|
31
|
+
const allSatellites = await this.service.listSatellites();
|
|
32
|
+
|
|
33
|
+
for (const satellite of allSatellites) {
|
|
34
|
+
const previousStatus = this.previousStatuses.get(satellite.id);
|
|
35
|
+
const currentStatus = satellite.status;
|
|
36
|
+
|
|
37
|
+
// Detect transition
|
|
38
|
+
if (previousStatus !== undefined && previousStatus !== currentStatus) {
|
|
39
|
+
this.logger.info(
|
|
40
|
+
`Satellite ${satellite.name} (${satellite.region}) status: ${previousStatus} → ${currentStatus}`,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
await this.signalService.broadcast(SATELLITE_STATUS_CHANGED, {
|
|
44
|
+
satelliteId: satellite.id,
|
|
45
|
+
status: currentStatus,
|
|
46
|
+
name: satellite.name,
|
|
47
|
+
region: satellite.region,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
this.previousStatuses.set(satellite.id, currentStatus);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Clean up tracked satellites that no longer exist
|
|
55
|
+
const currentIds = new Set(allSatellites.map((s) => s.id));
|
|
56
|
+
for (const trackedId of this.previousStatuses.keys()) {
|
|
57
|
+
if (!currentIds.has(trackedId)) {
|
|
58
|
+
this.previousStatuses.delete(trackedId);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Get the offline threshold in milliseconds.
|
|
65
|
+
* Exposed for testing.
|
|
66
|
+
*/
|
|
67
|
+
static get OFFLINE_THRESHOLD_MS(): number {
|
|
68
|
+
return OFFLINE_THRESHOLD_MS;
|
|
69
|
+
}
|
|
70
|
+
}
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createHook } from "@checkstack/backend-api";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Satellite hooks for cross-plugin communication.
|
|
5
|
+
* Other plugins (e.g., healthcheck-backend) can subscribe to clean up
|
|
6
|
+
* when a satellite is removed.
|
|
7
|
+
*/
|
|
8
|
+
export const satelliteHooks = {
|
|
9
|
+
/**
|
|
10
|
+
* Emitted when a satellite is deleted.
|
|
11
|
+
* Healthcheck-backend subscribes to scrub this satellite's ID
|
|
12
|
+
* from all systemHealthChecks.satelliteIds arrays.
|
|
13
|
+
*/
|
|
14
|
+
satelliteRemoved: createHook<{
|
|
15
|
+
satelliteId: string;
|
|
16
|
+
}>("satellite.removed"),
|
|
17
|
+
} as const;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import * as schema from "./schema";
|
|
2
|
+
import type { SafeDatabase } from "@checkstack/backend-api";
|
|
3
|
+
import { createBackendPlugin, coreServices } from "@checkstack/backend-api";
|
|
4
|
+
import {
|
|
5
|
+
satelliteAccessRules,
|
|
6
|
+
satelliteContract,
|
|
7
|
+
pluginMetadata,
|
|
8
|
+
HEARTBEAT_INTERVAL_MS,
|
|
9
|
+
} from "@checkstack/satellite-common";
|
|
10
|
+
import { HealthCheckApi } from "@checkstack/healthcheck-common";
|
|
11
|
+
import { healthCheckHooks } from "@checkstack/healthcheck-backend";
|
|
12
|
+
import { SatelliteService } from "./service";
|
|
13
|
+
import { createSatelliteRouter } from "./router";
|
|
14
|
+
import { HeartbeatMonitor } from "./heartbeat-monitor";
|
|
15
|
+
import { SatelliteWsHandler } from "./satellite-ws-handler";
|
|
16
|
+
import { ConfigRelay } from "./config-relay";
|
|
17
|
+
|
|
18
|
+
// Queue and job constants
|
|
19
|
+
const HEARTBEAT_QUEUE = "satellite-heartbeat";
|
|
20
|
+
const HEARTBEAT_JOB_ID = "satellite-heartbeat-check";
|
|
21
|
+
const HEARTBEAT_WORKER_GROUP = "satellite-heartbeat-worker";
|
|
22
|
+
|
|
23
|
+
export default createBackendPlugin({
|
|
24
|
+
metadata: pluginMetadata,
|
|
25
|
+
register(env) {
|
|
26
|
+
env.registerAccessRules(satelliteAccessRules);
|
|
27
|
+
|
|
28
|
+
env.registerInit({
|
|
29
|
+
schema,
|
|
30
|
+
deps: {
|
|
31
|
+
logger: coreServices.logger,
|
|
32
|
+
rpc: coreServices.rpc,
|
|
33
|
+
rpcClient: coreServices.rpcClient,
|
|
34
|
+
signalService: coreServices.signalService,
|
|
35
|
+
queueManager: coreServices.queueManager,
|
|
36
|
+
wsRegistry: coreServices.wsRegistry,
|
|
37
|
+
},
|
|
38
|
+
init: async ({ logger, database, rpc, signalService }) => {
|
|
39
|
+
logger.debug("🛰️ Initializing Satellite Backend...");
|
|
40
|
+
|
|
41
|
+
const service = new SatelliteService(
|
|
42
|
+
database as SafeDatabase<typeof schema>,
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const router = createSatelliteRouter({
|
|
46
|
+
service,
|
|
47
|
+
signalService,
|
|
48
|
+
logger,
|
|
49
|
+
});
|
|
50
|
+
rpc.registerRouter(router, satelliteContract);
|
|
51
|
+
|
|
52
|
+
logger.debug("✅ Satellite Backend initialized.");
|
|
53
|
+
},
|
|
54
|
+
afterPluginsReady: async ({
|
|
55
|
+
database,
|
|
56
|
+
queueManager,
|
|
57
|
+
logger,
|
|
58
|
+
signalService,
|
|
59
|
+
wsRegistry,
|
|
60
|
+
rpcClient,
|
|
61
|
+
onHook,
|
|
62
|
+
}) => {
|
|
63
|
+
const service = new SatelliteService(
|
|
64
|
+
database as SafeDatabase<typeof schema>,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
// Wire ConfigRelay via RPC loopback to healthcheck-backend
|
|
68
|
+
const configRelay = new ConfigRelay(async () => {
|
|
69
|
+
const hcClient = rpcClient.forPlugin(HealthCheckApi);
|
|
70
|
+
return {
|
|
71
|
+
getAssignmentsForSatellite: async (satelliteId: string) => {
|
|
72
|
+
return hcClient.getAssignmentsForSatellite({ satelliteId });
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Wire result handler — ingests satellite results into healthcheck-backend
|
|
78
|
+
const wsHandler = new SatelliteWsHandler(
|
|
79
|
+
service,
|
|
80
|
+
configRelay,
|
|
81
|
+
{
|
|
82
|
+
handleResult: async ({ satelliteId, sourceLabel, result }) => {
|
|
83
|
+
const hcClient = rpcClient.forPlugin(HealthCheckApi);
|
|
84
|
+
await hcClient.ingestSatelliteResult({
|
|
85
|
+
configId: result.configId,
|
|
86
|
+
systemId: result.systemId,
|
|
87
|
+
status: result.status,
|
|
88
|
+
latencyMs: result.latencyMs,
|
|
89
|
+
result: result.result,
|
|
90
|
+
executedAt: result.executedAt,
|
|
91
|
+
sourceId: satelliteId,
|
|
92
|
+
sourceLabel,
|
|
93
|
+
});
|
|
94
|
+
logger.debug(
|
|
95
|
+
`Ingested result from satellite ${satelliteId} (${sourceLabel}): ` +
|
|
96
|
+
`config=${result.configId} status=${result.status}`,
|
|
97
|
+
);
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
logger,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// Register satellite WebSocket endpoint via the scoped WS registry
|
|
104
|
+
// pluginId "satellite" is auto-prefixed → available at /api/ws/satellite
|
|
105
|
+
wsRegistry.register("/", wsHandler);
|
|
106
|
+
logger.debug("✅ Satellite WebSocket endpoint registered at /api/ws/satellite");
|
|
107
|
+
|
|
108
|
+
// Setup heartbeat monitor
|
|
109
|
+
const heartbeatMonitor = new HeartbeatMonitor(
|
|
110
|
+
service,
|
|
111
|
+
signalService,
|
|
112
|
+
logger,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const queue = queueManager.getQueue<Record<string, never>>(
|
|
116
|
+
HEARTBEAT_QUEUE,
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
// Subscribe to heartbeat check jobs
|
|
120
|
+
await queue.consume(
|
|
121
|
+
async () => {
|
|
122
|
+
await heartbeatMonitor.checkHeartbeats();
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
consumerGroup: HEARTBEAT_WORKER_GROUP,
|
|
126
|
+
maxRetries: 0,
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
// Schedule heartbeat check at the same interval as the heartbeat itself
|
|
131
|
+
const intervalSeconds = Math.round(HEARTBEAT_INTERVAL_MS / 1000);
|
|
132
|
+
await queue.scheduleRecurring(
|
|
133
|
+
{},
|
|
134
|
+
{
|
|
135
|
+
jobId: HEARTBEAT_JOB_ID,
|
|
136
|
+
intervalSeconds,
|
|
137
|
+
},
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
logger.debug(
|
|
141
|
+
`✅ Satellite heartbeat monitor scheduled (every ${intervalSeconds}s).`,
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
// Subscribe to assignment changes to push config to connected satellites
|
|
145
|
+
onHook(
|
|
146
|
+
healthCheckHooks.assignmentChanged,
|
|
147
|
+
async () => {
|
|
148
|
+
await wsHandler.pushConfigUpdateToAll();
|
|
149
|
+
},
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
logger.debug("✅ Satellite Backend afterPluginsReady complete.");
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Re-export hooks for other plugins to use
|
|
159
|
+
export { satelliteHooks } from "./hooks";
|