diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e2d1b0..4210057 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ ## Changelog +### What was just completed (2026-02-17 session 35) +- **v0.11.8 — Per-App Cross-Drive Backup (3-2-1 rule, second copy on different media):** + - **Feature: CrossDriveBackup data model** — `AppBackupPrefs` extended with `CrossDrive *CrossDriveBackup` field in `settings.go`. New methods: `GetCrossDriveConfig`, `SetCrossDriveConfig`, `UpdateCrossDriveStatus`, `GetAllCrossDriveConfigs`, `GetOrCreateCrossDrivePassword`. Existing `SetAppBackup`/`SetAppBackupBulk` now preserve cross-drive config. Auto-generated restic password stored in `settings.json`. + - **Feature: CrossDriveRunner** — New `internal/backup/crossdrive.go`. Supports rsync (simple mirror with `--delete`) and restic (versioned, deduplicated, shared repo). Safety guards: destination ≠ source, mount point check, writable check, per-app concurrency lock. `RunAllScheduled(ctx, schedule)` iterates all apps matching the given schedule. Status (last_run, last_status, last_error, last_duration, last_size_human) persisted to settings.json after each run. + - **Feature: Scheduler jobs** — Two new daily jobs: `cross-drive-daily` at 03:30 (for apps with `schedule: daily`), `cross-drive-weekly` at 04:30 Sundays only (for `schedule: weekly`). + - **Feature: API endpoints** — 4 new routes: `POST /api/stacks/{name}/cross-backup`, `POST /api/stacks/{name}/cross-backup/run`, `GET /api/stacks/{name}/cross-backup/status`, `POST /api/backup/cross-drive/run-all`. + - **Feature: Deploy/Settings page UI** — New "Biztonsági mentés" card on the deploy page for apps with HDD data. Shows nightly backup toggle (read-only link), cross-drive dropdowns (destination, method, schedule), last run status, manual trigger button. States: no other storage (info message), configured, destination unreachable (warning). Flash messages on save redirect. + - **Feature: Backup page summary** — New "Másolatok másik meghajtóra" section showing all configured apps with method, destination, last status, size. Warns about unconfigured apps with HDD data. Destination health warnings. "Összes futtatása most" button. + - **CSS:** `margin-bottom: 1.5rem` added to `.deploy-stale-data`. New styles: `.deploy-cross-drive`, `.cross-drive-list`, `.cross-drive-item`, `.cross-drive-header`, `.cross-drive-meta`, `.cross-drive-actions`. + - **Files modified (10):** `settings/settings.go`, `backup/crossdrive.go` (new), `backup/backup.go`, `api/router.go`, `web/handlers.go`, `web/server.go`, `web/templates/deploy.html`, `web/templates/backups.html`, `web/templates/style.css`, `cmd/controller/main.go` + ### What was just completed (2026-02-17 session 34) - **v0.11.7 — Stale Data Cleanup + FileBrowser Sync + UI Title Fix:** - **Feature: Stale data cleanup** — After app data migration, the deploy/settings page now shows leftover data on previous storage paths with size info and a delete button. Two-step confirmation required before deletion. Protected paths (storage root, media, Dokumentumok, appdata) cannot be deleted. Also available immediately after migration on the migration-done page. diff --git a/CONTEXT.md b/CONTEXT.md index eea7043..0ed7aac 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -7,7 +7,7 @@ > > Ask Claude Code: "Please update CONTEXT.md with what we did today" -Last updated: 2026-02-17 (session 33) +Last updated: 2026-02-17 (session 35) --- @@ -20,7 +20,7 @@ Last updated: 2026-02-17 (session 33) - Customer deployments use Docker Compose (not Kubernetes) for simplicity ### felhom-controller (this repo) -- **Version:** v0.11.6 +- **Version:** v0.11.8 - **Phase 1:** ✅ COMPLETE — Stack Manager + Deploy Flow - **Phase 2:** ✅ COMPLETE — Monitoring & Health (scheduler, CPU/temp, healthchecks.io pings) - **Phase 3:** ✅ COMPLETE — Backups (DB dumps, restic integration, manual trigger, **dedicated backup page**) @@ -36,6 +36,8 @@ Last updated: 2026-02-17 (session 33) - **v0.11.3 bugfix:** ✅ COMPLETE — Added `fdisk` package to Dockerfile (provides `sfdisk`; not in `util-linux` on Debian bookworm) - **v0.11.4 bugfix:** ✅ COMPLETE — FormatAndMount: fixed sfdisk (wipefs+force+`,,`), mount (explicit device path), mount propagation (rshared), ASCII label, smart partition skip, findmnt verification - **v0.11.6:** ✅ COMPLETE — FileBrowser auto-mount sync (`syncFileBrowserMounts()`) + 3 UI fixes (badge color, progress bar, button text) +- **v0.11.7:** ✅ COMPLETE — Stale data cleanup + FileBrowser sync after migration + deploy page title fix +- **v0.11.8:** ✅ COMPLETE — Per-App Cross-Drive Backup (3-2-1 rule): rsync/restic to secondary drive, deploy page UI, backup page summary, scheduler jobs, API endpoints - **First app deployed:** Paperless-ngx on demo-felhom.eu (2026-02-13) - **Running on:** demo-felhom (N100 mini PC) at 192.168.0.162:8080 - **All Phase 1-5 features working:** deploy, start/stop/restart/update, logs, health-aware states, auth, monitoring, backups, backup detail page, system monitoring page, settings page diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 5a8ab23..63ec111 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -116,12 +116,13 @@ func main() { // --- Initialize backup manager --- var backupMgr *backup.Manager + stackProv := &stackAdapter{ + mgr: stackMgr, + getStoragePaths: func() []settings.StoragePath { return sett.GetStoragePaths() }, + } if cfg.Backup.Enabled { backupMgr = backup.NewManager(cfg, pinger, sett, logger) - backupMgr.SetStackProvider(&stackAdapter{ - mgr: stackMgr, - getStoragePaths: func() []settings.StoragePath { return sett.GetStoragePaths() }, - }) + backupMgr.SetStackProvider(stackProv) backupMgr.AfterBackup = func() { nextDBDump := scheduler.NextDailyRun(cfg.Backup.DBDumpSchedule) nextBackup := scheduler.NextDailyRun(cfg.Backup.ResticSchedule) @@ -130,6 +131,9 @@ func main() { go backupMgr.LoadSnapshotHistory() } + // --- Initialize cross-drive backup runner --- + crossDriveRunner := backup.NewCrossDriveRunner(sett, stackProv, logger) + // --- Initialize alert manager --- alertMgr := web.NewAlertManager(logger) @@ -212,6 +216,18 @@ func main() { }) } + // Cross-drive backup — daily at 03:30 (after main backup at 03:00) + sched.Daily("cross-drive-daily", "03:30", func(ctx context.Context) error { + return crossDriveRunner.RunAllScheduled(ctx, "daily") + }) + // Cross-drive weekly — Sunday 04:30 (after integrity check at 04:00) + sched.Daily("cross-drive-weekly", "04:30", func(ctx context.Context) error { + if time.Now().Weekday() != time.Sunday { + return nil + } + return crossDriveRunner.RunAllScheduled(ctx, "weekly") + }) + // Metrics prune — daily at 04:00 if metricsStore != nil { sched.Daily("metrics-prune", "04:00", func(ctx context.Context) error { @@ -300,10 +316,10 @@ func main() { }() // --- Initialize API router --- - apiRouter := api.NewRouter(cfg, stackMgr, syncer, cpuCollector, backupMgr, metricsStore, logger) + apiRouter := api.NewRouter(cfg, sett, stackMgr, syncer, cpuCollector, backupMgr, crossDriveRunner, metricsStore, logger) // --- Initialize web server --- - webServer := web.NewServer(cfg, stackMgr, cpuCollector, backupMgr, sched, sett, alertMgr, notifier, logger, Version) + webServer := web.NewServer(cfg, stackMgr, cpuCollector, backupMgr, crossDriveRunner, sched, sett, alertMgr, notifier, logger, Version) // --- Build HTTP mux --- mux := http.NewServeMux() diff --git a/controller/internal/api/router.go b/controller/internal/api/router.go index 805fb18..0397153 100644 --- a/controller/internal/api/router.go +++ b/controller/internal/api/router.go @@ -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() diff --git a/controller/internal/backup/backup.go b/controller/internal/backup/backup.go index cca3677..571ad64 100644 --- a/controller/internal/backup/backup.go +++ b/controller/internal/backup/backup.go @@ -53,6 +53,21 @@ type SnapshotRecord struct { HasStats bool `json:"has_stats"` // false for historical entries loaded from restic } +// CrossDriveSummaryItem holds display data for one app's cross-drive backup. +type CrossDriveSummaryItem struct { + StackName string + DisplayName string + Method string // "rsync" or "restic" + MethodLabel string // "Egyszerű másolat" or "Restic" + DestPath string + DestLabel string // storage path label + Schedule string + ScheduleLabel string // "Naponta" or "Hetente" or "Kézi" + LastStatus string // "ok", "error", "running", "" + LastRunShort string // formatted short time e.g. "03:15" + SizeHuman string +} + // FullBackupStatus contains everything the backup page needs. type FullBackupStatus struct { Enabled bool @@ -88,6 +103,11 @@ type FullBackupStatus struct { // App data backup AppDataInfo []AppBackupInfo + // Cross-drive backup summary + CrossDriveSummary []CrossDriveSummaryItem + UnconfiguredApps []CrossDriveSummaryItem // apps with HDD data but no cross-drive config + CrossDriveWarnings []string // destination health warnings + // Flash messages (set by handlers, passed through redirect) FlashSuccess string FlashError string diff --git a/controller/internal/backup/crossdrive.go b/controller/internal/backup/crossdrive.go new file mode 100644 index 0000000..a286623 --- /dev/null +++ b/controller/internal/backup/crossdrive.go @@ -0,0 +1,308 @@ +package backup + +import ( + "context" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" + "gitea.dooplex.hu/admin/felhom-controller/internal/system" +) + +// CrossDriveRunner handles per-app backup to secondary storage. +type CrossDriveRunner struct { + sett *settings.Settings + stackProvider StackDataProvider + logger *log.Logger + mu sync.Mutex + running map[string]bool // per-app running state +} + +// NewCrossDriveRunner creates a new CrossDriveRunner. +func NewCrossDriveRunner(sett *settings.Settings, provider StackDataProvider, logger *log.Logger) *CrossDriveRunner { + return &CrossDriveRunner{ + sett: sett, + stackProvider: provider, + logger: logger, + running: make(map[string]bool), + } +} + +// RunAppBackup runs cross-drive backup for a single app. +func (r *CrossDriveRunner) RunAppBackup(ctx context.Context, stackName string) error { + cfg := r.sett.GetCrossDriveConfig(stackName) + if cfg == nil || !cfg.Enabled { + return fmt.Errorf("cross-drive backup not configured or disabled for %s", stackName) + } + + // Prevent concurrent runs for the same app + r.mu.Lock() + if r.running[stackName] { + r.mu.Unlock() + return fmt.Errorf("cross-drive backup already running for %s", stackName) + } + r.running[stackName] = true + r.mu.Unlock() + defer func() { + r.mu.Lock() + r.running[stackName] = false + r.mu.Unlock() + }() + + // Mark as running in settings + _ = r.sett.UpdateCrossDriveStatus(stackName, func(c *settings.CrossDriveBackup) { + c.LastStatus = "running" + }) + + start := time.Now() + r.logger.Printf("[INFO] Cross-drive backup starting: %s → %s (method: %s)", + stackName, cfg.DestinationPath, cfg.Method) + + if err := r.ValidateDestination(cfg.DestinationPath); err != nil { + r.updateStatus(stackName, "error", err.Error(), time.Since(start), "") + return fmt.Errorf("destination validation failed: %w", err) + } + + // Resolve HDD mounts for this app + mounts := r.stackProvider.GetStackHDDMounts(stackName) + if len(mounts) == 0 { + r.updateStatus(stackName, "error", "no HDD data paths found for this app", time.Since(start), "") + return fmt.Errorf("no HDD data paths found for %s", stackName) + } + + // Safety: destination must not overlap with any source + for _, m := range mounts { + if system.PathsOverlap(cfg.DestinationPath, m) { + msg := fmt.Sprintf("destination %s overlaps with source %s — aborted", cfg.DestinationPath, m) + r.updateStatus(stackName, "error", msg, time.Since(start), "") + return fmt.Errorf("%s", msg) + } + } + + var runErr error + switch cfg.Method { + case "rsync": + runErr = r.runRsyncBackup(ctx, stackName, cfg.DestinationPath, mounts) + case "restic": + runErr = r.runResticBackup(ctx, stackName, cfg.DestinationPath, mounts) + default: + runErr = fmt.Errorf("unknown backup method: %s", cfg.Method) + } + + duration := time.Since(start) + + if runErr != nil { + r.logger.Printf("[ERROR] Cross-drive backup failed: %s: %v", stackName, runErr) + r.updateStatus(stackName, "error", runErr.Error(), duration, "") + return runErr + } + + // Calculate backup size + var sizeHuman string + if cfg.Method == "rsync" { + destDir := filepath.Join(cfg.DestinationPath, "backups", "rsync", stackName) + if sz, err := dirSizeBytes(destDir); err == nil { + sizeHuman = humanizeBytes(sz) + } + } + + r.logger.Printf("[INFO] Cross-drive backup completed: %s (%s)", stackName, duration.Round(time.Second)) + r.updateStatus(stackName, "ok", "", duration, sizeHuman) + return nil +} + +// RunAllScheduled runs cross-drive backups for all apps matching the schedule. +// Runs sequentially (disk I/O bound). +func (r *CrossDriveRunner) RunAllScheduled(ctx context.Context, schedule string) error { + configs := r.sett.GetAllCrossDriveConfigs() + if len(configs) == 0 { + return nil + } + + var errs []string + for stackName, cfg := range configs { + if !cfg.Enabled { + continue + } + if cfg.Schedule != schedule { + continue + } + + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + if err := r.RunAppBackup(ctx, stackName); err != nil { + errs = append(errs, fmt.Sprintf("%s: %v", stackName, err)) + } + } + + if len(errs) > 0 { + return fmt.Errorf("cross-drive backup errors: %s", strings.Join(errs, "; ")) + } + return nil +} + +// IsRunning returns true if the given app's backup is currently running. +func (r *CrossDriveRunner) IsRunning(stackName string) bool { + r.mu.Lock() + defer r.mu.Unlock() + return r.running[stackName] +} + +// ValidateDestination checks that the destination path is a mount point, writable, +// and has sufficient free space (at least 100MB). +func (r *CrossDriveRunner) ValidateDestination(path string) error { + if path == "" { + return fmt.Errorf("destination path is empty") + } + if !system.IsMountPoint(path) { + return fmt.Errorf("destination %s is not a mount point", path) + } + if !system.IsWritable(path) { + return fmt.Errorf("destination %s is not writable", path) + } + di := system.GetDiskUsage(path) + if di != nil && di.AvailGB < 0.1 { + return fmt.Errorf("destination %s has insufficient free space (%.1f GB)", path, di.AvailGB) + } + return nil +} + +// --- rsync --- + +func (r *CrossDriveRunner) runRsyncBackup(ctx context.Context, stackName, destBase string, mounts []string) error { + destDir := filepath.Join(destBase, "backups", "rsync", stackName) + if err := os.MkdirAll(destDir, 0755); err != nil { + return fmt.Errorf("creating rsync dest dir: %w", err) + } + + for _, srcMount := range mounts { + // Preserve directory structure: strip the storage path prefix to get relative subpath + // e.g., /mnt/hdd_placeholder/storage/immich/ → storage/immich/ + rel := srcMount + // Find the topmost non-root segment of the mount path (after the mount point itself) + // Use a simple approach: keep everything from the first significant segment after /mnt/... + parts := strings.SplitN(strings.TrimPrefix(srcMount, "/"), "/", 3) + if len(parts) >= 3 { + rel = parts[2] // e.g., "storage/immich" + } else { + rel = filepath.Base(srcMount) + } + + dstPath := filepath.Join(destDir, rel) + if err := os.MkdirAll(dstPath, 0755); err != nil { + return fmt.Errorf("creating rsync destination: %w", err) + } + + // Ensure trailing slash on source for rsync semantics (copy contents, not the dir itself) + src := strings.TrimRight(srcMount, "/") + "/" + dst := strings.TrimRight(dstPath, "/") + "/" + + cmd := exec.CommandContext(ctx, "rsync", "-a", "--delete", src, dst) + r.logger.Printf("[DEBUG] rsync: %s → %s", src, dst) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("rsync failed for %s: %v (%s)", srcMount, err, strings.TrimSpace(string(out))) + } + } + return nil +} + +// --- restic --- + +func (r *CrossDriveRunner) runResticBackup(ctx context.Context, stackName, destBase string, mounts []string) error { + repoPath := filepath.Join(destBase, "backups", "restic") + + // Get or create the cross-drive restic password + password, err := r.sett.GetOrCreateCrossDrivePassword() + if err != nil { + return fmt.Errorf("getting restic password: %w", err) + } + + // Write password to a temp file (restic requires --password-file or env var) + pwFile, err := os.CreateTemp("", "felhom-crossdrive-pw-*") + if err != nil { + return fmt.Errorf("creating password file: %w", err) + } + defer os.Remove(pwFile.Name()) + if _, err := pwFile.WriteString(password); err != nil { + pwFile.Close() + return fmt.Errorf("writing password file: %w", err) + } + pwFile.Close() + + // Ensure repo is initialized + if err := r.ensureResticRepo(ctx, repoPath, pwFile.Name()); err != nil { + return err + } + + // Run restic backup + args := []string{ + "backup", "--repo", repoPath, + "--password-file", pwFile.Name(), + "--tag", stackName, + "--tag", "cross-drive", + } + args = append(args, mounts...) + + cmd := exec.CommandContext(ctx, "restic", args...) + r.logger.Printf("[DEBUG] restic backup: %v", args) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("restic backup failed: %v (%s)", err, strings.TrimSpace(string(out))) + } + return nil +} + +func (r *CrossDriveRunner) ensureResticRepo(ctx context.Context, repoPath, pwFile string) error { + // Check if repo config exists + if _, err := os.Stat(filepath.Join(repoPath, "config")); err == nil { + return nil // already initialized + } + + if err := os.MkdirAll(repoPath, 0755); err != nil { + return fmt.Errorf("creating restic repo dir: %w", err) + } + + cmd := exec.CommandContext(ctx, "restic", "init", "--repo", repoPath, "--password-file", pwFile) + r.logger.Printf("[INFO] Initializing cross-drive restic repo at %s", repoPath) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("restic init failed: %v (%s)", err, strings.TrimSpace(string(out))) + } + return nil +} + +// --- helpers --- + +func (r *CrossDriveRunner) updateStatus(stackName, status, errMsg string, duration time.Duration, sizeHuman string) { + _ = r.sett.UpdateCrossDriveStatus(stackName, func(c *settings.CrossDriveBackup) { + c.LastRun = time.Now().UTC().Format(time.RFC3339) + c.LastStatus = status + c.LastError = errMsg + c.LastDuration = duration.Round(time.Second).String() + if sizeHuman != "" { + c.LastSizeHuman = sizeHuman + } + }) +} + +// dirSizeBytes returns the total byte size of all files under path. +func dirSizeBytes(path string) (int64, error) { + var total int64 + err := filepath.Walk(path, func(_ string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + total += info.Size() + return nil + }) + return total, err +} + diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go index a796891..7559838 100644 --- a/controller/internal/settings/settings.go +++ b/controller/internal/settings/settings.go @@ -1,8 +1,10 @@ package settings import ( + "crypto/rand" "encoding/json" "fmt" + "io" "log" "os" "path/filepath" @@ -11,6 +13,9 @@ import ( "time" ) +// cryptoRandRead is a var so tests can stub it. +var cryptoRandRead = func(b []byte) (int, error) { return io.ReadFull(rand.Reader, b) } + // Settings holds customer-modifiable overrides and cached state. // Persisted as a single JSON file (settings.json) in the data directory. type Settings struct { @@ -32,11 +37,33 @@ type Settings struct { // Storage paths registry StoragePaths []StoragePath `json:"storage_paths,omitempty"` + + // Cross-drive restic repo password (auto-generated on first use) + CrossDriveResticPassword string `json:"cross_drive_restic_password,omitempty"` } // AppBackupPrefs holds per-app backup toggle state. type AppBackupPrefs struct { + // Existing: includes app data in nightly restic (same drive) Enabled bool `json:"enabled"` + + // Cross-drive backup to secondary storage + CrossDrive *CrossDriveBackup `json:"cross_drive,omitempty"` +} + +// CrossDriveBackup configures per-app backup to a secondary drive. +type CrossDriveBackup struct { + Enabled bool `json:"enabled"` + Method string `json:"method"` // "rsync" or "restic" + DestinationPath string `json:"destination_path"` // e.g., "/mnt/hdd_1" + Schedule string `json:"schedule"` // "daily", "weekly", "manual" + + // Runtime state (updated by backup runner, persisted for display) + LastRun string `json:"last_run,omitempty"` // RFC3339 + LastStatus string `json:"last_status,omitempty"` // "ok", "error", "running" + LastError string `json:"last_error,omitempty"` + LastDuration string `json:"last_duration,omitempty"` // "2m34s" + LastSizeHuman string `json:"last_size_human,omitempty"` // "1.2 GB" } // StoragePath represents a registered external storage location. @@ -204,13 +231,16 @@ func (s *Settings) IsAppBackupEnabled(stackName string) bool { } // SetAppBackup enables or disables backup for a stack and saves to disk. +// Preserves existing CrossDrive config. func (s *Settings) SetAppBackup(stackName string, enabled bool) error { s.mu.Lock() defer s.mu.Unlock() if s.AppBackup == nil { s.AppBackup = make(map[string]AppBackupPrefs) } - s.AppBackup[stackName] = AppBackupPrefs{Enabled: enabled} + existing := s.AppBackup[stackName] + existing.Enabled = enabled + s.AppBackup[stackName] = existing return s.save() } @@ -229,16 +259,111 @@ func (s *Settings) GetAppBackupMap() map[string]bool { } // SetAppBackupBulk updates backup prefs for all stacks at once and saves to disk. +// Preserves existing CrossDrive configs. func (s *Settings) SetAppBackupBulk(prefs map[string]bool) error { s.mu.Lock() defer s.mu.Unlock() - s.AppBackup = make(map[string]AppBackupPrefs, len(prefs)) + newMap := make(map[string]AppBackupPrefs, len(prefs)) for name, enabled := range prefs { - s.AppBackup[name] = AppBackupPrefs{Enabled: enabled} + existing := s.AppBackup[name] // preserves CrossDrive + existing.Enabled = enabled + newMap[name] = existing } + s.AppBackup = newMap return s.save() } +// GetAppBackupPrefs returns the full AppBackupPrefs for a stack. +func (s *Settings) GetAppBackupPrefs(stackName string) (AppBackupPrefs, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + if s.AppBackup == nil { + return AppBackupPrefs{}, false + } + prefs, ok := s.AppBackup[stackName] + return prefs, ok +} + +// GetCrossDriveConfig returns the cross-drive backup config for a stack (nil if not set). +func (s *Settings) GetCrossDriveConfig(stackName string) *CrossDriveBackup { + s.mu.RLock() + defer s.mu.RUnlock() + if s.AppBackup == nil { + return nil + } + prefs, ok := s.AppBackup[stackName] + if !ok || prefs.CrossDrive == nil { + return nil + } + cp := *prefs.CrossDrive + return &cp +} + +// SetCrossDriveConfig saves (or clears) the cross-drive backup config for a stack. +func (s *Settings) SetCrossDriveConfig(stackName string, cfg *CrossDriveBackup) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.AppBackup == nil { + s.AppBackup = make(map[string]AppBackupPrefs) + } + existing := s.AppBackup[stackName] + existing.CrossDrive = cfg + s.AppBackup[stackName] = existing + return s.save() +} + +// UpdateCrossDriveStatus updates runtime status fields for a cross-drive backup in-place. +// fn receives a pointer to the CrossDriveBackup (creates one if nil) and may mutate it. +func (s *Settings) UpdateCrossDriveStatus(stackName string, fn func(*CrossDriveBackup)) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.AppBackup == nil { + s.AppBackup = make(map[string]AppBackupPrefs) + } + existing := s.AppBackup[stackName] + if existing.CrossDrive == nil { + return nil // don't create config from thin air — just skip status update + } + fn(existing.CrossDrive) + s.AppBackup[stackName] = existing + return s.save() +} + +// GetAllCrossDriveConfigs returns all apps with a cross-drive config (enabled or not). +func (s *Settings) GetAllCrossDriveConfigs() map[string]*CrossDriveBackup { + s.mu.RLock() + defer s.mu.RUnlock() + result := make(map[string]*CrossDriveBackup) + for name, prefs := range s.AppBackup { + if prefs.CrossDrive != nil { + cp := *prefs.CrossDrive + result[name] = &cp + } + } + return result +} + +// GetOrCreateCrossDrivePassword returns the cross-drive restic password, +// generating and persisting one if it doesn't exist yet. +func (s *Settings) GetOrCreateCrossDrivePassword() (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.CrossDriveResticPassword != "" { + return s.CrossDriveResticPassword, nil + } + // Generate a random 32-byte password + buf := make([]byte, 32) + _, err := cryptoRandRead(buf) + if err != nil { + return "", fmt.Errorf("generating cross-drive restic password: %w", err) + } + s.CrossDriveResticPassword = fmt.Sprintf("%x", buf) + if err := s.save(); err != nil { + return "", err + } + return s.CrossDriveResticPassword, nil +} + // --- Storage Paths --- // GetStoragePaths returns a copy of all registered storage paths. diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 7855b37..efba338 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "gitea.dooplex.hu/admin/felhom-controller/internal/backup" "gitea.dooplex.hu/admin/felhom-controller/internal/scheduler" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" @@ -17,6 +18,7 @@ import ( "golang.org/x/crypto/bcrypt" ) + // DeployStoragePath extends StoragePath with free space data for the deploy dropdown. type DeployStoragePath struct { settings.StoragePath @@ -204,6 +206,45 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri if len(staleData) > 0 { data["StaleData"] = staleData } + + // Cross-drive backup config for this app + crossCfg := s.settings.GetCrossDriveConfig(name) + data["CrossDriveConfig"] = crossCfg + + // Other storage paths for destination dropdown (exclude the app's current storage path) + currentPath := "" + if storageInfo != nil { + currentPath = storageInfo.Path + } + var destPaths []DeployStoragePath + for _, sp := range s.settings.GetStoragePaths() { + if sp.Path == currentPath { + continue // skip the app's current storage — must be a DIFFERENT physical device + } + dp := DeployStoragePath{StoragePath: sp} + if di := system.GetDiskUsage(sp.Path); di != nil { + dp.FreeHuman = formatFreeSpace(di.AvailGB) + if di.TotalGB > 0 { + dp.FreePercent = di.AvailGB / di.TotalGB * 100 + } + } + destPaths = append(destPaths, dp) + } + data["BackupDestPaths"] = destPaths + + // Destination health warning + if crossCfg != nil && crossCfg.Enabled && crossCfg.DestinationPath != "" { + if !system.IsMountPoint(crossCfg.DestinationPath) || !system.IsWritable(crossCfg.DestinationPath) { + data["BackupDestWarning"] = fmt.Sprintf( + "A cél tárhely (%s) nem elérhető! Ellenőrizd a meghajtó csatlakozását.", + crossCfg.DestinationPath, + ) + } + } + + // Nightly backup toggle state + appBackupEnabled := s.settings.IsAppBackupEnabled(name) + data["AppBackupEnabled"] = appBackupEnabled } // Memory info for deploy page (only for non-deployed apps) @@ -243,6 +284,14 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri data["MemoryInfo"] = memInfo } + // Flash messages from cross-drive backup save redirect + if flash := r.URL.Query().Get("flash"); flash != "" { + data["FlashSuccess"] = flash + } + if flashErr := r.URL.Query().Get("flash_error"); flashErr != "" { + data["FlashError"] = flashErr + } + s.render(w, "deploy", data) } @@ -352,6 +401,71 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) { } } + // Build cross-drive summary + crossConfigs := s.settings.GetAllCrossDriveConfigs() + + // Build label lookup for dest paths + destLabels := make(map[string]string) + for _, sp := range storagePaths { + destLabels[sp.Path] = sp.Label + } + + for _, app := range fullStatus.AppDataInfo { + if !app.HasHDDData { + continue + } + cfg, hasCfg := crossConfigs[app.StackName] + if !hasCfg || cfg == nil { + fullStatus.UnconfiguredApps = append(fullStatus.UnconfiguredApps, backup.CrossDriveSummaryItem{ + StackName: app.StackName, + DisplayName: app.DisplayName, + }) + continue + } + + item := backup.CrossDriveSummaryItem{ + StackName: app.StackName, + DisplayName: app.DisplayName, + Method: cfg.Method, + DestPath: cfg.DestinationPath, + DestLabel: destLabels[cfg.DestinationPath], + Schedule: cfg.Schedule, + LastStatus: cfg.LastStatus, + SizeHuman: cfg.LastSizeHuman, + } + switch cfg.Method { + case "rsync": + item.MethodLabel = "rsync" + case "restic": + item.MethodLabel = "restic" + default: + item.MethodLabel = cfg.Method + } + switch cfg.Schedule { + case "daily": + item.ScheduleLabel = "Naponta" + case "weekly": + item.ScheduleLabel = "Hetente" + default: + item.ScheduleLabel = "Kézi" + } + if cfg.LastRun != "" { + if t, err := time.Parse(time.RFC3339, cfg.LastRun); err == nil { + loc, _ := time.LoadLocation("Europe/Budapest") + item.LastRunShort = t.In(loc).Format("01-02 15:04") + } + } + fullStatus.CrossDriveSummary = append(fullStatus.CrossDriveSummary, item) + + // Destination health warning + if cfg.Enabled && cfg.DestinationPath != "" { + if !system.IsMountPoint(cfg.DestinationPath) || !system.IsWritable(cfg.DestinationPath) { + fullStatus.CrossDriveWarnings = append(fullStatus.CrossDriveWarnings, + fmt.Sprintf("⚠️ %s mentési célja (%s) nem elérhető!", app.DisplayName, cfg.DestinationPath)) + } + } + } + data["Backup"] = fullStatus // Restic password for display @@ -399,6 +513,56 @@ func (s *Server) settingsAppBackupHandler(w http.ResponseWriter, r *http.Request http.Redirect(w, r, "/backups?flash=Alkalmaz%C3%A1s+ment%C3%A9si+be%C3%A1ll%C3%ADt%C3%A1sok+mentve.", http.StatusFound) } +// settingsCrossBackupHandler handles POST /settings/cross-backup/{name} +// Saves or updates the cross-drive backup configuration for an app. +func (s *Server) settingsCrossBackupHandler(w http.ResponseWriter, r *http.Request, name string) { + _ = r.ParseForm() + + enabled := r.FormValue("cross_drive_enabled") == "on" + method := r.FormValue("cross_drive_method") + destPath := r.FormValue("cross_drive_dest") + schedule := r.FormValue("cross_drive_schedule") + + // Validate method and schedule + if method != "rsync" && method != "restic" { + method = "rsync" + } + if schedule != "daily" && schedule != "weekly" && schedule != "manual" { + schedule = "daily" + } + + // Preserve existing runtime status fields + existing := s.settings.GetCrossDriveConfig(name) + + var cfg *settings.CrossDriveBackup + if destPath != "" { + cfg = &settings.CrossDriveBackup{ + Enabled: enabled, + Method: method, + DestinationPath: destPath, + Schedule: schedule, + } + if existing != nil { + cfg.LastRun = existing.LastRun + cfg.LastStatus = existing.LastStatus + cfg.LastError = existing.LastError + cfg.LastDuration = existing.LastDuration + cfg.LastSizeHuman = existing.LastSizeHuman + } + } + + if err := s.settings.SetCrossDriveConfig(name, cfg); err != nil { + s.logger.Printf("[ERROR] Failed to save cross-drive config for %s: %v", name, err) + http.Redirect(w, r, "/stacks/"+name+"/deploy?flash_error=Hiba+a+ment%C3%A9si+be%C3%A1ll%C3%ADt%C3%A1s+ment%C3%A9sakor", http.StatusFound) + return + } + + s.logger.Printf("[INFO] Cross-drive backup config saved for %s: method=%s dest=%s schedule=%s enabled=%v", + name, method, destPath, schedule, enabled) + + http.Redirect(w, r, "/stacks/"+name+"/deploy?flash=Ment%C3%A9si+be%C3%A1ll%C3%ADt%C3%A1s+mentve.", http.StatusFound) +} + func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index 54abfcc..09efc93 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -20,17 +20,18 @@ import ( ) type Server struct { - cfg *config.Config - stackMgr *stacks.Manager - cpuCollector *system.CPUCollector - backupMgr *backup.Manager - scheduler *scheduler.Scheduler - settings *settings.Settings - alertManager *AlertManager - notifier *notify.Notifier - logger *log.Logger - version string - tmpl *template.Template + cfg *config.Config + stackMgr *stacks.Manager + cpuCollector *system.CPUCollector + backupMgr *backup.Manager + crossDriveRunner *backup.CrossDriveRunner + scheduler *scheduler.Scheduler + settings *settings.Settings + alertManager *AlertManager + notifier *notify.Notifier + logger *log.Logger + version string + tmpl *template.Template sessions map[string]*session sessionsMu sync.RWMutex @@ -41,20 +42,21 @@ type Server struct { diskJob *activeDiskJob } -func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, sched *scheduler.Scheduler, sett *settings.Settings, alertMgr *AlertManager, notif *notify.Notifier, logger *log.Logger, version string) *Server { +func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, crossDrive *backup.CrossDriveRunner, sched *scheduler.Scheduler, sett *settings.Settings, alertMgr *AlertManager, notif *notify.Notifier, logger *log.Logger, version string) *Server { s := &Server{ - cfg: cfg, - stackMgr: stackMgr, - cpuCollector: cpuCollector, - backupMgr: backupMgr, - scheduler: sched, - settings: sett, - alertManager: alertMgr, - notifier: notif, - logger: logger, - version: version, - sessions: make(map[string]*session), - done: make(chan struct{}), + cfg: cfg, + stackMgr: stackMgr, + cpuCollector: cpuCollector, + backupMgr: backupMgr, + crossDriveRunner: crossDrive, + scheduler: sched, + settings: sett, + alertManager: alertMgr, + notifier: notif, + logger: logger, + version: version, + sessions: make(map[string]*session), + done: make(chan struct{}), } s.loadTemplates() go s.cleanupSessions() @@ -110,6 +112,9 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.settingsStorageLabelHandler(w, r) case path == "/settings/app-backup" && r.Method == http.MethodPost: s.settingsAppBackupHandler(w, r) + case strings.HasPrefix(path, "/settings/cross-backup/") && r.Method == http.MethodPost: + name := strings.TrimPrefix(path, "/settings/cross-backup/") + s.settingsCrossBackupHandler(w, r, name) case path == "/backup/restore" && r.Method == http.MethodPost: s.backupRestoreHandler(w, r) case path == "/settings/storage/init": diff --git a/controller/internal/web/templates/backups.html b/controller/internal/web/templates/backups.html index 1848568..52d1451 100644 --- a/controller/internal/web/templates/backups.html +++ b/controller/internal/web/templates/backups.html @@ -283,6 +283,57 @@ {{end}} + +{{if or .Backup.CrossDriveSummary .Backup.UnconfiguredApps}} +
Alkalmazás adatok biztonsági másolata külső meghajtóra (3-2-1 szabály).
+ + {{if .Backup.CrossDriveWarnings}} +