portok 1.0.2 → 1.0.4
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 +9 -0
- 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 +221 -2
- package/portok@.service +1 -1
- package/test/drain.test.js +27 -15
package/README.md
CHANGED
|
@@ -197,6 +197,7 @@ portok <command> [options]
|
|
|
197
197
|
Management Commands:
|
|
198
198
|
init Initialize portok directories (/etc/portok, /var/lib/portok)
|
|
199
199
|
add <name> Create a new service instance
|
|
200
|
+
remove <name> Remove a service instance (stops, disables, deletes config/state)
|
|
200
201
|
list List all configured instances and their status
|
|
201
202
|
|
|
202
203
|
Service Control Commands:
|
|
@@ -226,6 +227,10 @@ Options for 'add' command:
|
|
|
226
227
|
--health <path> Health check path (default: /health)
|
|
227
228
|
--force Overwrite existing config
|
|
228
229
|
|
|
230
|
+
Options for 'remove' command:
|
|
231
|
+
--force Skip confirmation prompt
|
|
232
|
+
--keep-state Keep state file (/var/lib/portok/<name>.json)
|
|
233
|
+
|
|
229
234
|
Options for 'logs' command:
|
|
230
235
|
--follow, -f Follow log output
|
|
231
236
|
--lines, -n Number of lines to show (default: 50)
|
|
@@ -275,6 +280,10 @@ sudo portok restart api
|
|
|
275
280
|
sudo portok enable api # Enable at boot
|
|
276
281
|
sudo portok disable api # Disable at boot
|
|
277
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
|
+
|
|
278
287
|
# View logs
|
|
279
288
|
portok logs api
|
|
280
289
|
portok logs api --follow # Follow log output
|
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,85 @@ async function cmdInit(options) {
|
|
|
351
353
|
results.push({ path: stateDir, status: 'error', error: err.message });
|
|
352
354
|
}
|
|
353
355
|
|
|
356
|
+
// Install systemd template unit (embedded content)
|
|
357
|
+
const systemdDest = path.join(systemdDir, serviceFileName);
|
|
358
|
+
const serviceContent = `# Portok systemd template unit for multi-instance deployments
|
|
359
|
+
# Auto-generated by: portok init
|
|
360
|
+
|
|
361
|
+
[Unit]
|
|
362
|
+
Description=Portok Zero-Downtime Proxy (%i)
|
|
363
|
+
After=network.target
|
|
364
|
+
Documentation=https://github.com/nicatdursunlu/portok
|
|
365
|
+
|
|
366
|
+
[Service]
|
|
367
|
+
Type=simple
|
|
368
|
+
User=www-data
|
|
369
|
+
Group=www-data
|
|
370
|
+
|
|
371
|
+
# Instance configuration from env file
|
|
372
|
+
EnvironmentFile=/etc/portok/%i.env
|
|
373
|
+
|
|
374
|
+
# Set instance name automatically from systemd specifier
|
|
375
|
+
Environment=INSTANCE_NAME=%i
|
|
376
|
+
|
|
377
|
+
# Working directory
|
|
378
|
+
WorkingDirectory=/opt/portok
|
|
379
|
+
|
|
380
|
+
# Start the daemon
|
|
381
|
+
ExecStart=/usr/bin/node /opt/portok/portokd.js
|
|
382
|
+
|
|
383
|
+
# Restart policy
|
|
384
|
+
Restart=always
|
|
385
|
+
RestartSec=5
|
|
386
|
+
StartLimitBurst=5
|
|
387
|
+
StartLimitIntervalSec=60
|
|
388
|
+
|
|
389
|
+
# Logging
|
|
390
|
+
StandardOutput=journal
|
|
391
|
+
StandardError=journal
|
|
392
|
+
SyslogIdentifier=portok-%i
|
|
393
|
+
|
|
394
|
+
# Security hardening
|
|
395
|
+
NoNewPrivileges=true
|
|
396
|
+
ProtectSystem=strict
|
|
397
|
+
ProtectHome=true
|
|
398
|
+
PrivateTmp=true
|
|
399
|
+
ProtectKernelTunables=true
|
|
400
|
+
ProtectKernelModules=true
|
|
401
|
+
ProtectControlGroups=true
|
|
402
|
+
|
|
403
|
+
# Allow writing to state directory
|
|
404
|
+
ReadWritePaths=/var/lib/portok
|
|
405
|
+
|
|
406
|
+
# Network access (required for proxying)
|
|
407
|
+
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
|
408
|
+
|
|
409
|
+
[Install]
|
|
410
|
+
WantedBy=multi-user.target
|
|
411
|
+
`;
|
|
412
|
+
|
|
413
|
+
try {
|
|
414
|
+
const existingContent = fs.existsSync(systemdDest) ? fs.readFileSync(systemdDest, 'utf-8') : null;
|
|
415
|
+
|
|
416
|
+
if (existingContent === serviceContent) {
|
|
417
|
+
results.push({ path: systemdDest, status: 'exists' });
|
|
418
|
+
} else {
|
|
419
|
+
fs.writeFileSync(systemdDest, serviceContent, { mode: 0o644 });
|
|
420
|
+
results.push({ path: systemdDest, status: 'created' });
|
|
421
|
+
|
|
422
|
+
// Reload systemd daemon
|
|
423
|
+
try {
|
|
424
|
+
const { execSync } = require('child_process');
|
|
425
|
+
execSync('systemctl daemon-reload', { stdio: 'pipe' });
|
|
426
|
+
results.push({ path: 'systemctl daemon-reload', status: 'created' });
|
|
427
|
+
} catch (e) {
|
|
428
|
+
// Ignore if systemctl not available (e.g., in Docker)
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
} catch (err) {
|
|
432
|
+
results.push({ path: systemdDest, status: 'error', error: err.message });
|
|
433
|
+
}
|
|
434
|
+
|
|
354
435
|
if (options.json) {
|
|
355
436
|
console.log(JSON.stringify({ success: true, results }, null, 2));
|
|
356
437
|
return 0;
|
|
@@ -360,9 +441,11 @@ async function cmdInit(options) {
|
|
|
360
441
|
for (const r of results) {
|
|
361
442
|
const icon = r.status === 'created' ? `${colors.green}✓${colors.reset}` :
|
|
362
443
|
r.status === 'exists' ? `${colors.dim}○${colors.reset}` :
|
|
444
|
+
r.status === 'skipped' ? `${colors.yellow}○${colors.reset}` :
|
|
363
445
|
`${colors.red}✗${colors.reset}`;
|
|
364
446
|
const status = r.status === 'created' ? 'Created' :
|
|
365
447
|
r.status === 'exists' ? 'Already exists' :
|
|
448
|
+
r.status === 'skipped' ? `Skipped: ${r.error}` :
|
|
366
449
|
`Error: ${r.error}`;
|
|
367
450
|
console.log(` ${icon} ${r.path} - ${status}`);
|
|
368
451
|
}
|
|
@@ -377,7 +460,8 @@ async function cmdInit(options) {
|
|
|
377
460
|
console.log(`\n${colors.green}Portok initialized successfully!${colors.reset}`);
|
|
378
461
|
console.log(`\nNext steps:`);
|
|
379
462
|
console.log(` 1. Create a service: ${colors.cyan}portok add <name>${colors.reset}`);
|
|
380
|
-
console.log(` 2.
|
|
463
|
+
console.log(` 2. Start service: ${colors.cyan}portok start <name>${colors.reset}`);
|
|
464
|
+
console.log(` 3. Enable at boot: ${colors.cyan}portok enable <name>${colors.reset}`);
|
|
381
465
|
|
|
382
466
|
return 0;
|
|
383
467
|
}
|
|
@@ -495,6 +579,130 @@ ROLLBACK_FAIL_THRESHOLD=3
|
|
|
495
579
|
return 0;
|
|
496
580
|
}
|
|
497
581
|
|
|
582
|
+
async function cmdRemove(name, options) {
|
|
583
|
+
if (!name) {
|
|
584
|
+
console.error(`${colors.red}Error:${colors.reset} Service name is required`);
|
|
585
|
+
console.error('Usage: portok remove <name> [--force] [--keep-state]');
|
|
586
|
+
return 1;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const envFilePath = path.join(ENV_FILE_DIR, `${name}.env`);
|
|
590
|
+
const stateFilePath = `/var/lib/portok/${name}.json`;
|
|
591
|
+
|
|
592
|
+
// Check if service exists
|
|
593
|
+
if (!fs.existsSync(envFilePath)) {
|
|
594
|
+
console.error(`${colors.red}Error:${colors.reset} Service '${name}' not found at ${envFilePath}`);
|
|
595
|
+
return 1;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Confirmation (unless --force)
|
|
599
|
+
if (!options.force && !options.json) {
|
|
600
|
+
console.log(`${colors.yellow}Warning:${colors.reset} This will remove service '${name}'`);
|
|
601
|
+
console.log(` Config file: ${envFilePath}`);
|
|
602
|
+
if (fs.existsSync(stateFilePath) && !options['keep-state']) {
|
|
603
|
+
console.log(` State file: ${stateFilePath}`);
|
|
604
|
+
}
|
|
605
|
+
console.log(`\nUse ${colors.cyan}--force${colors.reset} to confirm removal.`);
|
|
606
|
+
return 1;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const results = [];
|
|
610
|
+
const { execSync } = require('child_process');
|
|
611
|
+
|
|
612
|
+
// 1. Stop the service if running
|
|
613
|
+
try {
|
|
614
|
+
execSync(`systemctl is-active portok@${name}`, { stdio: 'pipe' });
|
|
615
|
+
// Service is running, stop it
|
|
616
|
+
try {
|
|
617
|
+
execSync(`systemctl stop portok@${name}`, { stdio: 'pipe' });
|
|
618
|
+
results.push({ action: 'stop', status: 'success' });
|
|
619
|
+
} catch (e) {
|
|
620
|
+
results.push({ action: 'stop', status: 'error', error: e.message });
|
|
621
|
+
}
|
|
622
|
+
} catch {
|
|
623
|
+
// Service not running, skip
|
|
624
|
+
results.push({ action: 'stop', status: 'skipped' });
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// 2. Disable the service
|
|
628
|
+
try {
|
|
629
|
+
execSync(`systemctl is-enabled portok@${name}`, { stdio: 'pipe' });
|
|
630
|
+
// Service is enabled, disable it
|
|
631
|
+
try {
|
|
632
|
+
execSync(`systemctl disable portok@${name}`, { stdio: 'pipe' });
|
|
633
|
+
results.push({ action: 'disable', status: 'success' });
|
|
634
|
+
} catch (e) {
|
|
635
|
+
results.push({ action: 'disable', status: 'error', error: e.message });
|
|
636
|
+
}
|
|
637
|
+
} catch {
|
|
638
|
+
// Service not enabled, skip
|
|
639
|
+
results.push({ action: 'disable', status: 'skipped' });
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// 3. Remove config file
|
|
643
|
+
try {
|
|
644
|
+
fs.unlinkSync(envFilePath);
|
|
645
|
+
results.push({ action: 'remove-config', status: 'success', path: envFilePath });
|
|
646
|
+
} catch (err) {
|
|
647
|
+
results.push({ action: 'remove-config', status: 'error', error: err.message });
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// 4. Remove state file (unless --keep-state)
|
|
651
|
+
if (!options['keep-state']) {
|
|
652
|
+
if (fs.existsSync(stateFilePath)) {
|
|
653
|
+
try {
|
|
654
|
+
fs.unlinkSync(stateFilePath);
|
|
655
|
+
results.push({ action: 'remove-state', status: 'success', path: stateFilePath });
|
|
656
|
+
} catch (err) {
|
|
657
|
+
results.push({ action: 'remove-state', status: 'error', error: err.message });
|
|
658
|
+
}
|
|
659
|
+
} else {
|
|
660
|
+
results.push({ action: 'remove-state', status: 'skipped' });
|
|
661
|
+
}
|
|
662
|
+
} else {
|
|
663
|
+
results.push({ action: 'remove-state', status: 'kept' });
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const hasError = results.some(r => r.status === 'error');
|
|
667
|
+
|
|
668
|
+
if (options.json) {
|
|
669
|
+
console.log(JSON.stringify({ success: !hasError, name, results }, null, 2));
|
|
670
|
+
return hasError ? 1 : 0;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Display results
|
|
674
|
+
console.log(`\n${colors.bold}Removing service '${name}'...${colors.reset}\n`);
|
|
675
|
+
|
|
676
|
+
for (const r of results) {
|
|
677
|
+
const icon = r.status === 'success' ? `${colors.green}✓${colors.reset}` :
|
|
678
|
+
r.status === 'skipped' ? `${colors.dim}○${colors.reset}` :
|
|
679
|
+
r.status === 'kept' ? `${colors.dim}○${colors.reset}` :
|
|
680
|
+
`${colors.red}✗${colors.reset}`;
|
|
681
|
+
|
|
682
|
+
const actionLabel = {
|
|
683
|
+
'stop': 'Stop service',
|
|
684
|
+
'disable': 'Disable service',
|
|
685
|
+
'remove-config': 'Remove config',
|
|
686
|
+
'remove-state': 'Remove state',
|
|
687
|
+
}[r.action];
|
|
688
|
+
|
|
689
|
+
const statusLabel = r.status === 'success' ? 'Done' :
|
|
690
|
+
r.status === 'skipped' ? 'Skipped (not running/enabled)' :
|
|
691
|
+
r.status === 'kept' ? 'Kept (--keep-state)' :
|
|
692
|
+
`Error: ${r.error}`;
|
|
693
|
+
|
|
694
|
+
console.log(` ${icon} ${actionLabel.padEnd(16)} ${statusLabel}`);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
if (hasError) {
|
|
698
|
+
console.log(`\n${colors.yellow}Warning:${colors.reset} Some operations failed. Check errors above.`);
|
|
699
|
+
return 1;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
console.log(`\n${colors.green}✓ Service '${name}' removed successfully!${colors.reset}`);
|
|
703
|
+
return 0;
|
|
704
|
+
}
|
|
705
|
+
|
|
498
706
|
async function cmdList(options) {
|
|
499
707
|
const configDir = ENV_FILE_DIR;
|
|
500
708
|
|
|
@@ -728,6 +936,7 @@ ${colors.bold}COMMANDS${colors.reset}
|
|
|
728
936
|
${colors.cyan}Management:${colors.reset}
|
|
729
937
|
init Initialize portok directories (/etc/portok, /var/lib/portok)
|
|
730
938
|
add <name> Create a new service instance
|
|
939
|
+
remove <name> Remove a service instance (config + state)
|
|
731
940
|
list List all configured instances and their status
|
|
732
941
|
|
|
733
942
|
${colors.cyan}Service Control:${colors.reset}
|
|
@@ -757,6 +966,10 @@ ${colors.bold}OPTIONS${colors.reset}
|
|
|
757
966
|
--health <path> Health check path (default: /health)
|
|
758
967
|
--force Overwrite existing config
|
|
759
968
|
|
|
969
|
+
${colors.dim}For 'remove' command:${colors.reset}
|
|
970
|
+
--force Skip confirmation prompt
|
|
971
|
+
--keep-state Keep state file (/var/lib/portok/<name>.json)
|
|
972
|
+
|
|
760
973
|
${colors.dim}For 'logs' command:${colors.reset}
|
|
761
974
|
--follow, -f Follow log output
|
|
762
975
|
--lines, -n Number of lines to show (default: 50)
|
|
@@ -779,6 +992,9 @@ ${colors.bold}EXAMPLES${colors.reset}
|
|
|
779
992
|
sudo portok restart api
|
|
780
993
|
portok logs api --follow
|
|
781
994
|
|
|
995
|
+
${colors.dim}# Remove a service${colors.reset}
|
|
996
|
+
sudo portok remove api --force
|
|
997
|
+
|
|
782
998
|
${colors.dim}# Check status of an instance${colors.reset}
|
|
783
999
|
portok status --instance api
|
|
784
1000
|
|
|
@@ -813,7 +1029,7 @@ async function main() {
|
|
|
813
1029
|
}
|
|
814
1030
|
|
|
815
1031
|
// Management commands (don't require daemon connection)
|
|
816
|
-
const managementCommands = ['init', 'add', 'list', 'start', 'stop', 'restart', 'enable', 'disable', 'logs'];
|
|
1032
|
+
const managementCommands = ['init', 'add', 'remove', 'list', 'start', 'stop', 'restart', 'enable', 'disable', 'logs'];
|
|
817
1033
|
|
|
818
1034
|
if (managementCommands.includes(args.command)) {
|
|
819
1035
|
let exitCode = 1;
|
|
@@ -824,6 +1040,9 @@ async function main() {
|
|
|
824
1040
|
case 'add':
|
|
825
1041
|
exitCode = await cmdAdd(args.positional[0], args.options);
|
|
826
1042
|
break;
|
|
1043
|
+
case 'remove':
|
|
1044
|
+
exitCode = await cmdRemove(args.positional[0], args.options);
|
|
1045
|
+
break;
|
|
827
1046
|
case 'list':
|
|
828
1047
|
exitCode = await cmdList(args.options);
|
|
829
1048
|
break;
|
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
|
});
|