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,105 @@
|
|
|
1
|
+
import si from 'systeminformation';
|
|
2
|
+
import { logger } from './logger.js';
|
|
3
|
+
export class ResourceMonitor {
|
|
4
|
+
interval = null;
|
|
5
|
+
lastStats = {};
|
|
6
|
+
async start() {
|
|
7
|
+
// check resources every 30 seconds
|
|
8
|
+
this.interval = setInterval(() => {
|
|
9
|
+
this.collectStats();
|
|
10
|
+
}, 30000);
|
|
11
|
+
// get initial stats
|
|
12
|
+
await this.collectStats();
|
|
13
|
+
}
|
|
14
|
+
async stop() {
|
|
15
|
+
if (this.interval) {
|
|
16
|
+
clearInterval(this.interval);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
async collectStats() {
|
|
20
|
+
try {
|
|
21
|
+
const [cpu, mem, disk, net, graphics] = await Promise.all([
|
|
22
|
+
si.currentLoad(),
|
|
23
|
+
si.mem(),
|
|
24
|
+
si.fsSize(),
|
|
25
|
+
si.networkStats(),
|
|
26
|
+
si.graphics()
|
|
27
|
+
]);
|
|
28
|
+
// collect gpu info
|
|
29
|
+
const gpus = graphics.controllers
|
|
30
|
+
.filter((g) => g.vram > 0) // only real gpus with vram
|
|
31
|
+
.map((g) => ({
|
|
32
|
+
vendor: g.vendor?.toLowerCase() || 'unknown',
|
|
33
|
+
model: g.model || 'unknown',
|
|
34
|
+
vram: Math.round(g.vram / 1024), // convert to gb
|
|
35
|
+
bus: g.bus || 'pci'
|
|
36
|
+
}));
|
|
37
|
+
this.lastStats = {
|
|
38
|
+
cpu: {
|
|
39
|
+
usage: cpu.currentLoad,
|
|
40
|
+
cores: cpu.cpus.length,
|
|
41
|
+
perCore: cpu.cpus.map(c => c.load)
|
|
42
|
+
},
|
|
43
|
+
memory: {
|
|
44
|
+
total: Math.round((mem.total / (1024 ** 3)) * 10) / 10, // gb with 1 decimal
|
|
45
|
+
available: Math.round((mem.available / (1024 ** 3)) * 10) / 10,
|
|
46
|
+
used: Math.round(((mem.total - mem.available) / (1024 ** 3)) * 10) / 10
|
|
47
|
+
},
|
|
48
|
+
disk: disk.map(d => ({
|
|
49
|
+
fs: d.fs,
|
|
50
|
+
total: Math.round((d.size / (1024 ** 3)) * 10) / 10, // gb with 1 decimal
|
|
51
|
+
available: Math.round((d.available / (1024 ** 3)) * 10) / 10
|
|
52
|
+
})),
|
|
53
|
+
network: {
|
|
54
|
+
rx: net[0]?.rx_sec || 0,
|
|
55
|
+
tx: net[0]?.tx_sec || 0
|
|
56
|
+
},
|
|
57
|
+
gpu: gpus,
|
|
58
|
+
timestamp: Date.now()
|
|
59
|
+
};
|
|
60
|
+
logger.debug(this.lastStats, 'collected system stats');
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
logger.error({ err }, 'failed to collect stats');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async getAvailableResources() {
|
|
67
|
+
// get fresh stats every time
|
|
68
|
+
await this.collectStats();
|
|
69
|
+
const stats = this.lastStats;
|
|
70
|
+
// what can we actually offer based on CURRENT usage
|
|
71
|
+
const reserved = {
|
|
72
|
+
memory: 0.5, // keep 0.5gb for system
|
|
73
|
+
disk: 10 // keep 10gb free
|
|
74
|
+
};
|
|
75
|
+
// calculate available CPU based on ACTUAL current load
|
|
76
|
+
const cpuUsagePercent = stats.cpu.usage || 0;
|
|
77
|
+
const availableCpuPercent = Math.max(0, 100 - cpuUsagePercent);
|
|
78
|
+
const availableCpuCores = (availableCpuPercent / 100) * stats.cpu.cores;
|
|
79
|
+
return {
|
|
80
|
+
cpu: {
|
|
81
|
+
cores: stats.cpu.cores,
|
|
82
|
+
// actual available CPU based on current load
|
|
83
|
+
available: Math.max(0, Math.round(availableCpuCores * 10) / 10)
|
|
84
|
+
},
|
|
85
|
+
memory: {
|
|
86
|
+
total: stats.memory.total,
|
|
87
|
+
// actual available memory from system
|
|
88
|
+
available: Math.max(0, stats.memory.available - reserved.memory)
|
|
89
|
+
},
|
|
90
|
+
disk: stats.disk.map((d) => ({
|
|
91
|
+
path: d.fs,
|
|
92
|
+
total: d.total,
|
|
93
|
+
available: Math.max(0, d.available - reserved.disk)
|
|
94
|
+
})),
|
|
95
|
+
network: {
|
|
96
|
+
// just report what we got
|
|
97
|
+
bandwidth: Math.round((stats.network.rx + stats.network.tx) / 125000) // mbps
|
|
98
|
+
},
|
|
99
|
+
gpu: stats.gpu || []
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
getStats() {
|
|
103
|
+
return this.lastStats;
|
|
104
|
+
}
|
|
105
|
+
}
|
package/dist/lib/p2p.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// @ts-ignore-next-line
|
|
2
|
+
import Hyperswarm from 'hyperswarm';
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
import { EventEmitter } from 'events';
|
|
5
|
+
import { logger } from './logger.js';
|
|
6
|
+
import { MessageSigner } from './message-signer.js';
|
|
7
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
import { homedir } from 'os';
|
|
10
|
+
export class P2PNode extends EventEmitter {
|
|
11
|
+
swarm;
|
|
12
|
+
topic;
|
|
13
|
+
connections = new Map();
|
|
14
|
+
signer;
|
|
15
|
+
savedKeyPair;
|
|
16
|
+
constructor(options) {
|
|
17
|
+
super();
|
|
18
|
+
this.signer = new MessageSigner();
|
|
19
|
+
this.topic = crypto.createHash('sha256').update('kova-network').digest();
|
|
20
|
+
this.savedKeyPair = this.loadKeyPair();
|
|
21
|
+
}
|
|
22
|
+
loadKeyPair() {
|
|
23
|
+
const kovaDir = join(homedir(), '.kova');
|
|
24
|
+
const keyPath = join(kovaDir, 'node-keypair.json');
|
|
25
|
+
try {
|
|
26
|
+
if (existsSync(keyPath)) {
|
|
27
|
+
const data = JSON.parse(readFileSync(keyPath, 'utf8'));
|
|
28
|
+
logger.info('loaded existing node keypair');
|
|
29
|
+
return {
|
|
30
|
+
publicKey: Buffer.from(data.publicKey, 'hex'),
|
|
31
|
+
secretKey: Buffer.from(data.secretKey, 'hex')
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
logger.warn({ err }, 'failed to load keypair, will generate on start');
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
saveKeyPair(keyPair) {
|
|
41
|
+
const kovaDir = join(homedir(), '.kova');
|
|
42
|
+
const keyPath = join(kovaDir, 'node-keypair.json');
|
|
43
|
+
try {
|
|
44
|
+
if (!existsSync(kovaDir)) {
|
|
45
|
+
mkdirSync(kovaDir, { recursive: true });
|
|
46
|
+
}
|
|
47
|
+
writeFileSync(keyPath, JSON.stringify({
|
|
48
|
+
publicKey: keyPair.publicKey.toString('hex'),
|
|
49
|
+
secretKey: keyPair.secretKey.toString('hex')
|
|
50
|
+
}), { mode: 0o600 });
|
|
51
|
+
logger.info('saved node keypair');
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
logger.warn({ err }, 'failed to save keypair');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async start() {
|
|
58
|
+
// let hyperswarm generate a proper noise keypair if we don't have one saved
|
|
59
|
+
const swarmOpts = this.savedKeyPair ? { keyPair: this.savedKeyPair } : {};
|
|
60
|
+
this.swarm = new Hyperswarm(swarmOpts);
|
|
61
|
+
// save the generated keypair for next run
|
|
62
|
+
if (!this.savedKeyPair && this.swarm.keyPair) {
|
|
63
|
+
this.saveKeyPair(this.swarm.keyPair);
|
|
64
|
+
}
|
|
65
|
+
// join the kova network topic
|
|
66
|
+
this.swarm.join(this.topic, { server: true, client: true });
|
|
67
|
+
// handle new connections
|
|
68
|
+
this.swarm.on('connection', (socket, info) => {
|
|
69
|
+
const connId = info.publicKey.toString('hex').substring(0, 12);
|
|
70
|
+
this.connections.set(connId, socket);
|
|
71
|
+
logger.info({ connId, peer: info.publicKey.toString('hex') }, 'new peer connected');
|
|
72
|
+
// handle incoming messages with size limit
|
|
73
|
+
const MAX_MESSAGE_SIZE = 1024 * 1024; // 1mb
|
|
74
|
+
socket.on('data', (data) => {
|
|
75
|
+
if (data.length > MAX_MESSAGE_SIZE) {
|
|
76
|
+
logger.warn({ connId, size: data.length }, 'message too large, dropping');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
const signedMessage = JSON.parse(data.toString());
|
|
81
|
+
if (!this.signer.verify(signedMessage)) {
|
|
82
|
+
logger.warn({ connId }, 'rejected message with bad signature');
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
this.handleMessage(signedMessage.payload, socket);
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
logger.debug({ err }, 'failed to parse p2p message');
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
socket.on('close', () => {
|
|
92
|
+
this.connections.delete(connId);
|
|
93
|
+
logger.debug({ connId }, 'peer disconnected');
|
|
94
|
+
});
|
|
95
|
+
socket.on('error', (err) => {
|
|
96
|
+
logger.debug({ err, connId }, 'peer connection error');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
logger.info({
|
|
100
|
+
topic: this.topic.toString('hex'),
|
|
101
|
+
connections: this.connections.size
|
|
102
|
+
}, 'p2p node started');
|
|
103
|
+
}
|
|
104
|
+
handleMessage(message, socket) {
|
|
105
|
+
switch (message.type) {
|
|
106
|
+
case 'job-request':
|
|
107
|
+
this.emit('job-request', message.data);
|
|
108
|
+
break;
|
|
109
|
+
case 'job-cancel':
|
|
110
|
+
this.emit('job-cancel', message.data);
|
|
111
|
+
break;
|
|
112
|
+
case 'deployment-manifest':
|
|
113
|
+
this.emit('deployment-manifest', message.data);
|
|
114
|
+
break;
|
|
115
|
+
case 'deployment-close':
|
|
116
|
+
this.emit('deployment-close', message.data);
|
|
117
|
+
break;
|
|
118
|
+
case 'deployment-paused':
|
|
119
|
+
this.emit('deployment-paused', message.data);
|
|
120
|
+
break;
|
|
121
|
+
// shell session handling - forward to deployment executor
|
|
122
|
+
case 'shell-start':
|
|
123
|
+
this.emit('shell-start', message.data);
|
|
124
|
+
break;
|
|
125
|
+
case 'shell-input':
|
|
126
|
+
this.emit('shell-input', message.data);
|
|
127
|
+
break;
|
|
128
|
+
case 'shell-resize':
|
|
129
|
+
this.emit('shell-resize', message.data);
|
|
130
|
+
break;
|
|
131
|
+
case 'shell-close':
|
|
132
|
+
this.emit('shell-close', message.data);
|
|
133
|
+
break;
|
|
134
|
+
case 'node-announcement':
|
|
135
|
+
break;
|
|
136
|
+
default:
|
|
137
|
+
logger.debug({ type: message.type }, 'unknown message type');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async stop() {
|
|
141
|
+
if (this.swarm) {
|
|
142
|
+
await this.swarm.destroy();
|
|
143
|
+
logger.info('p2p node stopped');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async advertiseCapabilities(resources) {
|
|
147
|
+
const payload = {
|
|
148
|
+
type: 'node-announcement',
|
|
149
|
+
data: {
|
|
150
|
+
nodeId: this.getPeerId(),
|
|
151
|
+
resources,
|
|
152
|
+
version: '0.0.1'
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
const signedMessage = this.signer.sign(payload);
|
|
156
|
+
for (const socket of this.connections.values()) {
|
|
157
|
+
try {
|
|
158
|
+
socket.write(JSON.stringify(signedMessage));
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
// connection probably dead
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
logger.info({ resources }, 'advertised capabilities to network');
|
|
165
|
+
}
|
|
166
|
+
getPeerId() {
|
|
167
|
+
// use swarm keyPair public key as peer id
|
|
168
|
+
return this.swarm?.keyPair?.publicKey?.toString('hex').substring(0, 16) || 'unknown';
|
|
169
|
+
}
|
|
170
|
+
async sendToOrchestrator(message) {
|
|
171
|
+
const signedMessage = this.signer.sign(message);
|
|
172
|
+
for (const socket of this.connections.values()) {
|
|
173
|
+
try {
|
|
174
|
+
socket.write(JSON.stringify(signedMessage));
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
getConnectionCount() {
|
|
184
|
+
return this.connections.size;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import si from 'systeminformation';
|
|
2
|
+
import { logger } from './logger.js';
|
|
3
|
+
export class ResourceLimitManager {
|
|
4
|
+
limits;
|
|
5
|
+
currentUsage = { cpu: 0, memory: 0, disk: 0 };
|
|
6
|
+
constructor(limits) {
|
|
7
|
+
this.limits = limits;
|
|
8
|
+
}
|
|
9
|
+
static async createFromOptions(options) {
|
|
10
|
+
const [cpu, mem] = await Promise.all([
|
|
11
|
+
si.cpu(),
|
|
12
|
+
si.mem()
|
|
13
|
+
]);
|
|
14
|
+
const totalCpu = cpu.cores;
|
|
15
|
+
const totalMemoryGB = mem.total / (1024 ** 3);
|
|
16
|
+
// use defaults if not specified
|
|
17
|
+
const limits = {
|
|
18
|
+
cpu: options.maxCpu ? parseFloat(options.maxCpu) : totalCpu,
|
|
19
|
+
memory: options.maxMemory ? parseFloat(options.maxMemory) : Math.floor(totalMemoryGB * 0.8),
|
|
20
|
+
disk: options.maxDisk ? parseFloat(options.maxDisk) : 100
|
|
21
|
+
};
|
|
22
|
+
// don't let them set more than they have
|
|
23
|
+
if (limits.cpu > totalCpu) {
|
|
24
|
+
logger.warn({ requested: limits.cpu, available: totalCpu }, 'capping cpu to system max');
|
|
25
|
+
limits.cpu = totalCpu;
|
|
26
|
+
}
|
|
27
|
+
if (limits.memory > totalMemoryGB) {
|
|
28
|
+
logger.warn({ requested: limits.memory, available: totalMemoryGB }, 'capping memory to system max');
|
|
29
|
+
limits.memory = Math.floor(totalMemoryGB);
|
|
30
|
+
}
|
|
31
|
+
logger.info({ limits }, 'resource limits set');
|
|
32
|
+
return new ResourceLimitManager(limits);
|
|
33
|
+
}
|
|
34
|
+
getLimits() {
|
|
35
|
+
return { ...this.limits };
|
|
36
|
+
}
|
|
37
|
+
getAvailableResources() {
|
|
38
|
+
return {
|
|
39
|
+
cpu: Math.max(0, this.limits.cpu - this.currentUsage.cpu),
|
|
40
|
+
memory: Math.max(0, this.limits.memory - this.currentUsage.memory),
|
|
41
|
+
disk: Math.max(0, this.limits.disk - this.currentUsage.disk)
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
canAcceptJob(required) {
|
|
45
|
+
const available = this.getAvailableResources();
|
|
46
|
+
const canAccept = required.cpu <= available.cpu &&
|
|
47
|
+
required.memory <= available.memory &&
|
|
48
|
+
(!required.disk || required.disk <= available.disk);
|
|
49
|
+
if (!canAccept) {
|
|
50
|
+
logger.warn({ required, available }, 'not enough resources');
|
|
51
|
+
}
|
|
52
|
+
return canAccept;
|
|
53
|
+
}
|
|
54
|
+
allocateResources(jobId, resources) {
|
|
55
|
+
if (!this.canAcceptJob(resources)) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
this.currentUsage.cpu += resources.cpu;
|
|
59
|
+
this.currentUsage.memory += resources.memory;
|
|
60
|
+
if (resources.disk) {
|
|
61
|
+
this.currentUsage.disk += resources.disk;
|
|
62
|
+
}
|
|
63
|
+
logger.info({ jobId, resources }, 'allocated resources');
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
releaseResources(jobId, resources) {
|
|
67
|
+
this.currentUsage.cpu = Math.max(0, this.currentUsage.cpu - resources.cpu);
|
|
68
|
+
this.currentUsage.memory = Math.max(0, this.currentUsage.memory - resources.memory);
|
|
69
|
+
if (resources.disk) {
|
|
70
|
+
this.currentUsage.disk = Math.max(0, this.currentUsage.disk - resources.disk);
|
|
71
|
+
}
|
|
72
|
+
logger.info({ jobId }, 'released resources');
|
|
73
|
+
}
|
|
74
|
+
getCurrentUsage() {
|
|
75
|
+
return { ...this.currentUsage };
|
|
76
|
+
}
|
|
77
|
+
getUsagePercentage() {
|
|
78
|
+
return {
|
|
79
|
+
cpu: (this.currentUsage.cpu / this.limits.cpu) * 100,
|
|
80
|
+
memory: (this.currentUsage.memory / this.limits.memory) * 100,
|
|
81
|
+
disk: (this.currentUsage.disk / this.limits.disk) * 100
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// persistent state management for the node
|
|
2
|
+
// saves to ~/.kova/node-state.json
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { homedir } from 'os';
|
|
6
|
+
class StateManager {
|
|
7
|
+
state = {
|
|
8
|
+
isRunning: false,
|
|
9
|
+
totalEarnings: 0,
|
|
10
|
+
jobsCompleted: 0,
|
|
11
|
+
jobsFailed: 0,
|
|
12
|
+
activeDeployments: []
|
|
13
|
+
};
|
|
14
|
+
statePath;
|
|
15
|
+
saveTimeout = null;
|
|
16
|
+
constructor() {
|
|
17
|
+
const kovaDir = join(homedir(), '.kova');
|
|
18
|
+
this.statePath = join(kovaDir, 'node-state.json');
|
|
19
|
+
// ensure directory exists
|
|
20
|
+
if (!existsSync(kovaDir)) {
|
|
21
|
+
mkdirSync(kovaDir, { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
// load persisted state
|
|
24
|
+
this.loadState();
|
|
25
|
+
}
|
|
26
|
+
loadState() {
|
|
27
|
+
try {
|
|
28
|
+
if (existsSync(this.statePath)) {
|
|
29
|
+
const data = JSON.parse(readFileSync(this.statePath, 'utf8'));
|
|
30
|
+
// restore persistent fields only (not runtime state like isRunning/pid)
|
|
31
|
+
this.state.totalEarnings = data.totalEarnings || 0;
|
|
32
|
+
this.state.jobsCompleted = data.jobsCompleted || 0;
|
|
33
|
+
this.state.jobsFailed = data.jobsFailed || 0;
|
|
34
|
+
this.state.activeDeployments = data.activeDeployments || [];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
// ignore load errors, start fresh
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
saveState() {
|
|
42
|
+
// debounce saves to avoid excessive disk writes
|
|
43
|
+
if (this.saveTimeout) {
|
|
44
|
+
clearTimeout(this.saveTimeout);
|
|
45
|
+
}
|
|
46
|
+
this.saveTimeout = setTimeout(() => {
|
|
47
|
+
try {
|
|
48
|
+
writeFileSync(this.statePath, JSON.stringify(this.state, null, 2));
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
// ignore save errors
|
|
52
|
+
}
|
|
53
|
+
}, 1000);
|
|
54
|
+
}
|
|
55
|
+
// force immediate save (for shutdown)
|
|
56
|
+
saveNow() {
|
|
57
|
+
if (this.saveTimeout) {
|
|
58
|
+
clearTimeout(this.saveTimeout);
|
|
59
|
+
this.saveTimeout = null;
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
writeFileSync(this.statePath, JSON.stringify(this.state, null, 2));
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
// ignore
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
setRunning(pid) {
|
|
69
|
+
this.state.isRunning = true;
|
|
70
|
+
this.state.pid = pid;
|
|
71
|
+
this.state.startTime = Date.now();
|
|
72
|
+
this.saveState();
|
|
73
|
+
}
|
|
74
|
+
setStopped() {
|
|
75
|
+
this.state.isRunning = false;
|
|
76
|
+
this.state.pid = undefined;
|
|
77
|
+
this.saveNow();
|
|
78
|
+
}
|
|
79
|
+
addEarnings(amount) {
|
|
80
|
+
this.state.totalEarnings += amount;
|
|
81
|
+
this.saveState();
|
|
82
|
+
}
|
|
83
|
+
incrementCompleted() {
|
|
84
|
+
this.state.jobsCompleted++;
|
|
85
|
+
this.saveState();
|
|
86
|
+
}
|
|
87
|
+
incrementFailed() {
|
|
88
|
+
this.state.jobsFailed++;
|
|
89
|
+
this.saveState();
|
|
90
|
+
}
|
|
91
|
+
addDeployment(deploymentId) {
|
|
92
|
+
if (!this.state.activeDeployments.includes(deploymentId)) {
|
|
93
|
+
this.state.activeDeployments.push(deploymentId);
|
|
94
|
+
this.saveState();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
removeDeployment(deploymentId) {
|
|
98
|
+
this.state.activeDeployments = this.state.activeDeployments.filter(id => id !== deploymentId);
|
|
99
|
+
this.saveState();
|
|
100
|
+
}
|
|
101
|
+
getActiveDeployments() {
|
|
102
|
+
return [...this.state.activeDeployments];
|
|
103
|
+
}
|
|
104
|
+
getState() {
|
|
105
|
+
return { ...this.state };
|
|
106
|
+
}
|
|
107
|
+
getUptime() {
|
|
108
|
+
if (!this.state.startTime)
|
|
109
|
+
return 0;
|
|
110
|
+
return Date.now() - this.state.startTime;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
export const stateManager = new StateManager();
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import Decimal from 'decimal.js';
|
|
2
|
+
export class UsageMeter {
|
|
3
|
+
records = new Map();
|
|
4
|
+
// track usage for a running job
|
|
5
|
+
startMeter(jobId, nodeId, userId) {
|
|
6
|
+
this.records.set(jobId, {
|
|
7
|
+
jobId,
|
|
8
|
+
nodeId,
|
|
9
|
+
userId,
|
|
10
|
+
startTime: new Date(),
|
|
11
|
+
endTime: new Date(), // will update
|
|
12
|
+
usage: {
|
|
13
|
+
cpuSeconds: 0,
|
|
14
|
+
memoryGBSeconds: 0,
|
|
15
|
+
storageGBHours: 0,
|
|
16
|
+
networkGBTransferred: 0
|
|
17
|
+
},
|
|
18
|
+
cost: new Decimal(0)
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
// update usage metrics
|
|
22
|
+
updateUsage(jobId, metrics) {
|
|
23
|
+
const record = this.records.get(jobId);
|
|
24
|
+
if (!record)
|
|
25
|
+
return;
|
|
26
|
+
const now = new Date();
|
|
27
|
+
const elapsed = (now.getTime() - record.endTime.getTime()) / 1000; // seconds
|
|
28
|
+
// accumulate usage
|
|
29
|
+
record.usage.cpuSeconds += metrics.cpu * elapsed;
|
|
30
|
+
record.usage.memoryGBSeconds += metrics.memory * elapsed;
|
|
31
|
+
if (metrics.storage) {
|
|
32
|
+
record.usage.storageGBHours += metrics.storage * (elapsed / 3600);
|
|
33
|
+
}
|
|
34
|
+
if (metrics.network) {
|
|
35
|
+
const transferred = (metrics.network.rx + metrics.network.tx) / (1024 ** 3); // to gb
|
|
36
|
+
record.usage.networkGBTransferred += transferred;
|
|
37
|
+
}
|
|
38
|
+
record.endTime = now;
|
|
39
|
+
}
|
|
40
|
+
// finalize and calculate total cost
|
|
41
|
+
finalizeMeter(jobId, pricing) {
|
|
42
|
+
const record = this.records.get(jobId);
|
|
43
|
+
if (!record)
|
|
44
|
+
return null;
|
|
45
|
+
// convert to hours for pricing
|
|
46
|
+
const cpuHours = record.usage.cpuSeconds / 3600;
|
|
47
|
+
const memoryGBHours = record.usage.memoryGBSeconds / 3600;
|
|
48
|
+
// calculate costs
|
|
49
|
+
const cpuCost = new Decimal(cpuHours).times(pricing.cpuPerHour);
|
|
50
|
+
const memoryCost = new Decimal(memoryGBHours).times(pricing.memoryPerGBHour);
|
|
51
|
+
const storageCost = new Decimal(record.usage.storageGBHours).times(pricing.storagePerGBHour);
|
|
52
|
+
const networkCost = new Decimal(record.usage.networkGBTransferred).times(pricing.networkPerGB);
|
|
53
|
+
record.cost = cpuCost.plus(memoryCost).plus(storageCost).plus(networkCost);
|
|
54
|
+
// minimum charge 1 cent
|
|
55
|
+
if (record.cost.lessThan(0.01)) {
|
|
56
|
+
record.cost = new Decimal(0.01);
|
|
57
|
+
}
|
|
58
|
+
return record;
|
|
59
|
+
}
|
|
60
|
+
getRecord(jobId) {
|
|
61
|
+
return this.records.get(jobId);
|
|
62
|
+
}
|
|
63
|
+
}
|