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,268 @@
1
+ // tests for LeaseHandler - lease polling, deployment lifecycle, log batching
2
+ jest.mock('../lib/logger.js', () => ({
3
+ logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() }
4
+ }));
5
+ // mock the state manager singleton
6
+ jest.mock('../lib/state.js', () => ({
7
+ stateManager: {
8
+ addDeployment: jest.fn(),
9
+ removeDeployment: jest.fn(),
10
+ getActiveDeployments: jest.fn().mockReturnValue([])
11
+ }
12
+ }));
13
+ // mock dockerode (LeaseHandler creates a Docker instance directly)
14
+ const mockContainer = {
15
+ start: jest.fn().mockResolvedValue(undefined),
16
+ stop: jest.fn().mockResolvedValue(undefined),
17
+ inspect: jest.fn().mockResolvedValue({ State: { Running: true } })
18
+ };
19
+ jest.mock('dockerode', () => {
20
+ return jest.fn().mockImplementation(() => ({
21
+ listContainers: jest.fn().mockResolvedValue([]),
22
+ getContainer: jest.fn().mockReturnValue(mockContainer)
23
+ }));
24
+ });
25
+ import { LeaseHandler } from '../services/lease-handler';
26
+ import { stateManager } from '../lib/state';
27
+ const mockFetch = jest.fn();
28
+ global.fetch = mockFetch;
29
+ // mock executor
30
+ const mockExecutor = {
31
+ executeDeployment: jest.fn().mockResolvedValue(undefined),
32
+ stopDeployment: jest.fn().mockResolvedValue(undefined),
33
+ closeDeployment: jest.fn().mockResolvedValue(undefined),
34
+ getRunningDeployments: jest.fn().mockReturnValue([]),
35
+ getDeployment: jest.fn().mockReturnValue(undefined),
36
+ updateDeploymentFiles: jest.fn().mockResolvedValue(undefined),
37
+ on: jest.fn(),
38
+ emit: jest.fn()
39
+ };
40
+ // mock p2p node
41
+ const mockP2p = {
42
+ on: jest.fn(),
43
+ emit: jest.fn()
44
+ };
45
+ // helper to wait for async stuff to settle
46
+ const settle = (ms = 150) => new Promise(r => setTimeout(r, ms));
47
+ describe('LeaseHandler', () => {
48
+ let handler;
49
+ const config = {
50
+ nodeId: 'node-abc',
51
+ providerId: 'provider-123',
52
+ orchestratorUrl: 'http://localhost:3000',
53
+ apiKey: 'sk_live_testkey'
54
+ };
55
+ const makeLease = (overrides = {}) => ({
56
+ id: 'lease-1',
57
+ deploymentId: 'deploy-1',
58
+ nodeId: 'node-abc',
59
+ pricePerBlock: 0.05,
60
+ manifest: { services: { web: { image: 'nginx' } } },
61
+ manifestVersion: '1',
62
+ filesVersion: 0,
63
+ state: 'active',
64
+ ...overrides
65
+ });
66
+ beforeEach(() => {
67
+ jest.clearAllMocks();
68
+ // reset executor mock
69
+ mockExecutor.getDeployment.mockReturnValue(undefined);
70
+ mockExecutor.getRunningDeployments.mockReturnValue([]);
71
+ handler = new LeaseHandler(config, mockExecutor, mockP2p);
72
+ });
73
+ afterEach(() => {
74
+ handler.stop();
75
+ });
76
+ // -- start/stop --
77
+ it('should start and immediately poll for leases', async () => {
78
+ mockFetch.mockResolvedValue({
79
+ ok: true,
80
+ json: async () => ({ leases: [] })
81
+ });
82
+ handler.start(60000);
83
+ await settle();
84
+ // immediate poll
85
+ expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/provider/leases', expect.objectContaining({
86
+ headers: { 'Authorization': 'Bearer sk_live_testkey' }
87
+ }));
88
+ });
89
+ it('should not start twice', async () => {
90
+ mockFetch.mockResolvedValue({
91
+ ok: true,
92
+ json: async () => ({ leases: [] })
93
+ });
94
+ handler.start(60000);
95
+ await settle();
96
+ const callCount = mockFetch.mock.calls.length;
97
+ handler.start(60000);
98
+ await settle();
99
+ // shouldn't have made additional immediate calls
100
+ expect(mockFetch.mock.calls.length).toBe(callCount);
101
+ });
102
+ it('should clear all intervals on stop', async () => {
103
+ mockFetch.mockResolvedValue({
104
+ ok: true,
105
+ json: async () => ({ leases: [] })
106
+ });
107
+ handler.start(60000);
108
+ handler.stop();
109
+ await settle();
110
+ // no throw, cleanup done
111
+ });
112
+ // -- lease polling --
113
+ it('should execute deployment for new lease', async () => {
114
+ const lease = makeLease();
115
+ mockFetch.mockResolvedValue({
116
+ ok: true,
117
+ json: async () => ({ leases: [lease] })
118
+ });
119
+ handler.start(60000);
120
+ await settle(300);
121
+ expect(mockExecutor.executeDeployment).toHaveBeenCalledWith({
122
+ deploymentId: 'deploy-1',
123
+ leaseId: 'lease-1',
124
+ manifest: lease.manifest
125
+ });
126
+ expect(stateManager.addDeployment).toHaveBeenCalledWith('deploy-1');
127
+ });
128
+ it('should skip leases for other nodes', async () => {
129
+ const lease = makeLease({ nodeId: 'other-node' });
130
+ mockFetch.mockResolvedValueOnce({
131
+ ok: true,
132
+ json: async () => ({ leases: [lease] })
133
+ });
134
+ handler.start(60000);
135
+ await settle(300);
136
+ expect(mockExecutor.executeDeployment).not.toHaveBeenCalled();
137
+ });
138
+ it('should not re-execute already running deployments', async () => {
139
+ const lease = makeLease();
140
+ // pretend it's already running
141
+ mockExecutor.getDeployment.mockReturnValue({ deploymentId: 'deploy-1' });
142
+ mockFetch.mockResolvedValueOnce({
143
+ ok: true,
144
+ json: async () => ({ leases: [lease] })
145
+ });
146
+ handler.start(60000);
147
+ await settle(300);
148
+ expect(mockExecutor.executeDeployment).not.toHaveBeenCalled();
149
+ });
150
+ it('should close deployments whose leases disappeared', async () => {
151
+ // first poll returns a lease
152
+ mockFetch.mockResolvedValueOnce({
153
+ ok: true,
154
+ json: async () => ({ leases: [makeLease()] })
155
+ });
156
+ handler.start(500);
157
+ await settle(300);
158
+ // now pretend executor still tracks it
159
+ mockExecutor.getRunningDeployments.mockReturnValue(['deploy-1']);
160
+ // second poll returns empty (lease gone)
161
+ mockFetch.mockResolvedValue({
162
+ ok: true,
163
+ json: async () => ({ leases: [] })
164
+ });
165
+ await settle(800);
166
+ expect(mockExecutor.closeDeployment).toHaveBeenCalledWith('deploy-1');
167
+ expect(stateManager.removeDeployment).toHaveBeenCalledWith('deploy-1');
168
+ });
169
+ // -- log batching --
170
+ it('should buffer logs from executor log events', () => {
171
+ expect(mockExecutor.on).toHaveBeenCalledWith('log', expect.any(Function));
172
+ });
173
+ it('should flush logs periodically', async () => {
174
+ mockFetch.mockResolvedValue({
175
+ ok: true,
176
+ json: async () => ({ leases: [] })
177
+ });
178
+ handler.start(60000);
179
+ // simulate a log event via the registered callback
180
+ const logCallback = mockExecutor.on.mock.calls.find((c) => c[0] === 'log')[1];
181
+ logCallback({
182
+ deploymentId: 'deploy-1',
183
+ serviceName: 'web',
184
+ logLine: 'test log line',
185
+ stream: 'stdout'
186
+ });
187
+ // wait for log flush (2s interval + margin)
188
+ await settle(2500);
189
+ // should have tried to POST log batch
190
+ const logCalls = mockFetch.mock.calls.filter((c) => c[0]?.includes('/logs/batch'));
191
+ expect(logCalls.length).toBeGreaterThanOrEqual(1);
192
+ });
193
+ it('should flush immediately when buffer hits max size', async () => {
194
+ mockFetch.mockResolvedValue({
195
+ ok: true,
196
+ json: async () => ({ leases: [] })
197
+ });
198
+ handler.start(60000);
199
+ const logCallback = mockExecutor.on.mock.calls.find((c) => c[0] === 'log')[1];
200
+ // send 50 logs to hit the batch max
201
+ for (let i = 0; i < 50; i++) {
202
+ logCallback({
203
+ deploymentId: 'deploy-1',
204
+ serviceName: 'web',
205
+ logLine: `log line ${i}`,
206
+ stream: 'stdout'
207
+ });
208
+ }
209
+ await settle(200);
210
+ // should have posted without waiting for 2s timer
211
+ const logCalls = mockFetch.mock.calls.filter((c) => c[0]?.includes('/logs/batch'));
212
+ expect(logCalls.length).toBeGreaterThanOrEqual(1);
213
+ });
214
+ // -- error handling --
215
+ it('should handle failed lease fetch gracefully', async () => {
216
+ mockFetch.mockResolvedValueOnce({
217
+ ok: false,
218
+ status: 500
219
+ });
220
+ handler.start(60000);
221
+ await settle(300);
222
+ // no crash
223
+ expect(mockExecutor.executeDeployment).not.toHaveBeenCalled();
224
+ });
225
+ it('should handle deployment execution failure', async () => {
226
+ mockExecutor.executeDeployment.mockRejectedValueOnce(new Error('docker broke'));
227
+ const lease = makeLease();
228
+ mockFetch.mockResolvedValueOnce({
229
+ ok: true,
230
+ json: async () => ({ leases: [lease] })
231
+ });
232
+ handler.start(60000);
233
+ await settle(300);
234
+ // should not crash
235
+ expect(mockExecutor.executeDeployment).toHaveBeenCalled();
236
+ });
237
+ it('should use apiKey for auth token', async () => {
238
+ mockFetch.mockResolvedValue({
239
+ ok: true,
240
+ json: async () => ({ leases: [] })
241
+ });
242
+ handler.start(60000);
243
+ await settle();
244
+ const authHeader = mockFetch.mock.calls[0][1].headers['Authorization'];
245
+ expect(authHeader).toBe('Bearer sk_live_testkey');
246
+ });
247
+ // -- files version tracking --
248
+ it('should detect files version changes and update files', async () => {
249
+ const lease = makeLease({ filesVersion: 1 });
250
+ // first poll - new deployment
251
+ mockFetch.mockResolvedValueOnce({
252
+ ok: true,
253
+ json: async () => ({ leases: [lease] })
254
+ });
255
+ handler.start(500);
256
+ await settle(300);
257
+ // now the deployment is "running"
258
+ mockExecutor.getDeployment.mockReturnValue({ deploymentId: 'deploy-1' });
259
+ // second poll - files version bumped
260
+ const updatedLease = makeLease({ filesVersion: 2 });
261
+ mockFetch.mockResolvedValue({
262
+ ok: true,
263
+ json: async () => ({ leases: [updatedLease] })
264
+ });
265
+ await settle(800);
266
+ expect(mockExecutor.updateDeploymentFiles).toHaveBeenCalledWith('deploy-1', 'web');
267
+ });
268
+ });
@@ -0,0 +1,164 @@
1
+ // tests for ResourceLimitManager - pure in-memory resource tracking
2
+ jest.mock('../lib/logger.js', () => ({
3
+ logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() }
4
+ }));
5
+ import { ResourceLimitManager } from '../lib/resource-limits';
6
+ describe('ResourceLimitManager', () => {
7
+ const defaultLimits = { cpu: 8, memory: 32, disk: 500 };
8
+ // -- initialization --
9
+ it('should store limits passed to constructor', () => {
10
+ const mgr = new ResourceLimitManager(defaultLimits);
11
+ const limits = mgr.getLimits();
12
+ expect(limits.cpu).toBe(8);
13
+ expect(limits.memory).toBe(32);
14
+ expect(limits.disk).toBe(500);
15
+ });
16
+ it('should return a copy from getLimits, not internal ref', () => {
17
+ const mgr = new ResourceLimitManager(defaultLimits);
18
+ const a = mgr.getLimits();
19
+ const b = mgr.getLimits();
20
+ expect(a).not.toBe(b);
21
+ expect(a).toEqual(b);
22
+ });
23
+ it('should start with zero usage', () => {
24
+ const mgr = new ResourceLimitManager(defaultLimits);
25
+ const usage = mgr.getCurrentUsage();
26
+ expect(usage.cpu).toBe(0);
27
+ expect(usage.memory).toBe(0);
28
+ expect(usage.disk).toBe(0);
29
+ });
30
+ it('should report full capacity as available initially', () => {
31
+ const mgr = new ResourceLimitManager(defaultLimits);
32
+ const avail = mgr.getAvailableResources();
33
+ expect(avail.cpu).toBe(8);
34
+ expect(avail.memory).toBe(32);
35
+ expect(avail.disk).toBe(500);
36
+ });
37
+ // -- canAcceptJob --
38
+ it('should accept a job that fits within limits', () => {
39
+ const mgr = new ResourceLimitManager(defaultLimits);
40
+ expect(mgr.canAcceptJob({ cpu: 4, memory: 16 })).toBe(true);
41
+ });
42
+ it('should reject a job that exceeds cpu', () => {
43
+ const mgr = new ResourceLimitManager(defaultLimits);
44
+ expect(mgr.canAcceptJob({ cpu: 10, memory: 8 })).toBe(false);
45
+ });
46
+ it('should reject a job that exceeds memory', () => {
47
+ const mgr = new ResourceLimitManager(defaultLimits);
48
+ expect(mgr.canAcceptJob({ cpu: 2, memory: 64 })).toBe(false);
49
+ });
50
+ it('should reject a job that exceeds disk when disk is specified', () => {
51
+ const mgr = new ResourceLimitManager(defaultLimits);
52
+ expect(mgr.canAcceptJob({ cpu: 1, memory: 1, disk: 600 })).toBe(false);
53
+ });
54
+ it('should accept a job with no disk requirement even when disk is limited', () => {
55
+ const mgr = new ResourceLimitManager({ cpu: 4, memory: 8, disk: 10 });
56
+ expect(mgr.canAcceptJob({ cpu: 2, memory: 4 })).toBe(true);
57
+ });
58
+ // -- allocateResources --
59
+ it('should allocate resources and update usage', () => {
60
+ const mgr = new ResourceLimitManager(defaultLimits);
61
+ const ok = mgr.allocateResources('job-1', { cpu: 2, memory: 8, disk: 50 });
62
+ expect(ok).toBe(true);
63
+ const usage = mgr.getCurrentUsage();
64
+ expect(usage.cpu).toBe(2);
65
+ expect(usage.memory).toBe(8);
66
+ expect(usage.disk).toBe(50);
67
+ });
68
+ it('should fail to allocate when resources are insufficient', () => {
69
+ const mgr = new ResourceLimitManager(defaultLimits);
70
+ mgr.allocateResources('job-1', { cpu: 6, memory: 28 });
71
+ const ok = mgr.allocateResources('job-2', { cpu: 4, memory: 8 });
72
+ expect(ok).toBe(false);
73
+ });
74
+ it('should reduce available after allocation', () => {
75
+ const mgr = new ResourceLimitManager(defaultLimits);
76
+ mgr.allocateResources('job-1', { cpu: 3, memory: 10, disk: 100 });
77
+ const avail = mgr.getAvailableResources();
78
+ expect(avail.cpu).toBe(5);
79
+ expect(avail.memory).toBe(22);
80
+ expect(avail.disk).toBe(400);
81
+ });
82
+ // -- releaseResources --
83
+ it('should free resources on release', () => {
84
+ const mgr = new ResourceLimitManager(defaultLimits);
85
+ mgr.allocateResources('job-1', { cpu: 4, memory: 16, disk: 200 });
86
+ mgr.releaseResources('job-1', { cpu: 4, memory: 16, disk: 200 });
87
+ const usage = mgr.getCurrentUsage();
88
+ expect(usage.cpu).toBe(0);
89
+ expect(usage.memory).toBe(0);
90
+ expect(usage.disk).toBe(0);
91
+ });
92
+ it('should not go below zero on release', () => {
93
+ const mgr = new ResourceLimitManager(defaultLimits);
94
+ mgr.allocateResources('job-1', { cpu: 2, memory: 4 });
95
+ // release more than allocated - shouldn't go negative
96
+ mgr.releaseResources('job-1', { cpu: 5, memory: 10, disk: 50 });
97
+ const usage = mgr.getCurrentUsage();
98
+ expect(usage.cpu).toBe(0);
99
+ expect(usage.memory).toBe(0);
100
+ expect(usage.disk).toBe(0);
101
+ });
102
+ // -- concurrent jobs --
103
+ it('should track multiple allocations correctly', () => {
104
+ const mgr = new ResourceLimitManager(defaultLimits);
105
+ mgr.allocateResources('job-1', { cpu: 2, memory: 8 });
106
+ mgr.allocateResources('job-2', { cpu: 3, memory: 12 });
107
+ const usage = mgr.getCurrentUsage();
108
+ expect(usage.cpu).toBe(5);
109
+ expect(usage.memory).toBe(20);
110
+ });
111
+ it('should allow new job after partial release', () => {
112
+ const mgr = new ResourceLimitManager(defaultLimits);
113
+ mgr.allocateResources('job-1', { cpu: 6, memory: 24 });
114
+ // can't fit another big job
115
+ expect(mgr.canAcceptJob({ cpu: 4, memory: 16 })).toBe(false);
116
+ // release the first one
117
+ mgr.releaseResources('job-1', { cpu: 6, memory: 24 });
118
+ expect(mgr.canAcceptJob({ cpu: 4, memory: 16 })).toBe(true);
119
+ });
120
+ // -- getUsagePercentage --
121
+ it('should report 0% usage when nothing allocated', () => {
122
+ const mgr = new ResourceLimitManager(defaultLimits);
123
+ const pct = mgr.getUsagePercentage();
124
+ expect(pct.cpu).toBe(0);
125
+ expect(pct.memory).toBe(0);
126
+ expect(pct.disk).toBe(0);
127
+ });
128
+ it('should report correct percentages', () => {
129
+ const mgr = new ResourceLimitManager({ cpu: 10, memory: 100, disk: 1000 });
130
+ mgr.allocateResources('job-1', { cpu: 5, memory: 25, disk: 200 });
131
+ const pct = mgr.getUsagePercentage();
132
+ expect(pct.cpu).toBe(50);
133
+ expect(pct.memory).toBe(25);
134
+ expect(pct.disk).toBe(20);
135
+ });
136
+ it('should report 100% when fully allocated', () => {
137
+ const mgr = new ResourceLimitManager({ cpu: 4, memory: 16, disk: 100 });
138
+ mgr.allocateResources('job-1', { cpu: 4, memory: 16, disk: 100 });
139
+ const pct = mgr.getUsagePercentage();
140
+ expect(pct.cpu).toBe(100);
141
+ expect(pct.memory).toBe(100);
142
+ expect(pct.disk).toBe(100);
143
+ });
144
+ // -- edge cases --
145
+ it('should handle zero resource limits without crashing', () => {
146
+ const mgr = new ResourceLimitManager({ cpu: 0, memory: 0, disk: 0 });
147
+ expect(mgr.canAcceptJob({ cpu: 1, memory: 1 })).toBe(false);
148
+ expect(mgr.allocateResources('job-1', { cpu: 1, memory: 1 })).toBe(false);
149
+ });
150
+ it('should handle allocation with only disk=0 (no disk requested)', () => {
151
+ const mgr = new ResourceLimitManager(defaultLimits);
152
+ const ok = mgr.allocateResources('job-1', { cpu: 1, memory: 1, disk: 0 });
153
+ // disk is falsy (0), so it shouldn't add disk usage
154
+ expect(ok).toBe(true);
155
+ expect(mgr.getCurrentUsage().disk).toBe(0);
156
+ });
157
+ it('should return a copy from getCurrentUsage', () => {
158
+ const mgr = new ResourceLimitManager(defaultLimits);
159
+ const a = mgr.getCurrentUsage();
160
+ const b = mgr.getCurrentUsage();
161
+ expect(a).not.toBe(b);
162
+ expect(a).toEqual(b);
163
+ });
164
+ });