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,28 @@
|
|||||||
|
name: CI & Test NaxOS Management API
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm ci || npm install
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: npx tsc --noEmit
|
||||||
|
|
||||||
|
- name: Run Unit Tests
|
||||||
|
run: npm test
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.naxos-data/
|
||||||
|
.naxos-repo/
|
||||||
|
*.log
|
||||||
@@ -1,3 +1,42 @@
|
|||||||
# naxos-api
|
# NaxOS Management Daemon & API (`naxos-api`)
|
||||||
|
|
||||||
NaxOS Management Daemon & API - Declarative GitOps engine, ZFS management, and App Engine
|
[](https://git.lholz.de/naxos/naxos-api/actions)
|
||||||
|
|
||||||
|
The **NaxOS Management Daemon** serves as the core orchestration brain of the NaxOS storage appliance. It bridges web dashboard interactions with underlying declarative NixOS module generation, OpenZFS primitives, GitOps synchronization, and workload lifecycle controls.
|
||||||
|
|
||||||
|
## Core Capabilities
|
||||||
|
|
||||||
|
- **Declarative NixOS Compiler**: Translates REST API actions directly into standard NixOS module syntax (`generated-naxos-config.nix`), validated before activating via `nixos-rebuild switch`.
|
||||||
|
- **GitOps Synchronization Engine**: Every configuration modification creates an atomic local Git commit and automatically pushes to the remote repository (e.g. Gitea) with instant 1-click rollback capability.
|
||||||
|
- **Native OpenZFS Management**: Full REST API for pools, vdevs, datasets, quotas, snapshots, scrubs, and TRIM.
|
||||||
|
- **Foreign Pool Migration**: Zero-data-loss safe discovery and import of foreign ZFS pools from TrueNAS or raw NixOS setups like `nixos-lukas`.
|
||||||
|
- **Tri-Tier Workload Engine**: Manages native NixOS systemd services (e.g., Immich with Intel QuickSync / OpenCL hardware acceleration), rootless Docker Compose, and lightweight K3s clusters.
|
||||||
|
- **Native Perses Analytics & SSE Logs**: Serves embedded Perses dashboard configurations and streams real-time journald logs.
|
||||||
|
|
||||||
|
## REST API Overview
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `/api/v1/system/status` | `GET` | Appliance health, CPU, RAM, ARC hit ratio |
|
||||||
|
| `/api/v1/system/rebuild` | `POST` | Triggers declarative rebuild; streams output via SSE |
|
||||||
|
| `/api/v1/storage/pools` | `GET`, `POST` | List and create ZFS pools |
|
||||||
|
| `/api/v1/storage/unimported` | `GET` | Scan for unimported foreign pools |
|
||||||
|
| `/api/v1/storage/import` | `POST` | Safely import foreign pool without data loss |
|
||||||
|
| `/api/v1/storage/datasets` | `GET`, `POST` | Manage ZFS datasets, quotas, recordsize |
|
||||||
|
| `/api/v1/storage/snapshots` | `GET`, `POST` | Snapshot creation and instant rollback |
|
||||||
|
| `/api/v1/shares/smb` | `GET`, `POST` | Samba shares and Time Machine configurations |
|
||||||
|
| `/api/v1/shares/nfs` | `GET`, `POST` | NFS exports and CIDR rules |
|
||||||
|
| `/api/v1/apps/catalog` | `GET` | Catalog of deployable applications |
|
||||||
|
| `/api/v1/apps/installed` | `GET`, `POST` | Install and manage active app workloads |
|
||||||
|
| `/api/v1/gitops/status` | `GET` | Remote synchronization and commit state |
|
||||||
|
| `/api/v1/gitops/rollback` | `POST` | Instant rollback to arbitrary commit SHA |
|
||||||
|
| `/api/v1/logs/stream` | `GET` | Real-time Server-Sent Events journald stream |
|
||||||
|
|
||||||
|
## Development & Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
npm start
|
||||||
|
```
|
||||||
Generated
+2682
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "@naxos/api",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "NaxOS Management Daemon & GitOps REST/WebSocket API",
|
||||||
|
"main": "dist/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/server.js",
|
||||||
|
"dev": "tsx watch src/server.ts",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"keywords": ["naxos", "nixos", "zfs", "nas", "gitops", "perses"],
|
||||||
|
"author": "NaxOS Authors",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fastify": "^4.28.1",
|
||||||
|
"@fastify/cors": "^9.0.1",
|
||||||
|
"@fastify/websocket": "^10.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"typescript": "^5.5.0",
|
||||||
|
"tsx": "^4.16.0",
|
||||||
|
"vitest": "^2.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
port: parseInt(process.env.PORT || '8088', 10),
|
||||||
|
host: process.env.HOST || '0.0.0.0',
|
||||||
|
dataDir: process.env.NAXOS_DATA_DIR || path.join(process.cwd(), '.naxos-data'),
|
||||||
|
configRepoDir: process.env.NAXOS_CONFIG_REPO || path.join(process.cwd(), '.naxos-repo'),
|
||||||
|
systemProfile: process.env.SYSTEM_PROFILE || '/nix/var/nix/profiles/system',
|
||||||
|
isDev: process.env.NODE_ENV !== 'production',
|
||||||
|
isLinux: process.platform === 'linux',
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { AppsService } from '../services/apps.service.js';
|
||||||
|
|
||||||
|
export function registerAppsRoutes(app: FastifyInstance, apps: AppsService) {
|
||||||
|
app.get('/api/v1/apps/catalog', async () => {
|
||||||
|
return { catalog: apps.getCatalog() };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/v1/apps/installed', async () => {
|
||||||
|
return { apps: apps.getInstalledApps() };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/v1/apps/install', async (req) => {
|
||||||
|
const body = req.body as {
|
||||||
|
appId: string;
|
||||||
|
runtime?: any;
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
return await apps.installApp(body);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/v1/apps/:appId/status', async (req) => {
|
||||||
|
const { appId } = req.params as { appId: string };
|
||||||
|
const { status } = req.body as { status: 'running' | 'stopped' };
|
||||||
|
return await apps.setAppStatus(appId, status);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/v1/apps/:appId', async (req) => {
|
||||||
|
const { appId } = req.params as { appId: string };
|
||||||
|
return await apps.uninstallApp(appId);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { GitOpsService } from '../services/gitops.service.js';
|
||||||
|
|
||||||
|
export function registerGitOpsRoutes(app: FastifyInstance, gitops: GitOpsService) {
|
||||||
|
app.get('/api/v1/gitops/status', async () => {
|
||||||
|
return { status: await gitops.getStatus() };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/v1/gitops/commits', async (req) => {
|
||||||
|
const { limit } = req.query as { limit?: string };
|
||||||
|
return { commits: await gitops.getCommits(limit ? parseInt(limit, 10) : 20) };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/v1/gitops/config', async () => {
|
||||||
|
return { config: gitops.getConfig() };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/v1/gitops/rollback', async (req) => {
|
||||||
|
const { commitSha } = req.body as { commitSha: string };
|
||||||
|
return await gitops.rollbackToCommit(commitSha);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/v1/gitops/update', async (req) => {
|
||||||
|
const body = req.body as {
|
||||||
|
config: any;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
return await gitops.updateConfig(body.config, body.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { LogsService } from '../services/logs.service.js';
|
||||||
|
|
||||||
|
export function registerLogsRoutes(app: FastifyInstance, logs: LogsService) {
|
||||||
|
app.get('/api/v1/logs', async (req) => {
|
||||||
|
const { limit, unit } = req.query as { limit?: string; unit?: string };
|
||||||
|
return { logs: logs.getRecentLogs(limit ? parseInt(limit, 10) : 100, unit) };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/v1/logs/stream', (req, reply) => {
|
||||||
|
const { unit } = req.query as { unit?: string };
|
||||||
|
reply.raw.setHeader('Content-Type', 'text/event-stream');
|
||||||
|
reply.raw.setHeader('Cache-Control', 'no-cache');
|
||||||
|
reply.raw.setHeader('Connection', 'keep-alive');
|
||||||
|
|
||||||
|
const emitter = logs.streamLogs(unit);
|
||||||
|
|
||||||
|
const listener = (entry: any) => {
|
||||||
|
reply.raw.write(`data: ${JSON.stringify(entry)}\n\n`);
|
||||||
|
};
|
||||||
|
|
||||||
|
emitter.on('log', listener);
|
||||||
|
|
||||||
|
req.raw.on('close', () => {
|
||||||
|
emitter.emit('close');
|
||||||
|
emitter.off('log', listener);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { SharesService } from '../services/shares.service.js';
|
||||||
|
import type { SmbShare, NfsExport } from '../types/index.js';
|
||||||
|
|
||||||
|
export function registerSharesRoutes(app: FastifyInstance, shares: SharesService) {
|
||||||
|
// SMB Shares
|
||||||
|
app.get('/api/v1/shares/smb', async () => {
|
||||||
|
return { shares: shares.getSmbShares() };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/v1/shares/smb', async (req) => {
|
||||||
|
const body = req.body as SmbShare;
|
||||||
|
return await shares.saveSmbShare(body);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/v1/shares/smb/:name', async (req) => {
|
||||||
|
const { name } = req.params as { name: string };
|
||||||
|
return await shares.deleteSmbShare(name);
|
||||||
|
});
|
||||||
|
|
||||||
|
// NFS Exports
|
||||||
|
app.get('/api/v1/shares/nfs', async () => {
|
||||||
|
return { exports: shares.getNfsExports() };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/v1/shares/nfs', async (req) => {
|
||||||
|
const body = req.body as NfsExport;
|
||||||
|
return await shares.saveNfsExport(body);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/v1/shares/nfs', async (req) => {
|
||||||
|
const { path } = req.query as { path: string };
|
||||||
|
return await shares.deleteNfsExport(path);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { ZfsService } from '../services/zfs.service.js';
|
||||||
|
|
||||||
|
export function registerStorageRoutes(app: FastifyInstance, zfs: ZfsService) {
|
||||||
|
// Pools
|
||||||
|
app.get('/api/v1/storage/pools', async () => {
|
||||||
|
return { pools: await zfs.listPools() };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/v1/storage/pools/:name', async (req, reply) => {
|
||||||
|
const { name } = req.params as { name: string };
|
||||||
|
const pool = await zfs.getPool(name);
|
||||||
|
if (!pool) {
|
||||||
|
return reply.status(404).send({ error: `Pool '${name}' not found.` });
|
||||||
|
}
|
||||||
|
return { pool };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/v1/storage/pools', async (req) => {
|
||||||
|
const body = req.body as {
|
||||||
|
name: string;
|
||||||
|
layout: any;
|
||||||
|
devices: string[];
|
||||||
|
ashift?: number;
|
||||||
|
};
|
||||||
|
return await zfs.createPool(body);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/v1/storage/pools/:name', async (req) => {
|
||||||
|
const { name } = req.params as { name: string };
|
||||||
|
return await zfs.exportPool(name);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/v1/storage/pools/:name/scrub', async (req) => {
|
||||||
|
const { name } = req.params as { name: string };
|
||||||
|
const { action } = req.body as { action: 'start' | 'stop' | 'pause' };
|
||||||
|
return await zfs.scrub(name, action || 'start');
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/v1/storage/pools/:name/trim', async (req) => {
|
||||||
|
const { name } = req.params as { name: string };
|
||||||
|
return await zfs.trim(name);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Foreign / Unimported Pools (Safe Migration from TrueNAS / nixos-lukas)
|
||||||
|
app.get('/api/v1/storage/unimported', async () => {
|
||||||
|
return { pools: await zfs.scanUnimportedPools() };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/v1/storage/import', async (req) => {
|
||||||
|
const body = req.body as {
|
||||||
|
poolName: string;
|
||||||
|
force?: boolean;
|
||||||
|
altroot?: string;
|
||||||
|
noMount?: boolean;
|
||||||
|
};
|
||||||
|
return await zfs.importPool(body);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Datasets
|
||||||
|
app.get('/api/v1/storage/datasets', async (req) => {
|
||||||
|
const { pool } = req.query as { pool?: string };
|
||||||
|
return { datasets: await zfs.listDatasets(pool) };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/v1/storage/datasets', async (req) => {
|
||||||
|
const body = req.body as {
|
||||||
|
name: string;
|
||||||
|
compression?: string;
|
||||||
|
recordsize?: string;
|
||||||
|
quota?: string;
|
||||||
|
mountpoint?: string;
|
||||||
|
};
|
||||||
|
return await zfs.createDataset(body);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/v1/storage/datasets/:name', async (req) => {
|
||||||
|
const { name } = req.params as { name: string };
|
||||||
|
const { recursive } = req.query as { recursive?: boolean };
|
||||||
|
return await zfs.destroyDataset(decodeURIComponent(name), Boolean(recursive));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/v1/storage/datasets/:name', async (req) => {
|
||||||
|
const { name } = req.params as { name: string };
|
||||||
|
const { property, value } = req.body as { property: string; value: string };
|
||||||
|
return await zfs.setDatasetProperty(decodeURIComponent(name), property, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Snapshots
|
||||||
|
app.get('/api/v1/storage/snapshots', async (req) => {
|
||||||
|
const { dataset } = req.query as { dataset?: string };
|
||||||
|
return { snapshots: await zfs.listSnapshots(dataset) };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/v1/storage/snapshots', async (req) => {
|
||||||
|
const { dataset, snapshotTag } = req.body as { dataset: string; snapshotTag: string };
|
||||||
|
return await zfs.createSnapshot(dataset, snapshotTag);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/v1/storage/snapshots/rollback', async (req) => {
|
||||||
|
const { snapshotName } = req.body as { snapshotName: string };
|
||||||
|
return await zfs.rollbackSnapshot(snapshotName);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/v1/storage/snapshots/:name', async (req) => {
|
||||||
|
const { name } = req.params as { name: string };
|
||||||
|
return await zfs.destroySnapshot(decodeURIComponent(name));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { PersesService } from '../services/perses.service.js';
|
||||||
|
import type { RebuildService } from '../services/rebuild.service.js';
|
||||||
|
|
||||||
|
export function registerSystemRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
perses: PersesService,
|
||||||
|
rebuild: RebuildService
|
||||||
|
) {
|
||||||
|
app.get('/api/v1/system/status', async () => {
|
||||||
|
return { status: await perses.getSystemStatus() };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/v1/perses/dashboards', async () => {
|
||||||
|
return { dashboards: perses.getDashboards() };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/v1/system/rebuild', async (req, reply) => {
|
||||||
|
reply.raw.setHeader('Content-Type', 'text/event-stream');
|
||||||
|
reply.raw.setHeader('Cache-Control', 'no-cache');
|
||||||
|
reply.raw.setHeader('Connection', 'keep-alive');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const emitter = (await rebuild.triggerSwitch()) as any;
|
||||||
|
|
||||||
|
emitter.on('data', (chunk: string) => {
|
||||||
|
reply.raw.write(`data: ${JSON.stringify({ output: chunk })}\n\n`);
|
||||||
|
});
|
||||||
|
|
||||||
|
emitter.on('end', () => {
|
||||||
|
reply.raw.write(`data: ${JSON.stringify({ done: true })}\n\n`);
|
||||||
|
reply.raw.end();
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
reply.raw.write(`data: ${JSON.stringify({ error: err.message })}\n\n`);
|
||||||
|
reply.raw.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/v1/system/reboot', async () => {
|
||||||
|
return { success: true, message: 'System reboot scheduled in 5 seconds.' };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { UsersService } from '../services/users.service.js';
|
||||||
|
import type { SystemUser } from '../types/index.js';
|
||||||
|
|
||||||
|
export function registerUsersRoutes(app: FastifyInstance, users: UsersService) {
|
||||||
|
app.get('/api/v1/users', async () => {
|
||||||
|
return { users: users.getUsers() };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/v1/users', async (req) => {
|
||||||
|
const body = req.body as SystemUser;
|
||||||
|
return await users.saveUser(body);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/v1/users/:username', async (req) => {
|
||||||
|
const { username } = req.params as { username: string };
|
||||||
|
return await users.deleteUser(username);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import Fastify from 'fastify';
|
||||||
|
import cors from '@fastify/cors';
|
||||||
|
import { config } from './config/index.js';
|
||||||
|
import { ZfsService } from './services/zfs.service.js';
|
||||||
|
import { GitOpsService } from './services/gitops.service.js';
|
||||||
|
import { RebuildService } from './services/rebuild.service.js';
|
||||||
|
import { AppsService } from './services/apps.service.js';
|
||||||
|
import { SharesService } from './services/shares.service.js';
|
||||||
|
import { UsersService } from './services/users.service.js';
|
||||||
|
import { LogsService } from './services/logs.service.js';
|
||||||
|
import { PersesService } from './services/perses.service.js';
|
||||||
|
|
||||||
|
import { registerStorageRoutes } from './routes/storage.routes.js';
|
||||||
|
import { registerGitOpsRoutes } from './routes/gitops.routes.js';
|
||||||
|
import { registerAppsRoutes } from './routes/apps.routes.js';
|
||||||
|
import { registerSharesRoutes } from './routes/shares.routes.js';
|
||||||
|
import { registerUsersRoutes } from './routes/users.routes.js';
|
||||||
|
import { registerLogsRoutes } from './routes/logs.routes.js';
|
||||||
|
import { registerSystemRoutes } from './routes/system.routes.js';
|
||||||
|
|
||||||
|
export async function createServer() {
|
||||||
|
const app = Fastify({
|
||||||
|
logger: config.isDev
|
||||||
|
? {
|
||||||
|
transport: {
|
||||||
|
target: 'pino-pretty',
|
||||||
|
options: { colorize: true },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(cors, {
|
||||||
|
origin: true,
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Instantiate core services
|
||||||
|
const zfs = new ZfsService();
|
||||||
|
const gitops = new GitOpsService();
|
||||||
|
await gitops.init();
|
||||||
|
|
||||||
|
const rebuild = new RebuildService();
|
||||||
|
const apps = new AppsService(gitops);
|
||||||
|
const shares = new SharesService(gitops);
|
||||||
|
const users = new UsersService(gitops);
|
||||||
|
const logs = new LogsService();
|
||||||
|
const perses = new PersesService(zfs, gitops);
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get('/health', async () => {
|
||||||
|
return { status: 'healthy', version: '1.0.0', uptime: process.uptime() };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register routes
|
||||||
|
registerStorageRoutes(app, zfs);
|
||||||
|
registerGitOpsRoutes(app, gitops);
|
||||||
|
registerAppsRoutes(app, apps);
|
||||||
|
registerSharesRoutes(app, shares);
|
||||||
|
registerUsersRoutes(app, users);
|
||||||
|
registerLogsRoutes(app, logs);
|
||||||
|
registerSystemRoutes(app, perses, rebuild);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
try {
|
||||||
|
const app = await createServer();
|
||||||
|
await app.listen({ port: config.port, host: config.host });
|
||||||
|
console.log(`NaxOS Management Daemon running on http://${config.host}:${config.port}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fatal error starting NaxOS Management Daemon:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && process.argv[1].endsWith('server.js')) {
|
||||||
|
start();
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import type { AppCatalogItem, InstalledApp, AppRuntime } from '../types/index.js';
|
||||||
|
import type { GitOpsService } from './gitops.service.js';
|
||||||
|
|
||||||
|
export class AppsService {
|
||||||
|
private catalog: AppCatalogItem[] = [
|
||||||
|
{
|
||||||
|
id: 'immich',
|
||||||
|
name: 'Immich Photo & Video Hub',
|
||||||
|
category: 'Media',
|
||||||
|
description: 'High performance self-hosted photo and video backup solution with Intel QuickSync / OpenCL machine learning acceleration.',
|
||||||
|
icon: 'Image',
|
||||||
|
defaultPort: 2283,
|
||||||
|
supportedRuntimes: ['systemd', 'docker'],
|
||||||
|
recommendedRuntime: 'systemd',
|
||||||
|
envVariables: {
|
||||||
|
MEDIA_LOCATION: '/tank/media/photos',
|
||||||
|
ACCELERATION_DEVICE: '/dev/dri/renderD128',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nextcloud',
|
||||||
|
name: 'Nextcloud Hub',
|
||||||
|
category: 'Productivity',
|
||||||
|
description: 'Self-hosted productivity platform for files, calendars, contacts, and collaborative office editing.',
|
||||||
|
icon: 'Cloud',
|
||||||
|
defaultPort: 8080,
|
||||||
|
supportedRuntimes: ['systemd', 'docker'],
|
||||||
|
recommendedRuntime: 'docker',
|
||||||
|
envVariables: {
|
||||||
|
DATA_DIRECTORY: '/tank/backup/nextcloud',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'jellyfin',
|
||||||
|
name: 'Jellyfin Media Server',
|
||||||
|
category: 'Media',
|
||||||
|
description: 'The volunteer-built media system that puts you in control of managing and streaming your media with hardware transcoding.',
|
||||||
|
icon: 'Film',
|
||||||
|
defaultPort: 8096,
|
||||||
|
supportedRuntimes: ['systemd', 'docker'],
|
||||||
|
recommendedRuntime: 'docker',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'paperless',
|
||||||
|
name: 'Paperless-ngx',
|
||||||
|
category: 'Productivity',
|
||||||
|
description: 'Document management system that transforms physical documents into a searchable online archive with automated scanner ingestion.',
|
||||||
|
icon: 'FileText',
|
||||||
|
defaultPort: 28981,
|
||||||
|
supportedRuntimes: ['systemd', 'docker'],
|
||||||
|
recommendedRuntime: 'systemd',
|
||||||
|
envVariables: {
|
||||||
|
CONSUMPTION_DIR: '/tank/scans',
|
||||||
|
MEDIA_DIR: '/tank/backup/paperless',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vaultwarden',
|
||||||
|
name: 'Vaultwarden',
|
||||||
|
category: 'Security',
|
||||||
|
description: 'Lightweight, single-binary Bitwarden-compatible password vault written in Rust with minimal memory footprint.',
|
||||||
|
icon: 'Lock',
|
||||||
|
defaultPort: 8222,
|
||||||
|
supportedRuntimes: ['systemd', 'docker'],
|
||||||
|
recommendedRuntime: 'systemd',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'homeassistant',
|
||||||
|
name: 'Home Assistant',
|
||||||
|
category: 'Automation',
|
||||||
|
description: 'Open source home automation that puts local control and privacy first.',
|
||||||
|
icon: 'Home',
|
||||||
|
defaultPort: 8123,
|
||||||
|
supportedRuntimes: ['docker', 'k3s'],
|
||||||
|
recommendedRuntime: 'docker',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
constructor(private gitopsService: GitOpsService) {}
|
||||||
|
|
||||||
|
getCatalog(): AppCatalogItem[] {
|
||||||
|
return this.catalog;
|
||||||
|
}
|
||||||
|
|
||||||
|
getInstalledApps(): InstalledApp[] {
|
||||||
|
const config = this.gitopsService.getConfig();
|
||||||
|
return Object.values(config.appEngine.apps);
|
||||||
|
}
|
||||||
|
|
||||||
|
async installApp(params: {
|
||||||
|
appId: string;
|
||||||
|
runtime?: AppRuntime;
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
}): Promise<InstalledApp> {
|
||||||
|
const item = this.catalog.find((c) => c.id === params.appId);
|
||||||
|
if (!item) {
|
||||||
|
throw new Error(`Application with ID '${params.appId}' not found in catalog.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtime = params.runtime || item.recommendedRuntime;
|
||||||
|
const installed: InstalledApp = {
|
||||||
|
id: `app-${item.id}`,
|
||||||
|
appId: item.id,
|
||||||
|
name: item.name,
|
||||||
|
runtime,
|
||||||
|
status: 'running',
|
||||||
|
port: item.defaultPort,
|
||||||
|
version: 'latest',
|
||||||
|
cpuUsagePercent: 0.8,
|
||||||
|
memoryUsageBytes: 350000000,
|
||||||
|
config: params.config || {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentConfig = this.gitopsService.getConfig();
|
||||||
|
currentConfig.appEngine.apps[item.id] = installed;
|
||||||
|
|
||||||
|
await this.gitopsService.updateConfig(
|
||||||
|
{ appEngine: currentConfig.appEngine },
|
||||||
|
`feat(app-engine): deployed application '${item.name}' on ${runtime} runtime`
|
||||||
|
);
|
||||||
|
|
||||||
|
return installed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setAppStatus(appId: string, status: 'running' | 'stopped'): Promise<InstalledApp> {
|
||||||
|
const currentConfig = this.gitopsService.getConfig();
|
||||||
|
const app = currentConfig.appEngine.apps[appId];
|
||||||
|
if (!app) {
|
||||||
|
throw new Error(`Application '${appId}' is not installed.`);
|
||||||
|
}
|
||||||
|
app.status = status;
|
||||||
|
await this.gitopsService.updateConfig(
|
||||||
|
{ appEngine: currentConfig.appEngine },
|
||||||
|
`chore(app-engine): changed status of '${app.name}' to ${status}`
|
||||||
|
);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
async uninstallApp(appId: string): Promise<{ success: boolean; message: string }> {
|
||||||
|
const currentConfig = this.gitopsService.getConfig();
|
||||||
|
const app = currentConfig.appEngine.apps[appId];
|
||||||
|
if (!app) {
|
||||||
|
throw new Error(`Application '${appId}' is not installed.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete currentConfig.appEngine.apps[appId];
|
||||||
|
await this.gitopsService.updateConfig(
|
||||||
|
{ appEngine: currentConfig.appEngine },
|
||||||
|
`chore(app-engine): uninstalled application '${app.name}'`
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true, message: `Application '${app.name}' uninstalled successfully.` };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
import fs from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
import { execFile } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import { config } from '../config/index.js';
|
||||||
|
import type { NaxosConfig, GitOpsStatus, GitCommit } from '../types/index.js';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
export class GitOpsService {
|
||||||
|
private repoDir: string;
|
||||||
|
private currentConfig: NaxosConfig;
|
||||||
|
|
||||||
|
constructor(repoDir?: string) {
|
||||||
|
this.repoDir = repoDir || config.configRepoDir;
|
||||||
|
this.currentConfig = {
|
||||||
|
core: {
|
||||||
|
hostname: 'naxos',
|
||||||
|
timezone: 'Europe/Berlin',
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
arcMaxBytes: 4294967296,
|
||||||
|
autoScrub: true,
|
||||||
|
autoTrim: true,
|
||||||
|
pools: {
|
||||||
|
tank: {
|
||||||
|
layout: 'mirror',
|
||||||
|
devices: ['/dev/sda', '/dev/sdb'],
|
||||||
|
ashift: 12,
|
||||||
|
datasets: {
|
||||||
|
media: {
|
||||||
|
mountpoint: '/tank/media',
|
||||||
|
compression: 'lz4',
|
||||||
|
recordsize: '1M',
|
||||||
|
},
|
||||||
|
backup: {
|
||||||
|
mountpoint: '/tank/backup',
|
||||||
|
compression: 'zstd',
|
||||||
|
recordsize: '128K',
|
||||||
|
quota: '2T',
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
mountpoint: '/var/lib/docker',
|
||||||
|
compression: 'lz4',
|
||||||
|
recordsize: '128K',
|
||||||
|
quota: '500G',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
importExistingPools: ['tank'],
|
||||||
|
},
|
||||||
|
shares: {
|
||||||
|
samba: {
|
||||||
|
enable: true,
|
||||||
|
workgroup: 'WORKGROUP',
|
||||||
|
shares: {
|
||||||
|
media: {
|
||||||
|
name: 'media',
|
||||||
|
path: '/tank/media',
|
||||||
|
readOnly: false,
|
||||||
|
browseable: true,
|
||||||
|
guestOk: true,
|
||||||
|
validUsers: [],
|
||||||
|
timeMachine: false,
|
||||||
|
},
|
||||||
|
backup: {
|
||||||
|
name: 'backup',
|
||||||
|
path: '/tank/backup',
|
||||||
|
readOnly: false,
|
||||||
|
browseable: true,
|
||||||
|
guestOk: false,
|
||||||
|
validUsers: ['admin', 'lukas'],
|
||||||
|
timeMachine: true,
|
||||||
|
timeMachineMaxSize: '1T',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
nfs: {
|
||||||
|
enable: true,
|
||||||
|
exports: [
|
||||||
|
{
|
||||||
|
path: '/tank/media',
|
||||||
|
clients: [
|
||||||
|
{
|
||||||
|
subnet: '10.0.0.0/23',
|
||||||
|
options: 'rw,sync,no_subtree_check,no_root_squash',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
appEngine: {
|
||||||
|
defaultRuntime: 'docker',
|
||||||
|
apps: {
|
||||||
|
immich: {
|
||||||
|
id: 'app-immich',
|
||||||
|
appId: 'immich',
|
||||||
|
name: 'Immich Photo Hub',
|
||||||
|
runtime: 'systemd',
|
||||||
|
status: 'running',
|
||||||
|
port: 2283,
|
||||||
|
version: 'v1.118.0',
|
||||||
|
cpuUsagePercent: 1.8,
|
||||||
|
memoryUsageBytes: 780000000,
|
||||||
|
config: {
|
||||||
|
mediaLocation: '/tank/media/photos',
|
||||||
|
acceleration: 'intel-qsv',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
jellyfin: {
|
||||||
|
id: 'app-jellyfin',
|
||||||
|
appId: 'jellyfin',
|
||||||
|
name: 'Jellyfin Media Server',
|
||||||
|
runtime: 'docker',
|
||||||
|
status: 'running',
|
||||||
|
port: 8096,
|
||||||
|
version: '10.9.8',
|
||||||
|
cpuUsagePercent: 0.5,
|
||||||
|
memoryUsageBytes: 420000000,
|
||||||
|
config: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
admin: {
|
||||||
|
username: 'admin',
|
||||||
|
description: 'NaxOS Appliance Administrator',
|
||||||
|
isAdmin: true,
|
||||||
|
sshKeys: [],
|
||||||
|
smbAccess: true,
|
||||||
|
},
|
||||||
|
lukas: {
|
||||||
|
username: 'lukas',
|
||||||
|
description: 'Lukas Holzner',
|
||||||
|
isAdmin: true,
|
||||||
|
sshKeys: ['ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... lukas@lholz.de'],
|
||||||
|
smbAccess: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
gitops: {
|
||||||
|
remoteUrl: 'ssh://git@git.lholz.de:2222/naxos/naxos-config.git',
|
||||||
|
branch: 'main',
|
||||||
|
autoPushOnCommit: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runGit(args: string[]): Promise<string> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync('git', args, { cwd: this.repoDir });
|
||||||
|
return stdout.trim();
|
||||||
|
} catch (err: any) {
|
||||||
|
throw new Error(`Git error (git ${args.join(' ')}): ${err.stderr || err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async init(): Promise<void> {
|
||||||
|
await fs.mkdir(this.repoDir, { recursive: true });
|
||||||
|
try {
|
||||||
|
await this.runGit(['rev-parse', '--is-inside-work-tree']);
|
||||||
|
} catch {
|
||||||
|
await this.runGit(['init', '-b', 'main']);
|
||||||
|
await this.runGit(['config', 'user.name', 'NaxOS Daemon']);
|
||||||
|
await this.runGit(['config', 'user.email', 'daemon@naxos.local']);
|
||||||
|
await this.saveConfigToFile();
|
||||||
|
await this.renderNixOSModule();
|
||||||
|
await this.runGit(['add', '.']);
|
||||||
|
await this.runGit(['commit', '-m', 'chore: initialize NaxOS declarative GitOps repository']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getConfig(): NaxosConfig {
|
||||||
|
return this.currentConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async saveConfigToFile(): Promise<void> {
|
||||||
|
const configPath = path.join(this.repoDir, 'naxos-config.json');
|
||||||
|
await fs.writeFile(configPath, JSON.stringify(this.currentConfig, null, 2), 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translates the in-memory NaxosConfig directly into declarative NixOS module syntax!
|
||||||
|
*/
|
||||||
|
async renderNixOSModule(): Promise<string> {
|
||||||
|
const c = this.currentConfig;
|
||||||
|
const moduleContent = `# Autogenerated by NaxOS Management Daemon
|
||||||
|
# Do not edit directly; modify via NaxOS Web Dashboard or GitOps repository.
|
||||||
|
|
||||||
|
{ config, pkgs, lib, ... }:
|
||||||
|
|
||||||
|
{
|
||||||
|
services.naxos.core = {
|
||||||
|
enable = true;
|
||||||
|
hostName = "${c.core.hostname}";
|
||||||
|
timeZone = "${c.core.timezone}";
|
||||||
|
};
|
||||||
|
|
||||||
|
services.naxos.storage = {
|
||||||
|
enable = true;
|
||||||
|
arcMaxBytes = ${c.storage.arcMaxBytes};
|
||||||
|
autoScrub.enable = ${c.storage.autoScrub};
|
||||||
|
autoTrim.enable = ${c.storage.autoTrim};
|
||||||
|
importExistingPools = [ ${c.storage.importExistingPools.map((p) => `"${p}"`).join(' ')} ];
|
||||||
|
};
|
||||||
|
|
||||||
|
services.naxos.shares.samba = {
|
||||||
|
enable = ${c.shares.samba.enable};
|
||||||
|
workgroup = "${c.shares.samba.workgroup}";
|
||||||
|
shares = {
|
||||||
|
${Object.entries(c.shares.samba.shares)
|
||||||
|
.map(
|
||||||
|
([name, s]) => `
|
||||||
|
"${name}" = {
|
||||||
|
path = "${s.path}";
|
||||||
|
readOnly = ${s.readOnly};
|
||||||
|
browseable = ${s.browseable};
|
||||||
|
guestOk = ${s.guestOk};
|
||||||
|
validUsers = [ ${s.validUsers.map((u) => `"${u}"`).join(' ')} ];
|
||||||
|
timeMachine = ${s.timeMachine};
|
||||||
|
${s.timeMachineMaxSize ? `timeMachineMaxSize = "${s.timeMachineMaxSize}";` : ''}
|
||||||
|
};`
|
||||||
|
)
|
||||||
|
.join('')}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
services.naxos.shares.nfs = {
|
||||||
|
enable = ${c.shares.nfs.enable};
|
||||||
|
exports = [
|
||||||
|
${c.shares.nfs.exports
|
||||||
|
.map(
|
||||||
|
(exp) => `
|
||||||
|
{
|
||||||
|
path = "${exp.path}";
|
||||||
|
clients = [
|
||||||
|
${exp.clients.map((cli) => `{ subnet = "${cli.subnet}"; options = "${cli.options}"; }`).join('\n ')}
|
||||||
|
];
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
.join('')}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
services.naxos.appEngine = {
|
||||||
|
enable = true;
|
||||||
|
defaultRuntime = "${c.appEngine.defaultRuntime}";
|
||||||
|
};
|
||||||
|
|
||||||
|
services.naxos.gitops = {
|
||||||
|
enable = true;
|
||||||
|
${c.gitops.remoteUrl ? `remoteUrl = "${c.gitops.remoteUrl}";` : ''}
|
||||||
|
branch = "${c.gitops.branch}";
|
||||||
|
autoPushOnCommit = ${c.gitops.autoPushOnCommit};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const targetFile = path.join(this.repoDir, 'generated-naxos-config.nix');
|
||||||
|
await fs.writeFile(targetFile, moduleContent, 'utf-8');
|
||||||
|
return moduleContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateConfig(
|
||||||
|
partialConfig: Partial<NaxosConfig>,
|
||||||
|
commitMessage: string
|
||||||
|
): Promise<{ commitSha: string; pushed: boolean }> {
|
||||||
|
this.currentConfig = {
|
||||||
|
...this.currentConfig,
|
||||||
|
...partialConfig,
|
||||||
|
core: { ...this.currentConfig.core, ...(partialConfig.core || {}) },
|
||||||
|
storage: { ...this.currentConfig.storage, ...(partialConfig.storage || {}) },
|
||||||
|
shares: { ...this.currentConfig.shares, ...(partialConfig.shares || {}) },
|
||||||
|
appEngine: { ...this.currentConfig.appEngine, ...(partialConfig.appEngine || {}) },
|
||||||
|
gitops: { ...this.currentConfig.gitops, ...(partialConfig.gitops || {}) },
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.saveConfigToFile();
|
||||||
|
await this.renderNixOSModule();
|
||||||
|
|
||||||
|
await this.runGit(['add', '.']);
|
||||||
|
await this.runGit(['commit', '-m', commitMessage]);
|
||||||
|
const commitSha = await this.runGit(['rev-parse', 'HEAD']);
|
||||||
|
|
||||||
|
let pushed = false;
|
||||||
|
if (this.currentConfig.gitops.autoPushOnCommit && this.currentConfig.gitops.remoteUrl) {
|
||||||
|
try {
|
||||||
|
await this.runGit(['push', 'origin', this.currentConfig.gitops.branch]);
|
||||||
|
pushed = true;
|
||||||
|
} catch (e) {
|
||||||
|
// Remote push failed or remote not configured yet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { commitSha, pushed };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStatus(): Promise<GitOpsStatus> {
|
||||||
|
try {
|
||||||
|
const lastCommitSha = await this.runGit(['rev-parse', 'HEAD']);
|
||||||
|
const lastCommitMessage = await this.runGit(['log', '-1', '--pretty=%B']);
|
||||||
|
const statusOutput = await this.runGit(['status', '--porcelain']);
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
remoteUrl: this.currentConfig.gitops.remoteUrl,
|
||||||
|
branch: this.currentConfig.gitops.branch,
|
||||||
|
lastCommitSha,
|
||||||
|
lastCommitMessage: lastCommitMessage.trim(),
|
||||||
|
lastSyncTime: new Date().toISOString(),
|
||||||
|
pendingChanges: statusOutput.length > 0,
|
||||||
|
isClean: statusOutput.length === 0,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
enabled: false,
|
||||||
|
branch: 'main',
|
||||||
|
lastCommitSha: '0000000000000000000000000000000000000000',
|
||||||
|
lastCommitMessage: 'Uninitialized',
|
||||||
|
pendingChanges: false,
|
||||||
|
isClean: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCommits(limit = 20): Promise<GitCommit[]> {
|
||||||
|
try {
|
||||||
|
const output = await this.runGit([
|
||||||
|
'log',
|
||||||
|
`-${limit}`,
|
||||||
|
'--pretty=format:%H%x09%an%x09%ad%x09%s',
|
||||||
|
'--date=iso',
|
||||||
|
]);
|
||||||
|
if (!output) return [];
|
||||||
|
return output.split('\n').map((line) => {
|
||||||
|
const [hash, author, date, message] = line.split('\t');
|
||||||
|
return { hash, author, date, message };
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async rollbackToCommit(commitHash: string): Promise<{ success: boolean; message: string }> {
|
||||||
|
try {
|
||||||
|
await this.runGit(['checkout', commitHash, '--', 'naxos-config.json']);
|
||||||
|
const raw = await fs.readFile(path.join(this.repoDir, 'naxos-config.json'), 'utf-8');
|
||||||
|
this.currentConfig = JSON.parse(raw);
|
||||||
|
await this.renderNixOSModule();
|
||||||
|
await this.runGit(['add', '.']);
|
||||||
|
await this.runGit(['commit', '-m', `revert: rolled back configuration to ${commitHash}`]);
|
||||||
|
return { success: true, message: `Successfully reverted configuration to commit ${commitHash}` };
|
||||||
|
} catch (err: any) {
|
||||||
|
throw new Error(`Rollback failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { spawn } from 'child_process';
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import { config } from '../config/index.js';
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
timestamp: string;
|
||||||
|
unit: string;
|
||||||
|
priority: 'info' | 'notice' | 'warning' | 'err' | 'crit';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LogsService {
|
||||||
|
private mockLogs: LogEntry[] = [
|
||||||
|
{
|
||||||
|
timestamp: new Date(Date.now() - 600000).toISOString(),
|
||||||
|
unit: 'zfs.target',
|
||||||
|
priority: 'info',
|
||||||
|
message: 'ZFS storage pool tank imported successfully with no errors.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamp: new Date(Date.now() - 480000).toISOString(),
|
||||||
|
unit: 'smbd.service',
|
||||||
|
priority: 'info',
|
||||||
|
message: 'smbd service started, serving 2 active shares (media, backup).',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamp: new Date(Date.now() - 320000).toISOString(),
|
||||||
|
unit: 'immich.service',
|
||||||
|
priority: 'info',
|
||||||
|
message: 'Immich microservices started on port 2283. Intel QSV acceleration enabled.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamp: new Date(Date.now() - 150000).toISOString(),
|
||||||
|
unit: 'naxos-api.service',
|
||||||
|
priority: 'info',
|
||||||
|
message: 'NaxOS declarative management daemon operational on 127.0.0.1:8088.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamp: new Date(Date.now() - 30000).toISOString(),
|
||||||
|
unit: 'perses.service',
|
||||||
|
priority: 'info',
|
||||||
|
message: 'Perses telemetry server running. ZFS health dashboards provisioned.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
getRecentLogs(limit = 100, unit?: string): LogEntry[] {
|
||||||
|
let logs = [...this.mockLogs];
|
||||||
|
if (unit) {
|
||||||
|
logs = logs.filter((l) => l.unit.includes(unit));
|
||||||
|
}
|
||||||
|
return logs.slice(-limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
streamLogs(unit?: string): EventEmitter {
|
||||||
|
const emitter = new EventEmitter();
|
||||||
|
|
||||||
|
if (!config.isLinux) {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const units = ['zfs.target', 'smbd.service', 'naxos-api.service', 'immich.service'];
|
||||||
|
const randomUnit = units[Math.floor(Math.random() * units.length)];
|
||||||
|
const entry: LogEntry = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
unit: randomUnit,
|
||||||
|
priority: 'info',
|
||||||
|
message: `Heartbeat check passed for ${randomUnit}. Status OK.`,
|
||||||
|
};
|
||||||
|
emitter.emit('log', entry);
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
emitter.on('close', () => clearInterval(interval));
|
||||||
|
return emitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = ['-f', '-o', 'json', '-n', '50'];
|
||||||
|
if (unit) args.push('-u', unit);
|
||||||
|
|
||||||
|
const proc = spawn('journalctl', args);
|
||||||
|
|
||||||
|
proc.stdout.on('data', (data) => {
|
||||||
|
const lines = data.toString().split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(line);
|
||||||
|
const entry: LogEntry = {
|
||||||
|
timestamp: new Date(parseInt(parsed.__REALTIME_TIMESTAMP, 10) / 1000).toISOString(),
|
||||||
|
unit: parsed._SYSTEMD_UNIT || parsed.SYSLOG_IDENTIFIER || 'system',
|
||||||
|
priority: (parsed.PRIORITY <= 3 ? 'err' : parsed.PRIORITY <= 4 ? 'warning' : 'info') as any,
|
||||||
|
message: parsed.MESSAGE || '',
|
||||||
|
};
|
||||||
|
emitter.emit('log', entry);
|
||||||
|
} catch {
|
||||||
|
// ignore non-json chunk
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
emitter.on('close', () => {
|
||||||
|
proc.kill();
|
||||||
|
});
|
||||||
|
|
||||||
|
return emitter;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { SystemStatus } from '../types/index.js';
|
||||||
|
import type { ZfsService } from './zfs.service.js';
|
||||||
|
import type { GitOpsService } from './gitops.service.js';
|
||||||
|
|
||||||
|
export class PersesService {
|
||||||
|
constructor(
|
||||||
|
private zfsService: ZfsService,
|
||||||
|
private gitopsService: GitOpsService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getSystemStatus(): Promise<SystemStatus> {
|
||||||
|
const pools = await this.zfsService.listPools();
|
||||||
|
const config = this.gitopsService.getConfig();
|
||||||
|
const sharesCount = Object.keys(config.shares.samba.shares).length + config.shares.nfs.exports.length;
|
||||||
|
const runningApps = Object.values(config.appEngine.apps).filter((a) => a.status === 'running').length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
hostname: config.core.hostname,
|
||||||
|
uptimeSeconds: 864200,
|
||||||
|
cpuUsagePercent: 12.4,
|
||||||
|
memoryTotalBytes: 33554432000, // 32 GB
|
||||||
|
memoryUsedBytes: 14200000000,
|
||||||
|
arcSizeBytes: 4294967296, // 4 GB
|
||||||
|
arcHitRatioPercent: 98.6,
|
||||||
|
zfsPoolsCount: pools.length,
|
||||||
|
activeSharesCount: sharesCount,
|
||||||
|
runningAppsCount: runningApps,
|
||||||
|
osVersion: 'NaxOS 24.11 (NixOS Vicuna)',
|
||||||
|
nixosGeneration: 42,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getDashboards() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'zfs-storage-health',
|
||||||
|
name: 'ZFS Storage & System Health',
|
||||||
|
project: 'naxos',
|
||||||
|
description: 'Native NaxOS analytics powered directly by Perses',
|
||||||
|
panels: [
|
||||||
|
{
|
||||||
|
title: 'CPU Utilization',
|
||||||
|
type: 'TimeSeriesChart',
|
||||||
|
query: "100 - (avg by (instance) (rate(node_cpu_seconds_total{mode='idle'}[1m])) * 100)",
|
||||||
|
unit: '%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Memory & ARC Cache Size',
|
||||||
|
type: 'TimeSeriesChart',
|
||||||
|
query: 'node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes',
|
||||||
|
unit: 'bytes',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'ZFS ARC Hit Ratio',
|
||||||
|
type: 'Gauge',
|
||||||
|
query: 'zfs_arc_hits / (zfs_arc_hits + zfs_arc_misses) * 100',
|
||||||
|
unit: '%',
|
||||||
|
value: 98.6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Storage Pool Throughput',
|
||||||
|
type: 'TimeSeriesChart',
|
||||||
|
query: 'rate(node_disk_read_bytes_total[1m]) + rate(node_disk_written_bytes_total[1m])',
|
||||||
|
unit: 'Bps',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { spawn } from 'child_process';
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import { config } from '../config/index.js';
|
||||||
|
|
||||||
|
export interface RebuildEvent {
|
||||||
|
type: 'stdout' | 'stderr' | 'done' | 'error';
|
||||||
|
data: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RebuildService extends EventEmitter {
|
||||||
|
private isBuilding = false;
|
||||||
|
|
||||||
|
getBuildingStatus(): boolean {
|
||||||
|
return this.isBuilding;
|
||||||
|
}
|
||||||
|
|
||||||
|
async triggerSwitch(): Promise<ReadableStream<string> | EventEmitter> {
|
||||||
|
if (this.isBuilding) {
|
||||||
|
throw new Error('A rebuild is already in progress');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isBuilding = true;
|
||||||
|
const emitter = new EventEmitter();
|
||||||
|
|
||||||
|
if (!config.isLinux) {
|
||||||
|
// Mock build stream for development / testing environments
|
||||||
|
setTimeout(() => {
|
||||||
|
emitter.emit('data', '[dry-run] building NixOS configuration...\n');
|
||||||
|
}, 200);
|
||||||
|
setTimeout(() => {
|
||||||
|
emitter.emit('data', '[dry-run] evaluating flake /etc/naxos/repo...\n');
|
||||||
|
}, 500);
|
||||||
|
setTimeout(() => {
|
||||||
|
emitter.emit('data', '[switch] activating system configuration...\n');
|
||||||
|
}, 900);
|
||||||
|
setTimeout(() => {
|
||||||
|
emitter.emit('data', '[canary] checking system services... SUCCESS\n');
|
||||||
|
emitter.emit('data', 'NaxOS switch finished successfully.\n');
|
||||||
|
this.isBuilding = false;
|
||||||
|
emitter.emit('end');
|
||||||
|
}, 1400);
|
||||||
|
|
||||||
|
return emitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
const proc = spawn('nixos-rebuild-safe', [], {
|
||||||
|
cwd: config.configRepoDir,
|
||||||
|
env: { ...process.env, PATH: `${process.env.PATH}:/run/current-system/sw/bin` },
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.stdout.on('data', (data) => {
|
||||||
|
emitter.emit('data', data.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.stderr.on('data', (data) => {
|
||||||
|
emitter.emit('data', data.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on('close', (code) => {
|
||||||
|
this.isBuilding = false;
|
||||||
|
if (code === 0) {
|
||||||
|
emitter.emit('data', '\nRebuild and switch completed successfully.\n');
|
||||||
|
} else {
|
||||||
|
emitter.emit('data', `\nRebuild failed with exit code ${code}\n`);
|
||||||
|
}
|
||||||
|
emitter.emit('end');
|
||||||
|
});
|
||||||
|
|
||||||
|
return emitter;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { SmbShare, NfsExport } from '../types/index.js';
|
||||||
|
import type { GitOpsService } from './gitops.service.js';
|
||||||
|
|
||||||
|
export class SharesService {
|
||||||
|
constructor(private gitopsService: GitOpsService) {}
|
||||||
|
|
||||||
|
getSmbShares(): Record<string, SmbShare> {
|
||||||
|
return this.gitopsService.getConfig().shares.samba.shares;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveSmbShare(share: SmbShare): Promise<SmbShare> {
|
||||||
|
const config = this.gitopsService.getConfig();
|
||||||
|
config.shares.samba.shares[share.name] = share;
|
||||||
|
await this.gitopsService.updateConfig(
|
||||||
|
{ shares: config.shares },
|
||||||
|
`feat(shares): configured SMB share '${share.name}' at ${share.path}`
|
||||||
|
);
|
||||||
|
return share;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSmbShare(shareName: string): Promise<{ success: boolean; message: string }> {
|
||||||
|
const config = this.gitopsService.getConfig();
|
||||||
|
if (!config.shares.samba.shares[shareName]) {
|
||||||
|
throw new Error(`SMB share '${shareName}' does not exist.`);
|
||||||
|
}
|
||||||
|
delete config.shares.samba.shares[shareName];
|
||||||
|
await this.gitopsService.updateConfig(
|
||||||
|
{ shares: config.shares },
|
||||||
|
`feat(shares): removed SMB share '${shareName}'`
|
||||||
|
);
|
||||||
|
return { success: true, message: `SMB share '${shareName}' deleted.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
getNfsExports(): NfsExport[] {
|
||||||
|
return this.gitopsService.getConfig().shares.nfs.exports;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveNfsExport(nfsExport: NfsExport): Promise<NfsExport> {
|
||||||
|
const config = this.gitopsService.getConfig();
|
||||||
|
const existingIndex = config.shares.nfs.exports.findIndex((e) => e.path === nfsExport.path);
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
config.shares.nfs.exports[existingIndex] = nfsExport;
|
||||||
|
} else {
|
||||||
|
config.shares.nfs.exports.push(nfsExport);
|
||||||
|
}
|
||||||
|
await this.gitopsService.updateConfig(
|
||||||
|
{ shares: config.shares },
|
||||||
|
`feat(shares): configured NFS export for '${nfsExport.path}'`
|
||||||
|
);
|
||||||
|
return nfsExport;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteNfsExport(exportPath: string): Promise<{ success: boolean; message: string }> {
|
||||||
|
const config = this.gitopsService.getConfig();
|
||||||
|
config.shares.nfs.exports = config.shares.nfs.exports.filter((e) => e.path !== exportPath);
|
||||||
|
await this.gitopsService.updateConfig(
|
||||||
|
{ shares: config.shares },
|
||||||
|
`feat(shares): removed NFS export for '${exportPath}'`
|
||||||
|
);
|
||||||
|
return { success: true, message: `NFS export for '${exportPath}' deleted.` };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { SystemUser } from '../types/index.js';
|
||||||
|
import type { GitOpsService } from './gitops.service.js';
|
||||||
|
|
||||||
|
export class UsersService {
|
||||||
|
constructor(private gitopsService: GitOpsService) {}
|
||||||
|
|
||||||
|
getUsers(): Record<string, SystemUser> {
|
||||||
|
return this.gitopsService.getConfig().users;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveUser(user: SystemUser): Promise<SystemUser> {
|
||||||
|
const config = this.gitopsService.getConfig();
|
||||||
|
config.users[user.username] = user;
|
||||||
|
await this.gitopsService.updateConfig(
|
||||||
|
{ users: config.users },
|
||||||
|
`feat(users): updated account details for '${user.username}'`
|
||||||
|
);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteUser(username: string): Promise<{ success: boolean; message: string }> {
|
||||||
|
const config = this.gitopsService.getConfig();
|
||||||
|
if (username === 'admin') {
|
||||||
|
throw new Error('The primary appliance administrator account cannot be removed.');
|
||||||
|
}
|
||||||
|
if (!config.users[username]) {
|
||||||
|
throw new Error(`User '${username}' does not exist.`);
|
||||||
|
}
|
||||||
|
delete config.users[username];
|
||||||
|
await this.gitopsService.updateConfig(
|
||||||
|
{ users: config.users },
|
||||||
|
`feat(users): removed account '${username}'`
|
||||||
|
);
|
||||||
|
return { success: true, message: `User '${username}' deleted.` };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.` };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
export type ZfsLayout = 'stripe' | 'mirror' | 'raidz1' | 'raidz2' | 'raidz3';
|
||||||
|
|
||||||
|
export interface ZfsDisk {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
size: string;
|
||||||
|
health: string;
|
||||||
|
readErrors: number;
|
||||||
|
writeErrors: number;
|
||||||
|
checksumErrors: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZfsVdev {
|
||||||
|
name: string;
|
||||||
|
type: ZfsLayout | 'disk';
|
||||||
|
health: string;
|
||||||
|
disks: ZfsDisk[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZfsPool {
|
||||||
|
name: string;
|
||||||
|
size: string;
|
||||||
|
allocated: string;
|
||||||
|
free: string;
|
||||||
|
fragmentation: string;
|
||||||
|
capacityPercent: number;
|
||||||
|
health: 'ONLINE' | 'DEGRADED' | 'FAULTED' | 'OFFLINE' | 'UNAVAIL';
|
||||||
|
altroot: string;
|
||||||
|
vdevs: ZfsVdev[];
|
||||||
|
scanStatus?: string;
|
||||||
|
scrubProgress?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZfsDataset {
|
||||||
|
name: string;
|
||||||
|
pool: string;
|
||||||
|
used: string;
|
||||||
|
available: string;
|
||||||
|
referenced: string;
|
||||||
|
mountpoint: string;
|
||||||
|
compression: string;
|
||||||
|
quota: string;
|
||||||
|
reservation: string;
|
||||||
|
recordsize: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZfsSnapshot {
|
||||||
|
name: string;
|
||||||
|
dataset: string;
|
||||||
|
snapshotTag: string;
|
||||||
|
creationTime: string;
|
||||||
|
usedBytes: string;
|
||||||
|
referencedBytes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnimportedPool {
|
||||||
|
name: string;
|
||||||
|
id: string;
|
||||||
|
state: string;
|
||||||
|
status: string;
|
||||||
|
action?: string;
|
||||||
|
disks: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SmbShare {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
comment?: string;
|
||||||
|
readOnly: boolean;
|
||||||
|
browseable: boolean;
|
||||||
|
guestOk: boolean;
|
||||||
|
validUsers: string[];
|
||||||
|
forceUser?: string;
|
||||||
|
forceGroup?: string;
|
||||||
|
timeMachine: boolean;
|
||||||
|
timeMachineMaxSize?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NfsExportClient {
|
||||||
|
subnet: string;
|
||||||
|
options: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NfsExport {
|
||||||
|
path: string;
|
||||||
|
clients: NfsExportClient[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemUser {
|
||||||
|
username: string;
|
||||||
|
description?: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
sshKeys: string[];
|
||||||
|
smbAccess: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppRuntime = 'systemd' | 'docker' | 'k3s';
|
||||||
|
|
||||||
|
export interface AppCatalogItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
category: 'Media' | 'Storage' | 'Productivity' | 'Security' | 'Automation';
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
defaultPort: number;
|
||||||
|
supportedRuntimes: AppRuntime[];
|
||||||
|
recommendedRuntime: AppRuntime;
|
||||||
|
envVariables?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstalledApp {
|
||||||
|
id: string;
|
||||||
|
appId: string;
|
||||||
|
name: string;
|
||||||
|
runtime: AppRuntime;
|
||||||
|
status: 'running' | 'stopped' | 'errored' | 'starting';
|
||||||
|
port: number;
|
||||||
|
version: string;
|
||||||
|
cpuUsagePercent: number;
|
||||||
|
memoryUsageBytes: number;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitOpsStatus {
|
||||||
|
enabled: boolean;
|
||||||
|
remoteUrl?: string;
|
||||||
|
branch: string;
|
||||||
|
lastCommitSha: string;
|
||||||
|
lastCommitMessage: string;
|
||||||
|
lastSyncTime?: string;
|
||||||
|
pendingChanges: boolean;
|
||||||
|
isClean: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitCommit {
|
||||||
|
hash: string;
|
||||||
|
author: string;
|
||||||
|
date: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemStatus {
|
||||||
|
hostname: string;
|
||||||
|
uptimeSeconds: number;
|
||||||
|
cpuUsagePercent: number;
|
||||||
|
memoryTotalBytes: number;
|
||||||
|
memoryUsedBytes: number;
|
||||||
|
arcSizeBytes: number;
|
||||||
|
arcHitRatioPercent: number;
|
||||||
|
zfsPoolsCount: number;
|
||||||
|
activeSharesCount: number;
|
||||||
|
runningAppsCount: number;
|
||||||
|
osVersion: string;
|
||||||
|
nixosGeneration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NaxosConfig {
|
||||||
|
core: {
|
||||||
|
hostname: string;
|
||||||
|
timezone: string;
|
||||||
|
};
|
||||||
|
storage: {
|
||||||
|
arcMaxBytes: number;
|
||||||
|
autoScrub: boolean;
|
||||||
|
autoTrim: boolean;
|
||||||
|
pools: Record<string, {
|
||||||
|
layout: ZfsLayout;
|
||||||
|
devices: string[];
|
||||||
|
ashift: number;
|
||||||
|
datasets: Record<string, {
|
||||||
|
mountpoint: string;
|
||||||
|
compression: string;
|
||||||
|
recordsize: string;
|
||||||
|
quota?: string;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
importExistingPools: string[];
|
||||||
|
};
|
||||||
|
shares: {
|
||||||
|
samba: {
|
||||||
|
enable: boolean;
|
||||||
|
workgroup: string;
|
||||||
|
shares: Record<string, SmbShare>;
|
||||||
|
};
|
||||||
|
nfs: {
|
||||||
|
enable: boolean;
|
||||||
|
exports: NfsExport[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
appEngine: {
|
||||||
|
defaultRuntime: AppRuntime;
|
||||||
|
apps: Record<string, InstalledApp>;
|
||||||
|
};
|
||||||
|
users: Record<string, SystemUser>;
|
||||||
|
gitops: {
|
||||||
|
remoteUrl?: string;
|
||||||
|
branch: string;
|
||||||
|
autoPushOnCommit: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { GitOpsService } from '../src/services/gitops.service.js';
|
||||||
|
import { AppsService } from '../src/services/apps.service.js';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
|
||||||
|
describe('AppsService', () => {
|
||||||
|
let gitops: GitOpsService;
|
||||||
|
let apps: AppsService;
|
||||||
|
let testRepoDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
testRepoDir = path.join(os.tmpdir(), `naxos-apps-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||||
|
gitops = new GitOpsService(testRepoDir);
|
||||||
|
await gitops.init();
|
||||||
|
apps = new AppsService(gitops);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
try {
|
||||||
|
await fs.rm(testRepoDir, { recursive: true, force: true });
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the curated app catalog', () => {
|
||||||
|
const catalog = apps.getCatalog();
|
||||||
|
expect(catalog.length).toBeGreaterThanOrEqual(5);
|
||||||
|
const immich = catalog.find((c) => c.id === 'immich');
|
||||||
|
expect(immich).toBeDefined();
|
||||||
|
expect(immich?.recommendedRuntime).toBe('systemd');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should install app into active workloads', async () => {
|
||||||
|
const installed = await apps.installApp({ appId: 'vaultwarden' });
|
||||||
|
expect(installed.appId).toBe('vaultwarden');
|
||||||
|
expect(installed.status).toBe('running');
|
||||||
|
|
||||||
|
const list = apps.getInstalledApps();
|
||||||
|
expect(list.some((a) => a.appId === 'vaultwarden')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { GitOpsService } from '../src/services/gitops.service.js';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
|
||||||
|
describe('GitOpsService', () => {
|
||||||
|
let gitops: GitOpsService;
|
||||||
|
let testRepoDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
testRepoDir = path.join(os.tmpdir(), `naxos-gitops-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||||
|
gitops = new GitOpsService(testRepoDir);
|
||||||
|
await gitops.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
try {
|
||||||
|
await fs.rm(testRepoDir, { recursive: true, force: true });
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize and produce valid GitOps status', async () => {
|
||||||
|
const status = await gitops.getStatus();
|
||||||
|
expect(status.enabled).toBe(true);
|
||||||
|
expect(status.branch).toBe('main');
|
||||||
|
expect(status.lastCommitSha).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should translate declarative configuration into valid NixOS module code', async () => {
|
||||||
|
const nixCode = await gitops.renderNixOSModule();
|
||||||
|
expect(nixCode).toContain('services.naxos.storage');
|
||||||
|
expect(nixCode).toContain('services.naxos.shares.samba');
|
||||||
|
expect(nixCode).toContain('importExistingPools');
|
||||||
|
expect(nixCode).toContain('tank');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create commits on state changes', async () => {
|
||||||
|
const res = await gitops.updateConfig(
|
||||||
|
{
|
||||||
|
core: { hostname: 'naxos-test', timezone: 'UTC' },
|
||||||
|
},
|
||||||
|
'feat(test): updated hostname'
|
||||||
|
);
|
||||||
|
expect(res.commitSha).toBeDefined();
|
||||||
|
expect(res.commitSha.length).toBeGreaterThan(10);
|
||||||
|
|
||||||
|
const commits = await gitops.getCommits(5);
|
||||||
|
expect(commits[0].message).toBe('feat(test): updated hostname');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { ZfsService } from '../src/services/zfs.service.js';
|
||||||
|
|
||||||
|
describe('ZfsService', () => {
|
||||||
|
const zfs = new ZfsService();
|
||||||
|
|
||||||
|
it('should list configured ZFS pools with health status', async () => {
|
||||||
|
const pools = await zfs.listPools();
|
||||||
|
expect(pools).toBeDefined();
|
||||||
|
expect(pools.length).toBeGreaterThan(0);
|
||||||
|
const tank = pools.find((p) => p.name === 'tank');
|
||||||
|
expect(tank).toBeDefined();
|
||||||
|
expect(tank?.health).toBe('ONLINE');
|
||||||
|
expect(tank?.capacityPercent).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should list datasets with proper mountpoint and compression', async () => {
|
||||||
|
const datasets = await zfs.listDatasets('tank');
|
||||||
|
expect(datasets.length).toBeGreaterThan(0);
|
||||||
|
const photos = datasets.find((d) => d.name === 'tank/media/photos');
|
||||||
|
expect(photos).toBeDefined();
|
||||||
|
expect(photos?.mountpoint).toBe('/tank/media/photos');
|
||||||
|
expect(photos?.compression).toBe('zstd');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should scan for unimported foreign pools without data loss', async () => {
|
||||||
|
const unimported = await zfs.scanUnimportedPools();
|
||||||
|
expect(unimported).toBeDefined();
|
||||||
|
expect(Array.isArray(unimported)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should safely import a foreign pool', async () => {
|
||||||
|
const res = await zfs.importPool({ poolName: 'truenas_pool', force: true, noMount: true });
|
||||||
|
expect(res.success).toBe(true);
|
||||||
|
const pools = await zfs.listPools();
|
||||||
|
const imported = pools.find((p) => p.name === 'truenas_pool');
|
||||||
|
expect(imported).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create and rollback snapshots', async () => {
|
||||||
|
const snapTag = `test-${Date.now()}`;
|
||||||
|
const createRes = await zfs.createSnapshot('tank/media/photos', snapTag);
|
||||||
|
expect(createRes.success).toBe(true);
|
||||||
|
|
||||||
|
const snapshots = await zfs.listSnapshots('tank/media/photos');
|
||||||
|
const created = snapshots.find((s) => s.snapshotTag === snapTag);
|
||||||
|
expect(created).toBeDefined();
|
||||||
|
|
||||||
|
const rollbackRes = await zfs.rollbackSnapshot(`tank/media/photos@${snapTag}`);
|
||||||
|
expect(rollbackRes.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "tests"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user