@yunsoft/yuncms 0.1.3 → 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,165 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { MAINTENANCE_BYPASS_ENV } from '@yunsoft/yuncms-core';
|
|
5
|
+
|
|
6
|
+
function delay(ms) {
|
|
7
|
+
return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function probeError(code, message, details = {}) {
|
|
11
|
+
const error = new Error(message);
|
|
12
|
+
error.code = code;
|
|
13
|
+
Object.assign(error, details);
|
|
14
|
+
return error;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function verifyInstalledRuntime({
|
|
18
|
+
cwd = process.cwd(),
|
|
19
|
+
env = process.env,
|
|
20
|
+
port,
|
|
21
|
+
maintenanceBypassToken = null,
|
|
22
|
+
fetchFn = globalThis.fetch,
|
|
23
|
+
spawnProcess = spawn,
|
|
24
|
+
timeoutMs = 15_000,
|
|
25
|
+
shutdownGraceMs = 3_000,
|
|
26
|
+
} = {}) {
|
|
27
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
28
|
+
throw probeError('UPDATE_PROBE_PORT_INVALID', `Invalid probe port: ${port}`);
|
|
29
|
+
}
|
|
30
|
+
if (typeof fetchFn !== 'function') {
|
|
31
|
+
throw probeError('UPDATE_PROBE_FETCH_UNAVAILABLE', 'Runtime probe requires fetch support');
|
|
32
|
+
}
|
|
33
|
+
if (maintenanceBypassToken !== null && (typeof maintenanceBypassToken !== 'string' || maintenanceBypassToken.length < 32)) {
|
|
34
|
+
throw probeError('UPDATE_PROBE_MAINTENANCE_TOKEN_INVALID', 'Runtime probe maintenance bypass token is invalid');
|
|
35
|
+
}
|
|
36
|
+
if (!Number.isInteger(shutdownGraceMs) || shutdownGraceMs < 1 || shutdownGraceMs > 30_000) {
|
|
37
|
+
throw probeError(
|
|
38
|
+
'UPDATE_PROBE_SHUTDOWN_GRACE_INVALID',
|
|
39
|
+
`Runtime probe shutdown grace must be an integer between 1 and 30000ms: ${shutdownGraceMs}`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const cliPath = resolve(cwd, 'node_modules', '@yunsoft', 'yuncms', 'bin', 'yuncms.js');
|
|
44
|
+
const origin = `http://127.0.0.1:${port}`;
|
|
45
|
+
const childEnv = {
|
|
46
|
+
...env,
|
|
47
|
+
HOST: '127.0.0.1',
|
|
48
|
+
PORT: String(port),
|
|
49
|
+
STUDIO_ORIGIN: origin,
|
|
50
|
+
AUTH_PUBLIC_URL: origin,
|
|
51
|
+
};
|
|
52
|
+
if (maintenanceBypassToken !== null) childEnv[MAINTENANCE_BYPASS_ENV] = maintenanceBypassToken;
|
|
53
|
+
|
|
54
|
+
const child = spawnProcess(process.execPath, [cliPath, 'start'], {
|
|
55
|
+
cwd,
|
|
56
|
+
env: childEnv,
|
|
57
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
let stderr = '';
|
|
61
|
+
child.stderr?.setEncoding?.('utf8');
|
|
62
|
+
child.stderr?.on?.('data', (chunk) => {
|
|
63
|
+
if (stderr.length < 64 * 1024) stderr += String(chunk).slice(0, (64 * 1024) - stderr.length);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
let settled = false;
|
|
67
|
+
let shutdownAttempted = false;
|
|
68
|
+
const exitPromise = new Promise((resolveExit, rejectExit) => {
|
|
69
|
+
child.once('error', (error) => {
|
|
70
|
+
if (settled) return;
|
|
71
|
+
settled = true;
|
|
72
|
+
error.code ||= 'UPDATE_PROBE_START_FAILED';
|
|
73
|
+
rejectExit(error);
|
|
74
|
+
});
|
|
75
|
+
child.once('exit', (code, signal) => {
|
|
76
|
+
if (settled) return;
|
|
77
|
+
settled = true;
|
|
78
|
+
resolveExit({ code, signal });
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
function requestKill(signal) {
|
|
83
|
+
try {
|
|
84
|
+
child.kill(signal);
|
|
85
|
+
} catch {
|
|
86
|
+
// The bounded shutdown path still escalates and reports a timeout.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function waitForExit() {
|
|
91
|
+
return new Promise((resolveWait, rejectWait) => {
|
|
92
|
+
const timeout = setTimeout(() => {
|
|
93
|
+
resolveWait({ exited: false, result: null });
|
|
94
|
+
}, shutdownGraceMs);
|
|
95
|
+
exitPromise.then(
|
|
96
|
+
(result) => {
|
|
97
|
+
clearTimeout(timeout);
|
|
98
|
+
resolveWait({ exited: true, result });
|
|
99
|
+
},
|
|
100
|
+
(error) => {
|
|
101
|
+
clearTimeout(timeout);
|
|
102
|
+
rejectWait(error);
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function stopProbe({ strict }) {
|
|
109
|
+
shutdownAttempted = true;
|
|
110
|
+
requestKill('SIGTERM');
|
|
111
|
+
let outcome = await waitForExit();
|
|
112
|
+
if (outcome.exited) return outcome.result;
|
|
113
|
+
|
|
114
|
+
requestKill('SIGKILL');
|
|
115
|
+
outcome = await waitForExit();
|
|
116
|
+
if (outcome.exited) return outcome.result;
|
|
117
|
+
if (!strict) return null;
|
|
118
|
+
throw probeError(
|
|
119
|
+
'UPDATE_PROBE_SHUTDOWN_TIMEOUT',
|
|
120
|
+
`Updated YunCMS did not stop within ${shutdownGraceMs * 2}ms after readiness`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const deadline = Date.now() + timeoutMs;
|
|
125
|
+
try {
|
|
126
|
+
while (Date.now() < deadline) {
|
|
127
|
+
const earlyExit = await Promise.race([
|
|
128
|
+
exitPromise.then((result) => ({ type: 'exit', result })),
|
|
129
|
+
delay(200).then(() => ({ type: 'tick' })),
|
|
130
|
+
]);
|
|
131
|
+
if (earlyExit.type === 'exit') {
|
|
132
|
+
throw probeError(
|
|
133
|
+
'UPDATE_PROBE_EXITED',
|
|
134
|
+
`Updated YunCMS exited before readiness${stderr.trim() ? `: ${stderr.trim()}` : ''}`,
|
|
135
|
+
earlyExit.result,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const response = await fetchFn(`${origin}/ready`, { signal: AbortSignal.timeout(1000) });
|
|
141
|
+
if (response.ok) {
|
|
142
|
+
const body = await response.json().catch(() => null);
|
|
143
|
+
if (body?.status === 'ready') {
|
|
144
|
+
const result = await stopProbe({ strict: true });
|
|
145
|
+
if (result.code !== 0 && result.signal !== 'SIGTERM') {
|
|
146
|
+
throw probeError('UPDATE_PROBE_SHUTDOWN_FAILED', 'Updated runtime did not stop cleanly', result);
|
|
147
|
+
}
|
|
148
|
+
return { ready: true, origin };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (error?.code?.startsWith?.('UPDATE_PROBE_')) throw error;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
throw probeError(
|
|
157
|
+
'UPDATE_PROBE_TIMEOUT',
|
|
158
|
+
`Updated YunCMS did not become ready within ${timeoutMs}ms${stderr.trim() ? `: ${stderr.trim()}` : ''}`,
|
|
159
|
+
);
|
|
160
|
+
} finally {
|
|
161
|
+
if (!settled && !shutdownAttempted) {
|
|
162
|
+
await stopProbe({ strict: false }).catch(() => null);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
function probeHost(host) {
|
|
2
|
+
const value = String(host ?? '').trim();
|
|
3
|
+
if (!value || value === '0.0.0.0' || value === '::' || value === '::0' || value === '[::]') {
|
|
4
|
+
return '127.0.0.1';
|
|
5
|
+
}
|
|
6
|
+
if (value.includes(':') && !value.startsWith('[')) return `[${value}]`;
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function localYunCmsHealthUrl({ host, port } = {}) {
|
|
11
|
+
return `http://${probeHost(host)}:${port}/health`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function isLocalYunCmsReachable({
|
|
15
|
+
host = '127.0.0.1',
|
|
16
|
+
port,
|
|
17
|
+
fetchFn = globalThis.fetch,
|
|
18
|
+
timeoutMs = 1200,
|
|
19
|
+
} = {}) {
|
|
20
|
+
if (typeof fetchFn !== 'function') return false;
|
|
21
|
+
try {
|
|
22
|
+
const response = await fetchFn(localYunCmsHealthUrl({ host, port }), {
|
|
23
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
24
|
+
});
|
|
25
|
+
return response.status >= 100 && response.status < 600;
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function assertYunCmsStopped(options = {}) {
|
|
32
|
+
const running = await isLocalYunCmsReachable(options);
|
|
33
|
+
if (!running) return true;
|
|
34
|
+
const error = new Error('YunCMS is currently reachable; stop the service supervisor before backup, restore or update');
|
|
35
|
+
error.code = 'UPDATE_APPLICATION_RUNNING';
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
package/src/start-command.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
3
|
|
|
4
|
+
import { assertMaintenanceStartupAllowed } from '@yunsoft/yuncms-core';
|
|
5
|
+
|
|
4
6
|
export async function runStartCommand({
|
|
5
7
|
env = process.env,
|
|
6
8
|
cwd = process.cwd(),
|
|
@@ -8,6 +10,8 @@ export async function runStartCommand({
|
|
|
8
10
|
spawnProcess = spawn,
|
|
9
11
|
signalSource = process,
|
|
10
12
|
} = {}) {
|
|
13
|
+
await assertMaintenanceStartupAllowed({ cwd, env });
|
|
14
|
+
|
|
11
15
|
const serverUrl = import.meta.resolve('@yunsoft/yuncms-api/server');
|
|
12
16
|
const serverPath = fileURLToPath(serverUrl);
|
|
13
17
|
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
|
|
3
|
+
import { loadConfig } from '@yunsoft/yuncms-core';
|
|
4
|
+
|
|
5
|
+
import { parseCommandOptions } from './command-options.js';
|
|
6
|
+
import { acquireDatabaseMaintenanceLock } from './maintenance-lock.js';
|
|
7
|
+
import {
|
|
8
|
+
createProjectBackup,
|
|
9
|
+
readBackupManifest,
|
|
10
|
+
restoreProjectBackup,
|
|
11
|
+
} from './project-backup.js';
|
|
12
|
+
import { runCapturedProcess } from './process-runner.js';
|
|
13
|
+
import { verifyInstalledRuntime } from './runtime-probe.js';
|
|
14
|
+
import { assertYunCmsStopped } from './service-state.js';
|
|
15
|
+
import {
|
|
16
|
+
assertUpdatePreflightReady,
|
|
17
|
+
collectUpdatePreflight,
|
|
18
|
+
} from './update-preflight.js';
|
|
19
|
+
import { acquireUpdateLock } from './update-lock.js';
|
|
20
|
+
|
|
21
|
+
function formatBytes(value) {
|
|
22
|
+
const bytes = Number(value) || 0;
|
|
23
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
24
|
+
const units = ['KB', 'MB', 'GB', 'TB'];
|
|
25
|
+
let current = bytes;
|
|
26
|
+
let unit = -1;
|
|
27
|
+
do {
|
|
28
|
+
current /= 1024;
|
|
29
|
+
unit += 1;
|
|
30
|
+
} while (current >= 1024 && unit < units.length - 1);
|
|
31
|
+
return `${current.toFixed(current >= 10 ? 1 : 2)} ${units[unit]}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function printPreflight(report, output) {
|
|
35
|
+
output.log?.(`YunCMS update: ${report.currentVersion} -> ${report.targetVersion}`);
|
|
36
|
+
output.log?.(`Database migrations: ${report.pendingMigrations.length > 0 ? report.pendingMigrations.join(', ') : 'none'}`);
|
|
37
|
+
output.log?.(`Estimated database size: ${formatBytes(report.databaseBytes)}`);
|
|
38
|
+
if (report.localBackupBytes != null) {
|
|
39
|
+
output.log?.(`Estimated local backup assets: ${formatBytes(report.localBackupBytes)}`);
|
|
40
|
+
}
|
|
41
|
+
output.log?.(`Free disk: ${formatBytes(report.freeDiskBytes)}`);
|
|
42
|
+
if (report.s3Configured) {
|
|
43
|
+
output.log?.(`S3 storage: ${report.s3Bucket} (provider-side object backup required)`);
|
|
44
|
+
}
|
|
45
|
+
if (report.blockers.length > 0) output.warn?.(`Preflight blockers: ${report.blockers.join(', ')}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function localCliPath(cwd) {
|
|
49
|
+
return resolve(cwd, 'node_modules', '@yunsoft', 'yuncms', 'bin', 'yuncms.js');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function dependencySaveArgs(dependencySection = 'dependencies') {
|
|
53
|
+
if (dependencySection === 'dependencies') return [];
|
|
54
|
+
if (dependencySection === 'devDependencies') return ['--save-dev'];
|
|
55
|
+
if (dependencySection === 'optionalDependencies') return ['--save-optional'];
|
|
56
|
+
const error = new Error(`Unsupported YunCMS dependency section: ${dependencySection}`);
|
|
57
|
+
error.code = 'UPDATE_DEPENDENCY_SECTION_INVALID';
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function assertMaintenanceLockContract(lock) {
|
|
62
|
+
if (!lock || typeof lock.assertHeld !== 'function' || typeof lock.release !== 'function') {
|
|
63
|
+
const error = new Error('Database maintenance lock implementation is invalid');
|
|
64
|
+
error.code = 'DATABASE_MAINTENANCE_LOCK_INVALID';
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
return lock;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function databaseStateRequiresAttention(report) {
|
|
71
|
+
return (report.pendingMigrations?.length ?? 0) > 0
|
|
72
|
+
|| (report.unknownAppliedMigrations?.length ?? 0) > 0
|
|
73
|
+
|| (report.migrationHistoryGap?.length ?? 0) > 0
|
|
74
|
+
|| (report.incompleteMigrationAttempts?.length ?? 0) > 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function installVersion({ cwd, env, targetVersion, dependencySection, runProcess }) {
|
|
78
|
+
return runProcess(
|
|
79
|
+
'npm',
|
|
80
|
+
[
|
|
81
|
+
'install',
|
|
82
|
+
'--save-exact',
|
|
83
|
+
...dependencySaveArgs(dependencySection),
|
|
84
|
+
'--no-audit',
|
|
85
|
+
'--no-fund',
|
|
86
|
+
`@yunsoft/yuncms@${targetVersion}`,
|
|
87
|
+
],
|
|
88
|
+
{ cwd, env },
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function bootstrapInstalledVersion({ cwd, env, runProcess }) {
|
|
93
|
+
return runProcess(process.execPath, [localCliPath(cwd), 'bootstrap'], { cwd, env });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function reinstallBackedUpDependencies({ cwd, env, manifest, runProcess }) {
|
|
97
|
+
const args = manifest.project.packageLock
|
|
98
|
+
? ['ci', '--no-audit', '--no-fund']
|
|
99
|
+
: ['install', '--no-audit', '--no-fund'];
|
|
100
|
+
return runProcess('npm', args, { cwd, env });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function rollbackFailure(originalError, rollbackError) {
|
|
104
|
+
const error = new Error(
|
|
105
|
+
`YunCMS update failed and automatic rollback also failed. Update error: ${originalError.message}. Rollback error: ${rollbackError.message}`,
|
|
106
|
+
);
|
|
107
|
+
error.code = 'UPDATE_ROLLBACK_FAILED';
|
|
108
|
+
error.updateError = originalError;
|
|
109
|
+
error.rollbackError = rollbackError;
|
|
110
|
+
return error;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function runUpdateCommand({
|
|
114
|
+
args = [],
|
|
115
|
+
cwd = process.cwd(),
|
|
116
|
+
env = process.env,
|
|
117
|
+
output = console,
|
|
118
|
+
runProcess = runCapturedProcess,
|
|
119
|
+
collectPreflight = collectUpdatePreflight,
|
|
120
|
+
createBackup = createProjectBackup,
|
|
121
|
+
restoreBackup = restoreProjectBackup,
|
|
122
|
+
verifyRuntime = verifyInstalledRuntime,
|
|
123
|
+
acquireLock = acquireUpdateLock,
|
|
124
|
+
acquireMaintenanceLock = acquireDatabaseMaintenanceLock,
|
|
125
|
+
assertStopped = assertYunCmsStopped,
|
|
126
|
+
fetchFn = globalThis.fetch,
|
|
127
|
+
} = {}) {
|
|
128
|
+
const { values } = parseCommandOptions(args, {
|
|
129
|
+
boolean: ['--dry-run', '--allow-unverified-s3'],
|
|
130
|
+
string: ['--to', '--backup-output'],
|
|
131
|
+
maxPositionals: 0,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const target = values['--to'] ?? 'latest';
|
|
135
|
+
const dryRun = values['--dry-run'] === true;
|
|
136
|
+
const allowUnverifiedS3 = values['--allow-unverified-s3'] === true;
|
|
137
|
+
const config = loadConfig(env);
|
|
138
|
+
const projectLock = dryRun ? null : await acquireLock({ cwd });
|
|
139
|
+
let maintenanceLock = null;
|
|
140
|
+
|
|
141
|
+
const assertServiceStopped = () => assertStopped({
|
|
142
|
+
host: config.server.host,
|
|
143
|
+
port: config.server.port,
|
|
144
|
+
fetchFn,
|
|
145
|
+
});
|
|
146
|
+
const assertMaintenanceHeld = async () => (
|
|
147
|
+
maintenanceLock ? maintenanceLock.assertHeld() : true
|
|
148
|
+
);
|
|
149
|
+
const maintenanceBypassToken = projectLock?.bypassToken ?? null;
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
if (!dryRun) {
|
|
153
|
+
maintenanceLock = assertMaintenanceLockContract(await acquireMaintenanceLock({ env }));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const report = await collectPreflight({
|
|
157
|
+
cwd,
|
|
158
|
+
env,
|
|
159
|
+
target,
|
|
160
|
+
allowUnverifiedS3,
|
|
161
|
+
runProcess,
|
|
162
|
+
fetchFn,
|
|
163
|
+
});
|
|
164
|
+
printPreflight(report, output);
|
|
165
|
+
|
|
166
|
+
if (report.upToDate && !databaseStateRequiresAttention(report)) {
|
|
167
|
+
output.log?.('YunCMS package and database are already on the requested version.');
|
|
168
|
+
return { changed: false, dryRun, report, backupPath: null };
|
|
169
|
+
}
|
|
170
|
+
if (dryRun) {
|
|
171
|
+
output.log?.(
|
|
172
|
+
report.blockers.length === 0
|
|
173
|
+
? 'Dry run passed; no changes were made.'
|
|
174
|
+
: 'Dry run found blockers; no changes were made.',
|
|
175
|
+
);
|
|
176
|
+
return { changed: false, dryRun: true, report, backupPath: null };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
assertUpdatePreflightReady(report);
|
|
180
|
+
await assertServiceStopped();
|
|
181
|
+
await assertMaintenanceHeld();
|
|
182
|
+
|
|
183
|
+
const backupPath = values['--backup-output']
|
|
184
|
+
? resolve(cwd, values['--backup-output'])
|
|
185
|
+
: null;
|
|
186
|
+
const backup = await createBackup({ cwd, env, output, backupPath });
|
|
187
|
+
await readBackupManifest(backup.backupPath);
|
|
188
|
+
await assertServiceStopped();
|
|
189
|
+
await assertMaintenanceHeld();
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
if (!report.upToDate) {
|
|
193
|
+
output.log?.(`Installing @yunsoft/yuncms@${report.targetVersion}`);
|
|
194
|
+
await installVersion({
|
|
195
|
+
cwd,
|
|
196
|
+
env,
|
|
197
|
+
targetVersion: report.targetVersion,
|
|
198
|
+
dependencySection: report.dependencySection,
|
|
199
|
+
runProcess,
|
|
200
|
+
});
|
|
201
|
+
} else {
|
|
202
|
+
output.log?.('YunCMS package already matches the target; skipping npm reinstall.');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
await assertServiceStopped();
|
|
206
|
+
await assertMaintenanceHeld();
|
|
207
|
+
|
|
208
|
+
output.log?.('Applying target database migrations');
|
|
209
|
+
await bootstrapInstalledVersion({ cwd, env, runProcess });
|
|
210
|
+
await assertServiceStopped();
|
|
211
|
+
await assertMaintenanceHeld();
|
|
212
|
+
|
|
213
|
+
output.log?.('Starting temporary readiness probe');
|
|
214
|
+
await verifyRuntime({
|
|
215
|
+
cwd,
|
|
216
|
+
env,
|
|
217
|
+
port: config.server.port,
|
|
218
|
+
maintenanceBypassToken,
|
|
219
|
+
fetchFn,
|
|
220
|
+
});
|
|
221
|
+
await assertServiceStopped();
|
|
222
|
+
await assertMaintenanceHeld();
|
|
223
|
+
|
|
224
|
+
output.log?.(`YunCMS update verified: ${report.currentVersion} -> ${report.targetVersion}`);
|
|
225
|
+
output.log?.('The verification process is stopped. Restart YunCMS through your normal service supervisor.');
|
|
226
|
+
return {
|
|
227
|
+
changed: true,
|
|
228
|
+
dryRun: false,
|
|
229
|
+
report,
|
|
230
|
+
backupPath: backup.backupPath,
|
|
231
|
+
rollbackPerformed: false,
|
|
232
|
+
};
|
|
233
|
+
} catch (updateError) {
|
|
234
|
+
output.warn?.(`Update failed; restoring backup ${backup.backupPath}`);
|
|
235
|
+
try {
|
|
236
|
+
const beforeDestructive = async () => {
|
|
237
|
+
await assertServiceStopped();
|
|
238
|
+
await assertMaintenanceHeld();
|
|
239
|
+
};
|
|
240
|
+
const restored = await restoreBackup({
|
|
241
|
+
backupPath: backup.backupPath,
|
|
242
|
+
cwd,
|
|
243
|
+
env,
|
|
244
|
+
output,
|
|
245
|
+
beforeDestructive,
|
|
246
|
+
});
|
|
247
|
+
await assertServiceStopped();
|
|
248
|
+
await assertMaintenanceHeld();
|
|
249
|
+
|
|
250
|
+
await reinstallBackedUpDependencies({
|
|
251
|
+
cwd,
|
|
252
|
+
env,
|
|
253
|
+
manifest: restored.manifest,
|
|
254
|
+
runProcess,
|
|
255
|
+
});
|
|
256
|
+
await assertServiceStopped();
|
|
257
|
+
await assertMaintenanceHeld();
|
|
258
|
+
|
|
259
|
+
await verifyRuntime({
|
|
260
|
+
cwd,
|
|
261
|
+
env,
|
|
262
|
+
port: config.server.port,
|
|
263
|
+
maintenanceBypassToken,
|
|
264
|
+
fetchFn,
|
|
265
|
+
});
|
|
266
|
+
await assertServiceStopped();
|
|
267
|
+
await assertMaintenanceHeld();
|
|
268
|
+
|
|
269
|
+
updateError.rollbackPerformed = true;
|
|
270
|
+
updateError.backupPath = backup.backupPath;
|
|
271
|
+
updateError.message = `${updateError.message} (automatic rollback completed successfully)`;
|
|
272
|
+
throw updateError;
|
|
273
|
+
} catch (rollbackError) {
|
|
274
|
+
if (rollbackError === updateError) throw updateError;
|
|
275
|
+
throw rollbackFailure(updateError, rollbackError);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
} finally {
|
|
279
|
+
if (maintenanceLock) await maintenanceLock.release();
|
|
280
|
+
if (projectLock) await projectLock.release();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { mkdir, open, rm } from 'node:fs/promises';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
hashMaintenanceBypassToken,
|
|
7
|
+
maintenanceLockPath,
|
|
8
|
+
} from '@yunsoft/yuncms-core';
|
|
9
|
+
|
|
10
|
+
export function updateLockPath(cwd = process.cwd()) {
|
|
11
|
+
return maintenanceLockPath(cwd);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function acquireUpdateLock({
|
|
15
|
+
cwd = process.cwd(),
|
|
16
|
+
now = new Date(),
|
|
17
|
+
pid = process.pid,
|
|
18
|
+
generateToken = () => randomBytes(32).toString('hex'),
|
|
19
|
+
} = {}) {
|
|
20
|
+
const path = updateLockPath(cwd);
|
|
21
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
22
|
+
|
|
23
|
+
let handle;
|
|
24
|
+
try {
|
|
25
|
+
handle = await open(path, 'wx', 0o600);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (error?.code === 'EEXIST') {
|
|
28
|
+
const locked = new Error(
|
|
29
|
+
`Another YunCMS backup/update/restore operation may already be running. Inspect and remove the stale lock only after verifying no operation is active: ${path}`,
|
|
30
|
+
);
|
|
31
|
+
locked.code = 'UPDATE_ALREADY_RUNNING';
|
|
32
|
+
locked.lockPath = path;
|
|
33
|
+
throw locked;
|
|
34
|
+
}
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let bypassToken;
|
|
39
|
+
try {
|
|
40
|
+
bypassToken = generateToken();
|
|
41
|
+
const bypassTokenHash = hashMaintenanceBypassToken(bypassToken);
|
|
42
|
+
await handle.writeFile(
|
|
43
|
+
`${JSON.stringify({
|
|
44
|
+
pid,
|
|
45
|
+
startedAt: now.toISOString(),
|
|
46
|
+
cwd: resolve(cwd),
|
|
47
|
+
bypassTokenHash,
|
|
48
|
+
}, null, 2)}\n`,
|
|
49
|
+
'utf8',
|
|
50
|
+
);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
await handle.close().catch(() => {});
|
|
53
|
+
await rm(path, { force: true }).catch(() => {});
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let released = false;
|
|
58
|
+
return {
|
|
59
|
+
path,
|
|
60
|
+
bypassToken,
|
|
61
|
+
async release() {
|
|
62
|
+
if (released) return;
|
|
63
|
+
released = true;
|
|
64
|
+
await handle.close().catch(() => {});
|
|
65
|
+
await rm(path, { force: true });
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|