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