feat(api): complete NaxOS management daemon, GitOps engine, ZFS controller, and test suite
CI & Test NaxOS Management API / test (push) Failing after 2m49s

This commit is contained in:
Lukas Holzner
2026-09-03 23:56:02 +02:00
parent 2f42656c2d
commit a283feedbd
27 changed files with 4882 additions and 2 deletions
+495
View File
@@ -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.` };
}
}