Add admin upgrade package preflight

This commit is contained in:
FengLee
2026-05-10 00:18:03 +08:00
parent 70656562b1
commit 8ae28e030d
3 changed files with 79 additions and 18 deletions

View File

@@ -15,6 +15,7 @@ const stateRoot = path.resolve(
const jobId = requireArg(args, 'job-id');
const mode = requireArg(args, 'mode');
const dryRun = args['dry-run'] === 'true';
const packagePath = path.resolve(requireArg(args, 'package'));
const packageName = args['package-name'] || path.basename(packagePath);
const jobDir = path.join(stateRoot, 'jobs', jobId);
@@ -64,6 +65,17 @@ let state = readState() || {
main().catch(error => {
log(`fatal: ${error instanceof Error ? error.stack || error.message : String(error)}`);
if (dryRun) {
updateState({
status: 'failed',
step: 'preflight_failed',
progress: 100,
message: '升级包预检失败,请按错误信息调整升级包',
error: error instanceof Error ? error.message : '升级包预检异常退出',
finishedAt: new Date().toISOString(),
});
return;
}
rollbackAfterFailure(error instanceof Error ? error.message : '升级任务异常退出').catch(rollbackError => {
updateState({
status: 'rollback_failed',
@@ -85,7 +97,7 @@ async function main() {
message: '正在检查升级包与运行环境',
startedAt: state.startedAt || new Date().toISOString(),
});
logStep('开始升级任务', `任务 ${jobId} 使用${mode === 'hot' ? '热更新' : '冷更新'}模式,升级包 ${packageName}`);
logStep('开始升级任务', `任务 ${jobId} 使用${mode === 'hot' ? '热更新' : '冷更新'}模式,升级包 ${packageName}${dryRun ? ',仅执行预检' : ''}`);
if (mode !== 'hot' && mode !== 'cold') {
throw new Error('升级方式无效');
@@ -118,8 +130,23 @@ async function main() {
restartRequired: mode === 'cold' || validation.requiresRestart,
packageHash: sha256(packagePath),
changedFiles: files,
dryRun,
});
if (dryRun) {
logStep('预检完成', `升级包可用于${mode === 'hot' ? '热更新' : '冷更新'}${mode === 'cold' || validation.requiresRestart ? '需要重启平台' : '无需重启平台'}`);
updateState({
status: 'succeeded',
step: 'preflight_completed',
progress: 100,
message: `预检通过:共 ${files.length} 个文件,${mode === 'cold' || validation.requiresRestart ? '执行时需要重启平台' : '执行时无需重启平台'}`,
finishedAt: new Date().toISOString(),
restartRequired: mode === 'cold' || validation.requiresRestart,
dryRun: true,
});
return;
}
updateState({ step: 'backup_data', progress: 22, message: '正在创建数据库、存储与环境配置备份' });
logStep('创建数据备份', '开始备份数据库、存储目录和环境配置');
const backupFile = runCapture('bash', ['./scripts/backup-create.sh'], {

View File

@@ -37,6 +37,7 @@ type UpgradeJobState = {
updatedAt: string;
finishedAt?: string;
logs: string[];
dryRun?: boolean;
};
const MAX_PACKAGE_BYTES = 300 * 1024 * 1024;
@@ -74,6 +75,7 @@ export async function POST(request: NextRequest) {
const form = await request.formData();
const modeValue = String(form.get('mode') || '');
const mode = modeValue === 'hot' || modeValue === 'cold' ? modeValue : null;
const dryRun = String(form.get('dryRun') || '') === 'true';
if (!mode) {
return NextResponse.json({ error: '请选择热更新或冷更新' }, { status: 400 });
}
@@ -116,10 +118,14 @@ export async function POST(request: NextRequest) {
startedAt: now,
updatedAt: now,
logs: [`[${now}] 上传升级包 ${file.name} (${file.size} bytes)`],
dryRun,
};
if (dryRun) {
initialState.message = '升级包已上传,正在执行预检';
}
await writeState(jobDir, initialState);
const child = spawn(process.execPath, [
const runnerArgs = [
path.join(process.cwd(), 'scripts/admin-upgrade-runner.mjs'),
'--job-id',
jobId,
@@ -131,7 +137,10 @@ export async function POST(request: NextRequest) {
file.name,
'--project',
process.cwd(),
], {
];
if (dryRun) runnerArgs.push('--dry-run', 'true');
const child = spawn(process.execPath, runnerArgs, {
cwd: process.cwd(),
detached: true,
stdio: 'ignore',
@@ -143,7 +152,7 @@ export async function POST(request: NextRequest) {
});
child.unref();
return NextResponse.json({ success: true, job: initialState });
return NextResponse.json({ success: true, dryRun, job: initialState });
} catch (error) {
console.error('[admin/upgrade] failed to start upgrade:', error);
return NextResponse.json({ error: error instanceof Error ? error.message : '创建升级任务失败' }, { status: 500 });

View File

@@ -16,6 +16,7 @@ import {
ShieldCheck,
UploadCloud,
XCircle,
SearchCheck,
} from 'lucide-react';
import { toast } from 'sonner';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
@@ -57,6 +58,7 @@ type UpgradeJob = {
updatedAt: string;
finishedAt?: string;
logs: string[];
dryRun?: boolean;
};
type UpgradeResponse = {
@@ -85,6 +87,7 @@ export default function SystemUpgradeTab() {
const [mode, setMode] = useState<UpgradeMode>('hot');
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [submitting, setSubmitting] = useState(false);
const [prechecking, setPrechecking] = useState(false);
const [loading, setLoading] = useState(true);
const [upgradeData, setUpgradeData] = useState<UpgradeResponse>({ latest: null, history: [], running: false, stateDir: '' });
const [currentLogJobIds, setCurrentLogJobIds] = useState<Set<string>>(new Set());
@@ -136,9 +139,12 @@ export default function SystemUpgradeTab() {
return () => window.clearTimeout(timer);
}, [latest?.id, latest?.status, latest, loadStatus]);
const canSubmit = useMemo(() => Boolean(selectedFile) && !submitting && !upgradeData.running, [selectedFile, submitting, upgradeData.running]);
const canSubmit = useMemo(
() => Boolean(selectedFile) && !submitting && !prechecking && !upgradeData.running,
[selectedFile, submitting, prechecking, upgradeData.running],
);
async function handleSubmit() {
async function handleSubmit(dryRun = false) {
if (!selectedFile) {
toast.error('请选择升级包');
return;
@@ -148,11 +154,16 @@ export default function SystemUpgradeTab() {
return;
}
setSubmitting(true);
if (dryRun) {
setPrechecking(true);
} else {
setSubmitting(true);
}
try {
const form = new FormData();
form.set('mode', mode);
form.set('package', selectedFile);
if (dryRun) form.set('dryRun', 'true');
const res = await fetch('/api/admin/upgrade', {
method: 'POST',
@@ -165,14 +176,20 @@ export default function SystemUpgradeTab() {
if (data.job?.id) {
setCurrentLogJobIds(previous => new Set(previous).add(data.job.id));
}
toast.success(mode === 'hot' ? '热更新任务已启动' : '冷更新任务已启动');
setSelectedFile(null);
if (fileInputRef.current) fileInputRef.current.value = '';
toast.success(dryRun ? '升级包预检已启动' : mode === 'hot' ? '热更新任务已启动' : '冷更新任务已启动');
if (!dryRun) {
setSelectedFile(null);
if (fileInputRef.current) fileInputRef.current.value = '';
}
await loadStatus({ silent: true });
} catch (error) {
toast.error(error instanceof Error ? error.message : '创建升级任务失败');
toast.error(error instanceof Error ? error.message : dryRun ? '启动预检失败' : '创建升级任务失败');
} finally {
setSubmitting(false);
if (dryRun) {
setPrechecking(false);
} else {
setSubmitting(false);
}
}
}
@@ -220,7 +237,7 @@ export default function SystemUpgradeTab() {
id="upgrade-package"
type="file"
accept=".tar,.tgz,.tar.gz,application/gzip,application/x-tar"
disabled={submitting || upgradeData.running}
disabled={submitting || prechecking || upgradeData.running}
onChange={event => setSelectedFile(event.target.files?.[0] || null)}
/>
<p className="text-xs text-muted-foreground">
@@ -250,10 +267,16 @@ export default function SystemUpgradeTab() {
</ul>
</div>
<Button onClick={handleSubmit} disabled={!canSubmit} className="gap-2">
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <UploadCloud className="h-4 w-4" />}
{submitting ? '正在上传...' : mode === 'hot' ? '启动热更新' : '启动冷更新'}
</Button>
<div className="flex flex-wrap gap-3">
<Button variant="outline" onClick={() => handleSubmit(true)} disabled={!canSubmit} className="gap-2">
{prechecking ? <Loader2 className="h-4 w-4 animate-spin" /> : <SearchCheck className="h-4 w-4" />}
{prechecking ? '正在预检...' : '先预检升级包'}
</Button>
<Button onClick={() => handleSubmit(false)} disabled={!canSubmit} className="gap-2">
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <UploadCloud className="h-4 w-4" />}
{submitting ? '正在上传...' : mode === 'hot' ? '启动热更新' : '启动冷更新'}
</Button>
</div>
</CardContent>
</Card>
@@ -393,7 +416,8 @@ function UpgradeStatusPanel({
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<StatusIcon status={job.status} />
<Badge variant="secondary">{job.mode === 'hot' ? '热更新' : '冷更新'}</Badge>
<Badge variant="secondary">{job.dryRun ? '预检' : job.mode === 'hot' ? '热更新' : '冷更新'}</Badge>
{job.dryRun && <Badge className="bg-sky-500/15 text-sky-600 hover:bg-sky-500/15"></Badge>}
<Badge className={statusBadgeClass(job.status)}>{statusLabel(job.status)}</Badge>
</div>
<div className="text-xs text-muted-foreground">{formatDate(job.updatedAt)}</div>
@@ -412,6 +436,7 @@ function UpgradeStatusPanel({
<InfoRow label="升级包" value={job.packageName} />
<InfoRow label="当前步骤" value={job.step} />
<InfoRow label="文件数量" value={`${changedFiles.length} 个文件`} />
<InfoRow label="需要重启" value={job.restartRequired ? '是' : '否'} />
{job.backupFile && <InfoRow label="数据备份" value={job.backupFile} />}
{job.sourceBackupFile && <InfoRow label="源码快照" value={job.sourceBackupFile} />}
</div>