portok 1.0.1 → 1.0.3
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 +57 -12
- package/bench/baseline.bench.js +1 -1
- package/bench/connections.bench.js +1 -1
- package/bench/helpers.js +124 -0
- package/bench/latency.bench.js +1 -1
- package/bench/run.js +0 -86
- package/bench/switching.bench.js +1 -1
- package/bench/throughput.bench.js +1 -1
- package/docker-compose.yml +1 -1
- package/package.json +1 -1
- package/portok.js +304 -7
- package/portok@.service +1 -1
- package/test/drain.test.js +27 -15
package/README.md
CHANGED
|
@@ -15,8 +15,15 @@ A lightweight Node.js "switchboard" proxy that enables zero-downtime deployments
|
|
|
15
15
|
|
|
16
16
|
### Installation
|
|
17
17
|
|
|
18
|
+
**Global Installation (Recommended):**
|
|
19
|
+
|
|
18
20
|
```bash
|
|
19
|
-
|
|
21
|
+
# Install globally
|
|
22
|
+
npm install -g portok
|
|
23
|
+
|
|
24
|
+
# Now you can use portok and portokd commands from anywhere
|
|
25
|
+
portok --help
|
|
26
|
+
portokd --help
|
|
20
27
|
```
|
|
21
28
|
|
|
22
29
|
### Start the Daemon
|
|
@@ -28,25 +35,27 @@ export INITIAL_TARGET_PORT=8080
|
|
|
28
35
|
export ADMIN_TOKEN=your-secret-token
|
|
29
36
|
|
|
30
37
|
# Start portokd
|
|
31
|
-
node portokd.
|
|
38
|
+
node portokd.js
|
|
32
39
|
```
|
|
33
40
|
|
|
34
41
|
### Use the CLI
|
|
35
42
|
|
|
36
43
|
```bash
|
|
37
44
|
# Check status
|
|
38
|
-
|
|
45
|
+
portok status --token your-secret-token
|
|
39
46
|
|
|
40
47
|
# Switch to new port
|
|
41
|
-
|
|
48
|
+
portok switch 8081 --token your-secret-token
|
|
42
49
|
|
|
43
50
|
# Check metrics
|
|
44
|
-
|
|
51
|
+
portok metrics --token your-secret-token
|
|
45
52
|
|
|
46
53
|
# Check health
|
|
47
|
-
|
|
54
|
+
portok health --token your-secret-token
|
|
48
55
|
```
|
|
49
56
|
|
|
57
|
+
> **Note:** If not installed globally, use `./portok.js` instead of `portok`.
|
|
58
|
+
|
|
50
59
|
## Configuration
|
|
51
60
|
|
|
52
61
|
All configuration is via environment variables:
|
|
@@ -188,8 +197,17 @@ portok <command> [options]
|
|
|
188
197
|
Management Commands:
|
|
189
198
|
init Initialize portok directories (/etc/portok, /var/lib/portok)
|
|
190
199
|
add <name> Create a new service instance
|
|
200
|
+
remove <name> Remove a service instance (stops, disables, deletes config/state)
|
|
191
201
|
list List all configured instances and their status
|
|
192
202
|
|
|
203
|
+
Service Control Commands:
|
|
204
|
+
start <name> Start a portok service (systemctl start portok@<name>)
|
|
205
|
+
stop <name> Stop a portok service
|
|
206
|
+
restart <name> Restart a portok service
|
|
207
|
+
enable <name> Enable service at boot
|
|
208
|
+
disable <name> Disable service at boot
|
|
209
|
+
logs <name> Show service logs (journalctl)
|
|
210
|
+
|
|
193
211
|
Operational Commands:
|
|
194
212
|
status Show current proxy status
|
|
195
213
|
metrics Show proxy metrics
|
|
@@ -208,6 +226,14 @@ Options for 'add' command:
|
|
|
208
226
|
--target <port> Target port (default: random 8000-8999)
|
|
209
227
|
--health <path> Health check path (default: /health)
|
|
210
228
|
--force Overwrite existing config
|
|
229
|
+
|
|
230
|
+
Options for 'remove' command:
|
|
231
|
+
--force Skip confirmation prompt
|
|
232
|
+
--keep-state Keep state file (/var/lib/portok/<name>.json)
|
|
233
|
+
|
|
234
|
+
Options for 'logs' command:
|
|
235
|
+
--follow, -f Follow log output
|
|
236
|
+
--lines, -n Number of lines to show (default: 50)
|
|
211
237
|
```
|
|
212
238
|
|
|
213
239
|
### Quick Start with CLI
|
|
@@ -220,12 +246,15 @@ sudo portok init
|
|
|
220
246
|
sudo portok add api --port 3001 --target 8001
|
|
221
247
|
|
|
222
248
|
# 3. Start the service
|
|
223
|
-
sudo
|
|
249
|
+
sudo portok start api
|
|
250
|
+
|
|
251
|
+
# 4. Enable at boot
|
|
252
|
+
sudo portok enable api
|
|
224
253
|
|
|
225
|
-
#
|
|
254
|
+
# 5. Check status
|
|
226
255
|
portok status --instance api
|
|
227
256
|
|
|
228
|
-
#
|
|
257
|
+
# 6. List all services
|
|
229
258
|
portok list
|
|
230
259
|
```
|
|
231
260
|
|
|
@@ -244,6 +273,22 @@ sudo portok init
|
|
|
244
273
|
sudo portok add api --port 3001 --target 8001
|
|
245
274
|
sudo portok add web --port 3002 --target 8002
|
|
246
275
|
|
|
276
|
+
# Service management
|
|
277
|
+
sudo portok start api
|
|
278
|
+
sudo portok stop api
|
|
279
|
+
sudo portok restart api
|
|
280
|
+
sudo portok enable api # Enable at boot
|
|
281
|
+
sudo portok disable api # Disable at boot
|
|
282
|
+
|
|
283
|
+
# Remove a service (stops, disables, removes config and state)
|
|
284
|
+
sudo portok remove api --force
|
|
285
|
+
sudo portok remove api --force --keep-state # Keep state file
|
|
286
|
+
|
|
287
|
+
# View logs
|
|
288
|
+
portok logs api
|
|
289
|
+
portok logs api --follow # Follow log output
|
|
290
|
+
portok logs api -n 100 # Show last 100 lines
|
|
291
|
+
|
|
247
292
|
# List all instances with status
|
|
248
293
|
portok list
|
|
249
294
|
|
|
@@ -276,7 +321,7 @@ After=network.target
|
|
|
276
321
|
Type=simple
|
|
277
322
|
User=www-data
|
|
278
323
|
WorkingDirectory=/opt/portok
|
|
279
|
-
ExecStart=/usr/bin/node /opt/portok/portokd.
|
|
324
|
+
ExecStart=/usr/bin/node /opt/portok/portokd.js
|
|
280
325
|
Restart=always
|
|
281
326
|
RestartSec=5
|
|
282
327
|
|
|
@@ -548,10 +593,10 @@ Run the validation benchmark to verify performance:
|
|
|
548
593
|
|
|
549
594
|
```bash
|
|
550
595
|
# Quick validation (3s)
|
|
551
|
-
FAST_PATH=1 node bench/validate.
|
|
596
|
+
FAST_PATH=1 node bench/validate.js --quick
|
|
552
597
|
|
|
553
598
|
# Full validation (10s)
|
|
554
|
-
FAST_PATH=1 node bench/validate.
|
|
599
|
+
FAST_PATH=1 node bench/validate.js
|
|
555
600
|
|
|
556
601
|
# Manual autocannon test
|
|
557
602
|
# Direct:
|
package/bench/baseline.bench.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
const autocannon = require('autocannon');
|
|
7
|
-
const { createMockServer, startDaemon, getFreePort, formatNumber } = require('./
|
|
7
|
+
const { createMockServer, startDaemon, getFreePort, formatNumber } = require('./helpers.js');
|
|
8
8
|
|
|
9
9
|
async function run({ duration, adminToken }) {
|
|
10
10
|
const shortDuration = Math.max(2, Math.floor(duration / 2));
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
const autocannon = require('autocannon');
|
|
7
|
-
const { createMockServer, startDaemon, getFreePort, formatNumber } = require('./
|
|
7
|
+
const { createMockServer, startDaemon, getFreePort, formatNumber } = require('./helpers.js');
|
|
8
8
|
|
|
9
9
|
async function run({ duration, adminToken }) {
|
|
10
10
|
// Setup
|
package/bench/helpers.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Benchmark Helpers
|
|
3
|
+
* Shared utilities for all benchmark files
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { spawn } = require('node:child_process');
|
|
7
|
+
const http = require('node:http');
|
|
8
|
+
const path = require('node:path');
|
|
9
|
+
|
|
10
|
+
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || 'bench-token-12345';
|
|
11
|
+
|
|
12
|
+
function formatNumber(n) {
|
|
13
|
+
return n.toLocaleString('en-US', { maximumFractionDigits: 2 });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function getFreePort() {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const server = http.createServer();
|
|
19
|
+
server.listen(0, '127.0.0.1', () => {
|
|
20
|
+
const port = server.address().port;
|
|
21
|
+
server.close(() => resolve(port));
|
|
22
|
+
});
|
|
23
|
+
server.on('error', reject);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function waitFor(condition, timeout = 10000) {
|
|
28
|
+
const start = Date.now();
|
|
29
|
+
while (Date.now() - start < timeout) {
|
|
30
|
+
if (await condition()) return true;
|
|
31
|
+
await new Promise(r => setTimeout(r, 100));
|
|
32
|
+
}
|
|
33
|
+
throw new Error('Timeout waiting for condition');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// =============================================================================
|
|
37
|
+
// Mock Server
|
|
38
|
+
// =============================================================================
|
|
39
|
+
|
|
40
|
+
async function createMockServer(options = {}) {
|
|
41
|
+
const { responseDelay = 0, responseSize = 13 } = options;
|
|
42
|
+
const port = await getFreePort();
|
|
43
|
+
|
|
44
|
+
const responseBody = responseSize <= 13
|
|
45
|
+
? 'Hello, World!'
|
|
46
|
+
: 'x'.repeat(responseSize);
|
|
47
|
+
|
|
48
|
+
const server = http.createServer((req, res) => {
|
|
49
|
+
if (req.url === '/health') {
|
|
50
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
51
|
+
res.end('{"status":"ok"}');
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (responseDelay > 0) {
|
|
56
|
+
setTimeout(() => {
|
|
57
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
58
|
+
res.end(responseBody);
|
|
59
|
+
}, responseDelay);
|
|
60
|
+
} else {
|
|
61
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
62
|
+
res.end(responseBody);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
server.listen(port, '127.0.0.1', () => {
|
|
68
|
+
resolve({
|
|
69
|
+
port,
|
|
70
|
+
close: () => new Promise(r => server.close(r)),
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
server.on('error', reject);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// =============================================================================
|
|
78
|
+
// Daemon Starter
|
|
79
|
+
// =============================================================================
|
|
80
|
+
|
|
81
|
+
async function startDaemon(proxyPort, targetPort, extraEnv = {}) {
|
|
82
|
+
const daemonPath = path.join(__dirname, '..', 'portokd.js');
|
|
83
|
+
|
|
84
|
+
const env = {
|
|
85
|
+
...process.env,
|
|
86
|
+
LISTEN_PORT: String(proxyPort),
|
|
87
|
+
INITIAL_TARGET_PORT: String(targetPort),
|
|
88
|
+
ADMIN_TOKEN,
|
|
89
|
+
DRAIN_MS: '100',
|
|
90
|
+
ROLLBACK_WINDOW_MS: '1000',
|
|
91
|
+
ROLLBACK_CHECK_EVERY_MS: '100',
|
|
92
|
+
ROLLBACK_FAIL_THRESHOLD: '2',
|
|
93
|
+
...extraEnv,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const daemon = spawn('node', [daemonPath], {
|
|
97
|
+
env,
|
|
98
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Wait for daemon to be ready
|
|
102
|
+
await waitFor(async () => {
|
|
103
|
+
try {
|
|
104
|
+
const res = await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
|
|
105
|
+
headers: { 'x-admin-token': ADMIN_TOKEN },
|
|
106
|
+
});
|
|
107
|
+
return res.ok;
|
|
108
|
+
} catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}, 10000);
|
|
112
|
+
|
|
113
|
+
return daemon;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = {
|
|
117
|
+
createMockServer,
|
|
118
|
+
startDaemon,
|
|
119
|
+
getFreePort,
|
|
120
|
+
waitFor,
|
|
121
|
+
formatNumber,
|
|
122
|
+
ADMIN_TOKEN,
|
|
123
|
+
};
|
|
124
|
+
|
package/bench/latency.bench.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
const autocannon = require('autocannon');
|
|
7
|
-
const { createMockServer, startDaemon, getFreePort } = require('./
|
|
7
|
+
const { createMockServer, startDaemon, getFreePort } = require('./helpers.js');
|
|
8
8
|
|
|
9
9
|
async function run({ duration, adminToken }) {
|
|
10
10
|
// Setup
|
package/bench/run.js
CHANGED
|
@@ -5,9 +5,6 @@
|
|
|
5
5
|
* Orchestrates all benchmarks and outputs formatted results
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
const { spawn } = require('node:child_process');
|
|
9
|
-
const http = require('node:http');
|
|
10
|
-
|
|
11
8
|
// =============================================================================
|
|
12
9
|
// Configuration
|
|
13
10
|
// =============================================================================
|
|
@@ -31,86 +28,6 @@ function log(msg) {
|
|
|
31
28
|
}
|
|
32
29
|
}
|
|
33
30
|
|
|
34
|
-
function formatNumber(n) {
|
|
35
|
-
return n.toLocaleString('en-US', { maximumFractionDigits: 2 });
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
async function getFreePort() {
|
|
39
|
-
return new Promise((resolve, reject) => {
|
|
40
|
-
const server = http.createServer();
|
|
41
|
-
server.listen(0, '127.0.0.1', () => {
|
|
42
|
-
const port = server.address().port;
|
|
43
|
-
server.close(() => resolve(port));
|
|
44
|
-
});
|
|
45
|
-
server.on('error', reject);
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
async function waitFor(condition, timeout = 10000) {
|
|
50
|
-
const start = Date.now();
|
|
51
|
-
while (Date.now() - start < timeout) {
|
|
52
|
-
if (await condition()) return true;
|
|
53
|
-
await new Promise(r => setTimeout(r, 100));
|
|
54
|
-
}
|
|
55
|
-
throw new Error('Timeout waiting for condition');
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// =============================================================================
|
|
59
|
-
// Mock Server
|
|
60
|
-
// =============================================================================
|
|
61
|
-
|
|
62
|
-
async function createMockServer(port = 0) {
|
|
63
|
-
const server = http.createServer((req, res) => {
|
|
64
|
-
if (req.url === '/health') {
|
|
65
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
66
|
-
res.end('{"status":"healthy"}');
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
70
|
-
res.end('OK');
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
await new Promise(resolve => server.listen(port, '127.0.0.1', resolve));
|
|
74
|
-
return {
|
|
75
|
-
port: server.address().port,
|
|
76
|
-
close: () => new Promise(resolve => server.close(resolve)),
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// =============================================================================
|
|
81
|
-
// Start Daemon
|
|
82
|
-
// =============================================================================
|
|
83
|
-
|
|
84
|
-
async function startDaemon(listenPort, targetPort) {
|
|
85
|
-
const proc = spawn('node', ['portokd.js'], {
|
|
86
|
-
env: {
|
|
87
|
-
...process.env,
|
|
88
|
-
LISTEN_PORT: String(listenPort),
|
|
89
|
-
INITIAL_TARGET_PORT: String(targetPort),
|
|
90
|
-
ADMIN_TOKEN,
|
|
91
|
-
STATE_FILE: `/tmp/portok-bench-${Date.now()}.json`,
|
|
92
|
-
DRAIN_MS: '1000',
|
|
93
|
-
ROLLBACK_WINDOW_MS: '60000',
|
|
94
|
-
ROLLBACK_CHECK_EVERY_MS: '5000',
|
|
95
|
-
ROLLBACK_FAIL_THRESHOLD: '3',
|
|
96
|
-
},
|
|
97
|
-
stdio: 'pipe',
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
await waitFor(async () => {
|
|
101
|
-
try {
|
|
102
|
-
const res = await fetch(`http://127.0.0.1:${listenPort}/__status`, {
|
|
103
|
-
headers: { 'x-admin-token': ADMIN_TOKEN },
|
|
104
|
-
});
|
|
105
|
-
return res.ok;
|
|
106
|
-
} catch {
|
|
107
|
-
return false;
|
|
108
|
-
}
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
return proc;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
31
|
// =============================================================================
|
|
115
32
|
// Run Individual Benchmarks
|
|
116
33
|
// =============================================================================
|
|
@@ -206,6 +123,3 @@ main().catch(err => {
|
|
|
206
123
|
console.error('Benchmark failed:', err);
|
|
207
124
|
process.exit(1);
|
|
208
125
|
});
|
|
209
|
-
|
|
210
|
-
module.exports = { createMockServer, startDaemon, getFreePort, waitFor, formatNumber, ADMIN_TOKEN };
|
|
211
|
-
|
package/bench/switching.bench.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
const autocannon = require('autocannon');
|
|
7
|
-
const { createMockServer, startDaemon, getFreePort, formatNumber, ADMIN_TOKEN } = require('./
|
|
7
|
+
const { createMockServer, startDaemon, getFreePort, formatNumber, ADMIN_TOKEN } = require('./helpers.js');
|
|
8
8
|
|
|
9
9
|
async function run({ duration, adminToken }) {
|
|
10
10
|
// Setup two servers
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
const autocannon = require('autocannon');
|
|
7
|
-
const { createMockServer, startDaemon, getFreePort, formatNumber } = require('./
|
|
7
|
+
const { createMockServer, startDaemon, getFreePort, formatNumber } = require('./helpers.js');
|
|
8
8
|
|
|
9
9
|
async function run({ duration, adminToken }) {
|
|
10
10
|
// Setup
|
package/docker-compose.yml
CHANGED
package/package.json
CHANGED
package/portok.js
CHANGED
|
@@ -314,6 +314,8 @@ async function cmdSwitch(baseUrl, token, port, options) {
|
|
|
314
314
|
async function cmdInit(options) {
|
|
315
315
|
const configDir = ENV_FILE_DIR;
|
|
316
316
|
const stateDir = '/var/lib/portok';
|
|
317
|
+
const systemdDir = '/etc/systemd/system';
|
|
318
|
+
const serviceFileName = 'portok@.service';
|
|
317
319
|
|
|
318
320
|
console.log(`${colors.bold}Initializing Portok...${colors.reset}\n`);
|
|
319
321
|
|
|
@@ -351,6 +353,51 @@ async function cmdInit(options) {
|
|
|
351
353
|
results.push({ path: stateDir, status: 'error', error: err.message });
|
|
352
354
|
}
|
|
353
355
|
|
|
356
|
+
// Install systemd template unit
|
|
357
|
+
const systemdDest = path.join(systemdDir, serviceFileName);
|
|
358
|
+
try {
|
|
359
|
+
// Find the service file relative to this script
|
|
360
|
+
const scriptDir = path.dirname(process.argv[1]);
|
|
361
|
+
const possiblePaths = [
|
|
362
|
+
path.join(scriptDir, serviceFileName),
|
|
363
|
+
path.join(scriptDir, '..', serviceFileName),
|
|
364
|
+
'/opt/portok/' + serviceFileName,
|
|
365
|
+
];
|
|
366
|
+
|
|
367
|
+
let sourceFile = null;
|
|
368
|
+
for (const p of possiblePaths) {
|
|
369
|
+
if (fs.existsSync(p)) {
|
|
370
|
+
sourceFile = p;
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (sourceFile) {
|
|
376
|
+
const content = fs.readFileSync(sourceFile, 'utf-8');
|
|
377
|
+
const existingContent = fs.existsSync(systemdDest) ? fs.readFileSync(systemdDest, 'utf-8') : null;
|
|
378
|
+
|
|
379
|
+
if (existingContent === content) {
|
|
380
|
+
results.push({ path: systemdDest, status: 'exists' });
|
|
381
|
+
} else {
|
|
382
|
+
fs.writeFileSync(systemdDest, content, { mode: 0o644 });
|
|
383
|
+
results.push({ path: systemdDest, status: 'created' });
|
|
384
|
+
|
|
385
|
+
// Reload systemd daemon
|
|
386
|
+
try {
|
|
387
|
+
const { execSync } = require('child_process');
|
|
388
|
+
execSync('systemctl daemon-reload', { stdio: 'pipe' });
|
|
389
|
+
results.push({ path: 'systemctl daemon-reload', status: 'created' });
|
|
390
|
+
} catch (e) {
|
|
391
|
+
// Ignore if systemctl not available (e.g., in Docker)
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
} else {
|
|
395
|
+
results.push({ path: systemdDest, status: 'skipped', error: 'Template file not found' });
|
|
396
|
+
}
|
|
397
|
+
} catch (err) {
|
|
398
|
+
results.push({ path: systemdDest, status: 'error', error: err.message });
|
|
399
|
+
}
|
|
400
|
+
|
|
354
401
|
if (options.json) {
|
|
355
402
|
console.log(JSON.stringify({ success: true, results }, null, 2));
|
|
356
403
|
return 0;
|
|
@@ -360,9 +407,11 @@ async function cmdInit(options) {
|
|
|
360
407
|
for (const r of results) {
|
|
361
408
|
const icon = r.status === 'created' ? `${colors.green}✓${colors.reset}` :
|
|
362
409
|
r.status === 'exists' ? `${colors.dim}○${colors.reset}` :
|
|
410
|
+
r.status === 'skipped' ? `${colors.yellow}○${colors.reset}` :
|
|
363
411
|
`${colors.red}✗${colors.reset}`;
|
|
364
412
|
const status = r.status === 'created' ? 'Created' :
|
|
365
413
|
r.status === 'exists' ? 'Already exists' :
|
|
414
|
+
r.status === 'skipped' ? `Skipped: ${r.error}` :
|
|
366
415
|
`Error: ${r.error}`;
|
|
367
416
|
console.log(` ${icon} ${r.path} - ${status}`);
|
|
368
417
|
}
|
|
@@ -377,7 +426,8 @@ async function cmdInit(options) {
|
|
|
377
426
|
console.log(`\n${colors.green}Portok initialized successfully!${colors.reset}`);
|
|
378
427
|
console.log(`\nNext steps:`);
|
|
379
428
|
console.log(` 1. Create a service: ${colors.cyan}portok add <name>${colors.reset}`);
|
|
380
|
-
console.log(` 2.
|
|
429
|
+
console.log(` 2. Start service: ${colors.cyan}portok start <name>${colors.reset}`);
|
|
430
|
+
console.log(` 3. Enable at boot: ${colors.cyan}portok enable <name>${colors.reset}`);
|
|
381
431
|
|
|
382
432
|
return 0;
|
|
383
433
|
}
|
|
@@ -495,6 +545,130 @@ ROLLBACK_FAIL_THRESHOLD=3
|
|
|
495
545
|
return 0;
|
|
496
546
|
}
|
|
497
547
|
|
|
548
|
+
async function cmdRemove(name, options) {
|
|
549
|
+
if (!name) {
|
|
550
|
+
console.error(`${colors.red}Error:${colors.reset} Service name is required`);
|
|
551
|
+
console.error('Usage: portok remove <name> [--force] [--keep-state]');
|
|
552
|
+
return 1;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const envFilePath = path.join(ENV_FILE_DIR, `${name}.env`);
|
|
556
|
+
const stateFilePath = `/var/lib/portok/${name}.json`;
|
|
557
|
+
|
|
558
|
+
// Check if service exists
|
|
559
|
+
if (!fs.existsSync(envFilePath)) {
|
|
560
|
+
console.error(`${colors.red}Error:${colors.reset} Service '${name}' not found at ${envFilePath}`);
|
|
561
|
+
return 1;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Confirmation (unless --force)
|
|
565
|
+
if (!options.force && !options.json) {
|
|
566
|
+
console.log(`${colors.yellow}Warning:${colors.reset} This will remove service '${name}'`);
|
|
567
|
+
console.log(` Config file: ${envFilePath}`);
|
|
568
|
+
if (fs.existsSync(stateFilePath) && !options['keep-state']) {
|
|
569
|
+
console.log(` State file: ${stateFilePath}`);
|
|
570
|
+
}
|
|
571
|
+
console.log(`\nUse ${colors.cyan}--force${colors.reset} to confirm removal.`);
|
|
572
|
+
return 1;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const results = [];
|
|
576
|
+
const { execSync } = require('child_process');
|
|
577
|
+
|
|
578
|
+
// 1. Stop the service if running
|
|
579
|
+
try {
|
|
580
|
+
execSync(`systemctl is-active portok@${name}`, { stdio: 'pipe' });
|
|
581
|
+
// Service is running, stop it
|
|
582
|
+
try {
|
|
583
|
+
execSync(`systemctl stop portok@${name}`, { stdio: 'pipe' });
|
|
584
|
+
results.push({ action: 'stop', status: 'success' });
|
|
585
|
+
} catch (e) {
|
|
586
|
+
results.push({ action: 'stop', status: 'error', error: e.message });
|
|
587
|
+
}
|
|
588
|
+
} catch {
|
|
589
|
+
// Service not running, skip
|
|
590
|
+
results.push({ action: 'stop', status: 'skipped' });
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// 2. Disable the service
|
|
594
|
+
try {
|
|
595
|
+
execSync(`systemctl is-enabled portok@${name}`, { stdio: 'pipe' });
|
|
596
|
+
// Service is enabled, disable it
|
|
597
|
+
try {
|
|
598
|
+
execSync(`systemctl disable portok@${name}`, { stdio: 'pipe' });
|
|
599
|
+
results.push({ action: 'disable', status: 'success' });
|
|
600
|
+
} catch (e) {
|
|
601
|
+
results.push({ action: 'disable', status: 'error', error: e.message });
|
|
602
|
+
}
|
|
603
|
+
} catch {
|
|
604
|
+
// Service not enabled, skip
|
|
605
|
+
results.push({ action: 'disable', status: 'skipped' });
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// 3. Remove config file
|
|
609
|
+
try {
|
|
610
|
+
fs.unlinkSync(envFilePath);
|
|
611
|
+
results.push({ action: 'remove-config', status: 'success', path: envFilePath });
|
|
612
|
+
} catch (err) {
|
|
613
|
+
results.push({ action: 'remove-config', status: 'error', error: err.message });
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// 4. Remove state file (unless --keep-state)
|
|
617
|
+
if (!options['keep-state']) {
|
|
618
|
+
if (fs.existsSync(stateFilePath)) {
|
|
619
|
+
try {
|
|
620
|
+
fs.unlinkSync(stateFilePath);
|
|
621
|
+
results.push({ action: 'remove-state', status: 'success', path: stateFilePath });
|
|
622
|
+
} catch (err) {
|
|
623
|
+
results.push({ action: 'remove-state', status: 'error', error: err.message });
|
|
624
|
+
}
|
|
625
|
+
} else {
|
|
626
|
+
results.push({ action: 'remove-state', status: 'skipped' });
|
|
627
|
+
}
|
|
628
|
+
} else {
|
|
629
|
+
results.push({ action: 'remove-state', status: 'kept' });
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const hasError = results.some(r => r.status === 'error');
|
|
633
|
+
|
|
634
|
+
if (options.json) {
|
|
635
|
+
console.log(JSON.stringify({ success: !hasError, name, results }, null, 2));
|
|
636
|
+
return hasError ? 1 : 0;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Display results
|
|
640
|
+
console.log(`\n${colors.bold}Removing service '${name}'...${colors.reset}\n`);
|
|
641
|
+
|
|
642
|
+
for (const r of results) {
|
|
643
|
+
const icon = r.status === 'success' ? `${colors.green}✓${colors.reset}` :
|
|
644
|
+
r.status === 'skipped' ? `${colors.dim}○${colors.reset}` :
|
|
645
|
+
r.status === 'kept' ? `${colors.dim}○${colors.reset}` :
|
|
646
|
+
`${colors.red}✗${colors.reset}`;
|
|
647
|
+
|
|
648
|
+
const actionLabel = {
|
|
649
|
+
'stop': 'Stop service',
|
|
650
|
+
'disable': 'Disable service',
|
|
651
|
+
'remove-config': 'Remove config',
|
|
652
|
+
'remove-state': 'Remove state',
|
|
653
|
+
}[r.action];
|
|
654
|
+
|
|
655
|
+
const statusLabel = r.status === 'success' ? 'Done' :
|
|
656
|
+
r.status === 'skipped' ? 'Skipped (not running/enabled)' :
|
|
657
|
+
r.status === 'kept' ? 'Kept (--keep-state)' :
|
|
658
|
+
`Error: ${r.error}`;
|
|
659
|
+
|
|
660
|
+
console.log(` ${icon} ${actionLabel.padEnd(16)} ${statusLabel}`);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (hasError) {
|
|
664
|
+
console.log(`\n${colors.yellow}Warning:${colors.reset} Some operations failed. Check errors above.`);
|
|
665
|
+
return 1;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
console.log(`\n${colors.green}✓ Service '${name}' removed successfully!${colors.reset}`);
|
|
669
|
+
return 0;
|
|
670
|
+
}
|
|
671
|
+
|
|
498
672
|
async function cmdList(options) {
|
|
499
673
|
const configDir = ENV_FILE_DIR;
|
|
500
674
|
|
|
@@ -631,6 +805,92 @@ async function cmdHealth(baseUrl, token, options) {
|
|
|
631
805
|
}
|
|
632
806
|
}
|
|
633
807
|
|
|
808
|
+
// =============================================================================
|
|
809
|
+
// Systemctl Commands (start, stop, restart, enable, disable)
|
|
810
|
+
// =============================================================================
|
|
811
|
+
|
|
812
|
+
const { execSync } = require('node:child_process');
|
|
813
|
+
|
|
814
|
+
async function cmdSystemctl(action, name, options) {
|
|
815
|
+
if (!name) {
|
|
816
|
+
console.error(`${colors.red}Error:${colors.reset} Instance name is required`);
|
|
817
|
+
console.error(`Usage: portok ${action} <name>`);
|
|
818
|
+
return 1;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// Validate name exists
|
|
822
|
+
const envFilePath = path.join(ENV_FILE_DIR, `${name}.env`);
|
|
823
|
+
if (!fs.existsSync(envFilePath)) {
|
|
824
|
+
console.error(`${colors.red}Error:${colors.reset} Instance '${name}' not found`);
|
|
825
|
+
console.error(`Make sure ${envFilePath} exists. Run ${colors.cyan}portok add ${name}${colors.reset} to create it.`);
|
|
826
|
+
return 1;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const serviceName = `portok@${name}`;
|
|
830
|
+
const actionVerb = {
|
|
831
|
+
start: 'Starting',
|
|
832
|
+
stop: 'Stopping',
|
|
833
|
+
restart: 'Restarting',
|
|
834
|
+
enable: 'Enabling',
|
|
835
|
+
disable: 'Disabling',
|
|
836
|
+
}[action];
|
|
837
|
+
|
|
838
|
+
console.log(`${colors.cyan}${actionVerb} ${serviceName}...${colors.reset}`);
|
|
839
|
+
|
|
840
|
+
try {
|
|
841
|
+
execSync(`systemctl ${action} ${serviceName}`, { stdio: 'inherit' });
|
|
842
|
+
|
|
843
|
+
const pastVerb = {
|
|
844
|
+
start: 'started',
|
|
845
|
+
stop: 'stopped',
|
|
846
|
+
restart: 'restarted',
|
|
847
|
+
enable: 'enabled',
|
|
848
|
+
disable: 'disabled',
|
|
849
|
+
}[action];
|
|
850
|
+
|
|
851
|
+
console.log(`${colors.green}✓ Service ${serviceName} ${pastVerb}${colors.reset}`);
|
|
852
|
+
|
|
853
|
+
// For start/restart, show status after
|
|
854
|
+
if (action === 'start' || action === 'restart') {
|
|
855
|
+
console.log('');
|
|
856
|
+
try {
|
|
857
|
+
execSync(`systemctl status ${serviceName} --no-pager -l`, { stdio: 'inherit' });
|
|
858
|
+
} catch {
|
|
859
|
+
// Status might return non-zero even when running, ignore
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
return 0;
|
|
864
|
+
} catch (err) {
|
|
865
|
+
console.error(`${colors.red}Failed to ${action} ${serviceName}${colors.reset}`);
|
|
866
|
+
if (err.status === 1) {
|
|
867
|
+
console.error(`${colors.yellow}Hint:${colors.reset} This command may require sudo privileges.`);
|
|
868
|
+
console.error(`Try: ${colors.cyan}sudo portok ${action} ${name}${colors.reset}`);
|
|
869
|
+
}
|
|
870
|
+
return 1;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
async function cmdLogs(name, options) {
|
|
875
|
+
if (!name) {
|
|
876
|
+
console.error(`${colors.red}Error:${colors.reset} Instance name is required`);
|
|
877
|
+
console.error('Usage: portok logs <name> [--follow]');
|
|
878
|
+
return 1;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
const serviceName = `portok@${name}`;
|
|
882
|
+
const followFlag = options.follow || options.f ? '-f' : '';
|
|
883
|
+
const lines = options.lines || options.n || '50';
|
|
884
|
+
|
|
885
|
+
try {
|
|
886
|
+
execSync(`journalctl -u ${serviceName} -n ${lines} ${followFlag} --no-pager`, { stdio: 'inherit' });
|
|
887
|
+
return 0;
|
|
888
|
+
} catch (err) {
|
|
889
|
+
console.error(`${colors.red}Failed to get logs for ${serviceName}${colors.reset}`);
|
|
890
|
+
return 1;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
634
894
|
function showHelp() {
|
|
635
895
|
console.log(`
|
|
636
896
|
${colors.bold}portok${colors.reset} - CLI for portokd zero-downtime proxy daemon
|
|
@@ -642,8 +902,17 @@ ${colors.bold}COMMANDS${colors.reset}
|
|
|
642
902
|
${colors.cyan}Management:${colors.reset}
|
|
643
903
|
init Initialize portok directories (/etc/portok, /var/lib/portok)
|
|
644
904
|
add <name> Create a new service instance
|
|
905
|
+
remove <name> Remove a service instance (config + state)
|
|
645
906
|
list List all configured instances and their status
|
|
646
907
|
|
|
908
|
+
${colors.cyan}Service Control:${colors.reset}
|
|
909
|
+
start <name> Start a portok service (systemctl start portok@<name>)
|
|
910
|
+
stop <name> Stop a portok service
|
|
911
|
+
restart <name> Restart a portok service
|
|
912
|
+
enable <name> Enable service at boot
|
|
913
|
+
disable <name> Disable service at boot
|
|
914
|
+
logs <name> Show service logs (journalctl)
|
|
915
|
+
|
|
647
916
|
${colors.cyan}Operations:${colors.reset}
|
|
648
917
|
status Show current proxy status (activePort, drainUntil, lastSwitch)
|
|
649
918
|
metrics Show proxy metrics (requests, errors, health, RPS)
|
|
@@ -663,17 +932,35 @@ ${colors.bold}OPTIONS${colors.reset}
|
|
|
663
932
|
--health <path> Health check path (default: /health)
|
|
664
933
|
--force Overwrite existing config
|
|
665
934
|
|
|
935
|
+
${colors.dim}For 'remove' command:${colors.reset}
|
|
936
|
+
--force Skip confirmation prompt
|
|
937
|
+
--keep-state Keep state file (/var/lib/portok/<name>.json)
|
|
938
|
+
|
|
939
|
+
${colors.dim}For 'logs' command:${colors.reset}
|
|
940
|
+
--follow, -f Follow log output
|
|
941
|
+
--lines, -n Number of lines to show (default: 50)
|
|
942
|
+
|
|
666
943
|
${colors.bold}EXAMPLES${colors.reset}
|
|
667
944
|
${colors.dim}# Initialize portok (run once, requires sudo)${colors.reset}
|
|
668
945
|
sudo portok init
|
|
669
946
|
|
|
670
|
-
${colors.dim}# Create a new service${colors.reset}
|
|
947
|
+
${colors.dim}# Create and start a new service${colors.reset}
|
|
671
948
|
sudo portok add api --port 3001 --target 8001
|
|
672
|
-
sudo portok
|
|
949
|
+
sudo portok start api
|
|
950
|
+
sudo portok enable api
|
|
673
951
|
|
|
674
952
|
${colors.dim}# List all instances${colors.reset}
|
|
675
953
|
portok list
|
|
676
954
|
|
|
955
|
+
${colors.dim}# Service management${colors.reset}
|
|
956
|
+
sudo portok start api
|
|
957
|
+
sudo portok stop api
|
|
958
|
+
sudo portok restart api
|
|
959
|
+
portok logs api --follow
|
|
960
|
+
|
|
961
|
+
${colors.dim}# Remove a service${colors.reset}
|
|
962
|
+
sudo portok remove api --force
|
|
963
|
+
|
|
677
964
|
${colors.dim}# Check status of an instance${colors.reset}
|
|
678
965
|
portok status --instance api
|
|
679
966
|
|
|
@@ -683,9 +970,6 @@ ${colors.bold}EXAMPLES${colors.reset}
|
|
|
683
970
|
${colors.dim}# Get metrics as JSON${colors.reset}
|
|
684
971
|
portok metrics --instance api --json
|
|
685
972
|
|
|
686
|
-
${colors.dim}# Direct URL mode (without instance)${colors.reset}
|
|
687
|
-
portok status --url http://127.0.0.1:3000 --token mysecret
|
|
688
|
-
|
|
689
973
|
${colors.bold}MULTI-INSTANCE${colors.reset}
|
|
690
974
|
When using --instance, the CLI reads /etc/portok/<name>.env
|
|
691
975
|
to resolve LISTEN_PORT and ADMIN_TOKEN for that instance.
|
|
@@ -711,7 +995,7 @@ async function main() {
|
|
|
711
995
|
}
|
|
712
996
|
|
|
713
997
|
// Management commands (don't require daemon connection)
|
|
714
|
-
const managementCommands = ['init', 'add', 'list'];
|
|
998
|
+
const managementCommands = ['init', 'add', 'remove', 'list', 'start', 'stop', 'restart', 'enable', 'disable', 'logs'];
|
|
715
999
|
|
|
716
1000
|
if (managementCommands.includes(args.command)) {
|
|
717
1001
|
let exitCode = 1;
|
|
@@ -722,9 +1006,22 @@ async function main() {
|
|
|
722
1006
|
case 'add':
|
|
723
1007
|
exitCode = await cmdAdd(args.positional[0], args.options);
|
|
724
1008
|
break;
|
|
1009
|
+
case 'remove':
|
|
1010
|
+
exitCode = await cmdRemove(args.positional[0], args.options);
|
|
1011
|
+
break;
|
|
725
1012
|
case 'list':
|
|
726
1013
|
exitCode = await cmdList(args.options);
|
|
727
1014
|
break;
|
|
1015
|
+
case 'start':
|
|
1016
|
+
case 'stop':
|
|
1017
|
+
case 'restart':
|
|
1018
|
+
case 'enable':
|
|
1019
|
+
case 'disable':
|
|
1020
|
+
exitCode = await cmdSystemctl(args.command, args.positional[0], args.options);
|
|
1021
|
+
break;
|
|
1022
|
+
case 'logs':
|
|
1023
|
+
exitCode = await cmdLogs(args.positional[0], args.options);
|
|
1024
|
+
break;
|
|
728
1025
|
}
|
|
729
1026
|
process.exit(exitCode);
|
|
730
1027
|
}
|
package/portok@.service
CHANGED
package/test/drain.test.js
CHANGED
|
@@ -210,25 +210,34 @@ describe('Connection Draining', () => {
|
|
|
210
210
|
|
|
211
211
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
212
212
|
|
|
213
|
-
// Start continuous traffic
|
|
213
|
+
// Start continuous traffic using a more reliable approach
|
|
214
214
|
let requestCount = 0;
|
|
215
215
|
let errorCount = 0;
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
216
|
+
let running = true;
|
|
217
|
+
|
|
218
|
+
// Use a loop instead of setInterval for more reliable timing
|
|
219
|
+
const trafficLoop = async () => {
|
|
220
|
+
while (running) {
|
|
221
|
+
try {
|
|
222
|
+
const res = await fetch(`http://127.0.0.1:${proxyPort}/`);
|
|
223
|
+
if (res.ok) {
|
|
224
|
+
requestCount++;
|
|
225
|
+
} else {
|
|
226
|
+
errorCount++;
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
223
229
|
errorCount++;
|
|
224
230
|
}
|
|
225
|
-
|
|
226
|
-
|
|
231
|
+
// Small delay between requests
|
|
232
|
+
await new Promise(resolve => setTimeout(resolve, 30));
|
|
227
233
|
}
|
|
228
|
-
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// Start traffic in background
|
|
237
|
+
const trafficPromise = trafficLoop();
|
|
229
238
|
|
|
230
239
|
// Wait a bit, then switch
|
|
231
|
-
await new Promise(resolve => setTimeout(resolve,
|
|
240
|
+
await new Promise(resolve => setTimeout(resolve, 150));
|
|
232
241
|
|
|
233
242
|
await fetch(`http://127.0.0.1:${proxyPort}/__switch?port=${mockServer2.port}`, {
|
|
234
243
|
method: 'POST',
|
|
@@ -236,12 +245,15 @@ describe('Connection Draining', () => {
|
|
|
236
245
|
});
|
|
237
246
|
|
|
238
247
|
// Continue traffic during switch
|
|
239
|
-
await new Promise(resolve => setTimeout(resolve,
|
|
248
|
+
await new Promise(resolve => setTimeout(resolve, 300));
|
|
240
249
|
|
|
241
|
-
|
|
250
|
+
// Stop traffic
|
|
251
|
+
running = false;
|
|
252
|
+
await trafficPromise;
|
|
242
253
|
|
|
243
254
|
// Should have processed requests with minimal errors
|
|
244
|
-
|
|
255
|
+
// At least 3 requests is enough to verify no drops during switch
|
|
256
|
+
assert.ok(requestCount >= 3, `Expected at least 3 requests, got ${requestCount}`);
|
|
245
257
|
assert.strictEqual(errorCount, 0, `Expected 0 errors, got ${errorCount}`);
|
|
246
258
|
});
|
|
247
259
|
});
|