Add admin upgrade package preflight
This commit is contained in:
@@ -15,6 +15,7 @@ const stateRoot = path.resolve(
|
|||||||
|
|
||||||
const jobId = requireArg(args, 'job-id');
|
const jobId = requireArg(args, 'job-id');
|
||||||
const mode = requireArg(args, 'mode');
|
const mode = requireArg(args, 'mode');
|
||||||
|
const dryRun = args['dry-run'] === 'true';
|
||||||
const packagePath = path.resolve(requireArg(args, 'package'));
|
const packagePath = path.resolve(requireArg(args, 'package'));
|
||||||
const packageName = args['package-name'] || path.basename(packagePath);
|
const packageName = args['package-name'] || path.basename(packagePath);
|
||||||
const jobDir = path.join(stateRoot, 'jobs', jobId);
|
const jobDir = path.join(stateRoot, 'jobs', jobId);
|
||||||
@@ -64,6 +65,17 @@ let state = readState() || {
|
|||||||
|
|
||||||
main().catch(error => {
|
main().catch(error => {
|
||||||
log(`fatal: ${error instanceof Error ? error.stack || error.message : String(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 => {
|
rollbackAfterFailure(error instanceof Error ? error.message : '升级任务异常退出').catch(rollbackError => {
|
||||||
updateState({
|
updateState({
|
||||||
status: 'rollback_failed',
|
status: 'rollback_failed',
|
||||||
@@ -85,7 +97,7 @@ async function main() {
|
|||||||
message: '正在检查升级包与运行环境',
|
message: '正在检查升级包与运行环境',
|
||||||
startedAt: state.startedAt || new Date().toISOString(),
|
startedAt: state.startedAt || new Date().toISOString(),
|
||||||
});
|
});
|
||||||
logStep('开始升级任务', `任务 ${jobId} 使用${mode === 'hot' ? '热更新' : '冷更新'}模式,升级包 ${packageName}`);
|
logStep('开始升级任务', `任务 ${jobId} 使用${mode === 'hot' ? '热更新' : '冷更新'}模式,升级包 ${packageName}${dryRun ? ',仅执行预检' : ''}`);
|
||||||
|
|
||||||
if (mode !== 'hot' && mode !== 'cold') {
|
if (mode !== 'hot' && mode !== 'cold') {
|
||||||
throw new Error('升级方式无效');
|
throw new Error('升级方式无效');
|
||||||
@@ -118,8 +130,23 @@ async function main() {
|
|||||||
restartRequired: mode === 'cold' || validation.requiresRestart,
|
restartRequired: mode === 'cold' || validation.requiresRestart,
|
||||||
packageHash: sha256(packagePath),
|
packageHash: sha256(packagePath),
|
||||||
changedFiles: files,
|
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: '正在创建数据库、存储与环境配置备份' });
|
updateState({ step: 'backup_data', progress: 22, message: '正在创建数据库、存储与环境配置备份' });
|
||||||
logStep('创建数据备份', '开始备份数据库、存储目录和环境配置');
|
logStep('创建数据备份', '开始备份数据库、存储目录和环境配置');
|
||||||
const backupFile = runCapture('bash', ['./scripts/backup-create.sh'], {
|
const backupFile = runCapture('bash', ['./scripts/backup-create.sh'], {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ type UpgradeJobState = {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
finishedAt?: string;
|
finishedAt?: string;
|
||||||
logs: string[];
|
logs: string[];
|
||||||
|
dryRun?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_PACKAGE_BYTES = 300 * 1024 * 1024;
|
const MAX_PACKAGE_BYTES = 300 * 1024 * 1024;
|
||||||
@@ -74,6 +75,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
const modeValue = String(form.get('mode') || '');
|
const modeValue = String(form.get('mode') || '');
|
||||||
const mode = modeValue === 'hot' || modeValue === 'cold' ? modeValue : null;
|
const mode = modeValue === 'hot' || modeValue === 'cold' ? modeValue : null;
|
||||||
|
const dryRun = String(form.get('dryRun') || '') === 'true';
|
||||||
if (!mode) {
|
if (!mode) {
|
||||||
return NextResponse.json({ error: '请选择热更新或冷更新' }, { status: 400 });
|
return NextResponse.json({ error: '请选择热更新或冷更新' }, { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -116,10 +118,14 @@ export async function POST(request: NextRequest) {
|
|||||||
startedAt: now,
|
startedAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
logs: [`[${now}] 上传升级包 ${file.name} (${file.size} bytes)`],
|
logs: [`[${now}] 上传升级包 ${file.name} (${file.size} bytes)`],
|
||||||
|
dryRun,
|
||||||
};
|
};
|
||||||
|
if (dryRun) {
|
||||||
|
initialState.message = '升级包已上传,正在执行预检';
|
||||||
|
}
|
||||||
await writeState(jobDir, initialState);
|
await writeState(jobDir, initialState);
|
||||||
|
|
||||||
const child = spawn(process.execPath, [
|
const runnerArgs = [
|
||||||
path.join(process.cwd(), 'scripts/admin-upgrade-runner.mjs'),
|
path.join(process.cwd(), 'scripts/admin-upgrade-runner.mjs'),
|
||||||
'--job-id',
|
'--job-id',
|
||||||
jobId,
|
jobId,
|
||||||
@@ -131,7 +137,10 @@ export async function POST(request: NextRequest) {
|
|||||||
file.name,
|
file.name,
|
||||||
'--project',
|
'--project',
|
||||||
process.cwd(),
|
process.cwd(),
|
||||||
], {
|
];
|
||||||
|
if (dryRun) runnerArgs.push('--dry-run', 'true');
|
||||||
|
|
||||||
|
const child = spawn(process.execPath, runnerArgs, {
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
detached: true,
|
detached: true,
|
||||||
stdio: 'ignore',
|
stdio: 'ignore',
|
||||||
@@ -143,7 +152,7 @@ export async function POST(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
child.unref();
|
child.unref();
|
||||||
|
|
||||||
return NextResponse.json({ success: true, job: initialState });
|
return NextResponse.json({ success: true, dryRun, job: initialState });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[admin/upgrade] failed to start upgrade:', error);
|
console.error('[admin/upgrade] failed to start upgrade:', error);
|
||||||
return NextResponse.json({ error: error instanceof Error ? error.message : '创建升级任务失败' }, { status: 500 });
|
return NextResponse.json({ error: error instanceof Error ? error.message : '创建升级任务失败' }, { status: 500 });
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
UploadCloud,
|
UploadCloud,
|
||||||
XCircle,
|
XCircle,
|
||||||
|
SearchCheck,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
@@ -57,6 +58,7 @@ type UpgradeJob = {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
finishedAt?: string;
|
finishedAt?: string;
|
||||||
logs: string[];
|
logs: string[];
|
||||||
|
dryRun?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type UpgradeResponse = {
|
type UpgradeResponse = {
|
||||||
@@ -85,6 +87,7 @@ export default function SystemUpgradeTab() {
|
|||||||
const [mode, setMode] = useState<UpgradeMode>('hot');
|
const [mode, setMode] = useState<UpgradeMode>('hot');
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [prechecking, setPrechecking] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [upgradeData, setUpgradeData] = useState<UpgradeResponse>({ latest: null, history: [], running: false, stateDir: '' });
|
const [upgradeData, setUpgradeData] = useState<UpgradeResponse>({ latest: null, history: [], running: false, stateDir: '' });
|
||||||
const [currentLogJobIds, setCurrentLogJobIds] = useState<Set<string>>(new Set());
|
const [currentLogJobIds, setCurrentLogJobIds] = useState<Set<string>>(new Set());
|
||||||
@@ -136,9 +139,12 @@ export default function SystemUpgradeTab() {
|
|||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [latest?.id, latest?.status, latest, loadStatus]);
|
}, [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) {
|
if (!selectedFile) {
|
||||||
toast.error('请选择升级包');
|
toast.error('请选择升级包');
|
||||||
return;
|
return;
|
||||||
@@ -148,11 +154,16 @@ export default function SystemUpgradeTab() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
setPrechecking(true);
|
||||||
|
} else {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.set('mode', mode);
|
form.set('mode', mode);
|
||||||
form.set('package', selectedFile);
|
form.set('package', selectedFile);
|
||||||
|
if (dryRun) form.set('dryRun', 'true');
|
||||||
|
|
||||||
const res = await fetch('/api/admin/upgrade', {
|
const res = await fetch('/api/admin/upgrade', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -165,16 +176,22 @@ export default function SystemUpgradeTab() {
|
|||||||
if (data.job?.id) {
|
if (data.job?.id) {
|
||||||
setCurrentLogJobIds(previous => new Set(previous).add(data.job.id));
|
setCurrentLogJobIds(previous => new Set(previous).add(data.job.id));
|
||||||
}
|
}
|
||||||
toast.success(mode === 'hot' ? '热更新任务已启动' : '冷更新任务已启动');
|
toast.success(dryRun ? '升级包预检已启动' : mode === 'hot' ? '热更新任务已启动' : '冷更新任务已启动');
|
||||||
|
if (!dryRun) {
|
||||||
setSelectedFile(null);
|
setSelectedFile(null);
|
||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
await loadStatus({ silent: true });
|
await loadStatus({ silent: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : '创建升级任务失败');
|
toast.error(error instanceof Error ? error.message : dryRun ? '启动预检失败' : '创建升级任务失败');
|
||||||
} finally {
|
} finally {
|
||||||
|
if (dryRun) {
|
||||||
|
setPrechecking(false);
|
||||||
|
} else {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -220,7 +237,7 @@ export default function SystemUpgradeTab() {
|
|||||||
id="upgrade-package"
|
id="upgrade-package"
|
||||||
type="file"
|
type="file"
|
||||||
accept=".tar,.tgz,.tar.gz,application/gzip,application/x-tar"
|
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)}
|
onChange={event => setSelectedFile(event.target.files?.[0] || null)}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -250,10 +267,16 @@ export default function SystemUpgradeTab() {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button onClick={handleSubmit} disabled={!canSubmit} className="gap-2">
|
<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 ? <Loader2 className="h-4 w-4 animate-spin" /> : <UploadCloud className="h-4 w-4" />}
|
||||||
{submitting ? '正在上传...' : mode === 'hot' ? '启动热更新' : '启动冷更新'}
|
{submitting ? '正在上传...' : mode === 'hot' ? '启动热更新' : '启动冷更新'}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -393,7 +416,8 @@ function UpgradeStatusPanel({
|
|||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<StatusIcon status={job.status} />
|
<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>
|
<Badge className={statusBadgeClass(job.status)}>{statusLabel(job.status)}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">{formatDate(job.updatedAt)}</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.packageName} />
|
||||||
<InfoRow label="当前步骤" value={job.step} />
|
<InfoRow label="当前步骤" value={job.step} />
|
||||||
<InfoRow label="文件数量" value={`${changedFiles.length} 个文件`} />
|
<InfoRow label="文件数量" value={`${changedFiles.length} 个文件`} />
|
||||||
|
<InfoRow label="需要重启" value={job.restartRequired ? '是' : '否'} />
|
||||||
{job.backupFile && <InfoRow label="数据备份" value={job.backupFile} />}
|
{job.backupFile && <InfoRow label="数据备份" value={job.backupFile} />}
|
||||||
{job.sourceBackupFile && <InfoRow label="源码快照" value={job.sourceBackupFile} />}
|
{job.sourceBackupFile && <InfoRow label="源码快照" value={job.sourceBackupFile} />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user