233 lines
7.2 KiB
JavaScript
Raw Permalink Normal View History

/**
* GMP 模块卸载器
* 工单编号: GH-GMP-004
* 开发者: 培园A04 (5TH-LE-HK-A04)
* 职责: 安全停止模块 · 备份数据 · 移除模块文件 · 注销注册
*
* 卸载流程:
* 1. 检查模块是否存在
* 2. 停止模块进程 (如果有)
* 3. 备份模块数据到归档目录
* 4. 移除模块文件
* 5. 从installedModules注销
* 6. 清理残留
*/
const path = require('path');
const fs = require('fs');
const { execFileSync } = require('child_process');
const { createLogger } = require('./lib/logger');
const { sanitizeModuleName, assertWithinBase } = require('./lib/path-guard');
const logger = createLogger('uninstaller');
class ModuleUninstaller {
constructor(config, installedModules) {
this.config = config;
this.installedModules = installedModules;
}
/**
* 卸载模块
* @param {Object} opts
* @param {string} opts.moduleName - 模块名称
* @param {boolean} opts.keepBackup - 是否保留备份 (默认true)
* @param {boolean} opts.force - 是否强制卸载 (跳过停止步骤)
* @returns {Object} 卸载结果
*/
async uninstall({ moduleName, keepBackup, force }) {
// 安全防线 1: 模块名白名单 + path.basename 净化 (CodeQL 公认 sanitizer)
const safeName = sanitizeModuleName(moduleName);
const startTime = Date.now();
const shouldBackup = keepBackup !== false; // 默认保留备份
logger.info('[Uninstaller] 开始卸载模块: ' + safeName + ' backup=' + shouldBackup + ' force=' + !!force);
// Step 1: 检查模块是否存在
const moduleInfo = this.installedModules.get(safeName);
if (!moduleInfo) {
throw new Error('模块未注册: ' + safeName);
}
const moduleDir = moduleInfo.path;
// 安全防线 2: resolve 后校验模块路径在 modulesDir 之内 (防路径穿越)
assertWithinBase(this.config.modulesDir, moduleDir);
if (!fs.existsSync(moduleDir)) {
// 文件不存在但注册表有记录 → 清理注册
this.installedModules.delete(safeName);
logger.warn('[Uninstaller] 模块目录不存在, 已清理注册: ' + safeName);
return { status: 'cleaned', module: safeName, note: '目录不存在·仅清理注册' };
}
try {
// Step 2: 停止模块进程
if (!force) {
logger.info('[Uninstaller] Step 1: 停止模块进程...');
await this._stopModule(safeName, moduleDir);
}
// Step 3: 备份
let backupPath = null;
if (shouldBackup) {
logger.info('[Uninstaller] Step 2: 备份模块...');
backupPath = this._backupModule(safeName, moduleDir);
}
// Step 4: 移除模块文件
logger.info('[Uninstaller] Step 3: 移除模块文件...');
this._removeModule(moduleDir);
// Step 5: 注销
this.installedModules.delete(safeName);
logger.info('[Uninstaller] Step 4: 模块已注销');
const duration = Date.now() - startTime;
logger.info('[Uninstaller] 模块卸载成功: ' + safeName + ' 耗时=' + duration + 'ms');
return {
status: 'uninstalled',
module: safeName,
duration: duration,
backupPath: backupPath,
backupKept: shouldBackup
};
} catch (err) {
const duration = Date.now() - startTime;
logger.error('[Uninstaller] 卸载失败: ' + safeName + ' -> ' + err.message + ' 耗时=' + duration + 'ms');
throw err;
}
}
/**
* 停止模块进程
* 约定: 模块如果有运行中的进程, 通过pidfile或pm2管理
*/
async _stopModule(moduleName, moduleDir) {
// 方式1: 检查pidfile
const pidFile = path.join(moduleDir, '.pid');
if (fs.existsSync(pidFile)) {
try {
const pid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
if (pid && !isNaN(pid)) {
process.kill(pid, 'SIGTERM');
logger.info('[Uninstaller] 已发送SIGTERM到PID: ' + pid);
// 等待进程退出
await this._waitForExit(pid, 5000);
}
} catch (err) {
if (err.code === 'ESRCH') {
logger.info('[Uninstaller] 进程已不存在 (PID文件过期)');
} else {
logger.warn('[Uninstaller] 停止进程警告: ' + err.message);
}
}
}
// 方式2: 尝试pm2 stop (如果使用PM2管理)
// moduleName 已在 uninstall() 入口经过白名单校验, 此处用 execFileSync 数组形式传参
try {
execFileSync('pm2', ['stop', moduleName], {
stdio: 'pipe',
timeout: 10000
});
} catch (err) {
// PM2不可用或模块不在PM2中, 忽略
}
// 方式3: 检查模块自带的stop脚本
const stopScript = path.join(moduleDir, 'stop.sh');
if (fs.existsSync(stopScript)) {
try {
execFileSync('bash', ['stop.sh'], {
cwd: moduleDir,
stdio: 'pipe',
timeout: 15000
});
logger.info('[Uninstaller] 已执行模块stop脚本');
} catch (err) {
logger.warn('[Uninstaller] stop脚本执行警告 (exit ' + (err.status || 'unknown') + ')');
}
}
}
/**
* 等待进程退出
*/
_waitForExit(pid, timeout) {
return new Promise((resolve) => {
const start = Date.now();
const check = () => {
try {
process.kill(pid, 0); // 检查进程是否存在
if (Date.now() - start > timeout) {
logger.warn('[Uninstaller] 进程未在超时内退出, PID=' + pid);
resolve();
} else {
setTimeout(check, 500);
}
} catch (err) {
// 进程已退出
logger.info('[Uninstaller] 进程已退出, PID=' + pid);
resolve();
}
};
check();
});
}
/**
* 备份模块到归档目录
*/
_backupModule(moduleName, moduleDir) {
// moduleName 已在 uninstall() 入口经过白名单校验
const archiveDir = path.join(this.config.modulesDir, '.archive');
if (!fs.existsSync(archiveDir)) {
fs.mkdirSync(archiveDir, { recursive: true });
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupDir = path.join(archiveDir, moduleName + '_' + timestamp);
// 路径校验: 备份目录必须在 archiveDir 之内
assertWithinBase(archiveDir, backupDir);
this._copyDir(moduleDir, backupDir);
logger.info('[Uninstaller] 模块已备份: ' + backupDir);
return backupDir;
}
/**
* 移除模块目录
*/
_removeModule(moduleDir) {
try {
fs.rmSync(moduleDir, { recursive: true, force: true });
logger.info('[Uninstaller] 模块目录已移除: ' + moduleDir);
} catch (err) {
throw new Error('移除模块目录失败: ' + err.message);
}
}
/**
* 递归复制目录 (用于备份)
*/
_copyDir(src, dest) {
fs.mkdirSync(dest, { recursive: true });
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name === '.git') continue;
this._copyDir(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
}
module.exports = { ModuleUninstaller };