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