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

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