853 lines
36 KiB
Markdown
853 lines
36 KiB
Markdown
# TASK: Phase 3 — Storage Overview & Per-App Backup Toggles
|
||
|
||
**Version target:** controller 0.8.0
|
||
**Repos:** `deploy-felhom-compose` (controller), `app-catalog-felhom.eu` (metadata)
|
||
|
||
## Overview
|
||
|
||
Currently, restic backs up three fixed paths: the stacks directory (compose files), DB dumps, and `controller.yaml`. User data stored on the HDD (Paperless documents, media files, etc.) and Docker named volumes are **not** included.
|
||
|
||
Phase 3 adds:
|
||
1. **Storage overview** on the backup page — SSD, HDD, and backup repo usage at a glance
|
||
2. **Per-app backup discovery** — controller discovers each app's user data (HDD bind mounts + Docker volumes)
|
||
3. **Per-app backup toggles** — customer enables/disables backup per app on the backup page
|
||
4. **Dynamic backup paths** — `RunBackup()` includes enabled app data paths in the restic snapshot
|
||
5. **Restic password visibility** — password shown on backup page (behind toggle) + synced to hub for disaster recovery
|
||
6. **Limited app restore** — per-app restore from snapshot with warnings, self-service emergency recovery
|
||
|
||
---
|
||
|
||
## 1. Current State
|
||
|
||
### What restic backs up today
|
||
|
||
```go
|
||
// In backup.go RunBackup():
|
||
paths := []string{
|
||
m.cfg.Paths.StacksDir, // /opt/docker/stacks (compose files + .felhom.yml)
|
||
m.cfg.Paths.DBDumpDir, // /srv/backups/db-dumps (nightly dumps)
|
||
"/opt/docker/felhom-controller/controller.yaml",
|
||
}
|
||
```
|
||
|
||
### What's NOT backed up
|
||
- HDD user data (e.g., `/mnt/hdd_1/paperless/media`, `/mnt/hdd_1/romm/...`)
|
||
- Docker named volumes (e.g., `actualbudget_data`, `docmost-postgres_data`)
|
||
|
||
### Existing infrastructure we can reuse
|
||
- `parseComposeHDDMounts(composePath, hddPath)` — already discovers HDD bind mount paths per stack by parsing compose files
|
||
- `GetStackHDDData(name)` — returns HDD paths + sizes for a stack
|
||
- `HDDPath` struct with `Path`, `SizeBytes`, `SizeHuman`, `Exists`
|
||
- HDD is already mounted into the controller container (read-only, fine for restic)
|
||
- `.felhom.yml` has `resources.needs_hdd: true` flag
|
||
- Monitoring page already shows SSD/HDD disk usage
|
||
|
||
### Controller container volume mounts (relevant)
|
||
```yaml
|
||
- /opt/docker/stacks:/opt/docker/stacks # compose files
|
||
- /srv/backups:/srv/backups # restic repo + db dumps
|
||
- ${HDD_PATH}:${HDD_PATH}:ro # HDD (read-only)
|
||
- /var/run/docker.sock:/var/run/docker.sock:ro # Docker socket
|
||
```
|
||
|
||
HDD data is accessible from the controller container for restic reads. Docker volume paths (`/var/lib/docker/volumes/`) are NOT mounted — volume backup requires a different approach (deferred).
|
||
|
||
---
|
||
|
||
## 2. Storage Overview (Backup Page Enhancement)
|
||
|
||
### 2.1 New section: "Tárhely áttekintés" (Storage Overview)
|
||
|
||
Add as the **first section** on the backup page, above the current "Ütemezés" section. Shows storage utilization relevant to backups:
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ Tárhely áttekintés │
|
||
│ │
|
||
│ SSD (/) ████████░░░░░░░░░░░░ 42.1 / 512.0 GB (8%) │
|
||
│ Külső HDD ██████████████░░░░░░ 680.2 / 1000.0 GB │
|
||
│ (68%) │
|
||
│ │
|
||
│ Mentési tároló 2.4 GB (/srv/backups/restic-repo) │
|
||
│ DB mentések 142 MB (/srv/backups/db-dumps) │
|
||
│ Pillanatképek 14 │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 2.2 Data source
|
||
|
||
Reuse `system.GetInfo()` for SSD/HDD disk stats (already available in `SystemInfo`). Add backup repo stats from existing `RepoStats`. DB dump dir size from `ListDumpFiles()`.
|
||
|
||
No new backend code needed — just template work. The `FullBackupStatus` already contains `RepoStats` and the monitoring page already has `SystemInfo`.
|
||
|
||
Add `SystemInfo` to the backup page template data if not already present:
|
||
|
||
```go
|
||
type BackupPageData struct {
|
||
// existing fields...
|
||
SystemInfo *system.Info // for SSD/HDD bars
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 3. Per-App Data Discovery
|
||
|
||
### 3.1 App data classification
|
||
|
||
For each deployed app, classify its data into three categories:
|
||
|
||
| Category | Example | Discovery method | Backup support |
|
||
|---|---|---|---|
|
||
| **HDD bind mounts** | `/mnt/hdd_1/paperless/media` | `parseComposeHDDMounts()` (existing) | ✅ This phase |
|
||
| **Named Docker volumes** | `paperless-ngx_postgres_data` | Parse compose `volumes:` section | ⏳ Future (not mounted in controller) |
|
||
| **Config (stacks dir)** | `/opt/docker/stacks/paperless-ngx/` | Always backed up | ✅ Already done |
|
||
|
||
### 3.2 New struct: `AppBackupInfo`
|
||
|
||
```go
|
||
// AppBackupInfo holds backup-relevant data paths for a deployed app.
|
||
type AppBackupInfo struct {
|
||
StackName string // e.g., "paperless-ngx"
|
||
DisplayName string // e.g., "Paperless-ngx"
|
||
NeedsHDD bool // from .felhom.yml resources.needs_hdd
|
||
|
||
// HDD bind mounts (backupable now)
|
||
HDDPaths []AppDataPath
|
||
HDDTotalSize int64 // bytes
|
||
HDDSizeHuman string
|
||
|
||
// Docker named volumes (info only, not backupable yet)
|
||
DockerVolumes []AppDockerVolume
|
||
|
||
// Backup state
|
||
BackupEnabled bool // from settings.json
|
||
HasHDDData bool // HDDPaths is non-empty
|
||
HasDBDump bool // app has a database container (already covered by DB dump)
|
||
}
|
||
|
||
type AppDataPath struct {
|
||
HostPath string // e.g., "/mnt/hdd_1/paperless/media"
|
||
Exists bool
|
||
SizeHuman string
|
||
SizeBytes int64
|
||
}
|
||
|
||
type AppDockerVolume struct {
|
||
Name string // e.g., "paperless-ngx_postgres_data"
|
||
Contains string // Human description (from .felhom.yml, or empty)
|
||
}
|
||
```
|
||
|
||
### 3.3 Discovery: `DiscoverAppData()`
|
||
|
||
New function in `internal/backup/appdata.go`:
|
||
|
||
```go
|
||
// DiscoverAppData discovers backup-relevant data for all deployed apps.
|
||
func DiscoverAppData(stackProvider StackDataProvider, hddPath string, backupPrefs map[string]bool, discoveredDBs []DiscoveredDB) []AppBackupInfo {
|
||
var result []AppBackupInfo
|
||
|
||
for _, stack := range stackProvider.ListDeployedStacks() {
|
||
info := AppBackupInfo{
|
||
StackName: stack.Name,
|
||
DisplayName: stack.DisplayName,
|
||
NeedsHDD: stack.NeedsHDD,
|
||
}
|
||
|
||
// Discover HDD bind mounts (reuse existing parser)
|
||
hddMounts := parseComposeHDDMounts(stack.ComposePath, hddPath)
|
||
for _, mount := range hddMounts {
|
||
path := AppDataPath{HostPath: mount}
|
||
if fi, err := os.Stat(mount); err == nil && fi.IsDir() {
|
||
path.Exists = true
|
||
path.SizeBytes = getDirSizeBytes(mount)
|
||
path.SizeHuman = getDirSizeHuman(mount)
|
||
}
|
||
info.HDDPaths = append(info.HDDPaths, path)
|
||
info.HDDTotalSize += path.SizeBytes
|
||
}
|
||
info.HDDSizeHuman = humanizeBytes(info.HDDTotalSize)
|
||
info.HasHDDData = len(info.HDDPaths) > 0
|
||
|
||
// Discover Docker named volumes (from compose file)
|
||
info.DockerVolumes = parseComposeNamedVolumes(stack.ComposePath)
|
||
|
||
// Check if app has a DB container (already backed up via DB dump)
|
||
for _, db := range discoveredDBs {
|
||
if db.StackName == stack.Name {
|
||
info.HasDBDump = true
|
||
break
|
||
}
|
||
}
|
||
|
||
info.BackupEnabled = backupPrefs[stack.Name]
|
||
|
||
if info.HasHDDData || len(info.DockerVolumes) > 0 {
|
||
result = append(result, info)
|
||
}
|
||
}
|
||
|
||
return result
|
||
}
|
||
```
|
||
|
||
### 3.4 Named volume parser: `parseComposeNamedVolumes()`
|
||
|
||
```go
|
||
// parseComposeNamedVolumes extracts named Docker volumes from a docker-compose.yml.
|
||
// Parses the top-level `volumes:` key. Skips external volumes.
|
||
func parseComposeNamedVolumes(composePath string) []AppDockerVolume {
|
||
data, err := os.ReadFile(composePath)
|
||
if err != nil { return nil }
|
||
|
||
var compose struct {
|
||
Volumes map[string]interface{} `yaml:"volumes"`
|
||
}
|
||
if err := yaml.Unmarshal(data, &compose); err != nil { return nil }
|
||
|
||
var volumes []AppDockerVolume
|
||
for name, cfg := range compose.Volumes {
|
||
// Skip external volumes (networks like traefik-public)
|
||
if cfgMap, ok := cfg.(map[string]interface{}); ok {
|
||
if ext, ok := cfgMap["external"]; ok && ext == true {
|
||
continue
|
||
}
|
||
}
|
||
volumes = append(volumes, AppDockerVolume{Name: name})
|
||
}
|
||
return volumes
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 4. Per-App Backup Toggles
|
||
|
||
### 4.1 Storage in `settings.json`
|
||
|
||
Add to the `Settings` struct:
|
||
|
||
```go
|
||
type Settings struct {
|
||
// ... existing fields ...
|
||
|
||
// Per-app backup preferences
|
||
AppBackup map[string]AppBackupPrefs `json:"app_backup,omitempty"`
|
||
}
|
||
|
||
type AppBackupPrefs struct {
|
||
Enabled bool `json:"enabled"`
|
||
}
|
||
```
|
||
|
||
Add getter/setter methods:
|
||
|
||
```go
|
||
func (s *Settings) IsAppBackupEnabled(stackName string) bool
|
||
func (s *Settings) SetAppBackup(stackName string, enabled bool) error
|
||
func (s *Settings) GetAppBackupMap() map[string]bool // stack_name -> enabled
|
||
```
|
||
|
||
### 4.2 Backup page: "Alkalmazás adatok" section
|
||
|
||
New section on the backup page, below "Tároló" (repo info), above snapshot history:
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ Alkalmazás adatok │
|
||
│ │
|
||
│ Az alkalmazások felhasználói adatainak biztonsági mentése. │
|
||
│ │
|
||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||
│ │ [✅] Paperless-ngx 2.4 GB (HDD) │ │
|
||
│ │ /mnt/hdd_1/paperless/media (2.1 GB) │ │
|
||
│ │ /mnt/hdd_1/paperless/consume (312 MB) │ │
|
||
│ │ 📦 Docker kötet: paperless-ngx_data (nem mentett) │ │
|
||
│ ├──────────────────────────────────────────────────────────┤ │
|
||
│ │ [☐] RoMM 8.7 GB (HDD) │ │
|
||
│ │ /mnt/hdd_1/romm/library (8.5 GB) │ │
|
||
│ │ /mnt/hdd_1/romm/assets (200 MB) │ │
|
||
│ ├──────────────────────────────────────────────────────────┤ │
|
||
│ │ [—] ActualBudget │ │
|
||
│ │ 📦 Docker kötet: actualbudget_data (nem mentett) │ │
|
||
│ │ ℹ️ Adatbázis mentés naponta (DB dump) │ │
|
||
│ └──────────────────────────────────────────────────────────┘ │
|
||
│ │
|
||
│ [Mentés] │
|
||
│ │
|
||
│ ⚠️ Docker kötetek mentése jelenleg nem támogatott. │
|
||
│ Az adatbázisokat az automatikus DB dump menti naponta. │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
**UI behavior:**
|
||
- Toggle checkbox per app — only enabled for apps with HDD data
|
||
- Apps with only Docker volumes show info state (no checkbox)
|
||
- Apps with databases show "DB dump naponta" note
|
||
- "Mentés" button saves all toggles at once via `POST /settings/app-backup`
|
||
- Sizes update on page refresh (from the cached `RefreshCache`)
|
||
|
||
### 4.3 Route
|
||
|
||
| Method | Path | Auth? | Handler |
|
||
|--------|------|-------|---------|
|
||
| POST | `/settings/app-backup` | Yes | Save per-app backup toggles |
|
||
|
||
**Handler:**
|
||
1. Parse form: checkboxes named `backup_{stack_name}` (checked = enabled)
|
||
2. For each app with HDD data: set enabled/disabled in settings
|
||
3. Save to `settings.json`
|
||
4. Refresh backup cache
|
||
5. Redirect to `/backups` with success flash: "Alkalmazás mentési beállítások mentve."
|
||
|
||
---
|
||
|
||
## 5. Dynamic Backup Paths in RunBackup
|
||
|
||
### 5.1 Modify `RunBackup()` in `backup.go`
|
||
|
||
```go
|
||
// Base paths (always backed up)
|
||
paths := []string{
|
||
m.cfg.Paths.StacksDir,
|
||
m.cfg.Paths.DBDumpDir,
|
||
"/opt/docker/felhom-controller/controller.yaml",
|
||
}
|
||
|
||
// Per-app HDD data paths (from settings)
|
||
appPaths := m.resolveAppBackupPaths()
|
||
paths = append(paths, appPaths...)
|
||
|
||
m.logger.Printf("[INFO] Backup paths (%d total): %v", len(paths), paths)
|
||
```
|
||
|
||
### 5.2 Path resolver
|
||
|
||
```go
|
||
func (m *Manager) resolveAppBackupPaths() []string {
|
||
appBackupMap := m.settings.GetAppBackupMap()
|
||
if len(appBackupMap) == 0 {
|
||
return nil
|
||
}
|
||
|
||
var paths []string
|
||
seen := make(map[string]bool)
|
||
|
||
for stackName, enabled := range appBackupMap {
|
||
if !enabled {
|
||
continue
|
||
}
|
||
composePath, ok := m.stackProvider.GetStackComposePath(stackName)
|
||
if !ok {
|
||
m.logger.Printf("[WARN] App backup enabled for %s but stack not found", stackName)
|
||
continue
|
||
}
|
||
hddMounts := parseComposeHDDMounts(composePath, m.cfg.Paths.HDDPath)
|
||
for _, mount := range hddMounts {
|
||
if seen[mount] {
|
||
continue
|
||
}
|
||
if _, err := os.Stat(mount); err == nil {
|
||
paths = append(paths, mount)
|
||
seen[mount] = true
|
||
m.logger.Printf("[DEBUG] Including app data: %s (from %s)", mount, stackName)
|
||
}
|
||
}
|
||
}
|
||
return paths
|
||
}
|
||
```
|
||
|
||
### 5.3 Stack data provider interface
|
||
|
||
To avoid circular imports between `backup` and `stacks` packages:
|
||
|
||
```go
|
||
// In backup package
|
||
type StackDataProvider interface {
|
||
GetStackComposePath(name string) (composePath string, ok bool)
|
||
ListDeployedStacks() []StackSummary
|
||
}
|
||
|
||
type StackSummary struct {
|
||
Name string
|
||
DisplayName string
|
||
ComposePath string
|
||
NeedsHDD bool
|
||
}
|
||
```
|
||
|
||
Implement this interface with a thin wrapper in `main.go` or as an adapter:
|
||
|
||
```go
|
||
// In main.go
|
||
type stackAdapter struct{ mgr *stacks.Manager }
|
||
|
||
func (a *stackAdapter) GetStackComposePath(name string) (string, bool) {
|
||
s, ok := a.mgr.GetStack(name)
|
||
if !ok { return "", false }
|
||
return s.ComposePath, true
|
||
}
|
||
func (a *stackAdapter) ListDeployedStacks() []backup.StackSummary { ... }
|
||
```
|
||
|
||
### 5.4 Update `FullBackupStatus`
|
||
|
||
```go
|
||
type FullBackupStatus struct {
|
||
// ... existing ...
|
||
AppDataInfo []AppBackupInfo // per-app backup info for backup page
|
||
AppDataPaths []string // resolved app data paths (for "Mentett útvonalak" display)
|
||
AppDataSizeHuman string // total size of enabled app data
|
||
}
|
||
```
|
||
|
||
Populate `AppDataInfo` during `RefreshCache()` (runs periodically).
|
||
|
||
---
|
||
|
||
## 6. App Catalog Metadata Enhancement (Optional)
|
||
|
||
### 6.1 Add `backup` section to `.felhom.yml`
|
||
|
||
While the controller can discover HDD paths from compose files, explicit metadata gives better descriptions:
|
||
|
||
```yaml
|
||
# In .felhom.yml for paperless-ngx
|
||
backup:
|
||
description: "Dokumentumok, beolvasott fájlok és feldolgozási queue"
|
||
data_paths:
|
||
- "${HDD_PATH}/paperless/media"
|
||
- "${HDD_PATH}/paperless/consume"
|
||
- "${HDD_PATH}/paperless/export"
|
||
docker_volumes:
|
||
- name: "paperless-ngx_data"
|
||
contains: "Alkalmazás belső adatai"
|
||
- name: "paperless-ngx_postgres_data"
|
||
contains: "Adatbázis (DB dump menti)"
|
||
```
|
||
|
||
**Optional** — the controller falls back to `parseComposeHDDMounts()` auto-discovery if the `backup` section doesn't exist.
|
||
|
||
### 6.2 Update `Metadata` struct
|
||
|
||
```go
|
||
type Metadata struct {
|
||
// ... existing fields ...
|
||
Backup BackupMetadata `yaml:"backup" json:"backup"`
|
||
}
|
||
|
||
type BackupMetadata struct {
|
||
Description string `yaml:"description" json:"description"`
|
||
DataPaths []string `yaml:"data_paths" json:"data_paths"`
|
||
DockerVolumes []VolumeDescription `yaml:"docker_volumes" json:"docker_volumes"`
|
||
}
|
||
```
|
||
|
||
### 6.3 Discovery priority
|
||
|
||
1. If `.felhom.yml` has `backup.data_paths` → use those (resolve `${HDD_PATH}`)
|
||
2. Else → fall back to `parseComposeHDDMounts()` auto-discovery
|
||
3. Docker volumes: merge `.felhom.yml` descriptions with parsed compose volumes
|
||
|
||
---
|
||
|
||
## 7. Restic Password Visibility
|
||
|
||
### 7.1 Problem
|
||
|
||
The restic password is auto-generated on first backup and stored at `/opt/docker/felhom-controller/data/restic-password` inside the `controller-data` Docker named volume. If the SSD dies, that password is gone and ALL backup snapshots become permanently inaccessible. The customer currently has zero visibility into this password.
|
||
|
||
### 7.2 Password display on backup page
|
||
|
||
Add a "Titkosítási kulcs" (Encryption Key) section on the backup page, within the storage/repo area:
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ Titkosítási kulcs │
|
||
│ │
|
||
│ A mentések titkosítva vannak. A visszaállításhoz szükség │
|
||
│ van erre a kulcsra. │
|
||
│ │
|
||
│ [••••••••••••••••••••••] [👁 Megjelenítés] [📋 Másolás] │
|
||
│ │
|
||
│ ⚠️ Mentse el biztonságos helyre! A kulcs nélkül a │
|
||
│ biztonsági mentések NEM állíthatók vissza. │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
**Implementation:**
|
||
|
||
```go
|
||
// Add to ResticManager
|
||
func (r *ResticManager) GetPassword() (string, error) {
|
||
data, err := os.ReadFile(r.passwordFile)
|
||
if err != nil {
|
||
return "", fmt.Errorf("reading restic password: %w", err)
|
||
}
|
||
return strings.TrimSpace(string(data)), nil
|
||
}
|
||
```
|
||
|
||
**Template behavior:**
|
||
- Password field is masked by default (`type="password"`)
|
||
- "Megjelenítés" button toggles visibility (JS, `type="text"`)
|
||
- "Másolás" button copies to clipboard (JS, `navigator.clipboard.writeText()`)
|
||
- Password is loaded in the template data (served over existing auth-protected page)
|
||
|
||
### 7.3 Password sync to hub
|
||
|
||
Add password to the periodic hub report so the operator has a backup copy:
|
||
|
||
```go
|
||
// In the hub report payload (already sent every 15m)
|
||
type ReportPayload struct {
|
||
// ... existing fields ...
|
||
ResticPassword string `json:"restic_password,omitempty"` // encrypted key for recovery
|
||
}
|
||
```
|
||
|
||
**Hub side** — store the password in the customer record. The hub is operator-controlled and already stores customer metadata. Add a `restic_password` field to the hub database for each customer.
|
||
|
||
**Sync trigger:**
|
||
- On every periodic report (piggyback on existing 15-minute push)
|
||
- On startup (controller init)
|
||
|
||
**Security note:** The hub API is already authenticated with `Bearer` token. The password travels over HTTPS (Cloudflare Tunnel). This is safer than the password being in a single Docker named volume with no redundancy.
|
||
|
||
---
|
||
|
||
## 8. Limited App Restore (Self-Service Emergency)
|
||
|
||
### 8.1 Approach
|
||
|
||
Provide a per-app "Visszaállítás" (Restore) feature on the backup page that restores an entire app's HDD data from a selected snapshot. This is a self-service emergency tool — customers can recover from accidental deletions without waiting for support.
|
||
|
||
**Scope:** Only restore HDD bind mount data for apps that have backup enabled. Compose files and controller.yaml are always restorable (they're in every snapshot).
|
||
|
||
### 8.2 UI: Restore section on backup page
|
||
|
||
Add a "Visszaállítás" section below the snapshot history:
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ Visszaállítás │
|
||
│ │
|
||
│ Alkalmazás: [▾ Paperless-ngx ] │
|
||
│ Pillanatkép: [▾ 2026-02-16 03:00 (legutóbbi) ] │
|
||
│ │
|
||
│ Visszaállítandó útvonalak: │
|
||
│ /mnt/hdd_1/paperless/media │
|
||
│ /mnt/hdd_1/paperless/consume │
|
||
│ │
|
||
│ ⚠️ FIGYELMEZTETÉS │
|
||
│ A visszaállítás FELÜLÍRJA a kiválasztott alkalmazás │
|
||
│ jelenlegi adatait a mentés pillanatának állapotával. │
|
||
│ Ez a művelet NEM vonható vissza! │
|
||
│ │
|
||
│ Javasoljuk az alkalmazás leállítását a visszaállítás előtt. │
|
||
│ │
|
||
│ [☐] Megértettem, visszaállítás saját felelősségre. │
|
||
│ │
|
||
│ [🔄 Visszaállítás indítása] (disabled until checkbox) │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 8.3 Restore flow
|
||
|
||
1. User selects app + snapshot from dropdowns
|
||
2. JS fetches app's HDD paths (already in `AppBackupInfo`) and shows them
|
||
3. User checks the "saját felelősségre" checkbox — this enables the button
|
||
4. `POST /backup/restore` with `stack_name`, `snapshot_id`
|
||
5. Handler validates:
|
||
- Stack exists and has backup enabled
|
||
- Snapshot ID exists in restic repo
|
||
- HDD paths are valid
|
||
6. Controller runs `restic restore` for the specific paths
|
||
7. Redirect to backup page with result flash message
|
||
|
||
### 8.4 Backend: `RestoreAppData()`
|
||
|
||
```go
|
||
// In ResticManager
|
||
func (r *ResticManager) RestoreAppData(snapshotID string, paths []string) error {
|
||
// Build --include flags for each HDD path
|
||
args := []string{
|
||
"restore", snapshotID,
|
||
"--target", "/", // restore to original absolute paths
|
||
"--password-file", r.passwordFile,
|
||
"--repo", r.repoPath,
|
||
"--cache-dir", r.cacheDir,
|
||
}
|
||
for _, p := range paths {
|
||
args = append(args, "--include", p)
|
||
}
|
||
|
||
r.logger.Printf("[WARN] RESTORE started: snapshot=%s, paths=%v", snapshotID, paths)
|
||
|
||
cmd := exec.Command("restic", args...)
|
||
output, err := cmd.CombinedOutput()
|
||
if err != nil {
|
||
r.logger.Printf("[ERROR] Restore failed: %v, output: %s", err, string(output))
|
||
return fmt.Errorf("restic restore failed: %w", err)
|
||
}
|
||
|
||
r.logger.Printf("[INFO] RESTORE completed: snapshot=%s, paths=%v", snapshotID, paths)
|
||
return nil
|
||
}
|
||
```
|
||
|
||
### 8.5 Backup Manager: `RestoreApp()`
|
||
|
||
```go
|
||
func (m *Manager) RestoreApp(stackName, snapshotID string) error {
|
||
// Validate app has backup enabled
|
||
if !m.settings.IsAppBackupEnabled(stackName) {
|
||
return fmt.Errorf("backup not enabled for %s", stackName)
|
||
}
|
||
|
||
// Resolve HDD paths for this app
|
||
composePath, ok := m.stackProvider.GetStackComposePath(stackName)
|
||
if !ok {
|
||
return fmt.Errorf("stack %s not found", stackName)
|
||
}
|
||
hddMounts := parseComposeHDDMounts(composePath, m.cfg.Paths.HDDPath)
|
||
if len(hddMounts) == 0 {
|
||
return fmt.Errorf("no HDD data paths found for %s", stackName)
|
||
}
|
||
|
||
// Validate snapshot exists
|
||
snapshots, err := m.restic.ListSnapshots(100)
|
||
if err != nil {
|
||
return fmt.Errorf("listing snapshots: %w", err)
|
||
}
|
||
found := false
|
||
for _, s := range snapshots {
|
||
if s.ID == snapshotID {
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if !found {
|
||
return fmt.Errorf("snapshot %s not found", snapshotID)
|
||
}
|
||
|
||
// Send notification before restore
|
||
m.notify("restore_started", fmt.Sprintf("Visszaállítás indult: %s (snapshot: %s)", stackName, snapshotID))
|
||
|
||
// Execute restore
|
||
if err := m.restic.RestoreAppData(snapshotID, hddMounts); err != nil {
|
||
m.notify("restore_failed", fmt.Sprintf("Visszaállítás sikertelen: %s — %v", stackName, err))
|
||
return err
|
||
}
|
||
|
||
m.notify("restore_completed", fmt.Sprintf("Visszaállítás kész: %s (snapshot: %s)", stackName, snapshotID))
|
||
return nil
|
||
}
|
||
```
|
||
|
||
### 8.6 Route
|
||
|
||
| Method | Path | Auth? | Handler |
|
||
|--------|------|-------|---------|
|
||
| POST | `/backup/restore` | Yes | Restore app HDD data from snapshot |
|
||
| GET | `/api/backup/snapshots` | Yes | List snapshots (JSON, for restore dropdown) |
|
||
|
||
**Restore handler:**
|
||
1. Parse form: `stack_name`, `snapshot_id`
|
||
2. Validate both fields present
|
||
3. Call `backupManager.RestoreApp(stackName, snapshotID)`
|
||
4. On success: redirect to `/backups` with flash "✅ {app} visszaállítva ({snapshot})."
|
||
5. On error: redirect to `/backups` with error flash "❌ Visszaállítás sikertelen: {error}"
|
||
|
||
**Snapshots JSON handler** (for dynamic dropdown population):
|
||
```go
|
||
func (s *Server) apiBackupSnapshotsHandler(w http.ResponseWriter, r *http.Request) {
|
||
snapshots, err := s.backupManager.ListSnapshots(50)
|
||
// return as JSON array
|
||
}
|
||
```
|
||
|
||
### 8.7 Important constraints
|
||
|
||
- **HDD must NOT be read-only for restore.** The controller currently mounts HDD as `:ro`. For restore to work, the mount must be `:rw` OR a separate restore path must be used.
|
||
- Change HDD mount to `:rw` (simpler, but less safe for normal operation)
|
||
|
||
- **Restore only for apps with enabled backup** — if the user never enabled backup for an app, there's no data to restore.
|
||
|
||
- **No concurrent restore + backup** — add a mutex/lock to prevent backup and restore running simultaneously.
|
||
|
||
- **Notification events:** Add `restore_started`, `restore_completed`, `restore_failed` event types to the notification system.
|
||
|
||
---
|
||
|
||
## 9. Implementation Order
|
||
|
||
### Step 1: Storage overview on backup page
|
||
- Add `SystemInfo` to backup page template data
|
||
- Add "Tárhely áttekintés" section to `backups.html` with SSD/HDD bars + backup repo stats
|
||
- Reuse existing CSS for storage bars (from monitoring page)
|
||
- **Test:** Backup page shows storage usage bars and repo stats
|
||
|
||
### Step 2: Restic password visibility
|
||
- Add `GetPassword()` to ResticManager
|
||
- Add "Titkosítási kulcs" section to `backups.html` with masked password, show/copy buttons
|
||
- Add password to hub report payload (`restic_password` field)
|
||
- Hub side: store password in customer record (new DB column or JSON field)
|
||
- **Test:** Password visible on backup page → copy works → hub DB has password after report cycle
|
||
|
||
### Step 3: App data discovery + settings struct
|
||
- Create `internal/backup/appdata.go` with `AppBackupInfo`, `DiscoverAppData()`, `parseComposeNamedVolumes()`
|
||
- Define `StackDataProvider` interface in backup package
|
||
- Add `AppBackupPrefs` + getter/setter to settings.go
|
||
- Create stack adapter in main.go
|
||
- **Test:** Call `DiscoverAppData()`, verify it finds paperless HDD paths
|
||
|
||
### Step 4: Per-app toggles on backup page
|
||
- Add "Alkalmazás adatok" section to `backups.html` with toggle checkboxes per app
|
||
- Add `POST /settings/app-backup` handler
|
||
- Include `AppDataInfo` in backup page template data (populate during `RefreshCache`)
|
||
- **Test:** Toggle paperless backup on → save → check settings.json → page shows updated state
|
||
|
||
### Step 5: Dynamic backup paths in RunBackup
|
||
- Add `resolveAppBackupPaths()` to backup Manager
|
||
- Modify `RunBackup()` to include enabled app HDD paths
|
||
- Update `BackupPaths` display in `FullBackupStatus`
|
||
- **Test:** Enable paperless backup → trigger manual backup → verify restic snapshot includes HDD data paths via `restic snapshots --json`
|
||
|
||
### Step 6: Limited app restore
|
||
- Change HDD container mount from `:ro` to `:rw` in controller docker-compose.yml
|
||
- Add `RestoreAppData()` to ResticManager
|
||
- Add `RestoreApp()` to backup Manager with validation and notifications
|
||
- Add `POST /backup/restore` handler and `GET /api/backup/snapshots` JSON endpoint
|
||
- Add "Visszaállítás" section to `backups.html` with app/snapshot dropdowns, warnings, confirmation checkbox
|
||
- Add restore notification events (`restore_started`, `restore_completed`, `restore_failed`)
|
||
- Add restore mutex to prevent concurrent backup/restore operations
|
||
- **Test:** Enable paperless backup → backup → delete test file → restore → verify file is back
|
||
|
||
### Step 7: Metadata enhancement (optional)
|
||
- Add `backup` section to `.felhom.yml` for relevant apps in app-catalog repo
|
||
- Update `Metadata` struct in `metadata.go`
|
||
- Update discovery to prefer metadata over compose parsing
|
||
- **Test:** Apps with `backup.data_paths` show correct descriptions and paths
|
||
|
||
### Step 8: Cleanup & version bump
|
||
- Update CONTEXT.md / CHANGELOG
|
||
- Bump to 0.8.0
|
||
- Build + deploy
|
||
- **Test end-to-end:** Full cycle — enable app backup → nightly backup runs → restore works → password visible and synced to hub
|
||
|
||
---
|
||
|
||
## 10. Files to Create / Modify
|
||
|
||
### New files:
|
||
- `controller/internal/backup/appdata.go` — `AppBackupInfo`, `DiscoverAppData()`, `parseComposeNamedVolumes()`, `StackDataProvider` interface
|
||
- `controller/internal/backup/restore.go` — `RestoreAppData()`, `RestoreApp()`, restore mutex
|
||
|
||
### Modified files:
|
||
- `controller/internal/backup/backup.go` — `RunBackup()` uses dynamic paths, `RefreshCache()` builds `AppDataInfo`, add `StackDataProvider` field + `resolveAppBackupPaths()`, restore mutex integration
|
||
- `controller/internal/backup/restic.go` — Add `GetPassword()`, `RestoreAppData()` methods to ResticManager
|
||
- `controller/internal/settings/settings.go` — `AppBackupPrefs` struct, getter/setter methods
|
||
- `controller/internal/web/handlers.go` — `POST /settings/app-backup` handler, `POST /backup/restore` handler, `GET /api/backup/snapshots` JSON endpoint, pass `AppDataInfo` + password to backup page
|
||
- `controller/internal/web/templates/backups.html` — "Tárhely áttekintés" section, "Titkosítási kulcs" section, "Alkalmazás adatok" section, "Visszaállítás" section
|
||
- `controller/internal/web/templates/style.css` — Styles for app backup cards, toggle rows, restore section, password field
|
||
- `controller/internal/notify/notifier.go` — Add `restore_started`, `restore_completed`, `restore_failed` event types
|
||
- `controller/internal/stacks/metadata.go` — Add `BackupMetadata` to `Metadata` struct (optional)
|
||
- `controller/cmd/controller/main.go` — Create stack adapter, wire into backup manager
|
||
- Controller `docker-compose.yml` — Change HDD mount from `:ro` to `:rw`
|
||
- Hub report payload struct — Add `restic_password` field
|
||
- Hub API/DB — Store `restic_password` per customer (hub-side change)
|
||
|
||
### App catalog (optional):
|
||
- `paperless-ngx/.felhom.yml` — Add `backup` section with data paths and volume descriptions
|
||
- Other apps with HDD data as applicable
|
||
|
||
---
|
||
|
||
## 11. Design Decisions & Notes
|
||
|
||
### Why only HDD data in this phase?
|
||
|
||
Docker named volumes are stored at `/var/lib/docker/volumes/` which is NOT mounted into the controller container. Backing them up would require either:
|
||
- Mounting `/var/lib/docker/volumes` into the controller (security concern, large mount)
|
||
- Running restic via a temporary Docker container (complex orchestration)
|
||
- Using `docker cp` to export data (slow, no incremental)
|
||
|
||
For most apps, the important user data is on HDD (documents, media, photos). Database data in named volumes is covered by the nightly DB dump. Config-only volumes are small and recoverable from compose + deploy fields.
|
||
|
||
### Why reuse `parseComposeHDDMounts` over explicit metadata?
|
||
|
||
Auto-discovery from compose files is zero-config for existing apps. The parser is already proven (used in the orphan delete workflow). Adding `backup.data_paths` to `.felhom.yml` is optional polish that gives better descriptions.
|
||
|
||
### Why toggles on the backup page instead of per-app detail?
|
||
|
||
Backup is a cross-cutting concern. Having all toggles in one place on the backup page gives the customer a complete picture of what's protected. Individual app detail pages could show backup status and link to the backup page.
|
||
|
||
### Backup size awareness
|
||
|
||
Enabling app data backup can dramatically increase backup duration and repo size (especially media files with low dedup potential). The repo lives on SSD (`/srv/backups/restic-repo`). Show an info banner:
|
||
|
||
"ℹ️ Az alkalmazás adatok mentésének bekapcsolása megnöveli a mentési időt és a tárhelyigényt."
|
||
|
||
The existing health check already warns on SSD disk usage > 80%.
|
||
|
||
### HDD read-only mount is fine
|
||
|
||
The controller mounts HDD as `:ro`. Restic only reads files to create snapshots — read-only access is sufficient and more secure.
|
||
|
||
### Circular import avoidance
|
||
|
||
The backup package needs stack data but shouldn't import the stacks package. Use a `StackDataProvider` interface defined in the backup package, implemented by a thin adapter in `main.go`. This keeps the dependency graph clean.
|
||
|
||
### Future considerations
|
||
- **Docker volume backup** — Phase 4: mount `/var/lib/docker/volumes:ro` into controller, or use a sidecar approach
|
||
- **Offsite backup** — the "Távoli másolat" placeholder: second restic repo to B2/S3/SFTP
|
||
- **Selective file restore** — browse individual files within snapshots (restic `ls` + `restore --include`). Phase 3 does whole-app restore only.
|
||
- **Backup scheduling per app** — different retention or frequency for different apps
|
||
- **Backup size quota warnings** — alert when backup repo exceeds a configurable threshold
|
||
|
||
### Encryption architecture
|
||
|
||
All restic backups are encrypted at rest — restic has no unencrypted mode. This is a feature for a managed service ("your backups are encrypted even if someone accesses the drive"). The password is:
|
||
|
||
1. Auto-generated (32 random bytes, base64url encoded) on first backup
|
||
2. Stored locally at `/opt/docker/felhom-controller/data/restic-password` (Docker named volume)
|
||
3. Displayed on the backup page behind a toggle (customer can copy/save it)
|
||
4. Synced to hub via periodic report (operator has recovery copy)
|
||
|
||
If the SSD fails, the password can be retrieved from hub to access the offsite/HDD backup repo.
|
||
|
||
### Why limited restore instead of full file browser?
|
||
|
||
A full file browser (restic `ls` per snapshot, pick individual files) adds significant UI complexity: directory tree rendering, file selection, path handling. For Phase 3, whole-app restore covers the primary emergency use case (accidental deletion, corruption). File-level restore can be added later as a power-user feature.
|
||
|
||
### Why show the password to the customer?
|
||
|
||
The managed service model means the operator (Viktor) has hub access and can recover the password. But showing it to the customer:
|
||
- Enables self-service disaster recovery if the customer is technically capable
|
||
- Builds trust — the customer isn't locked out of their own backups
|
||
- Reduces support burden for simple restore scenarios
|
||
|
||
The "behind toggle + clipboard" pattern prevents accidental exposure while making it accessible when needed.
|
||
|
||
### Why sync password to hub?
|
||
|
||
The password lives in a Docker named volume on the SSD. If the SSD fails:
|
||
- Without hub sync: password is permanently lost → all backups inaccessible
|
||
- With hub sync: operator retrieves from hub → restores access
|
||
|
||
This is the single most critical piece of information for backup recovery. Hub sync provides the necessary redundancy.
|
||
|
||
### HDD mount mode for restore
|
||
|
||
Changing from `:ro` to `:rw` is acceptable because:
|
||
- The controller is a trusted, operator-managed component
|
||
- It already has Docker socket access (much more privileged than filesystem write)
|
||
- The `:ro` mount was a defense-in-depth measure, not a hard security boundary
|
||
- Without `:rw`, restore would require either a sidecar container or host-level orchestration, adding significant complexity for minimal security benefit
|
||
|
||
### Restore concurrency
|
||
|
||
Backup and restore both use the same restic repository. Running them concurrently can cause lock contention or corruption. A simple `sync.Mutex` in the backup Manager prevents this. The mutex is held for the duration of either operation. |