unitup 0.1.5 → 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/CHANGELOG.md +5 -3
- package/README.md +89 -8
- package/biome.json +0 -1
- package/docs/index.html +188 -234
- package/index.d.ts +467 -440
- package/package.json +9 -3
- package/src/cli.js +561 -103
- 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/doctor.js +123 -56
- package/src/errors.js +131 -0
- package/src/index.js +186 -59
- package/src/logs.js +148 -0
- package/src/platform/base.js +211 -0
- package/src/platform/index.js +43 -0
- package/src/platform/linux.js +155 -0
- package/src/platform/macos.js +726 -0
- package/src/platform/windows-host.js +246 -0
- package/src/platform/windows.js +498 -0
- package/src/runtimes/bun.js +1 -1
- package/src/runtimes/common.js +24 -6
- package/src/runtimes/deno.js +1 -1
- package/src/runtimes/elixir.js +1 -1
- package/src/runtimes/go.js +1 -1
- package/src/runtimes/index.js +7 -7
- package/src/runtimes/native.js +2 -4
- package/src/runtimes/node.js +1 -1
- package/src/runtimes/php.js +1 -1
- package/src/runtimes/python.js +1 -1
- package/src/runtimes/ruby.js +1 -1
- package/src/runtimes/shell.js +1 -1
- package/src/schedule.js +31 -24
- package/src/service/config.js +63 -0
- package/src/service/manager.js +233 -0
- package/src/service/normalize.js +245 -0
- package/src/systemd.js +84 -38
- package/src/unit.js +17 -19
- package/src/utils.js +112 -18
- package/tsconfig.json +1 -1
- package/types-test/index.ts +158 -0
|
@@ -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
|
+
}
|
|
@@ -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();
|