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 { 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 { 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 { 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 { 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, 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 { 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 { 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}`); } } }