unitup 0.2.0 → 0.4.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,222 @@
1
+ import { DeploymentManager } from '../deploy/deployment-manager.js';
2
+ import { RollbackManager } from '../deploy/rollback-manager.js';
3
+ import { readAppMetadata, sanitizeServiceName } from '../utils.js';
4
+ import { DrainManager } from './drain-manager.js';
5
+ import { GenerationManager } from './generation-manager.js';
6
+ import { IPCServer } from './ipc.js';
7
+ import { DeploymentLock } from './lock.js';
8
+ import { ProcessManager } from './process-manager.js';
9
+ import { ReadinessChecker } from './readiness.js';
10
+ import { Router } from './router.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 || this.config.deploy?.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, '0.0.0.0', this.serviceName);
152
+ }
153
+ this.router.switchBackend(this.serviceName, active, publicPort);
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
+ }
@@ -0,0 +1,388 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { DrainManager } from '../core/drain-manager.js';
3
+ import { GenerationManager } from '../core/generation-manager.js';
4
+ import { IPCClient } from '../core/ipc.js';
5
+ import { DeploymentLock } from '../core/lock.js';
6
+ import { ProcessManager } from '../core/process-manager.js';
7
+ import { ReadinessChecker } from '../core/readiness.js';
8
+ import { Router } from '../core/router.js';
9
+ import { readAppMetadata } from '../utils.js';
10
+
11
+ /**
12
+ * Deployment State Machine states.
13
+ */
14
+ export const DeploymentState = {
15
+ IDLE: 'IDLE',
16
+ CREATING_GENERATION: 'CREATING_GENERATION',
17
+ STARTING: 'STARTING',
18
+ WAITING_READY: 'WAITING_READY',
19
+ SWITCHING: 'SWITCHING',
20
+ STABILIZING: 'STABILIZING',
21
+ SETTING_CANARY: 'SETTING_CANARY',
22
+ PROMOTING: 'PROMOTING',
23
+ DRAINING: 'DRAINING',
24
+ STOPPING_PREVIOUS: 'STOPPING_PREVIOUS',
25
+ COMPLETE: 'COMPLETE',
26
+ FAILED: 'FAILED'
27
+ };
28
+
29
+ /**
30
+ * DeploymentManager is orchestration only.
31
+ * It strictly composes the Unitup core primitives to achieve zero-downtime deployment.
32
+ */
33
+ export class DeploymentManager extends EventEmitter {
34
+ /**
35
+ * @param {object} [options]
36
+ * @param {import('../core/generation-manager.js').GenerationManager} [options.generationManager]
37
+ * @param {import('../core/router.js').Router} [options.router]
38
+ * @param {import('../core/readiness.js').ReadinessChecker} [options.readiness]
39
+ * @param {import('../core/drain-manager.js').DrainManager} [options.drainManager]
40
+ * @param {import('../core/process-manager.js').ProcessManager} [options.processManager]
41
+ * @param {import('../core/lock.js').DeploymentLock} [options.lock]
42
+ * @param {number} [options.defaultStabilizationMs=5000]
43
+ */
44
+ constructor(options = {}) {
45
+ super();
46
+ this.generationManager = options.generationManager;
47
+ this.router = options.router;
48
+ this.readiness = options.readiness;
49
+ this.drainManager = options.drainManager;
50
+ this.processManager = options.processManager;
51
+ this.lock = options.lock;
52
+ this.defaultStabilizationMs = options.defaultStabilizationMs !== undefined ? options.defaultStabilizationMs : 5000;
53
+ this.state = DeploymentState.IDLE;
54
+ }
55
+
56
+ _ensurePrimitives(serviceName, options = {}) {
57
+ if (!this.processManager) {
58
+ this.processManager = new ProcessManager();
59
+ }
60
+ if (!this.generationManager) {
61
+ this.generationManager = new GenerationManager({
62
+ processManager: this.processManager,
63
+ stateDir: options.stateDir
64
+ });
65
+ }
66
+ if (!this.router) {
67
+ this.router = new Router();
68
+ }
69
+ if (!this.readiness) {
70
+ this.readiness = new ReadinessChecker({ processManager: this.processManager });
71
+ }
72
+ if (!this.drainManager) {
73
+ this.drainManager = new DrainManager({
74
+ router: this.router,
75
+ processManager: this.processManager
76
+ });
77
+ }
78
+ if (!this.lock) {
79
+ this.lock = new DeploymentLock({
80
+ lockDir: options.lockDir,
81
+ processManager: this.processManager
82
+ });
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Emits state change event and optional progress notification.
88
+ * @param {string} newState
89
+ * @param {object} [details]
90
+ * @param {function} [onProgress]
91
+ */
92
+ _setState(newState, details = {}, onProgress) {
93
+ const oldState = this.state;
94
+ this.state = newState;
95
+ const payload = { from: oldState, to: newState, state: newState, ...details };
96
+ this.emit('stateChange', payload);
97
+ if (onProgress) {
98
+ onProgress(payload);
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Executes a zero-downtime deployment for a service.
104
+ *
105
+ * @param {string} serviceName
106
+ * @param {object} [serviceConfig] - Config containing command, args, cwd, env, port, deploy opts
107
+ * @param {object} [options]
108
+ * @param {number} [options.publicPort]
109
+ * @param {number} [options.stabilizationWindowMs]
110
+ * @param {number} [options.readinessTimeout]
111
+ * @param {string} [options.readyPath]
112
+ * @param {number} [options.drainTimeout]
113
+ * @param {function(object): void} [options.onProgress]
114
+ * @returns {Promise<object>} Deployment summary
115
+ */
116
+ async deploy(serviceName, serviceConfig = {}, options = {}) {
117
+ if (!serviceName) {
118
+ throw new Error('Service name is required for deployment.');
119
+ }
120
+
121
+ // Check if a supervisor daemon is running for this service via IPC
122
+ const ipcClient = new IPCClient(serviceName, options.ipcDir);
123
+ const isRemote = await ipcClient.isAlive();
124
+ if (isRemote) {
125
+ return ipcClient.send(
126
+ {
127
+ action: 'deploy',
128
+ options: { ...options, ...serviceConfig }
129
+ },
130
+ options.onProgress
131
+ );
132
+ }
133
+
134
+ this._ensurePrimitives(serviceName, options);
135
+
136
+ const config =
137
+ serviceConfig && (serviceConfig.command || serviceConfig.script)
138
+ ? serviceConfig
139
+ : readAppMetadata(serviceName) || serviceConfig || {};
140
+
141
+ // 1. Acquire per-service deployment lock
142
+ await this.lock.acquire(serviceName);
143
+
144
+ let next = null;
145
+ let previous = null;
146
+
147
+ try {
148
+ this._setState(DeploymentState.IDLE, { service: serviceName }, options.onProgress);
149
+
150
+ previous = this.generationManager.getActive(serviceName);
151
+
152
+ // 2. CREATING_GENERATION
153
+ this._setState(
154
+ DeploymentState.CREATING_GENERATION,
155
+ { service: serviceName, previousGeneration: previous?.id },
156
+ options.onProgress
157
+ );
158
+
159
+ next = await this.generationManager.create(serviceName, config, options);
160
+
161
+ // 3. STARTING
162
+ this._setState(
163
+ DeploymentState.STARTING,
164
+ {
165
+ service: serviceName,
166
+ generation: next.id,
167
+ pid: next.pid,
168
+ internalPort: next.internalPort
169
+ },
170
+ options.onProgress
171
+ );
172
+
173
+ // 4. WAITING_READY
174
+ this._setState(DeploymentState.WAITING_READY, { service: serviceName, generation: next.id }, options.onProgress);
175
+
176
+ try {
177
+ const readyPath = options.readyPath || config.deploy?.ready;
178
+ const readinessTimeout = options.readinessTimeout || 15000;
179
+ await this.readiness.wait(next, {
180
+ path: readyPath,
181
+ timeout: readinessTimeout,
182
+ configuredPort: options.publicPort || config.port,
183
+ router: this.router
184
+ });
185
+ } catch (readinessErr) {
186
+ // Readiness failed: clean up next generation, leave router pointing at previous
187
+ await this.processManager.stop(next.pid, { timeout: 2000 });
188
+ this.generationManager.remove(next, true);
189
+ this._setState(
190
+ DeploymentState.FAILED,
191
+ { service: serviceName, error: readinessErr.message },
192
+ options.onProgress
193
+ );
194
+ throw readinessErr;
195
+ }
196
+
197
+ const isCanary = Boolean(options.canary);
198
+ const rawWeight =
199
+ options.weight !== undefined ? options.weight : options.canaryWeight !== undefined ? options.canaryWeight : 0.1;
200
+ const parsedWeight =
201
+ typeof rawWeight === 'string' ? Number.parseFloat(rawWeight.replace('%', '')) : Number(rawWeight);
202
+ const numWeight = Number.isNaN(parsedWeight) ? 0.1 : parsedWeight;
203
+ const canaryWeight = Math.max(0, Math.min(1, numWeight > 1 ? numWeight / 100 : numWeight));
204
+
205
+ if (isCanary) {
206
+ const publicPort = options.publicPort || config.port;
207
+ if (publicPort) {
208
+ await this.router.listen(publicPort, '0.0.0.0', serviceName);
209
+ }
210
+
211
+ if (!previous) {
212
+ this.router.switchBackend(serviceName, next, publicPort);
213
+ this.generationManager.markActive(next);
214
+ }
215
+
216
+ this._setState(
217
+ DeploymentState.SETTING_CANARY,
218
+ { service: serviceName, generation: next.id, weight: canaryWeight },
219
+ options.onProgress
220
+ );
221
+
222
+ this.router.setCanary(serviceName, next, canaryWeight, publicPort);
223
+ this.generationManager.markCanary(next, canaryWeight);
224
+
225
+ const result = {
226
+ service: serviceName,
227
+ isCanary: true,
228
+ activeGeneration: previous?.id ?? next.id,
229
+ canaryGeneration: next.id,
230
+ canaryWeight,
231
+ downtimeMs: 0,
232
+ status: 'canary'
233
+ };
234
+
235
+ this._setState(DeploymentState.COMPLETE, result, options.onProgress);
236
+ return result;
237
+ }
238
+
239
+ // 5. SWITCHING
240
+ this._setState(DeploymentState.SWITCHING, { service: serviceName, generation: next.id }, options.onProgress);
241
+
242
+ const publicPort = options.publicPort || config.port;
243
+ if (publicPort) {
244
+ await this.router.listen(publicPort, '0.0.0.0', serviceName);
245
+ }
246
+
247
+ this.router.switchBackend(serviceName, next, publicPort);
248
+ this.generationManager.markActive(next);
249
+
250
+ // 6. STABILIZING
251
+ this._setState(DeploymentState.STABILIZING, { service: serviceName, generation: next.id }, options.onProgress);
252
+
253
+ const stabilizationMs =
254
+ options.stabilizationWindowMs !== undefined ? options.stabilizationWindowMs : this.defaultStabilizationMs;
255
+
256
+ if (stabilizationMs > 0) {
257
+ const checkInterval = 50;
258
+ const stabStart = Date.now();
259
+ while (Date.now() - stabStart < stabilizationMs) {
260
+ if (!this.processManager.isAlive(next.pid)) {
261
+ // New generation crashed during stabilization window! Revert to previous!
262
+ if (previous && this.processManager.isAlive(previous.pid)) {
263
+ this.router.switchBackend(serviceName, previous, publicPort);
264
+ this.generationManager.markActive(previous);
265
+ }
266
+ this.generationManager.remove(next, true);
267
+ this._setState(
268
+ DeploymentState.FAILED,
269
+ {
270
+ service: serviceName,
271
+ error: `Generation #${next.id} crashed during stabilization window.`
272
+ },
273
+ options.onProgress
274
+ );
275
+ throw new Error(
276
+ `Generation #${next.id} crashed during stabilization window. Rolled back traffic to previous generation #${previous?.id || 'none'}.`
277
+ );
278
+ }
279
+ await new Promise((resolve) => setTimeout(resolve, checkInterval));
280
+ }
281
+ }
282
+
283
+ // 7. DRAINING
284
+ if (previous && previous.id !== next.id) {
285
+ this._setState(DeploymentState.DRAINING, { service: serviceName, generation: previous.id }, options.onProgress);
286
+
287
+ this.generationManager.markDraining(previous);
288
+ const drainTimeout = options.drainTimeout || config.deploy?.drainTimeout || 10000;
289
+ await this.drainManager.drain(previous, { timeout: drainTimeout, stopProcess: false });
290
+ }
291
+
292
+ // 8. STOPPING_PREVIOUS
293
+ if (previous && previous.id !== next.id) {
294
+ this._setState(
295
+ DeploymentState.STOPPING_PREVIOUS,
296
+ { service: serviceName, generation: previous.id },
297
+ options.onProgress
298
+ );
299
+
300
+ await this.processManager.stop(previous.pid, { timeout: 3000 });
301
+ this.generationManager.remove(previous, false);
302
+ }
303
+
304
+ // 9. COMPLETE
305
+ const result = {
306
+ service: serviceName,
307
+ previousGeneration: previous?.id ?? null,
308
+ currentGeneration: next.id,
309
+ downtimeMs: 0,
310
+ status: 'complete'
311
+ };
312
+
313
+ this._setState(DeploymentState.COMPLETE, result, options.onProgress);
314
+ return result;
315
+ } finally {
316
+ await this.lock.release(serviceName);
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Promotes an existing canary generation to 100% active primary and drains previous.
322
+ *
323
+ * @param {string} serviceName
324
+ * @param {object} [config]
325
+ * @param {object} [options]
326
+ * @returns {Promise<object>}
327
+ */
328
+ async promote(serviceName, config = {}, options = {}) {
329
+ this._ensurePrimitives(serviceName, options);
330
+
331
+ const ipcClient = new IPCClient(serviceName, options.ipcDir);
332
+ if (await ipcClient.isAlive()) {
333
+ return ipcClient.send({ action: 'promote', options }, options.onProgress);
334
+ }
335
+
336
+ await this.lock.acquire(serviceName, { timeout: options.lockTimeout || 10000 });
337
+
338
+ try {
339
+ const canaryGen = this.generationManager.getCanary(serviceName);
340
+ if (!canaryGen) {
341
+ throw new Error(`No canary generation found for service "${serviceName}" to promote.`);
342
+ }
343
+
344
+ const previous = this.generationManager.getActive(serviceName);
345
+
346
+ this._setState(
347
+ DeploymentState.PROMOTING,
348
+ { service: serviceName, generation: canaryGen.id, previousGeneration: previous?.id },
349
+ options.onProgress
350
+ );
351
+
352
+ // 1. Promote in Router (clears canary, makes it primary)
353
+ this.router.promoteCanary(serviceName);
354
+ this.generationManager.markActive(canaryGen);
355
+
356
+ // 2. Drain previous generation
357
+ if (previous && previous.id !== canaryGen.id) {
358
+ this._setState(DeploymentState.DRAINING, { service: serviceName, generation: previous.id }, options.onProgress);
359
+ this.generationManager.markDraining(previous);
360
+ const drainTimeout = options.drainTimeout || config.deploy?.drainTimeout || 10000;
361
+ await this.drainManager.drain(previous, { timeout: drainTimeout, stopProcess: false });
362
+
363
+ this._setState(
364
+ DeploymentState.STOPPING_PREVIOUS,
365
+ { service: serviceName, generation: previous.id },
366
+ options.onProgress
367
+ );
368
+ await this.processManager.stop(previous.pid, { timeout: 3000 });
369
+ this.generationManager.remove(previous, false);
370
+ }
371
+
372
+ const result = {
373
+ service: serviceName,
374
+ promotedGeneration: canaryGen.id,
375
+ previousGeneration: previous?.id ?? null,
376
+ downtimeMs: 0,
377
+ status: 'promoted'
378
+ };
379
+
380
+ this._setState(DeploymentState.COMPLETE, result, options.onProgress);
381
+ return result;
382
+ } finally {
383
+ await this.lock.release(serviceName);
384
+ }
385
+ }
386
+ }
387
+
388
+ export const defaultDeploymentManager = new DeploymentManager();
@@ -0,0 +1,9 @@
1
+ export {
2
+ DeploymentManager,
3
+ DeploymentState,
4
+ defaultDeploymentManager
5
+ } from './deployment-manager.js';
6
+ export {
7
+ RollbackManager,
8
+ defaultRollbackManager
9
+ } from './rollback-manager.js';