Compare commits

..

3 Commits

Author SHA1 Message Date
admin 85a5727123 docs: v0.100.0 — CHANGELOG/CONTEXT/REUSE/README for the Tier-2 in-place file restore (F2 closed)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-05 13:23:03 +02:00
admin 27aeb415f4 feat(web): C2 Parts 2+3 — POST /backup/tier2/restore + "Fájlok visszaállítása" button
- Endpoint next to /backup/restore; handler mirrors backupRestoreHandler
  (ParseForm → validStackName → backupMgr guard → WARN with RemoteAddr →
  RestoreTier2Files → flash). Flash strings: "<stack>: N fájl visszaállítva a
  másodlagos másolatból." / "Nincs hiányzó fájl — minden fájl megvan a helyén."
  / "Fájl-visszaállítás sikertelen: <err>" (refusals carry the Hungarian
  reasons from the engine).
- backups.html: the button on the healthy Tier-2 layer row only (the
  Tier2Configured branch already excludes disconnected/inactive; additionally
  gated on Tier2LastRun), inline POST form with CSRF + confirm dialog naming
  the additive-only semantics and the last-copy timestamp. Template gates
  (id + emoji) green.
- Handler guard test (C6): traversal/empty → exact Hungarian flash, no work
  started (nil backupMgr would panic if reached).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-05 13:20:56 +02:00
admin 30b11100e0 feat(backup): C2 Part 1 — RestoreTier2Files: in-place, additive-only class-C file restore engine
Closes the engine half of drill finding F2: user files under appdata/<stack>
had no customer recovery path (operator copy-back only).

- RestoreTier2Files(stack): single-flight with backup/restore; ALL refusals
  before any stop (no Tier-2 record / LastRun empty / copy dir absent →
  "nincs másodlagos fájlmásolat"; Tier-2 drive disconnected; live drive
  disconnected/decommissioned — Hungarian, flash-ready); source is the
  RECORDED CrossDriveBackup.DestinationPath (never a fresh selectTier2Target);
  stop → copy → start → waitForHealthy; copy/restart errors surface (F17).
- rsyncRestoreMissing: rsyncMirror's exec shape with the OPPOSITE-direction
  flags: -a --ignore-existing --itemize-changes — existing live files are
  never overwritten, nothing is ever deleted (the --delete trap this task
  exists to avoid). Count = ">f" itemize lines (pure countRestoredFiles).
- restoreFilesCopier seam so orchestration tests never shell out; the one
  FS-level test of the real rsync is LookPath-guarded (runs on the Linux
  build server + live validation).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-05 13:17:40 +02:00
11 changed files with 606 additions and 1 deletions
+32
View File
@@ -1,5 +1,37 @@
## Changelog
### v0.100.0 — one-click class-C file restore from the Tier-2 copy (2026-07-05)
TASK C2 — closes drill finding **F2** (`DRILL-appdata-restore-2026-07-04.md` §4): HDD bind-mount
user files (`appdata/<stack>`) had no customer recovery path — Tier-2 protected them nightly, but
getting deleted files back was an operator copy-back by hand.
- **Engine** (`internal/backup/tier2_restore.go`): `Manager.RestoreTier2Files(stack)` — in-place,
**additive-only** restore from the RECORDED Tier-2 copy (`CrossDriveBackup.DestinationPath`, never
a fresh `selectTier2Target`). Semantics = `rsync -a --ignore-existing`: files missing live are
copied back; existing live files are NEVER overwritten (a customer edit after the last copy wins);
nothing is EVER deleted (the `rsyncMirror --delete` trap in this direction would erase every file
created since last night — the new `rsyncRestoreMissing` copies the mirror's exec shape with the
opposite-direction flags). Single-flight with backup/restore; all refusals (no copy / LastRun
empty / copy dir gone / either drive disconnected / live drive decommissioned) happen BEFORE the
stop, with customer-readable Hungarian reasons; stop → copy → start → health; copy/restart errors
surface (F17). File count from `--itemize-changes` (`>f` lines); file names never logged at INFO.
- **Endpoint + UI**: `POST /backup/tier2/restore` (`internal/web/server.go` + `handlers.go`,
backupRestoreHandler-shaped guards) + a **"Fájlok visszaállítása"** button on the healthy Tier-2
layer row (`templates/backups.html`; hidden when unconfigured / never ran / target drive
disconnected/inactive) with a confirm dialog stating the additive-only contract + last-copy time.
Zero files copied = success ("Nincs hiányzó fájl — minden fájl megvan a helyén."), not an error.
- Out of scope by design: overwrite/point-in-time restore (offbox + operator paths), per-file
selection, `recovery-unit/` (backup artifacts are not user files). Apps that index their data dir
(e.g. Nextcloud) may need a rescan before restored files appear in their own UI — noted in
`felhom.eu/documentation/controller/backup-architecture.md`.
- Tests: orchestration via a `restoreFilesCopier` seam (stop→copy→start order, src/dst contract,
refusal NON-effects: never stopped, copier never invoked), Scenario-D zero-copy success, itemize
parsing, handler guards, and an FS-level semantics test of the real rsync (LookPath-skipped where
rsync is absent). Companion red-proof: swapping the flags for `rsyncMirror`'s mirrors the backup
over live — the differing live file gets clobbered AND the live-only file gets deleted (both
assertions red; verified on the build server, reverted).
### v0.99.0 — restore-path fixes: dead restore UI + volume dumps + blank-secret redeploy (2026-07-05)
TASK C1 — fixes F1/F3/O4 from the 2026-07-04 restore drill
+7
View File
@@ -9,6 +9,13 @@
Last updated: 2026-07-03 (docs: CLAUDE.md refreshed — stable orientation; runbooks live in the felhom skills)
> **2026-07-05 — v0.100.0 (TASK C2): drill finding F2 CLOSED — one-click class-C file restore.**
> `POST /backup/tier2/restore` + "Fájlok visszaállítása" on the Tier-2 row: in-place, ADDITIVE-ONLY
> (`rsync -a --ignore-existing` from the recorded Tier-2 copy — never overwrites, never deletes).
> Serves "I deleted my files"; corruption/point-in-time stays offbox/operator. **The C-series
> (drill findings F1/F2/F3/O4) is now fully closed.** Reindex caveat (e.g. Nextcloud occ files:scan)
> documented in backup-architecture.md.
> **2026-07-05 — v0.99.0 restore-path fixes (TASK C1): drill findings F1/F3/O4 RESOLVED.**
> F1: `GET /api/backup/snapshots` implemented (`backup.ListRestorePoints`) — the restore panel
> populates and the restore button enables. F3: `runVolumeDumps` wired into the nightly/manual
+3 -1
View File
@@ -69,6 +69,7 @@
| `Manager.EnsureBaseStack` | controller/internal/stacks/infra.go | `() error` | Traefik/cloudflared/FileBrowser infra convergence | Renders from `internal/infra` templates |
| `backup.Manager.DumpAppVolumesSafe` | controller/internal/backup/backup.go | `(stackName) error` | Volume tar of a live app | Stops → dumps → restarts; surfaces BOTH errors (app may be left stopped). Check `GetDockerVolumes()!=0` + `IsProtectedStack` BEFORE calling — it stops the stack before its own volume check (see `runVolumeDumps`) |
| `backup.Manager.ListRestorePoints` | controller/internal/backup/restore_points.go | `(stackName) ([]RestorePoint, bool)` | Restorable keep-side backups (the /api/backup/snapshots payload) | ONE point per app (the current unit); tier always 1 — never list Tier-2 (not restorable via /backup/restore) |
| `backup.Manager.RestoreTier2Files` | controller/internal/backup/tier2_restore.go | `(stackName) (filesRestored int, err error)` | In-place ADDITIVE-ONLY class-C file restore from the recorded Tier-2 copy (`POST /backup/tier2/restore`) | Never overwrites/deletes live files; refusals (Hungarian) before any stop; source = recorded `DestinationPath`, never re-selected |
| `Manager.acquireRunning`/`releaseRunning`, `acquireMigrating` | controller/internal/backup/backup.go, controller/internal/stacks/migrate.go | `() error` | Single-flight for long ops | Copy this mutex-flag pattern for any new long-running manager op |
### Secrets hygiene
@@ -153,7 +154,7 @@
| Trap | Why it bites | Use instead |
|---|---|---|
| `rsyncMirror` (controller/internal/backup/tier2.go) | `rsync -a --delete` — DESTROYS anything extra at dst; correct only for tier-2 mirror dirs | `rsyncCopy` + `rsyncVerify` (controller/internal/stacks/migrate.go) for any move/copy; they are documented "NEVER --delete" |
| `rsyncMirror` (controller/internal/backup/tier2.go) | `rsync -a --delete` — DESTROYS anything extra at dst; correct only for tier-2 mirror dirs (backup DIRECTION). In the tier2→live restore direction it would erase every live file created since the last copy | `rsyncCopy` + `rsyncVerify` (controller/internal/stacks/migrate.go) for any move/copy; `rsyncRestoreMissing` (controller/internal/backup/tier2_restore.go, `-a --ignore-existing`) for the additive-only restore direction |
| raw `os.RemoveAll` on drive/HDD paths | Bypasses the protected-set; wipes appdata/backups/media | `Manager.DeleteStack`/`RemoveStack` (controller/internal/stacks/delete.go) — gated by `ProtectedHDDPaths` + orphan/protected/running checks |
| fresh `agentapi.New` per request | Idle-conn leak → EADDRNOTAVAIL, port exhaustion (live incident, fixed ctrl v0.74.0) | `Server.agentClient()` memoized accessor |
| `timeAgo` on an RFC3339 string field | Template 500 (OffboxTarget.LastRun bug, fixed v0.96.0) | `timeAgoStr` |
@@ -178,6 +179,7 @@
| `offboxRunner` (func) | controller/internal/backup/offbox.go | `defaultOffboxRunner` (restic exec) | `SetOffboxRunner` injection point |
| `dumpVolumesSafe` (func seam) | controller/internal/backup/backup.go | nil → real `DumpAppVolumesSafe` | injected in controller/internal/backup/volume_dumps_test.go (gating tests without Docker) |
| `generateSecret` (func seam) | controller/internal/backup/backup.go | `stacks.Manager.GenerateSecretForField` via `SetSecretGenerator` (main.go) | injected in controller/internal/backup/restore_secrets_gen_test.go |
| `restoreFilesCopier` (func seam) | controller/internal/backup/backup.go | nil → real `rsyncRestoreMissing` | injected in controller/internal/backup/tier2_restore_test.go (orchestration without rsync) |
Cross-repo edges:
- `controller/internal/agentapi/client.go`**felhom-agent** local API (`/storage`, `/disks*`, `/backup*`, `/netstorage*`, `/guest/*`): pinned leaf SHA-256 + per-guest bearer token from bootstrap.json.
+15
View File
@@ -477,6 +477,21 @@ reach bind mounts). Auto-targeted: **prefer another registered user-data drive**
`settings.CrossDriveBackup` and drives the "2. mentés" card. Runs daily (`tier2-backup`, 03:30) or via
`POST /api/backup/tier2`. restic is **not** used — a plain browsable mirror.
**In-place file restore from the Tier-2 copy (C2, v0.100.0 — closes drill finding F2)**
`POST /backup/tier2/restore` (`backup.RestoreTier2Files`, `internal/backup/tier2_restore.go`) + the
**"Fájlok visszaállítása"** button on the healthy Tier-2 layer row. **Additive-only** semantics
(`rsyncRestoreMissing`: `rsync -a --ignore-existing`): files missing from the live `appdata/<stack>`
are copied back from the RECORDED Tier-2 copy; existing live files are **never overwritten** (a
customer edit after the last copy wins) and **nothing is ever deleted** — this exactly serves the
"I deleted my files" scenario with zero risk to newer data. Source = the recorded
`CrossDriveBackup.DestinationPath` (never a fresh target selection). Single-flight with
backup/restore; refusals (no copy / never ran / copy dir gone / either drive disconnected /
decommissioned) happen before the app is stopped, with customer-readable Hungarian reasons;
stop → copy → start → health-wait; zero files copied is a success ("Nincs hiányzó fájl…"). Out of
scope by design: overwrite/point-in-time restore (offbox + operator paths), per-file selection,
`recovery-unit/`. Apps that index their data dir (e.g. Nextcloud) may need a rescan (occ
files:scan) before restored files appear in their own UI.
**Per-app Tier-2 config panel (v0.57.0)**`GET/POST /stacks/{name}/backup`
(`internal/web/tier2_config_handler.go` + `templates/tier2_config.html`). The "2. mentés" row's
**Beállítás** button links here (was the dead-end deploy page). Shows the effective off-drive target
+4
View File
@@ -50,6 +50,10 @@ type Manager struct {
// NEVER consulted for data-keys — the fail-closed gate refuses those before generation runs.
generateSecret func(stackName, envVar string) (string, bool)
// restoreFilesCopier (C2) — the Tier-2 in-place file-restore copy seam, overridable in tests so
// the orchestration never shells out. Nil → the real rsyncRestoreMissing (additive-only).
restoreFilesCopier func(src, dst string) (filesRestored int, err error)
// migrationRunning, if set, reports whether a data migration is in progress. The scheduled
// backup paths skip when it returns true (Change 3 — backup ↔ migration mutual exclusion), so a
// nightly dump/Tier-2 can't race a migration copy/cleanup on the same drive.
+147
View File
@@ -0,0 +1,147 @@
package backup
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// Tier-2 in-place file restore (TASK C2, closes drill finding F2): the customer-facing recovery for
// class-C data — HDD bind-mount user files under appdata/<stack>. Restores MISSING files from the
// recorded Tier-2 copy back into the live appdata dir, and touches NOTHING else:
//
// - a file that exists live is NEVER overwritten (a customer edit after the last Tier-2 run wins);
// - a live file absent from the backup is NEVER deleted (that is what rsyncMirror's --delete would
// do in this direction — the catastrophic trap this helper exists to avoid);
// - only files present in the copy and missing live are copied back (attrs preserved).
//
// This exactly serves the "I deleted my files" scenario. Corruption / point-in-time rollback stays
// with the offbox restore-to-verify + operator paths — deliberately out of scope.
// Refusal reasons (customer-readable — they surface verbatim in the flash message).
var (
errNoTier2Copy = errors.New("nincs másodlagos fájlmásolat ehhez az alkalmazáshoz")
errTier2DriveGone = errors.New("a másodlagos meghajtó nincs csatlakoztatva")
errLiveDriveGone = errors.New("az alkalmazás meghajtója nincs csatlakoztatva")
errLiveDriveDecommed = errors.New("az alkalmazás meghajtója le van szerelve")
)
// RestoreTier2Files restores the app's MISSING user files in place from its recorded Tier-2 copy
// (additive-only; see the package comment above). Returns how many regular files were copied back.
//
// The source is the RECORDED Tier-2 destination (settings.CrossDriveBackup.DestinationPath) — never
// a fresh selectTier2Target, which could re-pick a different (empty) drive and "restore" nothing.
// All refusals happen BEFORE the app is stopped. Stop-first is the locked consistency policy: the
// app must not be reorganizing its data dir mid-copy.
func (m *Manager) RestoreTier2Files(stackName string) (filesRestored int, err error) {
if m.stackProvider == nil {
return 0, fmt.Errorf("stack provider not configured")
}
if err := m.acquireRunning(); err != nil {
return 0, err // shares the backup/restore single-flight — must not race a running backup
}
defer m.releaseRunning()
// Live side: the app's drive must be present and in service.
drive := m.GetAppDrivePath(stackName)
if drive == "" || !filepath.IsAbs(drive) {
return 0, fmt.Errorf("cannot determine drive path for %s", stackName)
}
if m.settings != nil {
if m.settings.IsDisconnected(drive) {
return 0, fmt.Errorf("%w (%s)", errLiveDriveGone, drive)
}
if m.settings.IsDecommissioned(drive) {
return 0, fmt.Errorf("%w (%s)", errLiveDriveDecommed, drive)
}
}
liveDir := AppDataDir(m.namespaceRoot(drive), stackName)
// Source side: the RECORDED Tier-2 copy must exist and its drive must be connected.
var srcDir string
if m.settings != nil {
if cfg := m.settings.GetCrossDriveConfig(stackName); cfg != nil && cfg.LastRun != "" && cfg.DestinationPath != "" {
if m.settings.IsDisconnected(cfg.DestinationPath) {
return 0, errTier2DriveGone
}
// Same layout literals as RunTier2's destBase + the appdata leg.
srcDir = filepath.Join(cfg.DestinationPath, "backups", "secondary", stackName, "appdata")
}
}
if srcDir == "" {
return 0, errNoTier2Copy
}
if _, statErr := os.Stat(srcDir); statErr != nil {
return 0, errNoTier2Copy // recorded but the copy dir is gone — same honest refusal
}
m.logger.Printf("[INFO] [backup] Tier-2 file restore for %s: %s → %s (additive-only)", stackName, srcDir, liveDir)
copier := m.restoreFilesCopier
if copier == nil {
copier = rsyncRestoreMissing
}
// Stop → copy → start → health (the standard restore shape; F17: errors surface, never swallowed).
if stopErr := m.stackProvider.StopStack(stackName); stopErr != nil {
m.logger.Printf("[WARN] [backup] could not stop %s before Tier-2 file restore: %v (continuing)", stackName, stopErr)
}
start := time.Now()
filesRestored, copyErr := copier(srcDir, liveDir)
startErr := m.stackProvider.StartStack(stackName)
if startErr != nil {
m.logger.Printf("[ERROR] [backup] failed to restart %s after Tier-2 file restore: %v", stackName, startErr)
}
if healthErr := m.waitForHealthy(stackName, 90*time.Second); healthErr != nil {
m.logger.Printf("[WARN] [backup] %s Tier-2 file restore done but health check failed: %v", stackName, healthErr)
}
if copyErr != nil {
return filesRestored, fmt.Errorf("fájlmásolás sikertelen: %w", copyErr)
}
if startErr != nil {
return filesRestored, fmt.Errorf("%d fájl visszaállítva, de az alkalmazás újraindítása sikertelen: %w", filesRestored, startErr)
}
// Privacy: count + duration only — customer file names never at INFO.
m.logger.Printf("[INFO] [backup] Tier-2 file restore completed for %s: %d file(s) restored (%s)",
stackName, filesRestored, time.Since(start).Round(time.Second))
return filesRestored, nil
}
// rsyncRestoreMissing copies the files MISSING from dst back from src, and nothing else:
// `rsync -a --ignore-existing` — existing dst files are never overwritten, and (unlike rsyncMirror,
// which carries --delete for the backup direction) nothing at dst is ever deleted. Returns the
// number of regular files transferred, counted from --itemize-changes output.
func rsyncRestoreMissing(src, dst string) (int, error) {
if err := os.MkdirAll(dst, 0755); err != nil {
return 0, fmt.Errorf("mkdir %s: %w", dst, err)
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute)
defer cancel()
// Trailing slashes: copy the CONTENTS of src into dst (same shape as rsyncMirror).
cmd := exec.CommandContext(ctx, "rsync", "-a", "--ignore-existing", "--itemize-changes",
strings.TrimRight(src, "/")+"/", strings.TrimRight(dst, "/")+"/")
out, err := cmd.CombinedOutput()
if err != nil {
return 0, fmt.Errorf("%v: %s", err, strings.TrimSpace(string(out)))
}
return countRestoredFiles(string(out)), nil
}
// countRestoredFiles counts the itemize-changes lines that mark a TRANSFERRED regular file (">f…").
// Created dirs ("cd…") and symlinks ("cL…") are not counted — the flash reports files. Pure
// (unit-tested without rsync).
func countRestoredFiles(itemizedOut string) int {
n := 0
for _, line := range strings.Split(itemizedOut, "\n") {
if strings.HasPrefix(line, ">f") {
n++
}
}
return n
}
@@ -0,0 +1,301 @@
package backup
import (
"bytes"
"errors"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// t2rFakeProvider records the lifecycle calls RestoreTier2Files makes — the refusal tests assert
// the NON-effect (never stopped) and the happy path asserts the stop→copy→start order.
type t2rFakeProvider struct {
hdd string
stopped []string
started []string
order []string // interleaved event log: "stop", "copy" (appended by the copier seam), "start"
}
func (f *t2rFakeProvider) GetStackComposePath(string) (string, bool) { return "", false }
func (f *t2rFakeProvider) ListDeployedStacks() []StackSummary { return nil }
func (f *t2rFakeProvider) GetStackHDDMounts(string) []string { return nil }
func (f *t2rFakeProvider) GetStackHDDPath(string) string { return f.hdd }
func (f *t2rFakeProvider) GetDockerVolumes(string) []string { return nil }
func (f *t2rFakeProvider) StopStack(name string) error {
f.stopped = append(f.stopped, name)
f.order = append(f.order, "stop")
return nil
}
func (f *t2rFakeProvider) StartStack(name string) error {
f.started = append(f.started, name)
f.order = append(f.order, "start")
return nil
}
func (f *t2rFakeProvider) RefreshAndIsRunning(string) bool { return true }
func (f *t2rFakeProvider) GetStackRecoveryInfo(string) (RecoveryInfo, bool) {
return RecoveryInfo{}, false
}
func (f *t2rFakeProvider) RecoverStackSecrets(string, []string) map[string]string { return nil }
func (f *t2rFakeProvider) RecreateStackFromUnit(string, string, map[string]string) error {
return nil
}
// newT2RManager builds a Manager with a RECORDED Tier-2 copy for "app": live drive + a populated
// <dest>/backups/secondary/app/appdata dir, and a CrossDriveBackup entry pointing at dest.
func newT2RManager(t *testing.T) (m *Manager, fake *t2rFakeProvider, liveDrive, destDrive string) {
t.Helper()
tmp := t.TempDir()
liveDrive = filepath.Join(tmp, "usb")
destDrive = filepath.Join(tmp, "flash")
sett, err := settings.Load(filepath.Join(tmp, "settings.json"), log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
for _, p := range []string{liveDrive, destDrive} {
if err := sett.AddStoragePath(settings.StoragePath{Path: p, Label: filepath.Base(p)}); err != nil {
t.Fatal(err)
}
}
if err := sett.SetCrossDriveConfig("app", &settings.CrossDriveBackup{
Enabled: true, Method: "rsync", DestinationPath: destDrive,
LastRun: "2026-07-05T03:30:00Z", LastStatus: "ok",
}); err != nil {
t.Fatal(err)
}
mustWrite(t, filepath.Join(destDrive, "backups", "secondary", "app", "appdata", "photos", "a.jpg"), "JPEGDATA")
fake = &t2rFakeProvider{hdd: liveDrive}
m = &Manager{
logger: log.New(io.Discard, "", 0),
settings: sett,
systemDataPath: filepath.Join(tmp, "sys"),
stackProvider: fake,
}
return m, fake, liveDrive, destDrive
}
// TestRestoreTier2Files_Orchestration proves the stop → copy → start order, the src/dst the copier
// receives (recorded Tier-2 layout → live AppDataDir), and the returned count.
func TestRestoreTier2Files_Orchestration(t *testing.T) {
m, fake, liveDrive, destDrive := newT2RManager(t)
var gotSrc, gotDst string
m.restoreFilesCopier = func(src, dst string) (int, error) {
gotSrc, gotDst = src, dst
fake.order = append(fake.order, "copy")
return 3, nil
}
n, err := m.RestoreTier2Files("app")
if err != nil {
t.Fatalf("restore: %v", err)
}
if n != 3 {
t.Errorf("filesRestored = %d, want 3", n)
}
if want := filepath.Join(destDrive, "backups", "secondary", "app", "appdata"); gotSrc != want {
t.Errorf("copier src = %q, want the RECORDED Tier-2 copy %q", gotSrc, want)
}
if want := AppDataDir(liveDrive, "app"); gotDst != want {
t.Errorf("copier dst = %q, want live appdata %q", gotDst, want)
}
if len(fake.order) != 3 || fake.order[0] != "stop" || fake.order[1] != "copy" || fake.order[2] != "start" {
t.Errorf("order = %v, want [stop copy start]", fake.order)
}
}
// TestRestoreTier2Files_Refusals proves every refusal happens BEFORE any side effect: the app is
// never stopped and the copier is never invoked (Scenario C1C5).
func TestRestoreTier2Files_Refusals(t *testing.T) {
assertNoEffect := func(t *testing.T, m *Manager, fake *t2rFakeProvider, copierCalled *bool, wantErr error) {
t.Helper()
n, err := m.RestoreTier2Files("app")
if err == nil {
t.Fatal("expected refusal, got nil")
}
if wantErr != nil && !errors.Is(err, wantErr) {
t.Errorf("err = %v, want %v", err, wantErr)
}
if n != 0 {
t.Errorf("filesRestored = %d, want 0", n)
}
if len(fake.stopped) != 0 {
t.Errorf("app was STOPPED on a refusal: %v", fake.stopped)
}
if *copierCalled {
t.Error("copier was invoked on a refusal")
}
}
wire := func(m *Manager) *bool {
called := false
m.restoreFilesCopier = func(string, string) (int, error) { called = true; return 0, nil }
return &called
}
t.Run("C1 no Tier-2 record", func(t *testing.T) {
m, fake, _, _ := newT2RManager(t)
called := wire(m)
if err := m.settings.SetCrossDriveConfig("app", nil); err != nil {
t.Fatal(err)
}
assertNoEffect(t, m, fake, called, errNoTier2Copy)
})
t.Run("C1b LastRun empty", func(t *testing.T) {
m, fake, _, destDrive := newT2RManager(t)
called := wire(m)
if err := m.settings.SetCrossDriveConfig("app", &settings.CrossDriveBackup{
Enabled: true, DestinationPath: destDrive, // no LastRun — never actually ran
}); err != nil {
t.Fatal(err)
}
assertNoEffect(t, m, fake, called, errNoTier2Copy)
})
t.Run("C2 copy dir absent on target", func(t *testing.T) {
m, fake, _, destDrive := newT2RManager(t)
called := wire(m)
if err := os.RemoveAll(filepath.Join(destDrive, "backups")); err != nil {
t.Fatal(err)
}
assertNoEffect(t, m, fake, called, errNoTier2Copy)
})
t.Run("C3 Tier-2 drive disconnected", func(t *testing.T) {
m, fake, _, destDrive := newT2RManager(t)
called := wire(m)
if err := m.settings.SetDisconnected(destDrive, true, nil); err != nil {
t.Fatal(err)
}
assertNoEffect(t, m, fake, called, errTier2DriveGone)
})
t.Run("C4 live drive disconnected", func(t *testing.T) {
m, fake, liveDrive, _ := newT2RManager(t)
called := wire(m)
if err := m.settings.SetDisconnected(liveDrive, true, nil); err != nil {
t.Fatal(err)
}
assertNoEffect(t, m, fake, called, errLiveDriveGone)
})
t.Run("C4b live drive decommissioned", func(t *testing.T) {
m, fake, liveDrive, _ := newT2RManager(t)
called := wire(m)
if err := m.settings.SetDecommissioned(liveDrive, ""); err != nil {
t.Fatal(err)
}
assertNoEffect(t, m, fake, called, errLiveDriveDecommed)
})
t.Run("C5 backup already running", func(t *testing.T) {
m, fake, _, _ := newT2RManager(t)
called := wire(m)
m.mu.Lock()
m.running = true
m.mu.Unlock()
assertNoEffect(t, m, fake, called, nil) // acquireRunning's own error
m.mu.Lock()
m.running = false
m.mu.Unlock()
})
}
// TestRestoreTier2Files_NothingToRestore proves Scenario D: zero files copied is a SUCCESS
// (0, nil), not an error — the "everything is already in place" answer.
func TestRestoreTier2Files_NothingToRestore(t *testing.T) {
m, fake, _, _ := newT2RManager(t)
m.restoreFilesCopier = func(string, string) (int, error) { return 0, nil }
n, err := m.RestoreTier2Files("app")
if err != nil {
t.Fatalf("zero-copy restore must succeed: %v", err)
}
if n != 0 {
t.Errorf("filesRestored = %d, want 0", n)
}
// The stop/start cycle still happened (acceptable per spec).
if len(fake.stopped) != 1 || len(fake.started) != 1 {
t.Errorf("stop/start: %v/%v", fake.stopped, fake.started)
}
}
// TestRestoreTier2Files_CopyErrorSurfaces proves no-silent-failure: a copier error is returned
// (flash_error path) and the app is still restarted.
func TestRestoreTier2Files_CopyErrorSurfaces(t *testing.T) {
m, fake, _, _ := newT2RManager(t)
m.restoreFilesCopier = func(string, string) (int, error) { return 1, errors.New("disk full") }
if _, err := m.RestoreTier2Files("app"); err == nil {
t.Fatal("copier error must surface")
}
if len(fake.started) != 1 {
t.Error("app must still be restarted after a failed copy")
}
}
// TestCountRestoredFiles pins the itemize-changes parsing: only transferred regular files (">f")
// count — created dirs (cd), symlinks (cL) and untransferred lines do not.
func TestCountRestoredFiles(t *testing.T) {
out := ">f+++++++++ photos/a.jpg\n" +
"cd+++++++++ photos/\n" +
"cL+++++++++ link -> target\n" +
">f+++++++++ docs/b.pdf\n" +
".d..t...... ./\n" +
"\n"
if n := countRestoredFiles(out); n != 2 {
t.Errorf("count = %d, want 2", n)
}
if n := countRestoredFiles(""); n != 0 {
t.Errorf("empty output count = %d, want 0", n)
}
}
// TestRsyncRestoreMissing_Semantics is the FS-level proof of the additive-only contract (Scenarios
// A + B together), run against the REAL rsync (skipped where rsync is unavailable — it runs on the
// Linux build server and in the live validation):
// - a file missing live is restored byte-identical (and counted);
// - a live file with DIFFERENT content than the backup keeps its LIVE bytes;
// - a live-only file survives.
//
// §10 COMPANION: swapping the helper's flags for rsyncMirror's (-a --delete, no --ignore-existing)
// makes this fail TWICE — b.txt gets clobbered AND c.txt gets deleted.
func TestRsyncRestoreMissing_Semantics(t *testing.T) {
if _, err := exec.LookPath("rsync"); err != nil {
t.Skip("rsync not available on this machine — runs on the Linux build server")
}
tmp := t.TempDir()
src := filepath.Join(tmp, "tier2copy")
dst := filepath.Join(tmp, "live")
mustWrite(t, filepath.Join(src, "photos", "a.jpg"), "JPEG-FROM-BACKUP")
mustWrite(t, filepath.Join(src, "b.txt"), "BACKUP-VERSION")
mustWrite(t, filepath.Join(dst, "b.txt"), "LIVE-EDIT-AFTER-COPY")
mustWrite(t, filepath.Join(dst, "c.txt"), "LIVE-ONLY-NEW-FILE")
n, err := rsyncRestoreMissing(src, dst)
if err != nil {
t.Fatalf("rsyncRestoreMissing: %v", err)
}
if n != 1 {
t.Errorf("filesRestored = %d, want 1 (only the missing a.jpg)", n)
}
// Scenario A: the deleted file is back, byte-identical.
got, err := os.ReadFile(filepath.Join(dst, "photos", "a.jpg"))
if err != nil || !bytes.Equal(got, []byte("JPEG-FROM-BACKUP")) {
t.Errorf("a.jpg not restored byte-identical: %q %v", got, err)
}
// Scenario B core #1: the differing live file keeps its LIVE bytes (backup NOT restored).
if got := mustRead(t, filepath.Join(dst, "b.txt")); got != "LIVE-EDIT-AFTER-COPY" {
t.Errorf("live-edited b.txt was CLOBBERED: %q", got)
}
// Scenario B core #2: the live-only file was NOT deleted.
if got := mustRead(t, filepath.Join(dst, "c.txt")); got != "LIVE-ONLY-NEW-FILE" {
t.Errorf("live-only c.txt was harmed: %q", got)
}
}
+38
View File
@@ -896,6 +896,44 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/backups?flash="+msg, http.StatusFound)
}
// backupTier2RestoreHandler (C2, closes F2) restores an app's MISSING user files in place from its
// recorded Tier-2 copy — additive-only: existing live files are never overwritten and nothing is
// ever deleted (see backup.RestoreTier2Files). Same handler shape as backupRestoreHandler.
func (s *Server) backupTier2RestoreHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
stackName := r.FormValue("stack_name")
if stackName == "" {
http.Redirect(w, r, "/backups?flash_error=Hi%C3%A1nyz%C3%B3+param%C3%A9terek", http.StatusFound)
return
}
// Same F2-defense as the unit restore: a stack name is a single segment, never a path.
if !validStackName(stackName) {
s.logger.Printf("[WARN] [web] Tier-2 file restore rejected: invalid stack_name %q from %s", stackName, r.RemoteAddr)
http.Redirect(w, r, "/backups?flash_error=%C3%89rv%C3%A9nytelen+alkalmaz%C3%A1sn%C3%A9v", http.StatusFound)
return
}
if s.backupMgr == nil {
http.Redirect(w, r, "/backups?flash_error=Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva", http.StatusFound)
return
}
s.logger.Printf("[WARN] [web] Tier-2 file restore requested: stack=%s from %s", stackName, r.RemoteAddr)
n, err := s.backupMgr.RestoreTier2Files(stackName)
if err != nil {
s.logger.Printf("[ERROR] [web] Tier-2 file restore failed: %v", err)
http.Redirect(w, r, "/backups?flash_error="+url.QueryEscape("Fájl-visszaállítás sikertelen: "+err.Error()), http.StatusFound)
return
}
msg := "Nincs hiányzó fájl — minden fájl megvan a helyén."
if n > 0 {
msg = fmt.Sprintf("%s: %d fájl visszaállítva a másodlagos másolatból.", stackName, n)
}
http.Redirect(w, r, "/backups?flash="+url.QueryEscape(msg), http.StatusFound)
}
// settingsBaseData is the shared identity block used by every settings-family subpage
// (D1 split: /settings, /settings/notifications, /settings/security, /storage).
func (s *Server) settingsBaseData(page, title string) map[string]interface{} {
+3
View File
@@ -299,6 +299,9 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/storage/attach", http.StatusMovedPermanently)
case path == "/backup/restore" && r.Method == http.MethodPost:
s.backupRestoreHandler(w, r)
// C2: in-place, additive-only file restore from the Tier-2 copy (class-C user files)
case path == "/backup/tier2/restore" && r.Method == http.MethodPost:
s.backupTier2RestoreHandler(w, r)
// Off-box (NAS) restic-SFTP backup (Part B)
case path == "/backup/offbox/config" && r.Method == http.MethodPost:
s.offboxConfigHandler(w, r)
@@ -436,6 +436,12 @@
<span class="tier-contents">{{.BackupContents}}</span>
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
<div class="layer-actions">
{{if .Tier2LastRun}}
<form method="POST" action="/backup/tier2/restore" style="display:inline" onsubmit="return confirm('Visszaállítja a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi másolat: {{.Tier2LastRun}}')">{{$.CSRFField}}
<input type="hidden" name="stack_name" value="{{.StackName}}">
<button type="submit" class="btn btn-xs btn-outline">Fájlok visszaállítása</button>
</form>
{{end}}
<a href="/stacks/{{.StackName}}/backup" class="btn btn-xs btn-outline">Beállítás</a>
</div>
{{else}}
@@ -0,0 +1,50 @@
package web
import (
"io"
"log"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// postTier2Restore drives the handler with a form body, as the UI's inline form does.
func postTier2Restore(t *testing.T, s *Server, stackName string) *httptest.ResponseRecorder {
t.Helper()
form := url.Values{"stack_name": {stackName}}
req := httptest.NewRequest(http.MethodPost, "/backup/tier2/restore", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
s.backupTier2RestoreHandler(rec, req)
return rec
}
// TestTier2RestoreHandler_Guards proves Scenario C6 + the missing-param guard: traversal and empty
// names are rejected with the exact Hungarian flash BEFORE any restore work (backupMgr is nil here —
// reaching it would panic, so a pass also proves no work started).
func TestTier2RestoreHandler_Guards(t *testing.T) {
s := &Server{logger: log.New(io.Discard, "", 0)} // backupMgr nil on purpose
for name, want := range map[string]string{
"../../etc": "%C3%89rv%C3%A9nytelen+alkalmaz%C3%A1sn%C3%A9v", // Érvénytelen alkalmazásnév
"a/b": "%C3%89rv%C3%A9nytelen+alkalmaz%C3%A1sn%C3%A9v",
"": "Hi%C3%A1nyz%C3%B3+param%C3%A9terek", // Hiányzó paraméterek
} {
rec := postTier2Restore(t, s, name)
if rec.Code != http.StatusFound {
t.Errorf("%q: status = %d, want 302", name, rec.Code)
continue
}
if loc := rec.Header().Get("Location"); !strings.Contains(loc, want) {
t.Errorf("%q: redirect = %q, want flash %q", name, loc, want)
}
}
// Valid name but no backup manager → the not-configured flash (still no panic, no work).
rec := postTier2Restore(t, s, "nextcloud")
if loc := rec.Header().Get("Location"); !strings.Contains(loc, "Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva") {
t.Errorf("nil backupMgr: redirect = %q", loc)
}
}