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,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
+ }
@@ -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,2 @@
1
+ // shared types between all components
2
+ export {};
@@ -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
+ }