feat: add embedded systemd installer and Prometheus metrics
Some checks failed
CI/CD / Test (push) Successful in 2m42s
CI/CD / Lint (push) Successful in 2m50s
CI/CD / Build (amd64, darwin) (push) Successful in 2m0s
CI/CD / Build (amd64, linux) (push) Successful in 1m58s
CI/CD / Build (arm64, darwin) (push) Successful in 2m1s
CI/CD / Build (arm64, linux) (push) Has been cancelled
Some checks failed
CI/CD / Test (push) Successful in 2m42s
CI/CD / Lint (push) Successful in 2m50s
CI/CD / Build (amd64, darwin) (push) Successful in 2m0s
CI/CD / Build (amd64, linux) (push) Successful in 1m58s
CI/CD / Build (arm64, darwin) (push) Successful in 2m1s
CI/CD / Build (arm64, linux) (push) Has been cancelled
Systemd Integration: - New 'dbbackup install' command creates service/timer units - Supports single-database and cluster backup modes - Automatic dbbackup user/group creation with proper permissions - Hardened service units with security features - Template units with configurable OnCalendar schedules - 'dbbackup uninstall' for clean removal Prometheus Metrics: - 'dbbackup metrics export' for textfile collector format - 'dbbackup metrics serve' runs HTTP exporter on port 9399 - Metrics: last_success_timestamp, rpo_seconds, backup_total, etc. - Integration with node_exporter textfile collector - --with-metrics flag during install Technical: - Systemd templates embedded with //go:embed - Service units include ReadWritePaths, OOMScoreAdjust - Metrics exporter caches with 30s TTL - Graceful shutdown on SIGTERM
This commit is contained in:
634
internal/installer/installer.go
Normal file
634
internal/installer/installer.go
Normal file
@@ -0,0 +1,634 @@
|
||||
// Package installer provides systemd service installation for dbbackup
|
||||
package installer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"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
|
||||
}
|
||||
|
||||
// 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
|
||||
if os.Getuid() != 0 {
|
||||
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
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user