Files
deploy-felhom-compose/TASK.md
T

853 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.