Check disk space before admin upgrades
This commit is contained in:
@@ -52,6 +52,8 @@ const BLOCKED_ANYWHERE_NAMES = new Set(['.git', 'node_modules', '.next']);
|
||||
const MAX_EXTRACTED_FILES = Number(process.env.UPGRADE_MAX_EXTRACTED_FILES || 5000);
|
||||
const MAX_EXTRACTED_BYTES = Number(process.env.UPGRADE_MAX_EXTRACTED_BYTES || 500 * 1024 * 1024);
|
||||
const MAX_EXTRACTED_FILE_BYTES = Number(process.env.UPGRADE_MAX_EXTRACTED_FILE_BYTES || 200 * 1024 * 1024);
|
||||
const MIN_FREE_BYTES = Number(process.env.UPGRADE_MIN_FREE_BYTES || 1024 * 1024 * 1024);
|
||||
const BUILD_FREE_BYTES = Number(process.env.UPGRADE_BUILD_FREE_BYTES || 1024 * 1024 * 1024);
|
||||
const PAYLOAD_TOP_LEVEL_DIRECTORIES = new Set([
|
||||
...HOT_ALLOWED_PREFIXES.map(prefix => prefix.replace(/\/$/, '')),
|
||||
...COLD_ALLOWED_PREFIXES.map(prefix => prefix.replace(/\/$/, '')),
|
||||
@@ -117,6 +119,9 @@ async function main() {
|
||||
if (!isAllowedArchive(packageName) && !isAllowedArchive(packagePath)) {
|
||||
throw new Error('仅支持 .tar、.tar.gz、.tgz 升级包');
|
||||
}
|
||||
const packageBytes = fs.statSync(packagePath).size;
|
||||
const preExtractDiskChecks = validatePreExtractDiskSpace(packageBytes);
|
||||
updateState({ diskChecks: preExtractDiskChecks });
|
||||
|
||||
logStep('校验升级包', '正在读取压缩包目录并检查格式');
|
||||
run('tar', tarReadArgs('list', packagePath), { cwd: projectRoot, label: '检查升级包结构' });
|
||||
@@ -131,6 +136,7 @@ async function main() {
|
||||
throw new Error('升级包为空');
|
||||
}
|
||||
validatePackageSize(packageStats);
|
||||
const diskChecks = validateUpgradeDiskSpace(packageStats, packageBytes);
|
||||
|
||||
const validation = validateFiles(files, mode);
|
||||
logStep('升级包内容', `校验通过,共 ${files.length} 个文件,解压后 ${formatBytes(packageStats.totalBytes)}:${files.slice(0, 20).join('、')}${files.length > 20 ? ` 等 ${files.length} 个文件` : ''}`);
|
||||
@@ -144,6 +150,7 @@ async function main() {
|
||||
extractedFileCount: packageStats.files.length,
|
||||
extractedBytes: packageStats.totalBytes,
|
||||
largestFileBytes: packageStats.largestFileBytes,
|
||||
diskChecks,
|
||||
dryRun,
|
||||
});
|
||||
|
||||
@@ -450,6 +457,88 @@ function validatePackageSize(stats) {
|
||||
}
|
||||
}
|
||||
|
||||
function validatePreExtractDiskSpace(packageBytes) {
|
||||
const stateCheck = buildDiskCheck({
|
||||
label: '升级状态目录',
|
||||
targetPath: stateRoot,
|
||||
requiredBytes: packageBytes + MAX_EXTRACTED_BYTES + MIN_FREE_BYTES,
|
||||
});
|
||||
assertDiskSpace(stateCheck);
|
||||
logStep('磁盘空间检查', `${stateCheck.label} ${stateCheck.path} 可用 ${formatBytes(stateCheck.availableBytes)},需要 ${formatBytes(stateCheck.requiredBytes)}`);
|
||||
return [stateCheck];
|
||||
}
|
||||
|
||||
function validateUpgradeDiskSpace(stats, packageBytes) {
|
||||
const checksByPath = new Map();
|
||||
for (const check of [
|
||||
buildDiskCheck({
|
||||
label: '升级状态目录',
|
||||
targetPath: stateRoot,
|
||||
requiredBytes: packageBytes + stats.totalBytes + MIN_FREE_BYTES,
|
||||
}),
|
||||
buildDiskCheck({
|
||||
label: '项目目录',
|
||||
targetPath: projectRoot,
|
||||
requiredBytes: stats.totalBytes + (mode === 'cold' ? BUILD_FREE_BYTES : MIN_FREE_BYTES),
|
||||
}),
|
||||
]) {
|
||||
const existing = checksByPath.get(check.path);
|
||||
if (!existing || check.requiredBytes > existing.requiredBytes) {
|
||||
checksByPath.set(check.path, check);
|
||||
}
|
||||
}
|
||||
const checks = [...checksByPath.values()];
|
||||
for (const check of checks) {
|
||||
assertDiskSpace(check);
|
||||
logStep('磁盘空间检查', `${check.label} ${check.path} 可用 ${formatBytes(check.availableBytes)},需要 ${formatBytes(check.requiredBytes)}`);
|
||||
}
|
||||
return checks;
|
||||
}
|
||||
|
||||
function buildDiskCheck({ label, targetPath, requiredBytes }) {
|
||||
const usage = readDiskUsage(targetPath);
|
||||
return {
|
||||
label,
|
||||
path: usage.path,
|
||||
totalBytes: usage.totalBytes,
|
||||
availableBytes: usage.availableBytes,
|
||||
requiredBytes,
|
||||
usedPercent: usage.usedPercent,
|
||||
};
|
||||
}
|
||||
|
||||
function readDiskUsage(targetPath) {
|
||||
ensureDir(targetPath);
|
||||
const result = spawnSync('df', ['-Pk', targetPath], { encoding: 'utf8', timeout: 5000 });
|
||||
if (result.status !== 0 || !result.stdout) {
|
||||
const detail = `${result.stderr || result.stdout || ''}`.trim();
|
||||
throw new Error(`读取磁盘空间失败:${targetPath}${detail ? `;${detail}` : ''}`);
|
||||
}
|
||||
const lines = result.stdout.trim().split(/\r?\n/);
|
||||
const row = lines[lines.length - 1]?.trim().split(/\s+/);
|
||||
if (!row || row.length < 6) {
|
||||
throw new Error(`读取磁盘空间失败:${targetPath}`);
|
||||
}
|
||||
const totalBytes = Number(row[1]) * 1024;
|
||||
const availableBytes = Number(row[3]) * 1024;
|
||||
const usedPercent = Number(row[4].replace('%', ''));
|
||||
if (!Number.isFinite(totalBytes) || !Number.isFinite(availableBytes)) {
|
||||
throw new Error(`读取磁盘空间失败:${targetPath}`);
|
||||
}
|
||||
return {
|
||||
path: row.slice(5).join(' ') || targetPath,
|
||||
totalBytes,
|
||||
availableBytes,
|
||||
usedPercent: Number.isFinite(usedPercent) ? usedPercent : null,
|
||||
};
|
||||
}
|
||||
|
||||
function assertDiskSpace(check) {
|
||||
if (!Number.isFinite(check.requiredBytes) || check.requiredBytes <= 0) return;
|
||||
if (check.availableBytes >= check.requiredBytes) return;
|
||||
throw new Error(`升级前磁盘空间不足:${check.label} ${check.path} 可用 ${formatBytes(check.availableBytes)},需要至少 ${formatBytes(check.requiredBytes)}`);
|
||||
}
|
||||
|
||||
function validateFiles(files, updateMode) {
|
||||
for (const file of files) {
|
||||
assertSafeRelativePath(file);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import fsSync from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
@@ -36,6 +37,7 @@ type UpgradeJobState = {
|
||||
extractedFileCount?: number;
|
||||
extractedBytes?: number;
|
||||
largestFileBytes?: number;
|
||||
diskChecks?: DiskCheck[];
|
||||
preExistingFiles?: string[];
|
||||
error?: string;
|
||||
startedAt: string;
|
||||
@@ -47,12 +49,22 @@ type UpgradeJobState = {
|
||||
staleAt?: string;
|
||||
};
|
||||
|
||||
type DiskCheck = {
|
||||
label: string;
|
||||
path: string;
|
||||
totalBytes: number;
|
||||
availableBytes: number;
|
||||
requiredBytes?: number;
|
||||
usedPercent: number | null;
|
||||
};
|
||||
|
||||
type RuntimeStatus = {
|
||||
projectRoot: string;
|
||||
stateDir: string;
|
||||
nodeVersion: string;
|
||||
pm2Enabled: boolean;
|
||||
pm2SystemdEnabled: string | null;
|
||||
disks: DiskCheck[];
|
||||
processes: Array<{ name: string; status: string; uptime?: number; restarts?: number }>;
|
||||
};
|
||||
|
||||
@@ -96,10 +108,47 @@ function getRuntimeStatus(): RuntimeStatus {
|
||||
nodeVersion: process.version,
|
||||
pm2Enabled: commandExists('pm2'),
|
||||
pm2SystemdEnabled: getCommandOutput('systemctl', ['is-enabled', 'pm2-root']),
|
||||
disks: getRuntimeDisks(process.cwd(), getUpgradeStateRoot()),
|
||||
processes: getPm2Processes(),
|
||||
};
|
||||
}
|
||||
|
||||
function getRuntimeDisks(projectRoot: string, stateDir: string): DiskCheck[] {
|
||||
const checks = [
|
||||
readDiskUsage('项目目录', projectRoot),
|
||||
readDiskUsage('升级状态目录', stateDir),
|
||||
].filter((check): check is DiskCheck => Boolean(check));
|
||||
const byPath = new Map<string, DiskCheck>();
|
||||
for (const check of checks) {
|
||||
if (!byPath.has(check.path)) byPath.set(check.path, check);
|
||||
}
|
||||
return [...byPath.values()];
|
||||
}
|
||||
|
||||
function readDiskUsage(label: string, targetPath: string): DiskCheck | null {
|
||||
try {
|
||||
fsSync.mkdirSync(targetPath, { recursive: true, mode: 0o700 });
|
||||
const result = spawnSync('df', ['-Pk', targetPath], { encoding: 'utf8', timeout: 5000 });
|
||||
if (result.status !== 0 || !result.stdout) return null;
|
||||
const lines = result.stdout.trim().split(/\r?\n/);
|
||||
const row = lines[lines.length - 1]?.trim().split(/\s+/);
|
||||
if (!row || row.length < 6) return null;
|
||||
const totalBytes = Number(row[1]) * 1024;
|
||||
const availableBytes = Number(row[3]) * 1024;
|
||||
const usedPercent = Number(row[4].replace('%', ''));
|
||||
if (!Number.isFinite(totalBytes) || !Number.isFinite(availableBytes)) return null;
|
||||
return {
|
||||
label,
|
||||
path: row.slice(5).join(' ') || targetPath,
|
||||
totalBytes,
|
||||
availableBytes,
|
||||
usedPercent: Number.isFinite(usedPercent) ? usedPercent : null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function commandExists(command: string): boolean {
|
||||
return spawnSync('bash', ['-lc', `command -v ${command} >/dev/null 2>&1`], { encoding: 'utf8' }).status === 0;
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ type UpgradeJob = {
|
||||
extractedFileCount?: number;
|
||||
extractedBytes?: number;
|
||||
largestFileBytes?: number;
|
||||
diskChecks?: DiskCheck[];
|
||||
preExistingFiles?: string[];
|
||||
error?: string;
|
||||
startedAt: string;
|
||||
@@ -85,9 +86,19 @@ type RuntimeStatus = {
|
||||
nodeVersion: string;
|
||||
pm2Enabled: boolean;
|
||||
pm2SystemdEnabled: string | null;
|
||||
disks?: DiskCheck[];
|
||||
processes: Array<{ name: string; status: string; uptime?: number; restarts?: number }>;
|
||||
};
|
||||
|
||||
type DiskCheck = {
|
||||
label: string;
|
||||
path: string;
|
||||
totalBytes: number;
|
||||
availableBytes: number;
|
||||
requiredBytes?: number;
|
||||
usedPercent: number | null;
|
||||
};
|
||||
|
||||
const RUNNING_STATUSES = new Set<UpgradeStatus>(['queued', 'running', 'rolling_back']);
|
||||
const FINAL_STATUSES = new Set<UpgradeStatus>(['succeeded', 'failed', 'rolled_back', 'rollback_failed']);
|
||||
|
||||
@@ -533,6 +544,13 @@ function UpgradeStatusPanel({
|
||||
{job.sourceBackupHash && <InfoRow label="快照校验" value={job.sourceBackupHash} />}
|
||||
</div>
|
||||
|
||||
{job.diskChecks && job.diskChecks.length > 0 && (
|
||||
<div className="rounded-md border border-border bg-muted/25 p-3">
|
||||
<div className="mb-2 text-sm font-medium">磁盘校验</div>
|
||||
<DiskCheckList checks={job.diskChecks} showRequired />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!compact && changedFiles.length > 0 && (
|
||||
<div className="rounded-md border border-border bg-muted/25 p-3">
|
||||
<div className="mb-2 text-sm font-medium">升级内容</div>
|
||||
@@ -572,6 +590,7 @@ function UpgradeStatusPanel({
|
||||
|
||||
function RuntimeStatusPanel({ runtime, fallbackStateDir }: { runtime?: RuntimeStatus; fallbackStateDir: string }) {
|
||||
const processes = runtime?.processes || [];
|
||||
const disks = runtime?.disks || [];
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-2 text-sm">
|
||||
@@ -582,6 +601,15 @@ function RuntimeStatusPanel({ runtime, fallbackStateDir }: { runtime?: RuntimeSt
|
||||
<InfoRow label="开机自启" value={runtime?.pm2SystemdEnabled || '未知'} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-border bg-muted/25 p-3">
|
||||
<div className="mb-2 text-sm font-medium">磁盘空间</div>
|
||||
{disks.length > 0 ? (
|
||||
<DiskCheckList checks={disks} />
|
||||
) : (
|
||||
<div className="rounded bg-background/70 px-3 py-4 text-center text-sm text-muted-foreground">未读取到磁盘信息</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-border bg-muted/25 p-3">
|
||||
<div className="mb-2 text-sm font-medium">进程状态</div>
|
||||
{processes.length > 0 ? (
|
||||
@@ -609,6 +637,28 @@ function RuntimeStatusPanel({ runtime, fallbackStateDir }: { runtime?: RuntimeSt
|
||||
);
|
||||
}
|
||||
|
||||
function DiskCheckList({ checks, showRequired = false }: { checks: DiskCheck[]; showRequired?: boolean }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{checks.map(check => (
|
||||
<div key={`${check.label}-${check.path}`} className="rounded bg-background/70 px-3 py-2 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-medium">{check.label}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{check.usedPercent == null ? '使用率未知' : `已用 ${check.usedPercent}%`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 truncate font-mono text-xs text-muted-foreground" title={check.path}>{check.path}</div>
|
||||
<div className="mt-2 grid gap-2 text-xs text-muted-foreground sm:grid-cols-2">
|
||||
<span>可用 {formatBytes(check.availableBytes)} / 总计 {formatBytes(check.totalBytes)}</span>
|
||||
{showRequired && typeof check.requiredBytes === 'number' && <span>本次至少需要 {formatBytes(check.requiredBytes)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[5rem_1fr] gap-3 rounded-md bg-muted/35 px-3 py-2">
|
||||
|
||||
Reference in New Issue
Block a user