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,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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user