diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index 7be8d0d..bb3957b 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -18,7 +18,18 @@ jobs: with: go-version-file: 'go.mod' + - name: Resolve coverage toolchain + run: | + TOOLCHAIN_FROM_MOD=$(awk '/^toolchain /{print $2}' go.mod) + if [ -n "$TOOLCHAIN_FROM_MOD" ]; then + echo "COVER_GOTOOLCHAIN=${TOOLCHAIN_FROM_MOD}+auto" >> "$GITHUB_ENV" + else + echo "COVER_GOTOOLCHAIN=auto" >> "$GITHUB_ENV" + fi + - name: Run tests with coverage + env: + GOTOOLCHAIN: ${{ env.COVER_GOTOOLCHAIN }} run: | go test $(go list ./... | grep -v -E '/cmd/|/pbs$|/bech32$|^github.com/tis24dev/proxsave$') -coverprofile=coverage.out diff --git a/internal/orchestrator/additional_helpers_test.go b/internal/orchestrator/additional_helpers_test.go index c1b6caf..f82dd38 100644 --- a/internal/orchestrator/additional_helpers_test.go +++ b/internal/orchestrator/additional_helpers_test.go @@ -198,12 +198,14 @@ func TestApplyStorageStatsSimplePrimary(t *testing.T) { } func TestApplyStorageStatsGFSPrimary(t *testing.T) { - now := time.Now() + now := time.Now().UTC() + previousMonthBackup := time.Date(now.Year(), now.Month(), 1, now.Hour(), now.Minute(), now.Second(), now.Nanosecond(), time.UTC).Add(-24 * time.Hour) + previousYearBackup := time.Date(now.Year()-1, time.January, 15, now.Hour(), now.Minute(), now.Second(), now.Nanosecond(), time.UTC) backups := []*types.BackupMetadata{ {Timestamp: now}, // daily {Timestamp: now.AddDate(0, 0, -8)}, // weekly - {Timestamp: now.AddDate(0, -1, -1)}, // monthly - {Timestamp: now.AddDate(-1, 0, 0)}, // yearly + {Timestamp: previousMonthBackup}, // monthly + {Timestamp: previousYearBackup}, // yearly } adapter := &StorageAdapter{ backend: &stubStorage{loc: storage.LocationPrimary, list: backups}, @@ -962,7 +964,7 @@ func TestPromptClusterRestoreMode(t *testing.T) { } func TestConfirmRestoreAction(t *testing.T) { - cand := &decryptCandidate{ + cand := &backupCandidate{ Manifest: &backup.Manifest{ CreatedAt: time.Now(), ArchivePath: "test.tar", diff --git a/internal/orchestrator/backup_candidate_display.go b/internal/orchestrator/backup_candidate_display.go new file mode 100644 index 0000000..379b287 --- /dev/null +++ b/internal/orchestrator/backup_candidate_display.go @@ -0,0 +1,176 @@ +package orchestrator + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/tis24dev/proxsave/internal/backup" +) + +const ( + backupCandidateDisplayTimeLayout = "2006-01-02 15:04:05" + unknownBackupDateText = "unknown date" + unknownBackupHostText = "unknown host" + unknownBackupTargetText = "UNKNOWN" +) + +type backupCandidateDisplay struct { + Created string + Hostname string + Mode string + Tool string + Target string + Compression string + Base string + Summary string +} + +func describeBackupCandidate(cand *backupCandidate) backupCandidateDisplay { + display := backupCandidateDisplay{ + Created: formatBackupCandidateCreated(cand), + Hostname: formatBackupCandidateHostname(cand), + Mode: formatBackupCandidateMode(cand), + Tool: formatBackupCandidateTool(cand), + Target: formatBackupCandidateTarget(candidateManifest(cand)), + Compression: formatBackupCandidateCompression(cand), + Base: backupCandidateBaseName(cand), + } + display.Summary = formatBackupCandidateSummary(display) + return display +} + +func candidateManifest(cand *backupCandidate) *backup.Manifest { + if cand == nil { + return nil + } + return cand.Manifest +} + +func formatBackupCandidateCreated(cand *backupCandidate) string { + manifest := candidateManifest(cand) + if manifest == nil || manifest.CreatedAt.IsZero() { + return unknownBackupDateText + } + return manifest.CreatedAt.Format(backupCandidateDisplayTimeLayout) +} + +func formatBackupCandidateHostname(cand *backupCandidate) string { + manifest := candidateManifest(cand) + if manifest == nil { + return unknownBackupHostText + } + host := strings.TrimSpace(manifest.Hostname) + if host == "" { + return unknownBackupHostText + } + return host +} + +func formatBackupCandidateMode(cand *backupCandidate) string { + manifest := candidateManifest(cand) + if manifest == nil { + return "UNKNOWN" + } + mode := strings.ToUpper(statusFromManifest(manifest)) + if mode == "" { + return "UNKNOWN" + } + return mode +} + +func formatBackupCandidateTool(cand *backupCandidate) string { + manifest := candidateManifest(cand) + if manifest == nil { + return "Tool unknown" + } + version := strings.TrimSpace(manifest.ScriptVersion) + if version == "" { + return "Tool unknown" + } + if !strings.HasPrefix(strings.ToLower(version), "v") { + version = "v" + version + } + return "Tool " + version +} + +func formatBackupCandidateTarget(manifest *backup.Manifest) string { + if manifest == nil { + return unknownBackupTargetText + } + + targets := formatTargets(manifest) + targets = strings.TrimSpace(targets) + if targets == "" || targets == "unknown target" { + targets = unknownBackupTargetText + } else { + targets = strings.ToUpper(targets) + } + + version := normalizeProxmoxVersion(manifest.ProxmoxVersion) + if version != "" { + targets = fmt.Sprintf("%s %s", targets, version) + } + + if cluster := formatClusterMode(manifest.ClusterMode); cluster != "" { + targets = fmt.Sprintf("%s (%s)", targets, cluster) + } + + return targets +} + +func formatBackupCandidateCompression(cand *backupCandidate) string { + manifest := candidateManifest(cand) + if manifest == nil { + return "" + } + compression := strings.TrimSpace(manifest.CompressionType) + if compression == "" { + return "" + } + return strings.ToUpper(compression) +} + +func backupCandidateBaseName(cand *backupCandidate) string { + if cand == nil { + return "" + } + base := strings.TrimSpace(cand.DisplayBase) + if base != "" { + return base + } + + switch { + case strings.TrimSpace(cand.BundlePath) != "": + return filepath.Base(strings.TrimSpace(cand.BundlePath)) + case strings.TrimSpace(cand.RawArchivePath) != "": + return filepath.Base(strings.TrimSpace(cand.RawArchivePath)) + default: + return "" + } +} + +func formatBackupCandidateSummary(display backupCandidateDisplay) string { + parts := make([]string, 0, 2) + + if display.Hostname != "" && display.Hostname != unknownBackupHostText { + parts = append(parts, display.Hostname) + } + + switch { + case display.Base != "" && display.Created != "" && display.Created != unknownBackupDateText: + parts = append(parts, fmt.Sprintf("%s (%s)", display.Base, display.Created)) + case display.Base != "": + parts = append(parts, display.Base) + case display.Created != "" && display.Created != unknownBackupDateText: + parts = append(parts, display.Created) + } + + if len(parts) == 0 { + if display.Hostname != "" { + return display.Hostname + } + return display.Created + } + return strings.Join(parts, " • ") +} diff --git a/internal/orchestrator/backup_candidate_display_test.go b/internal/orchestrator/backup_candidate_display_test.go new file mode 100644 index 0000000..7f1dc78 --- /dev/null +++ b/internal/orchestrator/backup_candidate_display_test.go @@ -0,0 +1,119 @@ +package orchestrator + +import ( + "bufio" + "context" + "strings" + "testing" + "time" + + "github.com/tis24dev/proxsave/internal/backup" +) + +func TestDescribeBackupCandidate_Full(t *testing.T) { + created := time.Date(2026, time.March, 22, 12, 21, 22, 0, time.UTC) + cand := &backupCandidate{ + DisplayBase: "backup.tar.xz", + Manifest: &backup.Manifest{ + CreatedAt: created, + Hostname: "node1.example.com", + EncryptionMode: "age", + ScriptVersion: "1.2.3", + ProxmoxTargets: []string{"pve"}, + ProxmoxVersion: "8.0", + ClusterMode: "cluster", + CompressionType: "xz", + }, + } + + display := describeBackupCandidate(cand) + if display.Created != "2026-03-22 12:21:22" { + t.Fatalf("Created=%q", display.Created) + } + if display.Hostname != "node1.example.com" { + t.Fatalf("Hostname=%q", display.Hostname) + } + if display.Mode != "ENCRYPTED" { + t.Fatalf("Mode=%q", display.Mode) + } + if display.Tool != "Tool v1.2.3" { + t.Fatalf("Tool=%q", display.Tool) + } + if display.Target != "PVE v8.0 (cluster)" { + t.Fatalf("Target=%q", display.Target) + } + if display.Compression != "XZ" { + t.Fatalf("Compression=%q", display.Compression) + } + if display.Summary != "node1.example.com • backup.tar.xz (2026-03-22 12:21:22)" { + t.Fatalf("Summary=%q", display.Summary) + } +} + +func TestDescribeBackupCandidate_Fallbacks(t *testing.T) { + cand := &backupCandidate{ + RawArchivePath: "/tmp/archive.tar.xz", + Manifest: &backup.Manifest{}, + } + + display := describeBackupCandidate(cand) + if display.Created != unknownBackupDateText { + t.Fatalf("Created=%q, want %q", display.Created, unknownBackupDateText) + } + if display.Hostname != unknownBackupHostText { + t.Fatalf("Hostname=%q, want %q", display.Hostname, unknownBackupHostText) + } + if display.Mode != "PLAIN" { + t.Fatalf("Mode=%q, want %q", display.Mode, "PLAIN") + } + if display.Tool != "Tool unknown" { + t.Fatalf("Tool=%q, want %q", display.Tool, "Tool unknown") + } + if display.Target != unknownBackupTargetText { + t.Fatalf("Target=%q, want %q", display.Target, unknownBackupTargetText) + } + if display.Base != "archive.tar.xz" { + t.Fatalf("Base=%q, want %q", display.Base, "archive.tar.xz") + } + if display.Summary != "archive.tar.xz" { + t.Fatalf("Summary=%q, want %q", display.Summary, "archive.tar.xz") + } +} + +func TestBackupSummaryForUI_UsesSharedDisplayModel(t *testing.T) { + created := time.Date(2026, time.March, 22, 12, 21, 22, 0, time.UTC) + cand := &backupCandidate{ + DisplayBase: "backup.tar.xz", + Manifest: &backup.Manifest{ + CreatedAt: created, + Hostname: "node1.example.com", + }, + } + + if got := backupSummaryForUI(cand); got != "node1.example.com • backup.tar.xz (2026-03-22 12:21:22)" { + t.Fatalf("backupSummaryForUI()=%q", got) + } +} + +func TestPromptCandidateSelection_PrintsHostname(t *testing.T) { + reader := bufio.NewReader(strings.NewReader("0\n")) + candidates := []*backupCandidate{ + { + Manifest: &backup.Manifest{ + CreatedAt: time.Date(2026, time.March, 22, 12, 21, 22, 0, time.UTC), + Hostname: "node1.example.com", + EncryptionMode: "age", + ScriptVersion: "1.2.3", + ProxmoxType: "pve", + }, + }, + } + + stdout := captureCLIStdout(t, func() { + _, _ = promptCandidateSelection(context.Background(), reader, candidates) + }) + + if !strings.Contains(stdout, "Host node1.example.com") { + t.Fatalf("expected hostname in CLI output, got %q", stdout) + } +} diff --git a/internal/orchestrator/backup_sources.go b/internal/orchestrator/backup_sources.go index eb85872..86b5f2b 100644 --- a/internal/orchestrator/backup_sources.go +++ b/internal/orchestrator/backup_sources.go @@ -89,8 +89,8 @@ func buildDecryptPathOptions(cfg *config.Config, logger *logging.Logger) (option } // discoverRcloneBackups lists backup candidates from an rclone remote and returns -// decrypt candidates backed by that remote (bundles and raw archives). -func discoverRcloneBackups(ctx context.Context, cfg *config.Config, remotePath string, logger *logging.Logger, report ProgressReporter) (candidates []*decryptCandidate, err error) { +// backup candidates backed by that remote (bundles and raw archives). +func discoverRcloneBackups(ctx context.Context, cfg *config.Config, remotePath string, logger *logging.Logger, report ProgressReporter) (candidates []*backupCandidate, err error) { done := logging.DebugStart(logger, "discover rclone backups", "remote=%s", remotePath) defer func() { done(err) }() start := time.Now() @@ -128,7 +128,7 @@ func discoverRcloneBackups(ctx context.Context, cfg *config.Config, remotePath s } logging.DebugStep(logger, "discover rclone backups", "rclone lsf output bytes=%d elapsed=%s", len(output), time.Since(lsfStart)) - candidates = make([]*decryptCandidate, 0) + candidates = make([]*backupCandidate, 0) lines := strings.Split(string(output), "\n") totalEntries := len(lines) @@ -238,7 +238,7 @@ func discoverRcloneBackups(ctx context.Context, cfg *config.Config, remotePath s if strings.TrimSpace(displayBase) == "" { displayBase = filepath.Base(item.filename) } - candidates = append(candidates, &decryptCandidate{ + candidates = append(candidates, &backupCandidate{ Manifest: manifest, Source: sourceBundle, BundlePath: item.remoteBundle, @@ -295,7 +295,7 @@ func discoverRcloneBackups(ctx context.Context, cfg *config.Config, remotePath s if strings.TrimSpace(displayBase) == "" { displayBase = filepath.Base(baseNameFromRemoteRef(item.remoteArchive)) } - candidates = append(candidates, &decryptCandidate{ + candidates = append(candidates, &backupCandidate{ Manifest: manifest, Source: sourceRaw, RawArchivePath: item.remoteArchive, @@ -353,7 +353,7 @@ func discoverRcloneBackups(ctx context.Context, cfg *config.Config, remotePath s // discoverBackupCandidates scans a local or mounted directory for backup // candidates (bundle or raw triplet: archive + metadata + checksum). -func discoverBackupCandidates(logger *logging.Logger, root string) (candidates []*decryptCandidate, err error) { +func discoverBackupCandidates(logger *logging.Logger, root string) (candidates []*backupCandidate, err error) { done := logging.DebugStart(logger, "discover backup candidates", "root=%s", root) defer func() { done(err) }() entries, err := restoreFS.ReadDir(root) @@ -362,7 +362,7 @@ func discoverBackupCandidates(logger *logging.Logger, root string) (candidates [ } logging.DebugStep(logger, "discover backup candidates", "entries=%d", len(entries)) - candidates = make([]*decryptCandidate, 0) + candidates = make([]*backupCandidate, 0) rawBases := make(map[string]struct{}) filesSeen := 0 dirsSkipped := 0 @@ -395,7 +395,7 @@ func discoverBackupCandidates(logger *logging.Logger, root string) (candidates [ continue } logging.DebugStep(logger, "discover backup candidates", "bundle accepted: %s created_at=%s", name, manifest.CreatedAt.Format(time.RFC3339)) - candidates = append(candidates, &decryptCandidate{ + candidates = append(candidates, &backupCandidate{ Manifest: manifest, Source: sourceBundle, BundlePath: fullPath, @@ -453,7 +453,7 @@ func discoverBackupCandidates(logger *logging.Logger, root string) (candidates [ } rawBases[baseName] = struct{}{} - candidates = append(candidates, &decryptCandidate{ + candidates = append(candidates, &backupCandidate{ Manifest: manifest, Source: sourceRaw, RawArchivePath: archivePath, diff --git a/internal/orchestrator/decrypt.go b/internal/orchestrator/decrypt.go index 83b44f7..9ce590d 100644 --- a/internal/orchestrator/decrypt.go +++ b/internal/orchestrator/decrypt.go @@ -36,7 +36,7 @@ const ( sourceRaw ) -type decryptCandidate struct { +type backupCandidate struct { Manifest *backup.Manifest Source decryptSourceType BundlePath string @@ -99,7 +99,7 @@ func RunDecryptWorkflow(ctx context.Context, cfg *config.Config, logger *logging return RunDecryptWorkflowWithDeps(ctx, &deps, version) } -func selectDecryptCandidate(ctx context.Context, reader *bufio.Reader, cfg *config.Config, logger *logging.Logger, requireEncrypted bool) (candidate *decryptCandidate, err error) { +func selectDecryptCandidate(ctx context.Context, reader *bufio.Reader, cfg *config.Config, logger *logging.Logger, requireEncrypted bool) (candidate *backupCandidate, err error) { done := logging.DebugStart(logger, "select backup candidate", "requireEncrypted=%v", requireEncrypted) defer func() { done(err) }() @@ -334,18 +334,20 @@ func inspectRcloneMetadataManifest(ctx context.Context, remoteMetadataPath, remo return legacy, nil } -func promptCandidateSelection(ctx context.Context, reader *bufio.Reader, candidates []*decryptCandidate) (*decryptCandidate, error) { +func promptCandidateSelection(ctx context.Context, reader *bufio.Reader, candidates []*backupCandidate) (*backupCandidate, error) { for { fmt.Println("\nAvailable backups:") for idx, cand := range candidates { - created := cand.Manifest.CreatedAt.Format("2006-01-02 15:04:05") - enc := strings.ToUpper(statusFromManifest(cand.Manifest)) - toolVersion := cand.Manifest.ScriptVersion - if toolVersion == "" { - toolVersion = "unknown" - } - targetSummary := formatTargetSummary(cand.Manifest) - fmt.Printf(" [%d] %s • %s • Tool v%s • %s\n", idx+1, created, enc, toolVersion, targetSummary) + display := describeBackupCandidate(cand) + fmt.Printf( + " [%d] %s • Host %s • %s • %s • %s\n", + idx+1, + display.Created, + display.Hostname, + display.Mode, + display.Tool, + display.Target, + ) } fmt.Println(" [0] Exit") @@ -429,12 +431,12 @@ func downloadRcloneBackup(ctx context.Context, remotePath string, logger *loggin return tmpPath, cleanup, nil } -func preparePlainBundle(ctx context.Context, reader *bufio.Reader, cand *decryptCandidate, version string, logger *logging.Logger) (bundle *preparedBundle, err error) { +func preparePlainBundle(ctx context.Context, reader *bufio.Reader, cand *backupCandidate, version string, logger *logging.Logger) (bundle *preparedBundle, err error) { ui := newCLIWorkflowUI(reader, logger) return preparePlainBundleWithUI(ctx, cand, version, logger, ui) } -func prepareDecryptedBackup(ctx context.Context, reader *bufio.Reader, cfg *config.Config, logger *logging.Logger, version string, requireEncrypted bool) (candidate *decryptCandidate, prepared *preparedBundle, err error) { +func prepareDecryptedBackup(ctx context.Context, reader *bufio.Reader, cfg *config.Config, logger *logging.Logger, version string, requireEncrypted bool) (candidate *backupCandidate, prepared *preparedBundle, err error) { done := logging.DebugStart(logger, "prepare decrypted backup", "requireEncrypted=%v", requireEncrypted) defer func() { done(err) }() candidate, err = selectDecryptCandidate(ctx, reader, cfg, logger, requireEncrypted) @@ -546,7 +548,7 @@ func extractBundleToWorkdirWithLogger(bundlePath, workDir string, logger *loggin return staged, nil } -func copyRawArtifactsToWorkdir(ctx context.Context, cand *decryptCandidate, workDir string) (staged stagedFiles, err error) { +func copyRawArtifactsToWorkdir(ctx context.Context, cand *backupCandidate, workDir string) (staged stagedFiles, err error) { return copyRawArtifactsToWorkdirWithLogger(ctx, cand, workDir, nil) } @@ -577,7 +579,7 @@ func rcloneCopyTo(ctx context.Context, remotePath, localPath string, showProgres return cmd.Run() } -func copyRawArtifactsToWorkdirWithLogger(ctx context.Context, cand *decryptCandidate, workDir string, logger *logging.Logger) (staged stagedFiles, err error) { +func copyRawArtifactsToWorkdirWithLogger(ctx context.Context, cand *backupCandidate, workDir string, logger *logging.Logger) (staged stagedFiles, err error) { done := logging.DebugStart(logger, "stage raw artifacts", "archive=%s workdir=%s rclone=%v", cand.RawArchivePath, workDir, cand.IsRclone) defer func() { done(err) }() if ctx == nil { diff --git a/internal/orchestrator/decrypt_integrity.go b/internal/orchestrator/decrypt_integrity.go index aba5784..ec1b3f3 100644 --- a/internal/orchestrator/decrypt_integrity.go +++ b/internal/orchestrator/decrypt_integrity.go @@ -57,7 +57,7 @@ func resolveStagedIntegrityExpectation(staged stagedFiles, manifest *backup.Mani return resolveIntegrityExpectationValues(checksumFromFile, checksumFromManifest) } -func resolveCandidateIntegrityExpectation(staged stagedFiles, cand *decryptCandidate) (*stagedIntegrityExpectation, error) { +func resolveCandidateIntegrityExpectation(staged stagedFiles, cand *backupCandidate) (*stagedIntegrityExpectation, error) { if cand != nil && cand.Integrity != nil && strings.TrimSpace(cand.Integrity.Checksum) != "" { normalized, err := backup.NormalizeChecksum(cand.Integrity.Checksum) if err != nil { @@ -95,7 +95,7 @@ func resolveCandidateIntegrityExpectation(staged stagedFiles, cand *decryptCandi return resolveStagedIntegrityExpectation(staged, manifest) } -func verifyStagedArchiveIntegrity(ctx context.Context, logger *logging.Logger, staged stagedFiles, cand *decryptCandidate) (string, error) { +func verifyStagedArchiveIntegrity(ctx context.Context, logger *logging.Logger, staged stagedFiles, cand *backupCandidate) (string, error) { if staged.ArchivePath == "" { return "", fmt.Errorf("staged archive path is empty") } diff --git a/internal/orchestrator/decrypt_integrity_test.go b/internal/orchestrator/decrypt_integrity_test.go index a77b685..f34c99c 100644 --- a/internal/orchestrator/decrypt_integrity_test.go +++ b/internal/orchestrator/decrypt_integrity_test.go @@ -60,7 +60,7 @@ func TestPreparePlainBundle_RejectsMissingChecksumVerification(t *testing.T) { t.Fatalf("write metadata: %v", err) } - cand := &decryptCandidate{ + cand := &backupCandidate{ Manifest: manifest, Source: sourceRaw, RawArchivePath: archivePath, @@ -109,7 +109,7 @@ func TestPreparePlainBundle_RejectsChecksumMismatch(t *testing.T) { t.Fatalf("write checksum: %v", err) } - cand := &decryptCandidate{ + cand := &backupCandidate{ Manifest: manifest, Source: sourceRaw, RawArchivePath: archivePath, @@ -144,7 +144,7 @@ func TestVerifyStagedArchiveIntegrity_UsesCandidateIntegrityExpectation(t *testi got, err := verifyStagedArchiveIntegrity(context.Background(), logging.New(types.LogLevelError, false), stagedFiles{ ArchivePath: archivePath, - }, &decryptCandidate{ + }, &backupCandidate{ Integrity: &stagedIntegrityExpectation{ Checksum: strings.ToUpper(checksumHexForBytes(archiveData)), Source: "checksum file", diff --git a/internal/orchestrator/decrypt_prepare_common.go b/internal/orchestrator/decrypt_prepare_common.go index 20b85ad..f35813f 100644 --- a/internal/orchestrator/decrypt_prepare_common.go +++ b/internal/orchestrator/decrypt_prepare_common.go @@ -62,7 +62,7 @@ func resolvePreparedArchivePath(workDir, stagedArchivePath, currentEncryption st return filepath.Join(workDir, archiveBase), nil } -func preparePlainBundleCommon(ctx context.Context, cand *decryptCandidate, version string, logger *logging.Logger, decryptArchive archiveDecryptFunc) (bundle *preparedBundle, err error) { +func preparePlainBundleCommon(ctx context.Context, cand *backupCandidate, version string, logger *logging.Logger, decryptArchive archiveDecryptFunc) (bundle *preparedBundle, err error) { if cand == nil || cand.Manifest == nil { return nil, fmt.Errorf("invalid backup candidate") } diff --git a/internal/orchestrator/decrypt_test.go b/internal/orchestrator/decrypt_test.go index e6efd30..4932b1b 100644 --- a/internal/orchestrator/decrypt_test.go +++ b/internal/orchestrator/decrypt_test.go @@ -1044,7 +1044,7 @@ func TestPromptPathSelection_InvalidIndexRetries(t *testing.T) { func TestPromptCandidateSelection_Abort(t *testing.T) { reader := bufio.NewReader(strings.NewReader("0\n")) - candidates := []*decryptCandidate{ + candidates := []*backupCandidate{ {Manifest: &backup.Manifest{EncryptionMode: "age"}}, } @@ -1057,7 +1057,7 @@ func TestPromptCandidateSelection_Abort(t *testing.T) { func TestPromptCandidateSelection_EmptyInputRetries(t *testing.T) { // Empty input, then valid selection reader := bufio.NewReader(strings.NewReader("\n\n1\n")) - candidates := []*decryptCandidate{ + candidates := []*backupCandidate{ {Manifest: &backup.Manifest{EncryptionMode: "age"}}, } @@ -1073,7 +1073,7 @@ func TestPromptCandidateSelection_EmptyInputRetries(t *testing.T) { func TestPromptCandidateSelection_InvalidIndexRetries(t *testing.T) { // Invalid index, then valid selection reader := bufio.NewReader(strings.NewReader("99\n1\n")) - candidates := []*decryptCandidate{ + candidates := []*backupCandidate{ {Manifest: &backup.Manifest{EncryptionMode: "age"}}, } @@ -1537,7 +1537,7 @@ func TestPreparePlainBundle_UnsupportedSource(t *testing.T) { restoreFS = osFS{} t.Cleanup(func() { restoreFS = origFS }) - cand := &decryptCandidate{ + cand := &backupCandidate{ Manifest: &backup.Manifest{}, Source: decryptSourceType(99), // Invalid source } @@ -1573,7 +1573,7 @@ func TestPreparePlainBundle_SourceBundleSuccess(t *testing.T) { {name: "backup.sha256", data: checksumLineForBytes("archive.tar.xz", archiveData)}, }) - cand := &decryptCandidate{ + cand := &backupCandidate{ Manifest: &backup.Manifest{ArchivePath: filepath.Join(dir, "archive.tar.xz"), EncryptionMode: "none"}, Source: sourceBundle, BundlePath: bundlePath, @@ -1605,7 +1605,7 @@ func TestPreparePlainBundle_ExtractError(t *testing.T) { restoreFS = osFS{} t.Cleanup(func() { restoreFS = origFS }) - cand := &decryptCandidate{ + cand := &backupCandidate{ Manifest: &backup.Manifest{EncryptionMode: "none"}, Source: sourceBundle, BundlePath: "/nonexistent/bundle.tar", @@ -1937,7 +1937,7 @@ func TestCopyRawArtifactsToWorkdir_Success(t *testing.T) { t.Fatalf("write checksum: %v", err) } - cand := &decryptCandidate{ + cand := &backupCandidate{ RawArchivePath: archivePath, RawMetadataPath: metadataPath, RawChecksumPath: checksumPath, @@ -1957,7 +1957,7 @@ func TestCopyRawArtifactsToWorkdir_ArchiveError(t *testing.T) { restoreFS = osFS{} t.Cleanup(func() { restoreFS = origFS }) - cand := &decryptCandidate{ + cand := &backupCandidate{ RawArchivePath: "/nonexistent/archive.tar.xz", RawMetadataPath: "/nonexistent/backup.metadata", RawChecksumPath: "/nonexistent/backup.sha256", @@ -1986,7 +1986,7 @@ func TestCopyRawArtifactsToWorkdir_MetadataError(t *testing.T) { t.Fatalf("write archive: %v", err) } - cand := &decryptCandidate{ + cand := &backupCandidate{ RawArchivePath: archivePath, RawMetadataPath: "/nonexistent/backup.metadata", RawChecksumPath: "/nonexistent/backup.sha256", @@ -2019,7 +2019,7 @@ func TestCopyRawArtifactsToWorkdir_ChecksumError(t *testing.T) { t.Fatalf("write metadata: %v", err) } - cand := &decryptCandidate{ + cand := &backupCandidate{ RawArchivePath: archivePath, RawMetadataPath: metadataPath, RawChecksumPath: "/nonexistent/backup.sha256", @@ -2085,7 +2085,7 @@ esac t.Setenv("METADATA_SRC", metadataSrc) t.Setenv("CHECKSUM_SRC", checksumSrc) - cand := &decryptCandidate{ + cand := &backupCandidate{ IsRclone: true, RawArchivePath: "gdrive:backup.tar.xz", RawMetadataPath: "gdrive:backup.tar.xz.metadata", @@ -2552,7 +2552,7 @@ func TestCopyRawArtifactsToWorkdir_ContextWorks(t *testing.T) { t.Fatalf("write metadata: %v", err) } - cand := &decryptCandidate{ + cand := &backupCandidate{ RawArchivePath: archivePath, RawMetadataPath: metadataPath, RawChecksumPath: "", @@ -2575,7 +2575,7 @@ func TestCopyRawArtifactsToWorkdir_InvalidRclonePaths(t *testing.T) { workDir := t.TempDir() // Candidate with rclone but empty paths after colon - cand := &decryptCandidate{ + cand := &backupCandidate{ IsRclone: true, RawArchivePath: "gdrive:", // Empty path after colon RawMetadataPath: "gdrive:m", // Valid @@ -2765,7 +2765,7 @@ func TestPreparePlainBundle_CopyFileSamePath(t *testing.T) { t.Fatalf("write checksum: %v", err) } - cand := &decryptCandidate{ + cand := &backupCandidate{ Manifest: manifest, Source: sourceRaw, RawArchivePath: archivePath, @@ -2857,7 +2857,7 @@ esac return []byte(id.String()), nil } - cand := &decryptCandidate{ + cand := &backupCandidate{ Manifest: manifest, Source: sourceBundle, BundlePath: "gdrive:backup.bundle.tar", @@ -2899,7 +2899,7 @@ func TestPreparePlainBundleCommon_TrimmedAgeEncryptionTriggersDecrypt(t *testing t.Fatalf("write checksum: %v", err) } - cand := &decryptCandidate{ + cand := &backupCandidate{ Manifest: &backup.Manifest{ ArchivePath: workArchive, EncryptionMode: " age ", @@ -2965,7 +2965,7 @@ func TestPreparePlainBundleCommon_AgeModeRequiresAgeSuffix(t *testing.T) { t.Fatalf("write checksum: %v", err) } - cand := &decryptCandidate{ + cand := &backupCandidate{ Manifest: &backup.Manifest{ ArchivePath: workArchive, EncryptionMode: "age", @@ -3016,7 +3016,7 @@ func TestPreparePlainBundleCommon_NonAgeRejectsAgeSuffix(t *testing.T) { t.Fatalf("write checksum: %v", err) } - cand := &decryptCandidate{ + cand := &backupCandidate{ Manifest: &backup.Manifest{ ArchivePath: workArchive, EncryptionMode: "none", @@ -3154,7 +3154,7 @@ func TestPreparePlainBundle_SourceBundleAdditional(t *testing.T) { tw.Close() f.Close() - cand := &decryptCandidate{ + cand := &backupCandidate{ Manifest: manifest, Source: sourceBundle, BundlePath: bundlePath, @@ -3326,7 +3326,7 @@ func TestCopyRawArtifactsToWorkdir_WithChecksum(t *testing.T) { t.Fatalf("write checksum: %v", err) } - cand := &decryptCandidate{ + cand := &backupCandidate{ RawArchivePath: archivePath, RawMetadataPath: metadataPath, RawChecksumPath: checksumPath, @@ -3418,7 +3418,7 @@ func TestPromptPathSelection_InvalidThenValid(t *testing.T) { func TestPromptCandidateSelection_Exit(t *testing.T) { now := time.Now() - cands := []*decryptCandidate{ + cands := []*backupCandidate{ { Manifest: &backup.Manifest{ CreatedAt: now, @@ -3444,7 +3444,7 @@ func TestPreparePlainBundle_MkdirAllError(t *testing.T) { restoreFS = fake defer func() { restoreFS = orig }() - cand := &decryptCandidate{ + cand := &backupCandidate{ Source: sourceBundle, BundlePath: "/bundle.tar", Manifest: &backup.Manifest{EncryptionMode: "none"}, @@ -3470,7 +3470,7 @@ func TestPreparePlainBundle_MkdirTempError(t *testing.T) { restoreFS = fake defer func() { restoreFS = orig }() - cand := &decryptCandidate{ + cand := &backupCandidate{ Source: sourceBundle, BundlePath: "/bundle.tar", Manifest: &backup.Manifest{EncryptionMode: "none"}, @@ -3627,7 +3627,7 @@ exit 0 t.Fatalf("mkdir: %v", err) } - cand := &decryptCandidate{ + cand := &backupCandidate{ IsRclone: true, RawArchivePath: "remote:backup.tar.xz", RawMetadataPath: "remote:backup.metadata", @@ -3683,7 +3683,7 @@ fi t.Fatalf("mkdir: %v", err) } - cand := &decryptCandidate{ + cand := &backupCandidate{ IsRclone: true, RawArchivePath: "remote:backup.tar.xz", RawMetadataPath: "remote:backup.metadata", @@ -3912,7 +3912,7 @@ func TestPreparePlainBundle_StatErrorAfterExtract(t *testing.T) { restoreFS = fake defer func() { restoreFS = orig }() - cand := &decryptCandidate{ + cand := &backupCandidate{ Source: sourceBundle, BundlePath: bundlePath, Manifest: &backup.Manifest{EncryptionMode: "none", Hostname: "test"}, @@ -3951,7 +3951,7 @@ exit 1 prependPathEnv(t, tmp) - cand := &decryptCandidate{ + cand := &backupCandidate{ Source: sourceBundle, BundlePath: "remote:backup.bundle.tar", IsRclone: true, @@ -4013,7 +4013,7 @@ exit 1 // Call preparePlainBundle with rclone candidate // It will first download (success), then try MkdirAll for tempRoot - cand := &decryptCandidate{ + cand := &backupCandidate{ Source: sourceBundle, BundlePath: "remote:backup.bundle.tar", IsRclone: true, @@ -4194,7 +4194,7 @@ func TestPreparePlainBundle_CopyFileError(t *testing.T) { restoreFS = fake defer func() { restoreFS = orig }() - cand := &decryptCandidate{ + cand := &backupCandidate{ Source: sourceBundle, BundlePath: bundlePath, Manifest: &backup.Manifest{EncryptionMode: "none", Hostname: "test"}, @@ -4295,7 +4295,7 @@ func TestPreparePlainBundle_StatErrorOnPlainArchive(t *testing.T) { restoreFS = fake defer func() { restoreFS = orig }() - cand := &decryptCandidate{ + cand := &backupCandidate{ Source: sourceBundle, BundlePath: bundlePath, Manifest: &backup.Manifest{EncryptionMode: "none", Hostname: "test"}, @@ -4361,7 +4361,7 @@ exit 0 restoreFS = osFS{} defer func() { restoreFS = orig }() - cand := &decryptCandidate{ + cand := &backupCandidate{ Source: sourceBundle, BundlePath: "remote:backup.bundle.tar", IsRclone: true, @@ -4429,7 +4429,7 @@ func TestPreparePlainBundle_GenerateChecksumErrorPath(t *testing.T) { restoreFS = fake defer func() { restoreFS = orig }() - cand := &decryptCandidate{ + cand := &backupCandidate{ Source: sourceBundle, BundlePath: bundlePath, Manifest: &backup.Manifest{EncryptionMode: "none", Hostname: "test"}, @@ -4507,7 +4507,7 @@ exit 0 restoreFS = fake defer func() { restoreFS = orig }() - cand := &decryptCandidate{ + cand := &backupCandidate{ Source: sourceBundle, BundlePath: "remote:backup.bundle.tar", IsRclone: true, diff --git a/internal/orchestrator/decrypt_tui.go b/internal/orchestrator/decrypt_tui.go index 04ea8aa..350a31c 100644 --- a/internal/orchestrator/decrypt_tui.go +++ b/internal/orchestrator/decrypt_tui.go @@ -45,23 +45,7 @@ func RunDecryptWorkflowTUI(ctx context.Context, cfg *config.Config, logger *logg } func buildTargetInfo(manifest *backup.Manifest) string { - targets := formatTargets(manifest) - if targets == "" { - targets = "unknown" - } else { - targets = strings.ToUpper(targets) - } - - version := normalizeProxmoxVersion(manifest.ProxmoxVersion) - if version != "" { - targets = fmt.Sprintf("%s %s", targets, version) - } - - if cluster := formatClusterMode(manifest.ClusterMode); cluster != "" { - targets = fmt.Sprintf("%s (%s)", targets, cluster) - } - - return fmt.Sprintf("Targets: %s", targets) + return fmt.Sprintf("Targets: %s", formatBackupCandidateTarget(manifest)) } func normalizeProxmoxVersion(value string) string { @@ -75,11 +59,11 @@ func normalizeProxmoxVersion(value string) string { return version } -func filterEncryptedCandidates(candidates []*decryptCandidate) []*decryptCandidate { +func filterEncryptedCandidates(candidates []*backupCandidate) []*backupCandidate { if len(candidates) == 0 { return candidates } - filtered := make([]*decryptCandidate, 0, len(candidates)) + filtered := make([]*backupCandidate, 0, len(candidates)) for _, c := range candidates { if c == nil || c.Manifest == nil { continue diff --git a/internal/orchestrator/decrypt_tui_test.go b/internal/orchestrator/decrypt_tui_test.go index d94b78e..2c82e21 100644 --- a/internal/orchestrator/decrypt_tui_test.go +++ b/internal/orchestrator/decrypt_tui_test.go @@ -51,10 +51,10 @@ func TestBuildTargetInfo(t *testing.T) { func TestFilterEncryptedCandidates(t *testing.T) { now := time.Now() - encrypted := &decryptCandidate{Manifest: &backup.Manifest{EncryptionMode: "age", CreatedAt: now}} - plain := &decryptCandidate{Manifest: &backup.Manifest{EncryptionMode: "none", CreatedAt: now}} + encrypted := &backupCandidate{Manifest: &backup.Manifest{EncryptionMode: "age", CreatedAt: now}} + plain := &backupCandidate{Manifest: &backup.Manifest{EncryptionMode: "none", CreatedAt: now}} - filtered := filterEncryptedCandidates([]*decryptCandidate{nil, encrypted, plain, {}}) + filtered := filterEncryptedCandidates([]*backupCandidate{nil, encrypted, plain, {}}) if len(filtered) != 1 || filtered[0] != encrypted { t.Fatalf("filterEncryptedCandidates returned %+v, want only encrypted candidate", filtered) } diff --git a/internal/orchestrator/decrypt_workflow_test.go b/internal/orchestrator/decrypt_workflow_test.go index e145b7d..ed1644d 100644 --- a/internal/orchestrator/decrypt_workflow_test.go +++ b/internal/orchestrator/decrypt_workflow_test.go @@ -89,7 +89,7 @@ func TestPreparePlainBundle_AllowsMissingRawChecksumSidecar(t *testing.T) { // No checksum sidecar: restore/decrypt should still proceed when the manifest // already carries the expected archive checksum. - cand := &decryptCandidate{ + cand := &backupCandidate{ Manifest: manifest, Source: sourceRaw, RawArchivePath: archive, diff --git a/internal/orchestrator/decrypt_workflow_ui.go b/internal/orchestrator/decrypt_workflow_ui.go index 2cbd988..aadc971 100644 --- a/internal/orchestrator/decrypt_workflow_ui.go +++ b/internal/orchestrator/decrypt_workflow_ui.go @@ -30,7 +30,7 @@ func isNilInterface(v any) bool { } } -func selectBackupCandidateWithUI(ctx context.Context, ui BackupSelectionUI, cfg *config.Config, logger *logging.Logger, requireEncrypted bool) (candidate *decryptCandidate, err error) { +func selectBackupCandidateWithUI(ctx context.Context, ui BackupSelectionUI, cfg *config.Config, logger *logging.Logger, requireEncrypted bool) (candidate *backupCandidate, err error) { done := logging.DebugStart(logger, "select backup candidate (ui)", "requireEncrypted=%v", requireEncrypted) defer func() { done(err) }() @@ -51,7 +51,7 @@ func selectBackupCandidateWithUI(ctx context.Context, ui BackupSelectionUI, cfg logger.Info("Scanning %s for backups...", option.Path) - var candidates []*decryptCandidate + var candidates []*backupCandidate scanErr := ui.RunTask(ctx, "Scanning backups", "Scanning backup source...", func(scanCtx context.Context, report ProgressReporter) error { if option.IsRclone { found, err := discoverRcloneBackups(scanCtx, cfg, option.Path, logger, report) @@ -197,7 +197,7 @@ func decryptArchiveWithSecretPrompt(ctx context.Context, encryptedPath, outputPa } } -func preparePlainBundleWithUI(ctx context.Context, cand *decryptCandidate, version string, logger *logging.Logger, ui interface { +func preparePlainBundleWithUI(ctx context.Context, cand *backupCandidate, version string, logger *logging.Logger, ui interface { PromptDecryptSecret(ctx context.Context, displayName, previousError string) (string, error) }) (bundle *preparedBundle, err error) { if cand == nil || cand.Manifest == nil { diff --git a/internal/orchestrator/decrypt_workflow_ui_test.go b/internal/orchestrator/decrypt_workflow_ui_test.go index bea77a4..c37fccd 100644 --- a/internal/orchestrator/decrypt_workflow_ui_test.go +++ b/internal/orchestrator/decrypt_workflow_ui_test.go @@ -34,7 +34,7 @@ func (f *fakeDecryptWorkflowUI) SelectBackupSource(ctx context.Context, options panic("unexpected SelectBackupSource call") } -func (f *fakeDecryptWorkflowUI) SelectBackupCandidate(ctx context.Context, candidates []*decryptCandidate) (*decryptCandidate, error) { +func (f *fakeDecryptWorkflowUI) SelectBackupCandidate(ctx context.Context, candidates []*backupCandidate) (*backupCandidate, error) { panic("unexpected SelectBackupCandidate call") } @@ -186,7 +186,7 @@ func TestPreparePlainBundleWithUICopiesRawArtifacts(t *testing.T) { t.Fatalf("write checksum: %v", err) } - cand := &decryptCandidate{ + cand := &backupCandidate{ Manifest: &backup.Manifest{ ArchivePath: rawArchive, EncryptionMode: "none", @@ -254,7 +254,7 @@ func TestPreparePlainBundleWithUIRejectsMissingUI(t *testing.T) { t.Fatalf("write checksum: %v", err) } - cand := &decryptCandidate{ + cand := &backupCandidate{ Manifest: &backup.Manifest{ ArchivePath: rawArchive, EncryptionMode: "none", @@ -319,7 +319,7 @@ func TestPreparePlainBundleWithUIRejectsTypedNilUI(t *testing.T) { t.Fatalf("write checksum: %v", err) } - cand := &decryptCandidate{ + cand := &backupCandidate{ Manifest: &backup.Manifest{ ArchivePath: rawArchive, EncryptionMode: "none", diff --git a/internal/orchestrator/restore.go b/internal/orchestrator/restore.go index c774821..cf7db6a 100644 --- a/internal/orchestrator/restore.go +++ b/internal/orchestrator/restore.go @@ -688,7 +688,7 @@ func exportDestRoot(baseDir string) string { } // runFullRestore performs a full restore without selective options (fallback) -func runFullRestore(ctx context.Context, reader *bufio.Reader, candidate *decryptCandidate, prepared *preparedBundle, destRoot string, logger *logging.Logger, dryRun bool) error { +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 } @@ -760,7 +760,7 @@ func runFullRestore(ctx context.Context, reader *bufio.Reader, candidate *decryp return nil } -func confirmRestoreAction(ctx context.Context, reader *bufio.Reader, cand *decryptCandidate, dest string) error { +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")) diff --git a/internal/orchestrator/restore_coverage_extra_test.go b/internal/orchestrator/restore_coverage_extra_test.go index 922a1a6..15bdd2b 100644 --- a/internal/orchestrator/restore_coverage_extra_test.go +++ b/internal/orchestrator/restore_coverage_extra_test.go @@ -209,7 +209,7 @@ func TestRunFullRestore_ExtractsArchiveToDestination(t *testing.T) { } reader := bufio.NewReader(strings.NewReader("RESTORE\n")) - cand := &decryptCandidate{ + cand := &backupCandidate{ DisplayBase: "test", Manifest: &backup.Manifest{CreatedAt: time.Now()}, } diff --git a/internal/orchestrator/restore_errors_test.go b/internal/orchestrator/restore_errors_test.go index f8d7fb1..313cfa9 100644 --- a/internal/orchestrator/restore_errors_test.go +++ b/internal/orchestrator/restore_errors_test.go @@ -1266,7 +1266,7 @@ func TestRunSafeClusterApply_NoVMConfigs(t *testing.T) { // -------------------------------------------------------------------------- func TestConfirmRestoreAction_InvalidInput(t *testing.T) { - cand := &decryptCandidate{ + cand := &backupCandidate{ DisplayBase: "test-backup", Manifest: &backup.Manifest{CreatedAt: time.Now()}, } @@ -1281,7 +1281,7 @@ func TestConfirmRestoreAction_InvalidInput(t *testing.T) { } func TestConfirmRestoreAction_Cancel(t *testing.T) { - cand := &decryptCandidate{ + cand := &backupCandidate{ DisplayBase: "test-backup", Manifest: &backup.Manifest{CreatedAt: time.Now()}, } @@ -1463,7 +1463,7 @@ func TestRunFullRestore_ExtractError(t *testing.T) { restoreFS = fakeFS - cand := &decryptCandidate{ + cand := &backupCandidate{ DisplayBase: "test-backup", Manifest: &backup.Manifest{CreatedAt: time.Now()}, } diff --git a/internal/orchestrator/restore_workflow_abort_test.go b/internal/orchestrator/restore_workflow_abort_test.go index 9dc5d31..032330a 100644 --- a/internal/orchestrator/restore_workflow_abort_test.go +++ b/internal/orchestrator/restore_workflow_abort_test.go @@ -72,8 +72,8 @@ func TestRunRestoreWorkflow_FstabPromptInputAborted_AbortsWorkflow(t *testing.T) t.Fatalf("fakeFS.WriteFile(/bundle.tar): %v", err) } - prepareRestoreBundleFunc = func(ctx context.Context, cfg *config.Config, logger *logging.Logger, version string, ui RestoreWorkflowUI) (*decryptCandidate, *preparedBundle, error) { - cand := &decryptCandidate{ + prepareRestoreBundleFunc = func(ctx context.Context, cfg *config.Config, logger *logging.Logger, version string, ui RestoreWorkflowUI) (*backupCandidate, *preparedBundle, error) { + cand := &backupCandidate{ DisplayBase: "test", Manifest: &backup.Manifest{ CreatedAt: fakeNow.Now(), diff --git a/internal/orchestrator/restore_workflow_decision_test.go b/internal/orchestrator/restore_workflow_decision_test.go index 9f07c80..efa2f33 100644 --- a/internal/orchestrator/restore_workflow_decision_test.go +++ b/internal/orchestrator/restore_workflow_decision_test.go @@ -14,9 +14,9 @@ import ( "github.com/tis24dev/proxsave/internal/types" ) -func stubPreparedRestoreBundle(archivePath string, manifest *backup.Manifest) func(context.Context, *config.Config, *logging.Logger, string, RestoreWorkflowUI) (*decryptCandidate, *preparedBundle, error) { - return func(ctx context.Context, cfg *config.Config, logger *logging.Logger, version string, ui RestoreWorkflowUI) (*decryptCandidate, *preparedBundle, error) { - return &decryptCandidate{ +func stubPreparedRestoreBundle(archivePath string, manifest *backup.Manifest) func(context.Context, *config.Config, *logging.Logger, string, RestoreWorkflowUI) (*backupCandidate, *preparedBundle, error) { + return func(ctx context.Context, cfg *config.Config, logger *logging.Logger, version string, ui RestoreWorkflowUI) (*backupCandidate, *preparedBundle, error) { + return &backupCandidate{ DisplayBase: "test", Manifest: manifest, }, &preparedBundle{ diff --git a/internal/orchestrator/restore_workflow_errors_test.go b/internal/orchestrator/restore_workflow_errors_test.go index f43aa9f..ab91b8f 100644 --- a/internal/orchestrator/restore_workflow_errors_test.go +++ b/internal/orchestrator/restore_workflow_errors_test.go @@ -12,7 +12,7 @@ import ( func TestConfirmRestoreAction_Abort(t *testing.T) { reader := bufio.NewReader(strings.NewReader("0\n")) - cand := &decryptCandidate{ + cand := &backupCandidate{ DisplayBase: "test", Manifest: &backup.Manifest{CreatedAt: time.Now()}, } @@ -25,7 +25,7 @@ func TestConfirmRestoreAction_Abort(t *testing.T) { func TestConfirmRestoreAction_Proceed(t *testing.T) { reader := bufio.NewReader(strings.NewReader("RESTORE\n")) - cand := &decryptCandidate{ + cand := &backupCandidate{ DisplayBase: "test", Manifest: &backup.Manifest{CreatedAt: time.Now()}, } diff --git a/internal/orchestrator/restore_workflow_more_test.go b/internal/orchestrator/restore_workflow_more_test.go index e60eb56..dde8e1d 100644 --- a/internal/orchestrator/restore_workflow_more_test.go +++ b/internal/orchestrator/restore_workflow_more_test.go @@ -81,8 +81,8 @@ func TestRunRestoreWorkflow_ClusterBackupSafeMode_ExportsClusterAndRestoresNetwo t.Fatalf("fakeFS.WriteFile: %v", err) } - prepareRestoreBundleFunc = func(ctx context.Context, cfg *config.Config, logger *logging.Logger, version string, ui RestoreWorkflowUI) (*decryptCandidate, *preparedBundle, error) { - cand := &decryptCandidate{ + prepareRestoreBundleFunc = func(ctx context.Context, cfg *config.Config, logger *logging.Logger, version string, ui RestoreWorkflowUI) (*backupCandidate, *preparedBundle, error) { + cand := &backupCandidate{ DisplayBase: "test", Manifest: &backup.Manifest{ CreatedAt: fakeNow.Now(), @@ -206,8 +206,8 @@ func TestRunRestoreWorkflow_PBSStopsServicesAndChecksZFSWhenSelected(t *testing. t.Fatalf("fakeFS.WriteFile: %v", err) } - prepareRestoreBundleFunc = func(ctx context.Context, cfg *config.Config, logger *logging.Logger, version string, ui RestoreWorkflowUI) (*decryptCandidate, *preparedBundle, error) { - cand := &decryptCandidate{ + prepareRestoreBundleFunc = func(ctx context.Context, cfg *config.Config, logger *logging.Logger, version string, ui RestoreWorkflowUI) (*backupCandidate, *preparedBundle, error) { + cand := &backupCandidate{ DisplayBase: "test", Manifest: &backup.Manifest{ CreatedAt: fakeNow.Now(), @@ -334,8 +334,8 @@ func TestRunRestoreWorkflow_IncompatibilityAndSafetyBackupFailureCanContinue(t * t.Fatalf("restoreSandbox.WriteFile: %v", err) } - prepareRestoreBundleFunc = func(ctx context.Context, cfg *config.Config, logger *logging.Logger, version string, ui RestoreWorkflowUI) (*decryptCandidate, *preparedBundle, error) { - cand := &decryptCandidate{ + prepareRestoreBundleFunc = func(ctx context.Context, cfg *config.Config, logger *logging.Logger, version string, ui RestoreWorkflowUI) (*backupCandidate, *preparedBundle, error) { + cand := &backupCandidate{ DisplayBase: "test", Manifest: &backup.Manifest{ CreatedAt: fakeNow.Now(), @@ -438,8 +438,8 @@ func TestRunRestoreWorkflow_ClusterRecoveryModeStopsAndRestartsServices(t *testi t.Fatalf("fakeFS.WriteFile: %v", err) } - prepareRestoreBundleFunc = func(ctx context.Context, cfg *config.Config, logger *logging.Logger, version string, ui RestoreWorkflowUI) (*decryptCandidate, *preparedBundle, error) { - cand := &decryptCandidate{ + prepareRestoreBundleFunc = func(ctx context.Context, cfg *config.Config, logger *logging.Logger, version string, ui RestoreWorkflowUI) (*backupCandidate, *preparedBundle, error) { + cand := &backupCandidate{ DisplayBase: "test", Manifest: &backup.Manifest{ CreatedAt: fakeNow.Now(), diff --git a/internal/orchestrator/restore_workflow_test.go b/internal/orchestrator/restore_workflow_test.go index e6d836f..e22bbbf 100644 --- a/internal/orchestrator/restore_workflow_test.go +++ b/internal/orchestrator/restore_workflow_test.go @@ -73,8 +73,8 @@ func TestRunRestoreWorkflow_CustomModeNoCategories_Succeeds(t *testing.T) { tmp := t.TempDir() archivePath := writeMinimalTar(t, tmp) - prepareRestoreBundleFunc = func(ctx context.Context, cfg *config.Config, logger *logging.Logger, version string, ui RestoreWorkflowUI) (*decryptCandidate, *preparedBundle, error) { - cand := &decryptCandidate{ + prepareRestoreBundleFunc = func(ctx context.Context, cfg *config.Config, logger *logging.Logger, version string, ui RestoreWorkflowUI) (*backupCandidate, *preparedBundle, error) { + cand := &backupCandidate{ DisplayBase: "test", Manifest: &backup.Manifest{ CreatedAt: time.Unix(1700000000, 0), @@ -124,8 +124,8 @@ func TestRunRestoreWorkflow_ConfirmFalseAborts(t *testing.T) { tmp := t.TempDir() archivePath := writeMinimalTar(t, tmp) - prepareRestoreBundleFunc = func(ctx context.Context, cfg *config.Config, logger *logging.Logger, version string, ui RestoreWorkflowUI) (*decryptCandidate, *preparedBundle, error) { - cand := &decryptCandidate{ + prepareRestoreBundleFunc = func(ctx context.Context, cfg *config.Config, logger *logging.Logger, version string, ui RestoreWorkflowUI) (*backupCandidate, *preparedBundle, error) { + cand := &backupCandidate{ DisplayBase: "test", Manifest: &backup.Manifest{ CreatedAt: time.Unix(1700000000, 0), diff --git a/internal/orchestrator/restore_workflow_ui.go b/internal/orchestrator/restore_workflow_ui.go index 48e5ab5..a789de5 100644 --- a/internal/orchestrator/restore_workflow_ui.go +++ b/internal/orchestrator/restore_workflow_ui.go @@ -32,7 +32,7 @@ func fallbackRestoreDecisionInfoFromManifest(manifest *backup.Manifest) *Restore return info } -func prepareRestoreBundleWithUI(ctx context.Context, cfg *config.Config, logger *logging.Logger, version string, ui RestoreWorkflowUI) (*decryptCandidate, *preparedBundle, error) { +func prepareRestoreBundleWithUI(ctx context.Context, cfg *config.Config, logger *logging.Logger, version string, ui RestoreWorkflowUI) (*backupCandidate, *preparedBundle, error) { candidate, err := selectBackupCandidateWithUI(ctx, ui, cfg, logger, false) if err != nil { return nil, nil, err @@ -918,7 +918,7 @@ func dedupeCategoriesByID(categories []Category) []Category { return out } -func runFullRestoreWithUI(ctx context.Context, ui RestoreWorkflowUI, candidate *decryptCandidate, prepared *preparedBundle, destRoot string, logger *logging.Logger, dryRun bool) error { +func runFullRestoreWithUI(ctx context.Context, ui RestoreWorkflowUI, candidate *backupCandidate, prepared *preparedBundle, destRoot string, logger *logging.Logger, dryRun bool) error { if candidate == nil || prepared == nil || prepared.Manifest.ArchivePath == "" { return fmt.Errorf("invalid restore candidate") } diff --git a/internal/orchestrator/restore_workflow_ui_helpers_test.go b/internal/orchestrator/restore_workflow_ui_helpers_test.go index ca1a8fe..a3fa5dd 100644 --- a/internal/orchestrator/restore_workflow_ui_helpers_test.go +++ b/internal/orchestrator/restore_workflow_ui_helpers_test.go @@ -56,7 +56,7 @@ func (f *fakeRestoreWorkflowUI) SelectBackupSource(ctx context.Context, options return decryptPathOption{}, fmt.Errorf("unexpected SelectBackupSource call") } -func (f *fakeRestoreWorkflowUI) SelectBackupCandidate(ctx context.Context, candidates []*decryptCandidate) (*decryptCandidate, error) { +func (f *fakeRestoreWorkflowUI) SelectBackupCandidate(ctx context.Context, candidates []*backupCandidate) (*backupCandidate, error) { return nil, fmt.Errorf("unexpected SelectBackupCandidate call") } diff --git a/internal/orchestrator/restore_workflow_warnings_test.go b/internal/orchestrator/restore_workflow_warnings_test.go index 3da4b6b..154cbaf 100644 --- a/internal/orchestrator/restore_workflow_warnings_test.go +++ b/internal/orchestrator/restore_workflow_warnings_test.go @@ -89,8 +89,8 @@ func TestRunRestoreWorkflow_FstabMergeFails_ContinuesWithWarnings(t *testing.T) t.Fatalf("fakeFS.WriteFile(/bundle.tar): %v", err) } - prepareRestoreBundleFunc = func(ctx context.Context, cfg *config.Config, logger *logging.Logger, version string, ui RestoreWorkflowUI) (*decryptCandidate, *preparedBundle, error) { - cand := &decryptCandidate{ + prepareRestoreBundleFunc = func(ctx context.Context, cfg *config.Config, logger *logging.Logger, version string, ui RestoreWorkflowUI) (*backupCandidate, *preparedBundle, error) { + cand := &backupCandidate{ DisplayBase: "test", Manifest: &backup.Manifest{ CreatedAt: fakeNow.Now(), diff --git a/internal/orchestrator/workflow_ui.go b/internal/orchestrator/workflow_ui.go index 03db940..471ec90 100644 --- a/internal/orchestrator/workflow_ui.go +++ b/internal/orchestrator/workflow_ui.go @@ -29,7 +29,7 @@ type BackupSelectionUI interface { ShowMessage(ctx context.Context, title, message string) error ShowError(ctx context.Context, title, message string) error SelectBackupSource(ctx context.Context, options []decryptPathOption) (decryptPathOption, error) - SelectBackupCandidate(ctx context.Context, candidates []*decryptCandidate) (*decryptCandidate, error) + SelectBackupCandidate(ctx context.Context, candidates []*backupCandidate) (*backupCandidate, error) } // DecryptWorkflowUI groups prompts used by the decrypt workflow. diff --git a/internal/orchestrator/workflow_ui_cli.go b/internal/orchestrator/workflow_ui_cli.go index 9c069ec..836f305 100644 --- a/internal/orchestrator/workflow_ui_cli.go +++ b/internal/orchestrator/workflow_ui_cli.go @@ -81,7 +81,7 @@ func (u *cliWorkflowUI) SelectBackupSource(ctx context.Context, options []decryp return promptPathSelection(ctx, u.reader, options) } -func (u *cliWorkflowUI) SelectBackupCandidate(ctx context.Context, candidates []*decryptCandidate) (*decryptCandidate, error) { +func (u *cliWorkflowUI) SelectBackupCandidate(ctx context.Context, candidates []*backupCandidate) (*backupCandidate, error) { return promptCandidateSelection(ctx, u.reader, candidates) } diff --git a/internal/orchestrator/workflow_ui_tui_decrypt.go b/internal/orchestrator/workflow_ui_tui_decrypt.go index cae0c3f..531571d 100644 --- a/internal/orchestrator/workflow_ui_tui_decrypt.go +++ b/internal/orchestrator/workflow_ui_tui_decrypt.go @@ -209,10 +209,10 @@ func (u *tuiWorkflowUI) SelectBackupSource(ctx context.Context, options []decryp return selected, nil } -func (u *tuiWorkflowUI) SelectBackupCandidate(ctx context.Context, candidates []*decryptCandidate) (*decryptCandidate, error) { +func (u *tuiWorkflowUI) SelectBackupCandidate(ctx context.Context, candidates []*backupCandidate) (*backupCandidate, error) { app := newTUIApp() var ( - selected *decryptCandidate + selected *backupCandidate aborted bool ) @@ -223,76 +223,54 @@ func (u *tuiWorkflowUI) SelectBackupCandidate(ctx context.Context, candidates [] type row struct { created string + hostname string mode string tool string - targets string + target string compression string } rows := make([]row, len(candidates)) - var maxMode, maxTool, maxTargets, maxComp int + var maxHost, maxMode, maxTool, maxTarget, maxComp int for idx, cand := range candidates { - created := "" - if cand != nil && cand.Manifest != nil { - created = cand.Manifest.CreatedAt.Format("2006-01-02 15:04:05") - } - - mode := strings.ToUpper(statusFromManifest(cand.Manifest)) - if mode == "" { - mode = "UNKNOWN" - } - - toolVersion := "unknown" - if cand != nil && cand.Manifest != nil { - if v := strings.TrimSpace(cand.Manifest.ScriptVersion); v != "" { - toolVersion = v - } - } - tool := "Tool " + toolVersion - - targets := "Targets: unknown" - if cand != nil && cand.Manifest != nil { - targets = buildTargetInfo(cand.Manifest) - } - - comp := "" - if cand != nil && cand.Manifest != nil { - if c := strings.TrimSpace(cand.Manifest.CompressionType); c != "" { - comp = strings.ToUpper(c) - } - } + display := describeBackupCandidate(cand) rows[idx] = row{ - created: created, - mode: mode, - tool: tool, - targets: targets, - compression: comp, + created: display.Created, + hostname: display.Hostname, + mode: display.Mode, + tool: display.Tool, + target: display.Target, + compression: display.Compression, } - if len(mode) > maxMode { - maxMode = len(mode) + if len(display.Hostname) > maxHost { + maxHost = len(display.Hostname) + } + if len(display.Mode) > maxMode { + maxMode = len(display.Mode) } - if len(tool) > maxTool { - maxTool = len(tool) + if len(display.Tool) > maxTool { + maxTool = len(display.Tool) } - if len(targets) > maxTargets { - maxTargets = len(targets) + if len(display.Target) > maxTarget { + maxTarget = len(display.Target) } - if len(comp) > maxComp { - maxComp = len(comp) + if len(display.Compression) > maxComp { + maxComp = len(display.Compression) } } for idx, r := range rows { line := fmt.Sprintf( - "%2d) %s %-*s %-*s %-*s", + "%2d) %s %-*s %-*s %-*s %-*s", idx+1, r.created, + maxHost, r.hostname, maxMode, r.mode, maxTool, r.tool, - maxTargets, r.targets, + maxTarget, r.target, ) if maxComp > 0 { line = fmt.Sprintf("%s %-*s", line, maxComp, r.compression) @@ -407,31 +385,6 @@ func (u *tuiWorkflowUI) PromptDecryptSecret(ctx context.Context, displayName, pr return tuiPromptDecryptSecret(ctx, u.screenEnv(), displayName, previousError) } -func backupSummaryForUI(cand *decryptCandidate) string { - if cand == nil { - return "" - } - - base := strings.TrimSpace(cand.DisplayBase) - if base == "" { - switch { - case strings.TrimSpace(cand.BundlePath) != "": - base = filepath.Base(strings.TrimSpace(cand.BundlePath)) - case strings.TrimSpace(cand.RawArchivePath) != "": - base = filepath.Base(strings.TrimSpace(cand.RawArchivePath)) - } - } - - created := "" - if cand.Manifest != nil { - created = cand.Manifest.CreatedAt.Format("2006-01-02 15:04:05") - } - - if base == "" { - return created - } - if created == "" { - return base - } - return fmt.Sprintf("%s (%s)", base, created) +func backupSummaryForUI(cand *backupCandidate) string { + return describeBackupCandidate(cand).Summary }