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,358 @@
|
|
|
1
|
+
import { logger } from '../lib/logger.js';
|
|
2
|
+
import { P2PNode } from '../lib/p2p.js';
|
|
3
|
+
import { DockerManager } from '../lib/docker.js';
|
|
4
|
+
import { ResourceMonitor } from '../lib/monitor.js';
|
|
5
|
+
import { NodeConfig } from '../lib/config.js';
|
|
6
|
+
import { ContainerManager } from '../services/container-manager.js';
|
|
7
|
+
import { JobHandler } from '../services/job-handler.js';
|
|
8
|
+
import { HeartbeatService } from '../services/heartbeat.js';
|
|
9
|
+
import { NodeAPIServer } from '../api/server.js';
|
|
10
|
+
import { stateManager } from '../lib/state.js';
|
|
11
|
+
import { ResourceLimitManager } from '../lib/resource-limits.js';
|
|
12
|
+
import { AutoBidder } from '../services/auto-bidder.js';
|
|
13
|
+
import { LeaseHandler } from '../services/lease-handler.js';
|
|
14
|
+
import { DeploymentExecutor } from '../services/deployment-executor.js';
|
|
15
|
+
import { randomBytes } from 'crypto';
|
|
16
|
+
async function registerWithOrchestrator(nodeId, resources, apiKey, walletAddress, orchestratorUrl, maxRetries = 5) {
|
|
17
|
+
if (!orchestratorUrl) {
|
|
18
|
+
logger.warn('no orchestrator URL configured, skipping HTTP registration');
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
const body = {
|
|
22
|
+
nodeId,
|
|
23
|
+
resources,
|
|
24
|
+
timestamp: Date.now(),
|
|
25
|
+
version: '0.0.1'
|
|
26
|
+
};
|
|
27
|
+
// prefer api key over wallet
|
|
28
|
+
if (apiKey) {
|
|
29
|
+
body.apiKey = apiKey;
|
|
30
|
+
}
|
|
31
|
+
else if (walletAddress) {
|
|
32
|
+
body.walletAddress = walletAddress;
|
|
33
|
+
}
|
|
34
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
35
|
+
try {
|
|
36
|
+
const response = await fetch(`${orchestratorUrl}/api/v1/nodes/register`, {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: { 'Content-Type': 'application/json' },
|
|
39
|
+
body: JSON.stringify(body)
|
|
40
|
+
});
|
|
41
|
+
if (response.ok) {
|
|
42
|
+
const data = await response.json();
|
|
43
|
+
logger.info({
|
|
44
|
+
orchestratorUrl,
|
|
45
|
+
walletAddress: data.walletAddress,
|
|
46
|
+
providerId: data.providerId
|
|
47
|
+
}, 'registered with orchestrator');
|
|
48
|
+
return {
|
|
49
|
+
providerId: data.providerId,
|
|
50
|
+
walletAddress: data.walletAddress
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
logger.warn({ status: response.status, attempt }, 'failed to register with orchestrator');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
logger.warn({ err, attempt, maxRetries }, 'could not reach orchestrator for HTTP registration');
|
|
59
|
+
}
|
|
60
|
+
if (attempt < maxRetries) {
|
|
61
|
+
const delay = Math.min(attempt * 3000, 15000);
|
|
62
|
+
logger.info({ attempt, nextRetryMs: delay }, 'retrying registration...');
|
|
63
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
export async function startNode(options) {
|
|
69
|
+
logger.info('starting kova node...');
|
|
70
|
+
// check for authentication (api key or wallet)
|
|
71
|
+
const apiKey = options.apiKey || options.k;
|
|
72
|
+
const walletAddress = options.wallet || options.w;
|
|
73
|
+
if (!apiKey && !walletAddress) {
|
|
74
|
+
console.error('\n❌ ERROR: Either --api-key or --wallet is required');
|
|
75
|
+
console.error('\nRecommended: kova-node start --api-key YOUR_API_KEY');
|
|
76
|
+
console.error('Legacy: kova-node start --wallet YOUR_WALLET_ADDRESS');
|
|
77
|
+
console.error('\nGet your API key from https://test.kovanetwork.com/provider');
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
// validate wallet address format if provided
|
|
81
|
+
if (walletAddress && !/^0x[a-fA-F0-9]{40}$/.test(walletAddress)) {
|
|
82
|
+
console.error('\n❌ ERROR: Invalid wallet address format');
|
|
83
|
+
console.error('Wallet address must be a valid Ethereum address (0x followed by 40 hex characters)');
|
|
84
|
+
console.error(`Provided: ${walletAddress}`);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
// validate api key format if provided
|
|
88
|
+
if (apiKey && !apiKey.startsWith('sk_live_')) {
|
|
89
|
+
console.error('\n❌ ERROR: Invalid API key format');
|
|
90
|
+
console.error('API key must start with sk_live_');
|
|
91
|
+
console.error(`Provided: ${apiKey.substring(0, 15)}...`);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
// mark as running
|
|
95
|
+
stateManager.setRunning(process.pid);
|
|
96
|
+
// load config or use defaults
|
|
97
|
+
const config = await NodeConfig.load(options.config);
|
|
98
|
+
// setup resource limits based on CLI args
|
|
99
|
+
const limitManager = await ResourceLimitManager.createFromOptions({
|
|
100
|
+
maxCpu: options.maxCpu,
|
|
101
|
+
maxMemory: options.maxMemory,
|
|
102
|
+
maxDisk: options.maxDisk
|
|
103
|
+
});
|
|
104
|
+
// check if docker is even running
|
|
105
|
+
const docker = new DockerManager();
|
|
106
|
+
const dockerReady = await docker.checkDocker();
|
|
107
|
+
if (!dockerReady) {
|
|
108
|
+
logger.error('docker not running... install docker first');
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
// start monitoring resources
|
|
112
|
+
const monitor = new ResourceMonitor();
|
|
113
|
+
await monitor.start();
|
|
114
|
+
// join the p2p network
|
|
115
|
+
const p2p = new P2PNode({
|
|
116
|
+
port: parseInt(options.port) || 4001,
|
|
117
|
+
bootstrapNodes: config.bootstrapNodes
|
|
118
|
+
});
|
|
119
|
+
try {
|
|
120
|
+
await p2p.start();
|
|
121
|
+
const nodeId = p2p.getPeerId();
|
|
122
|
+
logger.info({ nodeId, walletAddress }, 'connected to kova network');
|
|
123
|
+
// get system resources but use provider limits
|
|
124
|
+
const systemResources = await monitor.getAvailableResources();
|
|
125
|
+
const providerLimits = limitManager.getLimits();
|
|
126
|
+
// advertise provider limits, not full system resources
|
|
127
|
+
const advertisedResources = {
|
|
128
|
+
cpu: {
|
|
129
|
+
cores: providerLimits.cpu,
|
|
130
|
+
available: providerLimits.cpu
|
|
131
|
+
},
|
|
132
|
+
memory: {
|
|
133
|
+
total: providerLimits.memory,
|
|
134
|
+
available: providerLimits.memory
|
|
135
|
+
},
|
|
136
|
+
disk: systemResources.disk,
|
|
137
|
+
network: systemResources.network,
|
|
138
|
+
gpu: systemResources.gpu || []
|
|
139
|
+
};
|
|
140
|
+
await p2p.advertiseCapabilities(advertisedResources);
|
|
141
|
+
// register with orchestrator via HTTP
|
|
142
|
+
const registrationResult = await registerWithOrchestrator(nodeId, advertisedResources, apiKey, walletAddress, config.orchestratorUrl);
|
|
143
|
+
const registered = !!registrationResult;
|
|
144
|
+
const effectiveWallet = registrationResult?.walletAddress || walletAddress;
|
|
145
|
+
if (registered) {
|
|
146
|
+
console.log('\n========================================');
|
|
147
|
+
console.log('✓ KOVA NODE STARTED SUCCESSFULLY');
|
|
148
|
+
console.log('========================================');
|
|
149
|
+
console.log(`Node ID: ${nodeId}`);
|
|
150
|
+
console.log(`Wallet: ${effectiveWallet}`);
|
|
151
|
+
console.log(`\nAllocated Resources:`);
|
|
152
|
+
console.log(`CPU: ${providerLimits.cpu} cores`);
|
|
153
|
+
console.log(`Memory: ${providerLimits.memory} GB`);
|
|
154
|
+
console.log(`Disk: ${providerLimits.disk} GB`);
|
|
155
|
+
console.log(`\nDashboard: https://test.kovanetwork.com`);
|
|
156
|
+
console.log('Connect with your wallet to view earnings');
|
|
157
|
+
console.log('========================================\n');
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
console.log('\n⚠️ Node started but registration failed');
|
|
161
|
+
console.log('Check orchestrator connection and try again\n');
|
|
162
|
+
}
|
|
163
|
+
// generate access token for orchestrator → provider shell proxy auth
|
|
164
|
+
const accessToken = randomBytes(32).toString('hex');
|
|
165
|
+
const apiPort = config.apiPort || 4002;
|
|
166
|
+
// start heartbeat service to keep orchestrator updated
|
|
167
|
+
let heartbeat = null;
|
|
168
|
+
if (config.orchestratorUrl) {
|
|
169
|
+
heartbeat = new HeartbeatService(nodeId, config.orchestratorUrl, monitor, limitManager, 60, apiPort, accessToken);
|
|
170
|
+
await heartbeat.start();
|
|
171
|
+
logger.info('heartbeat service started - sending status every 60 seconds');
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
logger.warn('no orchestrator URL configured - heartbeat disabled');
|
|
175
|
+
}
|
|
176
|
+
// setup job handling
|
|
177
|
+
const containerMgr = new ContainerManager();
|
|
178
|
+
await containerMgr.start();
|
|
179
|
+
const jobHandler = new JobHandler(p2p, containerMgr, limitManager, config.orchestratorUrl);
|
|
180
|
+
// initialize deployment executor whenever orchestrator is configured
|
|
181
|
+
// shell sessions and stats only need local docker access, not registration
|
|
182
|
+
let deploymentExecutor = null;
|
|
183
|
+
if (config.orchestratorUrl) {
|
|
184
|
+
deploymentExecutor = new DeploymentExecutor({
|
|
185
|
+
orchestratorUrl: config.orchestratorUrl,
|
|
186
|
+
apiKey
|
|
187
|
+
});
|
|
188
|
+
// discover any existing deployments from previous runs
|
|
189
|
+
await deploymentExecutor.discoverExistingDeployments();
|
|
190
|
+
logger.info('deployment executor initialized');
|
|
191
|
+
}
|
|
192
|
+
// start api server for exec/logs (with deployment executor if available)
|
|
193
|
+
const apiServer = new NodeAPIServer(containerMgr, deploymentExecutor || undefined, apiPort, accessToken);
|
|
194
|
+
await apiServer.start();
|
|
195
|
+
// listen for pending jobs from heartbeat
|
|
196
|
+
const processedJobs = new Set();
|
|
197
|
+
if (heartbeat) {
|
|
198
|
+
heartbeat.on('pending-jobs', (jobs) => {
|
|
199
|
+
logger.info({ count: jobs.length }, 'processing pending jobs from heartbeat');
|
|
200
|
+
for (const job of jobs) {
|
|
201
|
+
// skip if already processed
|
|
202
|
+
if (processedJobs.has(job.id)) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
processedJobs.add(job.id);
|
|
206
|
+
jobHandler.handleJob(job).catch(err => {
|
|
207
|
+
logger.error({ err, jobId: job.id }, 'failed to handle job from heartbeat');
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
// evict old entries instead of clearing everything
|
|
212
|
+
setInterval(() => {
|
|
213
|
+
// keep the set from growing unbounded but don't clear it wholesale
|
|
214
|
+
// which could cause duplicate processing
|
|
215
|
+
if (processedJobs.size > 10000) {
|
|
216
|
+
const entries = Array.from(processedJobs);
|
|
217
|
+
const toRemove = entries.slice(0, entries.length - 5000);
|
|
218
|
+
for (const id of toRemove) {
|
|
219
|
+
processedJobs.delete(id);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}, 5 * 60 * 1000);
|
|
223
|
+
}
|
|
224
|
+
// wire up earnings tracking
|
|
225
|
+
jobHandler.on('job-completed', ({ jobId, earnings }) => {
|
|
226
|
+
logger.info({ jobId, earnings }, 'earned from job');
|
|
227
|
+
stateManager.addEarnings(earnings);
|
|
228
|
+
stateManager.incrementCompleted();
|
|
229
|
+
});
|
|
230
|
+
jobHandler.on('job-failed', ({ jobId }) => {
|
|
231
|
+
logger.warn({ jobId }, 'job failed');
|
|
232
|
+
stateManager.incrementFailed();
|
|
233
|
+
});
|
|
234
|
+
// listen for jobs
|
|
235
|
+
p2p.on('job-request', async (job) => {
|
|
236
|
+
// only log non-sensitive job info (NOT env vars)
|
|
237
|
+
logger.info({
|
|
238
|
+
jobId: job.id,
|
|
239
|
+
image: job.image,
|
|
240
|
+
resources: job.resources
|
|
241
|
+
// intentionally NOT logging: env, userId (privacy)
|
|
242
|
+
}, 'got a job request');
|
|
243
|
+
const accepted = await jobHandler.handleJob(job);
|
|
244
|
+
if (accepted) {
|
|
245
|
+
logger.info({ jobId: job.id }, 'job accepted and running');
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
// new deployment system
|
|
249
|
+
let autoBidder = null;
|
|
250
|
+
let leaseHandler = null;
|
|
251
|
+
if (config.orchestratorUrl && registered && registrationResult) {
|
|
252
|
+
// use provider id from registration
|
|
253
|
+
const providerId = registrationResult.providerId;
|
|
254
|
+
// verify provider account is registered
|
|
255
|
+
try {
|
|
256
|
+
const providerRes = await fetch(`${config.orchestratorUrl}/api/v1/provider/status`, {
|
|
257
|
+
headers: {
|
|
258
|
+
'Authorization': `Bearer ${apiKey}`
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
if (providerRes.ok) {
|
|
262
|
+
logger.info({ providerId }, 'provider account ready');
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
catch (err) {
|
|
266
|
+
logger.warn('failed to verify provider account');
|
|
267
|
+
}
|
|
268
|
+
// deployment executor already initialized above
|
|
269
|
+
// setup auto-bidder for competitive bidding
|
|
270
|
+
autoBidder = new AutoBidder({
|
|
271
|
+
nodeId,
|
|
272
|
+
providerId,
|
|
273
|
+
orchestratorUrl: config.orchestratorUrl,
|
|
274
|
+
apiKey, // pass the api key for authentication
|
|
275
|
+
pricingStrategy: {
|
|
276
|
+
cpuPricePerCore: 0.00017, // ~$0.01 per core per hour
|
|
277
|
+
memoryPricePerGb: 0.00008, // ~$0.005 per GB per hour
|
|
278
|
+
gpuPricePerUnit: 0.01, // ~$0.60 per gpu per hour
|
|
279
|
+
margin: 0.9 // bid 10% below cost to win
|
|
280
|
+
}
|
|
281
|
+
}, monitor);
|
|
282
|
+
autoBidder.start(15000); // check for orders every 15s
|
|
283
|
+
logger.info({ nodeId }, 'auto-bidder started - will bid on suitable orders');
|
|
284
|
+
// setup lease handler
|
|
285
|
+
if (!deploymentExecutor) {
|
|
286
|
+
throw new Error('deployment executor not initialized');
|
|
287
|
+
}
|
|
288
|
+
leaseHandler = new LeaseHandler({
|
|
289
|
+
nodeId,
|
|
290
|
+
providerId,
|
|
291
|
+
orchestratorUrl: config.orchestratorUrl,
|
|
292
|
+
apiKey
|
|
293
|
+
}, deploymentExecutor);
|
|
294
|
+
leaseHandler.start(10000); // check for leases every 10s
|
|
295
|
+
logger.info({ nodeId }, 'lease handler started - will execute assigned deployments');
|
|
296
|
+
// wire up shell session handling from p2p to deployment executor
|
|
297
|
+
p2p.on('shell-start', async (data) => {
|
|
298
|
+
const { sessionId, deploymentId, serviceName } = data;
|
|
299
|
+
logger.info({ sessionId, deploymentId, serviceName }, 'shell-start request received');
|
|
300
|
+
const success = await deploymentExecutor.startShellSession(sessionId, deploymentId, serviceName, (output) => {
|
|
301
|
+
// send output back to orchestrator via p2p
|
|
302
|
+
p2p.sendToOrchestrator({
|
|
303
|
+
type: 'shell-output',
|
|
304
|
+
data: { sessionId, output }
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
if (!success) {
|
|
308
|
+
logger.warn({ sessionId, deploymentId }, 'failed to start shell session');
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
p2p.on('shell-input', (data) => {
|
|
312
|
+
const { sessionId, input } = data;
|
|
313
|
+
deploymentExecutor.sendShellInput(sessionId, input);
|
|
314
|
+
});
|
|
315
|
+
p2p.on('shell-resize', (data) => {
|
|
316
|
+
const { sessionId, cols, rows } = data;
|
|
317
|
+
deploymentExecutor.resizeShell(sessionId, cols, rows);
|
|
318
|
+
});
|
|
319
|
+
p2p.on('shell-close', (data) => {
|
|
320
|
+
const { sessionId } = data;
|
|
321
|
+
deploymentExecutor.closeShellSession(sessionId);
|
|
322
|
+
});
|
|
323
|
+
// forward shell-closed events from executor back to orchestrator
|
|
324
|
+
deploymentExecutor.on('shell-closed', ({ sessionId }) => {
|
|
325
|
+
p2p.sendToOrchestrator({
|
|
326
|
+
type: 'shell-closed',
|
|
327
|
+
data: { sessionId }
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
logger.info('shell session handlers configured');
|
|
331
|
+
}
|
|
332
|
+
// graceful shutdown on both SIGINT and SIGTERM
|
|
333
|
+
const shutdown = async (signal) => {
|
|
334
|
+
logger.info({ signal }, 'shutting down...');
|
|
335
|
+
stateManager.setStopped();
|
|
336
|
+
if (autoBidder) {
|
|
337
|
+
autoBidder.stop();
|
|
338
|
+
}
|
|
339
|
+
if (leaseHandler) {
|
|
340
|
+
leaseHandler.stop();
|
|
341
|
+
}
|
|
342
|
+
if (heartbeat) {
|
|
343
|
+
await heartbeat.stop();
|
|
344
|
+
}
|
|
345
|
+
await apiServer.stop();
|
|
346
|
+
await containerMgr.stop();
|
|
347
|
+
await p2p.stop();
|
|
348
|
+
await monitor.stop();
|
|
349
|
+
process.exit(0);
|
|
350
|
+
};
|
|
351
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
352
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
353
|
+
}
|
|
354
|
+
catch (err) {
|
|
355
|
+
logger.error({ err }, 'failed to start');
|
|
356
|
+
process.exit(1);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { logger } from '../lib/logger.js';
|
|
2
|
+
import { ResourceMonitor } from '../lib/monitor.js';
|
|
3
|
+
import { stateManager } from '../lib/state.js';
|
|
4
|
+
export async function statusCommand() {
|
|
5
|
+
try {
|
|
6
|
+
const state = stateManager.getState();
|
|
7
|
+
const monitor = new ResourceMonitor();
|
|
8
|
+
await monitor.start();
|
|
9
|
+
const resources = await monitor.getAvailableResources();
|
|
10
|
+
console.log('\n📊 kova node status\n');
|
|
11
|
+
// node running status
|
|
12
|
+
if (state.isRunning) {
|
|
13
|
+
const uptime = stateManager.getUptime();
|
|
14
|
+
const hours = Math.floor(uptime / 1000 / 60 / 60);
|
|
15
|
+
const minutes = Math.floor((uptime / 1000 / 60) % 60);
|
|
16
|
+
console.log(`node status: 🟢 online (${hours}h ${minutes}m uptime)`);
|
|
17
|
+
console.log(`pid: ${state.pid}`);
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
console.log('node status: 🔴 offline');
|
|
21
|
+
}
|
|
22
|
+
console.log('');
|
|
23
|
+
// resources
|
|
24
|
+
console.log(`cpu: ${resources.cpu.available.toFixed(1)} / ${resources.cpu.cores} cores available`);
|
|
25
|
+
console.log(`memory: ${resources.memory.available} / ${resources.memory.total} GB available`);
|
|
26
|
+
console.log(`network: ~${resources.network.bandwidth} Mbps`);
|
|
27
|
+
// check docker
|
|
28
|
+
const Docker = (await import('dockerode')).default;
|
|
29
|
+
const docker = new Docker();
|
|
30
|
+
try {
|
|
31
|
+
await docker.ping();
|
|
32
|
+
console.log('docker: ✅ running');
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
console.log('docker: ❌ not running');
|
|
36
|
+
}
|
|
37
|
+
// earnings summary
|
|
38
|
+
if (state.isRunning) {
|
|
39
|
+
console.log('');
|
|
40
|
+
console.log(`jobs completed: ${state.jobsCompleted}`);
|
|
41
|
+
console.log(`total earned: $${state.totalEarnings.toFixed(4)}`);
|
|
42
|
+
}
|
|
43
|
+
console.log('');
|
|
44
|
+
await monitor.stop();
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
logger.error({ err }, 'status check failed');
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// stop command - gracefully stops a running kova node
|
|
2
|
+
import { logger } from '../lib/logger.js';
|
|
3
|
+
import { stateManager } from '../lib/state.js';
|
|
4
|
+
// check if a process is running
|
|
5
|
+
function isProcessRunning(pid) {
|
|
6
|
+
try {
|
|
7
|
+
// sending signal 0 doesn't actually send a signal, just checks if process exists
|
|
8
|
+
process.kill(pid, 0);
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
catch (err) {
|
|
12
|
+
// process doesn't exist or we don't have permission
|
|
13
|
+
return err.code === 'EPERM';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
// wait for a process to terminate with timeout
|
|
17
|
+
async function waitForProcessExit(pid, timeoutMs = 30000) {
|
|
18
|
+
const startTime = Date.now();
|
|
19
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
20
|
+
if (!isProcessRunning(pid)) {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
export async function stopCommand() {
|
|
28
|
+
logger.info('stopping kova node...');
|
|
29
|
+
// check if node is running by reading state
|
|
30
|
+
const state = stateManager.getState();
|
|
31
|
+
if (!state.isRunning || !state.pid) {
|
|
32
|
+
console.log('\nkova node is not running');
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const pid = state.pid;
|
|
36
|
+
// verify the process is actually running
|
|
37
|
+
if (!isProcessRunning(pid)) {
|
|
38
|
+
console.log('\nkova node is not running (stale state)');
|
|
39
|
+
stateManager.setStopped();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
console.log(`\nfound kova node running with pid ${pid}`);
|
|
43
|
+
console.log('sending graceful shutdown signal...');
|
|
44
|
+
try {
|
|
45
|
+
// send SIGINT for graceful shutdown (same as ctrl+c)
|
|
46
|
+
process.kill(pid, 'SIGINT');
|
|
47
|
+
console.log('waiting for node to stop...');
|
|
48
|
+
// wait up to 30 seconds for graceful shutdown
|
|
49
|
+
const stopped = await waitForProcessExit(pid, 30000);
|
|
50
|
+
if (stopped) {
|
|
51
|
+
console.log('\n✓ kova node stopped successfully');
|
|
52
|
+
// show final stats
|
|
53
|
+
const finalState = stateManager.getState();
|
|
54
|
+
console.log(`\nfinal stats:`);
|
|
55
|
+
console.log(` jobs completed: ${finalState.jobsCompleted}`);
|
|
56
|
+
console.log(` jobs failed: ${finalState.jobsFailed}`);
|
|
57
|
+
console.log(` total earnings: ${finalState.totalEarnings.toFixed(4)}`);
|
|
58
|
+
console.log(` active deployments: ${finalState.activeDeployments.length}`);
|
|
59
|
+
if (finalState.activeDeployments.length > 0) {
|
|
60
|
+
console.log('\nwarning: there were active deployments when node stopped');
|
|
61
|
+
console.log('these will be picked up again when node restarts');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
// graceful shutdown timed out, try harder
|
|
66
|
+
console.log('\ngraceful shutdown timed out, forcing termination...');
|
|
67
|
+
try {
|
|
68
|
+
process.kill(pid, 'SIGTERM');
|
|
69
|
+
// wait another 10 seconds
|
|
70
|
+
const forceStopped = await waitForProcessExit(pid, 10000);
|
|
71
|
+
if (forceStopped) {
|
|
72
|
+
console.log('✓ node terminated');
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
// last resort
|
|
76
|
+
console.log('sending SIGKILL...');
|
|
77
|
+
process.kill(pid, 'SIGKILL');
|
|
78
|
+
await waitForProcessExit(pid, 5000);
|
|
79
|
+
console.log('✓ node killed');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
console.error('failed to terminate process');
|
|
84
|
+
}
|
|
85
|
+
// update state regardless
|
|
86
|
+
stateManager.setStopped();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
if (err.code === 'ESRCH') {
|
|
91
|
+
console.log('\nkova node already stopped');
|
|
92
|
+
stateManager.setStopped();
|
|
93
|
+
}
|
|
94
|
+
else if (err.code === 'EPERM') {
|
|
95
|
+
console.error('\n❌ permission denied - try running with sudo');
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
console.error(`\n❌ failed to stop node: ${err.message}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// shared cli helpers for api calls, auth, and output formatting
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
const CONFIG_DIR = join(homedir(), '.kova');
|
|
6
|
+
const AUTH_FILE = join(CONFIG_DIR, 'auth.json');
|
|
7
|
+
export function getApiUrl() {
|
|
8
|
+
return process.env.KOVA_API_URL || 'https://app.kovanetwork.com';
|
|
9
|
+
}
|
|
10
|
+
export function getAuthToken() {
|
|
11
|
+
try {
|
|
12
|
+
if (!existsSync(AUTH_FILE))
|
|
13
|
+
return null;
|
|
14
|
+
const data = JSON.parse(readFileSync(AUTH_FILE, 'utf8'));
|
|
15
|
+
return data.token || null;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function saveAuthToken(token) {
|
|
22
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
23
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
24
|
+
}
|
|
25
|
+
writeFileSync(AUTH_FILE, JSON.stringify({ token, savedAt: new Date().toISOString() }));
|
|
26
|
+
}
|
|
27
|
+
export function clearAuthToken() {
|
|
28
|
+
try {
|
|
29
|
+
if (existsSync(AUTH_FILE)) {
|
|
30
|
+
writeFileSync(AUTH_FILE, '{}');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch { }
|
|
34
|
+
}
|
|
35
|
+
export async function authFetch(path, options = {}) {
|
|
36
|
+
const token = getAuthToken();
|
|
37
|
+
const url = `${getApiUrl()}${path}`;
|
|
38
|
+
const headers = {
|
|
39
|
+
'Content-Type': 'application/json',
|
|
40
|
+
...(options.headers || {})
|
|
41
|
+
};
|
|
42
|
+
if (token) {
|
|
43
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
44
|
+
}
|
|
45
|
+
return fetch(url, { ...options, headers });
|
|
46
|
+
}
|
|
47
|
+
export function formatDate(dateStr) {
|
|
48
|
+
try {
|
|
49
|
+
const d = new Date(dateStr);
|
|
50
|
+
return d.toLocaleString();
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return dateStr;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export function formatTable(rows) {
|
|
57
|
+
if (rows.length === 0)
|
|
58
|
+
return;
|
|
59
|
+
const keys = Object.keys(rows[0]);
|
|
60
|
+
const widths = {};
|
|
61
|
+
// calculate column widths
|
|
62
|
+
for (const key of keys) {
|
|
63
|
+
widths[key] = key.length;
|
|
64
|
+
for (const row of rows) {
|
|
65
|
+
const val = String(row[key] ?? '');
|
|
66
|
+
widths[key] = Math.max(widths[key], val.length);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// print header
|
|
70
|
+
const header = keys.map(k => k.padEnd(widths[k])).join(' ');
|
|
71
|
+
console.log(header);
|
|
72
|
+
console.log(keys.map(k => '-'.repeat(widths[k])).join(' '));
|
|
73
|
+
// print rows
|
|
74
|
+
for (const row of rows) {
|
|
75
|
+
const line = keys.map(k => String(row[k] ?? '').padEnd(widths[k])).join(' ');
|
|
76
|
+
console.log(line);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export function handleApiError(err) {
|
|
80
|
+
if (err.cause?.code === 'ECONNREFUSED') {
|
|
81
|
+
console.error('\ncannot connect to kova api. check your network or KOVA_API_URL setting.');
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
console.error(`\nerror: ${err.message || 'unknown error'}`);
|
|
85
|
+
}
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|