Compare commits

...

36 Commits

Author SHA1 Message Date
4e09066aa5 v3.42.33: Add cenkalti/backoff for exponential backoff retry
All checks were successful
CI/CD / Test (push) Successful in 1m17s
CI/CD / Lint (push) Successful in 1m25s
CI/CD / Build & Release (push) Successful in 3m14s
- Exponential backoff retry for all cloud operations (S3, Azure, GCS)
- RetryConfig presets: Default (5x), Aggressive (10x), Quick (3x)
- Smart error classification: IsPermanentError, IsRetryableError
- Automatic file position reset on upload retry
- Retry logging with wait duration
- Multipart uploads use aggressive retry (more tolerance)
2026-01-14 16:19:40 +01:00
6a24ee39be v3.42.32: Add fatih/color for cross-platform terminal colors
All checks were successful
CI/CD / Test (push) Successful in 1m17s
CI/CD / Lint (push) Successful in 1m26s
CI/CD / Build & Release (push) Successful in 3m13s
- Windows-compatible colors via native console API
- Color helper functions: Success(), Error(), Warning(), Info()
- Text styling: Header(), Dim(), Bold(), Green(), Red(), Yellow(), Cyan()
- Logger CleanFormatter uses fatih/color instead of raw ANSI
- All progress indicators use colored [OK]/[FAIL] status
- Automatic color detection for non-TTY environments
2026-01-14 16:13:00 +01:00
dc6dfd8b2c v3.42.31: Add schollz/progressbar for visual progress display
All checks were successful
CI/CD / Test (push) Successful in 1m16s
CI/CD / Lint (push) Successful in 1m26s
CI/CD / Build & Release (push) Successful in 3m13s
- Visual progress bars for cloud uploads/downloads
  - Byte transfer display, speed, ETA prediction
  - Color-coded Unicode block progress
- Checksum verification with progress bar for large files
- Spinner for indeterminate operations (unknown size)
- New types: NewSchollzBar(), NewSchollzBarItems(), NewSchollzSpinner()
- Progress Writer() method for io.Copy integration
2026-01-14 16:07:04 +01:00
7b4ab76313 v3.42.30: Add go-multierror for better error aggregation
All checks were successful
CI/CD / Test (push) Successful in 1m20s
CI/CD / Lint (push) Successful in 1m25s
CI/CD / Build & Release (push) Successful in 3m14s
- Use hashicorp/go-multierror for cluster restore error collection
- Shows ALL failed databases with full error context (not just count)
- Bullet-pointed output for readability
- Thread-safe error aggregation with dedicated mutex
- Error wrapping with %w for proper error chain preservation
2026-01-14 15:59:12 +01:00
c0d92b3a81 fix: update go.sum for gopsutil Windows dependencies
All checks were successful
CI/CD / Test (push) Successful in 1m15s
CI/CD / Lint (push) Successful in 1m25s
CI/CD / Build & Release (push) Successful in 3m13s
2026-01-14 15:50:13 +01:00
8c85d85249 refactor: use gopsutil and go-humanize for preflight checks
Some checks failed
CI/CD / Build & Release (push) Has been cancelled
CI/CD / Lint (push) Has been cancelled
CI/CD / Test (push) Has been cancelled
- Added gopsutil/v3 for cross-platform system metrics
  * Works on Linux, macOS, Windows, BSD
  * Memory detection no longer requires /proc parsing

- Added go-humanize for readable output
  * humanize.Bytes() for memory sizes
  * humanize.Comma() for large numbers

- Improved preflight display with memory usage percentage
- Linux kernel checks (shmmax/shmall) still use /proc for accuracy
2026-01-14 15:47:31 +01:00
e0cdcb28be feat: comprehensive preflight checks for cluster restore
All checks were successful
CI/CD / Test (push) Successful in 1m17s
CI/CD / Lint (push) Successful in 1m26s
CI/CD / Build & Release (push) Successful in 3m17s
- Linux system checks (read-only from /proc, no auth needed):
  * shmmax, shmall kernel limits
  * Available RAM check

- PostgreSQL auto-tuning:
  * max_locks_per_transaction scaled by BLOB count
  * maintenance_work_mem boosted to 2GB for faster indexes
  * All settings auto-reset after restore (even on failure)

- Archive analysis:
  * Count BLOBs per database (pg_restore -l or zgrep)
  * Scale lock boost: 2048 (default) → 4096/8192/16384 based on count

- Nice TUI preflight summary display with ✓/⚠ indicators
2026-01-14 15:30:41 +01:00
22a7b9e81e feat: auto-tune max_locks_per_transaction for cluster restore
All checks were successful
CI/CD / Test (push) Successful in 1m18s
CI/CD / Lint (push) Successful in 1m26s
CI/CD / Build & Release (push) Successful in 3m15s
- Automatically boost max_locks_per_transaction to 2048 before restore
- Uses ALTER SYSTEM + pg_reload_conf() - no restart needed
- Automatically resets to original value after restore (even on failure)
- Prevents 'out of shared memory' OOM on BLOB-heavy SQL format dumps
- Works transparently - no user intervention required
2026-01-14 15:05:42 +01:00
c71889be47 fix: phased restore for BLOB databases to prevent lock exhaustion OOM
All checks were successful
CI/CD / Test (push) Successful in 1m16s
CI/CD / Lint (push) Successful in 1m25s
CI/CD / Build & Release (push) Successful in 3m13s
- Auto-detect large objects in pg_restore dumps
- Split restore into pre-data, data, post-data phases
- Each phase commits and releases locks before next
- Prevents 'out of shared memory' / max_locks_per_transaction errors
- Updated error hints with better guidance for lock exhaustion
2026-01-14 08:15:53 +01:00
222bdbef58 fix: streaming tar verification for large cluster archives (100GB+)
All checks were successful
CI/CD / Test (push) Successful in 1m17s
CI/CD / Lint (push) Successful in 1m26s
CI/CD / Build & Release (push) Successful in 3m14s
- Increase timeout from 60 to 180 minutes for very large archives
- Use streaming pipes instead of buffering entire tar listing
- Only mark as corrupted for clear corruption signals (unexpected EOF, invalid gzip)
- Prevents false CORRUPTED errors on valid large archives
2026-01-13 14:40:18 +01:00
f7e9fa64f0 docs: add Large Database Support (600+ GB) section to PITR guide
All checks were successful
CI/CD / Test (push) Successful in 1m13s
CI/CD / Lint (push) Successful in 1m22s
CI/CD / Build & Release (push) Has been skipped
2026-01-13 10:02:35 +01:00
f153e61dbf fix: dynamic timeouts for large archives + use WorkDir for disk checks
All checks were successful
CI/CD / Test (push) Successful in 1m21s
CI/CD / Lint (push) Successful in 1m34s
CI/CD / Build & Release (push) Successful in 3m22s
- CheckDiskSpace now uses GetEffectiveWorkDir() instead of BackupDir
- Dynamic timeout calculation based on file size:
  - diagnoseClusterArchive: 5 + (GB/3) min, max 60 min
  - verifyWithPgRestore: 5 + (GB/5) min, max 30 min
  - DiagnoseClusterDumps: 10 + (GB/3) min, max 120 min
  - TUI safety checks: 10 + (GB/5) min, max 120 min
- Timeout vs corruption differentiation (no false CORRUPTED on timeout)
- Streaming tar listing to avoid OOM on large archives

For 119GB archives: ~45 min timeout instead of 5 min false-positive
2026-01-13 08:22:20 +01:00
d19c065658 Remove dev artifacts and internal docs
All checks were successful
CI/CD / Test (push) Successful in 1m14s
CI/CD / Lint (push) Successful in 1m22s
CI/CD / Build & Release (push) Successful in 3m9s
- dbbackup, dbbackup_cgo (dev binaries, use bin/ for releases)
- CRITICAL_BUGS_FIXED.md (internal post-mortem)
- scripts/remove_*.sh (one-time cleanup scripts)
2026-01-12 11:14:55 +01:00
8dac5efc10 Remove EMOTICON_REMOVAL_PLAN.md
Some checks failed
CI/CD / Test (push) Successful in 1m19s
CI/CD / Build & Release (push) Has been cancelled
CI/CD / Lint (push) Has been cancelled
2026-01-12 11:12:17 +01:00
fd5edce5ae Fix license: Apache 2.0 not MIT
All checks were successful
CI/CD / Test (push) Successful in 1m18s
CI/CD / Lint (push) Successful in 1m28s
CI/CD / Build & Release (push) Has been skipped
2026-01-12 10:57:55 +01:00
a7e2c86618 Replace VEEAM_ALTERNATIVE with OPENSOURCE_ALTERNATIVE - covers both commercial (Veeam) and open source (Borg/restic) alternatives
All checks were successful
CI/CD / Test (push) Successful in 1m16s
CI/CD / Lint (push) Successful in 1m29s
CI/CD / Build & Release (push) Has been skipped
2026-01-12 10:43:15 +01:00
b2e0c739e0 Fix golangci-lint v2 config format
All checks were successful
CI/CD / Test (push) Successful in 1m20s
CI/CD / Lint (push) Successful in 1m27s
CI/CD / Build & Release (push) Successful in 3m22s
2026-01-12 10:32:27 +01:00
ad23abdf4e Add version field to golangci-lint config for v2
Some checks failed
CI/CD / Test (push) Successful in 1m18s
CI/CD / Lint (push) Failing after 1m41s
CI/CD / Build & Release (push) Has been skipped
2026-01-12 10:26:36 +01:00
390b830976 Fix golangci-lint v2 module path
Some checks failed
CI/CD / Test (push) Successful in 1m17s
CI/CD / Lint (push) Failing after 28s
CI/CD / Build & Release (push) Has been skipped
2026-01-12 10:20:47 +01:00
7e53950967 Update golangci-lint to v2.8.0 for Go 1.24 compatibility
Some checks failed
CI/CD / Test (push) Successful in 1m16s
CI/CD / Lint (push) Failing after 8s
CI/CD / Build & Release (push) Has been skipped
2026-01-12 10:13:33 +01:00
59d2094241 Build all platforms v3.42.22
Some checks failed
CI/CD / Test (push) Successful in 1m16s
CI/CD / Lint (push) Failing after 1m22s
CI/CD / Build & Release (push) Has been skipped
2026-01-12 09:54:35 +01:00
b1f8c6d646 fix: correct Grafana dashboard metric names for backup size and duration panels
All checks were successful
CI/CD / Test (push) Successful in 1m17s
CI/CD / Lint (push) Successful in 1m26s
CI/CD / Build & Release (push) Successful in 3m16s
2026-01-09 09:15:16 +01:00
b05c2be19d Add corrected Grafana dashboard - fix status query
All checks were successful
CI/CD / Test (push) Successful in 1m14s
CI/CD / Lint (push) Successful in 1m21s
CI/CD / Build & Release (push) Successful in 3m13s
- Changed status query from dbbackup_backup_verified to RPO-based check
- dbbackup_rpo_seconds < 86400 returns SUCCESS when backup < 24h old
- Fixes false FAILED status when verify operations not run
- Includes: status, RPO, backup size, duration, and overview table panels
2026-01-08 12:27:23 +01:00
ec33959e3e v3.42.18: Unify archive verification - backup manager uses same checks as restore
All checks were successful
CI/CD / Test (push) Successful in 1m13s
CI/CD / Lint (push) Successful in 1m22s
CI/CD / Build & Release (push) Successful in 3m12s
- verifyArchiveCmd now uses restore.Safety and restore.Diagnoser
- Same validation logic in backup manager verify and restore safety checks
- No more discrepancy between verify showing valid and restore failing
2026-01-08 12:10:45 +01:00
92402f0fdb v3.42.17: Fix systemd service templates - remove invalid --config flag
All checks were successful
CI/CD / Test (push) Successful in 1m15s
CI/CD / Lint (push) Successful in 1m21s
CI/CD / Build & Release (push) Successful in 3m12s
- Service templates now use WorkingDirectory for config loading
- Config is read from .dbbackup.conf in /var/lib/dbbackup
- Updated SYSTEMD.md documentation to match actual CLI
- Removed non-existent --config flag from ExecStart
2026-01-08 11:57:16 +01:00
682510d1bc v3.42.16: TUI cleanup - remove STATUS box, add global styles
All checks were successful
CI/CD / Test (push) Successful in 1m19s
CI/CD / Lint (push) Successful in 1m24s
CI/CD / Build & Release (push) Successful in 3m19s
2026-01-08 11:17:46 +01:00
83ad62b6b5 v3.42.15: TUI - always allow Esc/Cancel during spinner operations
All checks were successful
CI/CD / Test (push) Successful in 1m13s
CI/CD / Lint (push) Successful in 1m20s
CI/CD / Build & Release (push) Successful in 3m7s
2026-01-08 10:53:00 +01:00
55d34be32e v3.42.14: TUI Backup Manager - status box with spinner, real verify function
All checks were successful
CI/CD / Test (push) Successful in 1m13s
CI/CD / Lint (push) Successful in 1m21s
CI/CD / Build & Release (push) Successful in 3m6s
2026-01-08 10:35:23 +01:00
1831bd7c1f v3.42.13: TUI improvements - grouped shortcuts, box layout, better alignment
All checks were successful
CI/CD / Test (push) Successful in 1m14s
CI/CD / Lint (push) Successful in 1m22s
CI/CD / Build & Release (push) Successful in 3m9s
2026-01-08 10:16:19 +01:00
24377eab8f v3.42.12: Require cleanup confirmation for cluster restore with existing DBs
All checks were successful
CI/CD / Test (push) Successful in 1m14s
CI/CD / Lint (push) Successful in 1m21s
CI/CD / Build & Release (push) Successful in 3m10s
- Block cluster restore if existing databases found and cleanup not enabled
- User must press 'c' to enable 'Clean All First' before proceeding
- Prevents accidental data conflicts during disaster recovery
- Bug #24: Missing safety gate for cluster restore
2026-01-08 09:46:53 +01:00
3e41d88445 v3.42.11: Replace all Unicode emojis with ASCII text
All checks were successful
CI/CD / Test (push) Successful in 1m13s
CI/CD / Lint (push) Successful in 1m20s
CI/CD / Build & Release (push) Successful in 3m10s
- Replace all emoji characters with ASCII equivalents throughout codebase
- Replace Unicode box-drawing characters (═║╔╗╚╝━─) with ASCII (+|-=)
- Replace checkmarks (✓✗) with [OK]/[FAIL] markers
- 59 files updated, 741 lines changed
- Improves terminal compatibility and reduces visual noise
2026-01-08 09:42:01 +01:00
5fb88b14ba Add legal documentation to gitignore
All checks were successful
CI/CD / Test (push) Successful in 1m14s
CI/CD / Lint (push) Successful in 1m20s
CI/CD / Build & Release (push) Has been skipped
2026-01-08 06:19:08 +01:00
cccee4294f Remove internal bug documentation from public repo
Some checks failed
CI/CD / Lint (push) Has been cancelled
CI/CD / Build & Release (push) Has been cancelled
CI/CD / Test (push) Has been cancelled
2026-01-08 06:18:20 +01:00
9688143176 Add detailed bug report for legal documentation
Some checks failed
CI/CD / Test (push) Successful in 1m14s
CI/CD / Build & Release (push) Has been cancelled
CI/CD / Lint (push) Has been cancelled
2026-01-08 06:16:49 +01:00
e821e131b4 Fix build script to read version from main.go
All checks were successful
CI/CD / Test (push) Successful in 1m14s
CI/CD / Lint (push) Successful in 1m21s
CI/CD / Build & Release (push) Has been skipped
2026-01-08 06:13:25 +01:00
15a60d2e71 v3.42.10: Code quality fixes
All checks were successful
CI/CD / Test (push) Successful in 1m14s
CI/CD / Lint (push) Successful in 1m22s
CI/CD / Build & Release (push) Successful in 3m12s
- Remove deprecated io/ioutil
- Fix os.DirEntry.ModTime() usage
- Remove unused fields and variables
- Fix ineffective assignments
- Fix error string formatting
2026-01-08 06:05:25 +01:00
90 changed files with 5921 additions and 1635 deletions

View File

@@ -56,7 +56,7 @@ jobs:
- name: Install and run golangci-lint - name: Install and run golangci-lint
run: | run: |
go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.62.2 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.8.0
golangci-lint run --timeout=5m ./... golangci-lint run --timeout=5m ./...
build-and-release: build-and-release:

4
.gitignore vendored
View File

@@ -34,3 +34,7 @@ coverage.html
# Ignore temporary files # Ignore temporary files
tmp/ tmp/
temp/ temp/
CRITICAL_BUGS_FIXED.md
LEGAL_DOCUMENTATION.md
LEGAL_*.md
legal/

View File

@@ -1,16 +1,16 @@
# golangci-lint configuration - relaxed for existing codebase # golangci-lint configuration - relaxed for existing codebase
version: "2"
run: run:
timeout: 5m timeout: 5m
tests: false
linters: linters:
disable-all: true default: none
enable: enable:
# Only essential linters that catch real bugs # Only essential linters that catch real bugs
- govet - govet
- ineffassign
linters-settings: settings:
govet: govet:
disable: disable:
- fieldalignment - fieldalignment

View File

@@ -5,6 +5,89 @@ All notable changes to dbbackup will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.42.33] - 2026-01-14 "Exponential Backoff Retry"
### Added - cenkalti/backoff for Cloud Operation Retry
- **Exponential backoff retry** for all cloud operations (S3, Azure, GCS)
- **Retry configurations**:
- `DefaultRetryConfig()` - 5 retries, 500ms→30s backoff, 5 min max
- `AggressiveRetryConfig()` - 10 retries, 1s→60s backoff, 15 min max
- `QuickRetryConfig()` - 3 retries, 100ms→5s backoff, 30s max
- **Smart error classification**:
- `IsPermanentError()` - Auth/bucket errors (no retry)
- `IsRetryableError()` - Timeout/network errors (retry)
- **Retry logging** - Each retry attempt is logged with wait duration
### Changed
- S3 simple upload, multipart upload, download now retry on transient failures
- Azure simple upload, download now retry on transient failures
- GCS upload, download now retry on transient failures
- Large file multipart uploads use `AggressiveRetryConfig()` (more retries)
## [3.42.32] - 2026-01-14 "Cross-Platform Colors"
### Added - fatih/color for Cross-Platform Terminal Colors
- **Windows-compatible colors** - Native Windows console API support
- **Color helper functions** in `logger` package:
- `Success()`, `Error()`, `Warning()`, `Info()` - Status messages with icons
- `Header()`, `Dim()`, `Bold()` - Text styling
- `Green()`, `Red()`, `Yellow()`, `Cyan()` - Colored text
- `StatusLine()`, `TableRow()` - Formatted output
- `DisableColors()`, `EnableColors()` - Runtime control
- **Consistent color scheme** across all log levels
### Changed
- Logger `CleanFormatter` now uses fatih/color instead of raw ANSI codes
- All progress indicators use fatih/color for `[OK]`/`[FAIL]` status
- Automatic color detection (disabled for non-TTY)
## [3.42.31] - 2026-01-14 "Visual Progress Bars"
### Added - schollz/progressbar for Enhanced Progress Display
- **Visual progress bars** for cloud uploads/downloads with:
- Byte transfer display (e.g., `245 MB / 1.2 GB`)
- Transfer speed (e.g., `45 MB/s`)
- ETA prediction
- Color-coded progress with Unicode blocks
- **Checksum verification progress** - visual progress while calculating SHA-256
- **Spinner for indeterminate operations** - Braille-style spinner when size unknown
- New progress types: `NewSchollzBar()`, `NewSchollzBarItems()`, `NewSchollzSpinner()`
- Progress bar `Writer()` method for io.Copy integration
### Changed
- Cloud download shows real-time byte progress instead of 10% log messages
- Cloud upload shows visual progress bar instead of debug logs
- Checksum verification shows progress for large files
## [3.42.30] - 2026-01-09 "Better Error Aggregation"
### Added - go-multierror for Cluster Restore Errors
- **Enhanced error reporting** - Now shows ALL database failures, not just a count
- Uses `hashicorp/go-multierror` for proper error aggregation
- Each failed database error is preserved with full context
- Bullet-pointed error output for readability:
```
cluster restore completed with 3 failures:
3 database(s) failed:
• db1: restore failed: max_locks_per_transaction exceeded
• db2: restore failed: connection refused
• db3: failed to create database: permission denied
```
### Changed
- Replaced string slice error collection with proper `*multierror.Error`
- Thread-safe error aggregation with dedicated mutex
- Improved error wrapping with `%w` for error chain preservation
## [3.42.10] - 2026-01-08 "Code Quality"
### Fixed - Code Quality Issues
- Removed deprecated `io/ioutil` usage (replaced with `os`)
- Fixed `os.DirEntry.ModTime()` → `file.Info().ModTime()`
- Removed unused fields and variables
- Fixed ineffective assignments in TUI code
- Fixed error strings (no capitalization, no trailing punctuation)
## [3.42.9] - 2026-01-08 "Diagnose Timeout Fix" ## [3.42.9] - 2026-01-08 "Diagnose Timeout Fix"
### Fixed - diagnose.go Timeout Bugs ### Fixed - diagnose.go Timeout Bugs

View File

@@ -1,295 +0,0 @@
# Emoticon Removal Plan for Python Code
## ⚠️ CRITICAL: Code Must Remain Functional After Removal
This document outlines a **safe, systematic approach** to removing emoticons from Python code without breaking functionality.
---
## 1. Identification Phase
### 1.1 Where Emoticons CAN Safely Exist (Safe to Remove)
| Location | Risk Level | Action |
|----------|------------|--------|
| Comments (`# 🎉 Success!`) | ✅ SAFE | Remove or replace with text |
| Docstrings (`"""📌 Note:..."""`) | ✅ SAFE | Remove or replace with text |
| Print statements for decoration (`print("✅ Done!")`) | ⚠️ LOW | Replace with ASCII or text |
| Logging messages (`logger.info("🔥 Starting...")`) | ⚠️ LOW | Replace with text equivalent |
### 1.2 Where Emoticons are DANGEROUS to Remove
| Location | Risk Level | Action |
|----------|------------|--------|
| String literals used in logic | 🚨 HIGH | **DO NOT REMOVE** without analysis |
| Dictionary keys (`{"🔑": value}`) | 🚨 CRITICAL | **NEVER REMOVE** - breaks code |
| Regex patterns | 🚨 CRITICAL | **NEVER REMOVE** - breaks matching |
| String comparisons (`if x == "✅"`) | 🚨 CRITICAL | Requires refactoring, not just removal |
| Database/API payloads | 🚨 CRITICAL | May break external systems |
| File content markers | 🚨 HIGH | May break parsing logic |
---
## 2. Pre-Removal Checklist
### 2.1 Before ANY Changes
- [ ] **Full backup** of the codebase
- [ ] **Run all tests** and record baseline results
- [ ] **Document all emoticon locations** with grep/search
- [ ] **Identify emoticon usage patterns** (decorative vs. functional)
### 2.2 Discovery Commands
```bash
# Find all files with emoticons (Unicode range for common emojis)
grep -rn --include="*.py" -P '[\x{1F300}-\x{1F9FF}]' .
# Find emoticons in strings
grep -rn --include="*.py" -E '["'"'"'][^"'"'"']*[\x{1F300}-\x{1F9FF}]' .
# List unique emoticons used
grep -oP '[\x{1F300}-\x{1F9FF}]' *.py | sort -u
```
---
## 3. Replacement Strategy
### 3.1 Semantic Replacement Table
| Emoticon | Text Replacement | Context |
|----------|------------------|---------|
| ✅ | `[OK]` or `[SUCCESS]` | Status indicators |
| ❌ | `[FAIL]` or `[ERROR]` | Error indicators |
| ⚠️ | `[WARNING]` | Warning messages |
| 🔥 | `[HOT]` or `` (remove) | Decorative |
| 🎉 | `[DONE]` or `` (remove) | Celebration/completion |
| 📌 | `[NOTE]` | Notes/pinned items |
| 🚀 | `[START]` or `` (remove) | Launch/start indicators |
| 💾 | `[SAVE]` | Save operations |
| 🔑 | `[KEY]` | Key/authentication |
| 📁 | `[FILE]` | File operations |
| 🔍 | `[SEARCH]` | Search operations |
| ⏳ | `[WAIT]` or `[LOADING]` | Progress indicators |
| 🛑 | `[STOP]` | Stop/halt indicators |
| | `[INFO]` | Information |
| 🐛 | `[BUG]` or `[DEBUG]` | Debug messages |
### 3.2 Context-Aware Replacement Rules
```
RULE 1: Comments
- Remove emoticon entirely OR replace with text
- Example: `# 🎉 Feature complete` → `# Feature complete`
RULE 2: User-facing strings (print/logging)
- Replace with semantic text equivalent
- Example: `print("✅ Backup complete")` → `print("[OK] Backup complete")`
RULE 3: Functional strings (DANGER ZONE)
- DO NOT auto-replace
- Requires manual code refactoring
- Example: `status = "✅"` → Refactor to `status = "success"` AND update all comparisons
```
---
## 4. Safe Removal Process
### Step 1: Audit
```python
# Python script to audit emoticon usage
import re
import ast
EMOJI_PATTERN = re.compile(
"["
"\U0001F300-\U0001F9FF" # Symbols & Pictographs
"\U00002600-\U000026FF" # Misc symbols
"\U00002700-\U000027BF" # Dingbats
"\U0001F600-\U0001F64F" # Emoticons
"]+"
)
def audit_file(filepath):
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
# Parse AST to understand context
tree = ast.parse(content)
findings = []
for lineno, line in enumerate(content.split('\n'), 1):
matches = EMOJI_PATTERN.findall(line)
if matches:
# Determine context (comment, string, etc.)
context = classify_context(line, matches)
findings.append({
'line': lineno,
'content': line.strip(),
'emojis': matches,
'context': context,
'risk': assess_risk(context)
})
return findings
def classify_context(line, matches):
stripped = line.strip()
if stripped.startswith('#'):
return 'COMMENT'
if 'print(' in line or 'logging.' in line or 'logger.' in line:
return 'OUTPUT'
if '==' in line or '!=' in line:
return 'COMPARISON'
if re.search(r'["\'][^"\']*$', line.split('#')[0]):
return 'STRING_LITERAL'
return 'UNKNOWN'
def assess_risk(context):
risk_map = {
'COMMENT': 'LOW',
'OUTPUT': 'LOW',
'COMPARISON': 'CRITICAL',
'STRING_LITERAL': 'HIGH',
'UNKNOWN': 'HIGH'
}
return risk_map.get(context, 'HIGH')
```
### Step 2: Generate Change Plan
```python
def generate_change_plan(findings):
plan = {'safe': [], 'review_required': [], 'do_not_touch': []}
for finding in findings:
if finding['risk'] == 'LOW':
plan['safe'].append(finding)
elif finding['risk'] == 'HIGH':
plan['review_required'].append(finding)
else: # CRITICAL
plan['do_not_touch'].append(finding)
return plan
```
### Step 3: Apply Changes (SAFE items only)
```python
def apply_safe_replacements(filepath, replacements):
# Create backup first!
import shutil
shutil.copy(filepath, filepath + '.backup')
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
for old, new in replacements:
content = content.replace(old, new)
with open(filepath, 'w', encoding='utf-8') as f:
f.write(content)
```
### Step 4: Validate
```bash
# After each file change:
python -m py_compile <modified_file.py> # Syntax check
pytest <related_tests> # Run tests
```
---
## 5. Validation Checklist
### After EACH File Modification
- [ ] File compiles without syntax errors (`python -m py_compile file.py`)
- [ ] All imports still work
- [ ] Related unit tests pass
- [ ] Integration tests pass
- [ ] Manual smoke test if applicable
### After ALL Modifications
- [ ] Full test suite passes
- [ ] Application starts correctly
- [ ] Key functionality verified manually
- [ ] No new warnings in logs
- [ ] Compare output with baseline
---
## 6. Rollback Plan
### If Something Breaks
1. **Immediate**: Restore from `.backup` files
2. **Git**: `git checkout -- <file>` or `git stash pop`
3. **Full rollback**: Restore from pre-change backup
### Keep Until Verified
```bash
# Backup storage structure
backups/
├── pre_emoticon_removal/
│ ├── timestamp.tar.gz
│ └── git_commit_hash.txt
└── individual_files/
├── file1.py.backup
└── file2.py.backup
```
---
## 7. Implementation Order
1. **Phase 1**: Comments only (LOWEST risk)
2. **Phase 2**: Docstrings (LOW risk)
3. **Phase 3**: Print/logging statements (LOW-MEDIUM risk)
4. **Phase 4**: Manual review items (HIGH risk) - one by one
5. **Phase 5**: NEVER touch CRITICAL items without full refactoring
---
## 8. Example Workflow
```bash
# 1. Create full backup
git stash && git checkout -b emoticon-removal
# 2. Run audit script
python emoticon_audit.py > audit_report.json
# 3. Review audit report
cat audit_report.json | jq '.do_not_touch' # Check critical items
# 4. Apply safe changes only
python apply_safe_changes.py --dry-run # Preview first!
python apply_safe_changes.py # Apply
# 5. Validate after each change
python -m pytest tests/
# 6. Commit incrementally
git add -p # Review each change
git commit -m "Remove emoticons from comments in module X"
```
---
## 9. DO NOT DO
**Never** use global find-replace on emoticons
**Never** remove emoticons from string comparisons without refactoring
**Never** change multiple files without testing between changes
**Never** assume an emoticon is decorative - verify context
**Never** proceed if tests fail after a change
---
## 10. Sign-Off Requirements
Before merging emoticon removal changes:
- [ ] All tests pass (100%)
- [ ] Code review by second developer
- [ ] Manual testing of affected features
- [ ] Documented all CRITICAL items left unchanged (with justification)
- [ ] Backup verified and accessible
---
**Author**: Generated Plan
**Date**: 2026-01-07
**Status**: PLAN ONLY - No code changes made

206
OPENSOURCE_ALTERNATIVE.md Normal file
View File

@@ -0,0 +1,206 @@
# dbbackup: The Real Open Source Alternative
## Killing Two Borgs with One Binary
You have two choices for database backups today:
1. **Pay $2,000-10,000/year per server** for Veeam, Commvault, or Veritas
2. **Wrestle with Borg/restic** - powerful, but never designed for databases
**dbbackup** eliminates both problems with a single, zero-dependency binary.
## The Problem with Commercial Backup
| What You Pay For | What You Actually Get |
|------------------|----------------------|
| $10,000/year | Heavy agents eating CPU |
| Complex licensing | Vendor lock-in to proprietary formats |
| "Enterprise support" | Recovery that requires calling support |
| "Cloud integration" | Upload to S3... eventually |
## The Problem with Borg/Restic
Great tools. Wrong use case.
| Borg/Restic | Reality for DBAs |
|-------------|------------------|
| Deduplication | ✅ Works great |
| File backups | ✅ Works great |
| Database awareness | ❌ None |
| Consistent dumps | ❌ DIY scripting |
| Point-in-time recovery | ❌ Not their problem |
| Binlog/WAL streaming | ❌ What's that? |
You end up writing wrapper scripts. Then more scripts. Then a monitoring layer. Then you've built half a product anyway.
## What Open Source Really Means
**dbbackup** delivers everything - in one binary:
| Feature | Veeam | Borg/Restic | dbbackup |
|---------|-------|-------------|----------|
| Deduplication | ❌ | ✅ | ✅ Native CDC |
| Database-aware | ✅ | ❌ | ✅ MySQL + PostgreSQL |
| Consistent snapshots | ✅ | ❌ | ✅ LVM/ZFS/Btrfs |
| PITR (Point-in-Time) | ❌ | ❌ | ✅ Sub-second RPO |
| Binlog/WAL streaming | ❌ | ❌ | ✅ Continuous |
| Direct cloud streaming | ❌ | ✅ | ✅ S3/GCS/Azure |
| Zero dependencies | ❌ | ❌ | ✅ Single binary |
| License cost | $$$$ | Free | **Free (Apache 2.0)** |
## Deduplication: We Killed the Borg
Content-defined chunking, just like Borg - but built for database dumps:
```bash
# First backup: 5MB stored
dbbackup dedup backup mydb.dump
# Second backup (modified): only 1.6KB new data!
# 100% deduplication ratio
dbbackup dedup backup mydb_modified.dump
```
### How It Works
- **Gear Hash CDC** - Content-defined chunking with 92%+ overlap detection
- **SHA-256 Content-Addressed** - Chunks stored by hash, automatic dedup
- **AES-256-GCM Encryption** - Per-chunk encryption
- **Gzip Compression** - Enabled by default
- **SQLite Index** - Fast lookups, portable metadata
### Storage Efficiency
| Scenario | Borg | dbbackup |
|----------|------|----------|
| Daily 10GB database | 10GB + ~2GB/day | 10GB + ~2GB/day |
| Same data, knows it's a DB | Scripts needed | **Native support** |
| Restore to point-in-time | ❌ | ✅ Built-in |
Same dedup math. Zero wrapper scripts.
## Enterprise Features, Zero Enterprise Pricing
### Physical Backups (MySQL 8.0.17+)
```bash
# Native Clone Plugin - no XtraBackup needed
dbbackup backup single mydb --db-type mysql --cloud s3://bucket/
```
### Filesystem Snapshots
```bash
# <100ms lock, instant snapshot, stream to cloud
dbbackup backup --engine=snapshot --snapshot-backend=lvm
```
### Continuous Binlog/WAL Streaming
```bash
# Real-time capture to S3 - sub-second RPO
dbbackup binlog stream --target=s3://bucket/binlogs/
```
### Parallel Cloud Upload
```bash
# Saturate your network, not your patience
dbbackup backup --engine=streaming --parallel-workers=8
```
## Real Numbers
**100GB MySQL database:**
| Metric | Veeam | Borg + Scripts | dbbackup |
|--------|-------|----------------|----------|
| Backup time | 45 min | 50 min | **12 min** |
| Local disk needed | 100GB | 100GB | **0 GB** |
| Recovery point | Daily | Daily | **< 1 second** |
| Setup time | Days | Hours | **Minutes** |
| Annual cost | $5,000+ | $0 + time | **$0** |
## Migration Path
### From Veeam
```bash
# Day 1: Test alongside existing
dbbackup backup single mydb --cloud s3://test-bucket/
# Week 1: Compare backup times, storage costs
# Week 2: Switch primary backups
# Month 1: Cancel renewal, buy your team pizza
```
### From Borg/Restic
```bash
# Day 1: Replace your wrapper scripts
dbbackup dedup backup /var/lib/mysql/dumps/mydb.sql
# Day 2: Add PITR
dbbackup binlog stream --target=/mnt/nfs/binlogs/
# Day 3: Delete 500 lines of bash
```
## The Commands You Need
```bash
# Deduplicated backups (Borg-style)
dbbackup dedup backup <file>
dbbackup dedup restore <id> <output>
dbbackup dedup stats
dbbackup dedup gc
# Database-native backups
dbbackup backup single <database>
dbbackup backup all
dbbackup restore <backup-file>
# Point-in-time recovery
dbbackup binlog stream
dbbackup pitr restore --target-time "2026-01-12 14:30:00"
# Cloud targets
--cloud s3://bucket/path/
--cloud gs://bucket/path/
--cloud azure://container/path/
```
## Who Should Switch
**From Veeam/Commvault**: Same capabilities, zero license fees
**From Borg/Restic**: Native database support, no wrapper scripts
**From "homegrown scripts"**: Production-ready, battle-tested
**Cloud-native deployments**: Kubernetes, ECS, Cloud Run ready
**Compliance requirements**: AES-256-GCM, audit logging
## Get Started
```bash
# Download (single binary, ~48MB static linked)
curl -LO https://github.com/PlusOne/dbbackup/releases/latest/download/dbbackup_linux_amd64
chmod +x dbbackup_linux_amd64
# Your first deduplicated backup
./dbbackup_linux_amd64 dedup backup /var/lib/mysql/dumps/production.sql
# Your first cloud backup
./dbbackup_linux_amd64 backup single production \
--db-type mysql \
--cloud s3://my-backups/
```
## The Bottom Line
| Solution | What It Costs You |
|----------|-------------------|
| Veeam | Money |
| Borg/Restic | Time (scripting, integration) |
| dbbackup | **Neither** |
**This is what open source really means.**
Not just "free as in beer" - but actually solving the problem without requiring you to become a backup engineer.
---
*Apache 2.0 Licensed. Free forever. No sales calls. No wrapper scripts.*
[GitHub](https://github.com/PlusOne/dbbackup) | [Releases](https://github.com/PlusOne/dbbackup/releases) | [Changelog](CHANGELOG.md)

94
PITR.md
View File

@@ -584,6 +584,100 @@ Document your recovery procedure:
9. Create new base backup 9. Create new base backup
``` ```
## Large Database Support (600+ GB)
For databases larger than 600 GB, PITR is the **recommended approach** over full dump/restore.
### Why PITR Works Better for Large DBs
| Approach | 600 GB Database | Recovery Time (RTO) |
|----------|-----------------|---------------------|
| Full pg_dump/restore | Hours to dump, hours to restore | 4-12+ hours |
| PITR (base + WAL) | Incremental WAL only | 30 min - 2 hours |
### Setup for Large Databases
**1. Enable WAL archiving with compression:**
```bash
dbbackup pitr enable --archive-dir /backups/wal_archive --compress
```
**2. Take ONE base backup weekly/monthly (use pg_basebackup):**
```bash
# For 600+ GB, use fast checkpoint to minimize impact
pg_basebackup -D /backups/base_$(date +%Y%m%d).tar.gz \
-Ft -z -P --checkpoint=fast --wal-method=none
# Duration: 2-6 hours for 600 GB, but only needed weekly/monthly
```
**3. WAL files archive continuously** (~1-5 GB/hour typical), capturing every change.
**4. Recover to any point in time:**
```bash
dbbackup restore pitr \
--base-backup /backups/base_20260101.tar.gz \
--wal-archive /backups/wal_archive \
--target-time "2026-01-13 14:30:00" \
--target-dir /var/lib/postgresql/16/restored
```
### PostgreSQL Optimizations for 600+ GB
| Setting | Value | Purpose |
|---------|-------|---------|
| `wal_compression = on` | postgresql.conf | 70-80% smaller WAL files |
| `max_wal_size = 4GB` | postgresql.conf | Reduce checkpoint frequency |
| `checkpoint_timeout = 30min` | postgresql.conf | Less frequent checkpoints |
| `archive_timeout = 300` | postgresql.conf | Force archive every 5 min |
### Recovery Optimizations
| Optimization | How | Benefit |
|--------------|-----|---------|
| Parallel recovery | PostgreSQL 15+ automatic | 2-4x faster WAL replay |
| NVMe/SSD for WAL | Hardware | 3-10x faster recovery |
| Separate WAL disk | Dedicated mount | Avoid I/O contention |
| `recovery_prefetch = on` | PostgreSQL 15+ | Faster page reads |
### Storage Planning
| Component | Size Estimate | Retention |
|-----------|---------------|-----------|
| Base backup | ~200-400 GB compressed | 1-2 copies |
| WAL per day | 5-50 GB (depends on writes) | 7-14 days |
| Total archive | 100-400 GB WAL + base | - |
### RTO Estimates for Large Databases
| Database Size | Base Extraction | WAL Replay (1 week) | Total RTO |
|---------------|-----------------|---------------------|-----------|
| 200 GB | 15-30 min | 15-30 min | 30-60 min |
| 600 GB | 45-90 min | 30-60 min | 1-2.5 hours |
| 1 TB | 60-120 min | 45-90 min | 2-3.5 hours |
| 2 TB | 2-4 hours | 1-2 hours | 3-6 hours |
**Compare to full restore:** 600 GB pg_dump restore takes 8-12+ hours.
### Best Practices for 600+ GB
1. **Weekly base backups** - Monthly if storage is tight
2. **Test recovery monthly** - Verify WAL chain integrity
3. **Monitor WAL lag** - Alert if archive falls behind
4. **Use streaming replication** - For HA, combine with PITR for DR
5. **Separate archive storage** - Don't fill up the DB disk
```bash
# Quick health check for large DB PITR setup
dbbackup pitr status --verbose
# Expected output:
# Base Backup: 2026-01-06 (7 days old) - OK
# WAL Archive: 847 files, 52 GB
# Recovery Window: 2026-01-06 to 2026-01-13 (7 days)
# Estimated RTO: ~90 minutes
```
## Performance Considerations ## Performance Considerations
### WAL Archive Size ### WAL Archive Size

View File

@@ -116,8 +116,9 @@ sudo chmod 755 /usr/local/bin/dbbackup
### Step 2: Create Configuration ### Step 2: Create Configuration
```bash ```bash
# Main configuration # Main configuration in working directory (where service runs from)
sudo tee /etc/dbbackup/dbbackup.conf << 'EOF' # dbbackup reads .dbbackup.conf from WorkingDirectory
sudo tee /var/lib/dbbackup/.dbbackup.conf << 'EOF'
# DBBackup Configuration # DBBackup Configuration
db-type=postgres db-type=postgres
host=localhost host=localhost
@@ -128,6 +129,8 @@ compression=6
retention-days=30 retention-days=30
min-backups=7 min-backups=7
EOF EOF
sudo chown dbbackup:dbbackup /var/lib/dbbackup/.dbbackup.conf
sudo chmod 600 /var/lib/dbbackup/.dbbackup.conf
# Instance credentials (secure permissions) # Instance credentials (secure permissions)
sudo tee /etc/dbbackup/env.d/cluster.conf << 'EOF' sudo tee /etc/dbbackup/env.d/cluster.conf << 'EOF'
@@ -157,13 +160,15 @@ Group=dbbackup
# Load configuration # Load configuration
EnvironmentFile=-/etc/dbbackup/env.d/cluster.conf EnvironmentFile=-/etc/dbbackup/env.d/cluster.conf
# Working directory # Working directory (config is loaded from .dbbackup.conf here)
WorkingDirectory=/var/lib/dbbackup WorkingDirectory=/var/lib/dbbackup
# Execute backup # Execute backup (reads .dbbackup.conf from WorkingDirectory)
ExecStart=/usr/local/bin/dbbackup backup cluster \ ExecStart=/usr/local/bin/dbbackup backup cluster \
--config /etc/dbbackup/dbbackup.conf \
--backup-dir /var/lib/dbbackup/backups \ --backup-dir /var/lib/dbbackup/backups \
--host localhost \
--port 5432 \
--user postgres \
--allow-root --allow-root
# Security hardening # Security hardening
@@ -443,12 +448,12 @@ sudo systemctl status dbbackup-cluster.service
# View detailed error # View detailed error
sudo journalctl -u dbbackup-cluster.service -n 50 --no-pager sudo journalctl -u dbbackup-cluster.service -n 50 --no-pager
# Test manually as dbbackup user # Test manually as dbbackup user (run from working directory with .dbbackup.conf)
sudo -u dbbackup /usr/local/bin/dbbackup backup cluster --config /etc/dbbackup/dbbackup.conf cd /var/lib/dbbackup && sudo -u dbbackup /usr/local/bin/dbbackup backup cluster
# Check permissions # Check permissions
ls -la /var/lib/dbbackup/ ls -la /var/lib/dbbackup/
ls -la /etc/dbbackup/ ls -la /var/lib/dbbackup/.dbbackup.conf
``` ```
### Permission Denied ### Permission Denied

View File

@@ -1,133 +0,0 @@
# Why DBAs Are Switching from Veeam to dbbackup
## The Enterprise Backup Problem
You're paying **$2,000-10,000/year per database server** for enterprise backup solutions.
What are you actually getting?
- Heavy agents eating your CPU
- Complex licensing that requires a spreadsheet to understand
- Vendor lock-in to proprietary formats
- "Cloud support" that means "we'll upload your backup somewhere"
- Recovery that requires calling support
## What If There Was a Better Way?
**dbbackup v3.2.0** delivers enterprise-grade MySQL/MariaDB backup capabilities in a **single, zero-dependency binary**:
| Feature | Veeam/Commercial | dbbackup |
|---------|------------------|----------|
| Physical backups | ✅ Via XtraBackup | ✅ Native Clone Plugin |
| Consistent snapshots | ✅ | ✅ LVM/ZFS/Btrfs |
| Binlog streaming | ❌ | ✅ Continuous PITR |
| Direct cloud streaming | ❌ (stage to disk) | ✅ Zero local storage |
| Parallel uploads | ❌ | ✅ Configurable workers |
| License cost | $$$$ | **Free (MIT)** |
| Dependencies | Agent + XtraBackup + ... | **Single binary** |
## Real Numbers
**100GB database backup comparison:**
| Metric | Traditional | dbbackup v3.2 |
|--------|-------------|---------------|
| Backup time | 45 min | **12 min** |
| Local disk needed | 100GB | **0 GB** |
| Network efficiency | 1x | **3x** (parallel) |
| Recovery point | Daily | **< 1 second** |
## The Technical Revolution
### MySQL Clone Plugin (8.0.17+)
```bash
# Physical backup at InnoDB page level
# No XtraBackup. No external tools. Pure Go.
dbbackup backup single mydb --db-type mysql --cloud s3://bucket/backups/
```
### Filesystem Snapshots
```bash
# Brief lock (<100ms), instant snapshot, stream to cloud
dbbackup backup --engine=snapshot --snapshot-backend=lvm
```
### Continuous Binlog Streaming
```bash
# Real-time binlog capture to S3
# Sub-second RPO without touching the database server
dbbackup binlog stream --target=s3://bucket/binlogs/
```
### Parallel Cloud Upload
```bash
# Saturate your network, not your patience
dbbackup backup --engine=streaming --parallel-workers=8
```
## Who Should Switch?
**Cloud-native deployments** - Kubernetes, ECS, Cloud Run
**Cost-conscious enterprises** - Same capabilities, zero license fees
**DevOps teams** - Single binary, easy automation
**Compliance requirements** - AES-256-GCM encryption, audit logging
**Multi-cloud strategies** - S3, GCS, Azure Blob native support
## Migration Path
**Day 1**: Run dbbackup alongside existing solution
```bash
# Test backup
dbbackup backup single mydb --cloud s3://test-bucket/
# Verify integrity
dbbackup verify s3://test-bucket/mydb_20260115.dump.gz
```
**Week 1**: Compare backup times, storage costs, recovery speed
**Week 2**: Switch primary backups to dbbackup
**Month 1**: Cancel Veeam renewal, buy your team pizza with savings 🍕
## FAQ
**Q: Is this production-ready?**
A: Used in production by organizations managing petabytes of MySQL data.
**Q: What about support?**
A: Community support via GitHub. Enterprise support available.
**Q: Can it replace XtraBackup?**
A: For MySQL 8.0.17+, yes. We use native Clone Plugin instead.
**Q: What about PostgreSQL?**
A: Full PostgreSQL support including WAL archiving and PITR.
## Get Started
```bash
# Download (single binary, ~15MB)
curl -LO https://github.com/UUXO/dbbackup/releases/latest/download/dbbackup_linux_amd64
chmod +x dbbackup_linux_amd64
# Your first backup
./dbbackup_linux_amd64 backup single production \
--db-type mysql \
--cloud s3://my-backups/
```
## The Bottom Line
Every dollar you spend on backup licensing is a dollar not spent on:
- Better hardware
- Your team
- Actually useful tools
**dbbackup**: Enterprise capabilities. Zero enterprise pricing.
---
*Apache 2.0 Licensed. Free forever. No sales calls required.*
[GitHub](https://github.com/UUXO/dbbackup) | [Documentation](https://github.com/UUXO/dbbackup#readme) | [Changelog](CHANGELOG.md)

View File

@@ -3,9 +3,9 @@
This directory contains pre-compiled binaries for the DB Backup Tool across multiple platforms and architectures. This directory contains pre-compiled binaries for the DB Backup Tool across multiple platforms and architectures.
## Build Information ## Build Information
- **Version**: 3.42.1 - **Version**: 3.42.32
- **Build Time**: 2026-01-08_04:54:46_UTC - **Build Time**: 2026-01-14_15:13:08_UTC
- **Git Commit**: 627061c - **Git Commit**: 6a24ee3
## Recent Updates (v1.1.0) ## Recent Updates (v1.1.0)
- ✅ Fixed TUI progress display with line-by-line output - ✅ Fixed TUI progress display with line-by-line output

View File

@@ -15,7 +15,7 @@ echo "🔧 Using Go version: $GO_VERSION"
# Configuration # Configuration
APP_NAME="dbbackup" APP_NAME="dbbackup"
VERSION="3.42.1" VERSION=$(grep 'version.*=' main.go | head -1 | sed 's/.*"\(.*\)".*/\1/')
BUILD_TIME=$(date -u '+%Y-%m-%d_%H:%M:%S_UTC') BUILD_TIME=$(date -u '+%Y-%m-%d_%H:%M:%S_UTC')
GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
BIN_DIR="bin" BIN_DIR="bin"

View File

@@ -252,8 +252,8 @@ func runCatalogSync(cmd *cobra.Command, args []string) error {
} }
defer cat.Close() defer cat.Close()
fmt.Printf("📁 Syncing backups from: %s\n", absDir) fmt.Printf("[DIR] Syncing backups from: %s\n", absDir)
fmt.Printf("📊 Catalog database: %s\n\n", catalogDBPath) fmt.Printf("[STATS] Catalog database: %s\n\n", catalogDBPath)
ctx := context.Background() ctx := context.Background()
result, err := cat.SyncFromDirectory(ctx, absDir) result, err := cat.SyncFromDirectory(ctx, absDir)
@@ -265,17 +265,17 @@ func runCatalogSync(cmd *cobra.Command, args []string) error {
cat.SetLastSync(ctx) cat.SetLastSync(ctx)
// Show results // Show results
fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") fmt.Printf("=====================================================\n")
fmt.Printf(" Sync Results\n") fmt.Printf(" Sync Results\n")
fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") fmt.Printf("=====================================================\n")
fmt.Printf(" Added: %d\n", result.Added) fmt.Printf(" [OK] Added: %d\n", result.Added)
fmt.Printf(" 🔄 Updated: %d\n", result.Updated) fmt.Printf(" [SYNC] Updated: %d\n", result.Updated)
fmt.Printf(" 🗑️ Removed: %d\n", result.Removed) fmt.Printf(" [DEL] Removed: %d\n", result.Removed)
if result.Errors > 0 { if result.Errors > 0 {
fmt.Printf(" Errors: %d\n", result.Errors) fmt.Printf(" [FAIL] Errors: %d\n", result.Errors)
} }
fmt.Printf(" ⏱️ Duration: %.2fs\n", result.Duration) fmt.Printf(" [TIME] Duration: %.2fs\n", result.Duration)
fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") fmt.Printf("=====================================================\n")
// Show details if verbose // Show details if verbose
if catalogVerbose && len(result.Details) > 0 { if catalogVerbose && len(result.Details) > 0 {
@@ -323,7 +323,7 @@ func runCatalogList(cmd *cobra.Command, args []string) error {
// Table format // Table format
fmt.Printf("%-30s %-12s %-10s %-20s %-10s %s\n", fmt.Printf("%-30s %-12s %-10s %-20s %-10s %s\n",
"DATABASE", "TYPE", "SIZE", "CREATED", "STATUS", "PATH") "DATABASE", "TYPE", "SIZE", "CREATED", "STATUS", "PATH")
fmt.Println(strings.Repeat("", 120)) fmt.Println(strings.Repeat("-", 120))
for _, entry := range entries { for _, entry := range entries {
dbName := truncateString(entry.Database, 28) dbName := truncateString(entry.Database, 28)
@@ -331,10 +331,10 @@ func runCatalogList(cmd *cobra.Command, args []string) error {
status := string(entry.Status) status := string(entry.Status)
if entry.VerifyValid != nil && *entry.VerifyValid { if entry.VerifyValid != nil && *entry.VerifyValid {
status = " verified" status = "[OK] verified"
} }
if entry.DrillSuccess != nil && *entry.DrillSuccess { if entry.DrillSuccess != nil && *entry.DrillSuccess {
status = " tested" status = "[OK] tested"
} }
fmt.Printf("%-30s %-12s %-10s %-20s %-10s %s\n", fmt.Printf("%-30s %-12s %-10s %-20s %-10s %s\n",
@@ -377,20 +377,20 @@ func runCatalogStats(cmd *cobra.Command, args []string) error {
} }
// Table format // Table format
fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") fmt.Printf("=====================================================\n")
if catalogDatabase != "" { if catalogDatabase != "" {
fmt.Printf(" Catalog Statistics: %s\n", catalogDatabase) fmt.Printf(" Catalog Statistics: %s\n", catalogDatabase)
} else { } else {
fmt.Printf(" Catalog Statistics\n") fmt.Printf(" Catalog Statistics\n")
} }
fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n") fmt.Printf("=====================================================\n\n")
fmt.Printf("📊 Total Backups: %d\n", stats.TotalBackups) fmt.Printf("[STATS] Total Backups: %d\n", stats.TotalBackups)
fmt.Printf("💾 Total Size: %s\n", stats.TotalSizeHuman) fmt.Printf("[SAVE] Total Size: %s\n", stats.TotalSizeHuman)
fmt.Printf("📏 Average Size: %s\n", catalog.FormatSize(stats.AvgSize)) fmt.Printf("[SIZE] Average Size: %s\n", catalog.FormatSize(stats.AvgSize))
fmt.Printf("⏱️ Average Duration: %.1fs\n", stats.AvgDuration) fmt.Printf("[TIME] Average Duration: %.1fs\n", stats.AvgDuration)
fmt.Printf(" Verified: %d\n", stats.VerifiedCount) fmt.Printf("[OK] Verified: %d\n", stats.VerifiedCount)
fmt.Printf("🧪 Drill Tested: %d\n", stats.DrillTestedCount) fmt.Printf("[TEST] Drill Tested: %d\n", stats.DrillTestedCount)
if stats.OldestBackup != nil { if stats.OldestBackup != nil {
fmt.Printf("📅 Oldest Backup: %s\n", stats.OldestBackup.Format("2006-01-02 15:04")) fmt.Printf("📅 Oldest Backup: %s\n", stats.OldestBackup.Format("2006-01-02 15:04"))
@@ -400,27 +400,27 @@ func runCatalogStats(cmd *cobra.Command, args []string) error {
} }
if len(stats.ByDatabase) > 0 && catalogDatabase == "" { if len(stats.ByDatabase) > 0 && catalogDatabase == "" {
fmt.Printf("\n📁 By Database:\n") fmt.Printf("\n[DIR] By Database:\n")
for db, count := range stats.ByDatabase { for db, count := range stats.ByDatabase {
fmt.Printf(" %-30s %d\n", db, count) fmt.Printf(" %-30s %d\n", db, count)
} }
} }
if len(stats.ByType) > 0 { if len(stats.ByType) > 0 {
fmt.Printf("\n📦 By Type:\n") fmt.Printf("\n[PKG] By Type:\n")
for t, count := range stats.ByType { for t, count := range stats.ByType {
fmt.Printf(" %-15s %d\n", t, count) fmt.Printf(" %-15s %d\n", t, count)
} }
} }
if len(stats.ByStatus) > 0 { if len(stats.ByStatus) > 0 {
fmt.Printf("\n📋 By Status:\n") fmt.Printf("\n[LOG] By Status:\n")
for s, count := range stats.ByStatus { for s, count := range stats.ByStatus {
fmt.Printf(" %-15s %d\n", s, count) fmt.Printf(" %-15s %d\n", s, count)
} }
} }
fmt.Printf("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") fmt.Printf("\n=====================================================\n")
return nil return nil
} }
@@ -488,26 +488,26 @@ func runCatalogGaps(cmd *cobra.Command, args []string) error {
} }
if len(allGaps) == 0 { if len(allGaps) == 0 {
fmt.Printf(" No backup gaps detected (expected interval: %s)\n", interval) fmt.Printf("[OK] No backup gaps detected (expected interval: %s)\n", interval)
return nil return nil
} }
fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") fmt.Printf("=====================================================\n")
fmt.Printf(" Backup Gaps Detected (expected interval: %s)\n", interval) fmt.Printf(" Backup Gaps Detected (expected interval: %s)\n", interval)
fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n") fmt.Printf("=====================================================\n\n")
totalGaps := 0 totalGaps := 0
criticalGaps := 0 criticalGaps := 0
for database, gaps := range allGaps { for database, gaps := range allGaps {
fmt.Printf("📁 %s (%d gaps)\n", database, len(gaps)) fmt.Printf("[DIR] %s (%d gaps)\n", database, len(gaps))
for _, gap := range gaps { for _, gap := range gaps {
totalGaps++ totalGaps++
icon := "" icon := "[INFO]"
switch gap.Severity { switch gap.Severity {
case catalog.SeverityWarning: case catalog.SeverityWarning:
icon = "⚠️" icon = "[WARN]"
case catalog.SeverityCritical: case catalog.SeverityCritical:
icon = "🚨" icon = "🚨"
criticalGaps++ criticalGaps++
@@ -523,7 +523,7 @@ func runCatalogGaps(cmd *cobra.Command, args []string) error {
fmt.Println() fmt.Println()
} }
fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") fmt.Printf("=====================================================\n")
fmt.Printf("Total: %d gaps detected", totalGaps) fmt.Printf("Total: %d gaps detected", totalGaps)
if criticalGaps > 0 { if criticalGaps > 0 {
fmt.Printf(" (%d critical)", criticalGaps) fmt.Printf(" (%d critical)", criticalGaps)
@@ -598,20 +598,20 @@ func runCatalogSearch(cmd *cobra.Command, args []string) error {
fmt.Printf("Found %d matching backups:\n\n", len(entries)) fmt.Printf("Found %d matching backups:\n\n", len(entries))
for _, entry := range entries { for _, entry := range entries {
fmt.Printf("📁 %s\n", entry.Database) fmt.Printf("[DIR] %s\n", entry.Database)
fmt.Printf(" Path: %s\n", entry.BackupPath) fmt.Printf(" Path: %s\n", entry.BackupPath)
fmt.Printf(" Type: %s | Size: %s | Created: %s\n", fmt.Printf(" Type: %s | Size: %s | Created: %s\n",
entry.DatabaseType, entry.DatabaseType,
catalog.FormatSize(entry.SizeBytes), catalog.FormatSize(entry.SizeBytes),
entry.CreatedAt.Format("2006-01-02 15:04:05")) entry.CreatedAt.Format("2006-01-02 15:04:05"))
if entry.Encrypted { if entry.Encrypted {
fmt.Printf(" 🔒 Encrypted\n") fmt.Printf(" [LOCK] Encrypted\n")
} }
if entry.VerifyValid != nil && *entry.VerifyValid { if entry.VerifyValid != nil && *entry.VerifyValid {
fmt.Printf(" Verified: %s\n", entry.VerifiedAt.Format("2006-01-02 15:04")) fmt.Printf(" [OK] Verified: %s\n", entry.VerifiedAt.Format("2006-01-02 15:04"))
} }
if entry.DrillSuccess != nil && *entry.DrillSuccess { if entry.DrillSuccess != nil && *entry.DrillSuccess {
fmt.Printf(" 🧪 Drill Tested: %s\n", entry.DrillTestedAt.Format("2006-01-02 15:04")) fmt.Printf(" [TEST] Drill Tested: %s\n", entry.DrillTestedAt.Format("2006-01-02 15:04"))
} }
fmt.Println() fmt.Println()
} }
@@ -655,64 +655,64 @@ func runCatalogInfo(cmd *cobra.Command, args []string) error {
return nil return nil
} }
fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") fmt.Printf("=====================================================\n")
fmt.Printf(" Backup Details\n") fmt.Printf(" Backup Details\n")
fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n") fmt.Printf("=====================================================\n\n")
fmt.Printf("📁 Database: %s\n", entry.Database) fmt.Printf("[DIR] Database: %s\n", entry.Database)
fmt.Printf("🔧 Type: %s\n", entry.DatabaseType) fmt.Printf("🔧 Type: %s\n", entry.DatabaseType)
fmt.Printf("🖥️ Host: %s:%d\n", entry.Host, entry.Port) fmt.Printf("[HOST] Host: %s:%d\n", entry.Host, entry.Port)
fmt.Printf("📂 Path: %s\n", entry.BackupPath) fmt.Printf("📂 Path: %s\n", entry.BackupPath)
fmt.Printf("📦 Backup Type: %s\n", entry.BackupType) fmt.Printf("[PKG] Backup Type: %s\n", entry.BackupType)
fmt.Printf("💾 Size: %s (%d bytes)\n", catalog.FormatSize(entry.SizeBytes), entry.SizeBytes) fmt.Printf("[SAVE] Size: %s (%d bytes)\n", catalog.FormatSize(entry.SizeBytes), entry.SizeBytes)
fmt.Printf("🔐 SHA256: %s\n", entry.SHA256) fmt.Printf("[HASH] SHA256: %s\n", entry.SHA256)
fmt.Printf("📅 Created: %s\n", entry.CreatedAt.Format("2006-01-02 15:04:05 MST")) fmt.Printf("📅 Created: %s\n", entry.CreatedAt.Format("2006-01-02 15:04:05 MST"))
fmt.Printf("⏱️ Duration: %.2fs\n", entry.Duration) fmt.Printf("[TIME] Duration: %.2fs\n", entry.Duration)
fmt.Printf("📋 Status: %s\n", entry.Status) fmt.Printf("[LOG] Status: %s\n", entry.Status)
if entry.Compression != "" { if entry.Compression != "" {
fmt.Printf("📦 Compression: %s\n", entry.Compression) fmt.Printf("[PKG] Compression: %s\n", entry.Compression)
} }
if entry.Encrypted { if entry.Encrypted {
fmt.Printf("🔒 Encrypted: yes\n") fmt.Printf("[LOCK] Encrypted: yes\n")
} }
if entry.CloudLocation != "" { if entry.CloudLocation != "" {
fmt.Printf("☁️ Cloud: %s\n", entry.CloudLocation) fmt.Printf("[CLOUD] Cloud: %s\n", entry.CloudLocation)
} }
if entry.RetentionPolicy != "" { if entry.RetentionPolicy != "" {
fmt.Printf("📆 Retention: %s\n", entry.RetentionPolicy) fmt.Printf("📆 Retention: %s\n", entry.RetentionPolicy)
} }
fmt.Printf("\n📊 Verification:\n") fmt.Printf("\n[STATS] Verification:\n")
if entry.VerifiedAt != nil { if entry.VerifiedAt != nil {
status := " Failed" status := "[FAIL] Failed"
if entry.VerifyValid != nil && *entry.VerifyValid { if entry.VerifyValid != nil && *entry.VerifyValid {
status = " Valid" status = "[OK] Valid"
} }
fmt.Printf(" Status: %s (checked %s)\n", status, entry.VerifiedAt.Format("2006-01-02 15:04")) fmt.Printf(" Status: %s (checked %s)\n", status, entry.VerifiedAt.Format("2006-01-02 15:04"))
} else { } else {
fmt.Printf(" Status: Not verified\n") fmt.Printf(" Status: [WAIT] Not verified\n")
} }
fmt.Printf("\n🧪 DR Drill Test:\n") fmt.Printf("\n[TEST] DR Drill Test:\n")
if entry.DrillTestedAt != nil { if entry.DrillTestedAt != nil {
status := " Failed" status := "[FAIL] Failed"
if entry.DrillSuccess != nil && *entry.DrillSuccess { if entry.DrillSuccess != nil && *entry.DrillSuccess {
status = " Passed" status = "[OK] Passed"
} }
fmt.Printf(" Status: %s (tested %s)\n", status, entry.DrillTestedAt.Format("2006-01-02 15:04")) fmt.Printf(" Status: %s (tested %s)\n", status, entry.DrillTestedAt.Format("2006-01-02 15:04"))
} else { } else {
fmt.Printf(" Status: Not tested\n") fmt.Printf(" Status: [WAIT] Not tested\n")
} }
if len(entry.Metadata) > 0 { if len(entry.Metadata) > 0 {
fmt.Printf("\n📝 Additional Metadata:\n") fmt.Printf("\n[NOTE] Additional Metadata:\n")
for k, v := range entry.Metadata { for k, v := range entry.Metadata {
fmt.Printf(" %s: %s\n", k, v) fmt.Printf(" %s: %s\n", k, v)
} }
} }
fmt.Printf("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") fmt.Printf("\n=====================================================\n")
return nil return nil
} }

View File

@@ -115,7 +115,7 @@ func runCleanup(cmd *cobra.Command, args []string) error {
DryRun: dryRun, DryRun: dryRun,
} }
fmt.Printf("🗑️ Cleanup Policy:\n") fmt.Printf("[CLEANUP] Cleanup Policy:\n")
fmt.Printf(" Directory: %s\n", backupDir) fmt.Printf(" Directory: %s\n", backupDir)
fmt.Printf(" Retention: %d days\n", policy.RetentionDays) fmt.Printf(" Retention: %d days\n", policy.RetentionDays)
fmt.Printf(" Min backups: %d\n", policy.MinBackups) fmt.Printf(" Min backups: %d\n", policy.MinBackups)
@@ -142,16 +142,16 @@ func runCleanup(cmd *cobra.Command, args []string) error {
} }
// Display results // Display results
fmt.Printf("📊 Results:\n") fmt.Printf("[RESULTS] Results:\n")
fmt.Printf(" Total backups: %d\n", result.TotalBackups) fmt.Printf(" Total backups: %d\n", result.TotalBackups)
fmt.Printf(" Eligible for deletion: %d\n", result.EligibleForDeletion) fmt.Printf(" Eligible for deletion: %d\n", result.EligibleForDeletion)
if len(result.Deleted) > 0 { if len(result.Deleted) > 0 {
fmt.Printf("\n") fmt.Printf("\n")
if dryRun { if dryRun {
fmt.Printf("🔍 Would delete %d backup(s):\n", len(result.Deleted)) fmt.Printf("[DRY-RUN] Would delete %d backup(s):\n", len(result.Deleted))
} else { } else {
fmt.Printf(" Deleted %d backup(s):\n", len(result.Deleted)) fmt.Printf("[OK] Deleted %d backup(s):\n", len(result.Deleted))
} }
for _, file := range result.Deleted { for _, file := range result.Deleted {
fmt.Printf(" - %s\n", filepath.Base(file)) fmt.Printf(" - %s\n", filepath.Base(file))
@@ -159,33 +159,33 @@ func runCleanup(cmd *cobra.Command, args []string) error {
} }
if len(result.Kept) > 0 && len(result.Kept) <= 10 { if len(result.Kept) > 0 && len(result.Kept) <= 10 {
fmt.Printf("\n📦 Kept %d backup(s):\n", len(result.Kept)) fmt.Printf("\n[KEPT] Kept %d backup(s):\n", len(result.Kept))
for _, file := range result.Kept { for _, file := range result.Kept {
fmt.Printf(" - %s\n", filepath.Base(file)) fmt.Printf(" - %s\n", filepath.Base(file))
} }
} else if len(result.Kept) > 10 { } else if len(result.Kept) > 10 {
fmt.Printf("\n📦 Kept %d backup(s)\n", len(result.Kept)) fmt.Printf("\n[KEPT] Kept %d backup(s)\n", len(result.Kept))
} }
if !dryRun && result.SpaceFreed > 0 { if !dryRun && result.SpaceFreed > 0 {
fmt.Printf("\n💾 Space freed: %s\n", metadata.FormatSize(result.SpaceFreed)) fmt.Printf("\n[FREED] Space freed: %s\n", metadata.FormatSize(result.SpaceFreed))
} }
if len(result.Errors) > 0 { if len(result.Errors) > 0 {
fmt.Printf("\n⚠️ Errors:\n") fmt.Printf("\n[WARN] Errors:\n")
for _, err := range result.Errors { for _, err := range result.Errors {
fmt.Printf(" - %v\n", err) fmt.Printf(" - %v\n", err)
} }
} }
fmt.Println(strings.Repeat("", 50)) fmt.Println(strings.Repeat("-", 50))
if dryRun { if dryRun {
fmt.Println(" Dry run completed (no files were deleted)") fmt.Println("[OK] Dry run completed (no files were deleted)")
} else if len(result.Deleted) > 0 { } else if len(result.Deleted) > 0 {
fmt.Println(" Cleanup completed successfully") fmt.Println("[OK] Cleanup completed successfully")
} else { } else {
fmt.Println(" No backups eligible for deletion") fmt.Println("[INFO] No backups eligible for deletion")
} }
return nil return nil
@@ -212,7 +212,7 @@ func runCloudCleanup(ctx context.Context, uri string) error {
return fmt.Errorf("invalid cloud URI: %w", err) return fmt.Errorf("invalid cloud URI: %w", err)
} }
fmt.Printf("☁️ Cloud Cleanup Policy:\n") fmt.Printf("[CLOUD] Cloud Cleanup Policy:\n")
fmt.Printf(" URI: %s\n", uri) fmt.Printf(" URI: %s\n", uri)
fmt.Printf(" Provider: %s\n", cloudURI.Provider) fmt.Printf(" Provider: %s\n", cloudURI.Provider)
fmt.Printf(" Bucket: %s\n", cloudURI.Bucket) fmt.Printf(" Bucket: %s\n", cloudURI.Bucket)
@@ -295,7 +295,7 @@ func runCloudCleanup(ctx context.Context, uri string) error {
} }
// Display results // Display results
fmt.Printf("📊 Results:\n") fmt.Printf("[RESULTS] Results:\n")
fmt.Printf(" Total backups: %d\n", totalBackups) fmt.Printf(" Total backups: %d\n", totalBackups)
fmt.Printf(" Eligible for deletion: %d\n", len(toDelete)) fmt.Printf(" Eligible for deletion: %d\n", len(toDelete))
fmt.Printf(" Will keep: %d\n", len(toKeep)) fmt.Printf(" Will keep: %d\n", len(toKeep))
@@ -303,9 +303,9 @@ func runCloudCleanup(ctx context.Context, uri string) error {
if len(toDelete) > 0 { if len(toDelete) > 0 {
if dryRun { if dryRun {
fmt.Printf("🔍 Would delete %d backup(s):\n", len(toDelete)) fmt.Printf("[DRY-RUN] Would delete %d backup(s):\n", len(toDelete))
} else { } else {
fmt.Printf("🗑️ Deleting %d backup(s):\n", len(toDelete)) fmt.Printf("[DELETE] Deleting %d backup(s):\n", len(toDelete))
} }
var totalSize int64 var totalSize int64
@@ -321,7 +321,7 @@ func runCloudCleanup(ctx context.Context, uri string) error {
if !dryRun { if !dryRun {
if err := backend.Delete(ctx, backup.Key); err != nil { if err := backend.Delete(ctx, backup.Key); err != nil {
fmt.Printf(" Error: %v\n", err) fmt.Printf(" [FAIL] Error: %v\n", err)
} else { } else {
deletedCount++ deletedCount++
// Also try to delete metadata // Also try to delete metadata
@@ -330,12 +330,12 @@ func runCloudCleanup(ctx context.Context, uri string) error {
} }
} }
fmt.Printf("\n💾 Space %s: %s\n", fmt.Printf("\n[FREED] Space %s: %s\n",
map[bool]string{true: "would be freed", false: "freed"}[dryRun], map[bool]string{true: "would be freed", false: "freed"}[dryRun],
cloud.FormatSize(totalSize)) cloud.FormatSize(totalSize))
if !dryRun && deletedCount > 0 { if !dryRun && deletedCount > 0 {
fmt.Printf(" Successfully deleted %d backup(s)\n", deletedCount) fmt.Printf("[OK] Successfully deleted %d backup(s)\n", deletedCount)
} }
} else { } else {
fmt.Println("No backups eligible for deletion") fmt.Println("No backups eligible for deletion")
@@ -405,7 +405,7 @@ func runGFSCleanup(backupDir string) error {
} }
// Display tier breakdown // Display tier breakdown
fmt.Printf("📊 Backup Classification:\n") fmt.Printf("[STATS] Backup Classification:\n")
fmt.Printf(" Yearly: %d\n", result.YearlyKept) fmt.Printf(" Yearly: %d\n", result.YearlyKept)
fmt.Printf(" Monthly: %d\n", result.MonthlyKept) fmt.Printf(" Monthly: %d\n", result.MonthlyKept)
fmt.Printf(" Weekly: %d\n", result.WeeklyKept) fmt.Printf(" Weekly: %d\n", result.WeeklyKept)
@@ -416,9 +416,9 @@ func runGFSCleanup(backupDir string) error {
// Display deletions // Display deletions
if len(result.Deleted) > 0 { if len(result.Deleted) > 0 {
if dryRun { if dryRun {
fmt.Printf("🔍 Would delete %d backup(s):\n", len(result.Deleted)) fmt.Printf("[SEARCH] Would delete %d backup(s):\n", len(result.Deleted))
} else { } else {
fmt.Printf(" Deleted %d backup(s):\n", len(result.Deleted)) fmt.Printf("[OK] Deleted %d backup(s):\n", len(result.Deleted))
} }
for _, file := range result.Deleted { for _, file := range result.Deleted {
fmt.Printf(" - %s\n", filepath.Base(file)) fmt.Printf(" - %s\n", filepath.Base(file))
@@ -427,7 +427,7 @@ func runGFSCleanup(backupDir string) error {
// Display kept backups (limited display) // Display kept backups (limited display)
if len(result.Kept) > 0 && len(result.Kept) <= 15 { if len(result.Kept) > 0 && len(result.Kept) <= 15 {
fmt.Printf("\n📦 Kept %d backup(s):\n", len(result.Kept)) fmt.Printf("\n[PKG] Kept %d backup(s):\n", len(result.Kept))
for _, file := range result.Kept { for _, file := range result.Kept {
// Show tier classification // Show tier classification
info, _ := os.Stat(file) info, _ := os.Stat(file)
@@ -440,28 +440,28 @@ func runGFSCleanup(backupDir string) error {
} }
} }
} else if len(result.Kept) > 15 { } else if len(result.Kept) > 15 {
fmt.Printf("\n📦 Kept %d backup(s)\n", len(result.Kept)) fmt.Printf("\n[PKG] Kept %d backup(s)\n", len(result.Kept))
} }
if !dryRun && result.SpaceFreed > 0 { if !dryRun && result.SpaceFreed > 0 {
fmt.Printf("\n💾 Space freed: %s\n", metadata.FormatSize(result.SpaceFreed)) fmt.Printf("\n[SAVE] Space freed: %s\n", metadata.FormatSize(result.SpaceFreed))
} }
if len(result.Errors) > 0 { if len(result.Errors) > 0 {
fmt.Printf("\n⚠️ Errors:\n") fmt.Printf("\n[WARN] Errors:\n")
for _, err := range result.Errors { for _, err := range result.Errors {
fmt.Printf(" - %v\n", err) fmt.Printf(" - %v\n", err)
} }
} }
fmt.Println(strings.Repeat("", 50)) fmt.Println(strings.Repeat("-", 50))
if dryRun { if dryRun {
fmt.Println(" GFS dry run completed (no files were deleted)") fmt.Println("[OK] GFS dry run completed (no files were deleted)")
} else if len(result.Deleted) > 0 { } else if len(result.Deleted) > 0 {
fmt.Println(" GFS cleanup completed successfully") fmt.Println("[OK] GFS cleanup completed successfully")
} else { } else {
fmt.Println(" No backups eligible for deletion under GFS policy") fmt.Println("[INFO] No backups eligible for deletion under GFS policy")
} }
return nil return nil

View File

@@ -189,12 +189,12 @@ func runCloudUpload(cmd *cobra.Command, args []string) error {
} }
} }
fmt.Printf("☁️ Uploading %d file(s) to %s...\n\n", len(files), backend.Name()) fmt.Printf("[CLOUD] Uploading %d file(s) to %s...\n\n", len(files), backend.Name())
successCount := 0 successCount := 0
for _, localPath := range files { for _, localPath := range files {
filename := filepath.Base(localPath) filename := filepath.Base(localPath)
fmt.Printf("📤 %s\n", filename) fmt.Printf("[UPLOAD] %s\n", filename)
// Progress callback // Progress callback
var lastPercent int var lastPercent int
@@ -214,21 +214,21 @@ func runCloudUpload(cmd *cobra.Command, args []string) error {
err := backend.Upload(ctx, localPath, filename, progress) err := backend.Upload(ctx, localPath, filename, progress)
if err != nil { if err != nil {
fmt.Printf(" Failed: %v\n\n", err) fmt.Printf(" [FAIL] Failed: %v\n\n", err)
continue continue
} }
// Get file size // Get file size
if info, err := os.Stat(localPath); err == nil { if info, err := os.Stat(localPath); err == nil {
fmt.Printf(" Uploaded (%s)\n\n", cloud.FormatSize(info.Size())) fmt.Printf(" [OK] Uploaded (%s)\n\n", cloud.FormatSize(info.Size()))
} else { } else {
fmt.Printf(" Uploaded\n\n") fmt.Printf(" [OK] Uploaded\n\n")
} }
successCount++ successCount++
} }
fmt.Println(strings.Repeat("", 50)) fmt.Println(strings.Repeat("-", 50))
fmt.Printf(" Successfully uploaded %d/%d file(s)\n", successCount, len(files)) fmt.Printf("[OK] Successfully uploaded %d/%d file(s)\n", successCount, len(files))
return nil return nil
} }
@@ -248,8 +248,8 @@ func runCloudDownload(cmd *cobra.Command, args []string) error {
localPath = filepath.Join(localPath, filepath.Base(remotePath)) localPath = filepath.Join(localPath, filepath.Base(remotePath))
} }
fmt.Printf("☁️ Downloading from %s...\n\n", backend.Name()) fmt.Printf("[CLOUD] Downloading from %s...\n\n", backend.Name())
fmt.Printf("📥 %s %s\n", remotePath, localPath) fmt.Printf("[DOWNLOAD] %s -> %s\n", remotePath, localPath)
// Progress callback // Progress callback
var lastPercent int var lastPercent int
@@ -274,9 +274,9 @@ func runCloudDownload(cmd *cobra.Command, args []string) error {
// Get file size // Get file size
if info, err := os.Stat(localPath); err == nil { if info, err := os.Stat(localPath); err == nil {
fmt.Printf(" Downloaded (%s)\n", cloud.FormatSize(info.Size())) fmt.Printf(" [OK] Downloaded (%s)\n", cloud.FormatSize(info.Size()))
} else { } else {
fmt.Printf(" Downloaded\n") fmt.Printf(" [OK] Downloaded\n")
} }
return nil return nil
@@ -294,7 +294,7 @@ func runCloudList(cmd *cobra.Command, args []string) error {
prefix = args[0] prefix = args[0]
} }
fmt.Printf("☁️ Listing backups in %s/%s...\n\n", backend.Name(), cloudBucket) fmt.Printf("[CLOUD] Listing backups in %s/%s...\n\n", backend.Name(), cloudBucket)
backups, err := backend.List(ctx, prefix) backups, err := backend.List(ctx, prefix)
if err != nil { if err != nil {
@@ -311,7 +311,7 @@ func runCloudList(cmd *cobra.Command, args []string) error {
totalSize += backup.Size totalSize += backup.Size
if cloudVerbose { if cloudVerbose {
fmt.Printf("📦 %s\n", backup.Name) fmt.Printf("[FILE] %s\n", backup.Name)
fmt.Printf(" Size: %s\n", cloud.FormatSize(backup.Size)) fmt.Printf(" Size: %s\n", cloud.FormatSize(backup.Size))
fmt.Printf(" Modified: %s\n", backup.LastModified.Format(time.RFC3339)) fmt.Printf(" Modified: %s\n", backup.LastModified.Format(time.RFC3339))
if backup.StorageClass != "" { if backup.StorageClass != "" {
@@ -328,7 +328,7 @@ func runCloudList(cmd *cobra.Command, args []string) error {
} }
} }
fmt.Println(strings.Repeat("", 50)) fmt.Println(strings.Repeat("-", 50))
fmt.Printf("Total: %d backup(s), %s\n", len(backups), cloud.FormatSize(totalSize)) fmt.Printf("Total: %d backup(s), %s\n", len(backups), cloud.FormatSize(totalSize))
return nil return nil
@@ -360,7 +360,7 @@ func runCloudDelete(cmd *cobra.Command, args []string) error {
// Confirmation prompt // Confirmation prompt
if !cloudConfirm { if !cloudConfirm {
fmt.Printf("⚠️ Delete %s (%s) from cloud storage?\n", remotePath, cloud.FormatSize(size)) fmt.Printf("[WARN] Delete %s (%s) from cloud storage?\n", remotePath, cloud.FormatSize(size))
fmt.Print("Type 'yes' to confirm: ") fmt.Print("Type 'yes' to confirm: ")
var response string var response string
fmt.Scanln(&response) fmt.Scanln(&response)
@@ -370,14 +370,14 @@ func runCloudDelete(cmd *cobra.Command, args []string) error {
} }
} }
fmt.Printf("🗑️ Deleting %s...\n", remotePath) fmt.Printf("[DELETE] Deleting %s...\n", remotePath)
err = backend.Delete(ctx, remotePath) err = backend.Delete(ctx, remotePath)
if err != nil { if err != nil {
return fmt.Errorf("delete failed: %w", err) return fmt.Errorf("delete failed: %w", err)
} }
fmt.Printf(" Deleted %s (%s)\n", remotePath, cloud.FormatSize(size)) fmt.Printf("[OK] Deleted %s (%s)\n", remotePath, cloud.FormatSize(size))
return nil return nil
} }

View File

@@ -61,10 +61,10 @@ func runCPUInfo(ctx context.Context) error {
// Show current vs optimal // Show current vs optimal
if cfg.AutoDetectCores { if cfg.AutoDetectCores {
fmt.Println("\n CPU optimization is enabled") fmt.Println("\n[OK] CPU optimization is enabled")
fmt.Println("Job counts are automatically optimized based on detected hardware") fmt.Println("Job counts are automatically optimized based on detected hardware")
} else { } else {
fmt.Println("\n⚠️ CPU optimization is disabled") fmt.Println("\n[WARN] CPU optimization is disabled")
fmt.Println("Consider enabling --auto-detect-cores for better performance") fmt.Println("Consider enabling --auto-detect-cores for better performance")
} }

View File

@@ -1,11 +1,13 @@
package cmd package cmd
import ( import (
"compress/gzip"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"io" "io"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
@@ -34,7 +36,24 @@ Storage Structure:
chunks/ # Content-addressed chunk files chunks/ # Content-addressed chunk files
ab/cdef... # Sharded by first 2 chars of hash ab/cdef... # Sharded by first 2 chars of hash
manifests/ # JSON manifest per backup manifests/ # JSON manifest per backup
chunks.db # SQLite index`, chunks.db # SQLite index
NFS/CIFS NOTICE:
SQLite may have locking issues on network storage.
Use --index-db to put the SQLite index on local storage while keeping
chunks on network storage:
dbbackup dedup backup mydb.sql \
--dedup-dir /mnt/nfs/backups/dedup \
--index-db /var/lib/dbbackup/dedup-index.db
This avoids "database is locked" errors while still storing chunks remotely.
COMPRESSED INPUT NOTICE:
Pre-compressed files (.gz) have poor deduplication ratios (<10%).
Use --decompress-input to decompress before chunking for better results:
dbbackup dedup backup mydb.sql.gz --decompress-input`,
} }
var dedupBackupCmd = &cobra.Command{ var dedupBackupCmd = &cobra.Command{
@@ -89,16 +108,93 @@ var dedupDeleteCmd = &cobra.Command{
RunE: runDedupDelete, RunE: runDedupDelete,
} }
var dedupVerifyCmd = &cobra.Command{
Use: "verify [manifest-id]",
Short: "Verify chunk integrity against manifests",
Long: `Verify that all chunks referenced by manifests exist and have correct hashes.
Without arguments, verifies all backups. With a manifest ID, verifies only that backup.
Examples:
dbbackup dedup verify # Verify all backups
dbbackup dedup verify 2026-01-07_mydb # Verify specific backup`,
RunE: runDedupVerify,
}
var dedupPruneCmd = &cobra.Command{
Use: "prune",
Short: "Apply retention policy to manifests",
Long: `Delete old manifests based on retention policy (like borg prune).
Keeps a specified number of recent backups per database and deletes the rest.
Examples:
dbbackup dedup prune --keep-last 7 # Keep 7 most recent
dbbackup dedup prune --keep-daily 7 --keep-weekly 4 # Keep 7 daily + 4 weekly`,
RunE: runDedupPrune,
}
var dedupBackupDBCmd = &cobra.Command{
Use: "backup-db",
Short: "Direct database dump with deduplication",
Long: `Dump a database directly into deduplicated chunks without temp files.
Streams the database dump through the chunker for efficient deduplication.
Examples:
dbbackup dedup backup-db --db-type postgres --db-name mydb
dbbackup dedup backup-db -d mariadb --database production_db --host db.local`,
RunE: runDedupBackupDB,
}
// Prune flags
var (
pruneKeepLast int
pruneKeepDaily int
pruneKeepWeekly int
pruneDryRun bool
)
// backup-db flags
var (
backupDBDatabase string
backupDBUser string
backupDBPassword string
)
// metrics flags
var (
dedupMetricsOutput string
dedupMetricsInstance string
)
var dedupMetricsCmd = &cobra.Command{
Use: "metrics",
Short: "Export dedup statistics as Prometheus metrics",
Long: `Export deduplication statistics in Prometheus format.
Can write to a textfile for node_exporter's textfile collector,
or print to stdout for custom integrations.
Examples:
dbbackup dedup metrics # Print to stdout
dbbackup dedup metrics --output /var/lib/node_exporter/textfile_collector/dedup.prom
dbbackup dedup metrics --instance prod-db-1`,
RunE: runDedupMetrics,
}
// Flags // Flags
var ( var (
dedupDir string dedupDir string
dedupCompress bool dedupIndexDB string // Separate path for SQLite index (for NFS/CIFS support)
dedupEncrypt bool dedupCompress bool
dedupKey string dedupEncrypt bool
dedupName string dedupKey string
dedupDBType string dedupName string
dedupDBName string dedupDBType string
dedupDBHost string dedupDBName string
dedupDBHost string
dedupDecompress bool // Auto-decompress gzip input
) )
func init() { func init() {
@@ -109,9 +205,14 @@ func init() {
dedupCmd.AddCommand(dedupStatsCmd) dedupCmd.AddCommand(dedupStatsCmd)
dedupCmd.AddCommand(dedupGCCmd) dedupCmd.AddCommand(dedupGCCmd)
dedupCmd.AddCommand(dedupDeleteCmd) dedupCmd.AddCommand(dedupDeleteCmd)
dedupCmd.AddCommand(dedupVerifyCmd)
dedupCmd.AddCommand(dedupPruneCmd)
dedupCmd.AddCommand(dedupBackupDBCmd)
dedupCmd.AddCommand(dedupMetricsCmd)
// Global dedup flags // Global dedup flags
dedupCmd.PersistentFlags().StringVar(&dedupDir, "dedup-dir", "", "Dedup storage directory (default: $BACKUP_DIR/dedup)") dedupCmd.PersistentFlags().StringVar(&dedupDir, "dedup-dir", "", "Dedup storage directory (default: $BACKUP_DIR/dedup)")
dedupCmd.PersistentFlags().StringVar(&dedupIndexDB, "index-db", "", "SQLite index path (local recommended for NFS/CIFS chunk dirs)")
dedupCmd.PersistentFlags().BoolVar(&dedupCompress, "compress", true, "Compress chunks with gzip") dedupCmd.PersistentFlags().BoolVar(&dedupCompress, "compress", true, "Compress chunks with gzip")
dedupCmd.PersistentFlags().BoolVar(&dedupEncrypt, "encrypt", false, "Encrypt chunks with AES-256-GCM") dedupCmd.PersistentFlags().BoolVar(&dedupEncrypt, "encrypt", false, "Encrypt chunks with AES-256-GCM")
dedupCmd.PersistentFlags().StringVar(&dedupKey, "key", "", "Encryption key (hex) or use DBBACKUP_DEDUP_KEY env") dedupCmd.PersistentFlags().StringVar(&dedupKey, "key", "", "Encryption key (hex) or use DBBACKUP_DEDUP_KEY env")
@@ -121,6 +222,26 @@ func init() {
dedupBackupCmd.Flags().StringVar(&dedupDBType, "db-type", "", "Database type (postgres/mysql)") dedupBackupCmd.Flags().StringVar(&dedupDBType, "db-type", "", "Database type (postgres/mysql)")
dedupBackupCmd.Flags().StringVar(&dedupDBName, "db-name", "", "Database name") dedupBackupCmd.Flags().StringVar(&dedupDBName, "db-name", "", "Database name")
dedupBackupCmd.Flags().StringVar(&dedupDBHost, "db-host", "", "Database host") dedupBackupCmd.Flags().StringVar(&dedupDBHost, "db-host", "", "Database host")
dedupBackupCmd.Flags().BoolVar(&dedupDecompress, "decompress-input", false, "Auto-decompress gzip input before chunking (improves dedup ratio)")
// Prune flags
dedupPruneCmd.Flags().IntVar(&pruneKeepLast, "keep-last", 0, "Keep the last N backups")
dedupPruneCmd.Flags().IntVar(&pruneKeepDaily, "keep-daily", 0, "Keep N daily backups")
dedupPruneCmd.Flags().IntVar(&pruneKeepWeekly, "keep-weekly", 0, "Keep N weekly backups")
dedupPruneCmd.Flags().BoolVar(&pruneDryRun, "dry-run", false, "Show what would be deleted without actually deleting")
// backup-db flags
dedupBackupDBCmd.Flags().StringVarP(&dedupDBType, "db-type", "d", "", "Database type (postgres/mariadb/mysql)")
dedupBackupDBCmd.Flags().StringVar(&backupDBDatabase, "database", "", "Database name to backup")
dedupBackupDBCmd.Flags().StringVar(&dedupDBHost, "host", "localhost", "Database host")
dedupBackupDBCmd.Flags().StringVarP(&backupDBUser, "user", "u", "", "Database user")
dedupBackupDBCmd.Flags().StringVarP(&backupDBPassword, "password", "p", "", "Database password (or use env)")
dedupBackupDBCmd.MarkFlagRequired("db-type")
dedupBackupDBCmd.MarkFlagRequired("database")
// Metrics flags
dedupMetricsCmd.Flags().StringVarP(&dedupMetricsOutput, "output", "o", "", "Output file path (default: stdout)")
dedupMetricsCmd.Flags().StringVar(&dedupMetricsInstance, "instance", "", "Instance label for metrics (default: hostname)")
} }
func getDedupDir() string { func getDedupDir() string {
@@ -133,6 +254,14 @@ func getDedupDir() string {
return filepath.Join(os.Getenv("HOME"), "db_backups", "dedup") return filepath.Join(os.Getenv("HOME"), "db_backups", "dedup")
} }
func getIndexDBPath() string {
if dedupIndexDB != "" {
return dedupIndexDB
}
// Default: same directory as chunks (may have issues on NFS/CIFS)
return filepath.Join(getDedupDir(), "chunks.db")
}
func getEncryptionKey() string { func getEncryptionKey() string {
if dedupKey != "" { if dedupKey != "" {
return dedupKey return dedupKey
@@ -155,6 +284,25 @@ func runDedupBackup(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to stat input file: %w", err) return fmt.Errorf("failed to stat input file: %w", err)
} }
// Check for compressed input and warn/handle
var reader io.Reader = file
isGzipped := strings.HasSuffix(strings.ToLower(inputPath), ".gz")
if isGzipped && !dedupDecompress {
fmt.Printf("Warning: Input appears to be gzip compressed (.gz)\n")
fmt.Printf(" Compressed data typically has poor dedup ratios (<10%%).\n")
fmt.Printf(" Consider using --decompress-input for better deduplication.\n\n")
}
if isGzipped && dedupDecompress {
fmt.Printf("Auto-decompressing gzip input for better dedup ratio...\n")
gzReader, err := gzip.NewReader(file)
if err != nil {
return fmt.Errorf("failed to decompress gzip input: %w", err)
}
defer gzReader.Close()
reader = gzReader
}
// Setup dedup storage // Setup dedup storage
basePath := getDedupDir() basePath := getDedupDir()
encKey := "" encKey := ""
@@ -179,7 +327,7 @@ func runDedupBackup(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to open manifest store: %w", err) return fmt.Errorf("failed to open manifest store: %w", err)
} }
index, err := dedup.NewChunkIndex(basePath) index, err := dedup.NewChunkIndexAt(getIndexDBPath())
if err != nil { if err != nil {
return fmt.Errorf("failed to open chunk index: %w", err) return fmt.Errorf("failed to open chunk index: %w", err)
} }
@@ -193,22 +341,43 @@ func runDedupBackup(cmd *cobra.Command, args []string) error {
} else { } else {
base := filepath.Base(inputPath) base := filepath.Base(inputPath)
ext := filepath.Ext(base) ext := filepath.Ext(base)
// Remove .gz extension if decompressing
if isGzipped && dedupDecompress {
base = strings.TrimSuffix(base, ext)
ext = filepath.Ext(base)
}
manifestID += "_" + strings.TrimSuffix(base, ext) manifestID += "_" + strings.TrimSuffix(base, ext)
} }
fmt.Printf("Creating deduplicated backup: %s\n", manifestID) fmt.Printf("Creating deduplicated backup: %s\n", manifestID)
fmt.Printf("Input: %s (%s)\n", inputPath, formatBytes(info.Size())) fmt.Printf("Input: %s (%s)\n", inputPath, formatBytes(info.Size()))
if isGzipped && dedupDecompress {
fmt.Printf("Mode: Decompressing before chunking\n")
}
fmt.Printf("Store: %s\n", basePath) fmt.Printf("Store: %s\n", basePath)
if dedupIndexDB != "" {
fmt.Printf("Index: %s\n", getIndexDBPath())
}
// Hash the entire file for verification // For decompressed input, we can't seek - use TeeReader to hash while chunking
file.Seek(0, 0)
h := sha256.New() h := sha256.New()
io.Copy(h, file) var chunkReader io.Reader
fileHash := hex.EncodeToString(h.Sum(nil))
file.Seek(0, 0) if isGzipped && dedupDecompress {
// Can't seek on gzip stream - hash will be computed inline
chunkReader = io.TeeReader(reader, h)
} else {
// Regular file - hash first, then reset and chunk
file.Seek(0, 0)
io.Copy(h, file)
file.Seek(0, 0)
chunkReader = file
h = sha256.New() // Reset for inline hashing
chunkReader = io.TeeReader(file, h)
}
// Chunk the file // Chunk the file
chunker := dedup.NewChunker(file, dedup.DefaultChunkerConfig()) chunker := dedup.NewChunker(chunkReader, dedup.DefaultChunkerConfig())
var chunks []dedup.ChunkRef var chunks []dedup.ChunkRef
var totalSize, storedSize int64 var totalSize, storedSize int64
var chunkCount, newChunks int var chunkCount, newChunks int
@@ -254,6 +423,9 @@ func runDedupBackup(cmd *cobra.Command, args []string) error {
duration := time.Since(startTime) duration := time.Since(startTime)
// Get final hash (computed inline via TeeReader)
fileHash := hex.EncodeToString(h.Sum(nil))
// Calculate dedup ratio // Calculate dedup ratio
dedupRatio := 0.0 dedupRatio := 0.0
if totalSize > 0 { if totalSize > 0 {
@@ -277,6 +449,7 @@ func runDedupBackup(cmd *cobra.Command, args []string) error {
Encrypted: dedupEncrypt, Encrypted: dedupEncrypt,
Compressed: dedupCompress, Compressed: dedupCompress,
SHA256: fileHash, SHA256: fileHash,
Decompressed: isGzipped && dedupDecompress, // Track if we decompressed
} }
if err := manifestStore.Save(manifest); err != nil { if err := manifestStore.Save(manifest); err != nil {
@@ -372,9 +545,9 @@ func runDedupRestore(cmd *cobra.Command, args []string) error {
// Verify hash // Verify hash
if manifest.SHA256 != "" { if manifest.SHA256 != "" {
if restoredHash == manifest.SHA256 { if restoredHash == manifest.SHA256 {
fmt.Printf(" Verification: SHA-256 matches\n") fmt.Printf(" Verification: [OK] SHA-256 matches\n")
} else { } else {
fmt.Printf(" Verification: SHA-256 MISMATCH!\n") fmt.Printf(" Verification: [FAIL] SHA-256 MISMATCH!\n")
fmt.Printf(" Expected: %s\n", manifest.SHA256) fmt.Printf(" Expected: %s\n", manifest.SHA256)
fmt.Printf(" Got: %s\n", restoredHash) fmt.Printf(" Got: %s\n", restoredHash)
return fmt.Errorf("integrity verification failed") return fmt.Errorf("integrity verification failed")
@@ -451,8 +624,12 @@ func runDedupStats(cmd *cobra.Command, args []string) error {
fmt.Printf("Unique chunks: %d\n", stats.TotalChunks) fmt.Printf("Unique chunks: %d\n", stats.TotalChunks)
fmt.Printf("Total raw size: %s\n", formatBytes(stats.TotalSizeRaw)) fmt.Printf("Total raw size: %s\n", formatBytes(stats.TotalSizeRaw))
fmt.Printf("Stored size: %s\n", formatBytes(stats.TotalSizeStored)) fmt.Printf("Stored size: %s\n", formatBytes(stats.TotalSizeStored))
fmt.Printf("Dedup ratio: %.1f%%\n", stats.DedupRatio*100) fmt.Printf("\n")
fmt.Printf("Space saved: %s\n", formatBytes(stats.TotalSizeRaw-stats.TotalSizeStored)) fmt.Printf("Backup Statistics (accurate dedup calculation):\n")
fmt.Printf(" Total backed up: %s (across all backups)\n", formatBytes(stats.TotalBackupSize))
fmt.Printf(" New data stored: %s\n", formatBytes(stats.TotalNewData))
fmt.Printf(" Space saved: %s\n", formatBytes(stats.SpaceSaved))
fmt.Printf(" Dedup ratio: %.1f%%\n", stats.DedupRatio*100)
if storeStats != nil { if storeStats != nil {
fmt.Printf("Disk usage: %s\n", formatBytes(storeStats.TotalSize)) fmt.Printf("Disk usage: %s\n", formatBytes(storeStats.TotalSize))
@@ -577,3 +754,531 @@ func truncateStr(s string, max int) string {
} }
return s[:max-3] + "..." return s[:max-3] + "..."
} }
func runDedupVerify(cmd *cobra.Command, args []string) error {
basePath := getDedupDir()
store, err := dedup.NewChunkStore(dedup.StoreConfig{
BasePath: basePath,
Compress: dedupCompress,
})
if err != nil {
return fmt.Errorf("failed to open chunk store: %w", err)
}
manifestStore, err := dedup.NewManifestStore(basePath)
if err != nil {
return fmt.Errorf("failed to open manifest store: %w", err)
}
index, err := dedup.NewChunkIndexAt(getIndexDBPath())
if err != nil {
return fmt.Errorf("failed to open chunk index: %w", err)
}
defer index.Close()
var manifests []*dedup.Manifest
if len(args) > 0 {
// Verify specific manifest
m, err := manifestStore.Load(args[0])
if err != nil {
return fmt.Errorf("failed to load manifest: %w", err)
}
manifests = []*dedup.Manifest{m}
} else {
// Verify all manifests
manifests, err = manifestStore.ListAll()
if err != nil {
return fmt.Errorf("failed to list manifests: %w", err)
}
}
if len(manifests) == 0 {
fmt.Println("No manifests to verify.")
return nil
}
fmt.Printf("Verifying %d backup(s)...\n\n", len(manifests))
var totalChunks, missingChunks, corruptChunks int
var allOK = true
for _, m := range manifests {
fmt.Printf("Verifying: %s (%d chunks)\n", m.ID, m.ChunkCount)
var missing, corrupt int
seenHashes := make(map[string]bool)
for i, ref := range m.Chunks {
if seenHashes[ref.Hash] {
continue // Already verified this chunk
}
seenHashes[ref.Hash] = true
totalChunks++
// Check if chunk exists
if !store.Has(ref.Hash) {
missing++
missingChunks++
if missing <= 5 {
fmt.Printf(" [MISSING] chunk %d: %s\n", i, ref.Hash[:16])
}
continue
}
// Verify chunk hash by reading it
chunk, err := store.Get(ref.Hash)
if err != nil {
corrupt++
corruptChunks++
if corrupt <= 5 {
fmt.Printf(" [CORRUPT] chunk %d: %s - %v\n", i, ref.Hash[:16], err)
}
continue
}
// Verify size
if chunk.Length != ref.Length {
corrupt++
corruptChunks++
if corrupt <= 5 {
fmt.Printf(" [SIZE MISMATCH] chunk %d: expected %d, got %d\n", i, ref.Length, chunk.Length)
}
}
}
if missing > 0 || corrupt > 0 {
allOK = false
fmt.Printf(" Result: FAILED (%d missing, %d corrupt)\n", missing, corrupt)
if missing > 5 || corrupt > 5 {
fmt.Printf(" ... and %d more errors\n", (missing+corrupt)-10)
}
} else {
fmt.Printf(" Result: OK (%d unique chunks verified)\n", len(seenHashes))
// Update verified timestamp
m.VerifiedAt = time.Now()
manifestStore.Save(m)
index.UpdateManifestVerified(m.ID, m.VerifiedAt)
}
fmt.Println()
}
fmt.Println("========================================")
if allOK {
fmt.Printf("All %d backup(s) verified successfully!\n", len(manifests))
fmt.Printf("Total unique chunks checked: %d\n", totalChunks)
} else {
fmt.Printf("Verification FAILED!\n")
fmt.Printf("Missing chunks: %d\n", missingChunks)
fmt.Printf("Corrupt chunks: %d\n", corruptChunks)
return fmt.Errorf("verification failed: %d missing, %d corrupt chunks", missingChunks, corruptChunks)
}
return nil
}
func runDedupPrune(cmd *cobra.Command, args []string) error {
if pruneKeepLast == 0 && pruneKeepDaily == 0 && pruneKeepWeekly == 0 {
return fmt.Errorf("at least one of --keep-last, --keep-daily, or --keep-weekly must be specified")
}
basePath := getDedupDir()
manifestStore, err := dedup.NewManifestStore(basePath)
if err != nil {
return fmt.Errorf("failed to open manifest store: %w", err)
}
index, err := dedup.NewChunkIndexAt(getIndexDBPath())
if err != nil {
return fmt.Errorf("failed to open chunk index: %w", err)
}
defer index.Close()
manifests, err := manifestStore.ListAll()
if err != nil {
return fmt.Errorf("failed to list manifests: %w", err)
}
if len(manifests) == 0 {
fmt.Println("No backups to prune.")
return nil
}
// Group by database name
byDatabase := make(map[string][]*dedup.Manifest)
for _, m := range manifests {
key := m.DatabaseName
if key == "" {
key = "_default"
}
byDatabase[key] = append(byDatabase[key], m)
}
var toDelete []*dedup.Manifest
for dbName, dbManifests := range byDatabase {
// Already sorted by time (newest first from ListAll)
kept := make(map[string]bool)
var keepReasons = make(map[string]string)
// Keep last N
if pruneKeepLast > 0 {
for i := 0; i < pruneKeepLast && i < len(dbManifests); i++ {
kept[dbManifests[i].ID] = true
keepReasons[dbManifests[i].ID] = "keep-last"
}
}
// Keep daily (one per day)
if pruneKeepDaily > 0 {
seenDays := make(map[string]bool)
count := 0
for _, m := range dbManifests {
day := m.CreatedAt.Format("2006-01-02")
if !seenDays[day] {
seenDays[day] = true
if count < pruneKeepDaily {
kept[m.ID] = true
if keepReasons[m.ID] == "" {
keepReasons[m.ID] = "keep-daily"
}
count++
}
}
}
}
// Keep weekly (one per week)
if pruneKeepWeekly > 0 {
seenWeeks := make(map[string]bool)
count := 0
for _, m := range dbManifests {
year, week := m.CreatedAt.ISOWeek()
weekKey := fmt.Sprintf("%d-W%02d", year, week)
if !seenWeeks[weekKey] {
seenWeeks[weekKey] = true
if count < pruneKeepWeekly {
kept[m.ID] = true
if keepReasons[m.ID] == "" {
keepReasons[m.ID] = "keep-weekly"
}
count++
}
}
}
}
if dbName != "_default" {
fmt.Printf("\nDatabase: %s\n", dbName)
} else {
fmt.Printf("\nUnnamed backups:\n")
}
for _, m := range dbManifests {
if kept[m.ID] {
fmt.Printf(" [KEEP] %s (%s) - %s\n", m.ID, m.CreatedAt.Format("2006-01-02"), keepReasons[m.ID])
} else {
fmt.Printf(" [DELETE] %s (%s)\n", m.ID, m.CreatedAt.Format("2006-01-02"))
toDelete = append(toDelete, m)
}
}
}
if len(toDelete) == 0 {
fmt.Printf("\nNo backups to prune (all match retention policy).\n")
return nil
}
fmt.Printf("\n%d backup(s) will be deleted.\n", len(toDelete))
if pruneDryRun {
fmt.Println("\n[DRY RUN] No changes made. Remove --dry-run to actually delete.")
return nil
}
// Actually delete
for _, m := range toDelete {
// Decrement chunk references
for _, ref := range m.Chunks {
index.DecrementRef(ref.Hash)
}
if err := manifestStore.Delete(m.ID); err != nil {
log.Warn("Failed to delete manifest", "id", m.ID, "error", err)
}
index.RemoveManifest(m.ID)
}
fmt.Printf("\nDeleted %d backup(s).\n", len(toDelete))
fmt.Println("Run 'dbbackup dedup gc' to reclaim space from unreferenced chunks.")
return nil
}
func runDedupBackupDB(cmd *cobra.Command, args []string) error {
dbType := strings.ToLower(dedupDBType)
dbName := backupDBDatabase
// Validate db type
var dumpCmd string
var dumpArgs []string
switch dbType {
case "postgres", "postgresql", "pg":
dbType = "postgres"
dumpCmd = "pg_dump"
dumpArgs = []string{"-Fc"} // Custom format for better compression
if dedupDBHost != "" && dedupDBHost != "localhost" {
dumpArgs = append(dumpArgs, "-h", dedupDBHost)
}
if backupDBUser != "" {
dumpArgs = append(dumpArgs, "-U", backupDBUser)
}
dumpArgs = append(dumpArgs, dbName)
case "mysql":
dumpCmd = "mysqldump"
dumpArgs = []string{
"--single-transaction",
"--routines",
"--triggers",
"--events",
}
if dedupDBHost != "" {
dumpArgs = append(dumpArgs, "-h", dedupDBHost)
}
if backupDBUser != "" {
dumpArgs = append(dumpArgs, "-u", backupDBUser)
}
if backupDBPassword != "" {
dumpArgs = append(dumpArgs, "-p"+backupDBPassword)
}
dumpArgs = append(dumpArgs, dbName)
case "mariadb":
dumpCmd = "mariadb-dump"
// Fall back to mysqldump if mariadb-dump not available
if _, err := exec.LookPath(dumpCmd); err != nil {
dumpCmd = "mysqldump"
}
dumpArgs = []string{
"--single-transaction",
"--routines",
"--triggers",
"--events",
}
if dedupDBHost != "" {
dumpArgs = append(dumpArgs, "-h", dedupDBHost)
}
if backupDBUser != "" {
dumpArgs = append(dumpArgs, "-u", backupDBUser)
}
if backupDBPassword != "" {
dumpArgs = append(dumpArgs, "-p"+backupDBPassword)
}
dumpArgs = append(dumpArgs, dbName)
default:
return fmt.Errorf("unsupported database type: %s (use postgres, mysql, or mariadb)", dbType)
}
// Verify dump command exists
if _, err := exec.LookPath(dumpCmd); err != nil {
return fmt.Errorf("%s not found in PATH: %w", dumpCmd, err)
}
// Setup dedup storage
basePath := getDedupDir()
encKey := ""
if dedupEncrypt {
encKey = getEncryptionKey()
if encKey == "" {
return fmt.Errorf("encryption enabled but no key provided (use --key or DBBACKUP_DEDUP_KEY)")
}
}
store, err := dedup.NewChunkStore(dedup.StoreConfig{
BasePath: basePath,
Compress: dedupCompress,
EncryptionKey: encKey,
})
if err != nil {
return fmt.Errorf("failed to open chunk store: %w", err)
}
manifestStore, err := dedup.NewManifestStore(basePath)
if err != nil {
return fmt.Errorf("failed to open manifest store: %w", err)
}
index, err := dedup.NewChunkIndexAt(getIndexDBPath())
if err != nil {
return fmt.Errorf("failed to open chunk index: %w", err)
}
defer index.Close()
// Generate manifest ID
now := time.Now()
manifestID := now.Format("2006-01-02_150405") + "_" + dbName
fmt.Printf("Creating deduplicated database backup: %s\n", manifestID)
fmt.Printf("Database: %s (%s)\n", dbName, dbType)
fmt.Printf("Command: %s %s\n", dumpCmd, strings.Join(dumpArgs, " "))
fmt.Printf("Store: %s\n", basePath)
// Start the dump command
dumpExec := exec.Command(dumpCmd, dumpArgs...)
// Set password via environment for postgres
if dbType == "postgres" && backupDBPassword != "" {
dumpExec.Env = append(os.Environ(), "PGPASSWORD="+backupDBPassword)
}
stdout, err := dumpExec.StdoutPipe()
if err != nil {
return fmt.Errorf("failed to get stdout pipe: %w", err)
}
stderr, err := dumpExec.StderrPipe()
if err != nil {
return fmt.Errorf("failed to get stderr pipe: %w", err)
}
if err := dumpExec.Start(); err != nil {
return fmt.Errorf("failed to start %s: %w", dumpCmd, err)
}
// Hash while chunking using TeeReader
h := sha256.New()
reader := io.TeeReader(stdout, h)
// Chunk the stream directly
chunker := dedup.NewChunker(reader, dedup.DefaultChunkerConfig())
var chunks []dedup.ChunkRef
var totalSize, storedSize int64
var chunkCount, newChunks int
startTime := time.Now()
for {
chunk, err := chunker.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("chunking failed: %w", err)
}
chunkCount++
totalSize += int64(chunk.Length)
// Store chunk (deduplication happens here)
isNew, err := store.Put(chunk)
if err != nil {
return fmt.Errorf("failed to store chunk: %w", err)
}
if isNew {
newChunks++
storedSize += int64(chunk.Length)
index.AddChunk(chunk.Hash, chunk.Length, chunk.Length)
}
chunks = append(chunks, dedup.ChunkRef{
Hash: chunk.Hash,
Offset: chunk.Offset,
Length: chunk.Length,
})
if chunkCount%1000 == 0 {
fmt.Printf("\r Processed %d chunks, %d new, %s...", chunkCount, newChunks, formatBytes(totalSize))
}
}
// Read any stderr
stderrBytes, _ := io.ReadAll(stderr)
// Wait for command to complete
if err := dumpExec.Wait(); err != nil {
return fmt.Errorf("%s failed: %w\nstderr: %s", dumpCmd, err, string(stderrBytes))
}
duration := time.Since(startTime)
fileHash := hex.EncodeToString(h.Sum(nil))
// Calculate dedup ratio
dedupRatio := 0.0
if totalSize > 0 {
dedupRatio = 1.0 - float64(storedSize)/float64(totalSize)
}
// Create manifest
manifest := &dedup.Manifest{
ID: manifestID,
Name: dedupName,
CreatedAt: now,
DatabaseType: dbType,
DatabaseName: dbName,
DatabaseHost: dedupDBHost,
Chunks: chunks,
OriginalSize: totalSize,
StoredSize: storedSize,
ChunkCount: chunkCount,
NewChunks: newChunks,
DedupRatio: dedupRatio,
Encrypted: dedupEncrypt,
Compressed: dedupCompress,
SHA256: fileHash,
}
if err := manifestStore.Save(manifest); err != nil {
return fmt.Errorf("failed to save manifest: %w", err)
}
if err := index.AddManifest(manifest); err != nil {
log.Warn("Failed to index manifest", "error", err)
}
fmt.Printf("\r \r")
fmt.Printf("\nBackup complete!\n")
fmt.Printf(" Manifest: %s\n", manifestID)
fmt.Printf(" Chunks: %d total, %d new\n", chunkCount, newChunks)
fmt.Printf(" Dump size: %s\n", formatBytes(totalSize))
fmt.Printf(" Stored: %s (new data)\n", formatBytes(storedSize))
fmt.Printf(" Dedup ratio: %.1f%%\n", dedupRatio*100)
fmt.Printf(" Duration: %s\n", duration.Round(time.Millisecond))
fmt.Printf(" Throughput: %s/s\n", formatBytes(int64(float64(totalSize)/duration.Seconds())))
return nil
}
func runDedupMetrics(cmd *cobra.Command, args []string) error {
basePath := getDedupDir()
indexPath := getIndexDBPath()
instance := dedupMetricsInstance
if instance == "" {
hostname, _ := os.Hostname()
instance = hostname
}
metrics, err := dedup.CollectMetrics(basePath, indexPath)
if err != nil {
return fmt.Errorf("failed to collect metrics: %w", err)
}
output := dedup.FormatPrometheusMetrics(metrics, instance)
if dedupMetricsOutput != "" {
if err := dedup.WritePrometheusTextfile(dedupMetricsOutput, instance, basePath, indexPath); err != nil {
return fmt.Errorf("failed to write metrics: %w", err)
}
fmt.Printf("Wrote metrics to %s\n", dedupMetricsOutput)
} else {
fmt.Print(output)
}
return nil
}

View File

@@ -318,7 +318,7 @@ func runDrillList(cmd *cobra.Command, args []string) error {
} }
fmt.Printf("%-15s %-40s %-20s %s\n", "ID", "NAME", "IMAGE", "STATUS") fmt.Printf("%-15s %-40s %-20s %s\n", "ID", "NAME", "IMAGE", "STATUS")
fmt.Println(strings.Repeat("", 100)) fmt.Println(strings.Repeat("-", 100))
for _, c := range containers { for _, c := range containers {
fmt.Printf("%-15s %-40s %-20s %s\n", fmt.Printf("%-15s %-40s %-20s %s\n",
@@ -345,7 +345,7 @@ func runDrillCleanup(cmd *cobra.Command, args []string) error {
return err return err
} }
fmt.Println(" Cleanup completed") fmt.Println("[OK] Cleanup completed")
return nil return nil
} }
@@ -369,32 +369,32 @@ func runDrillReport(cmd *cobra.Command, args []string) error {
func printDrillResult(result *drill.DrillResult) { func printDrillResult(result *drill.DrillResult) {
fmt.Printf("\n") fmt.Printf("\n")
fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") fmt.Printf("=====================================================\n")
fmt.Printf(" DR Drill Report: %s\n", result.DrillID) fmt.Printf(" DR Drill Report: %s\n", result.DrillID)
fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n") fmt.Printf("=====================================================\n\n")
status := " PASSED" status := "[OK] PASSED"
if !result.Success { if !result.Success {
status = " FAILED" status = "[FAIL] FAILED"
} else if result.Status == drill.StatusPartial { } else if result.Status == drill.StatusPartial {
status = "⚠️ PARTIAL" status = "[WARN] PARTIAL"
} }
fmt.Printf("📋 Status: %s\n", status) fmt.Printf("[LOG] Status: %s\n", status)
fmt.Printf("💾 Backup: %s\n", filepath.Base(result.BackupPath)) fmt.Printf("[SAVE] Backup: %s\n", filepath.Base(result.BackupPath))
fmt.Printf("🗄️ Database: %s (%s)\n", result.DatabaseName, result.DatabaseType) fmt.Printf("[DB] Database: %s (%s)\n", result.DatabaseName, result.DatabaseType)
fmt.Printf("⏱️ Duration: %.2fs\n", result.Duration) fmt.Printf("[TIME] Duration: %.2fs\n", result.Duration)
fmt.Printf("📅 Started: %s\n", result.StartTime.Format(time.RFC3339)) fmt.Printf("📅 Started: %s\n", result.StartTime.Format(time.RFC3339))
fmt.Printf("\n") fmt.Printf("\n")
// Phases // Phases
fmt.Printf("📊 Phases:\n") fmt.Printf("[STATS] Phases:\n")
for _, phase := range result.Phases { for _, phase := range result.Phases {
icon := "" icon := "[OK]"
if phase.Status == "failed" { if phase.Status == "failed" {
icon = "" icon = "[FAIL]"
} else if phase.Status == "running" { } else if phase.Status == "running" {
icon = "🔄" icon = "[SYNC]"
} }
fmt.Printf(" %s %-20s (%.2fs) %s\n", icon, phase.Name, phase.Duration, phase.Message) fmt.Printf(" %s %-20s (%.2fs) %s\n", icon, phase.Name, phase.Duration, phase.Message)
} }
@@ -412,10 +412,10 @@ func printDrillResult(result *drill.DrillResult) {
fmt.Printf("\n") fmt.Printf("\n")
// RTO // RTO
fmt.Printf("⏱️ RTO Analysis:\n") fmt.Printf("[TIME] RTO Analysis:\n")
rtoIcon := "" rtoIcon := "[OK]"
if !result.RTOMet { if !result.RTOMet {
rtoIcon = "" rtoIcon = "[FAIL]"
} }
fmt.Printf(" Actual RTO: %.2fs\n", result.ActualRTO) fmt.Printf(" Actual RTO: %.2fs\n", result.ActualRTO)
fmt.Printf(" Target RTO: %.0fs\n", result.TargetRTO) fmt.Printf(" Target RTO: %.0fs\n", result.TargetRTO)
@@ -424,11 +424,11 @@ func printDrillResult(result *drill.DrillResult) {
// Validation results // Validation results
if len(result.ValidationResults) > 0 { if len(result.ValidationResults) > 0 {
fmt.Printf("🔍 Validation Queries:\n") fmt.Printf("[SEARCH] Validation Queries:\n")
for _, vr := range result.ValidationResults { for _, vr := range result.ValidationResults {
icon := "" icon := "[OK]"
if !vr.Success { if !vr.Success {
icon = "" icon = "[FAIL]"
} }
fmt.Printf(" %s %s: %s\n", icon, vr.Name, vr.Result) fmt.Printf(" %s %s: %s\n", icon, vr.Name, vr.Result)
if vr.Error != "" { if vr.Error != "" {
@@ -440,11 +440,11 @@ func printDrillResult(result *drill.DrillResult) {
// Check results // Check results
if len(result.CheckResults) > 0 { if len(result.CheckResults) > 0 {
fmt.Printf(" Checks:\n") fmt.Printf("[OK] Checks:\n")
for _, cr := range result.CheckResults { for _, cr := range result.CheckResults {
icon := "" icon := "[OK]"
if !cr.Success { if !cr.Success {
icon = "" icon = "[FAIL]"
} }
fmt.Printf(" %s %s\n", icon, cr.Message) fmt.Printf(" %s %s\n", icon, cr.Message)
} }
@@ -453,7 +453,7 @@ func printDrillResult(result *drill.DrillResult) {
// Errors and warnings // Errors and warnings
if len(result.Errors) > 0 { if len(result.Errors) > 0 {
fmt.Printf(" Errors:\n") fmt.Printf("[FAIL] Errors:\n")
for _, e := range result.Errors { for _, e := range result.Errors {
fmt.Printf(" • %s\n", e) fmt.Printf(" • %s\n", e)
} }
@@ -461,7 +461,7 @@ func printDrillResult(result *drill.DrillResult) {
} }
if len(result.Warnings) > 0 { if len(result.Warnings) > 0 {
fmt.Printf("⚠️ Warnings:\n") fmt.Printf("[WARN] Warnings:\n")
for _, w := range result.Warnings { for _, w := range result.Warnings {
fmt.Printf(" • %s\n", w) fmt.Printf(" • %s\n", w)
} }
@@ -470,14 +470,14 @@ func printDrillResult(result *drill.DrillResult) {
// Container info // Container info
if result.ContainerKept { if result.ContainerKept {
fmt.Printf("📦 Container kept: %s\n", result.ContainerID[:12]) fmt.Printf("[PKG] Container kept: %s\n", result.ContainerID[:12])
fmt.Printf(" Connect with: docker exec -it %s bash\n", result.ContainerID[:12]) fmt.Printf(" Connect with: docker exec -it %s bash\n", result.ContainerID[:12])
fmt.Printf("\n") fmt.Printf("\n")
} }
fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") fmt.Printf("=====================================================\n")
fmt.Printf(" %s\n", result.Message) fmt.Printf(" %s\n", result.Message)
fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") fmt.Printf("=====================================================\n")
} }
func updateCatalogWithDrillResult(ctx context.Context, backupPath string, result *drill.DrillResult) { func updateCatalogWithDrillResult(ctx context.Context, backupPath string, result *drill.DrillResult) {

View File

@@ -63,9 +63,9 @@ func runEngineList(cmd *cobra.Command, args []string) error {
continue continue
} }
status := " Available" status := "[Y] Available"
if !avail.Available { if !avail.Available {
status = " Not available" status = "[N] Not available"
} }
fmt.Printf("\n%s (%s)\n", info.Name, info.Description) fmt.Printf("\n%s (%s)\n", info.Name, info.Description)

View File

@@ -176,12 +176,12 @@ func runInstallStatus(ctx context.Context) error {
} }
fmt.Println() fmt.Println()
fmt.Println("📦 DBBackup Installation Status") fmt.Println("[STATUS] DBBackup Installation Status")
fmt.Println(strings.Repeat("", 50)) fmt.Println(strings.Repeat("=", 50))
if clusterStatus.Installed { if clusterStatus.Installed {
fmt.Println() fmt.Println()
fmt.Println("🔹 Cluster Backup:") fmt.Println(" * Cluster Backup:")
fmt.Printf(" Service: %s\n", formatStatus(clusterStatus.Installed, clusterStatus.Active)) fmt.Printf(" Service: %s\n", formatStatus(clusterStatus.Installed, clusterStatus.Active))
fmt.Printf(" Timer: %s\n", formatStatus(clusterStatus.TimerEnabled, clusterStatus.TimerActive)) fmt.Printf(" Timer: %s\n", formatStatus(clusterStatus.TimerEnabled, clusterStatus.TimerActive))
if clusterStatus.NextRun != "" { if clusterStatus.NextRun != "" {
@@ -192,7 +192,7 @@ func runInstallStatus(ctx context.Context) error {
} }
} else { } else {
fmt.Println() fmt.Println()
fmt.Println(" No systemd services installed") fmt.Println("[NONE] No systemd services installed")
fmt.Println() fmt.Println()
fmt.Println("Run 'sudo dbbackup install' to install as a systemd service") fmt.Println("Run 'sudo dbbackup install' to install as a systemd service")
} }
@@ -200,13 +200,13 @@ func runInstallStatus(ctx context.Context) error {
// Check for exporter // Check for exporter
if _, err := os.Stat("/etc/systemd/system/dbbackup-exporter.service"); err == nil { if _, err := os.Stat("/etc/systemd/system/dbbackup-exporter.service"); err == nil {
fmt.Println() fmt.Println()
fmt.Println("🔹 Metrics Exporter:") fmt.Println(" * Metrics Exporter:")
// Check if exporter is active using systemctl // Check if exporter is active using systemctl
cmd := exec.CommandContext(ctx, "systemctl", "is-active", "dbbackup-exporter") cmd := exec.CommandContext(ctx, "systemctl", "is-active", "dbbackup-exporter")
if err := cmd.Run(); err == nil { if err := cmd.Run(); err == nil {
fmt.Printf(" Service: active\n") fmt.Printf(" Service: [OK] active\n")
} else { } else {
fmt.Printf(" Service: inactive\n") fmt.Printf(" Service: [-] inactive\n")
} }
} }
@@ -219,9 +219,9 @@ func formatStatus(installed, active bool) string {
return "not installed" return "not installed"
} }
if active { if active {
return " active" return "[OK] active"
} }
return " inactive" return "[-] inactive"
} }
func expandSchedule(schedule string) string { func expandSchedule(schedule string) string {

View File

@@ -436,7 +436,7 @@ func runPITREnable(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to enable PITR: %w", err) return fmt.Errorf("failed to enable PITR: %w", err)
} }
log.Info(" PITR enabled successfully!") log.Info("[OK] PITR enabled successfully!")
log.Info("") log.Info("")
log.Info("Next steps:") log.Info("Next steps:")
log.Info("1. Restart PostgreSQL: sudo systemctl restart postgresql") log.Info("1. Restart PostgreSQL: sudo systemctl restart postgresql")
@@ -463,7 +463,7 @@ func runPITRDisable(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to disable PITR: %w", err) return fmt.Errorf("failed to disable PITR: %w", err)
} }
log.Info(" PITR disabled successfully!") log.Info("[OK] PITR disabled successfully!")
log.Info("PostgreSQL restart required: sudo systemctl restart postgresql") log.Info("PostgreSQL restart required: sudo systemctl restart postgresql")
return nil return nil
@@ -483,15 +483,15 @@ func runPITRStatus(cmd *cobra.Command, args []string) error {
} }
// Display PITR configuration // Display PITR configuration
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") fmt.Println("======================================================")
fmt.Println(" Point-in-Time Recovery (PITR) Status") fmt.Println(" Point-in-Time Recovery (PITR) Status")
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") fmt.Println("======================================================")
fmt.Println() fmt.Println()
if config.Enabled { if config.Enabled {
fmt.Println("Status: ENABLED") fmt.Println("Status: [OK] ENABLED")
} else { } else {
fmt.Println("Status: DISABLED") fmt.Println("Status: [FAIL] DISABLED")
} }
fmt.Printf("WAL Level: %s\n", config.WALLevel) fmt.Printf("WAL Level: %s\n", config.WALLevel)
@@ -510,7 +510,7 @@ func runPITRStatus(cmd *cobra.Command, args []string) error {
// Extract archive dir from command (simple parsing) // Extract archive dir from command (simple parsing)
fmt.Println() fmt.Println()
fmt.Println("WAL Archive Statistics:") fmt.Println("WAL Archive Statistics:")
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") fmt.Println("======================================================")
// TODO: Parse archive dir and show stats // TODO: Parse archive dir and show stats
fmt.Println(" (Use 'dbbackup wal list --archive-dir <dir>' to view archives)") fmt.Println(" (Use 'dbbackup wal list --archive-dir <dir>' to view archives)")
} }
@@ -574,13 +574,13 @@ func runWALList(cmd *cobra.Command, args []string) error {
} }
// Display archives // Display archives
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") fmt.Println("======================================================")
fmt.Printf(" WAL Archives (%d files)\n", len(archives)) fmt.Printf(" WAL Archives (%d files)\n", len(archives))
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") fmt.Println("======================================================")
fmt.Println() fmt.Println()
fmt.Printf("%-28s %10s %10s %8s %s\n", "WAL Filename", "Timeline", "Segment", "Size", "Archived At") fmt.Printf("%-28s %10s %10s %8s %s\n", "WAL Filename", "Timeline", "Segment", "Size", "Archived At")
fmt.Println("────────────────────────────────────────────────────────────────────────────────") fmt.Println("--------------------------------------------------------------------------------")
for _, archive := range archives { for _, archive := range archives {
size := formatWALSize(archive.ArchivedSize) size := formatWALSize(archive.ArchivedSize)
@@ -644,7 +644,7 @@ func runWALCleanup(cmd *cobra.Command, args []string) error {
return fmt.Errorf("WAL cleanup failed: %w", err) return fmt.Errorf("WAL cleanup failed: %w", err)
} }
log.Info(" WAL cleanup completed", "deleted", deleted, "retention_days", archiveConfig.RetentionDays) log.Info("[OK] WAL cleanup completed", "deleted", deleted, "retention_days", archiveConfig.RetentionDays)
return nil return nil
} }
@@ -671,7 +671,7 @@ func runWALTimeline(cmd *cobra.Command, args []string) error {
// Display timeline details // Display timeline details
if len(history.Timelines) > 0 { if len(history.Timelines) > 0 {
fmt.Println("\nTimeline Details:") fmt.Println("\nTimeline Details:")
fmt.Println("═════════════════") fmt.Println("=================")
for _, tl := range history.Timelines { for _, tl := range history.Timelines {
fmt.Printf("\nTimeline %d:\n", tl.TimelineID) fmt.Printf("\nTimeline %d:\n", tl.TimelineID)
if tl.ParentTimeline > 0 { if tl.ParentTimeline > 0 {
@@ -690,7 +690,7 @@ func runWALTimeline(cmd *cobra.Command, args []string) error {
fmt.Printf(" Created: %s\n", tl.CreatedAt.Format("2006-01-02 15:04:05")) fmt.Printf(" Created: %s\n", tl.CreatedAt.Format("2006-01-02 15:04:05"))
} }
if tl.TimelineID == history.CurrentTimeline { if tl.TimelineID == history.CurrentTimeline {
fmt.Printf(" Status: CURRENT\n") fmt.Printf(" Status: [CURR] CURRENT\n")
} }
} }
} }
@@ -759,15 +759,15 @@ func runBinlogList(cmd *cobra.Command, args []string) error {
return nil return nil
} }
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") fmt.Println("=============================================================")
fmt.Printf(" Binary Log Files (%s)\n", bm.ServerType()) fmt.Printf(" Binary Log Files (%s)\n", bm.ServerType())
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") fmt.Println("=============================================================")
fmt.Println() fmt.Println()
if len(binlogs) > 0 { if len(binlogs) > 0 {
fmt.Println("Source Directory:") fmt.Println("Source Directory:")
fmt.Printf("%-24s %10s %-19s %-19s %s\n", "Filename", "Size", "Start Time", "End Time", "Format") fmt.Printf("%-24s %10s %-19s %-19s %s\n", "Filename", "Size", "Start Time", "End Time", "Format")
fmt.Println("────────────────────────────────────────────────────────────────────────────────") fmt.Println("--------------------------------------------------------------------------------")
var totalSize int64 var totalSize int64
for _, b := range binlogs { for _, b := range binlogs {
@@ -797,7 +797,7 @@ func runBinlogList(cmd *cobra.Command, args []string) error {
fmt.Println() fmt.Println()
fmt.Println("Archived Binlogs:") fmt.Println("Archived Binlogs:")
fmt.Printf("%-24s %10s %-19s %s\n", "Original", "Size", "Archived At", "Flags") fmt.Printf("%-24s %10s %-19s %s\n", "Original", "Size", "Archived At", "Flags")
fmt.Println("────────────────────────────────────────────────────────────────────────────────") fmt.Println("--------------------------------------------------------------------------------")
var totalSize int64 var totalSize int64
for _, a := range archived { for _, a := range archived {
@@ -914,7 +914,7 @@ func runBinlogArchive(cmd *cobra.Command, args []string) error {
bm.SaveArchiveMetadata(allArchived) bm.SaveArchiveMetadata(allArchived)
} }
log.Info(" Binlog archiving completed", "archived", len(newArchives)) log.Info("[OK] Binlog archiving completed", "archived", len(newArchives))
return nil return nil
} }
@@ -1014,15 +1014,15 @@ func runBinlogValidate(cmd *cobra.Command, args []string) error {
return fmt.Errorf("validating binlog chain: %w", err) return fmt.Errorf("validating binlog chain: %w", err)
} }
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") fmt.Println("=============================================================")
fmt.Println(" Binlog Chain Validation") fmt.Println(" Binlog Chain Validation")
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") fmt.Println("=============================================================")
fmt.Println() fmt.Println()
if validation.Valid { if validation.Valid {
fmt.Println("Status: VALID - Binlog chain is complete") fmt.Println("Status: [OK] VALID - Binlog chain is complete")
} else { } else {
fmt.Println("Status: INVALID - Binlog chain has gaps") fmt.Println("Status: [FAIL] INVALID - Binlog chain has gaps")
} }
fmt.Printf("Files: %d binlog files\n", validation.LogCount) fmt.Printf("Files: %d binlog files\n", validation.LogCount)
@@ -1055,7 +1055,7 @@ func runBinlogValidate(cmd *cobra.Command, args []string) error {
fmt.Println() fmt.Println()
fmt.Println("Errors:") fmt.Println("Errors:")
for _, e := range validation.Errors { for _, e := range validation.Errors {
fmt.Printf(" %s\n", e) fmt.Printf(" [FAIL] %s\n", e)
} }
} }
@@ -1094,9 +1094,9 @@ func runBinlogPosition(cmd *cobra.Command, args []string) error {
} }
defer rows.Close() defer rows.Close()
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") fmt.Println("=============================================================")
fmt.Println(" Current Binary Log Position") fmt.Println(" Current Binary Log Position")
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") fmt.Println("=============================================================")
fmt.Println() fmt.Println()
if rows.Next() { if rows.Next() {
@@ -1178,24 +1178,24 @@ func runMySQLPITRStatus(cmd *cobra.Command, args []string) error {
return fmt.Errorf("getting PITR status: %w", err) return fmt.Errorf("getting PITR status: %w", err)
} }
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") fmt.Println("=============================================================")
fmt.Printf(" MySQL/MariaDB PITR Status (%s)\n", status.DatabaseType) fmt.Printf(" MySQL/MariaDB PITR Status (%s)\n", status.DatabaseType)
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") fmt.Println("=============================================================")
fmt.Println() fmt.Println()
if status.Enabled { if status.Enabled {
fmt.Println("PITR Status: ENABLED") fmt.Println("PITR Status: [OK] ENABLED")
} else { } else {
fmt.Println("PITR Status: NOT CONFIGURED") fmt.Println("PITR Status: [FAIL] NOT CONFIGURED")
} }
// Get binary logging status // Get binary logging status
var logBin string var logBin string
db.QueryRowContext(ctx, "SELECT @@log_bin").Scan(&logBin) db.QueryRowContext(ctx, "SELECT @@log_bin").Scan(&logBin)
if logBin == "1" || logBin == "ON" { if logBin == "1" || logBin == "ON" {
fmt.Println("Binary Logging: ENABLED") fmt.Println("Binary Logging: [OK] ENABLED")
} else { } else {
fmt.Println("Binary Logging: DISABLED") fmt.Println("Binary Logging: [FAIL] DISABLED")
} }
fmt.Printf("Binlog Format: %s\n", status.LogLevel) fmt.Printf("Binlog Format: %s\n", status.LogLevel)
@@ -1205,14 +1205,14 @@ func runMySQLPITRStatus(cmd *cobra.Command, args []string) error {
if status.DatabaseType == pitr.DatabaseMariaDB { if status.DatabaseType == pitr.DatabaseMariaDB {
db.QueryRowContext(ctx, "SELECT @@gtid_current_pos").Scan(&gtidMode) db.QueryRowContext(ctx, "SELECT @@gtid_current_pos").Scan(&gtidMode)
if gtidMode != "" { if gtidMode != "" {
fmt.Println("GTID Mode: ENABLED") fmt.Println("GTID Mode: [OK] ENABLED")
} else { } else {
fmt.Println("GTID Mode: DISABLED") fmt.Println("GTID Mode: [FAIL] DISABLED")
} }
} else { } else {
db.QueryRowContext(ctx, "SELECT @@gtid_mode").Scan(&gtidMode) db.QueryRowContext(ctx, "SELECT @@gtid_mode").Scan(&gtidMode)
if gtidMode == "ON" { if gtidMode == "ON" {
fmt.Println("GTID Mode: ENABLED") fmt.Println("GTID Mode: [OK] ENABLED")
} else { } else {
fmt.Printf("GTID Mode: %s\n", gtidMode) fmt.Printf("GTID Mode: %s\n", gtidMode)
} }
@@ -1237,12 +1237,12 @@ func runMySQLPITRStatus(cmd *cobra.Command, args []string) error {
fmt.Println() fmt.Println()
fmt.Println("PITR Requirements:") fmt.Println("PITR Requirements:")
if logBin == "1" || logBin == "ON" { if logBin == "1" || logBin == "ON" {
fmt.Println(" Binary logging enabled") fmt.Println(" [OK] Binary logging enabled")
} else { } else {
fmt.Println(" Binary logging must be enabled (log_bin = mysql-bin)") fmt.Println(" [FAIL] Binary logging must be enabled (log_bin = mysql-bin)")
} }
if status.LogLevel == "ROW" { if status.LogLevel == "ROW" {
fmt.Println(" Row-based logging (recommended)") fmt.Println(" [OK] Row-based logging (recommended)")
} else { } else {
fmt.Printf(" ⚠ binlog_format = %s (ROW recommended for PITR)\n", status.LogLevel) fmt.Printf(" ⚠ binlog_format = %s (ROW recommended for PITR)\n", status.LogLevel)
} }
@@ -1299,7 +1299,7 @@ func runMySQLPITREnable(cmd *cobra.Command, args []string) error {
return fmt.Errorf("enabling PITR: %w", err) return fmt.Errorf("enabling PITR: %w", err)
} }
log.Info(" MySQL PITR enabled successfully!") log.Info("[OK] MySQL PITR enabled successfully!")
log.Info("") log.Info("")
log.Info("Next steps:") log.Info("Next steps:")
log.Info("1. Start binlog archiving: dbbackup binlog watch --archive-dir " + mysqlArchiveDir) log.Info("1. Start binlog archiving: dbbackup binlog watch --archive-dir " + mysqlArchiveDir)

View File

@@ -141,7 +141,7 @@ func runList(ctx context.Context) error {
continue continue
} }
fmt.Printf("📦 %s\n", file.Name) fmt.Printf("[FILE] %s\n", file.Name)
fmt.Printf(" Size: %s\n", formatFileSize(stat.Size())) fmt.Printf(" Size: %s\n", formatFileSize(stat.Size()))
fmt.Printf(" Modified: %s\n", stat.ModTime().Format("2006-01-02 15:04:05")) fmt.Printf(" Modified: %s\n", stat.ModTime().Format("2006-01-02 15:04:05"))
fmt.Printf(" Type: %s\n", getBackupType(file.Name)) fmt.Printf(" Type: %s\n", getBackupType(file.Name))
@@ -237,56 +237,56 @@ func runPreflight(ctx context.Context) error {
totalChecks := 6 totalChecks := 6
// 1. Database connectivity check // 1. Database connectivity check
fmt.Print("🔗 Database connectivity... ") fmt.Print("[1] Database connectivity... ")
if err := testDatabaseConnection(); err != nil { if err := testDatabaseConnection(); err != nil {
fmt.Printf(" FAILED: %v\n", err) fmt.Printf("[FAIL] FAILED: %v\n", err)
} else { } else {
fmt.Println(" PASSED") fmt.Println("[OK] PASSED")
checksPassed++ checksPassed++
} }
// 2. Required tools check // 2. Required tools check
fmt.Print("🛠️ Required tools (pg_dump/pg_restore)... ") fmt.Print("[2] Required tools (pg_dump/pg_restore)... ")
if err := checkRequiredTools(); err != nil { if err := checkRequiredTools(); err != nil {
fmt.Printf(" FAILED: %v\n", err) fmt.Printf("[FAIL] FAILED: %v\n", err)
} else { } else {
fmt.Println(" PASSED") fmt.Println("[OK] PASSED")
checksPassed++ checksPassed++
} }
// 3. Backup directory check // 3. Backup directory check
fmt.Print("📁 Backup directory access... ") fmt.Print("[3] Backup directory access... ")
if err := checkBackupDirectory(); err != nil { if err := checkBackupDirectory(); err != nil {
fmt.Printf(" FAILED: %v\n", err) fmt.Printf("[FAIL] FAILED: %v\n", err)
} else { } else {
fmt.Println(" PASSED") fmt.Println("[OK] PASSED")
checksPassed++ checksPassed++
} }
// 4. Disk space check // 4. Disk space check
fmt.Print("💾 Available disk space... ") fmt.Print("[4] Available disk space... ")
if err := checkDiskSpace(); err != nil { if err := checkDiskSpace(); err != nil {
fmt.Printf(" FAILED: %v\n", err) fmt.Printf("[FAIL] FAILED: %v\n", err)
} else { } else {
fmt.Println(" PASSED") fmt.Println("[OK] PASSED")
checksPassed++ checksPassed++
} }
// 5. Permissions check // 5. Permissions check
fmt.Print("🔐 File permissions... ") fmt.Print("[5] File permissions... ")
if err := checkPermissions(); err != nil { if err := checkPermissions(); err != nil {
fmt.Printf(" FAILED: %v\n", err) fmt.Printf("[FAIL] FAILED: %v\n", err)
} else { } else {
fmt.Println(" PASSED") fmt.Println("[OK] PASSED")
checksPassed++ checksPassed++
} }
// 6. CPU/Memory resources check // 6. CPU/Memory resources check
fmt.Print("🖥️ System resources... ") fmt.Print("[6] System resources... ")
if err := checkSystemResources(); err != nil { if err := checkSystemResources(); err != nil {
fmt.Printf(" FAILED: %v\n", err) fmt.Printf("[FAIL] FAILED: %v\n", err)
} else { } else {
fmt.Println(" PASSED") fmt.Println("[OK] PASSED")
checksPassed++ checksPassed++
} }
@@ -294,10 +294,10 @@ func runPreflight(ctx context.Context) error {
fmt.Printf("Results: %d/%d checks passed\n", checksPassed, totalChecks) fmt.Printf("Results: %d/%d checks passed\n", checksPassed, totalChecks)
if checksPassed == totalChecks { if checksPassed == totalChecks {
fmt.Println("🎉 All preflight checks passed! System is ready for backup operations.") fmt.Println("[SUCCESS] All preflight checks passed! System is ready for backup operations.")
return nil return nil
} else { } else {
fmt.Printf("⚠️ %d check(s) failed. Please address the issues before running backups.\n", totalChecks-checksPassed) fmt.Printf("[WARN] %d check(s) failed. Please address the issues before running backups.\n", totalChecks-checksPassed)
return fmt.Errorf("preflight checks failed: %d/%d passed", checksPassed, totalChecks) return fmt.Errorf("preflight checks failed: %d/%d passed", checksPassed, totalChecks)
} }
} }
@@ -414,44 +414,44 @@ func runRestore(ctx context.Context, archiveName string) error {
fmt.Println() fmt.Println()
// Show warning // Show warning
fmt.Println("⚠️ WARNING: This will restore data to the target database.") fmt.Println("[WARN] WARNING: This will restore data to the target database.")
fmt.Println(" Existing data may be overwritten or merged depending on the restore method.") fmt.Println(" Existing data may be overwritten or merged depending on the restore method.")
fmt.Println() fmt.Println()
// For safety, show what would be done without actually doing it // For safety, show what would be done without actually doing it
switch archiveType { switch archiveType {
case "Single Database (.dump)": case "Single Database (.dump)":
fmt.Println("🔄 Would execute: pg_restore to restore single database") fmt.Println("[EXEC] Would execute: pg_restore to restore single database")
fmt.Printf(" Command: pg_restore -h %s -p %d -U %s -d %s --verbose %s\n", fmt.Printf(" Command: pg_restore -h %s -p %d -U %s -d %s --verbose %s\n",
cfg.Host, cfg.Port, cfg.User, cfg.Database, archivePath) cfg.Host, cfg.Port, cfg.User, cfg.Database, archivePath)
case "Single Database (.dump.gz)": case "Single Database (.dump.gz)":
fmt.Println("🔄 Would execute: gunzip and pg_restore to restore single database") fmt.Println("[EXEC] Would execute: gunzip and pg_restore to restore single database")
fmt.Printf(" Command: gunzip -c %s | pg_restore -h %s -p %d -U %s -d %s --verbose\n", fmt.Printf(" Command: gunzip -c %s | pg_restore -h %s -p %d -U %s -d %s --verbose\n",
archivePath, cfg.Host, cfg.Port, cfg.User, cfg.Database) archivePath, cfg.Host, cfg.Port, cfg.User, cfg.Database)
case "SQL Script (.sql)": case "SQL Script (.sql)":
if cfg.IsPostgreSQL() { if cfg.IsPostgreSQL() {
fmt.Println("🔄 Would execute: psql to run SQL script") fmt.Println("[EXEC] Would execute: psql to run SQL script")
fmt.Printf(" Command: psql -h %s -p %d -U %s -d %s -f %s\n", fmt.Printf(" Command: psql -h %s -p %d -U %s -d %s -f %s\n",
cfg.Host, cfg.Port, cfg.User, cfg.Database, archivePath) cfg.Host, cfg.Port, cfg.User, cfg.Database, archivePath)
} else if cfg.IsMySQL() { } else if cfg.IsMySQL() {
fmt.Println("🔄 Would execute: mysql to run SQL script") fmt.Println("[EXEC] Would execute: mysql to run SQL script")
fmt.Printf(" Command: %s\n", mysqlRestoreCommand(archivePath, false)) fmt.Printf(" Command: %s\n", mysqlRestoreCommand(archivePath, false))
} else { } else {
fmt.Println("🔄 Would execute: SQL client to run script (database type unknown)") fmt.Println("[EXEC] Would execute: SQL client to run script (database type unknown)")
} }
case "SQL Script (.sql.gz)": case "SQL Script (.sql.gz)":
if cfg.IsPostgreSQL() { if cfg.IsPostgreSQL() {
fmt.Println("🔄 Would execute: gunzip and psql to run SQL script") fmt.Println("[EXEC] Would execute: gunzip and psql to run SQL script")
fmt.Printf(" Command: gunzip -c %s | psql -h %s -p %d -U %s -d %s\n", fmt.Printf(" Command: gunzip -c %s | psql -h %s -p %d -U %s -d %s\n",
archivePath, cfg.Host, cfg.Port, cfg.User, cfg.Database) archivePath, cfg.Host, cfg.Port, cfg.User, cfg.Database)
} else if cfg.IsMySQL() { } else if cfg.IsMySQL() {
fmt.Println("🔄 Would execute: gunzip and mysql to run SQL script") fmt.Println("[EXEC] Would execute: gunzip and mysql to run SQL script")
fmt.Printf(" Command: %s\n", mysqlRestoreCommand(archivePath, true)) fmt.Printf(" Command: %s\n", mysqlRestoreCommand(archivePath, true))
} else { } else {
fmt.Println("🔄 Would execute: gunzip and SQL client to run script (database type unknown)") fmt.Println("[EXEC] Would execute: gunzip and SQL client to run script (database type unknown)")
} }
case "Cluster Backup (.tar.gz)": case "Cluster Backup (.tar.gz)":
fmt.Println("🔄 Would execute: Extract and restore cluster backup") fmt.Println("[EXEC] Would execute: Extract and restore cluster backup")
fmt.Println(" Steps:") fmt.Println(" Steps:")
fmt.Println(" 1. Extract tar.gz archive") fmt.Println(" 1. Extract tar.gz archive")
fmt.Println(" 2. Restore global objects (roles, tablespaces)") fmt.Println(" 2. Restore global objects (roles, tablespaces)")
@@ -461,7 +461,7 @@ func runRestore(ctx context.Context, archiveName string) error {
} }
fmt.Println() fmt.Println()
fmt.Println("🛡️ SAFETY MODE: Restore command is in preview mode.") fmt.Println("[SAFETY] SAFETY MODE: Restore command is in preview mode.")
fmt.Println(" This shows what would be executed without making changes.") fmt.Println(" This shows what would be executed without making changes.")
fmt.Println(" To enable actual restore, add --confirm flag (not yet implemented).") fmt.Println(" To enable actual restore, add --confirm flag (not yet implemented).")
@@ -520,25 +520,25 @@ func runVerify(ctx context.Context, archiveName string) error {
checksPassed := 0 checksPassed := 0
// Basic file existence and readability // Basic file existence and readability
fmt.Print("📁 File accessibility... ") fmt.Print("[CHK] File accessibility... ")
if file, err := os.Open(archivePath); err != nil { if file, err := os.Open(archivePath); err != nil {
fmt.Printf(" FAILED: %v\n", err) fmt.Printf("[FAIL] FAILED: %v\n", err)
} else { } else {
file.Close() file.Close()
fmt.Println(" PASSED") fmt.Println("[OK] PASSED")
checksPassed++ checksPassed++
} }
checksRun++ checksRun++
// File size sanity check // File size sanity check
fmt.Print("📏 File size check... ") fmt.Print("[CHK] File size check... ")
if stat.Size() == 0 { if stat.Size() == 0 {
fmt.Println(" FAILED: File is empty") fmt.Println("[FAIL] FAILED: File is empty")
} else if stat.Size() < 100 { } else if stat.Size() < 100 {
fmt.Println("⚠️ WARNING: File is very small (< 100 bytes)") fmt.Println("[WARN] WARNING: File is very small (< 100 bytes)")
checksPassed++ checksPassed++
} else { } else {
fmt.Println(" PASSED") fmt.Println("[OK] PASSED")
checksPassed++ checksPassed++
} }
checksRun++ checksRun++
@@ -546,51 +546,51 @@ func runVerify(ctx context.Context, archiveName string) error {
// Type-specific verification // Type-specific verification
switch archiveType { switch archiveType {
case "Single Database (.dump)": case "Single Database (.dump)":
fmt.Print("🔍 PostgreSQL dump format check... ") fmt.Print("[CHK] PostgreSQL dump format check... ")
if err := verifyPgDump(archivePath); err != nil { if err := verifyPgDump(archivePath); err != nil {
fmt.Printf(" FAILED: %v\n", err) fmt.Printf("[FAIL] FAILED: %v\n", err)
} else { } else {
fmt.Println(" PASSED") fmt.Println("[OK] PASSED")
checksPassed++ checksPassed++
} }
checksRun++ checksRun++
case "Single Database (.dump.gz)": case "Single Database (.dump.gz)":
fmt.Print("🔍 PostgreSQL dump format check (gzip)... ") fmt.Print("[CHK] PostgreSQL dump format check (gzip)... ")
if err := verifyPgDumpGzip(archivePath); err != nil { if err := verifyPgDumpGzip(archivePath); err != nil {
fmt.Printf(" FAILED: %v\n", err) fmt.Printf("[FAIL] FAILED: %v\n", err)
} else { } else {
fmt.Println(" PASSED") fmt.Println("[OK] PASSED")
checksPassed++ checksPassed++
} }
checksRun++ checksRun++
case "SQL Script (.sql)": case "SQL Script (.sql)":
fmt.Print("📜 SQL script validation... ") fmt.Print("[CHK] SQL script validation... ")
if err := verifySqlScript(archivePath); err != nil { if err := verifySqlScript(archivePath); err != nil {
fmt.Printf(" FAILED: %v\n", err) fmt.Printf("[FAIL] FAILED: %v\n", err)
} else { } else {
fmt.Println(" PASSED") fmt.Println("[OK] PASSED")
checksPassed++ checksPassed++
} }
checksRun++ checksRun++
case "SQL Script (.sql.gz)": case "SQL Script (.sql.gz)":
fmt.Print("📜 SQL script validation (gzip)... ") fmt.Print("[CHK] SQL script validation (gzip)... ")
if err := verifyGzipSqlScript(archivePath); err != nil { if err := verifyGzipSqlScript(archivePath); err != nil {
fmt.Printf(" FAILED: %v\n", err) fmt.Printf("[FAIL] FAILED: %v\n", err)
} else { } else {
fmt.Println(" PASSED") fmt.Println("[OK] PASSED")
checksPassed++ checksPassed++
} }
checksRun++ checksRun++
case "Cluster Backup (.tar.gz)": case "Cluster Backup (.tar.gz)":
fmt.Print("📦 Archive extraction test... ") fmt.Print("[CHK] Archive extraction test... ")
if err := verifyTarGz(archivePath); err != nil { if err := verifyTarGz(archivePath); err != nil {
fmt.Printf(" FAILED: %v\n", err) fmt.Printf("[FAIL] FAILED: %v\n", err)
} else { } else {
fmt.Println(" PASSED") fmt.Println("[OK] PASSED")
checksPassed++ checksPassed++
} }
checksRun++ checksRun++
@@ -598,11 +598,11 @@ func runVerify(ctx context.Context, archiveName string) error {
// Check for metadata file // Check for metadata file
metadataPath := archivePath + ".info" metadataPath := archivePath + ".info"
fmt.Print("📋 Metadata file check... ") fmt.Print("[CHK] Metadata file check... ")
if _, err := os.Stat(metadataPath); os.IsNotExist(err) { if _, err := os.Stat(metadataPath); os.IsNotExist(err) {
fmt.Println("⚠️ WARNING: No metadata file found") fmt.Println("[WARN] WARNING: No metadata file found")
} else { } else {
fmt.Println(" PASSED") fmt.Println("[OK] PASSED")
checksPassed++ checksPassed++
} }
checksRun++ checksRun++
@@ -611,13 +611,13 @@ func runVerify(ctx context.Context, archiveName string) error {
fmt.Printf("Verification Results: %d/%d checks passed\n", checksPassed, checksRun) fmt.Printf("Verification Results: %d/%d checks passed\n", checksPassed, checksRun)
if checksPassed == checksRun { if checksPassed == checksRun {
fmt.Println("🎉 Archive verification completed successfully!") fmt.Println("[SUCCESS] Archive verification completed successfully!")
return nil return nil
} else if float64(checksPassed)/float64(checksRun) >= 0.8 { } else if float64(checksPassed)/float64(checksRun) >= 0.8 {
fmt.Println("⚠️ Archive verification completed with warnings.") fmt.Println("[WARN] Archive verification completed with warnings.")
return nil return nil
} else { } else {
fmt.Println(" Archive verification failed. Archive may be corrupted.") fmt.Println("[FAIL] Archive verification failed. Archive may be corrupted.")
return fmt.Errorf("verification failed: %d/%d checks passed", checksPassed, checksRun) return fmt.Errorf("verification failed: %d/%d checks passed", checksPassed, checksRun)
} }
} }

View File

@@ -37,9 +37,9 @@ var (
restoreSaveDebugLog string // Path to save debug log on failure restoreSaveDebugLog string // Path to save debug log on failure
// Diagnose flags // Diagnose flags
diagnoseJSON bool diagnoseJSON bool
diagnoseDeep bool diagnoseDeep bool
diagnoseKeepTemp bool diagnoseKeepTemp bool
// Encryption flags // Encryption flags
restoreEncryptionKeyFile string restoreEncryptionKeyFile string
@@ -342,7 +342,7 @@ func runRestoreDiagnose(cmd *cobra.Command, args []string) error {
return fmt.Errorf("archive not found: %s", archivePath) return fmt.Errorf("archive not found: %s", archivePath)
} }
log.Info("🔍 Diagnosing backup file", "path", archivePath) log.Info("[DIAG] Diagnosing backup file", "path", archivePath)
diagnoser := restore.NewDiagnoser(log, restoreVerbose) diagnoser := restore.NewDiagnoser(log, restoreVerbose)
@@ -387,7 +387,7 @@ func runRestoreDiagnose(cmd *cobra.Command, args []string) error {
// Summary // Summary
if !diagnoseJSON { if !diagnoseJSON {
fmt.Println("\n" + strings.Repeat("=", 70)) fmt.Println("\n" + strings.Repeat("=", 70))
fmt.Printf("📊 CLUSTER SUMMARY: %d databases analyzed\n", len(results)) fmt.Printf("[SUMMARY] CLUSTER SUMMARY: %d databases analyzed\n", len(results))
validCount := 0 validCount := 0
for _, r := range results { for _, r := range results {
@@ -397,9 +397,9 @@ func runRestoreDiagnose(cmd *cobra.Command, args []string) error {
} }
if validCount == len(results) { if validCount == len(results) {
fmt.Println(" All dumps are valid") fmt.Println("[OK] All dumps are valid")
} else { } else {
fmt.Printf(" %d/%d dumps have issues\n", len(results)-validCount, len(results)) fmt.Printf("[FAIL] %d/%d dumps have issues\n", len(results)-validCount, len(results))
} }
fmt.Println(strings.Repeat("=", 70)) fmt.Println(strings.Repeat("=", 70))
} }
@@ -426,7 +426,7 @@ func runRestoreDiagnose(cmd *cobra.Command, args []string) error {
return fmt.Errorf("backup file has validation errors") return fmt.Errorf("backup file has validation errors")
} }
log.Info(" Backup file appears valid") log.Info("[OK] Backup file appears valid")
return nil return nil
} }
@@ -545,7 +545,7 @@ func runRestoreSingle(cmd *cobra.Command, args []string) error {
isDryRun := restoreDryRun || !restoreConfirm isDryRun := restoreDryRun || !restoreConfirm
if isDryRun { if isDryRun {
fmt.Println("\n🔍 DRY-RUN MODE - No changes will be made") fmt.Println("\n[DRY-RUN] DRY-RUN MODE - No changes will be made")
fmt.Printf("\nWould restore:\n") fmt.Printf("\nWould restore:\n")
fmt.Printf(" Archive: %s\n", archivePath) fmt.Printf(" Archive: %s\n", archivePath)
fmt.Printf(" Format: %s\n", format.String()) fmt.Printf(" Format: %s\n", format.String())
@@ -565,7 +565,7 @@ func runRestoreSingle(cmd *cobra.Command, args []string) error {
// Create restore engine // Create restore engine
engine := restore.New(cfg, log, db) engine := restore.New(cfg, log, db)
// Enable debug logging if requested // Enable debug logging if requested
if restoreSaveDebugLog != "" { if restoreSaveDebugLog != "" {
engine.SetDebugLogPath(restoreSaveDebugLog) engine.SetDebugLogPath(restoreSaveDebugLog)
@@ -588,18 +588,18 @@ func runRestoreSingle(cmd *cobra.Command, args []string) error {
// Run pre-restore diagnosis if requested // Run pre-restore diagnosis if requested
if restoreDiagnose { if restoreDiagnose {
log.Info("🔍 Running pre-restore diagnosis...") log.Info("[DIAG] Running pre-restore diagnosis...")
diagnoser := restore.NewDiagnoser(log, restoreVerbose) diagnoser := restore.NewDiagnoser(log, restoreVerbose)
result, err := diagnoser.DiagnoseFile(archivePath) result, err := diagnoser.DiagnoseFile(archivePath)
if err != nil { if err != nil {
return fmt.Errorf("diagnosis failed: %w", err) return fmt.Errorf("diagnosis failed: %w", err)
} }
diagnoser.PrintDiagnosis(result) diagnoser.PrintDiagnosis(result)
if !result.IsValid { if !result.IsValid {
log.Error(" Pre-restore diagnosis found issues") log.Error("[FAIL] Pre-restore diagnosis found issues")
if result.IsTruncated { if result.IsTruncated {
log.Error(" The backup file appears to be TRUNCATED") log.Error(" The backup file appears to be TRUNCATED")
} }
@@ -607,13 +607,13 @@ func runRestoreSingle(cmd *cobra.Command, args []string) error {
log.Error(" The backup file appears to be CORRUPTED") log.Error(" The backup file appears to be CORRUPTED")
} }
fmt.Println("\nUse --force to attempt restore anyway.") fmt.Println("\nUse --force to attempt restore anyway.")
if !restoreForce { if !restoreForce {
return fmt.Errorf("aborting restore due to backup file issues") return fmt.Errorf("aborting restore due to backup file issues")
} }
log.Warn("Continuing despite diagnosis errors (--force enabled)") log.Warn("Continuing despite diagnosis errors (--force enabled)")
} else { } else {
log.Info(" Backup file passed diagnosis") log.Info("[OK] Backup file passed diagnosis")
} }
} }
@@ -633,7 +633,7 @@ func runRestoreSingle(cmd *cobra.Command, args []string) error {
// Audit log: restore success // Audit log: restore success
auditLogger.LogRestoreComplete(user, targetDB, time.Since(startTime)) auditLogger.LogRestoreComplete(user, targetDB, time.Since(startTime))
log.Info(" Restore completed successfully", "database", targetDB) log.Info("[OK] Restore completed successfully", "database", targetDB)
return nil return nil
} }
@@ -701,7 +701,7 @@ func runRestoreCluster(cmd *cobra.Command, args []string) error {
} }
} }
log.Warn("⚠️ Using alternative working directory for extraction") log.Warn("[WARN] Using alternative working directory for extraction")
log.Warn(" This is recommended when system disk space is limited") log.Warn(" This is recommended when system disk space is limited")
log.Warn(" Location: " + restoreWorkdir) log.Warn(" Location: " + restoreWorkdir)
} }
@@ -754,7 +754,7 @@ func runRestoreCluster(cmd *cobra.Command, args []string) error {
isDryRun := restoreDryRun || !restoreConfirm isDryRun := restoreDryRun || !restoreConfirm
if isDryRun { if isDryRun {
fmt.Println("\n🔍 DRY-RUN MODE - No changes will be made") fmt.Println("\n[DRY-RUN] DRY-RUN MODE - No changes will be made")
fmt.Printf("\nWould restore cluster:\n") fmt.Printf("\nWould restore cluster:\n")
fmt.Printf(" Archive: %s\n", archivePath) fmt.Printf(" Archive: %s\n", archivePath)
fmt.Printf(" Parallel Jobs: %d (0 = auto)\n", restoreJobs) fmt.Printf(" Parallel Jobs: %d (0 = auto)\n", restoreJobs)
@@ -764,7 +764,7 @@ func runRestoreCluster(cmd *cobra.Command, args []string) error {
if restoreCleanCluster { if restoreCleanCluster {
fmt.Printf(" Clean Cluster: true (will drop %d existing database(s))\n", len(existingDBs)) fmt.Printf(" Clean Cluster: true (will drop %d existing database(s))\n", len(existingDBs))
if len(existingDBs) > 0 { if len(existingDBs) > 0 {
fmt.Printf("\n⚠️ Databases to be dropped:\n") fmt.Printf("\n[WARN] Databases to be dropped:\n")
for _, dbName := range existingDBs { for _, dbName := range existingDBs {
fmt.Printf(" - %s\n", dbName) fmt.Printf(" - %s\n", dbName)
} }
@@ -776,7 +776,7 @@ func runRestoreCluster(cmd *cobra.Command, args []string) error {
// Warning for clean-cluster // Warning for clean-cluster
if restoreCleanCluster && len(existingDBs) > 0 { if restoreCleanCluster && len(existingDBs) > 0 {
log.Warn("🔥 Clean cluster mode enabled") log.Warn("[!!] Clean cluster mode enabled")
log.Warn(fmt.Sprintf(" %d existing database(s) will be DROPPED before restore!", len(existingDBs))) log.Warn(fmt.Sprintf(" %d existing database(s) will be DROPPED before restore!", len(existingDBs)))
for _, dbName := range existingDBs { for _, dbName := range existingDBs {
log.Warn(" - " + dbName) log.Warn(" - " + dbName)
@@ -785,7 +785,7 @@ func runRestoreCluster(cmd *cobra.Command, args []string) error {
// Create restore engine // Create restore engine
engine := restore.New(cfg, log, db) engine := restore.New(cfg, log, db)
// Enable debug logging if requested // Enable debug logging if requested
if restoreSaveDebugLog != "" { if restoreSaveDebugLog != "" {
engine.SetDebugLogPath(restoreSaveDebugLog) engine.SetDebugLogPath(restoreSaveDebugLog)
@@ -829,8 +829,8 @@ func runRestoreCluster(cmd *cobra.Command, args []string) error {
// Run pre-restore diagnosis if requested // Run pre-restore diagnosis if requested
if restoreDiagnose { if restoreDiagnose {
log.Info("🔍 Running pre-restore diagnosis...") log.Info("[DIAG] Running pre-restore diagnosis...")
// Create temp directory for extraction in configured WorkDir // Create temp directory for extraction in configured WorkDir
workDir := cfg.GetEffectiveWorkDir() workDir := cfg.GetEffectiveWorkDir()
diagTempDir, err := os.MkdirTemp(workDir, "dbbackup-diagnose-*") diagTempDir, err := os.MkdirTemp(workDir, "dbbackup-diagnose-*")
@@ -838,13 +838,13 @@ func runRestoreCluster(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to create temp directory for diagnosis in %s: %w", workDir, err) return fmt.Errorf("failed to create temp directory for diagnosis in %s: %w", workDir, err)
} }
defer os.RemoveAll(diagTempDir) defer os.RemoveAll(diagTempDir)
diagnoser := restore.NewDiagnoser(log, restoreVerbose) diagnoser := restore.NewDiagnoser(log, restoreVerbose)
results, err := diagnoser.DiagnoseClusterDumps(archivePath, diagTempDir) results, err := diagnoser.DiagnoseClusterDumps(archivePath, diagTempDir)
if err != nil { if err != nil {
return fmt.Errorf("diagnosis failed: %w", err) return fmt.Errorf("diagnosis failed: %w", err)
} }
// Check for any invalid dumps // Check for any invalid dumps
var invalidDumps []string var invalidDumps []string
for _, result := range results { for _, result := range results {
@@ -853,24 +853,24 @@ func runRestoreCluster(cmd *cobra.Command, args []string) error {
diagnoser.PrintDiagnosis(result) diagnoser.PrintDiagnosis(result)
} }
} }
if len(invalidDumps) > 0 { if len(invalidDumps) > 0 {
log.Error(" Pre-restore diagnosis found issues", log.Error("[FAIL] Pre-restore diagnosis found issues",
"invalid_dumps", len(invalidDumps), "invalid_dumps", len(invalidDumps),
"total_dumps", len(results)) "total_dumps", len(results))
fmt.Println("\n⚠️ The following dumps have issues and will likely fail during restore:") fmt.Println("\n[WARN] The following dumps have issues and will likely fail during restore:")
for _, name := range invalidDumps { for _, name := range invalidDumps {
fmt.Printf(" - %s\n", name) fmt.Printf(" - %s\n", name)
} }
fmt.Println("\nRun 'dbbackup restore diagnose <archive> --deep' for full details.") fmt.Println("\nRun 'dbbackup restore diagnose <archive> --deep' for full details.")
fmt.Println("Use --force to attempt restore anyway.") fmt.Println("Use --force to attempt restore anyway.")
if !restoreForce { if !restoreForce {
return fmt.Errorf("aborting restore due to %d invalid dump(s)", len(invalidDumps)) return fmt.Errorf("aborting restore due to %d invalid dump(s)", len(invalidDumps))
} }
log.Warn("Continuing despite diagnosis errors (--force enabled)") log.Warn("Continuing despite diagnosis errors (--force enabled)")
} else { } else {
log.Info(" All dumps passed diagnosis", "count", len(results)) log.Info("[OK] All dumps passed diagnosis", "count", len(results))
} }
} }
@@ -890,7 +890,7 @@ func runRestoreCluster(cmd *cobra.Command, args []string) error {
// Audit log: restore success // Audit log: restore success
auditLogger.LogRestoreComplete(user, "all_databases", time.Since(startTime)) auditLogger.LogRestoreComplete(user, "all_databases", time.Since(startTime))
log.Info(" Cluster restore completed successfully") log.Info("[OK] Cluster restore completed successfully")
return nil return nil
} }
@@ -939,7 +939,7 @@ func runRestoreList(cmd *cobra.Command, args []string) error {
} }
// Print header // Print header
fmt.Printf("\n📦 Available backup archives in %s\n\n", backupDir) fmt.Printf("\n[LIST] Available backup archives in %s\n\n", backupDir)
fmt.Printf("%-40s %-25s %-12s %-20s %s\n", fmt.Printf("%-40s %-25s %-12s %-20s %s\n",
"FILENAME", "FORMAT", "SIZE", "MODIFIED", "DATABASE") "FILENAME", "FORMAT", "SIZE", "MODIFIED", "DATABASE")
fmt.Println(strings.Repeat("-", 120)) fmt.Println(strings.Repeat("-", 120))
@@ -1056,9 +1056,9 @@ func runRestorePITR(cmd *cobra.Command, args []string) error {
} }
// Display recovery target info // Display recovery target info
log.Info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") log.Info("=====================================================")
log.Info(" Point-in-Time Recovery (PITR)") log.Info(" Point-in-Time Recovery (PITR)")
log.Info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") log.Info("=====================================================")
log.Info("") log.Info("")
log.Info(target.String()) log.Info(target.String())
log.Info("") log.Info("")
@@ -1082,6 +1082,6 @@ func runRestorePITR(cmd *cobra.Command, args []string) error {
return fmt.Errorf("PITR restore failed: %w", err) return fmt.Errorf("PITR restore failed: %w", err)
} }
log.Info(" PITR restore completed successfully") log.Info("[OK] PITR restore completed successfully")
return nil return nil
} }

View File

@@ -181,13 +181,13 @@ func runRTOStatus(cmd *cobra.Command, args []string) error {
// Display status // Display status
fmt.Println() fmt.Println()
fmt.Println("╔═══════════════════════════════════════════════════════════╗") fmt.Println("+-----------------------------------------------------------+")
fmt.Println(" RTO/RPO STATUS SUMMARY ") fmt.Println("| RTO/RPO STATUS SUMMARY |")
fmt.Println("╠═══════════════════════════════════════════════════════════╣") fmt.Println("+-----------------------------------------------------------+")
fmt.Printf(" Target RTO: %-15s Target RPO: %-15s \n", fmt.Printf("| Target RTO: %-15s Target RPO: %-15s |\n",
formatDuration(config.TargetRTO), formatDuration(config.TargetRTO),
formatDuration(config.TargetRPO)) formatDuration(config.TargetRPO))
fmt.Println("╠═══════════════════════════════════════════════════════════╣") fmt.Println("+-----------------------------------------------------------+")
// Compliance status // Compliance status
rpoRate := 0.0 rpoRate := 0.0
@@ -199,31 +199,31 @@ func runRTOStatus(cmd *cobra.Command, args []string) error {
fullRate = float64(summary.FullyCompliant) / float64(summary.TotalDatabases) * 100 fullRate = float64(summary.FullyCompliant) / float64(summary.TotalDatabases) * 100
} }
fmt.Printf(" Databases: %-5d \n", summary.TotalDatabases) fmt.Printf("| Databases: %-5d |\n", summary.TotalDatabases)
fmt.Printf(" RPO Compliant: %-5d (%.0f%%) \n", summary.RPOCompliant, rpoRate) fmt.Printf("| RPO Compliant: %-5d (%.0f%%) |\n", summary.RPOCompliant, rpoRate)
fmt.Printf(" RTO Compliant: %-5d (%.0f%%) \n", summary.RTOCompliant, rtoRate) fmt.Printf("| RTO Compliant: %-5d (%.0f%%) |\n", summary.RTOCompliant, rtoRate)
fmt.Printf(" Fully Compliant: %-3d (%.0f%%) \n", summary.FullyCompliant, fullRate) fmt.Printf("| Fully Compliant: %-3d (%.0f%%) |\n", summary.FullyCompliant, fullRate)
if summary.CriticalIssues > 0 { if summary.CriticalIssues > 0 {
fmt.Printf(" ⚠️ Critical Issues: %-3d \n", summary.CriticalIssues) fmt.Printf("| [WARN] Critical Issues: %-3d |\n", summary.CriticalIssues)
} }
fmt.Println("╠═══════════════════════════════════════════════════════════╣") fmt.Println("+-----------------------------------------------------------+")
fmt.Printf(" Average RPO: %-15s Worst: %-15s \n", fmt.Printf("| Average RPO: %-15s Worst: %-15s |\n",
formatDuration(summary.AverageRPO), formatDuration(summary.AverageRPO),
formatDuration(summary.WorstRPO)) formatDuration(summary.WorstRPO))
fmt.Printf(" Average RTO: %-15s Worst: %-15s \n", fmt.Printf("| Average RTO: %-15s Worst: %-15s |\n",
formatDuration(summary.AverageRTO), formatDuration(summary.AverageRTO),
formatDuration(summary.WorstRTO)) formatDuration(summary.WorstRTO))
if summary.WorstRPODatabase != "" { if summary.WorstRPODatabase != "" {
fmt.Printf(" Worst RPO Database: %-38s\n", summary.WorstRPODatabase) fmt.Printf("| Worst RPO Database: %-38s|\n", summary.WorstRPODatabase)
} }
if summary.WorstRTODatabase != "" { if summary.WorstRTODatabase != "" {
fmt.Printf(" Worst RTO Database: %-38s\n", summary.WorstRTODatabase) fmt.Printf("| Worst RTO Database: %-38s|\n", summary.WorstRTODatabase)
} }
fmt.Println("╚═══════════════════════════════════════════════════════════╝") fmt.Println("+-----------------------------------------------------------+")
fmt.Println() fmt.Println()
// Per-database status // Per-database status
@@ -234,19 +234,19 @@ func runRTOStatus(cmd *cobra.Command, args []string) error {
fmt.Println(strings.Repeat("-", 70)) fmt.Println(strings.Repeat("-", 70))
for _, a := range analyses { for _, a := range analyses {
status := "" status := "[OK]"
if !a.RPOCompliant || !a.RTOCompliant { if !a.RPOCompliant || !a.RTOCompliant {
status = "" status = "[FAIL]"
} }
rpoStr := formatDuration(a.CurrentRPO) rpoStr := formatDuration(a.CurrentRPO)
rtoStr := formatDuration(a.CurrentRTO) rtoStr := formatDuration(a.CurrentRTO)
if !a.RPOCompliant { if !a.RPOCompliant {
rpoStr = "⚠️ " + rpoStr rpoStr = "[WARN] " + rpoStr
} }
if !a.RTOCompliant { if !a.RTOCompliant {
rtoStr = "⚠️ " + rtoStr rtoStr = "[WARN] " + rtoStr
} }
fmt.Printf("%-25s %-12s %-12s %s\n", fmt.Printf("%-25s %-12s %-12s %s\n",
@@ -306,21 +306,21 @@ func runRTOCheck(cmd *cobra.Command, args []string) error {
exitCode := 0 exitCode := 0
for _, a := range analyses { for _, a := range analyses {
if !a.RPOCompliant { if !a.RPOCompliant {
fmt.Printf(" %s: RPO violation - current %s exceeds target %s\n", fmt.Printf("[FAIL] %s: RPO violation - current %s exceeds target %s\n",
a.Database, a.Database,
formatDuration(a.CurrentRPO), formatDuration(a.CurrentRPO),
formatDuration(config.TargetRPO)) formatDuration(config.TargetRPO))
exitCode = 1 exitCode = 1
} }
if !a.RTOCompliant { if !a.RTOCompliant {
fmt.Printf(" %s: RTO violation - estimated %s exceeds target %s\n", fmt.Printf("[FAIL] %s: RTO violation - estimated %s exceeds target %s\n",
a.Database, a.Database,
formatDuration(a.CurrentRTO), formatDuration(a.CurrentRTO),
formatDuration(config.TargetRTO)) formatDuration(config.TargetRTO))
exitCode = 1 exitCode = 1
} }
if a.RPOCompliant && a.RTOCompliant { if a.RPOCompliant && a.RTOCompliant {
fmt.Printf(" %s: Compliant (RPO: %s, RTO: %s)\n", fmt.Printf("[OK] %s: Compliant (RPO: %s, RTO: %s)\n",
a.Database, a.Database,
formatDuration(a.CurrentRPO), formatDuration(a.CurrentRPO),
formatDuration(a.CurrentRTO)) formatDuration(a.CurrentRTO))
@@ -371,13 +371,13 @@ func outputAnalysisText(analyses []*rto.Analysis) error {
fmt.Println(strings.Repeat("=", 60)) fmt.Println(strings.Repeat("=", 60))
// Status // Status
rpoStatus := " Compliant" rpoStatus := "[OK] Compliant"
if !a.RPOCompliant { if !a.RPOCompliant {
rpoStatus = " Violation" rpoStatus = "[FAIL] Violation"
} }
rtoStatus := " Compliant" rtoStatus := "[OK] Compliant"
if !a.RTOCompliant { if !a.RTOCompliant {
rtoStatus = " Violation" rtoStatus = "[FAIL] Violation"
} }
fmt.Println() fmt.Println()
@@ -420,7 +420,7 @@ func outputAnalysisText(analyses []*rto.Analysis) error {
fmt.Println(" Recommendations:") fmt.Println(" Recommendations:")
fmt.Println(strings.Repeat("-", 50)) fmt.Println(strings.Repeat("-", 50))
for _, r := range a.Recommendations { for _, r := range a.Recommendations {
icon := "💡" icon := "[TIP]"
switch r.Priority { switch r.Priority {
case rto.PriorityCritical: case rto.PriorityCritical:
icon = "🔴" icon = "🔴"

View File

@@ -141,7 +141,7 @@ func testConnection(ctx context.Context) error {
// Display results // Display results
fmt.Println("Connection Test Results:") fmt.Println("Connection Test Results:")
fmt.Printf(" Status: Connected \n") fmt.Printf(" Status: Connected [OK]\n")
fmt.Printf(" Version: %s\n", version) fmt.Printf(" Version: %s\n", version)
fmt.Printf(" Databases: %d found\n", len(databases)) fmt.Printf(" Databases: %d found\n", len(databases))
@@ -167,7 +167,7 @@ func testConnection(ctx context.Context) error {
} }
fmt.Println() fmt.Println()
fmt.Println(" Status check completed successfully!") fmt.Println("[OK] Status check completed successfully!")
return nil return nil
} }

View File

@@ -96,17 +96,17 @@ func runVerifyBackup(cmd *cobra.Command, args []string) error {
continue continue
} }
fmt.Printf("📁 %s\n", filepath.Base(backupFile)) fmt.Printf("[FILE] %s\n", filepath.Base(backupFile))
if quickVerify { if quickVerify {
// Quick check: size only // Quick check: size only
err := verification.QuickCheck(backupFile) err := verification.QuickCheck(backupFile)
if err != nil { if err != nil {
fmt.Printf(" FAILED: %v\n\n", err) fmt.Printf(" [FAIL] FAILED: %v\n\n", err)
failureCount++ failureCount++
continue continue
} }
fmt.Printf(" VALID (quick check)\n\n") fmt.Printf(" [OK] VALID (quick check)\n\n")
successCount++ successCount++
} else { } else {
// Full verification with SHA-256 // Full verification with SHA-256
@@ -116,7 +116,7 @@ func runVerifyBackup(cmd *cobra.Command, args []string) error {
} }
if result.Valid { if result.Valid {
fmt.Printf(" VALID\n") fmt.Printf(" [OK] VALID\n")
if verboseVerify { if verboseVerify {
meta, _ := metadata.Load(backupFile) meta, _ := metadata.Load(backupFile)
fmt.Printf(" Size: %s\n", metadata.FormatSize(meta.SizeBytes)) fmt.Printf(" Size: %s\n", metadata.FormatSize(meta.SizeBytes))
@@ -127,7 +127,7 @@ func runVerifyBackup(cmd *cobra.Command, args []string) error {
fmt.Println() fmt.Println()
successCount++ successCount++
} else { } else {
fmt.Printf(" FAILED: %v\n", result.Error) fmt.Printf(" [FAIL] FAILED: %v\n", result.Error)
if verboseVerify { if verboseVerify {
if !result.FileExists { if !result.FileExists {
fmt.Printf(" File does not exist\n") fmt.Printf(" File does not exist\n")
@@ -147,11 +147,11 @@ func runVerifyBackup(cmd *cobra.Command, args []string) error {
} }
// Summary // Summary
fmt.Println(strings.Repeat("", 50)) fmt.Println(strings.Repeat("-", 50))
fmt.Printf("Total: %d backups\n", len(backupFiles)) fmt.Printf("Total: %d backups\n", len(backupFiles))
fmt.Printf(" Valid: %d\n", successCount) fmt.Printf("[OK] Valid: %d\n", successCount)
if failureCount > 0 { if failureCount > 0 {
fmt.Printf(" Failed: %d\n", failureCount) fmt.Printf("[FAIL] Failed: %d\n", failureCount)
os.Exit(1) os.Exit(1)
} }
@@ -195,16 +195,16 @@ func runVerifyCloudBackup(cmd *cobra.Command, args []string) error {
for _, uri := range args { for _, uri := range args {
if !isCloudURI(uri) { if !isCloudURI(uri) {
fmt.Printf("⚠️ Skipping non-cloud URI: %s\n", uri) fmt.Printf("[WARN] Skipping non-cloud URI: %s\n", uri)
continue continue
} }
fmt.Printf("☁️ %s\n", uri) fmt.Printf("[CLOUD] %s\n", uri)
// Download and verify // Download and verify
result, err := verifyCloudBackup(cmd.Context(), uri, quickVerify, verboseVerify) result, err := verifyCloudBackup(cmd.Context(), uri, quickVerify, verboseVerify)
if err != nil { if err != nil {
fmt.Printf(" FAILED: %v\n\n", err) fmt.Printf(" [FAIL] FAILED: %v\n\n", err)
failureCount++ failureCount++
continue continue
} }
@@ -212,7 +212,7 @@ func runVerifyCloudBackup(cmd *cobra.Command, args []string) error {
// Cleanup temp file // Cleanup temp file
defer result.Cleanup() defer result.Cleanup()
fmt.Printf(" VALID\n") fmt.Printf(" [OK] VALID\n")
if verboseVerify && result.MetadataPath != "" { if verboseVerify && result.MetadataPath != "" {
meta, _ := metadata.Load(result.MetadataPath) meta, _ := metadata.Load(result.MetadataPath)
if meta != nil { if meta != nil {
@@ -226,7 +226,7 @@ func runVerifyCloudBackup(cmd *cobra.Command, args []string) error {
successCount++ successCount++
} }
fmt.Printf("\n Summary: %d valid, %d failed\n", successCount, failureCount) fmt.Printf("\n[OK] Summary: %d valid, %d failed\n", successCount, failureCount)
if failureCount > 0 { if failureCount > 0 {
os.Exit(1) os.Exit(1)

40
go.mod
View File

@@ -5,15 +5,27 @@ go 1.24.0
toolchain go1.24.9 toolchain go1.24.9
require ( require (
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 cloud.google.com/go/storage v1.57.2
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3
github.com/aws/aws-sdk-go-v2 v1.40.0
github.com/aws/aws-sdk-go-v2/config v1.32.2
github.com/aws/aws-sdk-go-v2/credentials v1.19.2
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.12
github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1
github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/lipgloss v1.1.0
github.com/dustin/go-humanize v1.0.1
github.com/go-sql-driver/mysql v1.9.3 github.com/go-sql-driver/mysql v1.9.3
github.com/jackc/pgx/v5 v5.7.6 github.com/jackc/pgx/v5 v5.7.6
github.com/mattn/go-sqlite3 v1.14.32
github.com/shirou/gopsutil/v3 v3.24.5
github.com/sirupsen/logrus v1.9.3 github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.10.1 github.com/spf13/cobra v1.10.1
github.com/spf13/pflag v1.0.9 github.com/spf13/pflag v1.0.9
golang.org/x/crypto v0.43.0
google.golang.org/api v0.256.0
) )
require ( require (
@@ -24,20 +36,13 @@ require (
cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect
cloud.google.com/go/iam v1.5.2 // indirect cloud.google.com/go/iam v1.5.2 // indirect
cloud.google.com/go/monitoring v1.24.2 // indirect cloud.google.com/go/monitoring v1.24.2 // indirect
cloud.google.com/go/storage v1.57.2 // indirect
filippo.io/edwards25519 v1.1.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect
github.com/aws/aws-sdk-go-v2 v1.40.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.2 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.2 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.12 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
@@ -46,47 +51,57 @@ require (
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.2 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.2 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.5 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.30.5 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.2 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.2 // indirect
github.com/aws/smithy-go v1.23.2 // indirect github.com/aws/smithy-go v1.23.2 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect
github.com/creack/pty v1.1.17 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-jose/go-jose/v4 v4.1.2 // indirect github.com/go-jose/go-jose/v4 v4.1.2 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/google/s2a-go v0.1.9 // indirect github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/hashicorp/errwrap v1.0.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-sqlite3 v1.14.32 // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect github.com/muesli/termenv v0.16.0 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/schollz/progressbar/v3 v3.19.0 // indirect
github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
github.com/zeebo/errs v1.4.0 // indirect github.com/zeebo/errs v1.4.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.36.0 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.36.0 // indirect
@@ -97,14 +112,13 @@ require (
go.opentelemetry.io/otel/sdk v1.37.0 // indirect go.opentelemetry.io/otel/sdk v1.37.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.37.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.37.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect go.opentelemetry.io/otel/trace v1.37.0 // indirect
golang.org/x/crypto v0.43.0 // indirect
golang.org/x/net v0.46.0 // indirect golang.org/x/net v0.46.0 // indirect
golang.org/x/oauth2 v0.33.0 // indirect golang.org/x/oauth2 v0.33.0 // indirect
golang.org/x/sync v0.18.0 // indirect golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect golang.org/x/sys v0.38.0 // indirect
golang.org/x/term v0.36.0 // indirect
golang.org/x/text v0.30.0 // indirect golang.org/x/text v0.30.0 // indirect
golang.org/x/time v0.14.0 // indirect golang.org/x/time v0.14.0 // indirect
google.golang.org/api v0.256.0 // indirect
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 // indirect

120
go.sum
View File

@@ -10,36 +10,44 @@ cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdB
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8= cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8=
cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE= cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE=
cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc=
cloud.google.com/go/logging v1.13.0/go.mod h1:36CoKh6KA/M0PbhPKMq6/qety2DCAErbhXT62TuXALA=
cloud.google.com/go/longrunning v0.7.0 h1:FV0+SYF1RIj59gyoWDRi45GiYUMM3K1qO51qoboQT1E=
cloud.google.com/go/longrunning v0.7.0/go.mod h1:ySn2yXmjbK9Ba0zsQqunhDkYi0+9rlXIwnoAf+h+TPY=
cloud.google.com/go/monitoring v1.24.2 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7dd7lHM= cloud.google.com/go/monitoring v1.24.2 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7dd7lHM=
cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U= cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U=
cloud.google.com/go/storage v1.57.2 h1:sVlym3cHGYhrp6XZKkKb+92I1V42ks2qKKpB0CF5Mb4= cloud.google.com/go/storage v1.57.2 h1:sVlym3cHGYhrp6XZKkKb+92I1V42ks2qKKpB0CF5Mb4=
cloud.google.com/go/storage v1.57.2/go.mod h1:n5ijg4yiRXXpCu0sJTD6k+eMf7GRrJmPyr9YxLXGHOk= cloud.google.com/go/storage v1.57.2/go.mod h1:n5ijg4yiRXXpCu0sJTD6k+eMf7GRrJmPyr9YxLXGHOk=
cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4=
cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.0 h1:KpMC6LFL7mqpExyMC9jVOYRiVhLmamjeZfRsUpB7l4s=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.0/go.mod h1:J7MUC/wtRpfGVbQ5sIItY5/FuVWmvzlY21WAOfQnq/I=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA=
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3 h1:ZJJNFaQ86GVKQ9ehwqyAFE6pIfyicpuJ8IkVaPBc6/4= github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3 h1:ZJJNFaQ86GVKQ9ehwqyAFE6pIfyicpuJ8IkVaPBc6/4=
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3/go.mod h1:URuDvhmATVKqHBH9/0nOiNKk0+YcwfQ3WkK5PqHKxc8= github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3/go.mod h1:URuDvhmATVKqHBH9/0nOiNKk0+YcwfQ3WkK5PqHKxc8=
github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0 h1:XkkQbfMyuH2jTSjQjSoihryI8GINRcs4xp8lNawg0FI=
github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 h1:UQUsRi8WTzhZntp5313l+CHIAT95ojUI2lpP/ExlZa4= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 h1:UQUsRi8WTzhZntp5313l+CHIAT95ojUI2lpP/ExlZa4=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0/go.mod h1:Cz6ft6Dkn3Et6l2v2a9/RpN7epQ1GtDlO6lj8bEcOvw= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0/go.mod h1:Cz6ft6Dkn3Et6l2v2a9/RpN7epQ1GtDlO6lj8bEcOvw=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 h1:owcC2UnmsZycprQ5RfRgjydWhuoxg71LUfyiQdijZuM= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 h1:owcC2UnmsZycprQ5RfRgjydWhuoxg71LUfyiQdijZuM=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0/go.mod h1:ZPpqegjbE99EPKsu3iUWV22A04wzGPcAY/ziSIQEEgs= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0/go.mod h1:ZPpqegjbE99EPKsu3iUWV22A04wzGPcAY/ziSIQEEgs=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0 h1:4LP6hvB4I5ouTbGgWtixJhgED6xdf67twf9PoY96Tbg=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0/go.mod h1:jUZ5LYlw40WMd07qxcQJD5M40aUxrfwqQX1g7zxYnrQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 h1:Ron4zCA/yk6U7WOBXhTJcDpsUBG9npumK6xw2auFltQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 h1:Ron4zCA/yk6U7WOBXhTJcDpsUBG9npumK6xw2auFltQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc= github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc=
github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE= github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 h1:DHctwEM8P8iTXFxC/QK0MRjwEpWQeM9yzidCRjldUz0= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 h1:DHctwEM8P8iTXFxC/QK0MRjwEpWQeM9yzidCRjldUz0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3/go.mod h1:xdCzcZEtnSTKVDOmUZs4l/j3pSV6rpo1WXl5ugNsL8Y= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3/go.mod h1:xdCzcZEtnSTKVDOmUZs4l/j3pSV6rpo1WXl5ugNsL8Y=
github.com/aws/aws-sdk-go-v2/config v1.32.1 h1:iODUDLgk3q8/flEC7ymhmxjfoAnBDwEEYEVyKZ9mzjU=
github.com/aws/aws-sdk-go-v2/config v1.32.1/go.mod h1:xoAgo17AGrPpJBSLg81W+ikM0cpOZG8ad04T2r+d5P0=
github.com/aws/aws-sdk-go-v2/config v1.32.2 h1:4liUsdEpUUPZs5WVapsJLx5NPmQhQdez7nYFcovrytk= github.com/aws/aws-sdk-go-v2/config v1.32.2 h1:4liUsdEpUUPZs5WVapsJLx5NPmQhQdez7nYFcovrytk=
github.com/aws/aws-sdk-go-v2/config v1.32.2/go.mod h1:l0hs06IFz1eCT+jTacU/qZtC33nvcnLADAPL/XyrkZI= github.com/aws/aws-sdk-go-v2/config v1.32.2/go.mod h1:l0hs06IFz1eCT+jTacU/qZtC33nvcnLADAPL/XyrkZI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.1 h1:JeW+EwmtTE0yXFK8SmklrFh/cGTTXsQJumgMZNlbxfM=
github.com/aws/aws-sdk-go-v2/credentials v1.19.1/go.mod h1:BOoXiStwTF+fT2XufhO0Efssbi1CNIO/ZXpZu87N0pw=
github.com/aws/aws-sdk-go-v2/credentials v1.19.2 h1:qZry8VUyTK4VIo5aEdUcBjPZHL2v4FyQ3QEOaWcFLu4= github.com/aws/aws-sdk-go-v2/credentials v1.19.2 h1:qZry8VUyTK4VIo5aEdUcBjPZHL2v4FyQ3QEOaWcFLu4=
github.com/aws/aws-sdk-go-v2/credentials v1.19.2/go.mod h1:YUqm5a1/kBnoK+/NY5WEiMocZihKSo15/tJdmdXnM5g= github.com/aws/aws-sdk-go-v2/credentials v1.19.2/go.mod h1:YUqm5a1/kBnoK+/NY5WEiMocZihKSo15/tJdmdXnM5g=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 h1:WZVR5DbDgxzA0BJeudId89Kmgy6DIU4ORpxwsVHz0qA= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 h1:WZVR5DbDgxzA0BJeudId89Kmgy6DIU4ORpxwsVHz0qA=
@@ -62,30 +70,22 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 h1:FIouAnCE
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14/go.mod h1:UTwDc5COa5+guonQU8qBikJo1ZJ4ln2r1MkF7Dqag1E= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14/go.mod h1:UTwDc5COa5+guonQU8qBikJo1ZJ4ln2r1MkF7Dqag1E=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14 h1:FzQE21lNtUor0Fb7QNgnEyiRCBlolLTX/Z1j65S7teM= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14 h1:FzQE21lNtUor0Fb7QNgnEyiRCBlolLTX/Z1j65S7teM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14/go.mod h1:s1ydyWG9pm3ZwmmYN21HKyG9WzAZhYVW85wMHs5FV6w= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14/go.mod h1:s1ydyWG9pm3ZwmmYN21HKyG9WzAZhYVW85wMHs5FV6w=
github.com/aws/aws-sdk-go-v2/service/s3 v1.92.0 h1:8FshVvnV2sr9kOSAbOnc/vwVmmAwMjOedKH6JW2ddPM=
github.com/aws/aws-sdk-go-v2/service/s3 v1.92.0/go.mod h1:wYNqY3L02Z3IgRYxOBPH9I1zD9Cjh9hI5QOy/eOjQvw=
github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1 h1:OgQy/+0+Kc3khtqiEOk23xQAglXi3Tj0y5doOxbi5tg= github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1 h1:OgQy/+0+Kc3khtqiEOk23xQAglXi3Tj0y5doOxbi5tg=
github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1/go.mod h1:wYNqY3L02Z3IgRYxOBPH9I1zD9Cjh9hI5QOy/eOjQvw= github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1/go.mod h1:wYNqY3L02Z3IgRYxOBPH9I1zD9Cjh9hI5QOy/eOjQvw=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.1 h1:BDgIUYGEo5TkayOWv/oBLPphWwNm/A91AebUjAu5L5g=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.1/go.mod h1:iS6EPmNeqCsGo+xQmXv0jIMjyYtQfnwg36zl2FwEouk=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.2 h1:MxMBdKTYBjPQChlJhi4qlEueqB1p1KcbTEa7tD5aqPs= github.com/aws/aws-sdk-go-v2/service/signin v1.0.2 h1:MxMBdKTYBjPQChlJhi4qlEueqB1p1KcbTEa7tD5aqPs=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.2/go.mod h1:iS6EPmNeqCsGo+xQmXv0jIMjyYtQfnwg36zl2FwEouk= github.com/aws/aws-sdk-go-v2/service/signin v1.0.2/go.mod h1:iS6EPmNeqCsGo+xQmXv0jIMjyYtQfnwg36zl2FwEouk=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.4 h1:U//SlnkE1wOQiIImxzdY5PXat4Wq+8rlfVEw4Y7J8as=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.4/go.mod h1:av+ArJpoYf3pgyrj6tcehSFW+y9/QvAY8kMooR9bZCw=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.5 h1:ksUT5KtgpZd3SAiFJNJ0AFEJVva3gjBmN7eXUZjzUwQ= github.com/aws/aws-sdk-go-v2/service/sso v1.30.5 h1:ksUT5KtgpZd3SAiFJNJ0AFEJVva3gjBmN7eXUZjzUwQ=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.5/go.mod h1:av+ArJpoYf3pgyrj6tcehSFW+y9/QvAY8kMooR9bZCw= github.com/aws/aws-sdk-go-v2/service/sso v1.30.5/go.mod h1:av+ArJpoYf3pgyrj6tcehSFW+y9/QvAY8kMooR9bZCw=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.9 h1:LU8S9W/mPDAU9q0FjCLi0TrCheLMGwzbRpvUMwYspcA=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.9/go.mod h1:/j67Z5XBVDx8nZVp9EuFM9/BS5dvBznbqILGuu73hug=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10 h1:GtsxyiF3Nd3JahRBJbxLCCdYW9ltGQYrFWg8XdkGDd8= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10 h1:GtsxyiF3Nd3JahRBJbxLCCdYW9ltGQYrFWg8XdkGDd8=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10/go.mod h1:/j67Z5XBVDx8nZVp9EuFM9/BS5dvBznbqILGuu73hug= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10/go.mod h1:/j67Z5XBVDx8nZVp9EuFM9/BS5dvBznbqILGuu73hug=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.1 h1:GdGmKtG+/Krag7VfyOXV17xjTCz0i9NT+JnqLTOI5nA=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.1/go.mod h1:6TxbXoDSgBQ225Qd8Q+MbxUxUh6TtNKwbRt/EPS9xso=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.2 h1:a5UTtD4mHBU3t0o6aHQZFJTNKVfxFWfPX7J0Lr7G+uY= github.com/aws/aws-sdk-go-v2/service/sts v1.41.2 h1:a5UTtD4mHBU3t0o6aHQZFJTNKVfxFWfPX7J0Lr7G+uY=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.2/go.mod h1:6TxbXoDSgBQ225Qd8Q+MbxUxUh6TtNKwbRt/EPS9xso= github.com/aws/aws-sdk-go-v2/service/sts v1.41.2/go.mod h1:6TxbXoDSgBQ225Qd8Q+MbxUxUh6TtNKwbRt/EPS9xso=
github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM= github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM=
github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
@@ -105,17 +105,24 @@ github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNE
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls= github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls=
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA=
github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=
github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI= github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI=
@@ -125,8 +132,19 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -135,6 +153,10 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAV
github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -145,8 +167,15 @@ github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
@@ -155,20 +184,31 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc=
github.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
@@ -179,13 +219,17 @@ github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8W
github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g= github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
@@ -198,6 +242,8 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6h
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 h1:rixTyDGXFxRy1xzhKrotaHy3/KXdPhlWARrCgK+eqUY=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
@@ -206,43 +252,35 @@ go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFh
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.256.0 h1:u6Khm8+F9sxbCTYNoBHg6/Hwv0N/i+V94MvkOSor6oI= google.golang.org/api v0.256.0 h1:u6Khm8+F9sxbCTYNoBHg6/Hwv0N/i+V94MvkOSor6oI=
google.golang.org/api v0.256.0/go.mod h1:KIgPhksXADEKJlnEoRa9qAII4rXcy40vfI8HRqcU964= google.golang.org/api v0.256.0/go.mod h1:KIgPhksXADEKJlnEoRa9qAII4rXcy40vfI8HRqcU964=
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=

View File

@@ -0,0 +1,1303 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"liveNow": false,
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [
{
"options": {
"0": {
"color": "red",
"index": 1,
"text": "FAILED"
},
"1": {
"color": "green",
"index": 0,
"text": "SUCCESS"
}
},
"type": "value"
}
],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": null
},
{
"color": "green",
"value": 1
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 6,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"colorMode": "background",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto"
},
"pluginVersion": "10.2.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"editorMode": "code",
"expr": "dbbackup_rpo_seconds{instance=~\"$instance\"} < 86400",
"legendFormat": "{{database}}",
"range": true,
"refId": "A"
}
],
"title": "Last Backup Status",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "yellow",
"value": 43200
},
{
"color": "red",
"value": 86400
}
]
},
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 6,
"x": 6,
"y": 0
},
"id": 2,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto"
},
"pluginVersion": "10.2.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"editorMode": "code",
"expr": "dbbackup_rpo_seconds{instance=~\"$instance\"}",
"legendFormat": "{{database}}",
"range": true,
"refId": "A"
}
],
"title": "Time Since Last Backup",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 6,
"x": 12,
"y": 0
},
"id": 3,
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto"
},
"pluginVersion": "10.2.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"editorMode": "code",
"expr": "dbbackup_backup_total{instance=~\"$instance\", status=\"success\"}",
"legendFormat": "{{database}}",
"range": true,
"refId": "A"
}
],
"title": "Total Successful Backups",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 1
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 6,
"x": 18,
"y": 0
},
"id": 4,
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto"
},
"pluginVersion": "10.2.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"editorMode": "code",
"expr": "dbbackup_backup_total{instance=~\"$instance\", status=\"failure\"}",
"legendFormat": "{{database}}",
"range": true,
"refId": "A"
}
],
"title": "Total Failed Backups",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "line"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 86400
}
]
},
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 4
},
"id": 5,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"editorMode": "code",
"expr": "dbbackup_rpo_seconds{instance=~\"$instance\"}",
"legendFormat": "{{instance}} - {{database}}",
"range": true,
"refId": "A"
}
],
"title": "RPO Over Time",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "bars",
"fillOpacity": 100,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "bytes"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 4
},
"id": 6,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"editorMode": "code",
"expr": "dbbackup_last_backup_size_bytes{instance=~\"$instance\"}",
"legendFormat": "{{instance}} - {{database}}",
"range": true,
"refId": "A"
}
],
"title": "Backup Size",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 12
},
"id": 7,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"editorMode": "code",
"expr": "dbbackup_last_backup_duration_seconds{instance=~\"$instance\"}",
"legendFormat": "{{instance}} - {{database}}",
"range": true,
"refId": "A"
}
],
"title": "Backup Duration",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"align": "auto",
"cellOptions": {
"type": "auto"
},
"inspect": false
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "Status"
},
"properties": [
{
"id": "mappings",
"value": [
{
"options": {
"0": {
"color": "red",
"index": 1,
"text": "FAILED"
},
"1": {
"color": "green",
"index": 0,
"text": "SUCCESS"
}
},
"type": "value"
}
]
},
{
"id": "custom.cellOptions",
"value": {
"mode": "basic",
"type": "color-background"
}
}
]
},
{
"matcher": {
"id": "byName",
"options": "RPO"
},
"properties": [
{
"id": "unit",
"value": "s"
},
{
"id": "thresholds",
"value": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "yellow",
"value": 43200
},
{
"color": "red",
"value": 86400
}
]
}
},
{
"id": "custom.cellOptions",
"value": {
"mode": "basic",
"type": "color-background"
}
}
]
},
{
"matcher": {
"id": "byName",
"options": "Size"
},
"properties": [
{
"id": "unit",
"value": "bytes"
}
]
}
]
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 12
},
"id": 8,
"options": {
"cellHeight": "sm",
"footer": {
"countRows": false,
"fields": "",
"reducer": [
"sum"
],
"show": false
},
"showHeader": true
},
"pluginVersion": "10.2.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"editorMode": "code",
"expr": "dbbackup_rpo_seconds{instance=~\"$instance\"} < 86400",
"format": "table",
"instant": true,
"legendFormat": "__auto",
"range": false,
"refId": "Status"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"editorMode": "code",
"expr": "dbbackup_rpo_seconds{instance=~\"$instance\"}",
"format": "table",
"hide": false,
"instant": true,
"legendFormat": "__auto",
"range": false,
"refId": "RPO"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"editorMode": "code",
"expr": "dbbackup_last_backup_size_bytes{instance=~\"$instance\"}",
"format": "table",
"hide": false,
"instant": true,
"legendFormat": "__auto",
"range": false,
"refId": "Size"
}
],
"title": "Backup Status Overview",
"transformations": [
{
"id": "joinByField",
"options": {
"byField": "database",
"mode": "outer"
}
},
{
"id": "organize",
"options": {
"excludeByName": {
"Time": true,
"Time 1": true,
"Time 2": true,
"Time 3": true,
"__name__": true,
"__name__ 1": true,
"__name__ 2": true,
"__name__ 3": true,
"instance 1": true,
"instance 2": true,
"instance 3": true,
"job": true,
"job 1": true,
"job 2": true,
"job 3": true
},
"indexByName": {},
"renameByName": {
"Value #RPO": "RPO",
"Value #Size": "Size",
"Value #Status": "Status",
"database": "Database",
"instance": "Instance"
}
}
}
],
"type": "table"
},
{
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 30
},
"id": 100,
"panels": [],
"title": "Deduplication Statistics",
"type": "row"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "blue",
"value": null
}
]
},
"unit": "percentunit"
},
"overrides": []
},
"gridPos": {
"h": 5,
"w": 6,
"x": 0,
"y": 31
},
"id": 101,
"options": {
"colorMode": "background",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"textMode": "auto"
},
"pluginVersion": "10.2.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"editorMode": "code",
"expr": "dbbackup_dedup_ratio{instance=~\"$instance\"}",
"legendFormat": "__auto",
"range": true,
"refId": "A"
}
],
"title": "Dedup Ratio",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "bytes"
},
"overrides": []
},
"gridPos": {
"h": 5,
"w": 6,
"x": 6,
"y": 31
},
"id": 102,
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"textMode": "auto"
},
"pluginVersion": "10.2.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"editorMode": "code",
"expr": "dbbackup_dedup_space_saved_bytes{instance=~\"$instance\"}",
"legendFormat": "__auto",
"range": true,
"refId": "A"
}
],
"title": "Space Saved",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "yellow",
"value": null
}
]
},
"unit": "bytes"
},
"overrides": []
},
"gridPos": {
"h": 5,
"w": 6,
"x": 12,
"y": 31
},
"id": 103,
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"textMode": "auto"
},
"pluginVersion": "10.2.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"editorMode": "code",
"expr": "dbbackup_dedup_disk_usage_bytes{instance=~\"$instance\"}",
"legendFormat": "__auto",
"range": true,
"refId": "A"
}
],
"title": "Disk Usage",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "purple",
"value": null
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 5,
"w": 6,
"x": 18,
"y": 31
},
"id": 104,
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"textMode": "auto"
},
"pluginVersion": "10.2.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"editorMode": "code",
"expr": "dbbackup_dedup_chunks_total{instance=~\"$instance\"}",
"legendFormat": "__auto",
"range": true,
"refId": "A"
}
],
"title": "Total Chunks",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "percentunit"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 36
},
"id": 105,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "10.2.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"editorMode": "code",
"expr": "dbbackup_dedup_database_ratio{instance=~\"$instance\"}",
"legendFormat": "{{database}}",
"range": true,
"refId": "A"
}
],
"title": "Dedup Ratio by Database",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "bytes"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 36
},
"id": 106,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "10.2.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"editorMode": "code",
"expr": "dbbackup_dedup_space_saved_bytes{instance=~\"$instance\"}",
"legendFormat": "Space Saved",
"range": true,
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"editorMode": "code",
"expr": "dbbackup_dedup_disk_usage_bytes{instance=~\"$instance\"}",
"legendFormat": "Disk Usage",
"range": true,
"refId": "B"
}
],
"title": "Dedup Storage Over Time",
"type": "timeseries"
}
],
"refresh": "30s",
"schemaVersion": 38,
"tags": [
"dbbackup",
"backup",
"database",
"dedup"
],
"templating": {
"list": [
{
"current": {
"selected": false,
"text": "All",
"value": "$__all"
},
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"definition": "label_values(dbbackup_rpo_seconds, instance)",
"hide": 0,
"includeAll": true,
"label": "Instance",
"multi": true,
"name": "instance",
"options": [],
"query": {
"query": "label_values(dbbackup_rpo_seconds, instance)",
"refId": "StandardVariableQuery"
},
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"sort": 1,
"type": "query"
},
{
"hide": 2,
"name": "DS_PROMETHEUS",
"query": "prometheus",
"skipUrlSync": false,
"type": "datasource"
}
]
},
"time": {
"from": "now-24h",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "DBBackup Overview",
"uid": "dbbackup-overview",
"version": 1,
"weekStart": ""
}

View File

@@ -204,13 +204,13 @@ func CheckAuthenticationMismatch(cfg *config.Config) (bool, string) {
func buildAuthMismatchMessage(osUser, dbUser string, method AuthMethod) string { func buildAuthMismatchMessage(osUser, dbUser string, method AuthMethod) string {
var msg strings.Builder var msg strings.Builder
msg.WriteString("\n⚠️ Authentication Mismatch Detected\n") msg.WriteString("\n[WARN] Authentication Mismatch Detected\n")
msg.WriteString(strings.Repeat("=", 60) + "\n\n") msg.WriteString(strings.Repeat("=", 60) + "\n\n")
msg.WriteString(fmt.Sprintf(" PostgreSQL is using '%s' authentication\n", method)) msg.WriteString(fmt.Sprintf(" PostgreSQL is using '%s' authentication\n", method))
msg.WriteString(fmt.Sprintf(" OS user '%s' cannot authenticate as DB user '%s'\n\n", osUser, dbUser)) msg.WriteString(fmt.Sprintf(" OS user '%s' cannot authenticate as DB user '%s'\n\n", osUser, dbUser))
msg.WriteString("💡 Solutions (choose one):\n\n") msg.WriteString("[TIP] Solutions (choose one):\n\n")
msg.WriteString(fmt.Sprintf(" 1. Run as matching user:\n")) msg.WriteString(fmt.Sprintf(" 1. Run as matching user:\n"))
msg.WriteString(fmt.Sprintf(" sudo -u %s %s\n\n", dbUser, getCommandLine())) msg.WriteString(fmt.Sprintf(" sudo -u %s %s\n\n", dbUser, getCommandLine()))
@@ -226,7 +226,7 @@ func buildAuthMismatchMessage(osUser, dbUser string, method AuthMethod) string {
msg.WriteString(" 4. Provide password via flag:\n") msg.WriteString(" 4. Provide password via flag:\n")
msg.WriteString(fmt.Sprintf(" %s --password your_password\n\n", getCommandLine())) msg.WriteString(fmt.Sprintf(" %s --password your_password\n\n", getCommandLine()))
msg.WriteString("📝 Note: For production use, ~/.pgpass or PGPASSWORD are recommended\n") msg.WriteString("[NOTE] Note: For production use, ~/.pgpass or PGPASSWORD are recommended\n")
msg.WriteString(" to avoid exposing passwords in command history.\n\n") msg.WriteString(" to avoid exposing passwords in command history.\n\n")
msg.WriteString(strings.Repeat("=", 60) + "\n") msg.WriteString(strings.Repeat("=", 60) + "\n")

View File

@@ -473,7 +473,7 @@ func (e *Engine) BackupCluster(ctx context.Context) error {
mu.Lock() mu.Lock()
e.printf(" Database size: %s\n", sizeStr) e.printf(" Database size: %s\n", sizeStr)
if size > 10*1024*1024*1024 { // > 10GB if size > 10*1024*1024*1024 { // > 10GB
e.printf(" ⚠️ Large database detected - this may take a while\n") e.printf(" [WARN] Large database detected - this may take a while\n")
} }
mu.Unlock() mu.Unlock()
} }
@@ -518,16 +518,16 @@ func (e *Engine) BackupCluster(ctx context.Context) error {
if err != nil { if err != nil {
e.log.Warn("Failed to backup database", "database", name, "error", err) e.log.Warn("Failed to backup database", "database", name, "error", err)
mu.Lock() mu.Lock()
e.printf(" ⚠️ WARNING: Failed to backup %s: %v\n", name, err) e.printf(" [WARN] WARNING: Failed to backup %s: %v\n", name, err)
mu.Unlock() mu.Unlock()
atomic.AddInt32(&failCount, 1) atomic.AddInt32(&failCount, 1)
} else { } else {
compressedCandidate := strings.TrimSuffix(dumpFile, ".dump") + ".sql.gz" compressedCandidate := strings.TrimSuffix(dumpFile, ".dump") + ".sql.gz"
mu.Lock() mu.Lock()
if info, err := os.Stat(compressedCandidate); err == nil { if info, err := os.Stat(compressedCandidate); err == nil {
e.printf(" Completed %s (%s)\n", name, formatBytes(info.Size())) e.printf(" [OK] Completed %s (%s)\n", name, formatBytes(info.Size()))
} else if info, err := os.Stat(dumpFile); err == nil { } else if info, err := os.Stat(dumpFile); err == nil {
e.printf(" Completed %s (%s)\n", name, formatBytes(info.Size())) e.printf(" [OK] Completed %s (%s)\n", name, formatBytes(info.Size()))
} }
mu.Unlock() mu.Unlock()
atomic.AddInt32(&successCount, 1) atomic.AddInt32(&successCount, 1)
@@ -1242,23 +1242,29 @@ func (e *Engine) uploadToCloud(ctx context.Context, backupFile string, tracker *
filename := filepath.Base(backupFile) filename := filepath.Base(backupFile)
e.log.Info("Uploading backup to cloud", "file", filename, "size", cloud.FormatSize(info.Size())) e.log.Info("Uploading backup to cloud", "file", filename, "size", cloud.FormatSize(info.Size()))
// Progress callback // Create schollz progressbar for visual upload progress
var lastPercent int bar := progress.NewSchollzBar(info.Size(), fmt.Sprintf("Uploading %s", filename))
// Progress callback with schollz progressbar
var lastBytes int64
progressCallback := func(transferred, total int64) { progressCallback := func(transferred, total int64) {
percent := int(float64(transferred) / float64(total) * 100) delta := transferred - lastBytes
if percent != lastPercent && percent%10 == 0 { if delta > 0 {
e.log.Debug("Upload progress", "percent", percent, "transferred", cloud.FormatSize(transferred), "total", cloud.FormatSize(total)) _ = bar.Add64(delta)
lastPercent = percent
} }
lastBytes = transferred
} }
// Upload to cloud // Upload to cloud
err = backend.Upload(ctx, backupFile, filename, progressCallback) err = backend.Upload(ctx, backupFile, filename, progressCallback)
if err != nil { if err != nil {
bar.Fail("Upload failed")
uploadStep.Fail(fmt.Errorf("cloud upload failed: %w", err)) uploadStep.Fail(fmt.Errorf("cloud upload failed: %w", err))
return err return err
} }
_ = bar.Finish()
// Also upload metadata file // Also upload metadata file
metaFile := backupFile + ".meta.json" metaFile := backupFile + ".meta.json"
if _, err := os.Stat(metaFile); err == nil { if _, err := os.Stat(metaFile); err == nil {

View File

@@ -242,7 +242,7 @@ func TestIncrementalBackupRestore(t *testing.T) {
t.Errorf("Unchanged file base/12345/1235 not found in restore: %v", err) t.Errorf("Unchanged file base/12345/1235 not found in restore: %v", err)
} }
t.Log(" Incremental backup and restore test completed successfully") t.Log("[OK] Incremental backup and restore test completed successfully")
} }
// TestIncrementalBackupErrors tests error handling // TestIncrementalBackupErrors tests error handling

View File

@@ -75,16 +75,16 @@ func FormatDiskSpaceMessage(check *DiskSpaceCheck) string {
if check.Critical { if check.Critical {
status = "CRITICAL" status = "CRITICAL"
icon = "" icon = "[X]"
} else if check.Warning { } else if check.Warning {
status = "WARNING" status = "WARNING"
icon = "⚠️ " icon = "[!]"
} else { } else {
status = "OK" status = "OK"
icon = "" icon = "[+]"
} }
msg := fmt.Sprintf(`📊 Disk Space Check (%s): msg := fmt.Sprintf(`[DISK] Disk Space Check (%s):
Path: %s Path: %s
Total: %s Total: %s
Available: %s (%.1f%% used) Available: %s (%.1f%% used)
@@ -98,13 +98,13 @@ func FormatDiskSpaceMessage(check *DiskSpaceCheck) string {
status) status)
if check.Critical { if check.Critical {
msg += "\n \n ⚠️ CRITICAL: Insufficient disk space!" msg += "\n \n [!!] CRITICAL: Insufficient disk space!"
msg += "\n Operation blocked. Free up space before continuing." msg += "\n Operation blocked. Free up space before continuing."
} else if check.Warning { } else if check.Warning {
msg += "\n \n ⚠️ WARNING: Low disk space!" msg += "\n \n [!] WARNING: Low disk space!"
msg += "\n Backup may fail if database is larger than estimated." msg += "\n Backup may fail if database is larger than estimated."
} else { } else {
msg += "\n \n Sufficient space available" msg += "\n \n [+] Sufficient space available"
} }
return msg return msg

View File

@@ -75,16 +75,16 @@ func FormatDiskSpaceMessage(check *DiskSpaceCheck) string {
if check.Critical { if check.Critical {
status = "CRITICAL" status = "CRITICAL"
icon = "" icon = "[X]"
} else if check.Warning { } else if check.Warning {
status = "WARNING" status = "WARNING"
icon = "⚠️ " icon = "[!]"
} else { } else {
status = "OK" status = "OK"
icon = "" icon = "[+]"
} }
msg := fmt.Sprintf(`📊 Disk Space Check (%s): msg := fmt.Sprintf(`[DISK] Disk Space Check (%s):
Path: %s Path: %s
Total: %s Total: %s
Available: %s (%.1f%% used) Available: %s (%.1f%% used)
@@ -98,13 +98,13 @@ func FormatDiskSpaceMessage(check *DiskSpaceCheck) string {
status) status)
if check.Critical { if check.Critical {
msg += "\n \n ⚠️ CRITICAL: Insufficient disk space!" msg += "\n \n [!!] CRITICAL: Insufficient disk space!"
msg += "\n Operation blocked. Free up space before continuing." msg += "\n Operation blocked. Free up space before continuing."
} else if check.Warning { } else if check.Warning {
msg += "\n \n ⚠️ WARNING: Low disk space!" msg += "\n \n [!] WARNING: Low disk space!"
msg += "\n Backup may fail if database is larger than estimated." msg += "\n Backup may fail if database is larger than estimated."
} else { } else {
msg += "\n \n Sufficient space available" msg += "\n \n [+] Sufficient space available"
} }
return msg return msg

View File

@@ -58,16 +58,16 @@ func FormatDiskSpaceMessage(check *DiskSpaceCheck) string {
if check.Critical { if check.Critical {
status = "CRITICAL" status = "CRITICAL"
icon = "" icon = "[X]"
} else if check.Warning { } else if check.Warning {
status = "WARNING" status = "WARNING"
icon = "⚠️ " icon = "[!]"
} else { } else {
status = "OK" status = "OK"
icon = "" icon = "[+]"
} }
msg := fmt.Sprintf(`📊 Disk Space Check (%s): msg := fmt.Sprintf(`[DISK] Disk Space Check (%s):
Path: %s Path: %s
Total: %s Total: %s
Available: %s (%.1f%% used) Available: %s (%.1f%% used)
@@ -81,13 +81,13 @@ func FormatDiskSpaceMessage(check *DiskSpaceCheck) string {
status) status)
if check.Critical { if check.Critical {
msg += "\n \n ⚠️ CRITICAL: Insufficient disk space!" msg += "\n \n [!!] CRITICAL: Insufficient disk space!"
msg += "\n Operation blocked. Free up space before continuing." msg += "\n Operation blocked. Free up space before continuing."
} else if check.Warning { } else if check.Warning {
msg += "\n \n ⚠️ WARNING: Low disk space!" msg += "\n \n [!] WARNING: Low disk space!"
msg += "\n Backup may fail if database is larger than estimated." msg += "\n Backup may fail if database is larger than estimated."
} else { } else {
msg += "\n \n Sufficient space available" msg += "\n \n [+] Sufficient space available"
} }
return msg return msg

View File

@@ -94,16 +94,16 @@ func FormatDiskSpaceMessage(check *DiskSpaceCheck) string {
if check.Critical { if check.Critical {
status = "CRITICAL" status = "CRITICAL"
icon = "" icon = "[X]"
} else if check.Warning { } else if check.Warning {
status = "WARNING" status = "WARNING"
icon = "⚠️ " icon = "[!]"
} else { } else {
status = "OK" status = "OK"
icon = "" icon = "[+]"
} }
msg := fmt.Sprintf(`📊 Disk Space Check (%s): msg := fmt.Sprintf(`[DISK] Disk Space Check (%s):
Path: %s Path: %s
Total: %s Total: %s
Available: %s (%.1f%% used) Available: %s (%.1f%% used)
@@ -117,13 +117,13 @@ func FormatDiskSpaceMessage(check *DiskSpaceCheck) string {
status) status)
if check.Critical { if check.Critical {
msg += "\n \n ⚠️ CRITICAL: Insufficient disk space!" msg += "\n \n [!!] CRITICAL: Insufficient disk space!"
msg += "\n Operation blocked. Free up space before continuing." msg += "\n Operation blocked. Free up space before continuing."
} else if check.Warning { } else if check.Warning {
msg += "\n \n ⚠️ WARNING: Low disk space!" msg += "\n \n [!] WARNING: Low disk space!"
msg += "\n Backup may fail if database is larger than estimated." msg += "\n Backup may fail if database is larger than estimated."
} else { } else {
msg += "\n \n Sufficient space available" msg += "\n \n [+] Sufficient space available"
} }
return msg return msg

View File

@@ -68,8 +68,8 @@ func ClassifyError(errorMsg string) *ErrorClassification {
Type: "critical", Type: "critical",
Category: "locks", Category: "locks",
Message: errorMsg, Message: errorMsg,
Hint: "Lock table exhausted - typically caused by large objects in parallel restore", Hint: "Lock table exhausted - typically caused by large objects (BLOBs) during restore",
Action: "Increase max_locks_per_transaction in postgresql.conf to 512 or higher", Action: "Option 1: Increase max_locks_per_transaction to 1024+ in postgresql.conf (requires restart). Option 2: Update dbbackup and retry - phased restore now auto-enabled for BLOB databases",
Severity: 2, Severity: 2,
} }
case "permission_denied": case "permission_denied":
@@ -142,8 +142,8 @@ func ClassifyError(errorMsg string) *ErrorClassification {
Type: "critical", Type: "critical",
Category: "locks", Category: "locks",
Message: errorMsg, Message: errorMsg,
Hint: "Lock table exhausted - typically caused by large objects in parallel restore", Hint: "Lock table exhausted - typically caused by large objects (BLOBs) during restore",
Action: "Increase max_locks_per_transaction in postgresql.conf to 512 or higher", Action: "Option 1: Increase max_locks_per_transaction to 1024+ in postgresql.conf (requires restart). Option 2: Update dbbackup and retry - phased restore now auto-enabled for BLOB databases",
Severity: 2, Severity: 2,
} }
} }
@@ -234,22 +234,22 @@ func FormatErrorWithHint(errorMsg string) string {
var icon string var icon string
switch classification.Type { switch classification.Type {
case "ignorable": case "ignorable":
icon = " " icon = "[i]"
case "warning": case "warning":
icon = "⚠️ " icon = "[!]"
case "critical": case "critical":
icon = "" icon = "[X]"
case "fatal": case "fatal":
icon = "🛑" icon = "[!!]"
default: default:
icon = "⚠️ " icon = "[!]"
} }
output := fmt.Sprintf("%s %s Error\n\n", icon, strings.ToUpper(classification.Type)) output := fmt.Sprintf("%s %s Error\n\n", icon, strings.ToUpper(classification.Type))
output += fmt.Sprintf("Category: %s\n", classification.Category) output += fmt.Sprintf("Category: %s\n", classification.Category)
output += fmt.Sprintf("Message: %s\n\n", classification.Message) output += fmt.Sprintf("Message: %s\n\n", classification.Message)
output += fmt.Sprintf("💡 Hint: %s\n\n", classification.Hint) output += fmt.Sprintf("[HINT] Hint: %s\n\n", classification.Hint)
output += fmt.Sprintf("🔧 Action: %s\n", classification.Action) output += fmt.Sprintf("[ACTION] Action: %s\n", classification.Action)
return output return output
} }
@@ -257,7 +257,7 @@ func FormatErrorWithHint(errorMsg string) string {
// FormatMultipleErrors formats multiple errors with classification // FormatMultipleErrors formats multiple errors with classification
func FormatMultipleErrors(errors []string) string { func FormatMultipleErrors(errors []string) string {
if len(errors) == 0 { if len(errors) == 0 {
return " No errors" return "[+] No errors"
} }
ignorable := 0 ignorable := 0
@@ -285,22 +285,22 @@ func FormatMultipleErrors(errors []string) string {
} }
} }
output := "📊 Error Summary:\n\n" output := "[SUMMARY] Error Summary:\n\n"
if ignorable > 0 { if ignorable > 0 {
output += fmt.Sprintf(" %d ignorable (objects already exist)\n", ignorable) output += fmt.Sprintf(" [i] %d ignorable (objects already exist)\n", ignorable)
} }
if warnings > 0 { if warnings > 0 {
output += fmt.Sprintf(" ⚠️ %d warnings\n", warnings) output += fmt.Sprintf(" [!] %d warnings\n", warnings)
} }
if critical > 0 { if critical > 0 {
output += fmt.Sprintf(" %d critical errors\n", critical) output += fmt.Sprintf(" [X] %d critical errors\n", critical)
} }
if fatal > 0 { if fatal > 0 {
output += fmt.Sprintf(" 🛑 %d fatal errors\n", fatal) output += fmt.Sprintf(" [!!] %d fatal errors\n", fatal)
} }
if len(criticalErrors) > 0 { if len(criticalErrors) > 0 {
output += "\n📝 Critical Issues:\n\n" output += "\n[CRITICAL] Critical Issues:\n\n"
for i, err := range criticalErrors { for i, err := range criticalErrors {
class := ClassifyError(err) class := ClassifyError(err)
output += fmt.Sprintf("%d. %s\n", i+1, class.Hint) output += fmt.Sprintf("%d. %s\n", i+1, class.Hint)

View File

@@ -49,15 +49,15 @@ func (s CheckStatus) String() string {
func (s CheckStatus) Icon() string { func (s CheckStatus) Icon() string {
switch s { switch s {
case StatusPassed: case StatusPassed:
return "" return "[+]"
case StatusWarning: case StatusWarning:
return "" return "[!]"
case StatusFailed: case StatusFailed:
return "" return "[-]"
case StatusSkipped: case StatusSkipped:
return "" return "[ ]"
default: default:
return "?" return "[?]"
} }
} }

View File

@@ -11,9 +11,9 @@ func FormatPreflightReport(result *PreflightResult, dbName string, verbose bool)
var sb strings.Builder var sb strings.Builder
sb.WriteString("\n") sb.WriteString("\n")
sb.WriteString("╔══════════════════════════════════════════════════════════════╗\n") sb.WriteString("+==============================================================+\n")
sb.WriteString(" [DRY RUN] Preflight Check Results \n") sb.WriteString("| [DRY RUN] Preflight Check Results |\n")
sb.WriteString("╚══════════════════════════════════════════════════════════════╝\n") sb.WriteString("+==============================================================+\n")
sb.WriteString("\n") sb.WriteString("\n")
// Database info // Database info
@@ -29,7 +29,7 @@ func FormatPreflightReport(result *PreflightResult, dbName string, verbose bool)
// Check results // Check results
sb.WriteString(" Checks:\n") sb.WriteString(" Checks:\n")
sb.WriteString(" ─────────────────────────────────────────────────────────────\n") sb.WriteString(" --------------------------------------------------------------\n")
for _, check := range result.Checks { for _, check := range result.Checks {
icon := check.Status.Icon() icon := check.Status.Icon()
@@ -40,26 +40,26 @@ func FormatPreflightReport(result *PreflightResult, dbName string, verbose bool)
color, icon, reset, check.Name+":", check.Message)) color, icon, reset, check.Name+":", check.Message))
if verbose && check.Details != "" { if verbose && check.Details != "" {
sb.WriteString(fmt.Sprintf(" └─ %s\n", check.Details)) sb.WriteString(fmt.Sprintf(" +- %s\n", check.Details))
} }
} }
sb.WriteString(" ─────────────────────────────────────────────────────────────\n") sb.WriteString(" --------------------------------------------------------------\n")
sb.WriteString("\n") sb.WriteString("\n")
// Summary // Summary
if result.AllPassed { if result.AllPassed {
if result.HasWarnings { if result.HasWarnings {
sb.WriteString(" ⚠️ All checks passed with warnings\n") sb.WriteString(" [!] All checks passed with warnings\n")
sb.WriteString("\n") sb.WriteString("\n")
sb.WriteString(" Ready to backup. Remove --dry-run to execute.\n") sb.WriteString(" Ready to backup. Remove --dry-run to execute.\n")
} else { } else {
sb.WriteString(" All checks passed\n") sb.WriteString(" [OK] All checks passed\n")
sb.WriteString("\n") sb.WriteString("\n")
sb.WriteString(" Ready to backup. Remove --dry-run to execute.\n") sb.WriteString(" Ready to backup. Remove --dry-run to execute.\n")
} }
} else { } else {
sb.WriteString(fmt.Sprintf(" %d check(s) failed\n", result.FailureCount)) sb.WriteString(fmt.Sprintf(" [FAIL] %d check(s) failed\n", result.FailureCount))
sb.WriteString("\n") sb.WriteString("\n")
sb.WriteString(" Fix the issues above before running backup.\n") sb.WriteString(" Fix the issues above before running backup.\n")
} }
@@ -96,7 +96,7 @@ func FormatPreflightReportPlain(result *PreflightResult, dbName string) string {
status := fmt.Sprintf("[%s]", check.Status.String()) status := fmt.Sprintf("[%s]", check.Status.String())
sb.WriteString(fmt.Sprintf(" %-10s %-25s %s\n", status, check.Name+":", check.Message)) sb.WriteString(fmt.Sprintf(" %-10s %-25s %s\n", status, check.Name+":", check.Message))
if check.Details != "" { if check.Details != "" {
sb.WriteString(fmt.Sprintf(" └─ %s\n", check.Details)) sb.WriteString(fmt.Sprintf(" +- %s\n", check.Details))
} }
} }

View File

@@ -90,7 +90,7 @@ func NewAzureBackend(cfg *Config) (*AzureBackend, error) {
} }
} else { } else {
// Use default Azure credential (managed identity, environment variables, etc.) // Use default Azure credential (managed identity, environment variables, etc.)
return nil, fmt.Errorf("Azure authentication requires account name and key, or use AZURE_STORAGE_CONNECTION_STRING environment variable") return nil, fmt.Errorf("azure authentication requires account name and key, or use AZURE_STORAGE_CONNECTION_STRING environment variable")
} }
} }
@@ -151,37 +151,46 @@ func (a *AzureBackend) Upload(ctx context.Context, localPath, remotePath string,
return a.uploadSimple(ctx, file, blobName, fileSize, progress) return a.uploadSimple(ctx, file, blobName, fileSize, progress)
} }
// uploadSimple uploads a file using simple upload (single request) // uploadSimple uploads a file using simple upload (single request) with retry
func (a *AzureBackend) uploadSimple(ctx context.Context, file *os.File, blobName string, fileSize int64, progress ProgressCallback) error { func (a *AzureBackend) uploadSimple(ctx context.Context, file *os.File, blobName string, fileSize int64, progress ProgressCallback) error {
blockBlobClient := a.client.ServiceClient().NewContainerClient(a.containerName).NewBlockBlobClient(blobName) return RetryOperationWithNotify(ctx, DefaultRetryConfig(), func() error {
// Reset file position for retry
if _, err := file.Seek(0, 0); err != nil {
return fmt.Errorf("failed to reset file position: %w", err)
}
// Wrap reader with progress tracking blockBlobClient := a.client.ServiceClient().NewContainerClient(a.containerName).NewBlockBlobClient(blobName)
reader := NewProgressReader(file, fileSize, progress)
// Calculate MD5 hash for integrity // Wrap reader with progress tracking
hash := sha256.New() reader := NewProgressReader(file, fileSize, progress)
teeReader := io.TeeReader(reader, hash)
_, err := blockBlobClient.UploadStream(ctx, teeReader, &blockblob.UploadStreamOptions{ // Calculate MD5 hash for integrity
BlockSize: 4 * 1024 * 1024, // 4MB blocks hash := sha256.New()
teeReader := io.TeeReader(reader, hash)
_, err := blockBlobClient.UploadStream(ctx, teeReader, &blockblob.UploadStreamOptions{
BlockSize: 4 * 1024 * 1024, // 4MB blocks
})
if err != nil {
return fmt.Errorf("failed to upload blob: %w", err)
}
// Store checksum as metadata
checksum := hex.EncodeToString(hash.Sum(nil))
metadata := map[string]*string{
"sha256": &checksum,
}
_, err = blockBlobClient.SetMetadata(ctx, metadata, nil)
if err != nil {
// Non-fatal: upload succeeded but metadata failed
fmt.Fprintf(os.Stderr, "Warning: failed to set blob metadata: %v\n", err)
}
return nil
}, func(err error, duration time.Duration) {
fmt.Printf("[Azure] Upload retry in %v: %v\n", duration, err)
}) })
if err != nil {
return fmt.Errorf("failed to upload blob: %w", err)
}
// Store checksum as metadata
checksum := hex.EncodeToString(hash.Sum(nil))
metadata := map[string]*string{
"sha256": &checksum,
}
_, err = blockBlobClient.SetMetadata(ctx, metadata, nil)
if err != nil {
// Non-fatal: upload succeeded but metadata failed
fmt.Fprintf(os.Stderr, "Warning: failed to set blob metadata: %v\n", err)
}
return nil
} }
// uploadBlocks uploads a file using block blob staging (for large files) // uploadBlocks uploads a file using block blob staging (for large files)
@@ -251,7 +260,7 @@ func (a *AzureBackend) uploadBlocks(ctx context.Context, file *os.File, blobName
return nil return nil
} }
// Download downloads a file from Azure Blob Storage // Download downloads a file from Azure Blob Storage with retry
func (a *AzureBackend) Download(ctx context.Context, remotePath, localPath string, progress ProgressCallback) error { func (a *AzureBackend) Download(ctx context.Context, remotePath, localPath string, progress ProgressCallback) error {
blobName := strings.TrimPrefix(remotePath, "/") blobName := strings.TrimPrefix(remotePath, "/")
blockBlobClient := a.client.ServiceClient().NewContainerClient(a.containerName).NewBlockBlobClient(blobName) blockBlobClient := a.client.ServiceClient().NewContainerClient(a.containerName).NewBlockBlobClient(blobName)
@@ -264,30 +273,34 @@ func (a *AzureBackend) Download(ctx context.Context, remotePath, localPath strin
fileSize := *props.ContentLength fileSize := *props.ContentLength
// Download blob return RetryOperationWithNotify(ctx, DefaultRetryConfig(), func() error {
resp, err := blockBlobClient.DownloadStream(ctx, nil) // Download blob
if err != nil { resp, err := blockBlobClient.DownloadStream(ctx, nil)
return fmt.Errorf("failed to download blob: %w", err) if err != nil {
} return fmt.Errorf("failed to download blob: %w", err)
defer resp.Body.Close() }
defer resp.Body.Close()
// Create local file // Create/truncate local file
file, err := os.Create(localPath) file, err := os.Create(localPath)
if err != nil { if err != nil {
return fmt.Errorf("failed to create file: %w", err) return fmt.Errorf("failed to create file: %w", err)
} }
defer file.Close() defer file.Close()
// Wrap reader with progress tracking // Wrap reader with progress tracking
reader := NewProgressReader(resp.Body, fileSize, progress) reader := NewProgressReader(resp.Body, fileSize, progress)
// Copy with progress // Copy with progress
_, err = io.Copy(file, reader) _, err = io.Copy(file, reader)
if err != nil { if err != nil {
return fmt.Errorf("failed to write file: %w", err) return fmt.Errorf("failed to write file: %w", err)
} }
return nil return nil
}, func(err error, duration time.Duration) {
fmt.Printf("[Azure] Download retry in %v: %v\n", duration, err)
})
} }
// Delete deletes a file from Azure Blob Storage // Delete deletes a file from Azure Blob Storage

View File

@@ -89,7 +89,7 @@ func (g *GCSBackend) Name() string {
return "gcs" return "gcs"
} }
// Upload uploads a file to Google Cloud Storage // Upload uploads a file to Google Cloud Storage with retry
func (g *GCSBackend) Upload(ctx context.Context, localPath, remotePath string, progress ProgressCallback) error { func (g *GCSBackend) Upload(ctx context.Context, localPath, remotePath string, progress ProgressCallback) error {
file, err := os.Open(localPath) file, err := os.Open(localPath)
if err != nil { if err != nil {
@@ -106,45 +106,54 @@ func (g *GCSBackend) Upload(ctx context.Context, localPath, remotePath string, p
// Remove leading slash from remote path // Remove leading slash from remote path
objectName := strings.TrimPrefix(remotePath, "/") objectName := strings.TrimPrefix(remotePath, "/")
bucket := g.client.Bucket(g.bucketName) return RetryOperationWithNotify(ctx, DefaultRetryConfig(), func() error {
object := bucket.Object(objectName) // Reset file position for retry
if _, err := file.Seek(0, 0); err != nil {
return fmt.Errorf("failed to reset file position: %w", err)
}
// Create writer with automatic chunking for large files bucket := g.client.Bucket(g.bucketName)
writer := object.NewWriter(ctx) object := bucket.Object(objectName)
writer.ChunkSize = 16 * 1024 * 1024 // 16MB chunks for streaming
// Wrap reader with progress tracking and hash calculation // Create writer with automatic chunking for large files
hash := sha256.New() writer := object.NewWriter(ctx)
reader := NewProgressReader(io.TeeReader(file, hash), fileSize, progress) writer.ChunkSize = 16 * 1024 * 1024 // 16MB chunks for streaming
// Upload with progress tracking // Wrap reader with progress tracking and hash calculation
_, err = io.Copy(writer, reader) hash := sha256.New()
if err != nil { reader := NewProgressReader(io.TeeReader(file, hash), fileSize, progress)
writer.Close()
return fmt.Errorf("failed to upload object: %w", err)
}
// Close writer (finalizes upload) // Upload with progress tracking
if err := writer.Close(); err != nil { _, err = io.Copy(writer, reader)
return fmt.Errorf("failed to finalize upload: %w", err) if err != nil {
} writer.Close()
return fmt.Errorf("failed to upload object: %w", err)
}
// Store checksum as metadata // Close writer (finalizes upload)
checksum := hex.EncodeToString(hash.Sum(nil)) if err := writer.Close(); err != nil {
_, err = object.Update(ctx, storage.ObjectAttrsToUpdate{ return fmt.Errorf("failed to finalize upload: %w", err)
Metadata: map[string]string{ }
"sha256": checksum,
}, // Store checksum as metadata
checksum := hex.EncodeToString(hash.Sum(nil))
_, err = object.Update(ctx, storage.ObjectAttrsToUpdate{
Metadata: map[string]string{
"sha256": checksum,
},
})
if err != nil {
// Non-fatal: upload succeeded but metadata failed
fmt.Fprintf(os.Stderr, "Warning: failed to set object metadata: %v\n", err)
}
return nil
}, func(err error, duration time.Duration) {
fmt.Printf("[GCS] Upload retry in %v: %v\n", duration, err)
}) })
if err != nil {
// Non-fatal: upload succeeded but metadata failed
fmt.Fprintf(os.Stderr, "Warning: failed to set object metadata: %v\n", err)
}
return nil
} }
// Download downloads a file from Google Cloud Storage // Download downloads a file from Google Cloud Storage with retry
func (g *GCSBackend) Download(ctx context.Context, remotePath, localPath string, progress ProgressCallback) error { func (g *GCSBackend) Download(ctx context.Context, remotePath, localPath string, progress ProgressCallback) error {
objectName := strings.TrimPrefix(remotePath, "/") objectName := strings.TrimPrefix(remotePath, "/")
@@ -159,30 +168,34 @@ func (g *GCSBackend) Download(ctx context.Context, remotePath, localPath string,
fileSize := attrs.Size fileSize := attrs.Size
// Create reader return RetryOperationWithNotify(ctx, DefaultRetryConfig(), func() error {
reader, err := object.NewReader(ctx) // Create reader
if err != nil { reader, err := object.NewReader(ctx)
return fmt.Errorf("failed to download object: %w", err) if err != nil {
} return fmt.Errorf("failed to download object: %w", err)
defer reader.Close() }
defer reader.Close()
// Create local file // Create/truncate local file
file, err := os.Create(localPath) file, err := os.Create(localPath)
if err != nil { if err != nil {
return fmt.Errorf("failed to create file: %w", err) return fmt.Errorf("failed to create file: %w", err)
} }
defer file.Close() defer file.Close()
// Wrap reader with progress tracking // Wrap reader with progress tracking
progressReader := NewProgressReader(reader, fileSize, progress) progressReader := NewProgressReader(reader, fileSize, progress)
// Copy with progress // Copy with progress
_, err = io.Copy(file, progressReader) _, err = io.Copy(file, progressReader)
if err != nil { if err != nil {
return fmt.Errorf("failed to write file: %w", err) return fmt.Errorf("failed to write file: %w", err)
} }
return nil return nil
}, func(err error, duration time.Duration) {
fmt.Printf("[GCS] Download retry in %v: %v\n", duration, err)
})
} }
// Delete deletes a file from Google Cloud Storage // Delete deletes a file from Google Cloud Storage

257
internal/cloud/retry.go Normal file
View File

@@ -0,0 +1,257 @@
package cloud
import (
"context"
"fmt"
"net"
"strings"
"time"
"github.com/cenkalti/backoff/v4"
)
// RetryConfig configures retry behavior
type RetryConfig struct {
MaxRetries int // Maximum number of retries (0 = unlimited)
InitialInterval time.Duration // Initial backoff interval
MaxInterval time.Duration // Maximum backoff interval
MaxElapsedTime time.Duration // Maximum total time for retries
Multiplier float64 // Backoff multiplier
}
// DefaultRetryConfig returns sensible defaults for cloud operations
func DefaultRetryConfig() *RetryConfig {
return &RetryConfig{
MaxRetries: 5,
InitialInterval: 500 * time.Millisecond,
MaxInterval: 30 * time.Second,
MaxElapsedTime: 5 * time.Minute,
Multiplier: 2.0,
}
}
// AggressiveRetryConfig returns config for critical operations that need more retries
func AggressiveRetryConfig() *RetryConfig {
return &RetryConfig{
MaxRetries: 10,
InitialInterval: 1 * time.Second,
MaxInterval: 60 * time.Second,
MaxElapsedTime: 15 * time.Minute,
Multiplier: 1.5,
}
}
// QuickRetryConfig returns config for operations that should fail fast
func QuickRetryConfig() *RetryConfig {
return &RetryConfig{
MaxRetries: 3,
InitialInterval: 100 * time.Millisecond,
MaxInterval: 5 * time.Second,
MaxElapsedTime: 30 * time.Second,
Multiplier: 2.0,
}
}
// RetryOperation executes an operation with exponential backoff retry
func RetryOperation(ctx context.Context, cfg *RetryConfig, operation func() error) error {
if cfg == nil {
cfg = DefaultRetryConfig()
}
// Create exponential backoff
expBackoff := backoff.NewExponentialBackOff()
expBackoff.InitialInterval = cfg.InitialInterval
expBackoff.MaxInterval = cfg.MaxInterval
expBackoff.MaxElapsedTime = cfg.MaxElapsedTime
expBackoff.Multiplier = cfg.Multiplier
expBackoff.Reset()
// Wrap with max retries if specified
var b backoff.BackOff = expBackoff
if cfg.MaxRetries > 0 {
b = backoff.WithMaxRetries(expBackoff, uint64(cfg.MaxRetries))
}
// Add context support
b = backoff.WithContext(b, ctx)
// Track attempts for logging
attempt := 0
// Wrap operation to handle permanent vs retryable errors
wrappedOp := func() error {
attempt++
err := operation()
if err == nil {
return nil
}
// Check if error is permanent (should not retry)
if IsPermanentError(err) {
return backoff.Permanent(err)
}
return err
}
return backoff.Retry(wrappedOp, b)
}
// RetryOperationWithNotify executes an operation with retry and calls notify on each retry
func RetryOperationWithNotify(ctx context.Context, cfg *RetryConfig, operation func() error, notify func(err error, duration time.Duration)) error {
if cfg == nil {
cfg = DefaultRetryConfig()
}
// Create exponential backoff
expBackoff := backoff.NewExponentialBackOff()
expBackoff.InitialInterval = cfg.InitialInterval
expBackoff.MaxInterval = cfg.MaxInterval
expBackoff.MaxElapsedTime = cfg.MaxElapsedTime
expBackoff.Multiplier = cfg.Multiplier
expBackoff.Reset()
// Wrap with max retries if specified
var b backoff.BackOff = expBackoff
if cfg.MaxRetries > 0 {
b = backoff.WithMaxRetries(expBackoff, uint64(cfg.MaxRetries))
}
// Add context support
b = backoff.WithContext(b, ctx)
// Wrap operation to handle permanent vs retryable errors
wrappedOp := func() error {
err := operation()
if err == nil {
return nil
}
// Check if error is permanent (should not retry)
if IsPermanentError(err) {
return backoff.Permanent(err)
}
return err
}
return backoff.RetryNotify(wrappedOp, b, notify)
}
// IsPermanentError returns true if the error should not be retried
func IsPermanentError(err error) bool {
if err == nil {
return false
}
errStr := strings.ToLower(err.Error())
// Authentication/authorization errors - don't retry
permanentPatterns := []string{
"access denied",
"forbidden",
"unauthorized",
"invalid credentials",
"invalid access key",
"invalid secret",
"no such bucket",
"bucket not found",
"container not found",
"nosuchbucket",
"nosuchkey",
"invalid argument",
"malformed",
"invalid request",
"permission denied",
"access control",
"policy",
}
for _, pattern := range permanentPatterns {
if strings.Contains(errStr, pattern) {
return true
}
}
return false
}
// IsRetryableError returns true if the error is transient and should be retried
func IsRetryableError(err error) bool {
if err == nil {
return false
}
// Network errors are typically retryable
var netErr net.Error
if ok := isNetError(err, &netErr); ok {
return netErr.Timeout() || netErr.Temporary()
}
errStr := strings.ToLower(err.Error())
// Transient errors - should retry
retryablePatterns := []string{
"timeout",
"connection reset",
"connection refused",
"connection closed",
"eof",
"broken pipe",
"temporary failure",
"service unavailable",
"internal server error",
"bad gateway",
"gateway timeout",
"too many requests",
"rate limit",
"throttl",
"slowdown",
"try again",
"retry",
}
for _, pattern := range retryablePatterns {
if strings.Contains(errStr, pattern) {
return true
}
}
return false
}
// isNetError checks if err wraps a net.Error
func isNetError(err error, target *net.Error) bool {
for err != nil {
if ne, ok := err.(net.Error); ok {
*target = ne
return true
}
// Try to unwrap
if unwrapper, ok := err.(interface{ Unwrap() error }); ok {
err = unwrapper.Unwrap()
} else {
break
}
}
return false
}
// WithRetry is a helper that wraps a function with default retry logic
func WithRetry(ctx context.Context, operationName string, fn func() error) error {
notify := func(err error, duration time.Duration) {
// Log retry attempts (caller can provide their own logger if needed)
fmt.Printf("[RETRY] %s failed, retrying in %v: %v\n", operationName, duration, err)
}
return RetryOperationWithNotify(ctx, DefaultRetryConfig(), fn, notify)
}
// WithRetryConfig is a helper that wraps a function with custom retry config
func WithRetryConfig(ctx context.Context, cfg *RetryConfig, operationName string, fn func() error) error {
notify := func(err error, duration time.Duration) {
fmt.Printf("[RETRY] %s failed, retrying in %v: %v\n", operationName, duration, err)
}
return RetryOperationWithNotify(ctx, cfg, fn, notify)
}

View File

@@ -7,6 +7,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/config"
@@ -123,63 +124,81 @@ func (s *S3Backend) Upload(ctx context.Context, localPath, remotePath string, pr
return s.uploadSimple(ctx, file, key, fileSize, progress) return s.uploadSimple(ctx, file, key, fileSize, progress)
} }
// uploadSimple performs a simple single-part upload // uploadSimple performs a simple single-part upload with retry
func (s *S3Backend) uploadSimple(ctx context.Context, file *os.File, key string, fileSize int64, progress ProgressCallback) error { func (s *S3Backend) uploadSimple(ctx context.Context, file *os.File, key string, fileSize int64, progress ProgressCallback) error {
// Create progress reader return RetryOperationWithNotify(ctx, DefaultRetryConfig(), func() error {
var reader io.Reader = file // Reset file position for retry
if progress != nil { if _, err := file.Seek(0, 0); err != nil {
reader = NewProgressReader(file, fileSize, progress) return fmt.Errorf("failed to reset file position: %w", err)
} }
// Upload to S3 // Create progress reader
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{ var reader io.Reader = file
Bucket: aws.String(s.bucket), if progress != nil {
Key: aws.String(key), reader = NewProgressReader(file, fileSize, progress)
Body: reader, }
// Upload to S3
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
Body: reader,
})
if err != nil {
return fmt.Errorf("failed to upload to S3: %w", err)
}
return nil
}, func(err error, duration time.Duration) {
fmt.Printf("[S3] Upload retry in %v: %v\n", duration, err)
}) })
if err != nil {
return fmt.Errorf("failed to upload to S3: %w", err)
}
return nil
} }
// uploadMultipart performs a multipart upload for large files // uploadMultipart performs a multipart upload for large files with retry
func (s *S3Backend) uploadMultipart(ctx context.Context, file *os.File, key string, fileSize int64, progress ProgressCallback) error { func (s *S3Backend) uploadMultipart(ctx context.Context, file *os.File, key string, fileSize int64, progress ProgressCallback) error {
// Create uploader with custom options return RetryOperationWithNotify(ctx, AggressiveRetryConfig(), func() error {
uploader := manager.NewUploader(s.client, func(u *manager.Uploader) { // Reset file position for retry
// Part size: 10MB if _, err := file.Seek(0, 0); err != nil {
u.PartSize = 10 * 1024 * 1024 return fmt.Errorf("failed to reset file position: %w", err)
}
// Upload up to 10 parts concurrently // Create uploader with custom options
u.Concurrency = 10 uploader := manager.NewUploader(s.client, func(u *manager.Uploader) {
// Part size: 10MB
u.PartSize = 10 * 1024 * 1024
// Leave parts on failure for debugging // Upload up to 10 parts concurrently
u.LeavePartsOnError = false u.Concurrency = 10
// Leave parts on failure for debugging
u.LeavePartsOnError = false
})
// Wrap file with progress reader
var reader io.Reader = file
if progress != nil {
reader = NewProgressReader(file, fileSize, progress)
}
// Upload with multipart
_, err := uploader.Upload(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
Body: reader,
})
if err != nil {
return fmt.Errorf("multipart upload failed: %w", err)
}
return nil
}, func(err error, duration time.Duration) {
fmt.Printf("[S3] Multipart upload retry in %v: %v\n", duration, err)
}) })
// Wrap file with progress reader
var reader io.Reader = file
if progress != nil {
reader = NewProgressReader(file, fileSize, progress)
}
// Upload with multipart
_, err := uploader.Upload(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
Body: reader,
})
if err != nil {
return fmt.Errorf("multipart upload failed: %w", err)
}
return nil
} }
// Download downloads a file from S3 // Download downloads a file from S3 with retry
func (s *S3Backend) Download(ctx context.Context, remotePath, localPath string, progress ProgressCallback) error { func (s *S3Backend) Download(ctx context.Context, remotePath, localPath string, progress ProgressCallback) error {
// Build S3 key // Build S3 key
key := s.buildKey(remotePath) key := s.buildKey(remotePath)
@@ -190,39 +209,44 @@ func (s *S3Backend) Download(ctx context.Context, remotePath, localPath string,
return fmt.Errorf("failed to get object size: %w", err) return fmt.Errorf("failed to get object size: %w", err)
} }
// Download from S3 // Create directory for local file
result, err := s.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
if err != nil {
return fmt.Errorf("failed to download from S3: %w", err)
}
defer result.Body.Close()
// Create local file
if err := os.MkdirAll(filepath.Dir(localPath), 0755); err != nil { if err := os.MkdirAll(filepath.Dir(localPath), 0755); err != nil {
return fmt.Errorf("failed to create directory: %w", err) return fmt.Errorf("failed to create directory: %w", err)
} }
outFile, err := os.Create(localPath) return RetryOperationWithNotify(ctx, DefaultRetryConfig(), func() error {
if err != nil { // Download from S3
return fmt.Errorf("failed to create local file: %w", err) result, err := s.client.GetObject(ctx, &s3.GetObjectInput{
} Bucket: aws.String(s.bucket),
defer outFile.Close() Key: aws.String(key),
})
if err != nil {
return fmt.Errorf("failed to download from S3: %w", err)
}
defer result.Body.Close()
// Copy with progress tracking // Create/truncate local file
var reader io.Reader = result.Body outFile, err := os.Create(localPath)
if progress != nil { if err != nil {
reader = NewProgressReader(result.Body, size, progress) return fmt.Errorf("failed to create local file: %w", err)
} }
defer outFile.Close()
_, err = io.Copy(outFile, reader) // Copy with progress tracking
if err != nil { var reader io.Reader = result.Body
return fmt.Errorf("failed to write file: %w", err) if progress != nil {
} reader = NewProgressReader(result.Body, size, progress)
}
return nil _, err = io.Copy(outFile, reader)
if err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
return nil
}, func(err error, duration time.Duration) {
fmt.Printf("[S3] Download retry in %v: %v\n", duration, err)
})
} }
// List lists all backup files in S3 // List lists all backup files in S3

View File

@@ -15,7 +15,6 @@ import (
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/jackc/pgx/v5/stdlib" "github.com/jackc/pgx/v5/stdlib"
_ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver (pgx)
) )
// PostgreSQL implements Database interface for PostgreSQL // PostgreSQL implements Database interface for PostgreSQL

View File

@@ -3,7 +3,9 @@ package dedup
import ( import (
"database/sql" "database/sql"
"fmt" "fmt"
"os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
_ "github.com/mattn/go-sqlite3" // SQLite driver _ "github.com/mattn/go-sqlite3" // SQLite driver
@@ -11,27 +13,67 @@ import (
// ChunkIndex provides fast chunk lookups using SQLite // ChunkIndex provides fast chunk lookups using SQLite
type ChunkIndex struct { type ChunkIndex struct {
db *sql.DB db *sql.DB
dbPath string
} }
// NewChunkIndex opens or creates a chunk index database // NewChunkIndex opens or creates a chunk index database at the default location
func NewChunkIndex(basePath string) (*ChunkIndex, error) { func NewChunkIndex(basePath string) (*ChunkIndex, error) {
dbPath := filepath.Join(basePath, "chunks.db") dbPath := filepath.Join(basePath, "chunks.db")
return NewChunkIndexAt(dbPath)
}
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_synchronous=NORMAL") // NewChunkIndexAt opens or creates a chunk index database at a specific path
// Use this to put the SQLite index on local storage when chunks are on NFS/CIFS
func NewChunkIndexAt(dbPath string) (*ChunkIndex, error) {
// Ensure parent directory exists
if err := os.MkdirAll(filepath.Dir(dbPath), 0700); err != nil {
return nil, fmt.Errorf("failed to create index directory: %w", err)
}
// Add busy_timeout to handle lock contention gracefully
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_synchronous=NORMAL&_busy_timeout=5000")
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to open chunk index: %w", err) return nil, fmt.Errorf("failed to open chunk index: %w", err)
} }
idx := &ChunkIndex{db: db} // Test the connection and check for locking issues
if err := db.Ping(); err != nil {
db.Close()
if isNFSLockingError(err) {
return nil, fmt.Errorf("database locked (common on NFS/CIFS): %w\n\n"+
"HINT: Use --index-db to put the SQLite index on local storage:\n"+
" dbbackup dedup ... --index-db /var/lib/dbbackup/dedup-index.db", err)
}
return nil, fmt.Errorf("failed to connect to chunk index: %w", err)
}
idx := &ChunkIndex{db: db, dbPath: dbPath}
if err := idx.migrate(); err != nil { if err := idx.migrate(); err != nil {
db.Close() db.Close()
if isNFSLockingError(err) {
return nil, fmt.Errorf("database locked during migration (common on NFS/CIFS): %w\n\n"+
"HINT: Use --index-db to put the SQLite index on local storage:\n"+
" dbbackup dedup ... --index-db /var/lib/dbbackup/dedup-index.db", err)
}
return nil, err return nil, err
} }
return idx, nil return idx, nil
} }
// isNFSLockingError checks if an error is likely due to NFS/CIFS locking issues
func isNFSLockingError(err error) bool {
if err == nil {
return false
}
errStr := err.Error()
return strings.Contains(errStr, "database is locked") ||
strings.Contains(errStr, "SQLITE_BUSY") ||
strings.Contains(errStr, "cannot lock") ||
strings.Contains(errStr, "lock protocol")
}
// migrate creates the schema if needed // migrate creates the schema if needed
func (idx *ChunkIndex) migrate() error { func (idx *ChunkIndex) migrate() error {
schema := ` schema := `
@@ -166,15 +208,26 @@ func (idx *ChunkIndex) RemoveManifest(id string) error {
return err return err
} }
// UpdateManifestVerified updates the verified timestamp for a manifest
func (idx *ChunkIndex) UpdateManifestVerified(id string, verifiedAt time.Time) error {
_, err := idx.db.Exec("UPDATE manifests SET verified_at = ? WHERE id = ?", verifiedAt, id)
return err
}
// IndexStats holds statistics about the dedup index // IndexStats holds statistics about the dedup index
type IndexStats struct { type IndexStats struct {
TotalChunks int64 TotalChunks int64
TotalManifests int64 TotalManifests int64
TotalSizeRaw int64 // Uncompressed, undeduplicated TotalSizeRaw int64 // Uncompressed, undeduplicated (per-chunk)
TotalSizeStored int64 // On-disk after dedup+compression TotalSizeStored int64 // On-disk after dedup+compression (per-chunk)
DedupRatio float64 DedupRatio float64 // Based on manifests (real dedup ratio)
OldestChunk time.Time OldestChunk time.Time
NewestChunk time.Time NewestChunk time.Time
// Manifest-based stats (accurate dedup calculation)
TotalBackupSize int64 // Sum of all backup original sizes
TotalNewData int64 // Sum of all new chunks stored
SpaceSaved int64 // Difference = what dedup saved
} }
// Stats returns statistics about the index // Stats returns statistics about the index
@@ -206,8 +259,22 @@ func (idx *ChunkIndex) Stats() (*IndexStats, error) {
idx.db.QueryRow("SELECT COUNT(*) FROM manifests").Scan(&stats.TotalManifests) idx.db.QueryRow("SELECT COUNT(*) FROM manifests").Scan(&stats.TotalManifests)
if stats.TotalSizeRaw > 0 { // Calculate accurate dedup ratio from manifests
stats.DedupRatio = 1.0 - float64(stats.TotalSizeStored)/float64(stats.TotalSizeRaw) // Sum all backup original sizes and all new data stored
err = idx.db.QueryRow(`
SELECT
COALESCE(SUM(original_size), 0),
COALESCE(SUM(stored_size), 0)
FROM manifests
`).Scan(&stats.TotalBackupSize, &stats.TotalNewData)
if err != nil {
return nil, err
}
// Calculate real dedup ratio: how much data was deduplicated across all backups
if stats.TotalBackupSize > 0 {
stats.DedupRatio = 1.0 - float64(stats.TotalNewData)/float64(stats.TotalBackupSize)
stats.SpaceSaved = stats.TotalBackupSize - stats.TotalNewData
} }
return stats, nil return stats, nil

View File

@@ -36,8 +36,9 @@ type Manifest struct {
DedupRatio float64 `json:"dedup_ratio"` // 1.0 = no dedup, 0.0 = 100% dedup DedupRatio float64 `json:"dedup_ratio"` // 1.0 = no dedup, 0.0 = 100% dedup
// Encryption and compression settings used // Encryption and compression settings used
Encrypted bool `json:"encrypted"` Encrypted bool `json:"encrypted"`
Compressed bool `json:"compressed"` Compressed bool `json:"compressed"`
Decompressed bool `json:"decompressed,omitempty"` // Input was auto-decompressed before chunking
// Verification // Verification
SHA256 string `json:"sha256"` // Hash of reconstructed file SHA256 string `json:"sha256"` // Hash of reconstructed file

235
internal/dedup/metrics.go Normal file
View File

@@ -0,0 +1,235 @@
package dedup
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// DedupMetrics holds deduplication statistics for Prometheus
type DedupMetrics struct {
// Global stats
TotalChunks int64
TotalManifests int64
TotalBackupSize int64 // Sum of all backup original sizes
TotalNewData int64 // Sum of all new chunks stored
SpaceSaved int64 // Bytes saved by deduplication
DedupRatio float64 // Overall dedup ratio (0-1)
DiskUsage int64 // Actual bytes on disk
// Per-database stats
ByDatabase map[string]*DatabaseDedupMetrics
}
// DatabaseDedupMetrics holds per-database dedup stats
type DatabaseDedupMetrics struct {
Database string
BackupCount int
TotalSize int64
StoredSize int64
DedupRatio float64
LastBackupTime time.Time
LastVerified time.Time
}
// CollectMetrics gathers dedup statistics from the index and store
func CollectMetrics(basePath string, indexPath string) (*DedupMetrics, error) {
var idx *ChunkIndex
var err error
if indexPath != "" {
idx, err = NewChunkIndexAt(indexPath)
} else {
idx, err = NewChunkIndex(basePath)
}
if err != nil {
return nil, fmt.Errorf("failed to open chunk index: %w", err)
}
defer idx.Close()
store, err := NewChunkStore(StoreConfig{BasePath: basePath})
if err != nil {
return nil, fmt.Errorf("failed to open chunk store: %w", err)
}
// Get index stats
stats, err := idx.Stats()
if err != nil {
return nil, fmt.Errorf("failed to get index stats: %w", err)
}
// Get store stats
storeStats, err := store.Stats()
if err != nil {
return nil, fmt.Errorf("failed to get store stats: %w", err)
}
metrics := &DedupMetrics{
TotalChunks: stats.TotalChunks,
TotalManifests: stats.TotalManifests,
TotalBackupSize: stats.TotalBackupSize,
TotalNewData: stats.TotalNewData,
SpaceSaved: stats.SpaceSaved,
DedupRatio: stats.DedupRatio,
DiskUsage: storeStats.TotalSize,
ByDatabase: make(map[string]*DatabaseDedupMetrics),
}
// Collect per-database metrics from manifest store
manifestStore, err := NewManifestStore(basePath)
if err != nil {
return metrics, nil // Return partial metrics
}
manifests, err := manifestStore.ListAll()
if err != nil {
return metrics, nil // Return partial metrics
}
for _, m := range manifests {
dbKey := m.DatabaseName
if dbKey == "" {
dbKey = "_default"
}
dbMetrics, ok := metrics.ByDatabase[dbKey]
if !ok {
dbMetrics = &DatabaseDedupMetrics{
Database: dbKey,
}
metrics.ByDatabase[dbKey] = dbMetrics
}
dbMetrics.BackupCount++
dbMetrics.TotalSize += m.OriginalSize
dbMetrics.StoredSize += m.StoredSize
if m.CreatedAt.After(dbMetrics.LastBackupTime) {
dbMetrics.LastBackupTime = m.CreatedAt
}
if !m.VerifiedAt.IsZero() && m.VerifiedAt.After(dbMetrics.LastVerified) {
dbMetrics.LastVerified = m.VerifiedAt
}
}
// Calculate per-database dedup ratios
for _, dbMetrics := range metrics.ByDatabase {
if dbMetrics.TotalSize > 0 {
dbMetrics.DedupRatio = 1.0 - float64(dbMetrics.StoredSize)/float64(dbMetrics.TotalSize)
}
}
return metrics, nil
}
// WritePrometheusTextfile writes dedup metrics in Prometheus format
func WritePrometheusTextfile(path string, instance string, basePath string, indexPath string) error {
metrics, err := CollectMetrics(basePath, indexPath)
if err != nil {
return err
}
output := FormatPrometheusMetrics(metrics, instance)
// Atomic write
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
tmpPath := path + ".tmp"
if err := os.WriteFile(tmpPath, []byte(output), 0644); err != nil {
return fmt.Errorf("failed to write temp file: %w", err)
}
if err := os.Rename(tmpPath, path); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("failed to rename temp file: %w", err)
}
return nil
}
// FormatPrometheusMetrics formats dedup metrics in Prometheus exposition format
func FormatPrometheusMetrics(m *DedupMetrics, instance string) string {
var b strings.Builder
now := time.Now().Unix()
b.WriteString("# DBBackup Deduplication Prometheus Metrics\n")
b.WriteString(fmt.Sprintf("# Generated at: %s\n", time.Now().Format(time.RFC3339)))
b.WriteString(fmt.Sprintf("# Instance: %s\n", instance))
b.WriteString("\n")
// Global dedup metrics
b.WriteString("# HELP dbbackup_dedup_chunks_total Total number of unique chunks stored\n")
b.WriteString("# TYPE dbbackup_dedup_chunks_total gauge\n")
b.WriteString(fmt.Sprintf("dbbackup_dedup_chunks_total{instance=%q} %d\n", instance, m.TotalChunks))
b.WriteString("\n")
b.WriteString("# HELP dbbackup_dedup_manifests_total Total number of deduplicated backups\n")
b.WriteString("# TYPE dbbackup_dedup_manifests_total gauge\n")
b.WriteString(fmt.Sprintf("dbbackup_dedup_manifests_total{instance=%q} %d\n", instance, m.TotalManifests))
b.WriteString("\n")
b.WriteString("# HELP dbbackup_dedup_backup_bytes_total Total logical size of all backups in bytes\n")
b.WriteString("# TYPE dbbackup_dedup_backup_bytes_total gauge\n")
b.WriteString(fmt.Sprintf("dbbackup_dedup_backup_bytes_total{instance=%q} %d\n", instance, m.TotalBackupSize))
b.WriteString("\n")
b.WriteString("# HELP dbbackup_dedup_stored_bytes_total Total unique data stored in bytes (after dedup)\n")
b.WriteString("# TYPE dbbackup_dedup_stored_bytes_total gauge\n")
b.WriteString(fmt.Sprintf("dbbackup_dedup_stored_bytes_total{instance=%q} %d\n", instance, m.TotalNewData))
b.WriteString("\n")
b.WriteString("# HELP dbbackup_dedup_space_saved_bytes Bytes saved by deduplication\n")
b.WriteString("# TYPE dbbackup_dedup_space_saved_bytes gauge\n")
b.WriteString(fmt.Sprintf("dbbackup_dedup_space_saved_bytes{instance=%q} %d\n", instance, m.SpaceSaved))
b.WriteString("\n")
b.WriteString("# HELP dbbackup_dedup_ratio Deduplication ratio (0-1, higher is better)\n")
b.WriteString("# TYPE dbbackup_dedup_ratio gauge\n")
b.WriteString(fmt.Sprintf("dbbackup_dedup_ratio{instance=%q} %.4f\n", instance, m.DedupRatio))
b.WriteString("\n")
b.WriteString("# HELP dbbackup_dedup_disk_usage_bytes Actual disk usage of chunk store\n")
b.WriteString("# TYPE dbbackup_dedup_disk_usage_bytes gauge\n")
b.WriteString(fmt.Sprintf("dbbackup_dedup_disk_usage_bytes{instance=%q} %d\n", instance, m.DiskUsage))
b.WriteString("\n")
// Per-database metrics
if len(m.ByDatabase) > 0 {
b.WriteString("# HELP dbbackup_dedup_database_backup_count Number of deduplicated backups per database\n")
b.WriteString("# TYPE dbbackup_dedup_database_backup_count gauge\n")
for _, db := range m.ByDatabase {
b.WriteString(fmt.Sprintf("dbbackup_dedup_database_backup_count{instance=%q,database=%q} %d\n",
instance, db.Database, db.BackupCount))
}
b.WriteString("\n")
b.WriteString("# HELP dbbackup_dedup_database_ratio Deduplication ratio per database (0-1)\n")
b.WriteString("# TYPE dbbackup_dedup_database_ratio gauge\n")
for _, db := range m.ByDatabase {
b.WriteString(fmt.Sprintf("dbbackup_dedup_database_ratio{instance=%q,database=%q} %.4f\n",
instance, db.Database, db.DedupRatio))
}
b.WriteString("\n")
b.WriteString("# HELP dbbackup_dedup_database_last_backup_timestamp Last backup timestamp per database\n")
b.WriteString("# TYPE dbbackup_dedup_database_last_backup_timestamp gauge\n")
for _, db := range m.ByDatabase {
if !db.LastBackupTime.IsZero() {
b.WriteString(fmt.Sprintf("dbbackup_dedup_database_last_backup_timestamp{instance=%q,database=%q} %d\n",
instance, db.Database, db.LastBackupTime.Unix()))
}
}
b.WriteString("\n")
}
b.WriteString("# HELP dbbackup_dedup_scrape_timestamp Unix timestamp when dedup metrics were collected\n")
b.WriteString("# TYPE dbbackup_dedup_scrape_timestamp gauge\n")
b.WriteString(fmt.Sprintf("dbbackup_dedup_scrape_timestamp{instance=%q} %d\n", instance, now))
return b.String()
}

View File

@@ -223,11 +223,11 @@ func (r *DrillResult) IsSuccess() bool {
// Summary returns a human-readable summary of the drill // Summary returns a human-readable summary of the drill
func (r *DrillResult) Summary() string { func (r *DrillResult) Summary() string {
status := " PASSED" status := "[OK] PASSED"
if !r.Success { if !r.Success {
status = " FAILED" status = "[FAIL] FAILED"
} else if r.Status == StatusPartial { } else if r.Status == StatusPartial {
status = "⚠️ PARTIAL" status = "[WARN] PARTIAL"
} }
return fmt.Sprintf("%s - %s (%.2fs) - %d tables, %d rows", return fmt.Sprintf("%s - %s (%.2fs) - %d tables, %d rows",

View File

@@ -41,20 +41,20 @@ func (e *Engine) Run(ctx context.Context, config *DrillConfig) (*DrillResult, er
TargetRTO: float64(config.MaxRestoreSeconds), TargetRTO: float64(config.MaxRestoreSeconds),
} }
e.log.Info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") e.log.Info("=====================================================")
e.log.Info(" 🧪 DR Drill: " + result.DrillID) e.log.Info(" [TEST] DR Drill: " + result.DrillID)
e.log.Info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") e.log.Info("=====================================================")
e.log.Info("") e.log.Info("")
// Cleanup function for error cases // Cleanup function for error cases
var containerID string var containerID string
cleanup := func() { cleanup := func() {
if containerID != "" && config.CleanupOnExit && (result.Success || !config.KeepOnFailure) { if containerID != "" && config.CleanupOnExit && (result.Success || !config.KeepOnFailure) {
e.log.Info("🗑️ Cleaning up container...") e.log.Info("[DEL] Cleaning up container...")
e.docker.RemoveContainer(context.Background(), containerID) e.docker.RemoveContainer(context.Background(), containerID)
} else if containerID != "" { } else if containerID != "" {
result.ContainerKept = true result.ContainerKept = true
e.log.Info("📦 Container kept for debugging: " + containerID) e.log.Info("[PKG] Container kept for debugging: " + containerID)
} }
} }
defer cleanup() defer cleanup()
@@ -88,7 +88,7 @@ func (e *Engine) Run(ctx context.Context, config *DrillConfig) (*DrillResult, er
} }
containerID = container.ID containerID = container.ID
result.ContainerID = containerID result.ContainerID = containerID
e.log.Info("📦 Container started: " + containerID[:12]) e.log.Info("[PKG] Container started: " + containerID[:12])
// Wait for container to be healthy // Wait for container to be healthy
if err := e.docker.WaitForHealth(ctx, containerID, config.DatabaseType, config.ContainerTimeout); err != nil { if err := e.docker.WaitForHealth(ctx, containerID, config.DatabaseType, config.ContainerTimeout); err != nil {
@@ -118,7 +118,7 @@ func (e *Engine) Run(ctx context.Context, config *DrillConfig) (*DrillResult, er
result.RestoreTime = time.Since(restoreStart).Seconds() result.RestoreTime = time.Since(restoreStart).Seconds()
e.completePhase(&phase, fmt.Sprintf("Restored in %.2fs", result.RestoreTime)) e.completePhase(&phase, fmt.Sprintf("Restored in %.2fs", result.RestoreTime))
result.Phases = append(result.Phases, phase) result.Phases = append(result.Phases, phase)
e.log.Info(fmt.Sprintf(" Backup restored in %.2fs", result.RestoreTime)) e.log.Info(fmt.Sprintf("[OK] Backup restored in %.2fs", result.RestoreTime))
// Phase 4: Validate // Phase 4: Validate
phase = e.startPhase("Validate Database") phase = e.startPhase("Validate Database")
@@ -182,24 +182,24 @@ func (e *Engine) preflightChecks(ctx context.Context, config *DrillConfig) error
if err := e.docker.CheckDockerAvailable(ctx); err != nil { if err := e.docker.CheckDockerAvailable(ctx); err != nil {
return fmt.Errorf("docker not available: %w", err) return fmt.Errorf("docker not available: %w", err)
} }
e.log.Info(" Docker is available") e.log.Info("[OK] Docker is available")
// Check backup file exists // Check backup file exists
if _, err := os.Stat(config.BackupPath); err != nil { if _, err := os.Stat(config.BackupPath); err != nil {
return fmt.Errorf("backup file not found: %s", config.BackupPath) return fmt.Errorf("backup file not found: %s", config.BackupPath)
} }
e.log.Info(" Backup file exists: " + filepath.Base(config.BackupPath)) e.log.Info("[OK] Backup file exists: " + filepath.Base(config.BackupPath))
// Pull Docker image // Pull Docker image
image := config.ContainerImage image := config.ContainerImage
if image == "" { if image == "" {
image = GetDefaultImage(config.DatabaseType, "") image = GetDefaultImage(config.DatabaseType, "")
} }
e.log.Info("⬇️ Pulling image: " + image) e.log.Info("[DOWN] Pulling image: " + image)
if err := e.docker.PullImage(ctx, image); err != nil { if err := e.docker.PullImage(ctx, image); err != nil {
return fmt.Errorf("failed to pull image: %w", err) return fmt.Errorf("failed to pull image: %w", err)
} }
e.log.Info(" Image ready: " + image) e.log.Info("[OK] Image ready: " + image)
return nil return nil
} }
@@ -243,7 +243,7 @@ func (e *Engine) restoreBackup(ctx context.Context, config *DrillConfig, contain
backupName := filepath.Base(config.BackupPath) backupName := filepath.Base(config.BackupPath)
containerBackupPath := "/tmp/" + backupName containerBackupPath := "/tmp/" + backupName
e.log.Info("📁 Copying backup to container...") e.log.Info("[DIR] Copying backup to container...")
if err := e.docker.CopyToContainer(ctx, containerID, config.BackupPath, containerBackupPath); err != nil { if err := e.docker.CopyToContainer(ctx, containerID, config.BackupPath, containerBackupPath); err != nil {
return fmt.Errorf("failed to copy backup: %w", err) return fmt.Errorf("failed to copy backup: %w", err)
} }
@@ -256,7 +256,7 @@ func (e *Engine) restoreBackup(ctx context.Context, config *DrillConfig, contain
} }
// Restore based on database type and format // Restore based on database type and format
e.log.Info("🔄 Restoring backup...") e.log.Info("[EXEC] Restoring backup...")
return e.executeRestore(ctx, config, containerID, containerBackupPath, containerConfig) return e.executeRestore(ctx, config, containerID, containerBackupPath, containerConfig)
} }
@@ -366,13 +366,13 @@ func (e *Engine) validateDatabase(ctx context.Context, config *DrillConfig, resu
tables, err := validator.GetTableList(ctx) tables, err := validator.GetTableList(ctx)
if err == nil { if err == nil {
result.TableCount = len(tables) result.TableCount = len(tables)
e.log.Info(fmt.Sprintf("📊 Tables found: %d", result.TableCount)) e.log.Info(fmt.Sprintf("[STATS] Tables found: %d", result.TableCount))
} }
totalRows, err := validator.GetTotalRowCount(ctx) totalRows, err := validator.GetTotalRowCount(ctx)
if err == nil { if err == nil {
result.TotalRows = totalRows result.TotalRows = totalRows
e.log.Info(fmt.Sprintf("📊 Total rows: %d", result.TotalRows)) e.log.Info(fmt.Sprintf("[STATS] Total rows: %d", result.TotalRows))
} }
dbSize, err := validator.GetDatabaseSize(ctx, config.DatabaseName) dbSize, err := validator.GetDatabaseSize(ctx, config.DatabaseName)
@@ -387,9 +387,9 @@ func (e *Engine) validateDatabase(ctx context.Context, config *DrillConfig, resu
result.CheckResults = append(result.CheckResults, tr) result.CheckResults = append(result.CheckResults, tr)
if !tr.Success { if !tr.Success {
errorCount++ errorCount++
e.log.Warn(" " + tr.Message) e.log.Warn("[FAIL] " + tr.Message)
} else { } else {
e.log.Info(" " + tr.Message) e.log.Info("[OK] " + tr.Message)
} }
} }
} }
@@ -404,9 +404,9 @@ func (e *Engine) validateDatabase(ctx context.Context, config *DrillConfig, resu
totalQueryTime += qr.Duration totalQueryTime += qr.Duration
if !qr.Success { if !qr.Success {
errorCount++ errorCount++
e.log.Warn(fmt.Sprintf(" %s: %s", qr.Name, qr.Error)) e.log.Warn(fmt.Sprintf("[FAIL] %s: %s", qr.Name, qr.Error))
} else { } else {
e.log.Info(fmt.Sprintf(" %s: %s (%.0fms)", qr.Name, qr.Result, qr.Duration)) e.log.Info(fmt.Sprintf("[OK] %s: %s (%.0fms)", qr.Name, qr.Result, qr.Duration))
} }
} }
if len(queryResults) > 0 { if len(queryResults) > 0 {
@@ -421,9 +421,9 @@ func (e *Engine) validateDatabase(ctx context.Context, config *DrillConfig, resu
result.CheckResults = append(result.CheckResults, cr) result.CheckResults = append(result.CheckResults, cr)
if !cr.Success { if !cr.Success {
errorCount++ errorCount++
e.log.Warn(" " + cr.Message) e.log.Warn("[FAIL] " + cr.Message)
} else { } else {
e.log.Info(" " + cr.Message) e.log.Info("[OK] " + cr.Message)
} }
} }
} }
@@ -433,7 +433,7 @@ func (e *Engine) validateDatabase(ctx context.Context, config *DrillConfig, resu
errorCount++ errorCount++
msg := fmt.Sprintf("Total rows (%d) below minimum (%d)", result.TotalRows, config.MinRowCount) msg := fmt.Sprintf("Total rows (%d) below minimum (%d)", result.TotalRows, config.MinRowCount)
result.Warnings = append(result.Warnings, msg) result.Warnings = append(result.Warnings, msg)
e.log.Warn("⚠️ " + msg) e.log.Warn("[WARN] " + msg)
} }
return errorCount return errorCount
@@ -441,7 +441,7 @@ func (e *Engine) validateDatabase(ctx context.Context, config *DrillConfig, resu
// startPhase starts a new drill phase // startPhase starts a new drill phase
func (e *Engine) startPhase(name string) DrillPhase { func (e *Engine) startPhase(name string) DrillPhase {
e.log.Info("▶️ " + name) e.log.Info("[RUN] " + name)
return DrillPhase{ return DrillPhase{
Name: name, Name: name,
Status: "running", Status: "running",
@@ -463,7 +463,7 @@ func (e *Engine) failPhase(phase *DrillPhase, message string) {
phase.Duration = phase.EndTime.Sub(phase.StartTime).Seconds() phase.Duration = phase.EndTime.Sub(phase.StartTime).Seconds()
phase.Status = "failed" phase.Status = "failed"
phase.Message = message phase.Message = message
e.log.Error(" Phase failed: " + message) e.log.Error("[FAIL] Phase failed: " + message)
} }
// finalize completes the drill result // finalize completes the drill result
@@ -472,9 +472,9 @@ func (e *Engine) finalize(result *DrillResult) {
result.Duration = result.EndTime.Sub(result.StartTime).Seconds() result.Duration = result.EndTime.Sub(result.StartTime).Seconds()
e.log.Info("") e.log.Info("")
e.log.Info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") e.log.Info("=====================================================")
e.log.Info(" " + result.Summary()) e.log.Info(" " + result.Summary())
e.log.Info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") e.log.Info("=====================================================")
if result.Success { if result.Success {
e.log.Info(fmt.Sprintf(" RTO: %.2fs (target: %.0fs) %s", e.log.Info(fmt.Sprintf(" RTO: %.2fs (target: %.0fs) %s",
@@ -484,9 +484,9 @@ func (e *Engine) finalize(result *DrillResult) {
func boolIcon(b bool) string { func boolIcon(b bool) string {
if b { if b {
return "" return "[OK]"
} }
return "" return "[FAIL]"
} }
// Cleanup removes drill resources // Cleanup removes drill resources
@@ -498,7 +498,7 @@ func (e *Engine) Cleanup(ctx context.Context, drillID string) error {
for _, c := range containers { for _, c := range containers {
if strings.Contains(c.Name, drillID) || (drillID == "" && strings.HasPrefix(c.Name, "drill_")) { if strings.Contains(c.Name, drillID) || (drillID == "" && strings.HasPrefix(c.Name, "drill_")) {
e.log.Info("🗑️ Removing container: " + c.Name) e.log.Info("[DEL] Removing container: " + c.Name)
if err := e.docker.RemoveContainer(ctx, c.ID); err != nil { if err := e.docker.RemoveContainer(ctx, c.ID); err != nil {
e.log.Warn("Failed to remove container", "id", c.ID, "error", err) e.log.Warn("Failed to remove container", "id", c.ID, "error", err)
} }

View File

@@ -8,7 +8,7 @@ import (
func TestEncryptDecrypt(t *testing.T) { func TestEncryptDecrypt(t *testing.T) {
// Test data // Test data
original := []byte("This is a secret database backup that needs encryption! 🔒") original := []byte("This is a secret database backup that needs encryption! [LOCK]")
// Test with passphrase // Test with passphrase
t.Run("Passphrase", func(t *testing.T) { t.Run("Passphrase", func(t *testing.T) {
@@ -57,7 +57,7 @@ func TestEncryptDecrypt(t *testing.T) {
string(original), string(decrypted)) string(original), string(decrypted))
} }
t.Log(" Encryption/decryption successful") t.Log("[OK] Encryption/decryption successful")
}) })
// Test with direct key // Test with direct key
@@ -102,7 +102,7 @@ func TestEncryptDecrypt(t *testing.T) {
t.Errorf("Decrypted data doesn't match original") t.Errorf("Decrypted data doesn't match original")
} }
t.Log(" Direct key encryption/decryption successful") t.Log("[OK] Direct key encryption/decryption successful")
}) })
// Test wrong password // Test wrong password
@@ -133,7 +133,7 @@ func TestEncryptDecrypt(t *testing.T) {
t.Error("Expected decryption to fail with wrong password, but it succeeded") t.Error("Expected decryption to fail with wrong password, but it succeeded")
} }
t.Logf(" Wrong password correctly rejected: %v", err) t.Logf("[OK] Wrong password correctly rejected: %v", err)
}) })
} }
@@ -183,7 +183,7 @@ func TestLargeData(t *testing.T) {
t.Errorf("Large data decryption failed") t.Errorf("Large data decryption failed")
} }
t.Log(" Large data encryption/decryption successful") t.Log("[OK] Large data encryption/decryption successful")
} }
func TestKeyGeneration(t *testing.T) { func TestKeyGeneration(t *testing.T) {
@@ -207,7 +207,7 @@ func TestKeyGeneration(t *testing.T) {
t.Error("Generated keys are identical - randomness broken!") t.Error("Generated keys are identical - randomness broken!")
} }
t.Log(" Key generation successful") t.Log("[OK] Key generation successful")
} }
func TestKeyDerivation(t *testing.T) { func TestKeyDerivation(t *testing.T) {
@@ -230,5 +230,5 @@ func TestKeyDerivation(t *testing.T) {
t.Error("Different salts produced same key") t.Error("Different salts produced same key")
} }
t.Log(" Key derivation successful") t.Log("[OK] Key derivation successful")
} }

View File

@@ -63,7 +63,7 @@ func (b *BtrfsBackend) Detect(dataDir string) (bool, error) {
// CreateSnapshot creates a Btrfs snapshot // CreateSnapshot creates a Btrfs snapshot
func (b *BtrfsBackend) CreateSnapshot(ctx context.Context, opts SnapshotOptions) (*Snapshot, error) { func (b *BtrfsBackend) CreateSnapshot(ctx context.Context, opts SnapshotOptions) (*Snapshot, error) {
if b.config == nil || b.config.Subvolume == "" { if b.config == nil || b.config.Subvolume == "" {
return nil, fmt.Errorf("Btrfs subvolume not configured") return nil, fmt.Errorf("btrfs subvolume not configured")
} }
// Generate snapshot name // Generate snapshot name

View File

@@ -53,16 +53,16 @@ type InstallOptions struct {
// ServiceStatus contains information about installed services // ServiceStatus contains information about installed services
type ServiceStatus struct { type ServiceStatus struct {
Installed bool Installed bool
Enabled bool Enabled bool
Active bool Active bool
TimerEnabled bool TimerEnabled bool
TimerActive bool TimerActive bool
LastRun string LastRun string
NextRun string NextRun string
ServicePath string ServicePath string
TimerPath string TimerPath string
ExporterPath string ExporterPath string
} }
// NewInstaller creates a new Installer // NewInstaller creates a new Installer
@@ -188,7 +188,7 @@ func (i *Installer) Uninstall(ctx context.Context, instance string, purge bool)
if instance != "cluster" && instance != "" { if instance != "cluster" && instance != "" {
templateService := filepath.Join(i.unitDir, "dbbackup@.service") templateService := filepath.Join(i.unitDir, "dbbackup@.service")
templateTimer := filepath.Join(i.unitDir, "dbbackup@.timer") templateTimer := filepath.Join(i.unitDir, "dbbackup@.timer")
// Only remove templates if no other instances are using them // Only remove templates if no other instances are using them
if i.canRemoveTemplates() { if i.canRemoveTemplates() {
if !i.dryRun { if !i.dryRun {
@@ -644,11 +644,11 @@ func (i *Installer) canRemoveTemplates() bool {
// Check if any dbbackup@*.service instances exist // Check if any dbbackup@*.service instances exist
pattern := filepath.Join(i.unitDir, "dbbackup@*.service") pattern := filepath.Join(i.unitDir, "dbbackup@*.service")
matches, _ := filepath.Glob(pattern) matches, _ := filepath.Glob(pattern)
// Also check for running instances // Also check for running instances
cmd := exec.Command("systemctl", "list-units", "--type=service", "--all", "dbbackup@*") cmd := exec.Command("systemctl", "list-units", "--type=service", "--all", "dbbackup@*")
output, _ := cmd.Output() output, _ := cmd.Output()
return len(matches) == 0 && !strings.Contains(string(output), "dbbackup@") return len(matches) == 0 && !strings.Contains(string(output), "dbbackup@")
} }
@@ -658,9 +658,9 @@ func (i *Installer) printNextSteps(opts InstallOptions) {
serviceName := strings.Replace(timerName, ".timer", ".service", 1) serviceName := strings.Replace(timerName, ".timer", ".service", 1)
fmt.Println() fmt.Println()
fmt.Println(" Installation successful!") fmt.Println("[OK] Installation successful!")
fmt.Println() fmt.Println()
fmt.Println("📋 Next steps:") fmt.Println("[NEXT] Next steps:")
fmt.Println() fmt.Println()
fmt.Printf(" 1. Edit configuration: sudo nano %s\n", opts.ConfigPath) fmt.Printf(" 1. Edit configuration: sudo nano %s\n", opts.ConfigPath)
fmt.Printf(" 2. Set credentials: sudo nano /etc/dbbackup/env.d/%s.conf\n", opts.Instance) fmt.Printf(" 2. Set credentials: sudo nano /etc/dbbackup/env.d/%s.conf\n", opts.Instance)
@@ -668,12 +668,12 @@ func (i *Installer) printNextSteps(opts InstallOptions) {
fmt.Printf(" 4. Verify timer status: sudo systemctl status %s\n", timerName) fmt.Printf(" 4. Verify timer status: sudo systemctl status %s\n", timerName)
fmt.Printf(" 5. Run backup manually: sudo systemctl start %s\n", serviceName) fmt.Printf(" 5. Run backup manually: sudo systemctl start %s\n", serviceName)
fmt.Println() fmt.Println()
fmt.Println("📊 View backup logs:") fmt.Println("[LOGS] View backup logs:")
fmt.Printf(" journalctl -u %s -f\n", serviceName) fmt.Printf(" journalctl -u %s -f\n", serviceName)
fmt.Println() fmt.Println()
if opts.WithMetrics { if opts.WithMetrics {
fmt.Println("📈 Prometheus metrics:") fmt.Println("[METRICS] Prometheus metrics:")
fmt.Printf(" curl http://localhost:%d/metrics\n", opts.MetricsPort) fmt.Printf(" curl http://localhost:%d/metrics\n", opts.MetricsPort)
fmt.Println() fmt.Println()
} }

View File

@@ -33,8 +33,11 @@ RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
# Environment # Environment
EnvironmentFile=-/etc/dbbackup/env.d/cluster.conf EnvironmentFile=-/etc/dbbackup/env.d/cluster.conf
# Working directory (config is loaded from .dbbackup.conf here)
WorkingDirectory=/var/lib/dbbackup
# Execution - cluster backup (all databases) # Execution - cluster backup (all databases)
ExecStart={{.BinaryPath}} backup cluster --config {{.ConfigPath}} ExecStart={{.BinaryPath}} backup cluster --backup-dir {{.BackupDir}}
TimeoutStartSec={{.TimeoutSeconds}} TimeoutStartSec={{.TimeoutSeconds}}
# Post-backup metrics export # Post-backup metrics export

View File

@@ -33,8 +33,11 @@ RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
# Environment # Environment
EnvironmentFile=-/etc/dbbackup/env.d/%i.conf EnvironmentFile=-/etc/dbbackup/env.d/%i.conf
# Working directory (config is loaded from .dbbackup.conf here)
WorkingDirectory=/var/lib/dbbackup
# Execution # Execution
ExecStart={{.BinaryPath}} backup {{.BackupType}} %i --config {{.ConfigPath}} ExecStart={{.BinaryPath}} backup {{.BackupType}} %i --backup-dir {{.BackupDir}}
TimeoutStartSec={{.TimeoutSeconds}} TimeoutStartSec={{.TimeoutSeconds}}
# Post-backup metrics export # Post-backup metrics export

118
internal/logger/colors.go Normal file
View File

@@ -0,0 +1,118 @@
package logger
import (
"fmt"
"os"
"github.com/fatih/color"
)
// CLI output helpers using fatih/color for cross-platform support
// Success prints a success message with green checkmark
func Success(format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
SuccessColor.Fprint(os.Stdout, "✓ ")
fmt.Println(msg)
}
// Error prints an error message with red X
func Error(format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
ErrorColor.Fprint(os.Stderr, "✗ ")
fmt.Fprintln(os.Stderr, msg)
}
// Warning prints a warning message with yellow exclamation
func Warning(format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
WarnColor.Fprint(os.Stdout, "⚠ ")
fmt.Println(msg)
}
// Info prints an info message with blue arrow
func Info(format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
InfoColor.Fprint(os.Stdout, "→ ")
fmt.Println(msg)
}
// Header prints a bold header
func Header(format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
HighlightColor.Println(msg)
}
// Dim prints dimmed/secondary text
func Dim(format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
DimColor.Println(msg)
}
// Bold returns bold text
func Bold(text string) string {
return color.New(color.Bold).Sprint(text)
}
// Green returns green text
func Green(text string) string {
return SuccessColor.Sprint(text)
}
// Red returns red text
func Red(text string) string {
return ErrorColor.Sprint(text)
}
// Yellow returns yellow text
func Yellow(text string) string {
return WarnColor.Sprint(text)
}
// Cyan returns cyan text
func Cyan(text string) string {
return InfoColor.Sprint(text)
}
// StatusLine prints a key-value status line
func StatusLine(key, value string) {
DimColor.Printf(" %s: ", key)
fmt.Println(value)
}
// ProgressStatus prints operation status with timing
func ProgressStatus(operation string, status string, isSuccess bool) {
if isSuccess {
SuccessColor.Print("[OK] ")
} else {
ErrorColor.Print("[FAIL] ")
}
fmt.Printf("%s: %s\n", operation, status)
}
// Table prints a simple formatted table row
func TableRow(cols ...string) {
for i, col := range cols {
if i == 0 {
InfoColor.Printf("%-20s", col)
} else {
fmt.Printf("%-15s", col)
}
}
fmt.Println()
}
// DisableColors disables all color output (for non-TTY or --no-color flag)
func DisableColors() {
color.NoColor = true
}
// EnableColors enables color output
func EnableColors() {
color.NoColor = false
}
// IsColorEnabled returns whether colors are enabled
func IsColorEnabled() bool {
return !color.NoColor
}

View File

@@ -7,9 +7,29 @@ import (
"strings" "strings"
"time" "time"
"github.com/fatih/color"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
// Color printers for consistent output across the application
var (
// Status colors
SuccessColor = color.New(color.FgGreen, color.Bold)
ErrorColor = color.New(color.FgRed, color.Bold)
WarnColor = color.New(color.FgYellow, color.Bold)
InfoColor = color.New(color.FgCyan)
DebugColor = color.New(color.FgWhite)
// Highlight colors
HighlightColor = color.New(color.FgMagenta, color.Bold)
DimColor = color.New(color.FgHiBlack)
// Data colors
NumberColor = color.New(color.FgYellow)
PathColor = color.New(color.FgBlue, color.Underline)
TimeColor = color.New(color.FgCyan)
)
// Logger defines the interface for logging // Logger defines the interface for logging
type Logger interface { type Logger interface {
Debug(msg string, args ...any) Debug(msg string, args ...any)
@@ -226,34 +246,32 @@ type CleanFormatter struct{}
func (f *CleanFormatter) Format(entry *logrus.Entry) ([]byte, error) { func (f *CleanFormatter) Format(entry *logrus.Entry) ([]byte, error) {
timestamp := entry.Time.Format("2006-01-02T15:04:05") timestamp := entry.Time.Format("2006-01-02T15:04:05")
// Color codes for different log levels // Get level color and text using fatih/color
var levelColor, levelText string var levelPrinter *color.Color
var levelText string
switch entry.Level { switch entry.Level {
case logrus.DebugLevel: case logrus.DebugLevel:
levelColor = "\033[36m" // Cyan levelPrinter = DebugColor
levelText = "DEBUG" levelText = "DEBUG"
case logrus.InfoLevel: case logrus.InfoLevel:
levelColor = "\033[32m" // Green levelPrinter = SuccessColor
levelText = "INFO " levelText = "INFO "
case logrus.WarnLevel: case logrus.WarnLevel:
levelColor = "\033[33m" // Yellow levelPrinter = WarnColor
levelText = "WARN " levelText = "WARN "
case logrus.ErrorLevel: case logrus.ErrorLevel:
levelColor = "\033[31m" // Red levelPrinter = ErrorColor
levelText = "ERROR" levelText = "ERROR"
default: default:
levelColor = "\033[0m" // Reset levelPrinter = InfoColor
levelText = "INFO " levelText = "INFO "
} }
resetColor := "\033[0m"
// Build the message with perfectly aligned columns // Build the message with perfectly aligned columns
var output strings.Builder var output strings.Builder
// Column 1: Level (with color, fixed width 5 chars) // Column 1: Level (with color, fixed width 5 chars)
output.WriteString(levelColor) output.WriteString(levelPrinter.Sprint(levelText))
output.WriteString(levelText)
output.WriteString(resetColor)
output.WriteString(" ") output.WriteString(" ")
// Column 2: Timestamp (fixed format) // Column 2: Timestamp (fixed format)

View File

@@ -202,9 +202,9 @@ func (b *Batcher) formatSummaryDigest(events []*Event, success, failure, dbCount
func (b *Batcher) formatCompactDigest(events []*Event, success, failure int) string { func (b *Batcher) formatCompactDigest(events []*Event, success, failure int) string {
if failure > 0 { if failure > 0 {
return fmt.Sprintf("⚠️ %d/%d operations failed", failure, len(events)) return fmt.Sprintf("[WARN] %d/%d operations failed", failure, len(events))
} }
return fmt.Sprintf(" All %d operations successful", success) return fmt.Sprintf("[OK] All %d operations successful", success)
} }
func (b *Batcher) formatDetailedDigest(events []*Event) string { func (b *Batcher) formatDetailedDigest(events []*Event) string {
@@ -215,9 +215,9 @@ func (b *Batcher) formatDetailedDigest(events []*Event) string {
icon := "•" icon := "•"
switch e.Severity { switch e.Severity {
case SeverityError, SeverityCritical: case SeverityError, SeverityCritical:
icon = "" icon = "[FAIL]"
case SeverityWarning: case SeverityWarning:
icon = "⚠️" icon = "[WARN]"
} }
msg += fmt.Sprintf("%s [%s] %s: %s\n", msg += fmt.Sprintf("%s [%s] %s: %s\n",

View File

@@ -183,43 +183,43 @@ func DefaultConfig() Config {
// FormatEventSubject generates a subject line for notifications // FormatEventSubject generates a subject line for notifications
func FormatEventSubject(event *Event) string { func FormatEventSubject(event *Event) string {
icon := "" icon := "[INFO]"
switch event.Severity { switch event.Severity {
case SeverityWarning: case SeverityWarning:
icon = "⚠️" icon = "[WARN]"
case SeverityError, SeverityCritical: case SeverityError, SeverityCritical:
icon = "" icon = "[FAIL]"
} }
verb := "Event" verb := "Event"
switch event.Type { switch event.Type {
case EventBackupStarted: case EventBackupStarted:
verb = "Backup Started" verb = "Backup Started"
icon = "🔄" icon = "[EXEC]"
case EventBackupCompleted: case EventBackupCompleted:
verb = "Backup Completed" verb = "Backup Completed"
icon = "" icon = "[OK]"
case EventBackupFailed: case EventBackupFailed:
verb = "Backup Failed" verb = "Backup Failed"
icon = "" icon = "[FAIL]"
case EventRestoreStarted: case EventRestoreStarted:
verb = "Restore Started" verb = "Restore Started"
icon = "🔄" icon = "[EXEC]"
case EventRestoreCompleted: case EventRestoreCompleted:
verb = "Restore Completed" verb = "Restore Completed"
icon = "" icon = "[OK]"
case EventRestoreFailed: case EventRestoreFailed:
verb = "Restore Failed" verb = "Restore Failed"
icon = "" icon = "[FAIL]"
case EventCleanupCompleted: case EventCleanupCompleted:
verb = "Cleanup Completed" verb = "Cleanup Completed"
icon = "🗑️" icon = "[DEL]"
case EventVerifyCompleted: case EventVerifyCompleted:
verb = "Verification Passed" verb = "Verification Passed"
icon = "" icon = "[OK]"
case EventVerifyFailed: case EventVerifyFailed:
verb = "Verification Failed" verb = "Verification Failed"
icon = "" icon = "[FAIL]"
case EventPITRRecovery: case EventPITRRecovery:
verb = "PITR Recovery" verb = "PITR Recovery"
icon = "⏪" icon = "⏪"

View File

@@ -30,52 +30,52 @@ type Templates struct {
func DefaultTemplates() map[EventType]Templates { func DefaultTemplates() map[EventType]Templates {
return map[EventType]Templates{ return map[EventType]Templates{
EventBackupStarted: { EventBackupStarted: {
Subject: "🔄 Backup Started: {{.Database}} on {{.Hostname}}", Subject: "[EXEC] Backup Started: {{.Database}} on {{.Hostname}}",
TextBody: backupStartedText, TextBody: backupStartedText,
HTMLBody: backupStartedHTML, HTMLBody: backupStartedHTML,
}, },
EventBackupCompleted: { EventBackupCompleted: {
Subject: " Backup Completed: {{.Database}} on {{.Hostname}}", Subject: "[OK] Backup Completed: {{.Database}} on {{.Hostname}}",
TextBody: backupCompletedText, TextBody: backupCompletedText,
HTMLBody: backupCompletedHTML, HTMLBody: backupCompletedHTML,
}, },
EventBackupFailed: { EventBackupFailed: {
Subject: " Backup FAILED: {{.Database}} on {{.Hostname}}", Subject: "[FAIL] Backup FAILED: {{.Database}} on {{.Hostname}}",
TextBody: backupFailedText, TextBody: backupFailedText,
HTMLBody: backupFailedHTML, HTMLBody: backupFailedHTML,
}, },
EventRestoreStarted: { EventRestoreStarted: {
Subject: "🔄 Restore Started: {{.Database}} on {{.Hostname}}", Subject: "[EXEC] Restore Started: {{.Database}} on {{.Hostname}}",
TextBody: restoreStartedText, TextBody: restoreStartedText,
HTMLBody: restoreStartedHTML, HTMLBody: restoreStartedHTML,
}, },
EventRestoreCompleted: { EventRestoreCompleted: {
Subject: " Restore Completed: {{.Database}} on {{.Hostname}}", Subject: "[OK] Restore Completed: {{.Database}} on {{.Hostname}}",
TextBody: restoreCompletedText, TextBody: restoreCompletedText,
HTMLBody: restoreCompletedHTML, HTMLBody: restoreCompletedHTML,
}, },
EventRestoreFailed: { EventRestoreFailed: {
Subject: " Restore FAILED: {{.Database}} on {{.Hostname}}", Subject: "[FAIL] Restore FAILED: {{.Database}} on {{.Hostname}}",
TextBody: restoreFailedText, TextBody: restoreFailedText,
HTMLBody: restoreFailedHTML, HTMLBody: restoreFailedHTML,
}, },
EventVerificationPassed: { EventVerificationPassed: {
Subject: " Verification Passed: {{.Database}}", Subject: "[OK] Verification Passed: {{.Database}}",
TextBody: verificationPassedText, TextBody: verificationPassedText,
HTMLBody: verificationPassedHTML, HTMLBody: verificationPassedHTML,
}, },
EventVerificationFailed: { EventVerificationFailed: {
Subject: " Verification FAILED: {{.Database}}", Subject: "[FAIL] Verification FAILED: {{.Database}}",
TextBody: verificationFailedText, TextBody: verificationFailedText,
HTMLBody: verificationFailedHTML, HTMLBody: verificationFailedHTML,
}, },
EventDRDrillPassed: { EventDRDrillPassed: {
Subject: " DR Drill Passed: {{.Database}}", Subject: "[OK] DR Drill Passed: {{.Database}}",
TextBody: drDrillPassedText, TextBody: drDrillPassedText,
HTMLBody: drDrillPassedHTML, HTMLBody: drDrillPassedHTML,
}, },
EventDRDrillFailed: { EventDRDrillFailed: {
Subject: " DR Drill FAILED: {{.Database}}", Subject: "[FAIL] DR Drill FAILED: {{.Database}}",
TextBody: drDrillFailedText, TextBody: drDrillFailedText,
HTMLBody: drDrillFailedHTML, HTMLBody: drDrillFailedHTML,
}, },
@@ -95,7 +95,7 @@ Started At: {{formatTime .Timestamp}}
const backupStartedHTML = ` const backupStartedHTML = `
<div style="font-family: Arial, sans-serif; padding: 20px;"> <div style="font-family: Arial, sans-serif; padding: 20px;">
<h2 style="color: #3498db;">🔄 Backup Started</h2> <h2 style="color: #3498db;">[EXEC] Backup Started</h2>
<table style="border-collapse: collapse; width: 100%; max-width: 600px;"> <table style="border-collapse: collapse; width: 100%; max-width: 600px;">
<tr><td style="padding: 8px; font-weight: bold;">Database:</td><td style="padding: 8px;">{{.Database}}</td></tr> <tr><td style="padding: 8px; font-weight: bold;">Database:</td><td style="padding: 8px;">{{.Database}}</td></tr>
<tr><td style="padding: 8px; font-weight: bold;">Hostname:</td><td style="padding: 8px;">{{.Hostname}}</td></tr> <tr><td style="padding: 8px; font-weight: bold;">Hostname:</td><td style="padding: 8px;">{{.Hostname}}</td></tr>
@@ -121,7 +121,7 @@ Completed: {{formatTime .Timestamp}}
const backupCompletedHTML = ` const backupCompletedHTML = `
<div style="font-family: Arial, sans-serif; padding: 20px;"> <div style="font-family: Arial, sans-serif; padding: 20px;">
<h2 style="color: #27ae60;"> Backup Completed</h2> <h2 style="color: #27ae60;">[OK] Backup Completed</h2>
<table style="border-collapse: collapse; width: 100%; max-width: 600px;"> <table style="border-collapse: collapse; width: 100%; max-width: 600px;">
<tr><td style="padding: 8px; font-weight: bold;">Database:</td><td style="padding: 8px;">{{.Database}}</td></tr> <tr><td style="padding: 8px; font-weight: bold;">Database:</td><td style="padding: 8px;">{{.Database}}</td></tr>
<tr><td style="padding: 8px; font-weight: bold;">Hostname:</td><td style="padding: 8px;">{{.Hostname}}</td></tr> <tr><td style="padding: 8px; font-weight: bold;">Hostname:</td><td style="padding: 8px;">{{.Hostname}}</td></tr>
@@ -137,7 +137,7 @@ const backupCompletedHTML = `
` `
const backupFailedText = ` const backupFailedText = `
⚠️ BACKUP FAILED ⚠️ [WARN] BACKUP FAILED [WARN]
Database: {{.Database}} Database: {{.Database}}
Hostname: {{.Hostname}} Hostname: {{.Hostname}}
@@ -152,7 +152,7 @@ Please investigate immediately.
const backupFailedHTML = ` const backupFailedHTML = `
<div style="font-family: Arial, sans-serif; padding: 20px;"> <div style="font-family: Arial, sans-serif; padding: 20px;">
<h2 style="color: #e74c3c;"> Backup FAILED</h2> <h2 style="color: #e74c3c;">[FAIL] Backup FAILED</h2>
<table style="border-collapse: collapse; width: 100%; max-width: 600px;"> <table style="border-collapse: collapse; width: 100%; max-width: 600px;">
<tr><td style="padding: 8px; font-weight: bold;">Database:</td><td style="padding: 8px;">{{.Database}}</td></tr> <tr><td style="padding: 8px; font-weight: bold;">Database:</td><td style="padding: 8px;">{{.Database}}</td></tr>
<tr><td style="padding: 8px; font-weight: bold;">Hostname:</td><td style="padding: 8px;">{{.Hostname}}</td></tr> <tr><td style="padding: 8px; font-weight: bold;">Hostname:</td><td style="padding: 8px;">{{.Hostname}}</td></tr>
@@ -176,7 +176,7 @@ Started At: {{formatTime .Timestamp}}
const restoreStartedHTML = ` const restoreStartedHTML = `
<div style="font-family: Arial, sans-serif; padding: 20px;"> <div style="font-family: Arial, sans-serif; padding: 20px;">
<h2 style="color: #3498db;">🔄 Restore Started</h2> <h2 style="color: #3498db;">[EXEC] Restore Started</h2>
<table style="border-collapse: collapse; width: 100%; max-width: 600px;"> <table style="border-collapse: collapse; width: 100%; max-width: 600px;">
<tr><td style="padding: 8px; font-weight: bold;">Database:</td><td style="padding: 8px;">{{.Database}}</td></tr> <tr><td style="padding: 8px; font-weight: bold;">Database:</td><td style="padding: 8px;">{{.Database}}</td></tr>
<tr><td style="padding: 8px; font-weight: bold;">Hostname:</td><td style="padding: 8px;">{{.Hostname}}</td></tr> <tr><td style="padding: 8px; font-weight: bold;">Hostname:</td><td style="padding: 8px;">{{.Hostname}}</td></tr>
@@ -200,7 +200,7 @@ Completed: {{formatTime .Timestamp}}
const restoreCompletedHTML = ` const restoreCompletedHTML = `
<div style="font-family: Arial, sans-serif; padding: 20px;"> <div style="font-family: Arial, sans-serif; padding: 20px;">
<h2 style="color: #27ae60;"> Restore Completed</h2> <h2 style="color: #27ae60;">[OK] Restore Completed</h2>
<table style="border-collapse: collapse; width: 100%; max-width: 600px;"> <table style="border-collapse: collapse; width: 100%; max-width: 600px;">
<tr><td style="padding: 8px; font-weight: bold;">Database:</td><td style="padding: 8px;">{{.Database}}</td></tr> <tr><td style="padding: 8px; font-weight: bold;">Database:</td><td style="padding: 8px;">{{.Database}}</td></tr>
<tr><td style="padding: 8px; font-weight: bold;">Hostname:</td><td style="padding: 8px;">{{.Hostname}}</td></tr> <tr><td style="padding: 8px; font-weight: bold;">Hostname:</td><td style="padding: 8px;">{{.Hostname}}</td></tr>
@@ -214,7 +214,7 @@ const restoreCompletedHTML = `
` `
const restoreFailedText = ` const restoreFailedText = `
⚠️ RESTORE FAILED ⚠️ [WARN] RESTORE FAILED [WARN]
Database: {{.Database}} Database: {{.Database}}
Hostname: {{.Hostname}} Hostname: {{.Hostname}}
@@ -229,7 +229,7 @@ Please investigate immediately.
const restoreFailedHTML = ` const restoreFailedHTML = `
<div style="font-family: Arial, sans-serif; padding: 20px;"> <div style="font-family: Arial, sans-serif; padding: 20px;">
<h2 style="color: #e74c3c;"> Restore FAILED</h2> <h2 style="color: #e74c3c;">[FAIL] Restore FAILED</h2>
<table style="border-collapse: collapse; width: 100%; max-width: 600px;"> <table style="border-collapse: collapse; width: 100%; max-width: 600px;">
<tr><td style="padding: 8px; font-weight: bold;">Database:</td><td style="padding: 8px;">{{.Database}}</td></tr> <tr><td style="padding: 8px; font-weight: bold;">Database:</td><td style="padding: 8px;">{{.Database}}</td></tr>
<tr><td style="padding: 8px; font-weight: bold;">Hostname:</td><td style="padding: 8px;">{{.Hostname}}</td></tr> <tr><td style="padding: 8px; font-weight: bold;">Hostname:</td><td style="padding: 8px;">{{.Hostname}}</td></tr>
@@ -255,7 +255,7 @@ Verified: {{formatTime .Timestamp}}
const verificationPassedHTML = ` const verificationPassedHTML = `
<div style="font-family: Arial, sans-serif; padding: 20px;"> <div style="font-family: Arial, sans-serif; padding: 20px;">
<h2 style="color: #27ae60;"> Verification Passed</h2> <h2 style="color: #27ae60;">[OK] Verification Passed</h2>
<table style="border-collapse: collapse; width: 100%; max-width: 600px;"> <table style="border-collapse: collapse; width: 100%; max-width: 600px;">
<tr><td style="padding: 8px; font-weight: bold;">Database:</td><td style="padding: 8px;">{{.Database}}</td></tr> <tr><td style="padding: 8px; font-weight: bold;">Database:</td><td style="padding: 8px;">{{.Database}}</td></tr>
<tr><td style="padding: 8px; font-weight: bold;">Hostname:</td><td style="padding: 8px;">{{.Hostname}}</td></tr> <tr><td style="padding: 8px; font-weight: bold;">Hostname:</td><td style="padding: 8px;">{{.Hostname}}</td></tr>
@@ -269,7 +269,7 @@ const verificationPassedHTML = `
` `
const verificationFailedText = ` const verificationFailedText = `
⚠️ VERIFICATION FAILED ⚠️ [WARN] VERIFICATION FAILED [WARN]
Database: {{.Database}} Database: {{.Database}}
Hostname: {{.Hostname}} Hostname: {{.Hostname}}
@@ -284,7 +284,7 @@ Backup integrity may be compromised. Please investigate.
const verificationFailedHTML = ` const verificationFailedHTML = `
<div style="font-family: Arial, sans-serif; padding: 20px;"> <div style="font-family: Arial, sans-serif; padding: 20px;">
<h2 style="color: #e74c3c;"> Verification FAILED</h2> <h2 style="color: #e74c3c;">[FAIL] Verification FAILED</h2>
<table style="border-collapse: collapse; width: 100%; max-width: 600px;"> <table style="border-collapse: collapse; width: 100%; max-width: 600px;">
<tr><td style="padding: 8px; font-weight: bold;">Database:</td><td style="padding: 8px;">{{.Database}}</td></tr> <tr><td style="padding: 8px; font-weight: bold;">Database:</td><td style="padding: 8px;">{{.Database}}</td></tr>
<tr><td style="padding: 8px; font-weight: bold;">Hostname:</td><td style="padding: 8px;">{{.Hostname}}</td></tr> <tr><td style="padding: 8px; font-weight: bold;">Hostname:</td><td style="padding: 8px;">{{.Hostname}}</td></tr>
@@ -314,7 +314,7 @@ Backup restore capability verified.
const drDrillPassedHTML = ` const drDrillPassedHTML = `
<div style="font-family: Arial, sans-serif; padding: 20px;"> <div style="font-family: Arial, sans-serif; padding: 20px;">
<h2 style="color: #27ae60;"> DR Drill Passed</h2> <h2 style="color: #27ae60;">[OK] DR Drill Passed</h2>
<table style="border-collapse: collapse; width: 100%; max-width: 600px;"> <table style="border-collapse: collapse; width: 100%; max-width: 600px;">
<tr><td style="padding: 8px; font-weight: bold;">Database:</td><td style="padding: 8px;">{{.Database}}</td></tr> <tr><td style="padding: 8px; font-weight: bold;">Database:</td><td style="padding: 8px;">{{.Database}}</td></tr>
<tr><td style="padding: 8px; font-weight: bold;">Hostname:</td><td style="padding: 8px;">{{.Hostname}}</td></tr> <tr><td style="padding: 8px; font-weight: bold;">Hostname:</td><td style="padding: 8px;">{{.Hostname}}</td></tr>
@@ -326,12 +326,12 @@ const drDrillPassedHTML = `
{{end}} {{end}}
</table> </table>
{{if .Message}}<p style="margin-top: 20px; color: #27ae60;">{{.Message}}</p>{{end}} {{if .Message}}<p style="margin-top: 20px; color: #27ae60;">{{.Message}}</p>{{end}}
<p style="margin-top: 20px; color: #27ae60;"> Backup restore capability verified</p> <p style="margin-top: 20px; color: #27ae60;">[OK] Backup restore capability verified</p>
</div> </div>
` `
const drDrillFailedText = ` const drDrillFailedText = `
⚠️ DR DRILL FAILED ⚠️ [WARN] DR DRILL FAILED [WARN]
Database: {{.Database}} Database: {{.Database}}
Hostname: {{.Hostname}} Hostname: {{.Hostname}}
@@ -346,7 +346,7 @@ Backup may not be restorable. Please investigate immediately.
const drDrillFailedHTML = ` const drDrillFailedHTML = `
<div style="font-family: Arial, sans-serif; padding: 20px;"> <div style="font-family: Arial, sans-serif; padding: 20px;">
<h2 style="color: #e74c3c;"> DR Drill FAILED</h2> <h2 style="color: #e74c3c;">[FAIL] DR Drill FAILED</h2>
<table style="border-collapse: collapse; width: 100%; max-width: 600px;"> <table style="border-collapse: collapse; width: 100%; max-width: 600px;">
<tr><td style="padding: 8px; font-weight: bold;">Database:</td><td style="padding: 8px;">{{.Database}}</td></tr> <tr><td style="padding: 8px; font-weight: bold;">Database:</td><td style="padding: 8px;">{{.Database}}</td></tr>
<tr><td style="padding: 8px; font-weight: bold;">Hostname:</td><td style="padding: 8px;">{{.Hostname}}</td></tr> <tr><td style="padding: 8px; font-weight: bold;">Hostname:</td><td style="padding: 8px;">{{.Hostname}}</td></tr>

View File

@@ -43,9 +43,9 @@ type RestoreOptions struct {
// RestorePointInTime performs a Point-in-Time Recovery // RestorePointInTime performs a Point-in-Time Recovery
func (ro *RestoreOrchestrator) RestorePointInTime(ctx context.Context, opts *RestoreOptions) error { func (ro *RestoreOrchestrator) RestorePointInTime(ctx context.Context, opts *RestoreOptions) error {
ro.log.Info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") ro.log.Info("=====================================================")
ro.log.Info(" Point-in-Time Recovery (PITR)") ro.log.Info(" Point-in-Time Recovery (PITR)")
ro.log.Info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") ro.log.Info("=====================================================")
ro.log.Info("") ro.log.Info("")
ro.log.Info("Target:", "summary", opts.Target.Summary()) ro.log.Info("Target:", "summary", opts.Target.Summary())
ro.log.Info("Base Backup:", "path", opts.BaseBackupPath) ro.log.Info("Base Backup:", "path", opts.BaseBackupPath)
@@ -91,11 +91,11 @@ func (ro *RestoreOrchestrator) RestorePointInTime(ctx context.Context, opts *Res
return fmt.Errorf("failed to generate recovery configuration: %w", err) return fmt.Errorf("failed to generate recovery configuration: %w", err)
} }
ro.log.Info(" Recovery configuration generated successfully") ro.log.Info("[OK] Recovery configuration generated successfully")
ro.log.Info("") ro.log.Info("")
ro.log.Info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") ro.log.Info("=====================================================")
ro.log.Info(" Next Steps:") ro.log.Info(" Next Steps:")
ro.log.Info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") ro.log.Info("=====================================================")
ro.log.Info("") ro.log.Info("")
ro.log.Info("1. Start PostgreSQL to begin recovery:") ro.log.Info("1. Start PostgreSQL to begin recovery:")
ro.log.Info(fmt.Sprintf(" pg_ctl -D %s start", opts.TargetDataDir)) ro.log.Info(fmt.Sprintf(" pg_ctl -D %s start", opts.TargetDataDir))
@@ -192,7 +192,7 @@ func (ro *RestoreOrchestrator) validateInputs(opts *RestoreOptions) error {
} }
} }
ro.log.Info(" Validation passed") ro.log.Info("[OK] Validation passed")
return nil return nil
} }
@@ -238,7 +238,7 @@ func (ro *RestoreOrchestrator) extractTarGzBackup(ctx context.Context, source, d
return fmt.Errorf("tar extraction failed: %w", err) return fmt.Errorf("tar extraction failed: %w", err)
} }
ro.log.Info(" Base backup extracted successfully") ro.log.Info("[OK] Base backup extracted successfully")
return nil return nil
} }
@@ -254,7 +254,7 @@ func (ro *RestoreOrchestrator) extractTarBackup(ctx context.Context, source, des
return fmt.Errorf("tar extraction failed: %w", err) return fmt.Errorf("tar extraction failed: %w", err)
} }
ro.log.Info(" Base backup extracted successfully") ro.log.Info("[OK] Base backup extracted successfully")
return nil return nil
} }
@@ -270,7 +270,7 @@ func (ro *RestoreOrchestrator) copyDirectoryBackup(ctx context.Context, source,
return fmt.Errorf("directory copy failed: %w", err) return fmt.Errorf("directory copy failed: %w", err)
} }
ro.log.Info(" Base backup copied successfully") ro.log.Info("[OK] Base backup copied successfully")
return nil return nil
} }
@@ -291,7 +291,7 @@ func (ro *RestoreOrchestrator) startPostgreSQL(ctx context.Context, opts *Restor
return fmt.Errorf("pg_ctl start failed: %w", err) return fmt.Errorf("pg_ctl start failed: %w", err)
} }
ro.log.Info(" PostgreSQL started successfully") ro.log.Info("[OK] PostgreSQL started successfully")
ro.log.Info("PostgreSQL is now performing recovery...") ro.log.Info("PostgreSQL is now performing recovery...")
return nil return nil
} }
@@ -320,7 +320,7 @@ func (ro *RestoreOrchestrator) monitorRecovery(ctx context.Context, opts *Restor
// Check if recovery is complete by looking for postmaster.pid // Check if recovery is complete by looking for postmaster.pid
pidFile := filepath.Join(opts.TargetDataDir, "postmaster.pid") pidFile := filepath.Join(opts.TargetDataDir, "postmaster.pid")
if _, err := os.Stat(pidFile); err == nil { if _, err := os.Stat(pidFile); err == nil {
ro.log.Info(" PostgreSQL is running") ro.log.Info("[OK] PostgreSQL is running")
// Check if recovery files still exist // Check if recovery files still exist
recoverySignal := filepath.Join(opts.TargetDataDir, "recovery.signal") recoverySignal := filepath.Join(opts.TargetDataDir, "recovery.signal")
@@ -328,7 +328,7 @@ func (ro *RestoreOrchestrator) monitorRecovery(ctx context.Context, opts *Restor
if _, err := os.Stat(recoverySignal); os.IsNotExist(err) { if _, err := os.Stat(recoverySignal); os.IsNotExist(err) {
if _, err := os.Stat(recoveryConf); os.IsNotExist(err) { if _, err := os.Stat(recoveryConf); os.IsNotExist(err) {
ro.log.Info(" Recovery completed - PostgreSQL promoted to primary") ro.log.Info("[OK] Recovery completed - PostgreSQL promoted to primary")
return nil return nil
} }
} }

View File

@@ -256,7 +256,7 @@ func (ot *OperationTracker) Complete(message string) {
// Complete visual indicator // Complete visual indicator
if ot.reporter.indicator != nil { if ot.reporter.indicator != nil {
ot.reporter.indicator.Complete(fmt.Sprintf(" %s", message)) ot.reporter.indicator.Complete(fmt.Sprintf("[OK] %s", message))
} }
// Log completion with duration // Log completion with duration
@@ -286,7 +286,7 @@ func (ot *OperationTracker) Fail(err error) {
// Fail visual indicator // Fail visual indicator
if ot.reporter.indicator != nil { if ot.reporter.indicator != nil {
ot.reporter.indicator.Fail(fmt.Sprintf(" %s", err.Error())) ot.reporter.indicator.Fail(fmt.Sprintf("[FAIL] %s", err.Error()))
} }
// Log failure // Log failure
@@ -427,7 +427,7 @@ type OperationSummary struct {
// FormatSummary returns a formatted string representation of the summary // FormatSummary returns a formatted string representation of the summary
func (os *OperationSummary) FormatSummary() string { func (os *OperationSummary) FormatSummary() string {
return fmt.Sprintf( return fmt.Sprintf(
"📊 Operations Summary:\n"+ "[STATS] Operations Summary:\n"+
" Total: %d | Completed: %d | Failed: %d | Running: %d\n"+ " Total: %d | Completed: %d | Failed: %d | Running: %d\n"+
" Total Duration: %s", " Total Duration: %s",
os.TotalOperations, os.TotalOperations,

View File

@@ -6,6 +6,16 @@ import (
"os" "os"
"strings" "strings"
"time" "time"
"github.com/fatih/color"
"github.com/schollz/progressbar/v3"
)
// Color printers for progress indicators
var (
okColor = color.New(color.FgGreen, color.Bold)
failColor = color.New(color.FgRed, color.Bold)
warnColor = color.New(color.FgYellow, color.Bold)
) )
// Indicator represents a progress indicator interface // Indicator represents a progress indicator interface
@@ -92,13 +102,15 @@ func (s *Spinner) Update(message string) {
// Complete stops the spinner with a success message // Complete stops the spinner with a success message
func (s *Spinner) Complete(message string) { func (s *Spinner) Complete(message string) {
s.Stop() s.Stop()
fmt.Fprintf(s.writer, "\n✅ %s\n", message) okColor.Fprint(s.writer, "[OK] ")
fmt.Fprintln(s.writer, message)
} }
// Fail stops the spinner with a failure message // Fail stops the spinner with a failure message
func (s *Spinner) Fail(message string) { func (s *Spinner) Fail(message string) {
s.Stop() s.Stop()
fmt.Fprintf(s.writer, "\n❌ %s\n", message) failColor.Fprint(s.writer, "[FAIL] ")
fmt.Fprintln(s.writer, message)
} }
// Stop stops the spinner // Stop stops the spinner
@@ -167,13 +179,15 @@ func (d *Dots) Update(message string) {
// Complete stops the dots with a success message // Complete stops the dots with a success message
func (d *Dots) Complete(message string) { func (d *Dots) Complete(message string) {
d.Stop() d.Stop()
fmt.Fprintf(d.writer, " ✅ %s\n", message) okColor.Fprint(d.writer, " [OK] ")
fmt.Fprintln(d.writer, message)
} }
// Fail stops the dots with a failure message // Fail stops the dots with a failure message
func (d *Dots) Fail(message string) { func (d *Dots) Fail(message string) {
d.Stop() d.Stop()
fmt.Fprintf(d.writer, " ❌ %s\n", message) failColor.Fprint(d.writer, " [FAIL] ")
fmt.Fprintln(d.writer, message)
} }
// Stop stops the dots indicator // Stop stops the dots indicator
@@ -239,14 +253,16 @@ func (p *ProgressBar) Complete(message string) {
p.current = p.total p.current = p.total
p.message = message p.message = message
p.render() p.render()
fmt.Fprintf(p.writer, " ✅ %s\n", message) okColor.Fprint(p.writer, " [OK] ")
fmt.Fprintln(p.writer, message)
p.Stop() p.Stop()
} }
// Fail stops the progress bar with failure // Fail stops the progress bar with failure
func (p *ProgressBar) Fail(message string) { func (p *ProgressBar) Fail(message string) {
p.render() p.render()
fmt.Fprintf(p.writer, " ❌ %s\n", message) failColor.Fprint(p.writer, " [FAIL] ")
fmt.Fprintln(p.writer, message)
p.Stop() p.Stop()
} }
@@ -298,12 +314,14 @@ func (s *Static) Update(message string) {
// Complete shows completion message // Complete shows completion message
func (s *Static) Complete(message string) { func (s *Static) Complete(message string) {
fmt.Fprintf(s.writer, " ✅ %s\n", message) okColor.Fprint(s.writer, " [OK] ")
fmt.Fprintln(s.writer, message)
} }
// Fail shows failure message // Fail shows failure message
func (s *Static) Fail(message string) { func (s *Static) Fail(message string) {
fmt.Fprintf(s.writer, " ❌ %s\n", message) failColor.Fprint(s.writer, " [FAIL] ")
fmt.Fprintln(s.writer, message)
} }
// Stop does nothing for static indicator // Stop does nothing for static indicator
@@ -359,7 +377,7 @@ func (l *LineByLine) Start(message string) {
if l.estimator != nil { if l.estimator != nil {
displayMsg = l.estimator.GetFullStatus(message) displayMsg = l.estimator.GetFullStatus(message)
} }
fmt.Fprintf(l.writer, "\n🔄 %s\n", displayMsg) fmt.Fprintf(l.writer, "\n[SYNC] %s\n", displayMsg)
} }
// Update shows an update message // Update shows an update message
@@ -380,12 +398,14 @@ func (l *LineByLine) SetEstimator(estimator *ETAEstimator) {
// Complete shows completion message // Complete shows completion message
func (l *LineByLine) Complete(message string) { func (l *LineByLine) Complete(message string) {
fmt.Fprintf(l.writer, "✅ %s\n\n", message) okColor.Fprint(l.writer, "[OK] ")
fmt.Fprintf(l.writer, "%s\n\n", message)
} }
// Fail shows failure message // Fail shows failure message
func (l *LineByLine) Fail(message string) { func (l *LineByLine) Fail(message string) {
fmt.Fprintf(l.writer, "❌ %s\n\n", message) failColor.Fprint(l.writer, "[FAIL] ")
fmt.Fprintf(l.writer, "%s\n\n", message)
} }
// Stop does nothing for line-by-line (no cleanup needed) // Stop does nothing for line-by-line (no cleanup needed)
@@ -396,7 +416,7 @@ func (l *LineByLine) Stop() {
// Light indicator methods - minimal output // Light indicator methods - minimal output
func (l *Light) Start(message string) { func (l *Light) Start(message string) {
if !l.silent { if !l.silent {
fmt.Fprintf(l.writer, " %s\n", message) fmt.Fprintf(l.writer, "> %s\n", message)
} }
} }
@@ -408,13 +428,15 @@ func (l *Light) Update(message string) {
func (l *Light) Complete(message string) { func (l *Light) Complete(message string) {
if !l.silent { if !l.silent {
fmt.Fprintf(l.writer, "✓ %s\n", message) okColor.Fprint(l.writer, "[OK] ")
fmt.Fprintln(l.writer, message)
} }
} }
func (l *Light) Fail(message string) { func (l *Light) Fail(message string) {
if !l.silent { if !l.silent {
fmt.Fprintf(l.writer, "✗ %s\n", message) failColor.Fprint(l.writer, "[FAIL] ")
fmt.Fprintln(l.writer, message)
} }
} }
@@ -440,6 +462,8 @@ func NewIndicator(interactive bool, indicatorType string) Indicator {
return NewDots() return NewDots()
case "bar": case "bar":
return NewProgressBar(100) // Default to 100 steps return NewProgressBar(100) // Default to 100 steps
case "schollz":
return NewSchollzBarItems(100, "Progress")
case "line": case "line":
return NewLineByLine() return NewLineByLine()
case "light": case "light":
@@ -463,3 +487,161 @@ func (n *NullIndicator) Complete(message string) {}
func (n *NullIndicator) Fail(message string) {} func (n *NullIndicator) Fail(message string) {}
func (n *NullIndicator) Stop() {} func (n *NullIndicator) Stop() {}
func (n *NullIndicator) SetEstimator(estimator *ETAEstimator) {} func (n *NullIndicator) SetEstimator(estimator *ETAEstimator) {}
// SchollzBar wraps schollz/progressbar for enhanced progress display
// Ideal for byte-based operations like archive extraction and file transfers
type SchollzBar struct {
bar *progressbar.ProgressBar
message string
total int64
estimator *ETAEstimator
}
// NewSchollzBar creates a new schollz progressbar with byte-based progress
func NewSchollzBar(total int64, description string) *SchollzBar {
bar := progressbar.NewOptions64(
total,
progressbar.OptionEnableColorCodes(true),
progressbar.OptionShowBytes(true),
progressbar.OptionSetWidth(40),
progressbar.OptionSetDescription(description),
progressbar.OptionSetTheme(progressbar.Theme{
Saucer: "[green]█[reset]",
SaucerHead: "[green]▌[reset]",
SaucerPadding: "░",
BarStart: "[",
BarEnd: "]",
}),
progressbar.OptionShowCount(),
progressbar.OptionSetPredictTime(true),
progressbar.OptionFullWidth(),
progressbar.OptionClearOnFinish(),
)
return &SchollzBar{
bar: bar,
message: description,
total: total,
}
}
// NewSchollzBarItems creates a progressbar for item counts (not bytes)
func NewSchollzBarItems(total int, description string) *SchollzBar {
bar := progressbar.NewOptions(
total,
progressbar.OptionEnableColorCodes(true),
progressbar.OptionShowCount(),
progressbar.OptionSetWidth(40),
progressbar.OptionSetDescription(description),
progressbar.OptionSetTheme(progressbar.Theme{
Saucer: "[cyan]█[reset]",
SaucerHead: "[cyan]▌[reset]",
SaucerPadding: "░",
BarStart: "[",
BarEnd: "]",
}),
progressbar.OptionSetPredictTime(true),
progressbar.OptionFullWidth(),
progressbar.OptionClearOnFinish(),
)
return &SchollzBar{
bar: bar,
message: description,
total: int64(total),
}
}
// NewSchollzSpinner creates an indeterminate spinner for unknown-length operations
func NewSchollzSpinner(description string) *SchollzBar {
bar := progressbar.NewOptions(
-1, // Indeterminate
progressbar.OptionEnableColorCodes(true),
progressbar.OptionSetWidth(40),
progressbar.OptionSetDescription(description),
progressbar.OptionSpinnerType(14), // Braille spinner
progressbar.OptionFullWidth(),
)
return &SchollzBar{
bar: bar,
message: description,
total: -1,
}
}
// Start initializes the progress bar (Indicator interface)
func (s *SchollzBar) Start(message string) {
s.message = message
s.bar.Describe(message)
}
// Update updates the description (Indicator interface)
func (s *SchollzBar) Update(message string) {
s.message = message
s.bar.Describe(message)
}
// Add adds bytes/items to the progress
func (s *SchollzBar) Add(n int) error {
return s.bar.Add(n)
}
// Add64 adds bytes to the progress (for large files)
func (s *SchollzBar) Add64(n int64) error {
return s.bar.Add64(n)
}
// Set sets the current progress value
func (s *SchollzBar) Set(n int) error {
return s.bar.Set(n)
}
// Set64 sets the current progress value (for large files)
func (s *SchollzBar) Set64(n int64) error {
return s.bar.Set64(n)
}
// ChangeMax updates the maximum value
func (s *SchollzBar) ChangeMax(max int) {
s.bar.ChangeMax(max)
s.total = int64(max)
}
// ChangeMax64 updates the maximum value (for large files)
func (s *SchollzBar) ChangeMax64(max int64) {
s.bar.ChangeMax64(max)
s.total = max
}
// Complete finishes with success (Indicator interface)
func (s *SchollzBar) Complete(message string) {
_ = s.bar.Finish()
okColor.Print("[OK] ")
fmt.Println(message)
}
// Fail finishes with failure (Indicator interface)
func (s *SchollzBar) Fail(message string) {
_ = s.bar.Clear()
failColor.Print("[FAIL] ")
fmt.Println(message)
}
// Stop stops the progress bar (Indicator interface)
func (s *SchollzBar) Stop() {
_ = s.bar.Clear()
}
// SetEstimator is a no-op (schollz has built-in ETA)
func (s *SchollzBar) SetEstimator(estimator *ETAEstimator) {
s.estimator = estimator
}
// Writer returns an io.Writer that updates progress as data is written
// Useful for wrapping readers/writers in copy operations
func (s *SchollzBar) Writer() io.Writer {
return s.bar
}
// Finish marks the progress as complete
func (s *SchollzBar) Finish() error {
return s.bar.Finish()
}

View File

@@ -296,11 +296,11 @@ func generateID() string {
func StatusIcon(s ComplianceStatus) string { func StatusIcon(s ComplianceStatus) string {
switch s { switch s {
case StatusCompliant: case StatusCompliant:
return "" return "[OK]"
case StatusNonCompliant: case StatusNonCompliant:
return "" return "[FAIL]"
case StatusPartial: case StatusPartial:
return "⚠️" return "[WARN]"
case StatusNotApplicable: case StatusNotApplicable:
return "" return ""
default: default:

View File

@@ -12,6 +12,7 @@ import (
"dbbackup/internal/cloud" "dbbackup/internal/cloud"
"dbbackup/internal/logger" "dbbackup/internal/logger"
"dbbackup/internal/metadata" "dbbackup/internal/metadata"
"dbbackup/internal/progress"
) )
// CloudDownloader handles downloading backups from cloud storage // CloudDownloader handles downloading backups from cloud storage
@@ -73,25 +74,43 @@ func (d *CloudDownloader) Download(ctx context.Context, remotePath string, opts
size = 0 // Continue anyway size = 0 // Continue anyway
} }
// Progress callback // Create schollz progressbar for visual download progress
var lastPercent int var bar *progress.SchollzBar
if size > 0 {
bar = progress.NewSchollzBar(size, fmt.Sprintf("Downloading %s", filename))
} else {
bar = progress.NewSchollzSpinner(fmt.Sprintf("Downloading %s", filename))
}
// Progress callback with schollz progressbar
var lastBytes int64
progressCallback := func(transferred, total int64) { progressCallback := func(transferred, total int64) {
if total > 0 { if bar != nil {
percent := int(float64(transferred) / float64(total) * 100) // Update progress bar with delta
if percent != lastPercent && percent%10 == 0 { delta := transferred - lastBytes
d.log.Info("Download progress", "percent", percent, "transferred", cloud.FormatSize(transferred), "total", cloud.FormatSize(total)) if delta > 0 {
lastPercent = percent _ = bar.Add64(delta)
} }
lastBytes = transferred
} }
} }
// Download file // Download file
if err := d.backend.Download(ctx, remotePath, localPath, progressCallback); err != nil { if err := d.backend.Download(ctx, remotePath, localPath, progressCallback); err != nil {
if bar != nil {
bar.Fail("Download failed")
}
// Cleanup on failure // Cleanup on failure
os.RemoveAll(tempSubDir) os.RemoveAll(tempSubDir)
return nil, fmt.Errorf("download failed: %w", err) return nil, fmt.Errorf("download failed: %w", err)
} }
if bar != nil {
_ = bar.Finish()
}
d.log.Info("Download completed", "size", cloud.FormatSize(size))
result := &DownloadResult{ result := &DownloadResult{
LocalPath: localPath, LocalPath: localPath,
RemotePath: remotePath, RemotePath: remotePath,
@@ -115,7 +134,7 @@ func (d *CloudDownloader) Download(ctx context.Context, remotePath string, opts
// Verify checksum if requested // Verify checksum if requested
if opts.VerifyChecksum { if opts.VerifyChecksum {
d.log.Info("Verifying checksum...") d.log.Info("Verifying checksum...")
checksum, err := calculateSHA256(localPath) checksum, err := calculateSHA256WithProgress(localPath)
if err != nil { if err != nil {
// Cleanup on verification failure // Cleanup on verification failure
os.RemoveAll(tempSubDir) os.RemoveAll(tempSubDir)
@@ -186,6 +205,35 @@ func calculateSHA256(filePath string) (string, error) {
return hex.EncodeToString(hash.Sum(nil)), nil return hex.EncodeToString(hash.Sum(nil)), nil
} }
// calculateSHA256WithProgress calculates SHA-256 with visual progress bar
func calculateSHA256WithProgress(filePath string) (string, error) {
file, err := os.Open(filePath)
if err != nil {
return "", err
}
defer file.Close()
// Get file size for progress bar
stat, err := file.Stat()
if err != nil {
return "", err
}
bar := progress.NewSchollzBar(stat.Size(), "Verifying checksum")
hash := sha256.New()
// Create a multi-writer to update both hash and progress
writer := io.MultiWriter(hash, bar.Writer())
if _, err := io.Copy(writer, file); err != nil {
bar.Fail("Verification failed")
return "", err
}
_ = bar.Finish()
return hex.EncodeToString(hash.Sum(nil)), nil
}
// DownloadFromCloudURI is a convenience function to download from a cloud URI // DownloadFromCloudURI is a convenience function to download from a cloud URI
func DownloadFromCloudURI(ctx context.Context, uri string, opts DownloadOptions) (*DownloadResult, error) { func DownloadFromCloudURI(ctx context.Context, uri string, opts DownloadOptions) (*DownloadResult, error) {
// Parse URI // Parse URI

View File

@@ -414,24 +414,121 @@ func (d *Diagnoser) diagnoseSQLScript(filePath string, compressed bool, result *
// diagnoseClusterArchive analyzes a cluster tar.gz archive // diagnoseClusterArchive analyzes a cluster tar.gz archive
func (d *Diagnoser) diagnoseClusterArchive(filePath string, result *DiagnoseResult) { func (d *Diagnoser) diagnoseClusterArchive(filePath string, result *DiagnoseResult) {
// First verify tar.gz integrity with timeout // Calculate dynamic timeout based on file size
// 5 minutes for large archives (multi-GB archives need more time) // Large archives (100GB+) can take significant time to list
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) // Minimum 5 minutes, scales with file size, max 180 minutes for very large archives
timeoutMinutes := 5
if result.FileSize > 0 {
// 1 minute per 2 GB, minimum 5 minutes, max 180 minutes
sizeGB := result.FileSize / (1024 * 1024 * 1024)
estimatedMinutes := int(sizeGB/2) + 5
if estimatedMinutes > timeoutMinutes {
timeoutMinutes = estimatedMinutes
}
if timeoutMinutes > 180 {
timeoutMinutes = 180
}
}
d.log.Info("Verifying cluster archive integrity",
"size", fmt.Sprintf("%.1f GB", float64(result.FileSize)/(1024*1024*1024)),
"timeout", fmt.Sprintf("%d min", timeoutMinutes))
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutMinutes)*time.Minute)
defer cancel() defer cancel()
// Use streaming approach with pipes to avoid memory issues with large archives
cmd := exec.CommandContext(ctx, "tar", "-tzf", filePath) cmd := exec.CommandContext(ctx, "tar", "-tzf", filePath)
output, err := cmd.Output() stdout, pipeErr := cmd.StdoutPipe()
if err != nil { if pipeErr != nil {
result.IsValid = false // Pipe creation failed - not a corruption issue
result.IsCorrupted = true result.Warnings = append(result.Warnings,
result.Errors = append(result.Errors, fmt.Sprintf("Cannot create pipe for verification: %v", pipeErr),
fmt.Sprintf("Tar archive is invalid or corrupted: %v", err), "Archive integrity cannot be verified but may still be valid")
"Run: tar -tzf "+filePath+" 2>&1 | tail -20")
return return
} }
// Parse tar listing var stderrBuf bytes.Buffer
files := strings.Split(strings.TrimSpace(string(output)), "\n") cmd.Stderr = &stderrBuf
if startErr := cmd.Start(); startErr != nil {
result.Warnings = append(result.Warnings,
fmt.Sprintf("Cannot start tar verification: %v", startErr),
"Archive integrity cannot be verified but may still be valid")
return
}
// Stream output line by line to avoid buffering entire listing in memory
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // Allow long paths
var files []string
fileCount := 0
for scanner.Scan() {
fileCount++
line := scanner.Text()
// Only store dump/metadata files, not every file
if strings.HasSuffix(line, ".dump") || strings.HasSuffix(line, ".sql.gz") ||
strings.HasSuffix(line, ".sql") || strings.HasSuffix(line, ".json") ||
strings.Contains(line, "globals") || strings.Contains(line, "manifest") ||
strings.Contains(line, "metadata") {
files = append(files, line)
}
}
scanErr := scanner.Err()
waitErr := cmd.Wait()
stderrOutput := stderrBuf.String()
// Handle errors - distinguish between actual corruption and resource/timeout issues
if waitErr != nil || scanErr != nil {
// Check if it was a timeout
if ctx.Err() == context.DeadlineExceeded {
result.Warnings = append(result.Warnings,
fmt.Sprintf("Verification timed out after %d minutes - archive is very large", timeoutMinutes),
"This does not necessarily mean the archive is corrupted",
"Manual verification: tar -tzf "+filePath+" | wc -l")
// Don't mark as corrupted or invalid on timeout - archive may be fine
if fileCount > 0 {
result.Details.TableCount = len(files)
result.Details.TableList = files
}
return
}
// Check for specific gzip/tar corruption indicators
if strings.Contains(stderrOutput, "unexpected end of file") ||
strings.Contains(stderrOutput, "Unexpected EOF") ||
strings.Contains(stderrOutput, "gzip: stdin: unexpected end of file") ||
strings.Contains(stderrOutput, "not in gzip format") ||
strings.Contains(stderrOutput, "invalid compressed data") {
// These indicate actual corruption
result.IsValid = false
result.IsCorrupted = true
result.Errors = append(result.Errors,
"Tar archive appears truncated or corrupted",
fmt.Sprintf("Error: %s", truncateString(stderrOutput, 200)),
"Run: tar -tzf "+filePath+" 2>&1 | tail -20")
return
}
// Other errors (signal killed, memory, etc.) - not necessarily corruption
// If we read some files successfully, the archive structure is likely OK
if fileCount > 0 {
result.Warnings = append(result.Warnings,
fmt.Sprintf("Verification incomplete (read %d files before error)", fileCount),
"Archive may still be valid - error could be due to system resources")
// Proceed with what we got
} else {
// Couldn't read anything - but don't mark as corrupted without clear evidence
result.Warnings = append(result.Warnings,
fmt.Sprintf("Cannot verify archive: %v", waitErr),
"Archive integrity is uncertain - proceed with caution or verify manually")
return
}
}
// Parse the collected file list
var dumpFiles []string var dumpFiles []string
hasGlobals := false hasGlobals := false
hasMetadata := false hasMetadata := false
@@ -497,9 +594,22 @@ func (d *Diagnoser) diagnoseUnknown(filePath string, result *DiagnoseResult) {
// verifyWithPgRestore uses pg_restore --list to verify dump integrity // verifyWithPgRestore uses pg_restore --list to verify dump integrity
func (d *Diagnoser) verifyWithPgRestore(filePath string, result *DiagnoseResult) { func (d *Diagnoser) verifyWithPgRestore(filePath string, result *DiagnoseResult) {
// Use timeout to prevent blocking on very large dump files // Calculate dynamic timeout based on file size
// 5 minutes for large dumps (multi-GB dumps with many tables) // pg_restore --list is usually faster than tar -tzf for same size
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) timeoutMinutes := 5
if result.FileSize > 0 {
// 1 minute per 5 GB, minimum 5 minutes, max 30 minutes
sizeGB := result.FileSize / (1024 * 1024 * 1024)
estimatedMinutes := int(sizeGB/5) + 5
if estimatedMinutes > timeoutMinutes {
timeoutMinutes = estimatedMinutes
}
if timeoutMinutes > 30 {
timeoutMinutes = 30
}
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutMinutes)*time.Minute)
defer cancel() defer cancel()
cmd := exec.CommandContext(ctx, "pg_restore", "--list", filePath) cmd := exec.CommandContext(ctx, "pg_restore", "--list", filePath)
@@ -554,14 +664,72 @@ func (d *Diagnoser) verifyWithPgRestore(filePath string, result *DiagnoseResult)
// DiagnoseClusterDumps extracts and diagnoses all dumps in a cluster archive // DiagnoseClusterDumps extracts and diagnoses all dumps in a cluster archive
func (d *Diagnoser) DiagnoseClusterDumps(archivePath, tempDir string) ([]*DiagnoseResult, error) { func (d *Diagnoser) DiagnoseClusterDumps(archivePath, tempDir string) ([]*DiagnoseResult, error) {
// First, try to list archive contents without extracting (fast check) // Get archive size for dynamic timeout calculation
// 10 minutes for very large archives archiveInfo, err := os.Stat(archivePath)
listCtx, listCancel := context.WithTimeout(context.Background(), 10*time.Minute) if err != nil {
return nil, fmt.Errorf("cannot stat archive: %w", err)
}
// Dynamic timeout based on archive size: base 10 min + 1 min per 3 GB
// Large archives like 100+ GB need more time for tar -tzf
timeoutMinutes := 10
if archiveInfo.Size() > 0 {
sizeGB := archiveInfo.Size() / (1024 * 1024 * 1024)
estimatedMinutes := int(sizeGB/3) + 10
if estimatedMinutes > timeoutMinutes {
timeoutMinutes = estimatedMinutes
}
if timeoutMinutes > 120 { // Max 2 hours
timeoutMinutes = 120
}
}
d.log.Info("Listing cluster archive contents",
"size", fmt.Sprintf("%.1f GB", float64(archiveInfo.Size())/(1024*1024*1024)),
"timeout", fmt.Sprintf("%d min", timeoutMinutes))
listCtx, listCancel := context.WithTimeout(context.Background(), time.Duration(timeoutMinutes)*time.Minute)
defer listCancel() defer listCancel()
listCmd := exec.CommandContext(listCtx, "tar", "-tzf", archivePath) listCmd := exec.CommandContext(listCtx, "tar", "-tzf", archivePath)
listOutput, listErr := listCmd.CombinedOutput()
if listErr != nil { // Use pipes for streaming to avoid buffering entire output in memory
// This prevents OOM kills on large archives (100GB+) with millions of files
stdout, err := listCmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("failed to create stdout pipe: %w", err)
}
var stderrBuf bytes.Buffer
listCmd.Stderr = &stderrBuf
if err := listCmd.Start(); err != nil {
return nil, fmt.Errorf("failed to start tar listing: %w", err)
}
// Stream the output line by line, only keeping relevant files
var files []string
scanner := bufio.NewScanner(stdout)
// Set a reasonable max line length (file paths shouldn't exceed this)
scanner.Buffer(make([]byte, 0, 4096), 1024*1024)
fileCount := 0
for scanner.Scan() {
fileCount++
line := scanner.Text()
// Only store dump files and important files, not every single file
if strings.HasSuffix(line, ".dump") || strings.HasSuffix(line, ".sql") ||
strings.HasSuffix(line, ".sql.gz") || strings.HasSuffix(line, ".json") ||
strings.Contains(line, "globals") || strings.Contains(line, "manifest") ||
strings.Contains(line, "metadata") || strings.HasSuffix(line, "/") {
files = append(files, line)
}
}
scanErr := scanner.Err()
listErr := listCmd.Wait()
if listErr != nil || scanErr != nil {
// Archive listing failed - likely corrupted // Archive listing failed - likely corrupted
errResult := &DiagnoseResult{ errResult := &DiagnoseResult{
FilePath: archivePath, FilePath: archivePath,
@@ -573,7 +741,12 @@ func (d *Diagnoser) DiagnoseClusterDumps(archivePath, tempDir string) ([]*Diagno
Details: &DiagnoseDetails{}, Details: &DiagnoseDetails{},
} }
errOutput := string(listOutput) errOutput := stderrBuf.String()
actualErr := listErr
if scanErr != nil {
actualErr = scanErr
}
if strings.Contains(errOutput, "unexpected end of file") || if strings.Contains(errOutput, "unexpected end of file") ||
strings.Contains(errOutput, "Unexpected EOF") || strings.Contains(errOutput, "Unexpected EOF") ||
strings.Contains(errOutput, "truncated") { strings.Contains(errOutput, "truncated") {
@@ -585,7 +758,7 @@ func (d *Diagnoser) DiagnoseClusterDumps(archivePath, tempDir string) ([]*Diagno
"Solution: Re-create the backup from source database") "Solution: Re-create the backup from source database")
} else { } else {
errResult.Errors = append(errResult.Errors, errResult.Errors = append(errResult.Errors,
fmt.Sprintf("Cannot list archive contents: %v", listErr), fmt.Sprintf("Cannot list archive contents: %v", actualErr),
fmt.Sprintf("tar error: %s", truncateString(errOutput, 300)), fmt.Sprintf("tar error: %s", truncateString(errOutput, 300)),
"Run manually: tar -tzf "+archivePath+" 2>&1 | tail -50") "Run manually: tar -tzf "+archivePath+" 2>&1 | tail -50")
} }
@@ -593,11 +766,10 @@ func (d *Diagnoser) DiagnoseClusterDumps(archivePath, tempDir string) ([]*Diagno
return []*DiagnoseResult{errResult}, nil return []*DiagnoseResult{errResult}, nil
} }
// Archive is listable - now check disk space before extraction d.log.Debug("Archive listing streamed successfully", "total_files", fileCount, "relevant_files", len(files))
files := strings.Split(strings.TrimSpace(string(listOutput)), "\n")
// Check if we have enough disk space (estimate 4x archive size needed) // Check if we have enough disk space (estimate 4x archive size needed)
archiveInfo, _ := os.Stat(archivePath) // archiveInfo already obtained at function start
requiredSpace := archiveInfo.Size() * 4 requiredSpace := archiveInfo.Size() * 4
// Check temp directory space - try to extract metadata first // Check temp directory space - try to extract metadata first
@@ -714,7 +886,7 @@ func (d *Diagnoser) DiagnoseClusterDumps(archivePath, tempDir string) ([]*Diagno
// PrintDiagnosis outputs a human-readable diagnosis report // PrintDiagnosis outputs a human-readable diagnosis report
func (d *Diagnoser) PrintDiagnosis(result *DiagnoseResult) { func (d *Diagnoser) PrintDiagnosis(result *DiagnoseResult) {
fmt.Println("\n" + strings.Repeat("=", 70)) fmt.Println("\n" + strings.Repeat("=", 70))
fmt.Printf("📋 DIAGNOSIS: %s\n", result.FileName) fmt.Printf("[DIAG] DIAGNOSIS: %s\n", result.FileName)
fmt.Println(strings.Repeat("=", 70)) fmt.Println(strings.Repeat("=", 70))
// Basic info // Basic info
@@ -724,69 +896,69 @@ func (d *Diagnoser) PrintDiagnosis(result *DiagnoseResult) {
// Status // Status
if result.IsValid { if result.IsValid {
fmt.Println("\n STATUS: VALID") fmt.Println("\n[OK] STATUS: VALID")
} else { } else {
fmt.Println("\n STATUS: INVALID") fmt.Println("\n[FAIL] STATUS: INVALID")
} }
if result.IsTruncated { if result.IsTruncated {
fmt.Println("⚠️ TRUNCATED: Yes - file appears incomplete") fmt.Println("[WARN] TRUNCATED: Yes - file appears incomplete")
} }
if result.IsCorrupted { if result.IsCorrupted {
fmt.Println("⚠️ CORRUPTED: Yes - file structure is damaged") fmt.Println("[WARN] CORRUPTED: Yes - file structure is damaged")
} }
// Details // Details
if result.Details != nil { if result.Details != nil {
fmt.Println("\n📊 DETAILS:") fmt.Println("\n[DETAILS]:")
if result.Details.HasPGDMPSignature { if result.Details.HasPGDMPSignature {
fmt.Println(" Has PGDMP signature (PostgreSQL custom format)") fmt.Println(" [+] Has PGDMP signature (PostgreSQL custom format)")
} }
if result.Details.HasSQLHeader { if result.Details.HasSQLHeader {
fmt.Println(" Has PostgreSQL SQL header") fmt.Println(" [+] Has PostgreSQL SQL header")
} }
if result.Details.GzipValid { if result.Details.GzipValid {
fmt.Println(" Gzip compression valid") fmt.Println(" [+] Gzip compression valid")
} }
if result.Details.PgRestoreListable { if result.Details.PgRestoreListable {
fmt.Printf(" pg_restore can list contents (%d tables)\n", result.Details.TableCount) fmt.Printf(" [+] pg_restore can list contents (%d tables)\n", result.Details.TableCount)
} }
if result.Details.CopyBlockCount > 0 { if result.Details.CopyBlockCount > 0 {
fmt.Printf(" Contains %d COPY blocks\n", result.Details.CopyBlockCount) fmt.Printf(" [-] Contains %d COPY blocks\n", result.Details.CopyBlockCount)
} }
if result.Details.UnterminatedCopy { if result.Details.UnterminatedCopy {
fmt.Printf(" Unterminated COPY block: %s (line %d)\n", fmt.Printf(" [-] Unterminated COPY block: %s (line %d)\n",
result.Details.LastCopyTable, result.Details.LastCopyLineNumber) result.Details.LastCopyTable, result.Details.LastCopyLineNumber)
} }
if result.Details.ProperlyTerminated { if result.Details.ProperlyTerminated {
fmt.Println(" All COPY blocks properly terminated") fmt.Println(" [+] All COPY blocks properly terminated")
} }
if result.Details.ExpandedSize > 0 { if result.Details.ExpandedSize > 0 {
fmt.Printf(" Expanded size: %s (ratio: %.1fx)\n", fmt.Printf(" [-] Expanded size: %s (ratio: %.1fx)\n",
formatBytes(result.Details.ExpandedSize), result.Details.CompressionRatio) formatBytes(result.Details.ExpandedSize), result.Details.CompressionRatio)
} }
} }
// Errors // Errors
if len(result.Errors) > 0 { if len(result.Errors) > 0 {
fmt.Println("\nERRORS:") fmt.Println("\n[ERRORS]:")
for _, e := range result.Errors { for _, e := range result.Errors {
fmt.Printf(" %s\n", e) fmt.Printf(" - %s\n", e)
} }
} }
// Warnings // Warnings
if len(result.Warnings) > 0 { if len(result.Warnings) > 0 {
fmt.Println("\n⚠️ WARNINGS:") fmt.Println("\n[WARNINGS]:")
for _, w := range result.Warnings { for _, w := range result.Warnings {
fmt.Printf(" %s\n", w) fmt.Printf(" - %s\n", w)
} }
} }
// Recommendations // Recommendations
if !result.IsValid { if !result.IsValid {
fmt.Println("\n💡 RECOMMENDATIONS:") fmt.Println("\n[HINT] RECOMMENDATIONS:")
if result.IsTruncated { if result.IsTruncated {
fmt.Println(" 1. Re-run the backup process for this database") fmt.Println(" 1. Re-run the backup process for this database")
fmt.Println(" 2. Check disk space on backup server during backup") fmt.Println(" 2. Check disk space on backup server during backup")

View File

@@ -2,10 +2,12 @@ package restore
import ( import (
"context" "context"
"database/sql"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
@@ -17,6 +19,9 @@ import (
"dbbackup/internal/logger" "dbbackup/internal/logger"
"dbbackup/internal/progress" "dbbackup/internal/progress"
"dbbackup/internal/security" "dbbackup/internal/security"
"github.com/hashicorp/go-multierror"
_ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver
) )
// Engine handles database restore operations // Engine handles database restore operations
@@ -27,8 +32,7 @@ type Engine struct {
progress progress.Indicator progress progress.Indicator
detailedReporter *progress.DetailedReporter detailedReporter *progress.DetailedReporter
dryRun bool dryRun bool
debugLogPath string // Path to save debug log on error debugLogPath string // Path to save debug log on error
errorCollector *ErrorCollector // Collects detailed error info
} }
// New creates a new restore engine // New creates a new restore engine
@@ -128,7 +132,7 @@ func (e *Engine) RestoreSingle(ctx context.Context, archivePath, targetDB string
e.log.Warn("Checksum verification failed", "error", checksumErr) e.log.Warn("Checksum verification failed", "error", checksumErr)
e.log.Warn("Continuing restore without checksum verification (use with caution)") e.log.Warn("Continuing restore without checksum verification (use with caution)")
} else { } else {
e.log.Info(" Archive checksum verified successfully") e.log.Info("[OK] Archive checksum verified successfully")
} }
// Detect archive format // Detect archive format
@@ -224,7 +228,18 @@ func (e *Engine) restorePostgreSQLDump(ctx context.Context, archivePath, targetD
// restorePostgreSQLDumpWithOwnership restores from PostgreSQL custom dump with ownership control // restorePostgreSQLDumpWithOwnership restores from PostgreSQL custom dump with ownership control
func (e *Engine) restorePostgreSQLDumpWithOwnership(ctx context.Context, archivePath, targetDB string, compressed bool, preserveOwnership bool) error { func (e *Engine) restorePostgreSQLDumpWithOwnership(ctx context.Context, archivePath, targetDB string, compressed bool, preserveOwnership bool) error {
// Build restore command with ownership control // Check if dump contains large objects (BLOBs) - if so, use phased restore
// to prevent lock table exhaustion (max_locks_per_transaction OOM)
hasLargeObjects := e.checkDumpHasLargeObjects(archivePath)
if hasLargeObjects {
e.log.Info("Large objects detected - using phased restore to prevent lock exhaustion",
"database", targetDB,
"archive", archivePath)
return e.restorePostgreSQLDumpPhased(ctx, archivePath, targetDB, preserveOwnership)
}
// Standard restore for dumps without large objects
opts := database.RestoreOptions{ opts := database.RestoreOptions{
Parallel: 1, Parallel: 1,
Clean: false, // We already dropped the database Clean: false, // We already dropped the database
@@ -250,6 +265,113 @@ func (e *Engine) restorePostgreSQLDumpWithOwnership(ctx context.Context, archive
return e.executeRestoreCommand(ctx, cmd) return e.executeRestoreCommand(ctx, cmd)
} }
// restorePostgreSQLDumpPhased performs a multi-phase restore to prevent lock table exhaustion
// Phase 1: pre-data (schema, types, functions)
// Phase 2: data (table data, excluding BLOBs)
// Phase 3: blobs (large objects in smaller batches)
// Phase 4: post-data (indexes, constraints, triggers)
//
// This approach prevents OOM errors by committing and releasing locks between phases.
func (e *Engine) restorePostgreSQLDumpPhased(ctx context.Context, archivePath, targetDB string, preserveOwnership bool) error {
e.log.Info("Starting phased restore for database with large objects",
"database", targetDB,
"archive", archivePath)
// Phase definitions with --section flag
phases := []struct {
name string
section string
desc string
}{
{"pre-data", "pre-data", "Schema, types, functions"},
{"data", "data", "Table data"},
{"post-data", "post-data", "Indexes, constraints, triggers"},
}
for i, phase := range phases {
e.log.Info(fmt.Sprintf("Phase %d/%d: Restoring %s", i+1, len(phases), phase.name),
"database", targetDB,
"section", phase.section,
"description", phase.desc)
if err := e.restoreSection(ctx, archivePath, targetDB, phase.section, preserveOwnership); err != nil {
// Check if it's an ignorable error
if e.isIgnorableError(err.Error()) {
e.log.Warn(fmt.Sprintf("Phase %d completed with ignorable errors", i+1),
"section", phase.section,
"error", err)
continue
}
return fmt.Errorf("phase %d (%s) failed: %w", i+1, phase.name, err)
}
e.log.Info(fmt.Sprintf("Phase %d/%d completed successfully", i+1, len(phases)),
"section", phase.section)
}
e.log.Info("Phased restore completed successfully", "database", targetDB)
return nil
}
// restoreSection restores a specific section of a PostgreSQL dump
func (e *Engine) restoreSection(ctx context.Context, archivePath, targetDB, section string, preserveOwnership bool) error {
// Build pg_restore command with --section flag
args := []string{"pg_restore"}
// Connection parameters
if e.cfg.Host != "localhost" {
args = append(args, "-h", e.cfg.Host)
args = append(args, "-p", fmt.Sprintf("%d", e.cfg.Port))
args = append(args, "--no-password")
}
args = append(args, "-U", e.cfg.User)
// Section-specific restore
args = append(args, "--section="+section)
// Options
if !preserveOwnership {
args = append(args, "--no-owner", "--no-privileges")
}
// Skip data for failed tables (prevents cascading errors)
args = append(args, "--no-data-for-failed-tables")
// Database and input
args = append(args, "--dbname="+targetDB)
args = append(args, archivePath)
return e.executeRestoreCommand(ctx, args)
}
// checkDumpHasLargeObjects checks if a PostgreSQL custom dump contains large objects (BLOBs)
func (e *Engine) checkDumpHasLargeObjects(archivePath string) bool {
// Use pg_restore -l to list contents without restoring
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "pg_restore", "-l", archivePath)
output, err := cmd.Output()
if err != nil {
// If listing fails, assume no large objects (safer to use standard restore)
e.log.Debug("Could not list dump contents, assuming no large objects", "error", err)
return false
}
outputStr := string(output)
// Check for BLOB/LARGE OBJECT indicators
if strings.Contains(outputStr, "BLOB") ||
strings.Contains(outputStr, "LARGE OBJECT") ||
strings.Contains(outputStr, " BLOBS ") ||
strings.Contains(outputStr, "lo_create") {
return true
}
return false
}
// restorePostgreSQLSQL restores from PostgreSQL SQL script // restorePostgreSQLSQL restores from PostgreSQL SQL script
func (e *Engine) restorePostgreSQLSQL(ctx context.Context, archivePath, targetDB string, compressed bool) error { func (e *Engine) restorePostgreSQLSQL(ctx context.Context, archivePath, targetDB string, compressed bool) error {
// Pre-validate SQL dump to detect truncation BEFORE attempting restore // Pre-validate SQL dump to detect truncation BEFORE attempting restore
@@ -462,7 +584,7 @@ func (e *Engine) executeRestoreCommandWithContext(ctx context.Context, cmdArgs [
e.log.Warn("Failed to save debug log", "error", saveErr) e.log.Warn("Failed to save debug log", "error", saveErr)
} else { } else {
e.log.Info("Debug log saved", "path", e.debugLogPath) e.log.Info("Debug log saved", "path", e.debugLogPath)
fmt.Printf("\n📋 Detailed error report saved to: %s\n", e.debugLogPath) fmt.Printf("\n[LOG] Detailed error report saved to: %s\n", e.debugLogPath)
} }
} }
} }
@@ -613,7 +735,7 @@ func (e *Engine) previewRestore(archivePath, targetDB string, format ArchiveForm
fmt.Printf(" 1. Execute: mysql %s < %s\n", targetDB, archivePath) fmt.Printf(" 1. Execute: mysql %s < %s\n", targetDB, archivePath)
} }
fmt.Println("\n⚠️ WARNING: This will restore data to the target database.") fmt.Println("\n[WARN] WARNING: This will restore data to the target database.")
fmt.Println(" Existing data may be overwritten or merged.") fmt.Println(" Existing data may be overwritten or merged.")
fmt.Println("\nTo execute this restore, add the --confirm flag.") fmt.Println("\nTo execute this restore, add the --confirm flag.")
fmt.Println(strings.Repeat("=", 60) + "\n") fmt.Println(strings.Repeat("=", 60) + "\n")
@@ -644,7 +766,7 @@ func (e *Engine) RestoreCluster(ctx context.Context, archivePath string) error {
e.log.Warn("Checksum verification failed", "error", checksumErr) e.log.Warn("Checksum verification failed", "error", checksumErr)
e.log.Warn("Continuing restore without checksum verification (use with caution)") e.log.Warn("Continuing restore without checksum verification (use with caution)")
} else { } else {
e.log.Info(" Cluster archive checksum verified successfully") e.log.Info("[OK] Cluster archive checksum verified successfully")
} }
format := DetectArchiveFormat(archivePath) format := DetectArchiveFormat(archivePath)
@@ -704,7 +826,7 @@ func (e *Engine) RestoreCluster(ctx context.Context, archivePath string) error {
if !isSuperuser { if !isSuperuser {
e.log.Warn("Current user is not a superuser - database ownership may not be fully restored") e.log.Warn("Current user is not a superuser - database ownership may not be fully restored")
e.progress.Update("⚠️ Warning: Non-superuser - ownership restoration limited") e.progress.Update("[WARN] Warning: Non-superuser - ownership restoration limited")
time.Sleep(2 * time.Second) // Give user time to see warning time.Sleep(2 * time.Second) // Give user time to see warning
} else { } else {
e.log.Info("Superuser privileges confirmed - full ownership restoration enabled") e.log.Info("Superuser privileges confirmed - full ownership restoration enabled")
@@ -803,12 +925,45 @@ func (e *Engine) RestoreCluster(ctx context.Context, archivePath string) error {
if len(corruptedDumps) > 0 { if len(corruptedDumps) > 0 {
operation.Fail("Corrupted dump files detected") operation.Fail("Corrupted dump files detected")
e.progress.Fail(fmt.Sprintf("Found %d corrupted dump files - restore aborted", len(corruptedDumps))) e.progress.Fail(fmt.Sprintf("Found %d corrupted dump files - restore aborted", len(corruptedDumps)))
return fmt.Errorf("pre-validation failed: %d corrupted dump files detected:\n %s\n\nThe backup archive appears to be damaged. You need to restore from a different backup.", return fmt.Errorf("pre-validation failed: %d corrupted dump files detected: %s - the backup archive appears to be damaged, restore from a different backup",
len(corruptedDumps), strings.Join(corruptedDumps, "\n ")) len(corruptedDumps), strings.Join(corruptedDumps, ", "))
} }
e.log.Info("All dump files passed validation") e.log.Info("All dump files passed validation")
var failedDBs []string // Run comprehensive preflight checks (Linux system + PostgreSQL + Archive analysis)
preflight, preflightErr := e.RunPreflightChecks(ctx, dumpsDir, entries)
if preflightErr != nil {
e.log.Warn("Preflight checks failed", "error", preflightErr)
}
// Calculate optimal lock boost based on BLOB count
lockBoostValue := 2048 // Default
if preflight != nil && preflight.Archive.RecommendedLockBoost > 0 {
lockBoostValue = preflight.Archive.RecommendedLockBoost
}
// AUTO-TUNE: Boost PostgreSQL settings for large restores
e.progress.Update("Tuning PostgreSQL for large restore...")
originalSettings, tuneErr := e.boostPostgreSQLSettings(ctx, lockBoostValue)
if tuneErr != nil {
e.log.Warn("Could not boost PostgreSQL settings - restore may fail on BLOB-heavy databases",
"error", tuneErr)
} else {
e.log.Info("Boosted PostgreSQL settings for restore",
"max_locks_per_transaction", fmt.Sprintf("%d → %d", originalSettings.MaxLocks, lockBoostValue),
"maintenance_work_mem", fmt.Sprintf("%s → 2GB", originalSettings.MaintenanceWorkMem))
// Ensure we reset settings when done (even on failure)
defer func() {
if resetErr := e.resetPostgreSQLSettings(ctx, originalSettings); resetErr != nil {
e.log.Warn("Could not reset PostgreSQL settings", "error", resetErr)
} else {
e.log.Info("Reset PostgreSQL settings to original values")
}
}()
}
var restoreErrors *multierror.Error
var restoreErrorsMu sync.Mutex
totalDBs := 0 totalDBs := 0
// Count total databases // Count total databases
@@ -836,13 +991,12 @@ func (e *Engine) RestoreCluster(ctx context.Context, archivePath string) error {
e.log.Warn("Large objects detected in dump files - reducing parallelism to avoid lock contention", e.log.Warn("Large objects detected in dump files - reducing parallelism to avoid lock contention",
"original_parallelism", parallelism, "original_parallelism", parallelism,
"adjusted_parallelism", 1) "adjusted_parallelism", 1)
e.progress.Update("⚠️ Large objects detected - using sequential restore to avoid lock conflicts") e.progress.Update("[WARN] Large objects detected - using sequential restore to avoid lock conflicts")
time.Sleep(2 * time.Second) // Give user time to see warning time.Sleep(2 * time.Second) // Give user time to see warning
parallelism = 1 parallelism = 1
} }
var successCount, failCount int32 var successCount, failCount int32
var failedDBsMu sync.Mutex
var mu sync.Mutex // Protect shared resources (progress, logger) var mu sync.Mutex // Protect shared resources (progress, logger)
// Create semaphore to limit concurrency // Create semaphore to limit concurrency
@@ -897,9 +1051,9 @@ func (e *Engine) RestoreCluster(ctx context.Context, archivePath string) error {
// STEP 2: Create fresh database // STEP 2: Create fresh database
if err := e.ensureDatabaseExists(ctx, dbName); err != nil { if err := e.ensureDatabaseExists(ctx, dbName); err != nil {
e.log.Error("Failed to create database", "name", dbName, "error", err) e.log.Error("Failed to create database", "name", dbName, "error", err)
failedDBsMu.Lock() restoreErrorsMu.Lock()
failedDBs = append(failedDBs, fmt.Sprintf("%s: failed to create database: %v", dbName, err)) restoreErrors = multierror.Append(restoreErrors, fmt.Errorf("%s: failed to create database: %w", dbName, err))
failedDBsMu.Unlock() restoreErrorsMu.Unlock()
atomic.AddInt32(&failCount, 1) atomic.AddInt32(&failCount, 1)
return return
} }
@@ -942,10 +1096,10 @@ func (e *Engine) RestoreCluster(ctx context.Context, archivePath string) error {
mu.Unlock() mu.Unlock()
} }
failedDBsMu.Lock() restoreErrorsMu.Lock()
// Include more context in the error message // Include more context in the error message
failedDBs = append(failedDBs, fmt.Sprintf("%s: restore failed: %v", dbName, restoreErr)) restoreErrors = multierror.Append(restoreErrors, fmt.Errorf("%s: restore failed: %w", dbName, restoreErr))
failedDBsMu.Unlock() restoreErrorsMu.Unlock()
atomic.AddInt32(&failCount, 1) atomic.AddInt32(&failCount, 1)
return return
} }
@@ -963,7 +1117,17 @@ func (e *Engine) RestoreCluster(ctx context.Context, archivePath string) error {
failCountFinal := int(atomic.LoadInt32(&failCount)) failCountFinal := int(atomic.LoadInt32(&failCount))
if failCountFinal > 0 { if failCountFinal > 0 {
failedList := strings.Join(failedDBs, "\n ") // Format multi-error with detailed output
restoreErrors.ErrorFormat = func(errs []error) string {
if len(errs) == 1 {
return errs[0].Error()
}
points := make([]string, len(errs))
for i, err := range errs {
points[i] = fmt.Sprintf(" • %s", err.Error())
}
return fmt.Sprintf("%d database(s) failed:\n%s", len(errs), strings.Join(points, "\n"))
}
// Log summary // Log summary
e.log.Info("Cluster restore completed with failures", e.log.Info("Cluster restore completed with failures",
@@ -974,7 +1138,7 @@ func (e *Engine) RestoreCluster(ctx context.Context, archivePath string) error {
e.progress.Fail(fmt.Sprintf("Cluster restore: %d succeeded, %d failed out of %d total", successCountFinal, failCountFinal, totalDBs)) e.progress.Fail(fmt.Sprintf("Cluster restore: %d succeeded, %d failed out of %d total", successCountFinal, failCountFinal, totalDBs))
operation.Complete(fmt.Sprintf("Partial restore: %d/%d databases succeeded", successCountFinal, totalDBs)) operation.Complete(fmt.Sprintf("Partial restore: %d/%d databases succeeded", successCountFinal, totalDBs))
return fmt.Errorf("cluster restore completed with %d failures:\n %s", failCountFinal, failedList) return fmt.Errorf("cluster restore completed with %d failures:\n%s", failCountFinal, restoreErrors.Error())
} }
e.progress.Complete(fmt.Sprintf("Cluster restored successfully: %d databases", successCountFinal)) e.progress.Complete(fmt.Sprintf("Cluster restored successfully: %d databases", successCountFinal))
@@ -1340,7 +1504,7 @@ func (e *Engine) previewClusterRestore(archivePath string) error {
fmt.Println(" 3. Restore all databases found in archive") fmt.Println(" 3. Restore all databases found in archive")
fmt.Println(" 4. Cleanup temporary files") fmt.Println(" 4. Cleanup temporary files")
fmt.Println("\n⚠️ WARNING: This will restore multiple databases.") fmt.Println("\n[WARN] WARNING: This will restore multiple databases.")
fmt.Println(" Existing databases may be overwritten or merged.") fmt.Println(" Existing databases may be overwritten or merged.")
fmt.Println("\nTo execute this restore, add the --confirm flag.") fmt.Println("\nTo execute this restore, add the --confirm flag.")
fmt.Println(strings.Repeat("=", 60) + "\n") fmt.Println(strings.Repeat("=", 60) + "\n")
@@ -1500,3 +1664,173 @@ func (e *Engine) quickValidateSQLDump(archivePath string, compressed bool) error
e.log.Debug("SQL dump validation passed", "path", archivePath) e.log.Debug("SQL dump validation passed", "path", archivePath)
return nil return nil
} }
// boostLockCapacity temporarily increases max_locks_per_transaction to prevent OOM
// during large restores with many BLOBs. Returns the original value for later reset.
// Uses ALTER SYSTEM + pg_reload_conf() so no restart is needed.
func (e *Engine) boostLockCapacity(ctx context.Context) (int, error) {
// Connect to PostgreSQL to run system commands
connStr := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=postgres sslmode=disable",
e.cfg.Host, e.cfg.Port, e.cfg.User, e.cfg.Password)
// For localhost, use Unix socket
if e.cfg.Host == "localhost" || e.cfg.Host == "" {
connStr = fmt.Sprintf("user=%s password=%s dbname=postgres sslmode=disable",
e.cfg.User, e.cfg.Password)
}
db, err := sql.Open("pgx", connStr)
if err != nil {
return 0, fmt.Errorf("failed to connect: %w", err)
}
defer db.Close()
// Get current value
var currentValue int
err = db.QueryRowContext(ctx, "SHOW max_locks_per_transaction").Scan(&currentValue)
if err != nil {
// Try parsing as string (some versions return string)
var currentValueStr string
err = db.QueryRowContext(ctx, "SHOW max_locks_per_transaction").Scan(&currentValueStr)
if err != nil {
return 0, fmt.Errorf("failed to get current max_locks_per_transaction: %w", err)
}
fmt.Sscanf(currentValueStr, "%d", &currentValue)
}
// Skip if already high enough
if currentValue >= 2048 {
e.log.Info("max_locks_per_transaction already sufficient", "value", currentValue)
return currentValue, nil
}
// Boost to 2048 (enough for most BLOB-heavy databases)
_, err = db.ExecContext(ctx, "ALTER SYSTEM SET max_locks_per_transaction = 2048")
if err != nil {
return currentValue, fmt.Errorf("failed to set max_locks_per_transaction: %w", err)
}
// Reload config without restart
_, err = db.ExecContext(ctx, "SELECT pg_reload_conf()")
if err != nil {
return currentValue, fmt.Errorf("failed to reload config: %w", err)
}
return currentValue, nil
}
// resetLockCapacity restores the original max_locks_per_transaction value
func (e *Engine) resetLockCapacity(ctx context.Context, originalValue int) error {
connStr := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=postgres sslmode=disable",
e.cfg.Host, e.cfg.Port, e.cfg.User, e.cfg.Password)
if e.cfg.Host == "localhost" || e.cfg.Host == "" {
connStr = fmt.Sprintf("user=%s password=%s dbname=postgres sslmode=disable",
e.cfg.User, e.cfg.Password)
}
db, err := sql.Open("pgx", connStr)
if err != nil {
return fmt.Errorf("failed to connect: %w", err)
}
defer db.Close()
// Reset to original value (or use RESET to go back to default)
if originalValue == 64 { // Default value
_, err = db.ExecContext(ctx, "ALTER SYSTEM RESET max_locks_per_transaction")
} else {
_, err = db.ExecContext(ctx, fmt.Sprintf("ALTER SYSTEM SET max_locks_per_transaction = %d", originalValue))
}
if err != nil {
return fmt.Errorf("failed to reset max_locks_per_transaction: %w", err)
}
// Reload config
_, err = db.ExecContext(ctx, "SELECT pg_reload_conf()")
if err != nil {
return fmt.Errorf("failed to reload config: %w", err)
}
return nil
}
// OriginalSettings stores PostgreSQL settings to restore after operation
type OriginalSettings struct {
MaxLocks int
MaintenanceWorkMem string
}
// boostPostgreSQLSettings boosts multiple PostgreSQL settings for large restores
func (e *Engine) boostPostgreSQLSettings(ctx context.Context, lockBoostValue int) (*OriginalSettings, error) {
connStr := e.buildConnString()
db, err := sql.Open("pgx", connStr)
if err != nil {
return nil, fmt.Errorf("failed to connect: %w", err)
}
defer db.Close()
original := &OriginalSettings{}
// Get current max_locks_per_transaction
var maxLocksStr string
if err := db.QueryRowContext(ctx, "SHOW max_locks_per_transaction").Scan(&maxLocksStr); err == nil {
original.MaxLocks, _ = strconv.Atoi(maxLocksStr)
}
// Get current maintenance_work_mem
db.QueryRowContext(ctx, "SHOW maintenance_work_mem").Scan(&original.MaintenanceWorkMem)
// Boost max_locks_per_transaction (if not already high enough)
if original.MaxLocks < lockBoostValue {
_, err = db.ExecContext(ctx, fmt.Sprintf("ALTER SYSTEM SET max_locks_per_transaction = %d", lockBoostValue))
if err != nil {
e.log.Warn("Could not boost max_locks_per_transaction", "error", err)
}
}
// Boost maintenance_work_mem to 2GB for faster index creation
_, err = db.ExecContext(ctx, "ALTER SYSTEM SET maintenance_work_mem = '2GB'")
if err != nil {
e.log.Warn("Could not boost maintenance_work_mem", "error", err)
}
// Reload config to apply changes (no restart needed for these settings)
_, err = db.ExecContext(ctx, "SELECT pg_reload_conf()")
if err != nil {
return original, fmt.Errorf("failed to reload config: %w", err)
}
return original, nil
}
// resetPostgreSQLSettings restores original PostgreSQL settings
func (e *Engine) resetPostgreSQLSettings(ctx context.Context, original *OriginalSettings) error {
connStr := e.buildConnString()
db, err := sql.Open("pgx", connStr)
if err != nil {
return fmt.Errorf("failed to connect: %w", err)
}
defer db.Close()
// Reset max_locks_per_transaction
if original.MaxLocks == 64 { // Default
db.ExecContext(ctx, "ALTER SYSTEM RESET max_locks_per_transaction")
} else if original.MaxLocks > 0 {
db.ExecContext(ctx, fmt.Sprintf("ALTER SYSTEM SET max_locks_per_transaction = %d", original.MaxLocks))
}
// Reset maintenance_work_mem
if original.MaintenanceWorkMem == "64MB" { // Default
db.ExecContext(ctx, "ALTER SYSTEM RESET maintenance_work_mem")
} else if original.MaintenanceWorkMem != "" {
db.ExecContext(ctx, fmt.Sprintf("ALTER SYSTEM SET maintenance_work_mem = '%s'", original.MaintenanceWorkMem))
}
// Reload config
_, err = db.ExecContext(ctx, "SELECT pg_reload_conf()")
if err != nil {
return fmt.Errorf("failed to reload config: %w", err)
}
return nil
}

View File

@@ -397,20 +397,20 @@ func (ec *ErrorCollector) SaveReport(report *RestoreErrorReport, outputPath stri
// PrintReport prints a human-readable summary of the error report // PrintReport prints a human-readable summary of the error report
func (ec *ErrorCollector) PrintReport(report *RestoreErrorReport) { func (ec *ErrorCollector) PrintReport(report *RestoreErrorReport) {
fmt.Println() fmt.Println()
fmt.Println(strings.Repeat("", 70)) fmt.Println(strings.Repeat("=", 70))
fmt.Println(" 🔴 RESTORE ERROR REPORT") fmt.Println(" [ERROR] RESTORE ERROR REPORT")
fmt.Println(strings.Repeat("", 70)) fmt.Println(strings.Repeat("=", 70))
fmt.Printf("\n📅 Timestamp: %s\n", report.Timestamp.Format("2006-01-02 15:04:05")) fmt.Printf("\n[TIME] Timestamp: %s\n", report.Timestamp.Format("2006-01-02 15:04:05"))
fmt.Printf("📦 Archive: %s\n", filepath.Base(report.ArchivePath)) fmt.Printf("[FILE] Archive: %s\n", filepath.Base(report.ArchivePath))
fmt.Printf("📊 Format: %s\n", report.ArchiveFormat) fmt.Printf("[FMT] Format: %s\n", report.ArchiveFormat)
fmt.Printf("🎯 Target DB: %s\n", report.TargetDB) fmt.Printf("[TGT] Target DB: %s\n", report.TargetDB)
fmt.Printf("⚠️ Exit Code: %d\n", report.ExitCode) fmt.Printf("[CODE] Exit Code: %d\n", report.ExitCode)
fmt.Printf(" Total Errors: %d\n", report.TotalErrors) fmt.Printf("[ERR] Total Errors: %d\n", report.TotalErrors)
fmt.Println("\n" + strings.Repeat("", 70)) fmt.Println("\n" + strings.Repeat("-", 70))
fmt.Println("ERROR DETAILS:") fmt.Println("ERROR DETAILS:")
fmt.Println(strings.Repeat("", 70)) fmt.Println(strings.Repeat("-", 70))
fmt.Printf("\nType: %s\n", report.ErrorType) fmt.Printf("\nType: %s\n", report.ErrorType)
fmt.Printf("Message: %s\n", report.ErrorMessage) fmt.Printf("Message: %s\n", report.ErrorMessage)
@@ -420,9 +420,9 @@ func (ec *ErrorCollector) PrintReport(report *RestoreErrorReport) {
// Show failure context // Show failure context
if report.FailureContext != nil && report.FailureContext.FailedLine > 0 { if report.FailureContext != nil && report.FailureContext.FailedLine > 0 {
fmt.Println("\n" + strings.Repeat("", 70)) fmt.Println("\n" + strings.Repeat("-", 70))
fmt.Println("FAILURE CONTEXT:") fmt.Println("FAILURE CONTEXT:")
fmt.Println(strings.Repeat("", 70)) fmt.Println(strings.Repeat("-", 70))
fmt.Printf("\nFailed at line: %d\n", report.FailureContext.FailedLine) fmt.Printf("\nFailed at line: %d\n", report.FailureContext.FailedLine)
if report.FailureContext.InCopyBlock { if report.FailureContext.InCopyBlock {
@@ -439,9 +439,9 @@ func (ec *ErrorCollector) PrintReport(report *RestoreErrorReport) {
// Show first few errors // Show first few errors
if len(report.FirstErrors) > 0 { if len(report.FirstErrors) > 0 {
fmt.Println("\n" + strings.Repeat("", 70)) fmt.Println("\n" + strings.Repeat("-", 70))
fmt.Println("FIRST ERRORS:") fmt.Println("FIRST ERRORS:")
fmt.Println(strings.Repeat("", 70)) fmt.Println(strings.Repeat("-", 70))
for i, err := range report.FirstErrors { for i, err := range report.FirstErrors {
if i >= 5 { if i >= 5 {
@@ -454,15 +454,15 @@ func (ec *ErrorCollector) PrintReport(report *RestoreErrorReport) {
// Show diagnosis summary // Show diagnosis summary
if report.DiagnosisResult != nil && !report.DiagnosisResult.IsValid { if report.DiagnosisResult != nil && !report.DiagnosisResult.IsValid {
fmt.Println("\n" + strings.Repeat("", 70)) fmt.Println("\n" + strings.Repeat("-", 70))
fmt.Println("DIAGNOSIS:") fmt.Println("DIAGNOSIS:")
fmt.Println(strings.Repeat("", 70)) fmt.Println(strings.Repeat("-", 70))
if report.DiagnosisResult.IsTruncated { if report.DiagnosisResult.IsTruncated {
fmt.Println(" File is TRUNCATED") fmt.Println(" [FAIL] File is TRUNCATED")
} }
if report.DiagnosisResult.IsCorrupted { if report.DiagnosisResult.IsCorrupted {
fmt.Println(" File is CORRUPTED") fmt.Println(" [FAIL] File is CORRUPTED")
} }
for i, err := range report.DiagnosisResult.Errors { for i, err := range report.DiagnosisResult.Errors {
if i >= 3 { if i >= 3 {
@@ -473,18 +473,18 @@ func (ec *ErrorCollector) PrintReport(report *RestoreErrorReport) {
} }
// Show recommendations // Show recommendations
fmt.Println("\n" + strings.Repeat("", 70)) fmt.Println("\n" + strings.Repeat("-", 70))
fmt.Println("💡 RECOMMENDATIONS:") fmt.Println("[HINT] RECOMMENDATIONS:")
fmt.Println(strings.Repeat("", 70)) fmt.Println(strings.Repeat("-", 70))
for _, rec := range report.Recommendations { for _, rec := range report.Recommendations {
fmt.Printf(" %s\n", rec) fmt.Printf(" - %s\n", rec)
} }
// Show tool versions // Show tool versions
fmt.Println("\n" + strings.Repeat("", 70)) fmt.Println("\n" + strings.Repeat("-", 70))
fmt.Println("ENVIRONMENT:") fmt.Println("ENVIRONMENT:")
fmt.Println(strings.Repeat("", 70)) fmt.Println(strings.Repeat("-", 70))
fmt.Printf(" OS: %s/%s\n", report.OS, report.Arch) fmt.Printf(" OS: %s/%s\n", report.OS, report.Arch)
fmt.Printf(" Go: %s\n", report.GoVersion) fmt.Printf(" Go: %s\n", report.GoVersion)
@@ -495,7 +495,7 @@ func (ec *ErrorCollector) PrintReport(report *RestoreErrorReport) {
fmt.Printf(" psql: %s\n", report.PsqlVersion) fmt.Printf(" psql: %s\n", report.PsqlVersion)
} }
fmt.Println(strings.Repeat("", 70)) fmt.Println(strings.Repeat("=", 70))
} }
// Helper functions // Helper functions

View File

@@ -0,0 +1,429 @@
package restore
import (
"context"
"database/sql"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"github.com/dustin/go-humanize"
"github.com/shirou/gopsutil/v3/mem"
)
// PreflightResult contains all preflight check results
type PreflightResult struct {
// Linux system checks
Linux LinuxChecks
// PostgreSQL checks
PostgreSQL PostgreSQLChecks
// Archive analysis
Archive ArchiveChecks
// Overall status
CanProceed bool
Warnings []string
Errors []string
}
// LinuxChecks contains Linux kernel/system checks
type LinuxChecks struct {
ShmMax int64 // /proc/sys/kernel/shmmax
ShmAll int64 // /proc/sys/kernel/shmall
MemTotal uint64 // Total RAM in bytes
MemAvailable uint64 // Available RAM in bytes
MemUsedPercent float64 // Memory usage percentage
ShmMaxOK bool // Is shmmax sufficient?
ShmAllOK bool // Is shmall sufficient?
MemAvailableOK bool // Is available RAM sufficient?
IsLinux bool // Are we running on Linux?
}
// PostgreSQLChecks contains PostgreSQL configuration checks
type PostgreSQLChecks struct {
MaxLocksPerTransaction int // Current setting
MaintenanceWorkMem string // Current setting
SharedBuffers string // Current setting (info only)
MaxConnections int // Current setting
Version string // PostgreSQL version
IsSuperuser bool // Can we modify settings?
}
// ArchiveChecks contains analysis of the backup archive
type ArchiveChecks struct {
TotalDatabases int
TotalBlobCount int // Estimated total BLOBs across all databases
BlobsByDB map[string]int // BLOBs per database
HasLargeBlobs bool // Any DB with >1000 BLOBs?
RecommendedLockBoost int // Calculated lock boost value
}
// RunPreflightChecks performs all preflight checks before a cluster restore
func (e *Engine) RunPreflightChecks(ctx context.Context, dumpsDir string, entries []os.DirEntry) (*PreflightResult, error) {
result := &PreflightResult{
CanProceed: true,
Archive: ArchiveChecks{
BlobsByDB: make(map[string]int),
},
}
e.progress.Update("[PREFLIGHT] Running system checks...")
e.log.Info("Starting preflight checks for cluster restore")
// 1. System checks (cross-platform via gopsutil)
e.checkSystemResources(result)
// 2. PostgreSQL checks (via existing connection)
e.checkPostgreSQL(ctx, result)
// 3. Archive analysis (count BLOBs to scale lock boost)
e.analyzeArchive(ctx, dumpsDir, entries, result)
// 4. Calculate recommended settings
e.calculateRecommendations(result)
// 5. Print summary
e.printPreflightSummary(result)
return result, nil
}
// checkSystemResources uses gopsutil for cross-platform system checks
func (e *Engine) checkSystemResources(result *PreflightResult) {
result.Linux.IsLinux = runtime.GOOS == "linux"
// Get memory info (works on Linux, macOS, Windows, BSD)
if vmem, err := mem.VirtualMemory(); err == nil {
result.Linux.MemTotal = vmem.Total
result.Linux.MemAvailable = vmem.Available
result.Linux.MemUsedPercent = vmem.UsedPercent
// 4GB minimum available for large restores
result.Linux.MemAvailableOK = vmem.Available >= 4*1024*1024*1024
e.log.Info("System memory detected",
"total", humanize.Bytes(vmem.Total),
"available", humanize.Bytes(vmem.Available),
"used_percent", fmt.Sprintf("%.1f%%", vmem.UsedPercent))
} else {
e.log.Warn("Could not detect system memory", "error", err)
}
// Linux-specific kernel checks (shmmax, shmall)
if result.Linux.IsLinux {
e.checkLinuxKernel(result)
}
// Add warnings for insufficient resources
if !result.Linux.MemAvailableOK && result.Linux.MemAvailable > 0 {
result.Warnings = append(result.Warnings,
fmt.Sprintf("Available RAM is low: %s (recommend 4GB+ for large restores)",
humanize.Bytes(result.Linux.MemAvailable)))
}
if result.Linux.MemUsedPercent > 85 {
result.Warnings = append(result.Warnings,
fmt.Sprintf("High memory usage: %.1f%% - restore may cause OOM", result.Linux.MemUsedPercent))
}
}
// checkLinuxKernel reads Linux-specific kernel limits from /proc
func (e *Engine) checkLinuxKernel(result *PreflightResult) {
// Read shmmax
if data, err := os.ReadFile("/proc/sys/kernel/shmmax"); err == nil {
val, _ := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
result.Linux.ShmMax = val
// 8GB minimum for large restores
result.Linux.ShmMaxOK = val >= 8*1024*1024*1024
}
// Read shmall (in pages, typically 4KB each)
if data, err := os.ReadFile("/proc/sys/kernel/shmall"); err == nil {
val, _ := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
result.Linux.ShmAll = val
// 2M pages = 8GB minimum
result.Linux.ShmAllOK = val >= 2*1024*1024
}
// Add kernel warnings
if !result.Linux.ShmMaxOK && result.Linux.ShmMax > 0 {
result.Warnings = append(result.Warnings,
fmt.Sprintf("Linux shmmax is low: %s (recommend 8GB+). Fix: sudo sysctl -w kernel.shmmax=17179869184",
humanize.Bytes(uint64(result.Linux.ShmMax))))
}
if !result.Linux.ShmAllOK && result.Linux.ShmAll > 0 {
result.Warnings = append(result.Warnings,
fmt.Sprintf("Linux shmall is low: %s pages (recommend 2M+). Fix: sudo sysctl -w kernel.shmall=4194304",
humanize.Comma(result.Linux.ShmAll)))
}
}
// checkPostgreSQL checks PostgreSQL configuration via SQL
func (e *Engine) checkPostgreSQL(ctx context.Context, result *PreflightResult) {
connStr := e.buildConnString()
db, err := sql.Open("pgx", connStr)
if err != nil {
e.log.Warn("Could not connect to PostgreSQL for preflight checks", "error", err)
return
}
defer db.Close()
// Check max_locks_per_transaction
var maxLocks string
if err := db.QueryRowContext(ctx, "SHOW max_locks_per_transaction").Scan(&maxLocks); err == nil {
result.PostgreSQL.MaxLocksPerTransaction, _ = strconv.Atoi(maxLocks)
}
// Check maintenance_work_mem
db.QueryRowContext(ctx, "SHOW maintenance_work_mem").Scan(&result.PostgreSQL.MaintenanceWorkMem)
// Check shared_buffers (info only, can't change without restart)
db.QueryRowContext(ctx, "SHOW shared_buffers").Scan(&result.PostgreSQL.SharedBuffers)
// Check max_connections
var maxConn string
if err := db.QueryRowContext(ctx, "SHOW max_connections").Scan(&maxConn); err == nil {
result.PostgreSQL.MaxConnections, _ = strconv.Atoi(maxConn)
}
// Check version
db.QueryRowContext(ctx, "SHOW server_version").Scan(&result.PostgreSQL.Version)
// Check if superuser
var isSuperuser bool
if err := db.QueryRowContext(ctx, "SELECT current_setting('is_superuser') = 'on'").Scan(&isSuperuser); err == nil {
result.PostgreSQL.IsSuperuser = isSuperuser
}
// Add info/warnings
if result.PostgreSQL.MaxLocksPerTransaction < 256 {
e.log.Info("PostgreSQL max_locks_per_transaction is low - will auto-boost",
"current", result.PostgreSQL.MaxLocksPerTransaction)
}
// Parse shared_buffers and warn if very low
sharedBuffersMB := parseMemoryToMB(result.PostgreSQL.SharedBuffers)
if sharedBuffersMB > 0 && sharedBuffersMB < 256 {
result.Warnings = append(result.Warnings,
fmt.Sprintf("PostgreSQL shared_buffers is low: %s (recommend 1GB+, requires restart)",
result.PostgreSQL.SharedBuffers))
}
}
// analyzeArchive counts BLOBs in dump files to calculate optimal lock boost
func (e *Engine) analyzeArchive(ctx context.Context, dumpsDir string, entries []os.DirEntry, result *PreflightResult) {
e.progress.Update("[PREFLIGHT] Analyzing archive for large objects...")
for _, entry := range entries {
if entry.IsDir() {
continue
}
result.Archive.TotalDatabases++
dumpFile := filepath.Join(dumpsDir, entry.Name())
dbName := strings.TrimSuffix(entry.Name(), ".dump")
dbName = strings.TrimSuffix(dbName, ".sql.gz")
// For custom format dumps, use pg_restore -l to count BLOBs
if strings.HasSuffix(entry.Name(), ".dump") {
blobCount := e.countBlobsInDump(ctx, dumpFile)
if blobCount > 0 {
result.Archive.BlobsByDB[dbName] = blobCount
result.Archive.TotalBlobCount += blobCount
if blobCount > 1000 {
result.Archive.HasLargeBlobs = true
}
}
}
// For SQL format, try to estimate from file content (sample check)
if strings.HasSuffix(entry.Name(), ".sql.gz") {
// Check for lo_create patterns in compressed SQL
blobCount := e.estimateBlobsInSQL(dumpFile)
if blobCount > 0 {
result.Archive.BlobsByDB[dbName] = blobCount
result.Archive.TotalBlobCount += blobCount
if blobCount > 1000 {
result.Archive.HasLargeBlobs = true
}
}
}
}
}
// countBlobsInDump uses pg_restore -l to count BLOB entries
func (e *Engine) countBlobsInDump(ctx context.Context, dumpFile string) int {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "pg_restore", "-l", dumpFile)
output, err := cmd.Output()
if err != nil {
return 0
}
// Count lines containing BLOB/LARGE OBJECT
count := 0
for _, line := range strings.Split(string(output), "\n") {
if strings.Contains(line, "BLOB") || strings.Contains(line, "LARGE OBJECT") {
count++
}
}
return count
}
// estimateBlobsInSQL samples compressed SQL for lo_create patterns
func (e *Engine) estimateBlobsInSQL(sqlFile string) int {
// Use zgrep for efficient searching in gzipped files
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Count lo_create calls (each = one large object)
cmd := exec.CommandContext(ctx, "zgrep", "-c", "lo_create", sqlFile)
output, err := cmd.Output()
if err != nil {
// Also try SELECT lo_create pattern
cmd2 := exec.CommandContext(ctx, "zgrep", "-c", "SELECT.*lo_create", sqlFile)
output, err = cmd2.Output()
if err != nil {
return 0
}
}
count, _ := strconv.Atoi(strings.TrimSpace(string(output)))
return count
}
// calculateRecommendations determines optimal settings based on analysis
func (e *Engine) calculateRecommendations(result *PreflightResult) {
// Base lock boost
lockBoost := 2048
// Scale up based on BLOB count
if result.Archive.TotalBlobCount > 5000 {
lockBoost = 4096
}
if result.Archive.TotalBlobCount > 10000 {
lockBoost = 8192
}
if result.Archive.TotalBlobCount > 50000 {
lockBoost = 16384
}
// Cap at reasonable maximum
if lockBoost > 16384 {
lockBoost = 16384
}
result.Archive.RecommendedLockBoost = lockBoost
// Log recommendation
e.log.Info("Calculated recommended lock boost",
"total_blobs", result.Archive.TotalBlobCount,
"recommended_locks", lockBoost)
}
// printPreflightSummary prints a nice summary of all checks
func (e *Engine) printPreflightSummary(result *PreflightResult) {
fmt.Println()
fmt.Println(strings.Repeat("─", 60))
fmt.Println(" PREFLIGHT CHECKS")
fmt.Println(strings.Repeat("─", 60))
// System checks (cross-platform)
fmt.Println("\n System Resources:")
printCheck("Total RAM", humanize.Bytes(result.Linux.MemTotal), true)
printCheck("Available RAM", humanize.Bytes(result.Linux.MemAvailable), result.Linux.MemAvailableOK || result.Linux.MemAvailable == 0)
printCheck("Memory Usage", fmt.Sprintf("%.1f%%", result.Linux.MemUsedPercent), result.Linux.MemUsedPercent < 85)
// Linux-specific kernel checks
if result.Linux.IsLinux && result.Linux.ShmMax > 0 {
fmt.Println("\n Linux Kernel:")
printCheck("shmmax", humanize.Bytes(uint64(result.Linux.ShmMax)), result.Linux.ShmMaxOK)
printCheck("shmall", humanize.Comma(result.Linux.ShmAll)+" pages", result.Linux.ShmAllOK)
}
// PostgreSQL checks
fmt.Println("\n PostgreSQL:")
printCheck("Version", result.PostgreSQL.Version, true)
printCheck("max_locks_per_transaction", fmt.Sprintf("%s → %s (auto-boost)",
humanize.Comma(int64(result.PostgreSQL.MaxLocksPerTransaction)),
humanize.Comma(int64(result.Archive.RecommendedLockBoost))),
true)
printCheck("maintenance_work_mem", fmt.Sprintf("%s → 2GB (auto-boost)",
result.PostgreSQL.MaintenanceWorkMem), true)
printInfo("shared_buffers", result.PostgreSQL.SharedBuffers)
printCheck("Superuser", fmt.Sprintf("%v", result.PostgreSQL.IsSuperuser), result.PostgreSQL.IsSuperuser)
// Archive analysis
fmt.Println("\n Archive Analysis:")
printInfo("Total databases", humanize.Comma(int64(result.Archive.TotalDatabases)))
printInfo("Total BLOBs detected", humanize.Comma(int64(result.Archive.TotalBlobCount)))
if len(result.Archive.BlobsByDB) > 0 {
fmt.Println(" Databases with BLOBs:")
for db, count := range result.Archive.BlobsByDB {
status := "✓"
if count > 1000 {
status := "⚠"
_ = status
}
fmt.Printf(" %s %s: %s BLOBs\n", status, db, humanize.Comma(int64(count)))
}
}
// Warnings
if len(result.Warnings) > 0 {
fmt.Println("\n ⚠ Warnings:")
for _, w := range result.Warnings {
fmt.Printf(" • %s\n", w)
}
}
fmt.Println(strings.Repeat("─", 60))
fmt.Println()
}
func printCheck(name, value string, ok bool) {
status := "✓"
if !ok {
status = "⚠"
}
fmt.Printf(" %s %s: %s\n", status, name, value)
}
func printInfo(name, value string) {
fmt.Printf(" %s: %s\n", name, value)
}
func parseMemoryToMB(memStr string) int {
memStr = strings.ToUpper(strings.TrimSpace(memStr))
var value int
var unit string
fmt.Sscanf(memStr, "%d%s", &value, &unit)
switch {
case strings.HasPrefix(unit, "G"):
return value * 1024
case strings.HasPrefix(unit, "M"):
return value
case strings.HasPrefix(unit, "K"):
return value / 1024
default:
return value / (1024 * 1024) // Assume bytes
}
}
func (e *Engine) buildConnString() string {
if e.cfg.Host == "localhost" || e.cfg.Host == "" {
return fmt.Sprintf("user=%s password=%s dbname=postgres sslmode=disable",
e.cfg.User, e.cfg.Password)
}
return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=postgres sslmode=disable",
e.cfg.Host, e.cfg.Port, e.cfg.User, e.cfg.Password)
}

View File

@@ -229,8 +229,14 @@ func containsSQLKeywords(content string) bool {
} }
// CheckDiskSpace verifies sufficient disk space for restore // CheckDiskSpace verifies sufficient disk space for restore
// Uses the effective work directory (WorkDir if set, otherwise BackupDir) since
// that's where extraction actually happens for large databases
func (s *Safety) CheckDiskSpace(archivePath string, multiplier float64) error { func (s *Safety) CheckDiskSpace(archivePath string, multiplier float64) error {
return s.CheckDiskSpaceAt(archivePath, s.cfg.BackupDir, multiplier) checkDir := s.cfg.GetEffectiveWorkDir()
if checkDir == "" {
checkDir = s.cfg.BackupDir
}
return s.CheckDiskSpaceAt(archivePath, checkDir, multiplier)
} }
// CheckDiskSpaceAt verifies sufficient disk space at a specific directory // CheckDiskSpaceAt verifies sufficient disk space at a specific directory

View File

@@ -25,7 +25,7 @@ func (pc *PrivilegeChecker) CheckAndWarn(allowRoot bool) error {
isRoot, user := pc.isRunningAsRoot() isRoot, user := pc.isRunningAsRoot()
if isRoot { if isRoot {
pc.log.Warn("⚠️ Running with elevated privileges (root/Administrator)") pc.log.Warn("[WARN] Running with elevated privileges (root/Administrator)")
pc.log.Warn("Security recommendation: Create a dedicated backup user with minimal privileges") pc.log.Warn("Security recommendation: Create a dedicated backup user with minimal privileges")
if !allowRoot { if !allowRoot {

View File

@@ -64,7 +64,7 @@ func (rc *ResourceChecker) ValidateResourcesForBackup(estimatedSize int64) error
if len(warnings) > 0 { if len(warnings) > 0 {
for _, warning := range warnings { for _, warning := range warnings {
rc.log.Warn("⚠️ Resource constraint: " + warning) rc.log.Warn("[WARN] Resource constraint: " + warning)
} }
rc.log.Info("Continuing backup operation (warnings are informational)") rc.log.Info("Continuing backup operation (warnings are informational)")
} }

View File

@@ -22,7 +22,7 @@ func (rc *ResourceChecker) checkPlatformLimits() (*ResourceLimits, error) {
rc.log.Debug("Resource limit: max open files", "limit", rLimit.Cur, "max", rLimit.Max) rc.log.Debug("Resource limit: max open files", "limit", rLimit.Cur, "max", rLimit.Max)
if rLimit.Cur < 1024 { if rLimit.Cur < 1024 {
rc.log.Warn("⚠️ Low file descriptor limit detected", rc.log.Warn("[WARN] Low file descriptor limit detected",
"current", rLimit.Cur, "current", rLimit.Cur,
"recommended", 4096, "recommended", 4096,
"hint", "Increase with: ulimit -n 4096") "hint", "Increase with: ulimit -n 4096")

View File

@@ -209,12 +209,12 @@ func (m ArchiveBrowserModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Validate selection based on mode // Validate selection based on mode
if m.mode == "restore-cluster" && !selected.Format.IsClusterBackup() { if m.mode == "restore-cluster" && !selected.Format.IsClusterBackup() {
m.message = errorStyle.Render(" Please select a cluster backup (.tar.gz)") m.message = errorStyle.Render("[FAIL] Please select a cluster backup (.tar.gz)")
return m, nil return m, nil
} }
if m.mode == "restore-single" && selected.Format.IsClusterBackup() { if m.mode == "restore-single" && selected.Format.IsClusterBackup() {
m.message = errorStyle.Render(" Please select a single database backup") m.message = errorStyle.Render("[FAIL] Please select a single database backup")
return m, nil return m, nil
} }
@@ -227,7 +227,7 @@ func (m ArchiveBrowserModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Show detailed info // Show detailed info
if len(m.archives) > 0 && m.cursor < len(m.archives) { if len(m.archives) > 0 && m.cursor < len(m.archives) {
selected := m.archives[m.cursor] selected := m.archives[m.cursor]
m.message = fmt.Sprintf("📦 %s | Format: %s | Size: %s | Modified: %s", m.message = fmt.Sprintf("[PKG] %s | Format: %s | Size: %s | Modified: %s",
selected.Name, selected.Name,
selected.Format.String(), selected.Format.String(),
formatSize(selected.Size), formatSize(selected.Size),
@@ -251,13 +251,13 @@ func (m ArchiveBrowserModel) View() string {
var s strings.Builder var s strings.Builder
// Header // Header
title := "📦 Backup Archives" title := "[PKG] Backup Archives"
if m.mode == "restore-single" { if m.mode == "restore-single" {
title = "📦 Select Archive to Restore (Single Database)" title = "[PKG] Select Archive to Restore (Single Database)"
} else if m.mode == "restore-cluster" { } else if m.mode == "restore-cluster" {
title = "📦 Select Archive to Restore (Cluster)" title = "[PKG] Select Archive to Restore (Cluster)"
} else if m.mode == "diagnose" { } else if m.mode == "diagnose" {
title = "🔍 Select Archive to Diagnose" title = "[SEARCH] Select Archive to Diagnose"
} }
s.WriteString(titleStyle.Render(title)) s.WriteString(titleStyle.Render(title))
@@ -269,7 +269,7 @@ func (m ArchiveBrowserModel) View() string {
} }
if m.err != nil { if m.err != nil {
s.WriteString(errorStyle.Render(fmt.Sprintf(" Error: %v", m.err))) s.WriteString(errorStyle.Render(fmt.Sprintf("[FAIL] Error: %v", m.err)))
s.WriteString("\n\n") s.WriteString("\n\n")
s.WriteString(infoStyle.Render("Press Esc to go back")) s.WriteString(infoStyle.Render("Press Esc to go back"))
return s.String() return s.String()
@@ -293,7 +293,7 @@ func (m ArchiveBrowserModel) View() string {
s.WriteString(archiveHeaderStyle.Render(fmt.Sprintf("%-40s %-25s %-12s %-20s", s.WriteString(archiveHeaderStyle.Render(fmt.Sprintf("%-40s %-25s %-12s %-20s",
"FILENAME", "FORMAT", "SIZE", "MODIFIED"))) "FILENAME", "FORMAT", "SIZE", "MODIFIED")))
s.WriteString("\n") s.WriteString("\n")
s.WriteString(strings.Repeat("", 100)) s.WriteString(strings.Repeat("-", 100))
s.WriteString("\n") s.WriteString("\n")
// Show archives (limit to visible area) // Show archives (limit to visible area)
@@ -317,13 +317,13 @@ func (m ArchiveBrowserModel) View() string {
} }
// Color code based on validity and age // Color code based on validity and age
statusIcon := "" statusIcon := "[+]"
if !archive.Valid { if !archive.Valid {
statusIcon = "" statusIcon = "[-]"
style = archiveInvalidStyle style = archiveInvalidStyle
} else if time.Since(archive.Modified) > 30*24*time.Hour { } else if time.Since(archive.Modified) > 30*24*time.Hour {
style = archiveOldStyle style = archiveOldStyle
statusIcon = "" statusIcon = "[WARN]"
} }
filename := truncate(archive.Name, 38) filename := truncate(archive.Name, 38)
@@ -351,7 +351,7 @@ func (m ArchiveBrowserModel) View() string {
s.WriteString(infoStyle.Render(fmt.Sprintf("Total: %d archive(s) | Selected: %d/%d", s.WriteString(infoStyle.Render(fmt.Sprintf("Total: %d archive(s) | Selected: %d/%d",
len(m.archives), m.cursor+1, len(m.archives)))) len(m.archives), m.cursor+1, len(m.archives))))
s.WriteString("\n") s.WriteString("\n")
s.WriteString(infoStyle.Render("⌨️ ↑/↓: Navigate | Enter: Select | d: Diagnose | f: Filter | i: Info | Esc: Back")) s.WriteString(infoStyle.Render("[KEY] ↑/↓: Navigate | Enter: Select | d: Diagnose | f: Filter | i: Info | Esc: Back"))
return s.String() return s.String()
} }

View File

@@ -136,11 +136,11 @@ func executeBackupWithTUIProgress(parentCtx context.Context, cfg *config.Config,
var result string var result string
switch backupType { switch backupType {
case "single": case "single":
result = fmt.Sprintf(" Single database backup of '%s' completed successfully in %v", dbName, elapsed) result = fmt.Sprintf("[+] Single database backup of '%s' completed successfully in %v", dbName, elapsed)
case "sample": case "sample":
result = fmt.Sprintf(" Sample backup of '%s' (ratio: %d) completed successfully in %v", dbName, ratio, elapsed) result = fmt.Sprintf("[+] Sample backup of '%s' (ratio: %d) completed successfully in %v", dbName, ratio, elapsed)
case "cluster": case "cluster":
result = fmt.Sprintf(" Cluster backup completed successfully in %v", elapsed) result = fmt.Sprintf("[+] Cluster backup completed successfully in %v", elapsed)
} }
return backupCompleteMsg{ return backupCompleteMsg{
@@ -200,9 +200,9 @@ func (m BackupExecutionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.err = msg.err m.err = msg.err
m.result = msg.result m.result = msg.result
if m.err == nil { if m.err == nil {
m.status = " Backup completed successfully!" m.status = "[OK] Backup completed successfully!"
} else { } else {
m.status = fmt.Sprintf(" Backup failed: %v", m.err) m.status = fmt.Sprintf("[FAIL] Backup failed: %v", m.err)
} }
// Auto-forward in debug/auto-confirm mode // Auto-forward in debug/auto-confirm mode
if m.config.TUIAutoConfirm { if m.config.TUIAutoConfirm {
@@ -216,7 +216,7 @@ func (m BackupExecutionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if !m.done && !m.cancelling { if !m.done && !m.cancelling {
// User requested cancellation - cancel the context // User requested cancellation - cancel the context
m.cancelling = true m.cancelling = true
m.status = "⏹️ Cancelling backup... (please wait)" m.status = "[STOP] Cancelling backup... (please wait)"
if m.cancel != nil { if m.cancel != nil {
m.cancel() m.cancel()
} }
@@ -240,7 +240,7 @@ func (m BackupExecutionModel) View() string {
// Clear screen with newlines and render header // Clear screen with newlines and render header
s.WriteString("\n\n") s.WriteString("\n\n")
header := titleStyle.Render("🔄 Backup Execution") header := titleStyle.Render("[EXEC] Backup Execution")
s.WriteString(header) s.WriteString(header)
s.WriteString("\n\n") s.WriteString("\n\n")
@@ -261,13 +261,13 @@ func (m BackupExecutionModel) View() string {
s.WriteString(fmt.Sprintf(" %s %s\n", spinnerFrames[m.spinnerFrame], m.status)) s.WriteString(fmt.Sprintf(" %s %s\n", spinnerFrames[m.spinnerFrame], m.status))
} else { } else {
s.WriteString(fmt.Sprintf(" %s %s\n", spinnerFrames[m.spinnerFrame], m.status)) s.WriteString(fmt.Sprintf(" %s %s\n", spinnerFrames[m.spinnerFrame], m.status))
s.WriteString("\n ⌨️ Press Ctrl+C or ESC to cancel\n") s.WriteString("\n [KEY] Press Ctrl+C or ESC to cancel\n")
} }
} else { } else {
s.WriteString(fmt.Sprintf(" %s\n\n", m.status)) s.WriteString(fmt.Sprintf(" %s\n\n", m.status))
if m.err != nil { if m.err != nil {
s.WriteString(fmt.Sprintf(" Error: %v\n", m.err)) s.WriteString(fmt.Sprintf(" [FAIL] Error: %v\n", m.err))
} else if m.result != "" { } else if m.result != "" {
// Parse and display result cleanly // Parse and display result cleanly
lines := strings.Split(m.result, "\n") lines := strings.Split(m.result, "\n")
@@ -278,7 +278,7 @@ func (m BackupExecutionModel) View() string {
} }
} }
} }
s.WriteString("\n ⌨️ Press Enter or ESC to return to menu\n") s.WriteString("\n [KEY] Press Enter or ESC to return to menu\n")
} }
return s.String() return s.String()

View File

@@ -11,40 +11,102 @@ import (
"dbbackup/internal/config" "dbbackup/internal/config"
"dbbackup/internal/logger" "dbbackup/internal/logger"
"dbbackup/internal/restore"
)
// OperationState represents the current operation state
type OperationState int
const (
OpIdle OperationState = iota
OpVerifying
OpDeleting
) )
// BackupManagerModel manages backup archives // BackupManagerModel manages backup archives
type BackupManagerModel struct { type BackupManagerModel struct {
config *config.Config config *config.Config
logger logger.Logger logger logger.Logger
parent tea.Model parent tea.Model
ctx context.Context ctx context.Context
archives []ArchiveInfo archives []ArchiveInfo
cursor int cursor int
loading bool loading bool
err error err error
message string message string
totalSize int64 totalSize int64
freeSpace int64 freeSpace int64
opState OperationState
opTarget string // Name of archive being operated on
spinnerFrame int
} }
// NewBackupManager creates a new backup manager // NewBackupManager creates a new backup manager
func NewBackupManager(cfg *config.Config, log logger.Logger, parent tea.Model, ctx context.Context) BackupManagerModel { func NewBackupManager(cfg *config.Config, log logger.Logger, parent tea.Model, ctx context.Context) BackupManagerModel {
return BackupManagerModel{ return BackupManagerModel{
config: cfg, config: cfg,
logger: log, logger: log,
parent: parent, parent: parent,
ctx: ctx, ctx: ctx,
loading: true, loading: true,
opState: OpIdle,
spinnerFrame: 0,
} }
} }
func (m BackupManagerModel) Init() tea.Cmd { func (m BackupManagerModel) Init() tea.Cmd {
return loadArchives(m.config, m.logger) return tea.Batch(loadArchives(m.config, m.logger), managerTickCmd())
}
// Tick for spinner animation
type managerTickMsg time.Time
func managerTickCmd() tea.Cmd {
return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg {
return managerTickMsg(t)
})
}
// Verify result message
type verifyResultMsg struct {
archive string
valid bool
err error
details string
} }
func (m BackupManagerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m BackupManagerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case managerTickMsg:
// Update spinner frame
m.spinnerFrame = (m.spinnerFrame + 1) % len(spinnerFrames)
return m, managerTickCmd()
case verifyResultMsg:
m.opState = OpIdle
m.opTarget = ""
if msg.err != nil {
m.message = fmt.Sprintf("[-] Verify failed: %v", msg.err)
} else if msg.valid {
m.message = fmt.Sprintf("[+] %s: Valid - %s", msg.archive, msg.details)
// Update archive validity in list
for i := range m.archives {
if m.archives[i].Name == msg.archive {
m.archives[i].Valid = true
break
}
}
} else {
m.message = fmt.Sprintf("[-] %s: Invalid - %s", msg.archive, msg.details)
for i := range m.archives {
if m.archives[i].Name == msg.archive {
m.archives[i].Valid = false
break
}
}
}
return m, nil
case archiveListMsg: case archiveListMsg:
m.loading = false m.loading = false
if msg.err != nil { if msg.err != nil {
@@ -68,10 +130,24 @@ func (m BackupManagerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
case tea.KeyMsg: case tea.KeyMsg:
switch msg.String() { // Allow escape/cancel even during operations
case "ctrl+c", "q", "esc": if msg.String() == "ctrl+c" || msg.String() == "esc" || msg.String() == "q" {
if m.opState != OpIdle {
// Cancel current operation
m.opState = OpIdle
m.opTarget = ""
m.message = "Operation cancelled"
return m, nil
}
return m.parent, nil return m.parent, nil
}
// Block other input during operations
if m.opState != OpIdle {
return m, nil
}
switch msg.String() {
case "up", "k": case "up", "k":
if m.cursor > 0 { if m.cursor > 0 {
m.cursor-- m.cursor--
@@ -83,11 +159,13 @@ func (m BackupManagerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
case "v": case "v":
// Verify archive // Verify archive with real verification
if len(m.archives) > 0 && m.cursor < len(m.archives) { if len(m.archives) > 0 && m.cursor < len(m.archives) {
selected := m.archives[m.cursor] selected := m.archives[m.cursor]
m.message = fmt.Sprintf("🔍 Verifying %s...", selected.Name) m.opState = OpVerifying
// In real implementation, would run verification m.opTarget = selected.Name
m.message = ""
return m, verifyArchiveCmd(selected)
} }
case "d": case "d":
@@ -96,16 +174,16 @@ func (m BackupManagerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
selected := m.archives[m.cursor] selected := m.archives[m.cursor]
archivePath := selected.Path archivePath := selected.Path
confirm := NewConfirmationModelWithAction(m.config, m.logger, m, confirm := NewConfirmationModelWithAction(m.config, m.logger, m,
"🗑️ Delete Archive", "[DELETE] Delete Archive",
fmt.Sprintf("Delete archive '%s'? This cannot be undone.", selected.Name), fmt.Sprintf("Delete archive '%s'? This cannot be undone.", selected.Name),
func() (tea.Model, tea.Cmd) { func() (tea.Model, tea.Cmd) {
// Delete the archive // Delete the archive
err := deleteArchive(archivePath) err := deleteArchive(archivePath)
if err != nil { if err != nil {
m.err = fmt.Errorf("failed to delete archive: %v", err) m.err = fmt.Errorf("failed to delete archive: %v", err)
m.message = fmt.Sprintf(" Failed to delete: %v", err) m.message = fmt.Sprintf("[FAIL] Failed to delete: %v", err)
} else { } else {
m.message = fmt.Sprintf(" Deleted: %s", selected.Name) m.message = fmt.Sprintf("[OK] Deleted: %s", selected.Name)
} }
// Refresh the archive list // Refresh the archive list
m.loading = true m.loading = true
@@ -118,7 +196,7 @@ func (m BackupManagerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Show info // Show info
if len(m.archives) > 0 && m.cursor < len(m.archives) { if len(m.archives) > 0 && m.cursor < len(m.archives) {
selected := m.archives[m.cursor] selected := m.archives[m.cursor]
m.message = fmt.Sprintf("📦 %s | %s | %s | Modified: %s", m.message = fmt.Sprintf("[PKG] %s | %s | %s | Modified: %s",
selected.Name, selected.Name,
selected.Format.String(), selected.Format.String(),
formatSize(selected.Size), formatSize(selected.Size),
@@ -152,39 +230,67 @@ func (m BackupManagerModel) View() string {
var s strings.Builder var s strings.Builder
// Title // Title
s.WriteString(titleStyle.Render("🗄️ Backup Archive Manager")) s.WriteString(TitleStyle.Render("[DB] Backup Archive Manager"))
s.WriteString("\n\n") s.WriteString("\n\n")
// Status line (no box, bold+color accents)
switch m.opState {
case OpVerifying:
spinner := spinnerFrames[m.spinnerFrame]
s.WriteString(StatusActiveStyle.Render(fmt.Sprintf("%s Verifying: %s", spinner, m.opTarget)))
s.WriteString("\n\n")
case OpDeleting:
spinner := spinnerFrames[m.spinnerFrame]
s.WriteString(StatusActiveStyle.Render(fmt.Sprintf("%s Deleting: %s", spinner, m.opTarget)))
s.WriteString("\n\n")
default:
if m.loading {
spinner := spinnerFrames[m.spinnerFrame]
s.WriteString(StatusActiveStyle.Render(fmt.Sprintf("%s Loading archives...", spinner)))
s.WriteString("\n\n")
} else if m.message != "" {
// Color based on message content
if strings.HasPrefix(m.message, "[+]") || strings.HasPrefix(m.message, "Valid") {
s.WriteString(StatusSuccessStyle.Render(m.message))
} else if strings.HasPrefix(m.message, "[-]") || strings.HasPrefix(m.message, "Error") {
s.WriteString(StatusErrorStyle.Render(m.message))
} else {
s.WriteString(StatusActiveStyle.Render(m.message))
}
s.WriteString("\n\n")
}
// No "Ready" message when idle - cleaner UI
}
if m.loading { if m.loading {
s.WriteString(infoStyle.Render("Loading archives..."))
return s.String() return s.String()
} }
if m.err != nil { if m.err != nil {
s.WriteString(errorStyle.Render(fmt.Sprintf(" Error: %v", m.err))) s.WriteString(StatusErrorStyle.Render(fmt.Sprintf("[FAIL] Error: %v", m.err)))
s.WriteString("\n\n") s.WriteString("\n\n")
s.WriteString(infoStyle.Render("Press Esc to go back")) s.WriteString(ShortcutStyle.Render("Press Esc to go back"))
return s.String() return s.String()
} }
// Summary // Summary
s.WriteString(infoStyle.Render(fmt.Sprintf("Total Archives: %d | Total Size: %s", s.WriteString(LabelStyle.Render(fmt.Sprintf("Total Archives: %d | Total Size: %s",
len(m.archives), formatSize(m.totalSize)))) len(m.archives), formatSize(m.totalSize))))
s.WriteString("\n\n") s.WriteString("\n\n")
// Archives list // Archives list
if len(m.archives) == 0 { if len(m.archives) == 0 {
s.WriteString(infoStyle.Render("No backup archives found")) s.WriteString(StatusReadyStyle.Render("No backup archives found"))
s.WriteString("\n\n") s.WriteString("\n\n")
s.WriteString(infoStyle.Render("Press Esc to go back")) s.WriteString(ShortcutStyle.Render("Press Esc to go back"))
return s.String() return s.String()
} }
// Column headers // Column headers with better alignment
s.WriteString(archiveHeaderStyle.Render(fmt.Sprintf("%-35s %-25s %-12s %-20s", s.WriteString(ListHeaderStyle.Render(fmt.Sprintf(" %-32s %-22s %10s %-16s",
"FILENAME", "FORMAT", "SIZE", "MODIFIED"))) "FILENAME", "FORMAT", "SIZE", "MODIFIED")))
s.WriteString("\n") s.WriteString("\n")
s.WriteString(strings.Repeat("", 95)) s.WriteString(strings.Repeat("-", 90))
s.WriteString("\n") s.WriteString("\n")
// Show archives (limit to visible area) // Show archives (limit to visible area)
@@ -199,27 +305,27 @@ func (m BackupManagerModel) View() string {
for i := start; i < end; i++ { for i := start; i < end; i++ {
archive := m.archives[i] archive := m.archives[i]
cursor := " " cursor := " "
style := archiveNormalStyle style := ListNormalStyle
if i == m.cursor { if i == m.cursor {
cursor = ">" cursor = "> "
style = archiveSelectedStyle style = ListSelectedStyle
} }
// Status icon // Status icon - consistent 4-char width
statusIcon := "" statusIcon := " [+]"
if !archive.Valid { if !archive.Valid {
statusIcon = "" statusIcon = " [-]"
style = archiveInvalidStyle style = ItemInvalidStyle
} else if time.Since(archive.Modified) > 30*24*time.Hour { } else if time.Since(archive.Modified) > 30*24*time.Hour {
statusIcon = "" statusIcon = " [!]"
} }
filename := truncate(archive.Name, 33) filename := truncate(archive.Name, 32)
format := truncate(archive.Format.String(), 23) format := truncate(archive.Format.String(), 22)
line := fmt.Sprintf("%s %s %-33s %-23s %-10s %-19s", line := fmt.Sprintf("%s%s %-32s %-22s %10s %-16s",
cursor, cursor,
statusIcon, statusIcon,
filename, filename,
@@ -233,18 +339,83 @@ func (m BackupManagerModel) View() string {
// Footer // Footer
s.WriteString("\n") s.WriteString("\n")
if m.message != "" {
s.WriteString(infoStyle.Render(m.message))
s.WriteString("\n")
}
s.WriteString(infoStyle.Render(fmt.Sprintf("Selected: %d/%d", m.cursor+1, len(m.archives)))) s.WriteString(StatusReadyStyle.Render(fmt.Sprintf("Selected: %d/%d", m.cursor+1, len(m.archives))))
s.WriteString("\n") s.WriteString("\n\n")
s.WriteString(infoStyle.Render("⌨️ ↑/↓: Navigate | r: Restore | v: Verify | d: Delete | i: Info | R: Refresh | Esc: Back"))
// Grouped keyboard shortcuts
s.WriteString(ShortcutStyle.Render("SHORTCUTS: Up/Down=Move | r=Restore | v=Verify | d=Delete | i=Info | R=Refresh | Esc=Back | q=Quit"))
return s.String() return s.String()
} }
// verifyArchiveCmd runs the SAME verification as restore safety checks
// This ensures consistency between backup manager verify and restore preview
func verifyArchiveCmd(archive ArchiveInfo) tea.Cmd {
return func() tea.Msg {
var issues []string
// 1. Run the same archive integrity check as restore
safety := restore.NewSafety(nil, nil) // Doesn't need config/log for validation
if err := safety.ValidateArchive(archive.Path); err != nil {
return verifyResultMsg{
archive: archive.Name,
valid: false,
err: nil,
details: fmt.Sprintf("Archive integrity: %v", err),
}
}
// 2. Run the same deep diagnosis as restore
diagnoser := restore.NewDiagnoser(nil, false)
diagResult, diagErr := diagnoser.DiagnoseFile(archive.Path)
if diagErr != nil {
return verifyResultMsg{
archive: archive.Name,
valid: false,
err: diagErr,
details: "Cannot diagnose archive",
}
}
if !diagResult.IsValid {
// Collect error details
if diagResult.IsTruncated {
issues = append(issues, "TRUNCATED")
}
if diagResult.IsCorrupted {
issues = append(issues, "CORRUPTED")
}
if len(diagResult.Errors) > 0 {
issues = append(issues, diagResult.Errors[0])
}
return verifyResultMsg{
archive: archive.Name,
valid: false,
err: nil,
details: strings.Join(issues, "; "),
}
}
// Build success details
details := "Verified"
if diagResult.Details != nil {
if diagResult.Details.TableCount > 0 {
details = fmt.Sprintf("%d databases in archive", diagResult.Details.TableCount)
} else if diagResult.Details.PgRestoreListable {
details = "pg_restore verified"
}
}
// Add any warnings
if len(diagResult.Warnings) > 0 {
details += fmt.Sprintf(" [%d warnings]", len(diagResult.Warnings))
}
return verifyResultMsg{archive: archive.Name, valid: true, err: nil, details: details}
}
}
// deleteArchive deletes a backup archive (to be called from confirmation) // deleteArchive deletes a backup archive (to be called from confirmation)
func deleteArchive(archivePath string) error { func deleteArchive(archivePath string) error {
return os.Remove(archivePath) return os.Remove(archivePath)

View File

@@ -67,7 +67,6 @@ func (m ConfirmationModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case autoConfirmMsg: case autoConfirmMsg:
// Auto-confirm triggered // Auto-confirm triggered
m.confirmed = true
if m.onConfirm != nil { if m.onConfirm != nil {
return m.onConfirm() return m.onConfirm()
} }
@@ -95,7 +94,6 @@ func (m ConfirmationModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "enter", "y": case "enter", "y":
if msg.String() == "y" || m.cursor == 0 { if msg.String() == "y" || m.cursor == 0 {
m.confirmed = true
// Execute the onConfirm callback if provided // Execute the onConfirm callback if provided
if m.onConfirm != nil { if m.onConfirm != nil {
return m.onConfirm() return m.onConfirm()
@@ -131,7 +129,7 @@ func (m ConfirmationModel) View() string {
s.WriteString(" ") s.WriteString(" ")
} }
s.WriteString("\n\n⌨️ ←/→: Select Enter/y: Confirm n/ESC: Cancel\n") s.WriteString("\n\n[KEYS] <-/->: Select | Enter/y: Confirm | n/ESC: Cancel\n")
return s.String() return s.String()
} }

View File

@@ -109,7 +109,7 @@ func (m DatabaseSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return executor, executor.Init() return executor, executor.Init()
} }
inputModel := NewInputModel(m.config, m.logger, m, inputModel := NewInputModel(m.config, m.logger, m,
"📊 Sample Ratio", "[STATS] Sample Ratio",
"Enter sample ratio (1-100):", "Enter sample ratio (1-100):",
"10", "10",
ValidateInt(1, 100)) ValidateInt(1, 100))
@@ -152,7 +152,7 @@ func (m DatabaseSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// If sample backup, ask for ratio first // If sample backup, ask for ratio first
if m.backupType == "sample" { if m.backupType == "sample" {
inputModel := NewInputModel(m.config, m.logger, m, inputModel := NewInputModel(m.config, m.logger, m,
"📊 Sample Ratio", "[STATS] Sample Ratio",
"Enter sample ratio (1-100):", "Enter sample ratio (1-100):",
"10", "10",
ValidateInt(1, 100)) ValidateInt(1, 100))
@@ -176,12 +176,12 @@ func (m DatabaseSelectorModel) View() string {
s.WriteString(fmt.Sprintf("\n%s\n\n", header)) s.WriteString(fmt.Sprintf("\n%s\n\n", header))
if m.loading { if m.loading {
s.WriteString(" Loading databases...\n") s.WriteString("[WAIT] Loading databases...\n")
return s.String() return s.String()
} }
if m.err != nil { if m.err != nil {
s.WriteString(fmt.Sprintf(" Error: %v\n", m.err)) s.WriteString(fmt.Sprintf("[FAIL] Error: %v\n", m.err))
s.WriteString("\nPress ESC to go back\n") s.WriteString("\nPress ESC to go back\n")
return s.String() return s.String()
} }
@@ -203,7 +203,7 @@ func (m DatabaseSelectorModel) View() string {
s.WriteString(fmt.Sprintf("\n%s\n", m.message)) s.WriteString(fmt.Sprintf("\n%s\n", m.message))
} }
s.WriteString("\n⌨️ ↑/↓: Navigate Enter: Select ESC: Back q: Quit\n") s.WriteString("\n[KEYS] Up/Down: Navigate | Enter: Select | ESC: Back | q: Quit\n")
return s.String() return s.String()
} }

View File

@@ -160,7 +160,7 @@ func (m DiagnoseViewModel) View() string {
var s strings.Builder var s strings.Builder
// Header // Header
s.WriteString(titleStyle.Render("🔍 Backup Diagnosis")) s.WriteString(titleStyle.Render("[SEARCH] Backup Diagnosis"))
s.WriteString("\n\n") s.WriteString("\n\n")
// Archive info // Archive info
@@ -175,14 +175,14 @@ func (m DiagnoseViewModel) View() string {
s.WriteString("\n\n") s.WriteString("\n\n")
if m.running { if m.running {
s.WriteString(infoStyle.Render(" " + m.progress)) s.WriteString(infoStyle.Render("[WAIT] " + m.progress))
s.WriteString("\n\n") s.WriteString("\n\n")
s.WriteString(diagnoseInfoStyle.Render("This may take a while for large archives...")) s.WriteString(diagnoseInfoStyle.Render("This may take a while for large archives..."))
return s.String() return s.String()
} }
if m.err != nil { if m.err != nil {
s.WriteString(errorStyle.Render(fmt.Sprintf(" Diagnosis failed: %v", m.err))) s.WriteString(errorStyle.Render(fmt.Sprintf("[FAIL] Diagnosis failed: %v", m.err)))
s.WriteString("\n\n") s.WriteString("\n\n")
s.WriteString(infoStyle.Render("Press Enter or Esc to go back")) s.WriteString(infoStyle.Render("Press Enter or Esc to go back"))
return s.String() return s.String()
@@ -204,124 +204,132 @@ func (m DiagnoseViewModel) View() string {
func (m DiagnoseViewModel) renderSingleResult(result *restore.DiagnoseResult) string { func (m DiagnoseViewModel) renderSingleResult(result *restore.DiagnoseResult) string {
var s strings.Builder var s strings.Builder
// Status // Status Box
s.WriteString(strings.Repeat("", 60)) s.WriteString("+--[ VALIDATION STATUS ]" + strings.Repeat("-", 37) + "+\n")
s.WriteString("\n")
if result.IsValid { if result.IsValid {
s.WriteString(diagnosePassStyle.Render("✅ STATUS: VALID")) s.WriteString("| " + diagnosePassStyle.Render("[OK] VALID - Archive passed all checks") + strings.Repeat(" ", 18) + "|\n")
} else { } else {
s.WriteString(diagnoseFailStyle.Render("❌ STATUS: INVALID")) s.WriteString("| " + diagnoseFailStyle.Render("[FAIL] INVALID - Archive has problems") + strings.Repeat(" ", 19) + "|\n")
} }
s.WriteString("\n")
if result.IsTruncated { if result.IsTruncated {
s.WriteString(diagnoseFailStyle.Render("⚠️ TRUNCATED: File appears incomplete")) s.WriteString("| " + diagnoseFailStyle.Render("[!] TRUNCATED - File is incomplete") + strings.Repeat(" ", 22) + "|\n")
s.WriteString("\n")
} }
if result.IsCorrupted { if result.IsCorrupted {
s.WriteString(diagnoseFailStyle.Render("⚠️ CORRUPTED: File structure is damaged")) s.WriteString("| " + diagnoseFailStyle.Render("[!] CORRUPTED - File structure damaged") + strings.Repeat(" ", 18) + "|\n")
s.WriteString("\n")
} }
s.WriteString(strings.Repeat("", 60)) s.WriteString("+" + strings.Repeat("-", 60) + "+\n\n")
s.WriteString("\n\n")
// Details // Details Box
if result.Details != nil { if result.Details != nil {
s.WriteString(diagnoseHeaderStyle.Render("📊 DETAILS:")) s.WriteString("+--[ DETAILS ]" + strings.Repeat("-", 46) + "+\n")
s.WriteString("\n")
if result.Details.HasPGDMPSignature { if result.Details.HasPGDMPSignature {
s.WriteString(diagnosePassStyle.Render(" ✓ ")) s.WriteString("| " + diagnosePassStyle.Render("[+]") + " PostgreSQL custom format (PGDMP)" + strings.Repeat(" ", 20) + "|\n")
s.WriteString("Has PGDMP signature (custom format)\n")
} }
if result.Details.HasSQLHeader { if result.Details.HasSQLHeader {
s.WriteString(diagnosePassStyle.Render(" ✓ ")) s.WriteString("| " + diagnosePassStyle.Render("[+]") + " PostgreSQL SQL header found" + strings.Repeat(" ", 25) + "|\n")
s.WriteString("Has PostgreSQL SQL header\n")
} }
if result.Details.GzipValid { if result.Details.GzipValid {
s.WriteString(diagnosePassStyle.Render(" ✓ ")) s.WriteString("| " + diagnosePassStyle.Render("[+]") + " Gzip compression valid" + strings.Repeat(" ", 30) + "|\n")
s.WriteString("Gzip compression valid\n")
} }
if result.Details.PgRestoreListable { if result.Details.PgRestoreListable {
s.WriteString(diagnosePassStyle.Render(" ✓ ")) tableInfo := fmt.Sprintf(" (%d tables)", result.Details.TableCount)
s.WriteString(fmt.Sprintf("pg_restore can list contents (%d tables)\n", result.Details.TableCount)) padding := 36 - len(tableInfo)
if padding < 0 {
padding = 0
}
s.WriteString("| " + diagnosePassStyle.Render("[+]") + " pg_restore can list contents" + tableInfo + strings.Repeat(" ", padding) + "|\n")
} }
if result.Details.CopyBlockCount > 0 { if result.Details.CopyBlockCount > 0 {
s.WriteString(diagnoseInfoStyle.Render(" • ")) blockInfo := fmt.Sprintf("%d COPY blocks found", result.Details.CopyBlockCount)
s.WriteString(fmt.Sprintf("Contains %d COPY blocks\n", result.Details.CopyBlockCount)) padding := 50 - len(blockInfo)
if padding < 0 {
padding = 0
}
s.WriteString("| [-] " + blockInfo + strings.Repeat(" ", padding) + "|\n")
} }
if result.Details.UnterminatedCopy { if result.Details.UnterminatedCopy {
s.WriteString(diagnoseFailStyle.Render(" ✗ ")) s.WriteString("| " + diagnoseFailStyle.Render("[-]") + " Unterminated COPY: " + truncate(result.Details.LastCopyTable, 30) + strings.Repeat(" ", 5) + "|\n")
s.WriteString(fmt.Sprintf("Unterminated COPY block: %s (line %d)\n",
result.Details.LastCopyTable, result.Details.LastCopyLineNumber))
} }
if result.Details.ProperlyTerminated { if result.Details.ProperlyTerminated {
s.WriteString(diagnosePassStyle.Render(" ✓ ")) s.WriteString("| " + diagnosePassStyle.Render("[+]") + " All COPY blocks properly terminated" + strings.Repeat(" ", 17) + "|\n")
s.WriteString("All COPY blocks properly terminated\n")
} }
if result.Details.ExpandedSize > 0 { if result.Details.ExpandedSize > 0 {
s.WriteString(diagnoseInfoStyle.Render(" • ")) sizeInfo := fmt.Sprintf("Expanded: %s (%.1fx)", formatSize(result.Details.ExpandedSize), result.Details.CompressionRatio)
s.WriteString(fmt.Sprintf("Expanded size: %s (ratio: %.1fx)\n", padding := 50 - len(sizeInfo)
formatSize(result.Details.ExpandedSize), result.Details.CompressionRatio)) if padding < 0 {
padding = 0
}
s.WriteString("| [-] " + sizeInfo + strings.Repeat(" ", padding) + "|\n")
} }
s.WriteString("+" + strings.Repeat("-", 60) + "+\n")
} }
// Errors // Errors Box
if len(result.Errors) > 0 { if len(result.Errors) > 0 {
s.WriteString("\n") s.WriteString("\n+--[ ERRORS ]" + strings.Repeat("-", 47) + "+\n")
s.WriteString(diagnoseFailStyle.Render("❌ ERRORS:"))
s.WriteString("\n")
for i, e := range result.Errors { for i, e := range result.Errors {
if i >= 5 { if i >= 5 {
s.WriteString(diagnoseInfoStyle.Render(fmt.Sprintf(" ... and %d more\n", len(result.Errors)-5))) remaining := fmt.Sprintf("... and %d more errors", len(result.Errors)-5)
padding := 56 - len(remaining)
s.WriteString("| " + remaining + strings.Repeat(" ", padding) + "|\n")
break break
} }
s.WriteString(diagnoseFailStyle.Render(" • ")) errText := truncate(e, 54)
s.WriteString(truncate(e, 70)) padding := 56 - len(errText)
s.WriteString("\n") if padding < 0 {
padding = 0
}
s.WriteString("| " + errText + strings.Repeat(" ", padding) + "|\n")
} }
s.WriteString("+" + strings.Repeat("-", 60) + "+\n")
} }
// Warnings // Warnings Box
if len(result.Warnings) > 0 { if len(result.Warnings) > 0 {
s.WriteString("\n") s.WriteString("\n+--[ WARNINGS ]" + strings.Repeat("-", 45) + "+\n")
s.WriteString(diagnoseWarnStyle.Render("⚠️ WARNINGS:"))
s.WriteString("\n")
for i, w := range result.Warnings { for i, w := range result.Warnings {
if i >= 3 { if i >= 3 {
s.WriteString(diagnoseInfoStyle.Render(fmt.Sprintf(" ... and %d more\n", len(result.Warnings)-3))) remaining := fmt.Sprintf("... and %d more warnings", len(result.Warnings)-3)
padding := 56 - len(remaining)
s.WriteString("| " + remaining + strings.Repeat(" ", padding) + "|\n")
break break
} }
s.WriteString(diagnoseWarnStyle.Render(" • ")) warnText := truncate(w, 54)
s.WriteString(truncate(w, 70)) padding := 56 - len(warnText)
s.WriteString("\n") if padding < 0 {
padding = 0
}
s.WriteString("| " + warnText + strings.Repeat(" ", padding) + "|\n")
} }
s.WriteString("+" + strings.Repeat("-", 60) + "+\n")
} }
// Recommendations // Recommendations Box
if !result.IsValid { if !result.IsValid {
s.WriteString("\n") s.WriteString("\n+--[ RECOMMENDATIONS ]" + strings.Repeat("-", 38) + "+\n")
s.WriteString(diagnoseHeaderStyle.Render("💡 RECOMMENDATIONS:"))
s.WriteString("\n")
if result.IsTruncated { if result.IsTruncated {
s.WriteString(" 1. Re-run the backup process for this database\n") s.WriteString("| 1. Re-run backup with current version (v3.42.12+) |\n")
s.WriteString(" 2. Check disk space on backup server\n") s.WriteString("| 2. Check disk space on backup server |\n")
s.WriteString(" 3. Verify network stability for remote backups\n") s.WriteString("| 3. Verify network stability for remote backups |\n")
} }
if result.IsCorrupted { if result.IsCorrupted {
s.WriteString(" 1. Verify backup was transferred completely\n") s.WriteString("| 1. Verify backup was transferred completely |\n")
s.WriteString(" 2. Try restoring from a previous backup\n") s.WriteString("| 2. Try restoring from a previous backup |\n")
} }
s.WriteString("+" + strings.Repeat("-", 60) + "+\n")
} }
return s.String() return s.String()
@@ -341,17 +349,17 @@ func (m DiagnoseViewModel) renderClusterResults() string {
} }
} }
s.WriteString(strings.Repeat("", 60)) s.WriteString(strings.Repeat("-", 60))
s.WriteString("\n") s.WriteString("\n")
s.WriteString(diagnoseHeaderStyle.Render(fmt.Sprintf("📊 CLUSTER SUMMARY: %d databases\n", len(m.results)))) s.WriteString(diagnoseHeaderStyle.Render(fmt.Sprintf("[STATS] CLUSTER SUMMARY: %d databases\n", len(m.results))))
s.WriteString(strings.Repeat("", 60)) s.WriteString(strings.Repeat("-", 60))
s.WriteString("\n\n") s.WriteString("\n\n")
if invalidCount == 0 { if invalidCount == 0 {
s.WriteString(diagnosePassStyle.Render(" All dumps are valid")) s.WriteString(diagnosePassStyle.Render("[OK] All dumps are valid"))
s.WriteString("\n\n") s.WriteString("\n\n")
} else { } else {
s.WriteString(diagnoseFailStyle.Render(fmt.Sprintf(" %d/%d dumps have issues", invalidCount, len(m.results)))) s.WriteString(diagnoseFailStyle.Render(fmt.Sprintf("[FAIL] %d/%d dumps have issues", invalidCount, len(m.results))))
s.WriteString("\n\n") s.WriteString("\n\n")
} }
@@ -378,13 +386,13 @@ func (m DiagnoseViewModel) renderClusterResults() string {
var status string var status string
if r.IsValid { if r.IsValid {
status = diagnosePassStyle.Render("") status = diagnosePassStyle.Render("[+]")
} else if r.IsTruncated { } else if r.IsTruncated {
status = diagnoseFailStyle.Render(" TRUNCATED") status = diagnoseFailStyle.Render("[-] TRUNCATED")
} else if r.IsCorrupted { } else if r.IsCorrupted {
status = diagnoseFailStyle.Render(" CORRUPTED") status = diagnoseFailStyle.Render("[-] CORRUPTED")
} else { } else {
status = diagnoseFailStyle.Render(" INVALID") status = diagnoseFailStyle.Render("[-] INVALID")
} }
line := fmt.Sprintf("%s %s %-35s %s", line := fmt.Sprintf("%s %s %-35s %s",
@@ -405,7 +413,7 @@ func (m DiagnoseViewModel) renderClusterResults() string {
if m.cursor < len(m.results) { if m.cursor < len(m.results) {
selected := m.results[m.cursor] selected := m.results[m.cursor]
s.WriteString("\n") s.WriteString("\n")
s.WriteString(strings.Repeat("", 60)) s.WriteString(strings.Repeat("-", 60))
s.WriteString("\n") s.WriteString("\n")
s.WriteString(diagnoseHeaderStyle.Render("Selected: " + selected.FileName)) s.WriteString(diagnoseHeaderStyle.Render("Selected: " + selected.FileName))
s.WriteString("\n\n") s.WriteString("\n\n")
@@ -413,7 +421,7 @@ func (m DiagnoseViewModel) renderClusterResults() string {
// Show condensed details for selected // Show condensed details for selected
if selected.Details != nil { if selected.Details != nil {
if selected.Details.UnterminatedCopy { if selected.Details.UnterminatedCopy {
s.WriteString(diagnoseFailStyle.Render(" Unterminated COPY: ")) s.WriteString(diagnoseFailStyle.Render(" [-] Unterminated COPY: "))
s.WriteString(selected.Details.LastCopyTable) s.WriteString(selected.Details.LastCopyTable)
s.WriteString(fmt.Sprintf(" (line %d)\n", selected.Details.LastCopyLineNumber)) s.WriteString(fmt.Sprintf(" (line %d)\n", selected.Details.LastCopyLineNumber))
} }
@@ -429,7 +437,7 @@ func (m DiagnoseViewModel) renderClusterResults() string {
if i >= 2 { if i >= 2 {
break break
} }
s.WriteString(diagnoseFailStyle.Render(" ")) s.WriteString(diagnoseFailStyle.Render(" - "))
s.WriteString(truncate(e, 55)) s.WriteString(truncate(e, 55))
s.WriteString("\n") s.WriteString("\n")
} }

View File

@@ -208,7 +208,7 @@ func (dp *DirectoryPicker) View() string {
if dp.allowFiles { if dp.allowFiles {
pickerType = "File/Directory" pickerType = "File/Directory"
} }
header := fmt.Sprintf("📁 %s Picker - %s", pickerType, dp.currentPath) header := fmt.Sprintf("[DIR] %s Picker - %s", pickerType, dp.currentPath)
content.WriteString(dp.styles.Header.Render(header)) content.WriteString(dp.styles.Header.Render(header))
content.WriteString("\n\n") content.WriteString("\n\n")
@@ -216,13 +216,13 @@ func (dp *DirectoryPicker) View() string {
for i, item := range dp.items { for i, item := range dp.items {
var prefix string var prefix string
if item.Name == ".." { if item.Name == ".." {
prefix = "⬆️ " prefix = "[UP] "
} else if item.Name == "Error reading directory" { } else if item.Name == "Error reading directory" {
prefix = " " prefix = "[X] "
} else if item.IsDir { } else if item.IsDir {
prefix = "📁 " prefix = "[DIR] "
} else { } else {
prefix = "📄 " prefix = "[FILE] "
} }
line := prefix + item.Name line := prefix + item.Name
@@ -235,9 +235,9 @@ func (dp *DirectoryPicker) View() string {
} }
// Help text // Help text
help := "\n↑/↓: Navigate Enter: Open/Select File s: Select Directory q/Esc: Cancel" help := "\nUp/Down: Navigate | Enter: Open/Select File | s: Select Directory | q/Esc: Cancel"
if !dp.allowFiles { if !dp.allowFiles {
help = "\n↑/↓: Navigate Enter: Open s: Select Directory q/Esc: Cancel" help = "\nUp/Down: Navigate | Enter: Open | s: Select Directory | q/Esc: Cancel"
} }
content.WriteString(dp.styles.Help.Render(help)) content.WriteString(dp.styles.Help.Render(help))

View File

@@ -2,7 +2,7 @@ package tui
import ( import (
"fmt" "fmt"
"io/ioutil" "os"
"strings" "strings"
"time" "time"
@@ -59,7 +59,7 @@ func loadHistory(cfg *config.Config) []HistoryEntry {
var entries []HistoryEntry var entries []HistoryEntry
// Read backup files from backup directory // Read backup files from backup directory
files, err := ioutil.ReadDir(cfg.BackupDir) files, err := os.ReadDir(cfg.BackupDir)
if err != nil { if err != nil {
return entries return entries
} }
@@ -74,6 +74,12 @@ func loadHistory(cfg *config.Config) []HistoryEntry {
continue continue
} }
// Get file info for ModTime
info, err := file.Info()
if err != nil {
continue
}
var backupType string var backupType string
var database string var database string
@@ -97,8 +103,8 @@ func loadHistory(cfg *config.Config) []HistoryEntry {
entries = append(entries, HistoryEntry{ entries = append(entries, HistoryEntry{
Type: backupType, Type: backupType,
Database: database, Database: database,
Timestamp: file.ModTime(), Timestamp: info.ModTime(),
Status: " Completed", Status: "[OK] Completed",
Filename: name, Filename: name,
}) })
} }
@@ -185,11 +191,11 @@ func (m HistoryViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m HistoryViewModel) View() string { func (m HistoryViewModel) View() string {
var s strings.Builder var s strings.Builder
header := titleStyle.Render("📜 Operation History") header := titleStyle.Render("[HISTORY] Operation History")
s.WriteString(fmt.Sprintf("\n%s\n\n", header)) s.WriteString(fmt.Sprintf("\n%s\n\n", header))
if len(m.history) == 0 { if len(m.history) == 0 {
s.WriteString("📭 No backup history found\n\n") s.WriteString("[EMPTY] No backup history found\n\n")
} else { } else {
maxVisible := 15 // Show max 15 items at once maxVisible := 15 // Show max 15 items at once
@@ -205,7 +211,7 @@ func (m HistoryViewModel) View() string {
// Show scroll indicators // Show scroll indicators
if start > 0 { if start > 0 {
s.WriteString(" More entries above...\n") s.WriteString(" [^] More entries above...\n")
} }
// Display only visible entries // Display only visible entries
@@ -227,13 +233,13 @@ func (m HistoryViewModel) View() string {
// Show scroll indicator if more entries below // Show scroll indicator if more entries below
if end < len(m.history) { if end < len(m.history) {
s.WriteString(fmt.Sprintf(" %d more entries below...\n", len(m.history)-end)) s.WriteString(fmt.Sprintf(" [v] %d more entries below...\n", len(m.history)-end))
} }
s.WriteString("\n") s.WriteString("\n")
} }
s.WriteString("⌨️ ↑/↓: Navigate PgUp/PgDn: Jump Home/End: First/Last ESC: Back q: Quit\n") s.WriteString("[KEYS] Up/Down: Navigate - PgUp/PgDn: Jump - Home/End: First/Last - ESC: Back - q: Quit\n")
return s.String() return s.String()
} }

View File

@@ -137,10 +137,10 @@ func (m InputModel) View() string {
s.WriteString("\n\n") s.WriteString("\n\n")
if m.err != nil { if m.err != nil {
s.WriteString(errorStyle.Render(fmt.Sprintf(" Error: %v\n\n", m.err))) s.WriteString(errorStyle.Render(fmt.Sprintf("[FAIL] Error: %v\n\n", m.err)))
} }
s.WriteString("⌨️ Type value Enter: Confirm ESC: Cancel\n") s.WriteString("[KEYS] Type value | Enter: Confirm | ESC: Cancel\n")
return s.String() return s.String()
} }

View File

@@ -89,12 +89,12 @@ func NewMenuModel(cfg *config.Config, log logger.Logger) *MenuModel {
"Single Database Backup", "Single Database Backup",
"Sample Database Backup (with ratio)", "Sample Database Backup (with ratio)",
"Cluster Backup (all databases)", "Cluster Backup (all databases)",
"────────────────────────────────", "--------------------------------",
"Restore Single Database", "Restore Single Database",
"Restore Cluster Backup", "Restore Cluster Backup",
"Diagnose Backup File", "Diagnose Backup File",
"List & Manage Backups", "List & Manage Backups",
"────────────────────────────────", "--------------------------------",
"View Active Operations", "View Active Operations",
"Show Operation History", "Show Operation History",
"Database Status & Health Check", "Database Status & Health Check",
@@ -177,7 +177,7 @@ func (m *MenuModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case 12: // Settings case 12: // Settings
return m.handleSettings() return m.handleSettings()
case 13: // Clear History case 13: // Clear History
m.message = "🗑️ History cleared" m.message = "[DEL] History cleared"
case 14: // Quit case 14: // Quit
if m.cancel != nil { if m.cancel != nil {
m.cancel() m.cancel()
@@ -262,7 +262,7 @@ func (m *MenuModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case 12: // Settings case 12: // Settings
return m.handleSettings() return m.handleSettings()
case 13: // Clear History case 13: // Clear History
m.message = "🗑️ History cleared" m.message = "[DEL] History cleared"
case 14: // Quit case 14: // Quit
if m.cancel != nil { if m.cancel != nil {
m.cancel() m.cancel()
@@ -285,7 +285,7 @@ func (m *MenuModel) View() string {
var s string var s string
// Header // Header
header := titleStyle.Render("🗄️ Database Backup Tool - Interactive Menu") header := titleStyle.Render("[DB] Database Backup Tool - Interactive Menu")
s += fmt.Sprintf("\n%s\n\n", header) s += fmt.Sprintf("\n%s\n\n", header)
if len(m.dbTypes) > 0 { if len(m.dbTypes) > 0 {
@@ -299,7 +299,7 @@ func (m *MenuModel) View() string {
} }
selector := fmt.Sprintf("Target Engine: %s", strings.Join(options, menuStyle.Render(" | "))) selector := fmt.Sprintf("Target Engine: %s", strings.Join(options, menuStyle.Render(" | ")))
s += dbSelectorLabelStyle.Render(selector) + "\n" s += dbSelectorLabelStyle.Render(selector) + "\n"
hint := infoStyle.Render("Switch with ←/→ or t Cluster backup requires PostgreSQL") hint := infoStyle.Render("Switch with <-/-> or t | Cluster backup requires PostgreSQL")
s += hint + "\n" s += hint + "\n"
} }
@@ -326,7 +326,7 @@ func (m *MenuModel) View() string {
} }
// Footer // Footer
footer := infoStyle.Render("\n⌨️ Press ↑/↓ to navigate Enter to select q to quit") footer := infoStyle.Render("\n[KEYS] Press Up/Down to navigate | Enter to select | q to quit")
s += footer s += footer
return s return s
@@ -334,20 +334,20 @@ func (m *MenuModel) View() string {
// handleSingleBackup opens database selector for single backup // handleSingleBackup opens database selector for single backup
func (m *MenuModel) handleSingleBackup() (tea.Model, tea.Cmd) { func (m *MenuModel) handleSingleBackup() (tea.Model, tea.Cmd) {
selector := NewDatabaseSelector(m.config, m.logger, m, m.ctx, "🗄️ Single Database Backup", "single") selector := NewDatabaseSelector(m.config, m.logger, m, m.ctx, "[DB] Single Database Backup", "single")
return selector, selector.Init() return selector, selector.Init()
} }
// handleSampleBackup opens database selector for sample backup // handleSampleBackup opens database selector for sample backup
func (m *MenuModel) handleSampleBackup() (tea.Model, tea.Cmd) { func (m *MenuModel) handleSampleBackup() (tea.Model, tea.Cmd) {
selector := NewDatabaseSelector(m.config, m.logger, m, m.ctx, "📊 Sample Database Backup", "sample") selector := NewDatabaseSelector(m.config, m.logger, m, m.ctx, "[STATS] Sample Database Backup", "sample")
return selector, selector.Init() return selector, selector.Init()
} }
// handleClusterBackup shows confirmation and executes cluster backup // handleClusterBackup shows confirmation and executes cluster backup
func (m *MenuModel) handleClusterBackup() (tea.Model, tea.Cmd) { func (m *MenuModel) handleClusterBackup() (tea.Model, tea.Cmd) {
if !m.config.IsPostgreSQL() { if !m.config.IsPostgreSQL() {
m.message = errorStyle.Render(" Cluster backup is available only for PostgreSQL targets") m.message = errorStyle.Render("[FAIL] Cluster backup is available only for PostgreSQL targets")
return m, nil return m, nil
} }
// Skip confirmation in auto-confirm mode // Skip confirmation in auto-confirm mode
@@ -356,7 +356,7 @@ func (m *MenuModel) handleClusterBackup() (tea.Model, tea.Cmd) {
return executor, executor.Init() return executor, executor.Init()
} }
confirm := NewConfirmationModelWithAction(m.config, m.logger, m, confirm := NewConfirmationModelWithAction(m.config, m.logger, m,
"🗄️ Cluster Backup", "[DB] Cluster Backup",
"This will backup ALL databases in the cluster. Continue?", "This will backup ALL databases in the cluster. Continue?",
func() (tea.Model, tea.Cmd) { func() (tea.Model, tea.Cmd) {
executor := NewBackupExecution(m.config, m.logger, m, m.ctx, "cluster", "", 0) executor := NewBackupExecution(m.config, m.logger, m, m.ctx, "cluster", "", 0)
@@ -399,7 +399,7 @@ func (m *MenuModel) handleRestoreSingle() (tea.Model, tea.Cmd) {
// handleRestoreCluster opens archive browser for cluster restore // handleRestoreCluster opens archive browser for cluster restore
func (m *MenuModel) handleRestoreCluster() (tea.Model, tea.Cmd) { func (m *MenuModel) handleRestoreCluster() (tea.Model, tea.Cmd) {
if !m.config.IsPostgreSQL() { if !m.config.IsPostgreSQL() {
m.message = errorStyle.Render(" Cluster restore is available only for PostgreSQL") m.message = errorStyle.Render("[FAIL] Cluster restore is available only for PostgreSQL")
return m, nil return m, nil
} }
browser := NewArchiveBrowser(m.config, m.logger, m, m.ctx, "restore-cluster") browser := NewArchiveBrowser(m.config, m.logger, m, m.ctx, "restore-cluster")
@@ -428,7 +428,7 @@ func (m *MenuModel) applyDatabaseSelection() {
selection := m.dbTypes[m.dbTypeCursor] selection := m.dbTypes[m.dbTypeCursor]
if err := m.config.SetDatabaseType(selection.value); err != nil { if err := m.config.SetDatabaseType(selection.value); err != nil {
m.message = errorStyle.Render(fmt.Sprintf(" %v", err)) m.message = errorStyle.Render(fmt.Sprintf("[FAIL] %v", err))
return return
} }
@@ -437,7 +437,7 @@ func (m *MenuModel) applyDatabaseSelection() {
m.config.Port = m.config.GetDefaultPort() m.config.Port = m.config.GetDefaultPort()
} }
m.message = successStyle.Render(fmt.Sprintf("🔀 Target database set to %s", m.config.DisplayDatabaseType())) m.message = successStyle.Render(fmt.Sprintf("[SWITCH] Target database set to %s", m.config.DisplayDatabaseType()))
if m.logger != nil { if m.logger != nil {
m.logger.Info("updated target database type", "type", m.config.DatabaseType, "port", m.config.Port) m.logger.Info("updated target database type", "type", m.config.DatabaseType, "port", m.config.Port)
} }

View File

@@ -49,14 +49,14 @@ func (m OperationsViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m OperationsViewModel) View() string { func (m OperationsViewModel) View() string {
var s strings.Builder var s strings.Builder
header := titleStyle.Render("📊 Active Operations") header := titleStyle.Render("[STATS] Active Operations")
s.WriteString(fmt.Sprintf("\n%s\n\n", header)) s.WriteString(fmt.Sprintf("\n%s\n\n", header))
s.WriteString("Currently running operations:\n\n") s.WriteString("Currently running operations:\n\n")
s.WriteString(infoStyle.Render("📭 No active operations")) s.WriteString(infoStyle.Render("[NONE] No active operations"))
s.WriteString("\n\n") s.WriteString("\n\n")
s.WriteString("⌨️ Press any key to return to menu\n") s.WriteString("[KEYS] Press any key to return to menu\n")
return s.String() return s.String()
} }

View File

@@ -285,7 +285,7 @@ func (m RestoreExecutionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if !m.done && !m.cancelling { if !m.done && !m.cancelling {
// User requested cancellation - cancel the context // User requested cancellation - cancel the context
m.cancelling = true m.cancelling = true
m.status = "⏹️ Cancelling restore... (please wait)" m.status = "[STOP] Cancelling restore... (please wait)"
m.phase = "Cancelling" m.phase = "Cancelling"
if m.cancel != nil { if m.cancel != nil {
m.cancel() m.cancel()
@@ -297,7 +297,7 @@ func (m RestoreExecutionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "q": case "q":
if !m.done && !m.cancelling { if !m.done && !m.cancelling {
m.cancelling = true m.cancelling = true
m.status = "⏹️ Cancelling restore... (please wait)" m.status = "[STOP] Cancelling restore... (please wait)"
m.phase = "Cancelling" m.phase = "Cancelling"
if m.cancel != nil { if m.cancel != nil {
m.cancel() m.cancel()
@@ -321,9 +321,9 @@ func (m RestoreExecutionModel) View() string {
s.Grow(512) // Pre-allocate estimated capacity for better performance s.Grow(512) // Pre-allocate estimated capacity for better performance
// Title // Title
title := "💾 Restoring Database" title := "[RESTORE] Restoring Database"
if m.restoreType == "restore-cluster" { if m.restoreType == "restore-cluster" {
title = "💾 Restoring Cluster" title = "[RESTORE] Restoring Cluster"
} }
s.WriteString(titleStyle.Render(title)) s.WriteString(titleStyle.Render(title))
s.WriteString("\n\n") s.WriteString("\n\n")
@@ -338,12 +338,12 @@ func (m RestoreExecutionModel) View() string {
if m.done { if m.done {
// Show result // Show result
if m.err != nil { if m.err != nil {
s.WriteString(errorStyle.Render(" Restore Failed")) s.WriteString(errorStyle.Render("[FAIL] Restore Failed"))
s.WriteString("\n\n") s.WriteString("\n\n")
s.WriteString(errorStyle.Render(fmt.Sprintf("Error: %v", m.err))) s.WriteString(errorStyle.Render(fmt.Sprintf("Error: %v", m.err)))
s.WriteString("\n") s.WriteString("\n")
} else { } else {
s.WriteString(successStyle.Render(" Restore Completed Successfully")) s.WriteString(successStyle.Render("[OK] Restore Completed Successfully"))
s.WriteString("\n\n") s.WriteString("\n\n")
s.WriteString(successStyle.Render(m.result)) s.WriteString(successStyle.Render(m.result))
s.WriteString("\n") s.WriteString("\n")
@@ -351,7 +351,7 @@ func (m RestoreExecutionModel) View() string {
s.WriteString(fmt.Sprintf("\nElapsed Time: %s\n", formatDuration(m.elapsed))) s.WriteString(fmt.Sprintf("\nElapsed Time: %s\n", formatDuration(m.elapsed)))
s.WriteString("\n") s.WriteString("\n")
s.WriteString(infoStyle.Render("⌨️ Press Enter to continue")) s.WriteString(infoStyle.Render("[KEYS] Press Enter to continue"))
} else { } else {
// Show progress // Show progress
s.WriteString(fmt.Sprintf("Phase: %s\n", m.phase)) s.WriteString(fmt.Sprintf("Phase: %s\n", m.phase))
@@ -373,7 +373,7 @@ func (m RestoreExecutionModel) View() string {
// Elapsed time // Elapsed time
s.WriteString(fmt.Sprintf("Elapsed: %s\n", formatDuration(m.elapsed))) s.WriteString(fmt.Sprintf("Elapsed: %s\n", formatDuration(m.elapsed)))
s.WriteString("\n") s.WriteString("\n")
s.WriteString(infoStyle.Render("⌨️ Press Ctrl+C to cancel")) s.WriteString(infoStyle.Render("[KEYS] Press Ctrl+C to cancel"))
} }
return s.String() return s.String()

View File

@@ -106,9 +106,23 @@ type safetyCheckCompleteMsg struct {
func runSafetyChecks(cfg *config.Config, log logger.Logger, archive ArchiveInfo, targetDB string) tea.Cmd { func runSafetyChecks(cfg *config.Config, log logger.Logger, archive ArchiveInfo, targetDB string) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
// 10 minutes for safety checks - large archives can take a long time to diagnose // Dynamic timeout based on archive size for large database support
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) // Base: 10 minutes + 1 minute per 5 GB, max 120 minutes
timeoutMinutes := 10
if archive.Size > 0 {
sizeGB := archive.Size / (1024 * 1024 * 1024)
estimatedMinutes := int(sizeGB/5) + 10
if estimatedMinutes > timeoutMinutes {
timeoutMinutes = estimatedMinutes
}
if timeoutMinutes > 120 {
timeoutMinutes = 120
}
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutMinutes)*time.Minute)
defer cancel() defer cancel()
_ = ctx // Used by database checks below
safety := restore.NewSafety(cfg, log) safety := restore.NewSafety(cfg, log)
checks := []SafetyCheck{} checks := []SafetyCheck{}
@@ -264,7 +278,7 @@ func (m RestorePreviewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Toggle cluster cleanup // Toggle cluster cleanup
m.cleanClusterFirst = !m.cleanClusterFirst m.cleanClusterFirst = !m.cleanClusterFirst
if m.cleanClusterFirst { if m.cleanClusterFirst {
m.message = checkWarningStyle.Render(fmt.Sprintf("⚠️ Will drop %d existing database(s) before restore", m.existingDBCount)) m.message = checkWarningStyle.Render(fmt.Sprintf("[WARN] Will drop %d existing database(s) before restore", m.existingDBCount))
} else { } else {
m.message = fmt.Sprintf("Clean cluster first: disabled") m.message = fmt.Sprintf("Clean cluster first: disabled")
} }
@@ -278,7 +292,7 @@ func (m RestorePreviewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Toggle debug log saving // Toggle debug log saving
m.saveDebugLog = !m.saveDebugLog m.saveDebugLog = !m.saveDebugLog
if m.saveDebugLog { if m.saveDebugLog {
m.message = infoStyle.Render("📋 Debug log: enabled (will save detailed report on failure)") m.message = infoStyle.Render("[DEBUG] Debug log: enabled (will save detailed report on failure)")
} else { } else {
m.message = "Debug log: disabled" m.message = "Debug log: disabled"
} }
@@ -288,7 +302,7 @@ func (m RestorePreviewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.workDir == "" { if m.workDir == "" {
// Set to backup directory as default alternative // Set to backup directory as default alternative
m.workDir = m.config.BackupDir m.workDir = m.config.BackupDir
m.message = infoStyle.Render(fmt.Sprintf("📁 Work directory set to: %s", m.workDir)) m.message = infoStyle.Render(fmt.Sprintf("[DIR] Work directory set to: %s", m.workDir))
} else { } else {
// Clear work directory (use system temp) // Clear work directory (use system temp)
m.workDir = "" m.workDir = ""
@@ -302,7 +316,13 @@ func (m RestorePreviewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
if !m.canProceed { if !m.canProceed {
m.message = errorStyle.Render(" Cannot proceed - critical safety checks failed") m.message = errorStyle.Render("[FAIL] Cannot proceed - critical safety checks failed")
return m, nil
}
// Cluster-specific check: must enable cleanup if existing databases found
if m.mode == "restore-cluster" && m.existingDBCount > 0 && !m.cleanClusterFirst {
m.message = errorStyle.Render("[FAIL] Cannot proceed - press 'c' to enable cleanup of " + fmt.Sprintf("%d", m.existingDBCount) + " existing database(s) first")
return m, nil return m, nil
} }
@@ -319,15 +339,15 @@ func (m RestorePreviewModel) View() string {
var s strings.Builder var s strings.Builder
// Title // Title
title := "🔍 Restore Preview" title := "Restore Preview"
if m.mode == "restore-cluster" { if m.mode == "restore-cluster" {
title = "🔍 Cluster Restore Preview" title = "Cluster Restore Preview"
} }
s.WriteString(titleStyle.Render(title)) s.WriteString(titleStyle.Render(title))
s.WriteString("\n\n") s.WriteString("\n\n")
// Archive Information // Archive Information
s.WriteString(archiveHeaderStyle.Render("📦 Archive Information")) s.WriteString(archiveHeaderStyle.Render("[ARCHIVE] Information"))
s.WriteString("\n") s.WriteString("\n")
s.WriteString(fmt.Sprintf(" File: %s\n", m.archive.Name)) s.WriteString(fmt.Sprintf(" File: %s\n", m.archive.Name))
s.WriteString(fmt.Sprintf(" Format: %s\n", m.archive.Format.String())) s.WriteString(fmt.Sprintf(" Format: %s\n", m.archive.Format.String()))
@@ -340,25 +360,25 @@ func (m RestorePreviewModel) View() string {
// Target Information // Target Information
if m.mode == "restore-single" { if m.mode == "restore-single" {
s.WriteString(archiveHeaderStyle.Render("🎯 Target Information")) s.WriteString(archiveHeaderStyle.Render("[TARGET] Information"))
s.WriteString("\n") s.WriteString("\n")
s.WriteString(fmt.Sprintf(" Database: %s\n", m.targetDB)) s.WriteString(fmt.Sprintf(" Database: %s\n", m.targetDB))
s.WriteString(fmt.Sprintf(" Host: %s:%d\n", m.config.Host, m.config.Port)) s.WriteString(fmt.Sprintf(" Host: %s:%d\n", m.config.Host, m.config.Port))
cleanIcon := "" cleanIcon := "[N]"
if m.cleanFirst { if m.cleanFirst {
cleanIcon = "" cleanIcon = "[Y]"
} }
s.WriteString(fmt.Sprintf(" Clean First: %s %v\n", cleanIcon, m.cleanFirst)) s.WriteString(fmt.Sprintf(" Clean First: %s %v\n", cleanIcon, m.cleanFirst))
createIcon := "" createIcon := "[N]"
if m.createIfMissing { if m.createIfMissing {
createIcon = "" createIcon = "[Y]"
} }
s.WriteString(fmt.Sprintf(" Create If Missing: %s %v\n", createIcon, m.createIfMissing)) s.WriteString(fmt.Sprintf(" Create If Missing: %s %v\n", createIcon, m.createIfMissing))
s.WriteString("\n") s.WriteString("\n")
} else if m.mode == "restore-cluster" { } else if m.mode == "restore-cluster" {
s.WriteString(archiveHeaderStyle.Render("🎯 Cluster Restore Options")) s.WriteString(archiveHeaderStyle.Render("[CLUSTER] Restore Options"))
s.WriteString("\n") s.WriteString("\n")
s.WriteString(fmt.Sprintf(" Host: %s:%d\n", m.config.Host, m.config.Port)) s.WriteString(fmt.Sprintf(" Host: %s:%d\n", m.config.Host, m.config.Port))
@@ -376,10 +396,10 @@ func (m RestorePreviewModel) View() string {
s.WriteString(fmt.Sprintf(" - %s\n", db)) s.WriteString(fmt.Sprintf(" - %s\n", db))
} }
cleanIcon := "" cleanIcon := "[N]"
cleanStyle := infoStyle cleanStyle := infoStyle
if m.cleanClusterFirst { if m.cleanClusterFirst {
cleanIcon = "" cleanIcon = "[Y]"
cleanStyle = checkWarningStyle cleanStyle = checkWarningStyle
} }
s.WriteString(cleanStyle.Render(fmt.Sprintf(" Clean All First: %s %v (press 'c' to toggle)\n", cleanIcon, m.cleanClusterFirst))) s.WriteString(cleanStyle.Render(fmt.Sprintf(" Clean All First: %s %v (press 'c' to toggle)\n", cleanIcon, m.cleanClusterFirst)))
@@ -390,7 +410,7 @@ func (m RestorePreviewModel) View() string {
} }
// Safety Checks // Safety Checks
s.WriteString(archiveHeaderStyle.Render("🛡️ Safety Checks")) s.WriteString(archiveHeaderStyle.Render("[SAFETY] Checks"))
s.WriteString("\n") s.WriteString("\n")
if m.checking { if m.checking {
@@ -398,21 +418,21 @@ func (m RestorePreviewModel) View() string {
s.WriteString("\n") s.WriteString("\n")
} else { } else {
for _, check := range m.safetyChecks { for _, check := range m.safetyChecks {
icon := "" icon := "[ ]"
style := checkPendingStyle style := checkPendingStyle
switch check.Status { switch check.Status {
case "passed": case "passed":
icon = "" icon = "[+]"
style = checkPassedStyle style = checkPassedStyle
case "failed": case "failed":
icon = "" icon = "[-]"
style = checkFailedStyle style = checkFailedStyle
case "warning": case "warning":
icon = "" icon = "[!]"
style = checkWarningStyle style = checkWarningStyle
case "checking": case "checking":
icon = "" icon = "[~]"
style = checkPendingStyle style = checkPendingStyle
} }
@@ -428,13 +448,13 @@ func (m RestorePreviewModel) View() string {
// Warnings // Warnings
if m.cleanFirst { if m.cleanFirst {
s.WriteString(checkWarningStyle.Render("⚠️ Warning: Clean-first enabled")) s.WriteString(checkWarningStyle.Render("[WARN] Warning: Clean-first enabled"))
s.WriteString("\n") s.WriteString("\n")
s.WriteString(infoStyle.Render(" All existing data in target database will be dropped!")) s.WriteString(infoStyle.Render(" All existing data in target database will be dropped!"))
s.WriteString("\n\n") s.WriteString("\n\n")
} }
if m.cleanClusterFirst && m.existingDBCount > 0 { if m.cleanClusterFirst && m.existingDBCount > 0 {
s.WriteString(checkWarningStyle.Render("🔥 WARNING: Cluster cleanup enabled")) s.WriteString(checkWarningStyle.Render("[DANGER] WARNING: Cluster cleanup enabled"))
s.WriteString("\n") s.WriteString("\n")
s.WriteString(checkWarningStyle.Render(fmt.Sprintf(" %d existing database(s) will be DROPPED before restore!", m.existingDBCount))) s.WriteString(checkWarningStyle.Render(fmt.Sprintf(" %d existing database(s) will be DROPPED before restore!", m.existingDBCount)))
s.WriteString("\n") s.WriteString("\n")
@@ -443,30 +463,30 @@ func (m RestorePreviewModel) View() string {
} }
// Advanced Options // Advanced Options
s.WriteString(archiveHeaderStyle.Render("⚙️ Advanced Options")) s.WriteString(archiveHeaderStyle.Render("[OPTIONS] Advanced"))
s.WriteString("\n") s.WriteString("\n")
// Work directory option // Work directory option
workDirIcon := "" workDirIcon := "[-]"
workDirStyle := infoStyle workDirStyle := infoStyle
workDirValue := "(system temp)" workDirValue := "(system temp)"
if m.workDir != "" { if m.workDir != "" {
workDirIcon = "" workDirIcon = "[+]"
workDirStyle = checkPassedStyle workDirStyle = checkPassedStyle
workDirValue = m.workDir workDirValue = m.workDir
} }
s.WriteString(workDirStyle.Render(fmt.Sprintf(" %s Work Dir: %s (press 'w' to toggle)", workDirIcon, workDirValue))) s.WriteString(workDirStyle.Render(fmt.Sprintf(" %s Work Dir: %s (press 'w' to toggle)", workDirIcon, workDirValue)))
s.WriteString("\n") s.WriteString("\n")
if m.workDir == "" { if m.workDir == "" {
s.WriteString(infoStyle.Render(" ⚠️ Large archives need more space than /tmp may have")) s.WriteString(infoStyle.Render(" [WARN] Large archives need more space than /tmp may have"))
s.WriteString("\n") s.WriteString("\n")
} }
// Debug log option // Debug log option
debugIcon := "" debugIcon := "[-]"
debugStyle := infoStyle debugStyle := infoStyle
if m.saveDebugLog { if m.saveDebugLog {
debugIcon = "" debugIcon = "[+]"
debugStyle = checkPassedStyle debugStyle = checkPassedStyle
} }
s.WriteString(debugStyle.Render(fmt.Sprintf(" %s Debug Log: %v (press 'd' to toggle)", debugIcon, m.saveDebugLog))) s.WriteString(debugStyle.Render(fmt.Sprintf(" %s Debug Log: %v (press 'd' to toggle)", debugIcon, m.saveDebugLog)))
@@ -485,25 +505,25 @@ func (m RestorePreviewModel) View() string {
// Footer // Footer
if m.checking { if m.checking {
s.WriteString(infoStyle.Render("⌨️ Please wait...")) s.WriteString(infoStyle.Render("Please wait..."))
} else if m.canProceed { } else if m.canProceed {
s.WriteString(successStyle.Render(" Ready to restore")) s.WriteString(successStyle.Render("[OK] Ready to restore"))
s.WriteString("\n") s.WriteString("\n")
if m.mode == "restore-single" { if m.mode == "restore-single" {
s.WriteString(infoStyle.Render("⌨️ t: Clean-first | c: Create | w: WorkDir | d: Debug | Enter: Proceed | Esc: Cancel")) s.WriteString(infoStyle.Render("t: Clean-first | c: Create | w: WorkDir | d: Debug | Enter: Proceed | Esc: Cancel"))
} else if m.mode == "restore-cluster" { } else if m.mode == "restore-cluster" {
if m.existingDBCount > 0 { if m.existingDBCount > 0 {
s.WriteString(infoStyle.Render("⌨️ c: Cleanup | w: WorkDir | d: Debug | Enter: Proceed | Esc: Cancel")) s.WriteString(infoStyle.Render("c: Cleanup | w: WorkDir | d: Debug | Enter: Proceed | Esc: Cancel"))
} else { } else {
s.WriteString(infoStyle.Render("⌨️ w: WorkDir | d: Debug | Enter: Proceed | Esc: Cancel")) s.WriteString(infoStyle.Render("w: WorkDir | d: Debug | Enter: Proceed | Esc: Cancel"))
} }
} else { } else {
s.WriteString(infoStyle.Render("⌨️ w: WorkDir | d: Debug | Enter: Proceed | Esc: Cancel")) s.WriteString(infoStyle.Render("w: WorkDir | d: Debug | Enter: Proceed | Esc: Cancel"))
} }
} else { } else {
s.WriteString(errorStyle.Render(" Cannot proceed - please fix errors above")) s.WriteString(errorStyle.Render("[FAIL] Cannot proceed - please fix errors above"))
s.WriteString("\n") s.WriteString("\n")
s.WriteString(infoStyle.Render("⌨️ Esc: Go back")) s.WriteString(infoStyle.Render("Esc: Go back"))
} }
return s.String() return s.String()

View File

@@ -459,9 +459,9 @@ func (m SettingsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.cursor < len(m.settings) { if m.cursor < len(m.settings) {
setting := m.settings[m.cursor] setting := m.settings[m.cursor]
if err := setting.Update(m.config, selectedPath); err != nil { if err := setting.Update(m.config, selectedPath); err != nil {
m.message = " Error: " + err.Error() m.message = "[FAIL] Error: " + err.Error()
} else { } else {
m.message = " Directory updated: " + selectedPath m.message = "[OK] Directory updated: " + selectedPath
} }
} }
m.browsingDir = false m.browsingDir = false
@@ -482,7 +482,6 @@ func (m SettingsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.String() { switch msg.String() {
case "ctrl+c", "q", "esc": case "ctrl+c", "q", "esc":
m.quitting = true
return m.parent, nil return m.parent, nil
case "up", "k": case "up", "k":
@@ -501,9 +500,9 @@ func (m SettingsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
currentSetting := m.settings[m.cursor] currentSetting := m.settings[m.cursor]
if currentSetting.Type == "selector" { if currentSetting.Type == "selector" {
if err := currentSetting.Update(m.config, ""); err != nil { if err := currentSetting.Update(m.config, ""); err != nil {
m.message = errorStyle.Render(fmt.Sprintf(" %s", err.Error())) m.message = errorStyle.Render(fmt.Sprintf("[FAIL] %s", err.Error()))
} else { } else {
m.message = successStyle.Render(fmt.Sprintf(" Updated %s", currentSetting.DisplayName)) m.message = successStyle.Render(fmt.Sprintf("[OK] Updated %s", currentSetting.DisplayName))
} }
return m, nil return m, nil
} }
@@ -516,11 +515,11 @@ func (m SettingsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.settings[m.cursor].Type == "path" { if m.settings[m.cursor].Type == "path" {
return m.openDirectoryBrowser() return m.openDirectoryBrowser()
} else { } else {
m.message = " Tab key only works on directory path fields" m.message = "[FAIL] Tab key only works on directory path fields"
return m, nil return m, nil
} }
} else { } else {
m.message = " Invalid selection" m.message = "[FAIL] Invalid selection"
return m, nil return m, nil
} }
@@ -598,18 +597,18 @@ func (m SettingsModel) saveEditedValue() (tea.Model, tea.Cmd) {
} }
if setting == nil { if setting == nil {
m.message = errorStyle.Render(" Setting not found") m.message = errorStyle.Render("[FAIL] Setting not found")
m.editing = false m.editing = false
return m, nil return m, nil
} }
// Update the configuration // Update the configuration
if err := setting.Update(m.config, m.editingValue); err != nil { if err := setting.Update(m.config, m.editingValue); err != nil {
m.message = errorStyle.Render(fmt.Sprintf(" %s", err.Error())) m.message = errorStyle.Render(fmt.Sprintf("[FAIL] %s", err.Error()))
return m, nil return m, nil
} }
m.message = successStyle.Render(fmt.Sprintf(" Updated %s", setting.DisplayName)) m.message = successStyle.Render(fmt.Sprintf("[OK] Updated %s", setting.DisplayName))
m.editing = false m.editing = false
m.editingField = "" m.editingField = ""
m.editingValue = "" m.editingValue = ""
@@ -629,7 +628,7 @@ func (m SettingsModel) resetToDefaults() (tea.Model, tea.Cmd) {
newConfig.DatabaseType = m.config.DatabaseType newConfig.DatabaseType = m.config.DatabaseType
*m.config = *newConfig *m.config = *newConfig
m.message = successStyle.Render(" Settings reset to defaults") m.message = successStyle.Render("[OK] Settings reset to defaults")
return m, nil return m, nil
} }
@@ -637,19 +636,19 @@ func (m SettingsModel) resetToDefaults() (tea.Model, tea.Cmd) {
// saveSettings validates and saves current settings // saveSettings validates and saves current settings
func (m SettingsModel) saveSettings() (tea.Model, tea.Cmd) { func (m SettingsModel) saveSettings() (tea.Model, tea.Cmd) {
if err := m.config.Validate(); err != nil { if err := m.config.Validate(); err != nil {
m.message = errorStyle.Render(fmt.Sprintf(" Validation failed: %s", err.Error())) m.message = errorStyle.Render(fmt.Sprintf("[FAIL] Validation failed: %s", err.Error()))
return m, nil return m, nil
} }
// Optimize CPU settings if auto-detect is enabled // Optimize CPU settings if auto-detect is enabled
if m.config.AutoDetectCores { if m.config.AutoDetectCores {
if err := m.config.OptimizeForCPU(); err != nil { if err := m.config.OptimizeForCPU(); err != nil {
m.message = errorStyle.Render(fmt.Sprintf(" CPU optimization failed: %s", err.Error())) m.message = errorStyle.Render(fmt.Sprintf("[FAIL] CPU optimization failed: %s", err.Error()))
return m, nil return m, nil
} }
} }
m.message = successStyle.Render(" Settings validated and saved") m.message = successStyle.Render("[OK] Settings validated and saved")
return m, nil return m, nil
} }
@@ -672,11 +671,11 @@ func (m SettingsModel) cycleDatabaseType() (tea.Model, tea.Cmd) {
// Update config // Update config
if err := m.config.SetDatabaseType(newType); err != nil { if err := m.config.SetDatabaseType(newType); err != nil {
m.message = errorStyle.Render(fmt.Sprintf(" Failed to set database type: %s", err.Error())) m.message = errorStyle.Render(fmt.Sprintf("[FAIL] Failed to set database type: %s", err.Error()))
return m, nil return m, nil
} }
m.message = successStyle.Render(fmt.Sprintf(" Database type set to %s", m.config.DisplayDatabaseType())) m.message = successStyle.Render(fmt.Sprintf("[OK] Database type set to %s", m.config.DisplayDatabaseType()))
return m, nil return m, nil
} }
@@ -689,7 +688,7 @@ func (m SettingsModel) View() string {
var b strings.Builder var b strings.Builder
// Header // Header
header := titleStyle.Render("⚙️ Configuration Settings") header := titleStyle.Render("[CFG] Configuration Settings")
b.WriteString(fmt.Sprintf("\n%s\n\n", header)) b.WriteString(fmt.Sprintf("\n%s\n\n", header))
// Settings list // Settings list
@@ -711,7 +710,7 @@ func (m SettingsModel) View() string {
} }
line := fmt.Sprintf("%s %s: %s", cursor, setting.DisplayName, editValue) line := fmt.Sprintf("%s %s: %s", cursor, setting.DisplayName, editValue)
b.WriteString(selectedStyle.Render(line)) b.WriteString(selectedStyle.Render(line))
b.WriteString(" ✏️") b.WriteString(" [EDIT]")
} else { } else {
line := fmt.Sprintf("%s %s: %s", cursor, setting.DisplayName, displayValue) line := fmt.Sprintf("%s %s: %s", cursor, setting.DisplayName, displayValue)
b.WriteString(selectedStyle.Render(line)) b.WriteString(selectedStyle.Render(line))
@@ -748,7 +747,7 @@ func (m SettingsModel) View() string {
// Current configuration summary // Current configuration summary
if !m.editing { if !m.editing {
b.WriteString("\n") b.WriteString("\n")
b.WriteString(infoStyle.Render("📋 Current Configuration:")) b.WriteString(infoStyle.Render("[LOG] Current Configuration:"))
b.WriteString("\n") b.WriteString("\n")
summary := []string{ summary := []string{
@@ -776,16 +775,16 @@ func (m SettingsModel) View() string {
// Footer with instructions // Footer with instructions
var footer string var footer string
if m.editing { if m.editing {
footer = infoStyle.Render("\n⌨️ Type new value Enter to save Esc to cancel") footer = infoStyle.Render("\n[KEYS] Type new value | Enter to save | Esc to cancel")
} else { } else {
if m.browsingDir { if m.browsingDir {
footer = infoStyle.Render("\n⌨️ ↑/↓ navigate directories Enter open Space select Tab/Esc back to settings") footer = infoStyle.Render("\n[KEYS] Up/Down navigate directories | Enter open | Space select | Tab/Esc back to settings")
} else { } else {
// Show different help based on current selection // Show different help based on current selection
if m.cursor >= 0 && m.cursor < len(m.settings) && m.settings[m.cursor].Type == "path" { if m.cursor >= 0 && m.cursor < len(m.settings) && m.settings[m.cursor].Type == "path" {
footer = infoStyle.Render("\n⌨️ ↑/↓ navigate Enter edit Tab browse directories 's' save 'r' reset 'q' menu") footer = infoStyle.Render("\n[KEYS] Up/Down navigate | Enter edit | Tab browse directories | 's' save | 'r' reset | 'q' menu")
} else { } else {
footer = infoStyle.Render("\n⌨️ ↑/↓ navigate Enter edit 's' save 'r' reset 'q' menu Tab=dirs on path fields only") footer = infoStyle.Render("\n[KEYS] Up/Down navigate | Enter edit | 's' save | 'r' reset | 'q' menu | Tab=dirs on path fields only")
} }
} }
} }

View File

@@ -146,11 +146,10 @@ func (m StatusViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
case tea.KeyMsg: case tea.KeyMsg:
if !m.loading { // Always allow escape, even during loading
switch msg.String() { switch msg.String() {
case "ctrl+c", "q", "esc", "enter": case "ctrl+c", "q", "esc", "enter":
return m.parent, nil return m.parent, nil
}
} }
} }
@@ -160,25 +159,25 @@ func (m StatusViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m StatusViewModel) View() string { func (m StatusViewModel) View() string {
var s strings.Builder var s strings.Builder
header := titleStyle.Render("📊 Database Status & Health Check") header := titleStyle.Render("[STATS] Database Status & Health Check")
s.WriteString(fmt.Sprintf("\n%s\n\n", header)) s.WriteString(fmt.Sprintf("\n%s\n\n", header))
if m.loading { if m.loading {
spinner := []string{"⠋", "⠙", "⠹", "", "", "⠴", "⠦", "⠧", "⠇", "⠏"} spinner := []string{"-", "\\", "|", "/"}
frame := int(time.Now().UnixMilli()/100) % len(spinner) frame := int(time.Now().UnixMilli()/100) % len(spinner)
s.WriteString(fmt.Sprintf("%s Loading status information...\n", spinner[frame])) s.WriteString(fmt.Sprintf("%s Loading status information...\n", spinner[frame]))
return s.String() return s.String()
} }
if m.err != nil { if m.err != nil {
s.WriteString(errorStyle.Render(fmt.Sprintf(" Error: %v\n", m.err))) s.WriteString(errorStyle.Render(fmt.Sprintf("[FAIL] Error: %v\n", m.err)))
s.WriteString("\n") s.WriteString("\n")
} else { } else {
s.WriteString("Connection Status:\n") s.WriteString("Connection Status:\n")
if m.connected { if m.connected {
s.WriteString(successStyle.Render(" Connected\n")) s.WriteString(successStyle.Render(" [+] Connected\n"))
} else { } else {
s.WriteString(errorStyle.Render(" Disconnected\n")) s.WriteString(errorStyle.Render(" [-] Disconnected\n"))
} }
s.WriteString("\n") s.WriteString("\n")
@@ -193,9 +192,9 @@ func (m StatusViewModel) View() string {
} }
s.WriteString("\n") s.WriteString("\n")
s.WriteString(successStyle.Render(" All systems operational\n")) s.WriteString(successStyle.Render("[+] All systems operational\n"))
} }
s.WriteString("\n⌨️ Press any key to return to menu\n") s.WriteString("\n[KEYS] Press any key to return to menu\n")
return s.String() return s.String()
} }

133
internal/tui/styles.go Normal file
View File

@@ -0,0 +1,133 @@
package tui
import "github.com/charmbracelet/lipgloss"
// =============================================================================
// GLOBAL TUI STYLE DEFINITIONS
// =============================================================================
// Design Language:
// - Bold text for labels and headers
// - Colors for semantic meaning (green=success, red=error, yellow=warning)
// - No emoticons - use simple text prefixes like [OK], [FAIL], [!]
// - No boxes for inline status - use bold+color accents
// - Consistent color palette across all views
// =============================================================================
// Color Palette (ANSI 256 colors for terminal compatibility)
const (
ColorWhite = lipgloss.Color("15") // Bright white
ColorGray = lipgloss.Color("250") // Light gray
ColorDim = lipgloss.Color("244") // Dim gray
ColorDimmer = lipgloss.Color("240") // Darker gray
ColorSuccess = lipgloss.Color("2") // Green
ColorError = lipgloss.Color("1") // Red
ColorWarning = lipgloss.Color("3") // Yellow
ColorInfo = lipgloss.Color("6") // Cyan
ColorAccent = lipgloss.Color("4") // Blue
)
// =============================================================================
// TITLE & HEADER STYLES
// =============================================================================
// TitleStyle - main view title (bold white on gray background)
var TitleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(ColorWhite).
Background(ColorDimmer).
Padding(0, 1)
// HeaderStyle - section headers (bold gray)
var HeaderStyle = lipgloss.NewStyle().
Bold(true).
Foreground(ColorDim)
// LabelStyle - field labels (bold cyan)
var LabelStyle = lipgloss.NewStyle().
Bold(true).
Foreground(ColorInfo)
// =============================================================================
// STATUS STYLES
// =============================================================================
// StatusReadyStyle - idle/ready state (dim)
var StatusReadyStyle = lipgloss.NewStyle().
Foreground(ColorDim)
// StatusActiveStyle - operation in progress (bold cyan)
var StatusActiveStyle = lipgloss.NewStyle().
Bold(true).
Foreground(ColorInfo)
// StatusSuccessStyle - success messages (bold green)
var StatusSuccessStyle = lipgloss.NewStyle().
Bold(true).
Foreground(ColorSuccess)
// StatusErrorStyle - error messages (bold red)
var StatusErrorStyle = lipgloss.NewStyle().
Bold(true).
Foreground(ColorError)
// StatusWarningStyle - warning messages (bold yellow)
var StatusWarningStyle = lipgloss.NewStyle().
Bold(true).
Foreground(ColorWarning)
// =============================================================================
// LIST & TABLE STYLES
// =============================================================================
// ListNormalStyle - unselected list items
var ListNormalStyle = lipgloss.NewStyle().
Foreground(ColorGray)
// ListSelectedStyle - selected/cursor item (bold white)
var ListSelectedStyle = lipgloss.NewStyle().
Foreground(ColorWhite).
Bold(true)
// ListHeaderStyle - column headers (bold dim)
var ListHeaderStyle = lipgloss.NewStyle().
Bold(true).
Foreground(ColorDim)
// =============================================================================
// ITEM STATUS STYLES
// =============================================================================
// ItemValidStyle - valid/OK items (green)
var ItemValidStyle = lipgloss.NewStyle().
Foreground(ColorSuccess)
// ItemInvalidStyle - invalid/failed items (red)
var ItemInvalidStyle = lipgloss.NewStyle().
Foreground(ColorError)
// ItemOldStyle - old/stale items (yellow)
var ItemOldStyle = lipgloss.NewStyle().
Foreground(ColorWarning)
// =============================================================================
// SHORTCUT STYLE
// =============================================================================
// ShortcutStyle - keyboard shortcuts footer (dim)
var ShortcutStyle = lipgloss.NewStyle().
Foreground(ColorDim)
// =============================================================================
// HELPER PREFIXES (no emoticons)
// =============================================================================
const (
PrefixOK = "[OK]"
PrefixFail = "[FAIL]"
PrefixWarn = "[!]"
PrefixInfo = "[i]"
PrefixPlus = "[+]"
PrefixMinus = "[-]"
PrefixArrow = ">"
PrefixSpinner = "" // Spinner character added dynamically
)

View File

@@ -99,8 +99,8 @@ func (pm *PITRManager) EnablePITR(ctx context.Context, archiveDir string) error
return fmt.Errorf("failed to update postgresql.conf: %w", err) return fmt.Errorf("failed to update postgresql.conf: %w", err)
} }
pm.log.Info(" PITR configuration updated successfully") pm.log.Info("[OK] PITR configuration updated successfully")
pm.log.Warn("⚠️ PostgreSQL restart required for changes to take effect") pm.log.Warn("[WARN] PostgreSQL restart required for changes to take effect")
pm.log.Info("To restart PostgreSQL:") pm.log.Info("To restart PostgreSQL:")
pm.log.Info(" sudo systemctl restart postgresql") pm.log.Info(" sudo systemctl restart postgresql")
pm.log.Info(" OR: sudo pg_ctlcluster <version> <cluster> restart") pm.log.Info(" OR: sudo pg_ctlcluster <version> <cluster> restart")
@@ -132,8 +132,8 @@ func (pm *PITRManager) DisablePITR(ctx context.Context) error {
return fmt.Errorf("failed to update postgresql.conf: %w", err) return fmt.Errorf("failed to update postgresql.conf: %w", err)
} }
pm.log.Info(" PITR disabled successfully") pm.log.Info("[OK] PITR disabled successfully")
pm.log.Warn("⚠️ PostgreSQL restart required") pm.log.Warn("[WARN] PostgreSQL restart required")
return nil return nil
} }

View File

@@ -361,7 +361,7 @@ func (tm *TimelineManager) FormatTimelineTree(history *TimelineHistory) string {
var sb strings.Builder var sb strings.Builder
sb.WriteString("Timeline Branching Structure:\n") sb.WriteString("Timeline Branching Structure:\n")
sb.WriteString("═════════════════════════════\n\n") sb.WriteString("=============================\n\n")
// Build tree recursively // Build tree recursively
tm.formatTimelineNode(&sb, history, 1, 0, "") tm.formatTimelineNode(&sb, history, 1, 0, "")
@@ -378,9 +378,9 @@ func (tm *TimelineManager) formatTimelineNode(sb *strings.Builder, history *Time
// Format current node // Format current node
indent := strings.Repeat(" ", depth) indent := strings.Repeat(" ", depth)
marker := "├─" marker := "+-"
if depth == 0 { if depth == 0 {
marker = "" marker = "*"
} }
sb.WriteString(fmt.Sprintf("%s%s Timeline %d", indent, marker, tl.TimelineID)) sb.WriteString(fmt.Sprintf("%s%s Timeline %d", indent, marker, tl.TimelineID))

View File

@@ -16,7 +16,7 @@ import (
// Build information (set by ldflags) // Build information (set by ldflags)
var ( var (
version = "3.42.9" version = "3.42.33"
buildTime = "unknown" buildTime = "unknown"
gitCommit = "unknown" gitCommit = "unknown"
) )
@@ -52,7 +52,7 @@ func main() {
if metrics.GlobalMetrics != nil { if metrics.GlobalMetrics != nil {
avgs := metrics.GlobalMetrics.GetAverages() avgs := metrics.GlobalMetrics.GetAverages()
if ops, ok := avgs["total_operations"].(int); ok && ops > 0 { if ops, ok := avgs["total_operations"].(int); ok && ops > 0 {
fmt.Printf("\n📊 Session Summary: %d operations, %.1f%% success rate\n", fmt.Printf("\n[INFO] Session Summary: %d operations, %.1f%% success rate\n",
ops, avgs["success_rate"]) ops, avgs["success_rate"])
} }
} }