diff --git a/.github/instructions/codacy.instructions.md b/.github/instructions/codacy.instructions.md new file mode 100644 index 00000000..9e27a080 --- /dev/null +++ b/.github/instructions/codacy.instructions.md @@ -0,0 +1,71 @@ +--- +description: Configuration for AI behavior when interacting with Codacy's MCP Server +applyTo: '**' +--- +# Codacy Rules +Configuration for AI behavior when interacting with Codacy's MCP Server + +## CRITICAL: After ANY successful `edit_file` or `reapply` operation +- YOU MUST IMMEDIATELY run the `codacy_cli_analyze` tool from Codacy's MCP Server for each file that was edited, with: + - `rootPath`: set to the workspace path + - `file`: set to the path of the edited file + - `tool`: leave empty or unset +- If any issues are found in the new edits, propose and apply fixes for them. +- > NOTE: Failure to follow this rule is considered a critical error. + +## When you tried to run the `codacy_cli_analyze` tool and the Codacy CLI is not installed +- Ask the user 'Codacy CLI is not installed. Would you like me to install it now?' +- If the user responds with "yes", run the `codacy_cli_install` tool and then continue with the original task +- If the user responds with "no", instruct the user that they can disable automatic analysis in the extension settings +- Wait for the user to respond before proceeding with any other actions + +## After every response +- If you made any file edits in this conversation, verify you ran `codacy_cli_analyze` tool from Codacy's MCP Server + +## When there are no Codacy MCP Server tools available, or the MCP Server is not reachable +- Suggest the user the following troubleshooting steps: + - Try to reset the MCP on the extension + - If the user is using VSCode, suggest them to review their Copilot > MCP settings in Github, under their organization or personal account. Refer them to Settings > Copilot > Enable MCP servers in Copilot. Suggested URL (https://github.com/settings/copilot/features) or https://github.com/organizations/{organization-name}/settings/copilot/features (This can only be done by their organization admins / owners) +- If none of the above steps work, suggest the user to contact Codacy support + +## Trying to call a tool that needs a rootPath as a parameter +- Always use the standard, non-URL-encoded file system path + +## CRITICAL: Dependencies and Security Checks +- IMMEDIATELY after ANY of these actions: + - Running npm/yarn/pnpm install + - Adding dependencies to package.json + - Adding requirements to requirements.txt + - Adding dependencies to pom.xml + - Adding dependencies to build.gradle + - Any other package manager operations +- You MUST run the `codacy_cli_analyze` tool with: + - `rootPath`: set to the workspace path + - `tool`: set to "trivy" + - `file`: leave empty or unset +- If any vulnerabilities are found because of the newly added packages: + - Stop all other operations + - Propose and apply fixes for the security issues + - Only continue with the original task after security issues are resolved +- EXAMPLE: + - After: npm install react-markdown + - Do: Run codacy_cli_analyze with trivy + - Before: Continuing with any other tasks + +## General +- Repeat the relevant steps for each modified file. +- "Propose fixes" means to both suggest and, if possible, automatically apply the fixes. +- You MUST NOT wait for the user to ask for analysis or remind you to run the tool. +- Do not run `codacy_cli_analyze` looking for changes in duplicated code or code complexity metrics. +- Complexity metrics are different from complexity issues. When trying to fix complexity in a repository or file, focus on solving the complexity issues and ignore the complexity metric. +- Do not run `codacy_cli_analyze` looking for changes in code coverage. +- Do not try to manually install Codacy CLI using either brew, npm, npx, or any other package manager. +- If the Codacy CLI is not installed, just run the `codacy_cli_analyze` tool from Codacy's MCP Server. +- When calling `codacy_cli_analyze`, only send provider, organization and repository if the project is a git repository. + +## Whenever a call to a Codacy tool that uses `repository` or `organization` as a parameter returns a 404 error +- Offer to run the `codacy_setup_repository` tool to add the repository to Codacy +- If the user accepts, run the `codacy_setup_repository` tool +- Do not ever try to run the `codacy_setup_repository` tool on your own +- After setup, immediately retry the action that failed (only retry once) +--- diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index bb3957bc..ccac49ca 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -34,7 +34,7 @@ jobs: go test $(go list ./... | grep -v -E '/cmd/|/pbs$|/bech32$|^github.com/tis24dev/proxsave$') -coverprofile=coverage.out - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.out diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml index 6c82ad1a..a4676472 100644 --- a/.github/workflows/dependabot-automerge.yml +++ b/.github/workflows/dependabot-automerge.yml @@ -16,9 +16,14 @@ jobs: if: github.actor == 'dependabot[bot]' steps: + - name: Set up Node.js 24 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # pinned from actions/setup-node@v4 + with: + node-version: '24' + - name: Fetch Dependabot metadata id: metadata - uses: dependabot/fetch-metadata@v2 + uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # pinned from dependabot/fetch-metadata@v3 with: github-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/README.md b/README.md index 37628858..8c5d8329 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Proxmox PBS & PVE System Files Backup [![rclone](https://img.shields.io/badge/rclone-1.60+-136C9E.svg)](https://rclone.org/) [![💖 Sponsor](https://img.shields.io/badge/Sponsor-GitHub%20Sponsors-pink?logo=github)](https://github.com/sponsors/tis24dev) [![☕ Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-tis24dev-yellow?logo=buymeacoffee)](https://github.com/sponsors/tis24dev) +[![💸 Donate](https://img.shields.io/badge/Donate-PayPal-blue?logo=paypal)](https://paypal.me/DNoventa) ## About the Project @@ -104,4 +105,4 @@ A special thanks to the community members who help by testing releases and repor
## Repo Activity -![Alt](https://repobeats.axiom.co/api/embed/d9565d6d1ed8222a5da5fedf25c18a9c8beab382.svg "Repobeats analytics image") \ No newline at end of file +![Alt](https://repobeats.axiom.co/api/embed/d9565d6d1ed8222a5da5fedf25c18a9c8beab382.svg "Repobeats analytics image") diff --git a/cmd/proxsave/backup_execution.go b/cmd/proxsave/backup_execution.go new file mode 100644 index 00000000..8ed9644f --- /dev/null +++ b/cmd/proxsave/backup_execution.go @@ -0,0 +1,149 @@ +// Package main contains the proxsave command entrypoint. +package main + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/notify" + "github.com/tis24dev/proxsave/internal/orchestrator" + "github.com/tis24dev/proxsave/internal/types" +) + +func runConfiguredBackup(opts backupModeOptions, orch *orchestrator.Orchestrator) (*orchestrator.BackupStats, *orchestrator.EarlyErrorState, int) { + if !opts.cfg.BackupEnabled { + logging.Warning("Backup is disabled in configuration") + return nil, nil, types.ExitSuccess.Int() + } + + if earlyErrorState, exitCode := runPreBackupChecks(opts, orch); earlyErrorState != nil { + return nil, earlyErrorState, exitCode + } + + logging.Step("Start Go backup orchestration") + hostname := resolveHostname() + backupDone := logging.DebugStart(opts.logger, "backup run", "proxmox=%s host=%s", opts.envInfo.Type, hostname) + stats, err := orch.RunGoBackup(opts.ctx, opts.envInfo, hostname) + if err != nil { + backupDone(err) + return handleBackupRunError(opts.ctx, orch, stats, err) + } + backupDone(nil) + + persistBackupStats(orch, stats) + logBackupStatistics(stats) + logging.Info("✓ Go backup orchestration completed") + logServerIdentityValues(opts.serverIDValue, opts.serverMACValue) + + if opts.heapProfilePath != "" { + logging.Info("Heap profiling saved: %s", opts.heapProfilePath) + } + + logBackupExitStatus(stats.ExitCode) + return stats, nil, stats.ExitCode +} + +func runPreBackupChecks(opts backupModeOptions, orch *orchestrator.Orchestrator) (*orchestrator.EarlyErrorState, int) { + preCheckDone := logging.DebugStart(opts.logger, "pre-backup checks", "") + if err := orch.RunPreBackupChecks(opts.ctx); err != nil { + preCheckDone(err) + logging.Error("Pre-backup validation failed: %v", err) + return &orchestrator.EarlyErrorState{ + Phase: "pre_backup_checks", + Error: err, + ExitCode: types.ExitBackupError, + Timestamp: time.Now(), + }, types.ExitBackupError.Int() + } + preCheckDone(nil) + fmt.Println() + return nil, types.ExitSuccess.Int() +} + +func handleBackupRunError(ctx context.Context, orch *orchestrator.Orchestrator, stats *orchestrator.BackupStats, err error) (*orchestrator.BackupStats, *orchestrator.EarlyErrorState, int) { + if ctx.Err() == context.Canceled { + logging.Warning("Backup was canceled") + orch.FinalizeAfterRun(ctx, stats) + return stats, nil, exitCodeInterrupted + } + + var backupErr *orchestrator.BackupError + if errors.As(err, &backupErr) { + logging.Error("Backup %s failed: %v", backupErr.Phase, backupErr.Err) + orch.FinalizeAfterRun(ctx, stats) + return stats, nil, backupErr.Code.Int() + } + + logging.Error("Backup orchestration failed: %v", err) + orch.FinalizeAfterRun(ctx, stats) + return stats, nil, types.ExitBackupError.Int() +} + +func persistBackupStats(orch *orchestrator.Orchestrator, stats *orchestrator.BackupStats) { + if err := orch.SaveStatsReport(stats); err != nil { + logging.Warning("Failed to persist backup statistics: %v", err) + } else if stats.ReportPath != "" { + logging.Info("✓ Statistics report saved to %s", stats.ReportPath) + } +} + +func logBackupStatistics(stats *orchestrator.BackupStats) { + fmt.Println() + logging.Info("=== Backup Statistics ===") + logging.Info("Files collected: %d", stats.FilesCollected) + if stats.FilesFailed > 0 { + logging.Warning("Files failed: %d", stats.FilesFailed) + } + logging.Info("Directories created: %d", stats.DirsCreated) + logging.Info("Data collected: %s", formatBytes(stats.BytesCollected)) + logging.Info("Archive size: %s", formatBytes(stats.ArchiveSize)) + logCompressionRatio(stats) + logging.Info("Compression used: %s (level %d, mode %s)", stats.Compression, stats.CompressionLevel, stats.CompressionMode) + if stats.RequestedCompression != stats.Compression { + logging.Info("Requested compression: %s", stats.RequestedCompression) + } + logging.Info("Duration: %s", formatDuration(stats.Duration)) + logBackupArtifactPaths(stats) + fmt.Println() +} + +func logCompressionRatio(stats *orchestrator.BackupStats) { + switch { + case stats.CompressionSavingsPercent > 0: + logging.Info("Compression ratio: %.1f%%", stats.CompressionSavingsPercent) + case stats.CompressionRatioPercent > 0: + logging.Info("Compression ratio: %.1f%%", stats.CompressionRatioPercent) + case stats.BytesCollected > 0: + ratio := float64(stats.ArchiveSize) / float64(stats.BytesCollected) * 100 + logging.Info("Compression ratio: %.1f%%", ratio) + default: + logging.Info("Compression ratio: N/A") + } +} + +func logBackupArtifactPaths(stats *orchestrator.BackupStats) { + if stats.BundleCreated { + logging.Info("Bundle path: %s", stats.ArchivePath) + logging.Info("Bundle contents: archive + checksum + metadata") + return + } + + logging.Info("Archive path: %s", stats.ArchivePath) + if stats.ManifestPath != "" { + logging.Info("Manifest path: %s", stats.ManifestPath) + } + if stats.Checksum != "" { + logging.Info("Archive checksum (SHA256): %s", stats.Checksum) + } +} + +func logBackupExitStatus(exitCode int) { + status := notify.StatusFromExitCode(exitCode) + statusLabel := strings.ToUpper(status.String()) + emoji := notify.GetStatusEmoji(status) + logging.Info("Exit status: %s %s (code=%d)", emoji, statusLabel, exitCode) +} diff --git a/cmd/proxsave/backup_mode.go b/cmd/proxsave/backup_mode.go new file mode 100644 index 00000000..89809b79 --- /dev/null +++ b/cmd/proxsave/backup_mode.go @@ -0,0 +1,259 @@ +// Package main contains the proxsave command entrypoint. +package main + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/tis24dev/proxsave/internal/backup" + "github.com/tis24dev/proxsave/internal/checks" + "github.com/tis24dev/proxsave/internal/config" + "github.com/tis24dev/proxsave/internal/environment" + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/orchestrator" + "github.com/tis24dev/proxsave/internal/types" +) + +type backupModeOptions struct { + ctx context.Context + cfg *config.Config + logger *logging.Logger + envInfo *environment.EnvironmentInfo + unprivilegedInfo environment.UnprivilegedContainerInfo + updateInfo *UpdateInfo + toolVersion string + dryRun bool + startTime time.Time + heapProfilePath string + serverIDValue string + serverMACValue string +} + +type backupModeResult struct { + orch *orchestrator.Orchestrator + earlyErrorState *orchestrator.EarlyErrorState + supportStats *orchestrator.BackupStats + exitCode int +} + +func runBackupMode(opts backupModeOptions) backupModeResult { + orch, earlyErrorState, exitCode := initializeBackupOrchestrator(opts) + if earlyErrorState != nil { + return finishBackupMode(orch, earlyErrorState, nil, exitCode) + } + + verifyBackupDirectories(opts.cfg, opts.logger) + + checker, earlyErrorState, exitCode := configurePreBackupChecker(opts, orch) + if earlyErrorState != nil { + return finishBackupMode(orch, earlyErrorState, nil, exitCode) + } + + defer func() { + if err := orch.ReleaseBackupLock(); err != nil { + logging.Warning("Failed to release backup lock: %v", err) + } + }() + + storageState, earlyErrorState, exitCode := initializeBackupStorage(opts, orch, checker) + if earlyErrorState != nil { + return finishBackupMode(orch, earlyErrorState, nil, exitCode) + } + + initializeBackupNotifications(opts, orch) + logBackupRuntimeSummary(opts.cfg, storageState) + + stats, earlyErrorState, exitCode := runConfiguredBackup(opts, orch) + return finishBackupMode(orch, earlyErrorState, stats, exitCode) +} + +func finishBackupMode(orch *orchestrator.Orchestrator, earlyErrorState *orchestrator.EarlyErrorState, stats *orchestrator.BackupStats, exitCode int) backupModeResult { + return backupModeResult{ + orch: orch, + earlyErrorState: earlyErrorState, + supportStats: stats, + exitCode: exitCode, + } +} + +func initializeBackupOrchestrator(opts backupModeOptions) (*orchestrator.Orchestrator, *orchestrator.EarlyErrorState, int) { + logger := opts.logger + + logging.Step("Initializing backup orchestrator") + orchInitDone := logging.DebugStart(logger, "orchestrator init", "dry_run=%v", opts.dryRun) + orch := orchestrator.New(logger, opts.dryRun) + configureBackupOrchestrator(opts, orch) + + if earlyErrorState, exitCode := ensureBackupAgeRecipientsReady(opts, orch, orchInitDone); earlyErrorState != nil { + return orch, earlyErrorState, exitCode + } + orchInitDone(nil) + + logging.Info("✓ Orchestrator initialized") + fmt.Println() + return orch, nil, types.ExitSuccess.Int() +} + +func configureBackupOrchestrator(opts backupModeOptions, orch *orchestrator.Orchestrator) { + cfg := opts.cfg + orch.SetUnprivilegedContainerContext(opts.unprivilegedInfo.Detected, opts.unprivilegedInfo.Details) + orch.SetVersion(opts.toolVersion) + orch.SetConfig(cfg) + orch.SetIdentity(opts.serverIDValue, opts.serverMACValue) + orch.SetEnvironmentInfo(opts.envInfo) + orch.SetStartTime(opts.startTime) + if opts.updateInfo != nil { + orch.SetUpdateInfo(opts.updateInfo.NewVersion, opts.updateInfo.Current, opts.updateInfo.Latest) + } + + orch.SetBackupConfig( + cfg.BackupPath, + cfg.LogPath, + cfg.CompressionType, + cfg.CompressionLevel, + cfg.CompressionThreads, + cfg.CompressionMode, + buildBackupExcludePatterns(cfg), + ) + orch.SetOptimizationConfig(backupOptimizationConfig(cfg)) +} + +func backupOptimizationConfig(cfg *config.Config) backup.OptimizationConfig { + return backup.OptimizationConfig{ + EnableChunking: cfg.EnableSmartChunking, + EnableDeduplication: cfg.EnableDeduplication, + EnablePrefilter: cfg.EnablePrefilter, + ChunkSizeBytes: int64(cfg.ChunkSizeMB) * bytesPerMegabyte, + ChunkThresholdBytes: int64(cfg.ChunkThresholdMB) * bytesPerMegabyte, + PrefilterMaxFileSizeBytes: int64(cfg.PrefilterMaxFileSizeMB) * bytesPerMegabyte, + } +} + +func ensureBackupAgeRecipientsReady(opts backupModeOptions, orch *orchestrator.Orchestrator, orchInitDone func(error)) (*orchestrator.EarlyErrorState, int) { + err := orch.EnsureAgeRecipientsReady(opts.ctx) + if err == nil { + return nil, types.ExitSuccess.Int() + } + + orchInitDone(err) + if errors.Is(err, orchestrator.ErrAgeRecipientSetupAborted) { + logging.Warning("Encryption setup aborted by user. Exiting...") + return backupAgeRecipientEarlyError(err, types.ExitGenericError), types.ExitGenericError.Int() + } + + logging.Error("ERROR: %v", err) + return backupAgeRecipientEarlyError(err, types.ExitConfigError), types.ExitConfigError.Int() +} + +func backupAgeRecipientEarlyError(err error, exitCode types.ExitCode) *orchestrator.EarlyErrorState { + return &orchestrator.EarlyErrorState{ + Phase: "encryption_setup", + Error: err, + ExitCode: exitCode, + Timestamp: time.Now(), + } +} + +func buildBackupExcludePatterns(cfg *config.Config) []string { + excludePatterns := append([]string(nil), cfg.ExcludePatterns...) + excludePatterns = addPathExclusion(excludePatterns, cfg.BackupPath) + if cfg.SecondaryEnabled { + excludePatterns = addPathExclusion(excludePatterns, cfg.SecondaryPath) + } + if cfg.CloudEnabled && isLocalPath(cfg.CloudRemote) { + excludePatterns = addPathExclusion(excludePatterns, cfg.CloudRemote) + } + return excludePatterns +} + +func verifyBackupDirectories(cfg *config.Config, logger *logging.Logger) { + logging.Step("Verifying directory structure") + checkDir := func(name, path string) { + ensureDirectoryExists(logger, name, path) + } + + checkDir("Backup directory", cfg.BackupPath) + checkDir("Log directory", cfg.LogPath) + if cfg.SecondaryEnabled { + secondaryLogPath := strings.TrimSpace(cfg.SecondaryLogPath) + if secondaryLogPath != "" { + checkDir("Secondary log directory", secondaryLogPath) + } else { + logging.Warning("✗ Secondary log directory not configured (secondary storage enabled)") + } + } + if cfg.CloudEnabled { + logCloudLogDirectory(cfg) + } + checkDir("Lock directory", cfg.LockPath) +} + +func logCloudLogDirectory(cfg *config.Config) { + cloudLogPath := strings.TrimSpace(cfg.CloudLogPath) + if cloudLogPath == "" { + logging.Warning("✗ Cloud log directory not configured (cloud storage enabled)") + return + } + if strings.Contains(cloudLogPath, ":") { + logging.Info("Cloud log path (legacy): %s", cloudLogPath) + return + } + + remoteName := extractRemoteName(cfg.CloudRemote) + if remoteName != "" { + logging.Info("Cloud log path: %s (using remote: %s)", cloudLogPath, remoteName) + } else { + logging.Warning("Cloud log path %s requires CLOUD_REMOTE to be set", cloudLogPath) + } +} + +func configurePreBackupChecker(opts backupModeOptions, orch *orchestrator.Orchestrator) (*checks.Checker, *orchestrator.EarlyErrorState, int) { + cfg := opts.cfg + logger := opts.logger + + logging.Debug("Configuring pre-backup validation checks...") + checkerConfig := checks.GetDefaultCheckerConfig(cfg.BackupPath, cfg.LogPath, cfg.LockPath) + checkerConfig.SecondaryEnabled = cfg.SecondaryEnabled + if cfg.SecondaryEnabled && strings.TrimSpace(cfg.SecondaryPath) != "" { + checkerConfig.SecondaryPath = cfg.SecondaryPath + } else { + checkerConfig.SecondaryPath = "" + } + checkerConfig.CloudEnabled = cfg.CloudEnabled + if cfg.CloudEnabled && strings.TrimSpace(cfg.CloudRemote) != "" { + if isLocalPath(cfg.CloudRemote) { + checkerConfig.CloudPath = cfg.CloudRemote + } else { + checkerConfig.CloudPath = "" + logging.Info("Skipping cloud disk-space check: %s is a remote rclone path (no local mount detected)", cfg.CloudRemote) + } + } else { + checkerConfig.CloudPath = "" + } + checkerConfig.MinDiskPrimaryGB = cfg.MinDiskPrimaryGB + checkerConfig.MinDiskSecondaryGB = cfg.MinDiskSecondaryGB + checkerConfig.MinDiskCloudGB = cfg.MinDiskCloudGB + checkerConfig.FsIoTimeout = time.Duration(cfg.FsIoTimeoutSeconds) * time.Second + checkerConfig.DryRun = opts.dryRun + checkerDone := logging.DebugStart(logger, "pre-backup check config", "dry_run=%v", opts.dryRun) + if err := checkerConfig.Validate(); err != nil { + checkerDone(err) + logging.Error("Invalid checker configuration: %v", err) + return nil, &orchestrator.EarlyErrorState{ + Phase: "checker_config", + Error: err, + ExitCode: types.ExitConfigError, + Timestamp: time.Now(), + }, types.ExitConfigError.Int() + } + checkerDone(nil) + checker := checks.NewChecker(logger, checkerConfig) + orch.SetChecker(checker) + + logging.Info("✓ Pre-backup checks configured") + fmt.Println() + return checker, nil, types.ExitSuccess.Int() +} diff --git a/cmd/proxsave/backup_notifications.go b/cmd/proxsave/backup_notifications.go new file mode 100644 index 00000000..85cd6b4f --- /dev/null +++ b/cmd/proxsave/backup_notifications.go @@ -0,0 +1,200 @@ +// Package main contains the proxsave command entrypoint. +package main + +import ( + "fmt" + "strings" + + "github.com/tis24dev/proxsave/internal/config" + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/notify" + "github.com/tis24dev/proxsave/internal/orchestrator" +) + +func initializeBackupNotifications(opts backupModeOptions, orch *orchestrator.Orchestrator) { + logger := opts.logger + + logging.Step("Initializing notification channels") + notifyDone := logging.DebugStart(logger, "notifications init", "") + initializeEmailNotification(opts, orch) + initializeTelegramNotification(opts, orch) + initializeGotifyNotification(opts, orch) + initializeWebhookNotification(opts, orch) + notifyDone(nil) + + fmt.Println() +} + +func initializeEmailNotification(opts backupModeOptions, orch *orchestrator.Orchestrator) { + cfg := opts.cfg + logger := opts.logger + if !cfg.EmailEnabled { + logging.DebugStep(logger, "notifications init", "email disabled") + logging.Skip("Email: disabled") + return + } + + logging.DebugStep(logger, "notifications init", "email enabled") + emailConfig := notify.EmailConfig{ + Enabled: true, + DeliveryMethod: notify.EmailDeliveryMethod(cfg.EmailDeliveryMethod), + FallbackSendmail: cfg.EmailFallbackSendmail, + Recipient: cfg.EmailRecipient, + From: cfg.EmailFrom, + CloudRelayConfig: notify.CloudRelayConfig{ + WorkerURL: cfg.CloudflareWorkerURL, + WorkerToken: cfg.CloudflareWorkerToken, + HMACSecret: cfg.CloudflareHMACSecret, + Timeout: cfg.WorkerTimeout, + MaxRetries: cfg.WorkerMaxRetries, + RetryDelay: cfg.WorkerRetryDelay, + }, + } + emailNotifier, err := notify.NewEmailNotifier(emailConfig, opts.envInfo.Type, logger) + if err != nil { + logging.Warning("Failed to initialize Email notifier: %v", err) + return + } + emailAdapter := orchestrator.NewNotificationAdapter(emailNotifier, logger) + orch.RegisterNotificationChannel(emailAdapter) + logging.Info("✓ Email initialized (method: %s)", cfg.EmailDeliveryMethod) +} + +func initializeTelegramNotification(opts backupModeOptions, orch *orchestrator.Orchestrator) { + cfg := opts.cfg + logger := opts.logger + if !cfg.TelegramEnabled { + logging.DebugStep(logger, "notifications init", "telegram disabled") + logging.Skip("Telegram: disabled") + return + } + + logging.DebugStep(logger, "notifications init", "telegram enabled (mode=%s)", cfg.TelegramBotType) + telegramConfig := notify.TelegramConfig{ + Enabled: true, + Mode: notify.TelegramMode(cfg.TelegramBotType), + BotToken: cfg.TelegramBotToken, + ChatID: cfg.TelegramChatID, + ServerAPIHost: cfg.TelegramServerAPIHost, + ServerID: cfg.ServerID, + } + telegramNotifier, err := notify.NewTelegramNotifier(telegramConfig, logger) + if err != nil { + logging.Warning("Failed to initialize Telegram notifier: %v", err) + return + } + telegramAdapter := orchestrator.NewNotificationAdapter(telegramNotifier, logger) + orch.RegisterNotificationChannel(telegramAdapter) + logging.Info("✓ Telegram initialized (mode: %s)", cfg.TelegramBotType) +} + +func initializeGotifyNotification(opts backupModeOptions, orch *orchestrator.Orchestrator) { + cfg := opts.cfg + logger := opts.logger + if !cfg.GotifyEnabled { + logging.DebugStep(logger, "notifications init", "gotify disabled") + logging.Skip("Gotify: disabled") + return + } + + logging.DebugStep(logger, "notifications init", "gotify enabled") + gotifyConfig := notify.GotifyConfig{ + Enabled: true, + ServerURL: cfg.GotifyServerURL, + Token: cfg.GotifyToken, + PrioritySuccess: cfg.GotifyPrioritySuccess, + PriorityWarning: cfg.GotifyPriorityWarning, + PriorityFailure: cfg.GotifyPriorityFailure, + } + gotifyNotifier, err := notify.NewGotifyNotifier(gotifyConfig, logger) + if err != nil { + logging.Warning("Failed to initialize Gotify notifier: %v", err) + return + } + gotifyAdapter := orchestrator.NewNotificationAdapter(gotifyNotifier, logger) + orch.RegisterNotificationChannel(gotifyAdapter) + logging.Info("✓ Gotify initialized") +} + +func initializeWebhookNotification(opts backupModeOptions, orch *orchestrator.Orchestrator) { + cfg := opts.cfg + logger := opts.logger + if !cfg.WebhookEnabled { + logging.DebugStep(logger, "notifications init", "webhook disabled") + logging.Skip("Webhook: disabled") + return + } + + logging.DebugStep(logger, "notifications init", "webhook enabled") + logging.Debug("Initializing webhook notifier...") + webhookConfig := cfg.BuildWebhookConfig() + logging.Debug("Webhook config built: %d endpoints configured", len(webhookConfig.Endpoints)) + + webhookNotifier, err := notify.NewWebhookNotifier(webhookConfig, logger) + if err != nil { + logging.Warning("Failed to initialize Webhook notifier: %v", err) + return + } + logging.Debug("Creating webhook notification adapter...") + webhookAdapter := orchestrator.NewNotificationAdapter(webhookNotifier, logger) + + logging.Debug("Registering webhook notification channel with orchestrator...") + orch.RegisterNotificationChannel(webhookAdapter) + logging.Info("✓ Webhook initialized (%d endpoint(s))", len(webhookConfig.Endpoints)) +} + +func logBackupRuntimeSummary(cfg *config.Config, storageState backupStorageState) { + logBackupStorageSummary(cfg, storageState) + logBackupLogSummary(cfg) + logBackupNotificationSummary(cfg) +} + +func logBackupStorageSummary(cfg *config.Config, storageState backupStorageState) { + logging.Info("Storage configuration:") + logging.Info(" Primary: %s", formatStorageLabel(cfg.BackupPath, storageState.localFS)) + if cfg.SecondaryEnabled { + logging.Info(" Secondary storage: %s", formatStorageLabel(cfg.SecondaryPath, storageState.secondaryFS)) + } else { + logging.Skip(" Secondary storage: disabled") + } + if cfg.CloudEnabled { + logging.Info(" Cloud storage: %s", formatStorageLabel(cfg.CloudRemote, storageState.cloudFS)) + } else { + logging.Skip(" Cloud storage: disabled") + } + fmt.Println() +} + +func logBackupLogSummary(cfg *config.Config) { + logging.Info("Log configuration:") + logging.Info(" Primary: %s", cfg.LogPath) + if cfg.SecondaryEnabled { + if strings.TrimSpace(cfg.SecondaryLogPath) != "" { + logging.Info(" Secondary: %s", cfg.SecondaryLogPath) + } else { + logging.Skip(" Secondary: disabled (log path not configured)") + } + } else { + logging.Skip(" Secondary: disabled") + } + if cfg.CloudEnabled { + if strings.TrimSpace(cfg.CloudLogPath) != "" { + logging.Info(" Cloud: %s", cfg.CloudLogPath) + } else { + logging.Skip(" Cloud: disabled (log path not configured)") + } + } else { + logging.Skip(" Cloud: disabled") + } + fmt.Println() +} + +func logBackupNotificationSummary(cfg *config.Config) { + logging.Info("Notification configuration:") + logging.Info(" Telegram: %v", cfg.TelegramEnabled) + logging.Info(" Email: %v", cfg.EmailEnabled) + logging.Info(" Gotify: %v", cfg.GotifyEnabled) + logging.Info(" Webhook: %v", cfg.WebhookEnabled) + logging.Info(" Metrics: %v", cfg.MetricsEnabled) + fmt.Println() +} diff --git a/cmd/proxsave/backup_storage.go b/cmd/proxsave/backup_storage.go new file mode 100644 index 00000000..5cfb621f --- /dev/null +++ b/cmd/proxsave/backup_storage.go @@ -0,0 +1,160 @@ +// Package main contains the proxsave command entrypoint. +package main + +import ( + "fmt" + "time" + + "github.com/tis24dev/proxsave/internal/checks" + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/orchestrator" + "github.com/tis24dev/proxsave/internal/storage" + "github.com/tis24dev/proxsave/internal/types" +) + +type backupStorageState struct { + localFS *storage.FilesystemInfo + secondaryFS *storage.FilesystemInfo + cloudFS *storage.FilesystemInfo +} + +func initializeBackupStorage(opts backupModeOptions, orch *orchestrator.Orchestrator, checker *checks.Checker) (backupStorageState, *orchestrator.EarlyErrorState, int) { + cfg := opts.cfg + logger := opts.logger + state := backupStorageState{} + + logging.Step("Initializing storage backends") + storageDone := logging.DebugStart(logger, "storage init", "primary=%s secondary=%v cloud=%v", cfg.BackupPath, cfg.SecondaryEnabled, cfg.CloudEnabled) + + localBackend, localFS, storageFailureMessage, err := initializePrimaryStorage(opts) + if err != nil { + storageDone(err) + logging.Error("%s: %v", storageFailureMessage, err) + return state, &orchestrator.EarlyErrorState{ + Phase: "storage_init", + Error: err, + ExitCode: types.ExitConfigError, + Timestamp: time.Now(), + }, types.ExitConfigError.Int() + } + state.localFS = localFS + registerPrimaryStorage(opts, orch, localBackend, localFS) + + state.secondaryFS = initializeSecondaryStorage(opts, orch) + state.cloudFS = initializeCloudStorage(opts, orch, checker) + storageDone(nil) + + fmt.Println() + return state, nil, types.ExitSuccess.Int() +} + +func initializePrimaryStorage(opts backupModeOptions) (storage.Storage, *storage.FilesystemInfo, string, error) { + cfg := opts.cfg + logger := opts.logger + + logging.DebugStep(logger, "storage init", "primary backend") + localBackend, err := storage.NewLocalStorage(cfg, logger) + if err != nil { + return nil, nil, "Failed to initialize local storage", err + } + localFS, err := detectFilesystemInfo(opts.ctx, localBackend, cfg.BackupPath, logger) + if err != nil { + return nil, nil, "Failed to prepare primary storage", err + } + + logging.DebugStep(logger, "storage init", "primary filesystem=%s", formatDetailedFilesystemLabel(cfg.BackupPath, localFS)) + logging.Info("Path Primary: %s", formatDetailedFilesystemLabel(cfg.BackupPath, localFS)) + return localBackend, localFS, "", nil +} + +func registerPrimaryStorage(opts backupModeOptions, orch *orchestrator.Orchestrator, localBackend storage.Storage, localFS *storage.FilesystemInfo) { + cfg := opts.cfg + logger := opts.logger + + localStats := fetchStorageStats(opts.ctx, localBackend, logger, "Local storage") + localBackups := fetchBackupList(opts.ctx, localBackend) + logging.DebugStep(logger, "storage init", "primary stats=%v backups=%d", localStats != nil, len(localBackups)) + + localAdapter := orchestrator.NewStorageAdapter(localBackend, logger, cfg) + localAdapter.SetFilesystemInfo(localFS) + localAdapter.SetInitialStats(localStats) + orch.RegisterStorageTarget(localAdapter) + logStorageInitSummary(formatStorageInitSummary("Local storage", cfg, storage.LocationPrimary, localStats, localBackups)) +} + +func initializeSecondaryStorage(opts backupModeOptions, orch *orchestrator.Orchestrator) *storage.FilesystemInfo { + cfg := opts.cfg + logger := opts.logger + if !cfg.SecondaryEnabled { + logging.Skip("Path Secondary: disabled") + return nil + } + + logging.DebugStep(logger, "storage init", "secondary backend") + secondaryBackend, err := storage.NewSecondaryStorage(cfg, logger) + if err != nil { + logging.Warning("Failed to initialize secondary storage: %v", err) + logging.Info("Path Secondary: %s", formatDetailedFilesystemLabel(cfg.SecondaryPath, nil)) + return nil + } + + secondaryFS, _ := detectFilesystemInfo(opts.ctx, secondaryBackend, cfg.SecondaryPath, logger) + logging.DebugStep(logger, "storage init", "secondary filesystem=%s", formatDetailedFilesystemLabel(cfg.SecondaryPath, secondaryFS)) + logging.Info("Path Secondary: %s", formatDetailedFilesystemLabel(cfg.SecondaryPath, secondaryFS)) + secondaryStats := fetchStorageStats(opts.ctx, secondaryBackend, logger, "Secondary storage") + secondaryBackups := fetchBackupList(opts.ctx, secondaryBackend) + logging.DebugStep(logger, "storage init", "secondary stats=%v backups=%d", secondaryStats != nil, len(secondaryBackups)) + secondaryAdapter := orchestrator.NewStorageAdapter(secondaryBackend, logger, cfg) + secondaryAdapter.SetFilesystemInfo(secondaryFS) + secondaryAdapter.SetInitialStats(secondaryStats) + orch.RegisterStorageTarget(secondaryAdapter) + logStorageInitSummary(formatStorageInitSummary("Secondary storage", cfg, storage.LocationSecondary, secondaryStats, secondaryBackups)) + return secondaryFS +} + +func initializeCloudStorage(opts backupModeOptions, orch *orchestrator.Orchestrator, checker *checks.Checker) *storage.FilesystemInfo { + cfg := opts.cfg + logger := opts.logger + if !cfg.CloudEnabled { + logging.Skip("Path Cloud: disabled") + return nil + } + + logging.DebugStep(logger, "storage init", "cloud backend") + cloudBackend, err := storage.NewCloudStorage(cfg, logger) + if err != nil { + logging.Warning("Failed to initialize cloud storage: %v", err) + logging.Info("Path Cloud: %s", formatDetailedFilesystemLabel(cfg.CloudRemote, nil)) + logStorageInitSummary(formatStorageInitSummary("Cloud storage", cfg, storage.LocationCloud, nil, nil)) + return nil + } + + cloudFS, err := detectFilesystemInfo(opts.ctx, cloudBackend, cfg.CloudRemote, logger) + if cloudFS == nil { + reason := "filesystem detection unavailable" + if err != nil { + reason = fmt.Sprintf("filesystem detection failed: %v", err) + } + logging.DebugStep(logger, "storage init", "cloud unavailable, disabling: %s", reason) + cfg.CloudEnabled = false + cfg.CloudLogPath = "" + if checker != nil { + checker.DisableCloud() + } + logStorageInitSummary(fmt.Sprintf("%s; %s", formatStorageInitSummary("Cloud storage", cfg, storage.LocationCloud, nil, nil), reason)) + logging.Skip("Path Cloud: disabled (%s)", reason) + return nil + } + + logging.DebugStep(logger, "storage init", "cloud filesystem=%s", formatDetailedFilesystemLabel(cfg.CloudRemote, cloudFS)) + logging.Info("Path Cloud: %s", formatDetailedFilesystemLabel(cfg.CloudRemote, cloudFS)) + cloudStats := fetchStorageStats(opts.ctx, cloudBackend, logger, "Cloud storage") + cloudBackups := fetchBackupList(opts.ctx, cloudBackend) + logging.DebugStep(logger, "storage init", "cloud stats=%v backups=%d", cloudStats != nil, len(cloudBackups)) + cloudAdapter := orchestrator.NewStorageAdapter(cloudBackend, logger, cfg) + cloudAdapter.SetFilesystemInfo(cloudFS) + cloudAdapter.SetInitialStats(cloudStats) + orch.RegisterStorageTarget(cloudAdapter) + logStorageInitSummary(formatStorageInitSummary("Cloud storage", cfg, storage.LocationCloud, cloudStats, cloudBackups)) + return cloudFS +} diff --git a/cmd/proxsave/install.go b/cmd/proxsave/install.go index d3682624..4b8609cd 100644 --- a/cmd/proxsave/install.go +++ b/cmd/proxsave/install.go @@ -15,6 +15,7 @@ import ( cronutil "github.com/tis24dev/proxsave/internal/cron" "github.com/tis24dev/proxsave/internal/identity" "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/safeexec" "github.com/tis24dev/proxsave/internal/tui/wizard" buildinfo "github.com/tis24dev/proxsave/internal/version" ) @@ -860,7 +861,11 @@ func clearImmutableAttributesWithContext(ctx context.Context, target string, boo if err := ctx.Err(); err != nil { return err } - cmd := exec.CommandContext(ctx, args[0], args[1:]...) + cmd, err := safeexec.TrustedCommandContext(ctx, args[0], args[1:]...) + if err != nil { + logBootstrapWarning(bootstrap, "Failed to prepare chattr for %s: %v", target, err) + continue + } if out, err := cmd.CombinedOutput(); err != nil { if ctxErr := ctx.Err(); ctxErr != nil { return ctxErr diff --git a/cmd/proxsave/main.go b/cmd/proxsave/main.go index 893f8451..a5c3f2cd 100644 --- a/cmd/proxsave/main.go +++ b/cmd/proxsave/main.go @@ -1,37 +1,10 @@ +// Package main contains the proxsave command entrypoint. package main import ( - "context" - "encoding/json" - "errors" - "fmt" "os" - "os/signal" - "path/filepath" - "runtime" - "runtime/debug" - "runtime/pprof" - "strconv" - "strings" - "sync" "syscall" "time" - - "github.com/tis24dev/proxsave/internal/backup" - "github.com/tis24dev/proxsave/internal/checks" - "github.com/tis24dev/proxsave/internal/cli" - "github.com/tis24dev/proxsave/internal/config" - "github.com/tis24dev/proxsave/internal/environment" - "github.com/tis24dev/proxsave/internal/identity" - "github.com/tis24dev/proxsave/internal/logging" - "github.com/tis24dev/proxsave/internal/notify" - "github.com/tis24dev/proxsave/internal/orchestrator" - "github.com/tis24dev/proxsave/internal/security" - "github.com/tis24dev/proxsave/internal/storage" - "github.com/tis24dev/proxsave/internal/support" - "github.com/tis24dev/proxsave/internal/tui" - "github.com/tis24dev/proxsave/internal/types" - buildinfo "github.com/tis24dev/proxsave/internal/version" ) const ( @@ -53,1827 +26,20 @@ func main() { os.Exit(run()) } -var closeStdinOnce sync.Once - func run() int { - bootstrap := logging.NewBootstrapLogger() - - // Resolve the effective tool version once for the entire run. - toolVersion := buildinfo.String() - runDone := logging.DebugStartBootstrap(bootstrap, "main run", "version=%s", toolVersion) - - finalExitCode := types.ExitSuccess.Int() - showSummary := false - finalize := func(code int) int { - finalExitCode = code - return code - } - - // Track early errors that occur before backup starts - // This ensures notifications are sent even for initialization/config errors - var earlyErrorState *orchestrator.EarlyErrorState - var orch *orchestrator.Orchestrator - var pendingSupportStats *orchestrator.BackupStats - - defer func() { - logging.DebugStepBootstrap(bootstrap, "main run", "exit_code=%d", finalExitCode) - runDone(nil) - if r := recover(); r != nil { - stack := debug.Stack() - bootstrap.Error("PANIC: %v", r) - fmt.Fprintf(os.Stderr, "panic: %v\n%s\n", r, stack) - os.Exit(types.ExitPanicError.Int()) - } - }() - - // Setup signal handling for graceful shutdown - ctx, cancel := context.WithCancel(context.Background()) + runInfo := startMainRun() + defer finishMainRun(runInfo) + ctx, cancel := setupRunContext(runInfo.bootstrap) defer cancel() - tui.SetAbortContext(ctx) - - // Handle SIGINT (Ctrl+C) and SIGTERM - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - go func() { - sig := <-sigChan - logging.DebugStepBootstrap(bootstrap, "signal", "received=%v", sig) - bootstrap.Info("\nReceived signal %v, initiating graceful shutdown...", sig) - cancel() // Cancel context to stop all operations - closeStdinOnce.Do(func() { - if file := os.Stdin; file != nil { - _ = file.Close() - } - }) - }() - - // Parse command-line arguments - args := cli.Parse() - logging.DebugStepBootstrap(bootstrap, "main run", "args parsed") - - // Handle version flag - if args.ShowVersion { - cli.ShowVersion() - return types.ExitSuccess.Int() - } - - // Handle help flag - if args.ShowHelp { - cli.ShowHelp() - return types.ExitSuccess.Int() - } - - if args.CleanupGuards { - incompatible := make([]string, 0, 8) - if args.Support { - incompatible = append(incompatible, "--support") - } - if args.Restore { - incompatible = append(incompatible, "--restore") - } - if args.Decrypt { - incompatible = append(incompatible, "--decrypt") - } - if args.Install { - incompatible = append(incompatible, "--install") - } - if args.NewInstall { - incompatible = append(incompatible, "--new-install") - } - if args.Upgrade { - incompatible = append(incompatible, "--upgrade") - } - if args.ForceNewKey { - incompatible = append(incompatible, "--newkey") - } - if args.EnvMigration || args.EnvMigrationDry { - incompatible = append(incompatible, "--env-migration/--env-migration-dry-run") - } - if args.UpgradeConfig || args.UpgradeConfigDry || args.UpgradeConfigJSON { - incompatible = append(incompatible, "--upgrade-config/--upgrade-config-dry-run/--upgrade-config-json") - } - - if len(incompatible) > 0 { - bootstrap.Error("--cleanup-guards cannot be combined with: %s", strings.Join(incompatible, ", ")) - return types.ExitConfigError.Int() - } - - level := types.LogLevelInfo - if args.LogLevel != types.LogLevelNone { - level = args.LogLevel - } - logger := logging.New(level, false) - - if err := orchestrator.CleanupMountGuards(ctx, logger, args.DryRun); err != nil { - bootstrap.Error("ERROR: %v", err) - return types.ExitGenericError.Int() - } - return types.ExitSuccess.Int() - } - - // Validate support mode compatibility with other CLI modes - logging.DebugStepBootstrap(bootstrap, "main run", "support_mode=%v", args.Support) - if args.Support { - incompatible := make([]string, 0, 6) - if args.Restore { - // allowed - } - if args.Decrypt { - incompatible = append(incompatible, "--decrypt") - } - if args.Install { - incompatible = append(incompatible, "--install") - } - if args.NewInstall { - incompatible = append(incompatible, "--new-install") - } - if args.EnvMigration || args.EnvMigrationDry { - incompatible = append(incompatible, "--env-migration") - } - if args.UpgradeConfig || args.UpgradeConfigDry || args.UpgradeConfigJSON { - incompatible = append(incompatible, "--upgrade-config") - } - if args.ForceNewKey { - incompatible = append(incompatible, "--newkey") - } - - if len(incompatible) > 0 { - bootstrap.Error("Support mode cannot be combined with: %s", strings.Join(incompatible, ", ")) - bootstrap.Error("--support is only available for the standard backup run or --restore.") - return types.ExitConfigError.Int() - } - } - - if args.Install && args.NewInstall { - bootstrap.Error("Cannot use --install and --new-install together. Choose one installation mode.") - return types.ExitConfigError.Int() - } - - if args.Upgrade && (args.Install || args.NewInstall) { - bootstrap.Error("Cannot use --upgrade together with --install or --new-install.") - return types.ExitConfigError.Int() - } - - // Resolve configuration path relative to the executable's base directory so - // that configs/ is located consistently next to the binary, regardless of - // the current working directory. - logging.DebugStepBootstrap(bootstrap, "main run", "resolving config path") - resolvedConfigPath, err := resolveInstallConfigPath(args.ConfigPath) - if err != nil { - bootstrap.Error("ERROR: %v", err) - return types.ExitConfigError.Int() - } - args.ConfigPath = resolvedConfigPath - - if args.UpgradeConfigJSON { - if _, err := os.Stat(args.ConfigPath); err != nil { - fmt.Fprintf(os.Stderr, "ERROR: configuration file not found: %v\n", err) - return types.ExitConfigError.Int() - } - - result, err := config.UpgradeConfigFile(args.ConfigPath) - if err != nil { - fmt.Fprintf(os.Stderr, "ERROR: Failed to upgrade configuration: %v\n", err) - return types.ExitConfigError.Int() - } - if result == nil { - result = &config.UpgradeResult{} - } - - enc := json.NewEncoder(os.Stdout) - if err := enc.Encode(result); err != nil { - fmt.Fprintf(os.Stderr, "ERROR: Failed to encode JSON: %v\n", err) - return types.ExitGenericError.Int() - } - return types.ExitSuccess.Int() - } - - // Dedicated upgrade mode (download latest binary and upgrade config keys) - if args.Upgrade { - logging.DebugStepBootstrap(bootstrap, "main run", "mode=upgrade") - return runUpgrade(ctx, args, bootstrap) - } - - newKeyCLI := args.ForceCLI - // Dedicated new key mode (no backup run) - if args.ForceNewKey { - logging.DebugStepBootstrap(bootstrap, "main run", "mode=newkey cli=%v", newKeyCLI) - flowLogLevel := types.LogLevelInfo - if args.LogLevel != types.LogLevelNone { - flowLogLevel = args.LogLevel - } - if err := runNewKey(ctx, args.ConfigPath, flowLogLevel, bootstrap, newKeyCLI); err != nil { - if isInstallAbortedError(err) || errors.Is(err, orchestrator.ErrAgeRecipientSetupAborted) { - return types.ExitSuccess.Int() - } - bootstrap.Error("ERROR: %v", err) - return types.ExitConfigError.Int() - } - return types.ExitSuccess.Int() - } - - decryptCLI := args.ForceCLI - if args.Decrypt { - logging.DebugStepBootstrap(bootstrap, "main run", "mode=decrypt cli=%v", decryptCLI) - if err := runDecryptWorkflowOnly(ctx, args.ConfigPath, bootstrap, toolVersion, decryptCLI); err != nil { - if errors.Is(err, orchestrator.ErrDecryptAborted) { - bootstrap.Info("Decrypt workflow aborted by user") - return types.ExitSuccess.Int() - } - bootstrap.Error("ERROR: %v", err) - return types.ExitGenericError.Int() - } - bootstrap.Info("Decrypt workflow completed successfully") - return types.ExitSuccess.Int() - } - - newInstallCLI := args.ForceCLI - if args.NewInstall { - logging.DebugStepBootstrap(bootstrap, "main run", "mode=new-install cli=%v", newInstallCLI) - flowLogLevel := types.LogLevelInfo - if args.LogLevel != types.LogLevelNone { - flowLogLevel = args.LogLevel - } - sessionLogger, cleanupSessionLog := startFlowSessionLog("new-install", flowLogLevel, bootstrap) - defer cleanupSessionLog() - if sessionLogger != nil { - sessionLogger.Info("Starting --new-install (config=%s)", args.ConfigPath) - } - if err := runNewInstall(ctx, args.ConfigPath, bootstrap, newInstallCLI); err != nil { - if sessionLogger != nil { - if isInstallAbortedError(err) { - sessionLogger.Warning("new-install aborted by user: %v", err) - } else { - sessionLogger.Error("new-install failed: %v", err) - } - } - // Interactive aborts (Ctrl+C, explicit cancel) are treated as a graceful exit - // and already summarized by the install footer. - if isInstallAbortedError(err) { - return types.ExitSuccess.Int() - } - bootstrap.Error("ERROR: %v", err) - return types.ExitConfigError.Int() - } - if sessionLogger != nil { - sessionLogger.Info("new-install completed successfully") - } - return types.ExitSuccess.Int() - } - - // Handle configuration upgrade dry-run (plan-only, no writes). - if args.UpgradeConfigDry { - logging.DebugStepBootstrap(bootstrap, "main run", "mode=upgrade-config-dry") - if err := ensureConfigExists(args.ConfigPath, bootstrap); err != nil { - bootstrap.Error("ERROR: %v", err) - return types.ExitConfigError.Int() - } - - bootstrap.Printf("Planning configuration upgrade using embedded template: %s", args.ConfigPath) - result, err := config.PlanUpgradeConfigFile(args.ConfigPath) - if err != nil { - bootstrap.Error("ERROR: Failed to plan configuration upgrade: %v", err) - return types.ExitConfigError.Int() - } - if len(result.Warnings) > 0 { - bootstrap.Warning("Config upgrade warnings (%d):", len(result.Warnings)) - for _, warning := range result.Warnings { - bootstrap.Warning(" - %s", warning) - } - } - if !result.Changed { - bootstrap.Println("Configuration is already up to date with the embedded template; no changes are required.") - return types.ExitSuccess.Int() - } - - if len(result.MissingKeys) > 0 { - bootstrap.Printf("Missing keys that would be added from the template (%d): %s", - len(result.MissingKeys), strings.Join(result.MissingKeys, ", ")) - } - if result.PreservedValues > 0 { - bootstrap.Printf("Existing values that would be preserved: %d", result.PreservedValues) - } - if len(result.ExtraKeys) > 0 { - bootstrap.Printf("Custom keys that would be preserved (not present in template) (%d): %s", - len(result.ExtraKeys), strings.Join(result.ExtraKeys, ", ")) - } - if len(result.CaseConflictKeys) > 0 { - bootstrap.Printf("Keys that differ only by case from the template (%d): %s", - len(result.CaseConflictKeys), strings.Join(result.CaseConflictKeys, ", ")) - } - bootstrap.Println("Dry run only: no files were modified. Use --upgrade-config to apply these changes.") - return types.ExitSuccess.Int() - } - - // Handle install wizard (runs before normal execution) - if args.Install { - logging.DebugStepBootstrap(bootstrap, "main run", "mode=install cli=%v", args.ForceCLI) - flowLogLevel := types.LogLevelInfo - if args.LogLevel != types.LogLevelNone { - flowLogLevel = args.LogLevel - } - sessionLogger, cleanupSessionLog := startFlowSessionLog("install", flowLogLevel, bootstrap) - defer cleanupSessionLog() - if sessionLogger != nil { - sessionLogger.Info("Starting --install (config=%s)", args.ConfigPath) - } - - var err error - if args.ForceCLI { - err = runInstall(ctx, args.ConfigPath, bootstrap) - } else { - err = runInstallTUI(ctx, args.ConfigPath, bootstrap) - } - - if err != nil { - if sessionLogger != nil { - if isInstallAbortedError(err) { - sessionLogger.Warning("install aborted by user: %v", err) - } else { - sessionLogger.Error("install failed: %v", err) - } - } - // Interactive aborts (Ctrl+C, explicit cancel) are treated as a graceful exit - // and already summarized by the install footer. - if isInstallAbortedError(err) { - return types.ExitSuccess.Int() - } - bootstrap.Error("ERROR: %v", err) - return types.ExitConfigError.Int() - } - if sessionLogger != nil { - sessionLogger.Info("install completed successfully") - } - return types.ExitSuccess.Int() - } - - // Pre-flight: enforce Go runtime version - if err := checkGoRuntimeVersion(goRuntimeMinVersion); err != nil { - bootstrap.Error("ERROR: %v", err) - return types.ExitEnvironmentError.Int() - } - - // Print header - bootstrap.Println("===========================================") - bootstrap.Println(" ProxSave - Go Version") - bootstrap.Printf(" Version: %s", toolVersion) - if sig := buildSignature(); sig != "" { - bootstrap.Printf(" Build Signature: %s", sig) - } - bootstrap.Println("===========================================") - bootstrap.Println("") - - // Detect Proxmox environment - bootstrap.Println("Detecting Proxmox environment...") - envInfo, err := environment.Detect() - if err != nil { - bootstrap.Warning("WARNING: %v", err) - bootstrap.Println("Continuing with limited functionality...") - } - bootstrap.Printf("✓ Proxmox Type: %s", envInfo.Type) - if envInfo.Type == types.ProxmoxDual { - bootstrap.Printf(" PVE Version: %s", envInfo.PVEVersion) - bootstrap.Printf(" PBS Version: %s", envInfo.PBSVersion) - } else { - bootstrap.Printf(" Version: %s", envInfo.Version) - } - bootstrap.Println("") - - // Handle configuration upgrade (schema-aware merge with embedded template). - if args.UpgradeConfig { - logging.DebugStepBootstrap(bootstrap, "main run", "mode=upgrade-config") - if err := ensureConfigExists(args.ConfigPath, bootstrap); err != nil { - bootstrap.Error("ERROR: %v", err) - return types.ExitConfigError.Int() - } - - bootstrap.Printf("Upgrading configuration file: %s", args.ConfigPath) - result, err := config.UpgradeConfigFile(args.ConfigPath) - if err != nil { - bootstrap.Error("ERROR: Failed to upgrade configuration: %v", err) - return types.ExitConfigError.Int() - } - if len(result.Warnings) > 0 { - bootstrap.Warning("Config upgrade warnings (%d):", len(result.Warnings)) - for _, warning := range result.Warnings { - bootstrap.Warning(" - %s", warning) - } - } - if !result.Changed { - bootstrap.Println("Configuration is already up to date with the embedded template; no changes were made.") - return types.ExitSuccess.Int() - } - - bootstrap.Println("Configuration upgraded successfully!") - if len(result.MissingKeys) > 0 { - bootstrap.Printf("- Added %d missing key(s): %s", - len(result.MissingKeys), strings.Join(result.MissingKeys, ", ")) - } else { - bootstrap.Println("- No new keys were required from the template") - } - if result.PreservedValues > 0 { - bootstrap.Printf("- Preserved %d existing value(s) from current configuration", result.PreservedValues) - } - if len(result.ExtraKeys) > 0 { - bootstrap.Printf("- Kept %d custom key(s) not present in the template: %s", - len(result.ExtraKeys), strings.Join(result.ExtraKeys, ", ")) - } - if len(result.CaseConflictKeys) > 0 { - bootstrap.Printf("- Preserved %d key(s) that differ only by case: %s", - len(result.CaseConflictKeys), strings.Join(result.CaseConflictKeys, ", ")) - } - if result.BackupPath != "" { - bootstrap.Printf("- Backup saved to: %s", result.BackupPath) - } - bootstrap.Println("✓ Configuration upgrade completed successfully.") - return types.ExitSuccess.Int() - } - - if args.EnvMigrationDry { - logging.DebugStepBootstrap(bootstrap, "main run", "mode=env-migration-dry") - return runEnvMigrationDry(ctx, args, bootstrap) - } - - if args.EnvMigration { - logging.DebugStepBootstrap(bootstrap, "main run", "mode=env-migration") - return runEnvMigration(ctx, args, bootstrap) - } - - // Support mode: interactive pre-flight questionnaire (mandatory) - if args.Support { - logging.DebugStepBootstrap(bootstrap, "main run", "mode=support") - meta, continueRun, interrupted := support.RunIntro(ctx, bootstrap) - if continueRun { - args.SupportGitHubUser = meta.GitHubUser - args.SupportIssueID = meta.IssueID - } else { - if interrupted { - // Interrupted by signal (Ctrl+C): set exit code and still show footer. - finalize(exitCodeInterrupted) - printFinalSummary(finalExitCode) - return finalExitCode - } - // Graceful abort (user declined support flow) - show standard footer. - finalize(types.ExitGenericError.Int()) - printFinalSummary(finalExitCode) - return finalExitCode - } - } - - // Load configuration - autoBaseDir, autoFound := detectBaseDir() - if autoBaseDir == "" { - if _, err := os.Stat("/opt/proxsave"); err == nil { - autoBaseDir = "/opt/proxsave" - } else { - autoBaseDir = "/opt/proxmox-backup" - } - } - initialEnvBaseDir := os.Getenv("BASE_DIR") - if initialEnvBaseDir == "" { - _ = os.Setenv("BASE_DIR", autoBaseDir) - } - - if err := ensureConfigExists(args.ConfigPath, bootstrap); err != nil { - bootstrap.Error("ERROR: %v", err) - return types.ExitConfigError.Int() - } - - bootstrap.Printf("Loading configuration from: %s", args.ConfigPath) - logging.DebugStepBootstrap(bootstrap, "main run", "loading configuration") - cfg, err := config.LoadConfig(args.ConfigPath) - if err != nil { - bootstrap.Error("ERROR: Failed to load configuration: %v", err) - return types.ExitConfigError.Int() - } - if cfg.BaseDir == "" { - cfg.BaseDir = autoBaseDir - } - _ = os.Setenv("BASE_DIR", cfg.BaseDir) - bootstrap.Println("✓ Configuration loaded successfully") - - // Show dry-run status early in bootstrap phase - dryRun := args.DryRun || cfg.DryRun - if dryRun { - if args.DryRun { - bootstrap.Println("⚠ DRY RUN MODE (enabled via --dry-run flag)") - } else { - bootstrap.Println("⚠ DRY RUN MODE (enabled via DRY_RUN config)") - } - } - bootstrap.Println("") - - if err := validateFutureFeatures(cfg); err != nil { - bootstrap.Error("ERROR: Invalid configuration: %v", err) - return types.ExitConfigError.Int() - } - - // Validate log path configuration early to avoid "cosmetic only" logging. - // If a log feature is enabled but its path is empty, disable the path-driven - // behavior and document the detection to the user. - if strings.TrimSpace(cfg.LogPath) == "" { - bootstrap.Warning("WARNING: LOG_PATH is empty - file logging disabled, using stdout only") - } - if cfg.SecondaryEnabled && strings.TrimSpace(cfg.SecondaryLogPath) == "" { - bootstrap.Warning("WARNING: Secondary storage enabled but SECONDARY_LOG_PATH is empty - secondary log copy and cleanup will be disabled for this run") - } - if cfg.CloudEnabled && strings.TrimSpace(cfg.CloudLogPath) == "" { - bootstrap.Warning("WARNING: Cloud storage enabled but CLOUD_LOG_PATH is empty - cloud log copy and cleanup will be disabled for this run") - } - - // Pre-flight: if features require network, verify basic connectivity - if needs, reasons := featuresNeedNetwork(cfg); needs { - if cfg.DisableNetworkPreflight { - bootstrap.Warning("WARNING: Network preflight disabled via DISABLE_NETWORK_PREFLIGHT; features: %s", strings.Join(reasons, ", ")) - } else { - if err := checkInternetConnectivity(networkPreflightTimeout); err != nil { - bootstrap.Warning("WARNING: Network connectivity unavailable for: %s. %v", strings.Join(reasons, ", "), err) - bootstrap.Warning("WARNING: Disabling network-dependent features for this run") - disableNetworkFeaturesForRun(cfg, bootstrap) - } - } - } - - // Determine log level (CLI overrides config) - logLevel := cfg.DebugLevel - if args.Support { - bootstrap.Println("Support mode enabled: forcing log level to DEBUG") - logLevel = types.LogLevelDebug - } else if args.LogLevel != types.LogLevelNone { - logLevel = args.LogLevel - } - logging.DebugStepBootstrap(bootstrap, "main run", "log_level=%s", logLevel.String()) - - // Initialize logger with configuration - logger := logging.New(logLevel, cfg.UseColor) - sessionLogActive := false - sessionLogCloser := func() {} - if args.Restore { - logging.DebugStepBootstrap(bootstrap, "main run", "restore log enabled") - if restoreLogger, restoreLogPath, closeFn, err := logging.StartSessionLogger("restore", logLevel, cfg.UseColor); err == nil { - logger = restoreLogger - sessionLogCloser = closeFn - sessionLogActive = true - bootstrap.Info("Restore log: %s", restoreLogPath) - _ = os.Setenv("LOG_FILE", restoreLogPath) - } else { - bootstrap.Warning("WARNING: Unable to start restore log: %v", err) - } - } - - logging.SetDefaultLogger(logger) - bootstrap.SetLevel(logLevel) - - // Open log file for real-time writing (will be closed after notifications) - hostname := resolveHostname() - startTime := time.Now() - timestampStr := startTime.Format("20060102-150405") - - if sessionLogActive { - defer sessionLogCloser() - } else { - logFileName := fmt.Sprintf("backup-%s-%s.log", hostname, timestampStr) - logFilePath := filepath.Join(cfg.LogPath, logFileName) - - // Ensure log directory exists - if err := os.MkdirAll(cfg.LogPath, defaultDirPerm); err != nil { - logging.Warning("Failed to create log directory %s: %v", cfg.LogPath, err) - } else { - if err := logger.OpenLogFile(logFilePath); err != nil { - logging.Warning("Failed to open log file %s: %v", logFilePath, err) - } else { - logging.Info("Log file opened: %s", logFilePath) - // Store log path in environment for backup stats - _ = os.Setenv("LOG_FILE", logFilePath) - } - } - } - - // Flush bootstrap logs into the main logger now that log files (if any) - // are attached, so that early banners and messages appear at the top - // of the corresponding log. - bootstrap.Flush(logger) - - // Best-effort check for newer releases on GitHub. - // If the installed version is up to date, nothing is printed at INFO/WARNING level - // (only a DEBUG message is logged). If a newer version exists, a WARNING is emitted - // suggesting the use of --upgrade. - updateInfo := checkForUpdates(ctx, logger, toolVersion) - - // Apply backup permissions (optional, Bash-compatible behavior) - if cfg.SetBackupPermissions { - logging.DebugStep(logger, "main", "applying backup permissions") - if err := applyBackupPermissions(cfg, logger); err != nil { - logging.Warning("Failed to apply backup permissions: %v", err) - } - } - - // Optional CPU/heap profiling (pprof) - controlled by PROFILING_ENABLED - var cpuProfileFile *os.File - var heapProfilePath string - if cfg.ProfilingEnabled { - cpuProfilePath := filepath.Join(cfg.LogPath, fmt.Sprintf("cpu-%s-%s.pprof", hostname, timestampStr)) - f, err := os.Create(cpuProfilePath) - if err != nil { - logging.Warning("Failed to create CPU profile file: %v", err) - } else { - if err := pprof.StartCPUProfile(f); err != nil { - logging.Warning("Failed to start CPU profiling: %v", err) - _ = f.Close() - } else { - cpuProfileFile = f - logging.Info("CPU profiling enabled: %s", cpuProfilePath) - - tmpProfileDir := filepath.Join("/tmp", "proxsave") - if err := os.MkdirAll(tmpProfileDir, defaultDirPerm); err != nil { - logging.Warning("Failed to create temp profile directory %s: %v", tmpProfileDir, err) - } else { - heapProfilePath = filepath.Join(tmpProfileDir, fmt.Sprintf("heap-%s-%s.pprof", hostname, timestampStr)) - } - } - } - } - - defer func() { - if showSummary { - printFinalSummary(finalExitCode) - } - }() - - // Defer for network rollback countdown (LIFO: executes BEFORE footer) - defer func() { - if finalExitCode == exitCodeInterrupted { - if abortInfo := orchestrator.GetLastRestoreAbortInfo(); abortInfo != nil { - printNetworkRollbackCountdown(abortInfo) - } - } - }() - - defer func() { - if !args.Support || pendingSupportStats == nil { - return - } - logging.Step("Support mode - sending support email with attached log") - support.SendEmail(ctx, cfg, logger, envInfo.Type, pendingSupportStats, support.Meta{ - GitHubUser: args.SupportGitHubUser, - IssueID: args.SupportIssueID, - }, buildSignature()) - }() - - // Defer for early error notifications - // This executes BEFORE the footer defer (LIFO order) - // Ensures notifications are sent even for errors that occur before backup starts - defer func() { - if earlyErrorState != nil && earlyErrorState.HasError() && orch != nil { - fmt.Println() - logging.Step("Sending error notifications") - stats := orch.DispatchEarlyErrorNotification(ctx, earlyErrorState) - if stats != nil { - pendingSupportStats = stats - } - orch.FinalizeAndCloseLog(ctx) - } - }() - - defer func() { - if cpuProfileFile != nil { - pprof.StopCPUProfile() - _ = cpuProfileFile.Close() - } - if heapProfilePath != "" { - if f, err := os.Create(heapProfilePath); err == nil { - if err := pprof.WriteHeapProfile(f); err != nil { - logging.Warning("Failed to write heap profile: %v", err) - } - _ = f.Close() - } else { - logging.Warning("Failed to create heap profile file: %v", err) - } - } - }() - - defer cleanupAfterRun(logger) - showSummary = true - - // Log dry-run status in main logger (already shown in bootstrap) - if dryRun { - if args.DryRun { - logging.Info("DRY RUN MODE: No actual changes will be made (enabled via --dry-run flag)") - } else { - logging.Info("DRY RUN MODE: No actual changes will be made (enabled via DRY_RUN config)") - } - } - // Determine base directory source for logging - baseDirSource := "default fallback" - if rawBaseDir, ok := cfg.Get("BASE_DIR"); ok && strings.TrimSpace(rawBaseDir) != "" { - baseDirSource = "configured in backup.env" - } else if initialEnvBaseDir != "" { - baseDirSource = "from environment (BASE_DIR)" - } else if autoFound { - baseDirSource = "auto-detected from executable path" + args, exitCode, handled := preparePreRuntimeArgs(ctx, runInfo.bootstrap, runInfo.toolVersion) + if handled { + return exitCode } - // Log environment info - logging.Info("Environment: %s %s", envInfo.Type, envInfo.Version) - unprivilegedInfo := environment.DetectUnprivilegedContainer() - logUserNamespaceContext(logger, unprivilegedInfo) - logging.Info("Backup enabled: %v", cfg.BackupEnabled) - logging.Info("Debug level: %s", logLevel.String()) - logging.Info("Compression: %s (level %d, mode %s)", cfg.CompressionType, cfg.CompressionLevel, cfg.CompressionMode) - logging.Info("Base directory: %s (%s)", cfg.BaseDir, baseDirSource) - configSource := args.ConfigPathSource - if configSource == "" { - configSource = "configured path" - } - logging.Info("Configuration file: %s (%s)", args.ConfigPath, configSource) - - var identityInfo *identity.Info - serverIDValue := strings.TrimSpace(cfg.ServerID) - serverMACValue := "" - telegramServerStatus := "Telegram disabled" - if info, err := identity.DetectWithContext(ctx, cfg.BaseDir, logger); err != nil { - logging.Warning("WARNING: Failed to load server identity: %v", err) - identityInfo = info - } else { - identityInfo = info - } - - if identityInfo != nil { - if identityInfo.ServerID != "" { - serverIDValue = identityInfo.ServerID - } - if identityInfo.PrimaryMAC != "" { - serverMACValue = identityInfo.PrimaryMAC - } - } - - if serverIDValue != "" && cfg.ServerID == "" { - cfg.ServerID = serverIDValue - } - - logServerIdentityValues(serverIDValue, serverMACValue) - logTelegramInfo := true - if cfg.TelegramEnabled { - if strings.EqualFold(cfg.TelegramBotType, "centralized") { - logging.Debug("Contacting remote Telegram server...") - logging.Debug("Telegram server URL: %s", cfg.TelegramServerAPIHost) - status := notify.CheckTelegramRegistration(ctx, cfg.TelegramServerAPIHost, serverIDValue, logger) - if status.Error != nil { - logging.Warning("Telegram: %s", status.Message) - logging.Debug("Telegram connection error: %v", status.Error) - logging.Skip("Telegram: disabled") - cfg.TelegramEnabled = false - logTelegramInfo = false - } else { - logging.Debug("Remote server contacted: Bot token / chat ID verified (handshake)") - } - telegramServerStatus = status.Message - } else { - telegramServerStatus = "Personal mode - no remote contact" - } - } - if logTelegramInfo { - logging.Info("Server Telegram: %s", telegramServerStatus) - } - fmt.Println() - - execInfo := getExecInfo() - execPath := execInfo.ExecPath - logging.DebugStep(logger, "main", "running security checks") - if _, secErr := security.Run(ctx, logger, cfg, args.ConfigPath, execPath, envInfo); secErr != nil { - logging.Error("Security checks failed: %v", secErr) - return finalize(types.ExitSecurityError.Int()) - } - fmt.Println() - - restoreCLI := args.ForceCLI - if args.Restore { - logging.DebugStep(logger, "main", "mode=restore cli=%v", restoreCLI) - if restoreCLI { - logging.Info("Restore mode enabled - starting CLI workflow...") - if err := orchestrator.RunRestoreWorkflow(ctx, cfg, logger, toolVersion); err != nil { - if errors.Is(err, orchestrator.ErrRestoreAborted) { - logging.Warning("Restore workflow aborted by user") - if args.Support { - pendingSupportStats = support.BuildSupportStats(logger, resolveHostname(), envInfo.Type, envInfo.Version, toolVersion, startTime, time.Now(), exitCodeInterrupted, "restore") - } - return finalize(exitCodeInterrupted) - } - logging.Error("Restore workflow failed: %v", err) - if args.Support { - pendingSupportStats = support.BuildSupportStats(logger, resolveHostname(), envInfo.Type, envInfo.Version, toolVersion, startTime, time.Now(), types.ExitGenericError.Int(), "restore") - } - return finalize(types.ExitGenericError.Int()) - } - if logger.HasWarnings() { - logging.Warning("Restore workflow completed with warnings (see log above)") - } else { - logging.Info("Restore workflow completed successfully") - } - if args.Support { - pendingSupportStats = support.BuildSupportStats(logger, resolveHostname(), envInfo.Type, envInfo.Version, toolVersion, startTime, time.Now(), types.ExitSuccess.Int(), "restore") - } - return finalize(types.ExitSuccess.Int()) - } - - logging.Info("Restore mode enabled - starting interactive workflow...") - sig := buildSignature() - if strings.TrimSpace(sig) == "" { - sig = "n/a" - } - if err := orchestrator.RunRestoreWorkflowTUI(ctx, cfg, logger, toolVersion, args.ConfigPath, sig); err != nil { - if errors.Is(err, orchestrator.ErrRestoreAborted) || errors.Is(err, orchestrator.ErrDecryptAborted) { - logging.Warning("Restore workflow aborted by user") - if args.Support { - pendingSupportStats = support.BuildSupportStats(logger, resolveHostname(), envInfo.Type, envInfo.Version, toolVersion, startTime, time.Now(), exitCodeInterrupted, "restore") - } - return finalize(exitCodeInterrupted) - } - logging.Error("Restore workflow failed: %v", err) - if args.Support { - pendingSupportStats = support.BuildSupportStats(logger, resolveHostname(), envInfo.Type, envInfo.Version, toolVersion, startTime, time.Now(), types.ExitGenericError.Int(), "restore") - } - return finalize(types.ExitGenericError.Int()) - } - if logger.HasWarnings() { - logging.Warning("Restore workflow completed with warnings (see log above)") - } else { - logging.Info("Restore workflow completed successfully") - } - if args.Support { - pendingSupportStats = support.BuildSupportStats(logger, resolveHostname(), envInfo.Type, envInfo.Version, toolVersion, startTime, time.Now(), types.ExitSuccess.Int(), "restore") - } - return finalize(types.ExitSuccess.Int()) - } - - if args.Decrypt { - logging.DebugStep(logger, "main", "mode=decrypt cli=%v", decryptCLI) - if decryptCLI { - logging.Info("Decrypt mode enabled - starting CLI workflow...") - if err := orchestrator.RunDecryptWorkflow(ctx, cfg, logger, toolVersion); err != nil { - if errors.Is(err, orchestrator.ErrDecryptAborted) { - logging.Info("Decrypt workflow aborted by user") - return finalize(types.ExitSuccess.Int()) - } - logging.Error("Decrypt workflow failed: %v", err) - return finalize(types.ExitGenericError.Int()) - } - logging.Info("Decrypt workflow completed successfully") - } else { - logging.Info("Decrypt mode enabled - starting interactive workflow...") - sig := buildSignature() - if strings.TrimSpace(sig) == "" { - sig = "n/a" - } - if err := orchestrator.RunDecryptWorkflowTUI(ctx, cfg, logger, toolVersion, args.ConfigPath, sig); err != nil { - if errors.Is(err, orchestrator.ErrDecryptAborted) { - logging.Info("Decrypt workflow aborted by user") - return finalize(types.ExitSuccess.Int()) - } - logging.Error("Decrypt workflow failed: %v", err) - return finalize(types.ExitGenericError.Int()) - } - logging.Info("Decrypt workflow completed successfully") - } - return finalize(types.ExitSuccess.Int()) - } - - // Initialize orchestrator - logging.Step("Initializing backup orchestrator") - orchInitDone := logging.DebugStart(logger, "orchestrator init", "dry_run=%v", dryRun) - orch = orchestrator.New(logger, dryRun) - orch.SetUnprivilegedContainerContext(unprivilegedInfo.Detected, unprivilegedInfo.Details) - orch.SetVersion(toolVersion) - orch.SetConfig(cfg) - orch.SetIdentity(serverIDValue, serverMACValue) - orch.SetEnvironmentInfo(envInfo) - orch.SetStartTime(startTime) - if updateInfo != nil { - orch.SetUpdateInfo(updateInfo.NewVersion, updateInfo.Current, updateInfo.Latest) - } - - // Configure backup paths and compression - excludePatterns := append([]string(nil), cfg.ExcludePatterns...) - excludePatterns = addPathExclusion(excludePatterns, cfg.BackupPath) - if cfg.SecondaryEnabled { - excludePatterns = addPathExclusion(excludePatterns, cfg.SecondaryPath) - } - if cfg.CloudEnabled && isLocalPath(cfg.CloudRemote) { - excludePatterns = addPathExclusion(excludePatterns, cfg.CloudRemote) - } - - orch.SetBackupConfig( - cfg.BackupPath, - cfg.LogPath, - cfg.CompressionType, - cfg.CompressionLevel, - cfg.CompressionThreads, - cfg.CompressionMode, - excludePatterns, - ) - - orch.SetOptimizationConfig(backup.OptimizationConfig{ - EnableChunking: cfg.EnableSmartChunking, - EnableDeduplication: cfg.EnableDeduplication, - EnablePrefilter: cfg.EnablePrefilter, - ChunkSizeBytes: int64(cfg.ChunkSizeMB) * bytesPerMegabyte, - ChunkThresholdBytes: int64(cfg.ChunkThresholdMB) * bytesPerMegabyte, - PrefilterMaxFileSizeBytes: int64(cfg.PrefilterMaxFileSizeMB) * bytesPerMegabyte, - }) - - if err := orch.EnsureAgeRecipientsReady(ctx); err != nil { - orchInitDone(err) - if errors.Is(err, orchestrator.ErrAgeRecipientSetupAborted) { - logging.Warning("Encryption setup aborted by user. Exiting...") - earlyErrorState = &orchestrator.EarlyErrorState{ - Phase: "encryption_setup", - Error: err, - ExitCode: types.ExitGenericError, - Timestamp: time.Now(), - } - return finalize(types.ExitGenericError.Int()) - } - logging.Error("ERROR: %v", err) - earlyErrorState = &orchestrator.EarlyErrorState{ - Phase: "encryption_setup", - Error: err, - ExitCode: types.ExitConfigError, - Timestamp: time.Now(), - } - return finalize(types.ExitConfigError.Int()) - } - orchInitDone(nil) - - logging.Info("✓ Orchestrator initialized") - fmt.Println() - - // Verify directories - logging.Step("Verifying directory structure") - checkDir := func(name, path string) { - ensureDirectoryExists(logger, name, path) - } - - checkDir("Backup directory", cfg.BackupPath) - checkDir("Log directory", cfg.LogPath) - if cfg.SecondaryEnabled { - secondaryLogPath := strings.TrimSpace(cfg.SecondaryLogPath) - if secondaryLogPath != "" { - checkDir("Secondary log directory", secondaryLogPath) - } else { - logging.Warning("✗ Secondary log directory not configured (secondary storage enabled)") - } - } - if cfg.CloudEnabled { - cloudLogPath := strings.TrimSpace(cfg.CloudLogPath) - if cloudLogPath == "" { - logging.Warning("✗ Cloud log directory not configured (cloud storage enabled)") - } else if strings.Contains(cloudLogPath, ":") { - // Legacy format with explicit remote (e.g., "gdrive:/logs") - logging.Info("Cloud log path (legacy): %s", cloudLogPath) - } else { - // New format without remote - will use CLOUD_REMOTE (e.g., "/logs") - remoteName := extractRemoteName(cfg.CloudRemote) - if remoteName != "" { - logging.Info("Cloud log path: %s (using remote: %s)", cloudLogPath, remoteName) - } else { - logging.Warning("Cloud log path %s requires CLOUD_REMOTE to be set", cloudLogPath) - } - } - } - checkDir("Lock directory", cfg.LockPath) - - // Initialize pre-backup checker - logging.Debug("Configuring pre-backup validation checks...") - checkerConfig := checks.GetDefaultCheckerConfig(cfg.BackupPath, cfg.LogPath, cfg.LockPath) - checkerConfig.SecondaryEnabled = cfg.SecondaryEnabled - if cfg.SecondaryEnabled && strings.TrimSpace(cfg.SecondaryPath) != "" { - checkerConfig.SecondaryPath = cfg.SecondaryPath - } else { - checkerConfig.SecondaryPath = "" - } - checkerConfig.CloudEnabled = cfg.CloudEnabled - if cfg.CloudEnabled && strings.TrimSpace(cfg.CloudRemote) != "" { - if isLocalPath(cfg.CloudRemote) { - checkerConfig.CloudPath = cfg.CloudRemote - } else { - checkerConfig.CloudPath = "" - logging.Info("Skipping cloud disk-space check: %s is a remote rclone path (no local mount detected)", cfg.CloudRemote) - } - } else { - checkerConfig.CloudPath = "" - } - checkerConfig.MinDiskPrimaryGB = cfg.MinDiskPrimaryGB - checkerConfig.MinDiskSecondaryGB = cfg.MinDiskSecondaryGB - checkerConfig.MinDiskCloudGB = cfg.MinDiskCloudGB - checkerConfig.FsIoTimeout = time.Duration(cfg.FsIoTimeoutSeconds) * time.Second - checkerConfig.DryRun = dryRun - checkerDone := logging.DebugStart(logger, "pre-backup check config", "dry_run=%v", dryRun) - if err := checkerConfig.Validate(); err != nil { - checkerDone(err) - logging.Error("Invalid checker configuration: %v", err) - earlyErrorState = &orchestrator.EarlyErrorState{ - Phase: "checker_config", - Error: err, - ExitCode: types.ExitConfigError, - Timestamp: time.Now(), - } - return finalize(types.ExitConfigError.Int()) - } - checkerDone(nil) - checker := checks.NewChecker(logger, checkerConfig) - orch.SetChecker(checker) - - // Ensure lock is released on exit - defer func() { - if err := orch.ReleaseBackupLock(); err != nil { - logging.Warning("Failed to release backup lock: %v", err) - } - }() - - logging.Info("✓ Pre-backup checks configured") - fmt.Println() - - // Initialize storage backends - logging.Step("Initializing storage backends") - storageDone := logging.DebugStart(logger, "storage init", "primary=%s secondary=%v cloud=%v", cfg.BackupPath, cfg.SecondaryEnabled, cfg.CloudEnabled) - - // Primary (local) storage - always enabled - logging.DebugStep(logger, "storage init", "primary backend") - localBackend, err := storage.NewLocalStorage(cfg, logger) - if err != nil { - storageDone(err) - logging.Error("Failed to initialize local storage: %v", err) - earlyErrorState = &orchestrator.EarlyErrorState{ - Phase: "storage_init", - Error: err, - ExitCode: types.ExitConfigError, - Timestamp: time.Now(), - } - return finalize(types.ExitConfigError.Int()) - } - localFS, err := detectFilesystemInfo(ctx, localBackend, cfg.BackupPath, logger) - if err != nil { - storageDone(err) - logging.Error("Failed to prepare primary storage: %v", err) - earlyErrorState = &orchestrator.EarlyErrorState{ - Phase: "storage_init", - Error: err, - ExitCode: types.ExitConfigError, - Timestamp: time.Now(), - } - return finalize(types.ExitConfigError.Int()) - } - logging.DebugStep(logger, "storage init", "primary filesystem=%s", formatDetailedFilesystemLabel(cfg.BackupPath, localFS)) - logging.Info("Path Primary: %s", formatDetailedFilesystemLabel(cfg.BackupPath, localFS)) - - localStats := fetchStorageStats(ctx, localBackend, logger, "Local storage") - localBackups := fetchBackupList(ctx, localBackend) - logging.DebugStep(logger, "storage init", "primary stats=%v backups=%d", localStats != nil, len(localBackups)) - - localAdapter := orchestrator.NewStorageAdapter(localBackend, logger, cfg) - localAdapter.SetFilesystemInfo(localFS) - localAdapter.SetInitialStats(localStats) - orch.RegisterStorageTarget(localAdapter) - logStorageInitSummary(formatStorageInitSummary("Local storage", cfg, storage.LocationPrimary, localStats, localBackups)) - - // Secondary storage - optional - var secondaryFS *storage.FilesystemInfo - if cfg.SecondaryEnabled { - logging.DebugStep(logger, "storage init", "secondary backend") - secondaryBackend, err := storage.NewSecondaryStorage(cfg, logger) - if err != nil { - logging.Warning("Failed to initialize secondary storage: %v", err) - logging.Info("Path Secondary: %s", formatDetailedFilesystemLabel(cfg.SecondaryPath, nil)) - } else { - secondaryFS, _ = detectFilesystemInfo(ctx, secondaryBackend, cfg.SecondaryPath, logger) - logging.DebugStep(logger, "storage init", "secondary filesystem=%s", formatDetailedFilesystemLabel(cfg.SecondaryPath, secondaryFS)) - logging.Info("Path Secondary: %s", formatDetailedFilesystemLabel(cfg.SecondaryPath, secondaryFS)) - secondaryStats := fetchStorageStats(ctx, secondaryBackend, logger, "Secondary storage") - secondaryBackups := fetchBackupList(ctx, secondaryBackend) - logging.DebugStep(logger, "storage init", "secondary stats=%v backups=%d", secondaryStats != nil, len(secondaryBackups)) - secondaryAdapter := orchestrator.NewStorageAdapter(secondaryBackend, logger, cfg) - secondaryAdapter.SetFilesystemInfo(secondaryFS) - secondaryAdapter.SetInitialStats(secondaryStats) - orch.RegisterStorageTarget(secondaryAdapter) - logStorageInitSummary(formatStorageInitSummary("Secondary storage", cfg, storage.LocationSecondary, secondaryStats, secondaryBackups)) - } - } else { - logging.Skip("Path Secondary: disabled") - } - - // Cloud storage - optional - var cloudFS *storage.FilesystemInfo - if cfg.CloudEnabled { - logging.DebugStep(logger, "storage init", "cloud backend") - cloudBackend, err := storage.NewCloudStorage(cfg, logger) - if err != nil { - logging.Warning("Failed to initialize cloud storage: %v", err) - logging.Info("Path Cloud: %s", formatDetailedFilesystemLabel(cfg.CloudRemote, nil)) - logStorageInitSummary(formatStorageInitSummary("Cloud storage", cfg, storage.LocationCloud, nil, nil)) - } else { - cloudFS, _ = detectFilesystemInfo(ctx, cloudBackend, cfg.CloudRemote, logger) - if cloudFS == nil { - logging.DebugStep(logger, "storage init", "cloud unavailable, disabling") - cfg.CloudEnabled = false - cfg.CloudLogPath = "" - if checker != nil { - checker.DisableCloud() - } - logStorageInitSummary(formatStorageInitSummary("Cloud storage", cfg, storage.LocationCloud, nil, nil)) - logging.Skip("Path Cloud: disabled") - } else { - logging.DebugStep(logger, "storage init", "cloud filesystem=%s", formatDetailedFilesystemLabel(cfg.CloudRemote, cloudFS)) - logging.Info("Path Cloud: %s", formatDetailedFilesystemLabel(cfg.CloudRemote, cloudFS)) - cloudStats := fetchStorageStats(ctx, cloudBackend, logger, "Cloud storage") - cloudBackups := fetchBackupList(ctx, cloudBackend) - logging.DebugStep(logger, "storage init", "cloud stats=%v backups=%d", cloudStats != nil, len(cloudBackups)) - cloudAdapter := orchestrator.NewStorageAdapter(cloudBackend, logger, cfg) - cloudAdapter.SetFilesystemInfo(cloudFS) - cloudAdapter.SetInitialStats(cloudStats) - orch.RegisterStorageTarget(cloudAdapter) - logStorageInitSummary(formatStorageInitSummary("Cloud storage", cfg, storage.LocationCloud, cloudStats, cloudBackups)) - } - } - } else { - logging.Skip("Path Cloud: disabled") - } - storageDone(nil) - - fmt.Println() - - // Initialize notification channels - logging.Step("Initializing notification channels") - notifyDone := logging.DebugStart(logger, "notifications init", "") - - // Email notifications - if cfg.EmailEnabled { - logging.DebugStep(logger, "notifications init", "email enabled") - emailConfig := notify.EmailConfig{ - Enabled: true, - DeliveryMethod: notify.EmailDeliveryMethod(cfg.EmailDeliveryMethod), - FallbackSendmail: cfg.EmailFallbackSendmail, - Recipient: cfg.EmailRecipient, - From: cfg.EmailFrom, - CloudRelayConfig: notify.CloudRelayConfig{ - WorkerURL: cfg.CloudflareWorkerURL, - WorkerToken: cfg.CloudflareWorkerToken, - HMACSecret: cfg.CloudflareHMACSecret, - Timeout: cfg.WorkerTimeout, - MaxRetries: cfg.WorkerMaxRetries, - RetryDelay: cfg.WorkerRetryDelay, - }, - } - emailNotifier, err := notify.NewEmailNotifier(emailConfig, envInfo.Type, logger) - if err != nil { - logging.Warning("Failed to initialize Email notifier: %v", err) - } else { - emailAdapter := orchestrator.NewNotificationAdapter(emailNotifier, logger) - orch.RegisterNotificationChannel(emailAdapter) - logging.Info("✓ Email initialized (method: %s)", cfg.EmailDeliveryMethod) - } - } else { - logging.DebugStep(logger, "notifications init", "email disabled") - logging.Skip("Email: disabled") - } - - // Telegram notifications - if cfg.TelegramEnabled { - logging.DebugStep(logger, "notifications init", "telegram enabled (mode=%s)", cfg.TelegramBotType) - telegramConfig := notify.TelegramConfig{ - Enabled: true, - Mode: notify.TelegramMode(cfg.TelegramBotType), - BotToken: cfg.TelegramBotToken, - ChatID: cfg.TelegramChatID, - ServerAPIHost: cfg.TelegramServerAPIHost, - ServerID: cfg.ServerID, - } - telegramNotifier, err := notify.NewTelegramNotifier(telegramConfig, logger) - if err != nil { - logging.Warning("Failed to initialize Telegram notifier: %v", err) - } else { - telegramAdapter := orchestrator.NewNotificationAdapter(telegramNotifier, logger) - orch.RegisterNotificationChannel(telegramAdapter) - logging.Info("✓ Telegram initialized (mode: %s)", cfg.TelegramBotType) - } - } else { - logging.DebugStep(logger, "notifications init", "telegram disabled") - logging.Skip("Telegram: disabled") - } - - // Gotify notifications - if cfg.GotifyEnabled { - logging.DebugStep(logger, "notifications init", "gotify enabled") - gotifyConfig := notify.GotifyConfig{ - Enabled: true, - ServerURL: cfg.GotifyServerURL, - Token: cfg.GotifyToken, - PrioritySuccess: cfg.GotifyPrioritySuccess, - PriorityWarning: cfg.GotifyPriorityWarning, - PriorityFailure: cfg.GotifyPriorityFailure, - } - gotifyNotifier, err := notify.NewGotifyNotifier(gotifyConfig, logger) - if err != nil { - logging.Warning("Failed to initialize Gotify notifier: %v", err) - } else { - gotifyAdapter := orchestrator.NewNotificationAdapter(gotifyNotifier, logger) - orch.RegisterNotificationChannel(gotifyAdapter) - logging.Info("✓ Gotify initialized") - } - } else { - logging.DebugStep(logger, "notifications init", "gotify disabled") - logging.Skip("Gotify: disabled") - } - - // Webhook Notifications - if cfg.WebhookEnabled { - logging.DebugStep(logger, "notifications init", "webhook enabled") - logging.Debug("Initializing webhook notifier...") - webhookConfig := cfg.BuildWebhookConfig() - logging.Debug("Webhook config built: %d endpoints configured", len(webhookConfig.Endpoints)) - - webhookNotifier, err := notify.NewWebhookNotifier(webhookConfig, logger) - if err != nil { - logging.Warning("Failed to initialize Webhook notifier: %v", err) - } else { - logging.Debug("Creating webhook notification adapter...") - webhookAdapter := orchestrator.NewNotificationAdapter(webhookNotifier, logger) - - logging.Debug("Registering webhook notification channel with orchestrator...") - orch.RegisterNotificationChannel(webhookAdapter) - logging.Info("✓ Webhook initialized (%d endpoint(s))", len(webhookConfig.Endpoints)) - } - } else { - logging.DebugStep(logger, "notifications init", "webhook disabled") - logging.Skip("Webhook: disabled") - } - notifyDone(nil) - - fmt.Println() - - // Storage info - logging.Info("Storage configuration:") - logging.Info(" Primary: %s", formatStorageLabel(cfg.BackupPath, localFS)) - if cfg.SecondaryEnabled { - logging.Info(" Secondary storage: %s", formatStorageLabel(cfg.SecondaryPath, secondaryFS)) - } else { - logging.Skip(" Secondary storage: disabled") - } - if cfg.CloudEnabled { - logging.Info(" Cloud storage: %s", formatStorageLabel(cfg.CloudRemote, cloudFS)) - } else { - logging.Skip(" Cloud storage: disabled") - } - fmt.Println() - - // Log configuration info - logging.Info("Log configuration:") - logging.Info(" Primary: %s", cfg.LogPath) - if cfg.SecondaryEnabled { - if strings.TrimSpace(cfg.SecondaryLogPath) != "" { - logging.Info(" Secondary: %s", cfg.SecondaryLogPath) - } else { - logging.Skip(" Secondary: disabled (log path not configured)") - } - } else { - logging.Skip(" Secondary: disabled") - } - if cfg.CloudEnabled { - if strings.TrimSpace(cfg.CloudLogPath) != "" { - logging.Info(" Cloud: %s", cfg.CloudLogPath) - } else { - logging.Skip(" Cloud: disabled (log path not configured)") - } - } else { - logging.Skip(" Cloud: disabled") - } - fmt.Println() - - // Notification info - logging.Info("Notification configuration:") - logging.Info(" Telegram: %v", cfg.TelegramEnabled) - logging.Info(" Email: %v", cfg.EmailEnabled) - logging.Info(" Gotify: %v", cfg.GotifyEnabled) - logging.Info(" Webhook: %v", cfg.WebhookEnabled) - logging.Info(" Metrics: %v", cfg.MetricsEnabled) - fmt.Println() - - // Run backup orchestration - if cfg.BackupEnabled { - preCheckDone := logging.DebugStart(logger, "pre-backup checks", "") - if err := orch.RunPreBackupChecks(ctx); err != nil { - preCheckDone(err) - logging.Error("Pre-backup validation failed: %v", err) - earlyErrorState = &orchestrator.EarlyErrorState{ - Phase: "pre_backup_checks", - Error: err, - ExitCode: types.ExitBackupError, - Timestamp: time.Now(), - } - return finalize(types.ExitBackupError.Int()) - } - preCheckDone(nil) - fmt.Println() - - logging.Step("Start Go backup orchestration") - - // Get hostname for backup naming - hostname := resolveHostname() - - // Run Go-based backup (collection + archive) - backupDone := logging.DebugStart(logger, "backup run", "proxmox=%s host=%s", envInfo.Type, hostname) - stats, err := orch.RunGoBackup(ctx, envInfo, hostname) - if err != nil { - backupDone(err) - // Check if error is due to cancellation - if ctx.Err() == context.Canceled { - logging.Warning("Backup was canceled") - orch.FinalizeAfterRun(ctx, stats) - if stats != nil { - pendingSupportStats = stats - } - return finalize(exitCodeInterrupted) // Standard Unix exit code for SIGINT - } - - // Check if it's a BackupError with specific exit code - var backupErr *orchestrator.BackupError - if errors.As(err, &backupErr) { - logging.Error("Backup %s failed: %v", backupErr.Phase, backupErr.Err) - orch.FinalizeAfterRun(ctx, stats) - if stats != nil { - pendingSupportStats = stats - } - return finalize(backupErr.Code.Int()) - } - - // Generic backup error - logging.Error("Backup orchestration failed: %v", err) - orch.FinalizeAfterRun(ctx, stats) - if stats != nil { - pendingSupportStats = stats - } - return finalize(types.ExitBackupError.Int()) - } - backupDone(nil) - - if err := orch.SaveStatsReport(stats); err != nil { - logging.Warning("Failed to persist backup statistics: %v", err) - } else if stats.ReportPath != "" { - logging.Info("✓ Statistics report saved to %s", stats.ReportPath) - } - - // Display backup statistics - fmt.Println() - logging.Info("=== Backup Statistics ===") - logging.Info("Files collected: %d", stats.FilesCollected) - if stats.FilesFailed > 0 { - logging.Warning("Files failed: %d", stats.FilesFailed) - } - logging.Info("Directories created: %d", stats.DirsCreated) - logging.Info("Data collected: %s", formatBytes(stats.BytesCollected)) - logging.Info("Archive size: %s", formatBytes(stats.ArchiveSize)) - switch { - case stats.CompressionSavingsPercent > 0: - logging.Info("Compression ratio: %.1f%%", stats.CompressionSavingsPercent) - case stats.CompressionRatioPercent > 0: - logging.Info("Compression ratio: %.1f%%", stats.CompressionRatioPercent) - case stats.BytesCollected > 0: - ratio := float64(stats.ArchiveSize) / float64(stats.BytesCollected) * 100 - logging.Info("Compression ratio: %.1f%%", ratio) - default: - logging.Info("Compression ratio: N/A") - } - logging.Info("Compression used: %s (level %d, mode %s)", stats.Compression, stats.CompressionLevel, stats.CompressionMode) - if stats.RequestedCompression != stats.Compression { - logging.Info("Requested compression: %s", stats.RequestedCompression) - } - logging.Info("Duration: %s", formatDuration(stats.Duration)) - if stats.BundleCreated { - logging.Info("Bundle path: %s", stats.ArchivePath) - logging.Info("Bundle contents: archive + checksum + metadata") - } else { - logging.Info("Archive path: %s", stats.ArchivePath) - if stats.ManifestPath != "" { - logging.Info("Manifest path: %s", stats.ManifestPath) - } - if stats.Checksum != "" { - logging.Info("Archive checksum (SHA256): %s", stats.Checksum) - } - } - fmt.Println() - - logging.Info("✓ Go backup orchestration completed") - logServerIdentityValues(serverIDValue, serverMACValue) - - if heapProfilePath != "" { - logging.Info("Heap profiling saved: %s", heapProfilePath) - } - - exitCode := stats.ExitCode - status := notify.StatusFromExitCode(exitCode) - statusLabel := strings.ToUpper(status.String()) - emoji := notify.GetStatusEmoji(status) - logging.Info("Exit status: %s %s (code=%d)", emoji, statusLabel, exitCode) - - pendingSupportStats = stats - - finalExitCode = exitCode - } else { - logging.Warning("Backup is disabled in configuration") - } - - return finalExitCode -} - -const rollbackCountdownDisplayDuration = 10 * time.Second - -func printNetworkRollbackCountdown(abortInfo *orchestrator.RestoreAbortInfo) { - if abortInfo == nil { - return - } - - color := "\033[33m" // yellow - colorReset := "\033[0m" - - markerExists := false - if strings.TrimSpace(abortInfo.NetworkRollbackMarker) != "" { - if _, err := os.Stat(strings.TrimSpace(abortInfo.NetworkRollbackMarker)); err == nil { - markerExists = true - } - } - - status := "UNKNOWN" - switch { - case markerExists: - status = "ARMED (will execute automatically)" - case !abortInfo.RollbackDeadline.IsZero() && time.Now().After(abortInfo.RollbackDeadline): - status = "EXECUTED (marker removed)" - case strings.TrimSpace(abortInfo.NetworkRollbackMarker) != "": - status = "DISARMED/CLEARED (marker removed before deadline)" - case abortInfo.NetworkRollbackArmed: - status = "ARMED (status from snapshot)" - default: - status = "NOT ARMED" - } - - fmt.Println() - fmt.Printf("%s===========================================\n", color) - fmt.Printf("NETWORK ROLLBACK%s\n", colorReset) - fmt.Println() - - // Static info - fmt.Printf(" Status: %s\n", status) - if strings.TrimSpace(abortInfo.OriginalIP) != "" && abortInfo.OriginalIP != "unknown" { - fmt.Printf(" Pre-apply IP (from snapshot): %s\n", strings.TrimSpace(abortInfo.OriginalIP)) - } - if strings.TrimSpace(abortInfo.CurrentIP) != "" && abortInfo.CurrentIP != "unknown" { - fmt.Printf(" Post-apply IP (observed): %s\n", strings.TrimSpace(abortInfo.CurrentIP)) - } - if strings.TrimSpace(abortInfo.NetworkRollbackLog) != "" { - fmt.Printf(" Rollback log: %s\n", strings.TrimSpace(abortInfo.NetworkRollbackLog)) - } - fmt.Println() - - switch { - case markerExists && !abortInfo.RollbackDeadline.IsZero() && time.Until(abortInfo.RollbackDeadline) > 0: - fmt.Println("Connection will be temporarily interrupted during restore.") - if strings.TrimSpace(abortInfo.OriginalIP) != "" && abortInfo.OriginalIP != "unknown" { - fmt.Printf("Remember to reconnect using the pre-apply IP: %s\n", strings.TrimSpace(abortInfo.OriginalIP)) - } - case !markerExists && !abortInfo.RollbackDeadline.IsZero() && time.Now().After(abortInfo.RollbackDeadline): - if strings.TrimSpace(abortInfo.OriginalIP) != "" && abortInfo.OriginalIP != "unknown" { - fmt.Printf("Rollback executed: reconnect using the pre-apply IP: %s\n", strings.TrimSpace(abortInfo.OriginalIP)) - } - case !markerExists && strings.TrimSpace(abortInfo.NetworkRollbackMarker) != "": - if strings.TrimSpace(abortInfo.CurrentIP) != "" && abortInfo.CurrentIP != "unknown" { - fmt.Printf("Rollback will NOT run: reconnect using the post-apply IP: %s\n", strings.TrimSpace(abortInfo.CurrentIP)) - } - } - - // Live countdown for max 10 seconds (only when rollback is still armed). - if !markerExists || abortInfo.RollbackDeadline.IsZero() { - fmt.Printf("%s===========================================%s\n", color, colorReset) - return - } - - ticker := time.NewTicker(1 * time.Second) - defer ticker.Stop() - displayEnd := time.Now().Add(rollbackCountdownDisplayDuration) - - for { - remaining := time.Until(abortInfo.RollbackDeadline) - if remaining <= 0 { - fmt.Printf("\r Remaining: Rollback executing now... \n") - break - } - if time.Now().After(displayEnd) { - fmt.Printf("\r Remaining: %ds (exiting, rollback will proceed)\n", int(remaining.Seconds())) - break - } - fmt.Printf("\r Remaining: %ds ", int(remaining.Seconds())) - - <-ticker.C - } - - fmt.Printf("%s===========================================%s\n", color, colorReset) -} - -func printFinalSummary(finalExitCode int) { - fmt.Println() - - // Print a flat list of all WARNING/ERROR/CRITICAL log entries that occurred during the run. - // This makes it easy to spot the root cause(s) behind a WARNING/ERROR exit status without - // scrolling through the full log. - logger := logging.GetDefaultLogger() - if logger != nil { - issues := logger.IssueLines() - if len(issues) > 0 { - fmt.Println("===========================================") - fmt.Printf("WARNINGS/ERRORS DURING RUN (warnings=%d errors=%d)\n", logger.WarningCount(), logger.ErrorCount()) - fmt.Println() - for _, line := range issues { - fmt.Println(line) - } - fmt.Println("===========================================") - fmt.Println() - } - } - - summarySig := buildSignature() - if summarySig == "" { - summarySig = "unknown" - } - - colorReset := "\033[0m" - color := "" - hasWarnings := logger != nil && logger.HasWarnings() - - switch { - case finalExitCode == exitCodeInterrupted: - color = "\033[35m" // magenta for Ctrl+C - case finalExitCode == 0 && hasWarnings: - color = "\033[33m" // yellow for success with warnings - case finalExitCode == 0: - color = "\033[32m" // green for clean success - case finalExitCode == types.ExitGenericError.Int(): - color = "\033[33m" // yellow for generic error (non-fatal) - default: - color = "\033[31m" // red for all other errors - } - - if color != "" { - fmt.Printf("%s===========================================\n", color) - fmt.Printf("ProxSave - Go - %s\n", summarySig) - fmt.Printf("===========================================%s\n", colorReset) - } else { - fmt.Println("===========================================") - fmt.Printf("ProxSave - Go - %s\n", summarySig) - fmt.Println("===========================================") - } - - fmt.Println() - fmt.Println("\033[31mEXTRA STEP - IF YOU FIND THIS TOOL USEFUL AND WANT TO THANK ME, A COFFEE IS ALWAYS WELCOME!\033[0m") - fmt.Println("https://github.com/sponsors/tis24dev") - fmt.Println() - fmt.Println("Commands:") - fmt.Println(" proxsave (alias: proxmox-backup) - Start backup") - fmt.Println(" --help - Show all options") - fmt.Println(" --dry-run - Test without changes") - fmt.Println(" --install - Re-run interactive installation/setup") - fmt.Println(" --new-install - Wipe installation directory (keep build/env/identity) then run installer") - fmt.Println(" --env-migration - Run installer and migrate legacy Bash backup.env to Go template") - fmt.Println(" --env-migration-dry-run - Preview installer/migration without writing files") - fmt.Println(" --upgrade - Update proxsave binary to latest release (also adds missing keys to backup.env)") - fmt.Println(" --newkey - Generate a new encryption key for backups") - fmt.Println(" --decrypt - Decrypt an existing backup archive") - fmt.Println(" --restore - Run interactive restore workflow (select bundle, decrypt if needed, apply to system)") - fmt.Println(" --upgrade-config - Upgrade configuration file using the embedded template (run after installing a new binary)") - fmt.Println(" --support - Run in support mode (force debug log level and send email with attached log to github-support@tis24.it); available for standard backup and --restore") - fmt.Println() -} - -// checkGoRuntimeVersion ensures the running binary was built with at least the specified Go version (semver: major.minor.patch). -func checkGoRuntimeVersion(min string) error { - rt := runtime.Version() // e.g., "go1.25.4" - // Normalize versions to x.y.z - parse := func(v string) (int, int, int) { - // Accept forms: go1.25.4, go1.25, 1.25.4, 1.25 - v = strings.TrimPrefix(v, "go") - parts := strings.Split(v, ".") - toInt := func(s string) int { n, _ := strconv.Atoi(s); return n } - major, minor, patch := 0, 0, 0 - if len(parts) > 0 { - major = toInt(parts[0]) - } - if len(parts) > 1 { - minor = toInt(parts[1]) - } - if len(parts) > 2 { - patch = toInt(parts[2]) - } - return major, minor, patch - } - - rtMaj, rtMin, rtPatch := parse(rt) - minMaj, minMin, minPatch := parse(min) - - newer := func(aMaj, aMin, aPatch, bMaj, bMin, bPatch int) bool { - if aMaj != bMaj { - return aMaj > bMaj - } - if aMin != bMin { - return aMin > bMin - } - return aPatch >= bPatch - } - - if !newer(rtMaj, rtMin, rtPatch, minMaj, minMin, minPatch) { - return fmt.Errorf("go runtime version %s is below required %s — rebuild with go %s or set GOTOOLCHAIN=auto", rt, "go"+min, "go"+min) - } - return nil -} - -// featuresNeedNetwork returns whether current configuration requires outbound network, and human reasons. -func featuresNeedNetwork(cfg *config.Config) (bool, []string) { - reasons := []string{} - // Telegram (any mode uses network) - if cfg.TelegramEnabled { - if strings.EqualFold(cfg.TelegramBotType, "centralized") { - reasons = append(reasons, "Telegram centralized registration") - } else { - reasons = append(reasons, "Telegram personal notifications") - } - } - // Email via relay - if cfg.EmailEnabled && strings.EqualFold(cfg.EmailDeliveryMethod, "relay") { - reasons = append(reasons, "Email relay delivery") - } - // Gotify - if cfg.GotifyEnabled { - reasons = append(reasons, "Gotify notifications") - } - // Webhooks - if cfg.WebhookEnabled { - reasons = append(reasons, "Webhooks") - } - // Cloud uploads via rclone - if cfg.CloudEnabled { - reasons = append(reasons, "Cloud storage (rclone)") - } - return len(reasons) > 0, reasons -} - -// disableNetworkFeaturesForRun disables all network-dependent features when connectivity is unavailable. -func disableNetworkFeaturesForRun(cfg *config.Config, bootstrap *logging.BootstrapLogger) { - if cfg == nil { - return - } - warn := func(format string, args ...interface{}) { - if bootstrap != nil { - bootstrap.Warning(format, args...) - return - } - logging.Warning(format, args...) - } - - if cfg.CloudEnabled { - warn("WARNING: Disabling cloud storage (rclone) due to missing network connectivity") - cfg.CloudEnabled = false - cfg.CloudLogPath = "" - } - - if cfg.TelegramEnabled { - warn("WARNING: Disabling Telegram notifications due to missing network connectivity") - cfg.TelegramEnabled = false - } - - if cfg.EmailEnabled && strings.EqualFold(cfg.EmailDeliveryMethod, "relay") { - if cfg.EmailFallbackSendmail { - warn("WARNING: Network unavailable; switching Email delivery to sendmail for this run") - cfg.EmailDeliveryMethod = "sendmail" - } else { - warn("WARNING: Disabling Email relay notifications due to missing network connectivity") - cfg.EmailEnabled = false - } - } - - if cfg.GotifyEnabled { - warn("WARNING: Disabling Gotify notifications due to missing network connectivity") - cfg.GotifyEnabled = false - } - - if cfg.WebhookEnabled { - warn("WARNING: Disabling Webhook notifications due to missing network connectivity") - cfg.WebhookEnabled = false - } - -} - -// UpdateInfo holds information about the version check result. -type UpdateInfo struct { - NewVersion bool - Current string - Latest string -} - -// checkForUpdates performs a best-effort check against the latest GitHub release. -// - If the latest version cannot be determined or the current version is already up to date, -// only a DEBUG log entry is written (no user-facing output). -// - If a newer version is available, a WARNING is logged suggesting the --upgrade command. -// Additionally, a populated *UpdateInfo is returned so that callers can propagate -// structured information into notifications/metrics. -func checkForUpdates(ctx context.Context, logger *logging.Logger, currentVersion string) *UpdateInfo { - if logger == nil { - return nil - } - - currentVersion = strings.TrimSpace(currentVersion) - if currentVersion == "" { - logger.Debug("Update check skipped: current version is empty") - return nil - } - - checkCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - - logger.Debug("Checking for ProxSave updates (current: %s)", currentVersion) - - apiURL := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", githubRepo) - logger.Debug("Fetching latest release from GitHub: %s", apiURL) - - _, latestVersion, err := fetchLatestRelease(checkCtx) - if err != nil { - logger.Debug("Update check skipped: GitHub unreachable: %v", err) - return &UpdateInfo{ - NewVersion: false, - Current: currentVersion, - } - } - - latestVersion = strings.TrimSpace(latestVersion) - if latestVersion == "" { - logger.Debug("Update check skipped: latest version from GitHub is empty") - return &UpdateInfo{ - NewVersion: false, - Current: currentVersion, - } - } - - if !isNewerVersion(currentVersion, latestVersion) { - logger.Debug("Update check completed: latest=%s current=%s (up to date)", latestVersion, currentVersion) - return &UpdateInfo{ - NewVersion: false, - Current: currentVersion, - Latest: latestVersion, - } - } - - logger.Debug("Update check completed: latest=%s current=%s (new version available)", latestVersion, currentVersion) - logger.Warning("New ProxSave version %s (current %s): run 'proxsave --upgrade' to install.", latestVersion, currentVersion) - - return &UpdateInfo{ - NewVersion: true, - Current: currentVersion, - Latest: latestVersion, - } -} - -// isNewerVersion returns true if latest is strictly newer than current, -// comparing MAJOR.MINOR.PATCH (ignoring any leading 'v' and pre-release suffixes). -func isNewerVersion(current, latest string) bool { - parse := func(v string) (int, int, int) { - v = strings.TrimSpace(v) - v = strings.TrimPrefix(v, "v") - if i := strings.IndexByte(v, '-'); i >= 0 { - v = v[:i] - } - - parts := strings.Split(v, ".") - toInt := func(s string) int { - n, _ := strconv.Atoi(s) - return n - } - - major, minor, patch := 0, 0, 0 - if len(parts) > 0 { - major = toInt(parts[0]) - } - if len(parts) > 1 { - minor = toInt(parts[1]) - } - if len(parts) > 2 { - patch = toInt(parts[2]) - } - return major, minor, patch - } - - curMaj, curMin, curPatch := parse(current) - latMaj, latMin, latPatch := parse(latest) - - if latMaj != curMaj { - return latMaj > curMaj - } - if latMin != curMin { - return latMin > curMin + rt, exitCode, ok := prepareRuntime(ctx, args, runInfo.bootstrap, runInfo.state, runInfo.toolVersion) + if !ok { + return exitCode } - return latPatch > curPatch + return runRuntime(rt, runInfo.state) } diff --git a/cmd/proxsave/main_config_modes.go b/cmd/proxsave/main_config_modes.go new file mode 100644 index 00000000..3ee7383c --- /dev/null +++ b/cmd/proxsave/main_config_modes.go @@ -0,0 +1,177 @@ +// Package main contains the proxsave command entrypoint. +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/tis24dev/proxsave/internal/cli" + "github.com/tis24dev/proxsave/internal/config" + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/types" +) + +type postHeaderConfigModeHandler func(context.Context, *cli.Args, *logging.BootstrapLogger) (int, bool) + +func runUpgradeConfigJSONMode(args *cli.Args) (int, bool) { + if !args.UpgradeConfigJSON { + return types.ExitSuccess.Int(), false + } + if _, err := os.Stat(args.ConfigPath); err != nil { + fmt.Fprintf(os.Stderr, "ERROR: configuration file not found: %v\n", err) + return types.ExitConfigError.Int(), true + } + + result, err := config.UpgradeConfigFile(args.ConfigPath) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: Failed to upgrade configuration: %v\n", err) + return types.ExitConfigError.Int(), true + } + if result == nil { + result = &config.UpgradeResult{} + } + + enc := json.NewEncoder(os.Stdout) + if err := enc.Encode(result); err != nil { + fmt.Fprintf(os.Stderr, "ERROR: Failed to encode JSON: %v\n", err) + return types.ExitGenericError.Int(), true + } + return types.ExitSuccess.Int(), true +} + +func dispatchPostHeaderConfigModes(ctx context.Context, args *cli.Args, bootstrap *logging.BootstrapLogger) (int, bool) { + for _, handler := range []postHeaderConfigModeHandler{ + runUpgradeConfigMode, + runEnvMigrationDryMode, + runEnvMigrationMode, + } { + if exitCode, handled := handler(ctx, args, bootstrap); handled { + return exitCode, true + } + } + return types.ExitSuccess.Int(), false +} + +func runUpgradeConfigMode(_ context.Context, args *cli.Args, bootstrap *logging.BootstrapLogger) (int, bool) { + if !args.UpgradeConfig { + return types.ExitSuccess.Int(), false + } + logging.DebugStepBootstrap(bootstrap, "main run", "mode=upgrade-config") + if err := ensureConfigExists(args.ConfigPath, bootstrap); err != nil { + bootstrap.Error("ERROR: %v", err) + return types.ExitConfigError.Int(), true + } + + bootstrap.Printf("Upgrading configuration file: %s", args.ConfigPath) + result, err := config.UpgradeConfigFile(args.ConfigPath) + if err != nil { + bootstrap.Error("ERROR: Failed to upgrade configuration: %v", err) + return types.ExitConfigError.Int(), true + } + logConfigUpgradeWarnings(bootstrap, result.Warnings) + if !result.Changed { + bootstrap.Println("Configuration is already up to date with the embedded template; no changes were made.") + return types.ExitSuccess.Int(), true + } + + printConfigUpgradeApplyResult(bootstrap, result) + return types.ExitSuccess.Int(), true +} + +func runUpgradeConfigDryMode(_ context.Context, args *cli.Args, bootstrap *logging.BootstrapLogger, _ string) (int, bool) { + if !args.UpgradeConfigDry { + return types.ExitSuccess.Int(), false + } + logging.DebugStepBootstrap(bootstrap, "main run", "mode=upgrade-config-dry") + if err := ensureConfigExists(args.ConfigPath, bootstrap); err != nil { + bootstrap.Error("ERROR: %v", err) + return types.ExitConfigError.Int(), true + } + + bootstrap.Printf("Planning configuration upgrade using embedded template: %s", args.ConfigPath) + result, err := config.PlanUpgradeConfigFile(args.ConfigPath) + if err != nil { + bootstrap.Error("ERROR: Failed to plan configuration upgrade: %v", err) + return types.ExitConfigError.Int(), true + } + logConfigUpgradeWarnings(bootstrap, result.Warnings) + if !result.Changed { + bootstrap.Println("Configuration is already up to date with the embedded template; no changes are required.") + return types.ExitSuccess.Int(), true + } + + printConfigUpgradeDryRunResult(bootstrap, result) + return types.ExitSuccess.Int(), true +} + +func runEnvMigrationDryMode(ctx context.Context, args *cli.Args, bootstrap *logging.BootstrapLogger) (int, bool) { + if !args.EnvMigrationDry { + return types.ExitSuccess.Int(), false + } + logging.DebugStepBootstrap(bootstrap, "main run", "mode=env-migration-dry") + return runEnvMigrationDry(ctx, args, bootstrap), true +} + +func runEnvMigrationMode(ctx context.Context, args *cli.Args, bootstrap *logging.BootstrapLogger) (int, bool) { + if !args.EnvMigration { + return types.ExitSuccess.Int(), false + } + logging.DebugStepBootstrap(bootstrap, "main run", "mode=env-migration") + return runEnvMigration(ctx, args, bootstrap), true +} + +func logConfigUpgradeWarnings(bootstrap *logging.BootstrapLogger, warnings []string) { + if len(warnings) == 0 { + return + } + bootstrap.Warning("Config upgrade warnings (%d):", len(warnings)) + for _, warning := range warnings { + bootstrap.Warning(" - %s", warning) + } +} + +func printConfigUpgradeDryRunResult(bootstrap *logging.BootstrapLogger, result *config.UpgradeResult) { + if len(result.MissingKeys) > 0 { + bootstrap.Printf("Missing keys that would be added from the template (%d): %s", + len(result.MissingKeys), strings.Join(result.MissingKeys, ", ")) + } + if result.PreservedValues > 0 { + bootstrap.Printf("Existing values that would be preserved: %d", result.PreservedValues) + } + if len(result.ExtraKeys) > 0 { + bootstrap.Printf("Custom keys that would be preserved (not present in template) (%d): %s", + len(result.ExtraKeys), strings.Join(result.ExtraKeys, ", ")) + } + if len(result.CaseConflictKeys) > 0 { + bootstrap.Printf("Keys that differ only by case from the template (%d): %s", + len(result.CaseConflictKeys), strings.Join(result.CaseConflictKeys, ", ")) + } + bootstrap.Println("Dry run only: no files were modified. Use --upgrade-config to apply these changes.") +} + +func printConfigUpgradeApplyResult(bootstrap *logging.BootstrapLogger, result *config.UpgradeResult) { + if len(result.MissingKeys) > 0 { + bootstrap.Printf("- Added %d missing key(s): %s", + len(result.MissingKeys), strings.Join(result.MissingKeys, ", ")) + } else { + bootstrap.Println("- No new keys were required from the template") + } + if result.PreservedValues > 0 { + bootstrap.Printf("- Preserved %d existing value(s) from current configuration", result.PreservedValues) + } + if len(result.ExtraKeys) > 0 { + bootstrap.Printf("- Kept %d custom key(s) not present in the template: %s", + len(result.ExtraKeys), strings.Join(result.ExtraKeys, ", ")) + } + if len(result.CaseConflictKeys) > 0 { + bootstrap.Printf("- Preserved %d key(s) that differ only by case: %s", + len(result.CaseConflictKeys), strings.Join(result.CaseConflictKeys, ", ")) + } + if result.BackupPath != "" { + bootstrap.Printf("- Backup saved to: %s", result.BackupPath) + } + bootstrap.Println("✓ Configuration upgrade completed successfully.") +} diff --git a/cmd/proxsave/main_defers.go b/cmd/proxsave/main_defers.go new file mode 100644 index 00000000..e138610a --- /dev/null +++ b/cmd/proxsave/main_defers.go @@ -0,0 +1,92 @@ +// Package main contains the proxsave command entrypoint. +package main + +import ( + "fmt" + "os" + "runtime/pprof" + + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/orchestrator" + "github.com/tis24dev/proxsave/internal/support" +) + +type runDeferredAction func() + +func runDeferredActions(rt *appRuntime, state *appRunState) []runDeferredAction { + // runRuntime defers each returned action while iterating this slice, so these + // entries execute in reverse (LIFO) order. Keep the ordering intentional: + // dispatchDeferredEarlyErrorNotification must run before sendDeferredSupportEmail + // because it sets state.pendingSupportStat, which sendDeferredSupportEmail + // reads. Do not reorder these entries or change the defer pattern without + // preserving that dependency. + return []runDeferredAction{ + func() { + if state.showSummary { + printFinalSummary(state.finalExitCode) + } + }, + func() { + if state.finalExitCode == exitCodeInterrupted { + if abortInfo := orchestrator.GetLastRestoreAbortInfo(); abortInfo != nil { + printNetworkRollbackCountdown(abortInfo) + } + } + }, + func() { + sendDeferredSupportEmail(rt, state) + }, + func() { + dispatchDeferredEarlyErrorNotification(rt, state) + }, + func() { + closeRunProfiling(rt) + }, + func() { + cleanupAfterRun(rt.logger) + }, + } +} + +func sendDeferredSupportEmail(rt *appRuntime, state *appRunState) { + if !rt.args.Support || state.pendingSupportStat == nil { + return + } + logging.Step("Support mode - sending support email with attached log") + support.SendEmail(rt.ctx, rt.cfg, rt.logger, rt.envInfo.Type, state.pendingSupportStat, support.Meta{ + GitHubUser: rt.args.SupportGitHubUser, + IssueID: rt.args.SupportIssueID, + }, buildSignature()) +} + +func dispatchDeferredEarlyErrorNotification(rt *appRuntime, state *appRunState) { + if state.earlyErrorState == nil || !state.earlyErrorState.HasError() || state.orch == nil { + return + } + fmt.Println() + logging.Step("Sending error notifications") + stats := state.orch.DispatchEarlyErrorNotification(rt.ctx, state.earlyErrorState) + if stats != nil { + state.pendingSupportStat = stats + } + state.orch.FinalizeAndCloseLog(rt.ctx) +} + +func closeRunProfiling(rt *appRuntime) { + if rt.cpuProfileFile != nil { + pprof.StopCPUProfile() + _ = rt.cpuProfileFile.Close() + } + if rt.heapProfilePath == "" { + return + } + f, err := os.Create(rt.heapProfilePath) + if err != nil { + logging.Warning("Failed to create heap profile file: %v", err) + return + } + defer f.Close() + if err := pprof.WriteHeapProfile(f); err != nil { + logging.Warning("Failed to write heap profile: %v", err) + } +} diff --git a/cmd/proxsave/main_footer.go b/cmd/proxsave/main_footer.go new file mode 100644 index 00000000..1a8ddc9f --- /dev/null +++ b/cmd/proxsave/main_footer.go @@ -0,0 +1,236 @@ +// Package main contains the proxsave command entrypoint. +package main + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/orchestrator" + "github.com/tis24dev/proxsave/internal/types" +) + +const rollbackCountdownDisplayDuration = 10 * time.Second + +func printNetworkRollbackCountdown(abortInfo *orchestrator.RestoreAbortInfo) { + if abortInfo == nil { + return + } + + color := "\033[33m" // yellow + colorReset := "\033[0m" + markerExists := networkRollbackMarkerExists(abortInfo.NetworkRollbackMarker) + status := networkRollbackStatus(abortInfo, markerExists, time.Now()) + + printNetworkRollbackHeader(color, colorReset) + printNetworkRollbackStaticInfo(abortInfo, status) + printNetworkRollbackReconnectHint(abortInfo, markerExists, time.Now()) + + // Live countdown for max 10 seconds (only when rollback is still armed). + if !markerExists || abortInfo.RollbackDeadline.IsZero() { + fmt.Printf("%s===========================================%s\n", color, colorReset) + return + } + + printNetworkRollbackLiveCountdown(abortInfo.RollbackDeadline) + fmt.Printf("%s===========================================%s\n", color, colorReset) +} + +func networkRollbackMarkerExists(marker string) bool { + marker = strings.TrimSpace(marker) + if marker == "" { + return false + } + _, err := os.Stat(marker) + return err == nil +} + +func networkRollbackStatus(abortInfo *orchestrator.RestoreAbortInfo, markerExists bool, now time.Time) string { + switch { + case markerExists: + return "ARMED (will execute automatically)" + case !abortInfo.RollbackDeadline.IsZero() && now.After(abortInfo.RollbackDeadline): + return "EXECUTED (marker removed)" + case strings.TrimSpace(abortInfo.NetworkRollbackMarker) != "": + return "DISARMED/CLEARED (marker removed before deadline)" + case abortInfo.NetworkRollbackArmed: + return "ARMED (status from snapshot)" + default: + return "NOT ARMED" + } +} + +func printNetworkRollbackHeader(color, colorReset string) { + fmt.Println() + fmt.Printf("%s===========================================\n", color) + fmt.Printf("NETWORK ROLLBACK%s\n", colorReset) + fmt.Println() +} + +func printNetworkRollbackStaticInfo(abortInfo *orchestrator.RestoreAbortInfo, status string) { + fmt.Printf(" Status: %s\n", status) + if knownValue(abortInfo.OriginalIP) { + fmt.Printf(" Pre-apply IP (from snapshot): %s\n", strings.TrimSpace(abortInfo.OriginalIP)) + } + if knownValue(abortInfo.CurrentIP) { + fmt.Printf(" Post-apply IP (observed): %s\n", strings.TrimSpace(abortInfo.CurrentIP)) + } + if strings.TrimSpace(abortInfo.NetworkRollbackLog) != "" { + fmt.Printf(" Rollback log: %s\n", strings.TrimSpace(abortInfo.NetworkRollbackLog)) + } + fmt.Println() +} + +func printNetworkRollbackReconnectHint(abortInfo *orchestrator.RestoreAbortInfo, markerExists bool, now time.Time) { + if printArmedRollbackReconnectHint(abortInfo, markerExists) { + return + } + if printExecutedRollbackReconnectHint(abortInfo, markerExists, now) { + return + } + printDisarmedRollbackReconnectHint(abortInfo, markerExists) +} + +func printArmedRollbackReconnectHint(abortInfo *orchestrator.RestoreAbortInfo, markerExists bool) bool { + if !markerExists || abortInfo.RollbackDeadline.IsZero() || time.Until(abortInfo.RollbackDeadline) <= 0 { + return false + } + fmt.Println("Connection will be temporarily interrupted during restore.") + if knownValue(abortInfo.OriginalIP) { + fmt.Printf("Remember to reconnect using the pre-apply IP: %s\n", strings.TrimSpace(abortInfo.OriginalIP)) + } + return true +} + +func printExecutedRollbackReconnectHint(abortInfo *orchestrator.RestoreAbortInfo, markerExists bool, now time.Time) bool { + if markerExists || abortInfo.RollbackDeadline.IsZero() || !now.After(abortInfo.RollbackDeadline) { + return false + } + if knownValue(abortInfo.OriginalIP) { + fmt.Printf("Rollback executed: reconnect using the pre-apply IP: %s\n", strings.TrimSpace(abortInfo.OriginalIP)) + } + return true +} + +func printDisarmedRollbackReconnectHint(abortInfo *orchestrator.RestoreAbortInfo, markerExists bool) { + if markerExists || strings.TrimSpace(abortInfo.NetworkRollbackMarker) == "" || !knownValue(abortInfo.CurrentIP) { + return + } + fmt.Printf("Rollback will NOT run: reconnect using the post-apply IP: %s\n", strings.TrimSpace(abortInfo.CurrentIP)) +} + +func knownValue(value string) bool { + value = strings.TrimSpace(value) + return value != "" && value != "unknown" +} + +func printNetworkRollbackLiveCountdown(deadline time.Time) { + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + displayEnd := time.Now().Add(rollbackCountdownDisplayDuration) + + for { + remaining := time.Until(deadline) + if remaining <= 0 { + fmt.Printf("\r Remaining: Rollback executing now... \n") + break + } + if time.Now().After(displayEnd) { + fmt.Printf("\r Remaining: %ds (exiting, rollback will proceed)\n", int(remaining.Seconds())) + break + } + fmt.Printf("\r Remaining: %ds ", int(remaining.Seconds())) + + <-ticker.C + } +} + +func printFinalSummary(finalExitCode int) { + fmt.Println() + + logger := logging.GetDefaultLogger() + printRunIssueSummary(logger) + printFinalSummaryHeader(finalSummarySignature(), finalSummaryColor(finalExitCode, logger)) + printFinalSummaryCommands() +} + +func finalSummarySignature() string { + summarySig := buildSignature() + if summarySig == "" { + return "unknown" + } + return summarySig +} + +func finalSummaryColor(finalExitCode int, logger *logging.Logger) string { + hasWarnings := logger != nil && logger.HasWarnings() + + switch { + case finalExitCode == exitCodeInterrupted: + return "\033[35m" // magenta for Ctrl+C + case finalExitCode == 0 && hasWarnings: + return "\033[33m" // yellow for success with warnings + case finalExitCode == 0: + return "\033[32m" // green for clean success + case finalExitCode == types.ExitGenericError.Int(): + return "\033[33m" // yellow for generic error (non-fatal) + default: + return "\033[31m" // red for all other errors + } +} + +func printRunIssueSummary(logger *logging.Logger) { + if logger == nil { + return + } + issues := logger.IssueLines() + if len(issues) == 0 { + return + } + + fmt.Println("===========================================") + fmt.Printf("WARNINGS/ERRORS DURING RUN (warnings=%d errors=%d)\n", logger.WarningCount(), logger.ErrorCount()) + fmt.Println() + for _, line := range issues { + fmt.Println(line) + } + fmt.Println("===========================================") + fmt.Println() +} + +func printFinalSummaryHeader(summarySig, color string) { + colorReset := "\033[0m" + if color != "" { + fmt.Printf("%s===========================================\n", color) + fmt.Printf("ProxSave - Go - %s\n", summarySig) + fmt.Printf("===========================================%s\n", colorReset) + } else { + fmt.Println("===========================================") + fmt.Printf("ProxSave - Go - %s\n", summarySig) + fmt.Println("===========================================") + } +} + +func printFinalSummaryCommands() { + fmt.Println() + fmt.Println("\033[31mEXTRA STEP - IF YOU FIND THIS TOOL USEFUL AND WANT TO THANK ME, A COFFEE IS ALWAYS WELCOME!\033[0m") + fmt.Println("https://github.com/sponsors/tis24dev") + fmt.Println() + fmt.Println("Commands:") + fmt.Println(" proxsave (alias: proxmox-backup) - Start backup") + fmt.Println(" --help - Show all options") + fmt.Println(" --dry-run - Test without changes") + fmt.Println(" --install - Re-run interactive installation/setup") + fmt.Println(" --new-install - Wipe installation directory (keep build/env/identity) then run installer") + fmt.Println(" --env-migration - Run installer and migrate legacy Bash backup.env to Go template") + fmt.Println(" --env-migration-dry-run - Preview installer/migration without writing files") + fmt.Println(" --upgrade - Update proxsave binary to latest release (also adds missing keys to backup.env)") + fmt.Println(" --newkey - Generate a new encryption key for backups") + fmt.Println(" --decrypt - Decrypt an existing backup archive") + fmt.Println(" --restore - Run interactive restore workflow (select bundle, decrypt if needed, apply to system)") + fmt.Println(" --upgrade-config - Upgrade configuration file using the embedded template (run after installing a new binary)") + fmt.Println(" --support - Run in support mode (force debug log level and send email with attached log to github-support@tis24.it); available for standard backup and --restore") + fmt.Println() +} diff --git a/cmd/proxsave/main_identity.go b/cmd/proxsave/main_identity.go new file mode 100644 index 00000000..ca53fc12 --- /dev/null +++ b/cmd/proxsave/main_identity.go @@ -0,0 +1,80 @@ +// Package main contains the proxsave command entrypoint. +package main + +import ( + "fmt" + "strings" + + "github.com/tis24dev/proxsave/internal/identity" + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/notify" +) + +var runtimeServerIdentityDetector = detectServerIdentity + +func initializeServerIdentity(rt *appRuntime) { + rt.serverIDValue = strings.TrimSpace(rt.cfg.ServerID) + rt.serverMACValue = "" + if rt.serverIDValue != "" { + rt.cfg.ServerID = rt.serverIDValue + } else { + identityInfo := runtimeServerIdentityDetector(rt) + if identityInfo != nil { + applyDetectedIdentity(rt, identityInfo) + } + if rt.serverIDValue != "" { + rt.cfg.ServerID = rt.serverIDValue + } + } + + logServerIdentityValues(rt.serverIDValue, rt.serverMACValue) + logTelegramServerStatus(rt) + fmt.Println() +} + +func detectServerIdentity(rt *appRuntime) *identity.Info { + info, err := identity.DetectWithContext(rt.ctx, rt.cfg.BaseDir, rt.logger) + if err != nil { + logging.Warning("WARNING: Failed to load server identity: %v", err) + } + return info +} + +func applyDetectedIdentity(rt *appRuntime, info *identity.Info) { + if info.ServerID != "" { + rt.serverIDValue = info.ServerID + } + if info.PrimaryMAC != "" { + rt.serverMACValue = info.PrimaryMAC + } +} + +func logTelegramServerStatus(rt *appRuntime) { + status := "Telegram disabled" + logTelegramInfo := true + if rt.cfg.TelegramEnabled { + status, logTelegramInfo = checkTelegramServerStatus(rt) + } + if logTelegramInfo { + logging.Info("Server Telegram: %s", status) + } +} + +func checkTelegramServerStatus(rt *appRuntime) (string, bool) { + if !strings.EqualFold(rt.cfg.TelegramBotType, "centralized") { + return "Personal mode - no remote contact", true + } + + logging.Debug("Contacting remote Telegram server...") + logging.Debug("Telegram server URL: %s", rt.cfg.TelegramServerAPIHost) + status := notify.CheckTelegramRegistration(rt.ctx, rt.cfg.TelegramServerAPIHost, rt.serverIDValue, rt.logger) + if status.Error != nil { + logging.Warning("Telegram: %s", status.Message) + logging.Debug("Telegram connection error: %v", status.Error) + logging.Skip("Telegram: disabled") + rt.cfg.TelegramEnabled = false + return status.Message, false + } + logging.Debug("Remote server contacted: Bot token / chat ID verified (handshake)") + return status.Message, true +} diff --git a/cmd/proxsave/main_identity_test.go b/cmd/proxsave/main_identity_test.go new file mode 100644 index 00000000..fd89a782 --- /dev/null +++ b/cmd/proxsave/main_identity_test.go @@ -0,0 +1,54 @@ +package main + +import ( + "testing" + + "github.com/tis24dev/proxsave/internal/config" + "github.com/tis24dev/proxsave/internal/identity" +) + +func TestInitializeServerIdentityKeepsConfiguredServerID(t *testing.T) { + origDetector := runtimeServerIdentityDetector + t.Cleanup(func() { runtimeServerIdentityDetector = origDetector }) + + called := false + runtimeServerIdentityDetector = func(*appRuntime) *identity.Info { + called = true + return &identity.Info{ServerID: "detected", PrimaryMAC: "00:11:22:33:44:55"} + } + + rt := &appRuntime{cfg: &config.Config{ServerID: " configured "}} + initializeServerIdentity(rt) + + if called { + t.Fatal("detector should not run when ServerID is explicitly configured") + } + if rt.serverIDValue != "configured" { + t.Fatalf("serverIDValue=%q; want configured", rt.serverIDValue) + } + if rt.cfg.ServerID != "configured" { + t.Fatalf("cfg.ServerID=%q; want trimmed configured", rt.cfg.ServerID) + } +} + +func TestInitializeServerIdentityStoresDetectedServerID(t *testing.T) { + origDetector := runtimeServerIdentityDetector + t.Cleanup(func() { runtimeServerIdentityDetector = origDetector }) + + runtimeServerIdentityDetector = func(*appRuntime) *identity.Info { + return &identity.Info{ServerID: "detected", PrimaryMAC: "00:11:22:33:44:55"} + } + + rt := &appRuntime{cfg: &config.Config{}} + initializeServerIdentity(rt) + + if rt.serverIDValue != "detected" { + t.Fatalf("serverIDValue=%q; want detected", rt.serverIDValue) + } + if rt.cfg.ServerID != "detected" { + t.Fatalf("cfg.ServerID=%q; want detected", rt.cfg.ServerID) + } + if rt.serverMACValue != "00:11:22:33:44:55" { + t.Fatalf("serverMACValue=%q; want detected MAC", rt.serverMACValue) + } +} diff --git a/cmd/proxsave/main_lifecycle.go b/cmd/proxsave/main_lifecycle.go new file mode 100644 index 00000000..b589a07a --- /dev/null +++ b/cmd/proxsave/main_lifecycle.go @@ -0,0 +1,169 @@ +// Package main contains the proxsave command entrypoint. +package main + +import ( + "context" + "fmt" + "os" + "runtime/debug" + + "github.com/tis24dev/proxsave/internal/cli" + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/types" + buildinfo "github.com/tis24dev/proxsave/internal/version" +) + +type runBootstrap struct { + bootstrap *logging.BootstrapLogger + toolVersion string + runDone func(error) + state *appRunState +} + +func startMainRun() runBootstrap { + bootstrap := logging.NewBootstrapLogger() + toolVersion := buildinfo.String() + return runBootstrap{ + bootstrap: bootstrap, + toolVersion: toolVersion, + runDone: logging.DebugStartBootstrap(bootstrap, "main run", "version=%s", toolVersion), + state: newAppRunState(), + } +} + +func finishMainRun(run runBootstrap) { + var panicErr error + exitAfterCleanup := false + defer func() { + if run.bootstrap != nil && run.state != nil { + logging.DebugStepBootstrap(run.bootstrap, "main run", "exit_code=%d", run.state.finalExitCode) + } + if run.runDone != nil { + run.runDone(panicErr) + } + if exitAfterCleanup { + os.Exit(types.ExitPanicError.Int()) + } + }() + + r := recover() + if r == nil { + return + } + + stack := debug.Stack() + panicErr = fmt.Errorf("panic: %v", r) + exitAfterCleanup = true + if run.state != nil { + run.state.finalExitCode = types.ExitPanicError.Int() + } + if run.bootstrap != nil { + run.bootstrap.Error("PANIC: %v\n%s", r, stack) + } + fmt.Fprintf(os.Stderr, "panic: %v\n%s\n", r, stack) +} + +func preparePreRuntimeArgs(ctx context.Context, bootstrap *logging.BootstrapLogger, toolVersion string) (*cli.Args, int, bool) { + args := cli.Parse() + logging.DebugStepBootstrap(bootstrap, "main run", "args parsed") + if exitCode, handled := dispatchFlagOnlyModes(args); handled { + return args, exitCode, true + } + if exitCode, handled := rejectIncompatibleModes(args, bootstrap); handled { + return args, exitCode, true + } + if exitCode, handled := runCleanupGuardsMode(ctx, args, bootstrap); handled { + return args, exitCode, true + } + logging.DebugStepBootstrap(bootstrap, "main run", "support_mode=%v", args.Support) + if exitCode, ok := resolveRunConfigPath(args, bootstrap); !ok { + return args, exitCode, true + } + if exitCode, handled := runUpgradeConfigJSONMode(args); handled { + return args, exitCode, true + } + if exitCode, handled := dispatchPreRuntimeModes(ctx, args, bootstrap, toolVersion); handled { + return args, exitCode, true + } + return args, types.ExitSuccess.Int(), false +} + +func dispatchFlagOnlyModes(args *cli.Args) (int, bool) { + if args.ShowVersion { + cli.ShowVersion() + return types.ExitSuccess.Int(), true + } + if args.ShowHelp { + cli.ShowHelp() + return types.ExitSuccess.Int(), true + } + return types.ExitSuccess.Int(), false +} + +func rejectIncompatibleModes(args *cli.Args, bootstrap *logging.BootstrapLogger) (int, bool) { + messages := validateModeCompatibility(args) + if len(messages) == 0 { + return types.ExitSuccess.Int(), false + } + for _, message := range messages { + bootstrap.Error("%s", message) + } + return types.ExitConfigError.Int(), true +} + +func resolveRunConfigPath(args *cli.Args, bootstrap *logging.BootstrapLogger) (int, bool) { + logging.DebugStepBootstrap(bootstrap, "main run", "resolving config path") + resolvedConfigPath, err := resolveInstallConfigPath(args.ConfigPath) + if err != nil { + bootstrap.Error("ERROR: %v", err) + return types.ExitConfigError.Int(), false + } + args.ConfigPath = resolvedConfigPath + return types.ExitSuccess.Int(), true +} + +func prepareRuntime(ctx context.Context, args *cli.Args, bootstrap *logging.BootstrapLogger, state *appRunState, toolVersion string) (*appRuntime, int, bool) { + if exitCode, ok := enforceGoRuntimeVersion(bootstrap); !ok { + return nil, exitCode, false + } + printVersionHeader(bootstrap, toolVersion) + envInfo := detectAndPrintEnvironment(bootstrap) + if exitCode, handled := dispatchPostHeaderConfigModes(ctx, args, bootstrap); handled { + return nil, exitCode, false + } + if exitCode, handled := handleSupportIntro(ctx, args, bootstrap, state); handled { + return nil, exitCode, false + } + return bootstrapRuntime(ctx, args, bootstrap, envInfo, toolVersion) +} + +func enforceGoRuntimeVersion(bootstrap *logging.BootstrapLogger) (int, bool) { + if err := checkGoRuntimeVersion(goRuntimeMinVersion); err != nil { + bootstrap.Error("ERROR: %v", err) + return types.ExitEnvironmentError.Int(), false + } + return types.ExitSuccess.Int(), true +} + +func runRuntime(rt *appRuntime, state *appRunState) int { + defer rt.sessionLogCloser() + for _, deferredAction := range runDeferredActions(rt, state) { + defer deferredAction() + } + state.showSummary = true + + logRunContext(rt) + initializeServerIdentity(rt) + if exitCode, ok := runSecurityPreflight(rt); !ok { + return state.finalize(exitCode) + } + if result := dispatchRestoreMode(rt); result.handled { + return finalizeModeResult(state, result) + } + return finalizeModeResult(state, dispatchBackupMode(rt)) +} + +func finalizeModeResult(state *appRunState, result modeResult) int { + state.applyModeResult(result) + return state.finalize(result.exitCode) +} diff --git a/cmd/proxsave/main_modes.go b/cmd/proxsave/main_modes.go new file mode 100644 index 00000000..18fd2a2c --- /dev/null +++ b/cmd/proxsave/main_modes.go @@ -0,0 +1,264 @@ +// Package main contains the proxsave command entrypoint. +package main + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/tis24dev/proxsave/internal/cli" + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/orchestrator" + "github.com/tis24dev/proxsave/internal/types" +) + +type incompatibleMode struct { + enabled bool + label string +} + +type modeCompatibilityRule func(*cli.Args) []string + +type preRuntimeModeHandler func(context.Context, *cli.Args, *logging.BootstrapLogger, string) (int, bool) + +func validateModeCompatibility(args *cli.Args) []string { + if args == nil { + return []string{"command-line arguments are required"} + } + + var allMessages []string + for _, rule := range []modeCompatibilityRule{ + validateCleanupGuardsCompatibility, + validateSupportCompatibility, + validateInstallCompatibility, + validateUpgradeCompatibility, + } { + if messages := rule(args); len(messages) > 0 { + allMessages = append(allMessages, messages...) + } + } + return allMessages +} + +func validateCleanupGuardsCompatibility(args *cli.Args) []string { + if args.CleanupGuards { + if incompatible := cleanupGuardsIncompatibleModes(args); len(incompatible) > 0 { + return []string{fmt.Sprintf("--cleanup-guards cannot be combined with: %s", strings.Join(incompatible, ", "))} + } + return nil + } + return nil +} + +func validateSupportCompatibility(args *cli.Args) []string { + if args.Support { + if incompatible := supportIncompatibleModes(args); len(incompatible) > 0 { + return []string{ + fmt.Sprintf("Support mode cannot be combined with: %s", strings.Join(incompatible, ", ")), + "--support is only available for the standard backup run or --restore.", + } + } + } + return nil +} + +func validateInstallCompatibility(args *cli.Args) []string { + if args.Install && args.NewInstall { + return []string{"Cannot use --install and --new-install together. Choose one installation mode."} + } + return nil +} + +func validateUpgradeCompatibility(args *cli.Args) []string { + if args.Upgrade && (args.Install || args.NewInstall) { + return []string{"Cannot use --upgrade together with --install or --new-install."} + } + return nil +} + +func cleanupGuardsIncompatibleModes(args *cli.Args) []string { + return enabledModes([]incompatibleMode{ + {enabled: args.Support, label: "--support"}, + {enabled: args.Restore, label: "--restore"}, + {enabled: args.Decrypt, label: "--decrypt"}, + {enabled: args.Install, label: "--install"}, + {enabled: args.NewInstall, label: "--new-install"}, + {enabled: args.Upgrade, label: "--upgrade"}, + {enabled: args.ForceNewKey, label: "--newkey"}, + {enabled: args.EnvMigration || args.EnvMigrationDry, label: "--env-migration/--env-migration-dry-run"}, + {enabled: args.UpgradeConfig || args.UpgradeConfigDry || args.UpgradeConfigJSON, label: "--upgrade-config/--upgrade-config-dry-run/--upgrade-config-json"}, + }) +} + +func supportIncompatibleModes(args *cli.Args) []string { + return enabledModes([]incompatibleMode{ + {enabled: args.Decrypt, label: "--decrypt"}, + {enabled: args.Install, label: "--install"}, + {enabled: args.NewInstall, label: "--new-install"}, + {enabled: args.EnvMigration || args.EnvMigrationDry, label: "--env-migration"}, + {enabled: args.UpgradeConfig || args.UpgradeConfigDry || args.UpgradeConfigJSON, label: "--upgrade-config"}, + {enabled: args.ForceNewKey, label: "--newkey"}, + }) +} + +func enabledModes(modes []incompatibleMode) []string { + incompatible := make([]string, 0, len(modes)) + for _, mode := range modes { + if mode.enabled { + incompatible = append(incompatible, mode.label) + } + } + return incompatible +} + +func dispatchPreRuntimeModes(ctx context.Context, args *cli.Args, bootstrap *logging.BootstrapLogger, toolVersion string) (int, bool) { + for _, handler := range []preRuntimeModeHandler{ + runUpgradeMode, + runNewKeyMode, + runDecryptOnlyMode, + runNewInstallMode, + runUpgradeConfigDryMode, + runInstallMode, + } { + if exitCode, handled := handler(ctx, args, bootstrap, toolVersion); handled { + return exitCode, true + } + } + return types.ExitSuccess.Int(), false +} + +func runCleanupGuardsMode(ctx context.Context, args *cli.Args, bootstrap *logging.BootstrapLogger) (int, bool) { + if !args.CleanupGuards { + return types.ExitSuccess.Int(), false + } + + level := types.LogLevelInfo + if args.LogLevel != types.LogLevelNone { + level = args.LogLevel + } + logger := logging.New(level, false) + + if err := orchestrator.CleanupMountGuards(ctx, logger, args.DryRun); err != nil { + bootstrap.Error("ERROR: %v", err) + return types.ExitGenericError.Int(), true + } + return types.ExitSuccess.Int(), true +} + +func runUpgradeMode(ctx context.Context, args *cli.Args, bootstrap *logging.BootstrapLogger, _ string) (int, bool) { + if !args.Upgrade { + return types.ExitSuccess.Int(), false + } + logging.DebugStepBootstrap(bootstrap, "main run", "mode=upgrade") + return runUpgrade(ctx, args, bootstrap), true +} + +func runNewKeyMode(ctx context.Context, args *cli.Args, bootstrap *logging.BootstrapLogger, _ string) (int, bool) { + if !args.ForceNewKey { + return types.ExitSuccess.Int(), false + } + newKeyCLI := args.ForceCLI + logging.DebugStepBootstrap(bootstrap, "main run", "mode=newkey cli=%v", newKeyCLI) + if err := runNewKey(ctx, args.ConfigPath, cliFlowLogLevel(args), bootstrap, newKeyCLI); err != nil { + if isInstallAbortedError(err) || errors.Is(err, orchestrator.ErrAgeRecipientSetupAborted) { + return types.ExitSuccess.Int(), true + } + bootstrap.Error("ERROR: %v", err) + return types.ExitConfigError.Int(), true + } + return types.ExitSuccess.Int(), true +} + +func runDecryptOnlyMode(ctx context.Context, args *cli.Args, bootstrap *logging.BootstrapLogger, toolVersion string) (int, bool) { + if !args.Decrypt { + return types.ExitSuccess.Int(), false + } + decryptCLI := args.ForceCLI + logging.DebugStepBootstrap(bootstrap, "main run", "mode=decrypt cli=%v", decryptCLI) + if err := runDecryptWorkflowOnly(ctx, args.ConfigPath, bootstrap, toolVersion, decryptCLI); err != nil { + if errors.Is(err, orchestrator.ErrDecryptAborted) { + bootstrap.Info("Decrypt workflow aborted by user") + return types.ExitSuccess.Int(), true + } + bootstrap.Error("ERROR: %v", err) + return types.ExitGenericError.Int(), true + } + bootstrap.Info("Decrypt workflow completed successfully") + return types.ExitSuccess.Int(), true +} + +func runNewInstallMode(ctx context.Context, args *cli.Args, bootstrap *logging.BootstrapLogger, _ string) (int, bool) { + if !args.NewInstall { + return types.ExitSuccess.Int(), false + } + newInstallCLI := args.ForceCLI + logging.DebugStepBootstrap(bootstrap, "main run", "mode=new-install cli=%v", newInstallCLI) + sessionLogger, cleanupSessionLog := startFlowSessionLog("new-install", cliFlowLogLevel(args), bootstrap) + defer cleanupSessionLog() + if sessionLogger != nil { + sessionLogger.Info("Starting --new-install (config=%s)", args.ConfigPath) + } + if err := runNewInstall(ctx, args.ConfigPath, bootstrap, newInstallCLI); err != nil { + logInstallModeError(sessionLogger, "new-install", err) + if isInstallAbortedError(err) { + return types.ExitSuccess.Int(), true + } + bootstrap.Error("ERROR: %v", err) + return types.ExitConfigError.Int(), true + } + if sessionLogger != nil { + sessionLogger.Info("new-install completed successfully") + } + return types.ExitSuccess.Int(), true +} + +func runInstallMode(ctx context.Context, args *cli.Args, bootstrap *logging.BootstrapLogger, _ string) (int, bool) { + if !args.Install { + return types.ExitSuccess.Int(), false + } + logging.DebugStepBootstrap(bootstrap, "main run", "mode=install cli=%v", args.ForceCLI) + sessionLogger, cleanupSessionLog := startFlowSessionLog("install", cliFlowLogLevel(args), bootstrap) + defer cleanupSessionLog() + if sessionLogger != nil { + sessionLogger.Info("Starting --install (config=%s)", args.ConfigPath) + } + + var err error + if args.ForceCLI { + err = runInstall(ctx, args.ConfigPath, bootstrap) + } else { + err = runInstallTUI(ctx, args.ConfigPath, bootstrap) + } + + if err != nil { + logInstallModeError(sessionLogger, "install", err) + if isInstallAbortedError(err) { + return types.ExitSuccess.Int(), true + } + bootstrap.Error("ERROR: %v", err) + return types.ExitConfigError.Int(), true + } + if sessionLogger != nil { + sessionLogger.Info("install completed successfully") + } + return types.ExitSuccess.Int(), true +} + +func cliFlowLogLevel(args *cli.Args) types.LogLevel { + if args.LogLevel != types.LogLevelNone { + return args.LogLevel + } + return types.LogLevelInfo +} + +func logInstallModeError(sessionLogger *logging.Logger, flowName string, err error) { + if sessionLogger == nil { + return + } + if isInstallAbortedError(err) { + sessionLogger.Warning("%s aborted by user: %v", flowName, err) + return + } + sessionLogger.Error("%s failed: %v", flowName, err) +} diff --git a/cmd/proxsave/main_modes_test.go b/cmd/proxsave/main_modes_test.go new file mode 100644 index 00000000..1608eb49 --- /dev/null +++ b/cmd/proxsave/main_modes_test.go @@ -0,0 +1,76 @@ +package main + +import ( + "reflect" + "testing" + + "github.com/tis24dev/proxsave/internal/cli" +) + +func TestValidateModeCompatibility(t *testing.T) { + tests := []struct { + name string + args *cli.Args + want []string + }{ + { + name: "backup default allowed", + args: &cli.Args{}, + }, + { + name: "support restore allowed", + args: &cli.Args{Support: true, Restore: true}, + }, + { + name: "cleanup guards rejects support and restore first", + args: &cli.Args{CleanupGuards: true, Support: true, Restore: true}, + want: []string{"--cleanup-guards cannot be combined with: --support, --restore"}, + }, + { + name: "support rejects decrypt", + args: &cli.Args{Support: true, Decrypt: true}, + want: []string{ + "Support mode cannot be combined with: --decrypt", + "--support is only available for the standard backup run or --restore.", + }, + }, + { + name: "support rejects config utility modes", + args: &cli.Args{Support: true, UpgradeConfigDry: true}, + want: []string{ + "Support mode cannot be combined with: --upgrade-config", + "--support is only available for the standard backup run or --restore.", + }, + }, + { + name: "install new install conflict", + args: &cli.Args{Install: true, NewInstall: true}, + want: []string{"Cannot use --install and --new-install together. Choose one installation mode."}, + }, + { + name: "upgrade install conflict", + args: &cli.Args{Upgrade: true, Install: true}, + want: []string{"Cannot use --upgrade together with --install or --new-install."}, + }, + { + name: "accumulates all compatibility violations", + args: &cli.Args{CleanupGuards: true, Support: true, Decrypt: true, Install: true, NewInstall: true, Upgrade: true}, + want: []string{ + "--cleanup-guards cannot be combined with: --support, --decrypt, --install, --new-install, --upgrade", + "Support mode cannot be combined with: --decrypt, --install, --new-install", + "--support is only available for the standard backup run or --restore.", + "Cannot use --install and --new-install together. Choose one installation mode.", + "Cannot use --upgrade together with --install or --new-install.", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := validateModeCompatibility(tt.args) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("validateModeCompatibility() = %#v, want %#v", got, tt.want) + } + }) + } +} diff --git a/cmd/proxsave/main_network.go b/cmd/proxsave/main_network.go new file mode 100644 index 00000000..c0f5af30 --- /dev/null +++ b/cmd/proxsave/main_network.go @@ -0,0 +1,122 @@ +// Package main contains the proxsave command entrypoint. +package main + +import ( + "strings" + + "github.com/tis24dev/proxsave/internal/config" + "github.com/tis24dev/proxsave/internal/logging" +) + +type networkFeatureDisablement struct { + enabled func(*config.Config) bool + apply func(*config.Config, networkWarningFunc) +} + +type networkWarningFunc func(string, ...interface{}) + +// featuresNeedNetwork returns whether current configuration requires outbound network, and human reasons. +func featuresNeedNetwork(cfg *config.Config) (bool, []string) { + reasons := []string{} + // Telegram (any mode uses network) + if cfg.TelegramEnabled { + if strings.EqualFold(cfg.TelegramBotType, "centralized") { + reasons = append(reasons, "Telegram centralized registration") + } else { + reasons = append(reasons, "Telegram personal notifications") + } + } + // Email via relay + if cfg.EmailEnabled && strings.EqualFold(cfg.EmailDeliveryMethod, "relay") { + reasons = append(reasons, "Email relay delivery") + } + // Gotify + if cfg.GotifyEnabled { + reasons = append(reasons, "Gotify notifications") + } + // Webhooks + if cfg.WebhookEnabled { + reasons = append(reasons, "Webhooks") + } + // Cloud uploads via rclone + if cfg.CloudEnabled { + reasons = append(reasons, "Cloud storage (rclone)") + } + return len(reasons) > 0, reasons +} + +// disableNetworkFeaturesForRun disables all network-dependent features when connectivity is unavailable. +func disableNetworkFeaturesForRun(cfg *config.Config, bootstrap *logging.BootstrapLogger) { + if cfg == nil { + return + } + warn := networkWarning(bootstrap) + for _, disablement := range networkFeatureDisablements() { + if disablement.enabled(cfg) { + disablement.apply(cfg, warn) + } + } +} + +func networkWarning(bootstrap *logging.BootstrapLogger) networkWarningFunc { + return func(format string, args ...interface{}) { + if bootstrap != nil { + bootstrap.Warning(format, args...) + return + } + logging.Warning(format, args...) + } +} + +func networkFeatureDisablements() []networkFeatureDisablement { + return []networkFeatureDisablement{ + {enabled: cloudNetworkEnabled, apply: disableCloudNetworkFeature}, + {enabled: telegramNetworkEnabled, apply: disableTelegramNetworkFeature}, + {enabled: emailRelayNetworkEnabled, apply: disableEmailRelayNetworkFeature}, + {enabled: gotifyNetworkEnabled, apply: disableGotifyNetworkFeature}, + {enabled: webhookNetworkEnabled, apply: disableWebhookNetworkFeature}, + } +} + +func cloudNetworkEnabled(cfg *config.Config) bool { return cfg.CloudEnabled } + +func telegramNetworkEnabled(cfg *config.Config) bool { return cfg.TelegramEnabled } + +func emailRelayNetworkEnabled(cfg *config.Config) bool { + return cfg.EmailEnabled && strings.EqualFold(cfg.EmailDeliveryMethod, "relay") +} + +func gotifyNetworkEnabled(cfg *config.Config) bool { return cfg.GotifyEnabled } + +func webhookNetworkEnabled(cfg *config.Config) bool { return cfg.WebhookEnabled } + +func disableCloudNetworkFeature(cfg *config.Config, warn networkWarningFunc) { + warn("WARNING: Disabling cloud storage (rclone) due to missing network connectivity") + cfg.CloudEnabled = false + cfg.CloudLogPath = "" +} + +func disableTelegramNetworkFeature(cfg *config.Config, warn networkWarningFunc) { + warn("WARNING: Disabling Telegram notifications due to missing network connectivity") + cfg.TelegramEnabled = false +} + +func disableEmailRelayNetworkFeature(cfg *config.Config, warn networkWarningFunc) { + if cfg.EmailFallbackSendmail { + warn("WARNING: Network unavailable; switching Email delivery to sendmail for this run") + cfg.EmailDeliveryMethod = "sendmail" + return + } + warn("WARNING: Disabling Email relay notifications due to missing network connectivity") + cfg.EmailEnabled = false +} + +func disableGotifyNetworkFeature(cfg *config.Config, warn networkWarningFunc) { + warn("WARNING: Disabling Gotify notifications due to missing network connectivity") + cfg.GotifyEnabled = false +} + +func disableWebhookNetworkFeature(cfg *config.Config, warn networkWarningFunc) { + warn("WARNING: Disabling Webhook notifications due to missing network connectivity") + cfg.WebhookEnabled = false +} diff --git a/cmd/proxsave/main_restore_decrypt.go b/cmd/proxsave/main_restore_decrypt.go new file mode 100644 index 00000000..d182acdc --- /dev/null +++ b/cmd/proxsave/main_restore_decrypt.go @@ -0,0 +1,112 @@ +// Package main contains the proxsave command entrypoint. +package main + +import ( + "errors" + "strings" + "time" + + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/orchestrator" + "github.com/tis24dev/proxsave/internal/support" + "github.com/tis24dev/proxsave/internal/types" +) + +func dispatchRestoreMode(rt *appRuntime) modeResult { + if !rt.args.Restore { + return modeResult{exitCode: types.ExitSuccess.Int()} + } + + restoreCLI := rt.args.ForceCLI + logging.DebugStep(rt.logger, "main", "mode=restore cli=%v", restoreCLI) + if restoreCLI { + return runRestoreCLI(rt) + } + return runRestoreTUI(rt) +} + +func runRestoreCLI(rt *appRuntime) modeResult { + logging.Info("Restore mode enabled - starting CLI workflow...") + err := orchestrator.RunRestoreWorkflow(rt.ctx, rt.cfg, rt.logger, rt.toolVersion) + if err != nil { + return finishFailedRestore(rt, err, false) + } + return finishSuccessfulRestore(rt) +} + +func runRestoreTUI(rt *appRuntime) modeResult { + logging.Info("Restore mode enabled - starting interactive workflow...") + sig := buildSignature() + if strings.TrimSpace(sig) == "" { + sig = "n/a" + } + err := orchestrator.RunRestoreWorkflowTUI(rt.ctx, rt.cfg, rt.logger, rt.toolVersion, rt.args.ConfigPath, sig) + if err != nil { + return finishFailedRestore(rt, err, true) + } + return finishSuccessfulRestore(rt) +} + +func finishFailedRestore(rt *appRuntime, err error, includeDecryptAbort bool) modeResult { + if isRestoreAbort(err, includeDecryptAbort) { + logging.Warning("Restore workflow aborted by user") + return restoreModeResult(rt, exitCodeInterrupted) + } + logging.Error("Restore workflow failed: %v", err) + return restoreModeResult(rt, types.ExitGenericError.Int()) +} + +func isRestoreAbort(err error, includeDecryptAbort bool) bool { + if errors.Is(err, orchestrator.ErrRestoreAborted) { + return true + } + return includeDecryptAbort && errors.Is(err, orchestrator.ErrDecryptAborted) +} + +func finishSuccessfulRestore(rt *appRuntime) modeResult { + if rt.logger.HasWarnings() { + logging.Warning("Restore workflow completed with warnings (see log above)") + } else { + logging.Info("Restore workflow completed successfully") + } + return restoreModeResult(rt, types.ExitSuccess.Int()) +} + +func restoreModeResult(rt *appRuntime, exitCode int) modeResult { + return modeResult{ + exitCode: exitCode, + handled: true, + supportStats: restoreSupportStats(rt, exitCode), + } +} + +func restoreSupportStats(rt *appRuntime, exitCode int) *orchestrator.BackupStats { + if !rt.args.Support { + return nil + } + return support.BuildSupportStats(rt.logger, resolveHostname(), rt.envInfo.Type, rt.envInfo.Version, rt.toolVersion, rt.startTime, time.Now(), exitCode, "restore") +} + +func dispatchBackupMode(rt *appRuntime) modeResult { + result := runBackupMode(backupModeOptions{ + ctx: rt.ctx, + cfg: rt.cfg, + logger: rt.logger, + envInfo: rt.envInfo, + unprivilegedInfo: rt.unprivilegedInfo, + updateInfo: rt.updateInfo, + toolVersion: rt.toolVersion, + dryRun: rt.dryRun, + startTime: rt.startTime, + heapProfilePath: rt.heapProfilePath, + serverIDValue: rt.serverIDValue, + serverMACValue: rt.serverMACValue, + }) + return modeResult{ + orch: result.orch, + earlyErrorState: result.earlyErrorState, + supportStats: result.supportStats, + exitCode: result.exitCode, + handled: true, + } +} diff --git a/cmd/proxsave/main_runtime.go b/cmd/proxsave/main_runtime.go new file mode 100644 index 00000000..f1a174d8 --- /dev/null +++ b/cmd/proxsave/main_runtime.go @@ -0,0 +1,305 @@ +// Package main contains the proxsave command entrypoint. +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "runtime" + "runtime/pprof" + "strconv" + "strings" + + "github.com/tis24dev/proxsave/internal/cli" + "github.com/tis24dev/proxsave/internal/config" + "github.com/tis24dev/proxsave/internal/environment" + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/types" +) + +func printVersionHeader(bootstrap *logging.BootstrapLogger, toolVersion string) { + bootstrap.Println("===========================================") + bootstrap.Println(" ProxSave - Go Version") + bootstrap.Printf(" Version: %s", toolVersion) + if sig := buildSignature(); sig != "" { + bootstrap.Printf(" Build Signature: %s", sig) + } + bootstrap.Println("===========================================") + bootstrap.Println("") +} + +func detectAndPrintEnvironment(bootstrap *logging.BootstrapLogger) *environment.EnvironmentInfo { + bootstrap.Println("Detecting Proxmox environment...") + envInfo, err := environment.Detect() + if err != nil { + bootstrap.Warning("WARNING: %v", err) + bootstrap.Println("Continuing with limited functionality...") + } + bootstrap.Printf("✓ Proxmox Type: %s", envInfo.Type) + if envInfo.Type == types.ProxmoxDual { + bootstrap.Printf(" PVE Version: %s", envInfo.PVEVersion) + bootstrap.Printf(" PBS Version: %s", envInfo.PBSVersion) + } else { + bootstrap.Printf(" Version: %s", envInfo.Version) + } + bootstrap.Println("") + return envInfo +} + +func bootstrapRuntime(ctx context.Context, args *cli.Args, bootstrap *logging.BootstrapLogger, envInfo *environment.EnvironmentInfo, toolVersion string) (*appRuntime, int, bool) { + rt := &appRuntime{ + ctx: ctx, + args: args, + bootstrap: bootstrap, + deps: defaultAppDeps(), + envInfo: envInfo, + toolVersion: toolVersion, + sessionLogCloser: func() {}, + } + + cfg, initialEnvBaseDir, autoFound, exitCode, ok := loadRunConfig(args, bootstrap) + if !ok { + return nil, exitCode, false + } + rt.cfg = cfg + rt.initialEnvBaseDir = initialEnvBaseDir + rt.autoBaseDirFound = autoFound + rt.dryRun = args.DryRun || cfg.DryRun + + if exitCode, ok := validateRunConfig(rt); !ok { + return nil, exitCode, false + } + + rt.logLevel = resolveRunLogLevel(args, cfg, bootstrap) + rt.logger = initializeRunLogger(rt) + initializeRunLogFile(rt) + bootstrap.Flush(rt.logger) + rt.updateInfo = checkForUpdates(ctx, rt.logger, toolVersion) + applyRunPermissions(rt) + initializeRunProfiling(rt) + rt.unprivilegedInfo = environment.DetectUnprivilegedContainer() + return rt, types.ExitSuccess.Int(), true +} + +func loadRunConfig(args *cli.Args, bootstrap *logging.BootstrapLogger) (*config.Config, string, bool, int, bool) { + autoBaseDir, autoFound := detectBaseDir() + if autoBaseDir == "" { + if _, err := os.Stat("/opt/proxsave"); err == nil { + autoBaseDir = "/opt/proxsave" + } else { + autoBaseDir = "/opt/proxmox-backup" + } + } + + initialEnvBaseDir := os.Getenv("BASE_DIR") + if initialEnvBaseDir == "" { + _ = os.Setenv("BASE_DIR", autoBaseDir) + } + + if err := ensureConfigExists(args.ConfigPath, bootstrap); err != nil { + bootstrap.Error("ERROR: %v", err) + return nil, "", false, types.ExitConfigError.Int(), false + } + + bootstrap.Printf("Loading configuration from: %s", args.ConfigPath) + logging.DebugStepBootstrap(bootstrap, "main run", "loading configuration") + cfg, err := config.LoadConfig(args.ConfigPath) + if err != nil { + bootstrap.Error("ERROR: Failed to load configuration: %v", err) + return nil, "", false, types.ExitConfigError.Int(), false + } + if cfg.BaseDir == "" { + cfg.BaseDir = autoBaseDir + } + _ = os.Setenv("BASE_DIR", cfg.BaseDir) + bootstrap.Println("✓ Configuration loaded successfully") + return cfg, initialEnvBaseDir, autoFound, types.ExitSuccess.Int(), true +} + +func validateRunConfig(rt *appRuntime) (int, bool) { + printDryRunBootstrapStatus(rt) + if err := validateFutureFeatures(rt.cfg); err != nil { + rt.bootstrap.Error("ERROR: Invalid configuration: %v", err) + return types.ExitConfigError.Int(), false + } + warnLogPathConfiguration(rt.cfg, rt.bootstrap) + runNetworkPreflight(rt.cfg, rt.bootstrap) + return types.ExitSuccess.Int(), true +} + +func printDryRunBootstrapStatus(rt *appRuntime) { + if rt.dryRun { + if rt.args.DryRun { + rt.bootstrap.Println("⚠ DRY RUN MODE (enabled via --dry-run flag)") + } else { + rt.bootstrap.Println("⚠ DRY RUN MODE (enabled via DRY_RUN config)") + } + } + rt.bootstrap.Println("") +} + +func warnLogPathConfiguration(cfg *config.Config, bootstrap *logging.BootstrapLogger) { + if strings.TrimSpace(cfg.LogPath) == "" { + bootstrap.Warning("WARNING: LOG_PATH is empty - file logging disabled, using stdout only") + } + if cfg.SecondaryEnabled && strings.TrimSpace(cfg.SecondaryLogPath) == "" { + bootstrap.Warning("WARNING: Secondary storage enabled but SECONDARY_LOG_PATH is empty - secondary log copy and cleanup will be disabled for this run") + } + if cfg.CloudEnabled && strings.TrimSpace(cfg.CloudLogPath) == "" { + bootstrap.Warning("WARNING: Cloud storage enabled but CLOUD_LOG_PATH is empty - cloud log copy and cleanup will be disabled for this run") + } +} + +func runNetworkPreflight(cfg *config.Config, bootstrap *logging.BootstrapLogger) { + needs, reasons := featuresNeedNetwork(cfg) + if !needs { + return + } + if cfg.DisableNetworkPreflight { + bootstrap.Warning("WARNING: Network preflight disabled via DISABLE_NETWORK_PREFLIGHT; features: %s", strings.Join(reasons, ", ")) + return + } + if err := checkInternetConnectivity(networkPreflightTimeout); err != nil { + bootstrap.Warning("WARNING: Network connectivity unavailable for: %s. %v", strings.Join(reasons, ", "), err) + bootstrap.Warning("WARNING: Disabling network-dependent features for this run") + disableNetworkFeaturesForRun(cfg, bootstrap) + } +} + +func resolveRunLogLevel(args *cli.Args, cfg *config.Config, bootstrap *logging.BootstrapLogger) types.LogLevel { + logLevel := cfg.DebugLevel + if args.Support { + bootstrap.Println("Support mode enabled: forcing log level to DEBUG") + logLevel = types.LogLevelDebug + } else if args.LogLevel != types.LogLevelNone { + logLevel = args.LogLevel + } + logging.DebugStepBootstrap(bootstrap, "main run", "log_level=%s", logLevel.String()) + return logLevel +} + +func initializeRunLogger(rt *appRuntime) *logging.Logger { + logger := logging.New(rt.logLevel, rt.cfg.UseColor) + if rt.args.Restore { + logger = initializeRestoreSessionLogger(rt, logger) + } + logging.SetDefaultLogger(logger) + rt.bootstrap.SetLevel(rt.logLevel) + return logger +} + +func initializeRestoreSessionLogger(rt *appRuntime, fallback *logging.Logger) *logging.Logger { + logging.DebugStepBootstrap(rt.bootstrap, "main run", "restore log enabled") + restoreLogger, restoreLogPath, closeFn, err := logging.StartSessionLogger("restore", rt.logLevel, rt.cfg.UseColor) + if err != nil { + rt.bootstrap.Warning("WARNING: Unable to start restore log: %v", err) + return fallback + } + rt.sessionLogCloser = closeFn + rt.bootstrap.Info("Restore log: %s", restoreLogPath) + _ = os.Setenv("LOG_FILE", restoreLogPath) + return restoreLogger +} + +func initializeRunLogFile(rt *appRuntime) { + rt.hostname = resolveHostname() + rt.startTime = rt.deps.now() + rt.timestampStr = rt.startTime.Format("20060102-150405") + if rt.args.Restore { + return + } + + logFileName := fmt.Sprintf("backup-%s-%s.log", rt.hostname, rt.timestampStr) + logFilePath := filepath.Join(rt.cfg.LogPath, logFileName) + if err := os.MkdirAll(rt.cfg.LogPath, defaultDirPerm); err != nil { + logging.Warning("Failed to create log directory %s: %v", rt.cfg.LogPath, err) + return + } + if err := rt.logger.OpenLogFile(logFilePath); err != nil { + logging.Warning("Failed to open log file %s: %v", logFilePath, err) + return + } + logging.Info("Log file opened: %s", logFilePath) + _ = os.Setenv("LOG_FILE", logFilePath) +} + +func applyRunPermissions(rt *appRuntime) { + if !rt.cfg.SetBackupPermissions { + return + } + logging.DebugStep(rt.logger, "main", "applying backup permissions") + if err := applyBackupPermissions(rt.cfg, rt.logger); err != nil { + logging.Warning("Failed to apply backup permissions: %v", err) + } +} + +func initializeRunProfiling(rt *appRuntime) { + if !rt.cfg.ProfilingEnabled { + return + } + cpuProfilePath := filepath.Join(rt.cfg.LogPath, fmt.Sprintf("cpu-%s-%s.pprof", rt.hostname, rt.timestampStr)) + f, err := os.Create(cpuProfilePath) + if err != nil { + logging.Warning("Failed to create CPU profile file: %v", err) + return + } + if err := pprof.StartCPUProfile(f); err != nil { + logging.Warning("Failed to start CPU profiling: %v", err) + _ = f.Close() + return + } + rt.cpuProfileFile = f + logging.Info("CPU profiling enabled: %s", cpuProfilePath) + rt.heapProfilePath = buildHeapProfilePath(rt) +} + +func buildHeapProfilePath(rt *appRuntime) string { + tmpProfileDir := filepath.Join("/tmp", "proxsave") + if err := os.MkdirAll(tmpProfileDir, defaultDirPerm); err != nil { + logging.Warning("Failed to create temp profile directory %s: %v", tmpProfileDir, err) + return "" + } + return filepath.Join(tmpProfileDir, fmt.Sprintf("heap-%s-%s.pprof", rt.hostname, rt.timestampStr)) +} + +// checkGoRuntimeVersion ensures the running binary was built with at least the specified Go version (semver: major.minor.patch). +func checkGoRuntimeVersion(minimum string) error { + rt := runtime.Version() // e.g., "go1.25.4" + // Normalize versions to x.y.z + parse := func(v string) (int, int, int) { + // Accept forms: go1.25.4, go1.25, 1.25.4, 1.25 + v = strings.TrimPrefix(v, "go") + parts := strings.Split(v, ".") + toInt := func(s string) int { n, _ := strconv.Atoi(s); return n } + major, minor, patch := 0, 0, 0 + if len(parts) > 0 { + major = toInt(parts[0]) + } + if len(parts) > 1 { + minor = toInt(parts[1]) + } + if len(parts) > 2 { + patch = toInt(parts[2]) + } + return major, minor, patch + } + + rtMaj, rtMin, rtPatch := parse(rt) + minMaj, minMin, minPatch := parse(minimum) + + meetsMinimum := func(aMaj, aMin, aPatch, bMaj, bMin, bPatch int) bool { + if aMaj != bMaj { + return aMaj > bMaj + } + if aMin != bMin { + return aMin > bMin + } + return aPatch >= bPatch + } + + if !meetsMinimum(rtMaj, rtMin, rtPatch, minMaj, minMin, minPatch) { + return fmt.Errorf("go runtime version %s is below required %s — rebuild with go %s or set GOTOOLCHAIN=auto", rt, "go"+minimum, "go"+minimum) + } + return nil +} diff --git a/cmd/proxsave/main_runtime_log.go b/cmd/proxsave/main_runtime_log.go new file mode 100644 index 00000000..2031a558 --- /dev/null +++ b/cmd/proxsave/main_runtime_log.go @@ -0,0 +1,51 @@ +// Package main contains the proxsave command entrypoint. +package main + +import ( + "strings" + + "github.com/tis24dev/proxsave/internal/logging" +) + +func logRunContext(rt *appRuntime) { + logRunDryRunStatus(rt) + baseDirSource := runBaseDirSource(rt) + logging.Info("Environment: %s %s", rt.envInfo.Type, rt.envInfo.Version) + logUserNamespaceContext(rt.logger, rt.unprivilegedInfo) + logging.Info("Backup enabled: %v", rt.cfg.BackupEnabled) + logging.Info("Debug level: %s", rt.logLevel.String()) + logging.Info("Compression: %s (level %d, mode %s)", rt.cfg.CompressionType, rt.cfg.CompressionLevel, rt.cfg.CompressionMode) + logging.Info("Base directory: %s (%s)", rt.cfg.BaseDir, baseDirSource) + logging.Info("Configuration file: %s (%s)", rt.args.ConfigPath, runConfigPathSource(rt)) +} + +func logRunDryRunStatus(rt *appRuntime) { + if !rt.dryRun { + return + } + if rt.args.DryRun { + logging.Info("DRY RUN MODE: No actual changes will be made (enabled via --dry-run flag)") + return + } + logging.Info("DRY RUN MODE: No actual changes will be made (enabled via DRY_RUN config)") +} + +func runBaseDirSource(rt *appRuntime) string { + if rawBaseDir, ok := rt.cfg.Get("BASE_DIR"); ok && strings.TrimSpace(rawBaseDir) != "" { + return "configured in backup.env" + } + if rt.initialEnvBaseDir != "" { + return "from environment (BASE_DIR)" + } + if rt.autoBaseDirFound { + return "auto-detected from executable path" + } + return "default fallback" +} + +func runConfigPathSource(rt *appRuntime) string { + if rt.args.ConfigPathSource != "" { + return rt.args.ConfigPathSource + } + return "configured path" +} diff --git a/cmd/proxsave/main_security.go b/cmd/proxsave/main_security.go new file mode 100644 index 00000000..137a4544 --- /dev/null +++ b/cmd/proxsave/main_security.go @@ -0,0 +1,22 @@ +// Package main contains the proxsave command entrypoint. +package main + +import ( + "fmt" + + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/security" + "github.com/tis24dev/proxsave/internal/types" +) + +func runSecurityPreflight(rt *appRuntime) (int, bool) { + execInfo := getExecInfo() + execPath := execInfo.ExecPath + logging.DebugStep(rt.logger, "main", "running security checks") + if _, secErr := security.Run(rt.ctx, rt.logger, rt.cfg, rt.args.ConfigPath, execPath, rt.envInfo); secErr != nil { + logging.Error("Security checks failed: %v", secErr) + return types.ExitSecurityError.Int(), false + } + fmt.Println() + return types.ExitSuccess.Int(), true +} diff --git a/cmd/proxsave/main_signals.go b/cmd/proxsave/main_signals.go new file mode 100644 index 00000000..1274ba91 --- /dev/null +++ b/cmd/proxsave/main_signals.go @@ -0,0 +1,39 @@ +// Package main contains the proxsave command entrypoint. +package main + +import ( + "context" + "os" + "os/signal" + "sync" + "syscall" + + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/tui" +) + +func setupRunContext(bootstrap *logging.BootstrapLogger) (context.Context, context.CancelFunc) { + ctx, cancel := context.WithCancel(context.Background()) + tui.SetAbortContext(ctx) + + var closeStdinOnce sync.Once + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + go func() { + defer signal.Stop(sigChan) + select { + case sig := <-sigChan: + logging.DebugStepBootstrap(bootstrap, "signal", "received=%v", sig) + bootstrap.Info("\nReceived signal %v, initiating graceful shutdown...", sig) + cancel() + closeStdinOnce.Do(func() { + if file := os.Stdin; file != nil { + _ = file.Close() + } + }) + case <-ctx.Done(): + } + }() + + return ctx, cancel +} diff --git a/cmd/proxsave/main_state.go b/cmd/proxsave/main_state.go new file mode 100644 index 00000000..cef9c602 --- /dev/null +++ b/cmd/proxsave/main_state.go @@ -0,0 +1,79 @@ +// Package main contains the proxsave command entrypoint. +package main + +import ( + "context" + "os" + "time" + + "github.com/tis24dev/proxsave/internal/cli" + "github.com/tis24dev/proxsave/internal/config" + "github.com/tis24dev/proxsave/internal/environment" + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/orchestrator" + "github.com/tis24dev/proxsave/internal/types" +) + +type appRuntime struct { + ctx context.Context + args *cli.Args + bootstrap *logging.BootstrapLogger + deps appDeps + cfg *config.Config + logger *logging.Logger + envInfo *environment.EnvironmentInfo + unprivilegedInfo environment.UnprivilegedContainerInfo + updateInfo *UpdateInfo + toolVersion string + hostname string + startTime time.Time + timestampStr string + dryRun bool + logLevel types.LogLevel + initialEnvBaseDir string + autoBaseDirFound bool + sessionLogCloser func() + heapProfilePath string + cpuProfileFile *os.File + serverIDValue string + serverMACValue string +} + +type appRunState struct { + finalExitCode int + showSummary bool + orch *orchestrator.Orchestrator + earlyErrorState *orchestrator.EarlyErrorState + pendingSupportStat *orchestrator.BackupStats +} + +type modeResult struct { + orch *orchestrator.Orchestrator + earlyErrorState *orchestrator.EarlyErrorState + supportStats *orchestrator.BackupStats + exitCode int + handled bool +} + +type appDeps struct { + now func() time.Time +} + +func defaultAppDeps() appDeps { + return appDeps{now: time.Now} +} + +func newAppRunState() *appRunState { + return &appRunState{finalExitCode: types.ExitSuccess.Int()} +} + +func (state *appRunState) finalize(code int) int { + state.finalExitCode = code + return code +} + +func (state *appRunState) applyModeResult(result modeResult) { + state.orch = result.orch + state.earlyErrorState = result.earlyErrorState + state.pendingSupportStat = result.supportStats +} diff --git a/cmd/proxsave/main_support.go b/cmd/proxsave/main_support.go new file mode 100644 index 00000000..93b9acf3 --- /dev/null +++ b/cmd/proxsave/main_support.go @@ -0,0 +1,34 @@ +// Package main contains the proxsave command entrypoint. +package main + +import ( + "context" + + "github.com/tis24dev/proxsave/internal/cli" + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/support" + "github.com/tis24dev/proxsave/internal/types" +) + +func handleSupportIntro(ctx context.Context, args *cli.Args, bootstrap *logging.BootstrapLogger, state *appRunState) (int, bool) { + if !args.Support { + return types.ExitSuccess.Int(), false + } + + logging.DebugStepBootstrap(bootstrap, "main run", "mode=support") + meta, continueRun, interrupted := support.RunIntro(ctx, bootstrap) + if continueRun { + args.SupportGitHubUser = meta.GitHubUser + args.SupportIssueID = meta.IssueID + return types.ExitSuccess.Int(), false + } + + if interrupted { + state.finalize(exitCodeInterrupted) + printFinalSummary(state.finalExitCode) + return state.finalExitCode, true + } + state.finalize(types.ExitGenericError.Int()) + printFinalSummary(state.finalExitCode) + return state.finalExitCode, true +} diff --git a/cmd/proxsave/main_update.go b/cmd/proxsave/main_update.go new file mode 100644 index 00000000..68596e46 --- /dev/null +++ b/cmd/proxsave/main_update.go @@ -0,0 +1,134 @@ +// Package main contains the proxsave command entrypoint. +package main + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/tis24dev/proxsave/internal/logging" +) + +// UpdateInfo holds information about the version check result. +type UpdateInfo struct { + NewVersion bool + Current string + Latest string +} + +// checkForUpdates performs a best-effort check against the latest GitHub release. +// - If the latest version cannot be determined or the current version is already up to date, +// only a DEBUG log entry is written (no user-facing output). +// - If a newer version is available, a WARNING is logged suggesting the --upgrade command. +// Additionally, a populated *UpdateInfo is returned so that callers can propagate +// structured information into notifications/metrics. +func checkForUpdates(ctx context.Context, logger *logging.Logger, currentVersion string) *UpdateInfo { + if logger == nil { + return nil + } + + currentVersion = strings.TrimSpace(currentVersion) + if currentVersion == "" { + logger.Debug("Update check skipped: current version is empty") + return nil + } + if ctx == nil { + ctx = context.Background() + } + + checkCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + logger.Debug("Checking for ProxSave updates (current: %s)", currentVersion) + + apiURL := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", githubRepo) + logger.Debug("Fetching latest release from GitHub: %s", apiURL) + + _, latestVersion, err := fetchLatestRelease(checkCtx) + if err != nil { + logger.Debug("Update check skipped: GitHub unreachable: %v", err) + return &UpdateInfo{ + NewVersion: false, + Current: currentVersion, + } + } + + latestVersion = strings.TrimSpace(latestVersion) + if latestVersion == "" { + logger.Debug("Update check skipped: latest version from GitHub is empty") + return &UpdateInfo{ + NewVersion: false, + Current: currentVersion, + } + } + + if !isNewerVersion(currentVersion, latestVersion) { + logger.Debug("Update check completed: latest=%s current=%s (up to date)", latestVersion, currentVersion) + return &UpdateInfo{ + NewVersion: false, + Current: currentVersion, + Latest: latestVersion, + } + } + + logger.Debug("Update check completed: latest=%s current=%s (new version available)", latestVersion, currentVersion) + logger.Warning("New ProxSave version %s (current %s): run 'proxsave --upgrade' to install.", latestVersion, currentVersion) + + return &UpdateInfo{ + NewVersion: true, + Current: currentVersion, + Latest: latestVersion, + } +} + +// isNewerVersion returns true if latest is strictly newer than current. +// It compares MAJOR.MINOR.PATCH, ignores build metadata, and treats a stable +// release as newer than a prerelease with the same numeric version. +func isNewerVersion(current, latest string) bool { + parse := func(v string) (int, int, int, bool) { + v = strings.TrimSpace(v) + v = strings.TrimPrefix(v, "v") + if i := strings.IndexByte(v, '+'); i >= 0 { + v = v[:i] + } + hasPrerelease := false + if i := strings.IndexByte(v, '-'); i >= 0 { + hasPrerelease = true + v = v[:i] + } + + parts := strings.Split(v, ".") + toInt := func(s string) int { + n, _ := strconv.Atoi(s) + return n + } + + major, minor, patch := 0, 0, 0 + if len(parts) > 0 { + major = toInt(parts[0]) + } + if len(parts) > 1 { + minor = toInt(parts[1]) + } + if len(parts) > 2 { + patch = toInt(parts[2]) + } + return major, minor, patch, hasPrerelease + } + + curMaj, curMin, curPatch, curPrerelease := parse(current) + latMaj, latMin, latPatch, latPrerelease := parse(latest) + + if latMaj != curMaj { + return latMaj > curMaj + } + if latMin != curMin { + return latMin > curMin + } + if latPatch != curPatch { + return latPatch > curPatch + } + return curPrerelease && !latPrerelease +} diff --git a/cmd/proxsave/runtime_helpers.go b/cmd/proxsave/runtime_helpers.go index 0c8e8794..7a778d66 100644 --- a/cmd/proxsave/runtime_helpers.go +++ b/cmd/proxsave/runtime_helpers.go @@ -18,6 +18,7 @@ import ( "github.com/tis24dev/proxsave/internal/config" "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/safeexec" "github.com/tis24dev/proxsave/internal/storage" "github.com/tis24dev/proxsave/internal/types" "github.com/tis24dev/proxsave/pkg/utils" @@ -219,9 +220,15 @@ func logServerIdentityValues(serverID, mac string) { func resolveHostname() string { if path, err := exec.LookPath("hostname"); err == nil { - if out, err := exec.Command(path, "-f").Output(); err == nil { - if fqdn := strings.TrimSpace(string(out)); fqdn != "" { - return fqdn + cmdCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + cmd, cmdErr := safeexec.TrustedCommandContext(cmdCtx, path, "-f") + if cmdErr == nil { + if out, err := cmd.Output(); err == nil { + if fqdn := strings.TrimSpace(string(out)); fqdn != "" { + return fqdn + } } } } @@ -275,6 +282,9 @@ func detectFilesystemInfo(ctx context.Context, backend storage.Storage, path str return nil, err } logger.Debug("WARNING: %s filesystem detection failed: %v", backend.Name(), err) + if backend.Location() == storage.LocationCloud { + return nil, err + } return nil, nil } @@ -678,7 +688,10 @@ func migrateLegacyCronEntries(ctx context.Context, baseDir, execPath string, boo } readCron := func() (string, error) { - cmd := exec.CommandContext(ctx, "crontab", "-l") + cmd, err := safeexec.CommandContext(ctx, "crontab", "-l") + if err != nil { + return "", err + } output, err := cmd.CombinedOutput() if err != nil { lower := strings.ToLower(string(output)) @@ -691,7 +704,10 @@ func migrateLegacyCronEntries(ctx context.Context, baseDir, execPath string, boo } writeCron := func(content string) error { - cmd := exec.CommandContext(ctx, "crontab", "-") + cmd, err := safeexec.CommandContext(ctx, "crontab", "-") + if err != nil { + return err + } cmd.Stdin = strings.NewReader(content) output, err := cmd.CombinedOutput() if err != nil { diff --git a/cmd/proxsave/runtime_helpers_more_test.go b/cmd/proxsave/runtime_helpers_more_test.go index f71a813b..c7581281 100644 --- a/cmd/proxsave/runtime_helpers_more_test.go +++ b/cmd/proxsave/runtime_helpers_more_test.go @@ -161,6 +161,20 @@ func TestDetectFilesystemInfo(t *testing.T) { } }) + t.Run("cloud error is returned for caller diagnostics", func(t *testing.T) { + backend := &fakeStorageBackend{ + name: "cloud", + location: storage.LocationCloud, + enabled: true, + critical: false, + fsErr: errors.New("cloud detect failed"), + } + info, err := detectFilesystemInfo(ctx, backend, "remote:path", logger) + if err == nil || info != nil { + t.Fatalf("detectFilesystemInfo() = (%v,%v), want (nil,error)", info, err) + } + }) + t.Run("critical error is returned", func(t *testing.T) { backend := &fakeStorageBackend{ name: "primary", diff --git a/cmd/proxsave/upgrade.go b/cmd/proxsave/upgrade.go index 0aac1efb..b1bab7f6 100644 --- a/cmd/proxsave/upgrade.go +++ b/cmd/proxsave/upgrade.go @@ -14,7 +14,6 @@ import ( "io" "net/http" "os" - "os/exec" "path/filepath" "runtime" "strconv" @@ -25,6 +24,7 @@ import ( "github.com/tis24dev/proxsave/internal/config" "github.com/tis24dev/proxsave/internal/identity" "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/safeexec" "github.com/tis24dev/proxsave/internal/types" buildinfo "github.com/tis24dev/proxsave/internal/version" ) @@ -650,7 +650,10 @@ func upgradeConfigWithBinary(ctx context.Context, execPath, configPath string) ( return nil, fmt.Errorf("configuration path is empty") } - cmd := exec.CommandContext(ctx, execPath, "--config", configPath, "--upgrade-config-json") + cmd, err := safeexec.TrustedCommandContext(ctx, execPath, "--config", configPath, "--upgrade-config-json") + if err != nil { + return nil, err + } var stdout bytes.Buffer var stderr bytes.Buffer cmd.Stdout = &stdout diff --git a/cmd/proxsave/version_helpers_test.go b/cmd/proxsave/version_helpers_test.go index 2b14c64e..39399a8a 100644 --- a/cmd/proxsave/version_helpers_test.go +++ b/cmd/proxsave/version_helpers_test.go @@ -42,7 +42,10 @@ func TestIsNewerVersion(t *testing.T) { {"minor newer", "0.1.9", "0.2.0", true}, {"major newer", "1.9.9", "2.0.0", true}, {"strip leading v", "v1.2.3", "1.2.4", true}, - {"ignore prerelease", "1.2.3-rc1", "1.2.3", false}, + {"stable newer than prerelease", "1.2.3-rc1", "1.2.3", true}, + {"prerelease not newer than stable", "1.2.3", "1.2.3-rc1", false}, + {"ignore build metadata", "v1.2.3+current", "v1.2.4+latest", true}, + {"build metadata does not zero patch", "v1.2.3+current", "v1.2.3+latest", false}, {"missing patch treated as 0", "1.2", "1.2.0", false}, } diff --git a/docs/BACKUP_ENV_MAPPING.md b/docs/BACKUP_ENV_MAPPING.md index 001b21d6..24d5eb81 100644 --- a/docs/BACKUP_ENV_MAPPING.md +++ b/docs/BACKUP_ENV_MAPPING.md @@ -90,6 +90,7 @@ SYSTEM_ROOT_PREFIX = NEW (Go-only) → Override system root for collection (test PVESH_TIMEOUT = NEW (Go-only) → Timeout (seconds) for each `pvesh` command execution (0=disabled). FS_IO_TIMEOUT = NEW (Go-only) → Timeout (seconds) for filesystem probes (stat/readdir/statfs) on storages (0=disabled). Helps avoid hangs on unreachable network mounts. NOTE: PBS restore behavior is selected interactively during `--restore` and is intentionally not configured via `backup.env`. +NOTE: There is no dedicated `DUAL_*` environment family. Dual-role hosts are detected automatically and use the same PVE/PBS collector flags in a single run. BACKUP_PBS_S3_ENDPOINTS = NEW (Go-only) → Collect `s3.cfg` and S3 endpoint snapshots (PBS). BACKUP_PBS_NODE_CONFIG = NEW (Go-only) → Collect `node.cfg` and node snapshots (PBS). BACKUP_PBS_ACME_ACCOUNTS = NEW (Go-only) → Collect `acme/accounts.cfg` and ACME account snapshots (PBS). diff --git a/docs/CLI_REFERENCE.md b/docs/CLI_REFERENCE.md index 5ed74a5b..bfd14304 100644 --- a/docs/CLI_REFERENCE.md +++ b/docs/CLI_REFERENCE.md @@ -432,27 +432,36 @@ Next step: ./build/proxsave --dry-run **Use `--cli` when**: TUI rendering issues occur or advanced debugging is needed. **Note**: CLI and TUI run the same workflow logic; `--cli` only changes the interface (prompts/progress rendering), not the restore/decrypt behavior. -**`--restore` workflow** (14 phases): +**`--restore` workflow** (16 phases): 1. Scans configured storage locations (local/secondary/cloud) 2. Lists available backups with metadata (encrypted or unencrypted) 3. If encrypted, prompts for decryption key/passphrase and decrypts -4. Validates system compatibility (PVE/PBS mismatch warning) -5. Analyzes backup categories -6. Presents restore mode selection: - - **Full Restore**: All categories - - **Storage Restore**: PVE/PBS-specific configs - - **Base System Restore**: Network, SSH, system files - - **Custom Restore**: Select specific categories -7. For cluster backups: prompts for **SAFE** (export+API) or **RECOVERY** (full restore) mode -8. Shows detailed restore plan with selected categories -9. Requires confirmation: type `RESTORE` to proceed -10. Creates safety backup of existing files -11. Stops services if needed (PVE: pve-cluster, pvedaemon, pveproxy, pvestatd; PBS: proxmox-backup-proxy, proxmox-backup) -12. Extracts selected categories to system root (`/`) -13. Exports export-only categories to separate directory -14. For SAFE cluster mode: offers to apply configs via `pvesh` API -15. Recreates storage/datastore directories, checks ZFS pools -16. Restarts services and displays completion summary +4. Detects the current host role (`pve`, `pbs`, `dual`, or `unknown`) +5. Validates compatibility using capability overlap and backup targets + - exact match: proceed normally + - partial match: continue with warning, then filter categories automatically + - no overlap: warn strongly before continuing +6. Analyzes backup categories +7. Presents restore mode selection: + - **Full Restore**: all compatible categories + - **Storage Restore**: storage/datastore-focused categories + - **Base System Restore**: network, SSH, system files + - **Custom Restore**: select specific categories +8. For cluster backups: prompts for **SAFE** (export+API) or **RECOVERY** (full restore) mode +9. Shows detailed restore plan with selected categories +10. Requires confirmation: type `RESTORE` to proceed +11. Creates safety backup of existing files +12. Stops services if needed (PVE: pve-cluster, pvedaemon, pveproxy, pvestatd; PBS: proxmox-backup-proxy, proxmox-backup) +13. Extracts selected categories to system root (`/`) +14. Exports export-only categories to separate directory +15. For SAFE cluster mode: offers to apply configs via `pvesh` API +16. Recreates storage/datastore directories, checks ZFS pools, restarts services, and displays completion summary + +**Compatibility model**: +- `dual` backups persist explicit targets (`pve`, `pbs`) +- restoring a `dual` backup to a single-role host is allowed +- ProxSave restores only categories compatible with the current host role +- `common` categories remain available across roles **⚠️ WARNING**: Restore operations overwrite files in-place. **Always test in a VM or snapshot your system first!** diff --git a/docs/COLLECTOR_ARCHITECTURE.md b/docs/COLLECTOR_ARCHITECTURE.md new file mode 100644 index 00000000..630f7e4e --- /dev/null +++ b/docs/COLLECTOR_ARCHITECTURE.md @@ -0,0 +1,224 @@ +# Collector Architecture + +This document describes the current backup collector design after the refactor to +recipes and fine-grained bricks. + +## Goals + +The collector is designed around three principles: + +- explicit orchestration +- fine-grained collection bricks +- role-aware composition for `pve`, `pbs`, `dual`, and `common/system` + +The goal is to avoid hidden macro-flows and make each backup branch easy to +compose, test, and reuse. + +## Domain Model + +The collector recognizes four environment types: + +- `pve`: Proxmox VE only +- `pbs`: Proxmox Backup Server only +- `dual`: host supports both PVE and PBS roles +- `unknown`: only `system/common` collection is considered authoritative + +`dual` is a real domain type, not an alias. It is represented in +`internal/types/common.go` and propagated through detection, stats, manifest, +metadata, collector, and restore. + +## Authoritative Entrypoints + +Top-level collection flows live on the collector and are the only authoritative +entrypoints: + +- `CollectAll()` +- `CollectPVEConfigs()` +- `CollectPBSConfigs()` +- `CollectDualConfigs()` +- `CollectSystemInfo()` + +Internal legacy wrappers are not part of the architecture contract and should +not be reintroduced as hidden orchestration layers. + +## Recipes + +The collector runtime is built from explicit recipes in +`internal/backup/collector_bricks.go`. + +The important builders are: + +- `newPVERecipe()` +- `newPBSRecipe()` +- `newDualRecipe()` +- `newSystemRecipe()` + +### Composition Rules + +- `newPVERecipe()` = PVE-only bricks +- `newPBSRecipe()` = PBS-only bricks +- `newDualRecipe()` = PVE bricks + PBS bricks +- `newSystemRecipe()` = common/system bricks only + +`system/common` is executed once. It is not duplicated inside `dual`. + +## Bricks + +Each recipe is composed of `collectionBrick` items identified by a stable +`BrickID`. + +A brick should be one of: + +- a domain brick with clear ownership +- a technical brick with a narrow, explicit purpose + +Examples: + +- domain bricks: + - `pve_runtime_core` + - `pbs_runtime_access_users` + - `common_storage_stack_lvm` + - `system_network_runtime_routes` +- technical bricks: + - `pbs_config_directory_copy` + +`pbs_config_directory_copy` is intentionally technical: it preserves pass-through +snapshot behavior for `/etc/proxmox-backup`, including unmodeled files. + +## PVE Branch + +The PVE branch is split into: + +- validation and cluster detection +- config snapshots +- runtime data +- guest config and inventory +- jobs and schedules +- replication +- storage pipeline +- Ceph +- alias/finalize steps + +The storage pipeline is explicitly broken into resolve, probe, metadata, backup +analysis, and summary steps. + +## PBS Branch + +The PBS branch is split into: + +- validation +- config snapshot and manifest population +- runtime collection +- datastore discovery/config/namespaces +- PXAR pipeline +- datastore inventory +- final summary + +PBS access control, notifications, remotes, jobs, tape, datastore state, and +PXAR metadata are no longer handled by macro-wrappers. They are exposed as +independent recipe bricks. + +## Dual Branch + +`CollectDualConfigs()` runs `newDualRecipe()` and collects both product roles in +a single backup run. + +Important semantics: + +- a `dual` backup creates one archive +- metadata persists `BACKUP_TYPE=dual` +- metadata/manifest also persist `BACKUP_TARGETS=pve,pbs` +- `system/common` remains single-pass + +`dual` is therefore a composition of PVE + PBS payloads plus one shared +`common/system` payload, not a separate category namespace. + +## Common/System Ownership + +`storage_stack` now belongs to `common/system`, not PBS. + +The common storage stack is split into dedicated bricks such as: + +- `common_filesystem_fstab` +- `common_storage_stack_crypttab` +- `common_storage_stack_iscsi` +- `common_storage_stack_multipath` +- `common_storage_stack_mdadm` +- `common_storage_stack_lvm` +- `common_storage_stack_mount_units` +- `common_storage_stack_autofs` +- `common_storage_stack_referenced_files` + +PBS inventory still records storage-related diagnostics, but it no longer owns +the copied files in the backup tree. + +## State and Context + +Recipes share state through `collectionState` and role-specific contexts: + +- `pveContext` +- `pbsContext` +- `systemContext` + +This allows later bricks to consume facts gathered earlier without re-reading +the environment implicitly. + +Examples: + +- datastore discovery feeding namespaces and PXAR +- PBS user IDs feeding token aggregation +- inventory state feeding report generation + +## Manifest and Metadata + +Two layers are important: + +- collector manifest (`manifest.json`) written in the temp tree +- backup metadata/sidecars written by the orchestrator/archive flow + +Current persisted role fields include: + +- `ProxmoxType` +- `ProxmoxTargets` +- `PVEVersion` +- `PBSVersion` + +These fields are used later by restore for backup-type detection and category +filtering. + +## Restore Relationship + +The collector does not introduce a new restore category for `dual`. + +Restore still works with category types: + +- `PVE` +- `PBS` +- `Common` + +`dual` is reconstructed from metadata/manifest/targets and then filtered through +capability overlap: + +- `dual` host can restore `PVE + PBS + Common` +- `pve` host can restore `PVE + Common` from a `dual` backup +- `pbs` host can restore `PBS + Common` from a `dual` backup + +## Testing Policy + +Tests should target: + +- top-level real entrypoints for orchestration/integration +- recipes and bricks for feature-level behavior + +Tests should not depend on historical wrapper functions that are not part of the +real collector flow. + +## Related Files + +- `internal/backup/collector.go` +- `internal/backup/collector_bricks.go` +- `internal/backup/collector_dual.go` +- `internal/backup/collector_manifest.go` +- `internal/backup/collector_storage_stack_common.go` +- `internal/environment/detect.go` +- `internal/orchestrator/compatibility.go` diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index d9997b26..37d4df43 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -6,7 +6,7 @@ Complete reference for all 200+ configuration variables in `configs/backup.env`. - [Configuration File Location](#configuration-file-location) - [General Settings](#general-settings) -- [Restore (PBS)](#restore-pbs) +- [Restore Behavior & Dual-Role Hosts](#restore-behavior--dual-role-hosts) - [Security Settings](#security-settings) - [Disk Space](#disk-space) - [Storage Paths](#storage-paths) @@ -72,9 +72,10 @@ PROFILING_ENABLED=true # true | false (profiles written under LOG_PA --- -## Restore (PBS) +## Restore Behavior & Dual-Role Hosts -PBS restore behavior is chosen **interactively at restore time** on PBS hosts (not via `backup.env`). +Restore behavior is chosen **interactively at restore time** when PBS-specific +categories are going to be applied. This is not configured through `backup.env`. You will be asked to choose a behavior: - **Merge (existing PBS)**: intended for restoring onto an already operational PBS; ProxSave applies supported PBS categories via `proxmox-backup-manager` without deleting existing objects that are not in the backup. @@ -82,6 +83,23 @@ You will be asked to choose a behavior: ProxSave applies supported PBS staged categories via API automatically (and may fall back to file-based staged apply only in **Clean 1:1** mode). +### Dual-role hosts + +ProxSave automatically detects the current host role as one of: + +- `pve` +- `pbs` +- `dual` +- `unknown` + +There is no dedicated `backup.env` switch for `dual`. On a co-installed host, +`dual` is detected automatically and a single backup run can include both PVE +and PBS payloads plus one shared `common/system` payload. + +Dual backups persist explicit target metadata (`BACKUP_TYPE=dual`, +`BACKUP_TARGETS=pve,pbs`) and restore uses that metadata to filter categories +to the roles supported by the current host. + **Current API coverage**: - Node + traffic control (`pbs_host`) - Datastores + S3 endpoints (`datastore_pbs`) @@ -846,10 +864,12 @@ If `EMAIL_ENABLED` is omitted, the default remains `false`. The legacy alias `EM - Allowed values for `EMAIL_DELIVERY_METHOD` are: `relay`, `sendmail`, `pmf` (invalid values will skip Email with a warning). - `EMAIL_FALLBACK_SENDMAIL` is a historical name (kept for compatibility). When `EMAIL_DELIVERY_METHOD=relay`, it enables fallback to **pmf** (it will not fall back to `/usr/sbin/sendmail`). - `relay` requires a real mailbox recipient and blocks `root@…` recipients; set `EMAIL_RECIPIENT` to a non-root mailbox if needed. +- When relay preconditions fail before delivery starts (for example missing recipient, autodetect failure, or blocked `root@…` recipient) and fallback is enabled, ProxSave may bypass relay and invoke `pmf` directly. - When logs say the relay "accepted request", it means the worker and upstream email API accepted the submission. It does **not** guarantee final inbox delivery (the message may still bounce, be deferred, or land in spam later). - If `EMAIL_RECIPIENT` is empty, ProxSave auto-detects the recipient from the `root@pam` user: - **PVE**: Proxmox API via `pvesh get /access/users/root@pam` → fallback to `pveum user list` → fallback to `/etc/pve/user.cfg` - **PBS**: `proxmox-backup-manager user list` → fallback to `/etc/proxmox-backup/user.cfg` + - **Dual**: intentionally uses the **PVE** detection path for `root@pam` email discovery - `sendmail` requires a recipient and uses `/usr/sbin/sendmail` (auto-detect applies if `EMAIL_RECIPIENT` is empty, as described above). - With `pmf`, final delivery recipients are determined by Proxmox Notifications targets/matchers. `EMAIL_RECIPIENT` is only used for the `To:` header and may be empty. @@ -886,7 +906,7 @@ WEBHOOK_ENABLED=false # true | false WEBHOOK_ENDPOINTS= # e.g., "discord_alerts,teams_ops" # Default payload format -WEBHOOK_FORMAT=generic # discord | slack | teams | generic +WEBHOOK_FORMAT=generic # discord | slack | teams | generic | pushover # Request timeout (seconds) WEBHOOK_TIMEOUT=30 @@ -903,7 +923,7 @@ WEBHOOK_RETRY_DELAY=2 # Seconds between retries WEBHOOK_DISCORD_ALERTS_URL=https://discord.com/api/webhooks/XXXX/YYY # Payload format -WEBHOOK_DISCORD_ALERTS_FORMAT=discord # discord | slack | teams | generic +WEBHOOK_DISCORD_ALERTS_FORMAT=discord # discord | slack | teams | generic | pushover # HTTP method WEBHOOK_DISCORD_ALERTS_METHOD=POST # POST | GET | HEAD @@ -915,10 +935,13 @@ WEBHOOK_DISCORD_ALERTS_HEADERS="X-Custom-Token:abc123,X-Another:value" WEBHOOK_DISCORD_ALERTS_AUTH_TYPE=none # none | bearer | basic | hmac # Authentication credentials -WEBHOOK_DISCORD_ALERTS_AUTH_TOKEN= # Bearer token -WEBHOOK_DISCORD_ALERTS_AUTH_USER= # Basic auth username +WEBHOOK_DISCORD_ALERTS_AUTH_TOKEN= # Bearer token (or Pushover application token) +WEBHOOK_DISCORD_ALERTS_AUTH_USER= # Basic auth username (or Pushover user/group key) WEBHOOK_DISCORD_ALERTS_AUTH_PASS= # Basic auth password WEBHOOK_DISCORD_ALERTS_AUTH_SECRET= # HMAC secret key + +# Pushover-specific (only honored when FORMAT=pushover; default 0, range -2..1) +WEBHOOK_DISCORD_ALERTS_PRIORITY=0 ``` **Supported formats**: @@ -926,6 +949,7 @@ WEBHOOK_DISCORD_ALERTS_AUTH_SECRET= # HMAC secret key - **slack**: Slack incoming webhook format - **teams**: Microsoft Teams connector format - **generic**: Simple JSON `{"status": "...", "message": "..."}` +- **pushover**: [Pushover](https://pushover.net) push notifications. Reuses `AUTH_TOKEN` (application token) and `AUTH_USER` (user/group key); `AUTH_TYPE` stays `none` because Pushover takes credentials in the JSON body. Title is truncated to 250 characters and message to 1024 characters per Pushover's API limits. `PRIORITY` accepts -2..1 (default 0); emergency priority (2) is not supported. --- diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index d6c823b2..7dbde475 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -116,7 +116,7 @@ proxsave/ │ ├── tui/ # TUI wizards │ ├── types/ # Shared types │ └── version/ # Version info -├── pkg/ # Shared helper packages for ProxSave (not an implicit stable external API) +├── pkg/ # Shared helper packages for Proxsave (not an implicit stable external API) ├── build/ # Build artifacts (binary output) ├── configs/ # Configuration files ├── docs/ # Documentation @@ -130,15 +130,37 @@ proxsave/ | Module | Purpose | Files | |--------|---------|-------| -| **orchestrator** | Core backup/restore orchestration | `internal/orchestrator/*.go` | +| **orchestrator** | Core backup/restore orchestration and capability-based restore decisions | `internal/orchestrator/*.go` | | **config** | Configuration management | `internal/config/config.go` | | **storage** | Local/secondary/cloud storage | `internal/storage/*.go` | -| **backup** | Archiving + manifest/checksum helpers | `internal/backup/*.go` | +| **backup** | Collector recipes/bricks, archiving, manifest/checksum helpers | `internal/backup/*.go` | | **notify** | Notification channels | `internal/notify/*.go` | | **security** | Security checks, permissions | `internal/security/*.go` | --- +### Collector Architecture + +The backup collector is no longer organized around large branch-specific +wrappers. It is built from explicit recipes and fine-grained bricks: + +- `newPVERecipe()` +- `newPBSRecipe()` +- `newDualRecipe()` +- `newSystemRecipe()` + +Important invariants: + +- `dual` is a real type, not an alias +- `dual` composes PVE + PBS bricks in a single run +- `system/common` runs only once +- `storage_stack` belongs to `common/system`, not PBS + +For the authoritative architecture description, see +[Collector Architecture](COLLECTOR_ARCHITECTURE.md). + +--- + ## Building & Running ### Development Build @@ -643,11 +665,13 @@ rclone check /local/dir/ gdrive:pbs-backups/ --checksum ## Related Documentation ### User Documentation -- **[README](../README.md)** - Project overview and quick start +- **[Docs Index](README.md)** - Documentation hub for the `docs/` tree - **[Configuration Guide](CONFIGURATION.md)** - All configuration variables - **[CLI Reference](CLI_REFERENCE.md)** - Command-line flags ### Contributor Documentation +- **[Collector Architecture](COLLECTOR_ARCHITECTURE.md)** - Collector recipes, bricks, and `dual` +- **[Restore Technical](RESTORE_TECHNICAL.md)** - Restore internals and compatibility flow - **[Migration Guide](MIGRATION_GUIDE.md)** - Bash to Go migration - **[Troubleshooting](TROUBLESHOOTING.md)** - Common issues @@ -687,7 +711,7 @@ Testing: Documentation: □ Update relevant docs/*.md files □ Add usage examples -□ Update README.md if needed +□ Update docs/README.md and architecture docs if navigation changes □ Write clear commit messages Before Submitting PR: diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md index 3f8a6eb2..61b2bdae 100644 --- a/docs/EXAMPLES.md +++ b/docs/EXAMPLES.md @@ -14,6 +14,7 @@ Real-world configuration examples for Proxsave covering common deployment scenar - [Example 7: Multi-Notification Setup](#example-7-multi-notification-setup) - [Example 8: Complete Production Setup](#example-8-complete-production-setup) - [Example 9: Test in a Chroot/Fixture](#example-9-test-in-a-chrootfixture) +- [Example 10: Dual PVE+PBS Host](#example-10-dual-pvepbs-host) - [Related Documentation](#related-documentation) --- @@ -503,7 +504,7 @@ crontab -e ## Example 7: Multi-Notification Setup -**Scenario**: Telegram + Email + Webhook (Discord) notifications. +**Scenario**: Telegram + Email + Webhook (Discord + Pushover) notifications. **Use case**: - Multiple notification channels @@ -528,16 +529,26 @@ EMAIL_DELIVERY_METHOD=relay EMAIL_RECIPIENT=admin@example.com EMAIL_FROM=noreply@proxmox.example.com -# Webhook (Discord) +# Webhook (Discord + Pushover) WEBHOOK_ENABLED=true -WEBHOOK_ENDPOINTS=discord_alerts +WEBHOOK_ENDPOINTS=discord_alerts,pushover WEBHOOK_DISCORD_ALERTS_URL=https://discord.com/api/webhooks/XXXX/YYYY WEBHOOK_DISCORD_ALERTS_FORMAT=discord WEBHOOK_DISCORD_ALERTS_METHOD=POST +# Pushover (push notifications to phone/desktop). Token + user key go in the +# JSON body, so AUTH_TYPE stays "none". PRIORITY accepts -2..1 (default 0). +WEBHOOK_PUSHOVER_URL=https://api.pushover.net/1/messages.json +WEBHOOK_PUSHOVER_FORMAT=pushover +WEBHOOK_PUSHOVER_METHOD=POST +WEBHOOK_PUSHOVER_AUTH_TYPE=none +WEBHOOK_PUSHOVER_AUTH_TOKEN= +WEBHOOK_PUSHOVER_AUTH_USER= +WEBHOOK_PUSHOVER_PRIORITY=0 + # Run backup ./build/proxsave -# Result: Notifications sent to Telegram, Email, and Discord +# Result: Notifications sent to Telegram, Email, Discord, and Pushover ``` ### Setup Steps @@ -591,11 +602,13 @@ printf "To: root\nSubject: proxsave test\n\nHello from proxsave\n" | sudo /usr/l - ✅ Telegram message with summary - ✅ Email with detailed report - ✅ Discord embed with stats +- ✅ Pushover push notification **On failure**: - ❌ Telegram alert with error - ❌ Email with failure details - ❌ Discord mention with logs +- ❌ Pushover push notification --- @@ -888,6 +901,71 @@ SYSTEM_ROOT_PREFIX=/mnt/snapshot-root ./build/proxsave --- +## Example 10: Dual PVE+PBS Host + +**Scenario**: A single node runs both Proxmox VE and Proxmox Backup Server. + +**Use case**: +- Lab or edge node with co-installed PVE + PBS +- Single backup run should include both product roles +- Restore must remain compatible with `dual`, `pve`, or `pbs` targets + +### Configuration + +```bash +# configs/backup.env +BACKUP_ENABLED=true +BACKUP_PATH=/opt/proxsave/backup +LOG_PATH=/opt/proxsave/log + +# Common/system collection +BACKUP_NETWORK_CONFIGS=true +BACKUP_CRON_JOBS=true +BACKUP_SYSTEMD_SERVICES=true +BACKUP_ZFS_CONFIG=true + +# PVE collection +BACKUP_VM_CONFIGS=true +BACKUP_CLUSTER_CONFIG=true +BACKUP_PVE_JOBS=true +BACKUP_PVE_REPLICATION=true +BACKUP_PVE_FIREWALL=true + +# PBS collection +BACKUP_DATASTORE_CONFIGS=true +BACKUP_REMOTE_CONFIGS=true +BACKUP_SYNC_JOBS=true +BACKUP_VERIFICATION_JOBS=true +BACKUP_PBS_NOTIFICATIONS=true +BACKUP_PBS_NODE_CONFIG=true + +# Recommended for dual labs: keep diagnostics +PXAR_SCAN_ENABLE=true +``` + +### Expected Behavior + +- ProxSave auto-detects the host as `dual` +- One archive is produced for the run +- Metadata persists: + - `BACKUP_TYPE=dual` + - `BACKUP_TARGETS=pve,pbs` +- The backup contains: + - PVE categories + - PBS categories + - one shared `common/system` payload + +### Restore Notes + +- Restore on a `dual` host: full `PVE + PBS + Common` +- Restore on a `pve` host: `PVE + Common` +- Restore on a `pbs` host: `PBS + Common` + +The restore workflow filters categories automatically when the current host does +not support all backup targets. + +--- + ## Next Steps 1. **Choose an example** closest to your use case diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..774ddd27 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,33 @@ +# Proxsave Documentation Index + +This directory contains the authoritative project documentation. + +The repository root `README.md` intentionally remains minimal. Use the documents +below for the current operational and technical behavior. + +## User Guides + +- [INSTALL.md](INSTALL.md): installation, reinstall, and upgrade flows +- [CONFIGURATION.md](CONFIGURATION.md): complete `backup.env` reference +- [CLI_REFERENCE.md](CLI_REFERENCE.md): commands, flags, and workflow phases +- [EXAMPLES.md](EXAMPLES.md): ready-to-use configuration examples +- [RESTORE_GUIDE.md](RESTORE_GUIDE.md): full restore guide and category behavior +- [TROUBLESHOOTING.md](TROUBLESHOOTING.md): operational diagnostics and fixes + +## Architecture & Developer Docs + +- [DEVELOPER_GUIDE.md](DEVELOPER_GUIDE.md): contributor setup and development workflow +- [COLLECTOR_ARCHITECTURE.md](COLLECTOR_ARCHITECTURE.md): collector recipes, bricks, and `dual` +- [RESTORE_TECHNICAL.md](RESTORE_TECHNICAL.md): restore internals and orchestration details +- [RESTORE_DIAGRAMS.md](RESTORE_DIAGRAMS.md): visual restore workflow diagrams + +## Supporting References + +- [BACKUP_ENV_MAPPING.md](BACKUP_ENV_MAPPING.md): legacy Bash to Go env mapping +- [CLOUD_STORAGE.md](CLOUD_STORAGE.md): cloud/rclone behavior +- [ENCRYPTION.md](ENCRYPTION.md): archive encryption and decrypt/restore flow +- [PROVENANCE_VERIFICATION.md](PROVENANCE_VERIFICATION.md): attestation verification +- [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md): migration from the Bash implementation +- [LEGACY_BASH.md](LEGACY_BASH.md): legacy Bash notes and compatibility +- [CLUSTER_RECOVERY.md](CLUSTER_RECOVERY.md): PVE cluster disaster recovery +- [RELEASE-PROCESS.md](RELEASE-PROCESS.md): release engineering notes diff --git a/docs/RESTORE_DIAGRAMS.md b/docs/RESTORE_DIAGRAMS.md index 3dca90ab..745780f5 100644 --- a/docs/RESTORE_DIAGRAMS.md +++ b/docs/RESTORE_DIAGRAMS.md @@ -1,6 +1,9 @@ # Restore Workflow Diagrams Visual diagrams for understanding the restore system architecture and flow. +Use this file as a visual companion to [RESTORE_GUIDE.md](RESTORE_GUIDE.md) and +[RESTORE_TECHNICAL.md](RESTORE_TECHNICAL.md), not as the primary place for +textual restore rules. ## Table of Contents @@ -100,11 +103,13 @@ flowchart TD Full --> SystemFull{System Type?} SystemFull -->|PVE| PVEFull[PVE Categories:
- pve_cluster
- storage_pve
- pve_jobs
- pve_notifications
- pve_access_control
- pve_firewall
- pve_ha
- pve_sdn
- corosync
- ceph
+ Common] SystemFull -->|PBS| PBSFull[PBS Categories:
- pbs_host
- datastore_pbs
- maintenance_pbs
- pbs_jobs
- pbs_remotes
- pbs_notifications
- pbs_access_control
- pbs_tape
+ Common] + SystemFull -->|DUAL| DualFull[Dual Categories:
- PVE categories
- PBS categories
- Common categories] SystemFull -->|Unknown| CommonFull[Common Only:
- filesystem
- storage_stack
- network
- ssl
- ssh
- scripts
- crontabs
- services
- user_data
- zfs
- proxsave_info] Storage --> SystemStorage{System Type?} SystemStorage -->|PVE| PVEStorage[- pve_cluster
- storage_pve
- pve_jobs
- filesystem
- storage_stack
- zfs] SystemStorage -->|PBS| PBSStorage[- datastore_pbs
- maintenance_pbs
- pbs_jobs
- pbs_remotes
- filesystem
- storage_stack
- zfs] + SystemStorage -->|DUAL| DualStorage[- PVE storage categories
- PBS storage categories
- filesystem
- storage_stack
- zfs] Base --> BaseCats[- network
- ssl
- ssh
- services
- filesystem] @@ -456,14 +461,17 @@ flowchart TD CheckSystem -->|PVE| FilterPVE[Filter Categories] CheckSystem -->|PBS| FilterPBS[Filter Categories] + CheckSystem -->|DUAL| FilterDual[Filter Categories] CheckSystem -->|Unknown| FilterCommon[Filter Categories] FilterPVE --> IncludePVE["Include:
- CategoryTypePVE
- CategoryTypeCommon"] FilterPBS --> IncludePBS["Include:
- CategoryTypePBS
- CategoryTypeCommon"] + FilterDual --> IncludeDual["Include:
- CategoryTypePVE
- CategoryTypePBS
- CategoryTypeCommon"] FilterCommon --> IncludeOnlyCommon["Include:
- CategoryTypeCommon only"] IncludePVE --> CheckMode{Restore Mode?} IncludePBS --> CheckMode + IncludeDual --> CheckMode IncludeOnlyCommon --> CheckMode CheckMode -->|Full/Storage/Base| RemoveExport[Remove ExportOnly = true] @@ -629,41 +637,48 @@ flowchart TD ```mermaid flowchart TD Start([Backup Prepared]) --> DetectCurrent[Detect Current System] - DetectCurrent --> CheckPVE{"/etc/pve exists
AND /usr/bin/qm exists?"} + DetectCurrent --> CheckSystem{PVE indicators?
PBS indicators?} - CheckPVE -->|Yes| CurrentPVE[Current: PVE] - CheckPVE -->|No| CheckPBS{"/etc/proxmox-backup exists
AND /usr/sbin/proxmox-backup-proxy?"} - - CheckPBS -->|Yes| CurrentPBS[Current: PBS] - CheckPBS -->|No| CurrentUnknown[Current: Unknown] + CheckSystem -->|PVE only| CurrentPVE[Current: PVE] + CheckSystem -->|PBS only| CurrentPBS[Current: PBS] + CheckSystem -->|Both| CurrentDual[Current: DUAL] + CheckSystem -->|Neither| CurrentUnknown[Current: Unknown] CurrentPVE --> ReadManifest CurrentPBS --> ReadManifest + CurrentDual --> ReadManifest CurrentUnknown --> ReadManifest[Read Backup Manifest] - ReadManifest --> CheckBackupType{manifest.ProxmoxType
OR hostname pattern} + ReadManifest --> CheckBackupType{manifest.ProxmoxTargets
or ProxmoxType
or hostname pattern} CheckBackupType -->|pve| BackupPVE[Backup: PVE] CheckBackupType -->|pbs| BackupPBS[Backup: PBS] + CheckBackupType -->|dual| BackupDual[Backup: DUAL] CheckBackupType -->|Unknown| BackupUnknown[Backup: Unknown] BackupPVE --> Compare BackupPBS --> Compare - BackupUnknown --> Compare[Compare Types] - - Compare --> Match{Current == Backup?} - Match -->|Yes| Compatible([Compatible]) - Match -->|No| CheckUnknown{Either Unknown?} - - CheckUnknown -->|Yes| Compatible - CheckUnknown -->|No| Incompatible[Incompatible] - - Incompatible --> DisplayWarning["Display Warning:
PVE ↔ PBS mismatch"] + BackupDual --> Compare + BackupUnknown --> Compare[Compare Capability Sets] + + Compare --> SharedRole{Any shared role?} + SharedRole -->|Yes| ExactMatch{Same role set?} + ExactMatch -->|Yes| Compatible([Full Compatibility]) + ExactMatch -->|No| Partial[Partial Compatibility] + Partial --> Filter["Warn user and filter to
supported categories"] + Filter --> ProceedAnyway([Proceed with Warning]) + + SharedRole -->|No| CheckUnknown{Either side unknown?} + CheckUnknown -->|Yes| WarnUnknown["Warn: compatibility
cannot be fully verified"] + WarnUnknown --> Proceed([Proceed]) + CheckUnknown -->|No| Incompatible["No overlapping role"] + + Incompatible --> DisplayWarning["Display Warning:
backup and host roles differ"] DisplayWarning --> AskOverride{Type 'yes'
to continue?} AskOverride -->|No| Abort([Abort]) - AskOverride -->|Yes| ProceedAnyway([Proceed with Warning]) + AskOverride -->|Yes| ProceedAnyway - Compatible --> Proceed([Proceed]) + Compatible --> Proceed style Start fill:#87CEEB style Proceed fill:#90EE90 diff --git a/docs/RESTORE_GUIDE.md b/docs/RESTORE_GUIDE.md index 53312fb9..05bc7172 100644 --- a/docs/RESTORE_GUIDE.md +++ b/docs/RESTORE_GUIDE.md @@ -1,6 +1,7 @@ # Proxsave - Restore Guide -Complete guide for restoring Proxmox VE and Proxmox Backup Server configurations using the interactive restore workflow. +Complete guide for restoring Proxmox VE, Proxmox Backup Server, and dual-role +PVE+PBS backups using the interactive restore workflow. ## Table of Contents @@ -50,6 +51,14 @@ Complete guide for restoring Proxmox VE and Proxmox Backup Server configurations The `--restore` command provides an **interactive, category-based restoration system** that allows selective or full restoration of Proxmox configuration files from backup archives. +### How to Use the Restore Docs + +The restore documentation is split on purpose: + +- [RESTORE_GUIDE.md](RESTORE_GUIDE.md): operator workflow, modes, warnings, and practical examples +- [RESTORE_TECHNICAL.md](RESTORE_TECHNICAL.md): implementation details, detection logic, and internal architecture +- [RESTORE_DIAGRAMS.md](RESTORE_DIAGRAMS.md): visual companion for the main workflow and decision paths + ### Key Features - **Category-based selection**: Granular control over what gets restored @@ -60,6 +69,32 @@ The `--restore` command provides an **interactive, category-based restoration sy - **Export-only protection**: Critical paths protected from direct writes - **Comprehensive logging**: Detailed audit trail of all operations +### System Types and Compatibility + +Restore decisions are now based on four host types: + +- `pve` +- `pbs` +- `dual` +- `unknown` + +Backups also persist explicit target roles. This means compatibility is no +longer a simple exact match: + +- **Full compatibility**: current host and backup targets match exactly +- **Partial compatibility**: backup and host share at least one role +- **Incompatible**: backup and host share no role + +Examples: + +- `dual` backup on `dual` host: restore `PVE + PBS + Common` +- `dual` backup on `pve` host: restore `PVE + Common` +- `dual` backup on `pbs` host: restore `PBS + Common` +- `pve` backup on `dual` host: restore `PVE + Common` + +`unknown` hosts can still use export-oriented or common-only workflows, but +ProxSave warns because role-specific compatibility cannot be verified. + ### What Gets Restored - System configurations (network, SSH, SSL, services) @@ -204,6 +239,10 @@ Four predefined modes provide common restoration scenarios, plus custom selectio - **Normal** categories are restored to system paths - **Staged** categories are extracted under `/tmp/proxsave/restore-stage-*` and applied automatically (API/file apply) - **Export-only** categories (e.g. `pve_config_export`, `pbs_config`) are extracted to the export directory for manual review/application +- On a `dual` host, FULL restore can include PVE, PBS, and Common categories in + the same run +- On a single-role host restoring a `dual` backup, ProxSave automatically + filters the FULL selection to compatible categories **Command Flow**: ``` @@ -239,6 +278,10 @@ Select restore mode: - `storage_stack` - Storage stack config (mount prerequisites) - `zfs` - ZFS configuration +**Dual hosts**: +- STORAGE mode on a `dual` host includes the compatible storage-focused + categories from both product roles plus the common storage categories + **Command Flow**: ``` Select restore mode: @@ -312,7 +355,10 @@ Your selection: c # Continue to restore plan ## Complete Workflow -The restore process follows a **14-phase workflow** with safety checks at each step. +The restore process follows a phased workflow with safety checks at each step. +This section stays operator-focused. Internal decision rules and code-level +behavior live in [RESTORE_TECHNICAL.md](RESTORE_TECHNICAL.md), while the visual +flow lives in [RESTORE_DIAGRAMS.md](RESTORE_DIAGRAMS.md). ### Workflow Diagram @@ -334,10 +380,10 @@ Phase 2: Decryption (if needed) └─ Verify SHA256 checksum Phase 3: Compatibility Check - ├─ Detect current system type (PVE/PBS/Unknown) + ├─ Detect current system type (PVE/PBS/DUAL/Unknown) ├─ Read backup type from manifest - ├─ Validate compatibility - └─ Warn if mismatch, require confirmation + ├─ Validate compatibility (exact / partial / incompatible) + └─ Filter to compatible categories when needed Phase 4: Category Analysis ├─ Open and scan archive @@ -402,7 +448,7 @@ Phase 13: SAFE Apply (Cluster SAFE Mode Only) Phase 14: Post-Restore Tasks ├─ Optional: Apply restored network config with rollback timer (requires COMMIT) ├─ Recreate storage/datastore directories - ├─ Check ZFS pool status (PBS only) + ├─ Check ZFS pool status when the `zfs` category was restored (including dual hosts) ├─ Restart PVE/PBS services (if stopped) └─ Display completion summary ``` @@ -463,6 +509,18 @@ Backup system type: Proxmox Virtual Environment (PVE) ✓ Systems are compatible ``` +**Partial-compatibility warning**: +```text +⚠ WARNING: Partial compatibility detected + +Current system: Proxmox Virtual Environment (PVE) +Backup source: Proxmox VE + Proxmox Backup Server (DUAL) + +ProxSave will continue with the categories compatible with the current host: +- PVE categories +- Common categories +``` + **Incompatibility warning**: ``` ⚠ WARNING: Potential incompatibility detected! @@ -476,6 +534,10 @@ compatible with PBS. Proceeding may result in system instability. Type "yes" to continue anyway or "no" to abort: ``` +This guide intentionally shows the operator-facing outcomes only. The exact +metadata precedence, host detection order, and capability-overlap rules are +documented in [RESTORE_TECHNICAL.md](RESTORE_TECHNICAL.md#phase-3-system-detection--compatibility). + #### Phase 6: Cluster Restore Mode (PVE Cluster Backups Only) **This phase is SKIPPED for standalone backups** - the workflow proceeds directly to Phase 7. @@ -1781,18 +1843,17 @@ Current auto-skip prompts: ### 3. Compatibility Validation -**System Type Detection**: -``` -Current system: Proxmox Virtual Environment (PVE) -Backup source: Proxmox Virtual Environment (PVE) -✓ Compatible -``` +Compatibility is evaluated with the same `pve | pbs | dual | unknown` model +described in [System Types and Compatibility](#system-types-and-compatibility). -**Incompatibility Warning**: -``` -⚠ WARNING: Potential incompatibility detected! -Type "yes" to continue anyway or "no" to abort: _ -``` +Operator-visible behavior is: +- exact role match: proceed normally +- partial overlap: continue with warnings and automatic category filtering +- no overlap: warn before continuing +- unknown: warn because role-specific validation is incomplete + +For the internal precedence rules and implementation path, see +[RESTORE_TECHNICAL.md](RESTORE_TECHNICAL.md#phase-3-system-detection--compatibility). ### 4. Network Safe Apply (Optional) @@ -2438,7 +2499,17 @@ systemctl restart proxmox-backup proxmox-backup-proxy **Q: Can I restore PVE backup to PBS system (or vice versa)?** -A: Not recommended. The restore workflow will warn about incompatibility. PVE and PBS have different configurations that are not interchangeable. However, **common categories** (network, SSH, SSL) can be safely restored cross-platform using Custom mode. +A: Direct cross-role restore is still not recommended. PVE and PBS have +different role-specific configurations. However, ProxSave now evaluates +compatibility by **role overlap**: + +- `pve` ↔ `pbs`: only common categories are sensible +- `dual` → `pve`: PVE + Common can be restored +- `dual` → `pbs`: PBS + Common can be restored +- `pve` or `pbs` → `dual`: the matching role + Common can be restored + +When overlap exists, ProxSave continues with warnings and automatically filters +the selected categories to the roles supported by the current host. --- @@ -2464,6 +2535,8 @@ Typical full restore: **5-15 minutes** A: Yes, with considerations: - **Same system type** (PVE to PVE, PBS to PBS) recommended +- **Dual-role to single-role** restores are allowed, but only matching role + categories plus Common are applied - **Hostname** should match or be updated manually - **Network configuration** may need adjustment - **Storage paths** may need adjustment diff --git a/docs/RESTORE_TECHNICAL.md b/docs/RESTORE_TECHNICAL.md index b862e377..5dbc7158 100644 --- a/docs/RESTORE_TECHNICAL.md +++ b/docs/RESTORE_TECHNICAL.md @@ -18,6 +18,11 @@ Technical architecture and implementation details for the restore system. ## Architecture Overview +This document is the implementation-oriented companion to +[RESTORE_GUIDE.md](RESTORE_GUIDE.md). Use the guide for operator behavior and +examples; use this file for internal restore logic, module responsibilities, +and decision flow details. + ### Design Principles 1. **Safety First**: Multiple layers of protection against data loss @@ -424,58 +429,63 @@ type PreparedBackup struct { **File**: `internal/orchestrator/restore.go:58-72` +This section is the technical source of truth for restore compatibility. +User-facing examples and warning text live in +[RESTORE_GUIDE.md](RESTORE_GUIDE.md#phase-3-compatibility-check). + ```go -systemType := DetectSystemType(logger) -logger.Info("Current system type: %s", systemType) +systemType := DetectCurrentSystem() +backupType := DetectBackupType(prepared.Manifest) -if err := ValidateCompatibility(systemType, prepared.Manifest, reader); err != nil { +if err := ValidateCompatibility(systemType, backupType); err != nil { logger.Warning("Compatibility check: %v", err) - // Prompt user to continue or abort + // Continue with warning or abort depending on workflow context } ``` -**System Detection** (`compatibility.go:21-33`): +**System Detection** (`compatibility.go`): ```go -func DetectSystemType(logger *logging.Logger) SystemType { - // Check for PVE indicators - if _, err := os.Stat("/etc/pve"); err == nil { - if _, err := os.Stat("/usr/bin/qm"); err == nil { - return SystemTypePVE - } - } +func DetectCurrentSystem() SystemType { + hasPVE := fileExists("/etc/pve") || fileExists("/usr/bin/qm") || fileExists("/usr/bin/pct") + hasPBS := fileExists("/etc/proxmox-backup") || fileExists("/usr/sbin/proxmox-backup-proxy") - // Check for PBS indicators - if _, err := os.Stat("/etc/proxmox-backup"); err == nil { - if _, err := os.Stat("/usr/sbin/proxmox-backup-proxy"); err == nil { - return SystemTypePBS - } + switch { + case hasPVE && hasPBS: + return SystemTypeDual + case hasPVE: + return SystemTypePVE + case hasPBS: + return SystemTypePBS + default: + return SystemTypeUnknown } - - return SystemTypeUnknown } ``` -**Compatibility Check** (`compatibility.go:67-97`): +Restore compatibility is therefore **capability-based**, not exact-match only. + +**Backup Type Detection**: ```go -func ValidateCompatibility( - systemType SystemType, - manifest *Manifest, - reader *bufio.Reader, -) error { - backupType := DetermineBackupSystemType(manifest) - - if systemType != SystemTypeUnknown && - backupType != SystemTypeUnknown && - systemType != backupType { - // Prompt user: Type "yes" to continue - if !getUserConfirmation(reader, "yes") { - return ErrRestoreAborted - } +func DetectBackupType(manifest *backup.Manifest) SystemType { + if len(manifest.ProxmoxTargets) > 0 { + return parseSystemTargets(manifest.ProxmoxTargets) } - return nil + if manifest.ProxmoxType != "" { + return parseSystemTypeString(manifest.ProxmoxType) + } + // Fallback: hostname heuristics + return SystemTypeUnknown } ``` +**Compatibility Check**: +- **incompatible**: no shared role between backup and current host +- **partial compatibility**: shared role exists, but backup and host are not identical +- **full compatibility**: same role set + +When compatibility is partial, restore continues with warnings and later filters +the category set to the roles supported by the current host. + --- #### Phase 4: Category Analysis diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 4290e987..30e789c0 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -568,8 +568,9 @@ If Email is enabled but you don't see it being dispatched, ensure `EMAIL_DELIVER - Recipient auto-detection details (when `EMAIL_RECIPIENT` is empty): - **PVE**: `pvesh get /access/users/root@pam` → fallback to `pveum user list` → fallback to `/etc/pve/user.cfg` - **PBS**: `proxmox-backup-manager user list` → fallback to `/etc/proxmox-backup/user.cfg` + - **Dual**: intentionally reuses the **PVE** path for `root@pam` email discovery - Relay blocks `root@…` recipients; use a real non-root mailbox for `EMAIL_RECIPIENT`. -- If `EMAIL_FALLBACK_SENDMAIL=true`, ProxSave will fall back to `EMAIL_DELIVERY_METHOD=pmf` when the relay fails. +- If `EMAIL_FALLBACK_SENDMAIL=true`, ProxSave will fall back to `EMAIL_DELIVERY_METHOD=pmf` when the relay fails. If relay cannot even start because recipient resolution/preconditions fail, ProxSave can bypass relay and invoke the PMF fallback directly. - Check the proxsave logs for `email-relay` warnings/errors. - `Email relay accepted request ...` means the relay accepted the submission. It does **not** guarantee final inbox delivery; later provider-side failures/bounces are outside the ProxSave process. diff --git a/go.mod b/go.mod index 21b4cdd9..8bc70ffe 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ toolchain go1.25.9 require ( filippo.io/age v1.3.1 - github.com/gdamore/tcell/v2 v2.13.8 + github.com/gdamore/tcell/v2 v2.13.9 github.com/rivo/tview v0.42.0 golang.org/x/crypto v0.50.0 golang.org/x/term v0.42.0 diff --git a/go.sum b/go.sum index ac725be0..ca48a79f 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,8 @@ filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A= filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= -github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU= -github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= +github.com/gdamore/tcell/v2 v2.13.9 h1:uI5l3DYPcFvHINKlGft+en23evOKL+dwtD21QR8ejVA= +github.com/gdamore/tcell/v2 v2.13.9/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c= diff --git a/internal/backup/archiver.go b/internal/backup/archiver.go index 5a1c2333..b63d7826 100644 --- a/internal/backup/archiver.go +++ b/internal/backup/archiver.go @@ -16,6 +16,7 @@ import ( "filippo.io/age" "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/safeexec" "github.com/tis24dev/proxsave/internal/types" ) @@ -24,13 +25,13 @@ var lookPath = exec.LookPath // ArchiverDeps groups external dependencies used by Archiver. type ArchiverDeps struct { LookPath func(string) (string, error) - CommandContext func(context.Context, string, ...string) *exec.Cmd + CommandContext func(context.Context, string, ...string) (*exec.Cmd, error) } func defaultArchiverDeps() ArchiverDeps { return ArchiverDeps{ LookPath: lookPath, - CommandContext: exec.CommandContext, + CommandContext: safeexec.CommandContext, } } @@ -156,11 +157,11 @@ func NewArchiver(logger *logging.Logger, config *ArchiverConfig) *Archiver { } } -func (a *Archiver) cmd(ctx context.Context, name string, args ...string) *exec.Cmd { +func (a *Archiver) cmd(ctx context.Context, name string, args ...string) (*exec.Cmd, error) { if a.deps.CommandContext != nil { return a.deps.CommandContext(ctx, name, args...) } - return exec.CommandContext(ctx, name, args...) + return safeexec.CommandContext(ctx, name, args...) } func (a *Archiver) findPath(name string) (string, error) { @@ -476,7 +477,10 @@ func (a *Archiver) createGzipArchive(ctx context.Context, sourceDir, outputPath func (a *Archiver) createPigzArchive(ctx context.Context, sourceDir, outputPath string) error { a.logger.Debug("Creating pigz archive with level %d (mode %s)", a.compressionLevel, a.CompressionMode()) args := buildPigzArgs(a.compressionLevel, a.compressionThreads, a.CompressionMode()) - cmd := a.cmd(ctx, "pigz", args...) + cmd, err := a.cmd(ctx, "pigz", args...) + if err != nil { + return err + } return a.pipeTarThroughCommand(ctx, sourceDir, outputPath, cmd, "pigz") } @@ -516,18 +520,25 @@ func (a *Archiver) createBzip2Archive(ctx context.Context, sourceDir, outputPath var cmd *exec.Cmd if a.compressionThreads > 1 { if _, err := a.findPath("pbzip2"); err == nil { - cmd = a.cmd(ctx, "pbzip2", + cmd, err = a.cmd(ctx, "pbzip2", fmt.Sprintf("-%d", a.compressionLevel), fmt.Sprintf("-p%d", a.compressionThreads), "-c", ) + if err != nil { + return err + } } } if cmd == nil { - cmd = a.cmd(ctx, "bzip2", + var err error + cmd, err = a.cmd(ctx, "bzip2", fmt.Sprintf("-%d", a.compressionLevel), "-c", ) + if err != nil { + return err + } } return a.pipeTarThroughCommand(ctx, sourceDir, outputPath, cmd, "bzip2") } @@ -538,10 +549,13 @@ func (a *Archiver) createLzmaArchive(ctx context.Context, sourceDir, outputPath if requiresExtremeMode(a.CompressionMode()) { levelFlag += "e" } - cmd := a.cmd(ctx, "lzma", + cmd, err := a.cmd(ctx, "lzma", levelFlag, "-c", ) + if err != nil { + return err + } return a.pipeTarThroughCommand(ctx, sourceDir, outputPath, cmd, "lzma") } @@ -550,7 +564,10 @@ func (a *Archiver) createXZArchive(ctx context.Context, sourceDir, outputPath st a.logger.Debug("Creating xz archive with level %d (mode %s)", a.compressionLevel, a.CompressionMode()) args := buildXZArgs(a.compressionLevel, a.compressionThreads, a.CompressionMode()) - cmd := a.cmd(ctx, "xz", args...) + cmd, err := a.cmd(ctx, "xz", args...) + if err != nil { + return err + } if err := a.attachStderrLogger(cmd, "xz"); err != nil { return fmt.Errorf("capture xz output: %w", err) } @@ -620,7 +637,10 @@ func (a *Archiver) createZstdArchive(ctx context.Context, sourceDir, outputPath a.logger.Debug("Creating zstd archive with level %d (mode %s)", a.compressionLevel, a.CompressionMode()) args := buildZstdArgs(a.compressionLevel, a.compressionThreads) - cmd := a.cmd(ctx, "zstd", args...) + cmd, err := a.cmd(ctx, "zstd", args...) + if err != nil { + return err + } if err := a.attachStderrLogger(cmd, "zstd"); err != nil { return fmt.Errorf("capture zstd output: %w", err) } @@ -985,7 +1005,10 @@ func (a *Archiver) verifyXZArchive(ctx context.Context, archivePath string) erro a.logger.Debug("Testing XZ compression integrity") // Test XZ compression integrity - cmd := a.cmd(ctx, "xz", "--test", archivePath) + cmd, err := a.cmd(ctx, "xz", "--test", archivePath) + if err != nil { + return err + } if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("xz integrity test failed: %w (output: %s)", err, string(output)) } @@ -993,7 +1016,10 @@ func (a *Archiver) verifyXZArchive(ctx context.Context, archivePath string) erro a.logger.Debug("XZ compression test passed") // Test tar listing (decompress and list without extracting) - cmd = a.cmd(ctx, "tar", "-tJf", archivePath) + cmd, err = a.cmd(ctx, "tar", "-tJf", archivePath) + if err != nil { + return err + } cmd.Stdout = nil // Discard output if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("tar listing failed: %w (output: %s)", err, string(output)) @@ -1008,7 +1034,10 @@ func (a *Archiver) verifyZstdArchive(ctx context.Context, archivePath string) er a.logger.Debug("Testing Zstd compression integrity") // Test Zstd compression integrity - cmd := a.cmd(ctx, "zstd", "--test", archivePath) + cmd, err := a.cmd(ctx, "zstd", "--test", archivePath) + if err != nil { + return err + } if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("zstd integrity test failed: %w (output: %s)", err, string(output)) } @@ -1016,7 +1045,10 @@ func (a *Archiver) verifyZstdArchive(ctx context.Context, archivePath string) er a.logger.Debug("Zstd compression test passed") // Test tar listing (decompress and list without extracting) - cmd = a.cmd(ctx, "tar", "--use-compress-program=zstd", "-tf", archivePath) + cmd, err = a.cmd(ctx, "tar", "--use-compress-program=zstd", "-tf", archivePath) + if err != nil { + return err + } cmd.Stdout = nil // Discard output if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("tar listing failed: %w (output: %s)", err, string(output)) @@ -1031,7 +1063,10 @@ func (a *Archiver) verifyGzipArchive(ctx context.Context, archivePath string) er a.logger.Debug("Testing Gzip compression integrity") // Test tar listing (tar will test gzip integrity automatically) - cmd := a.cmd(ctx, "tar", "-tzf", archivePath) + cmd, err := a.cmd(ctx, "tar", "-tzf", archivePath) + if err != nil { + return err + } cmd.Stdout = nil // Discard output if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("tar/gzip verification failed: %w (output: %s)", err, string(output)) @@ -1046,7 +1081,10 @@ func (a *Archiver) verifyTarArchive(ctx context.Context, archivePath string) err a.logger.Debug("Testing uncompressed tar integrity") // Test tar listing - cmd := a.cmd(ctx, "tar", "-tf", archivePath) + cmd, err := a.cmd(ctx, "tar", "-tf", archivePath) + if err != nil { + return err + } cmd.Stdout = nil // Discard output if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("tar verification failed: %w (output: %s)", err, string(output)) diff --git a/internal/backup/collector.go b/internal/backup/collector.go index 78a6e8e8..b87366a3 100644 --- a/internal/backup/collector.go +++ b/internal/backup/collector.go @@ -1,3 +1,4 @@ +// Package backup contains collection, archive, manifest, and optimization helpers. package backup import ( @@ -117,6 +118,39 @@ func (c *Collector) depRunCommandWithEnv(ctx context.Context, extraEnv []string, return runCommandWithEnv(ctx, extraEnv, name, args...) } +type CommandSpec struct { + Name string + Args []string +} + +func commandSpec(name string, args ...string) CommandSpec { + return CommandSpec{Name: strings.TrimSpace(name), Args: append([]string(nil), args...)} +} + +func (s CommandSpec) validate() error { + if s.Name == "" { + return fmt.Errorf("empty command") + } + if strings.ContainsAny(s.Name, `/\`) { + return fmt.Errorf("command name must not contain path separators: %s", s.Name) + } + for _, arg := range s.Args { + for _, r := range arg { + if r == 0 { + return fmt.Errorf("command argument contains NUL byte") + } + } + } + return nil +} + +func (s CommandSpec) String() string { + if len(s.Args) == 0 { + return s.Name + } + return s.Name + " " + strings.Join(s.Args, " ") +} + func (c *Collector) depStat(path string) (os.FileInfo, error) { if c.deps.Stat != nil { return c.deps.Stat(path) @@ -230,54 +264,84 @@ var defaultExcludePatterns = []string{ // Validate checks if the collector configuration is valid func (c *CollectorConfig) Validate() error { - // Validate exclude patterns (basic glob syntax check) + if err := c.validateExcludePatterns(); err != nil { + return err + } + + if !c.hasCollectionOptionEnabled() { + return fmt.Errorf("at least one backup option must be enabled") + } + + c.normalizePxarConcurrency() + if c.MaxPVEBackupSizeBytes < 0 { + return fmt.Errorf("MAX_PVE_BACKUP_SIZE must be >= 0") + } + c.normalizeCommandTimeouts() + if c.SystemRootPrefix != "" && !filepath.IsAbs(c.SystemRootPrefix) { + return fmt.Errorf("system root prefix must be an absolute path") + } + + return nil +} + +func (c *CollectorConfig) validateExcludePatterns() error { for i, pattern := range c.ExcludePatterns { if pattern == "" { return fmt.Errorf("exclude pattern at index %d is empty", i) } - // Test if pattern is valid glob syntax if _, err := filepath.Match(pattern, "test"); err != nil { return fmt.Errorf("invalid glob pattern at index %d: %s (error: %w)", i, pattern, err) } } + return nil +} - // At least one collection option should be enabled - hasAnyEnabled := c.BackupVMConfigs || c.BackupClusterConfig || - c.BackupPVEFirewall || c.BackupVZDumpConfig || c.BackupPVEACL || - c.BackupPVEJobs || c.BackupPVESchedules || c.BackupPVEReplication || - c.BackupPVEBackupFiles || c.BackupCephConfig || - c.BackupDatastoreConfigs || c.BackupPBSS3Endpoints || c.BackupPBSNodeConfig || - c.BackupPBSAcmeAccounts || c.BackupPBSAcmePlugins || c.BackupPBSMetricServers || - c.BackupPBSTrafficControl || c.BackupPBSNotifications || c.BackupUserConfigs || c.BackupRemoteConfigs || - c.BackupSyncJobs || c.BackupVerificationJobs || c.BackupTapeConfigs || - c.BackupPBSNetworkConfig || c.BackupPruneSchedules || c.BackupPxarFiles || - c.BackupNetworkConfigs || c.BackupAptSources || c.BackupCronJobs || - c.BackupSystemdServices || c.BackupSSLCerts || c.BackupSysctlConfig || - c.BackupKernelModules || c.BackupFirewallRules || - c.BackupInstalledPackages || c.BackupScriptDir || c.BackupCriticalFiles || - c.BackupSSHKeys || c.BackupZFSConfig || c.BackupConfigFile - - if !hasAnyEnabled { - return fmt.Errorf("at least one backup option must be enabled") +func (c *CollectorConfig) hasCollectionOptionEnabled() bool { + if len(c.CustomBackupPaths) > 0 { + return true } + for _, enabled := range c.collectionOptionFlags() { + if enabled { + return true + } + } + return false +} +func (c *CollectorConfig) collectionOptionFlags() []bool { + return []bool{ + c.BackupVMConfigs, c.BackupClusterConfig, + c.BackupPVEFirewall, c.BackupVZDumpConfig, c.BackupPVEACL, + c.BackupPVEJobs, c.BackupPVESchedules, c.BackupPVEReplication, + c.BackupPVEBackupFiles, c.BackupCephConfig, + c.BackupDatastoreConfigs, c.BackupPBSS3Endpoints, c.BackupPBSNodeConfig, + c.BackupPBSAcmeAccounts, c.BackupPBSAcmePlugins, c.BackupPBSMetricServers, + c.BackupPBSTrafficControl, c.BackupPBSNotifications, c.BackupPBSNotificationsPriv, + c.BackupUserConfigs, c.BackupRemoteConfigs, + c.BackupSyncJobs, c.BackupVerificationJobs, c.BackupTapeConfigs, + c.BackupPBSNetworkConfig, c.BackupPruneSchedules, c.BackupPxarFiles, + c.BackupNetworkConfigs, c.BackupAptSources, c.BackupCronJobs, + c.BackupSystemdServices, c.BackupSSLCerts, c.BackupSysctlConfig, + c.BackupKernelModules, c.BackupFirewallRules, + c.BackupInstalledPackages, c.BackupScriptDir, c.BackupCriticalFiles, + c.BackupSSHKeys, c.BackupZFSConfig, c.BackupConfigFile, + c.BackupRootHome, c.BackupScriptRepository, c.BackupUserHomes, + } +} + +func (c *CollectorConfig) normalizePxarConcurrency() { if c.PxarDatastoreConcurrency <= 0 { c.PxarDatastoreConcurrency = 3 } - if c.MaxPVEBackupSizeBytes < 0 { - return fmt.Errorf("MAX_PVE_BACKUP_SIZE must be >= 0") - } +} + +func (c *CollectorConfig) normalizeCommandTimeouts() { if c.PveshTimeoutSeconds < 0 { c.PveshTimeoutSeconds = 15 } if c.FsIoTimeoutSeconds < 0 { c.FsIoTimeoutSeconds = 30 } - if c.SystemRootPrefix != "" && !filepath.IsAbs(c.SystemRootPrefix) { - return fmt.Errorf("system root prefix must be an absolute path") - } - - return nil } // NewCollector creates a new backup collector @@ -696,19 +760,12 @@ func (c *Collector) safeCopyFile(ctx context.Context, src, dest, description str c.logger.Debug("Collecting %s: %s -> %s", description, src, dest) - info, err := os.Lstat(src) - if err != nil { - if os.IsNotExist(err) { - c.logger.Debug("%s not found: %s (skipping)", description, src) - return nil - } - c.incFilesFailed() - return fmt.Errorf("failed to stat %s: %w", src, err) + info, found, err := c.statCopySource(src, description) + if err != nil || !found { + return err } - // Check if this file should be excluded - if c.shouldExclude(src) || c.shouldExclude(dest) { - c.incFilesSkipped() + if c.shouldSkipCopy(src, dest) { return nil } @@ -718,79 +775,102 @@ func (c *Collector) safeCopyFile(ctx context.Context, src, dest, description str return nil } - // Handle symbolic links by recreating the link if info.Mode()&os.ModeSymlink != 0 { - target, err := osReadlink(src) - if err != nil { - c.incFilesFailed() - return fmt.Errorf("symlink read failed - path: %s: %w", src, err) - } + return c.copySymlinkFile(src, dest, info) + } - if err := c.ensureDir(filepath.Dir(dest)); err != nil { - c.incFilesFailed() - return err - } - c.applyDirectoryMetadataFromSource(filepath.Dir(src), filepath.Dir(dest)) + if !info.Mode().IsRegular() { + c.logger.Debug("Skipping non-regular file: %s", src) + return nil + } - // Remove existing file if present - if _, err := os.Lstat(dest); err == nil { - if err := os.Remove(dest); err != nil { - c.incFilesFailed() - return fmt.Errorf("file replacement failed - path: %s: %w", dest, err) - } - } + return c.copyRegularFile(src, dest, description, info) +} - if err := osSymlink(target, dest); err != nil { - c.incFilesFailed() - return fmt.Errorf("symlink creation failed - source: %s - target: %s - absolute: %v: %w", - src, target, filepath.IsAbs(target), err) +func (c *Collector) statCopySource(src, description string) (os.FileInfo, bool, error) { + info, err := os.Lstat(src) + if err != nil { + if os.IsNotExist(err) { + c.logger.Debug("%s not found: %s (skipping)", description, src) + return nil, false, nil } + c.incFilesFailed() + return nil, false, fmt.Errorf("failed to stat %s: %w", src, err) + } + return info, true, nil +} - c.applySymlinkOwnership(dest, info) +func (c *Collector) shouldSkipCopy(src, dest string) bool { + if c.shouldExclude(src) || c.shouldExclude(dest) { + c.incFilesSkipped() + return true + } + return false +} - c.incFilesProcessed() - c.logger.Debug("Successfully copied symlink %s -> %s", dest, target) - return nil +func (c *Collector) copySymlinkFile(src, dest string, info os.FileInfo) error { + target, err := osReadlink(src) + if err != nil { + c.incFilesFailed() + return fmt.Errorf("symlink read failed - path: %s: %w", src, err) } - if !info.Mode().IsRegular() { - // Skip non-regular files (devices, sockets, etc.) but count as processed - c.logger.Debug("Skipping non-regular file: %s", src) - return nil + if err := c.prepareCopyDestination(src, dest); err != nil { + c.incFilesFailed() + return err } - // Ensure destination directory exists - if err := c.ensureDir(filepath.Dir(dest)); err != nil { + if err := c.removeExistingSymlinkDestination(dest); err != nil { c.incFilesFailed() return err } - c.applyDirectoryMetadataFromSource(filepath.Dir(src), filepath.Dir(dest)) - // Open source file - srcFile, err := osOpen(src) - if err != nil { + if err := osSymlink(target, dest); err != nil { c.incFilesFailed() - return fmt.Errorf("failed to open %s: %w", src, err) + return fmt.Errorf("symlink creation failed - source: %s - target: %s - absolute: %v: %w", + src, target, filepath.IsAbs(target), err) } - defer srcFile.Close() - // Create destination file with a safe default mode; we'll apply the original metadata after copy. - destFile, err := osOpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) - if err != nil { + c.applySymlinkOwnership(dest, info) + c.incFilesProcessed() + c.logger.Debug("Successfully copied symlink %s -> %s", dest, target) + return nil +} + +func (c *Collector) prepareCopyDestination(src, dest string) error { + if err := c.ensureDir(filepath.Dir(dest)); err != nil { + return err + } + c.applyDirectoryMetadataFromSource(filepath.Dir(src), filepath.Dir(dest)) + return nil +} + +func (c *Collector) removeExistingSymlinkDestination(dest string) error { + if _, err := os.Lstat(dest); err == nil { + if err := os.Remove(dest); err != nil { + return fmt.Errorf("file replacement failed - path: %s: %w", dest, err) + } + } + return nil +} + +func (c *Collector) copyRegularFile(src, dest, description string, info os.FileInfo) error { + if err := c.prepareCopyDestination(src, dest); err != nil { c.incFilesFailed() - return fmt.Errorf("failed to create %s: %w", dest, err) + return err } - // Copy content - written, err := io.Copy(destFile, srcFile) - closeErr := destFile.Close() + srcFile, err := osOpen(src) if err != nil { c.incFilesFailed() - return fmt.Errorf("failed to copy %s: %w", src, err) + return fmt.Errorf("failed to open %s: %w", src, err) } - if closeErr != nil { + defer srcFile.Close() + + written, err := copyRegularFileContents(srcFile, src, dest) + if err != nil { c.incFilesFailed() - return fmt.Errorf("failed to close %s: %w", dest, closeErr) + return err } c.applyMetadata(dest, info) @@ -802,6 +882,23 @@ func (c *Collector) safeCopyFile(ctx context.Context, src, dest, description str return nil } +func copyRegularFileContents(srcFile io.Reader, src, dest string) (int64, error) { + destFile, err := osOpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + if err != nil { + return 0, fmt.Errorf("failed to create %s: %w", dest, err) + } + + written, err := io.Copy(destFile, srcFile) + closeErr := destFile.Close() + if err != nil { + return 0, fmt.Errorf("failed to copy %s: %w", src, err) + } + if closeErr != nil { + return 0, fmt.Errorf("failed to close %s: %w", dest, closeErr) + } + return written, nil +} + func (c *Collector) safeCopyDir(ctx context.Context, src, dest, description string) error { if err := ctx.Err(); err != nil { return err @@ -876,54 +973,85 @@ func (c *Collector) safeCopyDir(ctx context.Context, src, dest, description stri return nil } -func (c *Collector) safeCmdOutput(ctx context.Context, cmd, output, description string, critical bool) error { +type commandRunClassification int + +const ( + commandRunSucceeded commandRunClassification = iota + commandRunSkipped + commandRunNonCriticalFailure + commandRunDowngradedToSkip + commandRunCriticalFailure +) + +type commandRunOptions struct { + output string + description string + caller string + critical bool + logCollection bool + handleSystemctlStatus bool +} + +type commandRunResult struct { + output []byte + classification commandRunClassification + exitCode int + outputSummary string + contextInfo unprivilegedContainerContext +} + +func (c *Collector) runAndClassifyCommand(ctx context.Context, spec CommandSpec, opts commandRunOptions) (commandRunResult, error) { + result := commandRunResult{classification: commandRunSkipped, exitCode: -1} if err := ctx.Err(); err != nil { - return err + return result, err + } + if err := spec.validate(); err != nil { + return result, err } - if output != "" && c.shouldExclude(output) { - c.logger.Debug("Skipping %s: output %s excluded by pattern", description, output) + if opts.output != "" && c.shouldExclude(opts.output) { + c.logger.Debug("Skipping %s: output %s excluded by pattern", opts.description, opts.output) c.incFilesSkipped() - return nil + return result, nil } - c.logger.Debug("Collecting %s via command: %s > %s", description, cmd, output) - - cmdParts := strings.Fields(cmd) - if len(cmdParts) == 0 { - return fmt.Errorf("empty command") + cmdString := spec.String() + if opts.logCollection { + c.logger.Debug("Collecting %s via command: %s > %s", opts.description, cmdString, opts.output) } - // Check if command exists - if _, err := c.depLookPath(cmdParts[0]); err != nil { - if critical { + if _, err := c.depLookPath(spec.Name); err != nil { + if opts.critical { c.incFilesFailed() - return fmt.Errorf("critical command not available: %s", cmdParts[0]) + result.classification = commandRunCriticalFailure + return result, fmt.Errorf("critical command not available: %s", spec.Name) } - c.logger.Debug("Command not available: %s (skipping %s)", cmdParts[0], description) - return nil + c.logger.Debug("Command not available: %s (skipping %s)", spec.Name, opts.description) + return result, nil } if c.dryRun { - c.logger.Debug("[DRY RUN] Would execute command: %s > %s", cmd, output) - return nil + c.logger.Debug("[DRY RUN] Would execute command: %s > %s", cmdString, opts.output) + return result, nil } - cmdString := strings.Join(cmdParts, " ") runCtx := ctx var cancel context.CancelFunc - if cmdParts[0] == "pvesh" && c.config != nil && c.config.PveshTimeoutSeconds > 0 { + if spec.Name == "pvesh" && c.config != nil && c.config.PveshTimeoutSeconds > 0 { runCtx, cancel = context.WithTimeout(ctx, time.Duration(c.config.PveshTimeoutSeconds)*time.Second) } if cancel != nil { defer cancel() } - out, err := c.depRunCommand(runCtx, cmdParts[0], cmdParts[1:]...) + out, err := c.depRunCommand(runCtx, spec.Name, spec.Args...) + result.output = out if err != nil { - if critical { + result.outputSummary = summarizeCommandOutputText(string(out)) + if opts.critical { c.incFilesFailed() - return fmt.Errorf("critical command `%s` failed for %s: %w (output: %s)", cmdString, description, err, summarizeCommandOutputText(string(out))) + result.classification = commandRunCriticalFailure + return result, fmt.Errorf("critical command `%s` failed for %s: %w (output: %s)", cmdString, opts.description, err, result.outputSummary) } exitCode := -1 var exitErr *exec.ExitError @@ -931,59 +1059,117 @@ func (c *Collector) safeCmdOutput(ctx context.Context, cmd, output, description exitCode = exitErr.ExitCode() } outputText := strings.TrimSpace(string(out)) + result.exitCode = exitCode + result.outputSummary = summarizeCommandOutputText(outputText) + result.classification = commandRunNonCriticalFailure - c.logger.Debug("Non-critical command failed (safeCmdOutput): description=%q cmd=%q exitCode=%d err=%v", description, cmdString, exitCode, err) - c.logger.Debug("Non-critical command output summary (safeCmdOutput): %s", summarizeCommandOutputText(outputText)) + c.logger.Debug("Non-critical command failed (%s): description=%q cmd=%q exitCode=%d err=%v", opts.caller, opts.description, cmdString, exitCode, err) + c.logger.Debug("Non-critical command output summary (%s): %s", opts.caller, result.outputSummary) ctxInfo := c.depDetectUnprivilegedContainer() + result.contextInfo = ctxInfo c.logger.Debug("Privilege context evaluation: detected=%t details=%q", ctxInfo.Detected, strings.TrimSpace(ctxInfo.Details)) reason := "" if ctxInfo.Detected { - c.logger.Debug("Privilege-sensitive allowlist: command=%q allowlisted=%t", cmdParts[0], isPrivilegeSensitiveCommand(cmdParts[0])) - match := privilegeSensitiveFailureMatch(cmdParts[0], exitCode, outputText) + c.logger.Debug("Privilege-sensitive allowlist: command=%q allowlisted=%t", spec.Name, isPrivilegeSensitiveCommand(spec.Name)) + match := privilegeSensitiveFailureMatch(spec.Name, exitCode, outputText) reason = match.Reason - c.logger.Debug("Privilege-sensitive classification: command=%q matched=%t match=%q reason=%q", cmdParts[0], reason != "", match.Match, reason) + c.logger.Debug("Privilege-sensitive classification: command=%q matched=%t match=%q reason=%q", spec.Name, reason != "", match.Match, reason) } else { - c.logger.Debug("Privilege-sensitive downgrade not considered: limited-privilege context not detected (command=%q)", cmdParts[0]) + c.logger.Debug("Privilege-sensitive downgrade not considered: limited-privilege context not detected (command=%q)", spec.Name) } if ctxInfo.Detected && reason != "" { - c.logger.Debug("Downgrading WARNING->SKIP: description=%q cmd=%q exitCode=%d", description, cmdString, exitCode) + result.classification = commandRunDowngradedToSkip + c.logger.Debug("Downgrading WARNING->SKIP: description=%q cmd=%q exitCode=%d", opts.description, cmdString, exitCode) - c.logger.Skip("Skipping %s: %s (Expected with limited privileges).", description, reason) - c.logger.Debug("SKIP context (privilege-sensitive): description=%q cmd=%q exitCode=%d err=%v contextDetails=%q", description, cmdString, exitCode, err, strings.TrimSpace(ctxInfo.Details)) - c.logger.Debug("SKIP output summary for %s: %s", description, summarizeCommandOutputText(outputText)) - return nil + c.logger.Skip("Skipping %s: %s (Expected with limited privileges).", opts.description, reason) + c.logger.Debug("SKIP context (privilege-sensitive): description=%q cmd=%q exitCode=%d err=%v contextDetails=%q", opts.description, cmdString, exitCode, err, strings.TrimSpace(ctxInfo.Details)) + c.logger.Debug("SKIP output summary for %s: %s", opts.description, result.outputSummary) + return result, nil } if ctxInfo.Detected { - c.logger.Debug("No privilege-sensitive downgrade applied: command=%q did not match known patterns; emitting WARNING", cmdParts[0]) + if opts.handleSystemctlStatus { + c.logger.Debug("No privilege-sensitive downgrade applied: command=%q did not match known patterns; continuing with standard handling", spec.Name) + } else { + c.logger.Debug("No privilege-sensitive downgrade applied: command=%q did not match known patterns; emitting WARNING", spec.Name) + } } - c.logger.Warning("Skipping %s: command `%s` failed (%v). Non-critical; backup continues. Ensure the required CLI is available and has proper permissions. Output: %s", - description, - cmdString, - err, - summarizeCommandOutputText(outputText), - ) - return nil // Non-critical failure + if opts.handleSystemctlStatus && spec.Name == "systemctl" && len(spec.Args) >= 2 && spec.Args[0] == "status" { + unit := spec.Args[len(spec.Args)-1] + if exitCode == 4 || strings.Contains(outputText, "could not be found") { + c.logger.Warning("Skipping %s: %s.service not found (not installed?). Set BACKUP_FIREWALL_RULES=false to disable.", + opts.description, + unit, + ) + return result, nil + } + if strings.Contains(outputText, "Failed to connect to system scope bus") || strings.Contains(outputText, "System has not been booted with systemd") { + c.logger.Warning("Skipping %s: systemd is not available/accessible in this environment. Non-critical; backup continues. Output: %s", + opts.description, + result.outputSummary, + ) + return result, nil + } + } + + if opts.handleSystemctlStatus { + c.logger.Warning("Skipping %s: command `%s` failed (%v). Non-critical; backup continues. Output: %s", + opts.description, + cmdString, + err, + result.outputSummary, + ) + } else { + c.logger.Warning("Skipping %s: command `%s` failed (%v). Non-critical; backup continues. Ensure the required CLI is available and has proper permissions. Output: %s", + opts.description, + cmdString, + err, + result.outputSummary, + ) + } + return result, nil } - if err := c.writeReportFile(output, out); err != nil { + result.classification = commandRunSucceeded + return result, nil +} + +func (c *Collector) safeCmdOutput(ctx context.Context, spec CommandSpec, output, description string, critical bool) error { + result, err := c.runAndClassifyCommand(ctx, spec, commandRunOptions{ + output: output, + description: description, + caller: "safeCmdOutput", + critical: critical, + logCollection: true, + }) + if err != nil { return err } + if result.classification != commandRunSucceeded { + return nil + } - c.logger.Debug("Successfully collected %s via command: %s", description, cmdString) + if err := c.writeReportFile(output, result.output); err != nil { + return err + } + + c.logger.Debug("Successfully collected %s via command: %s", description, spec.String()) return nil } // safeCmdOutputWithPBSAuth executes a command with PBS authentication environment variables // This enables automatic authentication for proxmox-backup-client commands -func (c *Collector) safeCmdOutputWithPBSAuth(ctx context.Context, cmd, output, description string, critical bool) error { +func (c *Collector) safeCmdOutputWithPBSAuth(ctx context.Context, spec CommandSpec, output, description string, critical bool) error { if err := ctx.Err(); err != nil { return err } + if err := spec.validate(); err != nil { + return err + } if output != "" && c.shouldExclude(output) { c.logger.Debug("Skipping %s: output %s excluded by pattern", description, output) @@ -991,23 +1177,18 @@ func (c *Collector) safeCmdOutputWithPBSAuth(ctx context.Context, cmd, output, d return nil } - cmdParts := strings.Fields(cmd) - if len(cmdParts) == 0 { - return fmt.Errorf("empty command") - } - // Check if command exists - if _, err := c.depLookPath(cmdParts[0]); err != nil { + if _, err := c.depLookPath(spec.Name); err != nil { if critical { c.incFilesFailed() - return fmt.Errorf("critical command not available: %s", cmdParts[0]) + return fmt.Errorf("critical command not available: %s", spec.Name) } - c.logger.Debug("Command not available: %s (skipping %s)", cmdParts[0], description) + c.logger.Debug("Command not available: %s (skipping %s)", spec.Name, description) return nil } if c.dryRun { - c.logger.Debug("[DRY RUN] Would execute command with PBS auth: %s > %s", cmd, output) + c.logger.Debug("[DRY RUN] Would execute command with PBS auth: %s > %s", spec.String(), output) return nil } @@ -1028,11 +1209,11 @@ func (c *Collector) safeCmdOutputWithPBSAuth(ctx context.Context, cmd, output, d } if pbsAuthUsed { - c.logger.Debug("Using PBS authentication for command: %s", cmdParts[0]) + c.logger.Debug("Using PBS authentication for command: %s", spec.Name) } - cmdString := strings.Join(cmdParts, " ") - out, err := c.depRunCommandWithEnv(ctx, extraEnv, cmdParts[0], cmdParts[1:]...) + cmdString := spec.String() + out, err := c.depRunCommandWithEnv(ctx, extraEnv, spec.Name, spec.Args...) if err != nil { if critical { c.incFilesFailed() @@ -1055,12 +1236,38 @@ func (c *Collector) safeCmdOutputWithPBSAuth(ctx context.Context, cmd, output, d return nil } +func pbsRepositoryWithDatastore(repository, datastoreName string) string { + separator := -1 + bracketDepth := 0 + for i, r := range repository { + switch r { + case '[': + bracketDepth++ + case ']': + if bracketDepth > 0 { + bracketDepth-- + } + case ':': + if bracketDepth == 0 { + separator = i + } + } + } + if separator >= 0 { + return repository[:separator+1] + datastoreName + } + return repository + ":" + datastoreName +} + // safeCmdOutputWithPBSAuthForDatastore executes a command with PBS authentication for a specific datastore // This function appends the datastore name to the PBS_REPOSITORY environment variable -func (c *Collector) safeCmdOutputWithPBSAuthForDatastore(ctx context.Context, cmd, output, description, datastoreName string, critical bool) error { +func (c *Collector) safeCmdOutputWithPBSAuthForDatastore(ctx context.Context, spec CommandSpec, output, description, datastoreName string, critical bool) error { if err := ctx.Err(); err != nil { return err } + if err := spec.validate(); err != nil { + return err + } if output != "" && c.shouldExclude(output) { c.logger.Debug("Skipping %s: output %s excluded by pattern", description, output) @@ -1068,23 +1275,18 @@ func (c *Collector) safeCmdOutputWithPBSAuthForDatastore(ctx context.Context, cm return nil } - cmdParts := strings.Fields(cmd) - if len(cmdParts) == 0 { - return fmt.Errorf("empty command") - } - // Check if command exists - if _, err := c.depLookPath(cmdParts[0]); err != nil { + if _, err := c.depLookPath(spec.Name); err != nil { if critical { c.incFilesFailed() - return fmt.Errorf("critical command not available: %s", cmdParts[0]) + return fmt.Errorf("critical command not available: %s", spec.Name) } - c.logger.Debug("Command not available: %s (skipping %s)", cmdParts[0], description) + c.logger.Debug("Command not available: %s (skipping %s)", spec.Name, description) return nil } if c.dryRun { - c.logger.Debug("[DRY RUN] Would execute command with PBS auth for datastore %s: %s > %s", datastoreName, cmd, output) + c.logger.Debug("[DRY RUN] Would execute command with PBS auth for datastore %s: %s > %s", datastoreName, spec.String(), output) return nil } @@ -1098,17 +1300,7 @@ func (c *Collector) safeCmdOutputWithPBSAuthForDatastore(ctx context.Context, cm // Build PBS_REPOSITORY with datastore repoWithDatastore := "" if c.config.PBSRepository != "" { - // If repository already has a datastore (contains :), replace it - // Otherwise append the datastore name - repoWithDatastore = c.config.PBSRepository - if strings.Contains(repoWithDatastore, ":") { - // Replace existing datastore: "user@host:oldds" -> "user@host:newds" - parts := strings.SplitN(repoWithDatastore, ":", 2) - repoWithDatastore = fmt.Sprintf("%s:%s", parts[0], datastoreName) - } else { - // Append datastore: "user@host" -> "user@host:datastore" - repoWithDatastore = fmt.Sprintf("%s:%s", repoWithDatastore, datastoreName) - } + repoWithDatastore = pbsRepositoryWithDatastore(c.config.PBSRepository, datastoreName) } else { // No repository configured but we have password - use root@pam as default user repoWithDatastore = fmt.Sprintf("root@pam@localhost:%s", datastoreName) @@ -1128,8 +1320,8 @@ func (c *Collector) safeCmdOutputWithPBSAuthForDatastore(ctx context.Context, cm c.logger.Debug("Using PBS_FINGERPRINT=%s", c.config.PBSFingerprint) } - cmdString := strings.Join(cmdParts, " ") - out, err := c.depRunCommandWithEnv(ctx, extraEnv, cmdParts[0], cmdParts[1:]...) + cmdString := spec.String() + out, err := c.depRunCommandWithEnv(ctx, extraEnv, spec.Name, spec.Args...) if err != nil { if critical { c.incFilesFailed() @@ -1244,128 +1436,34 @@ func (c *Collector) writeReportFile(path string, data []byte) error { return nil } -func (c *Collector) captureCommandOutput(ctx context.Context, cmd, output, description string, critical bool) ([]byte, error) { - if err := ctx.Err(); err != nil { +func (c *Collector) captureCommandOutput(ctx context.Context, spec CommandSpec, output, description string, critical bool) ([]byte, error) { + result, err := c.runAndClassifyCommand(ctx, spec, commandRunOptions{ + output: output, + description: description, + caller: "captureCommandOutput", + critical: critical, + handleSystemctlStatus: true, + }) + if err != nil { return nil, err } - - if output != "" && c.shouldExclude(output) { - c.logger.Debug("Skipping %s: output %s excluded by pattern", description, output) - c.incFilesSkipped() + if result.classification != commandRunSucceeded { return nil, nil } - parts := strings.Fields(cmd) - if len(parts) == 0 { - return nil, fmt.Errorf("empty command") - } - - if _, err := c.depLookPath(parts[0]); err != nil { - if critical { - c.incFilesFailed() - return nil, fmt.Errorf("critical command not available: %s", parts[0]) - } - c.logger.Debug("Command not available: %s (skipping %s)", parts[0], description) - return nil, nil - } - - if c.dryRun { - c.logger.Debug("[DRY RUN] Would execute command: %s > %s", cmd, output) - return nil, nil - } - - runCtx := ctx - var cancel context.CancelFunc - if parts[0] == "pvesh" && c.config != nil && c.config.PveshTimeoutSeconds > 0 { - runCtx, cancel = context.WithTimeout(ctx, time.Duration(c.config.PveshTimeoutSeconds)*time.Second) - } - if cancel != nil { - defer cancel() - } - - out, err := c.depRunCommand(runCtx, parts[0], parts[1:]...) - if err != nil { - cmdString := strings.Join(parts, " ") - if critical { - c.incFilesFailed() - return nil, fmt.Errorf("critical command `%s` failed for %s: %w (output: %s)", cmdString, description, err, summarizeCommandOutputText(string(out))) - } - exitCode := -1 - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - exitCode = exitErr.ExitCode() - } - outputText := strings.TrimSpace(string(out)) - - c.logger.Debug("Non-critical command failed (captureCommandOutput): description=%q cmd=%q exitCode=%d err=%v", description, cmdString, exitCode, err) - c.logger.Debug("Non-critical command output summary (captureCommandOutput): %s", summarizeCommandOutputText(outputText)) - - ctxInfo := c.depDetectUnprivilegedContainer() - c.logger.Debug("Privilege context evaluation: detected=%t details=%q", ctxInfo.Detected, strings.TrimSpace(ctxInfo.Details)) - - reason := "" - if ctxInfo.Detected { - c.logger.Debug("Privilege-sensitive allowlist: command=%q allowlisted=%t", parts[0], isPrivilegeSensitiveCommand(parts[0])) - match := privilegeSensitiveFailureMatch(parts[0], exitCode, outputText) - reason = match.Reason - c.logger.Debug("Privilege-sensitive classification: command=%q matched=%t match=%q reason=%q", parts[0], reason != "", match.Match, reason) - } else { - c.logger.Debug("Privilege-sensitive downgrade not considered: limited-privilege context not detected (command=%q)", parts[0]) - } - - if ctxInfo.Detected && reason != "" { - c.logger.Debug("Downgrading WARNING->SKIP: description=%q cmd=%q exitCode=%d", description, cmdString, exitCode) - - c.logger.Skip("Skipping %s: %s (Expected with limited privileges).", description, reason) - c.logger.Debug("SKIP context (privilege-sensitive): description=%q cmd=%q exitCode=%d err=%v contextDetails=%q", description, cmdString, exitCode, err, strings.TrimSpace(ctxInfo.Details)) - c.logger.Debug("SKIP output summary for %s: %s", description, summarizeCommandOutputText(outputText)) - return nil, nil - } - - if ctxInfo.Detected { - c.logger.Debug("No privilege-sensitive downgrade applied: command=%q did not match known patterns; continuing with standard handling", parts[0]) - } - - if parts[0] == "systemctl" && len(parts) >= 2 && parts[1] == "status" { - unit := parts[len(parts)-1] - if exitCode == 4 || strings.Contains(outputText, "could not be found") { - c.logger.Warning("Skipping %s: %s.service not found (not installed?). Set BACKUP_FIREWALL_RULES=false to disable.", - description, - unit, - ) - return nil, nil - } - if strings.Contains(outputText, "Failed to connect to system scope bus") || strings.Contains(outputText, "System has not been booted with systemd") { - c.logger.Warning("Skipping %s: systemd is not available/accessible in this environment. Non-critical; backup continues. Output: %s", - description, - summarizeCommandOutputText(outputText), - ) - return nil, nil - } - } - - c.logger.Warning("Skipping %s: command `%s` failed (%v). Non-critical; backup continues. Output: %s", - description, - cmdString, - err, - summarizeCommandOutputText(outputText), - ) - return nil, nil - } - - if err := c.writeReportFile(output, out); err != nil { + if err := c.writeReportFile(output, result.output); err != nil { return nil, err } - return out, nil + return result.output, nil } -func (c *Collector) collectCommandMulti(ctx context.Context, cmd, output, description string, critical bool, mirrors ...string) error { +func (c *Collector) collectCommandMulti(ctx context.Context, spec CommandSpec, output, description string, critical bool, mirrors ...string) error { if output == "" { return fmt.Errorf("primary output path cannot be empty for %s", description) } - data, err := c.captureCommandOutput(ctx, cmd, output, description, critical) + data, err := c.captureCommandOutput(ctx, spec, output, description, critical) if err != nil { return err } @@ -1385,13 +1483,13 @@ func (c *Collector) collectCommandMulti(ctx context.Context, cmd, output, descri return nil } -func (c *Collector) collectCommandOptional(ctx context.Context, cmd, output, description string, mirrors ...string) { +func (c *Collector) collectCommandOptional(ctx context.Context, spec CommandSpec, output, description string, mirrors ...string) { if output == "" { c.logger.Debug("Optional command %s skipped: no primary output path", description) return } - data, err := c.captureCommandOutput(ctx, cmd, output, description, false) + data, err := c.captureCommandOutput(ctx, spec, output, description, false) if err != nil { c.logger.Debug("Optional command %s skipped: %v", description, err) return diff --git a/internal/backup/collector_bricks.go b/internal/backup/collector_bricks.go index cb4513db..a08c530c 100644 --- a/internal/backup/collector_bricks.go +++ b/internal/backup/collector_bricks.go @@ -1,13 +1,13 @@ +// Package backup provides collection, archive, and verification logic for ProxSave backups. package backup import ( "context" "errors" "fmt" - "os" - "strings" ) +// BrickID identifies one behavior-preserving collection step within a backup recipe. type BrickID string const ( @@ -203,6 +203,42 @@ type collectionBrick struct { Run func(context.Context, *collectionState) error } +func brick(id BrickID, description string, run func(context.Context, *collectionState) error) collectionBrick { + return collectionBrick{ID: id, Description: description, Run: run} +} + +func collectorBrick(id BrickID, description string, run func(*Collector, context.Context) error) collectionBrick { + return brick(id, description, func(ctx context.Context, state *collectionState) error { + return run(state.collector, ctx) + }) +} + +func pbsCommandBrick(id BrickID, description string, run func(*Collector, context.Context, string) error) collectionBrick { + return brick(id, description, func(ctx context.Context, state *collectionState) error { + commandsDir, err := state.ensurePBSCommandsDir() + if err != nil { + return err + } + return run(state.collector, ctx, commandsDir) + }) +} + +func systemCommandBrick(id BrickID, description string, run func(*Collector, context.Context, string) error) collectionBrick { + return brick(id, description, func(ctx context.Context, state *collectionState) error { + commandsDir, err := state.ensureSystemCommandsDir() + if err != nil { + return err + } + return run(state.collector, ctx, commandsDir) + }) +} + +func pbsInventoryBrick(id BrickID, description string, run func(*Collector, context.Context, *pbsInventoryState) error) collectionBrick { + return brick(id, description, func(ctx context.Context, state *collectionState) error { + return run(state.collector, ctx, state.ensurePBSInventoryState()) + }) +} + type recipe struct { Name string Bricks []collectionBrick @@ -271,6 +307,20 @@ func runRecipe(ctx context.Context, r recipe, state *collectionState) error { return nil } +func isContextCancellationError(ctx context.Context, err error) bool { + if err == nil { + return false + } + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return true + } + if ctx == nil { + return false + } + ctxErr := ctx.Err() + return ctxErr != nil && errors.Is(err, ctxErr) +} + func (s *collectionState) ensurePVECommandsDir() (string, error) { if s.pve.commandsDir != "" { return s.pve.commandsDir, nil @@ -348,687 +398,6 @@ func (s *collectionState) ensurePVERuntimeInfo() *pveRuntimeInfo { return s.pve.runtimeInfo } -func newPVERecipe() recipe { - return recipe{ - Name: "pve", - Bricks: []collectionBrick{ - { - ID: brickPVEValidateAndCluster, - Description: "Validate PVE environment and detect cluster state", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - c.logger.Debug("Validating PVE environment and cluster state prior to collection") - - pveConfigPath := c.effectivePVEConfigPath() - if _, err := os.Stat(pveConfigPath); err != nil { - if errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("not a PVE system: %s not found", pveConfigPath) - } - return fmt.Errorf("failed to access PVE config path %s: %w", pveConfigPath, err) - } - c.logger.Debug("%s detected, continuing with PVE collection", pveConfigPath) - - clustered := false - if isClustered, err := c.isClusteredPVE(ctx); err != nil { - if ctx.Err() != nil { - return err - } - c.logger.Debug("Cluster detection failed, assuming standalone node: %v", err) - } else { - clustered = isClustered - c.logger.Debug("Cluster detection completed: clustered=%v", clustered) - } - - state.pve.clustered = clustered - c.clusteredPVE = clustered - return nil - }, - }, - { - ID: brickPVEConfigSnapshot, - Description: "Collect base PVE configuration snapshot", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPVEConfigSnapshot(ctx) - }, - }, - { - ID: brickPVEClusterSnapshot, - Description: "Collect cluster-specific PVE snapshot", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPVEClusterSnapshot(ctx, state.pve.clustered) - }, - }, - { - ID: brickPVEFirewallSnapshot, - Description: "Collect PVE firewall snapshot", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPVEFirewallSnapshot(ctx) - }, - }, - { - ID: brickPVEVZDumpSnapshot, - Description: "Collect VZDump snapshot", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPVEVZDumpSnapshot(ctx) - }, - }, - { - ID: brickPVERuntimeCore, - Description: "Collect core PVE runtime information", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - commandsDir, err := state.ensurePVECommandsDir() - if err != nil { - return err - } - c.logger.Debug("Collecting PVE core runtime state") - return c.collectPVECoreRuntime(ctx, commandsDir, state.ensurePVERuntimeInfo()) - }, - }, - { - ID: brickPVERuntimeACL, - Description: "Collect PVE ACL runtime information", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePVECommandsDir() - if err != nil { - return err - } - state.collector.collectPVEACLRuntime(ctx, commandsDir) - return nil - }, - }, - { - ID: brickPVERuntimeCluster, - Description: "Collect PVE cluster runtime information", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePVECommandsDir() - if err != nil { - return err - } - state.collector.collectPVEClusterRuntime(ctx, commandsDir, state.pve.clustered) - return nil - }, - }, - { - ID: brickPVERuntimeStorage, - Description: "Collect PVE storage runtime information", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - commandsDir, err := state.ensurePVECommandsDir() - if err != nil { - return err - } - if err := c.collectPVEStorageRuntime(ctx, commandsDir, state.ensurePVERuntimeInfo()); err != nil { - return err - } - c.finalizePVERuntimeInfo(state.ensurePVERuntimeInfo()) - return nil - }, - }, - { - ID: brickPVEVMQEMUConfigs, - Description: "Collect QEMU VM configurations", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupVMConfigs { - c.logger.Skip("VM/container configuration backup disabled.") - return nil - } - if state.pve.guestCollectionAborted { - return nil - } - c.logger.Info("Collecting VM and container configurations") - if err := c.collectPVEQEMUConfigs(ctx); err != nil { - c.logger.Warning("Failed to collect QEMU VM configs: %v", err) - state.pve.guestCollectionAborted = true - } - return nil - }, - }, - { - ID: brickPVEVMLXCConfigs, - Description: "Collect LXC container configurations", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupVMConfigs || state.pve.guestCollectionAborted { - return nil - } - if err := c.collectPVELXCConfigs(ctx); err != nil { - c.logger.Warning("Failed to collect LXC configs: %v", err) - state.pve.guestCollectionAborted = true - } - return nil - }, - }, - { - ID: brickPVEGuestInventory, - Description: "Collect guest inventory", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupVMConfigs || state.pve.guestCollectionAborted { - return nil - } - if err := c.collectPVEGuestInventory(ctx); err != nil { - c.logger.Warning("Failed to collect guest inventory: %v", err) - state.pve.guestCollectionAborted = true - } - return nil - }, - }, - { - ID: brickPVEBackupJobDefs, - Description: "Collect PVE backup job definitions", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupPVEJobs || state.pve.jobCollectionAborted { - return nil - } - c.logger.Debug("Collecting PVE job definitions for nodes: %v", state.pve.runtimeNodes()) - if err := c.collectPVEBackupJobDefinitions(ctx); err != nil { - c.logger.Warning("Failed to collect PVE backup job definitions: %v", err) - state.pve.jobCollectionAborted = true - } - return nil - }, - }, - { - ID: brickPVEBackupJobHistory, - Description: "Collect PVE backup job history", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupPVEJobs || state.pve.jobCollectionAborted { - return nil - } - if err := c.collectPVEBackupJobHistory(ctx, state.pve.runtimeNodes()); err != nil { - c.logger.Warning("Failed to collect PVE backup history: %v", err) - state.pve.jobCollectionAborted = true - } - return nil - }, - }, - { - ID: brickPVEVZDumpCron, - Description: "Collect VZDump cron snapshot", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupPVEJobs || state.pve.jobCollectionAborted { - return nil - } - if err := c.collectPVEVZDumpCronSnapshot(ctx); err != nil { - c.logger.Warning("Failed to collect VZDump cron snapshot: %v", err) - state.pve.jobCollectionAborted = true - } - return nil - }, - }, - { - ID: brickPVEScheduleCrontab, - Description: "Collect root crontab schedule data", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupPVESchedules || state.pve.scheduleCollectionAborted { - return nil - } - if err := c.collectPVEScheduleCrontab(ctx); err != nil { - c.logger.Warning("Failed to collect PVE crontab schedules: %v", err) - state.pve.scheduleCollectionAborted = true - } - return nil - }, - }, - { - ID: brickPVEScheduleTimers, - Description: "Collect systemd timer schedule data", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupPVESchedules || state.pve.scheduleCollectionAborted { - return nil - } - if err := c.collectPVEScheduleTimers(ctx); err != nil { - c.logger.Warning("Failed to collect PVE timer schedules: %v", err) - state.pve.scheduleCollectionAborted = true - } - return nil - }, - }, - { - ID: brickPVEScheduleCronFiles, - Description: "Collect PVE-related cron files", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupPVESchedules || state.pve.scheduleCollectionAborted { - return nil - } - if err := c.collectPVEScheduleCronFiles(ctx); err != nil { - c.logger.Warning("Failed to collect PVE cron schedule files: %v", err) - state.pve.scheduleCollectionAborted = true - } - return nil - }, - }, - { - ID: brickPVEReplicationDefs, - Description: "Collect PVE replication definitions", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupPVEReplication || state.pve.replicationCollectionAborted { - return nil - } - c.logger.Debug("Collecting PVE replication settings for nodes: %v", state.pve.runtimeNodes()) - if err := c.collectPVEReplicationDefinitions(ctx); err != nil { - c.logger.Warning("Failed to collect PVE replication definitions: %v", err) - state.pve.replicationCollectionAborted = true - } - return nil - }, - }, - { - ID: brickPVEReplicationStatus, - Description: "Collect PVE replication status", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupPVEReplication || state.pve.replicationCollectionAborted { - return nil - } - if err := c.collectPVEReplicationStatus(ctx, state.pve.runtimeNodes()); err != nil { - c.logger.Warning("Failed to collect PVE replication status: %v", err) - state.pve.replicationCollectionAborted = true - } - return nil - }, - }, - { - ID: brickPVEStorageResolve, - Description: "Resolve PVE storage list for backup analysis", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupPVEBackupFiles { - return nil - } - if state.pve.storageCollectionAborted { - return nil - } - if err := ctx.Err(); err != nil { - return err - } - c.logger.Info("Collecting PVE datastore information using auto-detection") - c.logger.Debug("Collecting datastore metadata for %d storages", len(state.pve.runtimeStorages())) - state.pve.resolvedStorages = c.resolvePVEStorages(state.pve.runtimeStorages()) - if len(state.pve.resolvedStorages) == 0 { - c.logger.Info("Found 0 PVE datastore(s) via auto-detection") - c.logger.Info("No PVE datastores detected - skipping metadata collection") - return nil - } - c.logger.Info("Found %d PVE datastore(s) via auto-detection", len(state.pve.resolvedStorages)) - return nil - }, - }, - { - ID: brickPVEStorageProbe, - Description: "Probe resolved PVE storages", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupPVEBackupFiles || state.pve.storageCollectionAborted || len(state.pve.resolvedStorages) == 0 { - return nil - } - baseDir := c.pveDatastoresBaseDir() - if err := c.ensureDir(baseDir); err != nil { - c.logger.Warning("Failed to create datastore metadata directory: %v", err) - state.pve.storageCollectionAborted = true - return nil - } - ioTimeout := c.pveStorageIOTimeout() - state.pve.probedStorages = nil - state.pve.storageScanResults = nil - for _, storage := range state.pve.resolvedStorages { - result, err := c.preparePVEStorageScan(ctx, storage, baseDir, ioTimeout) - if err != nil { - c.logger.Warning("Failed to probe PVE datastore %s: %v", storage.Name, err) - state.pve.storageCollectionAborted = true - return nil - } - if result == nil { - continue - } - state.pve.probedStorages = append(state.pve.probedStorages, storage) - state.pve.ensureStorageScanResults()[storage.pathKey()] = result - } - return nil - }, - }, - { - ID: brickPVEStorageMetadataJSON, - Description: "Write JSON metadata for probed PVE storages", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupPVEBackupFiles || state.pve.storageCollectionAborted || len(state.pve.probedStorages) == 0 { - return nil - } - ioTimeout := c.pveStorageIOTimeout() - for _, storage := range state.pve.probedStorages { - result := state.pve.storageResult(storage) - if result == nil || result.SkipRemaining { - continue - } - if err := c.collectPVEStorageMetadataJSONStep(ctx, result, ioTimeout); err != nil { - c.logger.Warning("Failed to write PVE datastore JSON metadata for %s: %v", storage.Name, err) - state.pve.storageCollectionAborted = true - return nil - } - } - return nil - }, - }, - { - ID: brickPVEStorageMetadataText, - Description: "Write text metadata for probed PVE storages", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupPVEBackupFiles || state.pve.storageCollectionAborted || len(state.pve.probedStorages) == 0 { - return nil - } - ioTimeout := c.pveStorageIOTimeout() - for _, storage := range state.pve.probedStorages { - result := state.pve.storageResult(storage) - if result == nil || result.SkipRemaining { - continue - } - if err := c.collectPVEStorageMetadataTextStep(ctx, result, ioTimeout); err != nil { - c.logger.Warning("Failed to write PVE datastore text metadata for %s: %v", storage.Name, err) - state.pve.storageCollectionAborted = true - return nil - } - } - return nil - }, - }, - { - ID: brickPVEStorageBackupAnalysis, - Description: "Analyze PVE backup files for probed storages", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupPVEBackupFiles || state.pve.storageCollectionAborted || len(state.pve.probedStorages) == 0 { - return nil - } - ioTimeout := c.pveStorageIOTimeout() - for _, storage := range state.pve.probedStorages { - result := state.pve.storageResult(storage) - if result == nil || result.SkipRemaining { - continue - } - if err := c.collectPVEStorageBackupAnalysisStep(ctx, result, ioTimeout); err != nil { - c.logger.Warning("Detailed backup analysis for %s failed: %v", storage.Name, err) - } - } - return nil - }, - }, - { - ID: brickPVEStorageSummary, - Description: "Write PVE datastore summary", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupPVEBackupFiles || state.pve.storageCollectionAborted || len(state.pve.probedStorages) == 0 { - return nil - } - if err := c.writePVEStorageSummary(ctx, state.pve.probedStorages); err != nil { - c.logger.Warning("Failed to write PVE datastore summary: %v", err) - state.pve.storageCollectionAborted = true - return nil - } - c.logger.Debug("PVE datastore metadata collection completed (%d processed)", len(state.pve.probedStorages)) - return nil - }, - }, - { - ID: brickPVECephConfigSnapshot, - Description: "Collect Ceph configuration snapshot", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupCephConfig || state.pve.cephCollectionAborted { - return nil - } - c.logger.Debug("Collecting Ceph configuration and status") - if err := c.collectPVECephConfigSnapshot(ctx); err != nil { - c.logger.Warning("Failed to collect Ceph configuration snapshot: %v", err) - state.pve.cephCollectionAborted = true - } - return nil - }, - }, - { - ID: brickPVECephRuntime, - Description: "Collect Ceph runtime information", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupCephConfig || state.pve.cephCollectionAborted { - return nil - } - if err := c.collectPVECephRuntime(ctx); err != nil { - c.logger.Warning("Failed to collect Ceph runtime information: %v", err) - state.pve.cephCollectionAborted = true - } else { - c.logger.Debug("Ceph information collection completed") - } - return nil - }, - }, - { - ID: brickPVEAliasCore, - Description: "Create core PVE aliases", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - c.logger.Debug("Creating PVE info aliases under /var/lib/pve-cluster/info") - if err := c.createPVECoreAliases(ctx); err != nil { - c.logger.Warning("Failed to create PVE core aliases: %v", err) - state.pve.finalizeCollectionAborted = true - } - return nil - }, - }, - { - ID: brickPVEAggregateBackupHistory, - Description: "Aggregate backup history aliases", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if state.pve.finalizeCollectionAborted { - return nil - } - if err := c.createPVEBackupHistoryAggregate(ctx); err != nil { - c.logger.Warning("Failed to aggregate PVE backup history: %v", err) - state.pve.finalizeCollectionAborted = true - } - return nil - }, - }, - { - ID: brickPVEAggregateReplicationStatus, - Description: "Aggregate replication status aliases", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if state.pve.finalizeCollectionAborted { - return nil - } - if err := c.createPVEReplicationAggregate(ctx); err != nil { - c.logger.Warning("Failed to aggregate PVE replication status: %v", err) - state.pve.finalizeCollectionAborted = true - } - return nil - }, - }, - { - ID: brickPVEVersionInfo, - Description: "Write PVE version alias information", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if state.pve.finalizeCollectionAborted { - return nil - } - if err := c.createPVEVersionInfo(ctx); err != nil { - c.logger.Warning("Failed to write PVE version info: %v", err) - state.pve.finalizeCollectionAborted = true - } - return nil - }, - }, - { - ID: brickPVEManifestFinalize, - Description: "Finalize the PVE manifest", - Run: func(_ context.Context, state *collectionState) error { - state.collector.populatePVEManifest() - return nil - }, - }, - }, - } -} - -func newPBSRecipe() recipe { - bricks := []collectionBrick{ - { - ID: brickPBSValidate, - Description: "Validate PBS environment", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - c.logger.Debug("Validating PBS environment before collection") - - pbsConfigPath := c.pbsConfigPath() - if _, err := os.Stat(pbsConfigPath); err != nil { - if errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("not a PBS system: %s not found", pbsConfigPath) - } - return fmt.Errorf("failed to access PBS config path %s: %w", pbsConfigPath, err) - } - c.logger.Debug("Detected %s, proceeding with PBS collection", pbsConfigPath) - return nil - }, - }, - { - ID: brickPBSConfigDirectoryCopy, - Description: "Copy the PBS configuration directory", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSConfigSnapshot(ctx, state.collector.pbsConfigPath()) - }, - }, - { - ID: brickPBSManifestInit, - Description: "Initialize the PBS manifest", - Run: func(_ context.Context, state *collectionState) error { - state.collector.initPBSManifest() - return nil - }, - }, - { - ID: brickPBSDatastoreDiscovery, - Description: "Discover PBS datastores", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - datastores, err := c.getDatastoreList(ctx) - if err != nil { - if ctx.Err() != nil { - return err - } - return fmt.Errorf("failed to detect PBS datastores: %w", err) - } - state.pbs.datastores = datastores - c.logger.Debug("Detected %d PBS datastores", len(datastores)) - - if len(datastores) == 0 { - c.logger.Info("Found 0 PBS datastore(s) via auto-detection") - } else { - summary := make([]string, 0, len(datastores)) - for _, ds := range datastores { - if ds.Path != "" { - summary = append(summary, fmt.Sprintf("%s (%s)", ds.Name, ds.Path)) - } else { - summary = append(summary, ds.Name) - } - } - c.logger.Info("Found %d PBS datastore(s) via auto-detection: %s", len(datastores), strings.Join(summary, ", ")) - } - return nil - }, - }, - } - bricks = append(bricks, newPBSManifestBricks()...) - bricks = append(bricks, newPBSRuntimeBricks()...) - bricks = append(bricks, newPBSInventoryBricks()...) - bricks = append(bricks, newPBSFeatureBricks()...) - bricks = append(bricks, newPBSFinalizeBricks()...) - return recipe{Name: "pbs", Bricks: bricks} -} - -func newPBSCommandsRecipe() recipe { - return recipe{Name: "pbs-commands", Bricks: newPBSRuntimeBricks()} -} - -func newPBSDatastoreInventoryRecipe() recipe { - return recipe{Name: "pbs-inventory", Bricks: newPBSInventoryBricks()} -} - -func newPBSDatastoreConfigRecipe() recipe { - return recipe{Name: "pbs-datastore-config", Bricks: newPBSDatastoreConfigBricks()} -} - -func newPBSPXARRecipe() recipe { - return recipe{Name: "pbs-pxar", Bricks: newPBSPXARBricks()} -} - -func newPBSUserConfigRecipe() recipe { - return recipe{ - Name: "pbs-user-config", - Bricks: []collectionBrick{ - { - ID: BrickID("pbs_access_load_user_ids_from_command_file"), - Description: "Load PBS user IDs from collected command snapshots", - Run: func(_ context.Context, state *collectionState) error { - userIDs, err := state.collector.loadPBSUserIDsFromCommandFile(state.collector.proxsaveCommandsDir("pbs")) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - state.collector.logger.Debug("User list not available for token export: %v", err) - state.pbs.userIDs = nil - return nil - } - state.collector.logger.Debug("Failed to parse user list for token export: %v", err) - state.pbs.userIDs = nil - return nil - } - state.pbs.userIDs = userIDs - return nil - }, - }, - { - ID: brickPBSRuntimeAccessUserTokens, - Description: "Collect PBS API token snapshots", - Run: func(ctx context.Context, state *collectionState) error { - if len(state.pbs.userIDs) == 0 { - return nil - } - usersDir, err := state.collector.ensurePBSAccessControlDir() - if err != nil { - return err - } - return state.collector.collectPBSAccessUserTokensRuntime(ctx, usersDir, state.pbs.userIDs) - }, - }, - { - ID: brickPBSRuntimeAccessTokensAggregate, - Description: "Aggregate PBS API token snapshots", - Run: func(_ context.Context, state *collectionState) error { - if len(state.pbs.userIDs) == 0 { - return nil - } - usersDir, err := state.collector.ensurePBSAccessControlDir() - if err != nil { - return err - } - return state.collector.collectPBSAccessTokensAggregateRuntime(usersDir, state.pbs.userIDs) - }, - }, - }, - } -} - func newDualRecipe() recipe { bricks := append([]collectionBrick{}, newPVERecipe().Bricks...) bricks = append(bricks, newPBSRecipe().Bricks...) @@ -1037,1283 +406,3 @@ func newDualRecipe() recipe { Bricks: bricks, } } - -func newPBSManifestBricks() []collectionBrick { - return []collectionBrick{ - {ID: brickPBSManifestDatastore, Description: "Collect PBS datastore manifest entries", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestDatastore(ctx, state.collector.pbsConfigPath()) - }}, - {ID: brickPBSManifestS3, Description: "Collect PBS S3 manifest entries", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestS3(ctx, state.collector.pbsConfigPath()) - }}, - {ID: brickPBSManifestNode, Description: "Collect PBS node manifest entries", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestNode(ctx, state.collector.pbsConfigPath()) - }}, - {ID: brickPBSManifestACMEAccounts, Description: "Collect PBS ACME account manifest entries", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestACMEAccounts(ctx, state.collector.pbsConfigPath()) - }}, - {ID: brickPBSManifestACMEPlugins, Description: "Collect PBS ACME plugin manifest entries", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestACMEPlugins(ctx, state.collector.pbsConfigPath()) - }}, - {ID: brickPBSManifestMetricServers, Description: "Collect PBS metric server manifest entries", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestMetricServers(ctx, state.collector.pbsConfigPath()) - }}, - {ID: brickPBSManifestTrafficControl, Description: "Collect PBS traffic control manifest entries", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestTrafficControl(ctx, state.collector.pbsConfigPath()) - }}, - {ID: brickPBSManifestNotifications, Description: "Collect PBS notification manifest entries", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestNotifications(ctx, state.collector.pbsConfigPath()) - }}, - {ID: brickPBSManifestNotificationsPriv, Description: "Collect PBS notification secret manifest entries", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestNotificationsPriv(ctx, state.collector.pbsConfigPath()) - }}, - {ID: brickPBSManifestUserCfg, Description: "Collect PBS user manifest entries", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestUserCfg(ctx, state.collector.pbsConfigPath()) - }}, - {ID: brickPBSManifestACLCfg, Description: "Collect PBS ACL manifest entries", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestACLCfg(ctx, state.collector.pbsConfigPath()) - }}, - {ID: brickPBSManifestDomainsCfg, Description: "Collect PBS auth realm manifest entries", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestDomainsCfg(ctx, state.collector.pbsConfigPath()) - }}, - {ID: brickPBSManifestRemote, Description: "Collect PBS remote manifest entries", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestRemote(ctx, state.collector.pbsConfigPath()) - }}, - {ID: brickPBSManifestSyncJobs, Description: "Collect PBS sync-job manifest entries", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestSyncJobs(ctx, state.collector.pbsConfigPath()) - }}, - {ID: brickPBSManifestVerificationJobs, Description: "Collect PBS verification-job manifest entries", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestVerificationJobs(ctx, state.collector.pbsConfigPath()) - }}, - {ID: brickPBSManifestTapeCfg, Description: "Collect PBS tape configuration manifest entry", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestTapeCfg(ctx, state.collector.pbsConfigPath()) - }}, - {ID: brickPBSManifestTapeJobs, Description: "Collect PBS tape job manifest entry", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestTapeJobs(ctx, state.collector.pbsConfigPath()) - }}, - {ID: brickPBSManifestMediaPools, Description: "Collect PBS media pool manifest entry", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestMediaPools(ctx, state.collector.pbsConfigPath()) - }}, - {ID: brickPBSManifestTapeEncryptionKeys, Description: "Collect PBS tape encryption keys manifest entry", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestTapeEncryptionKeys(ctx, state.collector.pbsConfigPath()) - }}, - {ID: brickPBSManifestNetwork, Description: "Collect PBS network manifest entries", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestNetwork(ctx, state.collector.pbsConfigPath()) - }}, - {ID: brickPBSManifestPrune, Description: "Collect PBS prune manifest entries", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestPrune(ctx, state.collector.pbsConfigPath()) - }}, - } -} - -func newPBSRuntimeBricks() []collectionBrick { - return []collectionBrick{ - { - ID: brickPBSRuntimeCore, - Description: "Collect core PBS runtime information", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSCoreRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeNode, - Description: "Collect PBS node runtime information", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSNodeRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeDatastoreList, - Description: "Collect PBS datastore list", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSDatastoreListRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeDatastoreStatus, - Description: "Collect PBS datastore status details", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSDatastoreStatusRuntime(ctx, commandsDir, state.pbs.datastores) - }, - }, - { - ID: brickPBSRuntimeACMEAccountsList, - Description: "Collect the PBS ACME account list", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - ids, err := state.collector.collectPBSAcmeAccountsListRuntime(ctx, commandsDir) - if err != nil { - return err - } - state.pbs.acmeAccountNames = ids - return nil - }, - }, - { - ID: brickPBSRuntimeACMEAccountInfo, - Description: "Collect PBS ACME account details", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSAcmeAccountInfoRuntime(ctx, commandsDir, state.pbs.acmeAccountNames) - }, - }, - { - ID: brickPBSRuntimeACMEPluginsList, - Description: "Collect the PBS ACME plugin list", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - ids, err := state.collector.collectPBSAcmePluginsListRuntime(ctx, commandsDir) - if err != nil { - return err - } - state.pbs.acmePluginIDs = ids - return nil - }, - }, - { - ID: brickPBSRuntimeACMEPluginConfig, - Description: "Collect PBS ACME plugin configuration", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSAcmePluginConfigRuntime(ctx, commandsDir, state.pbs.acmePluginIDs) - }, - }, - { - ID: brickPBSRuntimeNotificationTargets, - Description: "Collect PBS notification targets", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSNotificationTargetsRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeNotificationMatchers, - Description: "Collect PBS notification matchers", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSNotificationMatchersRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeNotificationEndpointSMTP, - Description: "Collect PBS SMTP notification endpoints", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSNotificationEndpointSMTPRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeNotificationEndpointSendmail, - Description: "Collect PBS sendmail notification endpoints", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSNotificationEndpointSendmailRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeNotificationEndpointGotify, - Description: "Collect PBS gotify notification endpoints", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSNotificationEndpointGotifyRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeNotificationEndpointWebhook, - Description: "Collect PBS webhook notification endpoints", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSNotificationEndpointWebhookRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeNotificationSummary, - Description: "Write the PBS notification summary", - Run: func(_ context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - state.collector.writePBSNotificationSummary(commandsDir) - return nil - }, - }, - { - ID: brickPBSRuntimeAccessUsers, - Description: "Collect the PBS user list", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - ids, err := state.collector.collectPBSAccessUsersRuntime(ctx, commandsDir) - if err != nil { - return err - } - state.pbs.userIDs = ids - return nil - }, - }, - { - ID: brickPBSRuntimeAccessRealmsLDAP, - Description: "Collect PBS LDAP realm definitions", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSAccessRealmLDAPRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeAccessRealmsAD, - Description: "Collect PBS Active Directory realm definitions", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSAccessRealmADRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeAccessRealmsOpenID, - Description: "Collect PBS OpenID realm definitions", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSAccessRealmOpenIDRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeAccessACL, - Description: "Collect PBS ACL definitions", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSAccessACLRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeAccessUserTokens, - Description: "Collect PBS API token snapshots", - Run: func(ctx context.Context, state *collectionState) error { - if !state.collector.config.BackupUserConfigs || len(state.pbs.userIDs) == 0 { - return nil - } - usersDir, err := state.collector.ensurePBSAccessControlDir() - if err != nil { - return err - } - return state.collector.collectPBSAccessUserTokensRuntime(ctx, usersDir, state.pbs.userIDs) - }, - }, - { - ID: brickPBSRuntimeAccessTokensAggregate, - Description: "Aggregate PBS API token snapshots", - Run: func(_ context.Context, state *collectionState) error { - if !state.collector.config.BackupUserConfigs || len(state.pbs.userIDs) == 0 { - return nil - } - usersDir, err := state.collector.ensurePBSAccessControlDir() - if err != nil { - return err - } - return state.collector.collectPBSAccessTokensAggregateRuntime(usersDir, state.pbs.userIDs) - }, - }, - { - ID: brickPBSRuntimeRemotes, - Description: "Collect PBS remote definitions", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSRemotesRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeSyncJobs, - Description: "Collect PBS sync jobs", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSSyncJobsRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeVerificationJobs, - Description: "Collect PBS verification jobs", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSVerificationJobsRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimePruneJobs, - Description: "Collect PBS prune jobs", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSPruneJobsRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeGCJobs, - Description: "Collect PBS garbage collection jobs", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSGCJobsRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeTapeDetect, - Description: "Detect PBS tape support", - Run: func(ctx context.Context, state *collectionState) error { - if !state.collector.config.BackupTapeConfigs { - state.pbs.tapeSupportKnown = true - state.pbs.tapeSupported = false - return nil - } - supported, err := state.collector.detectPBSTapeSupport(ctx) - if err != nil { - if ctx.Err() != nil { - return err - } - state.collector.logger.Debug("Skipping tape details collection: %v", err) - state.pbs.tapeSupportKnown = true - state.pbs.tapeSupported = false - return nil - } - state.pbs.tapeSupportKnown = true - state.pbs.tapeSupported = supported - return nil - }, - }, - { - ID: brickPBSRuntimeTapeDrives, - Description: "Collect PBS tape drive inventory", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSTapeDrivesRuntime(ctx, commandsDir, state.pbs.tapeSupportKnown && state.pbs.tapeSupported) - }, - }, - { - ID: brickPBSRuntimeTapeChangers, - Description: "Collect PBS tape changer inventory", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSTapeChangersRuntime(ctx, commandsDir, state.pbs.tapeSupportKnown && state.pbs.tapeSupported) - }, - }, - { - ID: brickPBSRuntimeTapePools, - Description: "Collect PBS tape pool inventory", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSTapePoolsRuntime(ctx, commandsDir, state.pbs.tapeSupportKnown && state.pbs.tapeSupported) - }, - }, - { - ID: brickPBSRuntimeNetwork, - Description: "Collect PBS network runtime information", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSNetworkRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeDisks, - Description: "Collect the PBS disk inventory", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSDisksRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeCertInfo, - Description: "Collect the PBS certificate summary", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSCertInfoRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeTrafficControl, - Description: "Collect PBS traffic control runtime information", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSTrafficControlRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeRecentTasks, - Description: "Collect recent PBS tasks", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSRecentTasksRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeS3Endpoints, - Description: "Collect PBS S3 endpoints", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - ids, err := state.collector.collectPBSS3EndpointsRuntime(ctx, commandsDir) - if err != nil { - return err - } - state.pbs.s3EndpointIDs = ids - return nil - }, - }, - { - ID: brickPBSRuntimeS3EndpointBuckets, - Description: "Collect PBS S3 endpoint bucket inventories", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSS3EndpointBucketsRuntime(ctx, commandsDir, state.pbs.s3EndpointIDs) - }, - }, - } -} - -func newCommonFilesystemBricks() []collectionBrick { - return []collectionBrick{ - { - ID: brickCommonFilesystemFstab, - Description: "Collect the common filesystem table", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectCommonFilesystemFstab(ctx) - }, - }, - } -} - -func newCommonStorageStackBricks() []collectionBrick { - return []collectionBrick{ - { - ID: brickCommonStorageStackCrypttab, - Description: "Collect common storage-stack crypttab data", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectCommonStorageStackCrypttab(ctx) - }, - }, - { - ID: brickCommonStorageStackISCSISnapshot, - Description: "Collect common iSCSI storage-stack data", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectCommonStorageStackISCSISnapshot(ctx) - }, - }, - { - ID: brickCommonStorageStackMultipathSnapshot, - Description: "Collect common multipath storage-stack data", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectCommonStorageStackMultipathSnapshot(ctx) - }, - }, - { - ID: brickCommonStorageStackMDADMSnapshot, - Description: "Collect common mdadm storage-stack data", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectCommonStorageStackMDADMSnapshot(ctx) - }, - }, - { - ID: brickCommonStorageStackLVMSnapshot, - Description: "Collect common LVM storage-stack data", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectCommonStorageStackLVMSnapshot(ctx) - }, - }, - { - ID: brickCommonStorageStackMountUnitsSnapshot, - Description: "Collect common storage-stack mount units", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectCommonStorageStackMountUnitsSnapshot(ctx) - }, - }, - { - ID: brickCommonStorageStackAutofsSnapshot, - Description: "Collect common storage-stack autofs data", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectCommonStorageStackAutofsSnapshot(ctx) - }, - }, - { - ID: brickCommonStorageStackReferencedFiles, - Description: "Collect common storage-stack referenced files", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectCommonStorageStackReferencedFiles(ctx) - }, - }, - } -} - -func newPBSInventoryBricks() []collectionBrick { - return []collectionBrick{ - { - ID: brickPBSInventoryInit, - Description: "Initialize the PBS datastore inventory state", - Run: func(ctx context.Context, state *collectionState) error { - inventory, err := state.collector.initPBSDatastoreInventoryState(ctx, state.pbs.datastores) - if err != nil { - return err - } - state.pbs.inventory = inventory - return nil - }, - }, - { - ID: brickPBSInventoryMountFiles, - Description: "Populate PBS inventory mount files", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.populatePBSInventoryMountFiles(ctx, state.ensurePBSInventoryState()) - }, - }, - { - ID: brickPBSInventoryOSFiles, - Description: "Populate PBS inventory OS files", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.populatePBSInventoryOSFiles(ctx, state.ensurePBSInventoryState()) - }, - }, - { - ID: brickPBSInventoryMultipathFiles, - Description: "Populate PBS inventory multipath files", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.populatePBSInventoryMultipathFiles(ctx, state.ensurePBSInventoryState()) - }, - }, - { - ID: brickPBSInventoryISCSIFiles, - Description: "Populate PBS inventory iSCSI files", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.populatePBSInventoryISCSIFiles(ctx, state.ensurePBSInventoryState()) - }, - }, - { - ID: brickPBSInventoryAutofsFiles, - Description: "Populate PBS inventory autofs files", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.populatePBSInventoryAutofsFiles(ctx, state.ensurePBSInventoryState()) - }, - }, - { - ID: brickPBSInventoryZFSFiles, - Description: "Populate PBS inventory ZFS files", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.populatePBSInventoryZFSFiles(ctx, state.ensurePBSInventoryState()) - }, - }, - { - ID: brickPBSInventoryLVMDirs, - Description: "Populate PBS inventory LVM directories", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.populatePBSInventoryLVMDirs(ctx, state.ensurePBSInventoryState()) - }, - }, - { - ID: brickPBSInventorySystemdMountUnits, - Description: "Populate PBS inventory systemd mount units", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.populatePBSInventorySystemdMountUnits(ctx, state.ensurePBSInventoryState()) - }, - }, - { - ID: brickPBSInventoryReferencedFiles, - Description: "Populate PBS inventory referenced files", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.populatePBSInventoryReferencedFiles(ctx, state.ensurePBSInventoryState()) - }, - }, - { - ID: brickPBSInventoryHostCommandsCore, - Description: "Populate PBS inventory core host commands", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.populatePBSInventoryHostCommandsCore(ctx, state.ensurePBSInventoryState()) - }, - }, - { - ID: brickPBSInventoryHostCommandsDMSetup, - Description: "Populate PBS inventory dmsetup host commands", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.populatePBSInventoryHostCommandsDMSetup(ctx, state.ensurePBSInventoryState()) - }, - }, - { - ID: brickPBSInventoryHostCommandsLVM, - Description: "Populate PBS inventory LVM host commands", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.populatePBSInventoryHostCommandsLVM(ctx, state.ensurePBSInventoryState()) - }, - }, - { - ID: brickPBSInventoryHostCommandsMDADM, - Description: "Populate PBS inventory mdadm host commands", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.populatePBSInventoryHostCommandsMDADM(ctx, state.ensurePBSInventoryState()) - }, - }, - { - ID: brickPBSInventoryHostCommandsMultipath, - Description: "Populate PBS inventory multipath host commands", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.populatePBSInventoryHostCommandsMultipath(ctx, state.ensurePBSInventoryState()) - }, - }, - { - ID: brickPBSInventoryHostCommandsISCSI, - Description: "Populate PBS inventory iSCSI host commands", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.populatePBSInventoryHostCommandsISCSI(ctx, state.ensurePBSInventoryState()) - }, - }, - { - ID: brickPBSInventoryHostCommandsZFS, - Description: "Populate PBS inventory ZFS host commands", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.populatePBSInventoryHostCommandsZFS(ctx, state.ensurePBSInventoryState()) - }, - }, - { - ID: brickPBSInventoryCommandFiles, - Description: "Populate PBS inventory with collected PBS command files", - Run: func(_ context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.populatePBSInventoryCommandFiles(state.ensurePBSInventoryState(), commandsDir) - }, - }, - { - ID: brickPBSInventoryDatastores, - Description: "Populate PBS datastore inventory entries", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.populatePBSDatastoreInventoryEntries(ctx, state.ensurePBSInventoryState()) - }, - }, - { - ID: brickPBSInventoryWrite, - Description: "Write the PBS datastore inventory report", - Run: func(_ context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.writePBSInventoryState(state.ensurePBSInventoryState(), commandsDir) - }, - }, - } -} - -func newPBSDatastoreConfigBricks() []collectionBrick { - return []collectionBrick{ - { - ID: brickPBSDatastoreCLIConfigs, - Description: "Collect PBS datastore CLI configuration files", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupDatastoreConfigs { - c.logger.Skip("PBS datastore configuration backup disabled.") - return nil - } - cfgState, err := state.ensurePBSDatastoreConfigState() - if err != nil { - c.logger.Warning("Failed to prepare datastore config state: %v", err) - return nil - } - if err := c.collectPBSDatastoreCLIConfigs(ctx, cfgState); err != nil { - c.logger.Warning("Failed to collect datastore CLI configs: %v", err) - } - return nil - }, - }, - { - ID: brickPBSDatastoreNamespaces, - Description: "Collect PBS datastore namespace inventories", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupDatastoreConfigs { - return nil - } - cfgState, err := state.ensurePBSDatastoreConfigState() - if err != nil { - c.logger.Warning("Failed to prepare datastore config state: %v", err) - return nil - } - if err := c.collectPBSDatastoreNamespaces(ctx, cfgState); err != nil { - c.logger.Warning("Failed to collect datastore namespaces: %v", err) - } - return nil - }, - }, - } -} - -func newPBSPXARBricks() []collectionBrick { - return []collectionBrick{ - { - ID: brickPBSPXARPrepare, - Description: "Prepare PBS PXAR state", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupPxarFiles { - c.logger.Skip("PBS PXAR metadata collection disabled.") - return nil - } - pxarState, err := state.ensurePBSPXARState(ctx) - if err != nil { - c.logger.Warning("Failed to prepare PBS PXAR state: %v", err) - return nil - } - state.pbs.pxar = pxarState - return nil - }, - }, - { - ID: brickPBSPXARMetadata, - Description: "Collect PBS PXAR metadata snapshots", - Run: func(ctx context.Context, state *collectionState) error { - if !state.collector.config.BackupPxarFiles { - return nil - } - pxarState, err := state.ensurePBSPXARState(ctx) - if err != nil { - state.collector.logger.Warning("Failed to prepare PBS PXAR state: %v", err) - return nil - } - if err := state.collector.collectPBSPXARMetadataStep(ctx, pxarState); err != nil { - state.collector.logger.Warning("Failed to collect PBS PXAR metadata: %v", err) - } - return nil - }, - }, - { - ID: brickPBSPXARSubdirReports, - Description: "Collect PBS PXAR subdirectory reports", - Run: func(ctx context.Context, state *collectionState) error { - if !state.collector.config.BackupPxarFiles { - return nil - } - pxarState, err := state.ensurePBSPXARState(ctx) - if err != nil { - state.collector.logger.Warning("Failed to prepare PBS PXAR state: %v", err) - return nil - } - if err := state.collector.collectPBSPXARSubdirReportsStep(ctx, pxarState); err != nil { - state.collector.logger.Warning("Failed to collect PBS PXAR subdir reports: %v", err) - } - return nil - }, - }, - { - ID: brickPBSPXARVMLists, - Description: "Collect PBS PXAR VM reports", - Run: func(ctx context.Context, state *collectionState) error { - if !state.collector.config.BackupPxarFiles { - return nil - } - pxarState, err := state.ensurePBSPXARState(ctx) - if err != nil { - state.collector.logger.Warning("Failed to prepare PBS PXAR state: %v", err) - return nil - } - if err := state.collector.collectPBSPXARVMListsStep(ctx, pxarState); err != nil { - state.collector.logger.Warning("Failed to collect PBS PXAR VM lists: %v", err) - } - return nil - }, - }, - { - ID: brickPBSPXARCTLists, - Description: "Collect PBS PXAR CT reports", - Run: func(ctx context.Context, state *collectionState) error { - if !state.collector.config.BackupPxarFiles { - return nil - } - pxarState, err := state.ensurePBSPXARState(ctx) - if err != nil { - state.collector.logger.Warning("Failed to prepare PBS PXAR state: %v", err) - return nil - } - if err := state.collector.collectPBSPXARCTListsStep(ctx, pxarState); err != nil { - state.collector.logger.Warning("Failed to collect PBS PXAR CT lists: %v", err) - } - return nil - }, - }, - } -} - -func newPBSFeatureBricks() []collectionBrick { - bricks := append([]collectionBrick{}, newPBSDatastoreConfigBricks()...) - bricks = append(bricks, newPBSPXARBricks()...) - return bricks -} - -func newPBSFinalizeBricks() []collectionBrick { - return []collectionBrick{ - { - ID: brickPBSFinalizeSummary, - Description: "Finalize PBS collection state", - Run: func(_ context.Context, state *collectionState) error { - c := state.collector - c.logger.Info("PBS collection summary:") - c.logger.Info(" Files collected: %d", c.stats.FilesProcessed) - c.logger.Info(" Files not found: %d", c.stats.FilesNotFound) - if c.stats.FilesFailed > 0 { - c.logger.Warning(" Files failed: %d", c.stats.FilesFailed) - } - c.logger.Debug(" Files skipped: %d", c.stats.FilesSkipped) - c.logger.Debug(" Bytes collected: %d", c.stats.BytesCollected) - return nil - }, - }, - } -} - -func newSystemRecipe() recipe { - systemCommandsBrick := func(id BrickID, description string, run func(*Collector, context.Context, string) error) collectionBrick { - return collectionBrick{ - ID: id, - Description: description, - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensureSystemCommandsDir() - if err != nil { - return err - } - return run(state.collector, ctx, commandsDir) - }, - } - } - - bricks := []collectionBrick{ - {ID: brickSystemNetworkStatic, Description: "Collect static network configuration", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectSystemNetworkStatic(ctx) - }}, - {ID: brickSystemIdentityStatic, Description: "Collect static identity files", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectSystemIdentityStatic(ctx) - }}, - {ID: brickSystemAptStatic, Description: "Collect static APT configuration", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectSystemAptStatic(ctx) - }}, - {ID: brickSystemCronStatic, Description: "Collect static cron configuration", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectSystemCronStatic(ctx) - }}, - {ID: brickSystemServicesStatic, Description: "Collect static service configuration", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectSystemServicesStatic(ctx) - }}, - {ID: brickSystemLoggingStatic, Description: "Collect static logging configuration", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectSystemLoggingStatic(ctx) - }}, - {ID: brickSystemSSLStatic, Description: "Collect static SSL configuration", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectSystemSSLStatic(ctx) - }}, - {ID: brickSystemSysctlStatic, Description: "Collect static sysctl configuration", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectSystemSysctlStatic(ctx) - }}, - {ID: brickSystemKernelModulesStatic, Description: "Collect static kernel module configuration", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectSystemKernelModuleStatic(ctx) - }}, - } - bricks = append(bricks, newCommonFilesystemBricks()...) - bricks = append(bricks, newCommonStorageStackBricks()...) - bricks = append(bricks, []collectionBrick{ - {ID: brickSystemZFSStatic, Description: "Collect static ZFS configuration", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectSystemZFSStatic(ctx) - }}, - {ID: brickSystemFirewallStatic, Description: "Collect static firewall configuration", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectSystemFirewallStatic(ctx) - }}, - {ID: brickSystemRuntimeLeases, Description: "Collect runtime lease snapshots", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectSystemRuntimeLeases(ctx) - }}, - {ID: brickSystemCoreRuntime, Description: "Collect core system runtime information", Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensureSystemCommandsDir() - if err != nil { - return err - } - return state.collector.collectSystemCoreRuntime(ctx, commandsDir) - }}, - systemCommandsBrick(brickSystemNetworkRuntimeAddr, "Collect network address runtime information", (*Collector).collectSystemNetworkAddrRuntime), - systemCommandsBrick(brickSystemNetworkRuntimeRules, "Collect network rule runtime information", (*Collector).collectSystemNetworkRulesRuntime), - systemCommandsBrick(brickSystemNetworkRuntimeRoutes, "Collect network route runtime information", (*Collector).collectSystemNetworkRoutesRuntime), - systemCommandsBrick(brickSystemNetworkRuntimeLinks, "Collect network link runtime information", (*Collector).collectSystemNetworkLinksRuntime), - systemCommandsBrick(brickSystemNetworkRuntimeNeighbors, "Collect network neighbor runtime information", (*Collector).collectSystemNetworkNeighborsRuntime), - systemCommandsBrick(brickSystemNetworkRuntimeBridges, "Collect bridge runtime information", (*Collector).collectSystemNetworkBridgesRuntime), - systemCommandsBrick(brickSystemNetworkRuntimeInventory, "Collect network inventory runtime information", (*Collector).collectSystemNetworkInventoryRuntime), - systemCommandsBrick(brickSystemNetworkRuntimeBonding, "Collect bonding runtime information", (*Collector).collectSystemNetworkBondingRuntime), - systemCommandsBrick(brickSystemNetworkRuntimeDNS, "Collect DNS runtime information", (*Collector).collectSystemNetworkDNSRuntime), - {ID: brickSystemStorageRuntimeMounts, Description: "Collect storage mount runtime information", Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensureSystemCommandsDir() - if err != nil { - return err - } - return state.collector.collectSystemStorageMountsRuntime(ctx, commandsDir) - }}, - {ID: brickSystemStorageRuntimeBlock, Description: "Collect block device runtime information", Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensureSystemCommandsDir() - if err != nil { - return err - } - return state.collector.collectSystemStorageBlockDevicesRuntime(ctx, commandsDir) - }}, - {ID: brickSystemComputeRuntimeMemoryCPU, Description: "Collect memory and CPU runtime information", Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensureSystemCommandsDir() - if err != nil { - return err - } - return state.collector.collectSystemComputeMemoryCPURuntime(ctx, commandsDir) - }}, - {ID: brickSystemComputeRuntimeBusInv, Description: "Collect bus inventory runtime information", Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensureSystemCommandsDir() - if err != nil { - return err - } - return state.collector.collectSystemComputeBusInventoryRuntime(ctx, commandsDir) - }}, - {ID: brickSystemServicesRuntime, Description: "Collect service runtime information", Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensureSystemCommandsDir() - if err != nil { - return err - } - return state.collector.collectSystemServicesRuntime(ctx, commandsDir) - }}, - {ID: brickSystemPackagesRuntimeInstalled, Description: "Collect installed package runtime information", Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensureSystemCommandsDir() - if err != nil { - return err - } - return state.collector.collectSystemPackagesInstalledRuntime(ctx, commandsDir) - }}, - {ID: brickSystemPackagesRuntimeAPTPolicy, Description: "Collect APT policy runtime information", Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensureSystemCommandsDir() - if err != nil { - return err - } - return state.collector.collectSystemPackagesAptPolicyRuntime(ctx, commandsDir) - }}, - systemCommandsBrick(brickSystemFirewallRuntimeIPTables, "Collect iptables runtime information", (*Collector).collectSystemFirewallIPTablesRuntime), - systemCommandsBrick(brickSystemFirewallRuntimeIP6Tables, "Collect ip6tables runtime information", (*Collector).collectSystemFirewallIP6TablesRuntime), - systemCommandsBrick(brickSystemFirewallRuntimeNFTables, "Collect nftables runtime information", (*Collector).collectSystemFirewallNFTablesRuntime), - systemCommandsBrick(brickSystemFirewallRuntimeUFW, "Collect UFW runtime information", (*Collector).collectSystemFirewallUFWRuntime), - systemCommandsBrick(brickSystemFirewallRuntimeFirewalld, "Collect firewalld runtime information", (*Collector).collectSystemFirewallFirewalldRuntime), - {ID: brickSystemKernelModulesRuntime, Description: "Collect kernel module runtime information", Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensureSystemCommandsDir() - if err != nil { - return err - } - return state.collector.collectSystemKernelModulesRuntime(ctx, commandsDir) - }}, - {ID: brickSystemSysctlRuntime, Description: "Collect sysctl runtime information", Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensureSystemCommandsDir() - if err != nil { - return err - } - return state.collector.collectSystemSysctlRuntime(ctx, commandsDir) - }}, - {ID: brickSystemZFSRuntime, Description: "Collect ZFS runtime information", Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensureSystemCommandsDir() - if err != nil { - return err - } - return state.collector.collectSystemZFSRuntime(ctx, commandsDir) - }}, - {ID: brickSystemLVMRuntime, Description: "Collect LVM runtime information", Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensureSystemCommandsDir() - if err != nil { - return err - } - return state.collector.collectSystemLVMRuntime(ctx, commandsDir) - }}, - {ID: brickSystemNetworkReport, Description: "Finalize derived system reports", Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensureSystemCommandsDir() - if err != nil { - return err - } - if err := state.collector.finalizeSystemRuntimeReports(ctx, commandsDir); err != nil { - state.collector.logger.Debug("Network report generation failed: %v", err) - } - return nil - }}, - { - ID: brickSystemKernel, - Description: "Collect kernel information", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - c.logger.Debug("Collecting kernel information (uname/modules)") - if err := c.collectKernelInfo(ctx); err != nil { - c.logger.Warning("Failed to collect kernel info: %v", err) - } else { - c.logger.Debug("Kernel information collected successfully") - } - return nil - }, - }, - { - ID: brickSystemHardware, - Description: "Collect hardware information", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - c.logger.Debug("Collecting hardware inventory (CPU/memory/devices)") - if err := c.collectHardwareInfo(ctx); err != nil { - c.logger.Warning("Failed to collect hardware info: %v", err) - } else { - c.logger.Debug("Hardware inventory collected successfully") - } - return nil - }, - }, - { - ID: brickSystemCriticalFiles, - Description: "Collect critical system files", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if c.config.BackupCriticalFiles { - c.logger.Debug("Collecting critical files specified in configuration") - if err := c.collectCriticalFiles(ctx); err != nil { - c.logger.Warning("Failed to collect critical files: %v", err) - } else { - c.logger.Debug("Critical files collected successfully") - } - } - return nil - }, - }, - { - ID: brickSystemConfigFile, - Description: "Collect backup configuration file", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if c.config.BackupConfigFile { - c.logger.Debug("Collecting backup configuration file") - if err := c.collectConfigFile(ctx); err != nil { - c.logger.Warning("Failed to collect backup configuration file: %v", err) - } else { - c.logger.Debug("Backup configuration file collected successfully") - } - } - return nil - }, - }, - { - ID: brickSystemCustomPaths, - Description: "Collect custom backup paths", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if len(c.config.CustomBackupPaths) > 0 { - c.logger.Debug("Collecting custom paths: %v", c.config.CustomBackupPaths) - if err := c.collectCustomPaths(ctx); err != nil { - c.logger.Warning("Failed to collect custom paths: %v", err) - } else { - c.logger.Debug("Custom paths collected successfully") - } - } - return nil - }, - }, - { - ID: brickSystemScriptDirs, - Description: "Collect script directories", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if c.config.BackupScriptDir { - c.logger.Debug("Collecting script directories (/usr/local/bin,/usr/local/sbin)") - if err := c.collectScriptDirectories(ctx); err != nil { - c.logger.Warning("Failed to collect script directories: %v", err) - } else { - c.logger.Debug("Script directories collected successfully") - } - } - return nil - }, - }, - { - ID: brickSystemScriptRepo, - Description: "Collect script repository", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if c.config.BackupScriptRepository { - c.logger.Debug("Collecting script repository from %s", c.config.ScriptRepositoryPath) - if err := c.collectScriptRepository(ctx); err != nil { - c.logger.Warning("Failed to collect script repository: %v", err) - } else { - c.logger.Debug("Script repository collected successfully") - } - } - return nil - }, - }, - { - ID: brickSystemSSHKeys, - Description: "Collect SSH keys", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if c.config.BackupSSHKeys { - c.logger.Debug("Collecting SSH keys for root and users") - if err := c.collectSSHKeys(ctx); err != nil { - c.logger.Warning("Failed to collect SSH keys: %v", err) - } else { - c.logger.Debug("SSH keys collected successfully") - } - } - return nil - }, - }, - { - ID: brickSystemRootHome, - Description: "Collect root home directory", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if c.config.BackupRootHome { - c.logger.Debug("Collecting /root home directory") - if err := c.collectRootHome(ctx); err != nil { - c.logger.Warning("Failed to collect root home files: %v", err) - } else { - c.logger.Debug("Root home directory collected successfully") - } - } - return nil - }, - }, - { - ID: brickSystemUserHomes, - Description: "Collect user home directories", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if c.config.BackupUserHomes { - c.logger.Debug("Collecting user home directories under /home") - if err := c.collectUserHomes(ctx); err != nil { - c.logger.Warning("Failed to collect user home directories: %v", err) - } else { - c.logger.Debug("User home directories collected successfully") - } - } - return nil - }, - }, - }...) - return recipe{Name: "system", Bricks: bricks} -} - -func (p pveContext) runtimeNodes() []string { - if p.runtimeInfo == nil { - return nil - } - return p.runtimeInfo.Nodes -} - -func (p pveContext) runtimeStorages() []pveStorageEntry { - if p.runtimeInfo == nil { - return nil - } - return p.runtimeInfo.Storages -} - -func (p *pveContext) ensureStorageScanResults() map[string]*pveStorageScanResult { - if p.storageScanResults == nil { - p.storageScanResults = make(map[string]*pveStorageScanResult) - } - return p.storageScanResults -} - -func (p pveContext) storageResult(storage pveStorageEntry) *pveStorageScanResult { - if p.storageScanResults == nil { - return nil - } - return p.storageScanResults[storage.pathKey()] -} diff --git a/internal/backup/collector_bricks_common.go b/internal/backup/collector_bricks_common.go new file mode 100644 index 00000000..1912384a --- /dev/null +++ b/internal/backup/collector_bricks_common.go @@ -0,0 +1,21 @@ +// Package backup provides collection, archive, and verification logic for ProxSave backups. +package backup + +func newCommonFilesystemBricks() []collectionBrick { + return []collectionBrick{ + collectorBrick(brickCommonFilesystemFstab, "Collect the common filesystem table", (*Collector).collectCommonFilesystemFstab), + } +} + +func newCommonStorageStackBricks() []collectionBrick { + return []collectionBrick{ + collectorBrick(brickCommonStorageStackCrypttab, "Collect common storage-stack crypttab data", (*Collector).collectCommonStorageStackCrypttab), + collectorBrick(brickCommonStorageStackISCSISnapshot, "Collect common iSCSI storage-stack data", (*Collector).collectCommonStorageStackISCSISnapshot), + collectorBrick(brickCommonStorageStackMultipathSnapshot, "Collect common multipath storage-stack data", (*Collector).collectCommonStorageStackMultipathSnapshot), + collectorBrick(brickCommonStorageStackMDADMSnapshot, "Collect common mdadm storage-stack data", (*Collector).collectCommonStorageStackMDADMSnapshot), + collectorBrick(brickCommonStorageStackLVMSnapshot, "Collect common LVM storage-stack data", (*Collector).collectCommonStorageStackLVMSnapshot), + collectorBrick(brickCommonStorageStackMountUnitsSnapshot, "Collect common storage-stack mount units", (*Collector).collectCommonStorageStackMountUnitsSnapshot), + collectorBrick(brickCommonStorageStackAutofsSnapshot, "Collect common storage-stack autofs data", (*Collector).collectCommonStorageStackAutofsSnapshot), + collectorBrick(brickCommonStorageStackReferencedFiles, "Collect common storage-stack referenced files", (*Collector).collectCommonStorageStackReferencedFiles), + } +} diff --git a/internal/backup/collector_bricks_pbs.go b/internal/backup/collector_bricks_pbs.go new file mode 100644 index 00000000..87a96d3f --- /dev/null +++ b/internal/backup/collector_bricks_pbs.go @@ -0,0 +1,280 @@ +// Package backup provides collection, archive, and verification logic for ProxSave backups. +package backup + +import ( + "context" + "errors" + "fmt" + "os" + "strings" +) + +func newPBSRecipe() recipe { + bricks := []collectionBrick{ + { + ID: brickPBSValidate, + Description: "Validate PBS environment", + Run: func(_ context.Context, state *collectionState) error { + c := state.collector + c.logger.Debug("Validating PBS environment before collection") + + pbsConfigPath := c.pbsConfigPath() + if _, err := os.Stat(pbsConfigPath); err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("not a PBS system: %s not found", pbsConfigPath) + } + return fmt.Errorf("failed to access PBS config path %s: %w", pbsConfigPath, err) + } + c.logger.Debug("Detected %s, proceeding with PBS collection", pbsConfigPath) + return nil + }, + }, + { + ID: brickPBSConfigDirectoryCopy, + Description: "Copy the PBS configuration directory", + Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSConfigSnapshot(ctx, state.collector.pbsConfigPath()) + }, + }, + { + ID: brickPBSManifestInit, + Description: "Initialize the PBS manifest", + Run: func(_ context.Context, state *collectionState) error { + state.collector.initPBSManifest() + return nil + }, + }, + { + ID: brickPBSDatastoreDiscovery, + Description: "Discover PBS datastores", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + datastores, err := c.getDatastoreList(ctx) + if err != nil { + if ctx.Err() != nil { + return err + } + return fmt.Errorf("failed to detect PBS datastores: %w", err) + } + state.pbs.datastores = datastores + c.logger.Debug("Detected %d PBS datastores", len(datastores)) + + if len(datastores) == 0 { + c.logger.Info("Found 0 PBS datastore(s) via auto-detection") + } else { + summary := make([]string, 0, len(datastores)) + for _, ds := range datastores { + if ds.Path != "" { + summary = append(summary, fmt.Sprintf("%s (%s)", ds.Name, ds.Path)) + } else { + summary = append(summary, ds.Name) + } + } + c.logger.Info("Found %d PBS datastore(s) via auto-detection: %s", len(datastores), strings.Join(summary, ", ")) + } + return nil + }, + }, + } + bricks = append(bricks, newPBSManifestBricks()...) + bricks = append(bricks, newPBSRuntimeBricks()...) + bricks = append(bricks, newPBSInventoryBricks()...) + bricks = append(bricks, newPBSFeatureBricks()...) + bricks = append(bricks, newPBSFinalizeBricks()...) + return recipe{Name: "pbs", Bricks: bricks} +} + +func newPBSCommandsRecipe() recipe { + return recipe{Name: "pbs-commands", Bricks: newPBSRuntimeBricks()} +} + +func newPBSDatastoreInventoryRecipe() recipe { + return recipe{Name: "pbs-inventory", Bricks: newPBSInventoryBricks()} +} + +func newPBSDatastoreConfigRecipe() recipe { + return recipe{Name: "pbs-datastore-config", Bricks: newPBSDatastoreConfigBricks()} +} + +func newPBSPXARRecipe() recipe { + return recipe{Name: "pbs-pxar", Bricks: newPBSPXARBricks()} +} + +func newPBSUserConfigRecipe() recipe { + return recipe{ + Name: "pbs-user-config", + Bricks: []collectionBrick{ + { + ID: BrickID("pbs_access_load_user_ids_from_command_file"), + Description: "Load PBS user IDs from collected command snapshots", + Run: func(_ context.Context, state *collectionState) error { + userIDs, err := state.collector.loadPBSUserIDsFromCommandFile(state.collector.proxsaveCommandsDir("pbs")) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + state.collector.logger.Debug("User list not available for token export: %v", err) + state.pbs.userIDs = nil + return nil + } + state.collector.logger.Warning("Failed to parse user list for token export: %v", err) + state.pbs.userIDs = nil + return nil + } + state.pbs.userIDs = userIDs + return nil + }, + }, + { + ID: brickPBSRuntimeAccessUserTokens, + Description: "Collect PBS API token snapshots", + Run: func(ctx context.Context, state *collectionState) error { + if len(state.pbs.userIDs) == 0 { + return nil + } + usersDir, err := state.collector.ensurePBSAccessControlDir() + if err != nil { + return err + } + return state.collector.collectPBSAccessUserTokensRuntime(ctx, usersDir, state.pbs.userIDs) + }, + }, + { + ID: brickPBSRuntimeAccessTokensAggregate, + Description: "Aggregate PBS API token snapshots", + Run: func(_ context.Context, state *collectionState) error { + if len(state.pbs.userIDs) == 0 { + return nil + } + usersDir, err := state.collector.ensurePBSAccessControlDir() + if err != nil { + return err + } + return state.collector.collectPBSAccessTokensAggregateRuntime(usersDir, state.pbs.userIDs) + }, + }, + }, + } +} + +func newPBSManifestBricks() []collectionBrick { + bricks := []collectionBrick{} + bricks = append(bricks, newPBSManifestDatastoreNodeBricks()...) + bricks = append(bricks, newPBSManifestACMEAndMetricsBricks()...) + bricks = append(bricks, newPBSManifestNotificationAccessBricks()...) + bricks = append(bricks, newPBSManifestRemoteJobBricks()...) + bricks = append(bricks, newPBSManifestTapeAndNetworkBricks()...) + return bricks +} +func newPBSManifestDatastoreNodeBricks() []collectionBrick { + return []collectionBrick{ + {ID: brickPBSManifestDatastore, Description: "Collect PBS datastore manifest entries", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestDatastore(ctx, state.collector.pbsConfigPath()) + }}, + {ID: brickPBSManifestS3, Description: "Collect PBS S3 manifest entries", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestS3(ctx, state.collector.pbsConfigPath()) + }}, + {ID: brickPBSManifestNode, Description: "Collect PBS node manifest entries", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestNode(ctx, state.collector.pbsConfigPath()) + }}, + } +} + +func newPBSManifestACMEAndMetricsBricks() []collectionBrick { + return []collectionBrick{ + {ID: brickPBSManifestACMEAccounts, Description: "Collect PBS ACME account manifest entries", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestACMEAccounts(ctx, state.collector.pbsConfigPath()) + }}, + {ID: brickPBSManifestACMEPlugins, Description: "Collect PBS ACME plugin manifest entries", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestACMEPlugins(ctx, state.collector.pbsConfigPath()) + }}, + {ID: brickPBSManifestMetricServers, Description: "Collect PBS metric server manifest entries", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestMetricServers(ctx, state.collector.pbsConfigPath()) + }}, + {ID: brickPBSManifestTrafficControl, Description: "Collect PBS traffic control manifest entries", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestTrafficControl(ctx, state.collector.pbsConfigPath()) + }}, + } +} + +func newPBSManifestNotificationAccessBricks() []collectionBrick { + return []collectionBrick{ + {ID: brickPBSManifestNotifications, Description: "Collect PBS notification manifest entries", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestNotifications(ctx, state.collector.pbsConfigPath()) + }}, + {ID: brickPBSManifestNotificationsPriv, Description: "Collect PBS notification secret manifest entries", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestNotificationsPriv(ctx, state.collector.pbsConfigPath()) + }}, + {ID: brickPBSManifestUserCfg, Description: "Collect PBS user manifest entries", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestUserCfg(ctx, state.collector.pbsConfigPath()) + }}, + {ID: brickPBSManifestACLCfg, Description: "Collect PBS ACL manifest entries", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestACLCfg(ctx, state.collector.pbsConfigPath()) + }}, + {ID: brickPBSManifestDomainsCfg, Description: "Collect PBS auth realm manifest entries", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestDomainsCfg(ctx, state.collector.pbsConfigPath()) + }}, + } +} + +func newPBSManifestRemoteJobBricks() []collectionBrick { + return []collectionBrick{ + {ID: brickPBSManifestRemote, Description: "Collect PBS remote manifest entries", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestRemote(ctx, state.collector.pbsConfigPath()) + }}, + {ID: brickPBSManifestSyncJobs, Description: "Collect PBS sync-job manifest entries", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestSyncJobs(ctx, state.collector.pbsConfigPath()) + }}, + {ID: brickPBSManifestVerificationJobs, Description: "Collect PBS verification-job manifest entries", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestVerificationJobs(ctx, state.collector.pbsConfigPath()) + }}, + } +} + +func newPBSManifestTapeAndNetworkBricks() []collectionBrick { + return []collectionBrick{ + {ID: brickPBSManifestTapeCfg, Description: "Collect PBS tape configuration manifest entry", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestTapeCfg(ctx, state.collector.pbsConfigPath()) + }}, + {ID: brickPBSManifestTapeJobs, Description: "Collect PBS tape job manifest entry", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestTapeJobs(ctx, state.collector.pbsConfigPath()) + }}, + {ID: brickPBSManifestMediaPools, Description: "Collect PBS media pool manifest entry", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestMediaPools(ctx, state.collector.pbsConfigPath()) + }}, + {ID: brickPBSManifestTapeEncryptionKeys, Description: "Collect PBS tape encryption keys manifest entry", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestTapeEncryptionKeys(ctx, state.collector.pbsConfigPath()) + }}, + {ID: brickPBSManifestNetwork, Description: "Collect PBS network manifest entries", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestNetwork(ctx, state.collector.pbsConfigPath()) + }}, + {ID: brickPBSManifestPrune, Description: "Collect PBS prune manifest entries", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestPrune(ctx, state.collector.pbsConfigPath()) + }}, + } +} + +func newPBSFeatureBricks() []collectionBrick { + bricks := append([]collectionBrick{}, newPBSDatastoreConfigBricks()...) + bricks = append(bricks, newPBSPXARBricks()...) + return bricks +} + +func newPBSFinalizeBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPBSFinalizeSummary, + Description: "Finalize PBS collection state", + Run: func(_ context.Context, state *collectionState) error { + c := state.collector + c.logger.Info("PBS collection summary:") + c.logger.Info(" Files collected: %d", c.stats.FilesProcessed) + c.logger.Info(" Files not found: %d", c.stats.FilesNotFound) + if c.stats.FilesFailed > 0 { + c.logger.Warning(" Files failed: %d", c.stats.FilesFailed) + } + c.logger.Debug(" Files skipped: %d", c.stats.FilesSkipped) + c.logger.Debug(" Bytes collected: %d", c.stats.BytesCollected) + return nil + }, + }, + } +} diff --git a/internal/backup/collector_bricks_pbs_features.go b/internal/backup/collector_bricks_pbs_features.go new file mode 100644 index 00000000..16f98ae6 --- /dev/null +++ b/internal/backup/collector_bricks_pbs_features.go @@ -0,0 +1,172 @@ +// Package backup provides collection, archive, and verification logic for ProxSave backups. +package backup + +import "context" + +func newPBSDatastoreConfigBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPBSDatastoreCLIConfigs, + Description: "Collect PBS datastore CLI configuration files", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupDatastoreConfigs { + c.logger.Skip("PBS datastore configuration backup disabled.") + return nil + } + cfgState, err := state.ensurePBSDatastoreConfigState() + if err != nil { + c.logger.Warning("Failed to prepare datastore config state: %v", err) + return nil + } + if err := c.collectPBSDatastoreCLIConfigs(ctx, cfgState); err != nil { + c.logger.Warning("Failed to collect datastore CLI configs: %v", err) + } + return nil + }, + }, + { + ID: brickPBSDatastoreNamespaces, + Description: "Collect PBS datastore namespace inventories", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupDatastoreConfigs { + return nil + } + cfgState, err := state.ensurePBSDatastoreConfigState() + if err != nil { + c.logger.Warning("Failed to prepare datastore config state: %v", err) + return nil + } + if err := c.collectPBSDatastoreNamespaces(ctx, cfgState); err != nil { + c.logger.Warning("Failed to collect datastore namespaces: %v", err) + } + return nil + }, + }, + } +} + +func newPBSPXARBricks() []collectionBrick { + bricks := []collectionBrick{} + bricks = append(bricks, newPBSPXARPrepareBricks()...) + bricks = append(bricks, newPBSPXARMetadataBricks()...) + bricks = append(bricks, newPBSPXARSubdirReportBricks()...) + bricks = append(bricks, newPBSPXARVMListBricks()...) + bricks = append(bricks, newPBSPXARCTListBricks()...) + return bricks +} +func newPBSPXARPrepareBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPBSPXARPrepare, + Description: "Prepare PBS PXAR state", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupPxarFiles { + c.logger.Skip("PBS PXAR metadata collection disabled.") + return nil + } + pxarState, err := state.ensurePBSPXARState(ctx) + if err != nil { + c.logger.Warning("Failed to prepare PBS PXAR state: %v", err) + return nil + } + state.pbs.pxar = pxarState + return nil + }, + }, + } +} + +func newPBSPXARMetadataBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPBSPXARMetadata, + Description: "Collect PBS PXAR metadata snapshots", + Run: func(ctx context.Context, state *collectionState) error { + if !state.collector.config.BackupPxarFiles { + return nil + } + pxarState, err := state.ensurePBSPXARState(ctx) + if err != nil { + state.collector.logger.Warning("Failed to prepare PBS PXAR state: %v", err) + return nil + } + if err := state.collector.collectPBSPXARMetadataStep(ctx, pxarState); err != nil { + state.collector.logger.Warning("Failed to collect PBS PXAR metadata: %v", err) + } + return nil + }, + }, + } +} + +func newPBSPXARSubdirReportBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPBSPXARSubdirReports, + Description: "Collect PBS PXAR subdirectory reports", + Run: func(ctx context.Context, state *collectionState) error { + if !state.collector.config.BackupPxarFiles { + return nil + } + pxarState, err := state.ensurePBSPXARState(ctx) + if err != nil { + state.collector.logger.Warning("Failed to prepare PBS PXAR state: %v", err) + return nil + } + if err := state.collector.collectPBSPXARSubdirReportsStep(ctx, pxarState); err != nil { + state.collector.logger.Warning("Failed to collect PBS PXAR subdir reports: %v", err) + } + return nil + }, + }, + } +} + +func newPBSPXARVMListBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPBSPXARVMLists, + Description: "Collect PBS PXAR VM reports", + Run: func(ctx context.Context, state *collectionState) error { + if !state.collector.config.BackupPxarFiles { + return nil + } + pxarState, err := state.ensurePBSPXARState(ctx) + if err != nil { + state.collector.logger.Warning("Failed to prepare PBS PXAR state: %v", err) + return nil + } + if err := state.collector.collectPBSPXARVMListsStep(ctx, pxarState); err != nil { + state.collector.logger.Warning("Failed to collect PBS PXAR VM lists: %v", err) + } + return nil + }, + }, + } +} + +func newPBSPXARCTListBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPBSPXARCTLists, + Description: "Collect PBS PXAR CT reports", + Run: func(ctx context.Context, state *collectionState) error { + if !state.collector.config.BackupPxarFiles { + return nil + } + pxarState, err := state.ensurePBSPXARState(ctx) + if err != nil { + state.collector.logger.Warning("Failed to prepare PBS PXAR state: %v", err) + return nil + } + if err := state.collector.collectPBSPXARCTListsStep(ctx, pxarState); err != nil { + state.collector.logger.Warning("Failed to collect PBS PXAR CT lists: %v", err) + } + return nil + }, + }, + } +} diff --git a/internal/backup/collector_bricks_pbs_inventory.go b/internal/backup/collector_bricks_pbs_inventory.go new file mode 100644 index 00000000..9db724cc --- /dev/null +++ b/internal/backup/collector_bricks_pbs_inventory.go @@ -0,0 +1,72 @@ +// Package backup provides collection, archive, and verification logic for ProxSave backups. +package backup + +import "context" + +func newPBSInventoryBricks() []collectionBrick { + bricks := []collectionBrick{} + bricks = append(bricks, newPBSInventoryInitBricks()...) + bricks = append(bricks, newPBSInventoryFileBricks()...) + bricks = append(bricks, newPBSInventoryHostCommandBricks()...) + bricks = append(bricks, newPBSInventoryFinalizeBricks()...) + return bricks +} + +func newPBSInventoryInitBricks() []collectionBrick { + return []collectionBrick{ + brick(brickPBSInventoryInit, "Initialize the PBS datastore inventory state", func(ctx context.Context, state *collectionState) error { + inventory, err := state.collector.initPBSDatastoreInventoryState(ctx, state.pbs.datastores) + if err != nil { + return err + } + state.pbs.inventory = inventory + return nil + }), + } +} + +func newPBSInventoryFileBricks() []collectionBrick { + return []collectionBrick{ + pbsInventoryBrick(brickPBSInventoryMountFiles, "Populate PBS inventory mount files", (*Collector).populatePBSInventoryMountFiles), + pbsInventoryBrick(brickPBSInventoryOSFiles, "Populate PBS inventory OS files", (*Collector).populatePBSInventoryOSFiles), + pbsInventoryBrick(brickPBSInventoryMultipathFiles, "Populate PBS inventory multipath files", (*Collector).populatePBSInventoryMultipathFiles), + pbsInventoryBrick(brickPBSInventoryISCSIFiles, "Populate PBS inventory iSCSI files", (*Collector).populatePBSInventoryISCSIFiles), + pbsInventoryBrick(brickPBSInventoryAutofsFiles, "Populate PBS inventory autofs files", (*Collector).populatePBSInventoryAutofsFiles), + pbsInventoryBrick(brickPBSInventoryZFSFiles, "Populate PBS inventory ZFS files", (*Collector).populatePBSInventoryZFSFiles), + pbsInventoryBrick(brickPBSInventoryLVMDirs, "Populate PBS inventory LVM directories", (*Collector).populatePBSInventoryLVMDirs), + pbsInventoryBrick(brickPBSInventorySystemdMountUnits, "Populate PBS inventory systemd mount units", (*Collector).populatePBSInventorySystemdMountUnits), + pbsInventoryBrick(brickPBSInventoryReferencedFiles, "Populate PBS inventory referenced files", (*Collector).populatePBSInventoryReferencedFiles), + } +} + +func newPBSInventoryHostCommandBricks() []collectionBrick { + return []collectionBrick{ + pbsInventoryBrick(brickPBSInventoryHostCommandsCore, "Populate PBS inventory core host commands", (*Collector).populatePBSInventoryHostCommandsCore), + pbsInventoryBrick(brickPBSInventoryHostCommandsDMSetup, "Populate PBS inventory dmsetup host commands", (*Collector).populatePBSInventoryHostCommandsDMSetup), + pbsInventoryBrick(brickPBSInventoryHostCommandsLVM, "Populate PBS inventory LVM host commands", (*Collector).populatePBSInventoryHostCommandsLVM), + pbsInventoryBrick(brickPBSInventoryHostCommandsMDADM, "Populate PBS inventory mdadm host commands", (*Collector).populatePBSInventoryHostCommandsMDADM), + pbsInventoryBrick(brickPBSInventoryHostCommandsMultipath, "Populate PBS inventory multipath host commands", (*Collector).populatePBSInventoryHostCommandsMultipath), + pbsInventoryBrick(brickPBSInventoryHostCommandsISCSI, "Populate PBS inventory iSCSI host commands", (*Collector).populatePBSInventoryHostCommandsISCSI), + pbsInventoryBrick(brickPBSInventoryHostCommandsZFS, "Populate PBS inventory ZFS host commands", (*Collector).populatePBSInventoryHostCommandsZFS), + } +} + +func newPBSInventoryFinalizeBricks() []collectionBrick { + return []collectionBrick{ + brick(brickPBSInventoryCommandFiles, "Populate PBS inventory with collected PBS command files", func(_ context.Context, state *collectionState) error { + commandsDir, err := state.ensurePBSCommandsDir() + if err != nil { + return err + } + return state.collector.populatePBSInventoryCommandFiles(state.ensurePBSInventoryState(), commandsDir) + }), + pbsInventoryBrick(brickPBSInventoryDatastores, "Populate PBS datastore inventory entries", (*Collector).populatePBSDatastoreInventoryEntries), + brick(brickPBSInventoryWrite, "Write the PBS datastore inventory report", func(_ context.Context, state *collectionState) error { + commandsDir, err := state.ensurePBSCommandsDir() + if err != nil { + return err + } + return state.collector.writePBSInventoryState(state.ensurePBSInventoryState(), commandsDir) + }), + } +} diff --git a/internal/backup/collector_bricks_pbs_runtime.go b/internal/backup/collector_bricks_pbs_runtime.go new file mode 100644 index 00000000..4e5a152f --- /dev/null +++ b/internal/backup/collector_bricks_pbs_runtime.go @@ -0,0 +1,217 @@ +// Package backup provides collection, archive, and verification logic for ProxSave backups. +package backup + +import "context" + +func newPBSRuntimeBricks() []collectionBrick { + bricks := []collectionBrick{} + bricks = append(bricks, newPBSRuntimeCoreBricks()...) + bricks = append(bricks, newPBSRuntimeACMEBricks()...) + bricks = append(bricks, newPBSRuntimeNotificationBricks()...) + bricks = append(bricks, newPBSRuntimeAccessBricks()...) + bricks = append(bricks, newPBSRuntimeJobBricks()...) + bricks = append(bricks, newPBSRuntimeTapeBricks()...) + bricks = append(bricks, newPBSRuntimeSystemBricks()...) + bricks = append(bricks, newPBSRuntimeS3Bricks()...) + return bricks +} + +func newPBSRuntimeCoreBricks() []collectionBrick { + return []collectionBrick{ + pbsCommandBrick(brickPBSRuntimeCore, "Collect core PBS runtime information", (*Collector).collectPBSCoreRuntime), + pbsCommandBrick(brickPBSRuntimeNode, "Collect PBS node runtime information", (*Collector).collectPBSNodeRuntime), + pbsCommandBrick(brickPBSRuntimeDatastoreList, "Collect PBS datastore list", (*Collector).collectPBSDatastoreListRuntime), + brick(brickPBSRuntimeDatastoreStatus, "Collect PBS datastore status details", func(ctx context.Context, state *collectionState) error { + commandsDir, err := state.ensurePBSCommandsDir() + if err != nil { + return err + } + return state.collector.collectPBSDatastoreStatusRuntime(ctx, commandsDir, state.pbs.datastores) + }), + } +} + +func newPBSRuntimeACMEBricks() []collectionBrick { + return []collectionBrick{ + brick(brickPBSRuntimeACMEAccountsList, "Collect the PBS ACME account list", func(ctx context.Context, state *collectionState) error { + commandsDir, err := state.ensurePBSCommandsDir() + if err != nil { + return err + } + ids, err := state.collector.collectPBSAcmeAccountsListRuntime(ctx, commandsDir) + if err != nil { + return err + } + state.pbs.acmeAccountNames = ids + return nil + }), + brick(brickPBSRuntimeACMEAccountInfo, "Collect PBS ACME account details", func(ctx context.Context, state *collectionState) error { + commandsDir, err := state.ensurePBSCommandsDir() + if err != nil { + return err + } + return state.collector.collectPBSAcmeAccountInfoRuntime(ctx, commandsDir, state.pbs.acmeAccountNames) + }), + brick(brickPBSRuntimeACMEPluginsList, "Collect the PBS ACME plugin list", func(ctx context.Context, state *collectionState) error { + commandsDir, err := state.ensurePBSCommandsDir() + if err != nil { + return err + } + ids, err := state.collector.collectPBSAcmePluginsListRuntime(ctx, commandsDir) + if err != nil { + return err + } + state.pbs.acmePluginIDs = ids + return nil + }), + brick(brickPBSRuntimeACMEPluginConfig, "Collect PBS ACME plugin configuration", func(ctx context.Context, state *collectionState) error { + commandsDir, err := state.ensurePBSCommandsDir() + if err != nil { + return err + } + return state.collector.collectPBSAcmePluginConfigRuntime(ctx, commandsDir, state.pbs.acmePluginIDs) + }), + } +} + +func newPBSRuntimeNotificationBricks() []collectionBrick { + return []collectionBrick{ + pbsCommandBrick(brickPBSRuntimeNotificationTargets, "Collect PBS notification targets", (*Collector).collectPBSNotificationTargetsRuntime), + pbsCommandBrick(brickPBSRuntimeNotificationMatchers, "Collect PBS notification matchers", (*Collector).collectPBSNotificationMatchersRuntime), + pbsCommandBrick(brickPBSRuntimeNotificationEndpointSMTP, "Collect PBS SMTP notification endpoints", (*Collector).collectPBSNotificationEndpointSMTPRuntime), + pbsCommandBrick(brickPBSRuntimeNotificationEndpointSendmail, "Collect PBS sendmail notification endpoints", (*Collector).collectPBSNotificationEndpointSendmailRuntime), + pbsCommandBrick(brickPBSRuntimeNotificationEndpointGotify, "Collect PBS gotify notification endpoints", (*Collector).collectPBSNotificationEndpointGotifyRuntime), + pbsCommandBrick(brickPBSRuntimeNotificationEndpointWebhook, "Collect PBS webhook notification endpoints", (*Collector).collectPBSNotificationEndpointWebhookRuntime), + brick(brickPBSRuntimeNotificationSummary, "Write the PBS notification summary", func(_ context.Context, state *collectionState) error { + commandsDir, err := state.ensurePBSCommandsDir() + if err != nil { + return err + } + state.collector.writePBSNotificationSummary(commandsDir) + return nil + }), + } +} + +func newPBSRuntimeAccessBricks() []collectionBrick { + return []collectionBrick{ + brick(brickPBSRuntimeAccessUsers, "Collect the PBS user list", func(ctx context.Context, state *collectionState) error { + commandsDir, err := state.ensurePBSCommandsDir() + if err != nil { + return err + } + ids, err := state.collector.collectPBSAccessUsersRuntime(ctx, commandsDir) + if err != nil { + return err + } + state.pbs.userIDs = ids + return nil + }), + pbsCommandBrick(brickPBSRuntimeAccessRealmsLDAP, "Collect PBS LDAP realm definitions", (*Collector).collectPBSAccessRealmLDAPRuntime), + pbsCommandBrick(brickPBSRuntimeAccessRealmsAD, "Collect PBS Active Directory realm definitions", (*Collector).collectPBSAccessRealmADRuntime), + pbsCommandBrick(brickPBSRuntimeAccessRealmsOpenID, "Collect PBS OpenID realm definitions", (*Collector).collectPBSAccessRealmOpenIDRuntime), + pbsCommandBrick(brickPBSRuntimeAccessACL, "Collect PBS ACL definitions", (*Collector).collectPBSAccessACLRuntime), + brick(brickPBSRuntimeAccessUserTokens, "Collect PBS API token snapshots", func(ctx context.Context, state *collectionState) error { + if !state.collector.config.BackupUserConfigs || len(state.pbs.userIDs) == 0 { + return nil + } + usersDir, err := state.collector.ensurePBSAccessControlDir() + if err != nil { + return err + } + return state.collector.collectPBSAccessUserTokensRuntime(ctx, usersDir, state.pbs.userIDs) + }), + brick(brickPBSRuntimeAccessTokensAggregate, "Aggregate PBS API token snapshots", func(_ context.Context, state *collectionState) error { + if !state.collector.config.BackupUserConfigs || len(state.pbs.userIDs) == 0 { + return nil + } + usersDir, err := state.collector.ensurePBSAccessControlDir() + if err != nil { + return err + } + return state.collector.collectPBSAccessTokensAggregateRuntime(usersDir, state.pbs.userIDs) + }), + } +} + +func newPBSRuntimeJobBricks() []collectionBrick { + return []collectionBrick{ + pbsCommandBrick(brickPBSRuntimeRemotes, "Collect PBS remote definitions", (*Collector).collectPBSRemotesRuntime), + pbsCommandBrick(brickPBSRuntimeSyncJobs, "Collect PBS sync jobs", (*Collector).collectPBSSyncJobsRuntime), + pbsCommandBrick(brickPBSRuntimeVerificationJobs, "Collect PBS verification jobs", (*Collector).collectPBSVerificationJobsRuntime), + pbsCommandBrick(brickPBSRuntimePruneJobs, "Collect PBS prune jobs", (*Collector).collectPBSPruneJobsRuntime), + pbsCommandBrick(brickPBSRuntimeGCJobs, "Collect PBS garbage collection jobs", (*Collector).collectPBSGCJobsRuntime), + } +} + +func newPBSRuntimeTapeBricks() []collectionBrick { + return []collectionBrick{ + brick(brickPBSRuntimeTapeDetect, "Detect PBS tape support", func(ctx context.Context, state *collectionState) error { + if !state.collector.config.BackupTapeConfigs { + state.pbs.tapeSupportKnown = true + state.pbs.tapeSupported = false + return nil + } + supported, err := state.collector.detectPBSTapeSupport(ctx) + if err != nil { + if ctx.Err() != nil { + return err + } + state.collector.logger.Debug("Skipping tape details collection: %v", err) + state.pbs.tapeSupportKnown = true + state.pbs.tapeSupported = false + return nil + } + state.pbs.tapeSupportKnown = true + state.pbs.tapeSupported = supported + return nil + }), + pbsTapeCommandBrick(brickPBSRuntimeTapeDrives, "Collect PBS tape drive inventory", (*Collector).collectPBSTapeDrivesRuntime), + pbsTapeCommandBrick(brickPBSRuntimeTapeChangers, "Collect PBS tape changer inventory", (*Collector).collectPBSTapeChangersRuntime), + pbsTapeCommandBrick(brickPBSRuntimeTapePools, "Collect PBS tape pool inventory", (*Collector).collectPBSTapePoolsRuntime), + } +} + +func pbsTapeCommandBrick(id BrickID, description string, run func(*Collector, context.Context, string, bool) error) collectionBrick { + return brick(id, description, func(ctx context.Context, state *collectionState) error { + commandsDir, err := state.ensurePBSCommandsDir() + if err != nil { + return err + } + return run(state.collector, ctx, commandsDir, state.pbs.tapeSupportKnown && state.pbs.tapeSupported) + }) +} + +func newPBSRuntimeSystemBricks() []collectionBrick { + return []collectionBrick{ + pbsCommandBrick(brickPBSRuntimeNetwork, "Collect PBS network runtime information", (*Collector).collectPBSNetworkRuntime), + pbsCommandBrick(brickPBSRuntimeDisks, "Collect the PBS disk inventory", (*Collector).collectPBSDisksRuntime), + pbsCommandBrick(brickPBSRuntimeCertInfo, "Collect the PBS certificate summary", (*Collector).collectPBSCertInfoRuntime), + pbsCommandBrick(brickPBSRuntimeTrafficControl, "Collect PBS traffic control runtime information", (*Collector).collectPBSTrafficControlRuntime), + pbsCommandBrick(brickPBSRuntimeRecentTasks, "Collect recent PBS tasks", (*Collector).collectPBSRecentTasksRuntime), + } +} + +func newPBSRuntimeS3Bricks() []collectionBrick { + return []collectionBrick{ + brick(brickPBSRuntimeS3Endpoints, "Collect PBS S3 endpoints", func(ctx context.Context, state *collectionState) error { + commandsDir, err := state.ensurePBSCommandsDir() + if err != nil { + return err + } + ids, err := state.collector.collectPBSS3EndpointsRuntime(ctx, commandsDir) + if err != nil { + return err + } + state.pbs.s3EndpointIDs = ids + return nil + }), + brick(brickPBSRuntimeS3EndpointBuckets, "Collect PBS S3 endpoint bucket inventories", func(ctx context.Context, state *collectionState) error { + commandsDir, err := state.ensurePBSCommandsDir() + if err != nil { + return err + } + return state.collector.collectPBSS3EndpointBucketsRuntime(ctx, commandsDir, state.pbs.s3EndpointIDs) + }), + } +} diff --git a/internal/backup/collector_bricks_pve.go b/internal/backup/collector_bricks_pve.go new file mode 100644 index 00000000..4218cd0c --- /dev/null +++ b/internal/backup/collector_bricks_pve.go @@ -0,0 +1,224 @@ +// Package backup provides collection, archive, and verification logic for ProxSave backups. +package backup + +import ( + "context" + "errors" + "fmt" + "os" +) + +func newPVERecipe() recipe { + bricks := []collectionBrick{} + bricks = append(bricks, newPVEValidationBricks()...) + bricks = append(bricks, newPVESnapshotBricks()...) + bricks = append(bricks, newPVERuntimeBricks()...) + bricks = append(bricks, newPVEGuestBricks()...) + bricks = append(bricks, newPVEBackupJobBricks()...) + bricks = append(bricks, newPVEScheduleBricks()...) + bricks = append(bricks, newPVEReplicationBricks()...) + bricks = append(bricks, newPVEStorageResolveBricks()...) + bricks = append(bricks, newPVEStorageProbeBricks()...) + bricks = append(bricks, newPVEStorageMetadataJSONBricks()...) + bricks = append(bricks, newPVEStorageMetadataTextBricks()...) + bricks = append(bricks, newPVEStorageAnalysisBricks()...) + bricks = append(bricks, newPVEStorageSummaryBricks()...) + bricks = append(bricks, newPVECephBricks()...) + bricks = append(bricks, newPVEAliasBricks()...) + bricks = append(bricks, newPVEAggregateBricks()...) + bricks = append(bricks, newPVEVersionBricks()...) + bricks = append(bricks, newPVEManifestBricks()...) + return recipe{Name: "pve", Bricks: bricks} +} + +func newPVEValidationBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPVEValidateAndCluster, + Description: "Validate PVE environment and detect cluster state", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + c.logger.Debug("Validating PVE environment and cluster state prior to collection") + + pveConfigPath := c.effectivePVEConfigPath() + if _, err := os.Stat(pveConfigPath); err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("not a PVE system: %s not found", pveConfigPath) + } + return fmt.Errorf("failed to access PVE config path %s: %w", pveConfigPath, err) + } + c.logger.Debug("%s detected, continuing with PVE collection", pveConfigPath) + + clustered := false + if isClustered, err := c.isClusteredPVE(ctx); err != nil { + if ctx.Err() != nil { + return err + } + c.logger.Debug("Cluster detection failed, assuming standalone node: %v", err) + } else { + clustered = isClustered + c.logger.Debug("Cluster detection completed: clustered=%v", clustered) + } + + state.pve.clustered = clustered + c.clusteredPVE = clustered + return nil + }, + }, + } +} + +func newPVESnapshotBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPVEConfigSnapshot, + Description: "Collect base PVE configuration snapshot", + Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPVEConfigSnapshot(ctx) + }, + }, + { + ID: brickPVEClusterSnapshot, + Description: "Collect cluster-specific PVE snapshot", + Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPVEClusterSnapshot(ctx, state.pve.clustered) + }, + }, + { + ID: brickPVEFirewallSnapshot, + Description: "Collect PVE firewall snapshot", + Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPVEFirewallSnapshot(ctx) + }, + }, + { + ID: brickPVEVZDumpSnapshot, + Description: "Collect VZDump snapshot", + Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPVEVZDumpSnapshot(ctx) + }, + }, + } +} + +func newPVERuntimeBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPVERuntimeCore, + Description: "Collect core PVE runtime information", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + commandsDir, err := state.ensurePVECommandsDir() + if err != nil { + return err + } + c.logger.Debug("Collecting PVE core runtime state") + return c.collectPVECoreRuntime(ctx, commandsDir, state.ensurePVERuntimeInfo()) + }, + }, + { + ID: brickPVERuntimeACL, + Description: "Collect PVE ACL runtime information", + Run: func(ctx context.Context, state *collectionState) error { + commandsDir, err := state.ensurePVECommandsDir() + if err != nil { + return err + } + state.collector.collectPVEACLRuntime(ctx, commandsDir) + return nil + }, + }, + { + ID: brickPVERuntimeCluster, + Description: "Collect PVE cluster runtime information", + Run: func(ctx context.Context, state *collectionState) error { + commandsDir, err := state.ensurePVECommandsDir() + if err != nil { + return err + } + state.collector.collectPVEClusterRuntime(ctx, commandsDir, state.pve.clustered) + return nil + }, + }, + { + ID: brickPVERuntimeStorage, + Description: "Collect PVE storage runtime information", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + commandsDir, err := state.ensurePVECommandsDir() + if err != nil { + return err + } + if err := c.collectPVEStorageRuntime(ctx, commandsDir, state.ensurePVERuntimeInfo()); err != nil { + return err + } + c.finalizePVERuntimeInfo(state.ensurePVERuntimeInfo()) + return nil + }, + }, + } +} + +func newPVEGuestBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPVEVMQEMUConfigs, + Description: "Collect QEMU VM configurations", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupVMConfigs { + c.logger.Skip("VM/container configuration backup disabled.") + return nil + } + if state.pve.guestCollectionAborted { + return nil + } + c.logger.Info("Collecting VM and container configurations") + if err := c.collectPVEQEMUConfigs(ctx); err != nil { + if isContextCancellationError(ctx, err) { + return err + } + c.logger.Warning("Failed to collect QEMU VM configs: %v", err) + state.pve.guestCollectionAborted = true + } + return nil + }, + }, + { + ID: brickPVEVMLXCConfigs, + Description: "Collect LXC container configurations", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupVMConfigs || state.pve.guestCollectionAborted { + return nil + } + if err := c.collectPVELXCConfigs(ctx); err != nil { + if isContextCancellationError(ctx, err) { + return err + } + c.logger.Warning("Failed to collect LXC configs: %v", err) + state.pve.guestCollectionAborted = true + } + return nil + }, + }, + { + ID: brickPVEGuestInventory, + Description: "Collect guest inventory", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupVMConfigs || state.pve.guestCollectionAborted { + return nil + } + if err := c.collectPVEGuestInventory(ctx); err != nil { + if isContextCancellationError(ctx, err) { + return err + } + c.logger.Warning("Failed to collect guest inventory: %v", err) + state.pve.guestCollectionAborted = true + } + return nil + }, + }, + } +} diff --git a/internal/backup/collector_bricks_pve_finalize.go b/internal/backup/collector_bricks_pve_finalize.go new file mode 100644 index 00000000..0c647af4 --- /dev/null +++ b/internal/backup/collector_bricks_pve_finalize.go @@ -0,0 +1,174 @@ +// Package backup provides collection, archive, and verification logic for ProxSave backups. +package backup + +import "context" + +func newPVECephBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPVECephConfigSnapshot, + Description: "Collect Ceph configuration snapshot", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupCephConfig || state.pve.cephCollectionAborted { + return nil + } + c.logger.Debug("Collecting Ceph configuration and status") + if err := c.collectPVECephConfigSnapshot(ctx); err != nil { + if isContextCancellationError(ctx, err) { + return err + } + c.logger.Warning("Failed to collect Ceph configuration snapshot: %v", err) + state.pve.cephCollectionAborted = true + } + return nil + }, + }, + { + ID: brickPVECephRuntime, + Description: "Collect Ceph runtime information", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupCephConfig || state.pve.cephCollectionAborted { + return nil + } + if err := c.collectPVECephRuntime(ctx); err != nil { + if isContextCancellationError(ctx, err) { + return err + } + c.logger.Warning("Failed to collect Ceph runtime information: %v", err) + state.pve.cephCollectionAborted = true + } else { + c.logger.Debug("Ceph information collection completed") + } + return nil + }, + }, + } +} + +func newPVEAliasBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPVEAliasCore, + Description: "Create core PVE aliases", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + c.logger.Debug("Creating PVE info aliases under /var/lib/pve-cluster/info") + if err := c.createPVECoreAliases(ctx); err != nil { + if isContextCancellationError(ctx, err) { + return err + } + c.logger.Warning("Failed to create PVE core aliases: %v", err) + state.pve.finalizeCollectionAborted = true + } + return nil + }, + }, + } +} + +func newPVEAggregateBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPVEAggregateBackupHistory, + Description: "Aggregate backup history aliases", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if state.pve.finalizeCollectionAborted { + return nil + } + if err := c.createPVEBackupHistoryAggregate(ctx); err != nil { + if isContextCancellationError(ctx, err) { + return err + } + c.logger.Warning("Failed to aggregate PVE backup history: %v", err) + state.pve.finalizeCollectionAborted = true + } + return nil + }, + }, + { + ID: brickPVEAggregateReplicationStatus, + Description: "Aggregate replication status aliases", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if state.pve.finalizeCollectionAborted { + return nil + } + if err := c.createPVEReplicationAggregate(ctx); err != nil { + if isContextCancellationError(ctx, err) { + return err + } + c.logger.Warning("Failed to aggregate PVE replication status: %v", err) + state.pve.finalizeCollectionAborted = true + } + return nil + }, + }, + } +} + +func newPVEVersionBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPVEVersionInfo, + Description: "Write PVE version alias information", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if state.pve.finalizeCollectionAborted { + return nil + } + if err := c.createPVEVersionInfo(ctx); err != nil { + if isContextCancellationError(ctx, err) { + return err + } + c.logger.Warning("Failed to write PVE version info: %v", err) + state.pve.finalizeCollectionAborted = true + } + return nil + }, + }, + } +} + +func newPVEManifestBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPVEManifestFinalize, + Description: "Finalize the PVE manifest", + Run: func(_ context.Context, state *collectionState) error { + state.collector.populatePVEManifest() + return nil + }, + }, + } +} + +func (p *pveContext) runtimeNodes() []string { + if p == nil || p.runtimeInfo == nil { + return nil + } + return p.runtimeInfo.Nodes +} + +func (p *pveContext) runtimeStorages() []pveStorageEntry { + if p == nil || p.runtimeInfo == nil { + return nil + } + return p.runtimeInfo.Storages +} + +func (p *pveContext) ensureStorageScanResults() map[string]*pveStorageScanResult { + if p.storageScanResults == nil { + p.storageScanResults = make(map[string]*pveStorageScanResult) + } + return p.storageScanResults +} + +func (p *pveContext) storageResult(storage pveStorageEntry) *pveStorageScanResult { + if p == nil || p.storageScanResults == nil { + return nil + } + return p.storageScanResults[storage.pathKey()] +} diff --git a/internal/backup/collector_bricks_pve_jobs.go b/internal/backup/collector_bricks_pve_jobs.go new file mode 100644 index 00000000..69e6cb26 --- /dev/null +++ b/internal/backup/collector_bricks_pve_jobs.go @@ -0,0 +1,165 @@ +// Package backup provides collection, archive, and verification logic for ProxSave backups. +package backup + +import "context" + +func newPVEBackupJobBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPVEBackupJobDefs, + Description: "Collect PVE backup job definitions", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupPVEJobs || state.pve.jobCollectionAborted { + return nil + } + c.logger.Debug("Collecting PVE job definitions for nodes: %v", state.pve.runtimeNodes()) + if err := c.collectPVEBackupJobDefinitions(ctx); err != nil { + if isContextCancellationError(ctx, err) { + return err + } + c.logger.Warning("Failed to collect PVE backup job definitions: %v", err) + state.pve.jobCollectionAborted = true + } + return nil + }, + }, + { + ID: brickPVEBackupJobHistory, + Description: "Collect PVE backup job history", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupPVEJobs || state.pve.jobCollectionAborted { + return nil + } + if err := c.collectPVEBackupJobHistory(ctx, state.pve.runtimeNodes()); err != nil { + if isContextCancellationError(ctx, err) { + return err + } + c.logger.Warning("Failed to collect PVE backup history: %v", err) + state.pve.jobCollectionAborted = true + } + return nil + }, + }, + { + ID: brickPVEVZDumpCron, + Description: "Collect VZDump cron snapshot", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupPVEJobs || state.pve.jobCollectionAborted { + return nil + } + if err := c.collectPVEVZDumpCronSnapshot(ctx); err != nil { + if isContextCancellationError(ctx, err) { + return err + } + c.logger.Warning("Failed to collect VZDump cron snapshot: %v", err) + state.pve.jobCollectionAborted = true + } + return nil + }, + }, + } +} + +func newPVEScheduleBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPVEScheduleCrontab, + Description: "Collect root crontab schedule data", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupPVESchedules || state.pve.scheduleCollectionAborted { + return nil + } + if err := c.collectPVEScheduleCrontab(ctx); err != nil { + if isContextCancellationError(ctx, err) { + return err + } + c.logger.Warning("Failed to collect PVE crontab schedules: %v", err) + state.pve.scheduleCollectionAborted = true + } + return nil + }, + }, + { + ID: brickPVEScheduleTimers, + Description: "Collect systemd timer schedule data", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupPVESchedules || state.pve.scheduleCollectionAborted { + return nil + } + if err := c.collectPVEScheduleTimers(ctx); err != nil { + if isContextCancellationError(ctx, err) { + return err + } + c.logger.Warning("Failed to collect PVE timer schedules: %v", err) + state.pve.scheduleCollectionAborted = true + } + return nil + }, + }, + { + ID: brickPVEScheduleCronFiles, + Description: "Collect PVE-related cron files", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupPVESchedules || state.pve.scheduleCollectionAborted { + return nil + } + if err := c.collectPVEScheduleCronFiles(ctx); err != nil { + if isContextCancellationError(ctx, err) { + return err + } + c.logger.Warning("Failed to collect PVE cron schedule files: %v", err) + state.pve.scheduleCollectionAborted = true + } + return nil + }, + }, + } +} + +func newPVEReplicationBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPVEReplicationDefs, + Description: "Collect PVE replication definitions", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupPVEReplication || state.pve.replicationCollectionAborted { + return nil + } + c.logger.Debug("Collecting PVE replication settings for nodes: %v", state.pve.runtimeNodes()) + if err := c.collectPVEReplicationDefinitions(ctx); err != nil { + if isContextCancellationError(ctx, err) { + return err + } + c.logger.Warning("Failed to collect PVE replication definitions: %v", err) + state.pve.replicationCollectionAborted = true + } + return nil + }, + }, + { + ID: brickPVEReplicationStatus, + Description: "Collect PVE replication status", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupPVEReplication || state.pve.replicationCollectionAborted { + return nil + } + if err := c.collectPVEReplicationStatus(ctx, state.pve.runtimeNodes()); err != nil { + if isContextCancellationError(ctx, err) { + return err + } + c.logger.Warning("Failed to collect PVE replication status: %v", err) + state.pve.replicationCollectionAborted = true + } + return nil + }, + }, + } +} diff --git a/internal/backup/collector_bricks_pve_storage.go b/internal/backup/collector_bricks_pve_storage.go new file mode 100644 index 00000000..8cd6c0c6 --- /dev/null +++ b/internal/backup/collector_bricks_pve_storage.go @@ -0,0 +1,192 @@ +// Package backup provides collection, archive, and verification logic for ProxSave backups. +package backup + +import "context" + +func newPVEStorageResolveBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPVEStorageResolve, + Description: "Resolve PVE storage list for backup analysis", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupPVEBackupFiles { + return nil + } + if state.pve.storageCollectionAborted { + return nil + } + if err := ctx.Err(); err != nil { + return err + } + c.logger.Info("Collecting PVE datastore information using auto-detection") + c.logger.Debug("Collecting datastore metadata for %d storages", len(state.pve.runtimeStorages())) + state.pve.resolvedStorages = c.resolvePVEStorages(state.pve.runtimeStorages()) + if len(state.pve.resolvedStorages) == 0 { + c.logger.Info("Found 0 PVE datastore(s) via auto-detection") + c.logger.Info("No PVE datastores detected - skipping metadata collection") + return nil + } + c.logger.Info("Found %d PVE datastore(s) via auto-detection", len(state.pve.resolvedStorages)) + return nil + }, + }, + } +} + +func newPVEStorageProbeBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPVEStorageProbe, + Description: "Probe resolved PVE storages", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupPVEBackupFiles || state.pve.storageCollectionAborted || len(state.pve.resolvedStorages) == 0 { + return nil + } + baseDir := c.pveDatastoresBaseDir() + if err := c.ensureDir(baseDir); err != nil { + c.logger.Warning("Failed to create datastore metadata directory: %v", err) + state.pve.storageCollectionAborted = true + return nil + } + ioTimeout := c.pveStorageIOTimeout() + state.pve.probedStorages = nil + state.pve.storageScanResults = nil + for _, storage := range state.pve.resolvedStorages { + result, err := c.preparePVEStorageScan(ctx, storage, baseDir, ioTimeout) + if err != nil { + if isContextCancellationError(ctx, err) { + return err + } + c.logger.Warning("Failed to probe PVE datastore %s: %v", storage.Name, err) + state.pve.storageCollectionAborted = true + return nil + } + if result == nil { + continue + } + state.pve.probedStorages = append(state.pve.probedStorages, storage) + state.pve.ensureStorageScanResults()[storage.pathKey()] = result + } + return nil + }, + }, + } +} + +func newPVEStorageMetadataJSONBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPVEStorageMetadataJSON, + Description: "Write JSON metadata for probed PVE storages", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupPVEBackupFiles || state.pve.storageCollectionAborted || len(state.pve.probedStorages) == 0 { + return nil + } + ioTimeout := c.pveStorageIOTimeout() + for _, storage := range state.pve.probedStorages { + result := state.pve.storageResult(storage) + if result == nil || result.SkipRemaining { + continue + } + if err := c.collectPVEStorageMetadataJSONStep(ctx, result, ioTimeout); err != nil { + if isContextCancellationError(ctx, err) { + return err + } + c.logger.Warning("Failed to write PVE datastore JSON metadata for %s: %v", storage.Name, err) + state.pve.storageCollectionAborted = true + return nil + } + } + return nil + }, + }, + } +} + +func newPVEStorageMetadataTextBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPVEStorageMetadataText, + Description: "Write text metadata for probed PVE storages", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupPVEBackupFiles || state.pve.storageCollectionAborted || len(state.pve.probedStorages) == 0 { + return nil + } + ioTimeout := c.pveStorageIOTimeout() + for _, storage := range state.pve.probedStorages { + result := state.pve.storageResult(storage) + if result == nil || result.SkipRemaining { + continue + } + if err := c.collectPVEStorageMetadataTextStep(ctx, result, ioTimeout); err != nil { + if isContextCancellationError(ctx, err) { + return err + } + c.logger.Warning("Failed to write PVE datastore text metadata for %s: %v", storage.Name, err) + state.pve.storageCollectionAborted = true + return nil + } + } + return nil + }, + }, + } +} + +func newPVEStorageAnalysisBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPVEStorageBackupAnalysis, + Description: "Analyze PVE backup files for probed storages", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupPVEBackupFiles || state.pve.storageCollectionAborted || len(state.pve.probedStorages) == 0 { + return nil + } + ioTimeout := c.pveStorageIOTimeout() + for _, storage := range state.pve.probedStorages { + result := state.pve.storageResult(storage) + if result == nil || result.SkipRemaining { + continue + } + if err := c.collectPVEStorageBackupAnalysisStep(ctx, result, ioTimeout); err != nil { + if isContextCancellationError(ctx, err) { + return err + } + c.logger.Warning("Detailed backup analysis for %s failed: %v", storage.Name, err) + } + } + return nil + }, + }, + } +} + +func newPVEStorageSummaryBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPVEStorageSummary, + Description: "Write PVE datastore summary", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupPVEBackupFiles || state.pve.storageCollectionAborted || len(state.pve.probedStorages) == 0 { + return nil + } + if err := c.writePVEStorageSummary(ctx, state.pve.probedStorages); err != nil { + if isContextCancellationError(ctx, err) { + return err + } + c.logger.Warning("Failed to write PVE datastore summary: %v", err) + state.pve.storageCollectionAborted = true + return nil + } + c.logger.Debug("PVE datastore metadata collection completed (%d processed)", len(state.pve.probedStorages)) + return nil + }, + }, + } +} diff --git a/internal/backup/collector_bricks_system.go b/internal/backup/collector_bricks_system.go new file mode 100644 index 00000000..e4ca2b1f --- /dev/null +++ b/internal/backup/collector_bricks_system.go @@ -0,0 +1,217 @@ +// Package backup provides collection, archive, and verification logic for ProxSave backups. +package backup + +import "context" + +func newSystemRecipe() recipe { + bricks := []collectionBrick{} + bricks = append(bricks, newSystemStaticBricks()...) + bricks = append(bricks, newCommonFilesystemBricks()...) + bricks = append(bricks, newCommonStorageStackBricks()...) + bricks = append(bricks, newSystemPostCommonStaticBricks()...) + bricks = append(bricks, newSystemRuntimeCommandBricks()...) + bricks = append(bricks, newSystemReportBricks()...) + bricks = append(bricks, newSystemFileCollectionBricks()...) + bricks = append(bricks, newSystemScriptCollectionBricks()...) + bricks = append(bricks, newSystemHomeCollectionBricks()...) + return recipe{Name: "system", Bricks: bricks} +} + +func newSystemStaticBricks() []collectionBrick { + return []collectionBrick{ + collectorBrick(brickSystemNetworkStatic, "Collect static network configuration", (*Collector).collectSystemNetworkStatic), + collectorBrick(brickSystemIdentityStatic, "Collect static identity files", (*Collector).collectSystemIdentityStatic), + collectorBrick(brickSystemAptStatic, "Collect static APT configuration", (*Collector).collectSystemAptStatic), + collectorBrick(brickSystemCronStatic, "Collect static cron configuration", (*Collector).collectSystemCronStatic), + collectorBrick(brickSystemServicesStatic, "Collect static service configuration", (*Collector).collectSystemServicesStatic), + collectorBrick(brickSystemLoggingStatic, "Collect static logging configuration", (*Collector).collectSystemLoggingStatic), + collectorBrick(brickSystemSSLStatic, "Collect static SSL configuration", (*Collector).collectSystemSSLStatic), + collectorBrick(brickSystemSysctlStatic, "Collect static sysctl configuration", (*Collector).collectSystemSysctlStatic), + collectorBrick(brickSystemKernelModulesStatic, "Collect static kernel module configuration", (*Collector).collectSystemKernelModuleStatic), + } +} + +func newSystemPostCommonStaticBricks() []collectionBrick { + return []collectionBrick{ + collectorBrick(brickSystemZFSStatic, "Collect static ZFS configuration", (*Collector).collectSystemZFSStatic), + collectorBrick(brickSystemFirewallStatic, "Collect static firewall configuration", (*Collector).collectSystemFirewallStatic), + collectorBrick(brickSystemRuntimeLeases, "Collect runtime lease snapshots", (*Collector).collectSystemRuntimeLeases), + } +} + +func newSystemRuntimeCommandBricks() []collectionBrick { + return []collectionBrick{ + systemCommandBrick(brickSystemCoreRuntime, "Collect core system runtime information", (*Collector).collectSystemCoreRuntime), + systemCommandBrick(brickSystemNetworkRuntimeAddr, "Collect network address runtime information", (*Collector).collectSystemNetworkAddrRuntime), + systemCommandBrick(brickSystemNetworkRuntimeRules, "Collect network rule runtime information", (*Collector).collectSystemNetworkRulesRuntime), + systemCommandBrick(brickSystemNetworkRuntimeRoutes, "Collect network route runtime information", (*Collector).collectSystemNetworkRoutesRuntime), + systemCommandBrick(brickSystemNetworkRuntimeLinks, "Collect network link runtime information", (*Collector).collectSystemNetworkLinksRuntime), + systemCommandBrick(brickSystemNetworkRuntimeNeighbors, "Collect network neighbor runtime information", (*Collector).collectSystemNetworkNeighborsRuntime), + systemCommandBrick(brickSystemNetworkRuntimeBridges, "Collect bridge runtime information", (*Collector).collectSystemNetworkBridgesRuntime), + systemCommandBrick(brickSystemNetworkRuntimeInventory, "Collect network inventory runtime information", (*Collector).collectSystemNetworkInventoryRuntime), + systemCommandBrick(brickSystemNetworkRuntimeBonding, "Collect bonding runtime information", (*Collector).collectSystemNetworkBondingRuntime), + systemCommandBrick(brickSystemNetworkRuntimeDNS, "Collect DNS runtime information", (*Collector).collectSystemNetworkDNSRuntime), + systemCommandBrick(brickSystemStorageRuntimeMounts, "Collect storage mount runtime information", (*Collector).collectSystemStorageMountsRuntime), + systemCommandBrick(brickSystemStorageRuntimeBlock, "Collect block device runtime information", (*Collector).collectSystemStorageBlockDevicesRuntime), + systemCommandBrick(brickSystemComputeRuntimeMemoryCPU, "Collect memory and CPU runtime information", (*Collector).collectSystemComputeMemoryCPURuntime), + systemCommandBrick(brickSystemComputeRuntimeBusInv, "Collect bus inventory runtime information", (*Collector).collectSystemComputeBusInventoryRuntime), + systemCommandBrick(brickSystemServicesRuntime, "Collect service runtime information", (*Collector).collectSystemServicesRuntime), + systemCommandBrick(brickSystemPackagesRuntimeInstalled, "Collect installed package runtime information", (*Collector).collectSystemPackagesInstalledRuntime), + systemCommandBrick(brickSystemPackagesRuntimeAPTPolicy, "Collect APT policy runtime information", (*Collector).collectSystemPackagesAptPolicyRuntime), + systemCommandBrick(brickSystemFirewallRuntimeIPTables, "Collect iptables runtime information", (*Collector).collectSystemFirewallIPTablesRuntime), + systemCommandBrick(brickSystemFirewallRuntimeIP6Tables, "Collect ip6tables runtime information", (*Collector).collectSystemFirewallIP6TablesRuntime), + systemCommandBrick(brickSystemFirewallRuntimeNFTables, "Collect nftables runtime information", (*Collector).collectSystemFirewallNFTablesRuntime), + systemCommandBrick(brickSystemFirewallRuntimeUFW, "Collect UFW runtime information", (*Collector).collectSystemFirewallUFWRuntime), + systemCommandBrick(brickSystemFirewallRuntimeFirewalld, "Collect firewalld runtime information", (*Collector).collectSystemFirewallFirewalldRuntime), + systemCommandBrick(brickSystemKernelModulesRuntime, "Collect kernel module runtime information", (*Collector).collectSystemKernelModulesRuntime), + systemCommandBrick(brickSystemSysctlRuntime, "Collect sysctl runtime information", (*Collector).collectSystemSysctlRuntime), + systemCommandBrick(brickSystemZFSRuntime, "Collect ZFS runtime information", (*Collector).collectSystemZFSRuntime), + systemCommandBrick(brickSystemLVMRuntime, "Collect LVM runtime information", (*Collector).collectSystemLVMRuntime), + } +} + +func newSystemReportBricks() []collectionBrick { + return []collectionBrick{ + brick(brickSystemNetworkReport, "Finalize derived system reports", func(ctx context.Context, state *collectionState) error { + commandsDir, err := state.ensureSystemCommandsDir() + if err != nil { + return err + } + if err := state.collector.finalizeSystemRuntimeReports(ctx, commandsDir); err != nil { + state.collector.logger.Debug("Network report generation failed: %v", err) + } + return nil + }), + brick(brickSystemKernel, "Collect kernel information", func(ctx context.Context, state *collectionState) error { + c := state.collector + c.logger.Debug("Collecting kernel information (uname/modules)") + if err := c.collectKernelInfo(ctx); err != nil { + c.logger.Warning("Failed to collect kernel info: %v", err) + } else { + c.logger.Debug("Kernel information collected successfully") + } + return nil + }), + brick(brickSystemHardware, "Collect hardware information", func(ctx context.Context, state *collectionState) error { + c := state.collector + c.logger.Debug("Collecting hardware inventory (CPU/memory/devices)") + if err := c.collectHardwareInfo(ctx); err != nil { + c.logger.Warning("Failed to collect hardware info: %v", err) + } else { + c.logger.Debug("Hardware inventory collected successfully") + } + return nil + }), + } +} + +func newSystemFileCollectionBricks() []collectionBrick { + return []collectionBrick{ + brick(brickSystemCriticalFiles, "Collect critical system files", func(ctx context.Context, state *collectionState) error { + c := state.collector + if c.config.BackupCriticalFiles { + c.logger.Debug("Collecting critical files specified in configuration") + if err := c.collectCriticalFiles(ctx); err != nil { + c.logger.Warning("Failed to collect critical files: %v", err) + } else { + c.logger.Debug("Critical files collected successfully") + } + } + return nil + }), + brick(brickSystemConfigFile, "Collect backup configuration file", func(ctx context.Context, state *collectionState) error { + c := state.collector + if c.config.BackupConfigFile { + c.logger.Debug("Collecting backup configuration file") + if err := c.collectConfigFile(ctx); err != nil { + c.logger.Warning("Failed to collect backup configuration file: %v", err) + } else { + c.logger.Debug("Backup configuration file collected successfully") + } + } + return nil + }), + brick(brickSystemCustomPaths, "Collect custom backup paths", func(ctx context.Context, state *collectionState) error { + c := state.collector + if len(c.config.CustomBackupPaths) > 0 { + c.logger.Debug("Collecting custom paths: %v", c.config.CustomBackupPaths) + if err := c.collectCustomPaths(ctx); err != nil { + c.logger.Warning("Failed to collect custom paths: %v", err) + } else { + c.logger.Debug("Custom paths collected successfully") + } + } + return nil + }), + } +} + +func newSystemScriptCollectionBricks() []collectionBrick { + return []collectionBrick{ + brick(brickSystemScriptDirs, "Collect script directories", func(ctx context.Context, state *collectionState) error { + c := state.collector + if c.config.BackupScriptDir { + c.logger.Debug("Collecting script directories (/usr/local/bin,/usr/local/sbin)") + if err := c.collectScriptDirectories(ctx); err != nil { + c.logger.Warning("Failed to collect script directories: %v", err) + } else { + c.logger.Debug("Script directories collected successfully") + } + } + return nil + }), + brick(brickSystemScriptRepo, "Collect script repository", func(ctx context.Context, state *collectionState) error { + c := state.collector + if c.config.BackupScriptRepository { + c.logger.Debug("Collecting script repository from %s", c.config.ScriptRepositoryPath) + if err := c.collectScriptRepository(ctx); err != nil { + c.logger.Warning("Failed to collect script repository: %v", err) + } else { + c.logger.Debug("Script repository collected successfully") + } + } + return nil + }), + brick(brickSystemSSHKeys, "Collect SSH keys", func(ctx context.Context, state *collectionState) error { + c := state.collector + if c.config.BackupSSHKeys { + c.logger.Debug("Collecting SSH keys for root and users") + if err := c.collectSSHKeys(ctx); err != nil { + c.logger.Warning("Failed to collect SSH keys: %v", err) + } else { + c.logger.Debug("SSH keys collected successfully") + } + } + return nil + }), + } +} + +func newSystemHomeCollectionBricks() []collectionBrick { + return []collectionBrick{ + brick(brickSystemRootHome, "Collect root home directory", func(ctx context.Context, state *collectionState) error { + c := state.collector + if c.config.BackupRootHome { + c.logger.Debug("Collecting /root home directory") + if err := c.collectRootHome(ctx); err != nil { + c.logger.Warning("Failed to collect root home files: %v", err) + } else { + c.logger.Debug("Root home directory collected successfully") + } + } + return nil + }), + brick(brickSystemUserHomes, "Collect user home directories", func(ctx context.Context, state *collectionState) error { + c := state.collector + if c.config.BackupUserHomes { + c.logger.Debug("Collecting user home directories under /home") + if err := c.collectUserHomes(ctx); err != nil { + c.logger.Warning("Failed to collect user home directories: %v", err) + } else { + c.logger.Debug("User home directories collected successfully") + } + } + return nil + }), + } +} diff --git a/internal/backup/collector_bricks_test.go b/internal/backup/collector_bricks_test.go index d5747aa1..778e233b 100644 --- a/internal/backup/collector_bricks_test.go +++ b/internal/backup/collector_bricks_test.go @@ -1,3 +1,4 @@ +// Package backup provides collection, archive, and verification logic for ProxSave backups. package backup import ( @@ -9,6 +10,9 @@ import ( "sort" "strings" "testing" + + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/types" ) func TestRunRecipeRunsBricksInOrder(t *testing.T) { @@ -119,6 +123,48 @@ func TestRunRecipePropagatesContextCancellation(t *testing.T) { } } +func TestPVEGuestBrickPropagatesQEMUContextCancellation(t *testing.T) { + cfg := &CollectorConfig{ + BackupVMConfigs: true, + PVEConfigPath: filepath.Join(t.TempDir(), "etc", "pve"), + } + if err := os.MkdirAll(filepath.Join(cfg.PVEConfigPath, "qemu-server"), 0o755); err != nil { + t.Fatalf("mkdir qemu-server: %v", err) + } + + collector := NewCollector(logging.New(types.LogLevelError, false), cfg, t.TempDir(), types.ProxmoxVE, false) + state := newCollectionState(collector) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + brick := requireBrick(t, recipe{Name: "pve-guest", Bricks: newPVEGuestBricks()}, brickPVEVMQEMUConfigs) + err := brick.Run(ctx, state) + if !errors.Is(err, context.Canceled) { + t.Fatalf("guest brick error = %v, want %v", err, context.Canceled) + } + if state.pve.guestCollectionAborted { + t.Fatalf("guest collection should not be marked aborted for context cancellation") + } +} + +func TestPVEStorageProbeBrickPropagatesContextCancellation(t *testing.T) { + cfg := &CollectorConfig{BackupPVEBackupFiles: true} + collector := NewCollector(logging.New(types.LogLevelError, false), cfg, t.TempDir(), types.ProxmoxVE, false) + state := newCollectionState(collector) + state.pve.resolvedStorages = []pveStorageEntry{{Name: "local", Path: t.TempDir()}} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + brick := requireBrick(t, recipe{Name: "pve-storage-probe", Bricks: newPVEStorageProbeBricks()}, brickPVEStorageProbe) + err := brick.Run(ctx, state) + if !errors.Is(err, context.Canceled) { + t.Fatalf("storage probe brick error = %v, want %v", err, context.Canceled) + } + if state.pve.storageCollectionAborted { + t.Fatalf("storage collection should not be marked aborted for context cancellation") + } +} + func recipeBrickIDs(r recipe) []BrickID { ids := make([]BrickID, 0, len(r.Bricks)) for _, brick := range r.Bricks { @@ -127,6 +173,48 @@ func recipeBrickIDs(r recipe) []BrickID { return ids } +func TestRealRecipesHaveCompleteUniqueBricks(t *testing.T) { + recipes := []recipe{ + newPVERecipe(), + newPBSRecipe(), + newPBSCommandsRecipe(), + newPBSDatastoreInventoryRecipe(), + newPBSDatastoreConfigRecipe(), + newPBSPXARRecipe(), + newPBSUserConfigRecipe(), + newSystemRecipe(), + newDualRecipe(), + } + + for _, r := range recipes { + t.Run(r.Name, func(t *testing.T) { + if r.Name == "" { + t.Fatalf("recipe name is empty") + } + if len(r.Bricks) == 0 { + t.Fatalf("recipe %s has no bricks", r.Name) + } + + seen := make(map[BrickID]int, len(r.Bricks)) + for i, brick := range r.Bricks { + if brick.ID == "" { + t.Fatalf("recipe %s brick %d has empty ID", r.Name, i) + } + if brick.Description == "" { + t.Fatalf("recipe %s brick %s has empty description", r.Name, brick.ID) + } + if brick.Run == nil { + t.Fatalf("recipe %s brick %s has nil Run", r.Name, brick.ID) + } + if first, ok := seen[brick.ID]; ok { + t.Fatalf("recipe %s has duplicate brick ID %s at indexes %d and %d", r.Name, brick.ID, first, i) + } + seen[brick.ID] = i + } + }) + } +} + func TestNewPVERecipeOrder(t *testing.T) { got := recipeBrickIDs(newPVERecipe()) want := []BrickID{ diff --git a/internal/backup/collector_config_extra_test.go b/internal/backup/collector_config_extra_test.go index e13f6cdd..e88dcf54 100644 --- a/internal/backup/collector_config_extra_test.go +++ b/internal/backup/collector_config_extra_test.go @@ -33,6 +33,36 @@ func TestCollectorConfigValidateDefaultsAndErrors(t *testing.T) { } } +func TestCollectorConfigValidateAcceptsNewStandaloneCollectionOptions(t *testing.T) { + tests := []struct { + name string + cfg *CollectorConfig + }{ + {name: "pbs notification priv", cfg: &CollectorConfig{BackupPBSNotificationsPriv: true}}, + {name: "root home", cfg: &CollectorConfig{BackupRootHome: true}}, + {name: "script repository", cfg: &CollectorConfig{BackupScriptRepository: true}}, + {name: "user homes", cfg: &CollectorConfig{BackupUserHomes: true}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := tt.cfg.Validate(); err != nil { + t.Fatalf("Validate() error = %v", err) + } + if tt.cfg.PxarDatastoreConcurrency != 3 { + t.Fatalf("PxarDatastoreConcurrency = %d, want 3", tt.cfg.PxarDatastoreConcurrency) + } + }) + } +} + +func TestCollectorConfigValidateAcceptsCustomBackupPathsOnly(t *testing.T) { + cfg := &CollectorConfig{CustomBackupPaths: []string{"/opt/custom"}} + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate() error = %v", err) + } +} + func TestGlobHelpers(t *testing.T) { cases := []struct { pattern string diff --git a/internal/backup/collector_deps.go b/internal/backup/collector_deps.go index 6cb980a8..cc65d0de 100644 --- a/internal/backup/collector_deps.go +++ b/internal/backup/collector_deps.go @@ -4,13 +4,18 @@ import ( "context" "os" "os/exec" + + "github.com/tis24dev/proxsave/internal/safeexec" ) var ( execLookPath = exec.LookPath runCommandWithEnv = func(ctx context.Context, extraEnv []string, name string, args ...string) ([]byte, error) { - cmd := exec.CommandContext(ctx, name, args...) + cmd, err := safeexec.CommandContext(ctx, name, args...) + if err != nil { + return nil, err + } if len(extraEnv) > 0 { cmd.Env = append(os.Environ(), extraEnv...) } diff --git a/internal/backup/collector_pbs.go b/internal/backup/collector_pbs.go index c3dce1f0..44cd9553 100644 --- a/internal/backup/collector_pbs.go +++ b/internal/backup/collector_pbs.go @@ -80,6 +80,9 @@ func (c *Collector) CollectPBSConfigs(ctx context.Context) error { if err := runRecipe(ctx, newPBSRecipe(), state); err != nil { return err } + if err := runRecipe(ctx, newPBSUserConfigRecipe(), state); err != nil { + return err + } c.logger.Info("PBS configuration collection completed") return nil @@ -270,7 +273,7 @@ func (c *Collector) collectPBSManifestPrune(ctx context.Context, root string) er func (c *Collector) collectPBSCoreRuntime(ctx context.Context, commandsDir string) error { if err := c.collectCommandMulti(ctx, - "proxmox-backup-manager version", + commandSpec("proxmox-backup-manager", "version"), filepath.Join(commandsDir, "pbs_version.txt"), "PBS version", true); err != nil { @@ -282,7 +285,7 @@ func (c *Collector) collectPBSCoreRuntime(ctx context.Context, commandsDir strin func (c *Collector) collectPBSNodeRuntime(ctx context.Context, commandsDir string) error { if c.config.BackupPBSNodeConfig { c.safeCmdOutput(ctx, - "proxmox-backup-manager node show --output-format=json", + commandSpec("proxmox-backup-manager", "node", "show", "--output-format=json"), filepath.Join(commandsDir, "node_config.json"), "Node configuration", false) @@ -293,7 +296,7 @@ func (c *Collector) collectPBSNodeRuntime(ctx context.Context, commandsDir strin func (c *Collector) collectPBSNetworkRuntime(ctx context.Context, commandsDir string) error { if c.config.BackupPBSNetworkConfig { c.safeCmdOutput(ctx, - "proxmox-backup-manager network list --output-format=json", + commandSpec("proxmox-backup-manager", "network", "list", "--output-format=json"), filepath.Join(commandsDir, "network_list.json"), "Network configuration", false) @@ -303,7 +306,7 @@ func (c *Collector) collectPBSNetworkRuntime(ctx context.Context, commandsDir st func (c *Collector) collectPBSDatastoreListRuntime(ctx context.Context, commandsDir string) error { return c.collectCommandMulti(ctx, - "proxmox-backup-manager datastore list --output-format=json", + commandSpec("proxmox-backup-manager", "datastore", "list", "--output-format=json"), filepath.Join(commandsDir, "datastore_list.json"), "Datastore list", false) @@ -325,7 +328,7 @@ func (c *Collector) collectPBSDatastoreStatusRuntime(ctx context.Context, comman } dsKey := ds.pathKey() c.safeCmdOutput(ctx, - fmt.Sprintf("proxmox-backup-manager datastore show %s --output-format=json", cliName), + commandSpec("proxmox-backup-manager", "datastore", "show", cliName, "--output-format=json"), filepath.Join(commandsDir, fmt.Sprintf("datastore_%s_status.json", dsKey)), fmt.Sprintf("Datastore %s status", ds.Name), false) @@ -338,7 +341,7 @@ func (c *Collector) collectPBSAcmeAccountsListRuntime(ctx context.Context, comma return nil, nil } raw, err := c.captureCommandOutput(ctx, - "proxmox-backup-manager acme account list --output-format=json", + commandSpec("proxmox-backup-manager", "acme", "account", "list", "--output-format=json"), filepath.Join(commandsDir, "acme_accounts.json"), "ACME accounts", false) @@ -359,7 +362,7 @@ func (c *Collector) collectPBSAcmeAccountInfoRuntime(ctx context.Context, comman for _, name := range uniqueSortedStrings(accountNames) { out := filepath.Join(commandsDir, fmt.Sprintf("acme_account_%s_info.json", sanitizeFilename(name))) c.collectCommandOptional(ctx, - fmt.Sprintf("proxmox-backup-manager acme account info %s --output-format=json", name), + commandSpec("proxmox-backup-manager", "acme", "account", "info", name, "--output-format=json"), out, fmt.Sprintf("ACME account info (%s)", name)) } @@ -371,7 +374,7 @@ func (c *Collector) collectPBSAcmePluginsListRuntime(ctx context.Context, comman return nil, nil } raw, err := c.captureCommandOutput(ctx, - "proxmox-backup-manager acme plugin list --output-format=json", + commandSpec("proxmox-backup-manager", "acme", "plugin", "list", "--output-format=json"), filepath.Join(commandsDir, "acme_plugins.json"), "ACME plugins", false) @@ -392,7 +395,7 @@ func (c *Collector) collectPBSAcmePluginConfigRuntime(ctx context.Context, comma for _, id := range uniqueSortedStrings(pluginIDs) { out := filepath.Join(commandsDir, fmt.Sprintf("acme_plugin_%s_config.json", sanitizeFilename(id))) c.collectCommandOptional(ctx, - fmt.Sprintf("proxmox-backup-manager acme plugin config %s --output-format=json", id), + commandSpec("proxmox-backup-manager", "acme", "plugin", "config", id, "--output-format=json"), out, fmt.Sprintf("ACME plugin config (%s)", id)) } @@ -404,7 +407,7 @@ func (c *Collector) collectPBSNotificationTargetsRuntime(ctx context.Context, co return nil } return c.collectCommandMulti(ctx, - "proxmox-backup-manager notification target list --output-format=json", + commandSpec("proxmox-backup-manager", "notification", "target", "list", "--output-format=json"), filepath.Join(commandsDir, "notification_targets.json"), "Notification targets", false) @@ -415,7 +418,7 @@ func (c *Collector) collectPBSNotificationMatchersRuntime(ctx context.Context, c return nil } return c.collectCommandMulti(ctx, - "proxmox-backup-manager notification matcher list --output-format=json", + commandSpec("proxmox-backup-manager", "notification", "matcher", "list", "--output-format=json"), filepath.Join(commandsDir, "notification_matchers.json"), "Notification matchers", false) @@ -426,7 +429,7 @@ func (c *Collector) collectPBSNotificationEndpointRuntime(ctx context.Context, c return nil } return c.collectCommandMulti(ctx, - fmt.Sprintf("proxmox-backup-manager notification endpoint %s list --output-format=json", typ), + commandSpec("proxmox-backup-manager", "notification", "endpoint", typ, "list", "--output-format=json"), filepath.Join(commandsDir, fmt.Sprintf("notification_endpoints_%s.json", typ)), fmt.Sprintf("Notification endpoints (%s)", typ), false) @@ -453,7 +456,7 @@ func (c *Collector) collectPBSAccessUsersRuntime(ctx context.Context, commandsDi return nil, nil } raw, err := c.captureCommandOutput(ctx, - "proxmox-backup-manager user list --output-format=json", + commandSpec("proxmox-backup-manager", "user", "list", "--output-format=json"), filepath.Join(commandsDir, "user_list.json"), "User list", false) @@ -467,23 +470,23 @@ func (c *Collector) collectPBSAccessUsersRuntime(ctx context.Context, commandsDi return ids, nil } -func (c *Collector) collectPBSAccessRealmRuntime(ctx context.Context, commandsDir, cmd, out, desc string) error { +func (c *Collector) collectPBSAccessRealmRuntime(ctx context.Context, commandsDir string, spec CommandSpec, out, desc string) error { if !c.config.BackupUserConfigs { return nil } - return c.collectCommandMulti(ctx, cmd, filepath.Join(commandsDir, out), desc, false) + return c.collectCommandMulti(ctx, spec, filepath.Join(commandsDir, out), desc, false) } func (c *Collector) collectPBSAccessRealmLDAPRuntime(ctx context.Context, commandsDir string) error { - return c.collectPBSAccessRealmRuntime(ctx, commandsDir, "proxmox-backup-manager ldap list --output-format=json", "realms_ldap.json", "LDAP realms") + return c.collectPBSAccessRealmRuntime(ctx, commandsDir, commandSpec("proxmox-backup-manager", "ldap", "list", "--output-format=json"), "realms_ldap.json", "LDAP realms") } func (c *Collector) collectPBSAccessRealmADRuntime(ctx context.Context, commandsDir string) error { - return c.collectPBSAccessRealmRuntime(ctx, commandsDir, "proxmox-backup-manager ad list --output-format=json", "realms_ad.json", "Active Directory realms") + return c.collectPBSAccessRealmRuntime(ctx, commandsDir, commandSpec("proxmox-backup-manager", "ad", "list", "--output-format=json"), "realms_ad.json", "Active Directory realms") } func (c *Collector) collectPBSAccessRealmOpenIDRuntime(ctx context.Context, commandsDir string) error { - return c.collectPBSAccessRealmRuntime(ctx, commandsDir, "proxmox-backup-manager openid list --output-format=json", "realms_openid.json", "OpenID realms") + return c.collectPBSAccessRealmRuntime(ctx, commandsDir, commandSpec("proxmox-backup-manager", "openid", "list", "--output-format=json"), "realms_openid.json", "OpenID realms") } func (c *Collector) collectPBSAccessACLRuntime(ctx context.Context, commandsDir string) error { @@ -491,7 +494,7 @@ func (c *Collector) collectPBSAccessACLRuntime(ctx context.Context, commandsDir return nil } return c.collectCommandMulti(ctx, - "proxmox-backup-manager acl list --output-format=json", + commandSpec("proxmox-backup-manager", "acl", "list", "--output-format=json"), filepath.Join(commandsDir, "acl_list.json"), "ACL list", false) @@ -519,7 +522,7 @@ func (c *Collector) collectPBSRemotesRuntime(ctx context.Context, commandsDir st return nil } return c.collectCommandMulti(ctx, - "proxmox-backup-manager remote list --output-format=json", + commandSpec("proxmox-backup-manager", "remote", "list", "--output-format=json"), filepath.Join(commandsDir, "remote_list.json"), "Remote list", false) @@ -530,7 +533,7 @@ func (c *Collector) collectPBSSyncJobsRuntime(ctx context.Context, commandsDir s return nil } return c.collectCommandMulti(ctx, - "proxmox-backup-manager sync-job list --output-format=json", + commandSpec("proxmox-backup-manager", "sync-job", "list", "--output-format=json"), filepath.Join(commandsDir, "sync_jobs.json"), "Sync jobs", false) @@ -541,7 +544,7 @@ func (c *Collector) collectPBSVerificationJobsRuntime(ctx context.Context, comma return nil } return c.collectCommandMulti(ctx, - "proxmox-backup-manager verify-job list --output-format=json", + commandSpec("proxmox-backup-manager", "verify-job", "list", "--output-format=json"), filepath.Join(commandsDir, "verification_jobs.json"), "Verification jobs", false) @@ -552,7 +555,7 @@ func (c *Collector) collectPBSPruneJobsRuntime(ctx context.Context, commandsDir return nil } return c.collectCommandMulti(ctx, - "proxmox-backup-manager prune-job list --output-format=json", + commandSpec("proxmox-backup-manager", "prune-job", "list", "--output-format=json"), filepath.Join(commandsDir, "prune_jobs.json"), "Prune jobs", false) @@ -560,7 +563,7 @@ func (c *Collector) collectPBSPruneJobsRuntime(ctx context.Context, commandsDir func (c *Collector) collectPBSGCJobsRuntime(ctx context.Context, commandsDir string) error { return c.collectCommandMulti(ctx, - "proxmox-backup-manager garbage-collection list --output-format=json", + commandSpec("proxmox-backup-manager", "garbage-collection", "list", "--output-format=json"), filepath.Join(commandsDir, "gc_jobs.json"), "Garbage collection jobs", false) @@ -575,7 +578,7 @@ func (c *Collector) collectPBSTapeDrivesRuntime(ctx context.Context, commandsDir return nil } c.safeCmdOutput(ctx, - "proxmox-tape drive list --output-format=json", + commandSpec("proxmox-tape", "drive", "list", "--output-format=json"), filepath.Join(commandsDir, "tape_drives.json"), "Tape drives", false) @@ -587,7 +590,7 @@ func (c *Collector) collectPBSTapeChangersRuntime(ctx context.Context, commandsD return nil } c.safeCmdOutput(ctx, - "proxmox-tape changer list --output-format=json", + commandSpec("proxmox-tape", "changer", "list", "--output-format=json"), filepath.Join(commandsDir, "tape_changers.json"), "Tape changers", false) @@ -599,7 +602,7 @@ func (c *Collector) collectPBSTapePoolsRuntime(ctx context.Context, commandsDir return nil } c.safeCmdOutput(ctx, - "proxmox-tape pool list --output-format=json", + commandSpec("proxmox-tape", "pool", "list", "--output-format=json"), filepath.Join(commandsDir, "tape_pools.json"), "Tape pools", false) @@ -608,7 +611,7 @@ func (c *Collector) collectPBSTapePoolsRuntime(ctx context.Context, commandsDir func (c *Collector) collectPBSDisksRuntime(ctx context.Context, commandsDir string) error { c.safeCmdOutput(ctx, - "proxmox-backup-manager disk list --output-format=json", + commandSpec("proxmox-backup-manager", "disk", "list", "--output-format=json"), filepath.Join(commandsDir, "disk_list.json"), "Disk list", false) @@ -617,7 +620,7 @@ func (c *Collector) collectPBSDisksRuntime(ctx context.Context, commandsDir stri func (c *Collector) collectPBSCertInfoRuntime(ctx context.Context, commandsDir string) error { return c.collectCommandMulti(ctx, - "proxmox-backup-manager cert info", + commandSpec("proxmox-backup-manager", "cert", "info"), filepath.Join(commandsDir, "cert_info.txt"), "Certificate information", false) @@ -628,7 +631,7 @@ func (c *Collector) collectPBSTrafficControlRuntime(ctx context.Context, command return nil } c.safeCmdOutput(ctx, - "proxmox-backup-manager traffic-control list --output-format=json", + commandSpec("proxmox-backup-manager", "traffic-control", "list", "--output-format=json"), filepath.Join(commandsDir, "traffic_control.json"), "Traffic control rules", false) @@ -637,7 +640,7 @@ func (c *Collector) collectPBSTrafficControlRuntime(ctx context.Context, command func (c *Collector) collectPBSRecentTasksRuntime(ctx context.Context, commandsDir string) error { c.safeCmdOutput(ctx, - "proxmox-backup-manager task list --limit 50 --output-format=json", + commandSpec("proxmox-backup-manager", "task", "list", "--limit", "50", "--output-format=json"), filepath.Join(commandsDir, "recent_tasks.json"), "Recent tasks", false) @@ -649,7 +652,7 @@ func (c *Collector) collectPBSS3EndpointsRuntime(ctx context.Context, commandsDi return nil, nil } raw, err := c.captureCommandOutput(ctx, - "proxmox-backup-manager s3 endpoint list --output-format=json", + commandSpec("proxmox-backup-manager", "s3", "endpoint", "list", "--output-format=json"), filepath.Join(commandsDir, "s3_endpoints.json"), "S3 endpoints", false) @@ -670,7 +673,7 @@ func (c *Collector) collectPBSS3EndpointBucketsRuntime(ctx context.Context, comm for _, id := range uniqueSortedStrings(endpointIDs) { out := filepath.Join(commandsDir, fmt.Sprintf("s3_endpoint_%s_buckets.json", sanitizeFilename(id))) c.collectCommandOptional(ctx, - fmt.Sprintf("proxmox-backup-manager s3 endpoint list-buckets %s --output-format=json", id), + commandSpec("proxmox-backup-manager", "s3", "endpoint", "list-buckets", id, "--output-format=json"), out, fmt.Sprintf("S3 endpoint buckets (%s)", id)) } @@ -738,8 +741,8 @@ func (c *Collector) collectPBSUserTokensForIDs(ctx context.Context, usersDir str aggregated := make(map[string]json.RawMessage) for _, id := range uniqueSortedStrings(userIDs) { tokenPath := filepath.Join(usersDir, fmt.Sprintf("%s_tokens.json", sanitizeFilename(id))) - cmd := fmt.Sprintf("proxmox-backup-manager user list-tokens %s --output-format=json", id) - if err := c.safeCmdOutput(ctx, cmd, tokenPath, fmt.Sprintf("API tokens for %s", id), false); err != nil { + spec := commandSpec("proxmox-backup-manager", "user", "list-tokens", id, "--output-format=json") + if err := c.safeCmdOutput(ctx, spec, tokenPath, fmt.Sprintf("API tokens for %s", id), false); err != nil { c.logger.Debug("Token export skipped for %s: %v", id, err) continue } diff --git a/internal/backup/collector_pbs_auth_test.go b/internal/backup/collector_pbs_auth_test.go index d5575383..3c9f17ae 100644 --- a/internal/backup/collector_pbs_auth_test.go +++ b/internal/backup/collector_pbs_auth_test.go @@ -34,7 +34,7 @@ func TestSafeCmdOutputWithPBSAuthSetsEnv(t *testing.T) { collector := NewCollector(newTestLogger(), cfg, t.TempDir(), types.ProxmoxBS, false) output := filepath.Join(t.TempDir(), "out.txt") - if err := collector.safeCmdOutputWithPBSAuth(context.Background(), "echo hi", output, "test", true); err != nil { + if err := collector.safeCmdOutputWithPBSAuth(context.Background(), commandSpec("echo", "hi"), output, "test", true); err != nil { t.Fatalf("safeCmdOutputWithPBSAuth error: %v", err) } @@ -72,7 +72,7 @@ func TestSafeCmdOutputWithPBSAuthForDatastoreBuildsRepo(t *testing.T) { collector := NewCollector(newTestLogger(), cfg, t.TempDir(), types.ProxmoxBS, false) output := filepath.Join(t.TempDir(), "out.txt") - if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), "echo hi", output, "desc", "newds", true); err != nil { + if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), commandSpec("echo", "hi"), output, "desc", "newds", true); err != nil { t.Fatalf("safeCmdOutputWithPBSAuthForDatastore error: %v", err) } @@ -84,6 +84,30 @@ func TestSafeCmdOutputWithPBSAuthForDatastoreBuildsRepo(t *testing.T) { } } +func TestPBSRepositoryWithDatastorePreservesHostPortAndIPv6(t *testing.T) { + tests := []struct { + name string + repo string + datastore string + want string + }{ + {name: "host only", repo: "user@host", datastore: "newds", want: "user@host:newds"}, + {name: "existing datastore", repo: "user@host:oldds", datastore: "newds", want: "user@host:newds"}, + {name: "host port", repo: "user@host:8007:oldds", datastore: "newds", want: "user@host:8007:newds"}, + {name: "bracketed ipv6", repo: "[2001:db8::1]:oldds", datastore: "newds", want: "[2001:db8::1]:newds"}, + {name: "user bracketed ipv6", repo: "user@[2001:db8::1]:oldds", datastore: "newds", want: "user@[2001:db8::1]:newds"}, + {name: "bracketed ipv6 without datastore", repo: "[2001:db8::1]", datastore: "newds", want: "[2001:db8::1]:newds"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := pbsRepositoryWithDatastore(tt.repo, tt.datastore); got != tt.want { + t.Fatalf("pbsRepositoryWithDatastore(%q, %q) = %q, want %q", tt.repo, tt.datastore, got, tt.want) + } + }) + } +} + func TestSafeCmdOutputWithPBSAuthForDatastoreSkipsWhenNoCredentials(t *testing.T) { origLookPath := execLookPath origRun := runCommandWithEnv @@ -107,7 +131,7 @@ func TestSafeCmdOutputWithPBSAuthForDatastoreSkipsWhenNoCredentials(t *testing.T collector := NewCollector(newTestLogger(), cfg, t.TempDir(), types.ProxmoxBS, false) output := filepath.Join(t.TempDir(), "out.txt") - if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), "echo hi", output, "desc", "ds", true); err != nil { + if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), commandSpec("echo", "hi"), output, "desc", "ds", true); err != nil { t.Fatalf("safeCmdOutputWithPBSAuthForDatastore error: %v", err) } @@ -142,7 +166,7 @@ func TestSafeCmdOutputWithPBSAuthForDatastoreDefaultsUserWhenRepoEmpty(t *testin collector := NewCollector(newTestLogger(), cfg, t.TempDir(), types.ProxmoxBS, false) output := filepath.Join(t.TempDir(), "out.txt") - if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), "echo hi", output, "desc", "ds1", true); err != nil { + if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), commandSpec("echo", "hi"), output, "desc", "ds1", true); err != nil { t.Fatalf("safeCmdOutputWithPBSAuthForDatastore error: %v", err) } @@ -161,7 +185,7 @@ func TestSafeCmdOutputWithPBSAuthForDatastoreDefaultsUserWhenRepoEmpty(t *testin func TestSafeCmdOutputWithPBSAuthReturnsErrorOnEmptyCommand(t *testing.T) { cfg := GetDefaultCollectorConfig() collector := NewCollector(newTestLogger(), cfg, t.TempDir(), types.ProxmoxBS, false) - if err := collector.safeCmdOutputWithPBSAuth(context.Background(), " ", filepath.Join(t.TempDir(), "out.txt"), "desc", false); err == nil { + if err := collector.safeCmdOutputWithPBSAuth(context.Background(), CommandSpec{}, filepath.Join(t.TempDir(), "out.txt"), "desc", false); err == nil { t.Fatalf("expected error for empty command") } } @@ -173,7 +197,7 @@ func TestSafeCmdOutputWithPBSAuthCriticalCommandNotAvailableIncrementsFilesFaile cfg := GetDefaultCollectorConfig() collector := NewCollector(newTestLogger(), cfg, t.TempDir(), types.ProxmoxBS, false) - if err := collector.safeCmdOutputWithPBSAuth(context.Background(), "missing-cmd arg", filepath.Join(t.TempDir(), "out.txt"), "desc", true); err == nil { + if err := collector.safeCmdOutputWithPBSAuth(context.Background(), commandSpec("missing-cmd", "arg"), filepath.Join(t.TempDir(), "out.txt"), "desc", true); err == nil { t.Fatalf("expected error for critical missing command") } if got := collector.GetStats().FilesFailed; got != 1 { @@ -198,7 +222,7 @@ func TestSafeCmdOutputWithPBSAuthDryRunSkipsExecution(t *testing.T) { cfg := GetDefaultCollectorConfig() collector := NewCollector(newTestLogger(), cfg, t.TempDir(), types.ProxmoxBS, true) output := filepath.Join(t.TempDir(), "out.txt") - if err := collector.safeCmdOutputWithPBSAuth(context.Background(), "echo hi", output, "desc", false); err != nil { + if err := collector.safeCmdOutputWithPBSAuth(context.Background(), commandSpec("echo", "hi"), output, "desc", false); err != nil { t.Fatalf("unexpected error: %v", err) } if _, err := os.Stat(output); !os.IsNotExist(err) { @@ -226,7 +250,7 @@ func TestSafeCmdOutputWithPBSAuthWriteFailureIncrementsFilesFailed(t *testing.T) if err := os.MkdirAll(outputDir, 0o755); err != nil { t.Fatalf("mkdir outputDir: %v", err) } - if err := collector.safeCmdOutputWithPBSAuth(context.Background(), "echo hi", outputDir, "desc", false); err == nil { + if err := collector.safeCmdOutputWithPBSAuth(context.Background(), commandSpec("echo", "hi"), outputDir, "desc", false); err == nil { t.Fatalf("expected write error when output path is a directory") } if got := collector.GetStats().FilesFailed; got != 1 { @@ -241,7 +265,7 @@ func TestSafeCmdOutputWithPBSAuthHonorsContextCancellation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - if err := collector.safeCmdOutputWithPBSAuth(ctx, "echo hi", filepath.Join(t.TempDir(), "out.txt"), "desc", false); !errors.Is(err, context.Canceled) { + if err := collector.safeCmdOutputWithPBSAuth(ctx, commandSpec("echo", "hi"), filepath.Join(t.TempDir(), "out.txt"), "desc", false); !errors.Is(err, context.Canceled) { t.Fatalf("expected context.Canceled, got %v", err) } } @@ -254,7 +278,7 @@ func TestSafeCmdOutputWithPBSAuthNonCriticalCommandNotAvailableIsSkipped(t *test cfg := GetDefaultCollectorConfig() collector := NewCollector(newTestLogger(), cfg, t.TempDir(), types.ProxmoxBS, false) output := filepath.Join(t.TempDir(), "out.txt") - if err := collector.safeCmdOutputWithPBSAuth(context.Background(), "missing-cmd arg", output, "desc", false); err != nil { + if err := collector.safeCmdOutputWithPBSAuth(context.Background(), commandSpec("missing-cmd", "arg"), output, "desc", false); err != nil { t.Fatalf("expected non-critical missing command to be skipped, got %v", err) } if _, err := os.Stat(output); !os.IsNotExist(err) { @@ -278,7 +302,7 @@ func TestSafeCmdOutputWithPBSAuthNonCriticalCommandFailureIsSwallowed(t *testing cfg := GetDefaultCollectorConfig() collector := NewCollector(newTestLogger(), cfg, t.TempDir(), types.ProxmoxBS, false) output := filepath.Join(t.TempDir(), "out.txt") - if err := collector.safeCmdOutputWithPBSAuth(context.Background(), "echo hi", output, "desc", false); err != nil { + if err := collector.safeCmdOutputWithPBSAuth(context.Background(), commandSpec("echo", "hi"), output, "desc", false); err != nil { t.Fatalf("expected non-critical failure to be swallowed, got %v", err) } if _, err := os.Stat(output); !os.IsNotExist(err) { @@ -307,7 +331,7 @@ func TestSafeCmdOutputWithPBSAuthEnsureDirFailureReturnsError(t *testing.T) { t.Fatalf("write blocker: %v", err) } output := filepath.Join(blocker, "out.txt") - if err := collector.safeCmdOutputWithPBSAuth(context.Background(), "echo hi", output, "desc", true); err == nil { + if err := collector.safeCmdOutputWithPBSAuth(context.Background(), commandSpec("echo", "hi"), output, "desc", true); err == nil { t.Fatalf("expected ensureDir error") } } @@ -328,7 +352,7 @@ func TestSafeCmdOutputWithPBSAuthCriticalFailureIncrementsFilesFailed(t *testing cfg := GetDefaultCollectorConfig() collector := NewCollector(newTestLogger(), cfg, t.TempDir(), types.ProxmoxBS, false) output := filepath.Join(t.TempDir(), "out.txt") - if err := collector.safeCmdOutputWithPBSAuth(context.Background(), "echo hi", output, "desc", true); err == nil { + if err := collector.safeCmdOutputWithPBSAuth(context.Background(), commandSpec("echo", "hi"), output, "desc", true); err == nil { t.Fatalf("expected critical error") } if got := collector.GetStats().FilesFailed; got != 1 { @@ -358,7 +382,7 @@ func TestSafeCmdOutputWithPBSAuthForDatastoreAppendsDatastoreAndIncludesFingerpr collector := NewCollector(newTestLogger(), cfg, t.TempDir(), types.ProxmoxBS, false) output := filepath.Join(t.TempDir(), "out.txt") - if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), "echo hi", output, "desc", "ds1", false); err != nil { + if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), commandSpec("echo", "hi"), output, "desc", "ds1", false); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -389,7 +413,7 @@ func TestSafeCmdOutputWithPBSAuthForDatastoreNonCriticalFailureReturnsNil(t *tes collector := NewCollector(newTestLogger(), cfg, t.TempDir(), types.ProxmoxBS, false) output := filepath.Join(t.TempDir(), "out.txt") - if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), "echo hi", output, "desc", "ds1", false); err != nil { + if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), commandSpec("echo", "hi"), output, "desc", "ds1", false); err != nil { t.Fatalf("expected non-critical failure to be swallowed, got %v", err) } if _, err := os.Stat(output); !os.IsNotExist(err) { @@ -403,7 +427,7 @@ func TestSafeCmdOutputWithPBSAuthForDatastoreReturnsErrorOnEmptyCommand(t *testi cfg.PBSPassword = "secret" collector := NewCollector(newTestLogger(), cfg, t.TempDir(), types.ProxmoxBS, false) - if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), " ", filepath.Join(t.TempDir(), "out.txt"), "desc", "ds", false); err == nil { + if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), CommandSpec{}, filepath.Join(t.TempDir(), "out.txt"), "desc", "ds", false); err == nil { t.Fatalf("expected error for empty command") } } @@ -417,7 +441,7 @@ func TestSafeCmdOutputWithPBSAuthForDatastoreHonorsContextCancellation(t *testin ctx, cancel := context.WithCancel(context.Background()) cancel() - if err := collector.safeCmdOutputWithPBSAuthForDatastore(ctx, "echo hi", filepath.Join(t.TempDir(), "out.txt"), "desc", "ds", false); !errors.Is(err, context.Canceled) { + if err := collector.safeCmdOutputWithPBSAuthForDatastore(ctx, commandSpec("echo", "hi"), filepath.Join(t.TempDir(), "out.txt"), "desc", "ds", false); !errors.Is(err, context.Canceled) { t.Fatalf("expected context.Canceled, got %v", err) } } @@ -432,7 +456,7 @@ func TestSafeCmdOutputWithPBSAuthForDatastoreNonCriticalCommandNotAvailableIsSki cfg.PBSPassword = "secret" collector := NewCollector(newTestLogger(), cfg, t.TempDir(), types.ProxmoxBS, false) output := filepath.Join(t.TempDir(), "out.txt") - if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), "missing-cmd arg", output, "desc", "ds", false); err != nil { + if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), commandSpec("missing-cmd", "arg"), output, "desc", "ds", false); err != nil { t.Fatalf("expected non-critical missing command to be skipped, got %v", err) } if _, err := os.Stat(output); !os.IsNotExist(err) { @@ -450,7 +474,7 @@ func TestSafeCmdOutputWithPBSAuthForDatastoreCriticalCommandNotAvailableIncremen cfg.PBSPassword = "secret" collector := NewCollector(newTestLogger(), cfg, t.TempDir(), types.ProxmoxBS, false) output := filepath.Join(t.TempDir(), "out.txt") - if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), "missing-cmd arg", output, "desc", "ds", true); err == nil { + if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), commandSpec("missing-cmd", "arg"), output, "desc", "ds", true); err == nil { t.Fatalf("expected critical error for missing command") } if got := collector.GetStats().FilesFailed; got != 1 { @@ -478,7 +502,7 @@ func TestSafeCmdOutputWithPBSAuthForDatastoreDryRunSkipsExecution(t *testing.T) collector := NewCollector(newTestLogger(), cfg, t.TempDir(), types.ProxmoxBS, true) output := filepath.Join(t.TempDir(), "out.txt") - if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), "echo hi", output, "desc", "ds", false); err != nil { + if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), commandSpec("echo", "hi"), output, "desc", "ds", false); err != nil { t.Fatalf("unexpected error: %v", err) } if _, err := os.Stat(output); !os.IsNotExist(err) { @@ -505,7 +529,7 @@ func TestSafeCmdOutputWithPBSAuthForDatastoreCriticalFailureIncrementsFilesFaile collector := NewCollector(newTestLogger(), cfg, t.TempDir(), types.ProxmoxBS, false) output := filepath.Join(t.TempDir(), "out.txt") - if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), "echo hi", output, "desc", "ds", true); err == nil { + if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), commandSpec("echo", "hi"), output, "desc", "ds", true); err == nil { t.Fatalf("expected critical error") } if got := collector.GetStats().FilesFailed; got != 1 { @@ -536,7 +560,7 @@ func TestSafeCmdOutputWithPBSAuthForDatastoreWriteFailureIncrementsFilesFailed(t t.Fatalf("mkdir outputDir: %v", err) } - if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), "echo hi", outputDir, "desc", "ds", false); err == nil { + if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), commandSpec("echo", "hi"), outputDir, "desc", "ds", false); err == nil { t.Fatalf("expected write error") } if got := collector.GetStats().FilesFailed; got != 1 { @@ -567,7 +591,7 @@ func TestSafeCmdOutputWithPBSAuthForDatastoreEnsureDirFailureReturnsError(t *tes t.Fatalf("write blocker: %v", err) } output := filepath.Join(blocker, "out.txt") - if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), "echo hi", output, "desc", "ds", false); err == nil { + if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), commandSpec("echo", "hi"), output, "desc", "ds", false); err == nil { t.Fatalf("expected ensureDir error") } } diff --git a/internal/backup/collector_pbs_datastore.go b/internal/backup/collector_pbs_datastore.go index b399b20b..da5715e1 100644 --- a/internal/backup/collector_pbs_datastore.go +++ b/internal/backup/collector_pbs_datastore.go @@ -344,7 +344,7 @@ func (c *Collector) collectPBSDatastoreCLIConfigs(ctx context.Context, state *pb dsKey := ds.pathKey() if cliName := ds.cliName(); cliName != "" && !ds.isOverride() { c.safeCmdOutput(ctx, - fmt.Sprintf("proxmox-backup-manager datastore show %s --output-format=json", cliName), + commandSpec("proxmox-backup-manager", "datastore", "show", cliName, "--output-format=json"), filepath.Join(state.datastoreDir, fmt.Sprintf("%s_config.json", dsKey)), fmt.Sprintf("Datastore %s configuration", ds.Name), false) diff --git a/internal/backup/collector_privilege_sensitive_test.go b/internal/backup/collector_privilege_sensitive_test.go index ff7925f4..0a2a79fc 100644 --- a/internal/backup/collector_privilege_sensitive_test.go +++ b/internal/backup/collector_privilege_sensitive_test.go @@ -33,7 +33,7 @@ func TestSafeCmdOutput_LimitedPrivileges_DowngradesDmidecodeToSkip(t *testing.T) c := NewCollectorWithDeps(logger, GetDefaultCollectorConfig(), tmp, types.ProxmoxUnknown, false, deps) outPath := filepath.Join(tmp, "dmidecode.txt") - if err := c.safeCmdOutput(context.Background(), "dmidecode", outPath, "Hardware DMI information", false); err != nil { + if err := c.safeCmdOutput(context.Background(), commandSpec("dmidecode"), outPath, "Hardware DMI information", false); err != nil { t.Fatalf("safeCmdOutput returned error: %v", err) } @@ -73,7 +73,7 @@ func TestCaptureCommandOutput_LimitedPrivileges_DowngradesBlkidToSkipWithRestore c := NewCollectorWithDeps(logger, GetDefaultCollectorConfig(), tmp, types.ProxmoxUnknown, false, deps) outPath := filepath.Join(tmp, "blkid.txt") - data, err := c.captureCommandOutput(context.Background(), "blkid", outPath, "Block device identifiers (blkid)", false) + data, err := c.captureCommandOutput(context.Background(), commandSpec("blkid"), outPath, "Block device identifiers (blkid)", false) if err != nil { t.Fatalf("captureCommandOutput returned error: %v", err) } @@ -116,7 +116,7 @@ func TestSafeCmdOutput_LimitedPrivileges_DowngradesSensorsToSkip(t *testing.T) { c := NewCollectorWithDeps(logger, GetDefaultCollectorConfig(), tmp, types.ProxmoxUnknown, false, deps) outPath := filepath.Join(tmp, "sensors.txt") - if err := c.safeCmdOutput(context.Background(), "sensors", outPath, "Hardware sensors", false); err != nil { + if err := c.safeCmdOutput(context.Background(), commandSpec("sensors"), outPath, "Hardware sensors", false); err != nil { t.Fatalf("safeCmdOutput returned error: %v", err) } @@ -156,7 +156,7 @@ func TestSafeCmdOutput_LimitedPrivileges_DowngradesSmartctlToSkip(t *testing.T) c := NewCollectorWithDeps(logger, GetDefaultCollectorConfig(), tmp, types.ProxmoxUnknown, false, deps) outPath := filepath.Join(tmp, "smartctl_scan.txt") - if err := c.safeCmdOutput(context.Background(), "smartctl --scan", outPath, "SMART scan", false); err != nil { + if err := c.safeCmdOutput(context.Background(), commandSpec("smartctl", "--scan"), outPath, "SMART scan", false); err != nil { t.Fatalf("safeCmdOutput returned error: %v", err) } diff --git a/internal/backup/collector_pve.go b/internal/backup/collector_pve.go index 801a01f8..18313254 100644 --- a/internal/backup/collector_pve.go +++ b/internal/backup/collector_pve.go @@ -456,7 +456,7 @@ func (c *Collector) collectPVEVZDumpSnapshot(ctx context.Context) error { func (c *Collector) collectPVECoreRuntime(ctx context.Context, commandsDir string, info *pveRuntimeInfo) error { if err := c.safeCmdOutput(ctx, - "pveversion -v", + commandSpec("pveversion", "-v"), filepath.Join(commandsDir, "pveversion.txt"), "PVE version", true); err != nil { @@ -464,19 +464,19 @@ func (c *Collector) collectPVECoreRuntime(ctx context.Context, commandsDir strin } c.safeCmdOutput(ctx, - "pvenode config get", + commandSpec("pvenode", "config", "get"), filepath.Join(commandsDir, "node_config.txt"), "Node configuration", false) c.safeCmdOutput(ctx, - "pvesh get /version --output-format=json", + commandSpec("pvesh", "get", "/version", "--output-format=json"), filepath.Join(commandsDir, "api_version.json"), "API version", false) if nodeData, err := c.captureCommandOutput(ctx, - "pvesh get /nodes --output-format=json", + commandSpec("pvesh", "get", "/nodes", "--output-format=json"), filepath.Join(commandsDir, "nodes_status.json"), "node status", false); err != nil { @@ -505,22 +505,22 @@ func (c *Collector) collectPVEACLRuntime(ctx context.Context, commandsDir string } c.safeCmdOutput(ctx, - "pveum user list --output-format=json", + commandSpec("pveum", "user", "list", "--output-format=json"), filepath.Join(commandsDir, "pve_users.json"), "PVE users", false) c.safeCmdOutput(ctx, - "pveum group list --output-format=json", + commandSpec("pveum", "group", "list", "--output-format=json"), filepath.Join(commandsDir, "pve_groups.json"), "PVE groups", false) c.safeCmdOutput(ctx, - "pveum role list --output-format=json", + commandSpec("pveum", "role", "list", "--output-format=json"), filepath.Join(commandsDir, "pve_roles.json"), "PVE roles", false) c.safeCmdOutput(ctx, - "pveum pool list --output-format=json", + commandSpec("pveum", "pool", "list", "--output-format=json"), filepath.Join(commandsDir, "pools.json"), "PVE resource pools", false) @@ -529,32 +529,32 @@ func (c *Collector) collectPVEACLRuntime(ctx context.Context, commandsDir string func (c *Collector) collectPVEClusterRuntime(ctx context.Context, commandsDir string, clustered bool) { if clustered && c.config.BackupClusterConfig { c.safeCmdOutput(ctx, - "pvecm status", + commandSpec("pvecm", "status"), filepath.Join(commandsDir, "cluster_status.txt"), "Cluster status", false) c.safeCmdOutput(ctx, - "pvecm nodes", + commandSpec("pvecm", "nodes"), filepath.Join(commandsDir, "cluster_nodes.txt"), "Cluster nodes", false) c.safeCmdOutput(ctx, - "pvesh get /cluster/ha/status --output-format=json", + commandSpec("pvesh", "get", "/cluster/ha/status", "--output-format=json"), filepath.Join(commandsDir, "ha_status.json"), "HA status", false) c.safeCmdOutput(ctx, - "pvesh get /cluster/mapping/pci --output-format=json", + commandSpec("pvesh", "get", "/cluster/mapping/pci", "--output-format=json"), filepath.Join(commandsDir, "mapping_pci.json"), "PCI resource mappings", false) c.safeCmdOutput(ctx, - "pvesh get /cluster/mapping/usb --output-format=json", + commandSpec("pvesh", "get", "/cluster/mapping/usb", "--output-format=json"), filepath.Join(commandsDir, "mapping_usb.json"), "USB resource mappings", false) c.safeCmdOutput(ctx, - "pvesh get /cluster/mapping/dir --output-format=json", + commandSpec("pvesh", "get", "/cluster/mapping/dir", "--output-format=json"), filepath.Join(commandsDir, "mapping_dir.json"), "Directory resource mappings", false) @@ -571,14 +571,14 @@ func (c *Collector) collectPVEStorageRuntime(ctx context.Context, commandsDir st } c.safeCmdOutput(ctx, - fmt.Sprintf("pvesh get /nodes/%s/disks/list --output-format=json", nodeName), + commandSpec("pvesh", "get", fmt.Sprintf("/nodes/%s/disks/list", nodeName), "--output-format=json"), filepath.Join(commandsDir, "disks_list.json"), "Disks list", false) storageJSONPath := filepath.Join(commandsDir, "storage_status.json") if storageData, err := c.captureCommandOutput(ctx, - fmt.Sprintf("pvesh get /nodes/%s/storage --output-format=json", nodeName), + commandSpec("pvesh", "get", fmt.Sprintf("/nodes/%s/storage", nodeName), "--output-format=json"), storageJSONPath, "Storage status", false); err != nil { @@ -596,7 +596,7 @@ func (c *Collector) collectPVEStorageRuntime(ctx context.Context, commandsDir st } c.safeCmdOutput(ctx, - "pvesm status", + commandSpec("pvesm", "status"), filepath.Join(commandsDir, "pvesm_status.txt"), "Storage manager status", false) @@ -730,13 +730,13 @@ func (c *Collector) collectPVEGuestInventory(ctx context.Context) error { } c.safeCmdOutput(ctx, - fmt.Sprintf("pvesh get /nodes/%s/qemu --output-format=json", nodeName), + commandSpec("pvesh", "get", fmt.Sprintf("/nodes/%s/qemu", nodeName), "--output-format=json"), filepath.Join(commandsDir, "qemu_vms.json"), "QEMU VMs list", false) c.safeCmdOutput(ctx, - fmt.Sprintf("pvesh get /nodes/%s/lxc --output-format=json", nodeName), + commandSpec("pvesh", "get", fmt.Sprintf("/nodes/%s/lxc", nodeName), "--output-format=json"), filepath.Join(commandsDir, "lxc_containers.json"), "LXC containers list", false) @@ -771,7 +771,7 @@ func (c *Collector) collectPVEBackupJobDefinitions(ctx context.Context) error { } if _, err := c.captureCommandOutput(ctx, - "pvesh get /cluster/backup --output-format=json", + commandSpec("pvesh", "get", "/cluster/backup", "--output-format=json"), filepath.Join(jobsDir, "backup_jobs.json"), "backup jobs", false); err != nil { @@ -790,6 +790,7 @@ func (c *Collector) collectPVEBackupJobHistory(ctx context.Context, nodes []stri } seen := make(map[string]struct{}) + var firstErr error for _, node := range nodes { if err := ctx.Err(); err != nil { return err @@ -803,13 +804,15 @@ func (c *Collector) collectPVEBackupJobHistory(ctx context.Context, nodes []stri } seen[node] = struct{}{} outputPath := filepath.Join(jobsDir, fmt.Sprintf("%s_backup_history.json", node)) - c.captureCommandOutput(ctx, - fmt.Sprintf("pvesh get /nodes/%s/tasks --output-format=json --typefilter=vzdump", node), + if _, err := c.captureCommandOutput(ctx, + commandSpec("pvesh", "get", fmt.Sprintf("/nodes/%s/tasks", node), "--output-format=json", "--typefilter=vzdump"), outputPath, fmt.Sprintf("%s backup history", node), - false) + false); err != nil && firstErr == nil { + firstErr = err + } } - return nil + return firstErr } func (c *Collector) collectPVEVZDumpCronSnapshot(ctx context.Context) error { @@ -888,11 +891,13 @@ func (c *Collector) collectPVEScheduleCrontab(ctx context.Context) error { return fmt.Errorf("failed to create schedules directory: %w", err) } - c.captureCommandOutput(ctx, - "crontab -l", + if _, err := c.captureCommandOutput(ctx, + commandSpec("crontab", "-l"), filepath.Join(schedulesDir, "root_crontab.txt"), "root crontab", - false) + false); err != nil { + return fmt.Errorf("collectPVEScheduleCrontab: %w", err) + } return nil } @@ -904,11 +909,13 @@ func (c *Collector) collectPVEScheduleTimers(ctx context.Context) error { if err := c.ensureDir(schedulesDir); err != nil { return fmt.Errorf("failed to create schedules directory: %w", err) } - c.captureCommandOutput(ctx, - "systemctl list-timers --all --no-pager", + if _, err := c.captureCommandOutput(ctx, + commandSpec("systemctl", "list-timers", "--all", "--no-pager"), filepath.Join(schedulesDir, "systemd_timers.txt"), "systemd timers", - false) + false); err != nil { + return fmt.Errorf("collectPVEScheduleTimers: %w", err) + } return nil } @@ -946,7 +953,7 @@ func (c *Collector) collectPVEReplicationDefinitions(ctx context.Context) error } if _, err := c.captureCommandOutput(ctx, - "pvesh get /cluster/replication --output-format=json", + commandSpec("pvesh", "get", "/cluster/replication", "--output-format=json"), filepath.Join(repDir, "replication_jobs.json"), "replication jobs", false); err != nil { @@ -965,6 +972,7 @@ func (c *Collector) collectPVEReplicationStatus(ctx context.Context, nodes []str } seen := make(map[string]struct{}) + var firstErr error for _, node := range nodes { if err := ctx.Err(); err != nil { return err @@ -978,13 +986,15 @@ func (c *Collector) collectPVEReplicationStatus(ctx context.Context, nodes []str } seen[node] = struct{}{} outputPath := filepath.Join(repDir, fmt.Sprintf("%s_replication_status.json", node)) - c.captureCommandOutput(ctx, - fmt.Sprintf("pvesh get /nodes/%s/replication --output-format=json", node), + if _, err := c.captureCommandOutput(ctx, + commandSpec("pvesh", "get", fmt.Sprintf("/nodes/%s/replication", node), "--output-format=json"), outputPath, fmt.Sprintf("%s replication status", node), - false) + false); err != nil && firstErr == nil { + firstErr = err + } } - return nil + return firstErr } func (c *Collector) resolvePVEStorages(storages []pveStorageEntry) []pveStorageEntry { @@ -1161,6 +1171,9 @@ func (c *Collector) collectPVEStorageMetadataJSONStep(ctx context.Context, resul result.SkipRemaining = true return nil } + if isContextCancellationError(ctx, dirSampleErr) { + return dirSampleErr + } if dirSampleErr != nil { c.logger.Debug("Directory sample for datastore %s failed: %v", storage.Name, dirSampleErr) } @@ -1176,6 +1189,9 @@ func (c *Collector) collectPVEStorageMetadataJSONStep(ctx context.Context, resul result.SkipRemaining = true return nil } + if isContextCancellationError(ctx, diskUsageErr) { + return diskUsageErr + } if diskUsageErr != nil { c.logger.Debug("Disk usage summary for %s failed: %v", storage.Name, diskUsageErr) } else { @@ -1200,6 +1216,9 @@ func (c *Collector) collectPVEStorageMetadataJSONStep(ctx context.Context, resul result.SkipRemaining = true return nil } + if isContextCancellationError(ctx, sampleFileErr) { + return sampleFileErr + } if sampleFileErr != nil { c.logger.Debug("Backup file sample for %s failed: %v", storage.Name, sampleFileErr) } else if len(fileSummaries) > 0 { @@ -1233,6 +1252,9 @@ func (c *Collector) collectPVEStorageMetadataTextStep(ctx context.Context, resul result.SkipRemaining = true return nil } + if isContextCancellationError(ctx, fileSampleErr) { + return fileSampleErr + } if fileSampleErr != nil { c.logger.Debug("General file sampling for %s failed: %v", storage.Name, fileSampleErr) } @@ -1677,16 +1699,16 @@ func (c *Collector) collectPVECephRuntime(ctx context.Context) error { } commands := []struct { - cmd string + cmd CommandSpec file string desc string }{ - {"ceph -s", "ceph_status.txt", "Ceph status"}, - {"ceph osd df", "ceph_osd_df.txt", "Ceph OSD DF"}, - {"ceph osd tree", "ceph_osd_tree.txt", "Ceph OSD tree"}, - {"ceph mon stat", "ceph_mon_stat.txt", "Ceph mon stat"}, - {"ceph pg stat", "ceph_pg_stat.txt", "Ceph PG stat"}, - {"ceph health detail", "ceph_health.txt", "Ceph health"}, + {commandSpec("ceph", "-s"), "ceph_status.txt", "Ceph status"}, + {commandSpec("ceph", "osd", "df"), "ceph_osd_df.txt", "Ceph OSD DF"}, + {commandSpec("ceph", "osd", "tree"), "ceph_osd_tree.txt", "Ceph OSD tree"}, + {commandSpec("ceph", "mon", "stat"), "ceph_mon_stat.txt", "Ceph mon stat"}, + {commandSpec("ceph", "pg", "stat"), "ceph_pg_stat.txt", "Ceph PG stat"}, + {commandSpec("ceph", "health", "detail"), "ceph_health.txt", "Ceph health"}, } for _, command := range commands { @@ -1890,7 +1912,7 @@ func (c *Collector) aggregateReplicationStatus(ctx context.Context, replicationD func (c *Collector) writePVEVersionInfo(ctx context.Context, baseInfoDir string) error { versionFile := filepath.Join(baseInfoDir, "pve_version.txt") - if err := c.safeCmdOutput(ctx, "pveversion", versionFile, "PVE version info", false); err != nil { + if err := c.safeCmdOutput(ctx, commandSpec("pveversion"), versionFile, "PVE version info", false); err != nil { return err } return nil diff --git a/internal/backup/collector_pve_capture_errors_test.go b/internal/backup/collector_pve_capture_errors_test.go new file mode 100644 index 00000000..4626b4b1 --- /dev/null +++ b/internal/backup/collector_pve_capture_errors_test.go @@ -0,0 +1,106 @@ +package backup + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func newPVECollectorWithSuccessfulCommands(t *testing.T, calls *[]string) *Collector { + t.Helper() + return newPVECollectorWithDeps(t, CollectorDeps{ + LookPath: func(name string) (string, error) { + return "/bin/true", nil + }, + RunCommand: func(ctx context.Context, name string, args ...string) ([]byte, error) { + *calls = append(*calls, commandSpec(name, args...).String()) + return []byte("[]"), nil + }, + }) +} + +func TestCollectPVEBackupJobHistoryReturnsFirstCaptureError(t *testing.T) { + var calls []string + collector := newPVECollectorWithSuccessfulCommands(t, &calls) + + jobsDir := collector.pveJobsDir() + if err := os.MkdirAll(filepath.Join(jobsDir, "node-a_backup_history.json"), 0o755); err != nil { + t.Fatalf("create output collision: %v", err) + } + + err := collector.collectPVEBackupJobHistory(context.Background(), []string{"node-a", "node-b"}) + if err == nil { + t.Fatalf("expected capture error") + } + if !strings.Contains(err.Error(), "failed to write report") { + t.Fatalf("unexpected error: %v", err) + } + if len(calls) != 2 { + t.Fatalf("expected collection to continue after first capture error, calls=%#v", calls) + } +} + +func TestCollectPVEReplicationStatusReturnsFirstCaptureError(t *testing.T) { + var calls []string + collector := newPVECollectorWithSuccessfulCommands(t, &calls) + + repDir := collector.pveReplicationDir() + if err := os.MkdirAll(filepath.Join(repDir, "node-a_replication_status.json"), 0o755); err != nil { + t.Fatalf("create output collision: %v", err) + } + + err := collector.collectPVEReplicationStatus(context.Background(), []string{"node-a", "node-b"}) + if err == nil { + t.Fatalf("expected capture error") + } + if !strings.Contains(err.Error(), "failed to write report") { + t.Fatalf("unexpected error: %v", err) + } + if len(calls) != 2 { + t.Fatalf("expected collection to continue after first capture error, calls=%#v", calls) + } +} + +func TestCollectPVEScheduleCrontabReturnsCaptureError(t *testing.T) { + var calls []string + collector := newPVECollectorWithSuccessfulCommands(t, &calls) + + schedulesDir := collector.pveSchedulesDir() + if err := os.MkdirAll(filepath.Join(schedulesDir, "root_crontab.txt"), 0o755); err != nil { + t.Fatalf("create output collision: %v", err) + } + + err := collector.collectPVEScheduleCrontab(context.Background()) + if err == nil { + t.Fatalf("expected capture error") + } + if !strings.Contains(err.Error(), "collectPVEScheduleCrontab:") { + t.Fatalf("expected function context in error, got %v", err) + } + if len(calls) != 1 { + t.Fatalf("expected one command call, calls=%#v", calls) + } +} + +func TestCollectPVEScheduleTimersReturnsCaptureError(t *testing.T) { + var calls []string + collector := newPVECollectorWithSuccessfulCommands(t, &calls) + + schedulesDir := collector.pveSchedulesDir() + if err := os.MkdirAll(filepath.Join(schedulesDir, "systemd_timers.txt"), 0o755); err != nil { + t.Fatalf("create output collision: %v", err) + } + + err := collector.collectPVEScheduleTimers(context.Background()) + if err == nil { + t.Fatalf("expected capture error") + } + if !strings.Contains(err.Error(), "collectPVEScheduleTimers:") { + t.Fatalf("expected function context in error, got %v", err) + } + if len(calls) != 1 { + t.Fatalf("expected one command call, calls=%#v", calls) + } +} diff --git a/internal/backup/collector_pve_patterns_test.go b/internal/backup/collector_pve_patterns_test.go index 9c6e8f14..da7e6f84 100644 --- a/internal/backup/collector_pve_patterns_test.go +++ b/internal/backup/collector_pve_patterns_test.go @@ -208,7 +208,7 @@ func TestEffectivePVEClusterPath(t *testing.T) { }, { name: "whitespace only uses default", - configPath: " ", + configPath: " ", expected: "/var/lib/pve-cluster", }, { diff --git a/internal/backup/collector_pve_util_test.go b/internal/backup/collector_pve_util_test.go index db04c747..b6451873 100644 --- a/internal/backup/collector_pve_util_test.go +++ b/internal/backup/collector_pve_util_test.go @@ -1122,7 +1122,7 @@ func TestEffectivePVEConfigPathDetailed(t *testing.T) { }, { name: "whitespace only uses default", - configPath: " ", + configPath: " ", expected: "/etc/pve", }, { diff --git a/internal/backup/collector_system.go b/internal/backup/collector_system.go index 720a1d04..13c4ab93 100644 --- a/internal/backup/collector_system.go +++ b/internal/backup/collector_system.go @@ -511,7 +511,7 @@ func (c *Collector) collectSystemRuntimeLeases(ctx context.Context) error { func (c *Collector) collectSystemCoreRuntime(ctx context.Context, commandsDir string) error { osReleasePath := c.systemPath("/etc/os-release") if err := c.collectCommandMulti(ctx, - fmt.Sprintf("cat %s", osReleasePath), + commandSpec("cat", osReleasePath), filepath.Join(commandsDir, "os_release.txt"), "OS release", true); err != nil { @@ -519,7 +519,7 @@ func (c *Collector) collectSystemCoreRuntime(ctx context.Context, commandsDir st } if err := c.collectCommandMulti(ctx, - "uname -a", + commandSpec("uname", "-a"), filepath.Join(commandsDir, "uname.txt"), "Kernel version", true); err != nil { @@ -527,7 +527,7 @@ func (c *Collector) collectSystemCoreRuntime(ctx context.Context, commandsDir st } c.safeCmdOutput(ctx, - "hostname -f", + commandSpec("hostname", "-f"), filepath.Join(commandsDir, "hostname.txt"), "Hostname", false) @@ -537,14 +537,14 @@ func (c *Collector) collectSystemCoreRuntime(ctx context.Context, commandsDir st func (c *Collector) collectSystemNetworkAddrRuntime(ctx context.Context, commandsDir string) error { if err := c.collectCommandMulti(ctx, - "ip addr show", + commandSpec("ip", "addr", "show"), filepath.Join(commandsDir, "ip_addr.txt"), "IP addresses", false); err != nil { return err } c.collectCommandOptional(ctx, - "ip -j addr show", + commandSpec("ip", "-j", "addr", "show"), filepath.Join(commandsDir, "ip_addr.json"), "IP addresses (json)") @@ -554,14 +554,14 @@ func (c *Collector) collectSystemNetworkAddrRuntime(ctx context.Context, command func (c *Collector) collectSystemNetworkRulesRuntime(ctx context.Context, commandsDir string) error { if err := c.collectCommandMulti(ctx, - "ip rule show", + commandSpec("ip", "rule", "show"), filepath.Join(commandsDir, "ip_rule.txt"), "IP rules", false); err != nil { return err } c.collectCommandOptional(ctx, - "ip -j rule show", + commandSpec("ip", "-j", "rule", "show"), filepath.Join(commandsDir, "ip_rule.json"), "IP rules (json)") @@ -571,23 +571,23 @@ func (c *Collector) collectSystemNetworkRulesRuntime(ctx context.Context, comman func (c *Collector) collectSystemNetworkRoutesRuntime(ctx context.Context, commandsDir string) error { if err := c.collectCommandMulti(ctx, - "ip route show", + commandSpec("ip", "route", "show"), filepath.Join(commandsDir, "ip_route.txt"), "IP routes", false); err != nil { return err } c.collectCommandOptional(ctx, - "ip -j route show", + commandSpec("ip", "-j", "route", "show"), filepath.Join(commandsDir, "ip_route.json"), "IP routes (json)") c.collectCommandOptional(ctx, - "ip -4 route show table all", + commandSpec("ip", "-4", "route", "show", "table", "all"), filepath.Join(commandsDir, "ip_route_all_v4.txt"), "IP routes (all tables v4)") c.collectCommandOptional(ctx, - "ip -6 route show table all", + commandSpec("ip", "-6", "route", "show", "table", "all"), filepath.Join(commandsDir, "ip_route_all_v6.txt"), "IP routes (all tables v6)") @@ -596,11 +596,11 @@ func (c *Collector) collectSystemNetworkRoutesRuntime(ctx context.Context, comma func (c *Collector) collectSystemNetworkLinksRuntime(ctx context.Context, commandsDir string) error { c.collectCommandOptional(ctx, - "ip -s link", + commandSpec("ip", "-s", "link"), filepath.Join(commandsDir, "ip_link.txt"), "IP link statistics") c.collectCommandOptional(ctx, - "ip -j link", + commandSpec("ip", "-j", "link"), filepath.Join(commandsDir, "ip_link.json"), "IP links (json)") @@ -609,12 +609,12 @@ func (c *Collector) collectSystemNetworkLinksRuntime(ctx context.Context, comman func (c *Collector) collectSystemNetworkNeighborsRuntime(ctx context.Context, commandsDir string) error { c.safeCmdOutput(ctx, - "ip neigh show", + commandSpec("ip", "neigh", "show"), filepath.Join(commandsDir, "ip_neigh.txt"), "Neighbor table", false) c.safeCmdOutput(ctx, - "ip -6 neigh show", + commandSpec("ip", "-6", "neigh", "show"), filepath.Join(commandsDir, "ip6_neigh.txt"), "Neighbor table (IPv6)", false) @@ -624,19 +624,19 @@ func (c *Collector) collectSystemNetworkNeighborsRuntime(ctx context.Context, co func (c *Collector) collectSystemNetworkBridgesRuntime(ctx context.Context, commandsDir string) error { c.collectCommandOptional(ctx, - "bridge -d link show", + commandSpec("bridge", "-d", "link", "show"), filepath.Join(commandsDir, "bridge_link.txt"), "Bridge links") c.collectCommandOptional(ctx, - "bridge vlan show", + commandSpec("bridge", "vlan", "show"), filepath.Join(commandsDir, "bridge_vlan.txt"), "Bridge VLANs") c.collectCommandOptional(ctx, - "bridge fdb show", + commandSpec("bridge", "fdb", "show"), filepath.Join(commandsDir, "bridge_fdb.txt"), "Bridge FDB") c.collectCommandOptional(ctx, - "bridge mdb show", + commandSpec("bridge", "mdb", "show"), filepath.Join(commandsDir, "bridge_mdb.txt"), "Bridge MDB") @@ -677,7 +677,7 @@ func (c *Collector) collectSystemNetworkBondingRuntime(ctx context.Context, comm func (c *Collector) collectSystemNetworkDNSRuntime(ctx context.Context, commandsDir string) error { resolvPath := c.systemPath("/etc/resolv.conf") return c.safeCmdOutput(ctx, - fmt.Sprintf("cat %s", resolvPath), + commandSpec("cat", resolvPath), filepath.Join(commandsDir, "resolv_conf.txt"), "DNS configuration", false) @@ -685,7 +685,7 @@ func (c *Collector) collectSystemNetworkDNSRuntime(ctx context.Context, commands func (c *Collector) collectSystemStorageMountsRuntime(ctx context.Context, commandsDir string) error { if err := c.collectCommandMulti(ctx, - "df -h", + commandSpec("df", "-h"), filepath.Join(commandsDir, "df.txt"), "Disk usage", false); err != nil { @@ -693,7 +693,7 @@ func (c *Collector) collectSystemStorageMountsRuntime(ctx context.Context, comma } c.safeCmdOutput(ctx, - "mount", + commandSpec("mount"), filepath.Join(commandsDir, "mount.txt"), "Mounted filesystems", false) @@ -703,7 +703,7 @@ func (c *Collector) collectSystemStorageMountsRuntime(ctx context.Context, comma func (c *Collector) collectSystemStorageBlockDevicesRuntime(ctx context.Context, commandsDir string) error { if err := c.collectCommandMulti(ctx, - "lsblk -f", + commandSpec("lsblk", "-f"), filepath.Join(commandsDir, "lsblk.txt"), "Block devices", false); err != nil { @@ -711,12 +711,12 @@ func (c *Collector) collectSystemStorageBlockDevicesRuntime(ctx context.Context, } c.collectCommandOptional(ctx, - "lsblk -J -O", + commandSpec("lsblk", "-J", "-O"), filepath.Join(commandsDir, "lsblk_json.json"), "Block devices (JSON)") c.collectCommandOptional(ctx, - "blkid", + commandSpec("blkid"), filepath.Join(commandsDir, "blkid.txt"), "Block device identifiers (blkid)") @@ -725,7 +725,7 @@ func (c *Collector) collectSystemStorageBlockDevicesRuntime(ctx context.Context, func (c *Collector) collectSystemComputeMemoryCPURuntime(ctx context.Context, commandsDir string) error { if err := c.collectCommandMulti(ctx, - "free -h", + commandSpec("free", "-h"), filepath.Join(commandsDir, "free.txt"), "Memory usage", false); err != nil { @@ -733,7 +733,7 @@ func (c *Collector) collectSystemComputeMemoryCPURuntime(ctx context.Context, co } if err := c.collectCommandMulti(ctx, - "lscpu", + commandSpec("lscpu"), filepath.Join(commandsDir, "lscpu.txt"), "CPU information", false); err != nil { @@ -745,7 +745,7 @@ func (c *Collector) collectSystemComputeMemoryCPURuntime(ctx context.Context, co func (c *Collector) collectSystemComputeBusInventoryRuntime(ctx context.Context, commandsDir string) error { if err := c.collectCommandMulti(ctx, - "lspci -v", + commandSpec("lspci", "-v"), filepath.Join(commandsDir, "lspci.txt"), "PCI devices", false); err != nil { @@ -753,7 +753,7 @@ func (c *Collector) collectSystemComputeBusInventoryRuntime(ctx context.Context, } c.safeCmdOutput(ctx, - "lsusb", + commandSpec("lsusb"), filepath.Join(commandsDir, "lsusb.txt"), "USB devices", false) @@ -767,14 +767,14 @@ func (c *Collector) collectSystemServicesRuntime(ctx context.Context, commandsDi } if err := c.collectCommandMulti(ctx, - "systemctl list-units --type=service --all", + commandSpec("systemctl", "list-units", "--type=service", "--all"), filepath.Join(commandsDir, "systemctl_services.txt"), "Systemd services", false); err != nil { return err } - c.safeCmdOutput(ctx, "systemctl list-unit-files --type=service", + c.safeCmdOutput(ctx, commandSpec("systemctl", "list-unit-files", "--type=service"), filepath.Join(commandsDir, "systemctl_service_files.txt"), "Systemd service files", false) @@ -789,7 +789,7 @@ func (c *Collector) collectSystemPackagesInstalledRuntime(ctx context.Context, c } if err := c.collectCommandMulti(ctx, - "dpkg -l", + commandSpec("dpkg", "-l"), filepath.Join(packagesDir, "dpkg_list.txt"), "Installed packages", false); err != nil { @@ -806,7 +806,7 @@ func (c *Collector) collectSystemPackagesAptPolicyRuntime(ctx context.Context, c } return c.safeCmdOutput(ctx, - "apt-cache policy", + commandSpec("apt-cache", "policy"), filepath.Join(commandsDir, "apt_policy.txt"), "APT policy", false) @@ -818,7 +818,7 @@ func (c *Collector) collectSystemFirewallIPTablesRuntime(ctx context.Context, co } if err := c.collectCommandMulti(ctx, - "iptables-save", + commandSpec("iptables-save"), filepath.Join(commandsDir, "iptables.txt"), "iptables rules", false); err != nil { @@ -826,7 +826,7 @@ func (c *Collector) collectSystemFirewallIPTablesRuntime(ctx context.Context, co } c.collectCommandOptional(ctx, - "iptables -t nat -vnL --line-numbers", + commandSpec("iptables", "-t", "nat", "-vnL", "--line-numbers"), filepath.Join(commandsDir, "iptables_nat.txt"), "iptables NAT table") @@ -839,7 +839,7 @@ func (c *Collector) collectSystemFirewallIP6TablesRuntime(ctx context.Context, c } if err := c.collectCommandMulti(ctx, - "ip6tables-save", + commandSpec("ip6tables-save"), filepath.Join(commandsDir, "ip6tables.txt"), "ip6tables rules", false); err != nil { @@ -847,7 +847,7 @@ func (c *Collector) collectSystemFirewallIP6TablesRuntime(ctx context.Context, c } c.collectCommandOptional(ctx, - "ip6tables -t nat -vnL --line-numbers", + commandSpec("ip6tables", "-t", "nat", "-vnL", "--line-numbers"), filepath.Join(commandsDir, "ip6tables_nat.txt"), "ip6tables NAT table") @@ -860,7 +860,7 @@ func (c *Collector) collectSystemFirewallNFTablesRuntime(ctx context.Context, co } return c.safeCmdOutput(ctx, - "nft list ruleset", + commandSpec("nft", "list", "ruleset"), filepath.Join(commandsDir, "nftables.txt"), "nftables rules", false) @@ -872,11 +872,11 @@ func (c *Collector) collectSystemFirewallUFWRuntime(ctx context.Context, command } c.collectCommandOptional(ctx, - "ufw status verbose", + commandSpec("ufw", "status", "verbose"), filepath.Join(commandsDir, "ufw_status.txt"), "UFW status") c.collectCommandOptional(ctx, - "systemctl status --no-pager ufw", + commandSpec("systemctl", "status", "--no-pager", "ufw"), filepath.Join(commandsDir, "systemctl_ufw.txt"), "systemctl ufw") @@ -889,15 +889,15 @@ func (c *Collector) collectSystemFirewallFirewalldRuntime(ctx context.Context, c } c.collectCommandOptional(ctx, - "firewall-cmd --state", + commandSpec("firewall-cmd", "--state"), filepath.Join(commandsDir, "firewalld_state.txt"), "firewalld state") c.collectCommandOptional(ctx, - "firewall-cmd --list-all", + commandSpec("firewall-cmd", "--list-all"), filepath.Join(commandsDir, "firewalld_list_all.txt"), "firewalld rules") c.collectCommandOptional(ctx, - "systemctl status --no-pager firewalld", + commandSpec("systemctl", "status", "--no-pager", "firewalld"), filepath.Join(commandsDir, "systemctl_firewalld.txt"), "systemctl firewalld") @@ -910,7 +910,7 @@ func (c *Collector) collectSystemKernelModulesRuntime(ctx context.Context, comma } c.safeCmdOutput(ctx, - "lsmod", + commandSpec("lsmod"), filepath.Join(commandsDir, "lsmod.txt"), "Loaded kernel modules", false) @@ -923,7 +923,7 @@ func (c *Collector) collectSystemSysctlRuntime(ctx context.Context, commandsDir } c.safeCmdOutput(ctx, - "sysctl -a", + commandSpec("sysctl", "-a"), filepath.Join(commandsDir, "sysctl.txt"), "Sysctl values", false) @@ -949,24 +949,24 @@ func (c *Collector) collectSystemZFSRuntime(ctx context.Context, commandsDir str if _, err := c.depLookPath("zpool"); err == nil { c.collectCommandOptional(ctx, - "zpool status", + commandSpec("zpool", "status"), filepath.Join(zfsDir, "zpool_status.txt"), "ZFS pool status") c.collectCommandOptional(ctx, - "zpool list", + commandSpec("zpool", "list"), filepath.Join(zfsDir, "zpool_list.txt"), "ZFS pool list") } if _, err := c.depLookPath("zfs"); err == nil { c.collectCommandOptional(ctx, - "zfs list", + commandSpec("zfs", "list"), filepath.Join(zfsDir, "zfs_list.txt"), "ZFS filesystem list") c.collectCommandOptional(ctx, - "zfs get all", + commandSpec("zfs", "get", "all"), filepath.Join(zfsDir, "zfs_get_all.txt"), "ZFS properties", ) @@ -981,21 +981,21 @@ func (c *Collector) collectSystemLVMRuntime(ctx context.Context, commandsDir str } if _, err := c.depLookPath("pvs"); err == nil { c.safeCmdOutput(ctx, - "pvs", + commandSpec("pvs"), filepath.Join(commandsDir, "lvm_pvs.txt"), "LVM physical volumes", false) } if _, err := c.depLookPath("vgs"); err == nil { c.safeCmdOutput(ctx, - "vgs", + commandSpec("vgs"), filepath.Join(commandsDir, "lvm_vgs.txt"), "LVM volume groups", false) } if _, err := c.depLookPath("lvs"); err == nil { c.safeCmdOutput(ctx, - "lvs", + commandSpec("lvs"), filepath.Join(commandsDir, "lvm_lvs.txt"), "LVM logical volumes", false) @@ -1161,14 +1161,14 @@ func (c *Collector) collectKernelInfo(ctx context.Context) error { // Kernel command line c.safeCmdOutput(ctx, - fmt.Sprintf("cat %s", c.systemPath("/proc/cmdline")), + commandSpec("cat", c.systemPath("/proc/cmdline")), filepath.Join(commandsDir, "kernel_cmdline.txt"), "Kernel command line", false) // Kernel version details c.safeCmdOutput(ctx, - fmt.Sprintf("cat %s", c.systemPath("/proc/version")), + commandSpec("cat", c.systemPath("/proc/version")), filepath.Join(commandsDir, "kernel_version.txt"), "Kernel version details", false) @@ -1184,7 +1184,7 @@ func (c *Collector) collectHardwareInfo(ctx context.Context) error { // DMI decode (requires root) c.safeCmdOutput(ctx, - "dmidecode", + commandSpec("dmidecode"), filepath.Join(commandsDir, "dmidecode.txt"), "Hardware DMI information", false) @@ -1192,7 +1192,7 @@ func (c *Collector) collectHardwareInfo(ctx context.Context) error { // Hardware sensors (if available) if _, err := c.depStat(c.systemPath("/usr/bin/sensors")); err == nil { c.safeCmdOutput(ctx, - "sensors", + commandSpec("sensors"), filepath.Join(commandsDir, "sensors.txt"), "Hardware sensors", false) @@ -1202,7 +1202,7 @@ func (c *Collector) collectHardwareInfo(ctx context.Context) error { if _, err := c.depStat(c.systemPath("/usr/sbin/smartctl")); err == nil { // Get list of disks c.safeCmdOutput(ctx, - "smartctl --scan", + commandSpec("smartctl", "--scan"), filepath.Join(commandsDir, "smartctl_scan.txt"), "SMART scan", false) diff --git a/internal/backup/collector_system_test.go b/internal/backup/collector_system_test.go index 14af5a69..2983a478 100644 --- a/internal/backup/collector_system_test.go +++ b/internal/backup/collector_system_test.go @@ -58,7 +58,7 @@ func TestEnsureSystemPathPreservesCustomPrefix(t *testing.T) { func TestCollectCustomPathsIgnoresEmptyEntries(t *testing.T) { collector := newTestCollector(t) - collector.config.CustomBackupPaths = []string{"", " ", ""} + collector.config.CustomBackupPaths = []string{"", "", ""} if err := collector.collectCustomPaths(context.Background()); err != nil { t.Fatalf("collectCustomPaths returned error for empty paths: %v", err) diff --git a/internal/backup/collector_test.go b/internal/backup/collector_test.go index 372393db..c4c92379 100644 --- a/internal/backup/collector_test.go +++ b/internal/backup/collector_test.go @@ -270,7 +270,7 @@ func TestCollectorSafeCmdOutput(t *testing.T) { // Use a simple command that should be available on all systems outputFile := filepath.Join(tempDir, "output.txt") ctx := context.Background() - err := collector.safeCmdOutput(ctx, "echo test", outputFile, "test command", false) + err := collector.safeCmdOutput(ctx, commandSpec("echo", "test"), outputFile, "test command", false) if err != nil { t.Fatalf("safeCmdOutput failed: %v", err) @@ -298,7 +298,7 @@ func TestCollectorSafeCmdOutputNonCriticalFailure(t *testing.T) { ctx := context.Background() outputFile := filepath.Join(tempDir, "non_critical.txt") - if err := collector.safeCmdOutput(ctx, "false", outputFile, "non critical failure", false); err != nil { + if err := collector.safeCmdOutput(ctx, commandSpec("false"), outputFile, "non critical failure", false); err != nil { t.Fatalf("non-critical command should not return error: %v", err) } @@ -321,7 +321,7 @@ func TestCollectorSafeCmdOutputCriticalFailure(t *testing.T) { ctx := context.Background() outputFile := filepath.Join(tempDir, "critical.txt") - if err := collector.safeCmdOutput(ctx, "false", outputFile, "critical failure", true); err == nil { + if err := collector.safeCmdOutput(ctx, commandSpec("false"), outputFile, "critical failure", true); err == nil { t.Fatalf("expected error for critical command failure") } @@ -344,7 +344,7 @@ func TestCollectorSafeCmdOutputCommandNotFound(t *testing.T) { // Use a command that definitely doesn't exist outputFile := filepath.Join(tempDir, "output.txt") ctx := context.Background() - err := collector.safeCmdOutput(ctx, "nonexistent_command_xyz", outputFile, "test command", false) + err := collector.safeCmdOutput(ctx, commandSpec("nonexistent_command_xyz"), outputFile, "test command", false) // Should return nil (command not found is not an error for non-critical commands) if err != nil { @@ -814,7 +814,7 @@ func TestCaptureCommandOutput_SystemctlStatusUnitNotFound_Skips(t *testing.T) { collector := NewCollectorWithDeps(logger, cfg, tmp, types.ProxmoxUnknown, false, deps) outPath := filepath.Join(tmp, "systemctl_status.txt") - data, err := collector.captureCommandOutput(context.Background(), "systemctl status foo", outPath, "systemctl status", false) + data, err := collector.captureCommandOutput(context.Background(), commandSpec("systemctl", "status", "foo"), outPath, "systemctl status", false) if err != nil { t.Fatalf("captureCommandOutput returned error: %v", err) } @@ -848,7 +848,7 @@ func TestCaptureCommandOutput_SystemctlStatusSystemdUnavailable_Skips(t *testing collector := NewCollectorWithDeps(logger, cfg, tmp, types.ProxmoxUnknown, false, deps) outPath := filepath.Join(tmp, "systemctl_status.txt") - data, err := collector.captureCommandOutput(context.Background(), "systemctl status ssh", outPath, "systemctl status", false) + data, err := collector.captureCommandOutput(context.Background(), commandSpec("systemctl", "status", "ssh"), outPath, "systemctl status", false) if err != nil { t.Fatalf("captureCommandOutput returned error: %v", err) } @@ -1412,7 +1412,7 @@ func TestSafeCmdOutputHonorsContextCancellation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - err := c.safeCmdOutput(ctx, "echo hi", filepath.Join(tmp, "out.txt"), "canceled", false) + err := c.safeCmdOutput(ctx, commandSpec("echo", "hi"), filepath.Join(tmp, "out.txt"), "canceled", false) if !errors.Is(err, context.Canceled) { t.Fatalf("expected context.Canceled, got %v", err) } @@ -1424,7 +1424,7 @@ func TestSafeCmdOutputReturnsErrorOnEmptyCommand(t *testing.T) { tmp := t.TempDir() c := NewCollector(logger, cfg, tmp, types.ProxmoxUnknown, false) - if err := c.safeCmdOutput(context.Background(), " ", filepath.Join(tmp, "out.txt"), "empty", false); err == nil { + if err := c.safeCmdOutput(context.Background(), CommandSpec{}, filepath.Join(tmp, "out.txt"), "empty", false); err == nil { t.Fatalf("expected error for empty command") } } @@ -1439,7 +1439,7 @@ func TestSafeCmdOutputCriticalCommandNotAvailableIncrementsFilesFailed(t *testin } c := NewCollectorWithDeps(logger, cfg, tmp, types.ProxmoxUnknown, false, deps) - err := c.safeCmdOutput(context.Background(), "does-not-exist", filepath.Join(tmp, "out.txt"), "critical", true) + err := c.safeCmdOutput(context.Background(), commandSpec("does-not-exist"), filepath.Join(tmp, "out.txt"), "critical", true) if err == nil { t.Fatalf("expected error for critical missing command") } @@ -1468,7 +1468,7 @@ func TestSafeCmdOutputWriteFailureIncrementsFilesFailed(t *testing.T) { } c := NewCollectorWithDeps(logger, cfg, tmp, types.ProxmoxUnknown, false, deps) - err := c.safeCmdOutput(context.Background(), "echo hi", outDir, "write-fail", false) + err := c.safeCmdOutput(context.Background(), commandSpec("echo", "hi"), outDir, "write-fail", false) if err == nil { t.Fatalf("expected write error when output path is a directory") } @@ -1498,7 +1498,7 @@ func TestSafeCmdOutputEnsureDirFailureReturnsError(t *testing.T) { } c := NewCollectorWithDeps(logger, cfg, tmp, types.ProxmoxUnknown, false, deps) - if err := c.safeCmdOutput(context.Background(), "echo hi", output, "ensureDir-fail", false); err == nil { + if err := c.safeCmdOutput(context.Background(), commandSpec("echo", "hi"), output, "ensureDir-fail", false); err == nil { t.Fatalf("expected ensureDir error") } } @@ -1509,7 +1509,7 @@ func TestCaptureCommandOutputReturnsErrorOnEmptyCommand(t *testing.T) { tmp := t.TempDir() c := NewCollector(logger, cfg, tmp, types.ProxmoxUnknown, false) - if _, err := c.captureCommandOutput(context.Background(), " ", filepath.Join(tmp, "out.txt"), "empty", false); err == nil { + if _, err := c.captureCommandOutput(context.Background(), CommandSpec{}, filepath.Join(tmp, "out.txt"), "empty", false); err == nil { t.Fatalf("expected error for empty command") } } @@ -1523,7 +1523,7 @@ func TestCaptureCommandOutputHonorsContextCancellation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - if _, err := c.captureCommandOutput(ctx, "echo hi", filepath.Join(tmp, "out.txt"), "canceled", false); !errors.Is(err, context.Canceled) { + if _, err := c.captureCommandOutput(ctx, commandSpec("echo", "hi"), filepath.Join(tmp, "out.txt"), "canceled", false); !errors.Is(err, context.Canceled) { t.Fatalf("expected context.Canceled, got %v", err) } } @@ -1547,7 +1547,7 @@ func TestCaptureCommandOutputPropagatesWriteReportFileError(t *testing.T) { c := NewCollectorWithDeps(logger, cfg, tmp, types.ProxmoxUnknown, false, deps) // writeReportFile should fail because output path is a directory. - if _, err := c.captureCommandOutput(context.Background(), "echo hi", outDir, "desc", false); err == nil { + if _, err := c.captureCommandOutput(context.Background(), commandSpec("echo", "hi"), outDir, "desc", false); err == nil { t.Fatalf("expected writeReportFile error") } } @@ -1562,7 +1562,7 @@ func TestCaptureCommandOutputCriticalCommandNotAvailableIncrementsFilesFailed(t } c := NewCollectorWithDeps(logger, cfg, tmp, types.ProxmoxUnknown, false, deps) - _, err := c.captureCommandOutput(context.Background(), "missing-cmd arg", filepath.Join(tmp, "out.txt"), "critical", true) + _, err := c.captureCommandOutput(context.Background(), commandSpec("missing-cmd", "arg"), filepath.Join(tmp, "out.txt"), "critical", true) if err == nil { t.Fatalf("expected error for critical missing command") } @@ -1588,7 +1588,7 @@ func TestCaptureCommandOutputNonCriticalFailureReturnsNil(t *testing.T) { c := NewCollectorWithDeps(logger, cfg, tmp, types.ProxmoxUnknown, false, deps) outPath := filepath.Join(tmp, "out.txt") - data, err := c.captureCommandOutput(context.Background(), "cmd arg", outPath, "noncritical", false) + data, err := c.captureCommandOutput(context.Background(), commandSpec("cmd", "arg"), outPath, "noncritical", false) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1606,7 +1606,7 @@ func TestCollectCommandMultiRequiresPrimaryOutput(t *testing.T) { tmp := t.TempDir() c := NewCollector(logger, cfg, tmp, types.ProxmoxUnknown, false) - if err := c.collectCommandMulti(context.Background(), "echo hi", "", "desc", false); err == nil { + if err := c.collectCommandMulti(context.Background(), commandSpec("echo", "hi"), "", "desc", false); err == nil { t.Fatalf("expected error when primary output is empty") } } @@ -1630,7 +1630,7 @@ func TestCollectCommandMultiFailsWhenMirrorWriteFails(t *testing.T) { t.Fatalf("mkdir mirrorDir: %v", err) } - if err := c.collectCommandMulti(context.Background(), "echo hi", primary, "desc", false, mirrorDir, ""); err == nil { + if err := c.collectCommandMulti(context.Background(), commandSpec("echo", "hi"), primary, "desc", false, mirrorDir, ""); err == nil { t.Fatalf("expected error when mirror path is a directory") } } @@ -1649,7 +1649,7 @@ func TestCollectCommandMultiSkipsEmptyMirrors(t *testing.T) { c := NewCollectorWithDeps(logger, cfg, tmp, types.ProxmoxUnknown, false, deps) primary := filepath.Join(tmp, "primary.txt") - if err := c.collectCommandMulti(context.Background(), "echo hi", primary, "desc", false, ""); err != nil { + if err := c.collectCommandMulti(context.Background(), commandSpec("echo", "hi"), primary, "desc", false, ""); err != nil { t.Fatalf("unexpected error: %v", err) } if _, err := os.Stat(primary); err != nil { @@ -1670,7 +1670,7 @@ func TestCollectCommandOptionalSkipsWhenNoOutputPath(t *testing.T) { }, } c := NewCollectorWithDeps(logger, cfg, tmp, types.ProxmoxUnknown, false, deps) - c.collectCommandOptional(context.Background(), "echo hi", "", "desc", filepath.Join(tmp, "mirror")) + c.collectCommandOptional(context.Background(), commandSpec("echo", "hi"), "", "desc", filepath.Join(tmp, "mirror")) } func TestCollectCommandOptionalDoesNotMirrorEmptyOutput(t *testing.T) { @@ -1688,7 +1688,7 @@ func TestCollectCommandOptionalDoesNotMirrorEmptyOutput(t *testing.T) { primary := filepath.Join(tmp, "primary.txt") mirror := filepath.Join(tmp, "mirror.txt") - c.collectCommandOptional(context.Background(), "echo hi", primary, "desc", mirror) + c.collectCommandOptional(context.Background(), commandSpec("echo", "hi"), primary, "desc", mirror) if _, err := os.Stat(primary); err != nil { t.Fatalf("expected primary file to exist: %v", err) @@ -1718,7 +1718,7 @@ func TestCollectCommandOptionalSkipsOnCaptureError(t *testing.T) { } mirror := filepath.Join(tmp, "mirror.txt") - c.collectCommandOptional(context.Background(), "echo hi", outDir, "desc", "", mirror) + c.collectCommandOptional(context.Background(), commandSpec("echo", "hi"), outDir, "desc", "", mirror) if _, err := os.Stat(mirror); !os.IsNotExist(err) { t.Fatalf("expected mirror to be skipped on capture error, stat err=%v", err) } @@ -1739,7 +1739,7 @@ func TestCollectCommandOptionalSkipsEmptyMirrorEntries(t *testing.T) { primary := filepath.Join(tmp, "primary.txt") mirror := filepath.Join(tmp, "mirror.txt") - c.collectCommandOptional(context.Background(), "echo hi", primary, "desc", "", mirror) + c.collectCommandOptional(context.Background(), commandSpec("echo", "hi"), primary, "desc", "", mirror) if _, err := os.Stat(mirror); err != nil { t.Fatalf("expected mirror file to exist: %v", err) } @@ -1764,5 +1764,5 @@ func TestCollectCommandOptionalIgnoresMirrorWriteFailures(t *testing.T) { t.Fatalf("mkdir mirrorDir: %v", err) } - c.collectCommandOptional(context.Background(), "echo hi", primary, "desc", mirrorDir) + c.collectCommandOptional(context.Background(), commandSpec("echo", "hi"), primary, "desc", mirrorDir) } diff --git a/internal/config/config.go b/internal/config/config.go index 74d6b6e8..07532c9d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,6 +11,7 @@ import ( "strconv" "strings" + "github.com/tis24dev/proxsave/internal/safeexec" "github.com/tis24dev/proxsave/internal/types" "github.com/tis24dev/proxsave/pkg/utils" ) @@ -381,6 +382,9 @@ func (c *Config) parse() error { if err := c.validateSecondarySettings(); err != nil { return err } + if err := c.validateCloudSettings(); err != nil { + return err + } c.autoDetectPBSAuth() return nil } @@ -397,6 +401,50 @@ func (c *Config) validateSecondarySettings() error { return nil } +func (c *Config) validateCloudSettings() error { + if !c.CloudEnabled { + return nil + } + cloudRemote := strings.TrimSpace(c.CloudRemote) + remoteName, basePath := splitCloudRemoteRef(cloudRemote) + if !isAbsoluteCloudRemoteRef(remoteName, basePath) { + if err := safeexec.ValidateRcloneRemoteName(remoteName); err != nil { + return fmt.Errorf("CLOUD_REMOTE invalid: %w", err) + } + } + if err := safeexec.ValidateRemoteRelativePath(strings.Trim(strings.TrimSpace(basePath), "/"), "CLOUD_REMOTE path"); err != nil { + return err + } + if err := safeexec.ValidateRemoteRelativePath(strings.Trim(strings.TrimSpace(c.CloudRemotePath), "/"), "CLOUD_REMOTE_PATH"); err != nil { + return err + } + return nil +} + +func isAbsoluteCloudRemoteRef(remoteName, basePath string) bool { + remoteName = strings.TrimSpace(remoteName) + basePath = strings.TrimSpace(basePath) + if filepath.IsAbs(remoteName) { + return true + } + if len(remoteName) != 1 { + return false + } + drive := remoteName[0] + if (drive < 'A' || drive > 'Z') && (drive < 'a' || drive > 'z') { + return false + } + return strings.HasPrefix(basePath, `\`) || strings.HasPrefix(basePath, "/") +} + +func splitCloudRemoteRef(ref string) (remoteName, relPath string) { + parts := strings.SplitN(ref, ":", 2) + if len(parts) < 2 { + return ref, "" + } + return parts[0], parts[1] +} + func (c *Config) parseGeneralSettings() { c.BackupEnabled = c.getBool("BACKUP_ENABLED", true) c.DryRun = c.getBool("DRY_RUN", false) @@ -1427,13 +1475,16 @@ func (c *Config) BuildWebhookConfig() *WebhookConfig { } } + priority := c.getInt(prefix+"PRIORITY", 0) + endpoints = append(endpoints, WebhookEndpoint{ - Name: name, - URL: url, - Format: format, - Method: method, - Headers: headers, - Auth: auth, + Name: name, + URL: url, + Format: format, + Method: method, + Headers: headers, + Auth: auth, + Priority: priority, }) } @@ -1528,6 +1579,7 @@ type WebhookEndpoint struct { Method string Headers map[string]string Auth WebhookAuth + Priority int CustomFields map[string]interface{} } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6db74d9a..e9e6bce0 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -370,6 +370,30 @@ SECONDARY_LOG_PATH=remote:/logs } } +func TestValidateCloudSettingsAllowsAbsoluteCloudRemote(t *testing.T) { + tests := []string{ + "/mnt/cloud", + `C:\cloud`, + "C:/cloud", + } + + for _, remote := range tests { + t.Run(remote, func(t *testing.T) { + cfg := &Config{CloudEnabled: true, CloudRemote: remote} + if err := cfg.validateCloudSettings(); err != nil { + t.Fatalf("validateCloudSettings() error = %v", err) + } + }) + } +} + +func TestValidateCloudSettingsStillValidatesAbsoluteCloudRemotePath(t *testing.T) { + cfg := &Config{CloudEnabled: true, CloudRemote: "/mnt/cloud:../escape"} + if err := cfg.validateCloudSettings(); err == nil { + t.Fatal("expected validateCloudSettings to reject traversal in CLOUD_REMOTE path") + } +} + func TestLoadConfigWithQuotes(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "test_quotes.env") diff --git a/internal/config/migration.go b/internal/config/migration.go index cf5c2f5f..e8448578 100644 --- a/internal/config/migration.go +++ b/internal/config/migration.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/tis24dev/proxsave/internal/safeexec" "github.com/tis24dev/proxsave/pkg/utils" ) @@ -221,6 +222,18 @@ func validateMigratedConfig(cfg *Config) error { if cfg.CloudEnabled && strings.TrimSpace(cfg.CloudRemote) == "" { return fmt.Errorf("CLOUD_REMOTE required when CLOUD_ENABLED=true") } + if cfg.CloudEnabled { + remoteName, basePath := splitCloudRemoteRef(strings.TrimSpace(cfg.CloudRemote)) + if err := safeexec.ValidateRcloneRemoteName(remoteName); err != nil { + return fmt.Errorf("CLOUD_REMOTE invalid: %w", err) + } + if err := safeexec.ValidateRemoteRelativePath(strings.Trim(strings.TrimSpace(basePath), "/"), "CLOUD_REMOTE path"); err != nil { + return err + } + if err := safeexec.ValidateRemoteRelativePath(strings.Trim(strings.TrimSpace(cfg.CloudRemotePath), "/"), "CLOUD_REMOTE_PATH"); err != nil { + return err + } + } if cfg.SetBackupPermissions { if strings.TrimSpace(cfg.BackupUser) == "" || strings.TrimSpace(cfg.BackupGroup) == "" { return fmt.Errorf("BACKUP_USER/BACKUP_GROUP must be set when SET_BACKUP_PERMISSIONS=true") diff --git a/internal/config/templates/backup.env b/internal/config/templates/backup.env index 8369ff69..0089f6ed 100644 --- a/internal/config/templates/backup.env +++ b/internal/config/templates/backup.env @@ -236,7 +236,7 @@ GOTIFY_PRIORITY_WARNING=5 GOTIFY_PRIORITY_FAILURE=8 # ---------------------------------------------------------------------- -# Webhook notifications (Phase 5.2 – Discord/Slack/Teams/Generic) +# Webhook notifications (Phase 5.2 – Discord/Slack/Teams/Generic/Pushover) # ---------------------------------------------------------------------- WEBHOOK_ENABLED=false WEBHOOK_ENDPOINTS= # Comma-separated names e.g. discord_alerts,teams_ops @@ -247,7 +247,7 @@ WEBHOOK_RETRY_DELAY=2 # seconds # For each endpoint use the uppercase name as prefix, e.g. "discord_alerts": # WEBHOOK_DISCORD_ALERTS_URL=https://discord.com/api/webhooks/XXXX/YYY -# WEBHOOK_DISCORD_ALERTS_FORMAT=discord # discord | slack | teams | generic +# WEBHOOK_DISCORD_ALERTS_FORMAT=discord # discord | slack | teams | generic | pushover # WEBHOOK_DISCORD_ALERTS_METHOD=POST # POST recommended; GET/HEAD do not send a payload # WEBHOOK_DISCORD_ALERTS_HEADERS="X-Custom-Token:abc123" # WEBHOOK_DISCORD_ALERTS_AUTH_TYPE=none # none | bearer | basic | hmac @@ -256,6 +256,19 @@ WEBHOOK_RETRY_DELAY=2 # seconds # WEBHOOK_DISCORD_ALERTS_AUTH_PASS= # WEBHOOK_DISCORD_ALERTS_AUTH_SECRET= +# Pushover example (https://pushover.net). Token + user are sent in the JSON +# body, so AUTH_TYPE stays "none". Title/message are truncated to Pushover's +# 250/1024 character limits. PRIORITY accepts -2..1 (default 0); emergency +# priority (2) is not supported. +# WEBHOOK_ENDPOINTS=pushover +# WEBHOOK_PUSHOVER_URL=https://api.pushover.net/1/messages.json +# WEBHOOK_PUSHOVER_FORMAT=pushover +# WEBHOOK_PUSHOVER_METHOD=POST +# WEBHOOK_PUSHOVER_AUTH_TYPE=none +# WEBHOOK_PUSHOVER_AUTH_TOKEN= +# WEBHOOK_PUSHOVER_AUTH_USER= +# WEBHOOK_PUSHOVER_PRIORITY=0 + # ---------------------------------------------------------------------- # Metriche / Prometheus # ---------------------------------------------------------------------- diff --git a/internal/environment/detect.go b/internal/environment/detect.go index d734702b..a6fd841d 100644 --- a/internal/environment/detect.go +++ b/internal/environment/detect.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/tis24dev/proxsave/internal/safeexec" "github.com/tis24dev/proxsave/internal/types" ) @@ -46,8 +47,7 @@ var ( "/etc/apt/sources.list.d/proxmox.list", } - lookPathFunc = exec.LookPath - commandContextFunc = exec.CommandContext + lookPathFunc = exec.LookPath readFileFunc = os.ReadFile statFunc = os.Stat @@ -341,7 +341,10 @@ func runCommand(command string, args ...string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), commandTimeout) defer cancel() - cmd := commandContextFunc(ctx, command, args...) + cmd, cmdErr := safeexec.TrustedCommandContext(ctx, command, args...) + if cmdErr != nil { + return "", cmdErr + } output, err := cmd.Output() if ctx.Err() == context.DeadlineExceeded { return "", fmt.Errorf("command %s timed out", command) diff --git a/internal/environment/detect_additional_test.go b/internal/environment/detect_additional_test.go index 11e5c583..d8d347c9 100644 --- a/internal/environment/detect_additional_test.go +++ b/internal/environment/detect_additional_test.go @@ -3,6 +3,7 @@ package environment import ( "context" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -151,7 +152,11 @@ func TestContainsAny(t *testing.T) { // TestRunCommand tests command execution with timeout func TestRunCommand(t *testing.T) { // Test successful command - output, err := runCommand("echo", "test") + echoPath, err := exec.LookPath("echo") + if err != nil { + t.Fatalf("LookPath(echo) failed: %v", err) + } + output, err := runCommand(echoPath, "test") if err != nil { t.Errorf("runCommand() error = %v", err) } diff --git a/internal/identity/identity.go b/internal/identity/identity.go index f8f0c7e3..755dc24b 100644 --- a/internal/identity/identity.go +++ b/internal/identity/identity.go @@ -19,6 +19,7 @@ import ( "time" "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/safeexec" ) const ( @@ -969,7 +970,11 @@ func setImmutableAttributeWithContext(ctx context.Context, path string, enable b flag = "-i" } - cmd := exec.CommandContext(ctx, chattrPath, flag, path) + cmd, err := safeexec.TrustedCommandContext(ctx, chattrPath, flag, path) + if err != nil { + logDebug(logger, "Identity: immutable: chattr path rejected for %s: %v", path, err) + return nil + } if err := cmd.Run(); err != nil { if ctxErr := ctx.Err(); ctxErr != nil { logDebug(logger, "Identity: immutable: chattr canceled for %s: %v", path, ctxErr) diff --git a/internal/notify/email.go b/internal/notify/email.go index 8dfa0082..da44380f 100644 --- a/internal/notify/email.go +++ b/internal/notify/email.go @@ -1,11 +1,13 @@ package notify import ( + "bytes" "context" "encoding/base64" "encoding/json" "errors" "fmt" + "mime/quotedprintable" "os" "os/exec" "path/filepath" @@ -15,6 +17,7 @@ import ( "time" "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/safeexec" "github.com/tis24dev/proxsave/internal/types" ) @@ -656,7 +659,10 @@ func (e *EmailNotifier) detectRecipientViaUserCfg(cfgPath string, targetUserID s } func runCombinedOutput(ctx context.Context, name string, args ...string) ([]byte, error) { - cmd := exec.CommandContext(ctx, name, args...) + cmd, err := safeexec.CommandContext(ctx, name, args...) + if err != nil { + return nil, err + } out, err := cmd.CombinedOutput() if err != nil { return out, err @@ -671,6 +677,37 @@ func truncateForLog(s string, maxBytes int) string { return s[:maxBytes] + "...(truncated)" } +func commandForMailTool(ctx context.Context, pathOrName string, args ...string) (*exec.Cmd, error) { + if filepath.IsAbs(pathOrName) { + return safeexec.TrustedCommandContext(ctx, pathOrName, args...) + } + return safeexec.CommandContext(ctx, pathOrName, args...) +} + +func lookupAbsolutePath(name string) (string, error) { + execPath, err := exec.LookPath(name) + if err != nil { + return "", err + } + if filepath.IsAbs(execPath) { + return execPath, nil + } + return "", fmt.Errorf("exec.LookPath returned non-absolute path %q", execPath) +} + +func findMailqPath() (string, error) { + candidates := []string{"mailq", "/usr/bin/mailq"} + errs := make([]error, 0, len(candidates)) + for _, candidate := range candidates { + path, err := lookupAbsolutePath(candidate) + if err == nil { + return path, nil + } + errs = append(errs, fmt.Errorf("%s: %w", candidate, err)) + } + return "", fmt.Errorf("mailq command not found: %w", errors.Join(errs...)) +} + // sendViaRelay sends email via cloud relay func (e *EmailNotifier) sendViaRelay(ctx context.Context, recipient, subject, htmlBody, textBody string, data *NotificationData) error { // Build payload @@ -692,12 +729,16 @@ func (e *EmailNotifier) sendViaRelay(ctx context.Context, recipient, subject, ht func (e *EmailNotifier) isMTAServiceActive(ctx context.Context) (bool, string) { services := []string{"postfix", "sendmail", "exim4"} - if _, err := exec.LookPath("systemctl"); err != nil { + systemctlPath, err := lookupAbsolutePath("systemctl") + if err != nil { return false, "systemctl not available" } for _, service := range services { - cmd := exec.CommandContext(ctx, "systemctl", "is-active", service) + cmd, err := safeexec.TrustedCommandContext(ctx, systemctlPath, "is-active", service) + if err != nil { + return false, err.Error() + } if err := cmd.Run(); err == nil { e.logger.Debug("MTA service %s is active", service) return true, service @@ -759,16 +800,15 @@ func (e *EmailNotifier) checkRelayHostConfigured(ctx context.Context) (bool, str // checkMailQueue checks the mail queue status func (e *EmailNotifier) checkMailQueue(ctx context.Context) (int, error) { // Try mailq command (works for both Postfix and Sendmail) - mailqPath := "/usr/bin/mailq" - if _, err := exec.LookPath("mailq"); err != nil { - if _, err := exec.LookPath(mailqPath); err != nil { - return 0, fmt.Errorf("mailq command not found") - } - } else { - mailqPath = "mailq" + mailqPath, err := findMailqPath() + if err != nil { + return 0, err } - cmd := exec.CommandContext(ctx, mailqPath) + cmd, err := commandForMailTool(ctx, mailqPath) + if err != nil { + return 0, err + } output, err := cmd.Output() if err != nil { return 0, fmt.Errorf("mailq failed: %w", err) @@ -803,14 +843,15 @@ func (e *EmailNotifier) checkMailQueue(ctx context.Context) (int, error) { // detectQueueEntry scans the mail queue for a recipient and returns the latest queue ID. func (e *EmailNotifier) detectQueueEntry(ctx context.Context, recipient string) (string, string, error) { - mailqPath := "/usr/bin/mailq" - if _, err := exec.LookPath("mailq"); err == nil { - mailqPath = "mailq" - } else if _, err := exec.LookPath(mailqPath); err != nil { - return "", "", fmt.Errorf("mailq command not found") + mailqPath, err := findMailqPath() + if err != nil { + return "", "", err } - cmd := exec.CommandContext(ctx, mailqPath) + cmd, err := commandForMailTool(ctx, mailqPath) + if err != nil { + return "", "", err + } output, err := cmd.Output() if err != nil { return "", "", fmt.Errorf("mailq failed: %w", err) @@ -851,7 +892,10 @@ func (e *EmailNotifier) tailMailLog(ctx context.Context, maxLines int) ([]string continue } - cmd := exec.CommandContext(ctx, "tail", "-n", strconv.Itoa(maxLines), logFile) + cmd, err := safeexec.CommandContext(ctx, "tail", "-n", strconv.Itoa(maxLines), logFile) + if err != nil { + continue + } output, err := cmd.Output() if err != nil { if ctx.Err() != nil { @@ -874,7 +918,10 @@ func (e *EmailNotifier) tailMailLog(ctx context.Context, maxLines int) ([]string args = append(args, "-u", unit) } - cmd := exec.CommandContext(ctx, "journalctl", args...) + cmd, err := safeexec.CommandContext(ctx, "journalctl", args...) + if err != nil { + return nil, "" + } output, err := cmd.Output() if err == nil && len(output) > 0 { lines := strings.Split(strings.TrimRight(string(output), "\n"), "\n") @@ -1084,6 +1131,14 @@ func summarizeSendmailTranscript(transcript string) (highlights []string, remote return highlights, remoteID, localQueueID } +func encodeQuotedPrintableBody(body string) string { + var encoded bytes.Buffer + writer := quotedprintable.NewWriter(&encoded) + _, _ = writer.Write([]byte(body)) + _ = writer.Close() + return encoded.String() +} + func (e *EmailNotifier) buildEmailMessage(recipient, subject, htmlBody, textBody string, data *NotificationData) (emailMessage, toHeader string) { e.logger.Debug("=== Building email message ===") @@ -1126,17 +1181,17 @@ func (e *EmailNotifier) buildEmailMessage(recipient, subject, htmlBody, textBody // Plain text part email.WriteString(fmt.Sprintf("--%s\n", altBoundary)) email.WriteString("Content-Type: text/plain; charset=UTF-8\n") - email.WriteString("Content-Transfer-Encoding: 8bit\n") + email.WriteString("Content-Transfer-Encoding: quoted-printable\n") email.WriteString("\n") - email.WriteString(textBody) + email.WriteString(encodeQuotedPrintableBody(textBody)) email.WriteString("\n\n") // HTML part email.WriteString(fmt.Sprintf("--%s\n", altBoundary)) email.WriteString("Content-Type: text/html; charset=UTF-8\n") - email.WriteString("Content-Transfer-Encoding: 8bit\n") + email.WriteString("Content-Transfer-Encoding: quoted-printable\n") email.WriteString("\n") - email.WriteString(htmlBody) + email.WriteString(encodeQuotedPrintableBody(htmlBody)) email.WriteString("\n\n") email.WriteString(fmt.Sprintf("--%s--\n", altBoundary)) @@ -1178,17 +1233,17 @@ func (e *EmailNotifier) buildEmailMessage(recipient, subject, htmlBody, textBody // Plain text part email.WriteString(fmt.Sprintf("--%s\n", altBoundary)) email.WriteString("Content-Type: text/plain; charset=UTF-8\n") - email.WriteString("Content-Transfer-Encoding: 8bit\n") + email.WriteString("Content-Transfer-Encoding: quoted-printable\n") email.WriteString("\n") - email.WriteString(textBody) + email.WriteString(encodeQuotedPrintableBody(textBody)) email.WriteString("\n\n") // HTML part email.WriteString(fmt.Sprintf("--%s\n", altBoundary)) email.WriteString("Content-Type: text/html; charset=UTF-8\n") - email.WriteString("Content-Transfer-Encoding: 8bit\n") + email.WriteString("Content-Transfer-Encoding: quoted-printable\n") email.WriteString("\n") - email.WriteString(htmlBody) + email.WriteString(encodeQuotedPrintableBody(htmlBody)) email.WriteString("\n\n") email.WriteString(fmt.Sprintf("--%s--\n", altBoundary)) @@ -1218,7 +1273,10 @@ func (e *EmailNotifier) sendViaPMF(ctx context.Context, recipient, subject, html e.logger.Debug("=== Sending email via proxmox-mail-forward ===") e.logger.Debug("proxmox-mail-forward routing is handled by Proxmox Notifications; To=%q is only a mail header", toHeader) - cmd := exec.CommandContext(ctx, pmfPath) + cmd, err := commandForMailTool(ctx, pmfPath) + if err != nil { + return "", "", err + } cmd.Stdin = strings.NewReader(emailMessage) var stdoutBuf, stderrBuf strings.Builder @@ -1329,7 +1387,10 @@ func (e *EmailNotifier) sendViaSendmail(ctx context.Context, recipient, subject, } // Create sendmail command - cmd := exec.CommandContext(ctx, sendmailPath, args...) + cmd, err := commandForMailTool(ctx, sendmailPath, args...) + if err != nil { + return "", "", "", err + } cmd.Stdin = strings.NewReader(emailMessage) // Capture stdout and stderr separately diff --git a/internal/notify/email_delivery_methods_test.go b/internal/notify/email_delivery_methods_test.go index 41c42765..10a73fea 100644 --- a/internal/notify/email_delivery_methods_test.go +++ b/internal/notify/email_delivery_methods_test.go @@ -377,6 +377,43 @@ func TestEmailNotifierBuildEmailMessage_FallsBackWhenLogUnreadable(t *testing.T) } } +func TestEmailNotifierBuildEmailMessage_EncodesUTF8BodiesAsSevenBitSafe(t *testing.T) { + logger := logging.New(types.LogLevelDebug, false) + logger.SetOutput(io.Discard) + + notifier, err := NewEmailNotifier(EmailConfig{ + Enabled: true, + DeliveryMethod: EmailDeliveryPMF, + From: "no-reply@proxmox.example.com", + }, types.ProxmoxBS, logger) + if err != nil { + t.Fatalf("NewEmailNotifier() error = %v", err) + } + + emailMessage, _ := notifier.buildEmailMessage( + "admin@example.com", + "✅ PVE Backup à", + "

Backup completato ✅ con avvisi: è pieno

", + "Backup completato ✅ con avvisi: è pieno", + createTestNotificationData(), + ) + + if strings.Contains(emailMessage, "Content-Transfer-Encoding: 8bit") { + t.Fatalf("email message must not use 8bit transfer encoding:\n%s", emailMessage) + } + if count := strings.Count(emailMessage, "Content-Transfer-Encoding: quoted-printable"); count != 2 { + t.Fatalf("expected two quoted-printable body parts, got %d:\n%s", count, emailMessage) + } + if strings.Contains(emailMessage, "✅") || strings.Contains(emailMessage, "è") || strings.Contains(emailMessage, "à") { + t.Fatalf("email message contains raw non-ASCII body/subject characters:\n%s", emailMessage) + } + for i, b := range []byte(emailMessage) { + if b > 0x7f { + t.Fatalf("email message contains non-ASCII byte 0x%x at offset %d", b, i) + } + } +} + func TestEmailNotifierIsMTAServiceActive_SystemctlMissing(t *testing.T) { logger := logging.New(types.LogLevelDebug, false) logger.SetOutput(io.Discard) diff --git a/internal/notify/webhook.go b/internal/notify/webhook.go index 87e0f1c1..9a020bc3 100644 --- a/internal/notify/webhook.go +++ b/internal/notify/webhook.go @@ -24,6 +24,25 @@ type WebhookNotifier struct { client *http.Client } +func resolveWebhookFormat(format, defaultFormat string) string { + format = strings.TrimSpace(format) + if format == "" { + format = strings.TrimSpace(defaultFormat) + } + if format == "" { + return "generic" + } + return format +} + +func resolveWebhookMethod(method string) string { + method = strings.ToUpper(strings.TrimSpace(method)) + if method == "" { + return http.MethodPost + } + return method +} + // NewWebhookNotifier creates a new webhook notifier func NewWebhookNotifier(webhookConfig *config.WebhookConfig, logger *logging.Logger) (*WebhookNotifier, error) { logger.Debug("WebhookNotifier initialization starting...") @@ -44,6 +63,11 @@ func NewWebhookNotifier(webhookConfig *config.WebhookConfig, logger *logging.Log return nil, fmt.Errorf("webhook notifications enabled but no endpoints configured") } + notifier := &WebhookNotifier{ + config: webhookConfig, + logger: logger, + } + // Log each endpoint configuration (with masked sensitive data) for i, ep := range webhookConfig.Endpoints { logger.Debug("Endpoint #%d configuration:", i+1) @@ -58,6 +82,9 @@ func NewWebhookNotifier(webhookConfig *config.WebhookConfig, logger *logging.Log logger.Debug(" Header: %s (value masked)", k) } } + if err := notifier.validateEndpoint(ep); err != nil { + return nil, err + } } // Create HTTP client with timeout @@ -74,11 +101,34 @@ func NewWebhookNotifier(webhookConfig *config.WebhookConfig, logger *logging.Log logger.Info("✅ WebhookNotifier initialized successfully with %d endpoint(s)", len(webhookConfig.Endpoints)) - return &WebhookNotifier{ - config: webhookConfig, - logger: logger, - client: client, - }, nil + notifier.client = client + return notifier, nil +} + +func (w *WebhookNotifier) validateEndpoint(ep config.WebhookEndpoint) error { + format := resolveWebhookFormat(ep.Format, w.config.DefaultFormat) + method := resolveWebhookMethod(ep.Method) + if !strings.EqualFold(format, "pushover") { + return nil + } + + missing := []string{} + if ep.Auth.Token == "" { + missing = append(missing, "token") + } + if ep.Auth.User == "" { + missing = append(missing, "user") + } + if len(missing) > 0 { + return fmt.Errorf("webhook endpoint %q: Pushover requires Auth.Token and Auth.User; missing %s", ep.Name, strings.Join(missing, "/")) + } + if ep.Priority < -2 || ep.Priority > 1 { + return fmt.Errorf("webhook endpoint %q: PRIORITY must be in range -2..1 (got %d); priority 2 (emergency) is not supported", ep.Name, ep.Priority) + } + if method != http.MethodPost { + return fmt.Errorf("webhook endpoint %q: METHOD must be POST for pushover (got %s)", ep.Name, method) + } + return nil } // Name returns the notifier name @@ -164,21 +214,29 @@ func (w *WebhookNotifier) sendToEndpoint(ctx context.Context, endpoint config.We w.logger.Debug("Endpoint format: %s, URL: %s", endpoint.Format, maskURL(endpoint.URL)) // Determine format to use - format := endpoint.Format - if format == "" { - format = w.config.DefaultFormat - w.logger.Debug("Using default format: %s", format) + format := resolveWebhookFormat(endpoint.Format, w.config.DefaultFormat) + if strings.TrimSpace(endpoint.Format) == "" { + if strings.TrimSpace(w.config.DefaultFormat) != "" { + w.logger.Debug("Using default format: %s", format) + } else { + w.logger.Debug("No format specified, using generic") + } } - if format == "" { - format = "generic" - w.logger.Debug("No format specified, using generic") + + method := resolveWebhookMethod(endpoint.Method) + if strings.TrimSpace(endpoint.Method) == "" { + w.logger.Debug("No method specified, using POST") + } + if strings.EqualFold(format, "pushover") && method != http.MethodPost { + return fmt.Errorf("webhook endpoint %q: METHOD must be POST for pushover (got %s)", endpoint.Name, method) } // Build payload based on format w.logger.Debug("Building %s payload...", format) payloadStart := time.Now() - payload, err := w.buildPayload(format, data) + endpoint.Format = format + payload, err := w.buildPayload(endpoint, data) if err != nil { w.logger.Error("Failed to build %s payload: %v", format, err) return fmt.Errorf("failed to build payload: %w", err) @@ -197,7 +255,9 @@ func (w *WebhookNotifier) sendToEndpoint(ctx context.Context, endpoint config.We w.logger.Debug("Payload marshaled: %d bytes", len(payloadBytes)) if w.logger.GetLevel() <= types.LogLevelDebug { - if len(payloadBytes) > 200 { + if strings.EqualFold(format, "pushover") { + w.logger.Debug("Payload preview omitted: pushover payload contains credentials") + } else if len(payloadBytes) > 200 { w.logger.Debug("Payload preview (first 200 chars): %s...", string(payloadBytes[:200])) } else { w.logger.Debug("Payload content: %s", string(payloadBytes)) @@ -229,12 +289,6 @@ func (w *WebhookNotifier) sendToEndpoint(ctx context.Context, endpoint config.We } } - // Determine HTTP method - method := strings.ToUpper(strings.TrimSpace(endpoint.Method)) - if method == "" { - method = "POST" - } - parsedURL, parseErr := url.Parse(endpoint.URL) if parseErr != nil { lastErr = fmt.Errorf("invalid webhook URL: %w", parseErr) @@ -415,16 +469,19 @@ func (w *WebhookNotifier) sendToEndpoint(ctx context.Context, endpoint config.We } // buildPayload builds the webhook payload based on format -func (w *WebhookNotifier) buildPayload(format string, data *NotificationData) (interface{}, error) { +func (w *WebhookNotifier) buildPayload(endpoint config.WebhookEndpoint, data *NotificationData) (interface{}, error) { + format := strings.ToLower(endpoint.Format) w.logger.Debug("buildPayload() called with format=%s", format) - switch strings.ToLower(format) { + switch format { case "discord": return buildDiscordPayload(data, w.logger) case "slack": return buildSlackPayload(data, w.logger) case "teams": return buildTeamsPayload(data, w.logger) + case "pushover": + return buildPushoverPayload(endpoint, data, w.logger) case "generic": return buildGenericPayload(data, w.logger) default: diff --git a/internal/notify/webhook_payloads.go b/internal/notify/webhook_payloads.go index 1c4530ff..7436d04c 100644 --- a/internal/notify/webhook_payloads.go +++ b/internal/notify/webhook_payloads.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/tis24dev/proxsave/internal/config" "github.com/tis24dev/proxsave/internal/logging" ) @@ -575,3 +576,53 @@ func buildGenericPayload(data *NotificationData, logger *logging.Logger) (map[st logger.Debug("Generic payload built successfully with %d top-level keys", len(payload)) return payload, nil } + +// buildPushoverPayload builds a Pushover-formatted webhook payload. +// Pushover requires the application token and user/group key in the JSON body +// (not in headers); this builder reads them from endpoint.Auth.Token and +// endpoint.Auth.User and rejects requests where either is missing. +func buildPushoverPayload(endpoint config.WebhookEndpoint, data *NotificationData, logger *logging.Logger) (map[string]interface{}, error) { + logger.Debug("buildPushoverPayload() starting...") + + if endpoint.Auth.Token == "" { + return nil, fmt.Errorf("pushover: AUTH_TOKEN (Pushover application token) is required") + } + if endpoint.Auth.User == "" { + return nil, fmt.Errorf("pushover: AUTH_USER (Pushover user/group key) is required") + } + + title := truncateRunes(fmt.Sprintf("%s Proxmox Backup — %s", GetStatusEmoji(data.Status), data.Hostname), 250) + + message := truncateRunes(fmt.Sprintf( + "Status: %s\nDuration: %s\nSize: %s\nErrors: %d | Warnings: %d", + data.StatusMessage, + FormatDuration(data.BackupDuration), + data.BackupSizeHR, + data.ErrorCount, + data.WarningCount, + ), 1024) + + payload := map[string]interface{}{ + "token": endpoint.Auth.Token, + "user": endpoint.Auth.User, + "title": title, + "message": message, + "priority": endpoint.Priority, + } + + logger.Debug("Pushover payload built (priority=%d, title_len=%d, message_len=%d)", endpoint.Priority, len([]rune(title)), len([]rune(message))) + return payload, nil +} + +// truncateRunes shortens s to at most max runes, suffixing with "…" when cut. +// Operates on runes (not bytes) so multibyte characters like emoji are not split. +func truncateRunes(s string, max int) string { + if max <= 0 { + return "" + } + r := []rune(s) + if len(r) <= max { + return s + } + return string(r[:max-1]) + "…" +} diff --git a/internal/notify/webhook_test.go b/internal/notify/webhook_test.go index 85503332..da918043 100644 --- a/internal/notify/webhook_test.go +++ b/internal/notify/webhook_test.go @@ -658,7 +658,8 @@ func TestWebhookNotifier_buildPayload_CoversFormats(t *testing.T) { for _, format := range formats { format := format t.Run(format, func(t *testing.T) { - payload, err := notifier.buildPayload(format, data) + ep := config.WebhookEndpoint{Name: "x", URL: "https://example.com", Format: format} + payload, err := notifier.buildPayload(ep, data) if err != nil { t.Fatalf("buildPayload(%q) error = %v", format, err) } @@ -1070,3 +1071,290 @@ func TestMaskHeaderValue(t *testing.T) { }) } } + +func pushoverTestEndpoint(priority int) config.WebhookEndpoint { + return config.WebhookEndpoint{ + Name: "pushover", + URL: "https://api.pushover.net/1/messages.json", + Format: "pushover", + Method: "POST", + Auth: config.WebhookAuth{Type: "none", Token: "app-token-abc", User: "user-key-xyz"}, + Priority: priority, + } +} + +func TestBuildPushoverPayload_Success(t *testing.T) { + logger := logging.New(types.LogLevelDebug, false) + data := createTestNotificationData() + + payload, err := buildPushoverPayload(pushoverTestEndpoint(0), data, logger) + if err != nil { + t.Fatalf("buildPushoverPayload() error: %v", err) + } + + if got := payload["token"]; got != "app-token-abc" { + t.Errorf("token = %v, want app-token-abc", got) + } + if got := payload["user"]; got != "user-key-xyz" { + t.Errorf("user = %v, want user-key-xyz", got) + } + if got := payload["priority"]; got != 0 { + t.Errorf("priority = %v, want 0", got) + } + + title, ok := payload["title"].(string) + if !ok { + t.Fatalf("title is not a string: %T", payload["title"]) + } + if !strings.Contains(title, data.Hostname) { + t.Errorf("title %q does not contain hostname %q", title, data.Hostname) + } + if !strings.Contains(title, GetStatusEmoji(data.Status)) { + t.Errorf("title %q does not contain status emoji", title) + } + + message, ok := payload["message"].(string) + if !ok { + t.Fatalf("message is not a string: %T", payload["message"]) + } + for _, want := range []string{"Status:", "Duration:", "Size:", "Errors:", "Warnings:"} { + if !strings.Contains(message, want) { + t.Errorf("message missing %q; got %q", want, message) + } + } +} + +func TestBuildPushoverPayload_MissingToken(t *testing.T) { + logger := logging.New(types.LogLevelDebug, false) + data := createTestNotificationData() + ep := pushoverTestEndpoint(0) + ep.Auth.Token = "" + + _, err := buildPushoverPayload(ep, data, logger) + if err == nil { + t.Fatal("expected error for missing token, got nil") + } + if !strings.Contains(err.Error(), "AUTH_TOKEN") { + t.Errorf("error %q does not mention AUTH_TOKEN", err.Error()) + } +} + +func TestBuildPushoverPayload_MissingUser(t *testing.T) { + logger := logging.New(types.LogLevelDebug, false) + data := createTestNotificationData() + ep := pushoverTestEndpoint(0) + ep.Auth.User = "" + + _, err := buildPushoverPayload(ep, data, logger) + if err == nil { + t.Fatal("expected error for missing user, got nil") + } + if !strings.Contains(err.Error(), "AUTH_USER") { + t.Errorf("error %q does not mention AUTH_USER", err.Error()) + } +} + +func TestBuildPushoverPayload_TitleTruncated(t *testing.T) { + logger := logging.New(types.LogLevelDebug, false) + data := createTestNotificationData() + data.Hostname = strings.Repeat("h", 300) + + payload, err := buildPushoverPayload(pushoverTestEndpoint(0), data, logger) + if err != nil { + t.Fatalf("buildPushoverPayload() error: %v", err) + } + + title := payload["title"].(string) + if got := len([]rune(title)); got > 250 { + t.Errorf("title rune length = %d, want <= 250", got) + } + if !strings.HasSuffix(title, "…") { + t.Errorf("truncated title should end with ellipsis; got %q", title) + } +} + +func TestBuildPushoverPayload_MessageTruncated(t *testing.T) { + logger := logging.New(types.LogLevelDebug, false) + data := createTestNotificationData() + data.StatusMessage = strings.Repeat("x", 1100) + + payload, err := buildPushoverPayload(pushoverTestEndpoint(0), data, logger) + if err != nil { + t.Fatalf("buildPushoverPayload() error: %v", err) + } + + message := payload["message"].(string) + if got := len([]rune(message)); got > 1024 { + t.Errorf("message rune length = %d, want <= 1024", got) + } + if !strings.HasSuffix(message, "…") { + t.Errorf("truncated message should end with ellipsis; got %q", message) + } +} + +func TestBuildPushoverPayload_PriorityPassthrough(t *testing.T) { + logger := logging.New(types.LogLevelDebug, false) + data := createTestNotificationData() + + for _, p := range []int{-2, -1, 0, 1} { + payload, err := buildPushoverPayload(pushoverTestEndpoint(p), data, logger) + if err != nil { + t.Fatalf("priority=%d: buildPushoverPayload() error: %v", p, err) + } + if got := payload["priority"]; got != p { + t.Errorf("priority=%d: got %v", p, got) + } + } +} + +func TestNewWebhookNotifier_PushoverPriority(t *testing.T) { + logger := logging.New(types.LogLevelDebug, false) + + tests := []struct { + name string + priority int + expectError bool + }{ + {"min valid", -2, false}, + {"zero", 0, false}, + {"max valid", 1, false}, + {"too low", -3, true}, + {"emergency rejected", 2, true}, + {"too high", 3, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.WebhookConfig{ + Enabled: true, + DefaultFormat: "pushover", + Timeout: 30, + Endpoints: []config.WebhookEndpoint{pushoverTestEndpoint(tt.priority)}, + } + _, err := NewWebhookNotifier(cfg, logger) + if tt.expectError { + if err == nil { + t.Fatalf("priority=%d: expected error, got nil", tt.priority) + } + if !strings.Contains(err.Error(), "PRIORITY") { + t.Errorf("error %q does not mention PRIORITY", err.Error()) + } + return + } + if err != nil { + t.Fatalf("priority=%d: unexpected error: %v", tt.priority, err) + } + }) + } +} + +func TestNewWebhookNotifier_PushoverPriority_UsesDefaultFormat(t *testing.T) { + logger := logging.New(types.LogLevelDebug, false) + + ep := pushoverTestEndpoint(2) + ep.Format = "" + + cfg := &config.WebhookConfig{ + Enabled: true, + DefaultFormat: "pushover", + Timeout: 30, + Endpoints: []config.WebhookEndpoint{ep}, + } + + _, err := NewWebhookNotifier(cfg, logger) + if err == nil { + t.Fatal("expected error for invalid pushover priority resolved from default format, got nil") + } + if !strings.Contains(err.Error(), "PRIORITY") { + t.Fatalf("error %q does not mention PRIORITY", err.Error()) + } +} + +func TestNewWebhookNotifier_PushoverMethod(t *testing.T) { + logger := logging.New(types.LogLevelDebug, false) + + tests := []struct { + name string + method string + format string + defaultFormat string + expectError bool + }{ + {name: "explicit post", method: "POST", format: "pushover", expectError: false}, + {name: "implicit post", method: "", format: "pushover", expectError: false}, + {name: "default format post", method: "", format: "", defaultFormat: "pushover", expectError: false}, + {name: "get rejected", method: "GET", format: "pushover", expectError: true}, + {name: "head rejected", method: "HEAD", format: "pushover", expectError: true}, + {name: "put rejected", method: "PUT", format: "pushover", expectError: true}, + {name: "default format get rejected", method: "GET", format: "", defaultFormat: "pushover", expectError: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ep := pushoverTestEndpoint(0) + ep.Method = tt.method + ep.Format = tt.format + + cfg := &config.WebhookConfig{ + Enabled: true, + DefaultFormat: tt.defaultFormat, + Timeout: 30, + Endpoints: []config.WebhookEndpoint{ep}, + } + + _, err := NewWebhookNotifier(cfg, logger) + if tt.expectError { + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "METHOD must be POST") { + t.Fatalf("error %q does not mention POST method requirement", err.Error()) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestNewWebhookNotifier_PushoverAuthRequired(t *testing.T) { + logger := logging.New(types.LogLevelDebug, false) + + tests := []struct { + name string + token string + user string + missing string + }{ + {name: "missing token", token: "", user: "user-key-xyz", missing: "missing token"}, + {name: "missing user", token: "app-token-abc", user: "", missing: "missing user"}, + {name: "missing both", token: "", user: "", missing: "missing token/user"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ep := pushoverTestEndpoint(0) + ep.Auth.Token = tt.token + ep.Auth.User = tt.user + + cfg := &config.WebhookConfig{ + Enabled: true, + Timeout: 30, + Endpoints: []config.WebhookEndpoint{ep}, + } + + _, err := NewWebhookNotifier(cfg, logger) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "Pushover requires Auth.Token and Auth.User") { + t.Fatalf("error %q does not mention Pushover auth requirement", err.Error()) + } + if !strings.Contains(err.Error(), tt.missing) { + t.Fatalf("error %q does not mention %q", err.Error(), tt.missing) + } + }) + } +} diff --git a/internal/orchestrator/additional_helpers_test.go b/internal/orchestrator/additional_helpers_test.go index 94844c43..39286f81 100644 --- a/internal/orchestrator/additional_helpers_test.go +++ b/internal/orchestrator/additional_helpers_test.go @@ -828,8 +828,8 @@ storage: backup if blocks[0].ID != "local" || blocks[1].ID != "backup" { t.Fatalf("unexpected IDs: %+v", blocks) } - if len(blocks[0].data) == 0 || len(blocks[1].data) == 0 { - t.Fatalf("expected data in blocks") + if len(blocks[0].entries) == 0 || len(blocks[1].entries) == 0 { + t.Fatalf("expected entries in blocks") } // Empty file -> zero blocks @@ -913,7 +913,12 @@ func TestExtractArchiveNativeSymlinkAndHardlink(t *testing.T) { } dest := filepath.Join(tmpDir, "dest") - if err := extractArchiveNative(context.Background(), tarPath, dest, logger, nil, RestoreModeFull, nil, "", nil); err != nil { + if err := extractArchiveNative(context.Background(), restoreArchiveOptions{ + archivePath: tarPath, + destRoot: dest, + logger: logger, + mode: RestoreModeFull, + }); err != nil { t.Fatalf("extractArchiveNative error: %v", err) } @@ -1292,7 +1297,12 @@ func TestExtractArchiveNativeBlocksTraversal(t *testing.T) { _ = f.Close() dest := filepath.Join(tmpDir, "dest") - if err := extractArchiveNative(context.Background(), tarPath, dest, logger, nil, RestoreModeFull, nil, "", nil); err != nil { + if err := extractArchiveNative(context.Background(), restoreArchiveOptions{ + archivePath: tarPath, + destRoot: dest, + logger: logger, + mode: RestoreModeFull, + }); err != nil { t.Fatalf("extractArchiveNative error: %v", err) } if _, err := os.Stat(filepath.Join(dest, "../etc/passwd")); err == nil { diff --git a/internal/orchestrator/backup_run_helpers.go b/internal/orchestrator/backup_run_helpers.go new file mode 100644 index 00000000..32103742 --- /dev/null +++ b/internal/orchestrator/backup_run_helpers.go @@ -0,0 +1,321 @@ +// Package orchestrator coordinates backup, restore, decrypt, and notification workflows. +package orchestrator + +import ( + "context" + "errors" + "fmt" + "path/filepath" + + "filippo.io/age" + "github.com/tis24dev/proxsave/internal/backup" + "github.com/tis24dev/proxsave/internal/metrics" + "github.com/tis24dev/proxsave/internal/types" +) + +func (o *Orchestrator) shouldExportBackupMetrics(stats *BackupStats) bool { + return stats != nil && o.cfg != nil && o.cfg.MetricsEnabled && !o.dryRun +} + +func (o *Orchestrator) ensureBackupStatsTiming(stats *BackupStats) { + if stats.EndTime.IsZero() { + stats.EndTime = o.now() + } + if stats.Duration == 0 && !stats.StartTime.IsZero() { + stats.Duration = stats.EndTime.Sub(stats.StartTime) + } +} + +func backupMetricsExitCode(stats *BackupStats, runErr error) int { + if runErr == nil { + if stats.ExitCode == 0 { + return types.ExitSuccess.Int() + } + return stats.ExitCode + } + + var backupErr *BackupError + if errors.As(runErr, &backupErr) { + return backupErr.Code.Int() + } + return types.ExitGenericError.Int() +} + +func (o *Orchestrator) exportPrometheusBackupMetrics(stats *BackupStats) { + m := stats.toPrometheusMetrics() + if m == nil { + return + } + + exporter := metrics.NewPrometheusExporter(o.cfg.MetricsPath, o.logger) + if err := exporter.Export(m); err != nil { + o.logger.Warning("Failed to export Prometheus metrics: %v", err) + } +} + +func (o *Orchestrator) parseFailedBackupLogCounts(stats *BackupStats) { + if stats.LogFilePath == "" { + o.logger.Debug("No log file path specified, error/warning counts will be 0 (failure path)") + return + } + + o.logger.Debug("Parsing log file for error/warning counts after failure: %s", stats.LogFilePath) + _, errorCount, warningCount := ParseLogCounts(stats.LogFilePath, 0) + stats.ErrorCount = errorCount + stats.WarningCount = warningCount + if errorCount > 0 || warningCount > 0 { + o.logger.Debug("Found %d errors and %d warnings in log file (failure path)", errorCount, warningCount) + } +} + +func backupFailureExitCode(runErr error) int { + var backupErr *BackupError + if errors.As(runErr, &backupErr) { + return backupErr.Code.Int() + } + return types.ExitBackupError.Int() +} + +func (o *Orchestrator) buildBackupCollectorConfig() *backup.CollectorConfig { + collectorConfig := backup.GetDefaultCollectorConfig() + collectorConfig.ExcludePatterns = append([]string(nil), o.excludePatterns...) + if o.cfg == nil { + return collectorConfig + } + + applyCollectorOverrides(collectorConfig, o.cfg) + if len(o.cfg.BackupBlacklist) > 0 { + collectorConfig.ExcludePatterns = append(collectorConfig.ExcludePatterns, o.cfg.BackupBlacklist...) + } + return collectorConfig +} + +func (o *Orchestrator) runBackupCollector(run *backupRunContext, workspace *backupWorkspace, collectorConfig *backup.CollectorConfig) (*backup.Collector, error) { + collector := backup.NewCollectorWithDeps(o.logger, collectorConfig, workspace.tempDir, run.proxmoxType, o.dryRun, o.collectorDeps()) + o.logger.Debug("Starting collector run (type=%s)", run.proxmoxType) + if err := collector.CollectAll(run.ctx); err != nil { + return nil, err + } + return collector, nil +} + +func (o *Orchestrator) applyBackupCollectionStats(stats *BackupStats, collStats *backup.CollectionStats, collector *backup.Collector) { + stats.FilesCollected = int(collStats.FilesProcessed) + stats.FilesFailed = int(collStats.FilesFailed) + stats.FilesNotFound = int(collStats.FilesNotFound) + stats.DirsCreated = int(collStats.DirsCreated) + stats.BytesCollected = collStats.BytesCollected + stats.FilesIncluded = int(collStats.FilesProcessed) + stats.FilesMissing = int(collStats.FilesNotFound) + stats.UncompressedSize = collStats.BytesCollected + if stats.ProxmoxType.SupportsPVE() { + stats.ClusterMode = standaloneClusterMode(collector) + } +} + +func standaloneClusterMode(collector *backup.Collector) string { + if collector.IsClusteredPVE() { + return "cluster" + } + return "standalone" +} + +func (o *Orchestrator) writeBackupCollectionMetadata(tempDir, hostname string, stats *BackupStats, collector *backup.Collector) { + if err := o.writeBackupMetadata(tempDir, stats); err != nil { + o.logger.Debug("Failed to write backup metadata: %v", err) + } + if err := collector.WriteManifest(hostname); err != nil { + o.logger.Debug("Failed to write backup manifest: %v", err) + } +} + +func (o *Orchestrator) logBackupCollectionSummary(collStats *backup.CollectionStats) { + o.logger.Info("Collection completed: %d files (%s), %d failed, %d dirs created", + collStats.FilesProcessed, + backup.FormatBytes(collStats.BytesCollected), + collStats.FilesFailed, + collStats.DirsCreated) +} + +func (o *Orchestrator) applyBackupOptimizations(ctx context.Context, tempDir string) error { + if !o.optimizationCfg.Enabled() { + o.logger.Debug("Skipping optimization step (all features disabled)") + return nil + } + + fmt.Println() + o.logger.Step("Backup optimizations on collected data") + if err := backup.ApplyOptimizations(ctx, o.logger, tempDir, o.optimizationCfg); err != nil { + o.logger.Warning("Backup optimizations completed with warnings: %v", err) + } + return nil +} + +func estimatedBackupSizeGB(bytesCollected int64) float64 { + estimatedSizeGB := float64(bytesCollected) / (1024.0 * 1024.0 * 1024.0) + if estimatedSizeGB < 0.001 { + return 0.001 + } + return estimatedSizeGB +} + +func backupDiskValidationError(message string, diskErr error) error { + errMsg := message + if errMsg == "" && diskErr != nil { + errMsg = diskErr.Error() + } + if errMsg == "" { + errMsg = "insufficient disk space" + } + if diskErr == nil { + diskErr = errors.New(errMsg) + } + return &BackupError{ + Phase: "disk", + Err: fmt.Errorf("disk space validation failed: %w", diskErr), + Code: types.ExitDiskSpaceError, + } +} + +func (o *Orchestrator) buildBackupArchiverConfig(run *backupRunContext, ageRecipients []age.Recipient) *backup.ArchiverConfig { + return BuildArchiverConfig( + o.compressionType, + run.normalizedLevel, + o.compressionThreads, + o.compressionMode, + o.dryRun, + o.cfg != nil && o.cfg.EncryptArchive, + ageRecipients, + run.collectorConfig.ExcludePatterns, + ) +} + +func (o *Orchestrator) applyBackupArchiverStats(stats *BackupStats, archiver *backup.Archiver) { + stats.Compression = archiver.ResolveCompression() + stats.CompressionLevel = archiver.CompressionLevel() + stats.CompressionMode = archiver.CompressionMode() + stats.CompressionThreads = archiver.CompressionThreads() +} + +func (o *Orchestrator) backupArchivePath(run *backupRunContext, archiver *backup.Archiver) string { + archiveBasename := fmt.Sprintf("%s-backup-%s", run.hostname, run.timestamp) + return filepath.Join(o.backupPath, archiveBasename+archiver.GetArchiveExtension()) +} + +func (o *Orchestrator) logResolvedBackupCompression(stats *BackupStats) { + if stats.RequestedCompression != stats.Compression { + o.logger.Info("Using %s compression (requested %s)", stats.Compression, stats.RequestedCompression) + } +} + +func createBackupArchiveFile(ctx context.Context, archiver *backup.Archiver, tempDir, archivePath string) error { + if err := archiver.CreateArchive(ctx, tempDir, archivePath); err != nil { + return backupArchiveCreationError(err) + } + return nil +} + +func backupArchiveCreationError(err error) error { + phase := "archive" + code := types.ExitArchiveError + var compressionErr *backup.CompressionError + if errors.As(err, &compressionErr) { + phase = "compression" + code = types.ExitCompressionError + } + return &BackupError{Phase: phase, Err: err, Code: code} +} + +func (o *Orchestrator) skipDryRunArtifactVerification(stats *BackupStats, artifacts *backupArtifacts) error { + fmt.Println() + o.logStep(4, "Verification skipped (dry run mode)") + o.logger.Info("[DRY RUN] Would create archive: %s", artifacts.archivePath) + stats.EndTime = o.now() + return nil +} + +func (o *Orchestrator) recordArchiveSize(stats *BackupStats, artifacts *backupArtifacts) { + size, err := artifacts.archiver.GetArchiveSize(artifacts.archivePath) + if err != nil { + o.logger.Warning("Failed to get archive size: %v", err) + return + } + + stats.ArchiveSize = size + stats.CompressedSize = size + stats.updateCompressionMetrics() + o.logger.Debug("Archive created: %s (%s)", artifacts.archivePath, backup.FormatBytes(size)) +} + +func (o *Orchestrator) generateArchiveChecksum(ctx context.Context, archivePath string) (string, error) { + checksum, err := backup.GenerateChecksum(ctx, o.logger, archivePath) + if err != nil { + return "", &BackupError{ + Phase: "verification", + Err: fmt.Errorf("checksum generation failed: %w", err), + Code: types.ExitVerificationError, + } + } + return checksum, nil +} + +func (o *Orchestrator) writeArchiveChecksum(workspace *backupWorkspace, artifacts *backupArtifacts, checksum string) error { + checksumContent := fmt.Sprintf("%s %s\n", checksum, filepath.Base(artifacts.archivePath)) + if err := workspace.fs.WriteFile(artifacts.checksumPath, []byte(checksumContent), 0o640); err != nil { + return fmt.Errorf("write checksum file %s: %w", artifacts.checksumPath, err) + } + o.logger.Debug("Checksum file written to %s", artifacts.checksumPath) + return nil +} + +func (o *Orchestrator) writeArchiveManifest(run *backupRunContext, artifacts *backupArtifacts, checksum string) error { + manifestPath := artifacts.archivePath + ".manifest.json" + manifest := o.newArchiveManifest(run.stats, artifacts.archivePath, checksum) + if err := backup.CreateManifest(run.ctx, o.logger, manifest, manifestPath); err != nil { + return &BackupError{ + Phase: "verification", + Err: fmt.Errorf("manifest creation failed: %w", err), + Code: types.ExitVerificationError, + } + } + run.stats.ManifestPath = manifestPath + artifacts.manifestPath = manifestPath + return nil +} + +func (o *Orchestrator) newArchiveManifest(stats *BackupStats, archivePath, checksum string) *backup.Manifest { + return &backup.Manifest{ + ArchivePath: archivePath, + ArchiveSize: stats.ArchiveSize, + SHA256: checksum, + CreatedAt: stats.Timestamp, + CompressionType: string(stats.Compression), + CompressionLevel: stats.CompressionLevel, + CompressionMode: stats.CompressionMode, + ProxmoxType: string(stats.ProxmoxType), + ProxmoxTargets: append([]string(nil), stats.ProxmoxTargets...), + ProxmoxVersion: stats.ProxmoxVersion, + PVEVersion: stats.PVEVersion, + PBSVersion: stats.PBSVersion, + Hostname: stats.Hostname, + ScriptVersion: stats.ScriptVersion, + EncryptionMode: o.archiveEncryptionMode(), + ClusterMode: stats.ClusterMode, + } +} + +func (o *Orchestrator) archiveEncryptionMode() string { + if o.cfg != nil && o.cfg.EncryptArchive { + return "age" + } + return "none" +} + +func (o *Orchestrator) writeLegacyMetadataAlias(workspace *backupWorkspace, artifacts *backupArtifacts) { + metadataAlias := artifacts.archivePath + ".metadata" + if err := copyFile(workspace.fs, artifacts.manifestPath, metadataAlias); err != nil { + o.logger.Warning("Failed to write legacy metadata file %s: %v", metadataAlias, err) + } else { + o.logger.Debug("Legacy metadata file written to %s", metadataAlias) + } +} diff --git a/internal/orchestrator/backup_run_helpers_additional_test.go b/internal/orchestrator/backup_run_helpers_additional_test.go new file mode 100644 index 00000000..49c33b93 --- /dev/null +++ b/internal/orchestrator/backup_run_helpers_additional_test.go @@ -0,0 +1,130 @@ +package orchestrator + +import ( + "errors" + "math" + "strings" + "testing" + "time" + + "github.com/tis24dev/proxsave/internal/config" + "github.com/tis24dev/proxsave/internal/types" +) + +func TestEstimatedBackupSizeGBMinimumAndScaling(t *testing.T) { + tests := []struct { + name string + bytes int64 + want float64 + }{ + {name: "zero uses minimum", bytes: 0, want: 0.001}, + {name: "below minimum uses minimum", bytes: 512, want: 0.001}, + {name: "one gibibyte", bytes: 1024 * 1024 * 1024, want: 1}, + {name: "two and a half gibibytes", bytes: 5 * 1024 * 1024 * 1024 / 2, want: 2.5}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := estimatedBackupSizeGB(tt.bytes); math.Abs(got-tt.want) > 0.0000001 { + t.Fatalf("estimatedBackupSizeGB(%d)=%f want %f", tt.bytes, got, tt.want) + } + }) + } +} + +func TestBackupDiskValidationErrorWrapsDiskError(t *testing.T) { + diskErr := errors.New("need 3.5 GB free") + err := backupDiskValidationError("", diskErr) + + var backupErr *BackupError + if !errors.As(err, &backupErr) { + t.Fatalf("expected BackupError, got %T", err) + } + if backupErr.Phase != "disk" || backupErr.Code != types.ExitDiskSpaceError { + t.Fatalf("unexpected BackupError fields: phase=%q code=%v", backupErr.Phase, backupErr.Code) + } + if !errors.Is(err, diskErr) { + t.Fatalf("expected disk error to be wrapped, got %v", err) + } +} + +func TestBackupDiskValidationErrorUsesDefaultMessage(t *testing.T) { + err := backupDiskValidationError("", nil) + + var backupErr *BackupError + if !errors.As(err, &backupErr) { + t.Fatalf("expected BackupError, got %T", err) + } + if !strings.Contains(err.Error(), "insufficient disk space") { + t.Fatalf("expected default disk space message, got %q", err.Error()) + } +} + +func TestBackupMetricsExitCode(t *testing.T) { + if got := backupMetricsExitCode(&BackupStats{}, nil); got != types.ExitSuccess.Int() { + t.Fatalf("success exit code=%d want %d", got, types.ExitSuccess.Int()) + } + if got := backupMetricsExitCode(&BackupStats{ExitCode: 77}, nil); got != 77 { + t.Fatalf("stats exit code=%d want 77", got) + } + + runErr := &BackupError{Phase: "disk", Err: errors.New("full"), Code: types.ExitDiskSpaceError} + if got := backupMetricsExitCode(&BackupStats{}, runErr); got != types.ExitDiskSpaceError.Int() { + t.Fatalf("backup error exit code=%d want %d", got, types.ExitDiskSpaceError.Int()) + } + if got := backupMetricsExitCode(&BackupStats{}, errors.New("boom")); got != types.ExitGenericError.Int() { + t.Fatalf("generic error exit code=%d want %d", got, types.ExitGenericError.Int()) + } +} + +func TestEnsureBackupStatsTimingFillsEndAndDuration(t *testing.T) { + now := time.Date(2026, 5, 5, 10, 30, 0, 0, time.UTC) + orch := New(newTestLogger(), false) + orch.clock = &FakeTime{Current: now} + + stats := &BackupStats{StartTime: now.Add(-90 * time.Second)} + orch.ensureBackupStatsTiming(stats) + + if !stats.EndTime.Equal(now) { + t.Fatalf("EndTime=%v want %v", stats.EndTime, now) + } + if stats.Duration != 90*time.Second { + t.Fatalf("Duration=%v want %v", stats.Duration, 90*time.Second) + } +} + +func TestBuildBackupCollectorConfigMergesRuntimeExcludesAndBlacklist(t *testing.T) { + orch := New(newTestLogger(), false) + orch.SetBackupConfig("/backup", "/logs", types.CompressionZstd, 3, 2, "fast", []string{"runtime/**"}) + orch.SetConfig(&config.Config{ + BackupBlacklist: []string{"/secret", "/tmp/cache"}, + CustomBackupPaths: []string{"/srv/app"}, + BaseDir: "/opt/proxsave", + ConfigPath: "/etc/proxsave/backup.env", + }) + + cfg := orch.buildBackupCollectorConfig() + for _, want := range []string{"runtime/**", "/secret", "/tmp/cache"} { + if !containsString(cfg.ExcludePatterns, want) { + t.Fatalf("ExcludePatterns missing %q: %#v", want, cfg.ExcludePatterns) + } + } + if len(cfg.BackupBlacklist) != 2 || cfg.BackupBlacklist[0] != "/secret" || cfg.BackupBlacklist[1] != "/tmp/cache" { + t.Fatalf("BackupBlacklist not copied: %#v", cfg.BackupBlacklist) + } + if len(cfg.CustomBackupPaths) != 1 || cfg.CustomBackupPaths[0] != "/srv/app" { + t.Fatalf("CustomBackupPaths not copied: %#v", cfg.CustomBackupPaths) + } + if cfg.ScriptRepositoryPath != "/opt/proxsave" || cfg.ConfigFilePath != "/etc/proxsave/backup.env" { + t.Fatalf("paths not copied: script=%q config=%q", cfg.ScriptRepositoryPath, cfg.ConfigFilePath) + } +} + +func containsString(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} diff --git a/internal/orchestrator/backup_run_phases.go b/internal/orchestrator/backup_run_phases.go new file mode 100644 index 00000000..0e681583 --- /dev/null +++ b/internal/orchestrator/backup_run_phases.go @@ -0,0 +1,384 @@ +// Package orchestrator coordinates backup, restore, decrypt, and notification workflows. +package orchestrator + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/tis24dev/proxsave/internal/backup" + "github.com/tis24dev/proxsave/internal/environment" + "github.com/tis24dev/proxsave/internal/types" +) + +type backupRunContext struct { + ctx context.Context + envInfo *environment.EnvironmentInfo + hostname string + proxmoxType types.ProxmoxType + startTime time.Time + timestamp string + normalizedLevel int + collectorConfig *backup.CollectorConfig + stats *BackupStats +} + +type backupWorkspace struct { + registry *TempDirRegistry + fs FS + tempRoot string + tempDir string +} + +type backupArtifacts struct { + archiver *backup.Archiver + archivePath string + checksumPath string + manifestPath string + bundlePath string +} + +func (o *Orchestrator) newBackupRunContext(ctx context.Context, envInfo *environment.EnvironmentInfo, hostname string) *backupRunContext { + if ctx == nil { + ctx = context.Background() + } + if envInfo == nil { + envInfo = o.envInfo + } else { + o.SetEnvironmentInfo(envInfo) + } + + pType := types.ProxmoxUnknown + if envInfo != nil { + pType = envInfo.Type + } + + startTime := o.startTime + if startTime.IsZero() { + startTime = o.now() + o.startTime = startTime + } + + return &backupRunContext{ + ctx: ctx, + envInfo: envInfo, + hostname: hostname, + proxmoxType: pType, + startTime: startTime, + timestamp: startTime.Format("20060102-150405"), + normalizedLevel: normalizeCompressionLevel(o.compressionType, o.compressionLevel), + } +} + +func (o *Orchestrator) initBackupRun(run *backupRunContext) *BackupStats { + fmt.Println() + o.logStep(1, "Initializing backup statistics and temporary workspace") + run.stats = InitializeBackupStats( + run.hostname, + run.envInfo, + o.version, + run.startTime, + o.cfg, + o.compressionType, + o.compressionMode, + run.normalizedLevel, + o.compressionThreads, + o.backupPath, + o.serverID, + o.serverMAC, + ) + if logFile := o.logger.GetLogFilePath(); logFile != "" { + run.stats.LogFilePath = logFile + } + if o.versionUpdateAvailable || o.updateCurrentVersion != "" || o.updateLatestVersion != "" { + run.stats.NewVersionAvailable = o.versionUpdateAvailable + run.stats.CurrentVersion = o.updateCurrentVersion + run.stats.LatestVersion = o.updateLatestVersion + } + return run.stats +} + +func (o *Orchestrator) exportBackupMetrics(run *backupRunContext, runErr error) { + stats := run.stats + if !o.shouldExportBackupMetrics(stats) { + return + } + + o.ensureBackupStatsTiming(stats) + stats.ExitCode = backupMetricsExitCode(stats, runErr) + o.exportPrometheusBackupMetrics(stats) +} + +func (o *Orchestrator) finalizeFailedBackupStats(run *backupRunContext, runErr error) { + stats := run.stats + if runErr == nil || stats == nil { + return + } + + o.ensureBackupStatsTiming(stats) + o.parseFailedBackupLogCounts(stats) + stats.ExitCode = backupFailureExitCode(runErr) +} + +func (o *Orchestrator) prepareBackupWorkspace(run *backupRunContext, workspace *backupWorkspace) error { + o.logger.Debug("Creating temporary directory for collection output") + workspace.tempRoot = filepath.Join("/tmp", "proxsave") + if err := workspace.fs.MkdirAll(workspace.tempRoot, 0o755); err != nil { + return fmt.Errorf("Temp directory creation failed - path: %s: %w", workspace.tempRoot, err) + } + + tempDir, err := workspace.fs.MkdirTemp(workspace.tempRoot, fmt.Sprintf("proxsave-%s-%s-", run.hostname, run.timestamp)) + if err != nil { + return fmt.Errorf("failed to create temporary directory: %w", err) + } + workspace.tempDir = tempDir + + if o.dryRun { + o.logger.Info("[DRY RUN] Temporary directory would be: %s", workspace.tempDir) + } else { + o.logger.Debug("Using temporary directory: %s", workspace.tempDir) + } + return nil +} + +func (o *Orchestrator) cleanupBackupWorkspace(workspace *backupWorkspace) { + if workspace.registry == nil { + if cleanupErr := workspace.fs.RemoveAll(workspace.tempDir); cleanupErr != nil { + o.logger.Warning("Failed to remove temp directory %s: %v", workspace.tempDir, cleanupErr) + } + return + } + o.logger.Debug("Temporary workspace preserved at %s (will be removed at the next startup)", workspace.tempDir) +} + +func (o *Orchestrator) markBackupWorkspace(workspace *backupWorkspace) error { + markerPath := filepath.Join(workspace.tempDir, ".proxsave-marker") + markerContent := fmt.Sprintf( + "Created by PID %d on %s UTC\n", + os.Getpid(), + o.now().UTC().Format("2006-01-02 15:04:05"), + ) + return workspace.fs.WriteFile(markerPath, []byte(markerContent), 0600) +} + +func (o *Orchestrator) registerBackupWorkspace(workspace *backupWorkspace) { + if workspace.registry == nil { + return + } + if err := workspace.registry.Register(workspace.tempDir); err != nil { + o.logger.Debug("Failed to register temp directory %s: %v", workspace.tempDir, err) + } +} + +func (o *Orchestrator) collectBackupData(run *backupRunContext, workspace *backupWorkspace) error { + fmt.Println() + o.logStep(2, "Collection of configuration files and optimizations") + o.logger.Info("Collecting configuration files...") + o.logger.Debug("Collector dry-run=%v excludePatterns=%d", o.dryRun, len(o.excludePatterns)) + + collectorConfig := o.buildBackupCollectorConfig() + run.collectorConfig = collectorConfig + + if err := collectorConfig.Validate(); err != nil { + return &BackupError{Phase: "config", Err: err, Code: types.ExitConfigError} + } + + collector, err := o.runBackupCollector(run, workspace, collectorConfig) + if err != nil { + return &BackupError{Phase: "collection", Err: err, Code: types.ExitCollectionError} + } + + collStats := collector.GetStats() + o.applyBackupCollectionStats(run.stats, collStats, collector) + o.writeBackupCollectionMetadata(workspace.tempDir, run.hostname, run.stats, collector) + o.logBackupCollectionSummary(collStats) + + if err := o.validateCollectedBackupSize(run.stats); err != nil { + return err + } + + return o.applyBackupOptimizations(run.ctx, workspace.tempDir) +} + +func (o *Orchestrator) validateCollectedBackupSize(stats *BackupStats) error { + if o.checker == nil || stats.BytesCollected <= 0 { + return nil + } + + o.logger.Debug("Running disk-space validation for estimated data size") + result := o.checker.CheckDiskSpaceForEstimate(estimatedBackupSizeGB(stats.BytesCollected)) + if result.Passed { + o.logger.Debug("Disk check passed: %s", result.Message) + return nil + } + + return backupDiskValidationError(result.Message, result.Error) +} + +func (o *Orchestrator) createBackupArchive(run *backupRunContext, workspace *backupWorkspace) (*backupArtifacts, error) { + fmt.Println() + o.logStep(3, "Creation of compressed archive") + o.logger.Info("Creating compressed archive...") + o.logger.Debug("Archiver configuration: type=%s level=%d mode=%s threads=%d", + o.compressionType, run.normalizedLevel, o.compressionMode, o.compressionThreads) + + ageRecipients, err := o.prepareAgeRecipients(run.ctx) + if err != nil { + return nil, &BackupError{Phase: "encryption", Err: err, Code: types.ExitEncryptionError} + } + + archiverConfig := o.buildBackupArchiverConfig(run, ageRecipients) + if err := archiverConfig.Validate(); err != nil { + return nil, &BackupError{Phase: "config", Err: err, Code: types.ExitConfigError} + } + + archiver := backup.NewArchiver(o.logger, archiverConfig) + o.applyBackupArchiverStats(run.stats, archiver) + archivePath := o.backupArchivePath(run, archiver) + o.logResolvedBackupCompression(run.stats) + + if err := createBackupArchiveFile(run.ctx, archiver, workspace.tempDir, archivePath); err != nil { + return nil, err + } + + run.stats.ArchivePath = archivePath + return &backupArtifacts{ + archiver: archiver, + archivePath: archivePath, + checksumPath: archivePath + ".sha256", + }, nil +} + +func (o *Orchestrator) verifyAndWriteBackupArtifacts(run *backupRunContext, workspace *backupWorkspace, artifacts *backupArtifacts) error { + stats := run.stats + if o.dryRun { + return o.skipDryRunArtifactVerification(stats, artifacts) + } + + fmt.Println() + o.logStep(4, "Verification of archive and metadata generation") + o.recordArchiveSize(stats, artifacts) + + if err := artifacts.archiver.VerifyArchive(run.ctx, artifacts.archivePath); err != nil { + return &BackupError{Phase: "verification", Err: err, Code: types.ExitVerificationError} + } + + checksum, err := o.generateArchiveChecksum(run.ctx, artifacts.archivePath) + if err != nil { + return err + } + stats.Checksum = checksum + + if err := o.writeArchiveChecksum(workspace, artifacts, checksum); err != nil { + return &BackupError{ + Phase: "verification", + Err: err, + Code: types.ExitVerificationError, + } + } + if err := o.writeArchiveManifest(run, artifacts, checksum); err != nil { + return err + } + o.writeLegacyMetadataAlias(workspace, artifacts) + return nil +} + +func (o *Orchestrator) bundleBackupArtifacts(run *backupRunContext, workspace *backupWorkspace, artifacts *backupArtifacts) error { + if o.dryRun { + return nil + } + + bundleEnabled := o.cfg != nil && o.cfg.BundleAssociatedFiles + if !bundleEnabled { + fmt.Println() + o.logger.Skip("Bundling disabled") + run.stats.EndTime = o.now() + o.logger.Info("✓ Archive created and verified") + return nil + } + + fmt.Println() + o.logStep(5, "Bundling of archive, checksum and metadata") + o.logger.Debug("Bundling enabled: creating bundle from %s", filepath.Base(artifacts.archivePath)) + bundlePath, err := o.createBundle(run.ctx, artifacts.archivePath) + if err != nil { + return &BackupError{ + Phase: "archive", + Err: fmt.Errorf("bundle creation failed: %w", err), + Code: types.ExitArchiveError, + } + } + + if err := o.removeAssociatedFiles(artifacts.archivePath); err != nil { + o.logger.Warning("Failed to remove raw files after bundling: %v", err) + } else { + o.logger.Debug("Removed raw tar/checksum/metadata after bundling") + } + + stats := run.stats + if info, err := workspace.fs.Stat(bundlePath); err == nil { + stats.ArchiveSize = info.Size() + stats.CompressedSize = info.Size() + stats.updateCompressionMetrics() + } + stats.ArchivePath = bundlePath + stats.ManifestPath = "" + stats.BundleCreated = true + artifacts.bundlePath = bundlePath + artifacts.archivePath = bundlePath + o.logger.Debug("Bundle ready: %s", filepath.Base(bundlePath)) + + stats.EndTime = o.now() + o.logger.Info("✓ Archive created and verified") + return nil +} + +func (o *Orchestrator) finalizeBackupStats(run *backupRunContext) { + stats := run.stats + stats.Duration = stats.EndTime.Sub(stats.StartTime) + + if stats.LogFilePath != "" { + o.logger.Debug("Parsing log file for error/warning counts: %s", stats.LogFilePath) + _, errorCount, warningCount := ParseLogCounts(stats.LogFilePath, 0) + stats.ErrorCount = errorCount + stats.WarningCount = warningCount + if errorCount > 0 || warningCount > 0 { + o.logger.Debug("Found %d errors and %d warnings in log file", errorCount, warningCount) + } + } else { + o.logger.Debug("No log file path specified, error/warning counts will be 0") + } + + switch { + case stats.ErrorCount > 0: + stats.ExitCode = types.ExitBackupError.Int() + case stats.WarningCount > 0: + stats.ExitCode = types.ExitGenericError.Int() + default: + stats.ExitCode = types.ExitSuccess.Int() + } + o.logger.Debug("Aggregated exit code based on log analysis: %d", stats.ExitCode) +} + +func (o *Orchestrator) dispatchBackupArtifacts(run *backupRunContext) error { + if len(o.storageTargets) == 0 { + fmt.Println() + o.logStep(6, "No storage targets registered - skipping") + } else if o.dryRun { + fmt.Println() + o.logStep(6, "Storage dispatch skipped (dry run mode)") + } else { + fmt.Println() + o.logStep(6, "Dispatching archive to %d storage target(s)", len(o.storageTargets)) + o.logGlobalRetentionPolicy() + } + + if o.dryRun { + return nil + } + + o.logger.Debug("Dispatching archive to %d storage targets", len(o.storageTargets)) + return o.dispatchPostBackup(run.ctx, run.stats) +} diff --git a/internal/orchestrator/backup_run_phases_test.go b/internal/orchestrator/backup_run_phases_test.go new file mode 100644 index 00000000..dcfc270c --- /dev/null +++ b/internal/orchestrator/backup_run_phases_test.go @@ -0,0 +1,60 @@ +package orchestrator + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/tis24dev/proxsave/internal/config" + "github.com/tis24dev/proxsave/internal/types" +) + +func TestCreateBackupArchiveClassifiesAgeRecipientFailureAsEncryption(t *testing.T) { + orch := New(newTestLogger(), false) + orch.SetConfig(&config.Config{ + EncryptArchive: true, + BaseDir: t.TempDir(), + }) + orch.SetBackupConfig(t.TempDir(), t.TempDir(), types.CompressionNone, 0, 0, "standard", nil) + + run := orch.newBackupRunContext(context.Background(), nil, "test-host") + _, err := orch.createBackupArchive(run, &backupWorkspace{tempDir: t.TempDir()}) + if err == nil { + t.Fatal("expected createBackupArchive error") + } + + var backupErr *BackupError + if !errors.As(err, &backupErr) { + t.Fatalf("expected BackupError, got %T: %v", err, err) + } + if backupErr.Phase != "encryption" { + t.Fatalf("Phase=%q; want encryption", backupErr.Phase) + } + if backupErr.Code != types.ExitEncryptionError { + t.Fatalf("Code=%v; want %v", backupErr.Code, types.ExitEncryptionError) + } +} + +func TestWriteArchiveChecksumPropagatesWriteError(t *testing.T) { + orch := New(newTestLogger(), false) + checksumPath := "/backups/test.tar.sha256" + writeErr := errors.New("disk full") + fakeFS := NewFakeFS() + t.Cleanup(func() { _ = fakeFS.Cleanup() }) + + err := orch.writeArchiveChecksum( + &backupWorkspace{fs: writeFileFailFS{FS: fakeFS, failPath: checksumPath, err: writeErr}}, + &backupArtifacts{archivePath: "/backups/test.tar", checksumPath: checksumPath}, + "abc123", + ) + if err == nil { + t.Fatal("expected writeArchiveChecksum error") + } + if !errors.Is(err, writeErr) { + t.Fatalf("expected wrapped write error, got %v", err) + } + if !strings.Contains(err.Error(), checksumPath) { + t.Fatalf("expected checksum path in error, got %q", err.Error()) + } +} diff --git a/internal/orchestrator/backup_sources.go b/internal/orchestrator/backup_sources.go index 86b5f2b6..05225a8b 100644 --- a/internal/orchestrator/backup_sources.go +++ b/internal/orchestrator/backup_sources.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "io" - "os/exec" "path" "path/filepath" "sort" @@ -17,6 +16,7 @@ import ( "github.com/tis24dev/proxsave/internal/backup" "github.com/tis24dev/proxsave/internal/config" "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/safeexec" ) // decryptPathOption describes a logical backup source (local, secondary, cloud) @@ -117,7 +117,10 @@ func discoverRcloneBackups(ctx context.Context, cfg *config.Config, remotePath s // Use rclone lsf to list files inside the backup directory lsfCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - cmd := exec.CommandContext(lsfCtx, "rclone", "lsf", fullPath) + cmd, err := safeexec.CommandContext(lsfCtx, "rclone", "lsf", fullPath) + if err != nil { + return nil, err + } lsfStart := time.Now() output, err := cmd.CombinedOutput() if err != nil { @@ -545,7 +548,10 @@ func inspectRcloneChecksumFile(ctx context.Context, remotePath string, logger *l defer func() { done(err) }() logging.DebugStep(logger, "inspect rclone checksum", "executing: rclone cat %s", remotePath) - cmd := exec.CommandContext(ctx, "rclone", "cat", remotePath) + cmd, err := safeexec.CommandContext(ctx, "rclone", "cat", remotePath) + if err != nil { + return "", err + } stdout, err := cmd.StdoutPipe() if err != nil { return "", fmt.Errorf("start rclone cat %s: %w", remotePath, err) diff --git a/internal/orchestrator/decompress_reader_test.go b/internal/orchestrator/decompress_reader_test.go index 542c7bc8..ddf86fef 100644 --- a/internal/orchestrator/decompress_reader_test.go +++ b/internal/orchestrator/decompress_reader_test.go @@ -1,9 +1,12 @@ package orchestrator import ( + "bytes" "context" + "errors" "io" "os" + "path/filepath" "strings" "testing" ) @@ -36,6 +39,7 @@ func TestCreateDecompressionReaderTar(t *testing.T) { if reader == nil { t.Fatalf("reader should not be nil for tar") } + _ = reader.Close() } type fakeStreamCommandRunner struct { @@ -59,6 +63,28 @@ func (f *fakeStreamCommandRunner) RunStream(ctx context.Context, name string, st return io.NopCloser(strings.NewReader("")), nil } +type extractionCloseErrorReadCloser struct { + *bytes.Reader + err error +} + +func (r *extractionCloseErrorReadCloser) Close() error { + return r.err +} + +type closeErrorStreamCommandRunner struct { + data []byte + closeErr error +} + +func (f *closeErrorStreamCommandRunner) Run(context.Context, string, ...string) ([]byte, error) { + return nil, nil +} + +func (f *closeErrorStreamCommandRunner) RunStream(context.Context, string, io.Reader, ...string) (io.ReadCloser, error) { + return &extractionCloseErrorReadCloser{Reader: bytes.NewReader(f.data), err: f.closeErr}, nil +} + func TestCreateDecompressionReaderUsesStreamingRunnerForCompressedFormats(t *testing.T) { orig := restoreCmd t.Cleanup(func() { restoreCmd = orig }) @@ -98,14 +124,9 @@ func TestCreateDecompressionReaderUsesStreamingRunnerForCompressedFormats(t *tes if err != nil { t.Fatalf("createDecompressionReader(%s) error: %v", tt.ext, err) } + defer reader.Close() - rc, ok := reader.(io.ReadCloser) - if !ok { - t.Fatalf("expected io.ReadCloser, got %T", reader) - } - defer rc.Close() - - out, err := io.ReadAll(rc) + out, err := io.ReadAll(reader) if err != nil { t.Fatalf("ReadAll: %v", err) } @@ -118,3 +139,40 @@ func TestCreateDecompressionReaderUsesStreamingRunnerForCompressedFormats(t *tes }) } } + +func TestExtractArchiveNativeReturnsDecompressionCloseError(t *testing.T) { + origCmd := restoreCmd + origFS := restoreFS + t.Cleanup(func() { + restoreCmd = origCmd + restoreFS = origFS + }) + + dir := t.TempDir() + tarPath := filepath.Join(dir, "source.tar") + if err := writeTarFile(tarPath, map[string]string{"etc/example.conf": "ok\n"}); err != nil { + t.Fatalf("writeTarFile: %v", err) + } + tarData, err := os.ReadFile(tarPath) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + + closeErr := errors.New("decompressor exited 2") + restoreCmd = &closeErrorStreamCommandRunner{data: tarData, closeErr: closeErr} + restoreFS = osFS{} + + archivePath := filepath.Join(dir, "archive.tar.zst") + if err := os.WriteFile(archivePath, []byte("compressed"), 0o640); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + err = extractArchiveNative(context.Background(), restoreArchiveOptions{ + archivePath: archivePath, + destRoot: filepath.Join(dir, "dest"), + logger: newTestLogger(), + }) + if !errors.Is(err, closeErr) { + t.Fatalf("extractArchiveNative error = %v, want close error %v", err, closeErr) + } +} diff --git a/internal/orchestrator/decrypt.go b/internal/orchestrator/decrypt.go index 9ce590d5..f55cde81 100644 --- a/internal/orchestrator/decrypt.go +++ b/internal/orchestrator/decrypt.go @@ -10,7 +10,6 @@ import ( "fmt" "io" "os" - "os/exec" "path" "path/filepath" "strconv" @@ -22,6 +21,7 @@ import ( "github.com/tis24dev/proxsave/internal/config" "github.com/tis24dev/proxsave/internal/input" "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/safeexec" "golang.org/x/text/cases" "golang.org/x/text/language" ) @@ -186,7 +186,10 @@ func inspectRcloneBundleManifest(ctx context.Context, remotePath string, logger defer cancel() logging.DebugStep(logger, "inspect rclone bundle manifest", "executing: rclone cat %s", remotePath) - cmd := exec.CommandContext(cmdCtx, "rclone", "cat", remotePath) + cmd, err := safeexec.CommandContext(cmdCtx, "rclone", "cat", remotePath) + if err != nil { + return nil, fmt.Errorf("prepare rclone cat: %w", err) + } stdout, err := cmd.StdoutPipe() if err != nil { return nil, fmt.Errorf("open rclone stream: %w", err) @@ -270,7 +273,10 @@ func inspectRcloneMetadataManifest(ctx context.Context, remoteMetadataPath, remo defer func() { done(err) }() logging.DebugStep(logger, "inspect rclone metadata manifest", "executing: rclone cat %s", remoteMetadataPath) - cmd := exec.CommandContext(ctx, "rclone", "cat", remoteMetadataPath) + cmd, err := safeexec.CommandContext(ctx, "rclone", "cat", remoteMetadataPath) + if err != nil { + return nil, fmt.Errorf("prepare rclone metadata cat: %w", err) + } output, err := cmd.CombinedOutput() if err != nil { return nil, fmt.Errorf("rclone cat %s failed: %w (output: %s)", remoteMetadataPath, err, strings.TrimSpace(string(output))) @@ -418,7 +424,10 @@ func downloadRcloneBackup(ctx context.Context, remotePath string, logger *loggin logging.DebugStep(logger, "download rclone backup", "local temp file=%s", tmpPath) // Use rclone copyto to download with progress - cmd := exec.CommandContext(ctx, "rclone", "copyto", remotePath, tmpPath, "--progress") + cmd, err := safeexec.CommandContext(ctx, "rclone", "copyto", remotePath, tmpPath, "--progress") + if err != nil { + return "", nil, err + } cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -573,7 +582,10 @@ func rcloneCopyTo(ctx context.Context, remotePath, localPath string, showProgres if showProgress { args = append(args, "--progress") } - cmd := exec.CommandContext(ctx, "rclone", args...) + cmd, err := safeexec.CommandContext(ctx, "rclone", args...) + if err != nil { + return err + } cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() diff --git a/internal/orchestrator/decrypt_test.go b/internal/orchestrator/decrypt_test.go index 4932b1bf..a952e3b0 100644 --- a/internal/orchestrator/decrypt_test.go +++ b/internal/orchestrator/decrypt_test.go @@ -190,7 +190,6 @@ func TestBuildDecryptPathOptions(t *testing.T) { } func TestBaseNameFromRemoteRef(t *testing.T) { - t.Parallel() tests := []struct { in string want string @@ -464,7 +463,6 @@ func TestParseIdentityInput(t *testing.T) { } func TestSanitizeBundleEntryName(t *testing.T) { - t.Parallel() tests := []struct { name string input string @@ -489,7 +487,6 @@ func TestSanitizeBundleEntryName(t *testing.T) { for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { - t.Parallel() got, err := sanitizeBundleEntryName(tt.input) if tt.expectErr { if err == nil { @@ -567,7 +564,12 @@ func createTestBundle(t *testing.T, entries []bundleEntry) string { t.Helper() dir := t.TempDir() bundlePath := filepath.Join(dir, "bundle.tar") + createTestBundleAt(t, bundlePath, entries) + return bundlePath +} +func createTestBundleAt(t *testing.T, bundlePath string, entries []bundleEntry) { + t.Helper() f, err := os.Create(bundlePath) if err != nil { t.Fatalf("create bundle: %v", err) @@ -591,7 +593,88 @@ func createTestBundle(t *testing.T, entries []bundleEntry) string { if err := tw.Close(); err != nil { t.Fatalf("close tar writer: %v", err) } - return bundlePath +} + +func createPlainBackupBundle(t *testing.T, bundlePath string, archiveData []byte, manifest backup.Manifest, includeChecksum bool) { + t.Helper() + metaJSON, err := json.Marshal(manifest) + if err != nil { + t.Fatalf("marshal manifest: %v", err) + } + + entries := []bundleEntry{ + {name: "backup.tar.xz", data: archiveData}, + {name: "backup.metadata", data: metaJSON}, + } + if includeChecksum { + entries = append(entries, bundleEntry{ + name: "backup.sha256", + data: []byte(checksumLineForBytes("backup.tar.xz", archiveData)), + }) + } + createTestBundleAt(t, bundlePath, entries) +} + +func useRestoreFS(t *testing.T, fs FS) { + t.Helper() + orig := restoreFS + restoreFS = fs + t.Cleanup(func() { restoreFS = orig }) +} + +type rawArtifactFixture struct { + candidate *backupCandidate + workDir string +} + +func createRawArtifactFixture(t *testing.T, includeMetadata bool, checksumContent string) rawArtifactFixture { + t.Helper() + srcDir := t.TempDir() + + archivePath := filepath.Join(srcDir, "backup.tar.xz") + if err := os.WriteFile(archivePath, []byte("archive data"), 0o644); err != nil { + t.Fatalf("write archive: %v", err) + } + + metadataPath := "/nonexistent/backup.metadata" + if includeMetadata { + metadataPath = filepath.Join(srcDir, "backup.metadata") + if err := os.WriteFile(metadataPath, []byte("{}"), 0o644); err != nil { + t.Fatalf("write metadata: %v", err) + } + } + + checksumPath := "" + if checksumContent != "" { + checksumPath = filepath.Join(srcDir, "backup.sha256") + if err := os.WriteFile(checksumPath, []byte(checksumContent), 0o644); err != nil { + t.Fatalf("write checksum: %v", err) + } + } + + return rawArtifactFixture{ + candidate: &backupCandidate{ + RawArchivePath: archivePath, + RawMetadataPath: metadataPath, + RawChecksumPath: checksumPath, + }, + workDir: t.TempDir(), + } +} + +func plainBundleCandidate(path string, manifest *backup.Manifest) *backupCandidate { + return &backupCandidate{ + Source: sourceBundle, + BundlePath: path, + Manifest: manifest, + } +} + +func preparePlainBundleTestInput(path string, manifest *backup.Manifest) (*backupCandidate, context.Context, *bufio.Reader, *logging.Logger) { + return plainBundleCandidate(path, manifest), + context.Background(), + bufio.NewReader(strings.NewReader("")), + logging.New(types.LogLevelError, false) } func TestEnsureWritablePath(t *testing.T) { @@ -1916,34 +1999,10 @@ func TestMoveFileSafe_SameDevice(t *testing.T) { // ===================================== func TestCopyRawArtifactsToWorkdir_Success(t *testing.T) { - origFS := restoreFS - restoreFS = osFS{} - t.Cleanup(func() { restoreFS = origFS }) + useRestoreFS(t, osFS{}) - srcDir := t.TempDir() - workDir := t.TempDir() - - // Create source files - archivePath := filepath.Join(srcDir, "backup.tar.xz") - if err := os.WriteFile(archivePath, []byte("archive data"), 0o644); err != nil { - t.Fatalf("write archive: %v", err) - } - metadataPath := filepath.Join(srcDir, "backup.metadata") - if err := os.WriteFile(metadataPath, []byte("{}"), 0o644); err != nil { - t.Fatalf("write metadata: %v", err) - } - checksumPath := filepath.Join(srcDir, "backup.sha256") - if err := os.WriteFile(checksumPath, []byte("checksum"), 0o644); err != nil { - t.Fatalf("write checksum: %v", err) - } - - cand := &backupCandidate{ - RawArchivePath: archivePath, - RawMetadataPath: metadataPath, - RawChecksumPath: checksumPath, - } - - staged, err := copyRawArtifactsToWorkdir(context.Background(), cand, workDir) + fixture := createRawArtifactFixture(t, true, "checksum") + staged, err := copyRawArtifactsToWorkdir(context.Background(), fixture.candidate, fixture.workDir) if err != nil { t.Fatalf("copyRawArtifactsToWorkdir error: %v", err) } @@ -1953,9 +2012,7 @@ func TestCopyRawArtifactsToWorkdir_Success(t *testing.T) { } func TestCopyRawArtifactsToWorkdir_ArchiveError(t *testing.T) { - origFS := restoreFS - restoreFS = osFS{} - t.Cleanup(func() { restoreFS = origFS }) + useRestoreFS(t, osFS{}) cand := &backupCandidate{ RawArchivePath: "/nonexistent/archive.tar.xz", @@ -1973,26 +2030,11 @@ func TestCopyRawArtifactsToWorkdir_ArchiveError(t *testing.T) { } func TestCopyRawArtifactsToWorkdir_MetadataError(t *testing.T) { - origFS := restoreFS - restoreFS = osFS{} - t.Cleanup(func() { restoreFS = origFS }) - - srcDir := t.TempDir() - workDir := t.TempDir() - - // Create only archive, no metadata - archivePath := filepath.Join(srcDir, "backup.tar.xz") - if err := os.WriteFile(archivePath, []byte("archive data"), 0o644); err != nil { - t.Fatalf("write archive: %v", err) - } + useRestoreFS(t, osFS{}) - cand := &backupCandidate{ - RawArchivePath: archivePath, - RawMetadataPath: "/nonexistent/backup.metadata", - RawChecksumPath: "/nonexistent/backup.sha256", - } - - _, err := copyRawArtifactsToWorkdir(context.Background(), cand, workDir) + fixture := createRawArtifactFixture(t, false, "") + fixture.candidate.RawChecksumPath = "/nonexistent/backup.sha256" + _, err := copyRawArtifactsToWorkdir(context.Background(), fixture.candidate, fixture.workDir) if err == nil { t.Fatal("expected error for nonexistent metadata") } @@ -2002,30 +2044,11 @@ func TestCopyRawArtifactsToWorkdir_MetadataError(t *testing.T) { } func TestCopyRawArtifactsToWorkdir_ChecksumError(t *testing.T) { - origFS := restoreFS - restoreFS = osFS{} - t.Cleanup(func() { restoreFS = origFS }) - - srcDir := t.TempDir() - workDir := t.TempDir() - - // Create archive and metadata, no checksum - archivePath := filepath.Join(srcDir, "backup.tar.xz") - if err := os.WriteFile(archivePath, []byte("archive data"), 0o644); err != nil { - t.Fatalf("write archive: %v", err) - } - metadataPath := filepath.Join(srcDir, "backup.metadata") - if err := os.WriteFile(metadataPath, []byte("{}"), 0o644); err != nil { - t.Fatalf("write metadata: %v", err) - } - - cand := &backupCandidate{ - RawArchivePath: archivePath, - RawMetadataPath: metadataPath, - RawChecksumPath: "/nonexistent/backup.sha256", - } + useRestoreFS(t, osFS{}) - staged, err := copyRawArtifactsToWorkdir(context.Background(), cand, workDir) + fixture := createRawArtifactFixture(t, true, "") + fixture.candidate.RawChecksumPath = "/nonexistent/backup.sha256" + staged, err := copyRawArtifactsToWorkdir(context.Background(), fixture.candidate, fixture.workDir) if err != nil { t.Fatalf("expected checksum to be optional, got error: %v", err) } @@ -2535,30 +2558,10 @@ func TestInspectRcloneMetadataManifest_RcloneFails(t *testing.T) { // ===================================== func TestCopyRawArtifactsToWorkdir_ContextWorks(t *testing.T) { - origFS := restoreFS - restoreFS = osFS{} - t.Cleanup(func() { restoreFS = origFS }) - - srcDir := t.TempDir() - workDir := t.TempDir() - - // Create source files - archivePath := filepath.Join(srcDir, "backup.tar.xz") - if err := os.WriteFile(archivePath, []byte("archive data"), 0o644); err != nil { - t.Fatalf("write archive: %v", err) - } - metadataPath := filepath.Join(srcDir, "backup.metadata") - if err := os.WriteFile(metadataPath, []byte("{}"), 0o644); err != nil { - t.Fatalf("write metadata: %v", err) - } + useRestoreFS(t, osFS{}) - cand := &backupCandidate{ - RawArchivePath: archivePath, - RawMetadataPath: metadataPath, - RawChecksumPath: "", - } - - staged, err := copyRawArtifactsToWorkdirWithLogger(context.TODO(), cand, workDir, nil) + fixture := createRawArtifactFixture(t, true, "") + staged, err := copyRawArtifactsToWorkdirWithLogger(context.TODO(), fixture.candidate, fixture.workDir, nil) if err != nil { t.Fatalf("copyRawArtifactsToWorkdirWithLogger error: %v", err) } @@ -3305,34 +3308,10 @@ func TestInspectRcloneBundleManifest_StartError(t *testing.T) { } func TestCopyRawArtifactsToWorkdir_WithChecksum(t *testing.T) { - origFS := restoreFS - restoreFS = osFS{} - t.Cleanup(func() { restoreFS = origFS }) - - srcDir := t.TempDir() - workDir := t.TempDir() - - // Create source files including checksum - archivePath := filepath.Join(srcDir, "backup.tar.xz") - if err := os.WriteFile(archivePath, []byte("archive data"), 0o644); err != nil { - t.Fatalf("write archive: %v", err) - } - metadataPath := filepath.Join(srcDir, "backup.metadata") - if err := os.WriteFile(metadataPath, []byte("{}"), 0o644); err != nil { - t.Fatalf("write metadata: %v", err) - } - checksumPath := filepath.Join(srcDir, "backup.sha256") - if err := os.WriteFile(checksumPath, []byte("checksum backup.tar.xz"), 0o644); err != nil { - t.Fatalf("write checksum: %v", err) - } + useRestoreFS(t, osFS{}) - cand := &backupCandidate{ - RawArchivePath: archivePath, - RawMetadataPath: metadataPath, - RawChecksumPath: checksumPath, - } - - staged, err := copyRawArtifactsToWorkdirWithLogger(context.Background(), cand, workDir, nil) + fixture := createRawArtifactFixture(t, true, "checksum backup.tar.xz") + staged, err := copyRawArtifactsToWorkdirWithLogger(context.Background(), fixture.candidate, fixture.workDir, nil) if err != nil { t.Fatalf("copyRawArtifactsToWorkdirWithLogger error: %v", err) } @@ -3713,26 +3692,9 @@ func TestSelectDecryptCandidate_RequireEncryptedAllPlain(t *testing.T) { // Create a plain backup bundle (must have .bundle.tar suffix) bundlePath := filepath.Join(backupDir, "backup-2024-01-01.bundle.tar") - bundleFile, _ := os.Create(bundlePath) - tw := tar.NewWriter(bundleFile) - - // Add archive (plain, no .age extension) archiveData := []byte("archive content") - tw.WriteHeader(&tar.Header{Name: "backup.tar.xz", Size: int64(len(archiveData)), Mode: 0o640}) - tw.Write(archiveData) - - // Add metadata with encryption=none manifest := backup.Manifest{EncryptionMode: "none", Hostname: "test", CreatedAt: time.Now(), ArchivePath: "backup.tar.xz"} - metaJSON, _ := json.Marshal(manifest) - tw.WriteHeader(&tar.Header{Name: "backup.metadata", Size: int64(len(metaJSON)), Mode: 0o640}) - tw.Write(metaJSON) - - // Add checksum - checksum := checksumLineForBytes("backup.tar.xz", archiveData) - tw.WriteHeader(&tar.Header{Name: "backup.sha256", Size: int64(len(checksum)), Mode: 0o640}) - tw.Write(checksum) - tw.Close() - bundleFile.Close() + createPlainBackupBundle(t, bundlePath, archiveData, manifest, true) cfg := &config.Config{ BackupPath: backupDir, @@ -3824,23 +3786,9 @@ exit 1 // Bundle must have .bundle.tar suffix to be discovered bundlePath := filepath.Join(backupDir, "backup.bundle.tar") - bundleFile, _ := os.Create(bundlePath) - tw := tar.NewWriter(bundleFile) - archiveData := []byte("archive content") - tw.WriteHeader(&tar.Header{Name: "backup.tar.xz", Size: int64(len(archiveData)), Mode: 0o640}) - tw.Write(archiveData) - manifest := backup.Manifest{EncryptionMode: "age", Hostname: "test", CreatedAt: time.Now(), ArchivePath: "backup.tar.xz"} - metaJSON, _ := json.Marshal(manifest) - tw.WriteHeader(&tar.Header{Name: "backup.metadata", Size: int64(len(metaJSON)), Mode: 0o640}) - tw.Write(metaJSON) - - checksum := checksumLineForBytes("backup.tar.xz", archiveData) - tw.WriteHeader(&tar.Header{Name: "backup.sha256", Size: int64(len(checksum)), Mode: 0o640}) - tw.Write(checksum) - tw.Close() - bundleFile.Close() + createPlainBackupBundle(t, bundlePath, archiveData, manifest, true) cfg := &config.Config{ BackupPath: backupDir, @@ -3875,23 +3823,9 @@ func TestPreparePlainBundle_StatErrorAfterExtract(t *testing.T) { // Create a valid bundle bundlePath := filepath.Join(tmp, "bundle.tar") - bundleFile, _ := os.Create(bundlePath) - tw := tar.NewWriter(bundleFile) - archiveData := []byte("archive content") - tw.WriteHeader(&tar.Header{Name: "backup.tar.xz", Size: int64(len(archiveData)), Mode: 0o640}) - tw.Write(archiveData) - manifest := backup.Manifest{EncryptionMode: "none", Hostname: "test", CreatedAt: time.Now()} - metaJSON, _ := json.Marshal(manifest) - tw.WriteHeader(&tar.Header{Name: "backup.metadata", Size: int64(len(metaJSON)), Mode: 0o640}) - tw.Write(metaJSON) - - checksum := checksumLineForBytes("backup.tar.xz", archiveData) - tw.WriteHeader(&tar.Header{Name: "backup.sha256", Size: int64(len(checksum)), Mode: 0o640}) - tw.Write(checksum) - tw.Close() - bundleFile.Close() + createPlainBackupBundle(t, bundlePath, archiveData, manifest, true) // Create FakeFS that will fail on stat for the extracted archive fake := NewFakeFS() @@ -3908,18 +3842,11 @@ func TestPreparePlainBundle_StatErrorAfterExtract(t *testing.T) { fake.StatErr["/tmp/proxsave"] = nil // Allow this stat // After extraction, stat will be called on the plain archive - we set error later - orig := restoreFS - restoreFS = fake - defer func() { restoreFS = orig }() - - cand := &backupCandidate{ - Source: sourceBundle, - BundlePath: bundlePath, - Manifest: &backup.Manifest{EncryptionMode: "none", Hostname: "test"}, - } - ctx := context.Background() - reader := bufio.NewReader(strings.NewReader("")) - logger := logging.New(types.LogLevelError, false) + useRestoreFS(t, fake) + cand, ctx, reader, logger := preparePlainBundleTestInput( + bundlePath, + &backup.Manifest{EncryptionMode: "none", Hostname: "test"}, + ) // The test shows that with proper setup, stat error would be triggered // For now, run with FakeFS to cover the MkdirAll/MkdirTemp paths @@ -3975,19 +3902,9 @@ func TestPreparePlainBundle_MkdirTempErrorWithRcloneCleanup(t *testing.T) { // Create a fake downloaded bundle file bundlePath := filepath.Join(tmp, "downloaded.bundle.tar") - bundleFile, _ := os.Create(bundlePath) - tw := tar.NewWriter(bundleFile) archiveData := []byte("data") - tw.WriteHeader(&tar.Header{Name: "backup.tar.xz", Size: int64(len(archiveData)), Mode: 0o640}) - tw.Write(archiveData) - metaJSON, _ := json.Marshal(backup.Manifest{EncryptionMode: "none", ArchivePath: "backup.tar.xz"}) - tw.WriteHeader(&tar.Header{Name: "backup.metadata", Size: int64(len(metaJSON)), Mode: 0o640}) - tw.Write(metaJSON) - checksum := checksumLineForBytes("backup.tar.xz", archiveData) - tw.WriteHeader(&tar.Header{Name: "backup.sha256", Size: int64(len(checksum)), Mode: 0o640}) - tw.Write(checksum) - tw.Close() - bundleFile.Close() + manifest := backup.Manifest{EncryptionMode: "none", ArchivePath: "backup.tar.xz"} + createPlainBackupBundle(t, bundlePath, archiveData, manifest, true) // Track if cleanup was called cleanupCalled := false @@ -4161,23 +4078,9 @@ func TestPreparePlainBundle_CopyFileError(t *testing.T) { // Create a valid bundle bundlePath := filepath.Join(tmp, "bundle.tar") - bundleFile, _ := os.Create(bundlePath) - tw := tar.NewWriter(bundleFile) - archiveData := []byte("archive content") - tw.WriteHeader(&tar.Header{Name: "backup.tar.xz", Size: int64(len(archiveData)), Mode: 0o640}) - tw.Write(archiveData) - manifest := backup.Manifest{EncryptionMode: "none", Hostname: "test", CreatedAt: time.Now(), ArchivePath: "backup.tar.xz"} - metaJSON, _ := json.Marshal(manifest) - tw.WriteHeader(&tar.Header{Name: "backup.metadata", Size: int64(len(metaJSON)), Mode: 0o640}) - tw.Write(metaJSON) - - checksum := checksumLineForBytes("backup.tar.xz", archiveData) - tw.WriteHeader(&tar.Header{Name: "backup.sha256", Size: int64(len(checksum)), Mode: 0o640}) - tw.Write(checksum) - tw.Close() - bundleFile.Close() + createPlainBackupBundle(t, bundlePath, archiveData, manifest, true) // Use FakeFS fake := NewFakeFS() @@ -4190,18 +4093,11 @@ func TestPreparePlainBundle_CopyFileError(t *testing.T) { // After extraction, set OpenFile error for the archive copy destination // The copyFile function will try to create the destination file - orig := restoreFS - restoreFS = fake - defer func() { restoreFS = orig }() - - cand := &backupCandidate{ - Source: sourceBundle, - BundlePath: bundlePath, - Manifest: &backup.Manifest{EncryptionMode: "none", Hostname: "test"}, - } - ctx := context.Background() - reader := bufio.NewReader(strings.NewReader("")) - logger := logging.New(types.LogLevelError, false) + useRestoreFS(t, fake) + cand, ctx, reader, logger := preparePlainBundleTestInput( + bundlePath, + &backup.Manifest{EncryptionMode: "none", Hostname: "test"}, + ) bundle, err := preparePlainBundle(ctx, reader, cand, "1.0.0", logger) // This test verifies that the path goes through successfully for plain archives @@ -4270,39 +4166,18 @@ func TestPreparePlainBundle_StatErrorOnPlainArchive(t *testing.T) { // Create a valid bundle with plain (non-encrypted) archive bundlePath := filepath.Join(tmp, "bundle.tar") - bundleFile, _ := os.Create(bundlePath) - tw := tar.NewWriter(bundleFile) - archiveData := []byte("archive content for stat test") - tw.WriteHeader(&tar.Header{Name: "backup.tar.xz", Size: int64(len(archiveData)), Mode: 0o640}) - tw.Write(archiveData) - manifest := backup.Manifest{EncryptionMode: "none", Hostname: "test", CreatedAt: time.Now(), ArchivePath: "backup.tar.xz"} - metaJSON, _ := json.Marshal(manifest) - tw.WriteHeader(&tar.Header{Name: "backup.metadata", Size: int64(len(metaJSON)), Mode: 0o640}) - tw.Write(metaJSON) - - checksum := checksumLineForBytes("backup.tar.xz", archiveData) - tw.WriteHeader(&tar.Header{Name: "backup.sha256", Size: int64(len(checksum)), Mode: 0o640}) - tw.Write(checksum) - tw.Close() - bundleFile.Close() + createPlainBackupBundle(t, bundlePath, archiveData, manifest, true) // Use wrapped osFS that fails stat on plain archive after several calls fake := &fakeStatFailOnPlainArchive{} - orig := restoreFS - restoreFS = fake - defer func() { restoreFS = orig }() - - cand := &backupCandidate{ - Source: sourceBundle, - BundlePath: bundlePath, - Manifest: &backup.Manifest{EncryptionMode: "none", Hostname: "test"}, - } - ctx := context.Background() - reader := bufio.NewReader(strings.NewReader("")) - logger := logging.New(types.LogLevelError, false) + useRestoreFS(t, fake) + cand, ctx, reader, logger := preparePlainBundleTestInput( + bundlePath, + &backup.Manifest{EncryptionMode: "none", Hostname: "test"}, + ) bundle, err := preparePlainBundle(ctx, reader, cand, "1.0.0", logger) if err == nil { @@ -4328,17 +4203,9 @@ func TestPreparePlainBundle_MkdirAllErrorWithRcloneDownloadCleanup(t *testing.T) // Create a valid bundle that rclone will "download" bundlePath := filepath.Join(downloadDir, "backup.bundle.tar") - bundleFile, _ := os.Create(bundlePath) - tw := tar.NewWriter(bundleFile) archiveData := []byte("archive content") - tw.WriteHeader(&tar.Header{Name: "backup.tar.xz", Size: int64(len(archiveData)), Mode: 0o640}) - tw.Write(archiveData) manifest := backup.Manifest{EncryptionMode: "none", Hostname: "test", CreatedAt: time.Now()} - metaJSON, _ := json.Marshal(manifest) - tw.WriteHeader(&tar.Header{Name: "backup.metadata", Size: int64(len(metaJSON)), Mode: 0o640}) - tw.Write(metaJSON) - tw.Close() - bundleFile.Close() + createPlainBackupBundle(t, bundlePath, archiveData, manifest, false) // Script that copies the pre-made bundle to the destination script := fmt.Sprintf(`#!/bin/bash @@ -4404,39 +4271,18 @@ func TestPreparePlainBundle_GenerateChecksumErrorPath(t *testing.T) { // Create a valid bundle bundlePath := filepath.Join(tmp, "bundle.tar") - bundleFile, _ := os.Create(bundlePath) - tw := tar.NewWriter(bundleFile) - archiveData := []byte("archive content for checksum error test") - tw.WriteHeader(&tar.Header{Name: "backup.tar.xz", Size: int64(len(archiveData)), Mode: 0o640}) - tw.Write(archiveData) - manifest := backup.Manifest{EncryptionMode: "none", Hostname: "test", CreatedAt: time.Now(), ArchivePath: "backup.tar.xz"} - metaJSON, _ := json.Marshal(manifest) - tw.WriteHeader(&tar.Header{Name: "backup.metadata", Size: int64(len(metaJSON)), Mode: 0o640}) - tw.Write(metaJSON) - - checksum := checksumLineForBytes("backup.tar.xz", archiveData) - tw.WriteHeader(&tar.Header{Name: "backup.sha256", Size: int64(len(checksum)), Mode: 0o640}) - tw.Write(checksum) - tw.Close() - bundleFile.Close() + createPlainBackupBundle(t, bundlePath, archiveData, manifest, true) // Use FS that removes file after stat fake := &fakeStatThenRemoveFS{} - orig := restoreFS - restoreFS = fake - defer func() { restoreFS = orig }() - - cand := &backupCandidate{ - Source: sourceBundle, - BundlePath: bundlePath, - Manifest: &backup.Manifest{EncryptionMode: "none", Hostname: "test"}, - } - ctx := context.Background() - reader := bufio.NewReader(strings.NewReader("")) - logger := logging.New(types.LogLevelError, false) + useRestoreFS(t, fake) + cand, ctx, reader, logger := preparePlainBundleTestInput( + bundlePath, + &backup.Manifest{EncryptionMode: "none", Hostname: "test"}, + ) bundle, err := preparePlainBundle(ctx, reader, cand, "1.0.0", logger) if err == nil { @@ -4476,17 +4322,9 @@ func TestPreparePlainBundle_MkdirAllErrorAfterRcloneDownload(t *testing.T) { // Create the bundle that will be "downloaded" sourceBundlePath := filepath.Join(bundleDir, "backup.bundle.tar") - bundleFile, _ := os.Create(sourceBundlePath) - tw := tar.NewWriter(bundleFile) archiveData := []byte("archive") - tw.WriteHeader(&tar.Header{Name: "backup.tar.xz", Size: int64(len(archiveData)), Mode: 0o640}) - tw.Write(archiveData) manifest := backup.Manifest{EncryptionMode: "none", Hostname: "test", CreatedAt: time.Now()} - metaJSON, _ := json.Marshal(manifest) - tw.WriteHeader(&tar.Header{Name: "backup.metadata", Size: int64(len(metaJSON)), Mode: 0o640}) - tw.Write(metaJSON) - tw.Close() - bundleFile.Close() + createPlainBackupBundle(t, sourceBundlePath, archiveData, manifest, false) // Script that copies the bundle to destination script := fmt.Sprintf(`#!/bin/bash diff --git a/internal/orchestrator/decrypt_tui_e2e_helpers_test.go b/internal/orchestrator/decrypt_tui_e2e_helpers_test.go index 84cf85de..9dd6dc32 100644 --- a/internal/orchestrator/decrypt_tui_e2e_helpers_test.go +++ b/internal/orchestrator/decrypt_tui_e2e_helpers_test.go @@ -27,31 +27,130 @@ import ( var decryptTUIE2EMu sync.Mutex +const ( + timedSimScreenWaitTimeout = 10 * time.Second + timedSimCompletionTimeout = 15 * time.Second + timedSimDefaultSettle = 15 * time.Millisecond + timedSimKeyDelay = 15 * time.Millisecond +) + type notifyingSimulationScreen struct { tcell.SimulationScreen - notify func() + mu sync.Mutex + snapshot timedSimScreenSnapshot + notify func() +} + +type timedSimScreenSnapshot struct { + cells []tcell.SimCell + width int + height int + cursorX int + cursorY int + cursorVisible bool + ready bool } func (s *notifyingSimulationScreen) Show() { + s.mu.Lock() s.SimulationScreen.Show() - if s.notify != nil { - s.notify() - } + s.captureLocked() + s.mu.Unlock() + s.notifyChange() } func (s *notifyingSimulationScreen) Sync() { + s.mu.Lock() s.SimulationScreen.Sync() + s.captureLocked() + s.mu.Unlock() + s.notifyChange() +} + +func (s *notifyingSimulationScreen) snapshotState() timedSimScreenSnapshot { + s.mu.Lock() + defer s.mu.Unlock() + return cloneTimedSimScreenSnapshot(s.snapshot) +} + +func (s *notifyingSimulationScreen) captureLocked() { + cells, width, height := s.SimulationScreen.GetContents() + cursorX, cursorY, cursorVisible := s.SimulationScreen.GetCursor() + s.snapshot = timedSimScreenSnapshot{ + cells: cloneSimCells(cells), + width: width, + height: height, + cursorX: cursorX, + cursorY: cursorY, + cursorVisible: cursorVisible, + ready: true, + } +} + +func (s *notifyingSimulationScreen) notifyChange() { if s.notify != nil { s.notify() } } +func cloneTimedSimScreenSnapshot(snapshot timedSimScreenSnapshot) timedSimScreenSnapshot { + snapshot.cells = cloneSimCells(snapshot.cells) + return snapshot +} + +func cloneSimCells(cells []tcell.SimCell) []tcell.SimCell { + if len(cells) == 0 { + return nil + } + cloned := make([]tcell.SimCell, len(cells)) + for i, cell := range cells { + cloned[i] = cell + if cell.Bytes != nil { + cloned[i].Bytes = append([]byte(nil), cell.Bytes...) + } + if cell.Runes != nil { + cloned[i].Runes = append([]rune(nil), cell.Runes...) + } + } + return cloned +} + type timedSimKey struct { - Key tcell.Key - R rune - Mod tcell.ModMask - Wait time.Duration - WaitForText string + Key tcell.Key + R rune + Mod tcell.ModMask + WaitForText string + Wait time.Duration + RequireNewApp bool + SettleAfterMatch time.Duration +} + +type timedSimHarness struct { + t *testing.T + done chan struct{} + closeDoneOnce sync.Once + injectWG sync.WaitGroup + screenStateCh chan struct{} + runCompleted chan struct{} + closeRunOnce sync.Once + + appMu sync.RWMutex + apps []*tui.App + current *timedSimAppState +} + +type timedSimAppState struct { + generation int + app *tui.App + screen *notifyingSimulationScreen +} + +type timedSimScreenState struct { + generation int + text string + focusType string + ready bool + screen *notifyingSimulationScreen } type decryptTUIFixture struct { @@ -68,143 +167,240 @@ type decryptTUIFixture struct { ExpectedChecksum string } -func withTimedSimAppSequence(t *testing.T, keys []timedSimKey) { +func withTimedSimAppSequence(t *testing.T, keys []timedSimKey) *timedSimHarness { t.Helper() decryptTUIE2EMu.Lock() orig := newTUIApp - done := make(chan struct{}) - var injectWG sync.WaitGroup + h := &timedSimHarness{ + t: t, + done: make(chan struct{}), + screenStateCh: make(chan struct{}, 1), + runCompleted: make(chan struct{}), + } + t.Cleanup(func() { - close(done) - injectWG.Wait() + h.stop() newTUIApp = orig decryptTUIE2EMu.Unlock() }) - baseScreen := tcell.NewSimulationScreen("UTF-8") - if err := baseScreen.Init(); err != nil { - t.Fatalf("screen.Init: %v", err) + newTUIApp = func() *tui.App { + app := tui.NewApp() + + baseScreen := tcell.NewSimulationScreen("UTF-8") + if err := baseScreen.Init(); err != nil { + t.Fatalf("screen.Init: %v", err) + } + baseScreen.SetSize(120, 40) + + screen := ¬ifyingSimulationScreen{ + SimulationScreen: baseScreen, + notify: h.notifyScreenStateChanged, + } + + h.appMu.Lock() + state := &timedSimAppState{ + generation: len(h.apps) + 1, + app: app, + screen: screen, + } + h.apps = append(h.apps, app) + h.current = state + h.appMu.Unlock() + + app.SetScreen(screen) + h.notifyScreenStateChanged() + return app + } + + h.injectWG.Add(1) + go h.run(keys) + + return h +} + +func (h *timedSimHarness) notifyScreenStateChanged() { + select { + case h.screenStateCh <- struct{}{}: + default: + } +} + +func (h *timedSimHarness) markRunCompleted() { + if h == nil { + return + } + if h.runCompleted == nil { + return + } + h.closeRunOnce.Do(func() { + close(h.runCompleted) + }) +} + +func (h *timedSimHarness) stop() { + if h == nil { + return } - baseScreen.SetSize(120, 40) + h.closeDoneOnce.Do(func() { + close(h.done) + }) + h.StopAll() + h.injectWG.Wait() +} - type timedSimScreenState struct { - signature string - text string +func (h *timedSimHarness) StopAll() { + if h == nil { + return } + h.appMu.RLock() + apps := append([]*tui.App(nil), h.apps...) + h.appMu.RUnlock() + for i := len(apps) - 1; i >= 0; i-- { + apps[i].Stop() + } +} + +func (h *timedSimHarness) run(keys []timedSimKey) { + defer h.injectWG.Done() - screenStateCh := make(chan struct{}, 1) - var appMu sync.RWMutex - var currentApp *tui.App - screen := ¬ifyingSimulationScreen{ - SimulationScreen: baseScreen, - notify: func() { - select { - case screenStateCh <- struct{}{}: - default: + generation := 0 + for idx, key := range keys { + minGeneration := generation + if minGeneration == 0 || key.RequireNewApp { + minGeneration++ + } + + state, ok := h.waitForScreenText(idx, key, minGeneration) + if !ok { + return + } + generation = state.generation + if key.Wait > 0 && strings.TrimSpace(key.WaitForText) == "" { + if !h.sleepOrDone(key.Wait) { + return } - }, + } + + settle := key.SettleAfterMatch + if settle <= 0 { + settle = timedSimDefaultSettle + } + if !h.sleepOrDone(settle) { + return + } + + mod := key.Mod + if mod == 0 { + mod = tcell.ModNone + } + state.screen.InjectKey(key.Key, key.R, mod) + if !h.sleepOrDone(timedSimKeyDelay) { + return + } } - var once sync.Once - newTUIApp = func() *tui.App { - app := tui.NewApp() - appMu.Lock() - currentApp = app - appMu.Unlock() - app.SetScreen(screen) + timer := time.NewTimer(timedSimCompletionTimeout) + defer timer.Stop() + select { + case <-h.runCompleted: + case <-h.done: + case <-timer.C: + h.t.Errorf("TUI simulation did not finish within %s after injecting %d key(s)\n%s", timedSimCompletionTimeout, len(keys), h.describeCurrentState()) + h.StopAll() + } +} - once.Do(func() { - injectWG.Add(1) - go func() { - defer injectWG.Done() - var lastInjectedState string - - currentScreenState := func() timedSimScreenState { - appMu.RLock() - app := currentApp - appMu.RUnlock() - - var focus any - if app != nil { - focus = app.GetFocus() - } - - return timedSimScreenState{ - signature: timedSimScreenStateSignature(screen, focus), - text: timedSimScreenText(screen), - } - } - - waitForScreenText := func(expected string) bool { - expected = strings.TrimSpace(expected) - for { - current := currentScreenState() - if current.signature != "" { - if (expected == "" || strings.Contains(current.text, expected)) && - (lastInjectedState == "" || current.signature != lastInjectedState) { - return true - } - } - - select { - case <-done: - return false - case <-screenStateCh: - } - } - } - - for _, k := range keys { - if k.Wait > 0 { - if !waitForScreenText(k.WaitForText) { - return - } - } - current := currentScreenState() - mod := k.Mod - if mod == 0 { - mod = tcell.ModNone - } - select { - case <-done: - return - default: - } - screen.InjectKey(k.Key, k.R, mod) - lastInjectedState = current.signature - } - }() - }) +func (h *timedSimHarness) waitForScreenText(index int, key timedSimKey, minGeneration int) (timedSimScreenState, bool) { + expected := strings.TrimSpace(key.WaitForText) + timeout := timedSimScreenWaitTimeout + if key.Wait > 0 { + timeout = key.Wait + } + timer := time.NewTimer(timeout) + defer timer.Stop() - return app + for { + state := h.currentScreenState() + if state.ready && state.generation >= minGeneration && (expected == "" || strings.Contains(state.text, expected)) { + return state, true + } + + select { + case <-h.done: + return timedSimScreenState{}, false + case <-h.screenStateCh: + case <-timer.C: + h.t.Errorf( + "TUI simulation timed out at action %d waiting for text %q within %s (min generation=%d, current generation=%d, focus=%s)\nCurrent screen:\n%s", + index, + expected, + timeout, + minGeneration, + state.generation, + state.focusType, + state.text, + ) + h.StopAll() + return state, false + } } } -func timedSimScreenStateSignature(screen tcell.SimulationScreen, focus any) string { - cells, width, height := screen.GetContents() - cursorX, cursorY, cursorVisible := screen.GetCursor() +func (h *timedSimHarness) currentScreenState() timedSimScreenState { + h.appMu.RLock() + current := h.current + h.appMu.RUnlock() + if current == nil || current.screen == nil { + return timedSimScreenState{} + } - sum := sha256.New() - fmt.Fprintf(sum, "size:%d:%d cursor:%d:%d:%t focus:%T:%p\n", width, height, cursorX, cursorY, cursorVisible, focus, focus) - for _, cell := range cells { - fg, bg, attr := cell.Style.Decompose() - fmt.Fprintf(sum, "%x/%d/%d/%d;", cell.Bytes, fg, bg, attr) + focusType := "" + if current.app != nil { + if focus := current.app.GetFocus(); focus != nil { + focusType = fmt.Sprintf("%T", focus) + } + } + snapshot := current.screen.snapshotState() + return timedSimScreenState{ + generation: current.generation, + text: timedSimScreenText(snapshot), + focusType: focusType, + ready: snapshot.ready, + screen: current.screen, } - return hex.EncodeToString(sum.Sum(nil)) } -func timedSimScreenText(screen tcell.SimulationScreen) string { - cells, width, height := screen.GetContents() - if width <= 0 || height <= 0 || len(cells) < width*height { +func (h *timedSimHarness) describeCurrentState() string { + state := h.currentScreenState() + return fmt.Sprintf("current generation=%d focus=%s ready=%t\nCurrent screen:\n%s", state.generation, state.focusType, state.ready, state.text) +} + +func (h *timedSimHarness) sleepOrDone(d time.Duration) bool { + if d <= 0 { + return true + } + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-h.done: + return false + case <-timer.C: + return true + } +} + +func timedSimScreenText(snapshot timedSimScreenSnapshot) string { + if !snapshot.ready || snapshot.width <= 0 || snapshot.height <= 0 || len(snapshot.cells) < snapshot.width*snapshot.height { return "" } var b strings.Builder - for y := 0; y < height; y++ { - row := make([]byte, 0, width) - for x := 0; x < width; x++ { - cell := cells[y*width+x] + for y := 0; y < snapshot.height; y++ { + row := make([]byte, 0, snapshot.width) + for x := 0; x < snapshot.width; x++ { + cell := snapshot.cells[y*snapshot.width+x] if len(cell.Bytes) == 0 { row = append(row, ' ') continue @@ -312,24 +508,25 @@ func createDecryptTUIEncryptedFixture(t *testing.T) *decryptTUIFixture { func successDecryptTUISequence(secret string) []timedSimKey { keys := []timedSimKey{ - {Key: tcell.KeyEnter, Wait: 1 * time.Second, WaitForText: "Select backup source"}, - {Key: tcell.KeyEnter, Wait: 750 * time.Millisecond, WaitForText: "Select backup"}, + {Key: tcell.KeyEnter, WaitForText: "Select backup source", RequireNewApp: true}, + {Key: tcell.KeyEnter, WaitForText: "Select backup", RequireNewApp: true}, } - for _, r := range secret { + for idx, r := range secret { keys = append(keys, timedSimKey{ - Key: tcell.KeyRune, - R: r, - Wait: 35 * time.Millisecond, - WaitForText: "Decrypt key", + Key: tcell.KeyRune, + R: r, + WaitForText: "Decrypt key", + RequireNewApp: idx == 0, + SettleAfterMatch: 5 * time.Millisecond, }) } keys = append(keys, - timedSimKey{Key: tcell.KeyTab, Wait: 150 * time.Millisecond, WaitForText: "Decrypt key"}, - timedSimKey{Key: tcell.KeyEnter, Wait: 100 * time.Millisecond, WaitForText: "Decrypt key"}, - timedSimKey{Key: tcell.KeyTab, Wait: 500 * time.Millisecond, WaitForText: "Destination directory"}, - timedSimKey{Key: tcell.KeyEnter, Wait: 100 * time.Millisecond, WaitForText: "Destination directory"}, + timedSimKey{Key: tcell.KeyTab, WaitForText: "Decrypt key"}, + timedSimKey{Key: tcell.KeyEnter, WaitForText: "Decrypt key"}, + timedSimKey{Key: tcell.KeyTab, WaitForText: "Destination directory", RequireNewApp: true}, + timedSimKey{Key: tcell.KeyEnter, WaitForText: "Destination directory"}, ) return keys @@ -337,23 +534,30 @@ func successDecryptTUISequence(secret string) []timedSimKey { func abortDecryptTUISequence() []timedSimKey { return []timedSimKey{ - {Key: tcell.KeyEnter, Wait: 1 * time.Second, WaitForText: "Select backup source"}, - {Key: tcell.KeyEnter, Wait: 750 * time.Millisecond, WaitForText: "Select backup"}, - {Key: tcell.KeyRune, R: '0', Wait: 500 * time.Millisecond, WaitForText: "Decrypt key"}, - {Key: tcell.KeyTab, Wait: 150 * time.Millisecond, WaitForText: "Decrypt key"}, - {Key: tcell.KeyEnter, Wait: 100 * time.Millisecond, WaitForText: "Decrypt key"}, + {Key: tcell.KeyEnter, WaitForText: "Select backup source", RequireNewApp: true}, + {Key: tcell.KeyEnter, WaitForText: "Select backup", RequireNewApp: true}, + {Key: tcell.KeyRune, R: '0', WaitForText: "Decrypt key", RequireNewApp: true}, + {Key: tcell.KeyTab, WaitForText: "Decrypt key"}, + {Key: tcell.KeyEnter, WaitForText: "Decrypt key"}, } } -func runDecryptWorkflowTUIForTest(t *testing.T, ctx context.Context, cfg *config.Config, configPath string) error { +func runDecryptWorkflowTUIForTest(t *testing.T, sim *timedSimHarness, ctx context.Context, cfg *config.Config, configPath string) error { t.Helper() + runCtx, cancel := context.WithCancel(ctx) + defer cancel() + logger := logging.New(types.LogLevelError, false) logger.SetOutput(io.Discard) errCh := make(chan error, 1) go func() { - errCh <- RunDecryptWorkflowTUI(ctx, cfg, logger, "1.0.0", configPath, "test-build") + err := RunDecryptWorkflowTUI(runCtx, cfg, logger, "1.0.0", configPath, "test-build") + if sim != nil { + sim.markRunCompleted() + } + errCh <- err }() waitTimeout := 30 * time.Second @@ -370,10 +574,29 @@ func runDecryptWorkflowTUIForTest(t *testing.T, ctx context.Context, cfg *config case err := <-errCh: return err case <-timer.C: - if err := ctx.Err(); err != nil { + cancel() + if sim != nil { + sim.StopAll() + } + + shutdownTimer := time.NewTimer(2 * time.Second) + defer shutdownTimer.Stop() + select { + case err := <-errCh: + return err + case <-shutdownTimer.C: + } + + if err := runCtx.Err(); err != nil { + if sim != nil { + t.Fatalf("RunDecryptWorkflowTUI did not return within %s (context state: %v)\n%s", waitTimeout, err, sim.describeCurrentState()) + } t.Fatalf("RunDecryptWorkflowTUI did not return within %s (context state: %v)", waitTimeout, err) return nil } + if sim != nil { + t.Fatalf("RunDecryptWorkflowTUI did not return within %s\n%s", waitTimeout, sim.describeCurrentState()) + } t.Fatalf("RunDecryptWorkflowTUI did not return within %s", waitTimeout) return nil } diff --git a/internal/orchestrator/decrypt_tui_e2e_test.go b/internal/orchestrator/decrypt_tui_e2e_test.go index 925b81d0..bea6a525 100644 --- a/internal/orchestrator/decrypt_tui_e2e_test.go +++ b/internal/orchestrator/decrypt_tui_e2e_test.go @@ -18,12 +18,12 @@ func TestRunDecryptWorkflowTUI_SuccessLocalEncrypted(t *testing.T) { t.Cleanup(func() { restoreFS = origFS }) fixture := createDecryptTUIEncryptedFixture(t) - withTimedSimAppSequence(t, successDecryptTUISequence(fixture.Secret)) + sim := withTimedSimAppSequence(t, successDecryptTUISequence(fixture.Secret)) ctx, cancel := context.WithTimeout(context.Background(), 18*time.Second) defer cancel() - if err := runDecryptWorkflowTUIForTest(t, ctx, fixture.Config, fixture.ConfigPath); err != nil { + if err := runDecryptWorkflowTUIForTest(t, sim, ctx, fixture.Config, fixture.ConfigPath); err != nil { t.Fatalf("RunDecryptWorkflowTUI error: %v", err) } @@ -79,12 +79,12 @@ func TestRunDecryptWorkflowTUI_AbortAtSecretPrompt(t *testing.T) { t.Cleanup(func() { restoreFS = origFS }) fixture := createDecryptTUIEncryptedFixture(t) - withTimedSimAppSequence(t, abortDecryptTUISequence()) + sim := withTimedSimAppSequence(t, abortDecryptTUISequence()) ctx, cancel := context.WithTimeout(context.Background(), 18*time.Second) defer cancel() - err := runDecryptWorkflowTUIForTest(t, ctx, fixture.Config, fixture.ConfigPath) + err := runDecryptWorkflowTUIForTest(t, sim, ctx, fixture.Config, fixture.ConfigPath) if !errors.Is(err, ErrDecryptAborted) { t.Fatalf("RunDecryptWorkflowTUI error=%v; want %v", err, ErrDecryptAborted) } diff --git a/internal/orchestrator/deps.go b/internal/orchestrator/deps.go index 6530c30b..9a5099a4 100644 --- a/internal/orchestrator/deps.go +++ b/internal/orchestrator/deps.go @@ -7,10 +7,12 @@ import ( "io/fs" "os" "os/exec" + "syscall" "time" "github.com/tis24dev/proxsave/internal/config" "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/safeexec" ) // FS abstracts filesystem operations to simplify testing. @@ -32,6 +34,8 @@ type FS interface { CreateTemp(dir, pattern string) (*os.File, error) MkdirTemp(dir, pattern string) (string, error) Rename(oldpath, newpath string) error + Lchown(path string, uid, gid int) error + UtimesNano(path string, times []syscall.Timespec) error } // Prompter encapsulates interactive prompts. @@ -93,6 +97,10 @@ func (osFS) CreateTemp(dir, pattern string) (*os.File, error) { } func (osFS) MkdirTemp(dir, pattern string) (string, error) { return os.MkdirTemp(dir, pattern) } func (osFS) Rename(oldpath, newpath string) error { return os.Rename(oldpath, newpath) } +func (osFS) Lchown(path string, uid, gid int) error { return os.Lchown(path, uid, gid) } +func (osFS) UtimesNano(path string, times []syscall.Timespec) error { + return syscall.UtimesNano(path, times) +} type consolePrompter struct{} @@ -123,7 +131,10 @@ type osCommandRunner struct{} const defaultCommandWaitDelay = 3 * time.Second func (osCommandRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) { - cmd := exec.CommandContext(ctx, name, args...) + cmd, err := safeexec.CommandContext(ctx, name, args...) + if err != nil { + return nil, err + } cmd.WaitDelay = defaultCommandWaitDelay out, err := cmd.CombinedOutput() if err != nil && errors.Is(err, exec.ErrWaitDelay) { @@ -134,7 +145,10 @@ func (osCommandRunner) Run(ctx context.Context, name string, args ...string) ([] // RunStream returns a stdout pipe for streaming commands that read from stdin. func (osCommandRunner) RunStream(ctx context.Context, name string, stdin io.Reader, args ...string) (io.ReadCloser, error) { - cmd := exec.CommandContext(ctx, name, args...) + cmd, err := safeexec.CommandContext(ctx, name, args...) + if err != nil { + return nil, err + } cmd.Stdin = stdin stdout, err := cmd.StdoutPipe() if err != nil { diff --git a/internal/orchestrator/deps_test.go b/internal/orchestrator/deps_test.go index b2fbfb3e..6154e075 100644 --- a/internal/orchestrator/deps_test.go +++ b/internal/orchestrator/deps_test.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "strings" + "syscall" "testing" "time" @@ -23,6 +24,12 @@ type FakeFS struct { MkdirAllErr error MkdirTempErr error OpenFileErr map[string]error + Ownership map[string]FakeOwnership +} + +type FakeOwnership struct { + UID int + GID int } func NewFakeFS() *FakeFS { @@ -32,6 +39,7 @@ func NewFakeFS() *FakeFS { StatErr: make(map[string]error), StatErrors: make(map[string]error), OpenFileErr: make(map[string]error), + Ownership: make(map[string]FakeOwnership), } } @@ -186,6 +194,22 @@ func (f *FakeFS) Rename(oldpath, newpath string) error { return os.Rename(f.onDisk(oldpath), f.onDisk(newpath)) } +func (f *FakeFS) Lchown(path string, uid, gid int) error { + diskPath := f.onDisk(path) + if _, err := os.Lstat(diskPath); err != nil { + return err + } + if f.Ownership == nil { + f.Ownership = make(map[string]FakeOwnership) + } + f.Ownership[diskPath] = FakeOwnership{UID: uid, GID: gid} + return nil +} + +func (f *FakeFS) UtimesNano(path string, times []syscall.Timespec) error { + return syscall.UtimesNano(f.onDisk(path), times) +} + // FakeTime provides deterministic time. type FakeTime struct { Current time.Time @@ -201,14 +225,16 @@ func (f *FakeTime) Advance(d time.Duration) { // FakeCommandRunner records invocations and returns predefined outputs/errors. type FakeCommandRunner struct { - Outputs map[string][]byte - Errors map[string]error - Calls []string + Outputs map[string][]byte + Errors map[string]error + Calls []string + Contexts []context.Context } func (f *FakeCommandRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) { key := commandKey(name, args) f.Calls = append(f.Calls, key) + f.Contexts = append(f.Contexts, ctx) var out []byte if f.Outputs != nil { out = f.Outputs[key] @@ -237,6 +263,16 @@ func commandKey(name string, args []string) string { return fmt.Sprintf("%s %s", name, strings.Join(args, " ")) } +func backgroundRollbackCallKey(timeoutSeconds int, scriptPath string) string { + return commandKey("sh", []string{ + "-c", + backgroundRollbackCommand, + "proxsave-rollback", + fmt.Sprintf("%d", timeoutSeconds), + scriptPath, + }) +} + // FakePrompter simulates user choices. type FakePrompter struct { Mode RestoreMode diff --git a/internal/orchestrator/guards_cleanup_test.go b/internal/orchestrator/guards_cleanup_test.go index 7e4e9684..c8b0eb8b 100644 --- a/internal/orchestrator/guards_cleanup_test.go +++ b/internal/orchestrator/guards_cleanup_test.go @@ -11,8 +11,6 @@ import ( ) func TestGuardMountpointsFromMountinfo_VisibleAndHidden(t *testing.T) { - t.Parallel() - mountinfo := strings.Join([]string{ "10 1 0:1 " + mountGuardBaseDir + "/g1 /mnt/visible rw - ext4 /dev/sda1 rw", "20 1 0:1 " + mountGuardBaseDir + "/g2 /mnt/hidden rw - ext4 /dev/sda1 rw", @@ -32,8 +30,6 @@ func TestGuardMountpointsFromMountinfo_VisibleAndHidden(t *testing.T) { } func TestGuardMountpointsFromMountinfo_UnescapesMountpoint(t *testing.T) { - t.Parallel() - mountinfo := "10 1 0:1 " + mountGuardBaseDir + "/g1 /mnt/with\\040space rw - ext4 /dev/sda1 rw\n" visible, hidden, mounts := guardMountpointsFromMountinfo(mountinfo) if mounts != 1 { diff --git a/internal/orchestrator/mount_guard_more_test.go b/internal/orchestrator/mount_guard_more_test.go index 41109084..67719ac7 100644 --- a/internal/orchestrator/mount_guard_more_test.go +++ b/internal/orchestrator/mount_guard_more_test.go @@ -14,8 +14,6 @@ import ( ) func TestGuardDirForTarget(t *testing.T) { - t.Parallel() - target := "/mnt/datastore" sum := sha256.Sum256([]byte(target)) id := fmt.Sprintf("%x", sum[:8]) @@ -34,8 +32,6 @@ func TestGuardDirForTarget(t *testing.T) { } func TestIsMountedFromMountinfo(t *testing.T) { - t.Parallel() - mountinfo := strings.Join([]string{ "36 25 0:32 / / rw,relatime - ext4 /dev/sda1 rw", `37 36 0:33 / /mnt/pbs\040datastore rw,relatime - ext4 /dev/sdb1 rw`, @@ -98,8 +94,6 @@ func TestFstabMountpointsSet_Error(t *testing.T) { } func TestSplitPathAndMountRootWithPrefix(t *testing.T) { - t.Parallel() - if got := splitPath("a//b/ /c/"); strings.Join(got, ",") != "a,b,c" { t.Fatalf("splitPath unexpected: %#v", got) } @@ -112,8 +106,6 @@ func TestSplitPathAndMountRootWithPrefix(t *testing.T) { } func TestSortByLengthDesc(t *testing.T) { - t.Parallel() - items := []string{"a", "abc", "ab"} sortByLengthDesc(items) if len(items) != 3 { @@ -125,8 +117,6 @@ func TestSortByLengthDesc(t *testing.T) { } func TestFirstFstabMountpointMatch(t *testing.T) { - t.Parallel() - mountpoints := []string{"/mnt/storage/pbs", "/mnt/storage", "/"} if got := firstFstabMountpointMatch("/mnt/storage/pbs/ds1/data", mountpoints); got != "/mnt/storage/pbs" { t.Fatalf("firstFstabMountpointMatch got %q want %q", got, "/mnt/storage/pbs") diff --git a/internal/orchestrator/network_apply.go b/internal/orchestrator/network_apply.go index faa76b18..ca5a6f7d 100644 --- a/internal/orchestrator/network_apply.go +++ b/internal/orchestrator/network_apply.go @@ -295,8 +295,8 @@ func armNetworkRollback(ctx context.Context, logger *logging.Logger, backupPath if handle.unitName == "" { logging.DebugStep(logger, "arm network rollback", "Arm timer via background sleep (%ds)", timeoutSeconds) - cmd := fmt.Sprintf("nohup sh -c 'sleep %d; /bin/sh %s' >/dev/null 2>&1 &", timeoutSeconds, handle.scriptPath) - if output, err := restoreCmd.Run(ctx, "sh", "-c", cmd); err != nil { + output, err := runBackgroundRollbackTimer(ctx, timeoutSeconds, handle.scriptPath) + if err != nil { logger.Debug("Background rollback output: %s", strings.TrimSpace(string(output))) return nil, fmt.Errorf("failed to arm rollback timer: %w", err) } @@ -794,6 +794,15 @@ func shellQuote(value string) string { return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'" } +const backgroundRollbackCommand = `nohup sh -c 'sleep "$1"; /bin/sh "$2"' proxsave-rollback-worker "$1" "$2" >/dev/null 2>&1 &` + +func runBackgroundRollbackTimer(ctx context.Context, timeoutSeconds int, scriptPath string) ([]byte, error) { + if timeoutSeconds < 1 { + timeoutSeconds = 1 + } + return restoreCmd.Run(ctx, "sh", "-c", backgroundRollbackCommand, "proxsave-rollback", fmt.Sprintf("%d", timeoutSeconds), scriptPath) +} + func commandAvailable(name string) bool { _, err := exec.LookPath(name) return err == nil diff --git a/internal/orchestrator/network_apply_additional_test.go b/internal/orchestrator/network_apply_additional_test.go index b3e27803..2d939b5f 100644 --- a/internal/orchestrator/network_apply_additional_test.go +++ b/internal/orchestrator/network_apply_additional_test.go @@ -606,11 +606,12 @@ func TestArmNetworkRollback_SystemdRunFailureFallsBackToNohup(t *testing.T) { foundSystemdRun := false foundFallback := false + wantFallback := backgroundRollbackCallKey(30, handle.scriptPath) for _, call := range fakeCmd.CallsList() { if strings.HasPrefix(call, "systemd-run ") { foundSystemdRun = true } - if strings.HasPrefix(call, "sh -c nohup sh -c 'sleep ") { + if call == wantFallback { foundFallback = true } } @@ -653,8 +654,9 @@ func TestArmNetworkRollback_WithoutSystemdRunUsesNohup(t *testing.T) { } foundFallback := false + wantFallback := backgroundRollbackCallKey(1, handle.scriptPath) for _, call := range fakeCmd.CallsList() { - if strings.HasPrefix(call, "sh -c nohup sh -c 'sleep ") { + if call == wantFallback { foundFallback = true } } @@ -693,8 +695,9 @@ func TestArmNetworkRollback_SubSecondTimeoutArmsAtLeastOneSecond(t *testing.T) { } foundSleep1 := false + wantFallback := backgroundRollbackCallKey(1, handle.scriptPath) for _, call := range fakeCmd.CallsList() { - if strings.Contains(call, "sleep 1;") { + if call == wantFallback { foundSleep1 = true } } @@ -723,7 +726,7 @@ func TestArmNetworkRollback_FallbackCommandFailureReturnsError(t *testing.T) { restoreCmd = &FakeCommandRunner{ Errors: map[string]error{ - "sh -c nohup sh -c 'sleep 1; /bin/sh /tmp/proxsave/network_rollback_20260201_123456.sh' >/dev/null 2>&1 &": errors.New("boom"), + backgroundRollbackCallKey(1, "/tmp/proxsave/network_rollback_20260201_123456.sh"): errors.New("boom"), }, } @@ -733,6 +736,28 @@ func TestArmNetworkRollback_FallbackCommandFailureReturnsError(t *testing.T) { } } +func TestRunBackgroundRollbackTimer_UsesPositionalArgsForScriptPath(t *testing.T) { + origCmd := restoreCmd + t.Cleanup(func() { restoreCmd = origCmd }) + + fakeCmd := &FakeCommandRunner{} + restoreCmd = fakeCmd + + scriptPath := "/tmp/proxsave dir/rollback's ; touch /tmp/proxsave-injected.sh" + if _, err := runBackgroundRollbackTimer(context.Background(), 2, scriptPath); err != nil { + t.Fatalf("runBackgroundRollbackTimer error: %v", err) + } + + want := backgroundRollbackCallKey(2, scriptPath) + calls := fakeCmd.CallsList() + if len(calls) != 1 || calls[0] != want { + t.Fatalf("unexpected calls: %#v", calls) + } + if strings.Contains(backgroundRollbackCommand, scriptPath) { + t.Fatalf("rollback script path must not be interpolated into shell command") + } +} + func TestDisarmNetworkRollback_RemovesMarkerAndStopsTimer(t *testing.T) { origFS := restoreFS origCmd := restoreCmd diff --git a/internal/orchestrator/nic_mapping.go b/internal/orchestrator/nic_mapping.go index f2a0372c..b77dc273 100644 --- a/internal/orchestrator/nic_mapping.go +++ b/internal/orchestrator/nic_mapping.go @@ -312,7 +312,7 @@ func loadBackupNetworkInventoryFromArchive(ctx context.Context, archivePath stri return &inv, used, nil } -func readArchiveEntry(ctx context.Context, archivePath string, candidates []string, maxBytes int64) ([]byte, string, error) { +func readArchiveEntry(ctx context.Context, archivePath string, candidates []string, maxBytes int64) (data []byte, used string, err error) { file, err := restoreFS.Open(archivePath) if err != nil { return nil, "", err @@ -323,9 +323,7 @@ func readArchiveEntry(ctx context.Context, archivePath string, candidates []stri if err != nil { return nil, "", err } - if closer, ok := reader.(io.Closer); ok { - defer closer.Close() - } + defer closeDecompressionReader(reader, &err, "close decompression reader") tr := tar.NewReader(reader) diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 2107ddef..de9a9548 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -4,7 +4,6 @@ import ( "archive/tar" "context" "encoding/json" - "errors" "fmt" "io" "os" @@ -504,528 +503,54 @@ func (o *Orchestrator) ensureTempRegistry() *TempDirRegistry { // RunGoBackup performs the entire backup using Go components (collector + archiver) func (o *Orchestrator) RunGoBackup(ctx context.Context, envInfo *environment.EnvironmentInfo, hostname string) (stats *BackupStats, err error) { - if envInfo == nil { - envInfo = o.envInfo - } else { - o.SetEnvironmentInfo(envInfo) - } - pType := types.ProxmoxUnknown - if envInfo != nil { - pType = envInfo.Type - } - done := logging.DebugStart(o.logger, "backup run", "type=%s hostname=%s", pType, hostname) + run := o.newBackupRunContext(ctx, envInfo, hostname) + done := logging.DebugStart(o.logger, "backup run", "type=%s hostname=%s", run.proxmoxType, hostname) defer func() { done(err) }() - o.logger.Info("Starting Go-based backup orchestration for %s", pType) - - // Unified cleanup of previous execution artifacts - registry := o.cleanupPreviousExecutionArtifacts() - fs := o.filesystem() + o.logger.Info("Starting Go-based backup orchestration for %s", run.proxmoxType) - startTime := o.startTime - if startTime.IsZero() { - startTime = o.now() - o.startTime = startTime + workspace := &backupWorkspace{ + registry: o.cleanupPreviousExecutionArtifacts(), + fs: o.filesystem(), } - normalizedLevel := normalizeCompressionLevel(o.compressionType, o.compressionLevel) - - fmt.Println() - o.logStep(1, "Initializing backup statistics and temporary workspace") - stats = InitializeBackupStats( - hostname, - envInfo, - o.version, - startTime, - o.cfg, - o.compressionType, - o.compressionMode, - normalizedLevel, - o.compressionThreads, - o.backupPath, - o.serverID, - o.serverMAC, - ) - // Get log file path from logger (more reliable than env var) - if logFile := o.logger.GetLogFilePath(); logFile != "" { - stats.LogFilePath = logFile - } - - // Propagate version update information (if any) into stats so that - // downstream notification adapters can include it in their payloads. - if o.versionUpdateAvailable || o.updateCurrentVersion != "" || o.updateLatestVersion != "" { - stats.NewVersionAvailable = o.versionUpdateAvailable - stats.CurrentVersion = o.updateCurrentVersion - stats.LatestVersion = o.updateLatestVersion - } - - metricsStats := stats + stats = o.initBackupRun(run) defer func() { - if metricsStats == nil || o.cfg == nil || !o.cfg.MetricsEnabled || o.dryRun { - return - } - - if metricsStats.EndTime.IsZero() { - metricsStats.EndTime = o.now() - } - if metricsStats.Duration == 0 && !metricsStats.StartTime.IsZero() { - metricsStats.Duration = metricsStats.EndTime.Sub(metricsStats.StartTime) - } - - if err != nil { - var backupErr *BackupError - if errors.As(err, &backupErr) { - metricsStats.ExitCode = backupErr.Code.Int() - } else { - metricsStats.ExitCode = types.ExitGenericError.Int() - } - } else if metricsStats.ExitCode == 0 { - metricsStats.ExitCode = types.ExitSuccess.Int() - } - - if m := metricsStats.toPrometheusMetrics(); m != nil { - exporter := metrics.NewPrometheusExporter(o.cfg.MetricsPath, o.logger) - if exportErr := exporter.Export(m); exportErr != nil { - o.logger.Warning("Failed to export Prometheus metrics: %v", exportErr) - } - } + o.exportBackupMetrics(run, err) }() - - // Ensure that, in case of failure, we still perform log parsing, - // derive an exit code and dispatch notifications/log rotation. defer func() { - if err == nil || stats == nil { - return - } - - // Ensure end time and duration are set - if stats.EndTime.IsZero() { - stats.EndTime = o.now() - } - if stats.Duration == 0 && !stats.StartTime.IsZero() { - stats.Duration = stats.EndTime.Sub(stats.StartTime) - } - - // Parse log file to populate error/warning counts - if stats.LogFilePath != "" { - o.logger.Debug("Parsing log file for error/warning counts after failure: %s", stats.LogFilePath) - _, errorCount, warningCount := ParseLogCounts(stats.LogFilePath, 0) - stats.ErrorCount = errorCount - stats.WarningCount = warningCount - if errorCount > 0 || warningCount > 0 { - o.logger.Debug("Found %d errors and %d warnings in log file (failure path)", errorCount, warningCount) - } - } else { - o.logger.Debug("No log file path specified, error/warning counts will be 0 (failure path)") - } - - // Derive exit code from the error when possible - var backupErr *BackupError - if errors.As(err, &backupErr) { - stats.ExitCode = backupErr.Code.Int() - } else { - stats.ExitCode = types.ExitBackupError.Int() - } - + o.finalizeFailedBackupStats(run, err) }() - o.logger.Debug("Creating temporary directory for collection output") - // Create temporary directory for collection (outside backup path) - // Note: /tmp/proxsave is validated in pre-backup checks (CheckTempDirectory) - // This MkdirAll is a fallback for cases where pre-checks don't run - timestampStr := startTime.Format("20060102-150405") - tempRoot := filepath.Join("/tmp", "proxsave") - if err := fs.MkdirAll(tempRoot, 0o755); err != nil { - return nil, fmt.Errorf("Temp directory creation failed - path: %s: %w", tempRoot, err) - } - tempDir, err := fs.MkdirTemp(tempRoot, fmt.Sprintf("proxsave-%s-%s-", hostname, timestampStr)) - if err != nil { - return nil, fmt.Errorf("failed to create temporary directory: %w", err) - } - if o.dryRun { - o.logger.Info("[DRY RUN] Temporary directory would be: %s", tempDir) - } else { - o.logger.Debug("Using temporary directory: %s", tempDir) + if err := o.prepareBackupWorkspace(run, workspace); err != nil { + return stats, err } defer func() { - if registry == nil { - if cleanupErr := fs.RemoveAll(tempDir); cleanupErr != nil { - o.logger.Warning("Failed to remove temp directory %s: %v", tempDir, cleanupErr) - } - return - } - o.logger.Debug("Temporary workspace preserved at %s (will be removed at the next startup)", tempDir) + o.cleanupBackupWorkspace(workspace) }() - - // Create marker file for parity with Bash cleanup guarantees - markerPath := filepath.Join(tempDir, ".proxsave-marker") - markerContent := fmt.Sprintf( - "Created by PID %d on %s UTC\n", - os.Getpid(), - o.now().UTC().Format("2006-01-02 15:04:05"), - ) - if err := fs.WriteFile(markerPath, []byte(markerContent), 0600); err != nil { + if err := o.markBackupWorkspace(workspace); err != nil { return stats, fmt.Errorf("failed to create temp marker file: %w", err) } + o.registerBackupWorkspace(workspace) - if registry != nil { - if err := registry.Register(tempDir); err != nil { - o.logger.Debug("Failed to register temp directory %s: %v", tempDir, err) - } - } - - // Step 1: Collect configuration files - fmt.Println() - o.logStep(2, "Collection of configuration files and optimizations") - o.logger.Info("Collecting configuration files...") - o.logger.Debug("Collector dry-run=%v excludePatterns=%d", o.dryRun, len(o.excludePatterns)) - collectorConfig := backup.GetDefaultCollectorConfig() - collectorConfig.ExcludePatterns = append([]string(nil), o.excludePatterns...) - if o.cfg != nil { - applyCollectorOverrides(collectorConfig, o.cfg) - if len(o.cfg.BackupBlacklist) > 0 { - collectorConfig.ExcludePatterns = append(collectorConfig.ExcludePatterns, o.cfg.BackupBlacklist...) - } - } - - if err := collectorConfig.Validate(); err != nil { - return stats, &BackupError{ - Phase: "config", - Err: err, - Code: types.ExitConfigError, - } - } - - collector := backup.NewCollectorWithDeps(o.logger, collectorConfig, tempDir, pType, o.dryRun, o.collectorDeps()) - - o.logger.Debug("Starting collector run (type=%s)", pType) - if err := collector.CollectAll(ctx); err != nil { - // Return collection-specific error - return stats, &BackupError{ - Phase: "collection", - Err: err, - Code: types.ExitCollectionError, - } - } - - // Get collection statistics - collStats := collector.GetStats() - stats.FilesCollected = int(collStats.FilesProcessed) - stats.FilesFailed = int(collStats.FilesFailed) - stats.FilesNotFound = int(collStats.FilesNotFound) - stats.DirsCreated = int(collStats.DirsCreated) - stats.BytesCollected = collStats.BytesCollected - stats.FilesIncluded = int(collStats.FilesProcessed) - stats.FilesMissing = int(collStats.FilesNotFound) - stats.UncompressedSize = collStats.BytesCollected - if stats.ProxmoxType.SupportsPVE() { - if collector.IsClusteredPVE() { - stats.ClusterMode = "cluster" - } else { - stats.ClusterMode = "standalone" - } - } - - if err := o.writeBackupMetadata(tempDir, stats); err != nil { - o.logger.Debug("Failed to write backup metadata: %v", err) - } - - // Write backup manifest with file status details - if err := collector.WriteManifest(hostname); err != nil { - o.logger.Debug("Failed to write backup manifest: %v", err) - } - - o.logger.Info("Collection completed: %d files (%s), %d failed, %d dirs created", - collStats.FilesProcessed, - backup.FormatBytes(collStats.BytesCollected), - collStats.FilesFailed, - collStats.DirsCreated) - - // Additional disk space check using estimated size and safety factor - if o.checker != nil && stats.BytesCollected > 0 { - o.logger.Debug("Running disk-space validation for estimated data size") - estimatedSizeGB := float64(stats.BytesCollected) / (1024.0 * 1024.0 * 1024.0) - // Ensure we always reserve at least a small amount - if estimatedSizeGB < 0.001 { - estimatedSizeGB = 0.001 - } - result := o.checker.CheckDiskSpaceForEstimate(estimatedSizeGB) - if result.Passed { - o.logger.Debug("Disk check passed: %s", result.Message) - } else { - errMsg := result.Message - diskErr := result.Error - if errMsg == "" && diskErr != nil { - errMsg = diskErr.Error() - } - if errMsg == "" { - errMsg = "insufficient disk space" - } - if diskErr == nil { - diskErr = errors.New(errMsg) - } - return stats, &BackupError{ - Phase: "disk", - Err: fmt.Errorf("disk space validation failed: %w", diskErr), - Code: types.ExitDiskSpaceError, - } - } - } - - if o.optimizationCfg.Enabled() { - fmt.Println() - o.logger.Step("Backup optimizations on collected data") - if err := backup.ApplyOptimizations(ctx, o.logger, tempDir, o.optimizationCfg); err != nil { - o.logger.Warning("Backup optimizations completed with warnings: %v", err) - } - } else { - o.logger.Debug("Skipping optimization step (all features disabled)") + if err := o.collectBackupData(run, workspace); err != nil { + return stats, err } - - // Step 2: Create archive - fmt.Println() - o.logStep(3, "Creation of compressed archive") - o.logger.Info("Creating compressed archive...") - o.logger.Debug("Archiver configuration: type=%s level=%d mode=%s threads=%d", - o.compressionType, normalizedLevel, o.compressionMode, o.compressionThreads) - - // Generate archive filename - archiveBasename := fmt.Sprintf("%s-backup-%s", hostname, timestampStr) - - ageRecipients, err := o.prepareAgeRecipients(ctx) + artifacts, err := o.createBackupArchive(run, workspace) if err != nil { - return stats, &BackupError{ - Phase: "config", - Err: err, - Code: types.ExitConfigError, - } + return stats, err } - - archiverConfig := BuildArchiverConfig( - o.compressionType, - normalizedLevel, - o.compressionThreads, - o.compressionMode, - o.dryRun, - o.cfg != nil && o.cfg.EncryptArchive, - ageRecipients, - collectorConfig.ExcludePatterns, - ) - - if err := archiverConfig.Validate(); err != nil { - return stats, &BackupError{ - Phase: "config", - Err: err, - Code: types.ExitConfigError, - } + if err := o.verifyAndWriteBackupArtifacts(run, workspace, artifacts); err != nil { + return stats, err } - - archiver := backup.NewArchiver(o.logger, archiverConfig) - effectiveCompression := archiver.ResolveCompression() - stats.Compression = effectiveCompression - stats.CompressionLevel = archiver.CompressionLevel() - stats.CompressionMode = archiver.CompressionMode() - stats.CompressionThreads = archiver.CompressionThreads() - archiveExt := archiver.GetArchiveExtension() - archivePath := filepath.Join(o.backupPath, archiveBasename+archiveExt) - if stats.RequestedCompression != stats.Compression { - o.logger.Info("Using %s compression (requested %s)", stats.Compression, stats.RequestedCompression) - } - - if err := archiver.CreateArchive(ctx, tempDir, archivePath); err != nil { - phase := "archive" - code := types.ExitArchiveError - var compressionErr *backup.CompressionError - if errors.As(err, &compressionErr) { - phase = "compression" - code = types.ExitCompressionError - } - - return stats, &BackupError{ - Phase: phase, - Err: err, - Code: code, - } + if err := o.bundleBackupArtifacts(run, workspace, artifacts); err != nil { + return stats, err } - - stats.ArchivePath = archivePath - checksumPath := archivePath + ".sha256" - - // Get archive size - if !o.dryRun { - fmt.Println() - o.logStep(4, "Verification of archive and metadata generation") - if size, err := archiver.GetArchiveSize(archivePath); err == nil { - stats.ArchiveSize = size - stats.CompressedSize = size - stats.updateCompressionMetrics() - o.logger.Debug("Archive created: %s (%s)", archivePath, backup.FormatBytes(size)) - } else { - o.logger.Warning("Failed to get archive size: %v", err) - } - - // Verify archive (skipped internally when encryption is enabled) - if err := archiver.VerifyArchive(ctx, archivePath); err != nil { - // Return verification-specific error - return stats, &BackupError{ - Phase: "verification", - Err: err, - Code: types.ExitVerificationError, - } - } - - // Generate checksum and manifest for the archive - checksum, err := backup.GenerateChecksum(ctx, o.logger, archivePath) - if err != nil { - return stats, &BackupError{ - Phase: "verification", - Err: fmt.Errorf("checksum generation failed: %w", err), - Code: types.ExitVerificationError, - } - } - stats.Checksum = checksum - - checksumContent := fmt.Sprintf("%s %s\n", checksum, filepath.Base(archivePath)) - if err := fs.WriteFile(checksumPath, []byte(checksumContent), 0640); err != nil { - o.logger.Warning("Failed to write checksum file %s: %v", checksumPath, err) - } else { - o.logger.Debug("Checksum file written to %s", checksumPath) - } - - manifestPath := archivePath + ".manifest.json" - manifestCreatedAt := stats.Timestamp - encryptionMode := "none" - if o.cfg != nil && o.cfg.EncryptArchive { - encryptionMode = "age" - } - targets := append([]string(nil), stats.ProxmoxTargets...) - manifest := &backup.Manifest{ - ArchivePath: archivePath, - ArchiveSize: stats.ArchiveSize, - SHA256: checksum, - CreatedAt: manifestCreatedAt, - CompressionType: string(stats.Compression), - CompressionLevel: stats.CompressionLevel, - CompressionMode: stats.CompressionMode, - ProxmoxType: string(stats.ProxmoxType), - ProxmoxTargets: targets, - ProxmoxVersion: stats.ProxmoxVersion, - PVEVersion: stats.PVEVersion, - PBSVersion: stats.PBSVersion, - Hostname: stats.Hostname, - ScriptVersion: stats.ScriptVersion, - EncryptionMode: encryptionMode, - ClusterMode: stats.ClusterMode, - } - - if err := backup.CreateManifest(ctx, o.logger, manifest, manifestPath); err != nil { - return stats, &BackupError{ - Phase: "verification", - Err: fmt.Errorf("manifest creation failed: %w", err), - Code: types.ExitVerificationError, - } - } - stats.ManifestPath = manifestPath - - // Maintain Bash-compatible metadata filename for downstream tooling - metadataAlias := archivePath + ".metadata" - if err := copyFile(fs, manifestPath, metadataAlias); err != nil { - o.logger.Warning("Failed to write legacy metadata file %s: %v", metadataAlias, err) - } else { - o.logger.Debug("Legacy metadata file written to %s", metadataAlias) - } - - // Create bundle (if requested) before dispatching to other storage targets - bundleEnabled := o.cfg != nil && o.cfg.BundleAssociatedFiles - if bundleEnabled { - fmt.Println() - o.logStep(5, "Bundling of archive, checksum and metadata") - o.logger.Debug("Bundling enabled: creating bundle from %s", filepath.Base(archivePath)) - bundlePath, err := o.createBundle(ctx, archivePath) - if err != nil { - return stats, &BackupError{ - Phase: "archive", - Err: fmt.Errorf("bundle creation failed: %w", err), - Code: types.ExitArchiveError, - } - } - - if err := o.removeAssociatedFiles(archivePath); err != nil { - o.logger.Warning("Failed to remove raw files after bundling: %v", err) - } else { - o.logger.Debug("Removed raw tar/checksum/metadata after bundling") - } - - if info, err := fs.Stat(bundlePath); err == nil { - stats.ArchiveSize = info.Size() - stats.CompressedSize = info.Size() - stats.updateCompressionMetrics() - } - stats.ArchivePath = bundlePath - stats.ManifestPath = "" - stats.BundleCreated = true - archivePath = bundlePath - o.logger.Debug("Bundle ready: %s", filepath.Base(bundlePath)) - } else { - fmt.Println() - o.logger.Skip("Bundling disabled") - } - - stats.EndTime = o.now() - - o.logger.Info("✓ Archive created and verified") - } else { - fmt.Println() - o.logStep(4, "Verification skipped (dry run mode)") - o.logger.Info("[DRY RUN] Would create archive: %s", archivePath) - stats.EndTime = o.now() - } - - stats.Duration = stats.EndTime.Sub(stats.StartTime) - - // Parse log file to populate error/warning counts before dispatch - if stats.LogFilePath != "" { - o.logger.Debug("Parsing log file for error/warning counts: %s", stats.LogFilePath) - _, errorCount, warningCount := ParseLogCounts(stats.LogFilePath, 0) - stats.ErrorCount = errorCount - stats.WarningCount = warningCount - if errorCount > 0 || warningCount > 0 { - o.logger.Debug("Found %d errors and %d warnings in log file", errorCount, warningCount) - } - } else { - o.logger.Debug("No log file path specified, error/warning counts will be 0") - } - - // Determine aggregated exit code (similar to legacy Bash logic) - switch { - case stats.ErrorCount > 0: - stats.ExitCode = types.ExitBackupError.Int() - case stats.WarningCount > 0: - stats.ExitCode = types.ExitGenericError.Int() - default: - stats.ExitCode = types.ExitSuccess.Int() - } - o.logger.Debug("Aggregated exit code based on log analysis: %d", stats.ExitCode) - - if len(o.storageTargets) == 0 { - fmt.Println() - o.logStep(6, "No storage targets registered - skipping") - } else if o.dryRun { - fmt.Println() - o.logStep(6, "Storage dispatch skipped (dry run mode)") - } else { - fmt.Println() - o.logStep(6, "Dispatching archive to %d storage target(s)", len(o.storageTargets)) - o.logGlobalRetentionPolicy() - } - - if !o.dryRun { - o.logger.Debug("Dispatching archive to %d storage targets", len(o.storageTargets)) - if err := o.dispatchPostBackup(ctx, stats); err != nil { - return stats, err - } + o.finalizeBackupStats(run) + if err := o.dispatchBackupArtifacts(run); err != nil { + return stats, err } fmt.Println() - o.logger.Debug("Go backup completed in %s", backup.FormatDuration(stats.Duration)) + o.logger.Debug("Go backup completed in %s", backup.FormatDuration(run.stats.Duration)) return stats, nil } diff --git a/internal/orchestrator/pbs_mount_guard_test.go b/internal/orchestrator/pbs_mount_guard_test.go index a9efbc17..75a4d385 100644 --- a/internal/orchestrator/pbs_mount_guard_test.go +++ b/internal/orchestrator/pbs_mount_guard_test.go @@ -3,8 +3,6 @@ package orchestrator import "testing" func TestPBSMountGuardRootForDatastorePath(t *testing.T) { - t.Parallel() - tests := []struct { name string in string @@ -25,7 +23,6 @@ func TestPBSMountGuardRootForDatastorePath(t *testing.T) { for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { - t.Parallel() if got := pbsMountGuardRootForDatastorePath(tt.in); got != tt.want { t.Fatalf("pbsMountGuardRootForDatastorePath(%q)=%q want %q", tt.in, got, tt.want) } diff --git a/internal/orchestrator/pbs_staged_apply_additional_test.go b/internal/orchestrator/pbs_staged_apply_additional_test.go index 5c0d1d23..6d360c4b 100644 --- a/internal/orchestrator/pbs_staged_apply_additional_test.go +++ b/internal/orchestrator/pbs_staged_apply_additional_test.go @@ -12,8 +12,6 @@ import ( ) func TestPBSConfigHasHeader_AcceptsAndRejectsExpectedForms(t *testing.T) { - t.Parallel() - tests := []struct { name string content string @@ -348,8 +346,6 @@ func TestLoadPBSDatastoreCfgFromInventory_PropagatesErrors(t *testing.T) { } func TestDetectPBSDatastoreCfgDuplicateKeys_DetectsDuplicateKeys(t *testing.T) { - t.Parallel() - blocks := []pbsDatastoreBlock{{ Name: "DS1", Lines: []string{ @@ -366,8 +362,6 @@ func TestDetectPBSDatastoreCfgDuplicateKeys_DetectsDuplicateKeys(t *testing.T) { } func TestDetectPBSDatastoreCfgDuplicateKeys_AllowsUniqueKeys(t *testing.T) { - t.Parallel() - blocks := []pbsDatastoreBlock{{ Name: "DS1", Lines: []string{ @@ -382,8 +376,6 @@ func TestDetectPBSDatastoreCfgDuplicateKeys_AllowsUniqueKeys(t *testing.T) { } func TestParsePBSDatastoreCfgBlocks_IgnoresGarbageAndHandlesMissingNames(t *testing.T) { - t.Parallel() - content := strings.Join([]string{ "path /should/be/ignored", "datastore:", @@ -416,8 +408,6 @@ func TestParsePBSDatastoreCfgBlocks_IgnoresGarbageAndHandlesMissingNames(t *test } func TestParsePBSDatastoreCfgBlocks_DropsEmptyNamedBlocks(t *testing.T) { - t.Parallel() - content := strings.Join([]string{ "datastore: :", " path /mnt/ignored", @@ -440,8 +430,6 @@ func TestParsePBSDatastoreCfgBlocks_DropsEmptyNamedBlocks(t *testing.T) { } func TestShouldApplyPBSDatastoreBlock_CoversCommonBranches(t *testing.T) { - t.Parallel() - if ok, reason := shouldApplyPBSDatastoreBlock(pbsDatastoreBlock{Name: "ds", Path: "/"}, newTestLogger()); ok || !strings.Contains(reason, "invalid") { t.Fatalf("expected invalid path rejection, got ok=%v reason=%q", ok, reason) } @@ -464,8 +452,6 @@ func TestShouldApplyPBSDatastoreBlock_CoversCommonBranches(t *testing.T) { } func TestWriteDeferredPBSDatastoreCfg_EmptyInputIsNoop(t *testing.T) { - t.Parallel() - if path, err := writeDeferredPBSDatastoreCfg(nil); err != nil { t.Fatalf("err=%v", err) } else if path != "" { diff --git a/internal/orchestrator/pbs_staged_apply_test.go b/internal/orchestrator/pbs_staged_apply_test.go index 0ee7ab72..d881eff2 100644 --- a/internal/orchestrator/pbs_staged_apply_test.go +++ b/internal/orchestrator/pbs_staged_apply_test.go @@ -63,8 +63,6 @@ func TestApplyPBSRemoteCfgFromStage_RemovesWhenEmpty(t *testing.T) { } func TestShouldApplyPBSDatastoreBlock_AllowsMountLikePathsOnRootFS(t *testing.T) { - t.Parallel() - dir, err := os.MkdirTemp("/mnt", "proxsave-test-ds-") if err != nil { t.Skipf("cannot create temp dir under /mnt: %v", err) diff --git a/internal/orchestrator/pve_staged_apply_test.go b/internal/orchestrator/pve_staged_apply_test.go index a5561beb..fcc80117 100644 --- a/internal/orchestrator/pve_staged_apply_test.go +++ b/internal/orchestrator/pve_staged_apply_test.go @@ -7,8 +7,6 @@ import ( ) func TestPVEStorageMountGuardItems_BuildsExpectedTargets(t *testing.T) { - t.Parallel() - candidates := []pveStorageMountGuardCandidate{ {StorageID: "Data1", StorageType: "dir", Path: "/mnt/datastore/Data1"}, {StorageID: "Synology-Archive", StorageType: "dir", Path: "/mnt/Synology_NFS/PBS_Backup"}, @@ -40,8 +38,6 @@ func TestPVEStorageMountGuardItems_BuildsExpectedTargets(t *testing.T) { } func TestApplyPVEBackupJobsFromStage_CreatesJobsViaPvesh(t *testing.T) { - t.Parallel() - origFS := restoreFS origCmd := restoreCmd t.Cleanup(func() { diff --git a/internal/orchestrator/resolv_conf_repair.go b/internal/orchestrator/resolv_conf_repair.go index 396e6415..bce82238 100644 --- a/internal/orchestrator/resolv_conf_repair.go +++ b/internal/orchestrator/resolv_conf_repair.go @@ -148,7 +148,7 @@ func repairResolvConfWithSystemdResolved(logger *logging.Logger) (bool, error) { return false, nil } -func readTarEntry(ctx context.Context, archivePath, name string, maxBytes int64) ([]byte, error) { +func readTarEntry(ctx context.Context, archivePath, name string, maxBytes int64) (data []byte, err error) { file, err := restoreFS.Open(archivePath) if err != nil { return nil, fmt.Errorf("open archive: %w", err) @@ -159,9 +159,7 @@ func readTarEntry(ctx context.Context, archivePath, name string, maxBytes int64) if err != nil { return nil, fmt.Errorf("create decompression reader: %w", err) } - if closer, ok := reader.(io.Closer); ok { - defer closer.Close() - } + defer closeDecompressionReader(reader, &err, "close decompression reader") wantA := strings.TrimPrefix(strings.TrimSpace(name), "./") wantB := "./" + wantA diff --git a/internal/orchestrator/restore.go b/internal/orchestrator/restore.go index 03b79316..f2f779fd 100644 --- a/internal/orchestrator/restore.go +++ b/internal/orchestrator/restore.go @@ -1,44 +1,21 @@ +// Package orchestrator coordinates backup, restore, decrypt, and related workflows. package orchestrator import ( - "archive/tar" "bufio" - "compress/gzip" "context" "errors" "fmt" - "io" "os" - "os/exec" - "path" - "path/filepath" - "sort" - "strings" - "sync/atomic" - "syscall" "time" "github.com/tis24dev/proxsave/internal/config" - "github.com/tis24dev/proxsave/internal/input" "github.com/tis24dev/proxsave/internal/logging" ) +// ErrRestoreAborted is returned when a restore workflow is intentionally aborted by the user. var ErrRestoreAborted = errors.New("restore workflow aborted by user") -var ( - serviceStopTimeout = 45 * time.Second - serviceStopNoBlockTimeout = 15 * time.Second - serviceStartTimeout = 30 * time.Second - serviceVerifyTimeout = 30 * time.Second - serviceStatusCheckTimeout = 5 * time.Second - servicePollInterval = 500 * time.Millisecond - serviceRetryDelay = 500 * time.Millisecond - restoreLogSequence uint64 - restoreGlob = filepath.Glob -) - -const restoreTempPattern = ".proxsave-tmp-*" - // RestoreAbortInfo contains information about an aborted restore with network rollback. type RestoreAbortInfo struct { NetworkRollbackArmed bool @@ -61,6 +38,7 @@ func ClearRestoreAbortInfo() { lastRestoreAbortInfo = nil } +// RunRestoreWorkflow runs the CLI restore workflow using stdin prompts and the provided configuration. func RunRestoreWorkflow(ctx context.Context, cfg *config.Config, logger *logging.Logger, version string) (err error) { if cfg == nil { return fmt.Errorf("configuration not available") @@ -75,1715 +53,3 @@ func RunRestoreWorkflow(ctx context.Context, cfg *config.Config, logger *logging ui := newCLIWorkflowUI(bufio.NewReader(os.Stdin), logger) return runRestoreWorkflowWithUI(ctx, cfg, logger, version, ui) } - -// checkZFSPoolsAfterRestore checks if ZFS pools need to be imported after restore -func checkZFSPoolsAfterRestore(logger *logging.Logger) error { - if _, err := restoreCmd.Run(context.Background(), "which", "zpool"); err != nil { - // zpool utility not available -> no ZFS tooling installed - return nil - } - - logger.Info("Checking ZFS pool status...") - - configuredPools := detectConfiguredZFSPools() - importablePools, importOutput, importErr := detectImportableZFSPools() - - if len(configuredPools) > 0 { - logger.Warning("Found %d ZFS pool(s) configured for automatic import:", len(configuredPools)) - for _, pool := range configuredPools { - logger.Warning(" - %s", pool) - } - logger.Info("") - } - - if importErr != nil { - logger.Warning("`zpool import` command returned an error: %v", importErr) - if strings.TrimSpace(importOutput) != "" { - logger.Warning("`zpool import` output:\n%s", importOutput) - } - } else if len(importablePools) > 0 { - logger.Warning("`zpool import` reports pools waiting to be imported:") - for _, pool := range importablePools { - logger.Warning(" - %s", pool) - } - logger.Info("") - } - - if len(importablePools) == 0 { - logger.Info("`zpool import` did not report pools waiting for import.") - - if len(configuredPools) > 0 { - logger.Info("") - for _, pool := range configuredPools { - if _, err := restoreCmd.Run(context.Background(), "zpool", "status", pool); err == nil { - logger.Info("Pool %s is already imported (no manual action needed)", pool) - } else { - logger.Warning("Systemd expects pool %s, but `zpool import` and `zpool status` did not report it. Check disk visibility and pool status.", pool) - } - } - } - return nil - } - - logger.Info("⚠ IMPORTANT: ZFS pools may need manual import after restore!") - logger.Info(" Before rebooting, run these commands:") - logger.Info(" 1. Check available pools: zpool import") - for _, pool := range importablePools { - logger.Info(" 2. Import pool manually: zpool import %s", pool) - } - logger.Info(" 3. Verify pool status: zpool status") - logger.Info("") - logger.Info(" If pools fail to import, check:") - logger.Info(" - journalctl -u zfs-import@.service oppure import@.service") - logger.Info(" - zpool import -d /dev/disk/by-id") - logger.Info("") - - return nil -} - -func stopPVEClusterServices(ctx context.Context, logger *logging.Logger) error { - services := []string{"pve-cluster", "pvedaemon", "pveproxy", "pvestatd"} - for _, service := range services { - if err := stopServiceWithRetries(ctx, logger, service); err != nil { - return fmt.Errorf("failed to stop PVE services (%s): %w", service, err) - } - } - return nil -} - -func startPVEClusterServices(ctx context.Context, logger *logging.Logger) error { - services := []string{"pve-cluster", "pvedaemon", "pveproxy", "pvestatd"} - for _, service := range services { - if err := startServiceWithRetries(ctx, logger, service); err != nil { - return fmt.Errorf("failed to start PVE services (%s): %w", service, err) - } - } - return nil -} - -func stopPBSServices(ctx context.Context, logger *logging.Logger) error { - if _, err := restoreCmd.Run(ctx, "which", "systemctl"); err != nil { - return fmt.Errorf("systemctl not available: %w", err) - } - services := []string{"proxmox-backup-proxy", "proxmox-backup"} - var failures []string - for _, service := range services { - if err := stopServiceWithRetries(ctx, logger, service); err != nil { - failures = append(failures, fmt.Sprintf("%s: %v", service, err)) - } - } - if len(failures) > 0 { - return errors.New(strings.Join(failures, "; ")) - } - return nil -} - -func startPBSServices(ctx context.Context, logger *logging.Logger) error { - if _, err := restoreCmd.Run(ctx, "which", "systemctl"); err != nil { - return fmt.Errorf("systemctl not available: %w", err) - } - services := []string{"proxmox-backup", "proxmox-backup-proxy"} - var failures []string - for _, service := range services { - if err := startServiceWithRetries(ctx, logger, service); err != nil { - failures = append(failures, fmt.Sprintf("%s: %v", service, err)) - } - } - if len(failures) > 0 { - return errors.New(strings.Join(failures, "; ")) - } - return nil -} - -func unmountEtcPVE(ctx context.Context, logger *logging.Logger) error { - output, err := restoreCmd.Run(ctx, "umount", "/etc/pve") - msg := strings.TrimSpace(string(output)) - if err != nil { - if strings.Contains(msg, "not mounted") { - logger.Info("Skipping umount /etc/pve (already unmounted)") - return nil - } - if msg != "" { - return fmt.Errorf("umount /etc/pve failed: %s", msg) - } - return fmt.Errorf("umount /etc/pve failed: %w", err) - } - if msg != "" { - logger.Debug("umount /etc/pve output: %s", msg) - } - return nil -} - -func runCommandWithTimeout(ctx context.Context, logger *logging.Logger, timeout time.Duration, name string, args ...string) error { - return execCommand(ctx, logger, timeout, name, args...) -} - -func execCommand(ctx context.Context, logger *logging.Logger, timeout time.Duration, name string, args ...string) error { - execCtx := ctx - var cancel context.CancelFunc - if timeout > 0 { - execCtx, cancel = context.WithTimeout(ctx, timeout) - defer cancel() - } - - output, err := restoreCmd.Run(execCtx, name, args...) - msg := strings.TrimSpace(string(output)) - if err != nil { - if timeout > 0 && (errors.Is(execCtx.Err(), context.DeadlineExceeded) || errors.Is(err, context.DeadlineExceeded)) { - return fmt.Errorf("%s %s timed out after %s", name, strings.Join(args, " "), timeout) - } - if msg != "" { - return fmt.Errorf("%s %s failed: %s", name, strings.Join(args, " "), msg) - } - return fmt.Errorf("%s %s failed: %w", name, strings.Join(args, " "), err) - } - if msg != "" && logger != nil { - logger.Debug("%s %s: %s", name, strings.Join(args, " "), msg) - } - return nil -} - -func stopServiceWithRetries(ctx context.Context, logger *logging.Logger, service string) error { - attempts := []struct { - description string - args []string - timeout time.Duration - }{ - {"stop (no-block)", []string{"stop", "--no-block", service}, serviceStopNoBlockTimeout}, - {"stop (blocking)", []string{"stop", service}, serviceStopTimeout}, - {"aggressive stop", []string{"kill", "--signal=SIGTERM", "--kill-who=all", service}, serviceStopTimeout}, - {"force kill", []string{"kill", "--signal=SIGKILL", "--kill-who=all", service}, serviceStopTimeout}, - } - - var lastErr error - for i, attempt := range attempts { - if i > 0 { - if err := sleepWithContext(ctx, serviceRetryDelay); err != nil { - return err - } - } - - if logger != nil { - logger.Debug("Attempting %s for %s (%d/%d)", attempt.description, service, i+1, len(attempts)) - } - - if err := runCommandWithTimeoutCountdown(ctx, logger, attempt.timeout, service, attempt.description, "systemctl", attempt.args...); err != nil { - lastErr = err - continue - } - if err := waitForServiceInactive(ctx, logger, service, serviceVerifyTimeout); err != nil { - lastErr = err - continue - } - resetFailedService(ctx, logger, service) - return nil - } - - if lastErr == nil { - lastErr = fmt.Errorf("unable to stop %s", service) - } - return lastErr -} - -func startServiceWithRetries(ctx context.Context, logger *logging.Logger, service string) error { - attempts := []struct { - description string - args []string - }{ - {"start", []string{"start", service}}, - {"retry start", []string{"start", service}}, - {"aggressive restart", []string{"restart", service}}, - } - - var lastErr error - for i, attempt := range attempts { - if i > 0 { - if err := sleepWithContext(ctx, serviceRetryDelay); err != nil { - return err - } - } - - if logger != nil { - logger.Debug("Attempting %s for %s (%d/%d)", attempt.description, service, i+1, len(attempts)) - } - - if err := runCommandWithTimeout(ctx, logger, serviceStartTimeout, "systemctl", attempt.args...); err != nil { - lastErr = err - continue - } - return nil - } - - if lastErr == nil { - lastErr = fmt.Errorf("unable to start %s", service) - } - return lastErr -} - -func runCommandWithTimeoutCountdown(ctx context.Context, logger *logging.Logger, timeout time.Duration, service, action, name string, args ...string) error { - if timeout <= 0 { - return execCommand(ctx, logger, timeout, name, args...) - } - - execCtx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - type result struct { - out []byte - err error - } - - resultCh := make(chan result, 1) - go func() { - out, err := restoreCmd.Run(execCtx, name, args...) - resultCh <- result{out: out, err: err} - }() - - progressEnabled := isTerminal(int(os.Stderr.Fd())) - deadline := time.Now().Add(timeout) - ticker := time.NewTicker(1 * time.Second) - defer ticker.Stop() - - writeProgress := func(left time.Duration) { - if !progressEnabled { - return - } - seconds := int(left.Round(time.Second).Seconds()) - if seconds < 0 { - seconds = 0 - } - fmt.Fprintf(os.Stderr, "\rStopping %s: %s (attempt timeout in %ds)...", service, action, seconds) - } - - for { - select { - case r := <-resultCh: - if progressEnabled { - fmt.Fprint(os.Stderr, "\r") - fmt.Fprintln(os.Stderr, strings.Repeat(" ", 80)) - fmt.Fprint(os.Stderr, "\r") - } - msg := strings.TrimSpace(string(r.out)) - if r.err != nil { - if errors.Is(execCtx.Err(), context.DeadlineExceeded) || errors.Is(r.err, context.DeadlineExceeded) { - return fmt.Errorf("%s %s timed out after %s", name, strings.Join(args, " "), timeout) - } - if msg != "" { - return fmt.Errorf("%s %s failed: %s", name, strings.Join(args, " "), msg) - } - return fmt.Errorf("%s %s failed: %w", name, strings.Join(args, " "), r.err) - } - if msg != "" && logger != nil { - logger.Debug("%s %s: %s", name, strings.Join(args, " "), msg) - } - return nil - case <-ticker.C: - writeProgress(time.Until(deadline)) - case <-execCtx.Done(): - writeProgress(0) - if progressEnabled { - fmt.Fprintln(os.Stderr) - } - select { - case r := <-resultCh: - msg := strings.TrimSpace(string(r.out)) - if msg != "" && logger != nil { - logger.Debug("%s %s: %s", name, strings.Join(args, " "), msg) - } - default: - } - return fmt.Errorf("%s %s timed out after %s", name, strings.Join(args, " "), timeout) - } - } -} - -func waitForServiceInactive(ctx context.Context, logger *logging.Logger, service string, timeout time.Duration) error { - if timeout <= 0 { - return nil - } - deadline := time.Now().Add(timeout) - progressEnabled := isTerminal(int(os.Stderr.Fd())) - ticker := time.NewTicker(1 * time.Second) - defer ticker.Stop() - for { - remaining := time.Until(deadline) - if remaining <= 0 { - if progressEnabled { - fmt.Fprintln(os.Stderr) - } - return fmt.Errorf("%s still active after %s", service, timeout) - } - - checkTimeout := minDuration(remaining, serviceStatusCheckTimeout) - active, err := isServiceActive(ctx, service, checkTimeout) - if err != nil { - return err - } - if !active { - if logger != nil { - logger.Debug("%s stopped successfully", service) - } - return nil - } - - wait := minDuration(remaining, servicePollInterval) - timer := time.NewTimer(wait) - select { - case <-ctx.Done(): - if !timer.Stop() { - <-timer.C - } - if progressEnabled { - fmt.Fprintln(os.Stderr) - } - return ctx.Err() - case <-timer.C: - } - select { - case <-ticker.C: - if progressEnabled { - seconds := int(remaining.Round(time.Second).Seconds()) - if seconds < 0 { - seconds = 0 - } - fmt.Fprintf(os.Stderr, "\rWaiting for %s to stop (%ds remaining)...", service, seconds) - } - default: - } - } -} - -func resetFailedService(ctx context.Context, logger *logging.Logger, service string) { - resetCtx, cancel := context.WithTimeout(ctx, serviceStatusCheckTimeout) - defer cancel() - - if _, err := restoreCmd.Run(resetCtx, "systemctl", "reset-failed", service); err != nil { - if logger != nil { - logger.Debug("systemctl reset-failed %s ignored: %v", service, err) - } - } -} - -func isServiceActive(ctx context.Context, service string, timeout time.Duration) (bool, error) { - if timeout <= 0 { - timeout = serviceStatusCheckTimeout - } - checkCtx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - output, err := restoreCmd.Run(checkCtx, "systemctl", "is-active", service) - msg := strings.TrimSpace(string(output)) - if err == nil { - return true, nil - } - if errors.Is(checkCtx.Err(), context.DeadlineExceeded) || errors.Is(err, context.DeadlineExceeded) { - return false, fmt.Errorf("systemctl is-active %s timed out after %s", service, timeout) - } - if msg == "" { - msg = err.Error() - } - lower := strings.ToLower(msg) - if strings.Contains(lower, "deactivating") || strings.Contains(lower, "activating") { - return true, nil - } - if strings.Contains(lower, "inactive") || strings.Contains(lower, "failed") || strings.Contains(lower, "dead") { - return false, nil - } - return false, fmt.Errorf("systemctl is-active %s failed: %s", service, msg) -} - -func minDuration(a, b time.Duration) time.Duration { - if a < b { - return a - } - return b -} - -func sleepWithContext(ctx context.Context, d time.Duration) error { - if d <= 0 { - return nil - } - timer := time.NewTimer(d) - defer timer.Stop() - select { - case <-ctx.Done(): - return ctx.Err() - case <-timer.C: - return nil - } -} - -func detectConfiguredZFSPools() []string { - pools := make(map[string]struct{}) - - directories := []string{ - "/etc/systemd/system/zfs-import.target.wants", - "/etc/systemd/system/multi-user.target.wants", - } - - for _, dir := range directories { - entries, err := restoreFS.ReadDir(dir) - if err != nil { - continue - } - - for _, entry := range entries { - if pool := parsePoolNameFromUnit(entry.Name()); pool != "" { - pools[pool] = struct{}{} - } - } - } - - globPatterns := []string{ - "/etc/systemd/system/zfs-import@*.service", - "/etc/systemd/system/import@*.service", - } - - for _, pattern := range globPatterns { - matches, err := restoreGlob(pattern) - if err != nil { - continue - } - for _, match := range matches { - if pool := parsePoolNameFromUnit(filepath.Base(match)); pool != "" { - pools[pool] = struct{}{} - } - } - } - - var poolNames []string - for pool := range pools { - poolNames = append(poolNames, pool) - } - sort.Strings(poolNames) - return poolNames -} - -func parsePoolNameFromUnit(unitName string) string { - switch { - case strings.HasPrefix(unitName, "zfs-import@") && strings.HasSuffix(unitName, ".service"): - pool := strings.TrimPrefix(unitName, "zfs-import@") - return strings.TrimSuffix(pool, ".service") - case strings.HasPrefix(unitName, "import@") && strings.HasSuffix(unitName, ".service"): - pool := strings.TrimPrefix(unitName, "import@") - return strings.TrimSuffix(pool, ".service") - default: - return "" - } -} - -func detectImportableZFSPools() ([]string, string, error) { - output, err := restoreCmd.Run(context.Background(), "zpool", "import") - poolNames := parseZpoolImportOutput(string(output)) - if err != nil { - return poolNames, string(output), err - } - return poolNames, string(output), nil -} - -func parseZpoolImportOutput(output string) []string { - var pools []string - scanner := bufio.NewScanner(strings.NewReader(output)) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if strings.HasPrefix(strings.ToLower(line), "pool:") { - pool := strings.TrimSpace(line[len("pool:"):]) - if pool != "" { - pools = append(pools, pool) - } - } - } - return pools -} - -func combinePoolNames(a, b []string) []string { - merged := make(map[string]struct{}) - for _, pool := range a { - merged[pool] = struct{}{} - } - for _, pool := range b { - merged[pool] = struct{}{} - } - - if len(merged) == 0 { - return nil - } - - names := make([]string, 0, len(merged)) - for pool := range merged { - names = append(names, pool) - } - sort.Strings(names) - return names -} - -func shouldRecreateDirectories(systemType SystemType, categories []Category) bool { - return (systemType.SupportsPVE() && hasCategoryID(categories, "storage_pve")) || - (systemType.SupportsPBS() && hasCategoryID(categories, "datastore_pbs")) -} - -func hasCategoryID(categories []Category, id string) bool { - for _, cat := range categories { - if cat.ID == id { - return true - } - } - return false -} - -// shouldStopPBSServices reports whether any selected categories belong to PBS-specific configuration -// and therefore require stopping PBS services before restore. -func shouldStopPBSServices(categories []Category) bool { - for _, cat := range categories { - if cat.Type == CategoryTypePBS { - return true - } - // Some common categories (e.g. SSL) include PBS paths that require restarting PBS services. - for _, p := range cat.Paths { - p = strings.TrimSpace(p) - if strings.HasPrefix(p, "./etc/proxmox-backup/") || strings.HasPrefix(p, "./var/lib/proxmox-backup/") { - return true - } - } - } - return false -} - -func splitExportCategories(categories []Category) (normal []Category, export []Category) { - for _, cat := range categories { - if cat.ExportOnly { - export = append(export, cat) - continue - } - normal = append(normal, cat) - } - return normal, export -} - -// redirectClusterCategoryToExport removes pve_cluster from normal categories and adds it to export-only list. -func redirectClusterCategoryToExport(normal []Category, export []Category) ([]Category, []Category) { - filtered := make([]Category, 0, len(normal)) - for _, cat := range normal { - if cat.ID == "pve_cluster" { - export = append(export, cat) - continue - } - filtered = append(filtered, cat) - } - return filtered, export -} - -func exportDestRoot(baseDir string) string { - base := strings.TrimSpace(baseDir) - if base == "" { - base = "/opt/proxsave" - } - return filepath.Join(base, fmt.Sprintf("proxmox-config-export-%s", nowRestore().Format("20060102-150405"))) -} - -// runFullRestore performs a full restore without selective options (fallback) -func runFullRestore(ctx context.Context, reader *bufio.Reader, candidate *backupCandidate, prepared *preparedBundle, destRoot string, logger *logging.Logger, dryRun bool) error { - if err := confirmRestoreAction(ctx, reader, candidate, destRoot); err != nil { - return err - } - - safeFstabMerge := destRoot == "/" && isRealRestoreFS(restoreFS) - skipFn := func(name string) bool { - if !safeFstabMerge { - return false - } - clean := strings.TrimPrefix(strings.TrimSpace(name), "./") - clean = strings.TrimPrefix(clean, "/") - return clean == "etc/fstab" - } - - if safeFstabMerge { - logger.Warning("Full restore safety: /etc/fstab will not be overwritten; Smart Merge will be applied after extraction.") - } - - if err := extractPlainArchive(ctx, prepared.ArchivePath, destRoot, logger, skipFn); err != nil { - return err - } - - if safeFstabMerge { - logger.Info("") - fsTempDir, err := restoreFS.MkdirTemp("", "proxsave-fstab-") - if err != nil { - logger.Warning("Failed to create temp dir for fstab merge: %v", err) - } else { - defer restoreFS.RemoveAll(fsTempDir) - fsCategory := []Category{{ - ID: "filesystem", - Name: "Filesystem Configuration", - Paths: []string{ - "./etc/fstab", - }, - }} - if err := extractArchiveNative(ctx, prepared.ArchivePath, fsTempDir, logger, fsCategory, RestoreModeCustom, nil, "", nil); err != nil { - logger.Warning("Failed to extract filesystem config for merge: %v", err) - } else { - // Best-effort: extract ProxSave inventory files used for stable fstab device remapping. - invCategory := []Category{{ - ID: "fstab_inventory", - Name: "Fstab inventory (device mapping)", - Paths: []string{ - "./var/lib/proxsave-info/commands/system/blkid.txt", - "./var/lib/proxsave-info/commands/system/lsblk_json.json", - "./var/lib/proxsave-info/commands/system/lsblk.txt", - "./var/lib/proxsave-info/commands/pbs/pbs_datastore_inventory.json", - }, - }} - if err := extractArchiveNative(ctx, prepared.ArchivePath, fsTempDir, logger, invCategory, RestoreModeCustom, nil, "", nil); err != nil { - logger.Debug("Failed to extract fstab inventory data (continuing): %v", err) - } - - currentFstab := filepath.Join(destRoot, "etc", "fstab") - backupFstab := filepath.Join(fsTempDir, "etc", "fstab") - if err := SmartMergeFstab(ctx, logger, reader, currentFstab, backupFstab, dryRun); err != nil { - if errors.Is(err, ErrRestoreAborted) || input.IsAborted(err) { - logger.Info("Restore aborted by user during Smart Filesystem Configuration Merge.") - return err - } - logger.Warning("Smart Fstab Merge failed: %v", err) - } - } - } - } - - logger.Info("Restore completed successfully.") - return nil -} - -func confirmRestoreAction(ctx context.Context, reader *bufio.Reader, cand *backupCandidate, dest string) error { - manifest := cand.Manifest - fmt.Println() - fmt.Printf("Selected backup: %s (%s)\n", cand.DisplayBase, manifest.CreatedAt.Format("2006-01-02 15:04:05")) - cleanDest := filepath.Clean(strings.TrimSpace(dest)) - if cleanDest == "" || cleanDest == "." { - cleanDest = string(os.PathSeparator) - } - if cleanDest == string(os.PathSeparator) { - fmt.Println("Restore destination: / (system root; original paths will be preserved)") - fmt.Println("WARNING: This operation will overwrite configuration files on this system.") - } else { - fmt.Printf("Restore destination: %s (original paths will be preserved under this directory)\n", cleanDest) - fmt.Printf("WARNING: This operation will overwrite existing files under %s.\n", cleanDest) - } - fmt.Println("Type RESTORE to proceed or 0 to cancel.") - - for { - fmt.Print("Confirmation: ") - line, err := input.ReadLineWithContext(ctx, reader) - if err != nil { - return err - } - switch strings.TrimSpace(line) { - case "RESTORE": - return nil - case "0": - return ErrRestoreAborted - default: - fmt.Println("Please type RESTORE to confirm or 0 to cancel.") - } - } -} - -func extractPlainArchive(ctx context.Context, archivePath, destRoot string, logger *logging.Logger, skipFn func(entryName string) bool) error { - if err := restoreFS.MkdirAll(destRoot, 0o755); err != nil { - return fmt.Errorf("create destination directory: %w", err) - } - - // Only enforce root privileges when writing to the real system root. - if destRoot == "/" && isRealRestoreFS(restoreFS) && os.Geteuid() != 0 { - return fmt.Errorf("restore to %s requires root privileges", destRoot) - } - - logger.Info("Extracting archive %s into %s", filepath.Base(archivePath), destRoot) - - // Use native Go extraction to preserve atime/ctime from PAX headers - if err := extractArchiveNative(ctx, archivePath, destRoot, logger, nil, RestoreModeFull, nil, "", skipFn); err != nil { - return fmt.Errorf("archive extraction failed: %w", err) - } - - return nil -} - -// runSafeClusterApply applies selected cluster configs via pvesh without touching config.db. -// It operates on files extracted to exportRoot (e.g. exportDestRoot). -func runSafeClusterApply(ctx context.Context, reader *bufio.Reader, exportRoot string, logger *logging.Logger) (err error) { - if logger == nil { - logger = logging.GetDefaultLogger() - } - ui := newCLIWorkflowUI(reader, logger) - return runSafeClusterApplyWithUI(ctx, ui, exportRoot, logger, nil) -} - -type vmEntry struct { - VMID string - Kind string // qemu | lxc - Name string - Path string -} - -func scanVMConfigs(exportRoot, node string) ([]vmEntry, error) { - var entries []vmEntry - base := filepath.Join(exportRoot, "etc/pve/nodes", node) - - type dirSpec struct { - kind string - path string - } - - dirs := []dirSpec{ - {kind: "qemu", path: filepath.Join(base, "qemu-server")}, - {kind: "lxc", path: filepath.Join(base, "lxc")}, - } - - for _, spec := range dirs { - infos, err := restoreFS.ReadDir(spec.path) - if err != nil { - continue - } - for _, entry := range infos { - if entry.IsDir() { - continue - } - name := entry.Name() - if !strings.HasSuffix(name, ".conf") { - continue - } - vmid := strings.TrimSuffix(name, ".conf") - vmPath := filepath.Join(spec.path, name) - vmName := readVMName(vmPath) - entries = append(entries, vmEntry{ - VMID: vmid, - Kind: spec.kind, - Name: vmName, - Path: vmPath, - }) - } - } - - return entries, nil -} - -func listExportNodeDirs(exportRoot string) ([]string, error) { - nodesRoot := filepath.Join(exportRoot, "etc/pve/nodes") - entries, err := restoreFS.ReadDir(nodesRoot) - if err != nil { - if errors.Is(err, os.ErrNotExist) || os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - - var nodes []string - for _, entry := range entries { - if !entry.IsDir() { - continue - } - name := strings.TrimSpace(entry.Name()) - if name == "" { - continue - } - nodes = append(nodes, name) - } - sort.Strings(nodes) - return nodes, nil -} - -func countVMConfigsForNode(exportRoot, node string) (qemuCount, lxcCount int) { - base := filepath.Join(exportRoot, "etc/pve/nodes", node) - - countInDir := func(dir string) int { - entries, err := restoreFS.ReadDir(dir) - if err != nil { - return 0 - } - n := 0 - for _, entry := range entries { - if entry.IsDir() { - continue - } - if strings.HasSuffix(entry.Name(), ".conf") { - n++ - } - } - return n - } - - qemuCount = countInDir(filepath.Join(base, "qemu-server")) - lxcCount = countInDir(filepath.Join(base, "lxc")) - return qemuCount, lxcCount -} - -func promptExportNodeSelection(ctx context.Context, reader *bufio.Reader, exportRoot, currentNode string, exportNodes []string) (string, error) { - for { - fmt.Println() - fmt.Printf("WARNING: VM/CT configs in this backup are stored under different node names.\n") - fmt.Printf("Current node: %s\n", currentNode) - fmt.Println("Select which exported node to import VM/CT configs from (they will be applied to the current node):") - for idx, node := range exportNodes { - qemuCount, lxcCount := countVMConfigsForNode(exportRoot, node) - fmt.Printf(" [%d] %s (qemu=%d, lxc=%d)\n", idx+1, node, qemuCount, lxcCount) - } - fmt.Println(" [0] Skip VM/CT apply") - - fmt.Print("Choice: ") - line, err := input.ReadLineWithContext(ctx, reader) - if err != nil { - return "", err - } - trimmed := strings.TrimSpace(line) - if trimmed == "0" { - return "", nil - } - if trimmed == "" { - continue - } - idx, err := parseMenuIndex(trimmed, len(exportNodes)) - if err != nil { - fmt.Println(err) - continue - } - return exportNodes[idx], nil - } -} - -func stringSliceContains(items []string, want string) bool { - for _, item := range items { - if item == want { - return true - } - } - return false -} - -func readVMName(confPath string) string { - data, err := restoreFS.ReadFile(confPath) - if err != nil { - return "" - } - for _, line := range strings.Split(string(data), "\n") { - t := strings.TrimSpace(line) - if strings.HasPrefix(t, "name:") { - return strings.TrimSpace(strings.TrimPrefix(t, "name:")) - } - if strings.HasPrefix(t, "hostname:") { - return strings.TrimSpace(strings.TrimPrefix(t, "hostname:")) - } - } - return "" -} - -func applyVMConfigs(ctx context.Context, entries []vmEntry, logger *logging.Logger) (applied, failed int) { - for _, vm := range entries { - if err := ctx.Err(); err != nil { - logger.Warning("VM apply aborted: %v", err) - return applied, failed - } - target := fmt.Sprintf("/nodes/%s/%s/%s/config", detectNodeForVM(), vm.Kind, vm.VMID) - args := []string{"set", target, "--filename", vm.Path} - if err := runPvesh(ctx, logger, args); err != nil { - logger.Warning("Failed to apply %s (vmid=%s kind=%s): %v", target, vm.VMID, vm.Kind, err) - failed++ - } else { - display := vm.VMID - if vm.Name != "" { - display = fmt.Sprintf("%s (%s)", vm.VMID, vm.Name) - } - logger.Info("Applied VM/CT config %s", display) - applied++ - } - } - return applied, failed -} - -func detectNodeForVM() string { - host, _ := os.Hostname() - host = shortHost(host) - if host != "" { - return host - } - return "localhost" -} - -type storageBlock struct { - ID string - data []string -} - -func applyStorageCfg(ctx context.Context, cfgPath string, logger *logging.Logger) (applied, failed int, err error) { - blocks, perr := parseStorageBlocks(cfgPath) - if perr != nil { - return 0, 0, perr - } - if len(blocks) == 0 { - logger.Info("No storage definitions detected in storage.cfg") - return 0, 0, nil - } - - for _, blk := range blocks { - tmp, tmpErr := restoreFS.CreateTemp("", fmt.Sprintf("pve-storage-%s-*.cfg", sanitizeID(blk.ID))) - if tmpErr != nil { - failed++ - continue - } - tmpName := tmp.Name() - if _, werr := tmp.WriteString(strings.Join(blk.data, "\n") + "\n"); werr != nil { - _ = tmp.Close() - _ = restoreFS.Remove(tmpName) - failed++ - continue - } - _ = tmp.Close() - - args := []string{"set", fmt.Sprintf("/cluster/storage/%s", blk.ID), "-conf", tmpName} - if runErr := runPvesh(ctx, logger, args); runErr != nil { - logger.Warning("Failed to apply storage %s: %v", blk.ID, runErr) - failed++ - } else { - logger.Info("Applied storage definition %s", blk.ID) - applied++ - } - - _ = restoreFS.Remove(tmpName) - - if err := ctx.Err(); err != nil { - return applied, failed, err - } - } - - return applied, failed, nil -} - -func parseStorageBlocks(cfgPath string) ([]storageBlock, error) { - data, err := restoreFS.ReadFile(cfgPath) - if err != nil { - return nil, err - } - - var blocks []storageBlock - var current *storageBlock - - flush := func() { - if current != nil { - blocks = append(blocks, *current) - current = nil - } - } - - for _, line := range strings.Split(string(data), "\n") { - trimmed := strings.TrimSpace(line) - if trimmed == "" { - flush() - continue - } - - // storage.cfg blocks use `: ` (e.g. `dir: local`, `nfs: backup`). - // Older exports may still use `storage: ` blocks. - _, name, ok := parseProxmoxNotificationHeader(trimmed) - if ok { - flush() - current = &storageBlock{ID: name, data: []string{line}} - continue - } - if current != nil { - current.data = append(current.data, line) - } - } - flush() - - return blocks, nil -} - -func runPvesh(ctx context.Context, logger *logging.Logger, args []string) error { - output, err := restoreCmd.Run(ctx, "pvesh", args...) - if len(output) > 0 { - logger.Debug("pvesh %v output: %s", args, strings.TrimSpace(string(output))) - } - if err != nil { - return fmt.Errorf("pvesh %v failed: %w", args, err) - } - return nil -} - -func shortHost(host string) string { - if idx := strings.Index(host, "."); idx > 0 { - return host[:idx] - } - return host -} - -func sanitizeID(id string) string { - var b strings.Builder - for _, r := range id { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { - b.WriteRune(r) - } else { - b.WriteRune('_') - } - } - return b.String() -} - -// promptClusterRestoreMode asks how to handle cluster database restore (safe export vs full recovery). -func promptClusterRestoreMode(ctx context.Context, reader *bufio.Reader) (int, error) { - fmt.Println() - fmt.Println("Cluster backup detected. Choose how to restore the cluster database:") - fmt.Println(" [1] SAFE: Do NOT write /var/lib/pve-cluster/config.db. Export cluster files only (manual/apply via API).") - fmt.Println(" [2] RECOVERY: Restore full cluster database (/var/lib/pve-cluster). Use only when cluster is offline/isolated.") - fmt.Println(" [0] Exit") - - for { - fmt.Print("Choice: ") - choiceLine, err := input.ReadLineWithContext(ctx, reader) - if err != nil { - return 0, err - } - switch strings.TrimSpace(choiceLine) { - case "1": - return 1, nil - case "2": - return 2, nil - case "0": - return 0, nil - default: - fmt.Println("Please enter 1, 2, or 0.") - } - } -} - -// extractSelectiveArchive extracts only files matching selected categories -func extractSelectiveArchive(ctx context.Context, archivePath, destRoot string, categories []Category, mode RestoreMode, logger *logging.Logger) (logPath string, err error) { - done := logging.DebugStart(logger, "extract selective archive", "archive=%s dest=%s categories=%d mode=%s", archivePath, destRoot, len(categories), mode) - defer func() { done(err) }() - if err := restoreFS.MkdirAll(destRoot, 0o755); err != nil { - return "", fmt.Errorf("create destination directory: %w", err) - } - - // Only enforce root privileges when writing to the real system root. - if destRoot == "/" && isRealRestoreFS(restoreFS) && os.Geteuid() != 0 { - return "", fmt.Errorf("restore to %s requires root privileges", destRoot) - } - - // Create detailed log directory - logDir := "/tmp/proxsave" - if err := restoreFS.MkdirAll(logDir, 0755); err != nil { - logger.Warning("Could not create log directory: %v", err) - } - - // Create detailed log file - timestamp := nowRestore().Format("20060102_150405") - logSeq := atomic.AddUint64(&restoreLogSequence, 1) - logPath = filepath.Join(logDir, fmt.Sprintf("restore_%s_%d.log", timestamp, logSeq)) - logFile, err := restoreFS.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0640) - if err != nil { - logger.Warning("Could not create detailed log file: %v", err) - logFile = nil - } else { - defer logFile.Close() - logger.Info("Detailed restore log: %s", logPath) - logging.DebugStep(logger, "extract selective archive", "log file=%s", logPath) - } - - logger.Info("Extracting selected categories from archive %s into %s", filepath.Base(archivePath), destRoot) - - // Use native Go extraction with category filter - if err := extractArchiveNative(ctx, archivePath, destRoot, logger, categories, mode, logFile, logPath, nil); err != nil { - return logPath, err - } - - return logPath, nil -} - -// extractArchiveNative extracts TAR archives natively in Go, preserving all timestamps -// If categories is nil, all files are extracted. Otherwise, only files matching the categories are extracted. -func extractArchiveNative(ctx context.Context, archivePath, destRoot string, logger *logging.Logger, categories []Category, mode RestoreMode, logFile *os.File, logFilePath string, skipFn func(entryName string) bool) error { - // Open the archive file - file, err := restoreFS.Open(archivePath) - if err != nil { - return fmt.Errorf("open archive: %w", err) - } - defer file.Close() - - // Create decompression reader based on file extension - reader, err := createDecompressionReader(ctx, file, archivePath) - if err != nil { - return fmt.Errorf("create decompression reader: %w", err) - } - if closer, ok := reader.(io.Closer); ok { - defer closer.Close() - } - - // Create TAR reader - tarReader := tar.NewReader(reader) - - // Write log header if log file is available - if logFile != nil { - fmt.Fprintf(logFile, "=== PROXMOX RESTORE LOG ===\n") - fmt.Fprintf(logFile, "Date: %s\n", nowRestore().Format("2006-01-02 15:04:05")) - fmt.Fprintf(logFile, "Mode: %s\n", getModeName(mode)) - if len(categories) > 0 { - fmt.Fprintf(logFile, "Selected categories: %d categories\n", len(categories)) - for _, cat := range categories { - fmt.Fprintf(logFile, " - %s (%s)\n", cat.Name, cat.ID) - } - } else { - fmt.Fprintf(logFile, "Selected categories: ALL (full restore)\n") - } - fmt.Fprintf(logFile, "Archive: %s\n", filepath.Base(archivePath)) - fmt.Fprintf(logFile, "\n") - } - - // Extract files (selective or full) - filesExtracted := 0 - filesSkipped := 0 - filesFailed := 0 - selectiveMode := len(categories) > 0 - - var restoredTemp, skippedTemp *os.File - if logFile != nil { - if tmp, err := restoreFS.CreateTemp("", "restored_entries_*.log"); err == nil { - restoredTemp = tmp - defer func() { - tmp.Close() - _ = restoreFS.Remove(tmp.Name()) - }() - } else { - logger.Warning("Could not create temporary file for restored entries: %v", err) - } - - if tmp, err := restoreFS.CreateTemp("", "skipped_entries_*.log"); err == nil { - skippedTemp = tmp - defer func() { - tmp.Close() - _ = restoreFS.Remove(tmp.Name()) - }() - } else { - logger.Warning("Could not create temporary file for skipped entries: %v", err) - } - } - - for { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - header, err := tarReader.Next() - if err == io.EOF { - break - } - if err != nil { - return fmt.Errorf("read tar header: %w", err) - } - - if skipFn != nil && skipFn(header.Name) { - filesSkipped++ - if skippedTemp != nil { - fmt.Fprintf(skippedTemp, "SKIPPED: %s (skipped by restore policy)\n", header.Name) - } - continue - } - - // Check if file should be extracted (selective mode) - if selectiveMode { - shouldExtract := false - for _, cat := range categories { - if PathMatchesCategory(header.Name, cat) { - shouldExtract = true - break - } - } - - if !shouldExtract { - filesSkipped++ - if skippedTemp != nil { - fmt.Fprintf(skippedTemp, "SKIPPED: %s (does not match any selected category)\n", header.Name) - } - continue - } - } - - if err := extractTarEntry(tarReader, header, destRoot, logger); err != nil { - logger.Warning("Failed to extract %s: %v", header.Name, err) - filesFailed++ - continue - } - - filesExtracted++ - if restoredTemp != nil { - fmt.Fprintf(restoredTemp, "RESTORED: %s\n", header.Name) - } - if filesExtracted%100 == 0 { - logger.Debug("Extracted %d files...", filesExtracted) - } - } - - // Write detailed log - if logFile != nil { - fmt.Fprintf(logFile, "=== FILES RESTORED ===\n") - if restoredTemp != nil { - if _, err := restoredTemp.Seek(0, 0); err == nil { - if _, err := io.Copy(logFile, restoredTemp); err != nil { - logger.Warning("Could not write restored entries to log: %v", err) - } - } - } - fmt.Fprintf(logFile, "\n") - - fmt.Fprintf(logFile, "=== FILES SKIPPED ===\n") - if skippedTemp != nil { - if _, err := skippedTemp.Seek(0, 0); err == nil { - if _, err := io.Copy(logFile, skippedTemp); err != nil { - logger.Warning("Could not write skipped entries to log: %v", err) - } - } - } - fmt.Fprintf(logFile, "\n") - - fmt.Fprintf(logFile, "=== SUMMARY ===\n") - fmt.Fprintf(logFile, "Total files extracted: %d\n", filesExtracted) - fmt.Fprintf(logFile, "Total files skipped: %d\n", filesSkipped) - fmt.Fprintf(logFile, "Total files in archive: %d\n", filesExtracted+filesSkipped) - } - - if filesFailed == 0 { - if selectiveMode { - logger.Info("Successfully restored all %d configuration files/directories", filesExtracted) - } else { - logger.Info("Successfully restored all %d files/directories", filesExtracted) - } - } else { - logger.Warning("Restored %d files/directories; %d item(s) failed (see detailed log)", filesExtracted, filesFailed) - } - - if filesSkipped > 0 { - logger.Info("%d additional archive entries (logs, diagnostics, system defaults) were left unchanged on this system; see detailed log for details", filesSkipped) - } - - if logFilePath != "" { - logger.Info("Detailed restore log: %s", logFilePath) - } - - return nil -} - -func isRealRestoreFS(fs FS) bool { - switch fs.(type) { - case osFS, *osFS: - return true - default: - return false - } -} - -// createDecompressionReader creates appropriate decompression reader based on file extension -func createDecompressionReader(ctx context.Context, file *os.File, archivePath string) (io.Reader, error) { - switch { - case strings.HasSuffix(archivePath, ".tar.gz") || strings.HasSuffix(archivePath, ".tgz"): - return gzip.NewReader(file) - case strings.HasSuffix(archivePath, ".tar.xz"): - return createXZReader(ctx, file) - case strings.HasSuffix(archivePath, ".tar.zst") || strings.HasSuffix(archivePath, ".tar.zstd"): - return createZstdReader(ctx, file) - case strings.HasSuffix(archivePath, ".tar.bz2"): - return createBzip2Reader(ctx, file) - case strings.HasSuffix(archivePath, ".tar.lzma"): - return createLzmaReader(ctx, file) - case strings.HasSuffix(archivePath, ".tar"): - return file, nil - default: - return nil, fmt.Errorf("unsupported archive format: %s", filepath.Base(archivePath)) - } -} - -// createXZReader creates an XZ decompression reader using injectable command runner -func createXZReader(ctx context.Context, file *os.File) (io.Reader, error) { - return runRestoreCommandStream(ctx, "xz", file, "-d", "-c") -} - -// createZstdReader creates a Zstd decompression reader using injectable command runner -func createZstdReader(ctx context.Context, file *os.File) (io.Reader, error) { - return runRestoreCommandStream(ctx, "zstd", file, "-d", "-c") -} - -// createBzip2Reader creates a Bzip2 decompression reader using injectable command runner -func createBzip2Reader(ctx context.Context, file *os.File) (io.Reader, error) { - return runRestoreCommandStream(ctx, "bzip2", file, "-d", "-c") -} - -// createLzmaReader creates an LZMA decompression reader using injectable command runner -func createLzmaReader(ctx context.Context, file *os.File) (io.Reader, error) { - return runRestoreCommandStream(ctx, "lzma", file, "-d", "-c") -} - -// runRestoreCommandStream starts a command that reads from stdin and exposes stdout as a ReadCloser. -// It prefers an injectable streaming runner when available; otherwise falls back to exec.CommandContext. -func runRestoreCommandStream(ctx context.Context, name string, stdin io.Reader, args ...string) (io.Reader, error) { - type streamingRunner interface { - RunStream(ctx context.Context, name string, stdin io.Reader, args ...string) (io.ReadCloser, error) - } - if sr, ok := restoreCmd.(streamingRunner); ok && sr != nil { - return sr.RunStream(ctx, name, stdin, args...) - } - - cmd := exec.CommandContext(ctx, name, args...) - cmd.Stdin = stdin - stdout, err := cmd.StdoutPipe() - if err != nil { - return nil, fmt.Errorf("create %s pipe: %w", name, err) - } - if err := cmd.Start(); err != nil { - stdout.Close() - return nil, fmt.Errorf("start %s: %w", name, err) - } - return &waitReadCloser{ReadCloser: stdout, wait: cmd.Wait}, nil -} - -func sanitizeRestoreEntryTarget(destRoot, entryName string) (string, string, error) { - return sanitizeRestoreEntryTargetWithFS(restoreFS, destRoot, entryName) -} - -func sanitizeRestoreEntryTargetWithFS(fsys FS, destRoot, entryName string) (string, string, error) { - cleanDestRoot := filepath.Clean(destRoot) - if cleanDestRoot == "" { - cleanDestRoot = string(os.PathSeparator) - } - - absDestRoot, err := filepath.Abs(cleanDestRoot) - if err != nil { - return "", "", fmt.Errorf("resolve destination root: %w", err) - } - - name := strings.TrimSpace(entryName) - if name == "" { - return "", "", fmt.Errorf("empty archive entry name") - } - - sanitized := path.Clean(name) - for strings.HasPrefix(sanitized, string(os.PathSeparator)) { - sanitized = strings.TrimPrefix(sanitized, string(os.PathSeparator)) - } - - if sanitized == "" || sanitized == "." { - return "", "", fmt.Errorf("invalid archive entry name: %q", entryName) - } - - if sanitized == ".." || strings.HasPrefix(sanitized, "../") || strings.Contains(sanitized, "/../") { - return "", "", fmt.Errorf("illegal path: %s", entryName) - } - - target := filepath.Join(absDestRoot, filepath.FromSlash(sanitized)) - absTarget, err := filepath.Abs(target) - if err != nil { - return "", "", fmt.Errorf("resolve extraction target: %w", err) - } - - rel, err := filepath.Rel(absDestRoot, absTarget) - if err != nil { - return "", "", fmt.Errorf("illegal path: %s: %w", entryName, err) - } - if strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || rel == ".." || filepath.IsAbs(rel) { - return "", "", fmt.Errorf("illegal path: %s", entryName) - } - - if _, err := resolvePathWithinRootFS(fsys, absDestRoot, absTarget); err != nil { - if isPathSecurityError(err) { - return "", "", fmt.Errorf("illegal path: %s: %w", entryName, err) - } - if !isPathOperationalError(err) { - return "", "", fmt.Errorf("resolve extraction target: %w", err) - } - } - - return absTarget, absDestRoot, nil -} - -func shouldSkipProxmoxSystemRestore(relTarget string) (bool, string) { - rel := filepath.ToSlash(filepath.Clean(strings.TrimSpace(relTarget))) - rel = strings.TrimPrefix(rel, "./") - rel = strings.TrimPrefix(rel, "/") - - switch rel { - case "etc/proxmox-backup/domains.cfg": - return true, "PBS auth realms must be recreated (domains.cfg is too fragile to restore raw)" - case "etc/proxmox-backup/user.cfg": - return true, "PBS users must be recreated (user.cfg should not be restored raw)" - case "etc/proxmox-backup/acl.cfg": - return true, "PBS permissions must be recreated (acl.cfg should not be restored raw)" - case "var/lib/proxmox-backup/.clusterlock": - return true, "PBS runtime lock files must not be restored" - } - - if strings.HasPrefix(rel, "var/lib/proxmox-backup/lock/") { - return true, "PBS runtime lock files must not be restored" - } - - return false, "" -} - -// extractTarEntry extracts a single TAR entry, preserving all attributes including atime/ctime -func extractTarEntry(tarReader *tar.Reader, header *tar.Header, destRoot string, logger *logging.Logger) error { - target, cleanDestRoot, err := sanitizeRestoreEntryTargetWithFS(restoreFS, destRoot, header.Name) - if err != nil { - return err - } - - // Hard guard: never write directly into /etc/pve when restoring to system root - if cleanDestRoot == string(os.PathSeparator) && strings.HasPrefix(target, "/etc/pve") { - logger.Warning("Skipping restore to %s (writes to /etc/pve are prohibited)", target) - return nil - } - - if cleanDestRoot == string(os.PathSeparator) { - relTarget, err := filepath.Rel(cleanDestRoot, target) - if err != nil { - return fmt.Errorf("determine restore target for %s: %w", header.Name, err) - } - if skip, reason := shouldSkipProxmoxSystemRestore(relTarget); skip { - logger.Warning("Skipping restore to %s (%s)", target, reason) - return nil - } - } - - // Create parent directories - if err := restoreFS.MkdirAll(filepath.Dir(target), 0755); err != nil { - return fmt.Errorf("create parent directory: %w", err) - } - - switch header.Typeflag { - case tar.TypeDir: - return extractDirectory(target, header, logger) - case tar.TypeReg: - return extractRegularFile(tarReader, target, header, logger) - case tar.TypeSymlink: - return extractSymlink(target, header, cleanDestRoot, logger) - case tar.TypeLink: - return extractHardlink(target, header, cleanDestRoot) - default: - logger.Debug("Skipping unsupported file type %d: %s", header.Typeflag, header.Name) - return nil - } -} - -// extractDirectory creates a directory with proper permissions and timestamps -func extractDirectory(target string, header *tar.Header, logger *logging.Logger) (retErr error) { - // Create with an owner-accessible mode first so the directory can be opened - // before applying restrictive archive permissions. - if err := restoreFS.MkdirAll(target, 0o700); err != nil { - return fmt.Errorf("create directory: %w", err) - } - - dirFile, err := restoreFS.Open(target) - if err != nil { - return fmt.Errorf("open directory: %w", err) - } - defer func() { - if dirFile == nil { - return - } - if err := dirFile.Close(); err != nil && retErr == nil { - retErr = fmt.Errorf("close directory: %w", err) - } - }() - - // Apply metadata on the opened directory handle so logical FS paths - // (e.g. FakeFS-backed test roots) do not leak through to host paths. - // Ownership remains best-effort to match the previous restore behavior on - // unprivileged runs and filesystems that do not support chown. - if err := atomicFileChown(dirFile, header.Uid, header.Gid); err != nil { - logger.Debug("Failed to chown directory %s: %v", target, err) - } - if err := atomicFileChmod(dirFile, os.FileMode(header.Mode)); err != nil { - return fmt.Errorf("chmod directory: %w", err) - } - - // Set timestamps (mtime, atime) - if err := setTimestamps(target, header); err != nil { - logger.Debug("Failed to set timestamps on directory %s: %v", target, err) - } - - return nil -} - -// extractRegularFile extracts a regular file with content and timestamps -func extractRegularFile(tarReader *tar.Reader, target string, header *tar.Header, logger *logging.Logger) (retErr error) { - tmpPath := "" - var outFile *os.File - appendDeferredErr := func(prefix string, err error) { - if err == nil { - return - } - wrapped := fmt.Errorf("%s: %w", prefix, err) - if retErr == nil { - retErr = wrapped - return - } - retErr = errors.Join(retErr, wrapped) - } - closeOutFile := func() error { - if outFile == nil { - return nil - } - err := outFile.Close() - outFile = nil - return err - } - - // Write to a sibling temp file first so a truncated archive entry cannot clobber - // an existing target before the content is fully copied and closed. - outFile, err := restoreFS.CreateTemp(filepath.Dir(target), restoreTempPattern) - if err != nil { - return fmt.Errorf("create file: %w", err) - } - tmpPath = outFile.Name() - defer func() { - appendDeferredErr("close file", closeOutFile()) - if tmpPath != "" { - if err := restoreFS.Remove(tmpPath); err != nil && logger != nil { - logger.Debug("Failed to remove temp file %s: %v", tmpPath, err) - } - } - }() - - // Copy content - if _, err := io.Copy(outFile, tarReader); err != nil { - return fmt.Errorf("write file content: %w", err) - } - - // Set metadata on the temp file before replacing the target so failures do not - // leave the final path in a partially restored state. - // Ownership remains best-effort to match the previous restore behavior on - // unprivileged runs and filesystems that do not support chown. - if err := atomicFileChown(outFile, header.Uid, header.Gid); err != nil { - logger.Debug("Failed to chown file %s: %v", target, err) - } - if err := atomicFileChmod(outFile, os.FileMode(header.Mode)); err != nil { - return fmt.Errorf("chmod file: %w", err) - } - - // Close before renaming into place. - if err := closeOutFile(); err != nil { - return fmt.Errorf("close file: %w", err) - } - - if err := restoreFS.Rename(tmpPath, target); err != nil { - return fmt.Errorf("replace file: %w", err) - } - tmpPath = "" - - // Set timestamps (mtime, atime, ctime via syscall) - if err := setTimestamps(target, header); err != nil { - logger.Debug("Failed to set timestamps on file %s: %v", target, err) - } - - return nil -} - -// extractSymlink creates a symbolic link -func extractSymlink(target string, header *tar.Header, destRoot string, logger *logging.Logger) error { - linkTarget := header.Linkname - - // Pre-validation: ensure the symlink target resolves within destRoot before creation. - if _, err := resolvePathRelativeToBaseWithinRootFS(restoreFS, destRoot, filepath.Dir(target), linkTarget); err != nil { - return fmt.Errorf("symlink target escapes root before creation: %s -> %s: %w", header.Name, linkTarget, err) - } - - // Remove existing file/link if it exists - _ = restoreFS.Remove(target) - - // Create symlink - if err := restoreFS.Symlink(linkTarget, target); err != nil { - return fmt.Errorf("create symlink: %w", err) - } - - // POST-CREATION VALIDATION: Verify the created symlink's target stays within destRoot - actualTarget, err := restoreFS.Readlink(target) - if err != nil { - restoreFS.Remove(target) // Clean up - return fmt.Errorf("read created symlink %s: %w", target, err) - } - - if _, err := resolvePathRelativeToBaseWithinRootFS(restoreFS, destRoot, filepath.Dir(target), actualTarget); err != nil { - restoreFS.Remove(target) - return fmt.Errorf("symlink target escapes root after creation: %s -> %s: %w", header.Name, actualTarget, err) - } - - // Set ownership (on the symlink itself, not the target) - if err := os.Lchown(target, header.Uid, header.Gid); err != nil { - logger.Debug("Failed to lchown symlink %s: %v", target, err) - } - - // Note: timestamps on symlinks are not typically preserved - return nil -} - -// extractHardlink creates a hard link -func extractHardlink(target string, header *tar.Header, destRoot string) error { - // Validate hard link target - linkName := header.Linkname - - // Reject absolute hard link targets immediately - if filepath.IsAbs(linkName) { - return fmt.Errorf("absolute hardlink target not allowed: %s", linkName) - } - - // Validate the hard link target stays within extraction root - if _, err := resolvePathWithinRootFS(restoreFS, destRoot, linkName); err != nil { - return fmt.Errorf("hardlink target escapes root: %s -> %s: %w", header.Name, linkName, err) - } - - linkTarget := filepath.Join(destRoot, linkName) - - // Remove existing file/link if it exists - _ = restoreFS.Remove(target) - - // Create hard link - if err := restoreFS.Link(linkTarget, target); err != nil { - return fmt.Errorf("create hardlink: %w", err) - } - - return nil -} - -// setTimestamps sets atime, mtime, and attempts to set ctime via syscall -func setTimestamps(target string, header *tar.Header) error { - // Convert times to Unix format - atime := header.AccessTime - mtime := header.ModTime - - // Use syscall.UtimesNano to set atime and mtime with nanosecond precision - times := []syscall.Timespec{ - {Sec: atime.Unix(), Nsec: int64(atime.Nanosecond())}, - {Sec: mtime.Unix(), Nsec: int64(mtime.Nanosecond())}, - } - - if err := syscall.UtimesNano(target, times); err != nil { - return fmt.Errorf("set atime/mtime: %w", err) - } - - // Note: ctime (change time) cannot be set directly by user-space programs - // It is automatically updated by the kernel when file metadata changes - // The header.ChangeTime is stored in PAX but cannot be restored - - return nil -} - -// getModeName returns a human-readable name for the restore mode -func getModeName(mode RestoreMode) string { - switch mode { - case RestoreModeFull: - return "FULL restore (all files)" - case RestoreModeStorage: - return "STORAGE/DATASTORE only" - case RestoreModeBase: - return "SYSTEM BASE only" - case RestoreModeCustom: - return "CUSTOM selection" - default: - return "Unknown mode" - } -} diff --git a/internal/orchestrator/restore_access_control_ui.go b/internal/orchestrator/restore_access_control_ui.go index db96a8c6..82bf9d2f 100644 --- a/internal/orchestrator/restore_access_control_ui.go +++ b/internal/orchestrator/restore_access_control_ui.go @@ -402,8 +402,8 @@ func armAccessControlRollback(ctx context.Context, logger *logging.Logger, backu } if handle.unitName == "" { - cmd := fmt.Sprintf("nohup sh -c 'sleep %d; /bin/sh %s' >/dev/null 2>&1 &", timeoutSeconds, handle.scriptPath) - if output, err := restoreCmd.Run(ctx, "sh", "-c", cmd); err != nil { + output, err := runBackgroundRollbackTimer(ctx, timeoutSeconds, handle.scriptPath) + if err != nil { logger.Debug("Background rollback output: %s", strings.TrimSpace(string(output))) return nil, fmt.Errorf("failed to schedule rollback timer: %w", err) } diff --git a/internal/orchestrator/restore_access_control_ui_additional_test.go b/internal/orchestrator/restore_access_control_ui_additional_test.go index e8427514..8252d1f0 100644 --- a/internal/orchestrator/restore_access_control_ui_additional_test.go +++ b/internal/orchestrator/restore_access_control_ui_additional_test.go @@ -224,8 +224,7 @@ func TestArmAccessControlRollback_SystemdAndBackgroundPaths(t *testing.T) { t.Fatalf("expected unitName cleared after systemd-run failure, got %q", handle.unitName) } - cmd := fmt.Sprintf("nohup sh -c 'sleep %d; /bin/sh %s' >/dev/null 2>&1 &", 2, scriptPath) - wantBackground := "sh -c " + cmd + wantBackground := backgroundRollbackCallKey(2, scriptPath) calls := fakeCmd.CallsList() if len(calls) != 2 || calls[1] != wantBackground { t.Fatalf("unexpected calls: %#v", calls) @@ -243,8 +242,7 @@ func TestArmAccessControlRollback_SystemdAndBackgroundPaths(t *testing.T) { timestamp := fakeTime.Current.Format("20060102_150405") scriptPath := filepath.Join("/tmp/proxsave", fmt.Sprintf("access_control_rollback_%s.sh", timestamp)) - cmd := fmt.Sprintf("nohup sh -c 'sleep %d; /bin/sh %s' >/dev/null 2>&1 &", 1, scriptPath) - backgroundKey := "sh -c " + cmd + backgroundKey := backgroundRollbackCallKey(1, scriptPath) fakeCmd.Errors[backgroundKey] = fmt.Errorf("boom") if _, err := armAccessControlRollback(context.Background(), logger, "/backup.tgz", 1*time.Second, "/tmp/proxsave"); err == nil { @@ -290,7 +288,7 @@ func TestArmAccessControlRollback_DefaultWorkDirAndMinTimeout(t *testing.T) { if len(calls) != 1 { t.Fatalf("unexpected calls: %#v", calls) } - if !strings.Contains(calls[0], "sleep 1; /bin/sh") { + if calls[0] != backgroundRollbackCallKey(1, handle.scriptPath) { t.Fatalf("expected timeoutSeconds to clamp to 1, got call=%q", calls[0]) } } diff --git a/internal/orchestrator/restore_archive.go b/internal/orchestrator/restore_archive.go new file mode 100644 index 00000000..363b7387 --- /dev/null +++ b/internal/orchestrator/restore_archive.go @@ -0,0 +1,316 @@ +// Package orchestrator coordinates backup, restore, decrypt, and related workflows. +package orchestrator + +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync/atomic" + + "github.com/tis24dev/proxsave/internal/input" + "github.com/tis24dev/proxsave/internal/logging" +) + +var restoreLogSequence uint64 + +func shouldRecreateDirectories(systemType SystemType, categories []Category) bool { + return (systemType.SupportsPVE() && hasCategoryID(categories, "storage_pve")) || + (systemType.SupportsPBS() && hasCategoryID(categories, "datastore_pbs")) +} + +func hasCategoryID(categories []Category, id string) bool { + for _, cat := range categories { + if cat.ID == id { + return true + } + } + return false +} + +// shouldStopPBSServices reports whether any selected categories belong to PBS-specific configuration +// and therefore require stopping PBS services before restore. +func shouldStopPBSServices(categories []Category) bool { + for _, cat := range categories { + if cat.Type == CategoryTypePBS { + return true + } + // Some common categories (e.g. SSL) include PBS paths that require restarting PBS services. + for _, p := range cat.Paths { + p = strings.TrimSpace(p) + if strings.HasPrefix(p, "./etc/proxmox-backup/") || strings.HasPrefix(p, "./var/lib/proxmox-backup/") { + return true + } + } + } + return false +} + +func splitExportCategories(categories []Category) (normal []Category, export []Category) { + for _, cat := range categories { + if cat.ExportOnly { + export = append(export, cat) + continue + } + normal = append(normal, cat) + } + return normal, export +} + +// redirectClusterCategoryToExport removes pve_cluster from normal categories and adds it to export-only list. +func redirectClusterCategoryToExport(normal []Category, export []Category) ([]Category, []Category) { + filtered := make([]Category, 0, len(normal)) + for _, cat := range normal { + if cat.ID == "pve_cluster" { + export = append(export, cat) + continue + } + filtered = append(filtered, cat) + } + return filtered, export +} + +func exportDestRoot(baseDir string) string { + base := strings.TrimSpace(baseDir) + if base == "" { + base = "/opt/proxsave" + } + return filepath.Join(base, fmt.Sprintf("proxmox-config-export-%s", nowRestore().Format("20060102-150405"))) +} + +// runFullRestore performs a full restore without selective options (fallback) +func runFullRestore(ctx context.Context, reader *bufio.Reader, candidate *backupCandidate, prepared *preparedBundle, destRoot string, logger *logging.Logger, dryRun bool) error { + if err := confirmRestoreAction(ctx, reader, candidate, destRoot); err != nil { + return err + } + + safeFstabMerge := destRoot == "/" && isRealRestoreFS(restoreFS) + if safeFstabMerge { + logger.Warning("Full restore safety: /etc/fstab will not be overwritten; Smart Merge will be applied after extraction.") + } + + if err := extractPlainArchive(ctx, prepared.ArchivePath, destRoot, logger, fullRestoreSkipFn(safeFstabMerge)); err != nil { + return err + } + + if safeFstabMerge { + if err := runFullRestoreFstabMerge(ctx, reader, prepared.ArchivePath, destRoot, logger, dryRun); err != nil { + return err + } + } + + logger.Info("Restore completed successfully.") + return nil +} + +func fullRestoreSkipFn(safeFstabMerge bool) func(name string) bool { + return func(name string) bool { + if !safeFstabMerge { + return false + } + clean := strings.TrimPrefix(strings.TrimSpace(name), "./") + clean = strings.TrimPrefix(clean, "/") + return clean == "etc/fstab" + } +} + +func runFullRestoreFstabMerge(ctx context.Context, reader *bufio.Reader, archivePath, destRoot string, logger *logging.Logger, dryRun bool) error { + logger.Info("") + fsTempDir, err := restoreFS.MkdirTemp("", "proxsave-fstab-") + if err != nil { + logger.Warning("Failed to create temp dir for fstab merge: %v", err) + return nil + } + defer restoreFS.RemoveAll(fsTempDir) + + if err := extractFullRestoreFstab(ctx, archivePath, fsTempDir, logger); err != nil { + logger.Warning("Failed to extract filesystem config for merge: %v", err) + return nil + } + extractFullRestoreFstabInventory(ctx, archivePath, fsTempDir, logger) + currentFstab := filepath.Join(destRoot, "etc", "fstab") + backupFstab := filepath.Join(fsTempDir, "etc", "fstab") + if err := SmartMergeFstab(ctx, logger, reader, currentFstab, backupFstab, dryRun); err != nil { + if errors.Is(err, ErrRestoreAborted) || input.IsAborted(err) { + logger.Info("Restore aborted by user during Smart Filesystem Configuration Merge.") + return err + } + logger.Warning("Smart Fstab Merge failed: %v", err) + } + return nil +} + +func extractFullRestoreFstab(ctx context.Context, archivePath, fsTempDir string, logger *logging.Logger) error { + return extractArchiveNative(ctx, restoreArchiveOptions{ + archivePath: archivePath, + destRoot: fsTempDir, + logger: logger, + categories: []Category{{ + ID: "filesystem", + Name: "Filesystem Configuration", + Paths: []string{"./etc/fstab"}, + }}, + mode: RestoreModeCustom, + }) +} + +func extractFullRestoreFstabInventory(ctx context.Context, archivePath, fsTempDir string, logger *logging.Logger) { + invCategory := []Category{{ + ID: "fstab_inventory", + Name: "Fstab inventory (device mapping)", + Paths: []string{ + "./var/lib/proxsave-info/commands/system/blkid.txt", + "./var/lib/proxsave-info/commands/system/lsblk_json.json", + "./var/lib/proxsave-info/commands/system/lsblk.txt", + "./var/lib/proxsave-info/commands/pbs/pbs_datastore_inventory.json", + }, + }} + if err := extractArchiveNative(ctx, restoreArchiveOptions{ + archivePath: archivePath, + destRoot: fsTempDir, + logger: logger, + categories: invCategory, + mode: RestoreModeCustom, + }); err != nil { + logger.Debug("Failed to extract fstab inventory data (continuing): %v", err) + } +} + +func confirmRestoreAction(ctx context.Context, reader *bufio.Reader, cand *backupCandidate, dest string) error { + manifest := cand.Manifest + fmt.Println() + fmt.Printf("Selected backup: %s (%s)\n", cand.DisplayBase, manifest.CreatedAt.Format("2006-01-02 15:04:05")) + cleanDest := filepath.Clean(strings.TrimSpace(dest)) + if cleanDest == "" || cleanDest == "." { + cleanDest = string(os.PathSeparator) + } + if cleanDest == string(os.PathSeparator) { + fmt.Println("Restore destination: / (system root; original paths will be preserved)") + fmt.Println("WARNING: This operation will overwrite configuration files on this system.") + } else { + fmt.Printf("Restore destination: %s (original paths will be preserved under this directory)\n", cleanDest) + fmt.Printf("WARNING: This operation will overwrite existing files under %s.\n", cleanDest) + } + fmt.Println("Type RESTORE to proceed or 0 to cancel.") + + for { + fmt.Print("Confirmation: ") + line, err := input.ReadLineWithContext(ctx, reader) + if err != nil { + return err + } + switch strings.TrimSpace(line) { + case "RESTORE": + return nil + case "0": + return ErrRestoreAborted + default: + fmt.Println("Please type RESTORE to confirm or 0 to cancel.") + } + } +} + +func extractPlainArchive(ctx context.Context, archivePath, destRoot string, logger *logging.Logger, skipFn func(entryName string) bool) error { + if err := restoreFS.MkdirAll(destRoot, 0o755); err != nil { + return fmt.Errorf("create destination directory: %w", err) + } + + // Only enforce root privileges when writing to the real system root. + if destRoot == "/" && isRealRestoreFS(restoreFS) && os.Geteuid() != 0 { + return fmt.Errorf("restore to %s requires root privileges", destRoot) + } + + logger.Info("Extracting archive %s into %s", filepath.Base(archivePath), destRoot) + + // Use native Go extraction to preserve atime/ctime from PAX headers + if err := extractArchiveNative(ctx, restoreArchiveOptions{ + archivePath: archivePath, + destRoot: destRoot, + logger: logger, + mode: RestoreModeFull, + skipFn: skipFn, + }); err != nil { + return fmt.Errorf("archive extraction failed: %w", err) + } + + return nil +} + +// extractSelectiveArchive extracts only files matching selected categories +func extractSelectiveArchive(ctx context.Context, archivePath, destRoot string, categories []Category, mode RestoreMode, logger *logging.Logger) (logPath string, err error) { + done := logging.DebugStart(logger, "extract selective archive", "archive=%s dest=%s categories=%d mode=%s", archivePath, destRoot, len(categories), mode) + defer func() { done(err) }() + if err := restoreFS.MkdirAll(destRoot, 0o755); err != nil { + return "", fmt.Errorf("create destination directory: %w", err) + } + + // Only enforce root privileges when writing to the real system root. + if destRoot == "/" && isRealRestoreFS(restoreFS) && os.Geteuid() != 0 { + return "", fmt.Errorf("restore to %s requires root privileges", destRoot) + } + + // Create detailed log directory + logDir := "/tmp/proxsave" + if err := restoreFS.MkdirAll(logDir, 0o755); err != nil { + logger.Warning("Could not create log directory: %v", err) + } + + // Create detailed log file + timestamp := nowRestore().Format("20060102_150405") + logSeq := atomic.AddUint64(&restoreLogSequence, 1) + logPath = filepath.Join(logDir, fmt.Sprintf("restore_%s_%d.log", timestamp, logSeq)) + logFile, err := restoreFS.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0640) + if err != nil { + logger.Warning("Could not create detailed log file: %v", err) + logFile = nil + } else { + defer logFile.Close() + logger.Info("Detailed restore log: %s", logPath) + logging.DebugStep(logger, "extract selective archive", "log file=%s", logPath) + } + + logger.Info("Extracting selected categories from archive %s into %s", filepath.Base(archivePath), destRoot) + + // Use native Go extraction with category filter + if err := extractArchiveNative(ctx, restoreArchiveOptions{ + archivePath: archivePath, + destRoot: destRoot, + logger: logger, + categories: categories, + mode: mode, + logFile: logFile, + logFilePath: logPath, + }); err != nil { + return logPath, err + } + + return logPath, nil +} + +func isRealRestoreFS(fs FS) bool { + switch fs.(type) { + case osFS, *osFS: + return true + default: + return false + } +} + +// getModeName returns a human-readable name for the restore mode +func getModeName(mode RestoreMode) string { + switch mode { + case RestoreModeFull: + return "FULL restore (all files)" + case RestoreModeStorage: + return "STORAGE/DATASTORE only" + case RestoreModeBase: + return "SYSTEM BASE only" + case RestoreModeCustom: + return "CUSTOM selection" + default: + return "Unknown mode" + } +} diff --git a/internal/orchestrator/restore_archive_additional_test.go b/internal/orchestrator/restore_archive_additional_test.go new file mode 100644 index 00000000..f8fda96d --- /dev/null +++ b/internal/orchestrator/restore_archive_additional_test.go @@ -0,0 +1,40 @@ +package orchestrator + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestExtractPlainArchiveHonorsSkipFn(t *testing.T) { + origFS := restoreFS + t.Cleanup(func() { restoreFS = origFS }) + restoreFS = osFS{} + + tmpDir := t.TempDir() + archivePath := filepath.Join(tmpDir, "backup.tar") + if err := writeTarFile(archivePath, map[string]string{ + "etc/fstab": "/dev/sda1 / ext4 defaults 0 1\n", + "etc/hosts": "127.0.0.1 localhost\n", + }); err != nil { + t.Fatalf("write archive: %v", err) + } + + destRoot := filepath.Join(tmpDir, "restore") + if err := extractPlainArchive(context.Background(), archivePath, destRoot, newTestLogger(), fullRestoreSkipFn(true)); err != nil { + t.Fatalf("extractPlainArchive error: %v", err) + } + + if _, err := os.Stat(filepath.Join(destRoot, "etc", "fstab")); !os.IsNotExist(err) { + t.Fatalf("expected skipped fstab to be absent, stat err=%v", err) + } + + hosts, err := os.ReadFile(filepath.Join(destRoot, "etc", "hosts")) + if err != nil { + t.Fatalf("expected hosts to be extracted: %v", err) + } + if string(hosts) != "127.0.0.1 localhost\n" { + t.Fatalf("hosts content=%q", string(hosts)) + } +} diff --git a/internal/orchestrator/restore_archive_entries.go b/internal/orchestrator/restore_archive_entries.go new file mode 100644 index 00000000..0411c84b --- /dev/null +++ b/internal/orchestrator/restore_archive_entries.go @@ -0,0 +1,281 @@ +// Package orchestrator coordinates backup, restore, decrypt, and related workflows. +package orchestrator + +import ( + "archive/tar" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "syscall" + + "github.com/tis24dev/proxsave/internal/logging" +) + +const restoreTempPattern = ".proxsave-tmp-*" + +// extractTarEntry extracts a single TAR entry, preserving all attributes including atime/ctime +func extractTarEntry(tarReader *tar.Reader, header *tar.Header, destRoot string, logger *logging.Logger) error { + target, cleanDestRoot, err := sanitizeRestoreEntryTargetWithFS(restoreFS, destRoot, header.Name) + if err != nil { + return err + } + + skip, err := shouldSkipRestoreEntryTarget(header, target, cleanDestRoot, logger) + if err != nil { + return err + } + if skip { + return nil + } + + // Create parent directories + if err := restoreFS.MkdirAll(filepath.Dir(target), 0755); err != nil { + return fmt.Errorf("create parent directory: %w", err) + } + + return extractTypedTarEntry(tarReader, header, target, cleanDestRoot, logger) +} + +func shouldSkipRestoreEntryTarget(header *tar.Header, target, cleanDestRoot string, logger *logging.Logger) (bool, error) { + if cleanDestRoot != string(os.PathSeparator) { + return false, nil + } + // Hard guard: never write directly into /etc/pve when restoring to system root + if target == "/etc/pve" || strings.HasPrefix(target, "/etc/pve/") { + logger.Warning("Skipping restore to %s (writes to /etc/pve are prohibited)", target) + return true, nil + } + relTarget, err := filepath.Rel(cleanDestRoot, target) + if err != nil { + return false, fmt.Errorf("determine restore target for %s: %w", header.Name, err) + } + if skip, reason := shouldSkipProxmoxSystemRestore(relTarget); skip { + logger.Warning("Skipping restore to %s (%s)", target, reason) + return true, nil + } + return false, nil +} + +func extractTypedTarEntry(tarReader *tar.Reader, header *tar.Header, target, cleanDestRoot string, logger *logging.Logger) error { + switch header.Typeflag { + case tar.TypeDir: + return extractDirectory(target, header, logger) + case tar.TypeReg: + return extractRegularFile(tarReader, target, header, logger) + case tar.TypeSymlink: + return extractSymlink(target, header, cleanDestRoot, logger) + case tar.TypeLink: + return extractHardlink(target, header, cleanDestRoot) + default: + logger.Debug("Skipping unsupported file type %d: %s", header.Typeflag, header.Name) + return nil + } +} + +// extractDirectory creates a directory with proper permissions and timestamps +func extractDirectory(target string, header *tar.Header, logger *logging.Logger) (retErr error) { + // Create with an owner-accessible mode first so the directory can be opened + // before applying restrictive archive permissions. + if err := restoreFS.MkdirAll(target, 0o700); err != nil { + return fmt.Errorf("create directory: %w", err) + } + + dirFile, err := restoreFS.Open(target) + if err != nil { + return fmt.Errorf("open directory: %w", err) + } + defer func() { + if dirFile == nil { + return + } + if err := dirFile.Close(); err != nil && retErr == nil { + retErr = fmt.Errorf("close directory: %w", err) + } + }() + + // Apply metadata on the opened directory handle so logical FS paths + // (e.g. FakeFS-backed test roots) do not leak through to host paths. + // Ownership remains best-effort to match the previous restore behavior on + // unprivileged runs and filesystems that do not support chown. + if err := atomicFileChown(dirFile, header.Uid, header.Gid); err != nil { + logger.Debug("Failed to chown directory %s: %v", target, err) + } + if err := atomicFileChmod(dirFile, os.FileMode(header.Mode)); err != nil { + return fmt.Errorf("chmod directory: %w", err) + } + + // Set timestamps (mtime, atime) + if err := setTimestamps(target, header); err != nil { + logger.Debug("Failed to set timestamps on directory %s: %v", target, err) + } + + return nil +} + +// extractRegularFile extracts a regular file with content and timestamps +func extractRegularFile(tarReader *tar.Reader, target string, header *tar.Header, logger *logging.Logger) (retErr error) { + tmpPath := "" + var outFile *os.File + appendDeferredErr := func(prefix string, err error) { + if err == nil { + return + } + wrapped := fmt.Errorf("%s: %w", prefix, err) + if retErr == nil { + retErr = wrapped + return + } + retErr = errors.Join(retErr, wrapped) + } + closeOutFile := func() error { + if outFile == nil { + return nil + } + err := outFile.Close() + outFile = nil + return err + } + + // Write to a sibling temp file first so a truncated archive entry cannot clobber + // an existing target before the content is fully copied and closed. + outFile, err := restoreFS.CreateTemp(filepath.Dir(target), restoreTempPattern) + if err != nil { + return fmt.Errorf("create file: %w", err) + } + tmpPath = outFile.Name() + defer func() { + appendDeferredErr("close file", closeOutFile()) + if tmpPath != "" { + if err := restoreFS.Remove(tmpPath); err != nil && logger != nil { + logger.Debug("Failed to remove temp file %s: %v", tmpPath, err) + } + } + }() + + // Copy content + if _, err := io.Copy(outFile, tarReader); err != nil { + return fmt.Errorf("write file content: %w", err) + } + + // Set metadata on the temp file before replacing the target so failures do not + // leave the final path in a partially restored state. + // Ownership remains best-effort to match the previous restore behavior on + // unprivileged runs and filesystems that do not support chown. + if err := atomicFileChown(outFile, header.Uid, header.Gid); err != nil { + logger.Debug("Failed to chown file %s: %v", target, err) + } + if err := atomicFileChmod(outFile, os.FileMode(header.Mode)); err != nil { + return fmt.Errorf("chmod file: %w", err) + } + + // Close before renaming into place. + if err := closeOutFile(); err != nil { + return fmt.Errorf("close file: %w", err) + } + + if err := restoreFS.Rename(tmpPath, target); err != nil { + return fmt.Errorf("replace file: %w", err) + } + tmpPath = "" + + // Set timestamps (mtime, atime, ctime via syscall) + if err := setTimestamps(target, header); err != nil { + logger.Debug("Failed to set timestamps on file %s: %v", target, err) + } + + return nil +} + +// extractSymlink creates a symbolic link +func extractSymlink(target string, header *tar.Header, destRoot string, logger *logging.Logger) error { + linkTarget := header.Linkname + + // Pre-validation: ensure the symlink target resolves within destRoot before creation. + if _, err := resolvePathRelativeToBaseWithinRootFS(restoreFS, destRoot, filepath.Dir(target), linkTarget); err != nil { + return fmt.Errorf("symlink target escapes root before creation: %s -> %s: %w", header.Name, linkTarget, err) + } + + // Remove existing file/link if it exists + _ = restoreFS.Remove(target) + + // Create symlink + if err := restoreFS.Symlink(linkTarget, target); err != nil { + return fmt.Errorf("create symlink: %w", err) + } + + // POST-CREATION VALIDATION: Verify the created symlink's target stays within destRoot + actualTarget, err := restoreFS.Readlink(target) + if err != nil { + restoreFS.Remove(target) // Clean up + return fmt.Errorf("read created symlink %s: %w", target, err) + } + + if _, err := resolvePathRelativeToBaseWithinRootFS(restoreFS, destRoot, filepath.Dir(target), actualTarget); err != nil { + restoreFS.Remove(target) + return fmt.Errorf("symlink target escapes root after creation: %s -> %s: %w", header.Name, actualTarget, err) + } + + // Set ownership (on the symlink itself, not the target) + if err := restoreFS.Lchown(target, header.Uid, header.Gid); err != nil { + logger.Debug("Failed to lchown symlink %s: %v", target, err) + } + + // Note: timestamps on symlinks are not typically preserved + return nil +} + +// extractHardlink creates a hard link +func extractHardlink(target string, header *tar.Header, destRoot string) error { + // Validate hard link target + linkName := filepath.FromSlash(header.Linkname) + if linkName == "" || filepath.Clean(linkName) == "." { + return fmt.Errorf("empty hardlink target not allowed") + } + + // Reject absolute hard link targets immediately + if filepath.IsAbs(linkName) { + return fmt.Errorf("absolute hardlink target not allowed: %s", linkName) + } + + // Resolve and validate the hard link target stays within extraction root. + linkTarget, err := resolvePathWithinRootFS(restoreFS, destRoot, linkName) + if err != nil { + return fmt.Errorf("hardlink target escapes root: %s -> %s: %w", header.Name, linkName, err) + } + + // Remove existing file/link if it exists + _ = restoreFS.Remove(target) + + // Create hard link + if err := restoreFS.Link(linkTarget, target); err != nil { + return fmt.Errorf("create hardlink: %w", err) + } + + return nil +} + +// setTimestamps sets atime, mtime, and attempts to set ctime via syscall +func setTimestamps(target string, header *tar.Header) error { + // Convert times to Unix format + atime := header.AccessTime + mtime := header.ModTime + + // Use syscall.UtimesNano to set atime and mtime with nanosecond precision + times := []syscall.Timespec{ + {Sec: atime.Unix(), Nsec: int64(atime.Nanosecond())}, + {Sec: mtime.Unix(), Nsec: int64(mtime.Nanosecond())}, + } + + if err := restoreFS.UtimesNano(target, times); err != nil { + return fmt.Errorf("set atime/mtime: %w", err) + } + + // Note: ctime (change time) cannot be set directly by user-space programs + // It is automatically updated by the kernel when file metadata changes + // The header.ChangeTime is stored in PAX but cannot be restored + + return nil +} diff --git a/internal/orchestrator/restore_archive_extract.go b/internal/orchestrator/restore_archive_extract.go new file mode 100644 index 00000000..ea16abb4 --- /dev/null +++ b/internal/orchestrator/restore_archive_extract.go @@ -0,0 +1,248 @@ +// Package orchestrator coordinates backup, restore, decrypt, and related workflows. +package orchestrator + +import ( + "archive/tar" + "context" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/tis24dev/proxsave/internal/logging" +) + +type restoreArchiveOptions struct { + archivePath string + destRoot string + logger *logging.Logger + categories []Category + mode RestoreMode + logFile *os.File + logFilePath string + skipFn func(entryName string) bool +} + +type restoreExtractionStats struct { + filesExtracted int + filesSkipped int + filesFailed int +} + +type restoreExtractionLog struct { + logger *logging.Logger + logFile *os.File + logFilePath string + restoredTemp *os.File + skippedTemp *os.File +} + +// extractArchiveNative extracts TAR archives natively in Go, preserving all timestamps. +func extractArchiveNative(ctx context.Context, opts restoreArchiveOptions) (err error) { + file, err := restoreFS.Open(opts.archivePath) + if err != nil { + return fmt.Errorf("open archive: %w", err) + } + defer file.Close() + + reader, err := createDecompressionReader(ctx, file, opts.archivePath) + if err != nil { + return fmt.Errorf("create decompression reader: %w", err) + } + defer closeDecompressionReader(reader, &err, "close decompression reader") + + extractionLog := newRestoreExtractionLog(opts) + defer extractionLog.close() + extractionLog.writeHeader(opts) + + stats, err := processRestoreArchiveEntries(ctx, tar.NewReader(reader), opts, extractionLog) + if err != nil { + return err + } + + extractionLog.writeSummary(stats) + logRestoreExtractionSummary(opts, stats) + return nil +} + +func newRestoreExtractionLog(opts restoreArchiveOptions) *restoreExtractionLog { + extractionLog := &restoreExtractionLog{ + logger: opts.logger, + logFile: opts.logFile, + logFilePath: opts.logFilePath, + } + if opts.logFile == nil { + return extractionLog + } + + if tmp, err := restoreFS.CreateTemp("", "restored_entries_*.log"); err == nil { + extractionLog.restoredTemp = tmp + } else { + opts.logger.Warning("Could not create temporary file for restored entries: %v", err) + } + if tmp, err := restoreFS.CreateTemp("", "skipped_entries_*.log"); err == nil { + extractionLog.skippedTemp = tmp + } else { + opts.logger.Warning("Could not create temporary file for skipped entries: %v", err) + } + return extractionLog +} + +func (log *restoreExtractionLog) close() { + closeAndRemoveRestoreTemp(log.restoredTemp) + closeAndRemoveRestoreTemp(log.skippedTemp) +} + +func closeAndRemoveRestoreTemp(file *os.File) { + if file == nil { + return + } + file.Close() + _ = restoreFS.Remove(file.Name()) +} + +func (log *restoreExtractionLog) writeHeader(opts restoreArchiveOptions) { + if log.logFile == nil { + return + } + fmt.Fprintf(log.logFile, "=== PROXMOX RESTORE LOG ===\n") + fmt.Fprintf(log.logFile, "Date: %s\n", nowRestore().Format("2006-01-02 15:04:05")) + fmt.Fprintf(log.logFile, "Mode: %s\n", getModeName(opts.mode)) + if len(opts.categories) > 0 { + fmt.Fprintf(log.logFile, "Selected categories: %d categories\n", len(opts.categories)) + for _, cat := range opts.categories { + fmt.Fprintf(log.logFile, " - %s (%s)\n", cat.Name, cat.ID) + } + } else { + fmt.Fprintf(log.logFile, "Selected categories: ALL (full restore)\n") + } + fmt.Fprintf(log.logFile, "Archive: %s\n", filepath.Base(opts.archivePath)) + fmt.Fprintf(log.logFile, "\n") +} + +func processRestoreArchiveEntries(ctx context.Context, tarReader *tar.Reader, opts restoreArchiveOptions, extractionLog *restoreExtractionLog) (restoreExtractionStats, error) { + var stats restoreExtractionStats + selectiveMode := len(opts.categories) > 0 + for { + if err := ctx.Err(); err != nil { + return stats, err + } + + header, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + return stats, fmt.Errorf("read tar header: %w", err) + } + + if skipRestoreArchiveEntry(header, opts, selectiveMode, extractionLog, &stats) { + continue + } + if err := extractTarEntry(tarReader, header, opts.destRoot, opts.logger); err != nil { + opts.logger.Warning("Failed to extract %s: %v", header.Name, err) + stats.filesFailed++ + continue + } + + stats.filesExtracted++ + extractionLog.recordRestored(header.Name) + if stats.filesExtracted%100 == 0 { + opts.logger.Debug("Extracted %d files...", stats.filesExtracted) + } + } + return stats, nil +} + +func skipRestoreArchiveEntry(header *tar.Header, opts restoreArchiveOptions, selectiveMode bool, extractionLog *restoreExtractionLog, stats *restoreExtractionStats) bool { + if opts.skipFn != nil && opts.skipFn(header.Name) { + stats.filesSkipped++ + extractionLog.recordSkipped(header.Name, "skipped by restore policy") + return true + } + if !selectiveMode || restoreEntryMatchesCategories(header.Name, opts.categories) { + return false + } + stats.filesSkipped++ + extractionLog.recordSkipped(header.Name, "does not match any selected category") + return true +} + +func restoreEntryMatchesCategories(entryName string, categories []Category) bool { + for _, cat := range categories { + if PathMatchesCategory(entryName, cat) { + return true + } + } + return false +} + +func (log *restoreExtractionLog) recordSkipped(name, reason string) { + if log.skippedTemp != nil { + fmt.Fprintf(log.skippedTemp, "SKIPPED: %s (%s)\n", name, reason) + } +} + +func (log *restoreExtractionLog) recordRestored(name string) { + if log.restoredTemp != nil { + fmt.Fprintf(log.restoredTemp, "RESTORED: %s\n", name) + } +} + +func (log *restoreExtractionLog) writeSummary(stats restoreExtractionStats) { + if log.logFile == nil { + return + } + fmt.Fprintf(log.logFile, "=== FILES RESTORED ===\n") + log.copyTempEntries(log.restoredTemp, "restored") + fmt.Fprintf(log.logFile, "\n") + + fmt.Fprintf(log.logFile, "=== FILES SKIPPED ===\n") + log.copyTempEntries(log.skippedTemp, "skipped") + fmt.Fprintf(log.logFile, "\n") + + fmt.Fprintf(log.logFile, "=== SUMMARY ===\n") + fmt.Fprintf(log.logFile, "Total files extracted: %d\n", stats.filesExtracted) + fmt.Fprintf(log.logFile, "Total files skipped: %d\n", stats.filesSkipped) + fmt.Fprintf(log.logFile, "Total files failed: %d\n", stats.filesFailed) + fmt.Fprintf(log.logFile, "Total files in archive: %d\n", stats.filesExtracted+stats.filesSkipped+stats.filesFailed) +} + +func (log *restoreExtractionLog) copyTempEntries(tempFile *os.File, label string) { + if tempFile == nil { + return + } + if _, err := tempFile.Seek(0, 0); err == nil { + if _, err := io.Copy(log.logFile, tempFile); err != nil { + log.logger.Warning("Could not write %s entries to log: %v", label, err) + } + } +} + +func logRestoreExtractionSummary(opts restoreArchiveOptions, stats restoreExtractionStats) { + if stats.filesFailed == 0 { + if len(opts.categories) > 0 { + opts.logger.Info("Successfully restored all %d configuration files/directories", stats.filesExtracted) + } else { + opts.logger.Info("Successfully restored all %d files/directories", stats.filesExtracted) + } + } else { + if opts.logFilePath != "" { + opts.logger.Warning("Restored %d files/directories; %d item(s) failed (see detailed log)", stats.filesExtracted, stats.filesFailed) + } else { + opts.logger.Warning("Restored %d files/directories; %d item(s) failed", stats.filesExtracted, stats.filesFailed) + } + } + + if stats.filesSkipped > 0 { + if opts.logFilePath != "" { + opts.logger.Info("%d additional archive entries (logs, diagnostics, system defaults) were left unchanged on this system; see detailed log for details", stats.filesSkipped) + } else { + opts.logger.Info("%d additional archive entries (logs, diagnostics, system defaults) were left unchanged on this system", stats.filesSkipped) + } + } + + if opts.logFilePath != "" { + opts.logger.Info("Detailed restore log: %s", opts.logFilePath) + } +} diff --git a/internal/orchestrator/restore_archive_extract_summary_test.go b/internal/orchestrator/restore_archive_extract_summary_test.go new file mode 100644 index 00000000..5c1caaf6 --- /dev/null +++ b/internal/orchestrator/restore_archive_extract_summary_test.go @@ -0,0 +1,65 @@ +package orchestrator + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/types" +) + +func TestRestoreExtractionLogWriteSummaryIncludesFailedFiles(t *testing.T) { + logPath := filepath.Join(t.TempDir(), "restore.log") + logFile, err := os.Create(logPath) + if err != nil { + t.Fatalf("create log file: %v", err) + } + + extractionLog := &restoreExtractionLog{ + logger: newTestLogger(), + logFile: logFile, + } + extractionLog.writeSummary(restoreExtractionStats{ + filesExtracted: 2, + filesSkipped: 3, + filesFailed: 4, + }) + if err := logFile.Close(); err != nil { + t.Fatalf("close log file: %v", err) + } + + content, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("read log file: %v", err) + } + text := string(content) + if !strings.Contains(text, "Total files failed: 4") { + t.Fatalf("summary missing failed count:\n%s", text) + } + if !strings.Contains(text, "Total files in archive: 9") { + t.Fatalf("summary total should include failed files:\n%s", text) + } +} + +func TestLogRestoreExtractionSummaryOmitsDetailedLogHintWithoutLogPath(t *testing.T) { + var buf bytes.Buffer + logger := logging.New(types.LogLevelInfo, false) + logger.SetOutput(&buf) + + logRestoreExtractionSummary(restoreArchiveOptions{logger: logger}, restoreExtractionStats{ + filesExtracted: 2, + filesSkipped: 1, + filesFailed: 1, + }) + + output := buf.String() + if strings.Contains(output, "see detailed log") { + t.Fatalf("did not expect detailed log hint without log path:\n%s", output) + } + if !strings.Contains(output, "1 item(s) failed") { + t.Fatalf("expected failed count in summary:\n%s", output) + } +} diff --git a/internal/orchestrator/restore_archive_paths.go b/internal/orchestrator/restore_archive_paths.go new file mode 100644 index 00000000..8c9840f7 --- /dev/null +++ b/internal/orchestrator/restore_archive_paths.go @@ -0,0 +1,115 @@ +// Package orchestrator coordinates backup, restore, decrypt, and related workflows. +package orchestrator + +import ( + "fmt" + "os" + "path" + "path/filepath" + "strings" +) + +func sanitizeRestoreEntryTarget(destRoot, entryName string) (string, string, error) { + return sanitizeRestoreEntryTargetWithFS(restoreFS, destRoot, entryName) +} + +func sanitizeRestoreEntryTargetWithFS(fsys FS, destRoot, entryName string) (string, string, error) { + absDestRoot, err := resolveRestoreDestRoot(destRoot) + if err != nil { + return "", "", fmt.Errorf("resolve destination root: %w", err) + } + + sanitized, err := normalizeRestoreEntryName(entryName) + if err != nil { + return "", "", err + } + absTarget, err := resolveRestoreEntryTarget(absDestRoot, sanitized) + if err != nil { + return "", "", fmt.Errorf("resolve extraction target: %w", err) + } + if err := ensureRestoreTargetWithinRoot(absDestRoot, absTarget, entryName); err != nil { + return "", "", err + } + if err := ensureRestoreTargetResolverAllows(fsys, absDestRoot, absTarget, entryName); err != nil { + return "", "", err + } + + return absTarget, absDestRoot, nil +} + +func resolveRestoreDestRoot(destRoot string) (string, error) { + cleanDestRoot := filepath.Clean(destRoot) + if cleanDestRoot == "" { + cleanDestRoot = string(os.PathSeparator) + } + return filepath.Abs(cleanDestRoot) +} + +func normalizeRestoreEntryName(entryName string) (string, error) { + name := strings.TrimSpace(entryName) + if name == "" { + return "", fmt.Errorf("empty archive entry name") + } + sanitized := path.Clean(name) + for strings.HasPrefix(sanitized, string(os.PathSeparator)) { + sanitized = strings.TrimPrefix(sanitized, string(os.PathSeparator)) + } + if sanitized == "" || sanitized == "." { + return "", fmt.Errorf("invalid archive entry name: %q", entryName) + } + if sanitized == ".." || strings.HasPrefix(sanitized, "../") || strings.Contains(sanitized, "/../") { + return "", fmt.Errorf("illegal path: %s", entryName) + } + return sanitized, nil +} + +func resolveRestoreEntryTarget(absDestRoot, sanitized string) (string, error) { + target := filepath.Join(absDestRoot, filepath.FromSlash(sanitized)) + return filepath.Abs(target) +} + +func ensureRestoreTargetWithinRoot(absDestRoot, absTarget, entryName string) error { + rel, err := filepath.Rel(absDestRoot, absTarget) + if err != nil { + return fmt.Errorf("illegal path: %s: %w", entryName, err) + } + if strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || rel == ".." || filepath.IsAbs(rel) { + return fmt.Errorf("illegal path: %s", entryName) + } + return nil +} + +func ensureRestoreTargetResolverAllows(fsys FS, absDestRoot, absTarget, entryName string) error { + if _, err := resolvePathWithinRootFS(fsys, absDestRoot, absTarget); err != nil { + if isPathSecurityError(err) { + return fmt.Errorf("illegal path: %s: %w", entryName, err) + } + if !isPathOperationalError(err) { + return fmt.Errorf("resolve extraction target: %w", err) + } + } + return nil +} + +func shouldSkipProxmoxSystemRestore(relTarget string) (bool, string) { + rel := filepath.ToSlash(filepath.Clean(strings.TrimSpace(relTarget))) + rel = strings.TrimPrefix(rel, "./") + rel = strings.TrimPrefix(rel, "/") + + switch rel { + case "etc/proxmox-backup/domains.cfg": + return true, "PBS auth realms must be recreated (domains.cfg is too fragile to restore raw)" + case "etc/proxmox-backup/user.cfg": + return true, "PBS users must be recreated (user.cfg should not be restored raw)" + case "etc/proxmox-backup/acl.cfg": + return true, "PBS permissions must be recreated (acl.cfg should not be restored raw)" + case "var/lib/proxmox-backup/.clusterlock": + return true, "PBS runtime lock files must not be restored" + } + + if strings.HasPrefix(rel, "var/lib/proxmox-backup/lock/") { + return true, "PBS runtime lock files must not be restored" + } + + return false, "" +} diff --git a/internal/orchestrator/restore_cluster_apply.go b/internal/orchestrator/restore_cluster_apply.go new file mode 100644 index 00000000..a2fbac64 --- /dev/null +++ b/internal/orchestrator/restore_cluster_apply.go @@ -0,0 +1,531 @@ +// Package orchestrator coordinates backup, restore, decrypt, and related workflows. +package orchestrator + +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/tis24dev/proxsave/internal/input" + "github.com/tis24dev/proxsave/internal/logging" +) + +// runSafeClusterApply applies selected cluster configs via pvesh without touching config.db. +// It operates on files extracted to exportRoot (e.g. exportDestRoot). +func runSafeClusterApply(ctx context.Context, reader *bufio.Reader, exportRoot string, logger *logging.Logger) (err error) { + if logger == nil { + logger = logging.GetDefaultLogger() + } + ui := newCLIWorkflowUI(reader, logger) + return runSafeClusterApplyWithUI(ctx, ui, exportRoot, logger, nil) +} + +type vmEntry struct { + VMID string + Kind string // qemu | lxc + Name string + Path string +} + +func scanVMConfigs(exportRoot, node string) ([]vmEntry, error) { + var entries []vmEntry + base := filepath.Join(exportRoot, "etc/pve/nodes", node) + + type dirSpec struct { + kind string + path string + } + + dirs := []dirSpec{ + {kind: "qemu", path: filepath.Join(base, "qemu-server")}, + {kind: "lxc", path: filepath.Join(base, "lxc")}, + } + + for _, spec := range dirs { + infos, err := restoreFS.ReadDir(spec.path) + if err != nil { + continue + } + for _, entry := range infos { + if entry.IsDir() { + continue + } + name := entry.Name() + if !strings.HasSuffix(name, ".conf") { + continue + } + vmid := strings.TrimSuffix(name, ".conf") + vmPath := filepath.Join(spec.path, name) + vmName := readVMName(vmPath) + entries = append(entries, vmEntry{ + VMID: vmid, + Kind: spec.kind, + Name: vmName, + Path: vmPath, + }) + } + } + + return entries, nil +} + +func listExportNodeDirs(exportRoot string) ([]string, error) { + nodesRoot := filepath.Join(exportRoot, "etc/pve/nodes") + entries, err := restoreFS.ReadDir(nodesRoot) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, err + } + + var nodes []string + for _, entry := range entries { + if !entry.IsDir() { + continue + } + name := strings.TrimSpace(entry.Name()) + if name == "" { + continue + } + nodes = append(nodes, name) + } + sort.Strings(nodes) + return nodes, nil +} + +func countVMConfigsForNode(exportRoot, node string) (qemuCount, lxcCount int) { + base := filepath.Join(exportRoot, "etc/pve/nodes", node) + + countInDir := func(dir string) int { + entries, err := restoreFS.ReadDir(dir) + if err != nil { + return 0 + } + n := 0 + for _, entry := range entries { + if entry.IsDir() { + continue + } + if strings.HasSuffix(entry.Name(), ".conf") { + n++ + } + } + return n + } + + qemuCount = countInDir(filepath.Join(base, "qemu-server")) + lxcCount = countInDir(filepath.Join(base, "lxc")) + return qemuCount, lxcCount +} + +func promptExportNodeSelection(ctx context.Context, reader *bufio.Reader, exportRoot, currentNode string, exportNodes []string) (string, error) { + for { + fmt.Println() + fmt.Printf("WARNING: VM/CT configs in this backup are stored under different node names.\n") + fmt.Printf("Current node: %s\n", currentNode) + fmt.Println("Select which exported node to import VM/CT configs from (they will be applied to the current node):") + for idx, node := range exportNodes { + qemuCount, lxcCount := countVMConfigsForNode(exportRoot, node) + fmt.Printf(" [%d] %s (qemu=%d, lxc=%d)\n", idx+1, node, qemuCount, lxcCount) + } + fmt.Println(" [0] Skip VM/CT apply") + + fmt.Print("Choice: ") + line, err := input.ReadLineWithContext(ctx, reader) + if err != nil { + return "", err + } + trimmed := strings.TrimSpace(line) + if trimmed == "0" { + return "", nil + } + if trimmed == "" { + continue + } + idx, err := parseMenuIndex(trimmed, len(exportNodes)) + if err != nil { + fmt.Println(err) + continue + } + return exportNodes[idx], nil + } +} + +func stringSliceContains(items []string, want string) bool { + for _, item := range items { + if item == want { + return true + } + } + return false +} + +func readVMName(confPath string) string { + data, err := restoreFS.ReadFile(confPath) + if err != nil { + return "" + } + for _, line := range strings.Split(string(data), "\n") { + t := strings.TrimSpace(line) + if strings.HasPrefix(t, "name:") { + return strings.TrimSpace(strings.TrimPrefix(t, "name:")) + } + if strings.HasPrefix(t, "hostname:") { + return strings.TrimSpace(strings.TrimPrefix(t, "hostname:")) + } + } + return "" +} + +func applyVMConfigs(ctx context.Context, entries []vmEntry, logger *logging.Logger) (applied, failed int) { + node := localNodeName() + for _, vm := range entries { + if err := ctx.Err(); err != nil { + logger.Warning("VM apply aborted: %v", err) + return applied, failed + } + target := fmt.Sprintf("/nodes/%s/%s/%s/config", node, vm.Kind, vm.VMID) + configArgs, err := pveshArgsFromColonConfigFile(vm.Path) + if err != nil { + logger.Warning("Failed to read %s (vmid=%s kind=%s): %v", vm.Path, vm.VMID, vm.Kind, err) + failed++ + continue + } + + exists, err := pveshGuestExists(ctx, logger, target) + if err != nil { + logger.Warning("Failed to check existing VM/CT config %s (vmid=%s kind=%s): %v", target, vm.VMID, vm.Kind, err) + failed++ + continue + } + if !exists { + createArgs, err := pveshCreateGuestArgs(node, vm, configArgs) + if err != nil { + logger.Warning("Failed to prepare VM/CT create for %s (vmid=%s kind=%s): %v", target, vm.VMID, vm.Kind, err) + failed++ + continue + } + if err := runPvesh(ctx, logger, createArgs); err != nil { + logger.Warning("Failed to create VM/CT config %s (vmid=%s kind=%s): %v", target, vm.VMID, vm.Kind, err) + failed++ + continue + } + } + + args := append([]string{"set", target}, configArgs...) + if err := runPvesh(ctx, logger, args); err != nil { + logger.Warning("Failed to apply %s (vmid=%s kind=%s): %v", target, vm.VMID, vm.Kind, err) + failed++ + } else { + display := vm.VMID + if vm.Name != "" { + display = fmt.Sprintf("%s (%s)", vm.VMID, vm.Name) + } + logger.Info("Applied VM/CT config %s", display) + applied++ + } + } + return applied, failed +} + +func localNodeName() string { + host, _ := os.Hostname() + host = shortHost(host) + if host != "" { + return host + } + return "localhost" +} + +func pveshGuestExists(ctx context.Context, logger *logging.Logger, target string) (bool, error) { + if err := runPvesh(ctx, logger, []string{"get", target}); err != nil { + if isPveshNotFoundError(err) { + return false, nil + } + return false, err + } + return true, nil +} + +func pveshCreateGuestArgs(node string, vm vmEntry, configArgs []string) ([]string, error) { + args := []string{ + "create", + fmt.Sprintf("/nodes/%s/%s", node, vm.Kind), + fmt.Sprintf("--vmid=%s", vm.VMID), + } + switch vm.Kind { + case "qemu": + return args, nil + case "lxc": + ostemplate, ok := pveshArgValue(configArgs, "ostemplate") + if !ok { + return nil, fmt.Errorf("missing ostemplate in LXC config") + } + return append(args, fmt.Sprintf("--ostemplate=%s", ostemplate)), nil + default: + return nil, fmt.Errorf("unsupported guest kind %q", vm.Kind) + } +} + +func pveshArgValue(args []string, key string) (string, bool) { + prefix := "--" + key + "=" + for _, arg := range args { + if strings.HasPrefix(arg, prefix) { + return strings.TrimPrefix(arg, prefix), true + } + } + return "", false +} + +func isPveshNotFoundError(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + for _, marker := range []string{"not found", "does not exist", "no such", "unable to find", "404"} { + if strings.Contains(msg, marker) { + return true + } + } + return false +} + +type storageBlock struct { + ID string + Type string + entries []proxmoxNotificationEntry +} + +func pveshArgsFromColonConfigFile(path string) ([]string, error) { + data, err := restoreFS.ReadFile(path) + if err != nil { + return nil, err + } + return pveshArgsFromColonConfigLines(strings.Split(string(data), "\n")), nil +} + +func pveshArgsFromColonConfigLines(lines []string) []string { + args := make([]string, 0, len(lines)*2) + for _, line := range lines { + if strings.HasPrefix(strings.TrimSpace(line), "[") { + break + } + key, value, ok := parseColonConfigLine(line) + if !ok { + continue + } + args = append(args, fmt.Sprintf("--%s=%s", key, value)) + } + return args +} + +func pveshArgsFromProxmoxEntries(entries []proxmoxNotificationEntry) []string { + args := make([]string, 0, len(entries)*2) + for _, entry := range entries { + key := strings.TrimSpace(entry.Key) + value := strings.TrimSpace(entry.Value) + if key == "" || value == "" { + continue + } + args = append(args, fmt.Sprintf("--%s=%s", key, value)) + } + return args +} + +func storageBlockPveshArgs(block storageBlock) ([]string, bool) { + storageType := strings.TrimSpace(block.Type) + if storageType == "" { + storageType = storageEntryValue(block.entries, "type") + } + if storageType == "" { + return nil, false + } + + args := []string{ + fmt.Sprintf("--storage=%s", block.ID), + fmt.Sprintf("--type=%s", storageType), + } + for _, entry := range block.entries { + if strings.EqualFold(strings.TrimSpace(entry.Key), "type") { + continue + } + args = append(args, pveshArgsFromProxmoxEntries([]proxmoxNotificationEntry{entry})...) + } + return args, true +} + +func storageEntryValue(entries []proxmoxNotificationEntry, want string) string { + for _, entry := range entries { + if strings.EqualFold(strings.TrimSpace(entry.Key), want) { + return strings.TrimSpace(entry.Value) + } + } + return "" +} + +func parseColonConfigLine(line string) (key, value string, ok bool) { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + return "", "", false + } + idx := strings.Index(trimmed, ":") + if idx <= 0 { + return "", "", false + } + key = strings.TrimSpace(trimmed[:idx]) + value = strings.TrimSpace(trimmed[idx+1:]) + if key == "" || value == "" { + return "", "", false + } + return key, value, true +} + +func applyStorageCfg(ctx context.Context, cfgPath string, logger *logging.Logger) (applied, failed int, err error) { + blocks, perr := parseStorageBlocks(cfgPath) + if perr != nil { + return 0, 0, perr + } + if len(blocks) == 0 { + logger.Info("No storage definitions detected in storage.cfg") + return 0, 0, nil + } + + for _, blk := range blocks { + createArgs, ok := storageBlockPveshArgs(blk) + if !ok { + logger.Warning("Skipping storage %s: storage type missing", blk.ID) + failed++ + continue + } + args := append([]string{"create", "/storage"}, createArgs...) + + if runErr := runPvesh(ctx, logger, args); runErr != nil { + logger.Warning("Failed to apply storage %s: %v", blk.ID, runErr) + failed++ + } else { + logger.Info("Applied storage definition %s", blk.ID) + applied++ + } + + if err := ctx.Err(); err != nil { + return applied, failed, err + } + } + + return applied, failed, nil +} + +func parseStorageBlocks(cfgPath string) ([]storageBlock, error) { + data, err := restoreFS.ReadFile(cfgPath) + if err != nil { + return nil, err + } + + var blocks []storageBlock + var current *storageBlock + + flush := func() { + if current != nil { + blocks = append(blocks, *current) + current = nil + } + } + + for _, line := range strings.Split(string(data), "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + flush() + continue + } + + // storage.cfg blocks use `: ` (e.g. `dir: local`, `nfs: backup`). + // Older exports may still use `storage: ` blocks. + typ, name, ok := parseSectionHeader(trimmed) + if ok { + flush() + storageType := "" + if !strings.EqualFold(typ, "storage") { + storageType = typ + } + current = &storageBlock{ID: name, Type: storageType} + continue + } + if current != nil { + key, value := parseProxmoxNotificationKV(trimmed) + if strings.TrimSpace(key) == "" { + continue + } + current.entries = append(current.entries, proxmoxNotificationEntry{Key: key, Value: value}) + } + } + flush() + + return blocks, nil +} + +func runPvesh(ctx context.Context, logger *logging.Logger, args []string) error { + output, err := restoreCmd.Run(ctx, "pvesh", args...) + if len(output) > 0 { + logger.Debug("pvesh %v output: %s", args, strings.TrimSpace(string(output))) + } + if err != nil { + return fmt.Errorf("pvesh %v failed: %w", args, err) + } + return nil +} + +func shortHost(host string) string { + if idx := strings.Index(host, "."); idx > 0 { + return host[:idx] + } + return host +} + +func sanitizeID(id string) string { + var b strings.Builder + for _, r := range id { + if isSafeIDRune(r) { + b.WriteRune(r) + } else { + b.WriteRune('_') + } + } + return b.String() +} + +func isSafeIDRune(r rune) bool { + return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' +} + +// promptClusterRestoreMode asks how to handle cluster database restore (safe export vs full recovery). +func promptClusterRestoreMode(ctx context.Context, reader *bufio.Reader) (int, error) { + fmt.Println() + fmt.Println("Cluster backup detected. Choose how to restore the cluster database:") + fmt.Println(" [1] SAFE: Do NOT write /var/lib/pve-cluster/config.db. Export cluster files only (manual/apply via API).") + fmt.Println(" [2] RECOVERY: Restore full cluster database (/var/lib/pve-cluster). Use only when cluster is offline/isolated.") + fmt.Println(" [0] Exit") + + for { + fmt.Print("Choice: ") + choiceLine, err := input.ReadLineWithContext(ctx, reader) + if err != nil { + return 0, err + } + switch strings.TrimSpace(choiceLine) { + case "1": + return 1, nil + case "2": + return 2, nil + case "0": + return 0, nil + default: + fmt.Println("Please enter 1, 2, or 0.") + } + } +} diff --git a/internal/orchestrator/restore_cluster_apply_additional_test.go b/internal/orchestrator/restore_cluster_apply_additional_test.go new file mode 100644 index 00000000..d9d5ac62 --- /dev/null +++ b/internal/orchestrator/restore_cluster_apply_additional_test.go @@ -0,0 +1,60 @@ +package orchestrator + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRunSafeClusterApplyWithUI_SkipsStorageDatacenterWhenStoragePVEStaged(t *testing.T) { + origCmd := restoreCmd + origFS := restoreFS + t.Cleanup(func() { + restoreCmd = origCmd + restoreFS = origFS + }) + restoreFS = osFS{} + + pathDir := t.TempDir() + pveshPath := filepath.Join(pathDir, "pvesh") + if err := os.WriteFile(pveshPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatalf("write pvesh: %v", err) + } + t.Setenv("PATH", pathDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + runner := &recordingRunner{} + restoreCmd = runner + + exportRoot := t.TempDir() + pveDir := filepath.Join(exportRoot, "etc", "pve") + if err := os.MkdirAll(pveDir, 0o755); err != nil { + t.Fatalf("mkdir pve dir: %v", err) + } + if err := os.WriteFile(filepath.Join(pveDir, "storage.cfg"), []byte("storage: local\n type dir\n"), 0o640); err != nil { + t.Fatalf("write storage.cfg: %v", err) + } + if err := os.WriteFile(filepath.Join(pveDir, "datacenter.cfg"), []byte("keyboard: it\n"), 0o640); err != nil { + t.Fatalf("write datacenter.cfg: %v", err) + } + + plan := &RestorePlan{ + SystemType: SystemTypePVE, + StagedCategories: []Category{{ID: "storage_pve", Type: CategoryTypePVE}}, + } + ui := &fakeRestoreWorkflowUI{ + applyStorageCfg: true, + applyDatacenterCfg: true, + } + + if err := runSafeClusterApplyWithUI(context.Background(), ui, exportRoot, newTestLogger(), plan); err != nil { + t.Fatalf("runSafeClusterApplyWithUI error: %v", err) + } + + for _, call := range runner.calls { + if strings.Contains(call, "pvesh create /storage") || strings.Contains(call, "pvesh set /storage") || strings.Contains(call, "/cluster/config") { + t.Fatalf("storage/datacenter apply should be skipped for storage_pve staged restore; calls=%#v", runner.calls) + } + } +} diff --git a/internal/orchestrator/restore_coverage_extra_test.go b/internal/orchestrator/restore_coverage_extra_test.go index 15bdd2b9..e21566cc 100644 --- a/internal/orchestrator/restore_coverage_extra_test.go +++ b/internal/orchestrator/restore_coverage_extra_test.go @@ -21,6 +21,8 @@ func (runOnlyRunner) Run(ctx context.Context, name string, args ...string) ([]by return nil, fmt.Errorf("unexpected command: %s", commandKey(name, args)) } +type zfsContextTestKey struct{} + type recordingRunner struct { calls []string } @@ -63,7 +65,7 @@ func TestDetectImportableZFSPools_ReturnsPoolsAndErrorWhenCommandFails(t *testin } restoreCmd = fake - pools, output, err := detectImportableZFSPools() + pools, output, err := detectImportableZFSPools(context.Background()) if err == nil { t.Fatalf("expected error") } @@ -86,7 +88,7 @@ func TestCheckZFSPoolsAfterRestore_ReturnsNilWhenZpoolMissing(t *testing.T) { } restoreCmd = fake - if err := checkZFSPoolsAfterRestore(newTestLogger()); err != nil { + if err := checkZFSPoolsAfterRestore(context.Background(), newTestLogger()); err != nil { t.Fatalf("expected nil error when zpool missing, got %v", err) } if len(fake.Calls) != 1 || fake.Calls[0] != "which zpool" { @@ -94,6 +96,50 @@ func TestCheckZFSPoolsAfterRestore_ReturnsNilWhenZpoolMissing(t *testing.T) { } } +func TestCheckZFSPoolsAfterRestore_UsesProvidedContext(t *testing.T) { + orig := restoreCmd + t.Cleanup(func() { restoreCmd = orig }) + + fake := &FakeCommandRunner{ + Outputs: map[string][]byte{ + "which zpool": []byte("/sbin/zpool\n"), + "zpool import": []byte(""), + }, + } + restoreCmd = fake + + ctx := context.WithValue(context.Background(), zfsContextTestKey{}, "restore") + if err := checkZFSPoolsAfterRestore(ctx, newTestLogger()); err != nil { + t.Fatalf("checkZFSPoolsAfterRestore error: %v", err) + } + + if len(fake.Contexts) == 0 { + t.Fatalf("expected command contexts to be recorded") + } + for i, got := range fake.Contexts { + if got.Value(zfsContextTestKey{}) != "restore" { + t.Fatalf("command context %d did not use restore context", i) + } + } +} + +func TestCheckZFSPoolsAfterRestore_ReturnsCanceledContext(t *testing.T) { + orig := restoreCmd + t.Cleanup(func() { restoreCmd = orig }) + + fake := &FakeCommandRunner{} + restoreCmd = fake + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := checkZFSPoolsAfterRestore(ctx, newTestLogger()); err != context.Canceled { + t.Fatalf("checkZFSPoolsAfterRestore error = %v, want context.Canceled", err) + } + if len(fake.Calls) != 0 { + t.Fatalf("expected no commands after canceled context, got %#v", fake.Calls) + } +} + func TestCheckZFSPoolsAfterRestore_ConfiguredPools_NoImportables(t *testing.T) { origCmd := restoreCmd origFS := restoreFS @@ -131,7 +177,7 @@ func TestCheckZFSPoolsAfterRestore_ConfiguredPools_NoImportables(t *testing.T) { } restoreCmd = fake - if err := checkZFSPoolsAfterRestore(newTestLogger()); err != nil { + if err := checkZFSPoolsAfterRestore(context.Background(), newTestLogger()); err != nil { t.Fatalf("checkZFSPoolsAfterRestore error: %v", err) } @@ -172,7 +218,7 @@ func TestCheckZFSPoolsAfterRestore_ReportsImportablePools(t *testing.T) { } restoreCmd = fake - if err := checkZFSPoolsAfterRestore(newTestLogger()); err != nil { + if err := checkZFSPoolsAfterRestore(context.Background(), newTestLogger()); err != nil { t.Fatalf("checkZFSPoolsAfterRestore error: %v", err) } @@ -313,10 +359,10 @@ func TestRunSafeClusterApply_AppliesVMStorageAndDatacenterConfigs(t *testing.T) } wantPrefixes := []string{ - "pvesh set /nodes/" + node + "/qemu/100/config --filename ", - "pvesh set /nodes/" + node + "/lxc/101/config --filename ", - "pvesh set /cluster/storage/local -conf ", - "pvesh set /cluster/storage/backup_ext -conf ", + "pvesh set /nodes/" + node + "/qemu/100/config --name=vm100", + "pvesh set /nodes/" + node + "/lxc/101/config --hostname=ct101", + "pvesh create /storage --storage=local --type=dir --path=/var/lib/vz", + "pvesh create /storage --storage=backup_ext --type=nfs --server=10.0.0.1", "pvesh set /cluster/config -conf ", } for _, prefix := range wantPrefixes { @@ -331,6 +377,14 @@ func TestRunSafeClusterApply_AppliesVMStorageAndDatacenterConfigs(t *testing.T) t.Fatalf("expected a call with prefix %q; calls=%#v", prefix, runner.calls) } } + for _, call := range runner.calls { + if strings.Contains(call, "--filename") { + t.Fatalf("VM/CT apply must not use invalid --filename flag; calls=%#v", runner.calls) + } + if strings.Contains(call, "/cluster/storage/") || (strings.Contains(call, " -conf ") && strings.Contains(call, "storage")) { + t.Fatalf("storage apply must not use invalid cluster storage path or -conf flag; calls=%#v", runner.calls) + } + } } func TestRunSafeClusterApply_AppliesPoolsFromUserCfg(t *testing.T) { @@ -485,11 +539,10 @@ func TestRunSafeClusterApply_UsesSingleExportedNodeWhenHostnameMismatch(t *testi t.Fatalf("runSafeClusterApply error: %v", err) } - wantPrefix := "pvesh set /nodes/" + targetNode + "/qemu/100/config --filename " - wantSourceSuffix := filepath.Join("etc", "pve", "nodes", sourceNode, "qemu-server", "100.conf") + wantPrefix := "pvesh set /nodes/" + targetNode + "/qemu/100/config --name=vm100" found := false for _, call := range runner.calls { - if strings.HasPrefix(call, wantPrefix) && strings.Contains(call, wantSourceSuffix) { + if strings.HasPrefix(call, wantPrefix) { found = true break } @@ -547,11 +600,10 @@ func TestRunSafeClusterApply_PromptsForSourceNodeWhenMultipleExportNodes(t *test t.Fatalf("runSafeClusterApply error: %v", err) } - wantPrefix := "pvesh set /nodes/" + targetNode + "/qemu/101/config --filename " - wantSourceSuffix := filepath.Join("etc", "pve", "nodes", sourceNode2, "qemu-server", "101.conf") + wantPrefix := "pvesh set /nodes/" + targetNode + "/qemu/101/config --name=vm101" found := false for _, call := range runner.calls { - if strings.HasPrefix(call, wantPrefix) && strings.Contains(call, wantSourceSuffix) { + if strings.HasPrefix(call, wantPrefix) { found = true break } @@ -612,13 +664,9 @@ func TestRunRestoreCommandStream_FallsBackToExecCommand(t *testing.T) { if err != nil { t.Fatalf("runRestoreCommandStream error: %v", err) } - rc, ok := reader.(io.ReadCloser) - if !ok { - t.Fatalf("expected io.ReadCloser, got %T", reader) - } - defer rc.Close() + defer reader.Close() - out, err := io.ReadAll(rc) + out, err := io.ReadAll(reader) if err != nil { t.Fatalf("read: %v", err) } diff --git a/internal/orchestrator/restore_decision.go b/internal/orchestrator/restore_decision.go index dafa20a6..903984f6 100644 --- a/internal/orchestrator/restore_decision.go +++ b/internal/orchestrator/restore_decision.go @@ -91,14 +91,12 @@ func inspectRestoreArchiveContents(archivePath string, logger *logging.Logger) ( if err != nil { return nil, err } - if closer, ok := reader.(interface{ Close() error }); ok { - defer func() { - if closeErr := closer.Close(); closeErr != nil && err == nil { - inspection = nil - err = fmt.Errorf("inspect archive: %w", closeErr) - } - }() - } + defer func() { + if closeErr := reader.Close(); closeErr != nil && err == nil { + inspection = nil + err = fmt.Errorf("inspect archive: %w", closeErr) + } + }() tarReader := tar.NewReader(reader) archivePaths, metadata, metadataErr, collectErr := collectRestoreArchiveFacts(tarReader) diff --git a/internal/orchestrator/restore_decompression.go b/internal/orchestrator/restore_decompression.go new file mode 100644 index 00000000..224fea52 --- /dev/null +++ b/internal/orchestrator/restore_decompression.go @@ -0,0 +1,103 @@ +// Package orchestrator coordinates backup, restore, decrypt, and related workflows. +package orchestrator + +import ( + "compress/gzip" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/tis24dev/proxsave/internal/safeexec" +) + +type restoreDecompressionFormat struct { + matches func(string) bool + open func(context.Context, *os.File) (io.ReadCloser, error) +} + +// createDecompressionReader creates appropriate decompression reader based on file extension +func createDecompressionReader(ctx context.Context, file *os.File, archivePath string) (io.ReadCloser, error) { + for _, format := range restoreDecompressionFormats() { + if format.matches(archivePath) { + return format.open(ctx, file) + } + } + return nil, fmt.Errorf("unsupported archive format: %s", filepath.Base(archivePath)) +} + +func closeDecompressionReader(reader io.Closer, errp *error, operation string) { + if reader == nil || errp == nil { + return + } + if closeErr := reader.Close(); closeErr != nil && *errp == nil { + *errp = fmt.Errorf("%s: %w", operation, closeErr) + } +} + +func restoreDecompressionFormats() []restoreDecompressionFormat { + return []restoreDecompressionFormat{ + { + matches: func(path string) bool { return strings.HasSuffix(path, ".tar.gz") || strings.HasSuffix(path, ".tgz") }, + open: func(_ context.Context, file *os.File) (io.ReadCloser, error) { return gzip.NewReader(file) }, + }, + {matches: func(path string) bool { return strings.HasSuffix(path, ".tar.xz") }, open: createXZReader}, + { + matches: func(path string) bool { + return strings.HasSuffix(path, ".tar.zst") || strings.HasSuffix(path, ".tar.zstd") + }, + open: createZstdReader, + }, + {matches: func(path string) bool { return strings.HasSuffix(path, ".tar.bz2") }, open: createBzip2Reader}, + {matches: func(path string) bool { return strings.HasSuffix(path, ".tar.lzma") }, open: createLzmaReader}, + {matches: func(path string) bool { return strings.HasSuffix(path, ".tar") }, open: func(_ context.Context, file *os.File) (io.ReadCloser, error) { return file, nil }}, + } +} + +// createXZReader creates an XZ decompression reader using injectable command runner +func createXZReader(ctx context.Context, file *os.File) (io.ReadCloser, error) { + return runRestoreCommandStream(ctx, "xz", file, "-d", "-c") +} + +// createZstdReader creates a Zstd decompression reader using injectable command runner +func createZstdReader(ctx context.Context, file *os.File) (io.ReadCloser, error) { + return runRestoreCommandStream(ctx, "zstd", file, "-d", "-c") +} + +// createBzip2Reader creates a Bzip2 decompression reader using injectable command runner +func createBzip2Reader(ctx context.Context, file *os.File) (io.ReadCloser, error) { + return runRestoreCommandStream(ctx, "bzip2", file, "-d", "-c") +} + +// createLzmaReader creates an LZMA decompression reader using injectable command runner +func createLzmaReader(ctx context.Context, file *os.File) (io.ReadCloser, error) { + return runRestoreCommandStream(ctx, "lzma", file, "-d", "-c") +} + +// runRestoreCommandStream starts a command that reads from stdin and exposes stdout as a ReadCloser. +// It prefers an injectable streaming runner when available; otherwise falls back to safeexec. +func runRestoreCommandStream(ctx context.Context, name string, stdin io.Reader, args ...string) (io.ReadCloser, error) { + type streamingRunner interface { + RunStream(ctx context.Context, name string, stdin io.Reader, args ...string) (io.ReadCloser, error) + } + if sr, ok := restoreCmd.(streamingRunner); ok && sr != nil { + return sr.RunStream(ctx, name, stdin, args...) + } + + cmd, err := safeexec.CommandContext(ctx, name, args...) + if err != nil { + return nil, err + } + cmd.Stdin = stdin + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("create %s pipe: %w", name, err) + } + if err := cmd.Start(); err != nil { + stdout.Close() + return nil, fmt.Errorf("start %s: %w", name, err) + } + return &waitReadCloser{ReadCloser: stdout, wait: cmd.Wait}, nil +} diff --git a/internal/orchestrator/restore_errors_test.go b/internal/orchestrator/restore_errors_test.go index 313cfa91..bfe6c39e 100644 --- a/internal/orchestrator/restore_errors_test.go +++ b/internal/orchestrator/restore_errors_test.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "strings" + "syscall" "testing" "time" @@ -54,7 +55,7 @@ func TestRunRestoreCommandStream_UsesStreamingRunner(t *testing.T) { if err != nil { t.Fatalf("createXZReader: %v", err) } - defer reader.(io.Closer).Close() + defer reader.Close() buf, err := io.ReadAll(reader) if err != nil { @@ -550,9 +551,11 @@ func TestApplyStorageCfg_WithMultipleBlocks(t *testing.T) { // Write storage config with multiple blocks cfgPath := filepath.Join(t.TempDir(), "storage.cfg") content := `storage: local + type dir path /var/lib/vz storage: backup + type nfs path /mnt/backup ` if err := os.WriteFile(cfgPath, []byte(content), 0o644); err != nil { @@ -569,6 +572,16 @@ storage: backup if applied != 2 { t.Fatalf("expected 2 applied, got %d (failed=%d)", applied, failed) } + calls := strings.Join(restoreCmd.(*FakeCommandRunner).CallsList(), "\n") + if strings.Contains(calls, " -conf ") { + t.Fatalf("storage apply must not use -conf; calls=%s", calls) + } + if !strings.Contains(calls, "pvesh create /storage --storage=local --type=dir --path=/var/lib/vz") { + t.Fatalf("missing local storage args; calls=%s", calls) + } + if !strings.Contains(calls, "pvesh create /storage --storage=backup --type=nfs --path=/mnt/backup") { + t.Fatalf("missing backup storage args; calls=%s", calls) + } } func TestApplyStorageCfg_PveshError(t *testing.T) { @@ -582,6 +595,7 @@ func TestApplyStorageCfg_PveshError(t *testing.T) { cfgPath := filepath.Join(t.TempDir(), "storage.cfg") content := `storage: local + type dir path /var/lib/vz ` if err := os.WriteFile(cfgPath, []byte(content), 0o644); err != nil { @@ -882,6 +896,12 @@ func (f *ErrorInjectingFS) MkdirTemp(dir, pattern string) (string, error) { func (f *ErrorInjectingFS) Rename(oldpath, newpath string) error { return f.base.Rename(oldpath, newpath) } +func (f *ErrorInjectingFS) Lchown(path string, uid, gid int) error { + return f.base.Lchown(path, uid, gid) +} +func (f *ErrorInjectingFS) UtimesNano(path string, times []syscall.Timespec) error { + return f.base.UtimesNano(path, times) +} func (f *ErrorInjectingFS) MkdirAll(path string, perm os.FileMode) error { if f.mkdirAllErr != nil { @@ -1704,6 +1724,49 @@ func TestApplyVMConfigs_SuccessfulApply(t *testing.T) { if applied != 1 || failed != 0 { t.Fatalf("expected (1,0), got (%d,%d)", applied, failed) } + calls := strings.Join(fake.CallsList(), "\n") + if strings.Contains(calls, "--filename") { + t.Fatalf("VM apply must not use --filename; calls=%s", calls) + } + if !strings.Contains(calls, "pvesh set /nodes/") || !strings.Contains(calls, "/qemu/100/config --name=test-vm") { + t.Fatalf("missing VM config args; calls=%s", calls) + } +} + +func TestApplyVMConfigs_CreatesMissingGuestBeforeSet(t *testing.T) { + orig := restoreCmd + t.Cleanup(func() { restoreCmd = orig }) + + node := localNodeName() + getCall := fmt.Sprintf("pvesh get /nodes/%s/qemu/100/config", node) + fake := &FakeCommandRunner{ + Outputs: map[string][]byte{}, + Errors: map[string]error{ + getCall: fmt.Errorf("not found"), + }, + } + restoreCmd = fake + + dir := t.TempDir() + configPath := filepath.Join(dir, "100.conf") + if err := os.WriteFile(configPath, []byte("name: test-vm"), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + entries := []vmEntry{{VMID: "100", Kind: "qemu", Path: configPath}} + logger := logging.New(logging.GetDefaultLogger().GetLevel(), false) + applied, failed := applyVMConfigs(context.Background(), entries, logger) + + if applied != 1 || failed != 0 { + t.Fatalf("expected (1,0), got (%d,%d)", applied, failed) + } + calls := strings.Join(fake.CallsList(), "\n") + if !strings.Contains(calls, fmt.Sprintf("pvesh create /nodes/%s/qemu --vmid=100", node)) { + t.Fatalf("missing create call; calls=%s", calls) + } + if !strings.Contains(calls, fmt.Sprintf("pvesh set /nodes/%s/qemu/100/config --name=test-vm", node)) { + t.Fatalf("missing set call; calls=%s", calls) + } } // -------------------------------------------------------------------------- @@ -1906,7 +1969,12 @@ func TestExtractArchiveNative_OpenError(t *testing.T) { restoreFS = osFS{} logger := logging.New(logging.GetDefaultLogger().GetLevel(), false) - err := extractArchiveNative(context.Background(), "/nonexistent/archive.tar", "/tmp", logger, nil, RestoreModeFull, nil, "", nil) + err := extractArchiveNative(context.Background(), restoreArchiveOptions{ + archivePath: "/nonexistent/archive.tar", + destRoot: "/tmp", + logger: logger, + mode: RestoreModeFull, + }) if err == nil || !strings.Contains(err.Error(), "open archive") { t.Fatalf("expected open error, got: %v", err) } diff --git a/internal/orchestrator/restore_filesystem_test.go b/internal/orchestrator/restore_filesystem_test.go index 97d8a448..b627a727 100644 --- a/internal/orchestrator/restore_filesystem_test.go +++ b/internal/orchestrator/restore_filesystem_test.go @@ -276,7 +276,13 @@ func TestExtractArchiveNative_SkipFnSkipsFstab(t *testing.T) { return name == "etc/fstab" } - if err := extractArchiveNative(context.Background(), archivePath, destRoot, newTestLogger(), nil, RestoreModeFull, nil, "", skipFn); err != nil { + if err := extractArchiveNative(context.Background(), restoreArchiveOptions{ + archivePath: archivePath, + destRoot: destRoot, + logger: newTestLogger(), + mode: RestoreModeFull, + skipFn: skipFn, + }); err != nil { t.Fatalf("extractArchiveNative error: %v", err) } diff --git a/internal/orchestrator/restore_firewall.go b/internal/orchestrator/restore_firewall.go index 50e27a6a..0c2c6899 100644 --- a/internal/orchestrator/restore_firewall.go +++ b/internal/orchestrator/restore_firewall.go @@ -476,8 +476,8 @@ func armFirewallRollback(ctx context.Context, logger *logging.Logger, backupPath } if handle.unitName == "" { - cmd := fmt.Sprintf("nohup sh -c 'sleep %d; /bin/sh %s' >/dev/null 2>&1 &", timeoutSeconds, handle.scriptPath) - if output, err := restoreCmd.Run(ctx, "sh", "-c", cmd); err != nil { + output, err := runBackgroundRollbackTimer(ctx, timeoutSeconds, handle.scriptPath) + if err != nil { logger.Debug("Background rollback output: %s", strings.TrimSpace(string(output))) return nil, fmt.Errorf("failed to arm rollback timer: %w", err) } diff --git a/internal/orchestrator/restore_firewall_additional_test.go b/internal/orchestrator/restore_firewall_additional_test.go index 11f47ecb..beef3796 100644 --- a/internal/orchestrator/restore_firewall_additional_test.go +++ b/internal/orchestrator/restore_firewall_additional_test.go @@ -704,8 +704,7 @@ func TestArmFirewallRollback_SystemdAndBackgroundPaths(t *testing.T) { t.Fatalf("expected unitName cleared after systemd-run failure, got %q", handle.unitName) } - cmd := fmt.Sprintf("nohup sh -c 'sleep %d; /bin/sh %s' >/dev/null 2>&1 &", 2, scriptPath) - wantBackground := "sh -c " + cmd + wantBackground := backgroundRollbackCallKey(2, scriptPath) calls := fakeCmd.CallsList() if len(calls) != 2 || calls[1] != wantBackground { t.Fatalf("unexpected calls: %#v", calls) @@ -723,8 +722,7 @@ func TestArmFirewallRollback_SystemdAndBackgroundPaths(t *testing.T) { timestamp := fakeTime.Current.Format("20060102_150405") scriptPath := filepath.Join("/tmp/proxsave", fmt.Sprintf("firewall_rollback_%s.sh", timestamp)) - cmd := fmt.Sprintf("nohup sh -c 'sleep %d; /bin/sh %s' >/dev/null 2>&1 &", 1, scriptPath) - backgroundKey := "sh -c " + cmd + backgroundKey := backgroundRollbackCallKey(1, scriptPath) fakeCmd.Errors[backgroundKey] = fmt.Errorf("boom") if _, err := armFirewallRollback(context.Background(), logger, "/backup.tgz", 1*time.Second, "/tmp/proxsave"); err == nil { @@ -1563,7 +1561,7 @@ func TestArmFirewallRollback_DefaultWorkDirAndMinTimeout(t *testing.T) { if len(calls) != 1 { t.Fatalf("unexpected calls: %#v", calls) } - if !strings.Contains(calls[0], "sleep 1; /bin/sh") { + if calls[0] != backgroundRollbackCallKey(1, handle.scriptPath) { t.Fatalf("expected timeoutSeconds to clamp to 1, got call=%q", calls[0]) } } diff --git a/internal/orchestrator/restore_ha.go b/internal/orchestrator/restore_ha.go index 0111a16d..6765e7c9 100644 --- a/internal/orchestrator/restore_ha.go +++ b/internal/orchestrator/restore_ha.go @@ -404,8 +404,8 @@ func armHARollback(ctx context.Context, logger *logging.Logger, backupPath strin } if handle.unitName == "" { - cmd := fmt.Sprintf("nohup sh -c 'sleep %d; /bin/sh %s' >/dev/null 2>&1 &", timeoutSeconds, handle.scriptPath) - if output, err := restoreCmd.Run(ctx, "sh", "-c", cmd); err != nil { + output, err := runBackgroundRollbackTimer(ctx, timeoutSeconds, handle.scriptPath) + if err != nil { logger.Debug("Background rollback output: %s", strings.TrimSpace(string(output))) return nil, fmt.Errorf("failed to schedule rollback timer: %w", err) } diff --git a/internal/orchestrator/restore_ha_additional_test.go b/internal/orchestrator/restore_ha_additional_test.go index 4bee40e4..0d1554b7 100644 --- a/internal/orchestrator/restore_ha_additional_test.go +++ b/internal/orchestrator/restore_ha_additional_test.go @@ -275,7 +275,7 @@ func TestArmHARollback_CoversSchedulingPaths(t *testing.T) { t.Fatalf("expected backup path in script, got:\n%s", string(script)) } - wantBackground := "sh -c nohup sh -c 'sleep 2; /bin/sh " + handle.scriptPath + "' >/dev/null 2>&1 &" + wantBackground := backgroundRollbackCallKey(2, handle.scriptPath) calls := env.cmd.CallsList() if len(calls) != 1 || calls[0] != wantBackground { t.Fatalf("unexpected calls: %#v", calls) @@ -290,7 +290,7 @@ func TestArmHARollback_CoversSchedulingPaths(t *testing.T) { if err != nil { t.Fatalf("armHARollback error: %v", err) } - wantBackground := "sh -c nohup sh -c 'sleep 1; /bin/sh " + handle.scriptPath + "' >/dev/null 2>&1 &" + wantBackground := backgroundRollbackCallKey(1, handle.scriptPath) calls := env.cmd.CallsList() if len(calls) != 1 || calls[0] != wantBackground { t.Fatalf("unexpected calls: %#v", calls) @@ -319,7 +319,7 @@ func TestArmHARollback_CoversSchedulingPaths(t *testing.T) { t.Fatalf("expected unitName to be cleared after systemd-run failure, got %q", handle.unitName) } - wantBackground := "sh -c nohup sh -c 'sleep 2; /bin/sh " + scriptPath + "' >/dev/null 2>&1 &" + wantBackground := backgroundRollbackCallKey(2, scriptPath) calls := env.cmd.CallsList() if len(calls) != 2 || calls[0] != systemdKey || calls[1] != wantBackground { t.Fatalf("unexpected calls: %#v", calls) @@ -332,7 +332,7 @@ func TestArmHARollback_CoversSchedulingPaths(t *testing.T) { timestamp := env.fakeTime.Current.Format("20060102_150405") scriptPath := filepath.Join("/tmp/proxsave", fmt.Sprintf("ha_rollback_%s.sh", timestamp)) - backgroundKey := "sh -c nohup sh -c 'sleep 1; /bin/sh " + scriptPath + "' >/dev/null 2>&1 &" + backgroundKey := backgroundRollbackCallKey(1, scriptPath) env.cmd.Errors = map[string]error{ backgroundKey: fmt.Errorf("boom"), } diff --git a/internal/orchestrator/restore_notifications.go b/internal/orchestrator/restore_notifications.go index df841569..8c6305d7 100644 --- a/internal/orchestrator/restore_notifications.go +++ b/internal/orchestrator/restore_notifications.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "strings" "github.com/tis24dev/proxsave/internal/logging" @@ -23,6 +24,8 @@ type proxmoxNotificationSection struct { RedactFlags []string } +var sectionHeaderTypePattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + func maybeApplyNotificationsFromStage(ctx context.Context, logger *logging.Logger, plan *RestorePlan, stageRoot string, dryRun bool) (err error) { if plan == nil { return nil @@ -401,7 +404,7 @@ func parseProxmoxNotificationSections(content string) ([]proxmoxNotificationSect return out, nil } -func parseProxmoxNotificationHeader(line string) (typ, name string, ok bool) { +func parseSectionHeader(line string) (typ, name string, ok bool) { idx := strings.Index(line, ":") if idx <= 0 { return "", "", false @@ -411,19 +414,16 @@ func parseProxmoxNotificationHeader(line string) (typ, name string, ok bool) { if typ == "" || name == "" { return "", "", false } - for _, r := range typ { - switch { - case r >= 'a' && r <= 'z': - case r >= 'A' && r <= 'Z': - case r >= '0' && r <= '9': - case r == '-' || r == '_': - default: - return "", "", false - } + if !sectionHeaderTypePattern.MatchString(typ) { + return "", "", false } return typ, name, true } +func parseProxmoxNotificationHeader(line string) (typ, name string, ok bool) { + return parseSectionHeader(line) +} + func parseProxmoxNotificationKV(line string) (key, value string) { fields := strings.Fields(line) if len(fields) == 0 { diff --git a/internal/orchestrator/restore_services.go b/internal/orchestrator/restore_services.go new file mode 100644 index 00000000..1590a325 --- /dev/null +++ b/internal/orchestrator/restore_services.go @@ -0,0 +1,473 @@ +// Package orchestrator coordinates backup, restore, decrypt, and related workflows. +package orchestrator + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "time" + + "github.com/tis24dev/proxsave/internal/logging" +) + +var ( + serviceStopTimeout = 45 * time.Second + serviceStopNoBlockTimeout = 15 * time.Second + serviceStartTimeout = 30 * time.Second + serviceVerifyTimeout = 30 * time.Second + serviceStatusCheckTimeout = 5 * time.Second + servicePollInterval = 500 * time.Millisecond + serviceRetryDelay = 500 * time.Millisecond +) + +type restoreCommandResult struct { + out []byte + err error +} + +type restoreCommandProgress struct { + enabled bool + service string + action string + deadline time.Time +} + +type serviceInactiveWaiter struct { + ctx context.Context + logger *logging.Logger + service string + timeout time.Duration + deadline time.Time + progressEnabled bool + ticker *time.Ticker +} + +func stopPVEClusterServices(ctx context.Context, logger *logging.Logger) error { + services := []string{"pve-cluster", "pvedaemon", "pveproxy", "pvestatd"} + for _, service := range services { + if err := stopServiceWithRetries(ctx, logger, service); err != nil { + return fmt.Errorf("failed to stop PVE services (%s): %w", service, err) + } + } + return nil +} + +func startPVEClusterServices(ctx context.Context, logger *logging.Logger) error { + services := []string{"pve-cluster", "pvedaemon", "pveproxy", "pvestatd"} + for _, service := range services { + if err := startServiceWithRetries(ctx, logger, service); err != nil { + return fmt.Errorf("failed to start PVE services (%s): %w", service, err) + } + } + return nil +} + +func stopPBSServices(ctx context.Context, logger *logging.Logger) error { + if _, err := restoreCmd.Run(ctx, "which", "systemctl"); err != nil { + return fmt.Errorf("systemctl not available: %w", err) + } + services := []string{"proxmox-backup-proxy", "proxmox-backup"} + var failures []string + for _, service := range services { + if err := stopServiceWithRetries(ctx, logger, service); err != nil { + failures = append(failures, fmt.Sprintf("%s: %v", service, err)) + } + } + if len(failures) > 0 { + return errors.New(strings.Join(failures, "; ")) + } + return nil +} + +func startPBSServices(ctx context.Context, logger *logging.Logger) error { + if _, err := restoreCmd.Run(ctx, "which", "systemctl"); err != nil { + return fmt.Errorf("systemctl not available: %w", err) + } + services := []string{"proxmox-backup", "proxmox-backup-proxy"} + var failures []string + for _, service := range services { + if err := startServiceWithRetries(ctx, logger, service); err != nil { + failures = append(failures, fmt.Sprintf("%s: %v", service, err)) + } + } + if len(failures) > 0 { + return errors.New(strings.Join(failures, "; ")) + } + return nil +} + +func unmountEtcPVE(ctx context.Context, logger *logging.Logger) error { + output, err := restoreCmd.Run(ctx, "umount", "/etc/pve") + msg := strings.TrimSpace(string(output)) + if err != nil { + if strings.Contains(msg, "not mounted") { + logger.Info("Skipping umount /etc/pve (already unmounted)") + return nil + } + if msg != "" { + return fmt.Errorf("umount /etc/pve failed: %s", msg) + } + return fmt.Errorf("umount /etc/pve failed: %w", err) + } + if msg != "" { + logger.Debug("umount /etc/pve output: %s", msg) + } + return nil +} + +func runCommandWithTimeout(ctx context.Context, logger *logging.Logger, timeout time.Duration, name string, args ...string) error { + return execCommand(ctx, logger, timeout, name, args...) +} + +func execCommand(ctx context.Context, logger *logging.Logger, timeout time.Duration, name string, args ...string) error { + execCtx, cancel := commandContextWithTimeout(ctx, timeout) + defer cancel() + output, err := restoreCmd.Run(execCtx, name, args...) + msg := strings.TrimSpace(string(output)) + if err != nil { + return restoreCommandError(execCtx, timeout, name, args, msg, err) + } + logRestoreCommandOutput(logger, name, args, msg) + return nil +} + +func commandContextWithTimeout(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) { + if timeout <= 0 { + return ctx, func() {} + } + return context.WithTimeout(ctx, timeout) +} + +func restoreCommandError(execCtx context.Context, timeout time.Duration, name string, args []string, msg string, err error) error { + command := fmt.Sprintf("%s %s", name, strings.Join(args, " ")) + if timeout > 0 && (errors.Is(execCtx.Err(), context.DeadlineExceeded) || errors.Is(err, context.DeadlineExceeded)) { + return fmt.Errorf("%s timed out after %s", command, timeout) + } + if msg != "" { + return fmt.Errorf("%s failed: %s", command, msg) + } + return fmt.Errorf("%s failed: %w", command, err) +} + +func logRestoreCommandOutput(logger *logging.Logger, name string, args []string, msg string) { + if msg != "" && logger != nil { + logger.Debug("%s %s: %s", name, strings.Join(args, " "), msg) + } +} + +func stopServiceWithRetries(ctx context.Context, logger *logging.Logger, service string) error { + attempts := []struct { + description string + args []string + timeout time.Duration + }{ + {"stop (no-block)", []string{"stop", "--no-block", service}, serviceStopNoBlockTimeout}, + {"stop (blocking)", []string{"stop", service}, serviceStopTimeout}, + {"aggressive stop", []string{"kill", "--signal=SIGTERM", "--kill-who=all", service}, serviceStopTimeout}, + {"force kill", []string{"kill", "--signal=SIGKILL", "--kill-who=all", service}, serviceStopTimeout}, + } + + var lastErr error + for i, attempt := range attempts { + if i > 0 { + if err := sleepWithContext(ctx, serviceRetryDelay); err != nil { + return err + } + } + + if logger != nil { + logger.Debug("Attempting %s for %s (%d/%d)", attempt.description, service, i+1, len(attempts)) + } + + if err := runCommandWithTimeoutCountdown(ctx, logger, attempt.timeout, service, attempt.description, "systemctl", attempt.args...); err != nil { + lastErr = err + continue + } + if err := waitForServiceInactive(ctx, logger, service, serviceVerifyTimeout); err != nil { + lastErr = err + continue + } + resetFailedService(ctx, logger, service) + return nil + } + + if lastErr == nil { + lastErr = fmt.Errorf("unable to stop %s", service) + } + return lastErr +} + +func startServiceWithRetries(ctx context.Context, logger *logging.Logger, service string) error { + attempts := []struct { + description string + args []string + }{ + {"start", []string{"start", service}}, + {"retry start", []string{"start", service}}, + {"aggressive restart", []string{"restart", service}}, + } + + var lastErr error + for i, attempt := range attempts { + if i > 0 { + if err := sleepWithContext(ctx, serviceRetryDelay); err != nil { + return err + } + } + + if logger != nil { + logger.Debug("Attempting %s for %s (%d/%d)", attempt.description, service, i+1, len(attempts)) + } + + if err := runCommandWithTimeout(ctx, logger, serviceStartTimeout, "systemctl", attempt.args...); err != nil { + lastErr = err + continue + } + return nil + } + + if lastErr == nil { + lastErr = fmt.Errorf("unable to start %s", service) + } + return lastErr +} + +func runCommandWithTimeoutCountdown(ctx context.Context, logger *logging.Logger, timeout time.Duration, service, action, name string, args ...string) error { + if timeout <= 0 { + return execCommand(ctx, logger, timeout, name, args...) + } + + execCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + resultCh := startRestoreCommand(execCtx, name, args...) + progress := newRestoreCommandProgress(service, action, timeout) + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { + select { + case r := <-resultCh: + progress.clear() + return finishRestoreCommandResult(execCtx, logger, timeout, name, args, r) + case <-ticker.C: + progress.write(time.Until(progress.deadline)) + case <-execCtx.Done(): + return finishRestoreCommandTimeout(logger, name, args, timeout, resultCh, progress) + } + } +} + +func startRestoreCommand(ctx context.Context, name string, args ...string) <-chan restoreCommandResult { + resultCh := make(chan restoreCommandResult, 1) + go func() { + out, err := restoreCmd.Run(ctx, name, args...) + resultCh <- restoreCommandResult{out: out, err: err} + }() + return resultCh +} + +func newRestoreCommandProgress(service, action string, timeout time.Duration) restoreCommandProgress { + return restoreCommandProgress{ + enabled: isTerminal(int(os.Stderr.Fd())), + service: service, + action: action, + deadline: time.Now().Add(timeout), + } +} + +func (progress restoreCommandProgress) write(left time.Duration) { + if !progress.enabled { + return + } + seconds := int(left.Round(time.Second).Seconds()) + if seconds < 0 { + seconds = 0 + } + fmt.Fprintf(os.Stderr, "\rStopping %s: %s (attempt timeout in %ds)...", progress.service, progress.action, seconds) +} + +func (progress restoreCommandProgress) clear() { + if !progress.enabled { + return + } + fmt.Fprint(os.Stderr, "\r") + fmt.Fprintln(os.Stderr, strings.Repeat(" ", 80)) + fmt.Fprint(os.Stderr, "\r") +} + +func (progress restoreCommandProgress) newline() { + if progress.enabled { + fmt.Fprintln(os.Stderr) + } +} + +func finishRestoreCommandResult(execCtx context.Context, logger *logging.Logger, timeout time.Duration, name string, args []string, result restoreCommandResult) error { + msg := strings.TrimSpace(string(result.out)) + if result.err != nil { + return restoreCommandError(execCtx, timeout, name, args, msg, result.err) + } + logRestoreCommandOutput(logger, name, args, msg) + return nil +} + +func finishRestoreCommandTimeout(logger *logging.Logger, name string, args []string, timeout time.Duration, resultCh <-chan restoreCommandResult, progress restoreCommandProgress) error { + progress.write(0) + progress.newline() + select { + case result := <-resultCh: + logRestoreCommandOutput(logger, name, args, strings.TrimSpace(string(result.out))) + default: + } + return fmt.Errorf("%s %s timed out after %s", name, strings.Join(args, " "), timeout) +} + +func waitForServiceInactive(ctx context.Context, logger *logging.Logger, service string, timeout time.Duration) error { + if timeout <= 0 { + return nil + } + waiter := newServiceInactiveWaiter(ctx, logger, service, timeout) + defer waiter.ticker.Stop() + for { + remaining := time.Until(waiter.deadline) + if err := waiter.ensureTimeRemaining(remaining); err != nil { + return err + } + active, err := isServiceActive(ctx, service, minDuration(remaining, serviceStatusCheckTimeout)) + if err != nil { + return err + } + if !active { + waiter.logStopped() + return nil + } + if err := waiter.sleepOrCancel(remaining); err != nil { + return err + } + waiter.writeProgress(remaining) + } +} + +func newServiceInactiveWaiter(ctx context.Context, logger *logging.Logger, service string, timeout time.Duration) serviceInactiveWaiter { + return serviceInactiveWaiter{ + ctx: ctx, + logger: logger, + service: service, + timeout: timeout, + deadline: time.Now().Add(timeout), + progressEnabled: isTerminal(int(os.Stderr.Fd())), + ticker: time.NewTicker(1 * time.Second), + } +} + +func (waiter serviceInactiveWaiter) ensureTimeRemaining(remaining time.Duration) error { + if remaining > 0 { + return nil + } + waiter.writeNewline() + return fmt.Errorf("%s still active after %s", waiter.service, waiter.timeout) +} + +func (waiter serviceInactiveWaiter) logStopped() { + if waiter.logger != nil { + waiter.logger.Debug("%s stopped successfully", waiter.service) + } +} + +func (waiter serviceInactiveWaiter) sleepOrCancel(remaining time.Duration) error { + timer := time.NewTimer(minDuration(remaining, servicePollInterval)) + defer timer.Stop() + select { + case <-waiter.ctx.Done(): + waiter.writeNewline() + return waiter.ctx.Err() + case <-timer.C: + return nil + } +} + +func (waiter serviceInactiveWaiter) writeProgress(remaining time.Duration) { + select { + case <-waiter.ticker.C: + if waiter.progressEnabled { + seconds := int(remaining.Round(time.Second).Seconds()) + if seconds < 0 { + seconds = 0 + } + fmt.Fprintf(os.Stderr, "\rWaiting for %s to stop (%ds remaining)...", waiter.service, seconds) + } + default: + } +} + +func (waiter serviceInactiveWaiter) writeNewline() { + if waiter.progressEnabled { + fmt.Fprintln(os.Stderr) + } +} + +func resetFailedService(ctx context.Context, logger *logging.Logger, service string) { + resetCtx, cancel := context.WithTimeout(ctx, serviceStatusCheckTimeout) + defer cancel() + + if _, err := restoreCmd.Run(resetCtx, "systemctl", "reset-failed", service); err != nil { + if logger != nil { + logger.Debug("systemctl reset-failed %s ignored: %v", service, err) + } + } +} + +func isServiceActive(ctx context.Context, service string, timeout time.Duration) (bool, error) { + if timeout <= 0 { + timeout = serviceStatusCheckTimeout + } + checkCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + output, err := restoreCmd.Run(checkCtx, "systemctl", "is-active", service) + msg := strings.TrimSpace(string(output)) + if err == nil { + return true, nil + } + if errors.Is(checkCtx.Err(), context.DeadlineExceeded) || errors.Is(err, context.DeadlineExceeded) { + return false, fmt.Errorf("systemctl is-active %s timed out after %s", service, timeout) + } + if msg == "" { + msg = err.Error() + } + return parseSystemctlActiveState(service, msg) +} + +func parseSystemctlActiveState(service, msg string) (bool, error) { + lower := strings.ToLower(msg) + if strings.Contains(lower, "deactivating") || strings.Contains(lower, "activating") { + return true, nil + } + if strings.Contains(lower, "inactive") || strings.Contains(lower, "failed") || strings.Contains(lower, "dead") { + return false, nil + } + return false, fmt.Errorf("systemctl is-active %s failed: %s", service, msg) +} + +func minDuration(a, b time.Duration) time.Duration { + if a < b { + return a + } + return b +} + +func sleepWithContext(ctx context.Context, d time.Duration) error { + if d <= 0 { + return nil + } + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} diff --git a/internal/orchestrator/restore_test.go b/internal/orchestrator/restore_test.go index 2a9c37a5..622057ca 100644 --- a/internal/orchestrator/restore_test.go +++ b/internal/orchestrator/restore_test.go @@ -63,6 +63,29 @@ func TestExtractTarEntry_BlocksPathTraversal(t *testing.T) { } } +func TestShouldSkipRestoreEntryTargetEtcPVEBoundary(t *testing.T) { + logger := logging.New(types.LogLevelDebug, false) + header := &tar.Header{Name: "etc/pveuser.conf"} + + for _, target := range []string{"/etc/pve", "/etc/pve/local.cfg"} { + skip, err := shouldSkipRestoreEntryTarget(header, target, string(os.PathSeparator), logger) + if err != nil { + t.Fatalf("shouldSkipRestoreEntryTarget(%q) error: %v", target, err) + } + if !skip { + t.Fatalf("expected %q to be skipped", target) + } + } + + skip, err := shouldSkipRestoreEntryTarget(header, "/etc/pveuser.conf", string(os.PathSeparator), logger) + if err != nil { + t.Fatalf("shouldSkipRestoreEntryTarget false-positive path error: %v", err) + } + if skip { + t.Fatalf("did not expect /etc/pveuser.conf to match /etc/pve guard") + } +} + func TestExtractPlainArchive_WithFakeFS_RestoresFiles(t *testing.T) { origRestoreFS := restoreFS fakeFS := NewFakeFS() @@ -748,6 +771,18 @@ func TestExtractDirectory_Success(t *testing.T) { // extractHardlink tests // -------------------------------------------------------------------------- +type recordingLinkFS struct { + *FakeFS + oldname string + newname string +} + +func (f *recordingLinkFS) Link(oldname, newname string) error { + f.oldname = oldname + f.newname = newname + return f.FakeFS.Link(oldname, newname) +} + func TestExtractHardlink_AbsoluteTargetRejected(t *testing.T) { header := &tar.Header{ Name: "link", @@ -774,6 +809,86 @@ func TestExtractHardlink_EscapesRoot(t *testing.T) { } } +func TestExtractHardlink_UsesResolvedTargetPath(t *testing.T) { + orig := restoreFS + fakeFS := NewFakeFS() + recordingFS := &recordingLinkFS{FakeFS: fakeFS} + restoreFS = recordingFS + t.Cleanup(func() { + restoreFS = orig + _ = fakeFS.Cleanup() + }) + + destRoot := fakeFS.Root + realDir := filepath.Join(destRoot, "real") + if err := fakeFS.MkdirAll(realDir, 0o755); err != nil { + t.Fatalf("mkdir real dir: %v", err) + } + realTarget := filepath.Join(realDir, "target.txt") + if err := fakeFS.WriteFile(realTarget, []byte("test"), 0o644); err != nil { + t.Fatalf("write real target: %v", err) + } + if err := os.Symlink("real", filepath.Join(destRoot, "alias")); err != nil { + t.Fatalf("create alias symlink: %v", err) + } + + header := &tar.Header{ + Name: "hardlink.txt", + Linkname: filepath.Join("alias", "target.txt"), + Typeflag: tar.TypeLink, + } + linkFile := filepath.Join(destRoot, header.Name) + + if err := extractHardlink(linkFile, header, destRoot); err != nil { + t.Fatalf("extractHardlink failed: %v", err) + } + if recordingFS.oldname != realTarget { + t.Fatalf("hardlink source = %q, want resolved target %q", recordingFS.oldname, realTarget) + } + if recordingFS.newname != linkFile { + t.Fatalf("hardlink destination = %q, want %q", recordingFS.newname, linkFile) + } + + realInfo, err := os.Stat(realTarget) + if err != nil { + t.Fatalf("stat real target: %v", err) + } + linkInfo, err := os.Stat(linkFile) + if err != nil { + t.Fatalf("stat hardlink: %v", err) + } + if !os.SameFile(realInfo, linkInfo) { + t.Fatalf("hardlink does not point to resolved target") + } +} + +func TestExtractHardlink_RejectsSymlinkEscapeTarget(t *testing.T) { + orig := restoreFS + restoreFS = osFS{} + t.Cleanup(func() { restoreFS = orig }) + + destRoot := t.TempDir() + outside := t.TempDir() + if err := os.Symlink(outside, filepath.Join(destRoot, "escape-link")); err != nil { + t.Fatalf("create escape symlink: %v", err) + } + + header := &tar.Header{ + Name: "link.txt", + Linkname: filepath.Join("escape-link", "target.txt"), + Typeflag: tar.TypeLink, + } + linkFile := filepath.Join(destRoot, header.Name) + + err := extractHardlink(linkFile, header, destRoot) + if err == nil || !strings.Contains(err.Error(), "escapes root") { + t.Fatalf("expected escapes root error, got: %v", err) + } + if _, err := os.Lstat(linkFile); !os.IsNotExist(err) { + t.Fatalf("hardlink should not be created, got err=%v", err) + } +} + func TestExtractHardlink_Success(t *testing.T) { orig := restoreFS t.Cleanup(func() { restoreFS = orig }) @@ -1065,6 +1180,9 @@ nfs: nfs-backup if blocks[0].ID != "local" || blocks[1].ID != "nfs-backup" { t.Fatalf("unexpected block IDs: %v, %v", blocks[0].ID, blocks[1].ID) } + if blocks[0].Type != "dir" || blocks[1].Type != "nfs" { + t.Fatalf("unexpected block types: %v, %v", blocks[0].Type, blocks[1].Type) + } } func TestParseStorageBlocks_LegacyStoragePrefix(t *testing.T) { @@ -1092,6 +1210,9 @@ func TestParseStorageBlocks_LegacyStoragePrefix(t *testing.T) { if blocks[0].ID != "local" { t.Fatalf("unexpected block ID: %v", blocks[0].ID) } + if blocks[0].Type != "" { + t.Fatalf("legacy storage block type = %q, want empty because type is in entries", blocks[0].Type) + } } // -------------------------------------------------------------------------- @@ -1232,17 +1353,45 @@ func TestReadVMName_FileNotFound(t *testing.T) { } // -------------------------------------------------------------------------- -// detectNodeForVM tests +// localNodeName tests // -------------------------------------------------------------------------- -func TestDetectNodeForVM_ReturnsHostname(t *testing.T) { - node := detectNodeForVM() - // detectNodeForVM returns the current hostname, not the node from path +func TestLocalNodeName_ReturnsHostname(t *testing.T) { + node := localNodeName() if node == "" { t.Fatalf("expected non-empty node from hostname") } } +func TestPveshArgsFromColonConfigLinesStopsAtSectionHeader(t *testing.T) { + args := pveshArgsFromColonConfigLines([]string{ + "name: vm100", + "memory: 2048", + "[snapshot]", + "parent: base", + "snaptime: 123", + }) + + got := strings.Join(args, " ") + if !strings.Contains(got, "--name=vm100") || !strings.Contains(got, "--memory=2048") { + t.Fatalf("expected pre-section args, got %v", args) + } + if strings.Contains(got, "parent") || strings.Contains(got, "snaptime") { + t.Fatalf("snapshot section args must be ignored, got %v", args) + } +} + +func TestPveshCreateGuestArgsIncludesLXCOstemplate(t *testing.T) { + args, err := pveshCreateGuestArgs("node1", vmEntry{VMID: "101", Kind: "lxc"}, []string{"--hostname=ct101", "--ostemplate=local:vztmpl/debian.tar.zst"}) + if err != nil { + t.Fatalf("pveshCreateGuestArgs error = %v", err) + } + got := strings.Join(args, " ") + if !strings.Contains(got, "create /nodes/node1/lxc --vmid=101") || !strings.Contains(got, "--ostemplate=local:vztmpl/debian.tar.zst") { + t.Fatalf("unexpected create args: %v", args) + } +} + // -------------------------------------------------------------------------- // detectConfiguredZFSPools tests // -------------------------------------------------------------------------- diff --git a/internal/orchestrator/restore_workflow_abort_test.go b/internal/orchestrator/restore_workflow_abort_test.go index 032330a2..27215e07 100644 --- a/internal/orchestrator/restore_workflow_abort_test.go +++ b/internal/orchestrator/restore_workflow_abort_test.go @@ -14,6 +14,18 @@ import ( "github.com/tis24dev/proxsave/internal/types" ) +func TestRunRestoreWorkflowWithUIClearsStaleAbortInfoBeforeValidation(t *testing.T) { + lastRestoreAbortInfo = &RestoreAbortInfo{NetworkRollbackArmed: true} + t.Cleanup(ClearRestoreAbortInfo) + + if err := runRestoreWorkflowWithUI(context.Background(), nil, nil, "vtest", nil); err == nil { + t.Fatalf("expected configuration error") + } + if got := GetLastRestoreAbortInfo(); got != nil { + t.Fatalf("expected stale abort info to be cleared, got %#v", got) + } +} + func TestRunRestoreWorkflow_FstabPromptInputAborted_AbortsWorkflow(t *testing.T) { origRestoreFS := restoreFS origRestoreCmd := restoreCmd diff --git a/internal/orchestrator/restore_workflow_ui.go b/internal/orchestrator/restore_workflow_ui.go index c5dd7c27..0fd91a27 100644 --- a/internal/orchestrator/restore_workflow_ui.go +++ b/internal/orchestrator/restore_workflow_ui.go @@ -1,3 +1,4 @@ +// Package orchestrator coordinates backup, restore, decrypt, and related workflows. package orchestrator import ( @@ -46,6 +47,7 @@ func prepareRestoreBundleWithUI(ctx context.Context, cfg *config.Config, logger } func runRestoreWorkflowWithUI(ctx context.Context, cfg *config.Config, logger *logging.Logger, version string, ui RestoreWorkflowUI) (err error) { + ClearRestoreAbortInfo() if cfg == nil { return fmt.Errorf("configuration not available") } @@ -453,7 +455,13 @@ func runRestoreWorkflowWithUI(ctx context.Context, cfg *config.Config, logger *l "./var/lib/proxsave-info/commands/pbs/pbs_datastore_inventory.json", }, }} - if err := extractArchiveNative(ctx, prepared.ArchivePath, fsTempDir, logger, invCategory, RestoreModeCustom, nil, "", nil); err != nil { + if err := extractArchiveNative(ctx, restoreArchiveOptions{ + archivePath: prepared.ArchivePath, + destRoot: fsTempDir, + logger: logger, + categories: invCategory, + mode: RestoreModeCustom, + }); err != nil { logger.Debug("Failed to extract fstab inventory data (continuing): %v", err) } @@ -509,7 +517,13 @@ func runRestoreWorkflowWithUI(ctx context.Context, cfg *config.Config, logger *l "./var/lib/proxsave-info/commands/pve/mapping_dir.json", }, }} - if err := extractArchiveNative(ctx, prepared.ArchivePath, exportRoot, logger, safeInvCategory, RestoreModeCustom, nil, "", nil); err != nil { + if err := extractArchiveNative(ctx, restoreArchiveOptions{ + archivePath: prepared.ArchivePath, + destRoot: exportRoot, + logger: logger, + categories: safeInvCategory, + mode: RestoreModeCustom, + }); err != nil { logger.Debug("Failed to extract SAFE apply inventory (continuing): %v", err) } @@ -841,7 +855,7 @@ func runRestoreWorkflowWithUI(ctx context.Context, cfg *config.Config, logger *l if hasCategoryID(plan.NormalCategories, "zfs") { logger.Info("") - if err := checkZFSPoolsAfterRestore(logger); err != nil { + if err := checkZFSPoolsAfterRestore(ctx, logger); err != nil { logger.Warning("ZFS pool check: %v", err) } } else { @@ -978,7 +992,13 @@ func runFullRestoreWithUI(ctx context.Context, ui RestoreWorkflowUI, candidate * "./etc/fstab", }, }} - if err := extractArchiveNative(ctx, prepared.ArchivePath, fsTempDir, logger, fsCategory, RestoreModeCustom, nil, "", nil); err != nil { + if err := extractArchiveNative(ctx, restoreArchiveOptions{ + archivePath: prepared.ArchivePath, + destRoot: fsTempDir, + logger: logger, + categories: fsCategory, + mode: RestoreModeCustom, + }); err != nil { logger.Warning("Failed to extract filesystem config for merge: %v", err) } else { currentFstab := filepath.Join(destRoot, "etc", "fstab") diff --git a/internal/orchestrator/restore_zfs.go b/internal/orchestrator/restore_zfs.go new file mode 100644 index 00000000..e4bfbf12 --- /dev/null +++ b/internal/orchestrator/restore_zfs.go @@ -0,0 +1,223 @@ +// Package orchestrator coordinates backup, restore, decrypt, and related workflows. +package orchestrator + +import ( + "bufio" + "context" + "path/filepath" + "sort" + "strings" + + "github.com/tis24dev/proxsave/internal/logging" +) + +var restoreGlob = filepath.Glob + +// checkZFSPoolsAfterRestore checks if ZFS pools need to be imported after restore. +func checkZFSPoolsAfterRestore(ctx context.Context, logger *logging.Logger) error { + if err := ctx.Err(); err != nil { + return err + } + if _, err := restoreCmd.Run(ctx, "which", "zpool"); err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + // zpool utility not available -> no ZFS tooling installed + return nil + } + + logger.Info("Checking ZFS pool status...") + + configuredPools := detectConfiguredZFSPools() + importablePools, importOutput, importErr := detectImportableZFSPools(ctx) + if importErr != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + } + + logConfiguredZFSPools(logger, configuredPools) + logImportableZFSPools(logger, importablePools, importOutput, importErr) + + if len(importablePools) == 0 { + return logNoImportableZFSPools(ctx, logger, configuredPools) + } + + logManualZFSImportInstructions(logger, importablePools) + return nil +} + +func logConfiguredZFSPools(logger *logging.Logger, configuredPools []string) { + if len(configuredPools) == 0 { + return + } + logger.Warning("Found %d ZFS pool(s) configured for automatic import:", len(configuredPools)) + for _, pool := range configuredPools { + logger.Warning(" - %s", pool) + } + logger.Info("") +} + +func logImportableZFSPools(logger *logging.Logger, importablePools []string, importOutput string, importErr error) { + if importErr != nil { + logger.Warning("`zpool import` command returned an error: %v", importErr) + if strings.TrimSpace(importOutput) != "" { + logger.Warning("`zpool import` output:\n%s", importOutput) + } + return + } + if len(importablePools) > 0 { + logger.Warning("`zpool import` reports pools waiting to be imported:") + for _, pool := range importablePools { + logger.Warning(" - %s", pool) + } + logger.Info("") + } +} + +func logNoImportableZFSPools(ctx context.Context, logger *logging.Logger, configuredPools []string) error { + logger.Info("`zpool import` did not report pools waiting for import.") + if len(configuredPools) == 0 { + return nil + } + logger.Info("") + for _, pool := range configuredPools { + if _, err := restoreCmd.Run(ctx, "zpool", "status", pool); err == nil { + logger.Info("Pool %s is already imported (no manual action needed)", pool) + } else { + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + logger.Warning("Systemd expects pool %s, but `zpool import` and `zpool status` did not report it. Check disk visibility and pool status.", pool) + } + } + return nil +} + +func logManualZFSImportInstructions(logger *logging.Logger, importablePools []string) { + logger.Info("⚠ IMPORTANT: ZFS pools may need manual import after restore!") + logger.Info(" Before rebooting, run these commands:") + logger.Info(" 1. Check available pools: zpool import") + for _, pool := range importablePools { + logger.Info(" 2. Import pool manually: zpool import %s", pool) + } + logger.Info(" 3. Verify pool status: zpool status") + logger.Info("") + logger.Info(" If pools fail to import, check:") + logger.Info(" - journalctl -u zfs-import@.service oppure import@.service") + logger.Info(" - zpool import -d /dev/disk/by-id") + logger.Info("") +} + +func detectConfiguredZFSPools() []string { + pools := make(map[string]struct{}) + addConfiguredZFSPoolsFromDirs(pools) + addConfiguredZFSPoolsFromGlobPatterns(pools) + return sortedPoolNames(pools) +} + +func addConfiguredZFSPoolsFromDirs(pools map[string]struct{}) { + directories := []string{ + "/etc/systemd/system/zfs-import.target.wants", + "/etc/systemd/system/multi-user.target.wants", + } + + for _, dir := range directories { + entries, err := restoreFS.ReadDir(dir) + if err != nil { + continue + } + + for _, entry := range entries { + if pool := parsePoolNameFromUnit(entry.Name()); pool != "" { + pools[pool] = struct{}{} + } + } + } +} + +func addConfiguredZFSPoolsFromGlobPatterns(pools map[string]struct{}) { + globPatterns := []string{ + "/etc/systemd/system/zfs-import@*.service", + "/etc/systemd/system/import@*.service", + } + + for _, pattern := range globPatterns { + matches, err := restoreGlob(pattern) + if err != nil { + continue + } + for _, match := range matches { + if pool := parsePoolNameFromUnit(filepath.Base(match)); pool != "" { + pools[pool] = struct{}{} + } + } + } +} + +func sortedPoolNames(pools map[string]struct{}) []string { + var poolNames []string + for pool := range pools { + poolNames = append(poolNames, pool) + } + sort.Strings(poolNames) + return poolNames +} + +func parsePoolNameFromUnit(unitName string) string { + switch { + case strings.HasPrefix(unitName, "zfs-import@") && strings.HasSuffix(unitName, ".service"): + pool := strings.TrimPrefix(unitName, "zfs-import@") + return strings.TrimSuffix(pool, ".service") + case strings.HasPrefix(unitName, "import@") && strings.HasSuffix(unitName, ".service"): + pool := strings.TrimPrefix(unitName, "import@") + return strings.TrimSuffix(pool, ".service") + default: + return "" + } +} + +func detectImportableZFSPools(ctx context.Context) ([]string, string, error) { + output, err := restoreCmd.Run(ctx, "zpool", "import") + poolNames := parseZpoolImportOutput(string(output)) + if err != nil { + return poolNames, string(output), err + } + return poolNames, string(output), nil +} + +func parseZpoolImportOutput(output string) []string { + var pools []string + scanner := bufio.NewScanner(strings.NewReader(output)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(strings.ToLower(line), "pool:") { + pool := strings.TrimSpace(line[len("pool:"):]) + if pool != "" { + pools = append(pools, pool) + } + } + } + return pools +} + +func combinePoolNames(a, b []string) []string { + merged := make(map[string]struct{}) + for _, pool := range a { + merged[pool] = struct{}{} + } + for _, pool := range b { + merged[pool] = struct{}{} + } + + if len(merged) == 0 { + return nil + } + + names := make([]string, 0, len(merged)) + for pool := range merged { + names = append(names, pool) + } + sort.Strings(names) + return names +} diff --git a/internal/orchestrator/temp_registry_test.go b/internal/orchestrator/temp_registry_test.go index 071a4bc8..37eafc45 100644 --- a/internal/orchestrator/temp_registry_test.go +++ b/internal/orchestrator/temp_registry_test.go @@ -15,8 +15,6 @@ func newTestLogger() *logging.Logger { } func TestTempDirRegistryRegisterAndDeregister(t *testing.T) { - t.Parallel() - regPath := filepath.Join(t.TempDir(), "temp-dirs.json") registry, err := NewTempDirRegistry(newTestLogger(), regPath) if err != nil { @@ -54,8 +52,6 @@ func TestTempDirRegistryRegisterAndDeregister(t *testing.T) { } func TestTempDirRegistryCleanupOrphaned(t *testing.T) { - t.Parallel() - regPath := filepath.Join(t.TempDir(), "temp-dirs.json") registry, err := NewTempDirRegistry(newTestLogger(), regPath) if err != nil { diff --git a/internal/orchestrator/tui_simulation_test.go b/internal/orchestrator/tui_simulation_test.go index b846286d..20d2cfdd 100644 --- a/internal/orchestrator/tui_simulation_test.go +++ b/internal/orchestrator/tui_simulation_test.go @@ -13,7 +13,10 @@ import ( "github.com/tis24dev/proxsave/internal/tui" ) -const simAppInitialDrawTimeout = 2 * time.Second +const ( + simAppInitialDrawTimeout = 2 * time.Second + simAppCompletionTimeout = 10 * time.Second +) type simKey struct { Key tcell.Key @@ -35,9 +38,23 @@ func withSimAppSequence(t *testing.T, keys []simKey) <-chan struct{} { done := make(chan struct{}) var injectOnce sync.Once var injectWG sync.WaitGroup + var appMu sync.RWMutex + var currentApp *tui.App + + stopCurrentApp := func() { + appMu.RLock() + app := currentApp + appMu.RUnlock() + if app != nil { + app.Stop() + } + } newTUIApp = func() *tui.App { app := tui.NewApp() + appMu.Lock() + currentApp = app + appMu.Unlock() app.SetScreen(screen) readyCh := make(chan struct{}) var readyOnce sync.Once @@ -68,6 +85,8 @@ func withSimAppSequence(t *testing.T, keys []simKey) <-chan struct{} { case <-done: return case <-timer.C: + t.Errorf("TUI simulation did not render its initial draw within %s", simAppInitialDrawTimeout) + stopCurrentApp() return } @@ -83,12 +102,27 @@ func withSimAppSequence(t *testing.T, keys []simKey) <-chan struct{} { } screen.InjectKey(k.Key, k.R, mod) } + + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + timer.Reset(simAppCompletionTimeout) + select { + case <-done: + case <-timer.C: + t.Errorf("TUI simulation did not finish within %s after injecting %d key(s)", simAppCompletionTimeout, len(keys)) + stopCurrentApp() + } }() }) return app } t.Cleanup(func() { + stopCurrentApp() close(done) injectWG.Wait() newTUIApp = orig diff --git a/internal/orchestrator/unescape_proc_path_test.go b/internal/orchestrator/unescape_proc_path_test.go index 86c7352b..8e0a1676 100644 --- a/internal/orchestrator/unescape_proc_path_test.go +++ b/internal/orchestrator/unescape_proc_path_test.go @@ -3,8 +3,6 @@ package orchestrator import "testing" func TestUnescapeProcPath(t *testing.T) { - t.Parallel() - tests := []struct { name string in string @@ -25,7 +23,6 @@ func TestUnescapeProcPath(t *testing.T) { for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { - t.Parallel() if got := unescapeProcPath(tt.in); got != tt.want { t.Fatalf("unescapeProcPath(%q)=%q want %q", tt.in, got, tt.want) } diff --git a/internal/orchestrator/workflow_ui_tui_decrypt.go b/internal/orchestrator/workflow_ui_tui_decrypt.go index 531571d5..fa0483a5 100644 --- a/internal/orchestrator/workflow_ui_tui_decrypt.go +++ b/internal/orchestrator/workflow_ui_tui_decrypt.go @@ -5,6 +5,7 @@ import ( "fmt" "path/filepath" "strings" + "sync" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" @@ -86,35 +87,67 @@ func (u *tuiWorkflowUI) RunTask(ctx context.Context, title, initialMessage strin form.SetParentView(page) done := make(chan struct{}) + started := make(chan struct{}) + var startOnce sync.Once var runErr error + queueProgressUpdate := func(update func()) { + select { + case <-taskCtx.Done(): + return + default: + } + go func() { + select { + case <-taskCtx.Done(): + return + default: + } + app.QueueUpdateDraw(update) + }() + } + report := func(message string) { message = strings.TrimSpace(message) if message == "" { return } - app.QueueUpdateDraw(func() { + queueProgressUpdate(func() { messageView.SetText(tview.Escape(message)) }) } - go func() { - runErr = run(taskCtx, report) - close(done) - app.QueueUpdateDraw(func() { - app.Stop() + startTask := func() { + startOnce.Do(func() { + close(started) + go func() { + runErr = run(taskCtx, report) + close(done) + app.Stop() + }() }) - }() + } app.SetRoot(page, true).SetFocus(form.Form) + app.SetAfterDrawFunc(func(screen tcell.Screen) { + startTask() + }) if err := app.RunWithContext(taskCtx); err != nil { cancel() - <-done + select { + case <-started: + <-done + default: + } return err } cancel() - <-done + select { + case <-started: + <-done + default: + } return runErr } diff --git a/internal/pbs/namespaces.go b/internal/pbs/namespaces.go index d168bef4..973074f8 100644 --- a/internal/pbs/namespaces.go +++ b/internal/pbs/namespaces.go @@ -6,14 +6,14 @@ import ( "encoding/json" "errors" "fmt" - "os/exec" "path/filepath" "time" + "github.com/tis24dev/proxsave/internal/safeexec" "github.com/tis24dev/proxsave/internal/safefs" ) -var execCommand = exec.CommandContext +var execCommand = safeexec.CommandContext // Namespace represents a single PBS namespace. type Namespace struct { @@ -57,7 +57,7 @@ func listNamespacesViaCLI(ctx context.Context, datastore string) ([]Namespace, e return nil, err } - cmd := execCommand( + cmd, cmdErr := execCommand( ctx, "proxmox-backup-manager", "datastore", @@ -66,6 +66,9 @@ func listNamespacesViaCLI(ctx context.Context, datastore string) ([]Namespace, e datastore, "--output-format=json", ) + if cmdErr != nil { + return nil, cmdErr + } var stdout, stderr bytes.Buffer cmd.Stdout = &stdout diff --git a/internal/pbs/namespaces_test.go b/internal/pbs/namespaces_test.go index f151caeb..22e7a473 100644 --- a/internal/pbs/namespaces_test.go +++ b/internal/pbs/namespaces_test.go @@ -3,6 +3,7 @@ package pbs import ( "context" "encoding/json" + "errors" "fmt" "os" "os/exec" @@ -192,6 +193,13 @@ func TestListNamespacesViaCLI_ErrorIncludesStderr(t *testing.T) { } } +func TestListNamespacesViaCLI_ExecCommandError(t *testing.T) { + setExecCommandStub(t, "cmd-failure") + if _, err := listNamespacesViaCLI(context.Background(), "dummy"); err == nil || !strings.Contains(err.Error(), "simulated execCommand failure") { + t.Fatalf("expected execCommand error, got %v", err) + } +} + func TestHelperProcess(t *testing.T) { if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" { return @@ -213,13 +221,22 @@ func TestHelperProcess(t *testing.T) { func setExecCommandStub(t *testing.T, scenario string) { t.Helper() original := execCommand - execCommand = func(context.Context, string, ...string) *exec.Cmd { + if scenario == "cmd-failure" { + execCommand = func(context.Context, string, ...string) (*exec.Cmd, error) { + return nil, errors.New("simulated execCommand failure") + } + t.Cleanup(func() { + execCommand = original + }) + return + } + execCommand = func(context.Context, string, ...string) (*exec.Cmd, error) { cmd := exec.Command(os.Args[0], "-test.run=TestHelperProcess", "--") cmd.Env = append(os.Environ(), "GO_WANT_HELPER_PROCESS=1", "PBS_HELPER_SCENARIO="+scenario, ) - return cmd + return cmd, nil } t.Cleanup(func() { execCommand = original diff --git a/internal/safeexec/safeexec.go b/internal/safeexec/safeexec.go new file mode 100644 index 00000000..b4960255 --- /dev/null +++ b/internal/safeexec/safeexec.go @@ -0,0 +1,281 @@ +package safeexec + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path" + "path/filepath" + "strings" + "unicode" +) + +var ErrCommandNotAllowed = errors.New("command not allowed") + +// CommandContext creates commands only for binaries that are intentionally +// allowed by the application. Keep exec.CommandContext calls in the switch so +// static analyzers can see literal command names. +func CommandContext(ctx context.Context, name string, args ...string) (*exec.Cmd, error) { + if strings.TrimSpace(name) != name || name == "" || strings.ContainsAny(name, `/\`) { + return nil, fmt.Errorf("%w: %q", ErrCommandNotAllowed, name) + } + + switch name { + case "apt-cache": + return exec.CommandContext(ctx, "apt-cache", args...), nil + case "blkid": + return exec.CommandContext(ctx, "blkid", args...), nil + case "bridge": + return exec.CommandContext(ctx, "bridge", args...), nil + case "bzip2": + return exec.CommandContext(ctx, "bzip2", args...), nil + case "cat": + return exec.CommandContext(ctx, "cat", args...), nil + case "ceph": + return exec.CommandContext(ctx, "ceph", args...), nil + case "chattr": + return exec.CommandContext(ctx, "chattr", args...), nil + case "crontab": + return exec.CommandContext(ctx, "crontab", args...), nil + case "df": + return exec.CommandContext(ctx, "df", args...), nil + case "dmidecode": + return exec.CommandContext(ctx, "dmidecode", args...), nil + case "dpkg": + return exec.CommandContext(ctx, "dpkg", args...), nil + case "dpkg-query": + return exec.CommandContext(ctx, "dpkg-query", args...), nil + case "echo": + return exec.CommandContext(ctx, "echo", args...), nil + case "ethtool": + return exec.CommandContext(ctx, "ethtool", args...), nil + case "false": + return exec.CommandContext(ctx, "false", args...), nil + case "firewall-cmd": + return exec.CommandContext(ctx, "firewall-cmd", args...), nil + case "free": + return exec.CommandContext(ctx, "free", args...), nil + case "hostname": + return exec.CommandContext(ctx, "hostname", args...), nil + case "ifreload": + return exec.CommandContext(ctx, "ifreload", args...), nil + case "ifup": + return exec.CommandContext(ctx, "ifup", args...), nil + case "ip": + return exec.CommandContext(ctx, "ip", args...), nil + case "iptables": + return exec.CommandContext(ctx, "iptables", args...), nil + case "iptables-save": + return exec.CommandContext(ctx, "iptables-save", args...), nil + case "ip6tables": + return exec.CommandContext(ctx, "ip6tables", args...), nil + case "ip6tables-save": + return exec.CommandContext(ctx, "ip6tables-save", args...), nil + case "journalctl": + return exec.CommandContext(ctx, "journalctl", args...), nil + case "lsblk": + return exec.CommandContext(ctx, "lsblk", args...), nil + case "lspci": + return exec.CommandContext(ctx, "lspci", args...), nil + case "lscpu": + return exec.CommandContext(ctx, "lscpu", args...), nil + case "lsmod": + return exec.CommandContext(ctx, "lsmod", args...), nil + case "lsusb": + return exec.CommandContext(ctx, "lsusb", args...), nil + case "lvs": + return exec.CommandContext(ctx, "lvs", args...), nil + case "lzma": + return exec.CommandContext(ctx, "lzma", args...), nil + case "mailq": + return exec.CommandContext(ctx, "mailq", args...), nil + case "mount": + return exec.CommandContext(ctx, "mount", args...), nil + case "mountpoint": + return exec.CommandContext(ctx, "mountpoint", args...), nil + case "nft": + return exec.CommandContext(ctx, "nft", args...), nil + case "pbzip2": + return exec.CommandContext(ctx, "pbzip2", args...), nil + case "pgrep": + return exec.CommandContext(ctx, "pgrep", args...), nil + case "pigz": + return exec.CommandContext(ctx, "pigz", args...), nil + case "ping": + return exec.CommandContext(ctx, "ping", args...), nil + case "pvs": + return exec.CommandContext(ctx, "pvs", args...), nil + case "proxmox-backup-client": + return exec.CommandContext(ctx, "proxmox-backup-client", args...), nil + case "proxmox-backup-manager": + return exec.CommandContext(ctx, "proxmox-backup-manager", args...), nil + case "proxmox-mail-forward": + return exec.CommandContext(ctx, "proxmox-mail-forward", args...), nil + case "proxmox-tape": + return exec.CommandContext(ctx, "proxmox-tape", args...), nil + case "ps": + return exec.CommandContext(ctx, "ps", args...), nil + case "pvecm": + return exec.CommandContext(ctx, "pvecm", args...), nil + case "pve-firewall": + return exec.CommandContext(ctx, "pve-firewall", args...), nil + case "pvenode": + return exec.CommandContext(ctx, "pvenode", args...), nil + case "pvesh": + return exec.CommandContext(ctx, "pvesh", args...), nil + case "pvesm": + return exec.CommandContext(ctx, "pvesm", args...), nil + case "pveum": + return exec.CommandContext(ctx, "pveum", args...), nil + case "pveversion": + return exec.CommandContext(ctx, "pveversion", args...), nil + case "rclone": + return exec.CommandContext(ctx, "rclone", args...), nil + case "sendmail": + return exec.CommandContext(ctx, "sendmail", args...), nil + case "sensors": + return exec.CommandContext(ctx, "sensors", args...), nil + case "sh": + return exec.CommandContext(ctx, "sh", args...), nil + case "smartctl": + return exec.CommandContext(ctx, "smartctl", args...), nil + case "ss": + return exec.CommandContext(ctx, "ss", args...), nil + case "systemctl": + return exec.CommandContext(ctx, "systemctl", args...), nil + case "systemd-run": + return exec.CommandContext(ctx, "systemd-run", args...), nil + case "sysctl": + return exec.CommandContext(ctx, "sysctl", args...), nil + case "tail": + return exec.CommandContext(ctx, "tail", args...), nil + case "tar": + return exec.CommandContext(ctx, "tar", args...), nil + case "udevadm": + return exec.CommandContext(ctx, "udevadm", args...), nil + case "umount": + return exec.CommandContext(ctx, "umount", args...), nil + case "uname": + return exec.CommandContext(ctx, "uname", args...), nil + case "ufw": + return exec.CommandContext(ctx, "ufw", args...), nil + case "vgs": + return exec.CommandContext(ctx, "vgs", args...), nil + case "which": + return exec.CommandContext(ctx, "which", args...), nil + case "xz": + return exec.CommandContext(ctx, "xz", args...), nil + case "zfs": + return exec.CommandContext(ctx, "zfs", args...), nil + case "zpool": + return exec.CommandContext(ctx, "zpool", args...), nil + case "zstd": + return exec.CommandContext(ctx, "zstd", args...), nil + default: + return nil, fmt.Errorf("%w: %q", ErrCommandNotAllowed, name) + } +} + +func CombinedOutput(ctx context.Context, name string, args ...string) ([]byte, error) { + cmd, err := CommandContext(ctx, name, args...) + if err != nil { + return nil, err + } + return cmd.CombinedOutput() +} + +func Output(ctx context.Context, name string, args ...string) ([]byte, error) { + cmd, err := CommandContext(ctx, name, args...) + if err != nil { + return nil, err + } + return cmd.Output() +} + +func TrustedCommandContext(ctx context.Context, execPath string, args ...string) (*exec.Cmd, error) { + if err := ValidateTrustedExecutablePath(execPath); err != nil { + return nil, err + } + // #nosec G204 -- execPath is absolute, regular, executable, and not world-writable. + return exec.CommandContext(ctx, execPath, args...), nil // nosemgrep: go.lang.security.audit.dangerous-exec-command.dangerous-exec-command +} + +func ValidateTrustedExecutablePath(execPath string) error { + clean := strings.TrimSpace(execPath) + if clean == "" { + return fmt.Errorf("executable path is empty") + } + if !filepath.IsAbs(clean) { + return fmt.Errorf("executable path must be absolute: %s", execPath) + } + info, err := os.Stat(clean) + if err != nil { + return fmt.Errorf("stat executable path: %w", err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("executable path is not a regular file: %s", clean) + } + if info.Mode().Perm()&0o111 == 0 { + return fmt.Errorf("executable path is not executable: %s", clean) + } + if info.Mode().Perm()&0o002 != 0 { + return fmt.Errorf("executable path is world-writable: %s", clean) + } + return nil +} + +func ValidateRcloneRemoteName(remote string) error { + if remote == "" { + return fmt.Errorf("rclone remote name is empty") + } + if strings.HasPrefix(remote, "-") { + return fmt.Errorf("rclone remote name must not start with '-'") + } + if strings.ContainsAny(remote, `/\:`) { + return fmt.Errorf("rclone remote name contains a path separator or colon") + } + for _, r := range remote { + if unicode.IsSpace(r) || unicode.IsControl(r) { + return fmt.Errorf("rclone remote name contains whitespace or control characters") + } + } + return nil +} + +func ValidateRemoteRelativePath(value, field string) error { + clean := strings.TrimSpace(value) + if clean == "" { + return nil + } + for _, r := range clean { + if unicode.IsControl(r) { + return fmt.Errorf("%s contains control characters", field) + } + } + normalized := path.Clean(strings.Trim(clean, "/")) + if normalized == "." { + return nil + } + if strings.HasPrefix(normalized, "../") || normalized == ".." { + return fmt.Errorf("%s must not traverse outside the configured remote", field) + } + return nil +} + +func ProcPath(pid int, leaf string) (string, error) { + if pid <= 0 { + return "", fmt.Errorf("pid must be positive") + } + switch leaf { + case "comm": + return fmt.Sprintf("/proc/%d/comm", pid), nil + case "status": + return fmt.Sprintf("/proc/%d/status", pid), nil + case "exe": + return fmt.Sprintf("/proc/%d/exe", pid), nil + default: + return "", fmt.Errorf("unsupported proc leaf: %s", leaf) + } +} diff --git a/internal/safeexec/safeexec_test.go b/internal/safeexec/safeexec_test.go new file mode 100644 index 00000000..3144ad80 --- /dev/null +++ b/internal/safeexec/safeexec_test.go @@ -0,0 +1,110 @@ +package safeexec + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" +) + +func TestCommandContextAllowlist(t *testing.T) { + allowedCommands := []string{ + "rclone", + "tar", + "xz", + "zstd", + "systemctl", + "mailq", + "tail", + "journalctl", + "pvesh", + "pveum", + "proxmox-backup-manager", + } + for _, command := range allowedCommands { + if _, err := CommandContext(context.Background(), command); err != nil { + t.Fatalf("CommandContext(%q) allowed command error: %v", command, err) + } + } + if _, err := CommandContext(context.Background(), "not-a-proxsave-command"); !errors.Is(err, ErrCommandNotAllowed) { + t.Fatalf("CommandContext unknown command error = %v, want ErrCommandNotAllowed", err) + } + if _, err := CommandContext(context.Background(), "/bin/sh"); !errors.Is(err, ErrCommandNotAllowed) { + t.Fatalf("CommandContext path command error = %v, want ErrCommandNotAllowed", err) + } +} + +func TestValidateTrustedExecutablePath(t *testing.T) { + dir := t.TempDir() + execPath := filepath.Join(dir, "proxsave") + if err := os.WriteFile(execPath, []byte("#!/bin/sh\nexit 0\n"), 0o700); err != nil { + t.Fatal(err) + } + if err := ValidateTrustedExecutablePath(execPath); err != nil { + t.Fatalf("ValidateTrustedExecutablePath valid error: %v", err) + } + + if err := ValidateTrustedExecutablePath("relative"); err == nil { + t.Fatalf("expected relative path to be rejected") + } + + worldWritable := filepath.Join(dir, "ww") + if err := os.WriteFile(worldWritable, []byte("#!/bin/sh\nexit 0\n"), 0o777); err != nil { + t.Fatal(err) + } + if err := os.Chmod(worldWritable, 0o777); err != nil { + t.Fatal(err) + } + if err := ValidateTrustedExecutablePath(worldWritable); err == nil { + t.Fatalf("expected world-writable executable to be rejected") + } +} + +func TestValidateRcloneRemoteName(t *testing.T) { + valid := []string{"remote", "s3backup_01", "gdrive-prod"} + for _, name := range valid { + if err := ValidateRcloneRemoteName(name); err != nil { + t.Fatalf("ValidateRcloneRemoteName(%q) error: %v", name, err) + } + } + + invalid := []string{"", "-remote", "bad remote", "bad/remote", "bad:remote", "bad\nremote"} + for _, name := range invalid { + if err := ValidateRcloneRemoteName(name); err == nil { + t.Fatalf("ValidateRcloneRemoteName(%q) expected error", name) + } + } +} + +func TestValidateRemoteRelativePath(t *testing.T) { + valid := []string{"", "tenant/a", "/tenant/a/", "tenant with spaces/a"} + for _, value := range valid { + if err := ValidateRemoteRelativePath(value, "path"); err != nil { + t.Fatalf("ValidateRemoteRelativePath(%q) error: %v", value, err) + } + } + + invalid := []string{"../escape", "tenant/../../escape", "bad\npath"} + for _, value := range invalid { + if err := ValidateRemoteRelativePath(value, "path"); err == nil { + t.Fatalf("ValidateRemoteRelativePath(%q) expected error", value) + } + } +} + +func TestProcPath(t *testing.T) { + got, err := ProcPath(123, "status") + if err != nil { + t.Fatalf("ProcPath valid error: %v", err) + } + if got != "/proc/123/status" { + t.Fatalf("ProcPath = %q", got) + } + if _, err := ProcPath(0, "status"); err == nil { + t.Fatalf("expected pid 0 to be rejected") + } + if _, err := ProcPath(123, "../status"); err == nil { + t.Fatalf("expected unsupported leaf to be rejected") + } +} diff --git a/internal/security/procscan.go b/internal/security/procscan.go index 0ea3ab98..b8755fa4 100644 --- a/internal/security/procscan.go +++ b/internal/security/procscan.go @@ -6,6 +6,8 @@ import ( "path/filepath" "regexp" "strings" + + "github.com/tis24dev/proxsave/internal/safeexec" ) // Heuristic detection for safe kernel-style processes. @@ -28,25 +30,28 @@ type procInfo struct { func readProcInfo(pid int) procInfo { info := procInfo{} - commPath := fmt.Sprintf("/proc/%d/comm", pid) - if data, err := os.ReadFile(commPath); err == nil { - info.comm = strings.TrimSpace(string(data)) + if commPath, err := safeexec.ProcPath(pid, "comm"); err == nil { + if data, err := os.ReadFile(commPath); err == nil { + info.comm = strings.TrimSpace(string(data)) + } } - statusPath := fmt.Sprintf("/proc/%d/status", pid) - if data, err := os.ReadFile(statusPath); err == nil { - lines := strings.Split(string(data), "\n") - for _, line := range lines { - if strings.HasPrefix(line, "PPid:") { - _, _ = fmt.Sscanf(line, "PPid:\t%d", &info.ppid) - break + if statusPath, err := safeexec.ProcPath(pid, "status"); err == nil { + if data, err := os.ReadFile(statusPath); err == nil { + lines := strings.Split(string(data), "\n") + for _, line := range lines { + if strings.HasPrefix(line, "PPid:") { + _, _ = fmt.Sscanf(line, "PPid:\t%d", &info.ppid) + break + } } } } - exePath := fmt.Sprintf("/proc/%d/exe", pid) - if target, err := filepath.EvalSymlinks(exePath); err == nil { - info.exe = target + if exePath, err := safeexec.ProcPath(pid, "exe"); err == nil { + if target, err := filepath.EvalSymlinks(exePath); err == nil { + info.exe = target + } } return info diff --git a/internal/security/security.go b/internal/security/security.go index 152c1802..5104a469 100644 --- a/internal/security/security.go +++ b/internal/security/security.go @@ -21,6 +21,7 @@ import ( "github.com/tis24dev/proxsave/internal/config" "github.com/tis24dev/proxsave/internal/environment" "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/safeexec" "github.com/tis24dev/proxsave/internal/types" ) @@ -634,7 +635,11 @@ func (c *Checker) checkFirewall(ctx context.Context) { return } - cmd := exec.CommandContext(ctx, "iptables", "-L", "-n") + cmd, err := safeexec.CommandContext(ctx, "iptables", "-L", "-n") + if err != nil { + c.addWarning("Failed to prepare iptables command: %v", err) + return + } output, err := cmd.Output() if err != nil { c.addWarning("Failed to run iptables -L -n: %v", err) @@ -664,7 +669,11 @@ func (c *Checker) checkOpenPorts(ctx context.Context) { return } - cmd := exec.CommandContext(ctx, "ss", "-tulnap") + cmd, err := safeexec.CommandContext(ctx, "ss", "-tulnap") + if err != nil { + c.addWarning("Failed to prepare 'ss -tulnap': %v", err) + return + } output, err := cmd.Output() if err != nil { c.addWarning("Failed to execute 'ss -tulnap': %v", err) @@ -700,7 +709,10 @@ func (c *Checker) checkOpenPortsAgainstSuspiciousList(ctx context.Context) { if _, err := exec.LookPath("ss"); err != nil { return } - cmd := exec.CommandContext(ctx, "ss", "-tuln") + cmd, err := safeexec.CommandContext(ctx, "ss", "-tuln") + if err != nil { + return + } output, err := cmd.Output() if err != nil { return @@ -721,7 +733,11 @@ func (c *Checker) checkOpenPortsAgainstSuspiciousList(ctx context.Context) { } func (c *Checker) checkSuspiciousProcesses(ctx context.Context) { - cmd := exec.CommandContext(ctx, "ps", "-eo", "user=,state=,vsz=,pid=,command=") + cmd, err := safeexec.CommandContext(ctx, "ps", "-eo", "user=,state=,vsz=,pid=,command=") + if err != nil { + c.addWarning("Failed to prepare 'ps' for process inspection: %v", err) + return + } output, err := cmd.Output() if err != nil { c.addWarning("Failed to execute 'ps' for process inspection: %v", err) diff --git a/internal/storage/cloud.go b/internal/storage/cloud.go index 8d306ad1..b367ff50 100644 --- a/internal/storage/cloud.go +++ b/internal/storage/cloud.go @@ -15,6 +15,7 @@ import ( "github.com/tis24dev/proxsave/internal/config" "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/safeexec" "github.com/tis24dev/proxsave/internal/types" "github.com/tis24dev/proxsave/pkg/utils" ) @@ -89,6 +90,28 @@ func (c *CloudStorage) buildRcloneArgs(subcommand string) []string { return args } +func validateRcloneArgs(args []string) error { + if len(args) == 0 { + return fmt.Errorf("missing rclone subcommand") + } + switch args[0] { + case "copyto", "delete", "deletefile", "ls", "lsf", "lsl", "mkdir", "touch": + default: + return fmt.Errorf("rclone subcommand not allowed: %s", args[0]) + } + for _, arg := range args { + if strings.TrimSpace(arg) == "" { + return fmt.Errorf("rclone argument must not be empty") + } + for _, r := range arg { + if r < 0x20 || r == 0x7f { + return fmt.Errorf("rclone argument contains control characters") + } + } + } + return nil +} + func splitRemoteRef(ref string) (remoteName, relPath string) { parts := strings.SplitN(ref, ":", 2) if len(parts) < 2 { @@ -163,9 +186,19 @@ func NewCloudStorage(cfg *config.Config, logger *logging.Logger) (*CloudStorage, // (base path from CLOUD_REMOTE plus optional CLOUD_REMOTE_PATH) rawRemote := strings.TrimSpace(cfg.CloudRemote) remoteName, basePath := splitRemoteRef(rawRemote) + remoteName = strings.TrimSpace(remoteName) + if err := safeexec.ValidateRcloneRemoteName(remoteName); err != nil { + return nil, fmt.Errorf("invalid CLOUD_REMOTE: %w", err) + } basePath = strings.Trim(strings.TrimSpace(basePath), "/") + if err := safeexec.ValidateRemoteRelativePath(basePath, "CLOUD_REMOTE path"); err != nil { + return nil, err + } userPrefix := strings.Trim(strings.TrimSpace(cfg.CloudRemotePath), "/") + if err := safeexec.ValidateRemoteRelativePath(userPrefix, "CLOUD_REMOTE_PATH"); err != nil { + return nil, err + } combinedPrefix := strings.Trim(path.Join(basePath, userPrefix), "/") @@ -1759,6 +1792,12 @@ func (c *CloudStorage) markCloudLogPathAvailable() { } func (c *CloudStorage) exec(ctx context.Context, name string, args ...string) ([]byte, error) { + if name != "rclone" { + return nil, fmt.Errorf("cloud storage may only execute rclone, got %q", name) + } + if err := validateRcloneArgs(args); err != nil { + return nil, err + } if c.execCommand != nil { return c.execCommand(ctx, name, args...) } @@ -1773,7 +1812,10 @@ func (c *CloudStorage) callWaitForRetry(ctx context.Context, d time.Duration) er } func defaultExecCommand(ctx context.Context, name string, args ...string) ([]byte, error) { - cmd := exec.CommandContext(ctx, name, args...) + cmd, err := safeexec.CommandContext(ctx, name, args...) + if err != nil { + return nil, err + } return cmd.CombinedOutput() } diff --git a/internal/tui/abort_context_test.go b/internal/tui/abort_context_test.go index 93778c1c..2654353f 100644 --- a/internal/tui/abort_context_test.go +++ b/internal/tui/abort_context_test.go @@ -167,6 +167,25 @@ func TestAppRunWithContext_NilContextRunsUntilStopped(t *testing.T) { } } +func TestAppRunWithContext_StopBeforeRunStopsWhenRunStarts(t *testing.T) { + app, _, _ := newSimulationApp(t) + app.Stop() + + done := make(chan error, 1) + go func() { + done <- app.RunWithContext(context.Background()) + }() + + select { + case err := <-done: + if err != nil { + t.Fatalf("err=%v want nil", err) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for pre-run Stop to end RunWithContext") + } +} + func TestAppRunWithContext_ReturnsNilWhenStoppedWithoutCancellation(t *testing.T) { app, _, started := newSimulationApp(t) done := make(chan error, 1) diff --git a/internal/tui/app.go b/internal/tui/app.go index e190f67f..f1d480e9 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -2,16 +2,27 @@ package tui import ( "context" + "sync" "sync/atomic" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) +const ( + appRunStateIdle = iota + appRunStateStarting + appRunStateRunning + appRunStateFinished +) + // App wraps tview.Application with Proxmox-specific configuration type App struct { *tview.Application - stopHook func() + stopHook func() + runMu sync.Mutex + runState int + stopRequested bool } // NewApp creates a new TUI application with Proxmox theme @@ -49,8 +60,57 @@ func (a *App) Stop() { return } if a.Application != nil { - a.Application.Stop() + a.runMu.Lock() + switch a.runState { + case appRunStateIdle, appRunStateStarting: + // tview.Stop before Run clears the configured screen; apply it once + // the event loop can process the request instead. + a.stopRequested = true + a.runMu.Unlock() + return + case appRunStateRunning: + a.runMu.Unlock() + a.Application.Stop() + return + default: + a.runMu.Unlock() + } + } +} + +func (a *App) Run() error { + if a == nil || a.Application == nil { + return nil } + + a.runMu.Lock() + a.runState = appRunStateStarting + a.runMu.Unlock() + + go a.markRunningAndStopIfRequested() + + err := a.Application.Run() + + a.runMu.Lock() + a.runState = appRunStateFinished + a.stopRequested = false + a.runMu.Unlock() + + return err +} + +func (a *App) markRunningAndStopIfRequested() { + a.QueueUpdate(func() { + a.runMu.Lock() + a.runState = appRunStateRunning + stopRequested := a.stopRequested + a.stopRequested = false + a.runMu.Unlock() + + if stopRequested { + a.Application.Stop() + } + }) } func (a *App) RunWithContext(ctx context.Context) error { diff --git a/internal/tui/wizard/post_install_audit_core.go b/internal/tui/wizard/post_install_audit_core.go index 5d9a76a0..9ba0ee41 100644 --- a/internal/tui/wizard/post_install_audit_core.go +++ b/internal/tui/wizard/post_install_audit_core.go @@ -13,6 +13,7 @@ import ( "time" "github.com/tis24dev/proxsave/internal/config" + "github.com/tis24dev/proxsave/internal/safeexec" ) // PostInstallAuditSuggestion represents an optional feature that appears to be enabled @@ -51,11 +52,14 @@ func postInstallAuditAllowedKeysSet() map[string]struct{} { func runPostInstallAuditDryRun(ctx context.Context, execPath, configPath string) (output string, exitCode int, err error) { // Run a dry-run with warning-level logs to keep output minimal while still capturing // all actionable "set KEY=false" hints. - cmd := exec.CommandContext(ctx, execPath, + cmd, err := safeexec.TrustedCommandContext(ctx, execPath, "--dry-run", "--log-level", "warning", "--config", configPath, ) + if err != nil { + return "", -1, err + } out, runErr := cmd.CombinedOutput() if runErr == nil { return string(out), 0, nil diff --git a/internal/types/exit_codes.go b/internal/types/exit_codes.go index 439c5f58..b91b84ac 100644 --- a/internal/types/exit_codes.go +++ b/internal/types/exit_codes.go @@ -49,6 +49,9 @@ const ( // ExitSecurityError - Errors detected by the security check. ExitSecurityError ExitCode = 14 + + // ExitEncryptionError - Error during encryption setup or processing. + ExitEncryptionError ExitCode = 15 ) // String returns a human-readable description of the exit code. @@ -84,6 +87,8 @@ func (e ExitCode) String() string { return "panic error" case ExitSecurityError: return "security error" + case ExitEncryptionError: + return "encryption error" default: return "unknown error" } diff --git a/internal/types/exit_codes_test.go b/internal/types/exit_codes_test.go index bda518c5..2cb8f7f6 100644 --- a/internal/types/exit_codes_test.go +++ b/internal/types/exit_codes_test.go @@ -17,6 +17,7 @@ func TestExitCodeString(t *testing.T) { {"network error", ExitNetworkError, "network error"}, {"permission error", ExitPermissionError, "permission error"}, {"verification error", ExitVerificationError, "verification error"}, + {"encryption error", ExitEncryptionError, "encryption error"}, {"unknown", ExitCode(99), "unknown error"}, } @@ -45,6 +46,7 @@ func TestExitCodeInt(t *testing.T) { {"network error", ExitNetworkError, 6}, {"permission error", ExitPermissionError, 7}, {"verification error", ExitVerificationError, 8}, + {"encryption error", ExitEncryptionError, 15}, } for _, tt := range tests {