backup.Service.Restore(id) schreibt /var/lib/edgeguard/restore.sh
und dispatcht via `sudo systemd-run --unit=edgeguard-restore.service`.
Skript-Ablauf:
1. tar -xzf der Backup-Datei → /var/lib/edgeguard/restore-tmp
2. state-files (setup.json/license/jwt/node.conf/acme-account) per
cp -a zurück, chown edgeguard
3. systemctl stop edgeguard-api + scheduler (DB-Connections freigeben)
4. sudo -u postgres psql -f dump.sql (--clean droppt + recreated)
5. edgeguard-ctl render-config (haproxy/nft/squid/unbound/chrony)
6. systemctl start edgeguard-api + scheduler
7. rm -rf restore-tmp + restore.sh
UI: pro Backup-Row neuer Restore-Button mit Popconfirm. Beim Trigger
zeigt sich das vertraute Fullscreen-Overlay (Klassen .update-modal*
re-used) mit 4 Steps (Extract / DB-Restore / Render / Restart) + Live-
Timer. Health-Poll alle 3s detektiert API-Restart + reload. Safety-
Timeout 3 min für große DB-Dumps.
postinst: sudoers für `systemd-run --unit=edgeguard-restore.service
--description=... --collect bash /var/lib/edgeguard/restore.sh` +
zugehöriges `systemctl reset-failed`. Pfad fix damit kein Wildcard
nötig wird.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
141 lines
3.9 KiB
Go
141 lines
3.9 KiB
Go
package handlers
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backup"
|
|
)
|
|
|
|
// BackupHandler exposes:
|
|
//
|
|
// GET /api/v1/backups — list
|
|
// POST /api/v1/backups — trigger manual backup (sync)
|
|
// GET /api/v1/backups/:id — single entry
|
|
// GET /api/v1/backups/:id/download — sendfile tar.gz
|
|
// DELETE /api/v1/backups/:id — delete tar.gz + row
|
|
type BackupHandler struct {
|
|
Service *backup.Service
|
|
Audit *audit.Repo
|
|
NodeID string
|
|
Version string
|
|
}
|
|
|
|
func NewBackupHandler(s *backup.Service, a *audit.Repo, nodeID, version string) *BackupHandler {
|
|
return &BackupHandler{Service: s, Audit: a, NodeID: nodeID, Version: version}
|
|
}
|
|
|
|
func (h *BackupHandler) Register(rg *gin.RouterGroup) {
|
|
g := rg.Group("/backups")
|
|
g.GET("", h.List)
|
|
g.POST("", h.Trigger)
|
|
g.GET("/:id", h.Get)
|
|
g.GET("/:id/download", h.Download)
|
|
g.POST("/:id/restore", h.Restore)
|
|
g.DELETE("/:id", h.Delete)
|
|
}
|
|
|
|
// Restore startet einen Restore aus einem vorhandenen Backup. Endpoint
|
|
// returnt sofort 202 Accepted — der eigentliche Restore läuft in einer
|
|
// transient systemd-Unit; die UI pollt /healthz für die Restart-
|
|
// Detection. Massive Audit-Trail, weil das ein destruktiver Eingriff
|
|
// in den live-DB-State ist.
|
|
func (h *BackupHandler) Restore(c *gin.Context) {
|
|
id, ok := parseID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
e, err := h.Service.Restore(c.Request.Context(), id)
|
|
if err != nil {
|
|
response.Err(c, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "backup.restore",
|
|
e.File, gin.H{"id": id, "sha256": e.SHA256}, h.NodeID)
|
|
c.JSON(http.StatusAccepted, response.Envelope{
|
|
Data: gin.H{"status": "restoring", "file": e.File, "id": id},
|
|
Message: "Restore gestartet",
|
|
})
|
|
}
|
|
|
|
func (h *BackupHandler) List(c *gin.Context) {
|
|
out, err := h.Service.List(c.Request.Context())
|
|
if err != nil {
|
|
response.Internal(c, err)
|
|
return
|
|
}
|
|
response.OK(c, gin.H{"backups": out})
|
|
}
|
|
|
|
func (h *BackupHandler) Trigger(c *gin.Context) {
|
|
// Manual backup läuft synchron — der Operator wartet vor dem
|
|
// Knopf. Bei echten Multi-GB-DBs wäre async besser, aber unsere
|
|
// edgeguard-DB ist klein (<50 MB typisch).
|
|
res, err := h.Service.Run(c.Request.Context(), backup.KindManual, h.Version)
|
|
if err != nil {
|
|
response.Err(c, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "backup.create",
|
|
res.File, gin.H{"size": res.SizeBytes, "sha256": res.SHA256}, h.NodeID)
|
|
response.OK(c, gin.H{
|
|
"id": res.ID,
|
|
"file": res.File,
|
|
"size_bytes": res.SizeBytes,
|
|
"sha256": res.SHA256,
|
|
"db_dump_bytes": res.DBDumpBytes,
|
|
"files_bytes": res.FilesBytes,
|
|
"started_at": res.StartedAt,
|
|
"finished_at": res.FinishedAt,
|
|
})
|
|
}
|
|
|
|
func (h *BackupHandler) Get(c *gin.Context) {
|
|
id, ok := parseID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
e, _, err := h.Service.Get(c.Request.Context(), id)
|
|
if err != nil {
|
|
response.NotFound(c, err)
|
|
return
|
|
}
|
|
response.OK(c, e)
|
|
}
|
|
|
|
func (h *BackupHandler) Download(c *gin.Context) {
|
|
id, ok := parseID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
e, path, err := h.Service.Get(c.Request.Context(), id)
|
|
if err != nil {
|
|
response.NotFound(c, err)
|
|
return
|
|
}
|
|
// gin.FileAttachment setzt Content-Disposition + sendet stream.
|
|
c.FileAttachment(path, e.File)
|
|
}
|
|
|
|
func (h *BackupHandler) Delete(c *gin.Context) {
|
|
id, ok := parseID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := h.Service.Delete(c.Request.Context(), id); err != nil {
|
|
response.Err(c, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "backup.delete",
|
|
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
|
|
response.NoContent(c)
|
|
}
|
|
|
|
// Defensive — falls jemand den Pool fehlerhaft injected.
|
|
var _ = errors.New
|