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,607 @@
|
|
|
1
|
+
import Fastify from 'fastify';
|
|
2
|
+
import cors from '@fastify/cors';
|
|
3
|
+
import websocket from '@fastify/websocket';
|
|
4
|
+
import { logger } from '../lib/logger.js';
|
|
5
|
+
import Docker from 'dockerode';
|
|
6
|
+
import http from 'http';
|
|
7
|
+
export class NodeAPIServer {
|
|
8
|
+
app;
|
|
9
|
+
containerManager;
|
|
10
|
+
deploymentExecutor;
|
|
11
|
+
port;
|
|
12
|
+
authToken;
|
|
13
|
+
constructor(containerManager, deploymentExecutor, port = 4002, accessToken) {
|
|
14
|
+
this.containerManager = containerManager;
|
|
15
|
+
this.deploymentExecutor = deploymentExecutor;
|
|
16
|
+
this.port = port;
|
|
17
|
+
// token for authenticating requests from orchestrator
|
|
18
|
+
this.authToken = accessToken || process.env.PROVIDER_TOKEN || '';
|
|
19
|
+
}
|
|
20
|
+
getAccessToken() {
|
|
21
|
+
return this.authToken;
|
|
22
|
+
}
|
|
23
|
+
// verify request has valid auth token
|
|
24
|
+
verifyAuth(request, reply) {
|
|
25
|
+
// health check doesn't need auth
|
|
26
|
+
if (request.url === '/health') {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
const authHeader = request.headers.authorization;
|
|
30
|
+
if (!authHeader) {
|
|
31
|
+
reply.code(401).send({ error: 'authorization required' });
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
const token = authHeader.replace('Bearer ', '');
|
|
35
|
+
if (!this.authToken || token !== this.authToken) {
|
|
36
|
+
logger.warn({ url: request.url }, 'invalid auth token');
|
|
37
|
+
reply.code(401).send({ error: 'invalid token' });
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
async start() {
|
|
43
|
+
this.app = Fastify({ logger: logger });
|
|
44
|
+
// cors config - restrict in production
|
|
45
|
+
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') || [];
|
|
46
|
+
const isDev = process.env.NODE_ENV !== 'production';
|
|
47
|
+
await this.app.register(cors, {
|
|
48
|
+
origin: (origin, callback) => {
|
|
49
|
+
if (!origin) {
|
|
50
|
+
callback(null, true);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (isDev && (origin.includes('localhost') || origin.includes('127.0.0.1'))) {
|
|
54
|
+
callback(null, true);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (allowedOrigins.includes(origin)) {
|
|
58
|
+
callback(null, true);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
callback(new Error('cors not allowed'), false);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
// register websocket support for shell access
|
|
66
|
+
await this.app.register(websocket);
|
|
67
|
+
// exec command in container
|
|
68
|
+
this.app.post('/jobs/:jobId/exec', async (request, reply) => {
|
|
69
|
+
if (!this.verifyAuth(request, reply))
|
|
70
|
+
return;
|
|
71
|
+
const { jobId } = request.params;
|
|
72
|
+
const { command } = request.body;
|
|
73
|
+
if (!command) {
|
|
74
|
+
return reply.code(400).send({ error: 'command required' });
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const result = await this.containerManager.execInContainer(jobId, command);
|
|
78
|
+
return {
|
|
79
|
+
success: true,
|
|
80
|
+
...result
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
logger.error({ err, jobId, command }, 'exec failed');
|
|
85
|
+
return reply.code(500).send({
|
|
86
|
+
error: 'exec failed',
|
|
87
|
+
message: err.message
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
// get container logs
|
|
92
|
+
this.app.get('/jobs/:jobId/logs', async (request, reply) => {
|
|
93
|
+
if (!this.verifyAuth(request, reply))
|
|
94
|
+
return;
|
|
95
|
+
const { jobId } = request.params;
|
|
96
|
+
const tail = parseInt(request.query.tail || '100');
|
|
97
|
+
try {
|
|
98
|
+
const logs = await this.containerManager.getContainerLogs(jobId, tail);
|
|
99
|
+
return {
|
|
100
|
+
success: true,
|
|
101
|
+
logs
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
logger.error({ err, jobId }, 'failed to get logs');
|
|
106
|
+
return reply.code(500).send({
|
|
107
|
+
error: 'failed to get logs',
|
|
108
|
+
message: err.message
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
// write file to container
|
|
113
|
+
this.app.post('/jobs/:jobId/files', async (request, reply) => {
|
|
114
|
+
if (!this.verifyAuth(request, reply))
|
|
115
|
+
return;
|
|
116
|
+
const { jobId } = request.params;
|
|
117
|
+
const { filepath, content } = request.body;
|
|
118
|
+
if (!filepath || content === undefined) {
|
|
119
|
+
return reply.code(400).send({ error: 'filepath and content required' });
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
await this.containerManager.writeFile(jobId, filepath, content);
|
|
123
|
+
return { success: true };
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
logger.error({ err, jobId, filepath }, 'failed to write file');
|
|
127
|
+
return reply.code(500).send({
|
|
128
|
+
error: 'failed to write file',
|
|
129
|
+
message: err.message
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
// read file from container
|
|
134
|
+
this.app.get('/jobs/:jobId/files/*', async (request, reply) => {
|
|
135
|
+
if (!this.verifyAuth(request, reply))
|
|
136
|
+
return;
|
|
137
|
+
const { jobId } = request.params;
|
|
138
|
+
const filepath = request.params['*'];
|
|
139
|
+
try {
|
|
140
|
+
const content = await this.containerManager.readFile(jobId, filepath);
|
|
141
|
+
return {
|
|
142
|
+
success: true,
|
|
143
|
+
filepath,
|
|
144
|
+
content
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
logger.error({ err, jobId, filepath }, 'failed to read file');
|
|
149
|
+
return reply.code(500).send({
|
|
150
|
+
error: 'failed to read file',
|
|
151
|
+
message: err.message
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
// proxy http requests to deployment containers
|
|
156
|
+
this.app.all('/deployments/:deploymentId/proxy', async (request, reply) => {
|
|
157
|
+
if (!this.verifyAuth(request, reply))
|
|
158
|
+
return;
|
|
159
|
+
const { deploymentId } = request.params;
|
|
160
|
+
const targetPort = parseInt(request.headers['x-target-port'] || '80');
|
|
161
|
+
try {
|
|
162
|
+
// get container name from deployment id
|
|
163
|
+
// containers are named: kova-{deploymentId}-{serviceName}
|
|
164
|
+
// for now, assume first service (need to parse sdl for multi-service)
|
|
165
|
+
const containerName = `kova-${deploymentId}`;
|
|
166
|
+
// containers on same docker network can access each other by name
|
|
167
|
+
// or we can get container IP
|
|
168
|
+
const docker = new Docker();
|
|
169
|
+
const containers = await docker.listContainers({
|
|
170
|
+
filters: { label: [`kova.deployment=${deploymentId}`] }
|
|
171
|
+
});
|
|
172
|
+
if (containers.length === 0) {
|
|
173
|
+
return reply.code(404).send({ error: 'deployment container not found or not running' });
|
|
174
|
+
}
|
|
175
|
+
// get container IP from docker network
|
|
176
|
+
const containerData = await docker.getContainer(containers[0].Id).inspect();
|
|
177
|
+
const networks = containerData.NetworkSettings.Networks;
|
|
178
|
+
const networkName = Object.keys(networks)[0];
|
|
179
|
+
const containerIP = networks[networkName]?.IPAddress;
|
|
180
|
+
if (!containerIP) {
|
|
181
|
+
return reply.code(502).send({ error: 'container has no network ip' });
|
|
182
|
+
}
|
|
183
|
+
// proxy to container
|
|
184
|
+
const targetUrl = `http://${containerIP}:${targetPort}${request.url.replace(`/deployments/${deploymentId}/proxy`, '')}`;
|
|
185
|
+
logger.info({ targetUrl, deploymentId }, 'proxying request');
|
|
186
|
+
// strip sensitive headers before forwarding to customer container
|
|
187
|
+
const forwardHeaders = { ...request.headers };
|
|
188
|
+
delete forwardHeaders['authorization'];
|
|
189
|
+
delete forwardHeaders['cookie'];
|
|
190
|
+
delete forwardHeaders['x-target-port'];
|
|
191
|
+
forwardHeaders.host = `${containerIP}:${targetPort}`;
|
|
192
|
+
const proxyReq = http.request(targetUrl, {
|
|
193
|
+
method: request.method,
|
|
194
|
+
headers: forwardHeaders
|
|
195
|
+
}, (proxyRes) => {
|
|
196
|
+
reply.code(proxyRes.statusCode);
|
|
197
|
+
// copy all headers from container response
|
|
198
|
+
Object.keys(proxyRes.headers).forEach(key => {
|
|
199
|
+
reply.header(key, proxyRes.headers[key]);
|
|
200
|
+
});
|
|
201
|
+
// stream response directly
|
|
202
|
+
reply.send(proxyRes);
|
|
203
|
+
});
|
|
204
|
+
proxyReq.on('error', (err) => {
|
|
205
|
+
logger.error({ err, targetUrl }, 'proxy request failed');
|
|
206
|
+
reply.code(502).send({ error: 'proxy failed', message: err.message });
|
|
207
|
+
});
|
|
208
|
+
if (request.body) {
|
|
209
|
+
proxyReq.write(JSON.stringify(request.body));
|
|
210
|
+
}
|
|
211
|
+
proxyReq.end();
|
|
212
|
+
return reply;
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
logger.error({ err, deploymentId }, 'proxy setup failed');
|
|
216
|
+
return reply.code(502).send({ error: 'proxy failed', message: err.message });
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
// update deployment files
|
|
220
|
+
this.app.post('/deployments/:deploymentId/services/:serviceName/update-files', async (request, reply) => {
|
|
221
|
+
if (!this.verifyAuth(request, reply))
|
|
222
|
+
return;
|
|
223
|
+
const { deploymentId, serviceName } = request.params;
|
|
224
|
+
if (!this.deploymentExecutor) {
|
|
225
|
+
return reply.code(503).send({ error: 'deployment executor not available' });
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
await this.deploymentExecutor.updateDeploymentFiles(deploymentId, serviceName);
|
|
229
|
+
return {
|
|
230
|
+
success: true,
|
|
231
|
+
message: 'files updated and deployment restarted'
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
catch (err) {
|
|
235
|
+
logger.error({ err, deploymentId, serviceName }, 'failed to update deployment files');
|
|
236
|
+
return reply.code(500).send({
|
|
237
|
+
error: 'failed to update files',
|
|
238
|
+
message: err.message
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
// health check
|
|
243
|
+
this.app.get('/health', async () => {
|
|
244
|
+
return {
|
|
245
|
+
status: 'ok',
|
|
246
|
+
runningJobs: this.containerManager.getRunningJobs().length
|
|
247
|
+
};
|
|
248
|
+
});
|
|
249
|
+
// websocket shell endpoint for direct access (http fallback when p2p unavailable)
|
|
250
|
+
this.app.get('/deployments/:deploymentId/shell', { websocket: true }, async (connection, req) => {
|
|
251
|
+
// verify auth token from query param or header
|
|
252
|
+
const token = req.query.token || req.headers.authorization?.replace('Bearer ', '');
|
|
253
|
+
if (!this.authToken) {
|
|
254
|
+
logger.warn({ deploymentId: req.params.deploymentId }, 'shell access denied - no auth token configured on provider');
|
|
255
|
+
connection.socket.close(1008, 'provider auth not configured');
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (!token || token !== this.authToken) {
|
|
259
|
+
logger.warn({ deploymentId: req.params.deploymentId, hasToken: !!token, tokenLen: token?.length }, 'shell access denied - token mismatch');
|
|
260
|
+
connection.socket.close(1008, 'unauthorized - invalid access token');
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const deploymentId = req.params.deploymentId;
|
|
264
|
+
const serviceName = req.query.service || 'web';
|
|
265
|
+
logger.info({ deploymentId, serviceName }, 'direct shell websocket connection');
|
|
266
|
+
if (!this.deploymentExecutor) {
|
|
267
|
+
connection.socket.close(1008, 'deployment executor not available');
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
let sessionId = null;
|
|
271
|
+
connection.socket.on('message', async (data) => {
|
|
272
|
+
try {
|
|
273
|
+
const message = JSON.parse(data.toString());
|
|
274
|
+
if (message.type === 'init') {
|
|
275
|
+
// generate session id and start shell
|
|
276
|
+
sessionId = `shell-${deploymentId}-${Date.now()}`;
|
|
277
|
+
const result = await this.deploymentExecutor.startShellSession(sessionId, deploymentId, serviceName, (output) => {
|
|
278
|
+
// send output back to client
|
|
279
|
+
connection.socket.send(JSON.stringify({ type: 'output', data: output }));
|
|
280
|
+
});
|
|
281
|
+
if (result.success) {
|
|
282
|
+
connection.socket.send(JSON.stringify({ type: 'ready', sessionId }));
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
connection.socket.send(JSON.stringify({ type: 'error', message: result.error || 'failed to start shell session' }));
|
|
286
|
+
connection.socket.close(1008, result.error || 'shell start failed');
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
else if (message.type === 'input' && sessionId) {
|
|
290
|
+
this.deploymentExecutor.sendShellInput(sessionId, message.data);
|
|
291
|
+
}
|
|
292
|
+
else if (message.type === 'resize' && sessionId) {
|
|
293
|
+
this.deploymentExecutor.resizeShell(sessionId, message.cols, message.rows);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
catch (err) {
|
|
297
|
+
logger.error({ err, deploymentId }, 'shell message error');
|
|
298
|
+
connection.socket.send(JSON.stringify({ type: 'error', message: 'command failed' }));
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
connection.socket.on('close', () => {
|
|
302
|
+
if (sessionId && this.deploymentExecutor) {
|
|
303
|
+
this.deploymentExecutor.closeShellSession(sessionId);
|
|
304
|
+
}
|
|
305
|
+
logger.info({ deploymentId }, 'direct shell websocket closed');
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
// container stats (cpu, memory, network) for a deployment
|
|
309
|
+
this.app.get('/deployments/:deploymentId/stats', async (request, reply) => {
|
|
310
|
+
if (!this.verifyAuth(request, reply))
|
|
311
|
+
return;
|
|
312
|
+
const { deploymentId } = request.params;
|
|
313
|
+
try {
|
|
314
|
+
const stats = await this.deploymentExecutor.getDeploymentStats(deploymentId);
|
|
315
|
+
return stats;
|
|
316
|
+
}
|
|
317
|
+
catch (err) {
|
|
318
|
+
logger.error({ err, deploymentId }, 'failed to get deployment stats');
|
|
319
|
+
return reply.code(500).send({ error: 'failed to get stats', message: err.message });
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
// container status (running, exited, etc) for a deployment
|
|
323
|
+
this.app.get('/deployments/:deploymentId/status', async (request, reply) => {
|
|
324
|
+
if (!this.verifyAuth(request, reply))
|
|
325
|
+
return;
|
|
326
|
+
const { deploymentId } = request.params;
|
|
327
|
+
try {
|
|
328
|
+
const status = await this.deploymentExecutor.getDeploymentStatus(deploymentId);
|
|
329
|
+
return status;
|
|
330
|
+
}
|
|
331
|
+
catch (err) {
|
|
332
|
+
logger.error({ err, deploymentId }, 'failed to get deployment status');
|
|
333
|
+
return reply.code(500).send({ error: 'failed to get status', message: err.message });
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
// container events (create, start, stop, health) for a deployment
|
|
337
|
+
this.app.get('/deployments/:deploymentId/events', async (request, reply) => {
|
|
338
|
+
if (!this.verifyAuth(request, reply))
|
|
339
|
+
return;
|
|
340
|
+
const { deploymentId } = request.params;
|
|
341
|
+
if (!this.deploymentExecutor) {
|
|
342
|
+
return reply.code(503).send({ error: 'deployment executor not available' });
|
|
343
|
+
}
|
|
344
|
+
try {
|
|
345
|
+
const events = await this.deploymentExecutor.getContainerEvents(deploymentId);
|
|
346
|
+
if (events.error) {
|
|
347
|
+
return reply.code(404).send(events);
|
|
348
|
+
}
|
|
349
|
+
return events;
|
|
350
|
+
}
|
|
351
|
+
catch (err) {
|
|
352
|
+
logger.error({ err, deploymentId }, 'failed to get container events');
|
|
353
|
+
return reply.code(500).send({ error: 'failed to get events', message: err.message });
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
// stream stats via websocket for live metrics
|
|
357
|
+
this.app.get('/deployments/:deploymentId/stats/stream', { websocket: true }, async (connection, req) => {
|
|
358
|
+
const token = req.query.token || req.headers.authorization?.replace('Bearer ', '');
|
|
359
|
+
if (!this.authToken || !token || token !== this.authToken) {
|
|
360
|
+
connection.socket.close(1008, 'unauthorized');
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const { deploymentId } = req.params;
|
|
364
|
+
const intervalMs = Math.max(parseInt(req.query.interval) || 3000, 2000); // min 2s
|
|
365
|
+
let active = true;
|
|
366
|
+
const sendStats = async () => {
|
|
367
|
+
if (!active)
|
|
368
|
+
return;
|
|
369
|
+
try {
|
|
370
|
+
const stats = await this.deploymentExecutor.getDeploymentStats(deploymentId);
|
|
371
|
+
if (active && connection.socket.readyState === 1) {
|
|
372
|
+
connection.socket.send(JSON.stringify(stats));
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
catch (err) {
|
|
376
|
+
if (active && connection.socket.readyState === 1) {
|
|
377
|
+
connection.socket.send(JSON.stringify({ error: err.message }));
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
// send initial stats immediately
|
|
382
|
+
await sendStats();
|
|
383
|
+
const interval = setInterval(sendStats, intervalMs);
|
|
384
|
+
connection.socket.on('close', () => {
|
|
385
|
+
active = false;
|
|
386
|
+
clearInterval(interval);
|
|
387
|
+
});
|
|
388
|
+
connection.socket.on('error', () => {
|
|
389
|
+
active = false;
|
|
390
|
+
clearInterval(interval);
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
// browse files inside a deployment container
|
|
394
|
+
this.app.get('/deployments/:deploymentId/services/:serviceName/browse', async (request, reply) => {
|
|
395
|
+
if (!this.verifyAuth(request, reply))
|
|
396
|
+
return;
|
|
397
|
+
const { deploymentId, serviceName } = request.params;
|
|
398
|
+
const dirPath = request.query.path || '/';
|
|
399
|
+
if (!this.deploymentExecutor) {
|
|
400
|
+
return reply.code(503).send({ error: 'deployment executor not available' });
|
|
401
|
+
}
|
|
402
|
+
try {
|
|
403
|
+
const result = await this.deploymentExecutor.browseFiles(deploymentId, serviceName, dirPath);
|
|
404
|
+
if (result.error) {
|
|
405
|
+
return reply.code(404).send(result);
|
|
406
|
+
}
|
|
407
|
+
return result;
|
|
408
|
+
}
|
|
409
|
+
catch (err) {
|
|
410
|
+
return reply.code(500).send({ error: 'failed to browse files', message: err.message });
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
// read a file from inside a deployment container
|
|
414
|
+
this.app.get('/deployments/:deploymentId/services/:serviceName/cat/*', async (request, reply) => {
|
|
415
|
+
if (!this.verifyAuth(request, reply))
|
|
416
|
+
return;
|
|
417
|
+
const { deploymentId, serviceName } = request.params;
|
|
418
|
+
const filePath = '/' + (request.params['*'] || '');
|
|
419
|
+
if (!this.deploymentExecutor) {
|
|
420
|
+
return reply.code(503).send({ error: 'deployment executor not available' });
|
|
421
|
+
}
|
|
422
|
+
try {
|
|
423
|
+
const result = await this.deploymentExecutor.readContainerFile(deploymentId, serviceName, filePath);
|
|
424
|
+
if (result.error) {
|
|
425
|
+
return reply.code(result.error === 'file not found' ? 404 : 500).send(result);
|
|
426
|
+
}
|
|
427
|
+
return result;
|
|
428
|
+
}
|
|
429
|
+
catch (err) {
|
|
430
|
+
return reply.code(500).send({ error: 'failed to read file', message: err.message });
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
// upload a file into a deployment container
|
|
434
|
+
this.app.post('/deployments/:deploymentId/services/:serviceName/upload', async (request, reply) => {
|
|
435
|
+
if (!this.verifyAuth(request, reply))
|
|
436
|
+
return;
|
|
437
|
+
const { deploymentId, serviceName } = request.params;
|
|
438
|
+
const { path: filePath, content, encoding } = request.body || {};
|
|
439
|
+
if (!filePath || content === undefined) {
|
|
440
|
+
return reply.code(400).send({ error: 'path and content required' });
|
|
441
|
+
}
|
|
442
|
+
if (!this.deploymentExecutor) {
|
|
443
|
+
return reply.code(503).send({ error: 'deployment executor not available' });
|
|
444
|
+
}
|
|
445
|
+
try {
|
|
446
|
+
const result = await this.deploymentExecutor.uploadFileToContainer(deploymentId, serviceName, filePath, content, encoding || 'utf8');
|
|
447
|
+
if (!result.success) {
|
|
448
|
+
return reply.code(400).send(result);
|
|
449
|
+
}
|
|
450
|
+
return result;
|
|
451
|
+
}
|
|
452
|
+
catch (err) {
|
|
453
|
+
return reply.code(500).send({ error: 'failed to upload file', message: err.message });
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
// restart all containers in a deployment
|
|
457
|
+
this.app.post('/deployments/:deploymentId/restart', async (request, reply) => {
|
|
458
|
+
if (!this.verifyAuth(request, reply))
|
|
459
|
+
return;
|
|
460
|
+
const { deploymentId } = request.params;
|
|
461
|
+
if (!this.deploymentExecutor) {
|
|
462
|
+
return reply.code(503).send({ error: 'deployment executor not available' });
|
|
463
|
+
}
|
|
464
|
+
try {
|
|
465
|
+
const services = await this.deploymentExecutor.restartDeployment(deploymentId);
|
|
466
|
+
return { restarted: true, services };
|
|
467
|
+
}
|
|
468
|
+
catch (err) {
|
|
469
|
+
logger.error({ err, deploymentId }, 'failed to restart deployment');
|
|
470
|
+
return reply.code(500).send({ error: 'restart failed', message: err.message });
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
// create a volume snapshot for a service
|
|
474
|
+
this.app.post('/deployments/:deploymentId/snapshots', async (request, reply) => {
|
|
475
|
+
if (!this.verifyAuth(request, reply))
|
|
476
|
+
return;
|
|
477
|
+
const { deploymentId } = request.params;
|
|
478
|
+
const { serviceName, snapshotId } = request.body || {};
|
|
479
|
+
if (!serviceName || !snapshotId) {
|
|
480
|
+
return reply.code(400).send({ error: 'serviceName and snapshotId required' });
|
|
481
|
+
}
|
|
482
|
+
if (!this.deploymentExecutor) {
|
|
483
|
+
return reply.code(503).send({ error: 'deployment executor not available' });
|
|
484
|
+
}
|
|
485
|
+
try {
|
|
486
|
+
const result = await this.deploymentExecutor.createVolumeSnapshot(deploymentId, serviceName, snapshotId);
|
|
487
|
+
return {
|
|
488
|
+
snapshotId,
|
|
489
|
+
volumeName: result.volumeName,
|
|
490
|
+
sizeBytes: result.sizeBytes,
|
|
491
|
+
snapshotKey: result.snapshotKey,
|
|
492
|
+
state: 'ready'
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
catch (err) {
|
|
496
|
+
logger.error({ err, deploymentId, serviceName, snapshotId }, 'failed to create snapshot');
|
|
497
|
+
return reply.code(500).send({ error: 'snapshot failed', message: err.message });
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
// restore a volume snapshot
|
|
501
|
+
this.app.post('/deployments/:deploymentId/snapshots/:snapshotId/restore', async (request, reply) => {
|
|
502
|
+
if (!this.verifyAuth(request, reply))
|
|
503
|
+
return;
|
|
504
|
+
const { deploymentId, snapshotId } = request.params;
|
|
505
|
+
const { serviceName, snapshotKey } = request.body || {};
|
|
506
|
+
if (!serviceName || !snapshotKey) {
|
|
507
|
+
return reply.code(400).send({ error: 'serviceName and snapshotKey required' });
|
|
508
|
+
}
|
|
509
|
+
if (!this.deploymentExecutor) {
|
|
510
|
+
return reply.code(503).send({ error: 'deployment executor not available' });
|
|
511
|
+
}
|
|
512
|
+
try {
|
|
513
|
+
await this.deploymentExecutor.restoreVolumeSnapshot(deploymentId, serviceName, snapshotKey);
|
|
514
|
+
return { restored: true };
|
|
515
|
+
}
|
|
516
|
+
catch (err) {
|
|
517
|
+
logger.error({ err, deploymentId, snapshotId }, 'failed to restore snapshot');
|
|
518
|
+
return reply.code(500).send({ error: 'restore failed', message: err.message });
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
// scale container resources (stop, remove, recreate with new limits)
|
|
522
|
+
this.app.put('/deployments/:deploymentId/scale', async (request, reply) => {
|
|
523
|
+
if (!this.verifyAuth(request, reply))
|
|
524
|
+
return;
|
|
525
|
+
const { deploymentId } = request.params;
|
|
526
|
+
const { serviceName, cpu, memory } = request.body || {};
|
|
527
|
+
if (!serviceName) {
|
|
528
|
+
return reply.code(400).send({ error: 'serviceName required' });
|
|
529
|
+
}
|
|
530
|
+
if (!this.deploymentExecutor) {
|
|
531
|
+
return reply.code(503).send({ error: 'deployment executor not available' });
|
|
532
|
+
}
|
|
533
|
+
const deployment = this.deploymentExecutor.getDeployment(deploymentId);
|
|
534
|
+
if (!deployment) {
|
|
535
|
+
return reply.code(404).send({ error: 'deployment not found' });
|
|
536
|
+
}
|
|
537
|
+
const containerId = deployment.containers.get(serviceName);
|
|
538
|
+
if (!containerId) {
|
|
539
|
+
return reply.code(404).send({ error: `service ${serviceName} not found in deployment` });
|
|
540
|
+
}
|
|
541
|
+
try {
|
|
542
|
+
const docker = new Docker();
|
|
543
|
+
const container = docker.getContainer(containerId);
|
|
544
|
+
const info = await container.inspect();
|
|
545
|
+
// stop and remove old container
|
|
546
|
+
try {
|
|
547
|
+
await container.stop({ t: 10 });
|
|
548
|
+
}
|
|
549
|
+
catch { /* may already be stopped */ }
|
|
550
|
+
await container.remove();
|
|
551
|
+
// compute new resource limits
|
|
552
|
+
const newMemory = memory
|
|
553
|
+
? this.parseMemoryString(memory)
|
|
554
|
+
: (info.HostConfig.Memory || 4 * 1024 * 1024 * 1024);
|
|
555
|
+
const newCpu = cpu || (info.HostConfig.CpuQuota ? info.HostConfig.CpuQuota / 100000 : 4);
|
|
556
|
+
// recreate with same config but new resource limits
|
|
557
|
+
const newConfig = {
|
|
558
|
+
name: info.Name.startsWith('/') ? info.Name.slice(1) : info.Name,
|
|
559
|
+
Image: info.Config.Image,
|
|
560
|
+
Env: info.Config.Env || [],
|
|
561
|
+
Cmd: info.Config.Cmd || undefined,
|
|
562
|
+
Entrypoint: info.Config.Entrypoint || undefined,
|
|
563
|
+
ExposedPorts: info.Config.ExposedPorts || {},
|
|
564
|
+
Labels: info.Config.Labels || {},
|
|
565
|
+
HostConfig: {
|
|
566
|
+
...info.HostConfig,
|
|
567
|
+
Memory: newMemory,
|
|
568
|
+
MemorySwap: newMemory,
|
|
569
|
+
CpuPeriod: 100000,
|
|
570
|
+
CpuQuota: Math.floor(newCpu * 100000),
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
const newContainer = await docker.createContainer(newConfig);
|
|
574
|
+
await newContainer.start();
|
|
575
|
+
// update container id in executor
|
|
576
|
+
deployment.containers.set(serviceName, newContainer.id);
|
|
577
|
+
logger.info({ deploymentId, serviceName, cpu: newCpu, memory: newMemory }, 'container scaled');
|
|
578
|
+
return { scaled: true, serviceName };
|
|
579
|
+
}
|
|
580
|
+
catch (err) {
|
|
581
|
+
logger.error({ err, deploymentId, serviceName }, 'failed to scale container');
|
|
582
|
+
return reply.code(500).send({ error: 'scale failed', message: err.message });
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
await this.app.listen({ port: this.port, host: '0.0.0.0' });
|
|
586
|
+
logger.info({ port: this.port }, 'node api server started');
|
|
587
|
+
}
|
|
588
|
+
// parse memory string like "512Mi", "2Gi" into bytes
|
|
589
|
+
parseMemoryString(size) {
|
|
590
|
+
const units = {
|
|
591
|
+
'K': 1000, 'M': 1000 ** 2, 'G': 1000 ** 3, 'T': 1000 ** 4,
|
|
592
|
+
'Ki': 1024, 'Mi': 1024 ** 2, 'Gi': 1024 ** 3, 'Ti': 1024 ** 4,
|
|
593
|
+
};
|
|
594
|
+
const match = size.match(/^(\d+(?:\.\d+)?)\s*([A-Za-z]+)$/);
|
|
595
|
+
if (!match)
|
|
596
|
+
return 4 * 1024 * 1024 * 1024; // 4gb fallback
|
|
597
|
+
const value = parseFloat(match[1]);
|
|
598
|
+
const unit = match[2];
|
|
599
|
+
return Math.floor(value * (units[unit] || 1));
|
|
600
|
+
}
|
|
601
|
+
async stop() {
|
|
602
|
+
if (this.app) {
|
|
603
|
+
await this.app.close();
|
|
604
|
+
logger.info('node api server stopped');
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { startNode } from './commands/start.js';
|
|
3
|
+
import { statusCommand } from './commands/status.js';
|
|
4
|
+
import { stopCommand } from './commands/stop.js';
|
|
5
|
+
import { earningsCommand } from './commands/earnings.js';
|
|
6
|
+
import { registerDeployCommands } from './commands/deploy.js';
|
|
7
|
+
import pino from 'pino';
|
|
8
|
+
// eh whatever logger setup
|
|
9
|
+
const logger = pino({
|
|
10
|
+
transport: process.env.NODE_ENV !== 'production' ? {
|
|
11
|
+
target: 'pino-pretty',
|
|
12
|
+
options: { colorize: true }
|
|
13
|
+
} : undefined
|
|
14
|
+
});
|
|
15
|
+
const program = new Command();
|
|
16
|
+
program
|
|
17
|
+
.name('kova-node')
|
|
18
|
+
.description('decentralized compute node - earn by sharing resources')
|
|
19
|
+
.version('0.0.1');
|
|
20
|
+
program
|
|
21
|
+
.command('start')
|
|
22
|
+
.description('start earning with your spare compute')
|
|
23
|
+
.option('-k, --api-key <key>', 'your provider api key (get from dashboard)')
|
|
24
|
+
.option('-w, --wallet <address>', 'your ethereum wallet address (legacy)')
|
|
25
|
+
.option('-p, --port <number>', 'p2p port', '4001')
|
|
26
|
+
.option('-c, --config <path>', 'config file path')
|
|
27
|
+
.option('--max-cpu <cores>', 'maximum CPU cores to allocate (default: all available)')
|
|
28
|
+
.option('--max-memory <gb>', 'maximum memory in GB to allocate (default: 80% of total)')
|
|
29
|
+
.option('--max-disk <gb>', 'maximum disk space in GB to allocate (default: 100GB)')
|
|
30
|
+
.option('-v, --verbose', 'show me everything')
|
|
31
|
+
.action(startNode);
|
|
32
|
+
program
|
|
33
|
+
.command('status')
|
|
34
|
+
.description('check how things are going')
|
|
35
|
+
.action(statusCommand);
|
|
36
|
+
program
|
|
37
|
+
.command('stop')
|
|
38
|
+
.description('stop the node gracefully')
|
|
39
|
+
.action(stopCommand);
|
|
40
|
+
program
|
|
41
|
+
.command('earnings')
|
|
42
|
+
.description('see how much youve made')
|
|
43
|
+
.action(earningsCommand);
|
|
44
|
+
// deployment management commands (customer side)
|
|
45
|
+
registerDeployCommands(program);
|
|
46
|
+
// lets gooo
|
|
47
|
+
program.parse(process.argv);
|