Files
deploy-felhom-compose/controller/internal/backup/dbdump.go
T
admin 37ff296a0d v0.4.5: Add dedicated Backup page (Biztonsági mentés)
New /backups page with full backup system visibility:
- Status overview cards (local/remote backup, DB count, repo size)
- Schedule section with next-run times and retention policy
- Database table with type, size, validation (table count), status
- Snapshot history table with per-snapshot stats
- Repository info card with paths, integrity status, remote placeholder
- "Mentés most" button with auto-refresh polling
- Empty state when backup not configured

Backend: SnapshotRecord history (ring buffer), DumpValidation,
ListDumpFiles, ListSnapshots, GetFullStatus, restic check tracking.
Server accepts scheduler for next-run time calculation.

Sidebar nav updated with 3rd item, dashboard backup card title clickable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 07:43:24 +01:00

458 lines
11 KiB
Go

package backup
import (
"context"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// DBType represents a database engine type.
type DBType string
const (
DBTypePostgres DBType = "postgres"
DBTypeMariaDB DBType = "mariadb"
)
// DiscoveredDB holds metadata about a running database container.
type DiscoveredDB struct {
ContainerName string
ContainerID string
DBType DBType
DBUser string
DBName string
StackName string
}
// DumpResult holds the outcome of a single database dump.
type DumpResult struct {
DB DiscoveredDB
FilePath string
Size int64
Duration time.Duration
Error error
Validation DumpValidation
}
// DumpValidation holds the result of a dump file structural check.
type DumpValidation struct {
Valid bool
TableCount int
Error string
FileSize int64
ModTime time.Time
}
// DumpFileInfo holds info about a dump file on disk.
type DumpFileInfo struct {
FileName string
StackName string
DBType DBType
Size int64
ModTime time.Time
Validation DumpValidation
}
// DiscoverDatabases finds running database containers via docker ps.
func DiscoverDatabases(ctx context.Context, logger *log.Logger) ([]DiscoveredDB, error) {
cmd := exec.CommandContext(ctx, "docker", "ps", "--format", "{{.ID}}\t{{.Names}}\t{{.Image}}", "--filter", "status=running")
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("docker ps failed: %w", err)
}
var dbs []DiscoveredDB
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line == "" {
continue
}
parts := strings.SplitN(line, "\t", 3)
if len(parts) < 3 {
continue
}
id, name, image := parts[0], parts[1], strings.ToLower(parts[2])
var dbType DBType
if strings.Contains(image, "postgres") {
dbType = DBTypePostgres
} else if strings.Contains(image, "mariadb") || strings.Contains(image, "mysql") {
dbType = DBTypeMariaDB
} else {
continue
}
db := DiscoveredDB{
ContainerID: id,
ContainerName: name,
DBType: dbType,
StackName: deriveStackName(name),
}
// Get env vars from container
if err := populateDBEnv(ctx, &db); err != nil {
logger.Printf("[WARN] Could not read env vars for %s: %v", name, err)
continue
}
dbs = append(dbs, db)
}
return dbs, nil
}
// DumpAll dumps all discovered databases.
func DumpAll(ctx context.Context, dbs []DiscoveredDB, dumpDir string, logger *log.Logger) []DumpResult {
// Clean up old .tmp files (older than 1 hour)
cleanupTmpFiles(dumpDir, logger)
var results []DumpResult
for _, db := range dbs {
result := DumpOne(ctx, db, dumpDir, logger)
results = append(results, result)
}
return results
}
// DumpOne dumps a single database.
func DumpOne(ctx context.Context, db DiscoveredDB, dumpDir string, logger *log.Logger) DumpResult {
start := time.Now()
result := DumpResult{DB: db}
// Ensure dump directory exists
if err := os.MkdirAll(dumpDir, 0755); err != nil {
result.Error = fmt.Errorf("creating dump dir: %w", err)
result.Duration = time.Since(start)
return result
}
filename := fmt.Sprintf("%s-%s.sql", db.StackName, db.DBType)
tmpPath := filepath.Join(dumpDir, filename+".tmp")
finalPath := filepath.Join(dumpDir, filename)
// 5-minute timeout per dump
dumpCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
// Verify container is still running
checkCmd := exec.CommandContext(dumpCtx, "docker", "inspect", "--format", "{{.State.Running}}", db.ContainerID)
checkOut, err := checkCmd.Output()
if err != nil || strings.TrimSpace(string(checkOut)) != "true" {
result.Error = fmt.Errorf("container %s no longer running", db.ContainerName)
result.Duration = time.Since(start)
return result
}
// Build dump command
var cmd *exec.Cmd
switch db.DBType {
case DBTypePostgres:
cmd = exec.CommandContext(dumpCtx, "docker", "exec", db.ContainerID,
"pg_dump", "-U", db.DBUser, "-d", db.DBName,
"--clean", "--if-exists", "--no-owner", "--no-privileges")
case DBTypeMariaDB:
// Get root password from container env
password := getMariaDBPassword(dumpCtx, db.ContainerID)
if password == "" {
result.Error = fmt.Errorf("could not determine MariaDB root password for %s", db.ContainerName)
result.Duration = time.Since(start)
return result
}
cmd = exec.CommandContext(dumpCtx, "docker", "exec", db.ContainerID,
"mariadb-dump", "-u", "root", "-p"+password,
"--single-transaction", "--routines", "--triggers", db.DBName)
default:
result.Error = fmt.Errorf("unsupported DB type: %s", db.DBType)
result.Duration = time.Since(start)
return result
}
// Write output to tmp file
tmpFile, err := os.Create(tmpPath)
if err != nil {
result.Error = fmt.Errorf("creating tmp file: %w", err)
result.Duration = time.Since(start)
return result
}
cmd.Stdout = tmpFile
var stderr strings.Builder
cmd.Stderr = &stderr
err = cmd.Run()
tmpFile.Close()
if err != nil {
os.Remove(tmpPath)
errMsg := stderr.String()
if len(errMsg) > 200 {
errMsg = errMsg[:200]
}
result.Error = fmt.Errorf("dump failed: %v — %s", err, errMsg)
result.Duration = time.Since(start)
return result
}
// Check file size
stat, err := os.Stat(tmpPath)
if err != nil || stat.Size() == 0 {
os.Remove(tmpPath)
result.Error = fmt.Errorf("dump produced empty file for %s", db.ContainerName)
result.Duration = time.Since(start)
return result
}
// Rename tmp to final
if err := os.Rename(tmpPath, finalPath); err != nil {
os.Remove(tmpPath)
result.Error = fmt.Errorf("renaming dump file: %w", err)
result.Duration = time.Since(start)
return result
}
result.FilePath = finalPath
result.Size = stat.Size()
result.Duration = time.Since(start)
// Run validation on the dump file
result.Validation = ValidateDump(finalPath, db.DBType)
logger.Printf("[INFO] DB dump: %s → %s (%s, %s, %d tables)", db.ContainerName, filename,
formatBytes(stat.Size()), result.Duration.Round(time.Millisecond), result.Validation.TableCount)
return result
}
// ValidateDump checks a SQL dump file for basic structural integrity.
func ValidateDump(filePath string, dbType DBType) DumpValidation {
stat, err := os.Stat(filePath)
if err != nil {
return DumpValidation{Error: fmt.Sprintf("stat failed: %v", err)}
}
v := DumpValidation{
FileSize: stat.Size(),
ModTime: stat.ModTime(),
}
if stat.Size() < 100 {
v.Error = "dump file too small (< 100 bytes)"
return v
}
data, err := os.ReadFile(filePath)
if err != nil {
v.Error = fmt.Sprintf("read failed: %v", err)
return v
}
content := string(data)
// Count CREATE TABLE statements
tableCount := 0
for _, line := range strings.Split(content, "\n") {
upper := strings.ToUpper(strings.TrimSpace(line))
if strings.HasPrefix(upper, "CREATE TABLE") {
tableCount++
}
}
v.TableCount = tableCount
// Basic header check
switch dbType {
case DBTypePostgres:
if !strings.HasPrefix(content, "--") {
v.Error = "PostgreSQL dump missing comment header"
return v
}
case DBTypeMariaDB:
if !strings.HasPrefix(content, "-- ") {
v.Error = "MariaDB dump missing comment header"
return v
}
}
if tableCount == 0 {
v.Error = "no CREATE TABLE statements found"
return v
}
v.Valid = true
return v
}
// ListDumpFiles returns info about SQL dump files on disk.
func ListDumpFiles(dumpDir string) ([]DumpFileInfo, error) {
entries, err := os.ReadDir(dumpDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("reading dump dir: %w", err)
}
var files []DumpFileInfo
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
continue
}
if strings.HasSuffix(e.Name(), ".tmp") {
continue
}
info, err := e.Info()
if err != nil {
continue
}
f := DumpFileInfo{
FileName: e.Name(),
Size: info.Size(),
ModTime: info.ModTime(),
}
// Parse stack name and DB type from filename: "paperless-ngx-postgres.sql"
base := strings.TrimSuffix(e.Name(), ".sql")
if strings.HasSuffix(base, "-postgres") {
f.StackName = strings.TrimSuffix(base, "-postgres")
f.DBType = DBTypePostgres
} else if strings.HasSuffix(base, "-mariadb") {
f.StackName = strings.TrimSuffix(base, "-mariadb")
f.DBType = DBTypeMariaDB
} else {
f.StackName = base
}
// Run validation on the file
fullPath := filepath.Join(dumpDir, e.Name())
f.Validation = ValidateDump(fullPath, f.DBType)
files = append(files, f)
}
return files, nil
}
func populateDBEnv(ctx context.Context, db *DiscoveredDB) error {
cmd := exec.CommandContext(ctx, "docker", "inspect", db.ContainerID,
"--format", "{{range .Config.Env}}{{println .}}{{end}}")
out, err := cmd.Output()
if err != nil {
return err
}
env := make(map[string]string)
for _, line := range strings.Split(string(out), "\n") {
if idx := strings.IndexByte(line, '='); idx > 0 {
env[line[:idx]] = line[idx+1:]
}
}
switch db.DBType {
case DBTypePostgres:
db.DBUser = env["POSTGRES_USER"]
if db.DBUser == "" {
db.DBUser = "postgres"
}
db.DBName = env["POSTGRES_DB"]
if db.DBName == "" {
db.DBName = db.DBUser
}
case DBTypeMariaDB:
db.DBName = env["MYSQL_DATABASE"]
if db.DBName == "" {
db.DBName = env["MARIADB_DATABASE"]
}
if db.DBName == "" {
db.DBName = "mysql" // fallback to dump all
}
db.DBUser = "root"
}
return nil
}
func getMariaDBPassword(ctx context.Context, containerID string) string {
cmd := exec.CommandContext(ctx, "docker", "inspect", containerID,
"--format", "{{range .Config.Env}}{{println .}}{{end}}")
out, err := cmd.Output()
if err != nil {
return ""
}
for _, line := range strings.Split(string(out), "\n") {
if strings.HasPrefix(line, "MYSQL_ROOT_PASSWORD=") {
return strings.TrimPrefix(line, "MYSQL_ROOT_PASSWORD=")
}
if strings.HasPrefix(line, "MARIADB_ROOT_PASSWORD=") {
return strings.TrimPrefix(line, "MARIADB_ROOT_PASSWORD=")
}
}
return ""
}
// deriveStackName strips known DB suffixes from container name.
func deriveStackName(containerName string) string {
knownSuffixes := []string{"postgres", "db", "mariadb", "mysql", "database", "redis", "cache"}
parts := strings.Split(containerName, "-")
if len(parts) <= 1 {
return containerName
}
last := strings.ToLower(parts[len(parts)-1])
for _, suffix := range knownSuffixes {
if last == suffix {
return strings.Join(parts[:len(parts)-1], "-")
}
}
return containerName
}
func cleanupTmpFiles(dumpDir string, logger *log.Logger) {
entries, err := os.ReadDir(dumpDir)
if err != nil {
return
}
cutoff := time.Now().Add(-1 * time.Hour)
for _, e := range entries {
if !strings.HasSuffix(e.Name(), ".tmp") {
continue
}
info, err := e.Info()
if err != nil {
continue
}
if info.ModTime().Before(cutoff) {
path := filepath.Join(dumpDir, e.Name())
os.Remove(path)
logger.Printf("[INFO] Cleaned up stale tmp file: %s", e.Name())
}
}
}
func formatBytes(b int64) string {
const (
kb = 1024
mb = 1024 * kb
gb = 1024 * mb
)
switch {
case b >= gb:
return fmt.Sprintf("%.1f GB", float64(b)/float64(gb))
case b >= mb:
return fmt.Sprintf("%.1f MB", float64(b)/float64(mb))
case b >= kb:
return fmt.Sprintf("%.1f KB", float64(b)/float64(kb))
default:
return fmt.Sprintf("%d B", b)
}
}