kova-node-cli 0.1.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/README.md +138 -0
- package/bin/cli.js +2 -0
- package/dist/__tests__/auto-bidder.test.js +267 -0
- package/dist/__tests__/container-manager.test.js +189 -0
- package/dist/__tests__/deployment-executor.test.js +332 -0
- package/dist/__tests__/heartbeat.test.js +191 -0
- package/dist/__tests__/lease-handler.test.js +268 -0
- package/dist/__tests__/resource-limits.test.js +164 -0
- package/dist/api/server.js +607 -0
- package/dist/cli.js +47 -0
- package/dist/commands/deploy.js +568 -0
- package/dist/commands/earnings.js +70 -0
- package/dist/commands/start.js +358 -0
- package/dist/commands/status.js +50 -0
- package/dist/commands/stop.js +101 -0
- package/dist/lib/client.js +87 -0
- package/dist/lib/config.js +107 -0
- package/dist/lib/docker.js +415 -0
- package/dist/lib/logger.js +12 -0
- package/dist/lib/message-signer.js +93 -0
- package/dist/lib/monitor.js +105 -0
- package/dist/lib/p2p.js +186 -0
- package/dist/lib/resource-limits.js +84 -0
- package/dist/lib/state.js +113 -0
- package/dist/lib/types.js +2 -0
- package/dist/lib/usage-meter.js +63 -0
- package/dist/services/auto-bidder.js +332 -0
- package/dist/services/container-manager.js +282 -0
- package/dist/services/deployment-executor.js +1562 -0
- package/dist/services/heartbeat.js +110 -0
- package/dist/services/job-handler.js +241 -0
- package/dist/services/lease-handler.js +382 -0
- package/package.json +51 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// tests for DeploymentExecutor - deployment lifecycle, shell sessions, events
|
|
2
|
+
jest.mock('../lib/logger.js', () => ({
|
|
3
|
+
logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() }
|
|
4
|
+
}));
|
|
5
|
+
// mock dockerode
|
|
6
|
+
const mockStream = {
|
|
7
|
+
on: jest.fn(),
|
|
8
|
+
write: jest.fn(),
|
|
9
|
+
end: jest.fn()
|
|
10
|
+
};
|
|
11
|
+
const mockExec = {
|
|
12
|
+
start: jest.fn().mockResolvedValue(mockStream),
|
|
13
|
+
resize: jest.fn()
|
|
14
|
+
};
|
|
15
|
+
const mockContainerInstance = {
|
|
16
|
+
id: 'ctr-abc123',
|
|
17
|
+
start: jest.fn().mockResolvedValue(undefined),
|
|
18
|
+
stop: jest.fn().mockResolvedValue(undefined),
|
|
19
|
+
remove: jest.fn().mockResolvedValue(undefined),
|
|
20
|
+
inspect: jest.fn().mockResolvedValue({
|
|
21
|
+
State: { Running: true, Status: 'running', StartedAt: new Date().toISOString(), FinishedAt: '0001-01-01T00:00:00Z', ExitCode: 0 },
|
|
22
|
+
Config: { Image: 'node:20-alpine', Env: [], WorkingDir: '/', ExposedPorts: {} },
|
|
23
|
+
HostConfig: { NetworkMode: 'bridge', Binds: [] },
|
|
24
|
+
RestartCount: 0,
|
|
25
|
+
Mounts: [],
|
|
26
|
+
NetworkSettings: { Networks: {} }
|
|
27
|
+
}),
|
|
28
|
+
exec: jest.fn().mockResolvedValue(mockExec),
|
|
29
|
+
logs: jest.fn().mockImplementation((_opts, cb) => {
|
|
30
|
+
// noop - just don't call cb to avoid issues
|
|
31
|
+
}),
|
|
32
|
+
modem: {
|
|
33
|
+
demuxStream: jest.fn()
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
const mockNetwork = {
|
|
37
|
+
id: 'net-123',
|
|
38
|
+
remove: jest.fn().mockResolvedValue(undefined)
|
|
39
|
+
};
|
|
40
|
+
const mockVolume = {
|
|
41
|
+
Name: 'kova-deploy1-web-data',
|
|
42
|
+
remove: jest.fn().mockResolvedValue(undefined)
|
|
43
|
+
};
|
|
44
|
+
const mockDocker = {
|
|
45
|
+
createContainer: jest.fn().mockResolvedValue(mockContainerInstance),
|
|
46
|
+
createNetwork: jest.fn().mockResolvedValue(mockNetwork),
|
|
47
|
+
createVolume: jest.fn().mockResolvedValue(mockVolume),
|
|
48
|
+
getContainer: jest.fn().mockReturnValue(mockContainerInstance),
|
|
49
|
+
getNetwork: jest.fn().mockReturnValue(mockNetwork),
|
|
50
|
+
getVolume: jest.fn().mockReturnValue(mockVolume),
|
|
51
|
+
listContainers: jest.fn().mockResolvedValue([]),
|
|
52
|
+
listNetworks: jest.fn().mockResolvedValue([]),
|
|
53
|
+
listVolumes: jest.fn().mockResolvedValue({ Volumes: [] }),
|
|
54
|
+
pull: jest.fn().mockImplementation((_image, _optsOrCb, maybeCb) => {
|
|
55
|
+
// handle both pull(image, cb) and pull(image, opts, cb) signatures
|
|
56
|
+
const cb = typeof maybeCb === 'function' ? maybeCb : _optsOrCb;
|
|
57
|
+
const fakeStream = { on: jest.fn() };
|
|
58
|
+
cb(null, fakeStream);
|
|
59
|
+
}),
|
|
60
|
+
modem: {
|
|
61
|
+
followProgress: jest.fn().mockImplementation((_stream, onFinish) => {
|
|
62
|
+
onFinish(null);
|
|
63
|
+
})
|
|
64
|
+
},
|
|
65
|
+
run: jest.fn().mockResolvedValue(undefined)
|
|
66
|
+
};
|
|
67
|
+
jest.mock('dockerode', () => {
|
|
68
|
+
return jest.fn().mockImplementation(() => mockDocker);
|
|
69
|
+
});
|
|
70
|
+
import { DeploymentExecutor } from '../services/deployment-executor';
|
|
71
|
+
describe('DeploymentExecutor', () => {
|
|
72
|
+
let executor;
|
|
73
|
+
const simpleManifest = {
|
|
74
|
+
version: '2.0',
|
|
75
|
+
services: {
|
|
76
|
+
web: {
|
|
77
|
+
image: 'nginx:latest',
|
|
78
|
+
expose: [{ port: 80, as: 80, to: [{ global: true }] }]
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
profiles: {
|
|
82
|
+
compute: {
|
|
83
|
+
webProfile: {
|
|
84
|
+
resources: {
|
|
85
|
+
cpu: { units: 2 },
|
|
86
|
+
memory: { size: '512Mi' }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
deployment: {
|
|
92
|
+
web: {
|
|
93
|
+
myProvider: { profile: 'webProfile', count: 1 }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
jest.clearAllMocks();
|
|
99
|
+
// reset container inspect to say it's not running (for create flow)
|
|
100
|
+
mockContainerInstance.inspect.mockRejectedValue(new Error('not found'));
|
|
101
|
+
executor = new DeploymentExecutor({
|
|
102
|
+
orchestratorUrl: 'http://localhost:3000',
|
|
103
|
+
apiKey: 'sk_test_key'
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
// -- executeDeployment --
|
|
107
|
+
it('should create network, pull image, create and start container', async () => {
|
|
108
|
+
await executor.executeDeployment({
|
|
109
|
+
deploymentId: 'deploy-1',
|
|
110
|
+
leaseId: 'lease-1',
|
|
111
|
+
manifest: simpleManifest
|
|
112
|
+
});
|
|
113
|
+
expect(mockDocker.createNetwork).toHaveBeenCalledWith(expect.objectContaining({
|
|
114
|
+
Name: expect.stringContaining('kova-deploy-'),
|
|
115
|
+
Driver: 'bridge'
|
|
116
|
+
}));
|
|
117
|
+
expect(mockDocker.pull).toHaveBeenCalledWith('nginx:latest', expect.any(Object), expect.any(Function));
|
|
118
|
+
expect(mockDocker.createContainer).toHaveBeenCalledWith(expect.objectContaining({
|
|
119
|
+
Image: 'nginx:latest',
|
|
120
|
+
Labels: expect.objectContaining({
|
|
121
|
+
'kova.deployment': 'deploy-1',
|
|
122
|
+
'kova.service': 'web'
|
|
123
|
+
})
|
|
124
|
+
}));
|
|
125
|
+
expect(mockContainerInstance.start).toHaveBeenCalled();
|
|
126
|
+
});
|
|
127
|
+
it('should emit deployment-started event', async () => {
|
|
128
|
+
const events = [];
|
|
129
|
+
executor.on('deployment-started', (data) => events.push(data));
|
|
130
|
+
await executor.executeDeployment({
|
|
131
|
+
deploymentId: 'deploy-1',
|
|
132
|
+
leaseId: 'lease-1',
|
|
133
|
+
manifest: simpleManifest
|
|
134
|
+
});
|
|
135
|
+
expect(events).toHaveLength(1);
|
|
136
|
+
expect(events[0].deploymentId).toBe('deploy-1');
|
|
137
|
+
expect(events[0].leaseId).toBe('lease-1');
|
|
138
|
+
});
|
|
139
|
+
it('should track deployment in running list', async () => {
|
|
140
|
+
await executor.executeDeployment({
|
|
141
|
+
deploymentId: 'deploy-1',
|
|
142
|
+
leaseId: 'lease-1',
|
|
143
|
+
manifest: simpleManifest
|
|
144
|
+
});
|
|
145
|
+
expect(executor.getRunningDeployments()).toContain('deploy-1');
|
|
146
|
+
});
|
|
147
|
+
it('should return deployment info via getDeployment', async () => {
|
|
148
|
+
await executor.executeDeployment({
|
|
149
|
+
deploymentId: 'deploy-1',
|
|
150
|
+
leaseId: 'lease-1',
|
|
151
|
+
manifest: simpleManifest
|
|
152
|
+
});
|
|
153
|
+
const dep = executor.getDeployment('deploy-1');
|
|
154
|
+
expect(dep).toBeDefined();
|
|
155
|
+
expect(dep.deploymentId).toBe('deploy-1');
|
|
156
|
+
expect(dep.leaseId).toBe('lease-1');
|
|
157
|
+
});
|
|
158
|
+
it('should return undefined for unknown deployment', () => {
|
|
159
|
+
expect(executor.getDeployment('nope')).toBeUndefined();
|
|
160
|
+
});
|
|
161
|
+
// -- resource limits from manifest --
|
|
162
|
+
it('should apply memory limit from compute profile', async () => {
|
|
163
|
+
await executor.executeDeployment({
|
|
164
|
+
deploymentId: 'deploy-1',
|
|
165
|
+
leaseId: 'lease-1',
|
|
166
|
+
manifest: simpleManifest
|
|
167
|
+
});
|
|
168
|
+
const containerConfig = mockDocker.createContainer.mock.calls[0][0];
|
|
169
|
+
// 512Mi = 512 * 1024^2 = 536870912 bytes
|
|
170
|
+
expect(containerConfig.HostConfig.Memory).toBe(536870912);
|
|
171
|
+
});
|
|
172
|
+
it('should apply cpu limit from compute profile', async () => {
|
|
173
|
+
await executor.executeDeployment({
|
|
174
|
+
deploymentId: 'deploy-1',
|
|
175
|
+
leaseId: 'lease-1',
|
|
176
|
+
manifest: simpleManifest
|
|
177
|
+
});
|
|
178
|
+
const containerConfig = mockDocker.createContainer.mock.calls[0][0];
|
|
179
|
+
// 2 cores: CpuQuota = 2 * 100000 = 200000
|
|
180
|
+
expect(containerConfig.HostConfig.CpuQuota).toBe(200000);
|
|
181
|
+
});
|
|
182
|
+
// -- stopDeployment --
|
|
183
|
+
it('should stop containers but preserve volumes', async () => {
|
|
184
|
+
await executor.executeDeployment({
|
|
185
|
+
deploymentId: 'deploy-1',
|
|
186
|
+
leaseId: 'lease-1',
|
|
187
|
+
manifest: simpleManifest
|
|
188
|
+
});
|
|
189
|
+
await executor.stopDeployment('deploy-1');
|
|
190
|
+
expect(mockContainerInstance.stop).toHaveBeenCalled();
|
|
191
|
+
expect(mockContainerInstance.remove).toHaveBeenCalled();
|
|
192
|
+
expect(executor.getRunningDeployments()).not.toContain('deploy-1');
|
|
193
|
+
});
|
|
194
|
+
it('should silently handle stop for unknown deployment', async () => {
|
|
195
|
+
await executor.stopDeployment('nonexistent');
|
|
196
|
+
// no throw
|
|
197
|
+
});
|
|
198
|
+
// -- closeDeployment --
|
|
199
|
+
it('should stop containers and delete volumes', async () => {
|
|
200
|
+
await executor.executeDeployment({
|
|
201
|
+
deploymentId: 'deploy-1',
|
|
202
|
+
leaseId: 'lease-1',
|
|
203
|
+
manifest: simpleManifest
|
|
204
|
+
});
|
|
205
|
+
await executor.closeDeployment('deploy-1');
|
|
206
|
+
expect(mockContainerInstance.stop).toHaveBeenCalled();
|
|
207
|
+
expect(executor.getRunningDeployments()).not.toContain('deploy-1');
|
|
208
|
+
// should attempt to list and cleanup volumes
|
|
209
|
+
expect(mockDocker.listVolumes).toHaveBeenCalled();
|
|
210
|
+
});
|
|
211
|
+
it('should attempt volume cleanup even if deployment is not in memory', async () => {
|
|
212
|
+
await executor.closeDeployment('orphaned-deploy');
|
|
213
|
+
expect(mockDocker.listVolumes).toHaveBeenCalledWith({
|
|
214
|
+
filters: { name: ['kova-orphaned-deploy'] }
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
// -- shell sessions --
|
|
218
|
+
it('should start shell session in deployment container', async () => {
|
|
219
|
+
await executor.executeDeployment({
|
|
220
|
+
deploymentId: 'deploy-1',
|
|
221
|
+
leaseId: 'lease-1',
|
|
222
|
+
manifest: simpleManifest
|
|
223
|
+
});
|
|
224
|
+
// mock container inspect to say it's running for shell
|
|
225
|
+
mockContainerInstance.inspect.mockResolvedValue({
|
|
226
|
+
State: { Running: true, Status: 'running', StartedAt: new Date().toISOString(), FinishedAt: '0001-01-01T00:00:00Z', ExitCode: 0 },
|
|
227
|
+
Config: { Image: 'node:20-alpine', Env: [], WorkingDir: '/', ExposedPorts: {} },
|
|
228
|
+
HostConfig: { NetworkMode: 'bridge', Binds: [] },
|
|
229
|
+
RestartCount: 0,
|
|
230
|
+
Mounts: [],
|
|
231
|
+
NetworkSettings: { Networks: {} }
|
|
232
|
+
});
|
|
233
|
+
const onOutput = jest.fn();
|
|
234
|
+
const result = await executor.startShellSession('sess-1', 'deploy-1', 'web', onOutput);
|
|
235
|
+
expect(result).toEqual({ success: true });
|
|
236
|
+
expect(mockContainerInstance.exec).toHaveBeenCalledWith(expect.objectContaining({
|
|
237
|
+
Cmd: ['/bin/sh'],
|
|
238
|
+
AttachStdin: true,
|
|
239
|
+
AttachStdout: true,
|
|
240
|
+
Tty: true
|
|
241
|
+
}));
|
|
242
|
+
});
|
|
243
|
+
it('should return error for shell on unknown deployment', async () => {
|
|
244
|
+
const result = await executor.startShellSession('sess-1', 'nope', 'web', jest.fn());
|
|
245
|
+
expect(result.success).toBe(false);
|
|
246
|
+
expect(result.error).toBeDefined();
|
|
247
|
+
});
|
|
248
|
+
it('should send input to shell session', async () => {
|
|
249
|
+
await executor.executeDeployment({
|
|
250
|
+
deploymentId: 'deploy-1',
|
|
251
|
+
leaseId: 'lease-1',
|
|
252
|
+
manifest: simpleManifest
|
|
253
|
+
});
|
|
254
|
+
mockContainerInstance.inspect.mockResolvedValue({
|
|
255
|
+
State: { Running: true }, Config: { Image: 'test', Env: [], WorkingDir: '/', ExposedPorts: {} },
|
|
256
|
+
HostConfig: { NetworkMode: 'bridge', Binds: [] }, RestartCount: 0, Mounts: [], NetworkSettings: { Networks: {} }
|
|
257
|
+
});
|
|
258
|
+
await executor.startShellSession('sess-1', 'deploy-1', 'web', jest.fn());
|
|
259
|
+
const ok = executor.sendShellInput('sess-1', 'ls\n');
|
|
260
|
+
expect(ok).toBe(true);
|
|
261
|
+
expect(mockStream.write).toHaveBeenCalledWith('ls\n');
|
|
262
|
+
});
|
|
263
|
+
it('should return false for input to nonexistent session', () => {
|
|
264
|
+
expect(executor.sendShellInput('nope', 'ls')).toBe(false);
|
|
265
|
+
});
|
|
266
|
+
it('should resize shell terminal', async () => {
|
|
267
|
+
await executor.executeDeployment({
|
|
268
|
+
deploymentId: 'deploy-1',
|
|
269
|
+
leaseId: 'lease-1',
|
|
270
|
+
manifest: simpleManifest
|
|
271
|
+
});
|
|
272
|
+
mockContainerInstance.inspect.mockResolvedValue({
|
|
273
|
+
State: { Running: true }, Config: { Image: 'test', Env: [], WorkingDir: '/', ExposedPorts: {} },
|
|
274
|
+
HostConfig: { NetworkMode: 'bridge', Binds: [] }, RestartCount: 0, Mounts: [], NetworkSettings: { Networks: {} }
|
|
275
|
+
});
|
|
276
|
+
await executor.startShellSession('sess-1', 'deploy-1', 'web', jest.fn());
|
|
277
|
+
const ok = executor.resizeShell('sess-1', 120, 40);
|
|
278
|
+
expect(ok).toBe(true);
|
|
279
|
+
expect(mockExec.resize).toHaveBeenCalledWith({ h: 40, w: 120 });
|
|
280
|
+
});
|
|
281
|
+
it('should close shell session', async () => {
|
|
282
|
+
await executor.executeDeployment({
|
|
283
|
+
deploymentId: 'deploy-1',
|
|
284
|
+
leaseId: 'lease-1',
|
|
285
|
+
manifest: simpleManifest
|
|
286
|
+
});
|
|
287
|
+
mockContainerInstance.inspect.mockResolvedValue({
|
|
288
|
+
State: { Running: true }, Config: { Image: 'test', Env: [], WorkingDir: '/', ExposedPorts: {} },
|
|
289
|
+
HostConfig: { NetworkMode: 'bridge', Binds: [] }, RestartCount: 0, Mounts: [], NetworkSettings: { Networks: {} }
|
|
290
|
+
});
|
|
291
|
+
await executor.startShellSession('sess-1', 'deploy-1', 'web', jest.fn());
|
|
292
|
+
executor.closeShellSession('sess-1');
|
|
293
|
+
expect(mockStream.end).toHaveBeenCalled();
|
|
294
|
+
// session should be gone now
|
|
295
|
+
expect(executor.sendShellInput('sess-1', 'test')).toBe(false);
|
|
296
|
+
});
|
|
297
|
+
// -- security --
|
|
298
|
+
it('should set security options on container', async () => {
|
|
299
|
+
await executor.executeDeployment({
|
|
300
|
+
deploymentId: 'deploy-1',
|
|
301
|
+
leaseId: 'lease-1',
|
|
302
|
+
manifest: simpleManifest
|
|
303
|
+
});
|
|
304
|
+
const containerConfig = mockDocker.createContainer.mock.calls[0][0];
|
|
305
|
+
expect(containerConfig.HostConfig.Privileged).toBe(false);
|
|
306
|
+
expect(containerConfig.HostConfig.CapDrop).toContain('ALL');
|
|
307
|
+
expect(containerConfig.HostConfig.SecurityOpt).toContain('no-new-privileges:true');
|
|
308
|
+
});
|
|
309
|
+
// -- multi-service --
|
|
310
|
+
it('should start multiple services from manifest', async () => {
|
|
311
|
+
const multiManifest = {
|
|
312
|
+
...simpleManifest,
|
|
313
|
+
services: {
|
|
314
|
+
web: { image: 'nginx:latest' },
|
|
315
|
+
api: { image: 'node:20' }
|
|
316
|
+
},
|
|
317
|
+
deployment: {
|
|
318
|
+
web: { myProvider: { profile: 'webProfile', count: 1 } },
|
|
319
|
+
api: { myProvider: { profile: 'webProfile', count: 1 } }
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
await executor.executeDeployment({
|
|
323
|
+
deploymentId: 'deploy-1',
|
|
324
|
+
leaseId: 'lease-1',
|
|
325
|
+
manifest: multiManifest
|
|
326
|
+
});
|
|
327
|
+
// should have pulled both images
|
|
328
|
+
expect(mockDocker.pull).toHaveBeenCalledTimes(2);
|
|
329
|
+
// should have created 2 containers
|
|
330
|
+
expect(mockDocker.createContainer).toHaveBeenCalledTimes(2);
|
|
331
|
+
});
|
|
332
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// tests for HeartbeatService - heartbeat lifecycle, events, payload
|
|
2
|
+
jest.mock('../lib/logger.js', () => ({
|
|
3
|
+
logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() }
|
|
4
|
+
}));
|
|
5
|
+
import { HeartbeatService } from '../services/heartbeat';
|
|
6
|
+
const mockFetch = jest.fn();
|
|
7
|
+
global.fetch = mockFetch;
|
|
8
|
+
// mock resource monitor
|
|
9
|
+
const mockMonitor = {
|
|
10
|
+
getAvailableResources: jest.fn().mockResolvedValue({
|
|
11
|
+
cpu: { cores: 8, available: 6 },
|
|
12
|
+
memory: { total: 32, available: 24 },
|
|
13
|
+
disk: [{ path: '/', total: 500, available: 400 }],
|
|
14
|
+
network: { bandwidth: 100 },
|
|
15
|
+
gpu: [{ vendor: 'nvidia', model: 'rtx 4090', vram: 24 }]
|
|
16
|
+
}),
|
|
17
|
+
start: jest.fn(),
|
|
18
|
+
stop: jest.fn()
|
|
19
|
+
};
|
|
20
|
+
// mock limit manager
|
|
21
|
+
const mockLimitManager = {
|
|
22
|
+
getLimits: jest.fn().mockReturnValue({ cpu: 4, memory: 16, disk: 200 }),
|
|
23
|
+
getAvailableResources: jest.fn().mockReturnValue({ cpu: 3, memory: 12, disk: 150 }),
|
|
24
|
+
getCurrentUsage: jest.fn().mockReturnValue({ cpu: 1, memory: 4, disk: 50 })
|
|
25
|
+
};
|
|
26
|
+
describe('HeartbeatService', () => {
|
|
27
|
+
let hb;
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
jest.clearAllMocks();
|
|
30
|
+
jest.useFakeTimers();
|
|
31
|
+
hb = new HeartbeatService('node-abc', 'http://localhost:3000', mockMonitor, mockLimitManager, 30 // 30s interval
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
afterEach(async () => {
|
|
35
|
+
await hb.stop();
|
|
36
|
+
jest.useRealTimers();
|
|
37
|
+
});
|
|
38
|
+
// -- start/stop --
|
|
39
|
+
it('should send initial heartbeat on start', async () => {
|
|
40
|
+
mockFetch.mockResolvedValue({
|
|
41
|
+
ok: true,
|
|
42
|
+
json: async () => ({ timestamp: Date.now() })
|
|
43
|
+
});
|
|
44
|
+
await hb.start();
|
|
45
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
46
|
+
expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/nodes/node-abc/heartbeat', expect.objectContaining({
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: { 'Content-Type': 'application/json' }
|
|
49
|
+
}));
|
|
50
|
+
});
|
|
51
|
+
it('should mark as active after start', async () => {
|
|
52
|
+
mockFetch.mockResolvedValue({
|
|
53
|
+
ok: true,
|
|
54
|
+
json: async () => ({})
|
|
55
|
+
});
|
|
56
|
+
expect(hb.isActive()).toBe(false);
|
|
57
|
+
await hb.start();
|
|
58
|
+
expect(hb.isActive()).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
it('should mark as inactive after stop', async () => {
|
|
61
|
+
mockFetch.mockResolvedValue({
|
|
62
|
+
ok: true,
|
|
63
|
+
json: async () => ({})
|
|
64
|
+
});
|
|
65
|
+
await hb.start();
|
|
66
|
+
await hb.stop();
|
|
67
|
+
expect(hb.isActive()).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
it('should not start twice', async () => {
|
|
70
|
+
mockFetch.mockResolvedValue({
|
|
71
|
+
ok: true,
|
|
72
|
+
json: async () => ({})
|
|
73
|
+
});
|
|
74
|
+
await hb.start();
|
|
75
|
+
await hb.start();
|
|
76
|
+
// only 1 initial heartbeat
|
|
77
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
78
|
+
});
|
|
79
|
+
// -- heartbeat payload --
|
|
80
|
+
it('should send provider limits in payload, not raw system resources', async () => {
|
|
81
|
+
mockFetch.mockResolvedValue({
|
|
82
|
+
ok: true,
|
|
83
|
+
json: async () => ({})
|
|
84
|
+
});
|
|
85
|
+
await hb.start();
|
|
86
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
87
|
+
// should use limit manager values, not monitor values
|
|
88
|
+
expect(body.resources.cpu.cores).toBe(4); // from getLimits()
|
|
89
|
+
expect(body.resources.cpu.available).toBe(3); // from getAvailableResources()
|
|
90
|
+
expect(body.resources.memory.total).toBe(16);
|
|
91
|
+
expect(body.resources.memory.available).toBe(12);
|
|
92
|
+
});
|
|
93
|
+
it('should include gpu info from monitor', async () => {
|
|
94
|
+
mockFetch.mockResolvedValue({
|
|
95
|
+
ok: true,
|
|
96
|
+
json: async () => ({})
|
|
97
|
+
});
|
|
98
|
+
await hb.start();
|
|
99
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
100
|
+
expect(body.resources.gpu).toHaveLength(1);
|
|
101
|
+
expect(body.resources.gpu[0].vendor).toBe('nvidia');
|
|
102
|
+
});
|
|
103
|
+
// -- events --
|
|
104
|
+
it('should emit heartbeat-success on ok response', async () => {
|
|
105
|
+
mockFetch.mockResolvedValue({
|
|
106
|
+
ok: true,
|
|
107
|
+
json: async () => ({ timestamp: 12345 })
|
|
108
|
+
});
|
|
109
|
+
const events = [];
|
|
110
|
+
hb.on('heartbeat-success', (data) => events.push(data));
|
|
111
|
+
await hb.start();
|
|
112
|
+
expect(events).toHaveLength(1);
|
|
113
|
+
expect(events[0].timestamp).toBe(12345);
|
|
114
|
+
expect(events[0].resources).toBeDefined();
|
|
115
|
+
});
|
|
116
|
+
it('should emit heartbeat-error on non-ok response', async () => {
|
|
117
|
+
mockFetch.mockResolvedValue({
|
|
118
|
+
ok: false,
|
|
119
|
+
status: 500,
|
|
120
|
+
text: async () => 'server error'
|
|
121
|
+
});
|
|
122
|
+
const errors = [];
|
|
123
|
+
hb.on('heartbeat-error', (data) => errors.push(data));
|
|
124
|
+
await hb.start();
|
|
125
|
+
expect(errors).toHaveLength(1);
|
|
126
|
+
expect(errors[0].status).toBe(500);
|
|
127
|
+
});
|
|
128
|
+
it('should emit heartbeat-error on network failure', async () => {
|
|
129
|
+
mockFetch.mockRejectedValue(new Error('connection refused'));
|
|
130
|
+
const errors = [];
|
|
131
|
+
hb.on('heartbeat-error', (data) => errors.push(data));
|
|
132
|
+
await hb.start();
|
|
133
|
+
expect(errors).toHaveLength(1);
|
|
134
|
+
expect(errors[0].error).toBeInstanceOf(Error);
|
|
135
|
+
});
|
|
136
|
+
it('should emit pending-jobs when orchestrator returns them', async () => {
|
|
137
|
+
const pendingJobs = [
|
|
138
|
+
{ id: 'job-1', manifest: {} },
|
|
139
|
+
{ id: 'job-2', manifest: {} }
|
|
140
|
+
];
|
|
141
|
+
mockFetch.mockResolvedValue({
|
|
142
|
+
ok: true,
|
|
143
|
+
json: async () => ({ timestamp: Date.now(), pendingJobs })
|
|
144
|
+
});
|
|
145
|
+
const received = [];
|
|
146
|
+
hb.on('pending-jobs', (jobs) => received.push(jobs));
|
|
147
|
+
await hb.start();
|
|
148
|
+
expect(received).toHaveLength(1);
|
|
149
|
+
expect(received[0]).toEqual(pendingJobs);
|
|
150
|
+
});
|
|
151
|
+
it('should not emit pending-jobs when list is empty', async () => {
|
|
152
|
+
mockFetch.mockResolvedValue({
|
|
153
|
+
ok: true,
|
|
154
|
+
json: async () => ({ timestamp: Date.now(), pendingJobs: [] })
|
|
155
|
+
});
|
|
156
|
+
const received = [];
|
|
157
|
+
hb.on('pending-jobs', (jobs) => received.push(jobs));
|
|
158
|
+
await hb.start();
|
|
159
|
+
expect(received).toHaveLength(0);
|
|
160
|
+
});
|
|
161
|
+
// -- triggerHeartbeat --
|
|
162
|
+
it('should allow manual heartbeat trigger', async () => {
|
|
163
|
+
mockFetch.mockResolvedValue({
|
|
164
|
+
ok: true,
|
|
165
|
+
json: async () => ({})
|
|
166
|
+
});
|
|
167
|
+
await hb.start();
|
|
168
|
+
mockFetch.mockClear();
|
|
169
|
+
await hb.triggerHeartbeat();
|
|
170
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
171
|
+
});
|
|
172
|
+
// -- interval --
|
|
173
|
+
it('should send periodic heartbeats', async () => {
|
|
174
|
+
jest.useRealTimers(); // real timers needed for this test
|
|
175
|
+
const shortHb = new HeartbeatService('node-abc', 'http://localhost:3000', mockMonitor, mockLimitManager, 1 // 1 second interval
|
|
176
|
+
);
|
|
177
|
+
mockFetch.mockResolvedValue({
|
|
178
|
+
ok: true,
|
|
179
|
+
json: async () => ({})
|
|
180
|
+
});
|
|
181
|
+
await shortHb.start();
|
|
182
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
183
|
+
// wait a bit over 1 second for the interval to fire
|
|
184
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
185
|
+
expect(mockFetch.mock.calls.length).toBeGreaterThanOrEqual(2);
|
|
186
|
+
await shortHb.stop();
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
function flushPromises() {
|
|
190
|
+
return new Promise((resolve) => setImmediate(resolve));
|
|
191
|
+
}
|