v0.11.8 — Per-App Cross-Drive Backup (3-2-1 rule)
New feature: backup app data to a secondary storage drive to satisfy
the "different media" requirement of the 3-2-1 backup rule.
- settings.go: CrossDriveBackup struct, AppBackupPrefs.CrossDrive field,
getter/setter methods, GetOrCreateCrossDrivePassword, preserves
cross-drive config when toggling nightly backup
- crossdrive.go (new): CrossDriveRunner with rsync and restic backends.
Validates destination (mount point, writable), prevents source/dest
overlap, per-app concurrency lock, persists last_run/status/size.
- main.go: wire CrossDriveRunner, register cross-drive-daily (03:30)
and cross-drive-weekly (04:30 Sundays) scheduler jobs
- router.go: 4 new API endpoints — save config, trigger run, get status,
run-all. Router now accepts Settings and CrossDriveRunner.
- server.go: Server struct accepts CrossDriveRunner, new web route
POST /settings/cross-backup/{name}
- handlers.go: deployHandler populates CrossDriveConfig, BackupDestPaths,
BackupDestWarning, AppBackupEnabled. settingsCrossBackupHandler saves
config. backupsHandler builds CrossDriveSummary, UnconfiguredApps,
CrossDriveWarnings for backup page.
- deploy.html: "Biztonsági mentés" card with destination/method/schedule
dropdowns, last-run status, manual trigger button, flash messages.
- backups.html: "Másolatok másik meghajtóra" section with per-app
status rows, unconfigured app warnings, "Összes futtatása most" button.
- style.css: margin-bottom fix for .deploy-stale-data, new cross-drive
card and list styles.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import (
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/metrics"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
catalogsync "gitea.dooplex.hu/admin/felhom-controller/internal/sync"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
||||
@@ -20,17 +21,19 @@ import (
|
||||
|
||||
// Router handles all /api/* requests.
|
||||
type Router struct {
|
||||
cfg *config.Config
|
||||
stackMgr *stacks.Manager
|
||||
syncer *catalogsync.Syncer
|
||||
cpuCollector *system.CPUCollector
|
||||
backupMgr *backup.Manager
|
||||
metricsStore *metrics.MetricsStore
|
||||
logger *log.Logger
|
||||
cfg *config.Config
|
||||
sett *settings.Settings
|
||||
stackMgr *stacks.Manager
|
||||
syncer *catalogsync.Syncer
|
||||
cpuCollector *system.CPUCollector
|
||||
backupMgr *backup.Manager
|
||||
crossDriveRunner *backup.CrossDriveRunner
|
||||
metricsStore *metrics.MetricsStore
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
func NewRouter(cfg *config.Config, stackMgr *stacks.Manager, syncer *catalogsync.Syncer, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, metricsStore *metrics.MetricsStore, logger *log.Logger) *Router {
|
||||
return &Router{cfg: cfg, stackMgr: stackMgr, syncer: syncer, cpuCollector: cpuCollector, backupMgr: backupMgr, metricsStore: metricsStore, logger: logger}
|
||||
func NewRouter(cfg *config.Config, sett *settings.Settings, stackMgr *stacks.Manager, syncer *catalogsync.Syncer, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, crossDrive *backup.CrossDriveRunner, metricsStore *metrics.MetricsStore, logger *log.Logger) *Router {
|
||||
return &Router{cfg: cfg, sett: sett, stackMgr: stackMgr, syncer: syncer, cpuCollector: cpuCollector, backupMgr: backupMgr, crossDriveRunner: crossDrive, metricsStore: metricsStore, logger: logger}
|
||||
}
|
||||
|
||||
type apiResponse struct {
|
||||
@@ -98,6 +101,22 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
case strings.HasPrefix(path, "/stacks/") && req.Method == http.MethodDelete && !hasSubpath(path, "/stacks/"):
|
||||
r.deleteStack(w, req, trimSegment(path, "/stacks/"))
|
||||
|
||||
// POST /api/stacks/{name}/cross-backup — save cross-drive config
|
||||
case hasSuffix(path, "/cross-backup") && req.Method == http.MethodPost && !hasSuffix(path, "/cross-backup/run") && !hasSuffix(path, "/cross-backup/status"):
|
||||
r.saveCrossBackupConfig(w, req, extractName(path, "/cross-backup"))
|
||||
|
||||
// POST /api/stacks/{name}/cross-backup/run — trigger manual run
|
||||
case hasSuffix(path, "/cross-backup/run") && req.Method == http.MethodPost:
|
||||
r.triggerCrossBackup(w, req, extractName(path, "/cross-backup/run"))
|
||||
|
||||
// GET /api/stacks/{name}/cross-backup/status — poll status
|
||||
case hasSuffix(path, "/cross-backup/status") && req.Method == http.MethodGet:
|
||||
r.getCrossBackupStatus(w, req, extractName(path, "/cross-backup/status"))
|
||||
|
||||
// POST /api/backup/cross-drive/run-all — trigger all scheduled cross-drive backups
|
||||
case path == "/backup/cross-drive/run-all" && req.Method == http.MethodPost:
|
||||
r.triggerAllCrossBackups(w, req)
|
||||
|
||||
// POST /api/sync — trigger immediate catalog sync
|
||||
case path == "/sync" && req.Method == http.MethodPost:
|
||||
r.triggerSync(w, req)
|
||||
@@ -536,6 +555,129 @@ func (r *Router) metricsSysInfo(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: info})
|
||||
}
|
||||
|
||||
// --- Cross-drive backup handlers ---
|
||||
|
||||
func (r *Router) saveCrossBackupConfig(w http.ResponseWriter, req *http.Request, name string) {
|
||||
if r.crossDriveRunner == nil {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "cross-drive runner not available"})
|
||||
return
|
||||
}
|
||||
limitBody(w, req)
|
||||
|
||||
var body struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Method string `json:"method"`
|
||||
DestinationPath string `json:"destination_path"`
|
||||
Schedule string `json:"schedule"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate method
|
||||
if body.Method != "rsync" && body.Method != "restic" {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "method must be 'rsync' or 'restic'"})
|
||||
return
|
||||
}
|
||||
// Validate schedule
|
||||
if body.Schedule != "daily" && body.Schedule != "weekly" && body.Schedule != "manual" {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "schedule must be 'daily', 'weekly', or 'manual'"})
|
||||
return
|
||||
}
|
||||
|
||||
// Preserve existing runtime status
|
||||
existing := r.sett.GetCrossDriveConfig(name)
|
||||
var lastRun, lastStatus, lastError, lastDuration, lastSize string
|
||||
if existing != nil {
|
||||
lastRun, lastStatus, lastError, lastDuration, lastSize =
|
||||
existing.LastRun, existing.LastStatus, existing.LastError, existing.LastDuration, existing.LastSizeHuman
|
||||
}
|
||||
|
||||
cfg := &settings.CrossDriveBackup{
|
||||
Enabled: body.Enabled,
|
||||
Method: body.Method,
|
||||
DestinationPath: body.DestinationPath,
|
||||
Schedule: body.Schedule,
|
||||
LastRun: lastRun,
|
||||
LastStatus: lastStatus,
|
||||
LastError: lastError,
|
||||
LastDuration: lastDuration,
|
||||
LastSizeHuman: lastSize,
|
||||
}
|
||||
|
||||
if err := r.sett.SetCrossDriveConfig(name, cfg); err != nil {
|
||||
r.logger.Printf("[API] Failed to save cross-drive config for %s: %v", name, err)
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
r.logger.Printf("[API] Cross-drive backup config saved for %s: method=%s dest=%s schedule=%s",
|
||||
name, body.Method, body.DestinationPath, body.Schedule)
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Cross-drive backup configuration saved"})
|
||||
}
|
||||
|
||||
func (r *Router) triggerCrossBackup(w http.ResponseWriter, req *http.Request, name string) {
|
||||
if r.crossDriveRunner == nil {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "cross-drive runner not available"})
|
||||
return
|
||||
}
|
||||
if r.crossDriveRunner.IsRunning(name) {
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: "Mentés már folyamatban"})
|
||||
return
|
||||
}
|
||||
|
||||
r.logger.Printf("[API] Cross-drive backup triggered for: %s", name)
|
||||
go func() {
|
||||
if err := r.crossDriveRunner.RunAppBackup(context.Background(), name); err != nil {
|
||||
r.logger.Printf("[API] Cross-drive backup failed for %s: %v", name, err)
|
||||
}
|
||||
}()
|
||||
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Mentés elindítva"})
|
||||
}
|
||||
|
||||
func (r *Router) getCrossBackupStatus(w http.ResponseWriter, _ *http.Request, name string) {
|
||||
cfg := r.sett.GetCrossDriveConfig(name)
|
||||
if cfg == nil {
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{"configured": false}})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{
|
||||
"configured": true,
|
||||
"enabled": cfg.Enabled,
|
||||
"method": cfg.Method,
|
||||
"schedule": cfg.Schedule,
|
||||
"running": r.crossDriveRunner != nil && r.crossDriveRunner.IsRunning(name),
|
||||
"last_run": cfg.LastRun,
|
||||
"last_status": cfg.LastStatus,
|
||||
"last_error": cfg.LastError,
|
||||
"last_duration": cfg.LastDuration,
|
||||
"last_size": cfg.LastSizeHuman,
|
||||
}})
|
||||
}
|
||||
|
||||
func (r *Router) triggerAllCrossBackups(w http.ResponseWriter, _ *http.Request) {
|
||||
if r.crossDriveRunner == nil {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "cross-drive runner not available"})
|
||||
return
|
||||
}
|
||||
r.logger.Println("[API] All cross-drive backups triggered")
|
||||
go func() {
|
||||
ctx := context.Background()
|
||||
if err := r.crossDriveRunner.RunAllScheduled(ctx, "daily"); err != nil {
|
||||
r.logger.Printf("[API] Cross-drive run-all error: %v", err)
|
||||
}
|
||||
if err := r.crossDriveRunner.RunAllScheduled(ctx, "weekly"); err != nil {
|
||||
r.logger.Printf("[API] Cross-drive run-all weekly error: %v", err)
|
||||
}
|
||||
if err := r.crossDriveRunner.RunAllScheduled(ctx, "manual"); err != nil {
|
||||
r.logger.Printf("[API] Cross-drive run-all manual error: %v", err)
|
||||
}
|
||||
}()
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Összes mentés elindítva"})
|
||||
}
|
||||
|
||||
// parseTimeRange reads range or from/to query params.
|
||||
func parseTimeRange(req *http.Request) (from, to time.Time) {
|
||||
to = time.Now()
|
||||
|
||||
Reference in New Issue
Block a user