Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0cdcb28be | |||
| 22a7b9e81e | |||
| c71889be47 | |||
| 222bdbef58 | |||
| f7e9fa64f0 | |||
| f153e61dbf | |||
| d19c065658 | |||
| 8dac5efc10 | |||
| fd5edce5ae | |||
| a7e2c86618 | |||
| b2e0c739e0 | |||
| ad23abdf4e | |||
| 390b830976 | |||
| 7e53950967 | |||
| 59d2094241 | |||
| b1f8c6d646 | |||
| b05c2be19d | |||
| ec33959e3e | |||
| 92402f0fdb | |||
| 682510d1bc | |||
| 83ad62b6b5 |
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
206
OPENSOURCE_ALTERNATIVE.md
Normal 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
94
PITR.md
@@ -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
|
||||||
|
|||||||
21
SYSTEMD.md
21
SYSTEMD.md
@@ -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
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -4,8 +4,8 @@ This directory contains pre-compiled binaries for the DB Backup Tool across mult
|
|||||||
|
|
||||||
## Build Information
|
## Build Information
|
||||||
- **Version**: 3.42.10
|
- **Version**: 3.42.10
|
||||||
- **Build Time**: 2026-01-08_09:19:02_UTC
|
- **Build Time**: 2026-01-14_14:06:01_UTC
|
||||||
- **Git Commit**: 1831bd7
|
- **Git Commit**: 22a7b9e
|
||||||
|
|
||||||
## 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
|
||||||
|
|||||||
723
cmd/dedup.go
723
cmd/dedup.go
@@ -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,9 +108,85 @@ 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
|
||||||
|
dedupIndexDB string // Separate path for SQLite index (for NFS/CIFS support)
|
||||||
dedupCompress bool
|
dedupCompress bool
|
||||||
dedupEncrypt bool
|
dedupEncrypt bool
|
||||||
dedupKey string
|
dedupKey string
|
||||||
@@ -99,6 +194,7 @@ var (
|
|||||||
dedupDBType string
|
dedupDBType string
|
||||||
dedupDBName string
|
dedupDBName string
|
||||||
dedupDBHost 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))
|
|
||||||
|
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)
|
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 {
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
1303
grafana/dbbackup-dashboard.json
Normal file
1303
grafana/dbbackup-dashboard.json
Normal 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": ""
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -12,26 +14,66 @@ 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
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ type Manifest struct {
|
|||||||
// 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
235
internal/dedup/metrics.go
Normal 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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
// Pipe creation failed - not a corruption issue
|
||||||
|
result.Warnings = append(result.Warnings,
|
||||||
|
fmt.Sprintf("Cannot create pipe for verification: %v", pipeErr),
|
||||||
|
"Archive integrity cannot be verified but may still be valid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var stderrBuf bytes.Buffer
|
||||||
|
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.IsValid = false
|
||||||
result.IsCorrupted = true
|
result.IsCorrupted = true
|
||||||
result.Errors = append(result.Errors,
|
result.Errors = append(result.Errors,
|
||||||
fmt.Sprintf("Tar archive is invalid or corrupted: %v", err),
|
"Tar archive appears truncated or corrupted",
|
||||||
|
fmt.Sprintf("Error: %s", truncateString(stderrOutput, 200)),
|
||||||
"Run: tar -tzf "+filePath+" 2>&1 | tail -20")
|
"Run: tar -tzf "+filePath+" 2>&1 | tail -20")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse tar listing
|
// Other errors (signal killed, memory, etc.) - not necessarily corruption
|
||||||
files := strings.Split(strings.TrimSpace(string(output)), "\n")
|
// 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
|
||||||
|
|||||||
@@ -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,8 @@ import (
|
|||||||
"dbbackup/internal/logger"
|
"dbbackup/internal/logger"
|
||||||
"dbbackup/internal/progress"
|
"dbbackup/internal/progress"
|
||||||
"dbbackup/internal/security"
|
"dbbackup/internal/security"
|
||||||
|
|
||||||
|
_ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver
|
||||||
)
|
)
|
||||||
|
|
||||||
// Engine handles database restore operations
|
// Engine handles database restore operations
|
||||||
@@ -223,7 +227,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
|
||||||
@@ -249,6 +264,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
|
||||||
@@ -807,6 +929,38 @@ func (e *Engine) RestoreCluster(ctx context.Context, archivePath string) error {
|
|||||||
}
|
}
|
||||||
e.log.Info("All dump files passed validation")
|
e.log.Info("All dump files passed validation")
|
||||||
|
|
||||||
|
// 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 failedDBs []string
|
var failedDBs []string
|
||||||
totalDBs := 0
|
totalDBs := 0
|
||||||
|
|
||||||
@@ -1499,3 +1653,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(¤tValue)
|
||||||
|
if err != nil {
|
||||||
|
// Try parsing as string (some versions return string)
|
||||||
|
var currentValueStr string
|
||||||
|
err = db.QueryRowContext(ctx, "SHOW max_locks_per_transaction").Scan(¤tValueStr)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to get current max_locks_per_transaction: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Sscanf(currentValueStr, "%d", ¤tValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|||||||
435
internal/restore/preflight.go
Normal file
435
internal/restore/preflight.go
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
package restore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 int64 // Total RAM in bytes
|
||||||
|
MemAvailable int64 // Available RAM in bytes
|
||||||
|
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. Linux system checks (read-only from /proc)
|
||||||
|
e.checkLinuxSystem(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
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkLinuxSystem reads kernel limits from /proc (no auth needed)
|
||||||
|
func (e *Engine) checkLinuxSystem(result *PreflightResult) {
|
||||||
|
result.Linux.IsLinux = runtime.GOOS == "linux"
|
||||||
|
|
||||||
|
if !result.Linux.IsLinux {
|
||||||
|
e.log.Info("Not running on Linux - skipping kernel checks", "os", runtime.GOOS)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read memory info
|
||||||
|
if file, err := os.Open("/proc/meminfo"); err == nil {
|
||||||
|
defer file.Close()
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if strings.HasPrefix(line, "MemTotal:") {
|
||||||
|
parts := strings.Fields(line)
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
val, _ := strconv.ParseInt(parts[1], 10, 64)
|
||||||
|
result.Linux.MemTotal = val * 1024 // Convert KB to bytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "MemAvailable:") {
|
||||||
|
parts := strings.Fields(line)
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
val, _ := strconv.ParseInt(parts[1], 10, 64)
|
||||||
|
result.Linux.MemAvailable = val * 1024 // Convert KB to bytes
|
||||||
|
// 4GB minimum available for large restores
|
||||||
|
result.Linux.MemAvailableOK = result.Linux.MemAvailable >= 4*1024*1024*1024
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add warnings for insufficient resources
|
||||||
|
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",
|
||||||
|
formatBytesLong(result.Linux.ShmMax)))
|
||||||
|
}
|
||||||
|
if !result.Linux.ShmAllOK && result.Linux.ShmAll > 0 {
|
||||||
|
result.Warnings = append(result.Warnings,
|
||||||
|
fmt.Sprintf("Linux shmall is low: %d pages (recommend 2M+). Fix: sudo sysctl -w kernel.shmall=4194304",
|
||||||
|
result.Linux.ShmAll))
|
||||||
|
}
|
||||||
|
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)",
|
||||||
|
formatBytesLong(result.Linux.MemAvailable)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
|
||||||
|
// Linux checks
|
||||||
|
if result.Linux.IsLinux {
|
||||||
|
fmt.Println("\n Linux System:")
|
||||||
|
printCheck("shmmax", formatBytesLong(result.Linux.ShmMax), result.Linux.ShmMaxOK || result.Linux.ShmMax == 0)
|
||||||
|
printCheck("shmall", fmt.Sprintf("%d pages", result.Linux.ShmAll), result.Linux.ShmAllOK || result.Linux.ShmAll == 0)
|
||||||
|
printCheck("Available RAM", formatBytesLong(result.Linux.MemAvailable), result.Linux.MemAvailableOK || result.Linux.MemAvailable == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostgreSQL checks
|
||||||
|
fmt.Println("\n PostgreSQL:")
|
||||||
|
printCheck("Version", result.PostgreSQL.Version, true)
|
||||||
|
printCheck("max_locks_per_transaction", fmt.Sprintf("%d → %d (auto-boost)",
|
||||||
|
result.PostgreSQL.MaxLocksPerTransaction, 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", fmt.Sprintf("%d", result.Archive.TotalDatabases))
|
||||||
|
printInfo("Total BLOBs detected", fmt.Sprintf("%d", 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 = "⚠"
|
||||||
|
}
|
||||||
|
fmt.Printf(" %s %s: %d BLOBs\n", status, db, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatBytesLong is a local formatting helper for preflight display
|
||||||
|
func formatBytesLong(bytes int64) string {
|
||||||
|
if bytes == 0 {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
const unit = 1024
|
||||||
|
if bytes < unit {
|
||||||
|
return fmt.Sprintf("%d B", bytes)
|
||||||
|
}
|
||||||
|
div, exp := int64(unit), 0
|
||||||
|
for n := bytes / unit; n >= unit; n /= unit {
|
||||||
|
div *= unit
|
||||||
|
exp++
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -12,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"dbbackup/internal/config"
|
"dbbackup/internal/config"
|
||||||
"dbbackup/internal/logger"
|
"dbbackup/internal/logger"
|
||||||
|
"dbbackup/internal/restore"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OperationState represents the current operation state
|
// OperationState represents the current operation state
|
||||||
@@ -130,15 +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:
|
||||||
// Block input during operations
|
// Allow escape/cancel even during operations
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block other input during operations
|
||||||
if m.opState != OpIdle {
|
if m.opState != OpIdle {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "ctrl+c", "q", "esc":
|
|
||||||
return m.parent, nil
|
|
||||||
|
|
||||||
case "up", "k":
|
case "up", "k":
|
||||||
if m.cursor > 0 {
|
if m.cursor > 0 {
|
||||||
m.cursor--
|
m.cursor--
|
||||||
@@ -221,64 +230,64 @@ func (m BackupManagerModel) View() string {
|
|||||||
var s strings.Builder
|
var s strings.Builder
|
||||||
|
|
||||||
// Title
|
// Title
|
||||||
s.WriteString(titleStyle.Render("[DB] Backup Archive Manager"))
|
s.WriteString(TitleStyle.Render("[DB] Backup Archive Manager"))
|
||||||
s.WriteString("\n\n")
|
s.WriteString("\n\n")
|
||||||
|
|
||||||
// Operation Status Box (always visible)
|
// Status line (no box, bold+color accents)
|
||||||
s.WriteString("+--[ STATUS ]" + strings.Repeat("-", 47) + "+\n")
|
|
||||||
switch m.opState {
|
switch m.opState {
|
||||||
case OpVerifying:
|
case OpVerifying:
|
||||||
spinner := spinnerFrames[m.spinnerFrame]
|
spinner := spinnerFrames[m.spinnerFrame]
|
||||||
statusText := fmt.Sprintf(" %s Verifying: %s", spinner, m.opTarget)
|
s.WriteString(StatusActiveStyle.Render(fmt.Sprintf("%s Verifying: %s", spinner, m.opTarget)))
|
||||||
s.WriteString("|" + statusText + strings.Repeat(" ", 59-len(statusText)) + "|\n")
|
s.WriteString("\n\n")
|
||||||
case OpDeleting:
|
case OpDeleting:
|
||||||
spinner := spinnerFrames[m.spinnerFrame]
|
spinner := spinnerFrames[m.spinnerFrame]
|
||||||
statusText := fmt.Sprintf(" %s Deleting: %s", spinner, m.opTarget)
|
s.WriteString(StatusActiveStyle.Render(fmt.Sprintf("%s Deleting: %s", spinner, m.opTarget)))
|
||||||
s.WriteString("|" + statusText + strings.Repeat(" ", 59-len(statusText)) + "|\n")
|
s.WriteString("\n\n")
|
||||||
default:
|
default:
|
||||||
if m.loading {
|
if m.loading {
|
||||||
spinner := spinnerFrames[m.spinnerFrame]
|
spinner := spinnerFrames[m.spinnerFrame]
|
||||||
statusText := fmt.Sprintf(" %s Loading archives...", spinner)
|
s.WriteString(StatusActiveStyle.Render(fmt.Sprintf("%s Loading archives...", spinner)))
|
||||||
s.WriteString("|" + statusText + strings.Repeat(" ", 59-len(statusText)) + "|\n")
|
s.WriteString("\n\n")
|
||||||
} else if m.message != "" {
|
} else if m.message != "" {
|
||||||
msgText := " " + m.message
|
// Color based on message content
|
||||||
if len(msgText) > 58 {
|
if strings.HasPrefix(m.message, "[+]") || strings.HasPrefix(m.message, "Valid") {
|
||||||
msgText = msgText[:55] + "..."
|
s.WriteString(StatusSuccessStyle.Render(m.message))
|
||||||
}
|
} else if strings.HasPrefix(m.message, "[-]") || strings.HasPrefix(m.message, "Error") {
|
||||||
s.WriteString("|" + msgText + strings.Repeat(" ", 59-len(msgText)) + "|\n")
|
s.WriteString(StatusErrorStyle.Render(m.message))
|
||||||
} else {
|
} else {
|
||||||
statusText := " Ready"
|
s.WriteString(StatusActiveStyle.Render(m.message))
|
||||||
s.WriteString("|" + statusText + strings.Repeat(" ", 59-len(statusText)) + "|\n")
|
|
||||||
}
|
}
|
||||||
|
s.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
// No "Ready" message when idle - cleaner UI
|
||||||
}
|
}
|
||||||
s.WriteString("+" + strings.Repeat("-", 60) + "+\n\n")
|
|
||||||
|
|
||||||
if m.loading {
|
if m.loading {
|
||||||
return s.String()
|
return s.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.err != nil {
|
if m.err != nil {
|
||||||
s.WriteString(errorStyle.Render(fmt.Sprintf("[FAIL] 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 with better alignment
|
// Column headers with better alignment
|
||||||
s.WriteString(archiveHeaderStyle.Render(fmt.Sprintf(" %-32s %-22s %10s %-16s",
|
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("-", 90))
|
s.WriteString(strings.Repeat("-", 90))
|
||||||
@@ -297,18 +306,18 @@ 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 - consistent 4-char width
|
// 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 = " [!]"
|
||||||
}
|
}
|
||||||
@@ -331,100 +340,79 @@ func (m BackupManagerModel) View() string {
|
|||||||
// Footer
|
// Footer
|
||||||
s.WriteString("\n")
|
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\n")
|
s.WriteString("\n\n")
|
||||||
|
|
||||||
// Grouped keyboard shortcuts for better readability
|
// Grouped keyboard shortcuts
|
||||||
s.WriteString("+--[ SHORTCUTS ]" + strings.Repeat("-", 44) + "+\n")
|
s.WriteString(ShortcutStyle.Render("SHORTCUTS: Up/Down=Move | r=Restore | v=Verify | d=Delete | i=Info | R=Refresh | Esc=Back | q=Quit"))
|
||||||
s.WriteString("| NAVIGATE ACTIONS OTHER |\n")
|
|
||||||
s.WriteString("| Up/Down: Move r: Restore R: Refresh |\n")
|
|
||||||
s.WriteString("| v: Verify Esc: Back |\n")
|
|
||||||
s.WriteString("| d: Delete q: Quit |\n")
|
|
||||||
s.WriteString("| i: Info |\n")
|
|
||||||
s.WriteString("+" + strings.Repeat("-", 60) + "+")
|
|
||||||
|
|
||||||
return s.String()
|
return s.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// verifyArchiveCmd runs actual archive verification
|
// 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 {
|
func verifyArchiveCmd(archive ArchiveInfo) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
// Determine verification method based on format
|
var issues []string
|
||||||
var valid bool
|
|
||||||
var details string
|
|
||||||
var err error
|
|
||||||
|
|
||||||
switch {
|
// 1. Run the same archive integrity check as restore
|
||||||
case strings.HasSuffix(archive.Path, ".tar.gz") || strings.HasSuffix(archive.Path, ".tgz"):
|
safety := restore.NewSafety(nil, nil) // Doesn't need config/log for validation
|
||||||
// Verify tar.gz archive
|
if err := safety.ValidateArchive(archive.Path); err != nil {
|
||||||
cmd := exec.Command("tar", "-tzf", archive.Path)
|
return verifyResultMsg{
|
||||||
output, cmdErr := cmd.CombinedOutput()
|
archive: archive.Name,
|
||||||
if cmdErr != nil {
|
valid: false,
|
||||||
return verifyResultMsg{archive: archive.Name, valid: false, err: nil, details: "Archive corrupt or incomplete"}
|
err: nil,
|
||||||
|
details: fmt.Sprintf("Archive integrity: %v", err),
|
||||||
}
|
}
|
||||||
lines := strings.Split(string(output), "\n")
|
|
||||||
fileCount := 0
|
|
||||||
for _, l := range lines {
|
|
||||||
if l != "" {
|
|
||||||
fileCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
valid = true
|
|
||||||
details = fmt.Sprintf("%d files in archive", fileCount)
|
|
||||||
|
|
||||||
case strings.HasSuffix(archive.Path, ".dump") || strings.HasSuffix(archive.Path, ".sql"):
|
|
||||||
// Verify PostgreSQL dump with pg_restore --list
|
|
||||||
cmd := exec.Command("pg_restore", "--list", archive.Path)
|
|
||||||
output, cmdErr := cmd.CombinedOutput()
|
|
||||||
if cmdErr != nil {
|
|
||||||
// Try as plain SQL
|
|
||||||
if strings.HasSuffix(archive.Path, ".sql") {
|
|
||||||
// Just check file is readable and has content
|
|
||||||
fi, statErr := os.Stat(archive.Path)
|
|
||||||
if statErr == nil && fi.Size() > 0 {
|
|
||||||
valid = true
|
|
||||||
details = "Plain SQL file readable"
|
|
||||||
} else {
|
|
||||||
return verifyResultMsg{archive: archive.Name, valid: false, err: nil, details: "File empty or unreadable"}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return verifyResultMsg{archive: archive.Name, valid: false, err: nil, details: "pg_restore cannot read dump"}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
lines := strings.Split(string(output), "\n")
|
|
||||||
objectCount := 0
|
|
||||||
for _, l := range lines {
|
|
||||||
if l != "" && !strings.HasPrefix(l, ";") {
|
|
||||||
objectCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
valid = true
|
|
||||||
details = fmt.Sprintf("%d objects in dump", objectCount)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case strings.HasSuffix(archive.Path, ".sql.gz"):
|
// 2. Run the same deep diagnosis as restore
|
||||||
// Verify gzipped SQL
|
diagnoser := restore.NewDiagnoser(nil, false)
|
||||||
cmd := exec.Command("gzip", "-t", archive.Path)
|
diagResult, diagErr := diagnoser.DiagnoseFile(archive.Path)
|
||||||
if cmdErr := cmd.Run(); cmdErr != nil {
|
if diagErr != nil {
|
||||||
return verifyResultMsg{archive: archive.Name, valid: false, err: nil, details: "Gzip archive corrupt"}
|
return verifyResultMsg{
|
||||||
|
archive: archive.Name,
|
||||||
|
valid: false,
|
||||||
|
err: diagErr,
|
||||||
|
details: "Cannot diagnose archive",
|
||||||
}
|
}
|
||||||
valid = true
|
|
||||||
details = "Gzip integrity OK"
|
|
||||||
|
|
||||||
default:
|
|
||||||
// Unknown format - just check file exists and has size
|
|
||||||
fi, statErr := os.Stat(archive.Path)
|
|
||||||
if statErr != nil {
|
|
||||||
return verifyResultMsg{archive: archive.Name, valid: false, err: statErr, details: "Cannot access file"}
|
|
||||||
}
|
|
||||||
if fi.Size() == 0 {
|
|
||||||
return verifyResultMsg{archive: archive.Name, valid: false, err: nil, details: "File is empty"}
|
|
||||||
}
|
|
||||||
valid = true
|
|
||||||
details = "File exists and has content"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return verifyResultMsg{archive: archive.Name, valid: valid, err: err, details: details}
|
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}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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{}
|
||||||
|
|||||||
@@ -146,13 +146,12 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|||||||
133
internal/tui/styles.go
Normal file
133
internal/tui/styles.go
Normal 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
|
||||||
|
)
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# COMPLETE emoji/Unicode removal - Replace ALL non-ASCII with ASCII equivalents
|
|
||||||
# Date: January 8, 2026
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
echo "[INFO] Starting COMPLETE Unicode->ASCII replacement..."
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Create backup
|
|
||||||
BACKUP_DIR="backup_unicode_removal_$(date +%Y%m%d_%H%M%S)"
|
|
||||||
mkdir -p "$BACKUP_DIR"
|
|
||||||
echo "[INFO] Creating backup in $BACKUP_DIR..."
|
|
||||||
find . -name "*.go" -type f -not -path "*/vendor/*" -not -path "*/.git/*" -exec bash -c 'mkdir -p "$1/$(dirname "$2")" && cp "$2" "$1/$2"' -- "$BACKUP_DIR" {} \;
|
|
||||||
echo "[OK] Backup created"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Find all affected files
|
|
||||||
echo "[SEARCH] Finding files with Unicode..."
|
|
||||||
FILES=$(find . -name "*.go" -type f -not -path "*/vendor/*" -not -path "*/.git/*")
|
|
||||||
|
|
||||||
PROCESSED=0
|
|
||||||
TOTAL=$(echo "$FILES" | wc -l)
|
|
||||||
|
|
||||||
for file in $FILES; do
|
|
||||||
PROCESSED=$((PROCESSED + 1))
|
|
||||||
|
|
||||||
if ! grep -qP '[\x{80}-\x{FFFF}]' "$file" 2>/dev/null; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[$PROCESSED/$TOTAL] Processing: $file"
|
|
||||||
|
|
||||||
# Create temp file for atomic replacements
|
|
||||||
TMPFILE="${file}.tmp"
|
|
||||||
cp "$file" "$TMPFILE"
|
|
||||||
|
|
||||||
# Box drawing / decorative (used in TUI borders)
|
|
||||||
sed -i 's/─/-/g' "$TMPFILE"
|
|
||||||
sed -i 's/━/-/g' "$TMPFILE"
|
|
||||||
sed -i 's/│/|/g' "$TMPFILE"
|
|
||||||
sed -i 's/║/|/g' "$TMPFILE"
|
|
||||||
sed -i 's/├/+/g' "$TMPFILE"
|
|
||||||
sed -i 's/└/+/g' "$TMPFILE"
|
|
||||||
sed -i 's/╔/+/g' "$TMPFILE"
|
|
||||||
sed -i 's/╗/+/g' "$TMPFILE"
|
|
||||||
sed -i 's/╚/+/g' "$TMPFILE"
|
|
||||||
sed -i 's/╝/+/g' "$TMPFILE"
|
|
||||||
sed -i 's/╠/+/g' "$TMPFILE"
|
|
||||||
sed -i 's/╣/+/g' "$TMPFILE"
|
|
||||||
sed -i 's/═/=/g' "$TMPFILE"
|
|
||||||
|
|
||||||
# Status symbols
|
|
||||||
sed -i 's/✅/[OK]/g' "$TMPFILE"
|
|
||||||
sed -i 's/❌/[FAIL]/g' "$TMPFILE"
|
|
||||||
sed -i 's/✓/[+]/g' "$TMPFILE"
|
|
||||||
sed -i 's/✗/[-]/g' "$TMPFILE"
|
|
||||||
sed -i 's/⚠️/[WARN]/g' "$TMPFILE"
|
|
||||||
sed -i 's/⚠/[!]/g' "$TMPFILE"
|
|
||||||
sed -i 's/❓/[?]/g' "$TMPFILE"
|
|
||||||
|
|
||||||
# Arrows
|
|
||||||
sed -i 's/←/</g' "$TMPFILE"
|
|
||||||
sed -i 's/→/>/g' "$TMPFILE"
|
|
||||||
sed -i 's/↑/^/g' "$TMPFILE"
|
|
||||||
sed -i 's/↓/v/g' "$TMPFILE"
|
|
||||||
sed -i 's/▲/^/g' "$TMPFILE"
|
|
||||||
sed -i 's/▼/v/g' "$TMPFILE"
|
|
||||||
sed -i 's/▶/>/g' "$TMPFILE"
|
|
||||||
|
|
||||||
# Shapes
|
|
||||||
sed -i 's/●/*\*/g' "$TMPFILE"
|
|
||||||
sed -i 's/○/o/g' "$TMPFILE"
|
|
||||||
sed -i 's/⚪/o/g' "$TMPFILE"
|
|
||||||
sed -i 's/•/-/g' "$TMPFILE"
|
|
||||||
sed -i 's/█/#/g' "$TMPFILE"
|
|
||||||
sed -i 's/▎/|/g' "$TMPFILE"
|
|
||||||
sed -i 's/░/./g' "$TMPFILE"
|
|
||||||
sed -i 's/➖/-/g' "$TMPFILE"
|
|
||||||
|
|
||||||
# Emojis - Info/Data
|
|
||||||
sed -i 's/📊/[INFO]/g' "$TMPFILE"
|
|
||||||
sed -i 's/📋/[LIST]/g' "$TMPFILE"
|
|
||||||
sed -i 's/📁/[DIR]/g' "$TMPFILE"
|
|
||||||
sed -i 's/📦/[PKG]/g' "$TMPFILE"
|
|
||||||
sed -i 's/📜/[LOG]/g' "$TMPFILE"
|
|
||||||
sed -i 's/📭/[EMPTY]/g' "$TMPFILE"
|
|
||||||
sed -i 's/📝/[NOTE]/g' "$TMPFILE"
|
|
||||||
sed -i 's/💡/[TIP]/g' "$TMPFILE"
|
|
||||||
|
|
||||||
# Emojis - Actions/Objects
|
|
||||||
sed -i 's/🎯/[TARGET]/g' "$TMPFILE"
|
|
||||||
sed -i 's/🛡️/[SECURE]/g' "$TMPFILE"
|
|
||||||
sed -i 's/🔒/[LOCK]/g' "$TMPFILE"
|
|
||||||
sed -i 's/🔓/[UNLOCK]/g' "$TMPFILE"
|
|
||||||
sed -i 's/🔍/[SEARCH]/g' "$TMPFILE"
|
|
||||||
sed -i 's/🔀/[SWITCH]/g' "$TMPFILE"
|
|
||||||
sed -i 's/🔥/[FIRE]/g' "$TMPFILE"
|
|
||||||
sed -i 's/💾/[SAVE]/g' "$TMPFILE"
|
|
||||||
sed -i 's/🗄️/[DB]/g' "$TMPFILE"
|
|
||||||
sed -i 's/🗄/[DB]/g' "$TMPFILE"
|
|
||||||
|
|
||||||
# Emojis - Time/Status
|
|
||||||
sed -i 's/⏱️/[TIME]/g' "$TMPFILE"
|
|
||||||
sed -i 's/⏱/[TIME]/g' "$TMPFILE"
|
|
||||||
sed -i 's/⏳/[WAIT]/g' "$TMPFILE"
|
|
||||||
sed -i 's/⏪/[REW]/g' "$TMPFILE"
|
|
||||||
sed -i 's/⏹️/[STOP]/g' "$TMPFILE"
|
|
||||||
sed -i 's/⏹/[STOP]/g' "$TMPFILE"
|
|
||||||
sed -i 's/⟳/[SYNC]/g' "$TMPFILE"
|
|
||||||
|
|
||||||
# Emojis - Cloud
|
|
||||||
sed -i 's/☁️/[CLOUD]/g' "$TMPFILE"
|
|
||||||
sed -i 's/☁/[CLOUD]/g' "$TMPFILE"
|
|
||||||
sed -i 's/📤/[UPLOAD]/g' "$TMPFILE"
|
|
||||||
sed -i 's/📥/[DOWNLOAD]/g' "$TMPFILE"
|
|
||||||
sed -i 's/🗑️/[DELETE]/g' "$TMPFILE"
|
|
||||||
|
|
||||||
# Emojis - Misc
|
|
||||||
sed -i 's/📈/[UP]/g' "$TMPFILE"
|
|
||||||
sed -i 's/📉/[DOWN]/g' "$TMPFILE"
|
|
||||||
sed -i 's/⌨️/[KEY]/g' "$TMPFILE"
|
|
||||||
sed -i 's/⌨/[KEY]/g' "$TMPFILE"
|
|
||||||
sed -i 's/⚙️/[CONFIG]/g' "$TMPFILE"
|
|
||||||
sed -i 's/⚙/[CONFIG]/g' "$TMPFILE"
|
|
||||||
sed -i 's/✏️/[EDIT]/g' "$TMPFILE"
|
|
||||||
sed -i 's/✏/[EDIT]/g' "$TMPFILE"
|
|
||||||
sed -i 's/⚡/[FAST]/g' "$TMPFILE"
|
|
||||||
|
|
||||||
# Spinner characters (braille patterns for loading animations)
|
|
||||||
sed -i 's/⠋/|/g' "$TMPFILE"
|
|
||||||
sed -i 's/⠙/\//g' "$TMPFILE"
|
|
||||||
sed -i 's/⠹/-/g' "$TMPFILE"
|
|
||||||
sed -i 's/⠸/\\/g' "$TMPFILE"
|
|
||||||
sed -i 's/⠼/|/g' "$TMPFILE"
|
|
||||||
sed -i 's/⠴/\//g' "$TMPFILE"
|
|
||||||
sed -i 's/⠦/-/g' "$TMPFILE"
|
|
||||||
sed -i 's/⠧/\\/g' "$TMPFILE"
|
|
||||||
sed -i 's/⠇/|/g' "$TMPFILE"
|
|
||||||
sed -i 's/⠏/\//g' "$TMPFILE"
|
|
||||||
|
|
||||||
# Move temp file over original
|
|
||||||
mv "$TMPFILE" "$file"
|
|
||||||
done
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "[OK] Replacement complete!"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Verify
|
|
||||||
REMAINING=$(grep -roP '[\x{80}-\x{FFFF}]' --include="*.go" . 2>/dev/null | wc -l || echo "0")
|
|
||||||
|
|
||||||
echo "[INFO] Unicode characters remaining: $REMAINING"
|
|
||||||
if [ "$REMAINING" -gt 0 ]; then
|
|
||||||
echo "[WARN] Some Unicode still exists (might be in comments or safe locations)"
|
|
||||||
echo "[INFO] Unique remaining characters:"
|
|
||||||
grep -roP '[\x{80}-\x{FFFF}]' --include="*.go" . 2>/dev/null | grep -oP '[\x{80}-\x{FFFF}]' | sort -u | head -20
|
|
||||||
else
|
|
||||||
echo "[OK] All Unicode characters replaced with ASCII!"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "[INFO] Backup: $BACKUP_DIR"
|
|
||||||
echo "[INFO] To restore: cp -r $BACKUP_DIR/* ."
|
|
||||||
echo ""
|
|
||||||
echo "[INFO] Next steps:"
|
|
||||||
echo " 1. go build"
|
|
||||||
echo " 2. go test ./..."
|
|
||||||
echo " 3. Test TUI: ./dbbackup"
|
|
||||||
echo " 4. Commit: git add . && git commit -m 'v3.42.11: Replace all Unicode with ASCII'"
|
|
||||||
echo ""
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Remove ALL emojis/unicode symbols from Go code and replace with ASCII
|
|
||||||
# Date: January 8, 2026
|
|
||||||
# Issue: 638 lines contain Unicode emojis causing display issues
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
echo "[INFO] Starting emoji removal process..."
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Find all Go files with emojis (expanded emoji list)
|
|
||||||
echo "[SEARCH] Finding affected files..."
|
|
||||||
FILES=$(find . -name "*.go" -type f -not -path "*/vendor/*" -not -path "*/.git/*" | xargs grep -l -P '[\x{1F000}-\x{1FFFF}]|[\x{2300}-\x{27BF}]|[\x{2600}-\x{26FF}]' 2>/dev/null || true)
|
|
||||||
|
|
||||||
if [ -z "$FILES" ]; then
|
|
||||||
echo "[WARN] No files with emojis found!"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
FILECOUNT=$(echo "$FILES" | wc -l)
|
|
||||||
echo "[INFO] Found $FILECOUNT files containing emojis"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Count total emojis before
|
|
||||||
BEFORE=$(find . -name "*.go" -type f -not -path "*/vendor/*" | xargs grep -oP '[\x{1F000}-\x{1FFFF}]|[\x{2300}-\x{27BF}]|[\x{2600}-\x{26FF}]' 2>/dev/null | wc -l || echo "0")
|
|
||||||
echo "[INFO] Total emojis found: $BEFORE"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Create backup
|
|
||||||
BACKUP_DIR="backup_before_emoji_removal_$(date +%Y%m%d_%H%M%S)"
|
|
||||||
mkdir -p "$BACKUP_DIR"
|
|
||||||
echo "[INFO] Creating backup in $BACKUP_DIR..."
|
|
||||||
for file in $FILES; do
|
|
||||||
mkdir -p "$BACKUP_DIR/$(dirname "$file")"
|
|
||||||
cp "$file" "$BACKUP_DIR/$file"
|
|
||||||
done
|
|
||||||
echo "[OK] Backup created"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Process each file
|
|
||||||
echo "[INFO] Replacing emojis with ASCII equivalents..."
|
|
||||||
PROCESSED=0
|
|
||||||
|
|
||||||
for file in $FILES; do
|
|
||||||
PROCESSED=$((PROCESSED + 1))
|
|
||||||
echo "[$PROCESSED/$FILECOUNT] Processing: $file"
|
|
||||||
|
|
||||||
# Create temp file
|
|
||||||
TMPFILE="${file}.tmp"
|
|
||||||
|
|
||||||
# Status indicators
|
|
||||||
sed 's/✅/[OK]/g' "$file" > "$TMPFILE" && mv "$TMPFILE" "$file"
|
|
||||||
sed 's/❌/[FAIL]/g' "$file" > "$TMPFILE" && mv "$TMPFILE" "$file"
|
|
||||||
sed 's/✓/[+]/g' "$file" > "$TMPFILE" && mv "$TMPFILE" "$file"
|
|
||||||
sed 's/✗/[-]/g' "$file" > "$TMPFILE" && mv "$TMPFILE" "$file"
|
|
||||||
|
|
||||||
# Warning symbols (⚠️ has variant selector, handle both)
|
|
||||||
sed 's/⚠️/[WARN]/g' "$file" > "$TMPFILE" && mv "$TMPFILE" "$file"
|
|
||||||
sed 's/⚠/[!]/g' "$file" > "$TMPFILE" && mv "$TMPFILE" "$file"
|
|
||||||
|
|
||||||
# Info/Data symbols
|
|
||||||
sed 's/📊/[INFO]/g' "$file" > "$TMPFILE" && mv "$TMPFILE" "$file"
|
|
||||||
sed 's/📋/[LIST]/g' "$file" > "$TMPFILE" && mv "$TMPFILE" "$file"
|
|
||||||
sed 's/📁/[DIR]/g' "$file" > "$TMPFILE" && mv "$TMPFILE" "$file"
|
|
||||||
sed 's/📦/[PKG]/g' "$file" > "$TMPFILE" && mv "$TMPFILE" "$file"
|
|
||||||
|
|
||||||
# Target/Security
|
|
||||||
sed 's/🎯/[TARGET]/g' "$file" > "$TMPFILE" && mv "$TMPFILE" "$file"
|
|
||||||
sed 's/🛡️/[SECURE]/g' "$file" > "$TMPFILE" && mv "$TMPFILE" "$file"
|
|
||||||
sed 's/🔒/[LOCK]/g' "$file" > "$TMPFILE" && mv "$TMPFILE" "$file"
|
|
||||||
sed 's/🔓/[UNLOCK]/g' "$file" > "$TMPFILE" && mv "$TMPFILE" "$file"
|
|
||||||
|
|
||||||
# Actions
|
|
||||||
sed 's/🔍/[SEARCH]/g' "$file" > "$TMPFILE" && mv "$TMPFILE" "$file"
|
|
||||||
sed 's/⏱️/[TIME]/g' "$file" > "$TMPFILE" && mv "$TMPFILE" "$file"
|
|
||||||
|
|
||||||
# Cloud operations (☁️ has variant selector, handle both)
|
|
||||||
sed 's/☁️/[CLOUD]/g' "$file" > "$TMPFILE" && mv "$TMPFILE" "$file"
|
|
||||||
sed 's/☁/[CLOUD]/g' "$file" > "$TMPFILE" && mv "$TMPFILE" "$file"
|
|
||||||
sed 's/📤/[UPLOAD]/g' "$file" > "$TMPFILE" && mv "$TMPFILE" "$file"
|
|
||||||
sed 's/📥/[DOWNLOAD]/g' "$file" > "$TMPFILE" && mv "$TMPFILE" "$file"
|
|
||||||
sed 's/🗑️/[DELETE]/g' "$file" > "$TMPFILE" && mv "$TMPFILE" "$file"
|
|
||||||
|
|
||||||
# Other
|
|
||||||
sed 's/📈/[UP]/g' "$file" > "$TMPFILE" && mv "$TMPFILE" "$file"
|
|
||||||
sed 's/📉/[DOWN]/g' "$file" > "$TMPFILE" && mv "$TMPFILE" "$file"
|
|
||||||
|
|
||||||
# Additional emojis found
|
|
||||||
sed 's/⌨️/[KEY]/g' "$file" > "$TMPFILE" && mv "$TMPFILE" "$file"
|
|
||||||
sed 's/⌨/[KEY]/g' "$file" > "$TMPFILE" && mv "$TMPFILE" "$file"
|
|
||||||
sed 's/🗄️/[DB]/g' "$file" > "$TMPFILE" && mv "$TMPFILE" "$file"
|
|
||||||
sed 's/🗄/[DB]/g' "$file" > "$TMPFILE" && mv "$TMPFILE" "$file"
|
|
||||||
sed 's/⚙️/[CONFIG]/g' "$file" > "$TMPFILE" && mv "$TMPFILE" "$file"
|
|
||||||
sed 's/⚙/[CONFIG]/g' "$file" > "$TMPFILE" && mv "$TMPFILE" "$file"
|
|
||||||
sed 's/✏️/[EDIT]/g' "$file" > "$TMPFILE" && mv "$TMPFILE" "$file"
|
|
||||||
sed 's/✏/[EDIT]/g' "$file" > "$TMPFILE" && mv "$TMPFILE" "$file"
|
|
||||||
done
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "[OK] Replacement complete!"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Count remaining emojis
|
|
||||||
AFTER=$(find . -name "*.go" -type f -not -path "*/vendor/*" | xargs grep -oP '[\x{1F000}-\x{1FFFF}]|[\x{2300}-\x{27BF}]|[\x{2600}-\x{26FF}]' 2>/dev/null | wc -l || echo "0")
|
|
||||||
|
|
||||||
echo "[INFO] Emojis before: $BEFORE"
|
|
||||||
echo "[INFO] Emojis after: $AFTER"
|
|
||||||
echo "[INFO] Emojis removed: $((BEFORE - AFTER))"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
if [ "$AFTER" -gt 0 ]; then
|
|
||||||
echo "[WARN] $AFTER emojis still remaining!"
|
|
||||||
echo "[INFO] Listing remaining emojis:"
|
|
||||||
find . -name "*.go" -type f -not -path "*/vendor/*" | xargs grep -nP '[\x{1F000}-\x{1FFFF}]|[\x{2300}-\x{27BF}]|[\x{2600}-\x{26FF}]' 2>/dev/null | head -20
|
|
||||||
else
|
|
||||||
echo "[OK] All emojis successfully removed!"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "[INFO] Backup location: $BACKUP_DIR"
|
|
||||||
echo "[INFO] To restore: cp -r $BACKUP_DIR/* ."
|
|
||||||
echo ""
|
|
||||||
echo "[INFO] Next steps:"
|
|
||||||
echo " 1. Build: go build"
|
|
||||||
echo " 2. Test: go test ./..."
|
|
||||||
echo " 3. Manual testing: ./dbbackup status"
|
|
||||||
echo " 4. If OK, commit: git add . && git commit -m 'Replace emojis with ASCII'"
|
|
||||||
echo " 5. If broken, restore: cp -r $BACKUP_DIR/* ."
|
|
||||||
echo ""
|
|
||||||
echo "[OK] Emoji removal script completed!"
|
|
||||||
Reference in New Issue
Block a user