import { execFile, execFileSync } from 'child_process'; import { promisify } from 'util'; import { config } from '../config/index.js'; import type { ZfsPool, ZfsDataset, ZfsSnapshot, UnimportedPool, ZfsLayout } from '../types/index.js'; const execFileAsync = promisify(execFile); export class ZfsService { private mockPools: ZfsPool[] = [ { name: 'tank', size: '14.5T', allocated: '8.2T', free: '6.3T', fragmentation: '14%', capacityPercent: 56, health: 'ONLINE', altroot: '-', scanStatus: 'scrub repaired 0B in 04:12:30 with 0 errors on Sun Sep 01 04:12:30 2026', scrubProgress: 100, vdevs: [ { name: 'mirror-0', type: 'mirror', health: 'ONLINE', disks: [ { name: 'sda', path: '/dev/disk/by-id/ata-WDC_WD100EFAX-68LHPN0_WD-WX11DC0E4J81', size: '10T', health: 'ONLINE', readErrors: 0, writeErrors: 0, checksumErrors: 0, }, { name: 'sdb', path: '/dev/disk/by-id/ata-WDC_WD100EFAX-68LHPN0_WD-WX21DC0E9L32', size: '10T', health: 'ONLINE', readErrors: 0, writeErrors: 0, checksumErrors: 0, }, ], }, ], }, ]; private mockDatasets: ZfsDataset[] = [ { name: 'tank', pool: 'tank', used: '8.2T', available: '6.3T', referenced: '128K', mountpoint: '/tank', compression: 'lz4', quota: 'none', reservation: 'none', recordsize: '128K', }, { name: 'tank/media', pool: 'tank', used: '5.1T', available: '6.3T', referenced: '5.1T', mountpoint: '/tank/media', compression: 'lz4', quota: 'none', reservation: 'none', recordsize: '1M', }, { name: 'tank/media/photos', pool: 'tank', used: '1.8T', available: '6.3T', referenced: '1.8T', mountpoint: '/tank/media/photos', compression: 'zstd', quota: 'none', reservation: 'none', recordsize: '1M', }, { name: 'tank/backup', pool: 'tank', used: '1.2T', available: '6.3T', referenced: '1.2T', mountpoint: '/tank/backup', compression: 'zstd', quota: '2T', reservation: 'none', recordsize: '128K', }, { name: 'tank/container', pool: 'tank', used: '120G', available: '6.3T', referenced: '120G', mountpoint: '/var/lib/docker', compression: 'lz4', quota: '500G', reservation: 'none', recordsize: '128K', }, ]; private mockSnapshots: ZfsSnapshot[] = [ { name: 'tank/media/photos@daily-2026-09-02', dataset: 'tank/media/photos', snapshotTag: 'daily-2026-09-02', creationTime: '2026-09-02T00:00:00Z', usedBytes: '14.2G', referencedBytes: '1.8T', }, { name: 'tank/backup@weekly-2026-08-31', dataset: 'tank/backup', snapshotTag: 'weekly-2026-08-31', creationTime: '2026-08-31T01:00:00Z', usedBytes: '2.4G', referencedBytes: '1.2T', }, ]; private mockUnimportedPools: UnimportedPool[] = [ { name: 'truenas_pool', id: '12495810294819284', state: 'ONLINE', status: 'The pool was exported from another host and is ready for import.', action: 'The pool can be imported using its name or numeric identifier.', disks: ['/dev/sdc', '/dev/sdd'], }, ]; private hasZfsBinaries: boolean = (() => { if (!config.isLinux) return false; try { execFileSync('which', ['zpool'], { stdio: 'ignore' }); execFileSync('which', ['zfs'], { stdio: 'ignore' }); return true; } catch { return false; } })(); private get useMock(): boolean { return !this.hasZfsBinaries; } private async runCommand(cmd: string, args: string[]): Promise { try { const { stdout } = await execFileAsync(cmd, args); return stdout.trim(); } catch (err: any) { throw new Error(`ZFS command error (${cmd} ${args.join(' ')}): ${err.stderr || err.message}`); } } async listPools(): Promise { if (this.useMock) { return this.mockPools; } try { const stdout = await this.runCommand('zpool', ['list', '-H', '-p', '-o', 'name,size,alloc,free,frag,cap,health,altroot']); if (!stdout) return []; const lines = stdout.split('\n'); return lines.map((line) => { const [name, size, alloc, free, frag, cap, health, altroot] = line.split('\t'); return { name, size, allocated: alloc, free, fragmentation: `${frag}%`, capacityPercent: parseInt(cap, 10) || 0, health: (health as any) || 'ONLINE', altroot, vdevs: [], }; }); } catch { return this.mockPools; } } async getPool(poolName: string): Promise { const pools = await this.listPools(); return pools.find((p) => p.name === poolName) || null; } async createPool(params: { name: string; layout: ZfsLayout; devices: string[]; ashift?: number; }): Promise<{ success: boolean; message: string }> { const ashift = params.ashift || 12; if (this.useMock) { const newPool: ZfsPool = { name: params.name, size: '19.0T', allocated: '1.2M', free: '19.0T', fragmentation: '0%', capacityPercent: 0, health: 'ONLINE', altroot: '-', vdevs: [ { name: `${params.layout}-0`, type: params.layout, health: 'ONLINE', disks: params.devices.map((dev) => ({ name: dev.split('/').pop() || dev, path: dev, size: '10T', health: 'ONLINE', readErrors: 0, writeErrors: 0, checksumErrors: 0, })), }, ], }; this.mockPools.push(newPool); return { success: true, message: `Pool '${params.name}' created successfully.` }; } const args = ['create', '-f', '-o', `ashift=${ashift}`, '-O', 'compression=lz4', '-O', 'xattr=sa', '-O', 'acltype=posixacl', params.name]; if (params.layout !== 'stripe') { args.push(params.layout); } args.push(...params.devices); await this.runCommand('zpool', args); return { success: true, message: `Pool '${params.name}' created successfully.` }; } async scanUnimportedPools(): Promise { if (this.useMock) { return this.mockUnimportedPools; } try { const stdout = await this.runCommand('zpool', ['import']); const pools: UnimportedPool[] = []; const lines = stdout.split('\n'); let currentPool: Partial | null = null; for (const line of lines) { const trimmed = line.trim(); if (trimmed.startsWith('pool:')) { if (currentPool && currentPool.name) { pools.push(currentPool as UnimportedPool); } currentPool = { name: trimmed.replace('pool:', '').trim(), disks: [], state: 'UNKNOWN', status: '', id: '', }; } else if (currentPool) { if (trimmed.startsWith('id:')) { currentPool.id = trimmed.replace('id:', '').trim(); } else if (trimmed.startsWith('state:')) { currentPool.state = trimmed.replace('state:', '').trim(); } else if (trimmed.startsWith('status:')) { currentPool.status = trimmed.replace('status:', '').trim(); } else if (trimmed.startsWith('action:')) { currentPool.action = trimmed.replace('action:', '').trim(); } } } if (currentPool && currentPool.name) { pools.push(currentPool as UnimportedPool); } return pools; } catch { return this.mockUnimportedPools; } } async importPool(params: { poolName: string; force?: boolean; altroot?: string; noMount?: boolean; }): Promise<{ success: boolean; message: string }> { if (this.useMock) { const found = this.mockUnimportedPools.find((p) => p.name === params.poolName); if (found) { this.mockUnimportedPools = this.mockUnimportedPools.filter((p) => p.name !== params.poolName); this.mockPools.push({ name: found.name, size: '18.2T', allocated: '4.1T', free: '14.1T', fragmentation: '3%', capacityPercent: 22, health: 'ONLINE', altroot: params.altroot || '-', vdevs: [], }); } return { success: true, message: `Pool '${params.poolName}' imported safely without data loss.` }; } const args = ['import']; if (params.force) args.push('-f'); if (params.noMount) args.push('-N'); if (params.altroot) { args.push('-R', params.altroot); } args.push(params.poolName); await this.runCommand('zpool', args); return { success: true, message: `Pool '${params.poolName}' imported safely without data loss.` }; } async exportPool(poolName: string): Promise<{ success: boolean; message: string }> { if (this.useMock) { this.mockPools = this.mockPools.filter((p) => p.name !== poolName); return { success: true, message: `Pool '${poolName}' exported.` }; } await this.runCommand('zpool', ['export', poolName]); return { success: true, message: `Pool '${poolName}' exported.` }; } async scrub(poolName: string, action: 'start' | 'stop' | 'pause'): Promise<{ success: boolean; message: string }> { if (this.useMock) { return { success: true, message: `Scrub ${action} requested for pool '${poolName}'.` }; } const flag = action === 'stop' ? '-s' : action === 'pause' ? '-p' : ''; const args = ['scrub']; if (flag) args.push(flag); args.push(poolName); await this.runCommand('zpool', args); return { success: true, message: `Scrub ${action} initiated for '${poolName}'.` }; } async trim(poolName: string): Promise<{ success: boolean; message: string }> { if (this.useMock) { return { success: true, message: `TRIM initiated for pool '${poolName}'.` }; } await this.runCommand('zpool', ['trim', poolName]); return { success: true, message: `TRIM operation started on '${poolName}'.` }; } async listDatasets(poolName?: string): Promise { if (this.useMock) { if (poolName) { return this.mockDatasets.filter((d) => d.pool === poolName); } return this.mockDatasets; } try { const args = ['list', '-H', '-o', 'name,used,avail,refer,mountpoint,compression,quota,reservation,recordsize']; if (poolName) args.push('-r', poolName); const stdout = await this.runCommand('zfs', args); if (!stdout) return []; return stdout.split('\n').map((line) => { const [name, used, available, referenced, mountpoint, compression, quota, reservation, recordsize] = line.split('\t'); const pool = name.split('/')[0]; return { name, pool, used, available, referenced, mountpoint, compression, quota, reservation, recordsize, }; }); } catch { return this.mockDatasets; } } async createDataset(params: { name: string; compression?: string; recordsize?: string; quota?: string; mountpoint?: string; }): Promise<{ success: boolean; message: string }> { if (this.useMock) { const pool = params.name.split('/')[0]; const newDs: ZfsDataset = { name: params.name, pool, used: '0B', available: '6.3T', referenced: '0B', mountpoint: params.mountpoint || `/${params.name}`, compression: params.compression || 'lz4', quota: params.quota || 'none', reservation: 'none', recordsize: params.recordsize || '128K', }; this.mockDatasets.push(newDs); return { success: true, message: `Dataset '${params.name}' created.` }; } const args = ['create']; if (params.compression) args.push('-o', `compression=${params.compression}`); if (params.recordsize) args.push('-o', `recordsize=${params.recordsize}`); if (params.quota) args.push('-o', `quota=${params.quota}`); if (params.mountpoint) args.push('-o', `mountpoint=${params.mountpoint}`); args.push(params.name); await this.runCommand('zfs', args); return { success: true, message: `Dataset '${params.name}' created.` }; } async destroyDataset(name: string, recursive = false): Promise<{ success: boolean; message: string }> { if (this.useMock) { this.mockDatasets = this.mockDatasets.filter((d) => d.name !== name && (!recursive || !d.name.startsWith(`${name}/`))); return { success: true, message: `Dataset '${name}' destroyed.` }; } const args = ['destroy']; if (recursive) args.push('-r'); args.push(name); await this.runCommand('zfs', args); return { success: true, message: `Dataset '${name}' destroyed.` }; } async setDatasetProperty(name: string, property: string, value: string): Promise<{ success: boolean; message: string }> { if (this.useMock) { const ds = this.mockDatasets.find((d) => d.name === name); if (ds) { (ds as any)[property] = value; } return { success: true, message: `Property '${property}' set to '${value}' for '${name}'.` }; } await this.runCommand('zfs', ['set', `${property}=${value}`, name]); return { success: true, message: `Property '${property}' set to '${value}' for '${name}'.` }; } async listSnapshots(datasetName?: string): Promise { if (this.useMock) { if (datasetName) { return this.mockSnapshots.filter((s) => s.dataset === datasetName); } return this.mockSnapshots; } try { const args = ['list', '-t', 'snapshot', '-H', '-o', 'name,creation,used,refer']; if (datasetName) args.push('-r', datasetName); const stdout = await this.runCommand('zfs', args); if (!stdout) return []; return stdout.split('\n').map((line) => { const [fullName, creationTime, usedBytes, referencedBytes] = line.split('\t'); const [dataset, snapshotTag] = fullName.split('@'); return { name: fullName, dataset, snapshotTag, creationTime, usedBytes, referencedBytes, }; }); } catch { return this.mockSnapshots; } } async createSnapshot(datasetName: string, snapshotTag: string): Promise<{ success: boolean; message: string }> { const fullName = `${datasetName}@${snapshotTag}`; if (this.useMock) { this.mockSnapshots.unshift({ name: fullName, dataset: datasetName, snapshotTag, creationTime: new Date().toISOString(), usedBytes: '0B', referencedBytes: '1.8T', }); return { success: true, message: `Snapshot '${fullName}' created.` }; } await this.runCommand('zfs', ['snapshot', fullName]); return { success: true, message: `Snapshot '${fullName}' created.` }; } async rollbackSnapshot(snapshotName: string): Promise<{ success: boolean; message: string }> { if (this.useMock) { return { success: true, message: `Dataset rolled back to '${snapshotName}'.` }; } await this.runCommand('zfs', ['rollback', '-r', snapshotName]); return { success: true, message: `Dataset rolled back to '${snapshotName}'.` }; } async destroySnapshot(snapshotName: string): Promise<{ success: boolean; message: string }> { if (this.useMock) { this.mockSnapshots = this.mockSnapshots.filter((s) => s.name !== snapshotName); return { success: true, message: `Snapshot '${snapshotName}' destroyed.` }; } await this.runCommand('zfs', ['destroy', snapshotName]); return { success: true, message: `Snapshot '${snapshotName}' destroyed.` }; } }