@serverkgg/bridge 0.0.21 → 0.1.1
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/package.json +3 -1
- package/src/bot/botDeploy.test.ts +363 -0
- package/src/bot/botDeploy.ts +136 -0
- package/src/bot/botEnvironment.test.ts +189 -0
- package/src/bot/botEnvironment.ts +141 -0
- package/src/bot/botIdentity.test.ts +251 -0
- package/src/bot/botIdentity.ts +140 -0
- package/src/bot/botPanel.test.ts +216 -0
- package/src/bot/botPanel.ts +121 -0
- package/src/bot/botState.test.ts +45 -0
- package/src/bot/botState.ts +54 -0
- package/src/bot/botTerminal.test.ts +79 -0
- package/src/bot/botTerminal.ts +62 -0
- package/src/bot/botVariables.test.ts +193 -0
- package/src/bot/botVariables.ts +73 -0
- package/src/bot/detect/botFramework.ts +23 -0
- package/src/bot/detect/botFrameworks.ts +198 -0
- package/src/bot/detect/botInspectWarning.ts +15 -0
- package/src/bot/detect/botPackageManager.ts +6 -0
- package/src/bot/detect/botPlatform.ts +10 -0
- package/src/bot/detect/botRuntime.ts +15 -0
- package/src/bot/detect/botToken.test.ts +71 -0
- package/src/bot/detect/botToken.ts +126 -0
- package/src/bot/detect/detect.test.ts +543 -0
- package/src/bot/detect/detect.ts +842 -0
- package/src/bot/detect/detectResult.ts +40 -0
- package/src/bot/detect/detectTable.test.ts +72 -0
- package/src/bot/detect/detectTable.ts +166 -0
- package/src/bot/detect/envExample.test.ts +29 -0
- package/src/bot/detect/envExample.ts +48 -0
- package/src/bot/detect/envKey.ts +7 -0
- package/src/bot/detect/envScan.test.ts +39 -0
- package/src/bot/detect/envScan.ts +87 -0
- package/src/bot/detect/index.ts +17 -0
- package/src/bot/detect/inferBotPlatform.test.ts +60 -0
- package/src/bot/detect/inferBotPlatform.ts +28 -0
- package/src/bot/detect/keys.test.ts +28 -0
- package/src/bot/detect/keys.ts +64 -0
- package/src/bot/detect/sensitiveKey.test.ts +91 -0
- package/src/bot/detect/sensitiveKey.ts +44 -0
- package/src/bot/detect/serverkJson.test.ts +88 -0
- package/src/bot/detect/serverkJson.ts +174 -0
- package/src/bot/index.ts +8 -0
- package/src/cli/detectTable.test.ts +66 -0
- package/src/cli/detectTable.ts +43 -0
- package/src/cli/main.ts +12 -4
- package/src/cli/renderedFile.ts +35 -0
- package/src/cli/schema.ts +7 -26
- package/src/cli/validate.test.ts +210 -2
- package/src/cli/validate.ts +71 -7
- package/src/context/bridgeContext.ts +11 -0
- package/src/core/bridgePort.test.ts +45 -0
- package/src/core/bridgePort.ts +26 -0
- package/src/guides/compile.ts +2 -2
- package/src/manifest/index.ts +1 -0
- package/src/manifest/kind.ts +5 -0
- package/src/manifest/manifest.test.ts +630 -3
- package/src/manifest/manifest.ts +391 -87
- package/src/manifest/runtime.ts +2 -2
- package/src/manifest/source.ts +4 -4
- package/src/modules/bridgeLifecycleSubject.ts +8 -0
- package/src/modules/bridgeModule.ts +6 -0
- package/src/modules/bridgeStopSignal.ts +13 -0
- package/src/modules/index.ts +2 -0
- package/src/panel/bridgeDetail.ts +2 -0
- package/src/protocol/bridgeConfig.ts +2 -0
- package/src/runtime/bridgeConfigReader.test.ts +80 -0
- package/src/runtime/bridgeConfigReader.ts +20 -0
- package/src/runtime/bridgeContext.test.ts +241 -0
- package/src/runtime/bridgeContext.ts +35 -9
- package/src/runtime/bridgeDriver.test.ts +132 -1
- package/src/runtime/bridgeDriver.ts +54 -1
- package/src/runtime/bridgeFiles.test.ts +62 -0
- package/src/runtime/bridgeFiles.ts +12 -2
- package/src/runtime/bridgeLiveConfig.ts +18 -0
- package/src/runtime/bridgeRedaction.test.ts +410 -0
- package/src/runtime/bridgeRedaction.ts +214 -0
- package/src/runtime/bridgeRegistry.test.ts +3 -0
- package/src/runtime/bridgeRegistry.ts +15 -0
- package/src/runtime/bridgeServer.test.ts +130 -0
- package/src/runtime/bridgeServer.ts +24 -7
- package/src/runtime/bridgeSetupHandlers.test.ts +88 -0
- package/src/runtime/bridgeSetupHandlers.ts +16 -6
- package/src/runtime/bridgeSupervisor.test.ts +670 -2
- package/src/runtime/bridgeSupervisor.ts +219 -67
- package/src/runtime/serve.ts +47 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serverkgg/bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Serverk game bridge and driver runtime, wire protocol, manifest schema, and maintainer CLI",
|
|
5
5
|
"homepage": "https://serverk.gg",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
"./blog": "./src/blog/index.ts",
|
|
25
25
|
"./probes": "./src/probes/index.ts",
|
|
26
26
|
"./wire": "./src/wire/index.ts",
|
|
27
|
+
"./bot": "./src/bot/index.ts",
|
|
28
|
+
"./bot/detect": "./src/bot/detect/index.ts",
|
|
27
29
|
"./catalogs": "./src/catalogs/index.ts",
|
|
28
30
|
"./install": "./src/install/index.ts",
|
|
29
31
|
"./net": "./src/net/index.ts",
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { BridgeValues } from "../core";
|
|
3
|
+
import { BridgeEventName } from "../protocol";
|
|
4
|
+
import {
|
|
5
|
+
DEPLOY_MARKER,
|
|
6
|
+
DEPLOY_MARKER_DRAFT,
|
|
7
|
+
DEPLOYMENT_EVENT_PATTERN,
|
|
8
|
+
DEPLOYMENT_LINE,
|
|
9
|
+
type DeployMarker,
|
|
10
|
+
deploymentLine,
|
|
11
|
+
deployPending,
|
|
12
|
+
requestDeploy,
|
|
13
|
+
} from "./botDeploy";
|
|
14
|
+
import { BotDeploymentOutcome, BotDeploymentSource, BotDeploymentStage, DEPLOYMENT_STATE_FILE } from "./botState";
|
|
15
|
+
|
|
16
|
+
const SHA = "0123456789abcdef0123456789abcdef01234567";
|
|
17
|
+
|
|
18
|
+
const deployContext = (running: boolean, files: Record<string, string> = {}) => {
|
|
19
|
+
const stored = new Map<string, string>(Object.entries(files));
|
|
20
|
+
const events: string[] = [];
|
|
21
|
+
const emitted: {
|
|
22
|
+
event: string;
|
|
23
|
+
payload: BridgeValues | undefined;
|
|
24
|
+
}[] = [];
|
|
25
|
+
|
|
26
|
+
const log = Object.assign(
|
|
27
|
+
(message: string) => {
|
|
28
|
+
events.push(`log ${message}`);
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
warn: () => {},
|
|
32
|
+
error: () => {},
|
|
33
|
+
},
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const context = {
|
|
37
|
+
server: {
|
|
38
|
+
code: "abc",
|
|
39
|
+
memoryMb: 512,
|
|
40
|
+
cpuPercent: 100,
|
|
41
|
+
running,
|
|
42
|
+
publicAddress: null,
|
|
43
|
+
},
|
|
44
|
+
files: {
|
|
45
|
+
async write(path: string, content: string) {
|
|
46
|
+
stored.set(path, content);
|
|
47
|
+
events.push(`write ${path}`);
|
|
48
|
+
},
|
|
49
|
+
async remove(path: string) {
|
|
50
|
+
stored.delete(path);
|
|
51
|
+
events.push(`remove ${path}`);
|
|
52
|
+
},
|
|
53
|
+
async move(from: string, to: string) {
|
|
54
|
+
const content = stored.get(from);
|
|
55
|
+
|
|
56
|
+
if (content === undefined || stored.has(to)) {
|
|
57
|
+
throw new Error(`cannot move ${from} to ${to}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
stored.delete(from);
|
|
61
|
+
stored.set(to, content);
|
|
62
|
+
events.push(`move ${from} ${to}`);
|
|
63
|
+
},
|
|
64
|
+
async exists(path: string) {
|
|
65
|
+
return stored.has(path);
|
|
66
|
+
},
|
|
67
|
+
async read(path: string) {
|
|
68
|
+
const content = stored.get(path);
|
|
69
|
+
|
|
70
|
+
if (content === undefined) {
|
|
71
|
+
throw new Error(`no ${path}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return content;
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
log,
|
|
78
|
+
emit(event: string, payload?: BridgeValues) {
|
|
79
|
+
emitted.push({
|
|
80
|
+
event,
|
|
81
|
+
payload,
|
|
82
|
+
});
|
|
83
|
+
},
|
|
84
|
+
async restart() {
|
|
85
|
+
events.push("restart");
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
context: context as never,
|
|
91
|
+
stored,
|
|
92
|
+
events,
|
|
93
|
+
emitted,
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const markerOf = (stored: Map<string, string>): DeployMarker => {
|
|
98
|
+
return JSON.parse(stored.get(DEPLOY_MARKER) ?? "null");
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const MARKER_WRITE = [
|
|
102
|
+
`write ${DEPLOY_MARKER_DRAFT}`,
|
|
103
|
+
`remove ${DEPLOY_MARKER}`,
|
|
104
|
+
`move ${DEPLOY_MARKER_DRAFT} ${DEPLOY_MARKER}`,
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
const liveState = (deployment: string, source: BotDeploymentSource = BotDeploymentSource.Git) => {
|
|
108
|
+
return {
|
|
109
|
+
[DEPLOYMENT_STATE_FILE]: JSON.stringify({
|
|
110
|
+
deployment,
|
|
111
|
+
source,
|
|
112
|
+
ref: source === BotDeploymentSource.Git ? SHA : null,
|
|
113
|
+
commit: SHA,
|
|
114
|
+
snapshot: source === BotDeploymentSource.Snapshot ? "snap_1" : null,
|
|
115
|
+
outcome: BotDeploymentOutcome.Ready,
|
|
116
|
+
stage: null,
|
|
117
|
+
detail: null,
|
|
118
|
+
finishedAt: "2026-09-27T10:00:00.000Z",
|
|
119
|
+
}),
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
describe("deploymentLine", () => {
|
|
124
|
+
test("announces a ready deployment with the short commit", () => {
|
|
125
|
+
expect(
|
|
126
|
+
deploymentLine({
|
|
127
|
+
deployment: "dep_a1b2c3d4",
|
|
128
|
+
outcome: BotDeploymentOutcome.Ready,
|
|
129
|
+
commit: SHA,
|
|
130
|
+
}),
|
|
131
|
+
).toBe("serverk: deployment dep_a1b2c3d4 ready at 0123456789ab");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("announces a ready deployment with no commit for files and snapshots", () => {
|
|
135
|
+
expect(
|
|
136
|
+
deploymentLine({
|
|
137
|
+
deployment: "dep_a1b2c3d4",
|
|
138
|
+
outcome: BotDeploymentOutcome.Ready,
|
|
139
|
+
commit: null,
|
|
140
|
+
}),
|
|
141
|
+
).toBe("serverk: deployment dep_a1b2c3d4 ready");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("names the stage and one line of detail for a failure", () => {
|
|
145
|
+
expect(
|
|
146
|
+
deploymentLine({
|
|
147
|
+
deployment: "dep_a1b2c3d4",
|
|
148
|
+
outcome: BotDeploymentOutcome.Failed,
|
|
149
|
+
stage: BotDeploymentStage.Sync,
|
|
150
|
+
detail: `fatal: remote error:\n upload-pack: not our ref ${"x".repeat(300)}`,
|
|
151
|
+
}),
|
|
152
|
+
).toBe(
|
|
153
|
+
`serverk: deployment dep_a1b2c3d4 failed: sync: fatal: remote error: upload-pack: not our ref ${"x".repeat(154)}`,
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("round-trips through the line pattern", () => {
|
|
158
|
+
const ready = DEPLOYMENT_LINE.exec(
|
|
159
|
+
deploymentLine({
|
|
160
|
+
deployment: "dep_a1b2c3d4",
|
|
161
|
+
outcome: BotDeploymentOutcome.Ready,
|
|
162
|
+
commit: SHA,
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
expect(ready?.groups).toMatchObject({
|
|
167
|
+
deployment: "dep_a1b2c3d4",
|
|
168
|
+
outcome: "ready",
|
|
169
|
+
commit: "0123456789ab",
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const failed = DEPLOYMENT_LINE.exec(
|
|
173
|
+
deploymentLine({
|
|
174
|
+
deployment: "dep_a1b2c3d4",
|
|
175
|
+
outcome: BotDeploymentOutcome.Failed,
|
|
176
|
+
stage: BotDeploymentStage.Install,
|
|
177
|
+
detail: "npm ci exited with code 1",
|
|
178
|
+
}),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
expect(failed?.groups).toMatchObject({
|
|
182
|
+
deployment: "dep_a1b2c3d4",
|
|
183
|
+
outcome: "failed",
|
|
184
|
+
stage: "install",
|
|
185
|
+
detail: "npm ci exited with code 1",
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("cuts the detail at 200 characters, counting an emoji as one the way the Python launcher does", () => {
|
|
190
|
+
const line = deploymentLine({
|
|
191
|
+
deployment: "dep_a1b2c3d4",
|
|
192
|
+
outcome: BotDeploymentOutcome.Failed,
|
|
193
|
+
stage: BotDeploymentStage.Start,
|
|
194
|
+
detail: "🤖".repeat(250),
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
expect(line).toBe(`serverk: deployment dep_a1b2c3d4 failed: start: ${"🤖".repeat(200)}`);
|
|
198
|
+
expect(DEPLOYMENT_LINE.exec(line)?.groups?.detail).toBe("🤖".repeat(200));
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("reads only deployment ids of at most 64 characters", () => {
|
|
202
|
+
expect(DEPLOYMENT_LINE.test(`serverk: deployment ${"d".repeat(64)} ready`)).toBe(true);
|
|
203
|
+
expect(DEPLOYMENT_LINE.test(`serverk: deployment ${"d".repeat(65)} ready`)).toBe(false);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("is reported as a server update tagged as a deployment", () => {
|
|
207
|
+
expect(DEPLOYMENT_EVENT_PATTERN.emit).toBe(BridgeEventName.ServerUpdated);
|
|
208
|
+
expect(DEPLOYMENT_EVENT_PATTERN.payload).toEqual({
|
|
209
|
+
kind: "deployment",
|
|
210
|
+
});
|
|
211
|
+
expect(DEPLOYMENT_EVENT_PATTERN.match.test("serverk: deployment dep_1 ready at abcdef1")).toBe(true);
|
|
212
|
+
expect(DEPLOYMENT_EVENT_PATTERN.match.test("serverk: bot process starting")).toBe(false);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe("requestDeploy", () => {
|
|
217
|
+
test("writes the request as JSON and restarts a running bot", async () => {
|
|
218
|
+
const { context, stored, events } = deployContext(true);
|
|
219
|
+
|
|
220
|
+
await requestDeploy(context, {
|
|
221
|
+
deployment: "dep_a1b2c3d4",
|
|
222
|
+
ref: SHA,
|
|
223
|
+
snapshot: null,
|
|
224
|
+
reinstall: false,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
expect(events).toEqual([
|
|
228
|
+
...MARKER_WRITE,
|
|
229
|
+
"log deploy requested, restarting the bot",
|
|
230
|
+
"restart",
|
|
231
|
+
]);
|
|
232
|
+
expect(stored.has(DEPLOY_MARKER_DRAFT)).toBe(false);
|
|
233
|
+
expect(markerOf(stored)).toMatchObject({
|
|
234
|
+
deployment: "dep_a1b2c3d4",
|
|
235
|
+
ref: SHA,
|
|
236
|
+
snapshot: null,
|
|
237
|
+
reinstall: false,
|
|
238
|
+
});
|
|
239
|
+
expect(Number.isNaN(Date.parse(markerOf(stored).requestedAt))).toBe(false);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("records the request without a restart while the bot is stopped", async () => {
|
|
243
|
+
const { context, stored, events } = deployContext(false);
|
|
244
|
+
|
|
245
|
+
await requestDeploy(context, {
|
|
246
|
+
deployment: "dep_a1b2c3d4",
|
|
247
|
+
ref: null,
|
|
248
|
+
snapshot: "snap_1",
|
|
249
|
+
reinstall: true,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
expect(events).toEqual([
|
|
253
|
+
...MARKER_WRITE,
|
|
254
|
+
"log deploy recorded, the bot runs it on its next start",
|
|
255
|
+
]);
|
|
256
|
+
expect(markerOf(stored)).toMatchObject({
|
|
257
|
+
snapshot: "snap_1",
|
|
258
|
+
reinstall: true,
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("does nothing for a deployment that is already live and reports it again", async () => {
|
|
263
|
+
const { context, stored, events, emitted } = deployContext(true, liveState("dep_a1b2c3d4"));
|
|
264
|
+
|
|
265
|
+
await requestDeploy(context, {
|
|
266
|
+
deployment: "dep_a1b2c3d4",
|
|
267
|
+
ref: SHA,
|
|
268
|
+
snapshot: null,
|
|
269
|
+
reinstall: false,
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
expect(stored.has(DEPLOY_MARKER)).toBe(false);
|
|
273
|
+
expect(events).toEqual([
|
|
274
|
+
"log the deployment is already live, nothing to do",
|
|
275
|
+
]);
|
|
276
|
+
expect(emitted).toEqual([
|
|
277
|
+
{
|
|
278
|
+
event: BridgeEventName.ServerUpdated,
|
|
279
|
+
payload: {
|
|
280
|
+
kind: "deployment",
|
|
281
|
+
deployment: "dep_a1b2c3d4",
|
|
282
|
+
outcome: "ready",
|
|
283
|
+
commit: "0123456789ab",
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
]);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("reports no commit again for a live deployment of uploaded code", async () => {
|
|
290
|
+
const { context, emitted } = deployContext(true, liveState("dep_a1b2c3d4", BotDeploymentSource.Snapshot));
|
|
291
|
+
|
|
292
|
+
await requestDeploy(context, {
|
|
293
|
+
deployment: "dep_a1b2c3d4",
|
|
294
|
+
ref: null,
|
|
295
|
+
snapshot: "snap_1",
|
|
296
|
+
reinstall: false,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
expect(emitted[0]?.payload).toMatchObject({
|
|
300
|
+
deployment: "dep_a1b2c3d4",
|
|
301
|
+
commit: null,
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("replaces a request the bot has not taken yet", async () => {
|
|
306
|
+
const { context, stored } = deployContext(false, {
|
|
307
|
+
[DEPLOY_MARKER]: "{}",
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
await requestDeploy(context, {
|
|
311
|
+
deployment: "dep_newer000",
|
|
312
|
+
ref: SHA,
|
|
313
|
+
snapshot: null,
|
|
314
|
+
reinstall: false,
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
expect(markerOf(stored).deployment).toBe("dep_newer000");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("still reinstalls a deployment that is already live", async () => {
|
|
321
|
+
const { context, events } = deployContext(true, liveState("dep_a1b2c3d4"));
|
|
322
|
+
|
|
323
|
+
await requestDeploy(context, {
|
|
324
|
+
deployment: "dep_a1b2c3d4",
|
|
325
|
+
ref: SHA,
|
|
326
|
+
snapshot: null,
|
|
327
|
+
reinstall: true,
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
expect(events.at(-1)).toBe("restart");
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("runs a new deployment when another one is live", async () => {
|
|
334
|
+
const { context, events } = deployContext(true, liveState("dep_old00000"));
|
|
335
|
+
|
|
336
|
+
await requestDeploy(context, {
|
|
337
|
+
deployment: "dep_new00000",
|
|
338
|
+
ref: SHA,
|
|
339
|
+
snapshot: null,
|
|
340
|
+
reinstall: false,
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
expect(events.at(-1)).toBe("restart");
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
describe("deployPending", () => {
|
|
348
|
+
test("is true only while a running bot has not taken the marker yet", async () => {
|
|
349
|
+
const running = deployContext(true);
|
|
350
|
+
|
|
351
|
+
expect(await deployPending(running.context)).toBe(false);
|
|
352
|
+
|
|
353
|
+
running.stored.set(DEPLOY_MARKER, "{}");
|
|
354
|
+
|
|
355
|
+
expect(await deployPending(running.context)).toBe(true);
|
|
356
|
+
|
|
357
|
+
const stopped = deployContext(false, {
|
|
358
|
+
[DEPLOY_MARKER]: "{}",
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
expect(await deployPending(stopped.context)).toBe(false);
|
|
362
|
+
});
|
|
363
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { BridgeContext } from "../context";
|
|
2
|
+
import type { BridgeValues } from "../core";
|
|
3
|
+
import type { BridgeEventPattern } from "../modules";
|
|
4
|
+
import { BridgeEventName } from "../protocol";
|
|
5
|
+
import {
|
|
6
|
+
BOT_STATE_DIR,
|
|
7
|
+
BotDeploymentOutcome,
|
|
8
|
+
BotDeploymentSource,
|
|
9
|
+
type BotDeploymentStage,
|
|
10
|
+
readDeploymentState,
|
|
11
|
+
} from "./botState";
|
|
12
|
+
|
|
13
|
+
export const DEPLOY_MARKER = `${BOT_STATE_DIR}/deploy-requested`;
|
|
14
|
+
|
|
15
|
+
export const DEPLOY_MARKER_DRAFT = `${DEPLOY_MARKER}.draft`;
|
|
16
|
+
|
|
17
|
+
export const DEPLOYMENT_EVENT_KIND = "deployment";
|
|
18
|
+
|
|
19
|
+
export const DEPLOYMENT_LINE =
|
|
20
|
+
/^serverk: deployment (?<deployment>\S{1,64}) (?<outcome>ready|failed)(?: at (?<commit>[0-9a-f]{7,64}))?(?:: (?<stage>[a-z-]+): (?<detail>.{1,200}))?$/u;
|
|
21
|
+
|
|
22
|
+
export const DEPLOYMENT_DETAIL_LIMIT = 200;
|
|
23
|
+
|
|
24
|
+
export const DEPLOYMENT_COMMIT_LENGTH = 12;
|
|
25
|
+
|
|
26
|
+
export interface DeployRequest {
|
|
27
|
+
deployment: string | null;
|
|
28
|
+
ref: string | null;
|
|
29
|
+
snapshot: string | null;
|
|
30
|
+
reinstall: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface DeployMarker extends DeployRequest {
|
|
34
|
+
requestedAt: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type DeploymentLineInput =
|
|
38
|
+
| {
|
|
39
|
+
deployment: string;
|
|
40
|
+
outcome: BotDeploymentOutcome.Ready;
|
|
41
|
+
commit: string | null;
|
|
42
|
+
}
|
|
43
|
+
| {
|
|
44
|
+
deployment: string;
|
|
45
|
+
outcome: BotDeploymentOutcome.Failed;
|
|
46
|
+
stage: BotDeploymentStage;
|
|
47
|
+
detail: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const deploymentLine = (input: DeploymentLineInput) => {
|
|
51
|
+
if (input.outcome === BotDeploymentOutcome.Ready) {
|
|
52
|
+
const commit = input.commit ? ` at ${input.commit.slice(0, DEPLOYMENT_COMMIT_LENGTH)}` : "";
|
|
53
|
+
|
|
54
|
+
return `serverk: deployment ${input.deployment} ready${commit}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const detail =
|
|
58
|
+
Array.from(input.detail.replace(/\s+/g, " ").trim()).slice(0, DEPLOYMENT_DETAIL_LIMIT).join("") || "no detail";
|
|
59
|
+
|
|
60
|
+
return `serverk: deployment ${input.deployment} failed: ${input.stage}: ${detail}`;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const DEPLOYMENT_EVENT_PATTERN: BridgeEventPattern = {
|
|
64
|
+
match: DEPLOYMENT_LINE,
|
|
65
|
+
emit: BridgeEventName.ServerUpdated,
|
|
66
|
+
payload: {
|
|
67
|
+
kind: DEPLOYMENT_EVENT_KIND,
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const deploymentEvent = (deployment: string, commit: string | null): BridgeValues => {
|
|
72
|
+
return {
|
|
73
|
+
kind: DEPLOYMENT_EVENT_KIND,
|
|
74
|
+
deployment,
|
|
75
|
+
outcome: BotDeploymentOutcome.Ready,
|
|
76
|
+
commit: commit ? commit.slice(0, DEPLOYMENT_COMMIT_LENGTH) : null,
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const deployPending = async (context: Pick<BridgeContext, "files" | "server">) => {
|
|
81
|
+
return context.server.running && (await context.files.exists(DEPLOY_MARKER));
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
type DeployContext = Pick<BridgeContext, "files" | "log" | "restart" | "server" | "emit">;
|
|
85
|
+
|
|
86
|
+
const alreadyLive = async (context: DeployContext, request: DeployRequest) => {
|
|
87
|
+
if (request.deployment === null || request.reinstall) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const state = await readDeploymentState(context.files);
|
|
92
|
+
|
|
93
|
+
if (state?.deployment !== request.deployment || state.outcome !== BotDeploymentOutcome.Ready) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
context.log("the deployment is already live, nothing to do", {
|
|
98
|
+
deployment: request.deployment,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
context.emit(
|
|
102
|
+
BridgeEventName.ServerUpdated,
|
|
103
|
+
deploymentEvent(request.deployment, state.source === BotDeploymentSource.Git ? state.commit : null),
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
return true;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export const requestDeploy = async (context: DeployContext, request: DeployRequest) => {
|
|
110
|
+
if (await alreadyLive(context, request)) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const marker: DeployMarker = {
|
|
115
|
+
...request,
|
|
116
|
+
requestedAt: new Date().toISOString(),
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
await context.files.write(DEPLOY_MARKER_DRAFT, `${JSON.stringify(marker)}\n`);
|
|
120
|
+
await context.files.remove(DEPLOY_MARKER);
|
|
121
|
+
await context.files.move(DEPLOY_MARKER_DRAFT, DEPLOY_MARKER);
|
|
122
|
+
|
|
123
|
+
if (!context.server.running) {
|
|
124
|
+
context.log("deploy recorded, the bot runs it on its next start", {
|
|
125
|
+
deployment: request.deployment,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
context.log(request.reinstall ? "reinstall requested, restarting the bot" : "deploy requested, restarting the bot", {
|
|
132
|
+
deployment: request.deployment,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
await context.restart();
|
|
136
|
+
};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
botProcessEnvironment,
|
|
4
|
+
customerEnvironment,
|
|
5
|
+
LAUNCHER_DEPLOYMENT_ID,
|
|
6
|
+
LAUNCHER_GIT_TOKEN,
|
|
7
|
+
LAUNCHER_INSTALL_COMMAND,
|
|
8
|
+
LAUNCHER_KEYS,
|
|
9
|
+
LAUNCHER_REPO_BRANCH,
|
|
10
|
+
LAUNCHER_REPO_REF,
|
|
11
|
+
LAUNCHER_REPO_URL,
|
|
12
|
+
LAUNCHER_ROOT_DIRECTORY,
|
|
13
|
+
LAUNCHER_SNAPSHOT_ID,
|
|
14
|
+
LAUNCHER_START_COMMAND,
|
|
15
|
+
LAUNCHER_STATE_DIR,
|
|
16
|
+
launcherEnvironment,
|
|
17
|
+
platformEnvironment,
|
|
18
|
+
} from "./botEnvironment";
|
|
19
|
+
import { type BotConfig, DEFAULT_REPO_BRANCH } from "./botVariables";
|
|
20
|
+
import { BotPlatform } from "./detect";
|
|
21
|
+
|
|
22
|
+
const SHA = "0123456789abcdef0123456789abcdef01234567";
|
|
23
|
+
|
|
24
|
+
const config = (overrides: Partial<BotConfig> = {}): BotConfig => ({
|
|
25
|
+
platform: null,
|
|
26
|
+
repo: null,
|
|
27
|
+
ref: null,
|
|
28
|
+
branch: DEFAULT_REPO_BRANCH,
|
|
29
|
+
startCommand: null,
|
|
30
|
+
installCommand: null,
|
|
31
|
+
rootDirectory: null,
|
|
32
|
+
deployment: null,
|
|
33
|
+
snapshot: null,
|
|
34
|
+
gitToken: null,
|
|
35
|
+
...overrides,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("customerEnvironment", () => {
|
|
39
|
+
test("hands the process every vault key and nothing the platform or the runtime owns", () => {
|
|
40
|
+
expect(
|
|
41
|
+
customerEnvironment(
|
|
42
|
+
{
|
|
43
|
+
MY_VAR: "plain",
|
|
44
|
+
node_env_lower: "kept",
|
|
45
|
+
NODE_OPTIONS: "--enable-source-maps",
|
|
46
|
+
REPO_URL: "https://github.com/you/bot",
|
|
47
|
+
REPO_REF: SHA,
|
|
48
|
+
START_COMMAND: "node .",
|
|
49
|
+
INSTALL_COMMAND: "npm i",
|
|
50
|
+
DEPLOYMENT_ID: "dep_1",
|
|
51
|
+
SNAPSHOT_ID: "snap_1",
|
|
52
|
+
BOT_PLATFORM: "discord",
|
|
53
|
+
SERVERK_BOT: "0",
|
|
54
|
+
SERVERK_ANYTHING: "x",
|
|
55
|
+
PATH: "/evil",
|
|
56
|
+
HOME: "/tmp",
|
|
57
|
+
VIRTUAL_ENV: "/tmp/venv",
|
|
58
|
+
PYTHONUNBUFFERED: "0",
|
|
59
|
+
TZ: "Asia/Riyadh",
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
DISCORD_TOKEN: "sealed-value",
|
|
63
|
+
GIT_TOKEN: "ghs_installation",
|
|
64
|
+
},
|
|
65
|
+
),
|
|
66
|
+
).toEqual({
|
|
67
|
+
MY_VAR: "plain",
|
|
68
|
+
node_env_lower: "kept",
|
|
69
|
+
NODE_OPTIONS: "--enable-source-maps",
|
|
70
|
+
DISCORD_TOKEN: "sealed-value",
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("lets a secret win over a plain value under the same key", () => {
|
|
75
|
+
expect(
|
|
76
|
+
customerEnvironment(
|
|
77
|
+
{
|
|
78
|
+
API_KEY: "plain",
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
API_KEY: "sealed",
|
|
82
|
+
},
|
|
83
|
+
),
|
|
84
|
+
).toEqual({
|
|
85
|
+
API_KEY: "sealed",
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("launcherEnvironment", () => {
|
|
91
|
+
test("maps the platform-owned keys onto the launcher internals", () => {
|
|
92
|
+
expect(
|
|
93
|
+
launcherEnvironment(
|
|
94
|
+
config({
|
|
95
|
+
repo: "https://github.com/you/bot",
|
|
96
|
+
ref: SHA,
|
|
97
|
+
branch: "dev",
|
|
98
|
+
gitToken: "ghs_installation",
|
|
99
|
+
startCommand: "node .",
|
|
100
|
+
installCommand: "npm install --legacy-peer-deps",
|
|
101
|
+
rootDirectory: "apps/bot",
|
|
102
|
+
deployment: "dep_a1b2c3d4",
|
|
103
|
+
}),
|
|
104
|
+
"/home/container/.serverk",
|
|
105
|
+
),
|
|
106
|
+
).toEqual({
|
|
107
|
+
[LAUNCHER_STATE_DIR]: "/home/container/.serverk",
|
|
108
|
+
[LAUNCHER_REPO_URL]: "https://github.com/you/bot",
|
|
109
|
+
[LAUNCHER_REPO_REF]: SHA,
|
|
110
|
+
[LAUNCHER_REPO_BRANCH]: "dev",
|
|
111
|
+
[LAUNCHER_GIT_TOKEN]: "ghs_installation",
|
|
112
|
+
[LAUNCHER_START_COMMAND]: "node .",
|
|
113
|
+
[LAUNCHER_INSTALL_COMMAND]: "npm install --legacy-peer-deps",
|
|
114
|
+
[LAUNCHER_ROOT_DIRECTORY]: "apps/bot",
|
|
115
|
+
[LAUNCHER_DEPLOYMENT_ID]: "dep_a1b2c3d4",
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("keeps the git keys out when no repository is linked", () => {
|
|
120
|
+
expect(
|
|
121
|
+
launcherEnvironment(
|
|
122
|
+
config({
|
|
123
|
+
ref: SHA,
|
|
124
|
+
gitToken: "ghs_installation",
|
|
125
|
+
snapshot: "snap_1",
|
|
126
|
+
}),
|
|
127
|
+
"/state",
|
|
128
|
+
),
|
|
129
|
+
).toEqual({
|
|
130
|
+
[LAUNCHER_STATE_DIR]: "/state",
|
|
131
|
+
[LAUNCHER_SNAPSHOT_ID]: "snap_1",
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("owns only SERVERK_ names the launcher strips before the bot starts", () => {
|
|
136
|
+
expect(LAUNCHER_KEYS.every((key) => key.startsWith("SERVERK_"))).toBe(true);
|
|
137
|
+
expect(LAUNCHER_KEYS).toContain(LAUNCHER_REPO_REF);
|
|
138
|
+
expect(LAUNCHER_KEYS).toContain(LAUNCHER_INSTALL_COMMAND);
|
|
139
|
+
expect(LAUNCHER_KEYS).toContain(LAUNCHER_ROOT_DIRECTORY);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("platformEnvironment", () => {
|
|
144
|
+
test("marks the process as a Serverk bot and names the platform when known", () => {
|
|
145
|
+
expect(platformEnvironment("abc", BotPlatform.Discord)).toEqual({
|
|
146
|
+
SERVERK_BOT: "1",
|
|
147
|
+
SERVERK_SERVER_CODE: "abc",
|
|
148
|
+
BOT_PLATFORM: BotPlatform.Discord,
|
|
149
|
+
});
|
|
150
|
+
expect(platformEnvironment("abc", null)).toEqual({
|
|
151
|
+
SERVERK_BOT: "1",
|
|
152
|
+
SERVERK_SERVER_CODE: "abc",
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("botProcessEnvironment", () => {
|
|
158
|
+
test("layers tool defaults under the customer, then the launcher and the platform", () => {
|
|
159
|
+
expect(
|
|
160
|
+
botProcessEnvironment({
|
|
161
|
+
tool: {
|
|
162
|
+
TZ: "UTC",
|
|
163
|
+
HOME: "/home/container",
|
|
164
|
+
NODE_OPTIONS: "--max-old-space-size=384",
|
|
165
|
+
[LAUNCHER_STATE_DIR]: "/home/container/.serverk",
|
|
166
|
+
},
|
|
167
|
+
customer: {
|
|
168
|
+
NODE_OPTIONS: "--enable-source-maps",
|
|
169
|
+
MY_VAR: "1",
|
|
170
|
+
},
|
|
171
|
+
launcher: {
|
|
172
|
+
[LAUNCHER_STATE_DIR]: "/state",
|
|
173
|
+
[LAUNCHER_DEPLOYMENT_ID]: "dep_1",
|
|
174
|
+
},
|
|
175
|
+
platform: platformEnvironment("abc", BotPlatform.Telegram),
|
|
176
|
+
}),
|
|
177
|
+
).toEqual({
|
|
178
|
+
TZ: "UTC",
|
|
179
|
+
HOME: "/home/container",
|
|
180
|
+
NODE_OPTIONS: "--enable-source-maps",
|
|
181
|
+
MY_VAR: "1",
|
|
182
|
+
[LAUNCHER_STATE_DIR]: "/state",
|
|
183
|
+
[LAUNCHER_DEPLOYMENT_ID]: "dep_1",
|
|
184
|
+
SERVERK_BOT: "1",
|
|
185
|
+
SERVERK_SERVER_CODE: "abc",
|
|
186
|
+
BOT_PLATFORM: BotPlatform.Telegram,
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
});
|