## BaraDB Backup & Restore — tar.gz snapshots with retention, verification & CLI
##
## Commands:
## backup Create a snapshot of the data directory
## restore Restore data directory from a snapshot
## list List available snapshots with size & timestamp
## verify Check archive integrity without extracting
## cleanup Remove old snapshots keeping N most recent
## history Show restore operation log
## help Show detailed usage information
##
## Options:
## --data-dir, -d
Data directory to backup (default: data/server)
## --output, -o Output archive path for backup
## --input, -i Input archive path for restore/verify
## --keep, -k Number of snapshots to retain (default: 5)
## --exclude, -e Exclude pattern (can be used multiple times)
## --level, -l <0-9> Compression level for gzip (default: 6)
## --dry-run Show what would be done without doing it
## --force, -f Skip confirmation prompts
## --verbose, -v Enable verbose output
import std/os
import std/osproc
import std/strutils
import std/times
import std/algorithm
import std/parseopt
type
Backup* = object
path*: string
timestamp*: int64
size*: int64
compressed*: bool
const
DEFAULT_DATA_DIR = "data/server"
DEFAULT_KEEP_COUNT = 5
DEFAULT_COMPRESSION = 6
HISTORY_FILE = "backup_history.log"
HELP_TEXT = """
BaraDB Backup Manager — Archive and restore your database safely
================================================================
USAGE:
backup [options]
COMMANDS:
backup Create a compressed tar.gz snapshot of the data directory.
By default archives are named backup_.tar.gz.
restore Replace the current data directory with contents from a snapshot.
WARNING: This DESTROYS existing data. Use with care.
Automatically verifies archive integrity before extracting.
list Show all snapshots found next to the data directory.
Displays size, timestamp and compression ratio if known.
verify Test archive integrity without extracting files.
Detects corrupted or incomplete snapshots early.
cleanup Delete old snapshots, keeping only the N most recent ones.
Default retention is 5 snapshots.
history Show log of all restore operations performed on this system.
help Show this help message.
OPTIONS:
-d, --data-dir Path to data directory (default: data/server)
-o, --output Destination path for new backup archive
-i, --input Source archive for restore or verify
-k, --keep Retention count for cleanup (default: 5)
-e, --exclude Exclude files matching pattern (repeatable)
-l, --level <0-9> Gzip compression level (default: 6, max: 9)
--dry-run Show what restore would do without changing anything
-f, --force Skip confirmation prompts (use with caution!)
-v, --verbose Print detailed progress information
EXAMPLES:
# Quick backup with default settings
backup backup
# Backup with maximum compression and custom name
backup backup --output=prod_backup_$(date +%F).tar.gz --level=9
# Restore from a specific snapshot
backup restore --input=backup_1715011200.tar.gz
# Dry-run restore — preview what will happen
backup restore --input=backup.tar.gz --dry-run
# Force restore without confirmation (automation/scripts)
backup restore --input=backup.tar.gz --force
# List all snapshots
backup list
# Verify archive before restoring
backup verify --input=backup_1715011200.tar.gz
# Keep only last 3 snapshots
backup cleanup --keep=3
# Exclude WAL files from backup
backup backup --exclude="*.log" --exclude="wal/*"
EXIT CODES:
0 Success
1 General error (invalid arguments, missing files, tar failure, etc.)
"""
proc formatBytes*(bytes: int64): string =
## Human readable byte size
const units = ["B", "KB", "MB", "GB", "TB"]
if bytes < 0: return "0 B"
var size = float64(bytes)
var unitIndex = 0
while size >= 1024.0 and unitIndex < units.high:
size /= 1024.0
unitIndex += 1
result = formatFloat(size, ffDecimal, precision = 2) & " " & units[unitIndex]
proc formatTimestamp*(ts: int64): string =
## Format unix timestamp to human readable string
try:
let dt = fromUnix(ts)
result = format(dt, "yyyy-MM-dd HH:mm:ss")
except:
result = $ts
proc parseBackupFilename*(filename: string): int64 =
## Try to extract timestamp from backup_1234567890.tar.gz
try:
let name = extractFilename(filename)
if name.startsWith("backup_"):
let tsStr = name[7..^8] # skip "backup_" and ".tar.gz"
result = parseBiggestInt(tsStr)
else:
result = 0
except:
result = 0
proc getArchiveSize*(input: string): int64 =
## Return uncompressed size estimate from tar archive
let cmd = "tar -tzf " & quoteShell(input) & " | wc -l"
let (outStr, exitCode) = execCmdEx(cmd)
if exitCode == 0:
try:
result = parseBiggestInt(strip(outStr))
except:
result = 0
else:
result = 0
proc getFreeSpace*(path: string): int64 =
## Return free disk space in bytes for the filesystem containing path
let cmd = "df -B1 " & quoteShell(path) & " | tail -1 | awk '{print $4}'"
let (outStr, exitCode) = execCmdEx(cmd)
if exitCode == 0:
try:
result = parseBiggestInt(strip(outStr))
except:
result = -1
else:
result = -1
proc logRestore*(archive: string, dataDir: string, success: bool, dryRun: bool = false) =
## Append restore operation to history log
let logPath = getCurrentDir() / HISTORY_FILE
let status = if dryRun: "DRY-RUN" elif success: "SUCCESS" else: "FAILED"
let entry = "[" & format(getTime(), "yyyy-MM-dd HH:mm:ss") & "] " &
status & " restore from " & absolutePath(archive) &
" to " & absolutePath(dataDir) & "\n"
try:
let f = open(logPath, fmAppend)
f.write(entry)
f.close()
except:
discard # Silently fail if log cannot be written
proc readHistory*(): seq[string] =
## Read restore history log
result = @[]
let logPath = getCurrentDir() / HISTORY_FILE
if not fileExists(logPath):
return
try:
let content = readFile(logPath)
for line in splitLines(content):
if line.len > 0:
result.add(line)
except:
discard
proc backupDataDir*(dataDir: string, output: string, excludes: seq[string] = @[], compression: int = DEFAULT_COMPRESSION, verbose: bool = false): bool =
## Create a tar.gz backup of the data directory
if not dirExists(dataDir):
echo "ERROR: Data directory not found: ", dataDir
return false
if fileExists(output):
echo "WARNING: Overwriting existing file: ", output
let parent = parentDir(dataDir)
let name = lastPathPart(dataDir)
var excludeArgs = ""
for pattern in excludes:
excludeArgs.add(" --exclude=" & quoteShell(pattern))
let tarCmd = "tar -cf -" & excludeArgs & " -C " & quoteShell(parent) & " " & quoteShell(name)
let gzipCmd = "gzip -" & $compression
let cmd = tarCmd & " | " & gzipCmd & " > " & quoteShell(output)
if verbose:
echo "Running: ", cmd
echo "Source: ", dataDir
echo "Target: ", output
let (outputStr, exitCode) = execCmdEx("bash -c " & quoteShell(cmd))
if exitCode != 0:
echo "ERROR: tar command failed with exit code ", exitCode
if outputStr.len > 0:
echo outputStr
return false
let size = getFileSize(output)
echo "Backup created successfully:"
echo " File: ", output
echo " Size: ", formatBytes(size)
echo " Source: ", dataDir
return true
proc restoreDataDir*(input: string, dataDir: string, verbose: bool = false, dryRun: bool = false): bool =
## Restore from a tar.gz backup.
## When dryRun is true, only prints what would be done.
if not fileExists(input):
echo "ERROR: Backup file not found: ", input
return false
let archiveSize = getFileSize(input)
let freeSpace = getFreeSpace(parentDir(dataDir))
let oldBackupPath = dataDir & ".old_" & $getTime().toUnix()
if dryRun:
echo "DRY-RUN: The following actions would be performed:"
echo " 1. Verify archive integrity: ", input
echo " 2. Move existing data to: ", oldBackupPath
echo " 3. Extract archive to: ", dataDir
echo " Archive size: ", formatBytes(archiveSize)
if freeSpace >= 0:
echo " Free space: ", formatBytes(freeSpace)
else:
echo " Free space: unable to determine"
return true
# Check free space
if freeSpace >= 0 and freeSpace < archiveSize * 2:
echo "WARNING: Free space (", formatBytes(freeSpace), ") may be insufficient."
echo " Archive is ", formatBytes(archiveSize), " — at least 2x is recommended."
if dirExists(dataDir):
if verbose:
echo "Moving existing data to: ", oldBackupPath
moveDir(dataDir, oldBackupPath)
createDir(dataDir)
let cmd = "tar -xzf " & quoteShell(input) & " -C " & quoteShell(dataDir)
if verbose:
echo "Running: ", cmd
let (outputStr, exitCode) = execCmdEx(cmd)
if exitCode != 0:
echo "ERROR: tar extraction failed with exit code ", exitCode
if outputStr.len > 0:
echo outputStr
# Attempt rollback
if dirExists(oldBackupPath):
echo "Attempting rollback..."
removeDir(dataDir)
moveDir(oldBackupPath, dataDir)
echo "Rollback complete. Data restored to previous state."
return false
echo "Restored successfully from: ", input
echo " Target: ", dataDir
if dirExists(oldBackupPath):
echo " Old data preserved at: ", oldBackupPath
return true
proc verifyArchive*(input: string, verbose: bool = false): bool =
## Verify tar.gz archive integrity without extracting
if not fileExists(input):
echo "ERROR: Archive not found: ", input
return false
let cmd = "tar -tzf " & quoteShell(input) & " > /dev/null"
if verbose:
echo "Verifying archive: ", input
let (_, exitCode) = execCmdEx(cmd)
if exitCode == 0:
let size = getFileSize(input)
echo "Archive is valid: ", input, " (", formatBytes(size), ")"
return true
else:
echo "ERROR: Archive is corrupted or unreadable: ", input
return false
proc listBackups*(dataDir: string): seq[Backup] =
## List all backup archives found in current directory and next to data directory
var backups: seq[Backup] = @[]
var seenPaths: seq[string] = @[]
proc scanDir(searchDir: string) =
if not dirExists(searchDir):
return
for kind, path in walkDir(searchDir):
if kind == pcFile:
let fullPath = absolutePath(path)
if fullPath in seenPaths:
continue
let (_, _, ext) = splitFile(path)
if ext == ".gz" or ext == ".tar" or path.endsWith(".tar.gz"):
seenPaths.add(fullPath)
var backup = Backup(path: path)
backup.size = getFileSize(path)
backup.timestamp = parseBackupFilename(path)
backup.compressed = path.endsWith(".gz")
backups.add(backup)
scanDir(getCurrentDir())
scanDir(parentDir(dataDir))
# Sort by timestamp descending (newest first)
backups.sort(proc(a, b: Backup): int =
if a.timestamp > b.timestamp: -1
elif a.timestamp < b.timestamp: 1
else: 0
)
result = backups
proc printBackups*(backups: seq[Backup]) =
## Pretty print backup list
if backups.len == 0:
echo "No backups found."
return
echo "Found ", backups.len, " backup(s):"
echo repeat("-", 80)
echo alignLeft("#", 4), alignLeft("Timestamp", 22), alignLeft("Size", 12), "Path"
echo repeat("-", 80)
for i, b in backups:
let ts = if b.timestamp > 0: formatTimestamp(b.timestamp) else: "unknown"
let idx = alignLeft($(i+1), 4)
echo idx, alignLeft(ts, 22), alignLeft(formatBytes(b.size), 12), b.path
echo repeat("-", 80)
proc cleanupOldBackups*(dataDir: string, keepLast: int = DEFAULT_KEEP_COUNT, verbose: bool = false) =
## Remove old backups keeping only N most recent
var backups = listBackups(dataDir)
if backups.len <= keepLast:
echo "Nothing to clean. Found ", backups.len, " backup(s), keeping up to ", keepLast, "."
return
let toDelete = backups.len - keepLast
echo "Removing ", toDelete, " old backup(s), keeping ", keepLast, " most recent."
for i in countdown(backups.high, keepLast):
if verbose:
echo " Deleting: ", backups[i].path, " (", formatBytes(backups[i].size), ")"
removeFile(backups[i].path)
echo "Cleanup complete."
proc printHistory*() =
## Display restore history
let entries = readHistory()
if entries.len == 0:
echo "No restore history found."
return
echo "Restore history:"
echo repeat("-", 80)
for entry in entries:
echo entry
echo repeat("-", 80)
# =============================================================================
# CLI Entry Point
# =============================================================================
when isMainModule:
var
command = ""
dataDir = DEFAULT_DATA_DIR
target = ""
keepCount = DEFAULT_KEEP_COUNT
excludes: seq[string] = @[]
compression = DEFAULT_COMPRESSION
verbose = false
dryRun = false
force = false
for kind, key, val in getopt():
case kind
of cmdArgument:
if command == "":
command = key
elif target == "":
target = key
of cmdLongOption, cmdShortOption:
case key
of "data-dir", "d": dataDir = val
of "output", "o": target = val
of "input", "i": target = val
of "keep", "k":
try: keepCount = parseInt(val)
except: quit("ERROR: --keep must be a number", 1)
of "exclude", "e": excludes.add(val)
of "level", "l":
try:
compression = parseInt(val)
if compression < 0 or compression > 9:
quit("ERROR: --level must be between 0 and 9", 1)
except: quit("ERROR: --level must be a number", 1)
of "dry-run": dryRun = true
of "force", "f": force = true
of "verbose", "v": verbose = true
of "help", "h":
echo HELP_TEXT
quit(0)
else: discard
of cmdEnd: discard
# If no command given, show help
if command == "":
echo HELP_TEXT
quit(0)
case command
of "backup":
let outputFile = if target.len > 0: target else: "backup_" & $getTime().toUnix() & ".tar.gz"
let ok = backupDataDir(dataDir, outputFile, excludes, compression, verbose)
if not ok:
quit("Backup failed", 1)
of "restore":
if target.len == 0:
quit("ERROR: restore requires --input=\nUse 'backup help' for usage.", 1)
# Always verify first unless dry-run
if not dryRun:
echo "Verifying archive before restore..."
let vok = verifyArchive(target, verbose)
if not vok:
logRestore(target, dataDir, false)
quit("Restore aborted: archive verification failed.", 1)
if not dryRun and not force:
echo "WARNING: This will REPLACE the data in: ", dataDir
echo "Continue? [y/N] "
let answer = readLine(stdin)
if answer.toLowerAscii() notin ["y", "yes"]:
echo "Restore cancelled."
logRestore(target, dataDir, false, dryRun = false)
quit(0)
let ok = restoreDataDir(target, dataDir, verbose, dryRun)
logRestore(target, dataDir, ok, dryRun)
if not ok and not dryRun:
quit("Restore failed", 1)
of "list":
let backups = listBackups(dataDir)
printBackups(backups)
of "verify":
if target.len == 0:
quit("ERROR: verify requires --input=\nUse 'backup help' for usage.", 1)
let ok = verifyArchive(target, verbose)
if not ok:
quit("Verification failed", 1)
of "cleanup":
cleanupOldBackups(dataDir, keepCount, verbose)
of "history":
printHistory()
of "help":
echo HELP_TEXT
else:
echo "ERROR: Unknown command: ", command
echo ""
echo HELP_TEXT
quit(1)