511 lines
16 KiB
TypeScript
511 lines
16 KiB
TypeScript
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<string> {
|
|
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<ZfsPool[]> {
|
|
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<ZfsPool | null> {
|
|
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<UnimportedPool[]> {
|
|
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<UnimportedPool> | 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<ZfsDataset[]> {
|
|
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<ZfsSnapshot[]> {
|
|
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.` };
|
|
}
|
|
}
|