nodio-cli 1.0.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/.env.example +6 -0
- package/README.md +127 -0
- package/package.json +33 -0
- package/render.yaml +22 -0
- package/src/common/crypto.js +41 -0
- package/src/node/index.js +52 -0
- package/src/node/runtime.js +154 -0
- package/src/node/storage.js +80 -0
- package/src/server/config.js +17 -0
- package/src/server/index.js +58 -0
- package/src/server/models.js +84 -0
- package/src/server/routes.js +372 -0
- package/src/server/services.js +109 -0
- package/src/user/client.js +20 -0
- package/src/user/commands.js +205 -0
- package/src/user/index.js +36 -0
package/.env.example
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Nodio-CLI
|
|
2
|
+
|
|
3
|
+
Nodio is a CLI-based distributed storage network.
|
|
4
|
+
|
|
5
|
+
This implementation includes:
|
|
6
|
+
- Central server (Express + MongoDB) for node registry, shard metadata, placement planning, heartbeat tracking, and replication task orchestration
|
|
7
|
+
- `nodio-node` donor CLI for running storage nodes, sending heartbeats every 30s, storing shard blobs, and executing replication tasks
|
|
8
|
+
- `nodio` user CLI for encrypted upload/download with shard distribution and reconstruction
|
|
9
|
+
|
|
10
|
+
## Requirements
|
|
11
|
+
|
|
12
|
+
- Node.js 20+
|
|
13
|
+
- MongoDB running locally or reachable by URI
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Configure
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
cp .env.example .env
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Environment values:
|
|
28
|
+
- `NODIO_SERVER_PORT`: central API port (default `4000`)
|
|
29
|
+
- `NODIO_MONGO_URI`: MongoDB connection string
|
|
30
|
+
- `NODIO_HEARTBEAT_INTERVAL_MS`: expected heartbeat interval (`30000`)
|
|
31
|
+
- `NODIO_OFFLINE_AFTER_MISSES`: offline threshold in missed heartbeats (`3`)
|
|
32
|
+
- `NODIO_MIN_REPLICAS`: target replicas for placement planning (`5`)
|
|
33
|
+
- `NODIO_EMERGENCY_REPLICA_FLOOR`: immediate repair threshold (`2`)
|
|
34
|
+
|
|
35
|
+
## Start Central Server
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npm run start:server
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Server API base: `http://127.0.0.1:4000/api`
|
|
42
|
+
|
|
43
|
+
## Deploy On Render (Central Server)
|
|
44
|
+
|
|
45
|
+
Use a **Web Service** for the central server.
|
|
46
|
+
|
|
47
|
+
- Build Command: `npm install`
|
|
48
|
+
- Start Command: `npm run start:server`
|
|
49
|
+
- Health Check Path: `/api/health`
|
|
50
|
+
|
|
51
|
+
Required environment variables on Render:
|
|
52
|
+
- `NODIO_MONGO_URI` (MongoDB Atlas URI)
|
|
53
|
+
|
|
54
|
+
Optional environment variables:
|
|
55
|
+
- `NODIO_HEARTBEAT_INTERVAL_MS`
|
|
56
|
+
- `NODIO_OFFLINE_AFTER_MISSES`
|
|
57
|
+
- `NODIO_MIN_REPLICAS`
|
|
58
|
+
- `NODIO_EMERGENCY_REPLICA_FLOOR`
|
|
59
|
+
|
|
60
|
+
Notes:
|
|
61
|
+
- Render sets `PORT` automatically, and the server now uses it.
|
|
62
|
+
- Donor nodes (`nodio-node`) should run on persistent machines or VMs, not on ephemeral web-service instances.
|
|
63
|
+
|
|
64
|
+
## Start Donor Node CLI
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
npm run start:node -- \
|
|
68
|
+
--node-id node-a \
|
|
69
|
+
--server http://127.0.0.1:4000 \
|
|
70
|
+
--host 127.0.0.1 \
|
|
71
|
+
--port 5001 \
|
|
72
|
+
--storage-dir ./.nodio-node-a \
|
|
73
|
+
--capacity-gb 10
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Run more nodes by changing `--node-id`, `--port`, and `--storage-dir`.
|
|
77
|
+
|
|
78
|
+
## User CLI (Phase 2)
|
|
79
|
+
|
|
80
|
+
Upload (encrypt + shard + distribute):
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
npm run start:cli -- upload \
|
|
84
|
+
--file ./example.txt \
|
|
85
|
+
--server http://127.0.0.1:4000 \
|
|
86
|
+
--shard-size-mb 1 \
|
|
87
|
+
--replicas 5
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The upload command prints:
|
|
91
|
+
- `fileId`
|
|
92
|
+
- `aes256KeyBase64` (required for download)
|
|
93
|
+
|
|
94
|
+
Download (fetch + verify + decrypt + reconstruct):
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
npm run start:cli -- download \
|
|
98
|
+
--file-id <FILE_ID> \
|
|
99
|
+
--key-base64 <AES_256_KEY_BASE64> \
|
|
100
|
+
--server http://127.0.0.1:4000 \
|
|
101
|
+
--output ./restored-example.txt
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Core Behavior Implemented
|
|
105
|
+
|
|
106
|
+
- Files can be represented as shards with metadata in MongoDB (`files`, `shards`, and `shard placements`)
|
|
107
|
+
- Placement planning enforces distinct nodes per shard and defaults to 5 replicas
|
|
108
|
+
- User uploads enforce at least 5 replicas per shard
|
|
109
|
+
- Node heartbeats every 30 seconds update status and available storage
|
|
110
|
+
- If a node misses 3 heartbeat intervals, it is marked offline
|
|
111
|
+
- When live replicas of a shard drop below 2, the server immediately creates replication tasks to healthy nodes
|
|
112
|
+
- Donor nodes fetch pending replication tasks through heartbeats and self-heal by copying shard data from source nodes
|
|
113
|
+
- User downloads verify shard checksums, decrypt with AES-256-GCM metadata, and reconstruct files in shard order
|
|
114
|
+
|
|
115
|
+
## Implemented API Surface
|
|
116
|
+
|
|
117
|
+
- `POST /api/nodes/register`
|
|
118
|
+
- `POST /api/nodes/heartbeat`
|
|
119
|
+
- `POST /api/replication-tasks/:taskId/complete`
|
|
120
|
+
- `POST /api/files/register`
|
|
121
|
+
- `POST /api/shards/register`
|
|
122
|
+
- `POST /api/shards/placement-plan`
|
|
123
|
+
- `GET /api/files/:fileId/manifest`
|
|
124
|
+
|
|
125
|
+
## Next Step
|
|
126
|
+
|
|
127
|
+
Add authentication between user CLI, central server, and donor nodes for production security.
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nodio-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Nodio distributed storage network",
|
|
5
|
+
"main": "src/server/index.js",
|
|
6
|
+
"type": "commonjs",
|
|
7
|
+
"bin": {
|
|
8
|
+
"nodio-server": "src/server/index.js",
|
|
9
|
+
"nodio-node": "src/node/index.js",
|
|
10
|
+
"nodio": "src/user/index.js"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"start:server": "node ./src/server/index.js",
|
|
14
|
+
"start:node": "node ./src/node/index.js",
|
|
15
|
+
"start:cli": "node ./src/user/index.js",
|
|
16
|
+
"lint": "node -e \"console.log('No linter configured')\""
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"distributed-storage",
|
|
20
|
+
"cli",
|
|
21
|
+
"nodio"
|
|
22
|
+
],
|
|
23
|
+
"author": "",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"axios": "^1.11.0",
|
|
27
|
+
"commander": "^14.0.1",
|
|
28
|
+
"dotenv": "^17.2.2",
|
|
29
|
+
"express": "^5.1.0",
|
|
30
|
+
"mongoose": "^8.18.0",
|
|
31
|
+
"uuid": "^13.0.0"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/render.yaml
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
services:
|
|
2
|
+
- type: web
|
|
3
|
+
name: nodio-server
|
|
4
|
+
env: node
|
|
5
|
+
rootDir: .
|
|
6
|
+
buildCommand: npm install
|
|
7
|
+
startCommand: npm run start:server
|
|
8
|
+
healthCheckPath: /api/health
|
|
9
|
+
autoDeploy: true
|
|
10
|
+
envVars:
|
|
11
|
+
- key: NODE_VERSION
|
|
12
|
+
value: 20
|
|
13
|
+
- key: NODIO_MONGO_URI
|
|
14
|
+
sync: false
|
|
15
|
+
- key: NODIO_HEARTBEAT_INTERVAL_MS
|
|
16
|
+
value: "30000"
|
|
17
|
+
- key: NODIO_OFFLINE_AFTER_MISSES
|
|
18
|
+
value: "3"
|
|
19
|
+
- key: NODIO_MIN_REPLICAS
|
|
20
|
+
value: "5"
|
|
21
|
+
- key: NODIO_EMERGENCY_REPLICA_FLOOR
|
|
22
|
+
value: "2"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
|
|
3
|
+
function sha256Hex(buffer) {
|
|
4
|
+
return crypto.createHash('sha256').update(buffer).digest('hex');
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function encryptAes256Gcm(plainBuffer, keyBuffer) {
|
|
8
|
+
if (!Buffer.isBuffer(keyBuffer) || keyBuffer.length !== 32) {
|
|
9
|
+
throw new Error('keyBuffer must be a 32-byte buffer for AES-256');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const iv = crypto.randomBytes(12);
|
|
13
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', keyBuffer, iv);
|
|
14
|
+
const encrypted = Buffer.concat([cipher.update(plainBuffer), cipher.final()]);
|
|
15
|
+
const authTag = cipher.getAuthTag();
|
|
16
|
+
return {
|
|
17
|
+
cipherText: encrypted,
|
|
18
|
+
iv: iv.toString('base64'),
|
|
19
|
+
authTag: authTag.toString('base64')
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function decryptAes256Gcm(cipherBuffer, keyBuffer, ivBase64, authTagBase64) {
|
|
24
|
+
if (!Buffer.isBuffer(keyBuffer) || keyBuffer.length !== 32) {
|
|
25
|
+
throw new Error('keyBuffer must be a 32-byte buffer for AES-256');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const decipher = crypto.createDecipheriv(
|
|
29
|
+
'aes-256-gcm',
|
|
30
|
+
keyBuffer,
|
|
31
|
+
Buffer.from(ivBase64, 'base64')
|
|
32
|
+
);
|
|
33
|
+
decipher.setAuthTag(Buffer.from(authTagBase64, 'base64'));
|
|
34
|
+
return Buffer.concat([decipher.update(cipherBuffer), decipher.final()]);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = {
|
|
38
|
+
sha256Hex,
|
|
39
|
+
encryptAes256Gcm,
|
|
40
|
+
decryptAes256Gcm
|
|
41
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const { Command } = require('commander');
|
|
5
|
+
const { v4: uuidv4 } = require('uuid');
|
|
6
|
+
const { NodioNodeRuntime } = require('./runtime');
|
|
7
|
+
|
|
8
|
+
const program = new Command();
|
|
9
|
+
|
|
10
|
+
program
|
|
11
|
+
.name('nodio-node')
|
|
12
|
+
.description('Nodio donor node CLI')
|
|
13
|
+
.option('--node-id <id>', 'unique node ID', uuidv4())
|
|
14
|
+
.option('--server <url>', 'central server URL', 'http://127.0.0.1:4000')
|
|
15
|
+
.option('--host <host>', 'host/IP exposed to network', '127.0.0.1')
|
|
16
|
+
.option('--port <port>', 'port to expose shard API', '5001')
|
|
17
|
+
.option('--storage-dir <path>', 'local shard storage directory', path.join(os.homedir(), '.nodio-node'))
|
|
18
|
+
.option('--capacity-gb <gb>', 'donated capacity in GB', '10')
|
|
19
|
+
.option('--heartbeat-ms <ms>', 'heartbeat interval in milliseconds', '30000');
|
|
20
|
+
|
|
21
|
+
program.action(async (options) => {
|
|
22
|
+
const port = Number(options.port);
|
|
23
|
+
const capacityGb = Number(options.capacityGb);
|
|
24
|
+
const heartbeatMs = Number(options.heartbeatMs);
|
|
25
|
+
|
|
26
|
+
if (!Number.isInteger(port) || port <= 0) {
|
|
27
|
+
throw new Error('port must be a positive integer');
|
|
28
|
+
}
|
|
29
|
+
if (!Number.isFinite(capacityGb) || capacityGb <= 0) {
|
|
30
|
+
throw new Error('capacity-gb must be greater than 0');
|
|
31
|
+
}
|
|
32
|
+
if (!Number.isFinite(heartbeatMs) || heartbeatMs <= 0) {
|
|
33
|
+
throw new Error('heartbeat-ms must be greater than 0');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const runtime = new NodioNodeRuntime({
|
|
37
|
+
nodeId: options.nodeId,
|
|
38
|
+
serverUrl: options.server,
|
|
39
|
+
publicUrl: `http://${options.host}:${port}`,
|
|
40
|
+
port,
|
|
41
|
+
storageDir: path.resolve(options.storageDir),
|
|
42
|
+
capacityBytes: Math.floor(capacityGb * 1024 * 1024 * 1024),
|
|
43
|
+
heartbeatIntervalMs: heartbeatMs
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
await runtime.start();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
program.parseAsync(process.argv).catch((error) => {
|
|
50
|
+
console.error('[nodio-node]', error.message);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const axios = require('axios');
|
|
3
|
+
const { LocalShardStore } = require('./storage');
|
|
4
|
+
|
|
5
|
+
function normalizeUrl(url) {
|
|
6
|
+
return String(url || '').replace(/\/+$/, '');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
class NodioNodeRuntime {
|
|
10
|
+
constructor(options) {
|
|
11
|
+
this.nodeId = options.nodeId;
|
|
12
|
+
this.serverUrl = normalizeUrl(options.serverUrl);
|
|
13
|
+
this.publicUrl = normalizeUrl(options.publicUrl);
|
|
14
|
+
this.port = Number(options.port);
|
|
15
|
+
this.capacityBytes = Number(options.capacityBytes);
|
|
16
|
+
this.heartbeatIntervalMs = Number(options.heartbeatIntervalMs || 30000);
|
|
17
|
+
this.shardStore = new LocalShardStore(options.storageDir);
|
|
18
|
+
this.heartbeatTimer = null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async start() {
|
|
22
|
+
await this.shardStore.init();
|
|
23
|
+
await this.startShardServer();
|
|
24
|
+
await this.registerNode();
|
|
25
|
+
await this.sendHeartbeat();
|
|
26
|
+
|
|
27
|
+
this.heartbeatTimer = setInterval(async () => {
|
|
28
|
+
try {
|
|
29
|
+
await this.sendHeartbeat();
|
|
30
|
+
} catch (error) {
|
|
31
|
+
console.error('[heartbeat]', error.message);
|
|
32
|
+
}
|
|
33
|
+
}, this.heartbeatIntervalMs);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async startShardServer() {
|
|
37
|
+
const app = express();
|
|
38
|
+
app.use('/shards/:shardId', express.raw({ type: '*/*', limit: '200mb' }));
|
|
39
|
+
|
|
40
|
+
app.put('/shards/:shardId', async (req, res) => {
|
|
41
|
+
try {
|
|
42
|
+
const { shardId } = req.params;
|
|
43
|
+
if (!Buffer.isBuffer(req.body)) {
|
|
44
|
+
return res.status(400).json({ error: 'binary payload is required' });
|
|
45
|
+
}
|
|
46
|
+
const record = await this.shardStore.saveShard(shardId, req.body);
|
|
47
|
+
res.json({ ok: true, shard: record });
|
|
48
|
+
} catch (error) {
|
|
49
|
+
res.status(500).json({ error: error.message });
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
app.get('/shards/:shardId', async (req, res) => {
|
|
54
|
+
try {
|
|
55
|
+
const { shardId } = req.params;
|
|
56
|
+
if (!(await this.shardStore.hasShard(shardId))) {
|
|
57
|
+
return res.status(404).json({ error: 'shard not found' });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const data = await this.shardStore.readShard(shardId);
|
|
61
|
+
res.setHeader('Content-Type', 'application/octet-stream');
|
|
62
|
+
res.send(data);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
res.status(500).json({ error: error.message });
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
app.get('/health', (_req, res) => {
|
|
69
|
+
res.json({ ok: true, nodeId: this.nodeId });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
await new Promise((resolve) => {
|
|
73
|
+
app.listen(this.port, () => {
|
|
74
|
+
console.log(`Nodio node ${this.nodeId} listening on port ${this.port}`);
|
|
75
|
+
resolve();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async freeBytes() {
|
|
81
|
+
const used = await this.shardStore.usedBytes();
|
|
82
|
+
return Math.max(this.capacityBytes - used, 0);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async registerNode() {
|
|
86
|
+
const response = await axios.post(`${this.serverUrl}/api/nodes/register`, {
|
|
87
|
+
nodeId: this.nodeId,
|
|
88
|
+
url: this.publicUrl,
|
|
89
|
+
capacityBytes: this.capacityBytes,
|
|
90
|
+
freeBytes: await this.freeBytes()
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const interval = Number(response.data.heartbeatIntervalMs);
|
|
94
|
+
if (Number.isFinite(interval) && interval > 0) {
|
|
95
|
+
this.heartbeatIntervalMs = interval;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
console.log(
|
|
99
|
+
`Registered node ${this.nodeId} | min replicas: ${response.data.minReplicas} | emergency floor: ${response.data.emergencyReplicaFloor}`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async sendHeartbeat() {
|
|
104
|
+
const shardIds = await this.shardStore.listShardIds();
|
|
105
|
+
const response = await axios.post(`${this.serverUrl}/api/nodes/heartbeat`, {
|
|
106
|
+
nodeId: this.nodeId,
|
|
107
|
+
freeBytes: await this.freeBytes(),
|
|
108
|
+
shardIds
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const tasks = response.data.replicationTasks || [];
|
|
112
|
+
for (const task of tasks) {
|
|
113
|
+
await this.executeReplicationTask(task);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
console.log(
|
|
117
|
+
`[heartbeat] ${new Date().toISOString()} | shards=${shardIds.length} | tasks=${tasks.length}`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async executeReplicationTask(task) {
|
|
122
|
+
const sourceShardUrl = `${normalizeUrl(task.sourceUrl)}/shards/${task.shardId}`;
|
|
123
|
+
try {
|
|
124
|
+
const sourceResponse = await axios.get(sourceShardUrl, {
|
|
125
|
+
responseType: 'arraybuffer',
|
|
126
|
+
timeout: 30000
|
|
127
|
+
});
|
|
128
|
+
const data = Buffer.from(sourceResponse.data);
|
|
129
|
+
await this.shardStore.saveShard(task.shardId, data);
|
|
130
|
+
|
|
131
|
+
await axios.post(`${this.serverUrl}/api/replication-tasks/${task.taskId}/complete`, {
|
|
132
|
+
nodeId: this.nodeId,
|
|
133
|
+
success: true
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
console.log(`[replication] completed task=${task.taskId} shard=${task.shardId}`);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
const message = error.response?.data?.error || error.message;
|
|
139
|
+
await axios
|
|
140
|
+
.post(`${this.serverUrl}/api/replication-tasks/${task.taskId}/complete`, {
|
|
141
|
+
nodeId: this.nodeId,
|
|
142
|
+
success: false,
|
|
143
|
+
errorMessage: message
|
|
144
|
+
})
|
|
145
|
+
.catch(() => null);
|
|
146
|
+
|
|
147
|
+
console.error(`[replication] failed task=${task.taskId} shard=${task.shardId}: ${message}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = {
|
|
153
|
+
NodioNodeRuntime
|
|
154
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
const fs = require('fs/promises');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { sha256Hex } = require('../common/crypto');
|
|
4
|
+
|
|
5
|
+
class LocalShardStore {
|
|
6
|
+
constructor(baseDir) {
|
|
7
|
+
this.baseDir = baseDir;
|
|
8
|
+
this.indexFile = path.join(baseDir, 'index.json');
|
|
9
|
+
this.shardsDir = path.join(baseDir, 'shards');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async init() {
|
|
13
|
+
await fs.mkdir(this.shardsDir, { recursive: true });
|
|
14
|
+
try {
|
|
15
|
+
await fs.access(this.indexFile);
|
|
16
|
+
} catch {
|
|
17
|
+
await fs.writeFile(this.indexFile, JSON.stringify({ shards: {} }, null, 2), 'utf-8');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async readIndex() {
|
|
22
|
+
const raw = await fs.readFile(this.indexFile, 'utf-8');
|
|
23
|
+
const parsed = JSON.parse(raw);
|
|
24
|
+
if (!parsed.shards || typeof parsed.shards !== 'object') {
|
|
25
|
+
return { shards: {} };
|
|
26
|
+
}
|
|
27
|
+
return parsed;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async writeIndex(index) {
|
|
31
|
+
await fs.writeFile(this.indexFile, JSON.stringify(index, null, 2), 'utf-8');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
shardPath(shardId) {
|
|
35
|
+
return path.join(this.shardsDir, `${shardId}.bin`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async saveShard(shardId, dataBuffer) {
|
|
39
|
+
const filePath = this.shardPath(shardId);
|
|
40
|
+
await fs.writeFile(filePath, dataBuffer);
|
|
41
|
+
|
|
42
|
+
const index = await this.readIndex();
|
|
43
|
+
index.shards[shardId] = {
|
|
44
|
+
shardId,
|
|
45
|
+
sizeBytes: dataBuffer.byteLength,
|
|
46
|
+
checksum: sha256Hex(dataBuffer),
|
|
47
|
+
updatedAt: new Date().toISOString()
|
|
48
|
+
};
|
|
49
|
+
await this.writeIndex(index);
|
|
50
|
+
return index.shards[shardId];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async readShard(shardId) {
|
|
54
|
+
const filePath = this.shardPath(shardId);
|
|
55
|
+
return fs.readFile(filePath);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async hasShard(shardId) {
|
|
59
|
+
try {
|
|
60
|
+
await fs.access(this.shardPath(shardId));
|
|
61
|
+
return true;
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async listShardIds() {
|
|
68
|
+
const index = await this.readIndex();
|
|
69
|
+
return Object.keys(index.shards);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async usedBytes() {
|
|
73
|
+
const index = await this.readIndex();
|
|
74
|
+
return Object.values(index.shards).reduce((sum, shard) => sum + Number(shard.sizeBytes || 0), 0);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = {
|
|
79
|
+
LocalShardStore
|
|
80
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
require('dotenv').config({ path: path.resolve(process.cwd(), '.env') });
|
|
3
|
+
|
|
4
|
+
function getServerConfig() {
|
|
5
|
+
return {
|
|
6
|
+
port: Number(process.env.PORT || process.env.NODIO_SERVER_PORT || 4000),
|
|
7
|
+
mongoUri: process.env.NODIO_MONGO_URI || 'mongodb://127.0.0.1:27017/nodio',
|
|
8
|
+
heartbeatIntervalMs: Number(process.env.NODIO_HEARTBEAT_INTERVAL_MS || 30000),
|
|
9
|
+
offlineAfterMisses: Number(process.env.NODIO_OFFLINE_AFTER_MISSES || 3),
|
|
10
|
+
minReplicas: Number(process.env.NODIO_MIN_REPLICAS || 5),
|
|
11
|
+
emergencyReplicaFloor: Number(process.env.NODIO_EMERGENCY_REPLICA_FLOOR || 2)
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
module.exports = {
|
|
16
|
+
getServerConfig
|
|
17
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const express = require('express');
|
|
3
|
+
const mongoose = require('mongoose');
|
|
4
|
+
const { getServerConfig } = require('./config');
|
|
5
|
+
const { buildRoutes } = require('./routes');
|
|
6
|
+
const { NodeModel } = require('./models');
|
|
7
|
+
const { markNodeOfflineAndRecover } = require('./services');
|
|
8
|
+
|
|
9
|
+
async function startServer() {
|
|
10
|
+
const config = getServerConfig();
|
|
11
|
+
|
|
12
|
+
await mongoose.connect(config.mongoUri);
|
|
13
|
+
|
|
14
|
+
const app = express();
|
|
15
|
+
app.use(express.json({ limit: '10mb' }));
|
|
16
|
+
app.use('/api', buildRoutes(config));
|
|
17
|
+
|
|
18
|
+
app.use((error, _req, res, _next) => {
|
|
19
|
+
const status = error.statusCode || 500;
|
|
20
|
+
const message = error.message || 'internal server error';
|
|
21
|
+
if (status >= 500) {
|
|
22
|
+
console.error('[server-error]', error);
|
|
23
|
+
}
|
|
24
|
+
res.status(status).json({ error: message });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const offlineThresholdMs = config.heartbeatIntervalMs * config.offlineAfterMisses;
|
|
28
|
+
|
|
29
|
+
setInterval(async () => {
|
|
30
|
+
try {
|
|
31
|
+
const staleBefore = new Date(Date.now() - offlineThresholdMs);
|
|
32
|
+
const staleNodes = await NodeModel.find({
|
|
33
|
+
status: 'online',
|
|
34
|
+
lastHeartbeatAt: { $lt: staleBefore }
|
|
35
|
+
})
|
|
36
|
+
.select('nodeId')
|
|
37
|
+
.lean();
|
|
38
|
+
|
|
39
|
+
for (const node of staleNodes) {
|
|
40
|
+
const changed = await markNodeOfflineAndRecover(node.nodeId, config.emergencyReplicaFloor);
|
|
41
|
+
if (changed) {
|
|
42
|
+
console.warn(`[heartbeat] node ${node.nodeId} marked offline`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
} catch (error) {
|
|
46
|
+
console.error('[offline-monitor]', error);
|
|
47
|
+
}
|
|
48
|
+
}, config.heartbeatIntervalMs);
|
|
49
|
+
|
|
50
|
+
app.listen(config.port, () => {
|
|
51
|
+
console.log(`Nodio central server listening on port ${config.port}`);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
startServer().catch((error) => {
|
|
56
|
+
console.error('[startup]', error);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
const mongoose = require('mongoose');
|
|
2
|
+
|
|
3
|
+
const nodeSchema = new mongoose.Schema(
|
|
4
|
+
{
|
|
5
|
+
nodeId: { type: String, required: true, unique: true, index: true },
|
|
6
|
+
url: { type: String, required: true },
|
|
7
|
+
capacityBytes: { type: Number, required: true, min: 1 },
|
|
8
|
+
freeBytes: { type: Number, required: true, min: 0 },
|
|
9
|
+
status: { type: String, enum: ['online', 'offline'], default: 'online', index: true },
|
|
10
|
+
lastHeartbeatAt: { type: Date, default: Date.now, index: true }
|
|
11
|
+
},
|
|
12
|
+
{ timestamps: true }
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
const fileSchema = new mongoose.Schema(
|
|
16
|
+
{
|
|
17
|
+
fileId: { type: String, required: true, unique: true, index: true },
|
|
18
|
+
originalName: { type: String, required: true },
|
|
19
|
+
sizeBytes: { type: Number, required: true, min: 0 },
|
|
20
|
+
shardCount: { type: Number, required: true, min: 1 },
|
|
21
|
+
cipher: { type: String, required: true, default: 'aes-256-gcm' },
|
|
22
|
+
metadata: { type: mongoose.Schema.Types.Mixed, default: {} }
|
|
23
|
+
},
|
|
24
|
+
{ timestamps: true }
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const shardSchema = new mongoose.Schema(
|
|
28
|
+
{
|
|
29
|
+
shardId: { type: String, required: true, unique: true, index: true },
|
|
30
|
+
fileId: { type: String, required: true, index: true },
|
|
31
|
+
order: { type: Number, required: true, min: 0 },
|
|
32
|
+
sizeBytes: { type: Number, required: true, min: 0 },
|
|
33
|
+
checksum: { type: String, required: true }
|
|
34
|
+
},
|
|
35
|
+
{ timestamps: true }
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const shardPlacementSchema = new mongoose.Schema(
|
|
39
|
+
{
|
|
40
|
+
shardId: { type: String, required: true, index: true },
|
|
41
|
+
fileId: { type: String, required: true, index: true },
|
|
42
|
+
nodeId: { type: String, required: true, index: true },
|
|
43
|
+
status: { type: String, enum: ['available', 'lost'], default: 'available', index: true }
|
|
44
|
+
},
|
|
45
|
+
{ timestamps: true }
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
shardPlacementSchema.index({ shardId: 1, nodeId: 1 }, { unique: true });
|
|
49
|
+
|
|
50
|
+
const replicationTaskSchema = new mongoose.Schema(
|
|
51
|
+
{
|
|
52
|
+
shardId: { type: String, required: true, index: true },
|
|
53
|
+
fileId: { type: String, required: true, index: true },
|
|
54
|
+
sourceNodeId: { type: String, required: true, index: true },
|
|
55
|
+
targetNodeId: { type: String, required: true, index: true },
|
|
56
|
+
status: {
|
|
57
|
+
type: String,
|
|
58
|
+
enum: ['pending', 'in_progress', 'completed', 'failed'],
|
|
59
|
+
default: 'pending',
|
|
60
|
+
index: true
|
|
61
|
+
},
|
|
62
|
+
errorMessage: { type: String, default: null },
|
|
63
|
+
attempts: { type: Number, default: 0 }
|
|
64
|
+
},
|
|
65
|
+
{ timestamps: true }
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
replicationTaskSchema.index(
|
|
69
|
+
{ shardId: 1, targetNodeId: 1, status: 1 },
|
|
70
|
+
{
|
|
71
|
+
unique: true,
|
|
72
|
+
partialFilterExpression: {
|
|
73
|
+
status: { $in: ['pending', 'in_progress'] }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
module.exports = {
|
|
79
|
+
NodeModel: mongoose.model('Node', nodeSchema),
|
|
80
|
+
FileModel: mongoose.model('File', fileSchema),
|
|
81
|
+
ShardModel: mongoose.model('Shard', shardSchema),
|
|
82
|
+
ShardPlacementModel: mongoose.model('ShardPlacement', shardPlacementSchema),
|
|
83
|
+
ReplicationTaskModel: mongoose.model('ReplicationTask', replicationTaskSchema)
|
|
84
|
+
};
|