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.
@@ -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
+ }