unitup 0.2.0 → 0.3.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/.agents/AGENTS.md +35 -0
- package/.agents/skills/multi-runtime-management/SKILL.md +80 -0
- package/.agents/skills/platform-service-adapters/SKILL.md +108 -0
- package/.agents/skills/unitup-development-and-qa/SKILL.md +88 -0
- package/.agents/skills/zero-downtime-deployment/SKILL.md +135 -0
- package/index.d.ts +136 -0
- package/package.json +1 -1
- package/src/cli.js +374 -2
- package/src/core/drain-manager.js +70 -0
- package/src/core/generation-manager.js +414 -0
- package/src/core/index.js +8 -0
- package/src/core/ipc.js +236 -0
- package/src/core/lock.js +133 -0
- package/src/core/process-manager.js +313 -0
- package/src/core/readiness.js +243 -0
- package/src/core/router.js +390 -0
- package/src/core/supervisor.js +222 -0
- package/src/deploy/deployment-manager.js +407 -0
- package/src/deploy/index.js +9 -0
- package/src/deploy/rollback-manager.js +186 -0
- package/src/index.js +54 -0
- package/src/platform/macos.js +4 -1
- package/src/platform/windows.js +4 -1
- package/src/service/config.js +8 -0
- package/src/service/normalize.js +30 -1
- package/src/unit.js +5 -1
- package/src/utils.js +80 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Router owns a stable public port and reverse-proxies HTTP & WebSocket traffic
|
|
6
|
+
* to whichever internal generation is marked active.
|
|
7
|
+
*
|
|
8
|
+
* It contains NO deployment sequencing decisions.
|
|
9
|
+
*/
|
|
10
|
+
export class Router {
|
|
11
|
+
constructor(options = {}) {
|
|
12
|
+
this.activeBackends = new Map(); // serviceName -> generation { id, internalPort }
|
|
13
|
+
this.canaryBackends = new Map(); // serviceName -> { generation, weight }
|
|
14
|
+
this.inFlightRequests = new Map(); // generationId -> number
|
|
15
|
+
this.server = null;
|
|
16
|
+
this.listeningPort = null;
|
|
17
|
+
this.listeningHost = null;
|
|
18
|
+
this.closing = false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Sets a canary backend with a specific traffic weight (0 to 1, or 1 to 100).
|
|
23
|
+
*
|
|
24
|
+
* @param {string} serviceName
|
|
25
|
+
* @param {object} generation
|
|
26
|
+
* @param {number} [weight=0.1] - Probability (0 to 1) or percentage (1 to 100)
|
|
27
|
+
*/
|
|
28
|
+
setCanary(serviceName, generation, weight = 0.1) {
|
|
29
|
+
const parsed = typeof weight === 'string' ? Number.parseFloat(weight.replace('%', '')) : Number(weight);
|
|
30
|
+
const num = Number.isNaN(parsed) ? 0.1 : parsed;
|
|
31
|
+
const normalizedWeight = num > 1 ? num / 100 : num;
|
|
32
|
+
this.canaryBackends.set(serviceName, {
|
|
33
|
+
generation,
|
|
34
|
+
weight: Math.max(0, Math.min(1, normalizedWeight))
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Clears the canary backend for a service.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} serviceName
|
|
42
|
+
*/
|
|
43
|
+
clearCanary(serviceName) {
|
|
44
|
+
this.canaryBackends.delete(serviceName);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Promotes the canary generation to the active primary generation,
|
|
49
|
+
* clearing the canary state.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} serviceName
|
|
52
|
+
* @returns {object|null} The promoted generation
|
|
53
|
+
*/
|
|
54
|
+
promoteCanary(serviceName) {
|
|
55
|
+
const canary = this.canaryBackends.get(serviceName);
|
|
56
|
+
if (!canary || !canary.generation) return null;
|
|
57
|
+
this.switchBackend(serviceName, canary.generation);
|
|
58
|
+
this.canaryBackends.delete(serviceName);
|
|
59
|
+
return canary.generation;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Gets the canary config for a service.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} serviceName
|
|
66
|
+
* @returns {{ generation: object, weight: number }|null}
|
|
67
|
+
*/
|
|
68
|
+
getCanary(serviceName) {
|
|
69
|
+
return this.canaryBackends.get(serviceName) || null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolves the target backend generation for an incoming request.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} serviceName
|
|
76
|
+
* @param {import('node:http').IncomingMessage} [req]
|
|
77
|
+
* @returns {object|null}
|
|
78
|
+
*/
|
|
79
|
+
resolveBackend(serviceName, req = null) {
|
|
80
|
+
const primary = this.activeBackends.get(serviceName);
|
|
81
|
+
const canary = this.canaryBackends.get(serviceName);
|
|
82
|
+
|
|
83
|
+
if (!canary || !canary.generation) {
|
|
84
|
+
return primary || null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!primary) {
|
|
88
|
+
return canary.generation;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Optional header-based override (e.g. x-canary: true or x-canary: always)
|
|
92
|
+
if (req?.headers) {
|
|
93
|
+
const canaryHeader = req.headers['x-canary'];
|
|
94
|
+
if (canaryHeader === 'true' || canaryHeader === 'always' || canaryHeader === '1') {
|
|
95
|
+
return canary.generation;
|
|
96
|
+
}
|
|
97
|
+
if (canaryHeader === 'false' || canaryHeader === 'never' || canaryHeader === '0') {
|
|
98
|
+
return primary;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Probabilistic routing based on weight
|
|
103
|
+
if (canary.weight > 0 && Math.random() < canary.weight) {
|
|
104
|
+
return canary.generation;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return primary;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Helper to find the service name matching the request.
|
|
112
|
+
* @param {import('node:http').IncomingMessage} [req]
|
|
113
|
+
* @returns {string|null}
|
|
114
|
+
*/
|
|
115
|
+
_resolveServiceName(req) {
|
|
116
|
+
if (this.activeBackends.size === 1) {
|
|
117
|
+
return this.activeBackends.keys().next().value;
|
|
118
|
+
}
|
|
119
|
+
if (this.activeBackends.size > 1 && req?.headers?.host) {
|
|
120
|
+
for (const srv of this.activeBackends.keys()) {
|
|
121
|
+
if (req.headers.host.includes(srv)) {
|
|
122
|
+
return srv;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (this.activeBackends.size > 0) {
|
|
127
|
+
return this.activeBackends.keys().next().value;
|
|
128
|
+
}
|
|
129
|
+
if (this.canaryBackends.size > 0) {
|
|
130
|
+
return this.canaryBackends.keys().next().value;
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Increments in-flight request counter for a generation.
|
|
137
|
+
* @param {number|string} generationId
|
|
138
|
+
*/
|
|
139
|
+
_incrementInFlight(generationId) {
|
|
140
|
+
const id = String(generationId);
|
|
141
|
+
const count = this.inFlightRequests.get(id) || 0;
|
|
142
|
+
this.inFlightRequests.set(id, count + 1);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Decrements in-flight request counter for a generation.
|
|
147
|
+
* @param {number|string} generationId
|
|
148
|
+
*/
|
|
149
|
+
_decrementInFlight(generationId) {
|
|
150
|
+
const id = String(generationId);
|
|
151
|
+
const count = this.inFlightRequests.get(id) || 0;
|
|
152
|
+
if (count <= 1) {
|
|
153
|
+
this.inFlightRequests.delete(id);
|
|
154
|
+
} else {
|
|
155
|
+
this.inFlightRequests.set(id, count - 1);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Gets the current number of in-flight requests for a generation.
|
|
161
|
+
*
|
|
162
|
+
* @param {number|string} generationId
|
|
163
|
+
* @returns {number}
|
|
164
|
+
*/
|
|
165
|
+
getInFlightCount(generationId) {
|
|
166
|
+
return this.inFlightRequests.get(String(generationId)) || 0;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Switches the active backend generation for a service.
|
|
171
|
+
* Switching is atomic and lock-free for incoming requests.
|
|
172
|
+
*
|
|
173
|
+
* @param {string} serviceName
|
|
174
|
+
* @param {object} generation - { id, internalPort, service }
|
|
175
|
+
*/
|
|
176
|
+
switchBackend(serviceName, generation) {
|
|
177
|
+
this.activeBackends.set(serviceName, generation);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Gets the active backend generation for a service.
|
|
182
|
+
*
|
|
183
|
+
* @param {string} serviceName
|
|
184
|
+
* @returns {object|null}
|
|
185
|
+
*/
|
|
186
|
+
getActiveBackend(serviceName) {
|
|
187
|
+
return this.activeBackends.get(serviceName) || null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Starts listening on the public port.
|
|
192
|
+
*
|
|
193
|
+
* @param {number} publicPort
|
|
194
|
+
* @param {string} [host='0.0.0.0']
|
|
195
|
+
* @returns {Promise<{ port: number, host: string }>}
|
|
196
|
+
*/
|
|
197
|
+
async listen(publicPort, host = '0.0.0.0') {
|
|
198
|
+
if (this.server && this.listeningPort === publicPort) {
|
|
199
|
+
return { port: this.listeningPort, host: this.listeningHost };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (this.server) {
|
|
203
|
+
await this.close();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
this.closing = false;
|
|
207
|
+
|
|
208
|
+
return new Promise((resolve, reject) => {
|
|
209
|
+
const server = http.createServer((req, res) => {
|
|
210
|
+
this._handleHttpRequest(req, res);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
server.on('upgrade', (req, clientSocket, head) => {
|
|
214
|
+
this._handleWebSocketUpgrade(req, clientSocket, head);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
server.once('error', (err) => {
|
|
218
|
+
if (err.code === 'EADDRINUSE') {
|
|
219
|
+
reject(new Error(`Public port ${publicPort} is already occupied by another process.`));
|
|
220
|
+
} else {
|
|
221
|
+
reject(err);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
server.listen(publicPort, host, () => {
|
|
226
|
+
this.server = server;
|
|
227
|
+
const actualPort = server.address() ? server.address().port : publicPort;
|
|
228
|
+
this.listeningPort = actualPort;
|
|
229
|
+
this.listeningHost = host;
|
|
230
|
+
resolve({ port: actualPort, host });
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Dispatches and proxies an HTTP request to the active backend.
|
|
237
|
+
*
|
|
238
|
+
* @param {import('node:http').IncomingMessage} req
|
|
239
|
+
* @param {import('node:http').ServerResponse} res
|
|
240
|
+
*/
|
|
241
|
+
_handleHttpRequest(req, res) {
|
|
242
|
+
const serviceName = this._resolveServiceName(req);
|
|
243
|
+
const backend = serviceName ? this.resolveBackend(serviceName, req) : null;
|
|
244
|
+
|
|
245
|
+
if (!backend || !backend.internalPort) {
|
|
246
|
+
res.writeHead(503, {
|
|
247
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
248
|
+
'Retry-After': '1'
|
|
249
|
+
});
|
|
250
|
+
res.end('503 Service Unavailable: No active generation backend available.');
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const genId = backend.id;
|
|
255
|
+
this._incrementInFlight(genId);
|
|
256
|
+
|
|
257
|
+
let decremented = false;
|
|
258
|
+
const cleanupInFlight = () => {
|
|
259
|
+
if (!decremented) {
|
|
260
|
+
decremented = true;
|
|
261
|
+
this._decrementInFlight(genId);
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
res.once('finish', cleanupInFlight);
|
|
266
|
+
res.once('close', cleanupInFlight);
|
|
267
|
+
|
|
268
|
+
// Forward headers
|
|
269
|
+
const forwardedHeaders = { ...req.headers };
|
|
270
|
+
const clientIp = req.socket?.remoteAddress || '127.0.0.1';
|
|
271
|
+
forwardedHeaders['x-forwarded-for'] = forwardedHeaders['x-forwarded-for']
|
|
272
|
+
? `${forwardedHeaders['x-forwarded-for']}, ${clientIp}`
|
|
273
|
+
: clientIp;
|
|
274
|
+
forwardedHeaders['x-forwarded-proto'] = forwardedHeaders['x-forwarded-proto'] || 'http';
|
|
275
|
+
forwardedHeaders['x-forwarded-host'] = req.headers.host || `localhost:${this.listeningPort}`;
|
|
276
|
+
forwardedHeaders['x-forwarded-port'] = String(this.listeningPort);
|
|
277
|
+
|
|
278
|
+
const proxyReq = http.request(
|
|
279
|
+
{
|
|
280
|
+
hostname: '127.0.0.1',
|
|
281
|
+
port: backend.internalPort,
|
|
282
|
+
path: req.url,
|
|
283
|
+
method: req.method,
|
|
284
|
+
headers: forwardedHeaders
|
|
285
|
+
},
|
|
286
|
+
(proxyRes) => {
|
|
287
|
+
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
|
288
|
+
proxyRes.pipe(res);
|
|
289
|
+
}
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
req.on('error', () => {
|
|
293
|
+
cleanupInFlight();
|
|
294
|
+
proxyReq.destroy();
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
proxyReq.on('error', (err) => {
|
|
298
|
+
cleanupInFlight();
|
|
299
|
+
if (!res.headersSent) {
|
|
300
|
+
res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
301
|
+
res.end(`502 Bad Gateway: Error communicating with generation #${genId} (${err.message}).`);
|
|
302
|
+
} else {
|
|
303
|
+
res.destroy();
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
req.pipe(proxyReq);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Dispatches and proxies WebSocket / Upgrade connections to the active backend.
|
|
312
|
+
*/
|
|
313
|
+
_handleWebSocketUpgrade(req, clientSocket, head) {
|
|
314
|
+
const serviceName = this._resolveServiceName(req);
|
|
315
|
+
const backend = serviceName ? this.resolveBackend(serviceName, req) : null;
|
|
316
|
+
if (!backend || !backend.internalPort) {
|
|
317
|
+
clientSocket.destroy();
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const genId = backend.id;
|
|
322
|
+
this._incrementInFlight(genId);
|
|
323
|
+
|
|
324
|
+
let decremented = false;
|
|
325
|
+
const cleanupInFlight = () => {
|
|
326
|
+
if (!decremented) {
|
|
327
|
+
decremented = true;
|
|
328
|
+
this._decrementInFlight(genId);
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
let backendSocket = null;
|
|
333
|
+
|
|
334
|
+
const onClientEnded = () => {
|
|
335
|
+
cleanupInFlight();
|
|
336
|
+
if (backendSocket && !backendSocket.destroyed) {
|
|
337
|
+
backendSocket.destroy();
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
clientSocket.once('close', onClientEnded);
|
|
342
|
+
clientSocket.once('end', onClientEnded);
|
|
343
|
+
clientSocket.once('error', onClientEnded);
|
|
344
|
+
|
|
345
|
+
backendSocket = net.connect(backend.internalPort, '127.0.0.1', () => {
|
|
346
|
+
backendSocket.write(
|
|
347
|
+
`${req.method} ${req.url} HTTP/${req.httpVersion}\r\n` +
|
|
348
|
+
Object.entries(req.headers)
|
|
349
|
+
.map(([k, v]) => `${k}: ${v}\r\n`)
|
|
350
|
+
.join('') +
|
|
351
|
+
'\r\n'
|
|
352
|
+
);
|
|
353
|
+
if (head && head.length > 0) {
|
|
354
|
+
backendSocket.write(head);
|
|
355
|
+
}
|
|
356
|
+
backendSocket.pipe(clientSocket);
|
|
357
|
+
clientSocket.pipe(backendSocket);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
const onBackendEnded = () => {
|
|
361
|
+
cleanupInFlight();
|
|
362
|
+
if (!clientSocket.destroyed) {
|
|
363
|
+
clientSocket.destroy();
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
backendSocket.once('close', onBackendEnded);
|
|
368
|
+
backendSocket.once('end', onBackendEnded);
|
|
369
|
+
backendSocket.on('error', onBackendEnded);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Closes the public router HTTP listener.
|
|
374
|
+
*
|
|
375
|
+
* @returns {Promise<void>}
|
|
376
|
+
*/
|
|
377
|
+
async close() {
|
|
378
|
+
this.closing = true;
|
|
379
|
+
if (!this.server) return;
|
|
380
|
+
|
|
381
|
+
return new Promise((resolve) => {
|
|
382
|
+
this.server.close(() => {
|
|
383
|
+
this.server = null;
|
|
384
|
+
this.listeningPort = null;
|
|
385
|
+
this.listeningHost = null;
|
|
386
|
+
resolve();
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { readAppMetadata, sanitizeServiceName } from '../utils.js';
|
|
2
|
+
import { DrainManager } from './drain-manager.js';
|
|
3
|
+
import { GenerationManager } from './generation-manager.js';
|
|
4
|
+
import { IPCServer } from './ipc.js';
|
|
5
|
+
import { DeploymentLock } from './lock.js';
|
|
6
|
+
import { ProcessManager } from './process-manager.js';
|
|
7
|
+
import { ReadinessChecker } from './readiness.js';
|
|
8
|
+
import { Router } from './router.js';
|
|
9
|
+
import { DeploymentManager } from '../deploy/deployment-manager.js';
|
|
10
|
+
import { RollbackManager } from '../deploy/rollback-manager.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Supervisor is the long-running host managed by the OS service manager (unitupd).
|
|
14
|
+
* It owns Router, child application generation processes, and IPC for control.
|
|
15
|
+
*/
|
|
16
|
+
export class Supervisor {
|
|
17
|
+
/**
|
|
18
|
+
* @param {string} serviceName
|
|
19
|
+
* @param {object} [serviceConfig]
|
|
20
|
+
* @param {object} [options]
|
|
21
|
+
*/
|
|
22
|
+
constructor(serviceName, serviceConfig = null, options = {}) {
|
|
23
|
+
this.serviceName = sanitizeServiceName(serviceName);
|
|
24
|
+
this.config = serviceConfig || readAppMetadata(this.serviceName) || {};
|
|
25
|
+
this.options = options;
|
|
26
|
+
|
|
27
|
+
this.processManager = new ProcessManager({
|
|
28
|
+
defaultShutdownTimeout: this.config.shutdownTimeout || 5000
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
this.generationManager = new GenerationManager({
|
|
32
|
+
processManager: this.processManager,
|
|
33
|
+
stateDir: options.stateDir
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
this.router = new Router();
|
|
37
|
+
this.readiness = new ReadinessChecker({ processManager: this.processManager });
|
|
38
|
+
this.drainManager = new DrainManager({
|
|
39
|
+
router: this.router,
|
|
40
|
+
processManager: this.processManager
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
this.lock = new DeploymentLock({
|
|
44
|
+
lockDir: options.lockDir,
|
|
45
|
+
processManager: this.processManager
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
this.deploymentManager = new DeploymentManager({
|
|
49
|
+
generationManager: this.generationManager,
|
|
50
|
+
router: this.router,
|
|
51
|
+
readiness: this.readiness,
|
|
52
|
+
drainManager: this.drainManager,
|
|
53
|
+
processManager: this.processManager,
|
|
54
|
+
lock: this.lock,
|
|
55
|
+
defaultStabilizationMs: options.stabilizationWindowMs !== undefined ? options.stabilizationWindowMs : 5000
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
this.rollbackManager = new RollbackManager({
|
|
59
|
+
generationManager: this.generationManager,
|
|
60
|
+
router: this.router,
|
|
61
|
+
readiness: this.readiness,
|
|
62
|
+
drainManager: this.drainManager,
|
|
63
|
+
processManager: this.processManager,
|
|
64
|
+
lock: this.lock
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
this.ipcServer = new IPCServer({
|
|
68
|
+
serviceName: this.serviceName,
|
|
69
|
+
ipcDir: options.ipcDir,
|
|
70
|
+
handler: this._handleIpcMessage.bind(this)
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
this.running = false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async _handleIpcMessage(message, sendProgress) {
|
|
77
|
+
if (!message || typeof message !== 'object') {
|
|
78
|
+
throw new Error('Invalid IPC request format');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const action = message.action;
|
|
82
|
+
|
|
83
|
+
if (action === 'deploy') {
|
|
84
|
+
// Reload config in case it changed on disk
|
|
85
|
+
const freshConfig = readAppMetadata(this.serviceName) || this.config;
|
|
86
|
+
return this.deploymentManager.deploy(this.serviceName, freshConfig, {
|
|
87
|
+
...message.options,
|
|
88
|
+
onProgress: sendProgress
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (action === 'rollback') {
|
|
93
|
+
const freshConfig = readAppMetadata(this.serviceName) || this.config;
|
|
94
|
+
return this.rollbackManager.rollback(this.serviceName, freshConfig, {
|
|
95
|
+
...message.options,
|
|
96
|
+
onProgress: sendProgress
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (action === 'promote') {
|
|
101
|
+
const freshConfig = readAppMetadata(this.serviceName) || this.config;
|
|
102
|
+
return this.deploymentManager.promote(this.serviceName, freshConfig, {
|
|
103
|
+
...message.options,
|
|
104
|
+
onProgress: sendProgress
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (action === 'status') {
|
|
109
|
+
const active = this.generationManager.getActive(this.serviceName);
|
|
110
|
+
const canaryGen = this.generationManager.getCanary(this.serviceName);
|
|
111
|
+
const canaryRouter = this.router.getCanary(this.serviceName);
|
|
112
|
+
return {
|
|
113
|
+
service: this.serviceName,
|
|
114
|
+
activeGeneration: active,
|
|
115
|
+
previousGeneration: this.generationManager.getPrevious(this.serviceName),
|
|
116
|
+
canaryGeneration: canaryGen,
|
|
117
|
+
canaryWeight: canaryRouter?.weight ?? canaryGen?.canaryWeight ?? null,
|
|
118
|
+
generations: this.generationManager.listGenerations(this.serviceName),
|
|
119
|
+
routerPort: this.router.listeningPort,
|
|
120
|
+
inFlightRequests: active ? this.router.getInFlightCount(active.id) : 0
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (action === 'stop') {
|
|
125
|
+
setTimeout(() => this.stop(), 50);
|
|
126
|
+
return { stopped: true };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
throw new Error(`Unknown supervisor action: "${action}"`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Starts the supervisor, IPC server, and initial generation.
|
|
134
|
+
*/
|
|
135
|
+
async start() {
|
|
136
|
+
this.running = true;
|
|
137
|
+
|
|
138
|
+
// 1. Start IPC server for remote CLI commands
|
|
139
|
+
await this.ipcServer.start();
|
|
140
|
+
|
|
141
|
+
// 2. Load state and cleanup stale dead PIDs from previous runs
|
|
142
|
+
this.generationManager.loadState(this.serviceName);
|
|
143
|
+
this.generationManager.cleanupStale(this.serviceName);
|
|
144
|
+
|
|
145
|
+
const publicPort = this.options.publicPort || this.config.port;
|
|
146
|
+
|
|
147
|
+
// 3. Check if there is an active running generation from persisted state
|
|
148
|
+
const active = this.generationManager.getActive(this.serviceName);
|
|
149
|
+
if (active && this.processManager.isAlive(active.pid)) {
|
|
150
|
+
if (publicPort) {
|
|
151
|
+
await this.router.listen(publicPort);
|
|
152
|
+
}
|
|
153
|
+
this.router.switchBackend(this.serviceName, active);
|
|
154
|
+
} else {
|
|
155
|
+
// Start initial generation
|
|
156
|
+
await this.deploymentManager.deploy(this.serviceName, this.config, {
|
|
157
|
+
publicPort,
|
|
158
|
+
stabilizationWindowMs: this.options.stabilizationWindowMs
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return this;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Stops the supervisor, closes router, and drains all active generations.
|
|
167
|
+
*/
|
|
168
|
+
async stop() {
|
|
169
|
+
if (!this.running) return;
|
|
170
|
+
this.running = false;
|
|
171
|
+
|
|
172
|
+
// 1. Close IPC server
|
|
173
|
+
await this.ipcServer.close();
|
|
174
|
+
|
|
175
|
+
// 2. Close router
|
|
176
|
+
await this.router.close();
|
|
177
|
+
|
|
178
|
+
// 3. Drain and stop active generations
|
|
179
|
+
const active = this.generationManager.getActive(this.serviceName);
|
|
180
|
+
if (active) {
|
|
181
|
+
await this.drainManager.drain(active, { timeout: 5000, stopProcess: true });
|
|
182
|
+
this.generationManager.remove(active, false);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Stop any other untracked or running child processes
|
|
186
|
+
for (const [pid] of this.processManager.processes.entries()) {
|
|
187
|
+
await this.processManager.stop(pid, { timeout: 2000 });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Standalone entry point when running `unitup supervisor <serviceName>`.
|
|
193
|
+
*
|
|
194
|
+
* @param {string} serviceName
|
|
195
|
+
*/
|
|
196
|
+
static async run(serviceName) {
|
|
197
|
+
if (!serviceName) {
|
|
198
|
+
process.stderr.write('Error: Service name is required to run supervisor.\n');
|
|
199
|
+
process.exit(1);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const supervisor = new Supervisor(serviceName);
|
|
203
|
+
|
|
204
|
+
const onSignal = async (sig) => {
|
|
205
|
+
try {
|
|
206
|
+
await supervisor.stop();
|
|
207
|
+
} finally {
|
|
208
|
+
process.exit(0);
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
process.on('SIGINT', () => onSignal('SIGINT'));
|
|
213
|
+
process.on('SIGTERM', () => onSignal('SIGTERM'));
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
await supervisor.start();
|
|
217
|
+
} catch (err) {
|
|
218
|
+
process.stderr.write(`[unitup supervisor] Failed to start: ${err.message}\n`);
|
|
219
|
+
process.exit(1);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|