fix: 修复 Alpine 视频转码空间检查
This commit is contained in:
parent
7968ee07c2
commit
98068a4209
@ -3,31 +3,114 @@
|
||||
*
|
||||
* 测试内容:
|
||||
* - formatBytes 函数
|
||||
* - getDiskUsageLevel 函数(逻辑验证)
|
||||
* - hasEnoughDiskSpace 函数(逻辑验证)
|
||||
* - ensureEnoughDiskSpace 函数(逻辑验证)
|
||||
* - Node statfs 跨平台磁盘空间读取
|
||||
* - getDiskUsageLevel / hasEnoughDiskSpace / ensureEnoughDiskSpace 逻辑
|
||||
*/
|
||||
|
||||
import { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { formatBytes } from '../disk-space'
|
||||
import { statfs } from 'fs/promises'
|
||||
import {
|
||||
checkDiskSpace,
|
||||
ensureEnoughDiskSpace,
|
||||
formatBytes,
|
||||
getDiskUsageLevel,
|
||||
hasEnoughDiskSpace,
|
||||
} from '../disk-space'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
// Mock child_process.exec at module level
|
||||
jest.mock('child_process', () => {
|
||||
const originalModule = jest.requireActual('child_process')
|
||||
jest.mock('fs/promises', () => {
|
||||
const originalModule = jest.requireActual('fs/promises')
|
||||
return {
|
||||
...originalModule,
|
||||
exec: jest.fn(),
|
||||
statfs: jest.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
const mockedStatfs = statfs as jest.MockedFunction<typeof statfs>
|
||||
const GIBIBYTE = BigInt(1024) * BigInt(1024) * BigInt(1024)
|
||||
|
||||
function mockSpace(total: bigint, free: bigint, available = free, blockSize = BigInt(1)) {
|
||||
mockedStatfs.mockResolvedValueOnce({
|
||||
type: BigInt(0),
|
||||
bsize: blockSize,
|
||||
blocks: total / blockSize,
|
||||
bfree: free / blockSize,
|
||||
bavail: available / blockSize,
|
||||
files: BigInt(0),
|
||||
ffree: BigInt(0),
|
||||
})
|
||||
}
|
||||
|
||||
describe('R38: disk-space', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('checkDiskSpace', () => {
|
||||
it('使用 Node statfs 读取字节数,不依赖系统 df 命令', async () => {
|
||||
mockSpace(BigInt(100_000), BigInt(80_000), BigInt(75_000))
|
||||
|
||||
await expect(checkDiskSpace('/app/uploads/transcoded')).resolves.toEqual({
|
||||
total: 100_000,
|
||||
used: 20_000,
|
||||
available: 75_000,
|
||||
usePercent: 22,
|
||||
mountPoint: '/app/uploads/transcoded',
|
||||
})
|
||||
expect(mockedStatfs).toHaveBeenCalledWith('/app/uploads/transcoded', { bigint: true })
|
||||
})
|
||||
|
||||
it('按块大小和非特权可用块计算并向上取整使用率', async () => {
|
||||
mockSpace(BigInt(12_288), BigInt(8_192), BigInt(4_096), BigInt(4_096))
|
||||
|
||||
const result = await checkDiskSpace('/app/uploads')
|
||||
|
||||
expect(result).toMatchObject({
|
||||
total: 12_288,
|
||||
used: 4_096,
|
||||
available: 4_096,
|
||||
usePercent: 50,
|
||||
})
|
||||
})
|
||||
|
||||
it('不把文件系统保留块算作 nextjs 可用空间', async () => {
|
||||
mockSpace(BigInt(100), BigInt(20), BigInt(10))
|
||||
|
||||
await expect(checkDiskSpace('/app/uploads')).resolves.toMatchObject({
|
||||
total: 100,
|
||||
used: 80,
|
||||
available: 10,
|
||||
usePercent: 89,
|
||||
})
|
||||
})
|
||||
|
||||
it('超出安全整数范围时钳制数值,避免精度溢出绕过空间门禁', async () => {
|
||||
const huge = BigInt(Number.MAX_SAFE_INTEGER) + BigInt(10_000)
|
||||
mockSpace(huge, huge, huge)
|
||||
|
||||
const result = await checkDiskSpace('/app/uploads')
|
||||
|
||||
expect(result?.total).toBe(Number.MAX_SAFE_INTEGER)
|
||||
expect(result?.available).toBe(Number.MAX_SAFE_INTEGER)
|
||||
expect(result?.used).toBe(0)
|
||||
})
|
||||
|
||||
it('空文件系统统计不会产生除零或非有限使用率', async () => {
|
||||
mockSpace(BigInt(0), BigInt(0), BigInt(0))
|
||||
|
||||
await expect(checkDiskSpace('/app/uploads')).resolves.toMatchObject({
|
||||
total: 0,
|
||||
used: 0,
|
||||
available: 0,
|
||||
usePercent: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('statfs 失败时返回 null,让严格调用方保持 fail-closed', async () => {
|
||||
mockedStatfs.mockRejectedValueOnce(Object.assign(new Error('stale mount'), { code: 'ESTALE' }))
|
||||
|
||||
await expect(checkDiskSpace('/app/uploads')).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatBytes', () => {
|
||||
it('应该正确格式化字节数', () => {
|
||||
expect(formatBytes(0)).toBe('0.00 B')
|
||||
@ -41,68 +124,29 @@ describe('R38: disk-space', () => {
|
||||
|
||||
describe('getDiskUsageLevel - 逻辑验证', () => {
|
||||
it('使用率低于 70% 应该返回 ok', async () => {
|
||||
const mockExec = exec as jest.MockedFunction<typeof exec>
|
||||
mockExec.mockImplementationOnce((command, options, callback) => {
|
||||
if (typeof options === 'function') {
|
||||
callback = options
|
||||
}
|
||||
// 模拟 PowerShell JSON 输出(60% 使用率)
|
||||
const stdout = JSON.stringify({ Used: 60000000000, Free: 40000000000 })
|
||||
;(callback as Function)(null, { stdout, stderr: '' })
|
||||
return {} as any
|
||||
})
|
||||
mockSpace(BigInt(100_000), BigInt(40_000))
|
||||
|
||||
const { getDiskUsageLevel } = require('../disk-space')
|
||||
const result = await getDiskUsageLevel()
|
||||
expect(result).toBe('ok')
|
||||
})
|
||||
|
||||
it('使用率在 70%-90% 之间应该返回 warning', async () => {
|
||||
const mockExec = exec as jest.MockedFunction<typeof exec>
|
||||
mockExec.mockImplementationOnce((command, options, callback) => {
|
||||
if (typeof options === 'function') {
|
||||
callback = options
|
||||
}
|
||||
// 模拟 PowerShell JSON 输出(75% 使用率)
|
||||
const stdout = JSON.stringify({ Used: 75000000000, Free: 25000000000 })
|
||||
;(callback as Function)(null, { stdout, stderr: '' })
|
||||
return {} as any
|
||||
})
|
||||
mockSpace(BigInt(100_000), BigInt(25_000))
|
||||
|
||||
const { getDiskUsageLevel } = require('../disk-space')
|
||||
const result = await getDiskUsageLevel()
|
||||
expect(result).toBe('warning')
|
||||
})
|
||||
|
||||
it('使用率大于 90% 应该返回 critical', async () => {
|
||||
const mockExec = exec as jest.MockedFunction<typeof exec>
|
||||
mockExec.mockImplementationOnce((command, options, callback) => {
|
||||
if (typeof options === 'function') {
|
||||
callback = options
|
||||
}
|
||||
// 模拟 PowerShell JSON 输出(95% 使用率)
|
||||
const stdout = JSON.stringify({ Used: 95000000000, Free: 5000000000 })
|
||||
;(callback as Function)(null, { stdout, stderr: '' })
|
||||
return {} as any
|
||||
})
|
||||
mockSpace(BigInt(100_000), BigInt(5_000))
|
||||
|
||||
const { getDiskUsageLevel } = require('../disk-space')
|
||||
const result = await getDiskUsageLevel()
|
||||
expect(result).toBe('critical')
|
||||
})
|
||||
|
||||
it('无法检查时应该返回 ok', async () => {
|
||||
const mockExec = exec as jest.MockedFunction<typeof exec>
|
||||
mockExec.mockImplementationOnce((command, options, callback) => {
|
||||
if (typeof options === 'function') {
|
||||
callback = options
|
||||
}
|
||||
// 模拟命令失败
|
||||
;(callback as Function)(new Error('Command failed'), null)
|
||||
return {} as any
|
||||
})
|
||||
mockedStatfs.mockRejectedValueOnce(new Error('statfs failed'))
|
||||
|
||||
const { getDiskUsageLevel } = require('../disk-space')
|
||||
const result = await getDiskUsageLevel()
|
||||
expect(result).toBe('ok')
|
||||
})
|
||||
@ -110,68 +154,29 @@ describe('R38: disk-space', () => {
|
||||
|
||||
describe('hasEnoughDiskSpace - 逻辑验证', () => {
|
||||
it('当空间足够时应该返回 true', async () => {
|
||||
const mockExec = exec as jest.MockedFunction<typeof exec>
|
||||
mockExec.mockImplementationOnce((command, options, callback) => {
|
||||
if (typeof options === 'function') {
|
||||
callback = options
|
||||
}
|
||||
// 模拟 PowerShell JSON 输出(20% 使用率,80GB 可用)
|
||||
const stdout = JSON.stringify({ Used: 20000000000, Free: 80000000000 })
|
||||
;(callback as Function)(null, { stdout, stderr: '' })
|
||||
return {} as any
|
||||
})
|
||||
mockSpace(BigInt(100) * GIBIBYTE, BigInt(80) * GIBIBYTE)
|
||||
|
||||
const { hasEnoughDiskSpace } = require('../disk-space')
|
||||
const result = await hasEnoughDiskSpace(10 * 1024 * 1024 * 1024)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('当可用空间不足时应该返回 false', async () => {
|
||||
const mockExec = exec as jest.MockedFunction<typeof exec>
|
||||
mockExec.mockImplementationOnce((command, options, callback) => {
|
||||
if (typeof options === 'function') {
|
||||
callback = options
|
||||
}
|
||||
// 模拟 PowerShell JSON 输出(90% 使用率,10GB 可用)
|
||||
const stdout = JSON.stringify({ Used: 90000000000, Free: 10000000000 })
|
||||
;(callback as Function)(null, { stdout, stderr: '' })
|
||||
return {} as any
|
||||
})
|
||||
mockSpace(BigInt(100) * GIBIBYTE, BigInt(10) * GIBIBYTE)
|
||||
|
||||
const { hasEnoughDiskSpace } = require('../disk-space')
|
||||
const result = await hasEnoughDiskSpace(20 * 1024 * 1024 * 1024)
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('当磁盘使用率过高时应该返回 false', async () => {
|
||||
const mockExec = exec as jest.MockedFunction<typeof exec>
|
||||
mockExec.mockImplementationOnce((command, options, callback) => {
|
||||
if (typeof options === 'function') {
|
||||
callback = options
|
||||
}
|
||||
// 模拟 PowerShell JSON 输出(95% 使用率,5GB 可用)
|
||||
const stdout = JSON.stringify({ Used: 95000000000, Free: 5000000000 })
|
||||
;(callback as Function)(null, { stdout, stderr: '' })
|
||||
return {} as any
|
||||
})
|
||||
mockSpace(BigInt(100) * GIBIBYTE, BigInt(5) * GIBIBYTE)
|
||||
|
||||
const { hasEnoughDiskSpace } = require('../disk-space')
|
||||
const result = await hasEnoughDiskSpace(100 * 1024 * 1024)
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('当无法检查时应该返回 true(保守处理)', async () => {
|
||||
const mockExec = exec as jest.MockedFunction<typeof exec>
|
||||
mockExec.mockImplementationOnce((command, options, callback) => {
|
||||
if (typeof options === 'function') {
|
||||
callback = options
|
||||
}
|
||||
// 模拟命令失败
|
||||
;(callback as Function)(new Error('Command failed'), null)
|
||||
return {} as any
|
||||
})
|
||||
mockedStatfs.mockRejectedValueOnce(new Error('statfs failed'))
|
||||
|
||||
const { hasEnoughDiskSpace } = require('../disk-space')
|
||||
const result = await hasEnoughDiskSpace(10 * 1024 * 1024 * 1024)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
@ -179,49 +184,20 @@ describe('R38: disk-space', () => {
|
||||
|
||||
describe('ensureEnoughDiskSpace - 逻辑验证', () => {
|
||||
it('当空间足够时不应该抛出错误', async () => {
|
||||
const mockExec = exec as jest.MockedFunction<typeof exec>
|
||||
mockExec.mockImplementationOnce((command, options, callback) => {
|
||||
if (typeof options === 'function') {
|
||||
callback = options
|
||||
}
|
||||
// 模拟 PowerShell JSON 输出(20% 使用率,80GB 可用)
|
||||
const stdout = JSON.stringify({ Used: 20000000000, Free: 80000000000 })
|
||||
;(callback as Function)(null, { stdout, stderr: '' })
|
||||
return {} as any
|
||||
})
|
||||
mockSpace(BigInt(100) * GIBIBYTE, BigInt(80) * GIBIBYTE)
|
||||
|
||||
const { ensureEnoughDiskSpace } = require('../disk-space')
|
||||
await expect(ensureEnoughDiskSpace(10 * 1024 * 1024 * 1024, '测试操作')).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it('当空间不足时应该抛出错误', async () => {
|
||||
const mockExec = exec as jest.MockedFunction<typeof exec>
|
||||
mockExec.mockImplementationOnce((command, options, callback) => {
|
||||
if (typeof options === 'function') {
|
||||
callback = options
|
||||
}
|
||||
// 模拟 PowerShell JSON 输出(90% 使用率,10GB 可用)
|
||||
const stdout = JSON.stringify({ Used: 90000000000, Free: 10000000000 })
|
||||
;(callback as Function)(null, { stdout, stderr: '' })
|
||||
return {} as any
|
||||
})
|
||||
mockSpace(BigInt(100) * GIBIBYTE, BigInt(10) * GIBIBYTE)
|
||||
|
||||
const { ensureEnoughDiskSpace } = require('../disk-space')
|
||||
await expect(ensureEnoughDiskSpace(20 * 1024 * 1024 * 1024, '测试操作')).rejects.toThrow('磁盘空间不足')
|
||||
})
|
||||
|
||||
it('当无法检查时不应该抛出错误', async () => {
|
||||
const mockExec = exec as jest.MockedFunction<typeof exec>
|
||||
mockExec.mockImplementationOnce((command, options, callback) => {
|
||||
if (typeof options === 'function') {
|
||||
callback = options
|
||||
}
|
||||
// 模拟命令失败
|
||||
;(callback as Function)(new Error('Command failed'), null)
|
||||
return {} as any
|
||||
})
|
||||
mockedStatfs.mockRejectedValueOnce(new Error('statfs failed'))
|
||||
|
||||
const { ensureEnoughDiskSpace } = require('../disk-space')
|
||||
await expect(ensureEnoughDiskSpace(10 * 1024 * 1024 * 1024, '测试操作')).resolves.not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@ -3,16 +3,10 @@
|
||||
*
|
||||
* 用途:在大文件操作前检查磁盘剩余空间,防止因磁盘写满导致服务崩溃
|
||||
*
|
||||
* 支持平台:
|
||||
* - Linux: 使用 `df` 命令
|
||||
* - Windows (WSL): 使用 `df` 命令
|
||||
* - Windows (原生): 使用 PowerShell 的 Get-PSDrive
|
||||
* 使用 Node.js 原生 statfs,不依赖 GNU/BusyBox 命令参数或文本输出格式。
|
||||
*/
|
||||
|
||||
import { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
import { statfs } from 'fs/promises'
|
||||
|
||||
interface DiskSpaceInfo {
|
||||
/** 磁盘总容量(字节) */
|
||||
@ -27,102 +21,44 @@ interface DiskSpaceInfo {
|
||||
mountPoint: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查磁盘空间(Linux/WSL 使用 df 命令)
|
||||
*/
|
||||
async function checkDiskSpaceLinux(path: string): Promise<DiskSpaceInfo | null> {
|
||||
try {
|
||||
// df -B1: 以字节为单位显示
|
||||
// --output: 指定输出字段
|
||||
const { stdout } = await execAsync(`df -B1 --output=size,used,avail,pcent,target "${path}"`, {
|
||||
timeout: 5000, // 5秒超时
|
||||
})
|
||||
|
||||
const lines = stdout.trim().split('\n')
|
||||
if (lines.length < 2) {
|
||||
console.warn('[DiskSpace] Failed to parse df output')
|
||||
return null
|
||||
}
|
||||
|
||||
// 跳过标题行,解析数据行
|
||||
const data = lines[1].trim().split(/\s+/)
|
||||
if (data.length < 5) {
|
||||
console.warn('[DiskSpace] Invalid df output format')
|
||||
return null
|
||||
}
|
||||
|
||||
const total = parseInt(data[0], 10)
|
||||
const used = parseInt(data[1], 10)
|
||||
const available = parseInt(data[2], 10)
|
||||
const usePercentStr = data[3].replace('%', '')
|
||||
const usePercent = parseInt(usePercentStr, 10)
|
||||
const mountPoint = data[4]
|
||||
|
||||
return {
|
||||
total,
|
||||
used,
|
||||
available,
|
||||
usePercent,
|
||||
mountPoint,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DiskSpace] Failed to check disk space:', error)
|
||||
return null
|
||||
}
|
||||
function clampToSafeNumber(value: bigint): number {
|
||||
if (value <= BigInt(0)) return 0
|
||||
const maxSafeInteger = BigInt(Number.MAX_SAFE_INTEGER)
|
||||
return value > maxSafeInteger ? Number.MAX_SAFE_INTEGER : Number(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查磁盘空间(Windows 原生使用 PowerShell)
|
||||
*/
|
||||
async function checkDiskSpaceWindows(path: string): Promise<DiskSpaceInfo | null> {
|
||||
try {
|
||||
// 获取盘符
|
||||
const driveLetter = path.charAt(0).toUpperCase()
|
||||
const drive = driveLetter + ':'
|
||||
|
||||
// 使用 PowerShell Get-PSDrive 获取磁盘信息
|
||||
const { stdout } = await execAsync(
|
||||
`powershell -Command "Get-PSDrive -Name '${driveLetter}' | Select-Object Used,Free | ConvertTo-Json"`,
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
|
||||
const result = JSON.parse(stdout)
|
||||
if (!result) {
|
||||
return null
|
||||
}
|
||||
|
||||
const used = result.Used || 0
|
||||
const free = result.Free || 0
|
||||
const total = used + free
|
||||
const usePercent = total > 0 ? Math.round((used / total) * 100) : 0
|
||||
|
||||
return {
|
||||
total,
|
||||
used,
|
||||
available: free,
|
||||
usePercent,
|
||||
mountPoint: drive,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DiskSpace] Failed to check disk space (Windows):', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查磁盘空间(自动检测平台)
|
||||
* 检查磁盘空间
|
||||
* @param path 要检查的路径(默认为临时目录)
|
||||
* @returns 磁盘空间信息,失败返回 null
|
||||
*/
|
||||
export async function checkDiskSpace(path: string = require('os').tmpdir()): Promise<DiskSpaceInfo | null> {
|
||||
const platform = process.platform
|
||||
try {
|
||||
const space = await statfs(path, { bigint: true })
|
||||
const zero = BigInt(0)
|
||||
const blockSize = space.bsize > zero ? space.bsize : zero
|
||||
const totalBytes = space.blocks > zero ? space.blocks * blockSize : zero
|
||||
const freeBytes = space.bfree > zero ? space.bfree * blockSize : zero
|
||||
const availableBytes = space.bavail > zero ? space.bavail * blockSize : zero
|
||||
const usedBytes = totalBytes > freeBytes ? totalBytes - freeBytes : zero
|
||||
// 与 df 的 Use% 语义一致:非特权进程不能使用的保留块不进入分母。
|
||||
// 绝对空间门禁同样使用 bavail,避免把 root 保留空间误算给 nextjs。
|
||||
const usableBytes = usedBytes + availableBytes
|
||||
const usePercent = usableBytes > zero
|
||||
? Number((usedBytes * BigInt(100) + usableBytes - BigInt(1)) / usableBytes)
|
||||
: 0
|
||||
|
||||
if (platform === 'win32' && !process.env.WSL_DISTRO_NAME) {
|
||||
// Windows 原生环境(非 WSL)
|
||||
return checkDiskSpaceWindows(path)
|
||||
} else {
|
||||
// Linux、macOS、WSL
|
||||
return checkDiskSpaceLinux(path)
|
||||
return {
|
||||
total: clampToSafeNumber(totalBytes),
|
||||
used: clampToSafeNumber(usedBytes),
|
||||
available: clampToSafeNumber(availableBytes),
|
||||
usePercent: Math.min(100, Math.max(0, usePercent)),
|
||||
// statfs 不返回挂载点;保留被检查路径用于日志与诊断。
|
||||
mountPoint: path,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DiskSpace] Failed to check disk space:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user