feat(api): complete NaxOS management daemon, GitOps engine, ZFS controller, and test suite
CI & Test NaxOS Management API / test (push) Failing after 2m49s
CI & Test NaxOS Management API / test (push) Failing after 2m49s
This commit is contained in:
@@ -0,0 +1,495 @@
|
||||
import { execFile } 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 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 (!config.isLinux) {
|
||||
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 (!config.isLinux) {
|
||||
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 (!config.isLinux) {
|
||||
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 (!config.isLinux) {
|
||||
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 (!config.isLinux) {
|
||||
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 (!config.isLinux) {
|
||||
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 (!config.isLinux) {
|
||||
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 (!config.isLinux) {
|
||||
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 (!config.isLinux) {
|
||||
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 (!config.isLinux) {
|
||||
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 (!config.isLinux) {
|
||||
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 (!config.isLinux) {
|
||||
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 (!config.isLinux) {
|
||||
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 (!config.isLinux) {
|
||||
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 (!config.isLinux) {
|
||||
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.` };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user