Check disk space before admin upgrades

This commit is contained in:
FengLee
2026-05-10 13:23:42 +08:00
parent d398ec967f
commit ee9516c733
3 changed files with 188 additions and 0 deletions

View File

@@ -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);

View 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;
}

View File

@@ -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">