- Remove invalid --config flag from exporter service template - Change ReadOnlyPaths to ReadWritePaths for catalog access - Add copyBinary() to install binary to /usr/local/bin (ProtectHome compat) - Fix exporter status detection using direct systemctl check - Add os/exec import for status check
681 lines
18 KiB
Go
681 lines
18 KiB
Go
// 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()
|
|
}
|
|
}
|