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.
- package/CHANGELOG.md +17 -0
- package/index.d.ts +183 -0
- package/package.json +5 -3
- 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 +244 -0
- package/src/core/router.js +507 -0
- package/src/core/supervisor.js +222 -0
- package/src/deploy/deployment-manager.js +388 -0
- package/src/deploy/index.js +9 -0
- package/src/deploy/rollback-manager.js +187 -0
- package/src/index.js +54 -0
- package/src/platform/macos.js +4 -1
- package/src/platform/windows-host.js +1 -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 +29 -3
- package/src/utils.js +80 -0
- package/SECURITY.md +0 -21
- package/biome.json +0 -44
- package/docs/index.html +0 -587
- package/tsconfig.json +0 -13
- package/types-test/index.ts +0 -158
|
@@ -0,0 +1,187 @@
|
|
|
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
|
+
const publicPort = options.publicPort || config.port;
|
|
155
|
+
this.router.switchBackend(serviceName, previous, publicPort);
|
|
156
|
+
this.generationManager.markActive(previous);
|
|
157
|
+
|
|
158
|
+
// Drain and stop the rolled-back current generation
|
|
159
|
+
if (current && current.id !== previous.id) {
|
|
160
|
+
if (options.onProgress) {
|
|
161
|
+
options.onProgress({ state: 'DRAINING_CURRENT', generation: current.id });
|
|
162
|
+
}
|
|
163
|
+
this.generationManager.markDraining(current);
|
|
164
|
+
const drainTimeout = options.drainTimeout || config.deploy?.drainTimeout || 10000;
|
|
165
|
+
await this.drainManager.drain(current, { timeout: drainTimeout, stopProcess: true });
|
|
166
|
+
this.generationManager.remove(current, false);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const result = {
|
|
170
|
+
service: serviceName,
|
|
171
|
+
rolledBackFrom: current?.id ?? null,
|
|
172
|
+
activeGeneration: previous.id,
|
|
173
|
+
status: 'rolled_back'
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
if (options.onProgress) {
|
|
177
|
+
options.onProgress({ state: 'COMPLETE', ...result });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return result;
|
|
181
|
+
} finally {
|
|
182
|
+
await this.lock.release(serviceName);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export const defaultRollbackManager = new RollbackManager();
|
package/src/index.js
CHANGED
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DeploymentLock,
|
|
3
|
+
DeploymentLockError,
|
|
4
|
+
DrainManager,
|
|
5
|
+
GenerationManager,
|
|
6
|
+
IPCClient,
|
|
7
|
+
IPCServer,
|
|
8
|
+
PortMismatchError,
|
|
9
|
+
ProcessManager,
|
|
10
|
+
ProcessStartupError,
|
|
11
|
+
ReadinessChecker,
|
|
12
|
+
ReadinessTimeoutError,
|
|
13
|
+
Router,
|
|
14
|
+
Supervisor,
|
|
15
|
+
findFreePort
|
|
16
|
+
} from './core/index.js';
|
|
17
|
+
import {
|
|
18
|
+
DeploymentManager,
|
|
19
|
+
DeploymentState,
|
|
20
|
+
RollbackManager,
|
|
21
|
+
defaultDeploymentManager,
|
|
22
|
+
defaultRollbackManager
|
|
23
|
+
} from './deploy/index.js';
|
|
1
24
|
import { getDoctorInfo, runDoctor } from './doctor.js';
|
|
2
25
|
import {
|
|
3
26
|
ExecutableNotFoundError,
|
|
@@ -73,6 +96,7 @@ import {
|
|
|
73
96
|
} from './unit.js';
|
|
74
97
|
import {
|
|
75
98
|
findProjectConfig,
|
|
99
|
+
formatDuration,
|
|
76
100
|
formatMemoryBytes,
|
|
77
101
|
getAppMetadataPath,
|
|
78
102
|
getAppsDir,
|
|
@@ -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,
|
package/src/platform/macos.js
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
deleteAppMetadata,
|
|
14
14
|
formatRelativeTime,
|
|
15
15
|
getAppsDir,
|
|
16
|
+
getUnitupBinPath,
|
|
16
17
|
getUnitupDir,
|
|
17
18
|
readAppMetadata,
|
|
18
19
|
sanitizeServiceName,
|
|
@@ -175,7 +176,9 @@ export class MacOSAdapter extends ServiceAdapter {
|
|
|
175
176
|
const safeName = sanitizeServiceName(config.name);
|
|
176
177
|
const label = this.getLabel(safeName);
|
|
177
178
|
|
|
178
|
-
const programArguments =
|
|
179
|
+
const programArguments = config.deploy?.zeroDowntime
|
|
180
|
+
? [process.execPath, getUnitupBinPath(), 'supervisor', safeName]
|
|
181
|
+
: [config.command, ...(config.args || [])];
|
|
179
182
|
|
|
180
183
|
// Ensure log directory exists
|
|
181
184
|
const stdoutLog = config.logs?.stdout || path.join(getUnitupDir(), 'logs', `${safeName}.log`);
|
package/src/platform/windows.js
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
deleteAppMetadata,
|
|
15
15
|
formatRelativeTime,
|
|
16
16
|
getAppsDir,
|
|
17
|
+
getUnitupBinPath,
|
|
17
18
|
getUnitupDir,
|
|
18
19
|
readAppMetadata,
|
|
19
20
|
sanitizeServiceName,
|
|
@@ -75,7 +76,9 @@ export class WindowsAdapter extends ServiceAdapter {
|
|
|
75
76
|
const safeName = sanitizeServiceName(config.name);
|
|
76
77
|
const serviceName = this.getServiceName(safeName);
|
|
77
78
|
const nodeExec = process.execPath;
|
|
78
|
-
const
|
|
79
|
+
const hostScript = config.deploy?.zeroDowntime ? getUnitupBinPath() : WINDOWS_HOST_PATH;
|
|
80
|
+
const hostArg = config.deploy?.zeroDowntime ? `supervisor "${safeName}"` : `"${safeName}"`;
|
|
81
|
+
const binPath = `"${nodeExec}" "${hostScript}" ${hostArg}`;
|
|
79
82
|
const startType = config.autostart !== false ? 'auto' : 'demand';
|
|
80
83
|
|
|
81
84
|
return {
|
package/src/service/config.js
CHANGED
|
@@ -48,6 +48,14 @@ export function mergeConfigWithOptions(opts = {}) {
|
|
|
48
48
|
memoryHigh: opts.memoryHigh || projectCfg?.resources?.memoryHigh || projectCfg?.memoryHigh || '',
|
|
49
49
|
memoryMax: opts.memoryMax || projectCfg?.resources?.memoryMax || projectCfg?.memoryMax || '',
|
|
50
50
|
memorySwapMax: opts.memorySwapMax || projectCfg?.resources?.memorySwapMax || projectCfg?.memorySwapMax || '',
|
|
51
|
+
port: opts.port !== undefined ? opts.port : projectCfg.port,
|
|
52
|
+
deploy: {
|
|
53
|
+
...(projectCfg.deploy && typeof projectCfg.deploy === 'object' ? projectCfg.deploy : {}),
|
|
54
|
+
...(opts.deploy && typeof opts.deploy === 'object' ? opts.deploy : {}),
|
|
55
|
+
...(opts.zeroDowntime !== undefined ? { zeroDowntime: !!opts.zeroDowntime } : {}),
|
|
56
|
+
...(opts.ready !== undefined ? { ready: opts.ready } : {}),
|
|
57
|
+
...(opts.drain !== undefined ? { drain: opts.drain } : {})
|
|
58
|
+
},
|
|
51
59
|
...opts,
|
|
52
60
|
env: { ...(projectCfg.env || {}), ...(opts.env || {}) },
|
|
53
61
|
cwd: targetCwd
|
package/src/service/normalize.js
CHANGED
|
@@ -2,7 +2,13 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { ExecutableNotFoundError, InvalidServiceConfigError } from '../errors.js';
|
|
4
4
|
import { detectRuntime, resolveRuntimeConfig } from '../runtimes/index.js';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
getUnitupDir,
|
|
7
|
+
parseDurationMs,
|
|
8
|
+
resolveEffectiveMemoryLimits,
|
|
9
|
+
resolveWorkingDirectory,
|
|
10
|
+
sanitizeServiceName
|
|
11
|
+
} from '../utils.js';
|
|
6
12
|
|
|
7
13
|
/**
|
|
8
14
|
* Resolves a binary or command name to an absolute executable path.
|
|
@@ -187,6 +193,27 @@ export async function normalizeServiceConfig(rawOpts = {}) {
|
|
|
187
193
|
// Memory and resource limits
|
|
188
194
|
const resources = resolveEffectiveMemoryLimits(rawOpts);
|
|
189
195
|
|
|
196
|
+
// Port and Deploy normalization
|
|
197
|
+
const port =
|
|
198
|
+
rawOpts.port !== undefined && rawOpts.port !== null && rawOpts.port !== '' ? Number(rawOpts.port) : undefined;
|
|
199
|
+
|
|
200
|
+
const rawDeploy = rawOpts.deploy && typeof rawOpts.deploy === 'object' ? rawOpts.deploy : {};
|
|
201
|
+
const zeroDowntime = !!(rawOpts.deploy === true || rawDeploy.zeroDowntime || rawOpts.zeroDowntime);
|
|
202
|
+
const readyPath =
|
|
203
|
+
typeof rawDeploy.ready === 'string'
|
|
204
|
+
? rawDeploy.ready
|
|
205
|
+
: typeof rawOpts.ready === 'string'
|
|
206
|
+
? rawOpts.ready
|
|
207
|
+
: undefined;
|
|
208
|
+
const drainRaw = rawDeploy.drain !== undefined ? rawDeploy.drain : rawOpts.drain;
|
|
209
|
+
const drainTimeout = drainRaw !== undefined ? parseDurationMs(drainRaw) : 10000;
|
|
210
|
+
const deploy = {
|
|
211
|
+
zeroDowntime,
|
|
212
|
+
ready: readyPath,
|
|
213
|
+
drain: drainRaw,
|
|
214
|
+
drainTimeout
|
|
215
|
+
};
|
|
216
|
+
|
|
190
217
|
return {
|
|
191
218
|
name,
|
|
192
219
|
displayName,
|
|
@@ -196,6 +223,8 @@ export async function normalizeServiceConfig(rawOpts = {}) {
|
|
|
196
223
|
args,
|
|
197
224
|
script: rawOpts.script ? path.resolve(cwd, rawOpts.script) : undefined,
|
|
198
225
|
cwd,
|
|
226
|
+
port,
|
|
227
|
+
deploy,
|
|
199
228
|
env,
|
|
200
229
|
envFile: rawOpts.envFile ? path.resolve(cwd, rawOpts.envFile) : undefined,
|
|
201
230
|
autostart,
|
package/src/unit.js
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
getServiceNameFromUnit,
|
|
10
10
|
getTimerFilename,
|
|
11
11
|
getUnitFilename,
|
|
12
|
+
getUnitupBinPath,
|
|
12
13
|
resolveAbsolutePath,
|
|
13
14
|
resolveEffectiveMemoryLimits,
|
|
14
15
|
resolveWorkingDirectory,
|
|
@@ -73,7 +74,10 @@ export function generateUnitContent(opts) {
|
|
|
73
74
|
let commandExec = '';
|
|
74
75
|
let execArgs = [];
|
|
75
76
|
|
|
76
|
-
if (opts.
|
|
77
|
+
if (opts.deploy?.zeroDowntime) {
|
|
78
|
+
commandExec = process.execPath;
|
|
79
|
+
execArgs = [getUnitupBinPath(), 'supervisor', safeName];
|
|
80
|
+
} else if (opts.command) {
|
|
77
81
|
commandExec = resolveAbsolutePath(opts.command);
|
|
78
82
|
execArgs = Array.isArray(opts.args) ? [...opts.args] : [];
|
|
79
83
|
} else if (opts.script) {
|
|
@@ -95,6 +99,10 @@ export function generateUnitContent(opts) {
|
|
|
95
99
|
.filter((p, i, self) => p && self.indexOf(p) === i)
|
|
96
100
|
.join(':');
|
|
97
101
|
|
|
102
|
+
const logLevelMax = opts.logLevelMax || 'info';
|
|
103
|
+
const logRateLimitIntervalSec = opts.logRateLimitIntervalSec || '10s';
|
|
104
|
+
const logRateLimitBurst = opts.logRateLimitBurst !== undefined ? opts.logRateLimitBurst : 200;
|
|
105
|
+
|
|
98
106
|
const serviceLines = [
|
|
99
107
|
'[Unit]',
|
|
100
108
|
`Description=unitup service: ${safeName}`,
|
|
@@ -108,7 +116,10 @@ export function generateUnitContent(opts) {
|
|
|
108
116
|
`Restart=${restartPolicy}`,
|
|
109
117
|
'RestartSec=3',
|
|
110
118
|
'StandardOutput=journal',
|
|
111
|
-
'StandardError=journal'
|
|
119
|
+
'StandardError=journal',
|
|
120
|
+
`LogLevelMax=${logLevelMax}`,
|
|
121
|
+
`LogRateLimitIntervalSec=${logRateLimitIntervalSec}`,
|
|
122
|
+
`LogRateLimitBurst=${logRateLimitBurst}`
|
|
112
123
|
];
|
|
113
124
|
|
|
114
125
|
const effectiveLimits = resolveEffectiveMemoryLimits(opts);
|
|
@@ -169,6 +180,8 @@ export function writeUnitFile(opts) {
|
|
|
169
180
|
script: opts.script,
|
|
170
181
|
cwd: opts.cwd,
|
|
171
182
|
node: opts.nodePath,
|
|
183
|
+
port: opts.port,
|
|
184
|
+
deploy: opts.deploy,
|
|
172
185
|
memoryHigh: opts.memoryHigh || opts.resources?.memoryHigh,
|
|
173
186
|
memoryMax: opts.memoryMax || opts.resources?.memoryMax,
|
|
174
187
|
memorySwapMax: opts.memorySwapMax || opts.resources?.memorySwapMax,
|
|
@@ -259,6 +272,12 @@ export function parseUnitContent(content) {
|
|
|
259
272
|
result.memoryMax = trimmed.slice(10).trim();
|
|
260
273
|
} else if (trimmed.startsWith('MemorySwapMax=')) {
|
|
261
274
|
result.memorySwapMax = trimmed.slice(14).trim();
|
|
275
|
+
} else if (trimmed.startsWith('LogLevelMax=')) {
|
|
276
|
+
result.logLevelMax = trimmed.slice(12).trim();
|
|
277
|
+
} else if (trimmed.startsWith('LogRateLimitIntervalSec=')) {
|
|
278
|
+
result.logRateLimitIntervalSec = trimmed.slice(24).trim();
|
|
279
|
+
} else if (trimmed.startsWith('LogRateLimitBurst=')) {
|
|
280
|
+
result.logRateLimitBurst = Number.parseInt(trimmed.slice(18).trim(), 10);
|
|
262
281
|
}
|
|
263
282
|
}
|
|
264
283
|
|
|
@@ -316,6 +335,10 @@ export function generateScheduleServiceContent(opts) {
|
|
|
316
335
|
.filter((p, i, self) => p && self.indexOf(p) === i)
|
|
317
336
|
.join(':');
|
|
318
337
|
|
|
338
|
+
const logLevelMax = opts.logLevelMax || 'info';
|
|
339
|
+
const logRateLimitIntervalSec = opts.logRateLimitIntervalSec || '10s';
|
|
340
|
+
const logRateLimitBurst = opts.logRateLimitBurst !== undefined ? opts.logRateLimitBurst : 200;
|
|
341
|
+
|
|
319
342
|
const serviceLines = [
|
|
320
343
|
'[Unit]',
|
|
321
344
|
`Description=unitup scheduled task: ${safeName}`,
|
|
@@ -327,7 +350,10 @@ export function generateScheduleServiceContent(opts) {
|
|
|
327
350
|
`WorkingDirectory=${cwd}`,
|
|
328
351
|
`ExecStart=${execStartLine}`,
|
|
329
352
|
'StandardOutput=journal',
|
|
330
|
-
'StandardError=journal'
|
|
353
|
+
'StandardError=journal',
|
|
354
|
+
`LogLevelMax=${logLevelMax}`,
|
|
355
|
+
`LogRateLimitIntervalSec=${logRateLimitIntervalSec}`,
|
|
356
|
+
`LogRateLimitBurst=${logRateLimitBurst}`
|
|
331
357
|
];
|
|
332
358
|
|
|
333
359
|
const effectiveLimits = resolveEffectiveMemoryLimits(opts);
|
package/src/utils.js
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Returns the absolute path to the unitup CLI binary.
|
|
8
|
+
* @returns {string}
|
|
9
|
+
*/
|
|
10
|
+
export function getUnitupBinPath() {
|
|
11
|
+
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
return path.resolve(currentDir, '../bin/unitup.js');
|
|
13
|
+
}
|
|
4
14
|
|
|
5
15
|
/**
|
|
6
16
|
* Returns the base unitup config directory (~/.config/unitup).
|
|
@@ -170,6 +180,8 @@ export function saveAppMetadata(meta) {
|
|
|
170
180
|
args,
|
|
171
181
|
cwd,
|
|
172
182
|
group: meta.group || 'default',
|
|
183
|
+
...(meta.port !== undefined ? { port: meta.port } : {}),
|
|
184
|
+
...(meta.deploy ? { deploy: meta.deploy } : {}),
|
|
173
185
|
// Optional resources section
|
|
174
186
|
...(Object.keys(resources).length > 0 ? { resources } : {}),
|
|
175
187
|
// Backward compatibility fields
|
|
@@ -415,6 +427,32 @@ export function formatRelativeTime(timestamp) {
|
|
|
415
427
|
return `${days} day${days === 1 ? '' : 's'} ago`;
|
|
416
428
|
}
|
|
417
429
|
|
|
430
|
+
/**
|
|
431
|
+
* Formats duration in milliseconds into a concise human-readable string (e.g. "23s", "2 mins", "3 hours", "2 days").
|
|
432
|
+
*
|
|
433
|
+
* @param {number} ms
|
|
434
|
+
* @returns {string}
|
|
435
|
+
*/
|
|
436
|
+
export function formatDuration(ms) {
|
|
437
|
+
if (typeof ms !== 'number' || isNaN(ms) || ms < 0) {
|
|
438
|
+
return '-';
|
|
439
|
+
}
|
|
440
|
+
const seconds = Math.floor(ms / 1000);
|
|
441
|
+
if (seconds < 60) {
|
|
442
|
+
return `${seconds}s`;
|
|
443
|
+
}
|
|
444
|
+
const minutes = Math.floor(seconds / 60);
|
|
445
|
+
if (minutes < 60) {
|
|
446
|
+
return `${minutes} min${minutes === 1 ? '' : 's'}`;
|
|
447
|
+
}
|
|
448
|
+
const hours = Math.floor(minutes / 60);
|
|
449
|
+
if (hours < 24) {
|
|
450
|
+
return `${hours} hour${hours === 1 ? '' : 's'}`;
|
|
451
|
+
}
|
|
452
|
+
const days = Math.floor(hours / 24);
|
|
453
|
+
return `${days} day${days === 1 ? '' : 's'}`;
|
|
454
|
+
}
|
|
455
|
+
|
|
418
456
|
/**
|
|
419
457
|
* Formats data rows into a text table.
|
|
420
458
|
*
|
|
@@ -603,6 +641,48 @@ export function validateDuration(val, paramName = 'Duration') {
|
|
|
603
641
|
return str;
|
|
604
642
|
}
|
|
605
643
|
|
|
644
|
+
/**
|
|
645
|
+
* Parses a duration string (e.g. '30s', '500ms', '1m') into milliseconds.
|
|
646
|
+
* @param {string|number} val
|
|
647
|
+
* @param {number} [defaultMs=10000]
|
|
648
|
+
* @returns {number}
|
|
649
|
+
*/
|
|
650
|
+
export function parseDurationMs(val, defaultMs = 10000) {
|
|
651
|
+
if (typeof val === 'number') return val;
|
|
652
|
+
if (!val || typeof val !== 'string') return defaultMs;
|
|
653
|
+
const str = val.trim().toLowerCase();
|
|
654
|
+
const match = str.match(/^(\d+(?:\.\d+)?)\s*(ms|msec|s|sec|seconds?|m|min|minutes?|h|hr|hours?|d|day|days?)?$/);
|
|
655
|
+
if (!match) return defaultMs;
|
|
656
|
+
const num = Number.parseFloat(match[1]);
|
|
657
|
+
const unit = match[2] || 's';
|
|
658
|
+
switch (unit) {
|
|
659
|
+
case 'ms':
|
|
660
|
+
case 'msec':
|
|
661
|
+
return num;
|
|
662
|
+
case 's':
|
|
663
|
+
case 'sec':
|
|
664
|
+
case 'second':
|
|
665
|
+
case 'seconds':
|
|
666
|
+
return num * 1000;
|
|
667
|
+
case 'm':
|
|
668
|
+
case 'min':
|
|
669
|
+
case 'minute':
|
|
670
|
+
case 'minutes':
|
|
671
|
+
return num * 60 * 1000;
|
|
672
|
+
case 'h':
|
|
673
|
+
case 'hr':
|
|
674
|
+
case 'hour':
|
|
675
|
+
case 'hours':
|
|
676
|
+
return num * 60 * 60 * 1000;
|
|
677
|
+
case 'd':
|
|
678
|
+
case 'day':
|
|
679
|
+
case 'days':
|
|
680
|
+
return num * 24 * 60 * 60 * 1000;
|
|
681
|
+
default:
|
|
682
|
+
return num * 1000;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
606
686
|
/**
|
|
607
687
|
* Formats a future date/timestamp into relative human readable string (e.g. "in 12 minutes", "tomorrow").
|
|
608
688
|
* @param {string|number|Date} dateVal
|
package/SECURITY.md
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
# Security Policy
|
|
2
|
-
|
|
3
|
-
## Supported Versions
|
|
4
|
-
|
|
5
|
-
Use this section to tell people about which versions of your project are
|
|
6
|
-
currently being supported with security updates.
|
|
7
|
-
|
|
8
|
-
| Version | Supported |
|
|
9
|
-
| ------- | ------------------ |
|
|
10
|
-
| 5.1.x | :white_check_mark: |
|
|
11
|
-
| 5.0.x | :x: |
|
|
12
|
-
| 4.0.x | :white_check_mark: |
|
|
13
|
-
| < 4.0 | :x: |
|
|
14
|
-
|
|
15
|
-
## Reporting a Vulnerability
|
|
16
|
-
|
|
17
|
-
Use this section to tell people how to report a vulnerability.
|
|
18
|
-
|
|
19
|
-
Tell them where to go, how often they can expect to get an update on a
|
|
20
|
-
reported vulnerability, what to expect if the vulnerability is accepted or
|
|
21
|
-
declined, etc.
|
package/biome.json
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
|
3
|
-
"vcs": {
|
|
4
|
-
"enabled": true,
|
|
5
|
-
"clientKind": "git",
|
|
6
|
-
"useIgnoreFile": true
|
|
7
|
-
},
|
|
8
|
-
"files": {
|
|
9
|
-
"ignoreUnknown": true,
|
|
10
|
-
"ignore": ["node_modules", "dist", "coverage"]
|
|
11
|
-
},
|
|
12
|
-
"formatter": {
|
|
13
|
-
"enabled": true,
|
|
14
|
-
"indentStyle": "space",
|
|
15
|
-
"indentWidth": 2,
|
|
16
|
-
"lineWidth": 120
|
|
17
|
-
},
|
|
18
|
-
"organizeImports": {
|
|
19
|
-
"enabled": true
|
|
20
|
-
},
|
|
21
|
-
"linter": {
|
|
22
|
-
"enabled": true,
|
|
23
|
-
"rules": {
|
|
24
|
-
"recommended": true,
|
|
25
|
-
"style": {
|
|
26
|
-
"noUnusedTemplateLiteral": "off"
|
|
27
|
-
},
|
|
28
|
-
"complexity": {
|
|
29
|
-
"noForEach": "off"
|
|
30
|
-
},
|
|
31
|
-
"suspicious": {
|
|
32
|
-
"noEmptyBlockStatements": "off"
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
},
|
|
36
|
-
"javascript": {
|
|
37
|
-
"formatter": {
|
|
38
|
-
"quoteStyle": "single",
|
|
39
|
-
"jsxQuoteStyle": "double",
|
|
40
|
-
"trailingCommas": "none",
|
|
41
|
-
"semicolons": "always"
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
}
|