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.
@@ -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
+ }