Database: use backup and restore apis (#29511)

This commit is contained in:
andig 2026-05-15 13:02:34 +02:00 • committed by GitHub
parent 758e89ff1a
commit b76af6101d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 122 additions and 78 deletions

View file

@ -1,9 +1,9 @@
package server
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
@ -340,95 +340,130 @@ func getBackup(authObject auth.Auth) http.HandlerFunc {
return
}
f, err := os.Open(db.FilePath)
filename := "evcc-backup-" + time.Now().Format("2006-01-02--15-04") + ".db"
tmpFile, err := os.CreateTemp("", "evcc-backup-*.db")
if err != nil {
http.Error(w, "Could not open DB file: "+err.Error(), http.StatusInternalServerError)
http.Error(w, "Creating backup failed", http.StatusInternalServerError)
return
}
tmpName := tmpFile.Name()
tmpFile.Close()
defer os.Remove(tmpName)
if err := db.Backup(r.Context(), tmpName); err != nil {
http.Error(w, "Backup failed", http.StatusInternalServerError)
return
}
f, err := os.Open(tmpName)
if err != nil {
http.Error(w, "Opening backup failed", http.StatusInternalServerError)
return
}
defer f.Close()
filename := "evcc-backup-" + time.Now().Format("2006-01-02--15-04") + ".db"
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
if _, err := io.Copy(w, f); err != nil {
http.Error(w, "Error streaming DB file: "+err.Error(), http.StatusInternalServerError)
http.Error(w, "Streaming backup failed", http.StatusInternalServerError)
return
}
}
}
// createLocalDatabaseBackup creates a local backup in case of catastrophic error in reset or restore
func createLocalDatabaseBackup() error {
backupPath := db.FilePath + ".bak"
src, err := os.Open(db.FilePath)
if err != nil {
return fmt.Errorf("failed to open database file: %w", err)
}
defer src.Close()
dst, err := os.Create(backupPath)
if err != nil {
return fmt.Errorf("failed to create backup file: %w", err)
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
// clean up partial backup on error
os.Remove(backupPath)
return fmt.Errorf("failed to copy database: %w", err)
}
return nil
func createLocalDatabaseBackup(ctx context.Context) error {
return db.Backup(ctx, db.FilePath()+".bak")
}
func restoreDatabase(authObject auth.Auth, shutdown func()) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Parse multipart form
err := r.ParseMultipartForm(32 << 20) // 32MB max memory
// cap upload size to bound disk usage
r.Body = http.MaxBytesReader(w, r.Body, 256<<20)
mr, err := r.MultipartReader()
if err != nil {
http.Error(w, "Failed to parse form: "+err.Error(), http.StatusBadRequest)
return
}
if !adminPasswordValid(authObject, r.FormValue("password")) {
var (
password string
tmpName string
)
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
http.Error(w, "Upload failed", http.StatusBadRequest)
return
}
switch part.FormName() {
case "password":
b, err := io.ReadAll(io.LimitReader(part, 1<<10))
part.Close()
if err != nil {
http.Error(w, "Upload failed", http.StatusBadRequest)
return
}
password = string(b)
case "file":
tmpFile, err := os.CreateTemp("", "evcc-restore-*.db")
if err != nil {
part.Close()
http.Error(w, "Failed to create temp file", http.StatusInternalServerError)
return
}
tmpName = tmpFile.Name()
defer os.Remove(tmpName)
_, copyErr := io.Copy(tmpFile, part)
closeErr := tmpFile.Close()
part.Close()
if copyErr != nil || closeErr != nil {
http.Error(w, "Upload failed", http.StatusBadRequest)
return
}
default:
part.Close()
}
}
if !adminPasswordValid(authObject, password) {
http.Error(w, "Invalid password", http.StatusUnauthorized)
return
}
file, _, err := r.FormFile("file")
if err != nil {
http.Error(w, "Failed to get uploaded file: "+err.Error(), http.StatusBadRequest)
if tmpName == "" {
http.Error(w, "Missing file", http.StatusBadRequest)
return
}
defer file.Close()
settings.Persist()
// close db connection to avoid corruption
if err := db.Close(); err != nil {
jsonError(w, http.StatusInternalServerError, err)
return
}
// create local backup before overwriting
if err := createLocalDatabaseBackup(); err != nil {
http.Error(w, "Failed to create local backup: "+err.Error(), http.StatusInternalServerError)
if err := createLocalDatabaseBackup(r.Context()); err != nil {
http.Error(w, "Backup failed", http.StatusInternalServerError)
return
}
// overwrite DB file
f, err := os.Create(db.FilePath)
if err != nil {
http.Error(w, "Could not open DB file for writing: "+err.Error(), http.StatusInternalServerError)
if err := db.Restore(r.Context(), tmpName); err != nil {
http.Error(w, "Restore failed", http.StatusInternalServerError)
return
}
defer f.Close()
if _, err := io.Copy(f, file); err != nil {
http.Error(w, "Failed to write DB file: "+err.Error(), http.StatusInternalServerError)
// close DB so the WAL is checkpointed into the main file before shutdown
// hooks (e.g. settings.Persist) can overwrite the restored content
if err := db.Close(); err != nil {
http.Error(w, "DB close failed", http.StatusInternalServerError)
return
}
@ -457,7 +492,7 @@ func resetDatabase(authObject auth.Auth, shutdown func()) http.HandlerFunc {
settings.Persist()
if err := createLocalDatabaseBackup(); err != nil {
if err := createLocalDatabaseBackup(r.Context()); err != nil {
jsonError(w, http.StatusInternalServerError, err)
return
}