portok 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/.dockerignore +10 -0
- package/Dockerfile +41 -0
- package/README.md +606 -0
- package/bench/baseline.bench.mjs +73 -0
- package/bench/connections.bench.mjs +70 -0
- package/bench/keepalive.bench.mjs +248 -0
- package/bench/latency.bench.mjs +47 -0
- package/bench/run.mjs +211 -0
- package/bench/switching.bench.mjs +96 -0
- package/bench/throughput.bench.mjs +44 -0
- package/bench/validate.mjs +260 -0
- package/docker-compose.yml +62 -0
- package/examples/api.env +30 -0
- package/examples/web.env +27 -0
- package/package.json +39 -0
- package/portok.mjs +793 -0
- package/portok@.service +62 -0
- package/portokd.mjs +793 -0
- package/test/cli.test.mjs +220 -0
- package/test/drain.test.mjs +249 -0
- package/test/helpers/mock-server.mjs +305 -0
- package/test/metrics.test.mjs +328 -0
- package/test/proxy.test.mjs +223 -0
- package/test/rollback.test.mjs +344 -0
- package/test/security.test.mjs +256 -0
- package/test/switching.test.mjs +261 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proxy Core Tests
|
|
3
|
+
* Tests HTTP proxying, WebSocket upgrades, and streaming
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, before, after, beforeEach } from 'node:test';
|
|
7
|
+
import assert from 'node:assert';
|
|
8
|
+
import http from 'node:http';
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { createMockServer, getFreePort, waitFor } from './helpers/mock-server.mjs';
|
|
11
|
+
import WebSocket from 'ws';
|
|
12
|
+
|
|
13
|
+
describe('Proxy Core', () => {
|
|
14
|
+
let mockServer;
|
|
15
|
+
let proxyPort;
|
|
16
|
+
let daemonProcess;
|
|
17
|
+
const adminToken = 'test-token-proxy';
|
|
18
|
+
|
|
19
|
+
before(async () => {
|
|
20
|
+
// Create mock backend server
|
|
21
|
+
mockServer = await createMockServer({ port: 0 });
|
|
22
|
+
|
|
23
|
+
// Get a free port for the proxy
|
|
24
|
+
proxyPort = await getFreePort();
|
|
25
|
+
|
|
26
|
+
// Start the daemon as a subprocess
|
|
27
|
+
daemonProcess = spawn('node', ['portokd.mjs'], {
|
|
28
|
+
env: {
|
|
29
|
+
...process.env,
|
|
30
|
+
LISTEN_PORT: String(proxyPort),
|
|
31
|
+
INITIAL_TARGET_PORT: String(mockServer.port),
|
|
32
|
+
ADMIN_TOKEN: adminToken,
|
|
33
|
+
STATE_FILE: `/tmp/portok-proxy-test-${Date.now()}.json`,
|
|
34
|
+
DRAIN_MS: '1000',
|
|
35
|
+
ROLLBACK_WINDOW_MS: '5000',
|
|
36
|
+
ROLLBACK_CHECK_EVERY_MS: '500',
|
|
37
|
+
ROLLBACK_FAIL_THRESHOLD: '3',
|
|
38
|
+
ADMIN_RATE_LIMIT: '1000',
|
|
39
|
+
},
|
|
40
|
+
stdio: 'pipe',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Wait for daemon to start
|
|
44
|
+
await waitFor(async () => {
|
|
45
|
+
try {
|
|
46
|
+
const res = await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
|
|
47
|
+
headers: { 'x-admin-token': adminToken },
|
|
48
|
+
});
|
|
49
|
+
return res.ok;
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}, 5000);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
after(async () => {
|
|
57
|
+
if (daemonProcess) {
|
|
58
|
+
daemonProcess.kill('SIGTERM');
|
|
59
|
+
}
|
|
60
|
+
if (mockServer) {
|
|
61
|
+
await mockServer.close();
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
mockServer.clearRequests();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('HTTP Proxying', () => {
|
|
70
|
+
it('should proxy GET requests to backend', async () => {
|
|
71
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/`);
|
|
72
|
+
assert.strictEqual(response.status, 200);
|
|
73
|
+
|
|
74
|
+
const body = await response.text();
|
|
75
|
+
assert.strictEqual(body, 'OK');
|
|
76
|
+
|
|
77
|
+
const requests = mockServer.getRequests();
|
|
78
|
+
assert.strictEqual(requests.length, 1);
|
|
79
|
+
assert.strictEqual(requests[0].method, 'GET');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should proxy POST requests with body', async () => {
|
|
83
|
+
const testBody = JSON.stringify({ test: 'data' });
|
|
84
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/echo`, {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
headers: { 'Content-Type': 'application/json' },
|
|
87
|
+
body: testBody,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
assert.strictEqual(response.status, 200);
|
|
91
|
+
|
|
92
|
+
const data = await response.json();
|
|
93
|
+
assert.strictEqual(data.method, 'POST');
|
|
94
|
+
assert.strictEqual(data.body, testBody);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should forward headers to backend', async () => {
|
|
98
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/echo`, {
|
|
99
|
+
headers: {
|
|
100
|
+
'X-Custom-Header': 'test-value',
|
|
101
|
+
'Accept': 'application/json',
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
assert.strictEqual(response.status, 200);
|
|
106
|
+
|
|
107
|
+
const data = await response.json();
|
|
108
|
+
assert.strictEqual(data.headers['x-custom-header'], 'test-value');
|
|
109
|
+
assert.strictEqual(data.headers['accept'], 'application/json');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should add X-Forwarded headers', async () => {
|
|
113
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/echo`);
|
|
114
|
+
const data = await response.json();
|
|
115
|
+
|
|
116
|
+
assert.ok(data.headers['x-forwarded-for']);
|
|
117
|
+
assert.ok(data.headers['x-forwarded-host']);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should handle backend errors', async () => {
|
|
121
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/error`);
|
|
122
|
+
assert.strictEqual(response.status, 500);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('Streaming', () => {
|
|
127
|
+
it('should proxy streamed responses', async () => {
|
|
128
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/stream`);
|
|
129
|
+
assert.strictEqual(response.status, 200);
|
|
130
|
+
|
|
131
|
+
const body = await response.text();
|
|
132
|
+
assert.ok(body.includes('chunk 0'));
|
|
133
|
+
assert.ok(body.includes('chunk 4'));
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('WebSocket Proxying', () => {
|
|
138
|
+
it('should proxy WebSocket connections', async () => {
|
|
139
|
+
const ws = new WebSocket(`ws://127.0.0.1:${proxyPort}`);
|
|
140
|
+
|
|
141
|
+
const messages = [];
|
|
142
|
+
await new Promise((resolve, reject) => {
|
|
143
|
+
const timeout = setTimeout(() => reject(new Error('WebSocket timeout')), 5000);
|
|
144
|
+
|
|
145
|
+
ws.on('open', () => {
|
|
146
|
+
// Wait for initial 'connected' message
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
ws.on('message', (data) => {
|
|
150
|
+
messages.push(data.toString());
|
|
151
|
+
if (messages.length === 1 && messages[0] === 'connected') {
|
|
152
|
+
ws.send('hello');
|
|
153
|
+
} else if (messages.length === 2) {
|
|
154
|
+
clearTimeout(timeout);
|
|
155
|
+
ws.close();
|
|
156
|
+
resolve();
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
ws.on('error', (err) => {
|
|
161
|
+
clearTimeout(timeout);
|
|
162
|
+
reject(err);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
assert.strictEqual(messages[0], 'connected');
|
|
167
|
+
assert.strictEqual(messages[1], 'echo: hello');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should handle WebSocket close properly', async () => {
|
|
171
|
+
const ws = new WebSocket(`ws://127.0.0.1:${proxyPort}`);
|
|
172
|
+
|
|
173
|
+
await new Promise((resolve, reject) => {
|
|
174
|
+
const timeout = setTimeout(() => reject(new Error('WebSocket timeout')), 5000);
|
|
175
|
+
|
|
176
|
+
ws.on('open', () => {
|
|
177
|
+
setTimeout(() => ws.close(), 100);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
ws.on('close', () => {
|
|
181
|
+
clearTimeout(timeout);
|
|
182
|
+
resolve();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
ws.on('error', reject);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Connection should be closed cleanly
|
|
189
|
+
assert.strictEqual(ws.readyState, WebSocket.CLOSED);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('Target Host Security', () => {
|
|
194
|
+
it('should only proxy to localhost', async () => {
|
|
195
|
+
// The proxy should always target 127.0.0.1
|
|
196
|
+
// This is verified by checking that requests reach the mock server
|
|
197
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/echo`);
|
|
198
|
+
const data = await response.json();
|
|
199
|
+
|
|
200
|
+
// Host header should show the proxy received the request
|
|
201
|
+
assert.ok(data.headers.host.includes('127.0.0.1'));
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe('Concurrent Requests', () => {
|
|
206
|
+
it('should handle multiple concurrent requests', async () => {
|
|
207
|
+
const promises = [];
|
|
208
|
+
for (let i = 0; i < 10; i++) {
|
|
209
|
+
promises.push(fetch(`http://127.0.0.1:${proxyPort}/`));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const responses = await Promise.all(promises);
|
|
213
|
+
|
|
214
|
+
for (const response of responses) {
|
|
215
|
+
assert.strictEqual(response.status, 200);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const requests = mockServer.getRequests();
|
|
219
|
+
assert.strictEqual(requests.length, 10);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-Rollback Tests
|
|
3
|
+
* Tests automatic rollback when health checks fail consecutively
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, before, after } from 'node:test';
|
|
7
|
+
import assert from 'node:assert';
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { createMockServer, getFreePort, waitFor } from './helpers/mock-server.mjs';
|
|
10
|
+
|
|
11
|
+
describe('Auto Rollback', () => {
|
|
12
|
+
const adminToken = 'test-token-rollback';
|
|
13
|
+
|
|
14
|
+
describe('Rollback Trigger', () => {
|
|
15
|
+
let mockServer1;
|
|
16
|
+
let mockServer2;
|
|
17
|
+
let proxyPort;
|
|
18
|
+
let daemonProcess;
|
|
19
|
+
|
|
20
|
+
before(async () => {
|
|
21
|
+
mockServer1 = await createMockServer({ port: 0, responseBody: 'SERVER1' });
|
|
22
|
+
mockServer2 = await createMockServer({ port: 0, responseBody: 'SERVER2' });
|
|
23
|
+
|
|
24
|
+
proxyPort = await getFreePort();
|
|
25
|
+
|
|
26
|
+
// Start daemon with aggressive rollback settings for testing
|
|
27
|
+
daemonProcess = spawn('node', ['portokd.mjs'], {
|
|
28
|
+
env: {
|
|
29
|
+
...process.env,
|
|
30
|
+
LISTEN_PORT: String(proxyPort),
|
|
31
|
+
INITIAL_TARGET_PORT: String(mockServer1.port),
|
|
32
|
+
ADMIN_TOKEN: adminToken,
|
|
33
|
+
STATE_FILE: `/tmp/portok-rollback-test-${Date.now()}.json`,
|
|
34
|
+
DRAIN_MS: '500',
|
|
35
|
+
ROLLBACK_WINDOW_MS: '10000',
|
|
36
|
+
ROLLBACK_CHECK_EVERY_MS: '200', // Check every 200ms
|
|
37
|
+
ROLLBACK_FAIL_THRESHOLD: '2',
|
|
38
|
+
ADMIN_RATE_LIMIT: '1000', // Rollback after 2 failures
|
|
39
|
+
},
|
|
40
|
+
stdio: 'pipe',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
await waitFor(async () => {
|
|
44
|
+
try {
|
|
45
|
+
const res = await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
|
|
46
|
+
headers: { 'x-admin-token': adminToken },
|
|
47
|
+
});
|
|
48
|
+
return res.ok;
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}, 5000);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
after(async () => {
|
|
56
|
+
if (daemonProcess) {
|
|
57
|
+
daemonProcess.kill('SIGTERM');
|
|
58
|
+
}
|
|
59
|
+
if (mockServer1) await mockServer1.close();
|
|
60
|
+
if (mockServer2) await mockServer2.close();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should rollback when new port becomes unhealthy', async () => {
|
|
64
|
+
// Switch to server2
|
|
65
|
+
const switchRes = await fetch(`http://127.0.0.1:${proxyPort}/__switch?port=${mockServer2.port}`, {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
headers: { 'x-admin-token': adminToken },
|
|
68
|
+
});
|
|
69
|
+
assert.strictEqual(switchRes.status, 200);
|
|
70
|
+
|
|
71
|
+
// Verify we switched
|
|
72
|
+
let status = await (await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
|
|
73
|
+
headers: { 'x-admin-token': adminToken },
|
|
74
|
+
})).json();
|
|
75
|
+
assert.strictEqual(status.activePort, mockServer2.port);
|
|
76
|
+
|
|
77
|
+
// Make server2 unhealthy
|
|
78
|
+
mockServer2.setHealthy(false);
|
|
79
|
+
|
|
80
|
+
// Wait for rollback to happen (2 failures * 200ms check interval + buffer)
|
|
81
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
82
|
+
|
|
83
|
+
// Should have rolled back to server1
|
|
84
|
+
status = await (await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
|
|
85
|
+
headers: { 'x-admin-token': adminToken },
|
|
86
|
+
})).json();
|
|
87
|
+
assert.strictEqual(status.activePort, mockServer1.port);
|
|
88
|
+
assert.strictEqual(status.lastSwitch.reason, 'auto-rollback');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should not rollback if health recovers', async () => {
|
|
92
|
+
// Make sure server2 is healthy first
|
|
93
|
+
mockServer2.setHealthy(true);
|
|
94
|
+
|
|
95
|
+
// Switch to server2
|
|
96
|
+
await fetch(`http://127.0.0.1:${proxyPort}/__switch?port=${mockServer2.port}`, {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
headers: { 'x-admin-token': adminToken },
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Make server2 unhealthy for just 1 check (below threshold)
|
|
102
|
+
mockServer2.setHealthy(false);
|
|
103
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
104
|
+
|
|
105
|
+
// Make it healthy again before threshold reached
|
|
106
|
+
mockServer2.setHealthy(true);
|
|
107
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
108
|
+
|
|
109
|
+
// Should still be on server2
|
|
110
|
+
const status = await (await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
|
|
111
|
+
headers: { 'x-admin-token': adminToken },
|
|
112
|
+
})).json();
|
|
113
|
+
assert.strictEqual(status.activePort, mockServer2.port);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('Rollback Loop Prevention', () => {
|
|
118
|
+
let mockServer1;
|
|
119
|
+
let mockServer2;
|
|
120
|
+
let proxyPort;
|
|
121
|
+
let daemonProcess;
|
|
122
|
+
|
|
123
|
+
before(async () => {
|
|
124
|
+
mockServer1 = await createMockServer({ port: 0, responseBody: 'SERVER1' });
|
|
125
|
+
mockServer2 = await createMockServer({ port: 0, responseBody: 'SERVER2' });
|
|
126
|
+
|
|
127
|
+
proxyPort = await getFreePort();
|
|
128
|
+
|
|
129
|
+
daemonProcess = spawn('node', ['portokd.mjs'], {
|
|
130
|
+
env: {
|
|
131
|
+
...process.env,
|
|
132
|
+
LISTEN_PORT: String(proxyPort),
|
|
133
|
+
INITIAL_TARGET_PORT: String(mockServer1.port),
|
|
134
|
+
ADMIN_TOKEN: adminToken,
|
|
135
|
+
STATE_FILE: `/tmp/portok-rollback-loop-test-${Date.now()}.json`,
|
|
136
|
+
DRAIN_MS: '200',
|
|
137
|
+
ROLLBACK_WINDOW_MS: '5000',
|
|
138
|
+
ROLLBACK_CHECK_EVERY_MS: '100',
|
|
139
|
+
ROLLBACK_FAIL_THRESHOLD: '2',
|
|
140
|
+
ADMIN_RATE_LIMIT: '1000',
|
|
141
|
+
},
|
|
142
|
+
stdio: 'pipe',
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
await waitFor(async () => {
|
|
146
|
+
try {
|
|
147
|
+
const res = await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
|
|
148
|
+
headers: { 'x-admin-token': adminToken },
|
|
149
|
+
});
|
|
150
|
+
return res.ok;
|
|
151
|
+
} catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}, 5000);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
after(async () => {
|
|
158
|
+
if (daemonProcess) {
|
|
159
|
+
daemonProcess.kill('SIGTERM');
|
|
160
|
+
}
|
|
161
|
+
if (mockServer1) await mockServer1.close();
|
|
162
|
+
if (mockServer2) await mockServer2.close();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should not start rollback monitor for rollback operation', async () => {
|
|
166
|
+
// Switch to server2
|
|
167
|
+
await fetch(`http://127.0.0.1:${proxyPort}/__switch?port=${mockServer2.port}`, {
|
|
168
|
+
method: 'POST',
|
|
169
|
+
headers: { 'x-admin-token': adminToken },
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Make server2 unhealthy to trigger rollback
|
|
173
|
+
mockServer2.setHealthy(false);
|
|
174
|
+
|
|
175
|
+
// Wait for rollback
|
|
176
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
177
|
+
|
|
178
|
+
// Verify rolled back to server1
|
|
179
|
+
let status = await (await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
|
|
180
|
+
headers: { 'x-admin-token': adminToken },
|
|
181
|
+
})).json();
|
|
182
|
+
assert.strictEqual(status.activePort, mockServer1.port);
|
|
183
|
+
|
|
184
|
+
// Now make server1 unhealthy too
|
|
185
|
+
mockServer1.setHealthy(false);
|
|
186
|
+
|
|
187
|
+
// Wait - should NOT trigger another rollback to server2
|
|
188
|
+
// because rollback operations don't start monitors
|
|
189
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
190
|
+
|
|
191
|
+
// Should still be on server1
|
|
192
|
+
status = await (await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
|
|
193
|
+
headers: { 'x-admin-token': adminToken },
|
|
194
|
+
})).json();
|
|
195
|
+
assert.strictEqual(status.activePort, mockServer1.port);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe('Rollback Window', () => {
|
|
200
|
+
let mockServer1;
|
|
201
|
+
let mockServer2;
|
|
202
|
+
let proxyPort;
|
|
203
|
+
let daemonProcess;
|
|
204
|
+
|
|
205
|
+
before(async () => {
|
|
206
|
+
mockServer1 = await createMockServer({ port: 0 });
|
|
207
|
+
mockServer2 = await createMockServer({ port: 0 });
|
|
208
|
+
|
|
209
|
+
proxyPort = await getFreePort();
|
|
210
|
+
|
|
211
|
+
daemonProcess = spawn('node', ['portokd.mjs'], {
|
|
212
|
+
env: {
|
|
213
|
+
...process.env,
|
|
214
|
+
LISTEN_PORT: String(proxyPort),
|
|
215
|
+
INITIAL_TARGET_PORT: String(mockServer1.port),
|
|
216
|
+
ADMIN_TOKEN: adminToken,
|
|
217
|
+
STATE_FILE: `/tmp/portok-rollback-window-test-${Date.now()}.json`,
|
|
218
|
+
DRAIN_MS: '100',
|
|
219
|
+
ROLLBACK_WINDOW_MS: '500', // Short window for testing
|
|
220
|
+
ROLLBACK_CHECK_EVERY_MS: '100',
|
|
221
|
+
ROLLBACK_FAIL_THRESHOLD: '2',
|
|
222
|
+
ADMIN_RATE_LIMIT: '1000',
|
|
223
|
+
},
|
|
224
|
+
stdio: 'pipe',
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await waitFor(async () => {
|
|
228
|
+
try {
|
|
229
|
+
const res = await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
|
|
230
|
+
headers: { 'x-admin-token': adminToken },
|
|
231
|
+
});
|
|
232
|
+
return res.ok;
|
|
233
|
+
} catch {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
}, 5000);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
after(async () => {
|
|
240
|
+
if (daemonProcess) {
|
|
241
|
+
daemonProcess.kill('SIGTERM');
|
|
242
|
+
}
|
|
243
|
+
if (mockServer1) await mockServer1.close();
|
|
244
|
+
if (mockServer2) await mockServer2.close();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('should not rollback after window expires', async () => {
|
|
248
|
+
// Switch to server2
|
|
249
|
+
await fetch(`http://127.0.0.1:${proxyPort}/__switch?port=${mockServer2.port}`, {
|
|
250
|
+
method: 'POST',
|
|
251
|
+
headers: { 'x-admin-token': adminToken },
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// Wait for rollback window to expire
|
|
255
|
+
await new Promise(resolve => setTimeout(resolve, 700));
|
|
256
|
+
|
|
257
|
+
// Now make server2 unhealthy
|
|
258
|
+
mockServer2.setHealthy(false);
|
|
259
|
+
|
|
260
|
+
// Wait some more - should NOT rollback because window expired
|
|
261
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
262
|
+
|
|
263
|
+
// Should still be on server2
|
|
264
|
+
const status = await (await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
|
|
265
|
+
headers: { 'x-admin-token': adminToken },
|
|
266
|
+
})).json();
|
|
267
|
+
assert.strictEqual(status.activePort, mockServer2.port);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe('Health Metrics During Rollback', () => {
|
|
272
|
+
let mockServer1;
|
|
273
|
+
let mockServer2;
|
|
274
|
+
let proxyPort;
|
|
275
|
+
let daemonProcess;
|
|
276
|
+
|
|
277
|
+
before(async () => {
|
|
278
|
+
mockServer1 = await createMockServer({ port: 0 });
|
|
279
|
+
mockServer2 = await createMockServer({ port: 0 });
|
|
280
|
+
|
|
281
|
+
proxyPort = await getFreePort();
|
|
282
|
+
|
|
283
|
+
daemonProcess = spawn('node', ['portokd.mjs'], {
|
|
284
|
+
env: {
|
|
285
|
+
...process.env,
|
|
286
|
+
LISTEN_PORT: String(proxyPort),
|
|
287
|
+
INITIAL_TARGET_PORT: String(mockServer1.port),
|
|
288
|
+
ADMIN_TOKEN: adminToken,
|
|
289
|
+
STATE_FILE: `/tmp/portok-rollback-metrics-test-${Date.now()}.json`,
|
|
290
|
+
DRAIN_MS: '100',
|
|
291
|
+
ROLLBACK_WINDOW_MS: '10000',
|
|
292
|
+
ROLLBACK_CHECK_EVERY_MS: '100',
|
|
293
|
+
ROLLBACK_FAIL_THRESHOLD: '3',
|
|
294
|
+
ADMIN_RATE_LIMIT: '1000',
|
|
295
|
+
},
|
|
296
|
+
stdio: 'pipe',
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
await waitFor(async () => {
|
|
300
|
+
try {
|
|
301
|
+
const res = await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
|
|
302
|
+
headers: { 'x-admin-token': adminToken },
|
|
303
|
+
});
|
|
304
|
+
return res.ok;
|
|
305
|
+
} catch {
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
}, 5000);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
after(async () => {
|
|
312
|
+
if (daemonProcess) {
|
|
313
|
+
daemonProcess.kill('SIGTERM');
|
|
314
|
+
}
|
|
315
|
+
if (mockServer1) await mockServer1.close();
|
|
316
|
+
if (mockServer2) await mockServer2.close();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('should track consecutive fails in metrics', async () => {
|
|
320
|
+
// Switch to server2
|
|
321
|
+
await fetch(`http://127.0.0.1:${proxyPort}/__switch?port=${mockServer2.port}`, {
|
|
322
|
+
method: 'POST',
|
|
323
|
+
headers: { 'x-admin-token': adminToken },
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// Make server2 unhealthy
|
|
327
|
+
mockServer2.setHealthy(false);
|
|
328
|
+
|
|
329
|
+
// Wait for some health checks
|
|
330
|
+
await new Promise(resolve => setTimeout(resolve, 350));
|
|
331
|
+
|
|
332
|
+
// Check metrics
|
|
333
|
+
const metricsRes = await fetch(`http://127.0.0.1:${proxyPort}/__metrics`, {
|
|
334
|
+
headers: { 'x-admin-token': adminToken },
|
|
335
|
+
});
|
|
336
|
+
const metrics = await metricsRes.json();
|
|
337
|
+
|
|
338
|
+
assert.ok(metrics.health.consecutiveFails > 0);
|
|
339
|
+
assert.strictEqual(metrics.health.activePortOk, false);
|
|
340
|
+
assert.ok(metrics.health.lastCheckedAt);
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|