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,70 @@
1
+ /**
2
+ * DrainManager is responsible for gracefully retiring a generation.
3
+ * It tracks in-flight requests, waits for them to complete up to a timeout,
4
+ * and terminates the process gracefully.
5
+ *
6
+ * It works independently from DeploymentManager.
7
+ */
8
+ export class DrainManager {
9
+ /**
10
+ * @param {object} options
11
+ * @param {import('./router.js').Router} options.router
12
+ * @param {import('./process-manager.js').ProcessManager} options.processManager
13
+ * @param {number} [options.defaultDrainTimeout=10000]
14
+ */
15
+ constructor(options = {}) {
16
+ this.router = options.router;
17
+ this.processManager = options.processManager;
18
+ this.defaultDrainTimeout = options.defaultDrainTimeout || 10000;
19
+ }
20
+
21
+ /**
22
+ * Drains an existing generation: waits for active in-flight requests to finish,
23
+ * then stops the process gracefully.
24
+ *
25
+ * @param {object} generation - { id, pid, internalPort }
26
+ * @param {object} [options]
27
+ * @param {number} [options.timeout] - Max wait time in ms before termination
28
+ * @param {number} [options.pollInterval=50]
29
+ * @param {boolean} [options.stopProcess=true] - Whether to stop the process after draining
30
+ * @returns {Promise<{ drained: boolean, inFlightRemaining: number, timedOut: boolean }>}
31
+ */
32
+ async drain(generation, options = {}) {
33
+ if (!generation) {
34
+ return { drained: true, inFlightRemaining: 0, timedOut: false };
35
+ }
36
+
37
+ const timeout = options.timeout !== undefined ? options.timeout : this.defaultDrainTimeout;
38
+ const pollInterval = options.pollInterval !== undefined ? options.pollInterval : 50;
39
+ const shouldStop = options.stopProcess !== false;
40
+
41
+ const startTime = Date.now();
42
+ let timedOut = false;
43
+
44
+ // 1. Wait for in-flight requests on this generation to complete
45
+ if (this.router) {
46
+ while (this.router.getInFlightCount(generation.id) > 0) {
47
+ if (Date.now() - startTime >= timeout) {
48
+ timedOut = true;
49
+ break;
50
+ }
51
+ await new Promise((resolve) => setTimeout(resolve, pollInterval));
52
+ }
53
+ }
54
+
55
+ const inFlightRemaining = this.router ? this.router.getInFlightCount(generation.id) : 0;
56
+
57
+ // 2. Stop process if requested and process is still alive
58
+ if (shouldStop && generation.pid && this.processManager && this.processManager.isAlive(generation.pid)) {
59
+ const elapsed = Date.now() - startTime;
60
+ const remainingTimeout = Math.max(1000, timeout - elapsed);
61
+ await this.processManager.stop(generation.pid, { timeout: remainingTimeout });
62
+ }
63
+
64
+ return {
65
+ drained: inFlightRemaining === 0,
66
+ inFlightRemaining,
67
+ timedOut
68
+ };
69
+ }
70
+ }
@@ -0,0 +1,414 @@
1
+ import fs from 'node:fs';
2
+ import net from 'node:net';
3
+ import path from 'node:path';
4
+ import { getUnitupDir, sanitizeServiceName } from '../utils.js';
5
+
6
+ /**
7
+ * Finds an available TCP port on 127.0.0.1.
8
+ * @returns {Promise<number>}
9
+ */
10
+ export function findFreePort() {
11
+ return new Promise((resolve, reject) => {
12
+ const srv = net.createServer();
13
+ srv.unref();
14
+ srv.on('error', reject);
15
+ srv.listen(0, '127.0.0.1', () => {
16
+ const port = srv.address().port;
17
+ srv.close(() => resolve(port));
18
+ });
19
+ });
20
+ }
21
+
22
+ /**
23
+ * GenerationManager manages the runtime instances (generations) and persistent state
24
+ * of Unitup-managed services.
25
+ */
26
+ export class GenerationManager {
27
+ /**
28
+ * @param {object} options
29
+ * @param {import('./process-manager.js').ProcessManager} options.processManager
30
+ * @param {string} [options.stateDir]
31
+ */
32
+ constructor(options = {}) {
33
+ this.processManager = options.processManager;
34
+ this.stateDir = options.stateDir || path.join(getUnitupDir(), 'state');
35
+ this.cache = new Map(); // serviceName -> state object
36
+ }
37
+
38
+ /**
39
+ * Returns the path to the state file for a service.
40
+ * @param {string} serviceName
41
+ * @returns {string}
42
+ */
43
+ getStateFilePath(serviceName) {
44
+ const safeName = sanitizeServiceName(serviceName);
45
+ return path.join(this.stateDir, `${safeName}.json`);
46
+ }
47
+
48
+ /**
49
+ * Loads state from disk or returns default structure.
50
+ *
51
+ * @param {string} serviceName
52
+ * @returns {object}
53
+ */
54
+ loadState(serviceName) {
55
+ const safeName = sanitizeServiceName(serviceName);
56
+ if (this.cache.has(safeName)) {
57
+ return this.cache.get(safeName);
58
+ }
59
+
60
+ const filePath = this.getStateFilePath(safeName);
61
+ let state = {
62
+ service: safeName,
63
+ activeGeneration: null,
64
+ generations: {}
65
+ };
66
+
67
+ if (fs.existsSync(filePath)) {
68
+ try {
69
+ const raw = fs.readFileSync(filePath, 'utf8');
70
+ const parsed = JSON.parse(raw);
71
+ if (parsed && typeof parsed === 'object') {
72
+ state = {
73
+ service: safeName,
74
+ activeGeneration: parsed.activeGeneration ?? null,
75
+ generations: parsed.generations && typeof parsed.generations === 'object' ? parsed.generations : {}
76
+ };
77
+ }
78
+ } catch {
79
+ // Fallback to empty state on read error
80
+ }
81
+ }
82
+
83
+ this.cache.set(safeName, state);
84
+ return state;
85
+ }
86
+
87
+ /**
88
+ * Persists state atomically to disk.
89
+ *
90
+ * @param {string} serviceName
91
+ * @param {object} [stateToSave]
92
+ */
93
+ saveState(serviceName, stateToSave) {
94
+ const safeName = sanitizeServiceName(serviceName);
95
+ const state = stateToSave || this.loadState(safeName);
96
+ this.cache.set(safeName, state);
97
+
98
+ if (!fs.existsSync(this.stateDir)) {
99
+ fs.mkdirSync(this.stateDir, { recursive: true });
100
+ }
101
+
102
+ const filePath = this.getStateFilePath(safeName);
103
+ const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
104
+
105
+ fs.writeFileSync(tmpPath, JSON.stringify(state, null, 2), 'utf8');
106
+ fs.renameSync(tmpPath, filePath);
107
+ }
108
+
109
+ /**
110
+ * Creates and starts a new generation for a service.
111
+ *
112
+ * @param {string} serviceName
113
+ * @param {object} config - Service configuration or launch options
114
+ * @param {string} config.command
115
+ * @param {string[]} [config.args]
116
+ * @param {string} [config.cwd]
117
+ * @param {object} [config.env]
118
+ * @param {object} [options]
119
+ * @param {number} [options.port] - Optional specific internal port
120
+ * @returns {Promise<object>} The new Generation object
121
+ */
122
+ async create(serviceName, config = {}, options = {}) {
123
+ const safeName = sanitizeServiceName(serviceName);
124
+ const state = this.loadState(safeName);
125
+
126
+ // Compute next generation ID
127
+ const existingIds = Object.keys(state.generations)
128
+ .map(Number)
129
+ .filter((n) => !Number.isNaN(n));
130
+ const nextId = existingIds.length > 0 ? Math.max(...existingIds) + 1 : 1;
131
+
132
+ // Allocate internal port
133
+ const internalPort = options.port || (await findFreePort());
134
+
135
+ // Prepare child environment with PORT and standard port aliases set to internalPort
136
+ const env = {
137
+ ...(config.env || {}),
138
+ PORT: String(internalPort),
139
+ NODE_PORT: String(internalPort),
140
+ HTTP_PORT: String(internalPort),
141
+ APP_PORT: String(internalPort),
142
+ SERVER_PORT: String(internalPort),
143
+ UNITUP_INTERNAL_PORT: String(internalPort),
144
+ UNITUP_PUBLIC_PORT: String(config.port || options.publicPort || ''),
145
+ UNITUP_GENERATION_ID: String(nextId),
146
+ UNITUP_SERVICE_NAME: safeName
147
+ };
148
+
149
+ let stdoutPath = config.logs?.stdout;
150
+ let stderrPath = config.logs?.stderr;
151
+ if (stdoutPath && stdoutPath.includes('[generation]')) {
152
+ stdoutPath = stdoutPath.replace('[generation]', String(nextId));
153
+ }
154
+ if (stderrPath && stderrPath.includes('[generation]')) {
155
+ stderrPath = stderrPath.replace('[generation]', String(nextId));
156
+ }
157
+
158
+ const proc = this.processManager.start(config.command, config.args || [], {
159
+ cwd: config.cwd,
160
+ env,
161
+ stdoutPath,
162
+ stderrPath
163
+ });
164
+
165
+ const generation = {
166
+ id: nextId,
167
+ service: safeName,
168
+ pid: proc.pid,
169
+ internalPort,
170
+ status: 'starting',
171
+ createdAt: new Date().toISOString()
172
+ };
173
+
174
+ state.generations[String(nextId)] = generation;
175
+ this.saveState(safeName, state);
176
+
177
+ return generation;
178
+ }
179
+
180
+ /**
181
+ * Gets the currently active generation for a service.
182
+ *
183
+ * @param {string} serviceName
184
+ * @returns {object|null}
185
+ */
186
+ getActive(serviceName) {
187
+ const safeName = sanitizeServiceName(serviceName);
188
+ const state = this.loadState(safeName);
189
+ if (!state.activeGeneration) return null;
190
+
191
+ const gen = state.generations[String(state.activeGeneration)];
192
+ if (!gen || gen.status !== 'active') return null;
193
+ return gen;
194
+ }
195
+
196
+ /**
197
+ * Gets the previous generation (e.g. for rollback).
198
+ *
199
+ * @param {string} serviceName
200
+ * @returns {object|null}
201
+ */
202
+ getPrevious(serviceName) {
203
+ const safeName = sanitizeServiceName(serviceName);
204
+ const state = this.loadState(safeName);
205
+
206
+ // Look for generation with status 'previous'
207
+ for (const gen of Object.values(state.generations)) {
208
+ if (gen.status === 'previous') {
209
+ return gen;
210
+ }
211
+ }
212
+
213
+ // Fallback: highest ID that is not current activeGeneration
214
+ const candidates = Object.values(state.generations)
215
+ .filter((g) => g.id !== state.activeGeneration && g.status !== 'stopped')
216
+ .sort((a, b) => b.id - a.id);
217
+
218
+ return candidates[0] || null;
219
+ }
220
+
221
+ /**
222
+ * Gets a specific generation by ID.
223
+ *
224
+ * @param {string} serviceName
225
+ * @param {number|string} id
226
+ * @returns {object|null}
227
+ */
228
+ getGeneration(serviceName, id) {
229
+ const safeName = sanitizeServiceName(serviceName);
230
+ const state = this.loadState(safeName);
231
+ return state.generations[String(id)] || null;
232
+ }
233
+
234
+ /**
235
+ * Marks a generation as active and demotes existing active generation to 'previous'.
236
+ *
237
+ * @param {object} generation
238
+ */
239
+ markActive(generation) {
240
+ if (!generation || !generation.service) return;
241
+ const safeName = sanitizeServiceName(generation.service);
242
+ const state = this.loadState(safeName);
243
+
244
+ if (state.activeGeneration && state.activeGeneration !== generation.id) {
245
+ const prev = state.generations[String(state.activeGeneration)];
246
+ if (prev && prev.status === 'active') {
247
+ prev.status = 'previous';
248
+ prev.deactivatedAt = new Date().toISOString();
249
+ }
250
+ }
251
+
252
+ if (state.canaryGeneration === generation.id) {
253
+ state.canaryGeneration = null;
254
+ }
255
+
256
+ let current = state.generations[String(generation.id)];
257
+ if (!current) {
258
+ current = { ...generation };
259
+ state.generations[String(generation.id)] = current;
260
+ }
261
+ current.status = 'active';
262
+ current.activatedAt = new Date().toISOString();
263
+ state.activeGeneration = generation.id;
264
+ this.saveState(safeName, state);
265
+ }
266
+
267
+ /**
268
+ * Marks a generation as canary with a traffic weight.
269
+ *
270
+ * @param {object} generation
271
+ * @param {number|string} [weight=0.1]
272
+ */
273
+ markCanary(generation, weight = 0.1) {
274
+ if (!generation || !generation.service) return;
275
+ const safeName = sanitizeServiceName(generation.service);
276
+ const state = this.loadState(safeName);
277
+
278
+ let current = state.generations[String(generation.id)];
279
+ if (!current) {
280
+ current = { ...generation };
281
+ state.generations[String(generation.id)] = current;
282
+ }
283
+
284
+ const parsed = typeof weight === 'string' ? Number.parseFloat(weight.replace('%', '')) : Number(weight);
285
+ const num = Number.isNaN(parsed) ? 0.1 : parsed;
286
+ const normalizedWeight = num > 1 ? num / 100 : num;
287
+
288
+ current.status = 'canary';
289
+ current.canaryWeight = Math.max(0, Math.min(1, normalizedWeight));
290
+ current.canaryAt = new Date().toISOString();
291
+ state.canaryGeneration = generation.id;
292
+ this.saveState(safeName, state);
293
+ }
294
+
295
+ /**
296
+ * Gets the active canary generation for a service, if any.
297
+ *
298
+ * @param {string} serviceName
299
+ * @returns {object|null}
300
+ */
301
+ getCanary(serviceName) {
302
+ const safeName = sanitizeServiceName(serviceName);
303
+ const state = this.loadState(safeName);
304
+ if (!state.canaryGeneration) {
305
+ for (const g of Object.values(state.generations)) {
306
+ if (g.status === 'canary') return g;
307
+ }
308
+ return null;
309
+ }
310
+ const gen = state.generations[String(state.canaryGeneration)];
311
+ return gen && gen.status === 'canary' ? gen : null;
312
+ }
313
+
314
+ /**
315
+ * Marks a generation as draining.
316
+ *
317
+ * @param {object} generation
318
+ */
319
+ markDraining(generation) {
320
+ if (!generation || !generation.service) return;
321
+ const safeName = sanitizeServiceName(generation.service);
322
+ const state = this.loadState(safeName);
323
+
324
+ const current = state.generations[String(generation.id)];
325
+ if (current) {
326
+ current.status = 'draining';
327
+ current.drainingAt = new Date().toISOString();
328
+ if (!current.deactivatedAt && current.activatedAt) {
329
+ current.deactivatedAt = current.drainingAt;
330
+ }
331
+ this.saveState(safeName, state);
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Removes or marks stopped an old generation.
337
+ *
338
+ * @param {object} generation
339
+ * @param {boolean} [purge=false]
340
+ */
341
+ remove(generation, purge = false) {
342
+ if (!generation || !generation.service) return;
343
+ const safeName = sanitizeServiceName(generation.service);
344
+ const state = this.loadState(safeName);
345
+
346
+ if (state.canaryGeneration === generation.id) {
347
+ state.canaryGeneration = null;
348
+ }
349
+
350
+ if (purge) {
351
+ delete state.generations[String(generation.id)];
352
+ if (state.activeGeneration === generation.id) {
353
+ state.activeGeneration = null;
354
+ }
355
+ } else {
356
+ const current = state.generations[String(generation.id)];
357
+ if (current) {
358
+ current.status = 'stopped';
359
+ current.stoppedAt = new Date().toISOString();
360
+ if (!current.deactivatedAt && current.activatedAt) {
361
+ current.deactivatedAt = current.stoppedAt;
362
+ }
363
+ }
364
+ }
365
+
366
+ this.saveState(safeName, state);
367
+ }
368
+
369
+ /**
370
+ * Scans state for dead processes and cleans up stale records.
371
+ *
372
+ * @param {string} serviceName
373
+ * @returns {number} Count of cleaned up generations
374
+ */
375
+ cleanupStale(serviceName) {
376
+ const safeName = sanitizeServiceName(serviceName);
377
+ const state = this.loadState(safeName);
378
+ let cleaned = 0;
379
+
380
+ for (const [id, gen] of Object.entries(state.generations)) {
381
+ if (gen.status !== 'stopped' && gen.pid) {
382
+ if (!this.processManager.isAlive(gen.pid)) {
383
+ gen.status = 'stopped';
384
+ gen.stoppedAt = new Date().toISOString();
385
+ if (!gen.deactivatedAt && gen.activatedAt) {
386
+ gen.deactivatedAt = gen.stoppedAt;
387
+ }
388
+ if (state.activeGeneration === gen.id) {
389
+ state.activeGeneration = null;
390
+ }
391
+ cleaned++;
392
+ }
393
+ }
394
+ }
395
+
396
+ if (cleaned > 0) {
397
+ this.saveState(safeName, state);
398
+ }
399
+
400
+ return cleaned;
401
+ }
402
+
403
+ /**
404
+ * Lists all generations for a service.
405
+ *
406
+ * @param {string} serviceName
407
+ * @returns {object[]}
408
+ */
409
+ listGenerations(serviceName) {
410
+ const safeName = sanitizeServiceName(serviceName);
411
+ const state = this.loadState(safeName);
412
+ return Object.values(state.generations).sort((a, b) => a.id - b.id);
413
+ }
414
+ }
@@ -0,0 +1,8 @@
1
+ export { ProcessManager } from './process-manager.js';
2
+ export { GenerationManager, findFreePort } from './generation-manager.js';
3
+ export { Router } from './router.js';
4
+ export { ReadinessChecker, ReadinessTimeoutError, ProcessStartupError, PortMismatchError } from './readiness.js';
5
+ export { DrainManager } from './drain-manager.js';
6
+ export { DeploymentLock, DeploymentLockError } from './lock.js';
7
+ export { IPCServer, IPCClient, getIpcPath } from './ipc.js';
8
+ export { Supervisor } from './supervisor.js';