@wopr-network/platform-core 1.39.4 → 1.39.6
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/dist/credits/credit-expiry-cron.test.js +30 -0
- package/dist/credits/ledger.js +11 -5
- package/dist/credits/ledger.test.js +87 -0
- package/dist/db/schema/ledger.js +6 -0
- package/dist/fleet/fleet-manager.d.ts +14 -35
- package/dist/fleet/fleet-manager.js +52 -236
- package/dist/fleet/fleet-manager.test.js +13 -85
- package/dist/fleet/instance.d.ts +58 -3
- package/dist/fleet/instance.js +297 -33
- package/dist/fleet/instance.test.d.ts +1 -0
- package/dist/fleet/instance.test.js +343 -0
- package/dist/node-agent/types.d.ts +1 -1
- package/package.json +1 -1
- package/src/credits/README.md +106 -0
- package/src/credits/credit-expiry-cron.test.ts +36 -0
- package/src/credits/ledger.test.ts +113 -0
- package/src/credits/ledger.ts +13 -7
- package/src/db/schema/ledger.ts +6 -0
- package/src/fleet/fleet-manager.test.ts +13 -111
- package/src/fleet/fleet-manager.ts +50 -255
- package/src/fleet/instance.test.ts +390 -0
- package/src/fleet/instance.ts +318 -33
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import { PassThrough } from "node:stream";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from "vitest";
|
|
3
|
+
import type { BotMetricsTracker } from "../gateway/bot-metrics-tracker.js";
|
|
4
|
+
import type { FleetEventEmitter } from "./fleet-event-emitter.js";
|
|
5
|
+
import { Instance, type InstanceDeps } from "./instance.js";
|
|
6
|
+
import type { BotProfile } from "./types.js";
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Helpers
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
function makeProfile(overrides: Partial<BotProfile> = {}): BotProfile {
|
|
13
|
+
return {
|
|
14
|
+
id: "bot-1",
|
|
15
|
+
tenantId: "tenant-1",
|
|
16
|
+
name: "test-bot",
|
|
17
|
+
description: "A test bot",
|
|
18
|
+
image: "ghcr.io/wopr-network/test:latest",
|
|
19
|
+
env: {},
|
|
20
|
+
restartPolicy: "unless-stopped",
|
|
21
|
+
releaseChannel: "stable",
|
|
22
|
+
updatePolicy: "manual",
|
|
23
|
+
...overrides,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Build a mock Docker.Container with sensible defaults */
|
|
28
|
+
function mockContainer(state: Partial<{ Running: boolean; Status: string }> = { Running: true, Status: "running" }) {
|
|
29
|
+
return {
|
|
30
|
+
start: vi.fn().mockResolvedValue(undefined),
|
|
31
|
+
stop: vi.fn().mockResolvedValue(undefined),
|
|
32
|
+
restart: vi.fn().mockResolvedValue(undefined),
|
|
33
|
+
remove: vi.fn().mockResolvedValue(undefined),
|
|
34
|
+
inspect: vi.fn().mockResolvedValue({
|
|
35
|
+
Id: "abc123",
|
|
36
|
+
Name: "/wopr-test-bot",
|
|
37
|
+
Created: "2026-01-01T00:00:00Z",
|
|
38
|
+
State: {
|
|
39
|
+
Running: state.Running ?? true,
|
|
40
|
+
Status: state.Status ?? "running",
|
|
41
|
+
StartedAt: "2026-01-01T00:00:00Z",
|
|
42
|
+
Health: { Status: "healthy" },
|
|
43
|
+
},
|
|
44
|
+
NetworkSettings: { Ports: {} },
|
|
45
|
+
}),
|
|
46
|
+
logs: vi.fn(),
|
|
47
|
+
stats: vi.fn().mockResolvedValue({
|
|
48
|
+
cpu_stats: { cpu_usage: { total_usage: 200 }, system_cpu_usage: 1000, online_cpus: 2 },
|
|
49
|
+
precpu_stats: { cpu_usage: { total_usage: 100 }, system_cpu_usage: 500 },
|
|
50
|
+
memory_stats: { usage: 100 * 1024 * 1024, limit: 512 * 1024 * 1024 },
|
|
51
|
+
}),
|
|
52
|
+
exec: vi.fn(),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function mockDocker(container: ReturnType<typeof mockContainer>) {
|
|
57
|
+
return {
|
|
58
|
+
getContainer: vi.fn().mockReturnValue(container),
|
|
59
|
+
pull: vi.fn(),
|
|
60
|
+
modem: {
|
|
61
|
+
followProgress: vi.fn((_stream: unknown, cb: (err: Error | null) => void) => cb(null)),
|
|
62
|
+
demuxStream: vi.fn(),
|
|
63
|
+
},
|
|
64
|
+
} as unknown as InstanceDeps["docker"];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function mockEventEmitter(): FleetEventEmitter {
|
|
68
|
+
return { emit: vi.fn() } as unknown as FleetEventEmitter;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function mockMetricsTracker(): BotMetricsTracker {
|
|
72
|
+
return { reset: vi.fn(), getMetrics: vi.fn().mockReturnValue(null) } as unknown as BotMetricsTracker;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function buildInstance(overrides: Partial<InstanceDeps> = {}): {
|
|
76
|
+
instance: Instance;
|
|
77
|
+
container: ReturnType<typeof mockContainer>;
|
|
78
|
+
docker: InstanceDeps["docker"];
|
|
79
|
+
emitter: FleetEventEmitter;
|
|
80
|
+
metrics: BotMetricsTracker;
|
|
81
|
+
} {
|
|
82
|
+
const container = mockContainer();
|
|
83
|
+
const docker = mockDocker(container);
|
|
84
|
+
const emitter = mockEventEmitter();
|
|
85
|
+
const metrics = mockMetricsTracker();
|
|
86
|
+
const profile = makeProfile();
|
|
87
|
+
|
|
88
|
+
const instance = new Instance({
|
|
89
|
+
docker,
|
|
90
|
+
profile,
|
|
91
|
+
containerId: "abc123",
|
|
92
|
+
containerName: "wopr-test-bot",
|
|
93
|
+
url: "http://wopr-test-bot:7437",
|
|
94
|
+
eventEmitter: emitter,
|
|
95
|
+
botMetricsTracker: metrics,
|
|
96
|
+
...overrides,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
return { instance, container, docker, emitter, metrics };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function buildRemoteInstance(): Instance {
|
|
103
|
+
const container = mockContainer();
|
|
104
|
+
const docker = mockDocker(container);
|
|
105
|
+
return new Instance({
|
|
106
|
+
docker,
|
|
107
|
+
profile: makeProfile(),
|
|
108
|
+
containerId: "remote:node-3",
|
|
109
|
+
containerName: "wopr-test-bot",
|
|
110
|
+
url: "remote://node-3/wopr-test-bot",
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Tests
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
describe("Instance", () => {
|
|
119
|
+
beforeEach(() => {
|
|
120
|
+
vi.useFakeTimers();
|
|
121
|
+
});
|
|
122
|
+
afterEach(() => {
|
|
123
|
+
vi.useRealTimers();
|
|
124
|
+
vi.restoreAllMocks();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// -----------------------------------------------------------------------
|
|
128
|
+
// P0: Remote guard
|
|
129
|
+
// -----------------------------------------------------------------------
|
|
130
|
+
describe("remote instances", () => {
|
|
131
|
+
const ops = [
|
|
132
|
+
"start",
|
|
133
|
+
"stop",
|
|
134
|
+
"restart",
|
|
135
|
+
"remove",
|
|
136
|
+
"pullImage",
|
|
137
|
+
"logs",
|
|
138
|
+
"logStream",
|
|
139
|
+
"getVolumeUsage",
|
|
140
|
+
"status",
|
|
141
|
+
"containerState",
|
|
142
|
+
] as const;
|
|
143
|
+
|
|
144
|
+
for (const op of ops) {
|
|
145
|
+
it(`${op}() throws on remote instance`, async () => {
|
|
146
|
+
const remote = buildRemoteInstance();
|
|
147
|
+
const args = op === "logStream" ? [{}] : [];
|
|
148
|
+
await expect((remote[op] as (...a: unknown[]) => Promise<unknown>)(...args)).rejects.toThrow(
|
|
149
|
+
"not supported on remote instances",
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
it("emitCreated() works on remote instances (no Docker)", () => {
|
|
155
|
+
const emitter = mockEventEmitter();
|
|
156
|
+
const docker = mockDocker(mockContainer());
|
|
157
|
+
const remote = new Instance({
|
|
158
|
+
docker,
|
|
159
|
+
profile: makeProfile(),
|
|
160
|
+
containerId: "remote:node-5",
|
|
161
|
+
containerName: "wopr-test-bot",
|
|
162
|
+
url: "remote://node-5/wopr-test-bot",
|
|
163
|
+
eventEmitter: emitter,
|
|
164
|
+
});
|
|
165
|
+
expect(() => remote.emitCreated()).not.toThrow();
|
|
166
|
+
expect((emitter.emit as Mock).mock.calls[0][0]).toMatchObject({ type: "bot.created" });
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// -----------------------------------------------------------------------
|
|
171
|
+
// restart()
|
|
172
|
+
// -----------------------------------------------------------------------
|
|
173
|
+
describe("restart()", () => {
|
|
174
|
+
it("restarts a running container and emits event", async () => {
|
|
175
|
+
const { instance, container, emitter } = buildInstance();
|
|
176
|
+
await instance.restart();
|
|
177
|
+
expect(container.inspect).toHaveBeenCalled();
|
|
178
|
+
expect(container.restart).toHaveBeenCalled();
|
|
179
|
+
expect((emitter.emit as Mock).mock.calls[0][0]).toMatchObject({ type: "bot.restarted" });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("rejects when container is in paused state", async () => {
|
|
183
|
+
const container = mockContainer({ Running: false, Status: "paused" });
|
|
184
|
+
const docker = mockDocker(container);
|
|
185
|
+
const instance = new Instance({
|
|
186
|
+
docker,
|
|
187
|
+
profile: makeProfile(),
|
|
188
|
+
containerId: "abc123",
|
|
189
|
+
containerName: "wopr-test-bot",
|
|
190
|
+
url: "http://wopr-test-bot:7437",
|
|
191
|
+
});
|
|
192
|
+
await expect(instance.restart()).rejects.toThrow(/Cannot restart.*paused/);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("accepts stopped/exited/dead states", async () => {
|
|
196
|
+
for (const status of ["stopped", "exited", "dead"]) {
|
|
197
|
+
const container = mockContainer({ Running: false, Status: status });
|
|
198
|
+
const docker = mockDocker(container);
|
|
199
|
+
const instance = new Instance({
|
|
200
|
+
docker,
|
|
201
|
+
profile: makeProfile(),
|
|
202
|
+
containerId: "abc123",
|
|
203
|
+
containerName: "wopr-test-bot",
|
|
204
|
+
url: "http://wopr-test-bot:7437",
|
|
205
|
+
});
|
|
206
|
+
await expect(instance.restart()).resolves.toBeUndefined();
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("resets metrics tracker on restart", async () => {
|
|
211
|
+
const { instance, metrics } = buildInstance();
|
|
212
|
+
await instance.restart();
|
|
213
|
+
expect(metrics.reset as Mock).toHaveBeenCalledWith("bot-1");
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// -----------------------------------------------------------------------
|
|
218
|
+
// pullImage()
|
|
219
|
+
// -----------------------------------------------------------------------
|
|
220
|
+
describe("pullImage()", () => {
|
|
221
|
+
it("pulls image without auth when no env vars set", async () => {
|
|
222
|
+
const { instance, docker } = buildInstance();
|
|
223
|
+
const pullMock = docker.pull as Mock;
|
|
224
|
+
pullMock.mockResolvedValue("stream");
|
|
225
|
+
await instance.pullImage();
|
|
226
|
+
expect(pullMock).toHaveBeenCalledWith("ghcr.io/wopr-network/test:latest", {});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("pulls image with auth when registry env vars are set", async () => {
|
|
230
|
+
process.env.REGISTRY_USERNAME = "user";
|
|
231
|
+
process.env.REGISTRY_PASSWORD = "pass";
|
|
232
|
+
process.env.REGISTRY_SERVER = "ghcr.io";
|
|
233
|
+
try {
|
|
234
|
+
const { instance, docker } = buildInstance();
|
|
235
|
+
const pullMock = docker.pull as Mock;
|
|
236
|
+
pullMock.mockResolvedValue("stream");
|
|
237
|
+
await instance.pullImage();
|
|
238
|
+
expect(pullMock).toHaveBeenCalledWith("ghcr.io/wopr-network/test:latest", {
|
|
239
|
+
authconfig: { username: "user", password: "pass", serveraddress: "ghcr.io" },
|
|
240
|
+
});
|
|
241
|
+
} finally {
|
|
242
|
+
delete process.env.REGISTRY_USERNAME;
|
|
243
|
+
delete process.env.REGISTRY_PASSWORD;
|
|
244
|
+
delete process.env.REGISTRY_SERVER;
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// -----------------------------------------------------------------------
|
|
250
|
+
// logs()
|
|
251
|
+
// -----------------------------------------------------------------------
|
|
252
|
+
describe("logs()", () => {
|
|
253
|
+
it("returns demuxed log output", async () => {
|
|
254
|
+
const { instance, container } = buildInstance();
|
|
255
|
+
// Build a Docker multiplexed frame: 8-byte header + payload
|
|
256
|
+
const payload = Buffer.from("hello from container\n");
|
|
257
|
+
const header = Buffer.alloc(8);
|
|
258
|
+
header.writeUInt8(1, 0); // stdout stream
|
|
259
|
+
header.writeUInt32BE(payload.length, 4);
|
|
260
|
+
const frame = Buffer.concat([header, payload]);
|
|
261
|
+
container.logs.mockResolvedValue(frame);
|
|
262
|
+
|
|
263
|
+
const result = await instance.logs(50);
|
|
264
|
+
expect(result).toBe("hello from container\n");
|
|
265
|
+
expect(container.logs).toHaveBeenCalledWith(
|
|
266
|
+
expect.objectContaining({ stdout: true, stderr: true, tail: 50, timestamps: true }),
|
|
267
|
+
);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// -----------------------------------------------------------------------
|
|
272
|
+
// getVolumeUsage()
|
|
273
|
+
// -----------------------------------------------------------------------
|
|
274
|
+
describe("getVolumeUsage()", () => {
|
|
275
|
+
it("returns parsed df output for a running container", async () => {
|
|
276
|
+
const { instance, container } = buildInstance();
|
|
277
|
+
const dfOutput =
|
|
278
|
+
"Filesystem 1B-blocks Used Available Use% Mounted on\n/dev/sda1 1073741824 536870912 536870912 50% /data\n";
|
|
279
|
+
const mockStream = new PassThrough();
|
|
280
|
+
const execObj = {
|
|
281
|
+
start: vi.fn((_opts: unknown, cb: (err: Error | null, stream: NodeJS.ReadableStream) => void) => {
|
|
282
|
+
cb(null, mockStream);
|
|
283
|
+
mockStream.end(dfOutput);
|
|
284
|
+
}),
|
|
285
|
+
};
|
|
286
|
+
container.exec.mockResolvedValue(execObj);
|
|
287
|
+
|
|
288
|
+
const result = await instance.getVolumeUsage();
|
|
289
|
+
expect(result).toEqual({
|
|
290
|
+
totalBytes: 1073741824,
|
|
291
|
+
usedBytes: 536870912,
|
|
292
|
+
availableBytes: 536870912,
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("returns null when container is not running", async () => {
|
|
297
|
+
const container = mockContainer({ Running: false, Status: "stopped" });
|
|
298
|
+
const docker = mockDocker(container);
|
|
299
|
+
const instance = new Instance({
|
|
300
|
+
docker,
|
|
301
|
+
profile: makeProfile(),
|
|
302
|
+
containerId: "abc123",
|
|
303
|
+
containerName: "wopr-test-bot",
|
|
304
|
+
url: "http://wopr-test-bot:7437",
|
|
305
|
+
});
|
|
306
|
+
const result = await instance.getVolumeUsage();
|
|
307
|
+
expect(result).toBeNull();
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// -----------------------------------------------------------------------
|
|
312
|
+
// status()
|
|
313
|
+
// -----------------------------------------------------------------------
|
|
314
|
+
describe("status()", () => {
|
|
315
|
+
it("returns BotStatus with stats for running container", async () => {
|
|
316
|
+
const { instance } = buildInstance();
|
|
317
|
+
const st = await instance.status();
|
|
318
|
+
expect(st.id).toBe("bot-1");
|
|
319
|
+
expect(st.state).toBe("running");
|
|
320
|
+
expect(st.containerId).toBe("abc123");
|
|
321
|
+
expect(st.stats).toBeDefined();
|
|
322
|
+
expect(st.stats?.cpuPercent).toBeGreaterThanOrEqual(0);
|
|
323
|
+
expect(st.stats?.memoryUsageMb).toBe(100);
|
|
324
|
+
expect(st.health).toBe("healthy");
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("returns offline status when container is gone", async () => {
|
|
328
|
+
const container = mockContainer();
|
|
329
|
+
container.inspect.mockRejectedValue(new Error("No such container"));
|
|
330
|
+
const docker = mockDocker(container);
|
|
331
|
+
const instance = new Instance({
|
|
332
|
+
docker,
|
|
333
|
+
profile: makeProfile(),
|
|
334
|
+
containerId: "abc123",
|
|
335
|
+
containerName: "wopr-test-bot",
|
|
336
|
+
url: "http://wopr-test-bot:7437",
|
|
337
|
+
});
|
|
338
|
+
const st = await instance.status();
|
|
339
|
+
expect(st.state).toBe("stopped");
|
|
340
|
+
expect(st.containerId).toBeNull();
|
|
341
|
+
expect(st.stats).toBeNull();
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// -----------------------------------------------------------------------
|
|
346
|
+
// Concurrency lock
|
|
347
|
+
// -----------------------------------------------------------------------
|
|
348
|
+
describe("withLock serialization", () => {
|
|
349
|
+
it("serializes concurrent restart calls", async () => {
|
|
350
|
+
const callOrder: string[] = [];
|
|
351
|
+
const container = mockContainer();
|
|
352
|
+
const docker = mockDocker(container);
|
|
353
|
+
|
|
354
|
+
// Make restart take some time
|
|
355
|
+
container.inspect.mockImplementation(async () => {
|
|
356
|
+
callOrder.push("inspect-start");
|
|
357
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
358
|
+
callOrder.push("inspect-end");
|
|
359
|
+
return {
|
|
360
|
+
Id: "abc123",
|
|
361
|
+
Name: "/wopr-test-bot",
|
|
362
|
+
Created: "2026-01-01T00:00:00Z",
|
|
363
|
+
State: { Running: true, Status: "running", StartedAt: "2026-01-01T00:00:00Z" },
|
|
364
|
+
};
|
|
365
|
+
});
|
|
366
|
+
container.restart.mockImplementation(async () => {
|
|
367
|
+
callOrder.push("restart");
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
const instance = new Instance({
|
|
371
|
+
docker,
|
|
372
|
+
profile: makeProfile(),
|
|
373
|
+
containerId: "abc123",
|
|
374
|
+
containerName: "wopr-test-bot",
|
|
375
|
+
url: "http://wopr-test-bot:7437",
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// Fire two concurrent restarts
|
|
379
|
+
const p1 = instance.restart();
|
|
380
|
+
const p2 = instance.restart();
|
|
381
|
+
|
|
382
|
+
// Advance timers to let both complete
|
|
383
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
384
|
+
await Promise.all([p1, p2]);
|
|
385
|
+
|
|
386
|
+
// Should see two full cycles without interleaving
|
|
387
|
+
expect(callOrder).toEqual(["inspect-start", "inspect-end", "restart", "inspect-start", "inspect-end", "restart"]);
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
});
|