@yunsoft/yuncms 0.1.2 → 0.1.5
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/README.md +16 -3
- package/package.json +3 -3
- package/src/backup-command.js +72 -0
- package/src/backup-integrity.js +118 -0
- package/src/cli.js +24 -7
- package/src/command-options.js +57 -0
- package/src/database-backup.js +336 -0
- package/src/database-reset.js +44 -0
- package/src/maintenance-lock.js +104 -0
- package/src/process-runner.js +184 -0
- package/src/project-backup.js +564 -0
- package/src/restore-command.js +75 -0
- package/src/runtime-probe.js +165 -0
- package/src/service-state.js +37 -0
- package/src/start-command.js +4 -0
- package/src/update-command.js +282 -0
- package/src/update-lock.js +68 -0
- package/src/update-preflight.js +418 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closeDatabasePool,
|
|
3
|
+
createDatabasePool,
|
|
4
|
+
quoteIdentifier,
|
|
5
|
+
} from '@yunsoft/yuncms-core';
|
|
6
|
+
|
|
7
|
+
export async function resetDatabaseObjects({
|
|
8
|
+
config,
|
|
9
|
+
createPool = createDatabasePool,
|
|
10
|
+
closePool = closeDatabasePool,
|
|
11
|
+
} = {}) {
|
|
12
|
+
if (!config?.database) throw new Error('Database config is required');
|
|
13
|
+
|
|
14
|
+
const pool = createPool(config);
|
|
15
|
+
const connection = await pool.getConnection();
|
|
16
|
+
try {
|
|
17
|
+
const [rows] = await connection.query(
|
|
18
|
+
`SELECT table_name, table_type
|
|
19
|
+
FROM information_schema.tables
|
|
20
|
+
WHERE table_schema = ?
|
|
21
|
+
ORDER BY CASE WHEN table_type = 'VIEW' THEN 0 ELSE 1 END, table_name ASC`,
|
|
22
|
+
[config.database],
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
await connection.query('SET FOREIGN_KEY_CHECKS = 0');
|
|
26
|
+
try {
|
|
27
|
+
for (const row of rows) {
|
|
28
|
+
const name = quoteIdentifier(row.table_name, 'database object name');
|
|
29
|
+
if (row.table_type === 'VIEW') {
|
|
30
|
+
await connection.query(`DROP VIEW IF EXISTS ${name}`);
|
|
31
|
+
} else {
|
|
32
|
+
await connection.query(`DROP TABLE IF EXISTS ${name}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
} finally {
|
|
36
|
+
await connection.query('SET FOREIGN_KEY_CHECKS = 1').catch(() => {});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return rows.length;
|
|
40
|
+
} finally {
|
|
41
|
+
connection.release();
|
|
42
|
+
await closePool(pool);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
closeDatabasePool,
|
|
5
|
+
createDatabasePool,
|
|
6
|
+
loadConfig,
|
|
7
|
+
} from '@yunsoft/yuncms-core';
|
|
8
|
+
|
|
9
|
+
function lockName(databaseName) {
|
|
10
|
+
const digest = createHash('sha256').update(String(databaseName)).digest('hex').slice(0, 32);
|
|
11
|
+
return `yuncms:maintenance:${digest}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function databaseMaintenanceLockName(databaseName) {
|
|
15
|
+
return lockName(databaseName);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function acquireDatabaseMaintenanceLock({
|
|
19
|
+
env = process.env,
|
|
20
|
+
timeoutSeconds = 0,
|
|
21
|
+
createPool = createDatabasePool,
|
|
22
|
+
closePool = closeDatabasePool,
|
|
23
|
+
} = {}) {
|
|
24
|
+
if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 0 || timeoutSeconds > 300) {
|
|
25
|
+
const error = new Error('Maintenance lock timeout must be an integer between 0 and 300 seconds');
|
|
26
|
+
error.code = 'MAINTENANCE_LOCK_TIMEOUT_INVALID';
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const config = loadConfig(env);
|
|
31
|
+
const pool = createPool(config.database);
|
|
32
|
+
let connection;
|
|
33
|
+
try {
|
|
34
|
+
connection = await pool.getConnection();
|
|
35
|
+
} catch (error) {
|
|
36
|
+
await closePool(pool).catch(() => {});
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const name = lockName(config.database.database);
|
|
41
|
+
let acquired = false;
|
|
42
|
+
let connectionId = null;
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const [rows] = await connection.query(
|
|
46
|
+
'SELECT GET_LOCK(?, ?) AS acquired, CONNECTION_ID() AS connection_id',
|
|
47
|
+
[name, timeoutSeconds],
|
|
48
|
+
);
|
|
49
|
+
acquired = Number(rows?.[0]?.acquired) === 1;
|
|
50
|
+
connectionId = Number(rows?.[0]?.connection_id);
|
|
51
|
+
if (!acquired || !Number.isInteger(connectionId) || connectionId <= 0) {
|
|
52
|
+
const error = new Error(
|
|
53
|
+
`Another YunCMS maintenance operation is using database ${config.database.database}`,
|
|
54
|
+
);
|
|
55
|
+
error.code = 'DATABASE_MAINTENANCE_LOCK_UNAVAILABLE';
|
|
56
|
+
error.database = config.database.database;
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
} catch (error) {
|
|
60
|
+
connection.release();
|
|
61
|
+
await closePool(pool).catch(() => {});
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let released = false;
|
|
66
|
+
return {
|
|
67
|
+
name,
|
|
68
|
+
database: config.database.database,
|
|
69
|
+
async assertHeld() {
|
|
70
|
+
if (released) {
|
|
71
|
+
const error = new Error('Database maintenance lock has already been released');
|
|
72
|
+
error.code = 'DATABASE_MAINTENANCE_LOCK_LOST';
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
let rows;
|
|
76
|
+
try {
|
|
77
|
+
[rows] = await connection.query('SELECT IS_USED_LOCK(?) AS connection_id', [name]);
|
|
78
|
+
} catch (cause) {
|
|
79
|
+
const error = new Error(`Database maintenance lock connection was lost for ${config.database.database}`);
|
|
80
|
+
error.code = 'DATABASE_MAINTENANCE_LOCK_LOST';
|
|
81
|
+
error.database = config.database.database;
|
|
82
|
+
error.cause = cause;
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
if (Number(rows?.[0]?.connection_id) !== connectionId) {
|
|
86
|
+
const error = new Error(`Database maintenance lock is no longer held for ${config.database.database}`);
|
|
87
|
+
error.code = 'DATABASE_MAINTENANCE_LOCK_LOST';
|
|
88
|
+
error.database = config.database.database;
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
return true;
|
|
92
|
+
},
|
|
93
|
+
async release() {
|
|
94
|
+
if (released) return;
|
|
95
|
+
released = true;
|
|
96
|
+
try {
|
|
97
|
+
if (acquired) await connection.query('SELECT RELEASE_LOCK(?) AS released', [name]);
|
|
98
|
+
} finally {
|
|
99
|
+
connection.release();
|
|
100
|
+
await closePool(pool);
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024;
|
|
4
|
+
export const DEFAULT_COMMAND_TIMEOUT_MS = 15 * 60 * 1000;
|
|
5
|
+
const MAX_COMMAND_TIMEOUT_MS = 4 * 60 * 60 * 1000;
|
|
6
|
+
const DEFAULT_KILL_GRACE_MS = 5_000;
|
|
7
|
+
|
|
8
|
+
function appendBounded(current, chunk, maxBytes) {
|
|
9
|
+
if (Buffer.byteLength(current) >= maxBytes) return current;
|
|
10
|
+
const remaining = maxBytes - Buffer.byteLength(current);
|
|
11
|
+
const value = Buffer.from(String(chunk));
|
|
12
|
+
return current + value.subarray(0, remaining).toString('utf8');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function positiveInteger(value, code, label, { max = Number.MAX_SAFE_INTEGER } = {}) {
|
|
16
|
+
const parsed = Number(value);
|
|
17
|
+
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > max) {
|
|
18
|
+
const error = new Error(`${label} must be an integer between 1 and ${max}`);
|
|
19
|
+
error.code = code;
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
return parsed;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function resolveCommandTimeoutMs(env = process.env, explicitTimeoutMs = null) {
|
|
26
|
+
const value = explicitTimeoutMs ?? env.YUNCMS_CLI_COMMAND_TIMEOUT_MS ?? DEFAULT_COMMAND_TIMEOUT_MS;
|
|
27
|
+
return positiveInteger(
|
|
28
|
+
value,
|
|
29
|
+
'COMMAND_TIMEOUT_INVALID',
|
|
30
|
+
'YunCMS CLI command timeout',
|
|
31
|
+
{ max: MAX_COMMAND_TIMEOUT_MS },
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function timeoutError(command, args, timeoutMs, stdout, stderr) {
|
|
36
|
+
const error = new Error(`${command} ${args.join(' ')} exceeded the ${timeoutMs}ms YunCMS CLI command timeout`);
|
|
37
|
+
error.code = 'COMMAND_TIMEOUT';
|
|
38
|
+
error.command = command;
|
|
39
|
+
error.args = [...args];
|
|
40
|
+
error.timeoutMs = timeoutMs;
|
|
41
|
+
error.stdout = stdout.trim();
|
|
42
|
+
error.stderr = stderr.trim();
|
|
43
|
+
return error;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function spawnCapturedProcess(spawnProcess, command, args, options) {
|
|
47
|
+
try {
|
|
48
|
+
return spawnProcess(command, args, options);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
error.code ||= 'COMMAND_START_FAILED';
|
|
51
|
+
error.command = command;
|
|
52
|
+
error.args = [...args];
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function runCapturedProcess(command, args = [], {
|
|
58
|
+
cwd = process.cwd(),
|
|
59
|
+
env = process.env,
|
|
60
|
+
spawnProcess = spawn,
|
|
61
|
+
maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES,
|
|
62
|
+
timeoutMs = null,
|
|
63
|
+
killGraceMs = DEFAULT_KILL_GRACE_MS,
|
|
64
|
+
} = {}) {
|
|
65
|
+
const resolvedTimeoutMs = resolveCommandTimeoutMs(env, timeoutMs);
|
|
66
|
+
const resolvedKillGraceMs = positiveInteger(
|
|
67
|
+
killGraceMs,
|
|
68
|
+
'COMMAND_KILL_GRACE_INVALID',
|
|
69
|
+
'Command kill grace period',
|
|
70
|
+
{ max: 60_000 },
|
|
71
|
+
);
|
|
72
|
+
const resolvedMaxOutputBytes = positiveInteger(
|
|
73
|
+
maxOutputBytes,
|
|
74
|
+
'COMMAND_MAX_OUTPUT_INVALID',
|
|
75
|
+
'Command output limit',
|
|
76
|
+
{ max: 16 * 1024 * 1024 },
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const child = spawnCapturedProcess(
|
|
80
|
+
spawnProcess,
|
|
81
|
+
command,
|
|
82
|
+
args,
|
|
83
|
+
{
|
|
84
|
+
cwd,
|
|
85
|
+
env: { ...env },
|
|
86
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
87
|
+
},
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
let stdout = '';
|
|
91
|
+
let stderr = '';
|
|
92
|
+
child.stdout?.setEncoding?.('utf8');
|
|
93
|
+
child.stderr?.setEncoding?.('utf8');
|
|
94
|
+
child.stdout?.on?.('data', (chunk) => {
|
|
95
|
+
stdout = appendBounded(stdout, chunk, resolvedMaxOutputBytes);
|
|
96
|
+
});
|
|
97
|
+
child.stderr?.on?.('data', (chunk) => {
|
|
98
|
+
stderr = appendBounded(stderr, chunk, resolvedMaxOutputBytes);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return new Promise((resolve, reject) => {
|
|
102
|
+
let settled = false;
|
|
103
|
+
let timedOut = false;
|
|
104
|
+
let timeoutHandle = null;
|
|
105
|
+
let forceKillHandle = null;
|
|
106
|
+
let forceRejectHandle = null;
|
|
107
|
+
|
|
108
|
+
function clearTimers() {
|
|
109
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
110
|
+
if (forceKillHandle) clearTimeout(forceKillHandle);
|
|
111
|
+
if (forceRejectHandle) clearTimeout(forceRejectHandle);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function finish(callback) {
|
|
115
|
+
if (settled) return;
|
|
116
|
+
settled = true;
|
|
117
|
+
clearTimers();
|
|
118
|
+
callback();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function requestKill(signal) {
|
|
122
|
+
try {
|
|
123
|
+
child.kill?.(signal);
|
|
124
|
+
} catch {
|
|
125
|
+
// The timeout path still rejects even when the child handle cannot be signalled.
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
timeoutHandle = setTimeout(() => {
|
|
130
|
+
if (settled) return;
|
|
131
|
+
timedOut = true;
|
|
132
|
+
requestKill('SIGTERM');
|
|
133
|
+
forceKillHandle = setTimeout(() => {
|
|
134
|
+
if (settled) return;
|
|
135
|
+
requestKill('SIGKILL');
|
|
136
|
+
forceRejectHandle = setTimeout(() => {
|
|
137
|
+
finish(() => reject(timeoutError(command, args, resolvedTimeoutMs, stdout, stderr)));
|
|
138
|
+
}, resolvedKillGraceMs);
|
|
139
|
+
}, resolvedKillGraceMs);
|
|
140
|
+
}, resolvedTimeoutMs);
|
|
141
|
+
|
|
142
|
+
child.once('error', (error) => {
|
|
143
|
+
finish(() => {
|
|
144
|
+
if (timedOut) {
|
|
145
|
+
reject(timeoutError(command, args, resolvedTimeoutMs, stdout, stderr));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
error.code ||= 'COMMAND_START_FAILED';
|
|
149
|
+
error.command = command;
|
|
150
|
+
error.args = [...args];
|
|
151
|
+
reject(error);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
child.once('exit', (code, signal) => {
|
|
156
|
+
finish(() => {
|
|
157
|
+
if (timedOut) {
|
|
158
|
+
const error = timeoutError(command, args, resolvedTimeoutMs, stdout, stderr);
|
|
159
|
+
error.exitCode = code;
|
|
160
|
+
error.signal = signal;
|
|
161
|
+
reject(error);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (code === 0) {
|
|
165
|
+
resolve({ stdout: stdout.trim(), stderr: stderr.trim(), code: 0, signal: null });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const detail = stderr.trim() || stdout.trim();
|
|
170
|
+
const error = new Error(
|
|
171
|
+
`${command} ${args.join(' ')} failed${signal ? ` with signal ${signal}` : ` with exit code ${code}`}${detail ? `: ${detail}` : ''}`,
|
|
172
|
+
);
|
|
173
|
+
error.code = 'COMMAND_FAILED';
|
|
174
|
+
error.command = command;
|
|
175
|
+
error.args = [...args];
|
|
176
|
+
error.exitCode = code;
|
|
177
|
+
error.signal = signal;
|
|
178
|
+
error.stdout = stdout.trim();
|
|
179
|
+
error.stderr = stderr.trim();
|
|
180
|
+
reject(error);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
}
|