From a698d00c30a9073341a03ef5d486c2dd16971612 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Sat, 28 Jun 2025 11:42:09 +0000 Subject: [PATCH] feat: add systemd service management for auto-start functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add service configuration to PostgreSQL instance types - Implement ServiceManager class for systemd integration - Add CLI commands: enable-service, disable-service, service-status - Update instance management to support service operations - Display service status in instance list and details - Support both system and user systemd services Closes #50 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Samuel --- index.ts | 79 +++++++++++ src/config/types.ts | 11 ++ src/instance/manager.ts | 160 ++++++++++++++++++++++ src/service/manager.ts | 288 ++++++++++++++++++++++++++++++++++++++++ src/utils/display.ts | 30 ++++- 5 files changed, 566 insertions(+), 2 deletions(-) create mode 100644 src/service/manager.ts diff --git a/index.ts b/index.ts index 64a2abc..1411949 100644 --- a/index.ts +++ b/index.ts @@ -471,6 +471,85 @@ program } }); +// Enable service command +program + .command('enable-service ') + .description('enable auto-start service for a PostgreSQL instance') + .option('--user', 'use user systemd service instead of system service') + .action(async (name, options) => { + const spinner = ora(`Enabling service for instance '${name}'...`).start(); + + try { + await instanceManager.enableService(name, options.user); + spinner.succeed(`Service enabled for instance '${name}'`); + + console.log(); + console.log(chalk.gray('The instance will now automatically start after system restart.')); + console.log(chalk.gray('Service management commands:')); + console.log(chalk.gray(` Check status: ${chalk.white('pgforge service-status ' + name)}`)); + console.log(chalk.gray(` Disable: ${chalk.white('pgforge disable-service ' + name)}`)); + + } catch (error) { + spinner.fail(`Failed to enable service: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } + }); + +// Disable service command +program + .command('disable-service ') + .description('disable auto-start service for a PostgreSQL instance') + .option('--user', 'use user systemd service instead of system service') + .action(async (name, options) => { + const spinner = ora(`Disabling service for instance '${name}'...`).start(); + + try { + await instanceManager.disableService(name, options.user); + spinner.succeed(`Service disabled for instance '${name}'`); + + console.log(); + console.log(chalk.gray('The instance will no longer automatically start after system restart.')); + console.log(chalk.gray(`Use ${chalk.white('pgforge enable-service ' + name)} to re-enable auto-start.`)); + + } catch (error) { + spinner.fail(`Failed to disable service: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } + }); + +// Service status command +program + .command('service-status ') + .description('show systemd service status for a PostgreSQL instance') + .option('--user', 'check user systemd service instead of system service') + .action(async (name, options) => { + const spinner = ora(`Checking service status for '${name}'...`).start(); + + try { + const status = await instanceManager.getServiceStatus(name, options.user); + spinner.stop(); + + console.log(chalk.bold(`Service Status for '${name}':`)); + console.log(` Service Name: ${chalk.cyan('pgforge-' + name)}`); + console.log(` Enabled: ${status.enabled ? chalk.green('Yes') : chalk.red('No')}`); + console.log(` Active: ${status.active ? chalk.green('Yes') : chalk.red('No')}`); + console.log(` Status: ${chalk.yellow(status.status)}`); + console.log(` Service Type: ${options.user ? chalk.blue('User') : chalk.blue('System')}`); + + if (status.enabled) { + console.log(); + console.log(chalk.gray('This instance will automatically start after system restart.')); + } else { + console.log(); + console.log(chalk.gray(`Use ${chalk.white('pgforge enable-service ' + name)} to enable auto-start.`)); + } + + } catch (error) { + spinner.fail(`Failed to get service status: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } + }); + // Error handling program.configureOutput({ writeErr: (str) => process.stderr.write(chalk.red(str)) diff --git a/src/config/types.ts b/src/config/types.ts index 6657495..86dcabf 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -63,6 +63,12 @@ export interface PostgreSQLInstanceConfig { format?: 'custom' | 'plain' | 'directory' | 'tar'; destination?: string; }; + service?: { + enabled: boolean; + autoStart: boolean; + restartPolicy?: 'always' | 'on-failure' | 'no'; + restartSec?: number; + }; }; status?: { state: 'stopped' | 'starting' | 'running' | 'stopping' | 'error'; @@ -72,6 +78,11 @@ export interface PostgreSQLInstanceConfig { version?: string; dataSize?: string; connections?: number; + service?: { + enabled: boolean; + active: boolean; + status?: string; + }; }; } diff --git a/src/instance/manager.ts b/src/instance/manager.ts index 1cc9222..b11ba8a 100644 --- a/src/instance/manager.ts +++ b/src/instance/manager.ts @@ -4,15 +4,18 @@ import { randomBytes } from 'crypto'; import { access, mkdir, writeFile, readFile, readdir, rmdir, unlink, stat } from 'fs/promises'; import { join, dirname } from 'path'; import { ConfigManager } from '../config/manager.js'; +import { ServiceManager } from '../service/manager.js'; import type { PostgreSQLInstanceConfig } from '../config/types.js'; const execAsync = promisify(exec); export class InstanceManager { private configManager: ConfigManager; + private serviceManager: ServiceManager; constructor() { this.configManager = new ConfigManager(); + this.serviceManager = new ServiceManager(); } async createInstance( @@ -171,6 +174,38 @@ export class InstanceManager { } } + // Check service status if service is enabled + if (config.spec.service?.enabled && await this.serviceManager.isSystemdAvailable()) { + try { + const serviceStatus = await this.serviceManager.getServiceStatus(name, false); + + // Update config with service status + if (!config.status) { + config.status = { + state: 'stopped', + version: config.spec.version, + connections: 0, + }; + } + config.status.service = serviceStatus; + + // If service is active but config shows stopped, update it + if (serviceStatus.active && config.status.state === 'stopped') { + config.status.state = 'running'; + config.status.startTime = new Date().toISOString(); + } else if (!serviceStatus.active && config.status.state === 'running') { + config.status.state = 'stopped'; + config.status.lastRestart = config.status.startTime; + config.status.startTime = undefined; + } + + await this.configManager.saveInstanceConfig(config); + } catch (error) { + // Service status check failed, don't fail the whole operation + console.warn(`Warning: Could not check service status for '${name}': ${error}`); + } + } + return config; } @@ -804,4 +839,129 @@ export class InstanceManager { // TODO: Implement backup functionality console.log('Backup functionality not yet implemented'); } + + /** + * Enable service auto-start for an instance + */ + async enableService(name: string, useUserService = false): Promise { + const config = await this.configManager.getInstanceConfig(name); + if (!config) { + throw new Error(`Instance '${name}' not found`); + } + + // Check if systemd is available + if (!await this.serviceManager.isSystemdAvailable()) { + throw new Error('systemd is not available on this system. Service management requires systemd.'); + } + + // Update configuration to enable service + config.spec.service = { + enabled: true, + autoStart: true, + restartPolicy: 'on-failure', + restartSec: 5, + ...config.spec.service + }; + + // Enable the service + await this.serviceManager.enableService(config, useUserService); + + // Update instance configuration + await this.configManager.saveInstanceConfig(config); + + console.log(`Service auto-start enabled for instance '${name}'`); + } + + /** + * Disable service auto-start for an instance + */ + async disableService(name: string, useUserService = false): Promise { + const config = await this.configManager.getInstanceConfig(name); + if (!config) { + throw new Error(`Instance '${name}' not found`); + } + + // Disable the service + await this.serviceManager.disableService(name, useUserService); + + // Update configuration to disable service + if (config.spec.service) { + config.spec.service.enabled = false; + config.spec.service.autoStart = false; + } + + // Update instance configuration + await this.configManager.saveInstanceConfig(config); + + console.log(`Service auto-start disabled for instance '${name}'`); + } + + /** + * Get service status for an instance + */ + async getServiceStatus(name: string, useUserService = false): Promise<{ + enabled: boolean; + active: boolean; + status: string; + }> { + return await this.serviceManager.getServiceStatus(name, useUserService); + } + + /** + * Start instance using service (if enabled) or direct process + */ + async startInstanceWithService(name: string, useUserService = false): Promise { + const config = await this.configManager.getInstanceConfig(name); + if (!config) { + throw new Error(`Instance '${name}' not found`); + } + + if (config.spec.service?.enabled && await this.serviceManager.isSystemdAvailable()) { + // Start using systemd service + await this.serviceManager.startService(name, useUserService); + + // Wait for service to start and update status + await new Promise(resolve => setTimeout(resolve, 2000)); + const serviceStatus = await this.serviceManager.getServiceStatus(name, useUserService); + + config.status = { + state: serviceStatus.active ? 'running' : 'stopped', + startTime: serviceStatus.active ? new Date().toISOString() : undefined, + version: config.spec.version, + connections: 0, + service: serviceStatus, + }; + } else { + // Start using direct process (existing method) + await this.startInstance(name); + } + } + + /** + * Stop instance using service (if enabled) or direct process + */ + async stopInstanceWithService(name: string, useUserService = false): Promise { + const config = await this.configManager.getInstanceConfig(name); + if (!config) { + throw new Error(`Instance '${name}' not found`); + } + + if (config.spec.service?.enabled && await this.serviceManager.isSystemdAvailable()) { + // Stop using systemd service + await this.serviceManager.stopService(name, useUserService); + + // Update status + const serviceStatus = await this.serviceManager.getServiceStatus(name, useUserService); + config.status = { + state: 'stopped', + lastRestart: config.status?.startTime, + version: config.spec.version, + connections: 0, + service: serviceStatus, + }; + } else { + // Stop using direct process (existing method) + await this.stopInstance(name); + } + } } \ No newline at end of file diff --git a/src/service/manager.ts b/src/service/manager.ts new file mode 100644 index 0000000..23d308d --- /dev/null +++ b/src/service/manager.ts @@ -0,0 +1,288 @@ +import { exec, execSync } from 'child_process'; +import { promisify } from 'util'; +import { writeFile, readFile, access, unlink } from 'fs/promises'; +import { join } from 'path'; +import type { PostgreSQLInstanceConfig } from '../config/types.js'; + +const execAsync = promisify(exec); + +export class ServiceManager { + private systemdPath = '/etc/systemd/system'; + private userSystemdPath: string; + + constructor() { + // User systemd services path + this.userSystemdPath = join(process.env.HOME || '/home/' + process.env.USER, '.config/systemd/user'); + } + + /** + * Check if systemd is available on the system + */ + async isSystemdAvailable(): Promise { + try { + await execAsync('systemctl --version'); + return true; + } catch { + return false; + } + } + + /** + * Enable service auto-start for an instance + */ + async enableService(config: PostgreSQLInstanceConfig, useUserService = false): Promise { + if (!await this.isSystemdAvailable()) { + throw new Error('systemd is not available on this system'); + } + + const serviceName = this.getServiceName(config.metadata.name); + const serviceFilePath = await this.createServiceFile(config, useUserService); + + try { + if (useUserService) { + // Enable user service + await execAsync('systemctl --user daemon-reload'); + await execAsync(`systemctl --user enable ${serviceName}`); + } else { + // Enable system service (requires sudo) + await execAsync('sudo systemctl daemon-reload'); + await execAsync(`sudo systemctl enable ${serviceName}`); + } + + console.log(`Service ${serviceName} enabled successfully`); + } catch (error) { + // Clean up service file if enable failed + try { + await unlink(serviceFilePath); + } catch {} + throw new Error(`Failed to enable service: ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * Disable service auto-start for an instance + */ + async disableService(instanceName: string, useUserService = false): Promise { + const serviceName = this.getServiceName(instanceName); + + try { + if (useUserService) { + await execAsync(`systemctl --user disable ${serviceName}`); + await execAsync('systemctl --user daemon-reload'); + } else { + await execAsync(`sudo systemctl disable ${serviceName}`); + await execAsync('sudo systemctl daemon-reload'); + } + + // Remove service file + const serviceFilePath = useUserService + ? join(this.userSystemdPath, `${serviceName}.service`) + : join(this.systemdPath, `${serviceName}.service`); + + try { + if (useUserService) { + await unlink(serviceFilePath); + } else { + await execAsync(`sudo rm -f "${serviceFilePath}"`); + } + } catch { + // Service file might not exist, ignore error + } + + console.log(`Service ${serviceName} disabled successfully`); + } catch (error) { + throw new Error(`Failed to disable service: ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * Start service + */ + async startService(instanceName: string, useUserService = false): Promise { + const serviceName = this.getServiceName(instanceName); + + try { + if (useUserService) { + await execAsync(`systemctl --user start ${serviceName}`); + } else { + await execAsync(`sudo systemctl start ${serviceName}`); + } + } catch (error) { + throw new Error(`Failed to start service: ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * Stop service + */ + async stopService(instanceName: string, useUserService = false): Promise { + const serviceName = this.getServiceName(instanceName); + + try { + if (useUserService) { + await execAsync(`systemctl --user stop ${serviceName}`); + } else { + await execAsync(`sudo systemctl stop ${serviceName}`); + } + } catch (error) { + throw new Error(`Failed to stop service: ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * Get service status + */ + async getServiceStatus(instanceName: string, useUserService = false): Promise<{ + enabled: boolean; + active: boolean; + status: string; + }> { + const serviceName = this.getServiceName(instanceName); + const systemctlCmd = useUserService ? 'systemctl --user' : 'systemctl'; + + try { + // Check if service is enabled + let enabled = false; + try { + const { stdout: enabledOutput } = await execAsync(`${systemctlCmd} is-enabled ${serviceName}`); + enabled = enabledOutput.trim() === 'enabled'; + } catch { + // Service not enabled or doesn't exist + } + + // Check if service is active + let active = false; + let status = 'inactive'; + try { + const { stdout: activeOutput } = await execAsync(`${systemctlCmd} is-active ${serviceName}`); + status = activeOutput.trim(); + active = status === 'active'; + } catch { + // Service not active or doesn't exist + } + + return { enabled, active, status }; + } catch (error) { + return { enabled: false, active: false, status: 'unknown' }; + } + } + + /** + * Create systemd service file for PostgreSQL instance + */ + private async createServiceFile(config: PostgreSQLInstanceConfig, useUserService = false): Promise { + const serviceName = this.getServiceName(config.metadata.name); + const serviceFilePath = useUserService + ? join(this.userSystemdPath, `${serviceName}.service`) + : join(this.systemdPath, `${serviceName}.service`); + + // Find PostgreSQL binary + const postgresPath = await this.findPostgreSQLBinary('postgres', config.spec.version); + + const serviceContent = this.generateServiceFile(config, postgresPath, useUserService); + + if (useUserService) { + // Ensure user systemd directory exists + await execAsync(`mkdir -p "${this.userSystemdPath}"`); + await writeFile(serviceFilePath, serviceContent); + } else { + // Write system service file (requires sudo) + const tempFile = `/tmp/${serviceName}.service`; + await writeFile(tempFile, serviceContent); + await execAsync(`sudo mv "${tempFile}" "${serviceFilePath}"`); + await execAsync(`sudo chown root:root "${serviceFilePath}"`); + await execAsync(`sudo chmod 644 "${serviceFilePath}"`); + } + + return serviceFilePath; + } + + /** + * Generate systemd service file content + */ + private generateServiceFile(config: PostgreSQLInstanceConfig, postgresPath: string, useUserService: boolean): string { + const restartPolicy = config.spec.service?.restartPolicy || 'on-failure'; + const restartSec = config.spec.service?.restartSec || 5; + const user = useUserService ? process.env.USER || 'postgres' : 'postgres'; + + return `[Unit] +Description=PostgreSQL database server for ${config.metadata.name} +Documentation=man:postgres(1) +After=network.target +Wants=network.target + +[Service] +Type=forking +User=${user} +ExecStart=${postgresPath} -D ${config.spec.storage.dataDirectory} +ExecReload=/bin/kill -HUP $MAINPID +KillMode=mixed +KillSignal=SIGINT +TimeoutSec=0 + +# Restart policy +Restart=${restartPolicy} +RestartSec=${restartSec} + +# Security +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ReadWritePaths=${config.spec.storage.dataDirectory} ${config.spec.storage.logDirectory} + +# Environment +Environment=PGDATA=${config.spec.storage.dataDirectory} +Environment=PGPORT=${config.spec.network.port} + +[Install] +WantedBy=${useUserService ? 'default.target' : 'multi-user.target'} +`; + } + + /** + * Get service name for an instance + */ + private getServiceName(instanceName: string): string { + return `pgforge-${instanceName}`; + } + + /** + * Find PostgreSQL binary (reused from InstanceManager) + */ + private async findPostgreSQLBinary(binary: string, version: string): Promise { + const majorVersion = version.split('.')[0]; + + const paths = [ + `/usr/lib/postgresql/${version}/bin/${binary}`, + `/usr/pgsql-${version}/bin/${binary}`, + `/opt/postgresql/${version}/bin/${binary}`, + `/usr/lib/postgresql/${majorVersion}/bin/${binary}`, + `/usr/pgsql-${majorVersion}/bin/${binary}`, + `/opt/postgresql/${majorVersion}/bin/${binary}`, + `/usr/bin/${binary}`, + `/usr/local/bin/${binary}`, + `/usr/local/pgsql/bin/${binary}`, + `/opt/postgresql/bin/${binary}`, + ]; + + for (const path of paths) { + try { + await access(path); + return path; + } catch { + continue; + } + } + + // Try using which + try { + const { stdout } = await execAsync(`which ${binary}`); + const whichResult = stdout.trim(); + if (whichResult) { + return whichResult; + } + } catch {} + + throw new Error(`PostgreSQL binary '${binary}' not found for version ${version}`); + } +} \ No newline at end of file diff --git a/src/utils/display.ts b/src/utils/display.ts index d829c8d..2906d37 100644 --- a/src/utils/display.ts +++ b/src/utils/display.ts @@ -11,10 +11,10 @@ export function displayInstanceTable(instances: PostgreSQLInstanceConfig[]): voi console.log(chalk.bold('PostgreSQL Instances:')); console.log(chalk.gray('─'.repeat(80))); - const headers = ['NAME', 'STATUS', 'VERSION', 'PORT', 'DATABASE']; + const headers = ['NAME', 'STATUS', 'VERSION', 'PORT', 'DATABASE', 'AUTO-START']; const headerRow = headers.map(h => chalk.bold(h)).join(' '); console.log(headerRow); - console.log(chalk.gray('─'.repeat(80))); + console.log(chalk.gray('─'.repeat(95))); for (const instance of instances) { const name = instance.metadata.name; @@ -22,6 +22,7 @@ export function displayInstanceTable(instances: PostgreSQLInstanceConfig[]): voi const version = instance.spec.version; const port = instance.spec.network.port.toString(); const database = instance.spec.database.name; + const autoStart = getAutoStartDisplay(instance); const row = [ chalk.cyan(name.padEnd(15)), @@ -29,6 +30,7 @@ export function displayInstanceTable(instances: PostgreSQLInstanceConfig[]): voi version.padEnd(8), port.padEnd(6), database.padEnd(20), + autoStart.padEnd(12), ].join(' '); console.log(row); @@ -54,6 +56,20 @@ export function displayInstanceDetails(instance: PostgreSQLInstanceConfig): void console.log(` Connections: ${instance.status.connections}`); } + // Display service status if available + if (instance.status?.service) { + console.log(); + console.log(chalk.bold('Service:')); + console.log(` Auto-start: ${instance.status.service.enabled ? chalk.green('Enabled') : chalk.red('Disabled')}`); + console.log(` Service Active: ${instance.status.service.active ? chalk.green('Yes') : chalk.red('No')}`); + console.log(` Service Status: ${chalk.yellow(instance.status.service.status || 'unknown')}`); + } else if (instance.spec.service?.enabled) { + console.log(); + console.log(chalk.bold('Service:')); + console.log(` Auto-start: ${chalk.green('Enabled')}`); + console.log(` Service Status: ${chalk.gray('Check with: pgforge service-status ' + instance.metadata.name)}`); + } + console.log(); console.log(chalk.bold('Configuration:')); console.log(` Version: ${instance.spec.version}`); @@ -121,6 +137,16 @@ export function getStatusDisplay(state: string): string { } } +export function getAutoStartDisplay(instance: PostgreSQLInstanceConfig): string { + if (instance.status?.service?.enabled) { + return chalk.green('✓ Enabled'); + } else if (instance.spec.service?.enabled) { + return chalk.yellow('✓ Configured'); + } else { + return chalk.gray('✗ Disabled'); + } +} + export function displayConnectionInfo(instance: PostgreSQLInstanceConfig): void { console.log(); console.log(chalk.bold(`Connection Information for ${chalk.cyan(instance.metadata.name)}`));