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,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock HTTP/WebSocket server for testing portokd
|
|
3
|
+
* Provides configurable responses, delays, and health status
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import http from 'node:http';
|
|
7
|
+
|
|
8
|
+
// Dynamically import ws to handle when not available
|
|
9
|
+
let WebSocketServer;
|
|
10
|
+
try {
|
|
11
|
+
const ws = await import('ws');
|
|
12
|
+
WebSocketServer = ws.WebSocketServer;
|
|
13
|
+
} catch {
|
|
14
|
+
WebSocketServer = null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Create a mock server with configurable behavior
|
|
19
|
+
* @param {Object} options
|
|
20
|
+
* @param {number} options.port - Port to listen on (0 for random)
|
|
21
|
+
* @param {number} options.healthStatus - HTTP status for /health endpoint (default: 200)
|
|
22
|
+
* @param {number} options.responseDelay - Delay in ms before responding (default: 0)
|
|
23
|
+
* @param {string} options.responseBody - Body to return for requests (default: 'OK')
|
|
24
|
+
* @param {boolean} options.enableWebSocket - Enable WebSocket support (default: true)
|
|
25
|
+
* @returns {Promise<MockServer>}
|
|
26
|
+
*/
|
|
27
|
+
export async function createMockServer(options = {}) {
|
|
28
|
+
const config = {
|
|
29
|
+
port: options.port || 0,
|
|
30
|
+
healthStatus: options.healthStatus ?? 200,
|
|
31
|
+
responseDelay: options.responseDelay || 0,
|
|
32
|
+
responseBody: options.responseBody || 'OK',
|
|
33
|
+
enableWebSocket: options.enableWebSocket !== false,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const requests = [];
|
|
37
|
+
let healthy = config.healthStatus >= 200 && config.healthStatus < 300;
|
|
38
|
+
let currentHealthStatus = config.healthStatus;
|
|
39
|
+
|
|
40
|
+
const server = http.createServer(async (req, res) => {
|
|
41
|
+
// Record request
|
|
42
|
+
requests.push({
|
|
43
|
+
method: req.method,
|
|
44
|
+
url: req.url,
|
|
45
|
+
headers: { ...req.headers },
|
|
46
|
+
timestamp: Date.now(),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Apply delay if configured
|
|
50
|
+
if (config.responseDelay > 0) {
|
|
51
|
+
await new Promise(resolve => setTimeout(resolve, config.responseDelay));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Health endpoint
|
|
55
|
+
if (req.url === '/health' || req.url.startsWith('/health?')) {
|
|
56
|
+
res.writeHead(currentHealthStatus, { 'Content-Type': 'application/json' });
|
|
57
|
+
res.end(JSON.stringify({ status: healthy ? 'healthy' : 'unhealthy' }));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Echo endpoint - returns request info
|
|
62
|
+
if (req.url === '/echo') {
|
|
63
|
+
let body = '';
|
|
64
|
+
for await (const chunk of req) {
|
|
65
|
+
body += chunk;
|
|
66
|
+
}
|
|
67
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
68
|
+
res.end(JSON.stringify({
|
|
69
|
+
method: req.method,
|
|
70
|
+
url: req.url,
|
|
71
|
+
headers: req.headers,
|
|
72
|
+
body,
|
|
73
|
+
}));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Slow endpoint - takes longer to respond
|
|
78
|
+
if (req.url === '/slow') {
|
|
79
|
+
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
80
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
81
|
+
res.end('Slow response');
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Error endpoint - returns 500
|
|
86
|
+
if (req.url === '/error') {
|
|
87
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
88
|
+
res.end(JSON.stringify({ error: 'Internal Server Error' }));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Stream endpoint - sends chunked data
|
|
93
|
+
if (req.url === '/stream') {
|
|
94
|
+
res.writeHead(200, {
|
|
95
|
+
'Content-Type': 'text/plain',
|
|
96
|
+
'Transfer-Encoding': 'chunked',
|
|
97
|
+
});
|
|
98
|
+
for (let i = 0; i < 5; i++) {
|
|
99
|
+
res.write(`chunk ${i}\n`);
|
|
100
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
101
|
+
}
|
|
102
|
+
res.end();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Default response
|
|
107
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
108
|
+
res.end(config.responseBody);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// WebSocket support
|
|
112
|
+
let wss = null;
|
|
113
|
+
const wsConnections = [];
|
|
114
|
+
|
|
115
|
+
if (config.enableWebSocket && WebSocketServer) {
|
|
116
|
+
wss = new WebSocketServer({ server });
|
|
117
|
+
|
|
118
|
+
wss.on('connection', (ws) => {
|
|
119
|
+
wsConnections.push(ws);
|
|
120
|
+
|
|
121
|
+
ws.on('message', (message) => {
|
|
122
|
+
// Echo messages back
|
|
123
|
+
ws.send(`echo: ${message}`);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
ws.on('close', () => {
|
|
127
|
+
const idx = wsConnections.indexOf(ws);
|
|
128
|
+
if (idx !== -1) wsConnections.splice(idx, 1);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
ws.send('connected');
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Start server
|
|
136
|
+
await new Promise((resolve, reject) => {
|
|
137
|
+
server.listen(config.port, '127.0.0.1', () => resolve());
|
|
138
|
+
server.on('error', reject);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const actualPort = server.address().port;
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
port: actualPort,
|
|
145
|
+
url: `http://127.0.0.1:${actualPort}`,
|
|
146
|
+
server,
|
|
147
|
+
wss,
|
|
148
|
+
|
|
149
|
+
// Get recorded requests
|
|
150
|
+
getRequests() {
|
|
151
|
+
return [...requests];
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
// Clear recorded requests
|
|
155
|
+
clearRequests() {
|
|
156
|
+
requests.length = 0;
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
// Set health status dynamically
|
|
160
|
+
setHealthy(isHealthy) {
|
|
161
|
+
healthy = isHealthy;
|
|
162
|
+
currentHealthStatus = isHealthy ? 200 : 503;
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
// Set specific health status code
|
|
166
|
+
setHealthStatus(statusCode) {
|
|
167
|
+
currentHealthStatus = statusCode;
|
|
168
|
+
healthy = statusCode >= 200 && statusCode < 300;
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
// Get WebSocket connections
|
|
172
|
+
getWebSocketConnections() {
|
|
173
|
+
return [...wsConnections];
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
// Broadcast to all WebSocket clients
|
|
177
|
+
broadcast(message) {
|
|
178
|
+
wsConnections.forEach(ws => {
|
|
179
|
+
if (ws.readyState === 1) { // OPEN
|
|
180
|
+
ws.send(message);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
// Close server
|
|
186
|
+
async close() {
|
|
187
|
+
// Close WebSocket connections
|
|
188
|
+
wsConnections.forEach(ws => ws.close());
|
|
189
|
+
|
|
190
|
+
// Close WebSocket server
|
|
191
|
+
if (wss) {
|
|
192
|
+
await new Promise(resolve => wss.close(resolve));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Close HTTP server
|
|
196
|
+
await new Promise(resolve => server.close(resolve));
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Create the portokd server for testing
|
|
203
|
+
* Sets up environment and spawns the daemon
|
|
204
|
+
*/
|
|
205
|
+
export async function createTestDaemon(options = {}) {
|
|
206
|
+
const {
|
|
207
|
+
listenPort = 0,
|
|
208
|
+
targetPort,
|
|
209
|
+
adminToken = 'test-token-12345',
|
|
210
|
+
healthPath = '/health',
|
|
211
|
+
drainMs = 1000,
|
|
212
|
+
rollbackWindowMs = 5000,
|
|
213
|
+
rollbackCheckEveryMs = 500,
|
|
214
|
+
rollbackFailThreshold = 2,
|
|
215
|
+
} = options;
|
|
216
|
+
|
|
217
|
+
// Set environment
|
|
218
|
+
process.env.LISTEN_PORT = String(listenPort);
|
|
219
|
+
process.env.INITIAL_TARGET_PORT = String(targetPort);
|
|
220
|
+
process.env.ADMIN_TOKEN = adminToken;
|
|
221
|
+
process.env.HEALTH_PATH = healthPath;
|
|
222
|
+
process.env.DRAIN_MS = String(drainMs);
|
|
223
|
+
process.env.ROLLBACK_WINDOW_MS = String(rollbackWindowMs);
|
|
224
|
+
process.env.ROLLBACK_CHECK_EVERY_MS = String(rollbackCheckEveryMs);
|
|
225
|
+
process.env.ROLLBACK_FAIL_THRESHOLD = String(rollbackFailThreshold);
|
|
226
|
+
process.env.STATE_FILE = `/tmp/portok-test-${Date.now()}.json`;
|
|
227
|
+
|
|
228
|
+
// Import the daemon (will start based on env vars)
|
|
229
|
+
const daemon = await import('../../portokd.mjs');
|
|
230
|
+
|
|
231
|
+
// Wait for server to be ready
|
|
232
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
233
|
+
|
|
234
|
+
const actualPort = daemon.server.address()?.port || listenPort;
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
port: actualPort,
|
|
238
|
+
url: `http://127.0.0.1:${actualPort}`,
|
|
239
|
+
adminToken,
|
|
240
|
+
daemon,
|
|
241
|
+
|
|
242
|
+
async close() {
|
|
243
|
+
daemon.server.close();
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Helper to make admin requests
|
|
250
|
+
*/
|
|
251
|
+
export async function adminRequest(baseUrl, endpoint, options = {}) {
|
|
252
|
+
const { method = 'GET', token = 'test-token-12345', body } = options;
|
|
253
|
+
|
|
254
|
+
const fetchOptions = {
|
|
255
|
+
method,
|
|
256
|
+
headers: {
|
|
257
|
+
'x-admin-token': token,
|
|
258
|
+
'Content-Type': 'application/json',
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
if (body) {
|
|
263
|
+
fetchOptions.body = JSON.stringify(body);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const response = await fetch(`${baseUrl}${endpoint}`, fetchOptions);
|
|
267
|
+
const data = await response.json().catch(() => ({}));
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
ok: response.ok,
|
|
271
|
+
status: response.status,
|
|
272
|
+
data,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Helper to wait for a condition
|
|
278
|
+
*/
|
|
279
|
+
export async function waitFor(condition, timeout = 5000, interval = 100) {
|
|
280
|
+
const startTime = Date.now();
|
|
281
|
+
|
|
282
|
+
while (Date.now() - startTime < timeout) {
|
|
283
|
+
if (await condition()) {
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
await new Promise(resolve => setTimeout(resolve, interval));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
throw new Error(`Condition not met within ${timeout}ms`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Helper to get a free port
|
|
294
|
+
*/
|
|
295
|
+
export async function getFreePort() {
|
|
296
|
+
return new Promise((resolve, reject) => {
|
|
297
|
+
const server = http.createServer();
|
|
298
|
+
server.listen(0, '127.0.0.1', () => {
|
|
299
|
+
const port = server.address().port;
|
|
300
|
+
server.close(() => resolve(port));
|
|
301
|
+
});
|
|
302
|
+
server.on('error', reject);
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metrics Tests
|
|
3
|
+
* Tests metrics collection: counters, RPS, inflight tracking
|
|
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('Metrics', () => {
|
|
12
|
+
let mockServer;
|
|
13
|
+
let proxyPort;
|
|
14
|
+
let daemonProcess;
|
|
15
|
+
const adminToken = 'test-token-metrics';
|
|
16
|
+
|
|
17
|
+
before(async () => {
|
|
18
|
+
mockServer = await createMockServer({ port: 0 });
|
|
19
|
+
proxyPort = await getFreePort();
|
|
20
|
+
|
|
21
|
+
daemonProcess = spawn('node', ['portokd.mjs'], {
|
|
22
|
+
env: {
|
|
23
|
+
...process.env,
|
|
24
|
+
LISTEN_PORT: String(proxyPort),
|
|
25
|
+
INITIAL_TARGET_PORT: String(mockServer.port),
|
|
26
|
+
ADMIN_TOKEN: adminToken,
|
|
27
|
+
STATE_FILE: `/tmp/portok-metrics-test-${Date.now()}.json`,
|
|
28
|
+
DRAIN_MS: '1000',
|
|
29
|
+
ROLLBACK_WINDOW_MS: '60000',
|
|
30
|
+
ROLLBACK_CHECK_EVERY_MS: '5000',
|
|
31
|
+
ROLLBACK_FAIL_THRESHOLD: '3',
|
|
32
|
+
ADMIN_RATE_LIMIT: '1000',
|
|
33
|
+
},
|
|
34
|
+
stdio: 'pipe',
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
await waitFor(async () => {
|
|
38
|
+
try {
|
|
39
|
+
const res = await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
|
|
40
|
+
headers: { 'x-admin-token': adminToken },
|
|
41
|
+
});
|
|
42
|
+
return res.ok;
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}, 5000);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
after(async () => {
|
|
50
|
+
if (daemonProcess) {
|
|
51
|
+
daemonProcess.kill('SIGTERM');
|
|
52
|
+
}
|
|
53
|
+
if (mockServer) await mockServer.close();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('Metrics Endpoint', () => {
|
|
57
|
+
it('should return all required metrics fields', async () => {
|
|
58
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/__metrics`, {
|
|
59
|
+
headers: { 'x-admin-token': adminToken },
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
assert.strictEqual(response.status, 200);
|
|
63
|
+
|
|
64
|
+
const metrics = await response.json();
|
|
65
|
+
|
|
66
|
+
// Check all required fields exist
|
|
67
|
+
assert.ok('startedAt' in metrics);
|
|
68
|
+
assert.ok('inflight' in metrics);
|
|
69
|
+
assert.ok('inflightMax' in metrics);
|
|
70
|
+
assert.ok('totalRequests' in metrics);
|
|
71
|
+
assert.ok('totalProxyErrors' in metrics);
|
|
72
|
+
assert.ok('statusCounters' in metrics);
|
|
73
|
+
assert.ok('rollingRps60' in metrics);
|
|
74
|
+
assert.ok('health' in metrics);
|
|
75
|
+
assert.ok('lastProxyError' in metrics);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should have valid startedAt timestamp', async () => {
|
|
79
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/__metrics`, {
|
|
80
|
+
headers: { 'x-admin-token': adminToken },
|
|
81
|
+
});
|
|
82
|
+
const metrics = await response.json();
|
|
83
|
+
|
|
84
|
+
const startedAt = new Date(metrics.startedAt);
|
|
85
|
+
assert.ok(!isNaN(startedAt.getTime()));
|
|
86
|
+
assert.ok(startedAt <= new Date());
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('Request Counting', () => {
|
|
91
|
+
it('should count total requests', async () => {
|
|
92
|
+
// Get initial count
|
|
93
|
+
const beforeRes = await fetch(`http://127.0.0.1:${proxyPort}/__metrics`, {
|
|
94
|
+
headers: { 'x-admin-token': adminToken },
|
|
95
|
+
});
|
|
96
|
+
const before = await beforeRes.json();
|
|
97
|
+
|
|
98
|
+
// Make some proxied requests
|
|
99
|
+
for (let i = 0; i < 5; i++) {
|
|
100
|
+
await fetch(`http://127.0.0.1:${proxyPort}/`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Get new count
|
|
104
|
+
const afterRes = await fetch(`http://127.0.0.1:${proxyPort}/__metrics`, {
|
|
105
|
+
headers: { 'x-admin-token': adminToken },
|
|
106
|
+
});
|
|
107
|
+
const after = await afterRes.json();
|
|
108
|
+
|
|
109
|
+
// Should have increased by at least 5 (proxied requests, not admin)
|
|
110
|
+
assert.ok(after.totalRequests >= before.totalRequests + 5);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('Status Counters', () => {
|
|
115
|
+
it('should count 2xx responses', async () => {
|
|
116
|
+
const beforeRes = await fetch(`http://127.0.0.1:${proxyPort}/__metrics`, {
|
|
117
|
+
headers: { 'x-admin-token': adminToken },
|
|
118
|
+
});
|
|
119
|
+
const before = await beforeRes.json();
|
|
120
|
+
|
|
121
|
+
// Make successful requests
|
|
122
|
+
for (let i = 0; i < 3; i++) {
|
|
123
|
+
await fetch(`http://127.0.0.1:${proxyPort}/`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const afterRes = await fetch(`http://127.0.0.1:${proxyPort}/__metrics`, {
|
|
127
|
+
headers: { 'x-admin-token': adminToken },
|
|
128
|
+
});
|
|
129
|
+
const after = await afterRes.json();
|
|
130
|
+
|
|
131
|
+
assert.ok(after.statusCounters['2xx'] >= before.statusCounters['2xx'] + 3);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should count 5xx responses', async () => {
|
|
135
|
+
const beforeRes = await fetch(`http://127.0.0.1:${proxyPort}/__metrics`, {
|
|
136
|
+
headers: { 'x-admin-token': adminToken },
|
|
137
|
+
});
|
|
138
|
+
const before = await beforeRes.json();
|
|
139
|
+
|
|
140
|
+
// Make requests that return 500
|
|
141
|
+
for (let i = 0; i < 2; i++) {
|
|
142
|
+
await fetch(`http://127.0.0.1:${proxyPort}/error`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const afterRes = await fetch(`http://127.0.0.1:${proxyPort}/__metrics`, {
|
|
146
|
+
headers: { 'x-admin-token': adminToken },
|
|
147
|
+
});
|
|
148
|
+
const after = await afterRes.json();
|
|
149
|
+
|
|
150
|
+
assert.ok(after.statusCounters['5xx'] >= before.statusCounters['5xx'] + 2);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should have all status counter categories', async () => {
|
|
154
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/__metrics`, {
|
|
155
|
+
headers: { 'x-admin-token': adminToken },
|
|
156
|
+
});
|
|
157
|
+
const metrics = await response.json();
|
|
158
|
+
|
|
159
|
+
assert.ok('2xx' in metrics.statusCounters);
|
|
160
|
+
assert.ok('3xx' in metrics.statusCounters);
|
|
161
|
+
assert.ok('4xx' in metrics.statusCounters);
|
|
162
|
+
assert.ok('5xx' in metrics.statusCounters);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe('Inflight Tracking', () => {
|
|
167
|
+
it('should track inflight requests', async () => {
|
|
168
|
+
// Make requests to slow endpoint
|
|
169
|
+
const promises = [];
|
|
170
|
+
for (let i = 0; i < 3; i++) {
|
|
171
|
+
promises.push(fetch(`http://127.0.0.1:${proxyPort}/slow`));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Check inflight while requests are in progress
|
|
175
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
176
|
+
|
|
177
|
+
const metricsRes = await fetch(`http://127.0.0.1:${proxyPort}/__metrics`, {
|
|
178
|
+
headers: { 'x-admin-token': adminToken },
|
|
179
|
+
});
|
|
180
|
+
const metrics = await metricsRes.json();
|
|
181
|
+
|
|
182
|
+
// Should have some inflight requests
|
|
183
|
+
// Note: timing-dependent, so just check inflightMax was tracked
|
|
184
|
+
assert.ok(metrics.inflightMax >= 0);
|
|
185
|
+
|
|
186
|
+
// Wait for slow requests to complete
|
|
187
|
+
await Promise.all(promises);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('should track inflightMax', async () => {
|
|
191
|
+
const metricsRes = await fetch(`http://127.0.0.1:${proxyPort}/__metrics`, {
|
|
192
|
+
headers: { 'x-admin-token': adminToken },
|
|
193
|
+
});
|
|
194
|
+
const metrics = await metricsRes.json();
|
|
195
|
+
|
|
196
|
+
// inflightMax should be >= current inflight
|
|
197
|
+
assert.ok(metrics.inflightMax >= metrics.inflight);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe('Rolling RPS', () => {
|
|
202
|
+
it('should calculate rolling RPS over 60 seconds', async () => {
|
|
203
|
+
// Make some requests
|
|
204
|
+
for (let i = 0; i < 10; i++) {
|
|
205
|
+
await fetch(`http://127.0.0.1:${proxyPort}/`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const metricsRes = await fetch(`http://127.0.0.1:${proxyPort}/__metrics`, {
|
|
209
|
+
headers: { 'x-admin-token': adminToken },
|
|
210
|
+
});
|
|
211
|
+
const metrics = await metricsRes.json();
|
|
212
|
+
|
|
213
|
+
// RPS should be a number >= 0
|
|
214
|
+
assert.ok(typeof metrics.rollingRps60 === 'number');
|
|
215
|
+
assert.ok(metrics.rollingRps60 >= 0);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('should update RPS as requests come in', async () => {
|
|
219
|
+
const beforeRes = await fetch(`http://127.0.0.1:${proxyPort}/__metrics`, {
|
|
220
|
+
headers: { 'x-admin-token': adminToken },
|
|
221
|
+
});
|
|
222
|
+
const before = await beforeRes.json();
|
|
223
|
+
|
|
224
|
+
// Make burst of requests
|
|
225
|
+
const promises = [];
|
|
226
|
+
for (let i = 0; i < 20; i++) {
|
|
227
|
+
promises.push(fetch(`http://127.0.0.1:${proxyPort}/`));
|
|
228
|
+
}
|
|
229
|
+
await Promise.all(promises);
|
|
230
|
+
|
|
231
|
+
const afterRes = await fetch(`http://127.0.0.1:${proxyPort}/__metrics`, {
|
|
232
|
+
headers: { 'x-admin-token': adminToken },
|
|
233
|
+
});
|
|
234
|
+
const after = await afterRes.json();
|
|
235
|
+
|
|
236
|
+
// RPS should have increased
|
|
237
|
+
assert.ok(after.rollingRps60 >= before.rollingRps60);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe('Health Metrics', () => {
|
|
242
|
+
it('should track health status', async () => {
|
|
243
|
+
const metricsRes = await fetch(`http://127.0.0.1:${proxyPort}/__metrics`, {
|
|
244
|
+
headers: { 'x-admin-token': adminToken },
|
|
245
|
+
});
|
|
246
|
+
const metrics = await metricsRes.json();
|
|
247
|
+
|
|
248
|
+
assert.ok('activePortOk' in metrics.health);
|
|
249
|
+
assert.ok('lastCheckedAt' in metrics.health);
|
|
250
|
+
assert.ok('consecutiveFails' in metrics.health);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('should update health after health check', async () => {
|
|
254
|
+
// Trigger a health check via /__health endpoint
|
|
255
|
+
await fetch(`http://127.0.0.1:${proxyPort}/__health`, {
|
|
256
|
+
headers: { 'x-admin-token': adminToken },
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const metricsRes = await fetch(`http://127.0.0.1:${proxyPort}/__metrics`, {
|
|
260
|
+
headers: { 'x-admin-token': adminToken },
|
|
261
|
+
});
|
|
262
|
+
const metrics = await metricsRes.json();
|
|
263
|
+
|
|
264
|
+
// lastCheckedAt should be set
|
|
265
|
+
assert.ok(metrics.health.lastCheckedAt);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe('Error Tracking', () => {
|
|
270
|
+
it('should track proxy errors', async () => {
|
|
271
|
+
const metricsRes = await fetch(`http://127.0.0.1:${proxyPort}/__metrics`, {
|
|
272
|
+
headers: { 'x-admin-token': adminToken },
|
|
273
|
+
});
|
|
274
|
+
const metrics = await metricsRes.json();
|
|
275
|
+
|
|
276
|
+
assert.ok(typeof metrics.totalProxyErrors === 'number');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('should record last proxy error details', async () => {
|
|
280
|
+
// Create a daemon with invalid target port
|
|
281
|
+
const badPort = await getFreePort();
|
|
282
|
+
const badDaemon = spawn('node', ['portokd.mjs'], {
|
|
283
|
+
env: {
|
|
284
|
+
...process.env,
|
|
285
|
+
LISTEN_PORT: String(badPort),
|
|
286
|
+
INITIAL_TARGET_PORT: '1', // Invalid/closed port
|
|
287
|
+
ADMIN_TOKEN: adminToken,
|
|
288
|
+
STATE_FILE: `/tmp/portok-error-test-${Date.now()}.json`,
|
|
289
|
+
DRAIN_MS: '1000',
|
|
290
|
+
ROLLBACK_WINDOW_MS: '60000',
|
|
291
|
+
ROLLBACK_CHECK_EVERY_MS: '5000',
|
|
292
|
+
ROLLBACK_FAIL_THRESHOLD: '3',
|
|
293
|
+
ADMIN_RATE_LIMIT: '1000',
|
|
294
|
+
},
|
|
295
|
+
stdio: 'pipe',
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
299
|
+
|
|
300
|
+
// Make a request that will fail
|
|
301
|
+
await fetch(`http://127.0.0.1:${badPort}/`).catch(() => {});
|
|
302
|
+
|
|
303
|
+
// Wait a bit for error to be recorded
|
|
304
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
const metricsRes = await fetch(`http://127.0.0.1:${badPort}/__metrics`, {
|
|
308
|
+
headers: { 'x-admin-token': adminToken },
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
if (metricsRes.ok) {
|
|
312
|
+
const metrics = await metricsRes.json();
|
|
313
|
+
|
|
314
|
+
if (metrics.lastProxyError) {
|
|
315
|
+
assert.ok(metrics.lastProxyError.message);
|
|
316
|
+
assert.ok(metrics.lastProxyError.timestamp);
|
|
317
|
+
assert.ok(metrics.lastProxyError.targetPort);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
} catch {
|
|
321
|
+
// Daemon might not be fully up, which is fine
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
badDaemon.kill('SIGTERM');
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|