// Package installer provides systemd service installation for dbbackup package installer import ( "context" "fmt" "io" "os" "os/exec" "os/user" "path/filepath" "runtime" "strings" "text/template" "dbbackup/internal/logger" ) // Installer handles systemd service installation type Installer struct { log logger.Logger unitDir string // /etc/systemd/system or custom dryRun bool } // InstallOptions configures the installation type InstallOptions struct { // Instance name (e.g., "production", "staging") Instance string // Binary path (auto-detected if empty) BinaryPath string // Backup configuration BackupType string // "single" or "cluster" Schedule string // OnCalendar format, e.g., "daily", "*-*-* 02:00:00" // Service user/group User string Group string // Paths BackupDir string ConfigPath string // Timeout in seconds (default: 3600) TimeoutSeconds int // Metrics WithMetrics bool MetricsPort int } // ServiceStatus contains information about installed services type ServiceStatus struct { Installed bool Enabled bool Active bool TimerEnabled bool TimerActive bool LastRun string NextRun string ServicePath string TimerPath string ExporterPath string } // NewInstaller creates a new Installer func NewInstaller(log logger.Logger, dryRun bool) *Installer { return &Installer{ log: log, unitDir: "/etc/systemd/system", dryRun: dryRun, } } // SetUnitDir allows overriding the systemd unit directory (for testing) func (i *Installer) SetUnitDir(dir string) { i.unitDir = dir } // Install installs the systemd service and timer func (i *Installer) Install(ctx context.Context, opts InstallOptions) error { // Validate platform if runtime.GOOS != "linux" { return fmt.Errorf("systemd installation only supported on Linux (current: %s)", runtime.GOOS) } // Validate prerequisites if err := i.validatePrerequisites(); err != nil { return err } // Set defaults if err := i.setDefaults(&opts); err != nil { return err } // Create user if needed if err := i.ensureUser(opts.User, opts.Group); err != nil { return err } // Create directories if err := i.createDirectories(opts); err != nil { return err } // Copy binary to /usr/local/bin (required for ProtectHome=yes) if err := i.copyBinary(&opts); err != nil { return err } // Write service and timer files if err := i.writeUnitFiles(opts); err != nil { return err } // Reload systemd if err := i.systemctl(ctx, "daemon-reload"); err != nil { return err } // Enable timer timerName := i.getTimerName(opts) if err := i.systemctl(ctx, "enable", timerName); err != nil { return err } // Install metrics exporter if requested if opts.WithMetrics { if err := i.installExporter(ctx, opts); err != nil { i.log.Warn("Failed to install metrics exporter", "error", err) } } i.log.Info("Installation complete", "instance", opts.Instance, "timer", timerName, "schedule", opts.Schedule) i.printNextSteps(opts) return nil } // Uninstall removes the systemd service and timer func (i *Installer) Uninstall(ctx context.Context, instance string, purge bool) error { if runtime.GOOS != "linux" { return fmt.Errorf("systemd uninstallation only supported on Linux") } if err := i.validatePrerequisites(); err != nil { return err } // Determine service names var serviceName, timerName string if instance == "cluster" || instance == "" { serviceName = "dbbackup-cluster.service" timerName = "dbbackup-cluster.timer" } else { serviceName = fmt.Sprintf("dbbackup@%s.service", instance) timerName = fmt.Sprintf("dbbackup@%s.timer", instance) } // Stop and disable timer _ = i.systemctl(ctx, "stop", timerName) _ = i.systemctl(ctx, "disable", timerName) // Stop and disable service _ = i.systemctl(ctx, "stop", serviceName) _ = i.systemctl(ctx, "disable", serviceName) // Remove unit files servicePath := filepath.Join(i.unitDir, serviceName) timerPath := filepath.Join(i.unitDir, timerName) if !i.dryRun { os.Remove(servicePath) os.Remove(timerPath) } else { i.log.Info("Would remove", "service", servicePath) i.log.Info("Would remove", "timer", timerPath) } // Also try to remove template units if they exist if instance != "cluster" && instance != "" { templateService := filepath.Join(i.unitDir, "dbbackup@.service") templateTimer := filepath.Join(i.unitDir, "dbbackup@.timer") // Only remove templates if no other instances are using them if i.canRemoveTemplates() { if !i.dryRun { os.Remove(templateService) os.Remove(templateTimer) } } } // Remove exporter exporterPath := filepath.Join(i.unitDir, "dbbackup-exporter.service") _ = i.systemctl(ctx, "stop", "dbbackup-exporter.service") _ = i.systemctl(ctx, "disable", "dbbackup-exporter.service") if !i.dryRun { os.Remove(exporterPath) } // Reload systemd _ = i.systemctl(ctx, "daemon-reload") // Purge config files if requested if purge { configDirs := []string{ "/etc/dbbackup", "/var/lib/dbbackup", } for _, dir := range configDirs { if !i.dryRun { if err := os.RemoveAll(dir); err != nil { i.log.Warn("Failed to remove directory", "path", dir, "error", err) } else { i.log.Info("Removed directory", "path", dir) } } else { i.log.Info("Would remove directory", "path", dir) } } } i.log.Info("Uninstallation complete", "instance", instance, "purge", purge) return nil } // Status returns the current installation status func (i *Installer) Status(ctx context.Context, instance string) (*ServiceStatus, error) { if runtime.GOOS != "linux" { return nil, fmt.Errorf("systemd status only supported on Linux") } status := &ServiceStatus{} // Determine service names var serviceName, timerName string if instance == "cluster" || instance == "" { serviceName = "dbbackup-cluster.service" timerName = "dbbackup-cluster.timer" } else { serviceName = fmt.Sprintf("dbbackup@%s.service", instance) timerName = fmt.Sprintf("dbbackup@%s.timer", instance) } // Check service file exists status.ServicePath = filepath.Join(i.unitDir, serviceName) if _, err := os.Stat(status.ServicePath); err == nil { status.Installed = true } // Check timer file exists status.TimerPath = filepath.Join(i.unitDir, timerName) // Check exporter status.ExporterPath = filepath.Join(i.unitDir, "dbbackup-exporter.service") // Check enabled/active status if status.Installed { status.Enabled = i.isEnabled(ctx, serviceName) status.Active = i.isActive(ctx, serviceName) status.TimerEnabled = i.isEnabled(ctx, timerName) status.TimerActive = i.isActive(ctx, timerName) // Get timer info status.NextRun = i.getTimerNext(ctx, timerName) status.LastRun = i.getTimerLast(ctx, timerName) } return status, nil } // validatePrerequisites checks system requirements func (i *Installer) validatePrerequisites() error { // Check root (skip in dry-run mode) if os.Getuid() != 0 && !i.dryRun { return fmt.Errorf("installation requires root privileges (use sudo)") } // Check systemd if _, err := exec.LookPath("systemctl"); err != nil { return fmt.Errorf("systemctl not found - is this a systemd-based system?") } // Check for container environment if _, err := os.Stat("/.dockerenv"); err == nil { i.log.Warn("Running inside Docker container - systemd may not work correctly") } return nil } // setDefaults fills in default values func (i *Installer) setDefaults(opts *InstallOptions) error { // Auto-detect binary path if opts.BinaryPath == "" { binPath, err := os.Executable() if err != nil { return fmt.Errorf("failed to detect binary path: %w", err) } binPath, err = filepath.EvalSymlinks(binPath) if err != nil { return fmt.Errorf("failed to resolve binary path: %w", err) } opts.BinaryPath = binPath } // Default instance if opts.Instance == "" { opts.Instance = "default" } // Default backup type if opts.BackupType == "" { opts.BackupType = "single" } // Default schedule (daily at 2am) if opts.Schedule == "" { opts.Schedule = "*-*-* 02:00:00" } // Default user/group if opts.User == "" { opts.User = "dbbackup" } if opts.Group == "" { opts.Group = "dbbackup" } // Default paths if opts.BackupDir == "" { opts.BackupDir = "/var/lib/dbbackup/backups" } if opts.ConfigPath == "" { opts.ConfigPath = "/etc/dbbackup/dbbackup.conf" } // Default timeout (1 hour) if opts.TimeoutSeconds == 0 { opts.TimeoutSeconds = 3600 } // Default metrics port if opts.MetricsPort == 0 { opts.MetricsPort = 9399 } return nil } // ensureUser creates the service user if it doesn't exist func (i *Installer) ensureUser(username, groupname string) error { // Check if user exists if _, err := user.Lookup(username); err == nil { i.log.Debug("User already exists", "user", username) return nil } if i.dryRun { i.log.Info("Would create user", "user", username, "group", groupname) return nil } // Create group first groupCmd := exec.Command("groupadd", "--system", groupname) if output, err := groupCmd.CombinedOutput(); err != nil { // Ignore if group already exists if !strings.Contains(string(output), "already exists") { i.log.Debug("Group creation output", "output", string(output)) } } // Create user userCmd := exec.Command("useradd", "--system", "--shell", "/usr/sbin/nologin", "--home-dir", "/var/lib/dbbackup", "--gid", groupname, username) if output, err := userCmd.CombinedOutput(); err != nil { if !strings.Contains(string(output), "already exists") { return fmt.Errorf("failed to create user %s: %w (%s)", username, err, output) } } i.log.Info("Created system user", "user", username, "group", groupname) return nil } // createDirectories creates required directories func (i *Installer) createDirectories(opts InstallOptions) error { dirs := []struct { path string mode os.FileMode }{ {"/etc/dbbackup", 0755}, {"/etc/dbbackup/env.d", 0700}, {"/var/lib/dbbackup", 0750}, {"/var/lib/dbbackup/backups", 0750}, {"/var/lib/dbbackup/metrics", 0755}, {"/var/log/dbbackup", 0750}, {opts.BackupDir, 0750}, } for _, d := range dirs { if i.dryRun { i.log.Info("Would create directory", "path", d.path, "mode", d.mode) continue } if err := os.MkdirAll(d.path, d.mode); err != nil { return fmt.Errorf("failed to create directory %s: %w", d.path, err) } // Set ownership u, err := user.Lookup(opts.User) if err == nil { var uid, gid int fmt.Sscanf(u.Uid, "%d", &uid) fmt.Sscanf(u.Gid, "%d", &gid) os.Chown(d.path, uid, gid) } } return nil } // copyBinary copies the binary to /usr/local/bin for systemd access // This is required because ProtectHome=yes blocks access to home directories func (i *Installer) copyBinary(opts *InstallOptions) error { const installPath = "/usr/local/bin/dbbackup" // Check if binary is already in a system path if opts.BinaryPath == installPath { return nil } if i.dryRun { i.log.Info("Would copy binary", "from", opts.BinaryPath, "to", installPath) opts.BinaryPath = installPath return nil } // Read source binary src, err := os.Open(opts.BinaryPath) if err != nil { return fmt.Errorf("failed to open source binary: %w", err) } defer src.Close() // Create destination dst, err := os.OpenFile(installPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) if err != nil { return fmt.Errorf("failed to create %s: %w", installPath, err) } defer dst.Close() // Copy if _, err := io.Copy(dst, src); err != nil { return fmt.Errorf("failed to copy binary: %w", err) } i.log.Info("Copied binary", "from", opts.BinaryPath, "to", installPath) opts.BinaryPath = installPath return nil } // writeUnitFiles renders and writes the systemd unit files func (i *Installer) writeUnitFiles(opts InstallOptions) error { // Prepare template data data := map[string]interface{}{ "User": opts.User, "Group": opts.Group, "BinaryPath": opts.BinaryPath, "BackupType": opts.BackupType, "BackupDir": opts.BackupDir, "ConfigPath": opts.ConfigPath, "TimeoutSeconds": opts.TimeoutSeconds, "Schedule": opts.Schedule, "MetricsPort": opts.MetricsPort, } // Determine which templates to use var serviceTemplate, timerTemplate string var serviceName, timerName string if opts.BackupType == "cluster" { serviceTemplate = "templates/dbbackup-cluster.service" timerTemplate = "templates/dbbackup-cluster.timer" serviceName = "dbbackup-cluster.service" timerName = "dbbackup-cluster.timer" } else { serviceTemplate = "templates/dbbackup@.service" timerTemplate = "templates/dbbackup@.timer" serviceName = "dbbackup@.service" timerName = "dbbackup@.timer" } // Write service file if err := i.writeTemplateFile(serviceTemplate, serviceName, data); err != nil { return fmt.Errorf("failed to write service file: %w", err) } // Write timer file if err := i.writeTemplateFile(timerTemplate, timerName, data); err != nil { return fmt.Errorf("failed to write timer file: %w", err) } return nil } // writeTemplateFile reads an embedded template and writes it to the unit directory func (i *Installer) writeTemplateFile(templatePath, outputName string, data map[string]interface{}) error { // Read template content, err := Templates.ReadFile(templatePath) if err != nil { return fmt.Errorf("failed to read template %s: %w", templatePath, err) } // Parse template tmpl, err := template.New(outputName).Parse(string(content)) if err != nil { return fmt.Errorf("failed to parse template %s: %w", templatePath, err) } // Render template var buf strings.Builder if err := tmpl.Execute(&buf, data); err != nil { return fmt.Errorf("failed to render template %s: %w", templatePath, err) } // Write file outputPath := filepath.Join(i.unitDir, outputName) if i.dryRun { i.log.Info("Would write unit file", "path", outputPath) i.log.Debug("Unit file content", "content", buf.String()) return nil } if err := os.WriteFile(outputPath, []byte(buf.String()), 0644); err != nil { return fmt.Errorf("failed to write %s: %w", outputPath, err) } i.log.Info("Created unit file", "path", outputPath) return nil } // installExporter installs the metrics exporter service func (i *Installer) installExporter(ctx context.Context, opts InstallOptions) error { data := map[string]interface{}{ "User": opts.User, "Group": opts.Group, "BinaryPath": opts.BinaryPath, "ConfigPath": opts.ConfigPath, "MetricsPort": opts.MetricsPort, } if err := i.writeTemplateFile("templates/dbbackup-exporter.service", "dbbackup-exporter.service", data); err != nil { return err } if err := i.systemctl(ctx, "daemon-reload"); err != nil { return err } if err := i.systemctl(ctx, "enable", "dbbackup-exporter.service"); err != nil { return err } if err := i.systemctl(ctx, "start", "dbbackup-exporter.service"); err != nil { return err } i.log.Info("Installed metrics exporter", "port", opts.MetricsPort) return nil } // getTimerName returns the timer unit name for the given options func (i *Installer) getTimerName(opts InstallOptions) string { if opts.BackupType == "cluster" { return "dbbackup-cluster.timer" } return fmt.Sprintf("dbbackup@%s.timer", opts.Instance) } // systemctl runs a systemctl command func (i *Installer) systemctl(ctx context.Context, args ...string) error { if i.dryRun { i.log.Info("Would run: systemctl", "args", args) return nil } cmd := exec.CommandContext(ctx, "systemctl", args...) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("systemctl %v failed: %w\n%s", args, err, string(output)) } return nil } // isEnabled checks if a unit is enabled func (i *Installer) isEnabled(ctx context.Context, unit string) bool { cmd := exec.CommandContext(ctx, "systemctl", "is-enabled", unit) return cmd.Run() == nil } // isActive checks if a unit is active func (i *Installer) isActive(ctx context.Context, unit string) bool { cmd := exec.CommandContext(ctx, "systemctl", "is-active", unit) return cmd.Run() == nil } // getTimerNext gets the next run time for a timer func (i *Installer) getTimerNext(ctx context.Context, timer string) string { cmd := exec.CommandContext(ctx, "systemctl", "show", timer, "--property=NextElapseUSecRealtime", "--value") output, err := cmd.Output() if err != nil { return "" } return strings.TrimSpace(string(output)) } // getTimerLast gets the last run time for a timer func (i *Installer) getTimerLast(ctx context.Context, timer string) string { cmd := exec.CommandContext(ctx, "systemctl", "show", timer, "--property=LastTriggerUSec", "--value") output, err := cmd.Output() if err != nil { return "" } return strings.TrimSpace(string(output)) } // canRemoveTemplates checks if template units can be safely removed func (i *Installer) canRemoveTemplates() bool { // Check if any dbbackup@*.service instances exist pattern := filepath.Join(i.unitDir, "dbbackup@*.service") matches, _ := filepath.Glob(pattern) // Also check for running instances cmd := exec.Command("systemctl", "list-units", "--type=service", "--all", "dbbackup@*") output, _ := cmd.Output() return len(matches) == 0 && !strings.Contains(string(output), "dbbackup@") } // printNextSteps prints helpful next steps after installation func (i *Installer) printNextSteps(opts InstallOptions) { timerName := i.getTimerName(opts) serviceName := strings.Replace(timerName, ".timer", ".service", 1) fmt.Println() fmt.Println("✅ Installation successful!") fmt.Println() fmt.Println("📋 Next steps:") fmt.Println() fmt.Printf(" 1. Edit configuration: sudo nano %s\n", opts.ConfigPath) fmt.Printf(" 2. Set credentials: sudo nano /etc/dbbackup/env.d/%s.conf\n", opts.Instance) fmt.Printf(" 3. Start the timer: sudo systemctl start %s\n", timerName) fmt.Printf(" 4. Verify timer status: sudo systemctl status %s\n", timerName) fmt.Printf(" 5. Run backup manually: sudo systemctl start %s\n", serviceName) fmt.Println() fmt.Println("📊 View backup logs:") fmt.Printf(" journalctl -u %s -f\n", serviceName) fmt.Println() if opts.WithMetrics { fmt.Println("📈 Prometheus metrics:") fmt.Printf(" curl http://localhost:%d/metrics\n", opts.MetricsPort) fmt.Println() } }