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
This commit is contained in:
2026-07-05 13:23:03 +02:00
parent 27aeb415f4
commit 85a5727123
4 changed files with 57 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