Compare commits

7 Commits
Author SHA1 Message Date
admin e3fce09d96 D62: restore copies kept 30 days, listed with their date, deletable by hand (typed name, never a needed copy, operator event); D63: a test-build-only fault switch for the R-1256 H4/H5 live proofs — tests red first
gates / gates (push) Successful in 33s
2026-10-29 08:50:59 +01:00
admin a815b37fbd Night 2026-10-28 docs: REPORT (H5 + H8 live on 9202, teardown), README (restore edges, host OOM count — unreleased), CONTEXT, CHANGELOG live line
gates / gates (push) Successful in 29s
2026-10-29 05:14:05 +01:00
admin 20c5877056 An action on a name that is no app answers 404, not 'settings cannot be saved' (found walking capability map B2 on 9202)
gates / gates (push) Successful in 27s
Test TestAnActionOnANameThatIsNoAppIsA404 red first. Commit only.
2026-10-28 23:56:36 +01:00
admin eed23e7594 R-528: the 30-s OOM scan also reads the host's per-container kernel counter (agent GET /guest/oom-kills)
gates / gates (push) Successful in 32s
A count above 0 in a running container's current run fires the existing app_oom line (source cgroup/docker in the
details), whatever Docker's OOMKilled says. Old agent: 404 back-off; no agent: Docker's flag only.
Tests TestR528_* red first / red-proved. Commit only.
2026-10-28 23:22:46 +01:00
admin e33cffaee1 R-1254 (controller half): escrow claim/confirm outwait the agent (60 s); an unknown confirm outcome promises nothing
gates / gates (push) Successful in 28s
504 from the agent (it cannot learn which package the hub holds) or a failed call -> api.escrow.confirm_store_unknown
(hu+en); only 502 keeps 'your previous code still works'. Tests TestR1254_* red before the fix. Commit only.
2026-10-28 22:59:52 +01:00
admin 342a394bff R-1256: four restore edges (review round 2 HUNT-1 H4/H5/H6/H8) — each test seen failing first
gates / gates (push) Successful in 31s
H4: a failed definition write in the off-site restore writes the live definition back before the start;
    a failed write-back holds the app (restore_mixed).
H5: a put-back whose stop fails pours nothing, keeps the copy and holds the app (off-site, local, power-cut);
    a failed definition write-back keeps the copy (D20); a failed 'volume rm -f' fails that volume (no merge).
H6: TryBeginRestoreOp/RestoreBusy: check every operation and record the restore in one step under m.mu;
    every web handler and the kept loads begin through it; install-time kept load asks about the mirror too.
H8: SweepUnusedRestoreCopies every 6 h: removes a copy only with no hold, no marker naming it, no restore
    running, and older than 14 days.

Commit only; rides the next controller release.
2026-10-28 22:24:16 +01:00
admin ed3927f03a REPORT: controller v0.346.0 released; R-1250 fixed the same day
gates / gates (push) Successful in 1m18s
2026-10-27 15:49:59 +01:00
54 changed files with 3372 additions and 84 deletions

No files matched your search

+78
View File
@@ -1,3 +1,81 @@
## Unreleased (2026-10-29, attended) — D62: restore copies kept 30 days, listed, and deletable by hand; D63: a test-build fault switch for the H4/H5 live proofs; commit only, rides the next release
**MinAgent: 0.158.1** (unchanged). Needs hub ≥ the release carrying `restore_copy_deleted` (felhom.eu `19afefab`): an
older hub answers that one event 400 and the record is lost (the delete itself is unaffected).
- **D62 (operator ruling 2026-10-29 08:17) — the age: 30 days** (`restoreCopyUnusedAge`, was 14, the night's safest
reading). `TestD62_AnUnusedCopyIsKept30Days` (red at 14: the 20-day copy was swept).
- **D62 — the list (`backup.RestoreCopies`, `web.restoreCopyRows`, `backups_restore.html` „Megőrzött másolatok"):** each
restore copy shows the app, the date of the restore it protects, its size and „automatikusan törlődik: <date>". A copy
a hold or a pending put-back needs (a hold; the putback_pending hold; the app-stop marker naming it; an unreadable
marker) shows why in one line and its button is off — it never gets a date, because the sweep never takes it.
- **D62 — „Törlés most" (`POST /backup/restore-copy/delete`, `backup.DeleteRestoreCopy`):** the warning names the app
and the restore's date (the operator's text, hu + en); the household types the app's name. JSON answer: 200; 400 a
wrong name; 404 no such copy; 409 the copy is needed, or a backup/restore runs, or the app updates. The check and the
removal are one step under the backup manager's lock (no restore can begin in between). Path guard: both names one
path element, the copy a real directory (not a link) directly under `backups/restore-undo/<app>/`. One `[WARN]` log
line and one operator event `restore_copy_deleted` (who, app, restore date, size) per delete. CSRF as every form.
Tests `TestD62_*` (backup, web, notify) — each seen failing by a mutation of the check it pins.
- **D63 — the fault switch, TEST BUILDS ONLY (`cmd/controller/faultpoint_{on,off}.go`):** `go build -tags faultinject`
arms two points from a file (`<data>/faults/<point>` = „<skip> <fail>"): `defwrite-after-compose` (the definition
writer fails after the compose was written — H4) and `stop-<app>` (the app's stop fails — H5). Without the tag it
is a no-op that reads nothing; `TestD63_TheReleaseBuildHasNoFaultSwitch` pins that, and that build.sh and the
Dockerfile pass no build tag (red-proofed both ways).
## Unreleased (2026-10-28 night) — four restore edges (felhom.eu R-1256, review round 2 HUNT-1 H4/H5/H6/H8); commit only, rides the next release
**MinAgent: 0.158.1** (unchanged). Each edge: a test seen failing first, then the fix; live proof on 9202 in the felhom.eu
night report (`audits/night-2026-10-28/`). **Live 2026-10-29 on 9202:** H5's busy volume (not written into, the live data
put back, no hold) and H8 (the 03:29 sweep removed the two old copies, kept the young one). H4, H5's failed stop and H6:
tests only — the attended live proof (or a revert of those two branches) is owed.
- **H4 — `internal/backup/offbox_reconstitute.go`:** an off-site restore whose definition write (or database-password
write) fails no longer starts the app at once. The writer writes the compose BEFORE the app's settings, so the stack
dir could hold the snapshot's (older) compose: the live definition is written back first, then the app starts
(`restartAtLiveDefinition`); when the write-back fails too, the app is held (`restore_mixed`), never started on the
snapshot's definition. Tests `TestR1256_H4_*`.
- **H5 — a put-back whose stop fails does not go on.** `putBackAfterFailedRestore` (off-site and local unit restore):
a failed stop pours nothing, keeps the copy and holds the app; a failed definition write-back keeps the copy too (D20:
a put-back that fails keeps its copy). `CrashPutBack.PutBackInterruptedRestore` (a restore cut by a power loss): a
failed stop is an error, so the start holds the app (R-1251 H2) instead of pouring under a running app.
`restoreDockerVolumesFrom`: a `docker volume rm -f` that fails (the volume is in use) counts as a failed volume — the
following `volume create` was a no-op and the tar was unpacked ON TOP of the old content (a merge). Tests
`TestR1256_H5_*`. One log line corrected: a failed start after a put-back no longer says the copy stays (it is removed;
the volumes are back).
- **H6 — a restore begins in the same step as the check for every other operation.** New
`backup.TryBeginRestoreOp` / `RestoreBusy` (under `m.mu`, the lock the second-drive mirror's `beginTier2Run` asks
under): a restore op in flight, the backup run, a second-drive copy. Every web restore handler begins through
`Server.beginRestoreOp` (a source test refuses a direct `BeginRestoreOp`); the kept loads (`LoadKeptApp`,
`LoadKeptOffsite`) return `ErrRestoreBusy` with nothing started, and `LoadKeptApp` takes the dated folder's file move
as `prepare`, inside the operation. The install-time „use my kept data" asks about the second-drive copy too
(`RestoreBusy`, was RestoreStatus/IsRunning only). Tests `TestR1256_H6_*` (backup, web, api).
- **R-1254 H2, the controller half (felhom.eu R-1254; agent `5ddd124`):** the escrow claim and confirm reach the agent
with their own 60 s timeout (`agentapi.escrowCallTimeout`; was the client's 15 s) — the agent may spend its 30 s
upload plus 15 s asking the hub before it answers, and a claim abandoned at 15 s could still store a package whose
code reached nobody. The confirm's household sentence: 502 (the hub holds another package) keeps „…a régi kódod
továbbra is érvényes…"; 504 (the agent cannot learn the outcome) or a failed call (0) now reads
`api.escrow.confirm_store_unknown` („Nem tudjuk biztosan… Őrizd meg mindkét kódot…", hu + en) and never promises the
previous code. `TestR1151_ConfirmAnswersInTheBoxLanguage`'s status-0 case changes with it, on purpose. Tests
`TestR1254_*` (agentapi, web), red before the fix.
- **R-528 — the OOM line sees a kill Docker does not report (felhom.eu R-528; agent `GET /guest/oom-kills`).** The
30-s dead-app scan (`stacks.ScanOOMKilled`) now also asks the host's agent once per scan for the kernel's per-container
`oom_kill` counter (`agentapi.GuestOOMKills`, wired in `cmd/controller/hostoom_wiring.go`, 5 s cap). A running
container whose current run has a count above 0 is an OOM kill whatever Docker's `OOMKilled` says: the existing
`app_oom` line (once per run, R-514/R-636) fires, its details carry `source` (`docker`, `cgroup` or `docker,cgroup`),
and the host's count is the run's kill count. An agent without the route (404) is asked again only after 10 minutes
and said once; no agent (9202) = Docker's flag only, as before. The inspect now reads `{{.Id}}` too. Tests
`TestR528_*` (stacks, cmd/controller), red before the fix (the host-counted kill was not seen) or red-proved by
mutation (the back-off, the wiring).
- **Found walking the capability map's B2 on 9202 (small, fixed in the session):** a Stop/Start of a name that is no
app on the box (the bootstrap-managed `felhom-controller`) answered 500 „…az alkalmazás beállításai nem menthetők." —
the desired-state write's „not found" read as a save failure. `stacks.SetDesiredState` returns the
`ErrStackNotFound` kind with the household's sentence (`err.stacks.alkalmazas_nem_talalhato`); `actionStack` answers
404 before recording any intent. Nothing was ever stopped. Test `TestAnActionOnANameThatIsNoAppIsA404` (red first).
- **H8 — `internal/backup/restore_copy_sweep.go`:** an unused restore copy goes after 14 days. Every 6 hours
(`restore-copy-sweep`) a copy is removed only when the app has no restore hold of any kind, the app-stop marker is
readable and does not name the app, no restore runs, and the copy is older than 14 days. Tests `TestR1256_H8_*`
(and the wiring test).
## v0.346.1 (2026-10-27) — SECURITY: traefik v3.7.16, the file manager 1.5.8-stable, cloudflared 2026.10.4 — built from v0.346.0 (`b1b9be3`) + one commit (felhom.eu R-1304, R-1305)
**MinAgent: 0.158.1** (unchanged). Box programs only; no other change from v0.346.0.
+7
View File
@@ -1,5 +1,12 @@
# CONTEXT.md — Project Memory
> **2026-10-28 night (unattended) — R-1256, R-1254 (controller half), R-528 and the unknown-app 404 on `main`, NOT
> released (`342a394`, `e33cffa`, `eed23e7`, `20c5877`; MinAgent unchanged).** Restore ops begin through
> `Manager.TryBeginRestoreOp` / `web.beginRestoreOp` only (`TestR1256_H6_NoHandlerBeginsARestoreUnchecked` refuses a direct
> `BeginRestoreOp` in a handler). **Decided by CC unattended — operator may reverse:** an unused restore copy goes after
> 14 days (`restoreCopyUnusedAge`; felhom.eu `07` §6.3). H4 and H5's failed stop: tests only, live proof owed. H5 (busy
> volume) proven live on 9202, R-528 on Tester 1, with the local test tag `0.347.0-rc.n1028` (never pushed).
> **2026-10-26 (evening) — D53 ruled (felhom.eu `09` §3 decision 265): held apps reach the OPERATOR first.** Next here:
> the controller reports its holds (stack, reason, since) to the hub as `app_held` / `app_hold_cleared` events plus a
> daily re-send (felhom.eu `08` §10). Commit only; the release rides a later attended run.
+34 -19
View File
@@ -1,24 +1,39 @@
# REPORT — D53: the hub learns which apps are held — committed, NOT released (2026-10-26 evening)
# REPORT — restore edges, the escrow timeout, the host OOM count: on main, NOT released (night 2026-10-28 → 29, unattended)
**Plain words first.** A held app (stopped on purpose until a person acts) now reaches the hub: the box sends one
message when a hold appears or changes, one when it goes, and repeats every hold once a day. The hub shows the list
and mails the operator after a waiting time (felhom.eu `7e57e824`). Not released (this run allows no release).
**Plain words first.** Four commits are on main, each with CI green and each new test seen failing first. Nothing is
released: the night brief allowed no release. The controller release goes on the morning list. It needs no new MinAgent:
with an older agent the OOM count answers 404 and the controller waits 10 minutes before it asks again.
## The change (`5bd03ad`, CI 2377 ✓)
**Decided by CC unattended — operator may reverse:** an unused restore copy goes after 14 days (options: 14 days, for
ever, 3 days; `07` §6.3 [DESIGN] 2026-10-28). H4 and H5's failed-stop branch keep only test proofs; their live proof is
owed to an attended session (or revert those two branches of `342a394`).
- `internal/holdreport`: one observer of the hold store, ticked every minute by the scheduler (`hold-report`). It sends
`app_held` (stack, reason, since, the hold's sentence) for a new or changed hold, `app_hold_cleared` when a hold is
gone — also one cleared while the controller was down (`settings.reported_holds`) — and every hold again daily and at
start. Severity info.
- `settings.RestoreHold.Why()` / `WireReason()`: the hold sentence lives in one place (`holdWhy` calls it); the legacy
empty reason is sent as `restore_failed`.
- Tests `internal/holdreport/holdreport_test.go` (8). Red-proved: an inert `Tick` failed all six behaviour tests; the
scheduler line commented out failed the wiring test.
## Commits
## Live proof (felhom.eu `documentation/audits/d53-held-apps-2026-10-26/`)
| Commit | CI (run id / #) | What |
|---|---|---|
| `342a394` | 2469 / #532 green | **R-1256** four restore edges. H4: an off-site restore whose definition write fails writes the LIVE definition back before the start; a failed write-back holds the app. H5: a put-back whose stop fails pours nothing, keeps the copy, holds the app; a failed definition write-back keeps the copy (D20); a `volume rm -f` that fails fails that volume (no merge on top of the old content). H6: a restore begins in ONE step with the check for a restore, the backup run and a second-drive copy (`TryBeginRestoreOp`); every restore button and both kept loads begin that way. H8: `SweepUnusedRestoreCopies`, every 6 h. |
| `e33cffa` | 2472 / #533 green | **R-1254** controller half: escrow claim/confirm wait 60 s (the agent's own upload wait is longer than the old 15 s); an unknown outcome is 504 `api.escrow.confirm_store_unknown` („keep both codes"), hu + en. |
| `eed23e7` | 2474 / #534 green | **R-528**: the 30-s OOM scan also reads the host's per-container `oom_kill` (agent `GET /guest/oom-kills`); a counted kill raises `app_oom` whatever Docker's flag says; `source` in the details. |
| `20c5877` | 2475 / #535 green | An action on a name that is no app answers 404, not „settings cannot be saved" (found walking the capability map). |
9202 ran a local-only image of this commit (`0.346.0-d53test`, never pushed to the registry) against a test hub process.
A throwaway Radicale, crashed seven times, was stopped and held by the box (17:37:52Z); `app_held` reached the hub at the
next tick; one operator reminder after the 3-minute test grace; the household's Start lifted the hold and
`app_hold_cleared` removed the row. The first tick also reported four holds 9202 has carried since 2026-09-24 for apps
that are not installed (felhom.eu R-1298). 9202 is back on 0.343.0 with its own config.
Red-proofs: felhom.eu `documentation/audits/night-2026-10-28/redproofs/README.md`.
## Live proof (test build `0.347.0-rc.n1028`, local tag only, never pushed)
- **H5, scratch 9202:** vikunja's first volume held by a created-only container, the household's restore pressed through
the page: „Failed to remove volume vikunja_vikunja_data before the restore … (not written into)", the restore failed,
the put-back poured the live copy of both volumes, the app ran, no hold; a project from before the restore point AND
one from after it read back.
- **H8, scratch 9202:** three copies planted by hand (2026-10-01, 2026-10-05, 2026-10-28); the first sweep at 03:29 UTC
removed the two older than 14 days and kept the young one (8 ms). The young one was then removed by hand.
- **R-528, Tester 1:** one `app_oom` „seen by docker,cgroup", one mail with `"source":"docker,cgroup"`.
- **Not live:** H4 (off-site only; 9202 has no off-site target), H5's failed stop and crash put-back, H6 (the old
code also refused at the early check, so a live press cannot tell the two apart). Tests only.
Both boxes put back after. **Scratch 9202** (2026-10-29 04:14 UTC): controller 0.346.1 healthy, the test image removed, the
live catalog (config byte-identical to the saved copy), no throwaway app, no planted restore copy, no volume made
tonight. **Tester 1** (2026-10-28 23:30 UTC): controller 0.346.1, agent 0.167.0 (sha256 `28bdd282…`), the test app
removed. The bench is stopped. Hub: nothing provisioned.
Evidence: felhom.eu `documentation/audits/night-2026-10-28/` (`r1256-restore/`, `r528-oom/`).
+3
View File
@@ -80,8 +80,10 @@
| `restoreOpInFlight` + `hasRecentRestoreResult` | controller/internal/web/restore_wizard.go | `(backup.RestoreOpStatus) bool` / `(st, app, now) bool` | THE "is a restore running / did one just finish" display reads | **TRAP (v0.154.0 shipped this bug): `Manager` has TWO running flags.** `IsRunning()` reads the CONCURRENCY flag, acquired inside the goroutine — and `RestoreOffboxScratch` never acquires it, so it is false for the whole verification restore. Display must read `RestoreStatus().Running` (set synchronously by `BeginRestoreOp`). Read the status ONCE per render or the strip and the suppression can disagree. `hasRecentRestoreResult` is app-bound and window-bounded — a process-wide result must not light another app's „Eredmény" |
| `restoreWizardPath` / `deriveWizardStep` / `resolveWizardApp` | controller/internal/web/restore_wizard.go | `(app) string` / `(restoreWizardInput) restoreWizardView` / `([]OffboxAppRow, name) *OffboxAppRow` | R-48 offsite restore wizard: URL builder + the PURE step/unlock derivation + the app-resolution refusals | The step is **never** taken from the request. Precedence is load-bearing: op-running outranks a stale `?full_prep=`, else a commit button reappears mid-restore. Truth table + red-proof: `restore_wizard_test.go`. Adding a form here that posts anywhere new breaks `TestRestoreWizard_NoNewMutationEndpoints` **by design** — R-48 adds no mutation surface |
| `restoreOpBlocked` | controller/internal/web/restore_wizard.go | `() (msg string, blocked bool)` | THE refusal gate before starting ANY restore | **Use this, never a bare `IsRunning()`.** It reads BOTH flags: `RestoreStatus().Running` (set synchronously by `BeginRestoreOp`, true for the whole off-box restore) and `IsRunning()` (the concurrency flag, the only one the nightly backup holds). **R-351: all seven handlers read only `IsRunning()`, which the goroutine acquires AFTER the handler returns — a second press started a second run and was told „…elindult".** Returns the Hungarian refusal, which names the running app and a route | **R-360 (v0.226.0): `offboxVerifyCopyDeleteHandler` was the LAST holdout and its doc comment claimed it already did this — the sentence is why nobody looked. `IsRunning()` is FALSE for the whole of a verification restore, so the copy a restore was writing into could be deleted from the UI (observed live 2026-08-21 22:35). No app-name comparison: refusing during ANY restore is stronger and uniform.**
| `Server.beginRestoreOp` → `backup.TryBeginRestoreOp` / `RestoreBusy` (R-1256 H6) | controller/internal/web/restore_wizard.go; controller/internal/backup/opstatus.go | `(op, stack) (msg string, ok bool)` / `(op, stack) (busy, mirrorApp string)` | THE way a restore op begins: the check for every other operation (a restore op, the backup run, a second-drive copy) and the record in ONE step under `m.mu` — the mirror asks under the same lock (`beginTier2Run`) | **Never call `BeginRestoreOp` from a handler** (`TestR1256_H6_NoHandlerBeginsARestoreUnchecked`): `restoreOpBlocked` asked early can be stale by the time the form is read, and a mirror that began in between ran beside the restore. `restoreOpBlocked` stays as the EARLY refusal; the kept loads begin inside `LoadKeptApp` / `LoadKeptOffsite` and return `ErrRestoreBusy` |
| `Manager.RestoreFromRecoveryUnitAt` / `RestoreFromRecoveryUnit` + `UnitRestoreResult` (R-353 v0.226.0; R-102 v0.229.0) | controller/internal/backup/restore_unit.go | `(stack, unitDir string) (UnitRestoreResult, error)` / `(stack string) (…)` | THE local recovery-unit restore, and the facts its surface must state | **Returns a RESULT, not just an error** — volumes replayed, DBs replayed, and what the manifest LISTED. The Manifest* counts are load-bearing: zero-replayed has two causes (the backup held no data / the backup listed data that did not come back) and they are opposite news. Pair it with `unitRestoreOutcomeMsg`; do NOT write a new sentence. **A claim about the APP is forbidden on this path** — it has no `SafetyDump` discriminator, unlike the off-site twin (CONTEXT.md ruling, 07-backup-architecture §6.3). `restoreDockerVolumes` now returns `(int, error)`; `restoreDockerVolumesFrom` is unchanged and still the shared implementation. **R-102: `…At` holds the whole body and takes the unit DIRECTORY; the one-argument form is the thin caller naming the primary unit.** THE SOURCE MOVES; THE DESTINATION DOES NOT — `GetAppDrivePath` still resolves where the data lands. The R-47 mutation order, the unit-over-guest secret precedence, the fail-closed data-key gate and the no-unit `RestoreApp` fallback are all pinned and unchanged. The volume leg goes through the R-354 `volumeReplayFrom` seam so the source directory is assertable without Docker |
| `Manager.putBackAfterFailedRestore` + `liveVolumeCopy` (R-893 option A, v0.336.0) | controller/internal/backup/offbox_restore_undo.go | `(stack, cause, live, copy, volumesMoved, filesPlaced, restart, holdKey) error` | THE restore undo — off-site (v0.336.0) and, since D22 (unreleased), the LOCAL unit restore (`RestoreFromRecoveryUnitAtWith`, so also the second drive's and a kept load): the live volumes it will replace, measured before the stop (`planLiveVolumeCopy`), copied right after it (`copyLiveVolumes`), poured back on any failure | One copy per restore under `<ns>/backups/restore-undo/<app>/` (no capture set reads `backups/`); the put-back uses the restore's own `volumeReplayFrom`/`restoreDockerVolumesFrom`, so there is one volume-replay implementation. A put-back that fails sets `keep` and HOLDS (option C) — never start on volumes that are not known to be back. Seams for tests without Docker: `undoVolumeProbeFn`, `undoVolumeTarFn`, `undoVolumeRemoveFn`; operator notice `SetRestorePutBackNotify` (leg `restore-put-back`). Pinned by `r893_putback_test.go` and `r1177_local_putback_test.go` |
| `Manager.SweepUnusedRestoreCopies` (R-1256 H8) | controller/internal/backup/restore_copy_sweep.go | `(now) []string` | THE only AUTOMATIC remover of a restore copy nothing names any more (`restore-copy-sweep`, every 6 h); the household's „Törlés most" is `DeleteRestoreCopy` (D62, restore_copies.go — same „needed" rule via `restoreCopyNeededFor`, check + removal under `m.mu`) | Removes a copy only when ALL hold: older than 30 days (D62), the app has no restore hold of ANY kind, the app-stop marker is readable and does not name the app, no restore op runs. It answers „no" when it cannot answer — a held or waiting copy is support's way back |
| `CrashPutBack` + `Manager.persistUndoForCrash` + `AppStopGuard.SetUndo` (D22 / R-1195, v0.338.0) | controller/internal/backup/restore_crash_putback.go | `NewCrashPutBack(stop, writeDef, logger)`; `PutBackInterruptedRestore(stack, undoDir) error` | A restore cut by a power loss is put back at start: the copy (volumes + `definition/` + `undo.json`) is recorded in the app-stop marker once whole; `Recover` pours it back BEFORE the restart | Call `persistUndoForCrash` right after `copyLiveVolumes` in any new restore path. `Recover` runs before the backup manager exists, so the put-back is wired in main.go with `stackMgr.StopStack` and the stack adapter's definition writer. A failed put-back = a FAILED recovery (app not started, marker kept). The household reads `note.restore.interrupted_put_back` (`LoadRestoreRecordAfterRecovery`). Pinned by `TestR1195_*` |
| `tier2CaptureSetFull(...) (legs, missing, warns, classified)` + `keptCopyRels` + `dropKeptCopiesUnder` (R-1252, unreleased) | controller/internal/backup/tier2_capture.go, tier2_kept.go | `classified` = the legs come from a VALID backup block | **The second-drive reconcile deletes only under a valid classification**; a rejected or absent block keeps every dest dir; kept copies (D25) are always in the keep set; a mirror deleted by Remove drops its kept records | A new reconcile caller must take `classified` and the kept rels too — never reconcile against the legacy legs. A rejected block is told by `Manager.BackupBlockRejected` (stacks; via the adapter's optional `BackupBlockRejected` method) and signalled once a day (`SetTier2SettingsUnreadableNotify`). Pinned by `r1252_rejected_block_keeps_copies_test.go` |
| `holdreport.Reporter` + `settings.RestoreHold.Why` / `WireReason` (D53, unreleased) | controller/internal/holdreport/holdreport.go · controller/internal/settings/settings.go | `New(store, notifier.PushEvent, logger)`; `Tick(now)` from the scheduler | Tells the hub every hold in place (`app_held`), every hold gone (`app_hold_cleared`), each again daily | **One observer of the hold store — never a send inside a hold writer.** A new hold writer needs nothing here. The hold sentence is `RestoreHold.Why()` (one per reason) — never a second copy. Pinned by `internal/holdreport/holdreport_test.go` |
@@ -357,6 +359,7 @@
|---|---|---|---|
| `diskAgent` | controller/internal/web/storage_handlers.go | `*agentapi.Client` | `mockAgent` in controller/internal/web/storage_handlers_test.go |
| `netAgent` + `Server.netAgentFn/netProbeFn/netListFn` | controller/internal/web/netstorage_job.go (+ server.go fields) | `*agentapi.Client` / `runNetProbe` (linux re-exec) / `agent.ListNetStorage` | `fakeNetAgent` + fn injections in controller/internal/web/netstorage_job_test.go — the NAS add orchestration never shells/TLS-dials in tests |
| `faultPoint(point) error` (D63, TEST BUILDS ONLY) | controller/cmd/controller/faultpoint_{off,on}.go | a no-op without `-tags faultinject` (the release image); with it, a file `<data>/faults/<point>` = „<skip> <fail>" fails the named point | `TestD63_TheReleaseBuildHasNoFaultSwitch` (release build: no-op; build.sh/Dockerfile pass no tag), `TestD63_TheTestBuildFailsTheArmedPointOnDemand` (`go test -tags faultinject`). Points: `defwrite-after-compose`, `stop-<app>`. A live proof of a branch Docker cannot be made to take on demand goes here — never an env switch in the release binary |
| `healthSettle` / `healthInterval` / `healthTimeout` (package vars, R-488) | controller/internal/backup/restore.go | production 3 s / 5 s / 90 s — `waitForHealthy`'s clock, used by every restore path | shortened ONCE in controller/internal/backup/main_test.go `TestMain` (0 / 2 ms / 50 ms; the package went 444 s → ~2 s); `TestR488_HealthWaitDefaultsAreProduction` pins the production values. A new real-clock wait in this package goes behind the same kind of var, not a per-test sleep. Also: `newOffboxManager` installs a failing `SetOffboxSSH` fake, so no backup test spawns a real `ssh` |
| `Server.agentLogsFn` (func seam) | controller/internal/web/server.go | nil → `agentClient().DebugLogs` (agent GET /debug/logs) | injected in controller/internal/web/observability_test.go (incl. the pre-0.83 typed-404 notice path) |
| `escrowAgent` + `Server.escrowAgentFn/escrowStageFn/escrowStaleFn` | controller/internal/web/escrow_handlers.go (+ server.go fields) | `*agentapi.Client` / `PushOffboxPasswordForEscrow` / `report.EscrowAutoConfirmer.StaleBlob` (SetEscrowStale) | `fakeEscrowAgent` + fn injections in escrow_wizard_test.go — call-ORDER assertions (stage BEFORE trigger) + agent-never-called gates. The claim leg is the ONLY surface R crosses: no-store, never logged, never templated |
+16
View File
@@ -1034,6 +1034,18 @@ Each app can define rich metadata in `.felhom.yml`:
`healthy`; the copy then goes after a further 3 min of the same (10 s samples, watched up to 30 min; a new restore
of the app ends the watch). A restore whose app does not start reads „…még nem indult el…", keeps the copy and
alarms the operator once (D49 = A: nothing is poured back by itself). `restore_verdict.go`. `offbox_restore_undo.go`.
- **Restore edges (unreleased, R-1256, night 2026-10-28).** A put-back whose stop fails pours nothing, keeps the copy
and holds the app; a failed definition write-back keeps the copy; a volume whose `docker volume rm -f` fails is a
failed volume (never unpacked on top of the old content). An off-site restore whose definition write fails writes
the LIVE definition back before the start, or holds the app. Every restore begins in one step with the check for a
restore, the backup run and a second-drive copy (`TryBeginRestoreOp`, also both kept loads). A copy that nothing
needs (no hold of any kind, not named by the app-stop marker, no restore running) goes after 30 days, checked every
6 h (`restore_copy_sweep.go`; operator ruling D62, 2026-10-29).
- **Restore copies in the kept-copies list (unreleased, D62).** The restore page's „Megőrzött másolatok" lists each
restore copy: the app, the restore's date, the size, „automatikusan törlődik: <date>". „Törlés most" deletes one
earlier, after a warning naming the app and the date and the app's name typed (`POST /backup/restore-copy/delete`,
JSON; 409 while a backup/restore runs or the app updates). A copy a hold or a pending put-back needs: the button is
off, the reason on one line. Each delete: one log line and the operator event `restore_copy_deleted`.
- **A restore cut by a power loss, on a drive that comes back late (unreleased, R-1251).** The copy of the live
data lives on the app's drive. When it cannot be reached at start because the drive is not bound yet, the app is
HELD (`putback_pending`; „…A visszaállítás előtti adatai egy meghajtón vannak, amely most nem érhető el…") and no
@@ -1488,6 +1500,10 @@ The nightly backup has two phases that run sequentially. All paths are **per-dri
> **OOM visibility (v0.243.0, R-514).** The 30 s dead-app check also reads `State.OOMKilled` for running app containers (`Manager.ScanOOMKilled`); the dashboard shows „Memória elfogyott" and the hub gets `app_oom` once per container run. The controller does not restart the app.
> **OOM storm (v0.265.0, R-636).** For a flagged container the scan also reads the kernel's own kill counter (`memory.events` `oom_kill`, plus `memory.max` / `memory.peak`) with one `docker exec … cat` — `OOMKilled` is sticky, so it cannot count. When the SAME container run's counter rises by **20 or more within 30 minutes**, the notifier sends **`app_oom_storm`** (severity `error`, operator-only, details `{app, container, kills, window_min, mem_limit, peak}`) — ONCE per container run. `app_oom` itself is unchanged. An unreadable counter (an image without `cat`) never escalates.
> **Host OOM count (unreleased, R-528, night 2026-10-28).** Docker inside an LXC guest misses real OOM kills. The scan
> also asks the agent for the host's per-container `oom_kill` (`GET /guest/oom-kills`, 5 s cap; a 404 from an older
> agent → 10 minutes before the next ask); a count above zero raises `app_oom` whatever `OOMKilled` says, with `source`
> (`docker`, `cgroup` or `docker,cgroup`) in the details. `cmd/controller/hostoom_wiring.go`, `internal/stacks/oom.go`.
> **Multi-tier whole-guest backup (v0.174.0, R-82 Slice B).** The agent can serve SEVERAL whole-guest
> backup tiers with independent cadences — "local daily + PBS weekly" (agent >= v0.97.0,
@@ -0,0 +1,34 @@
//go:build faultinject
package main
import (
"os"
"path/filepath"
"testing"
)
// D63 (test build only): "<skip> <fail>" lets <skip> passes through, fails the next <fail>, then removes the file.
func TestD63_TheTestBuildFailsTheArmedPointOnDemand(t *testing.T) {
dir := t.TempDir()
t.Setenv("FELHOM_FAULT_DIR", dir)
p := filepath.Join(dir, "stop-radicale")
if err := os.WriteFile(p, []byte("1 1\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := faultPoint("stop-radicale"); err != nil {
t.Fatalf("the first pass must go through: %v", err)
}
if err := faultPoint("stop-other"); err != nil {
t.Fatalf("another point is not armed: %v", err)
}
if err := faultPoint("stop-radicale"); err == nil {
t.Fatal("the second pass must fail")
}
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Fatalf("the spent file must be gone: %v", err)
}
if err := faultPoint("stop-radicale"); err != nil {
t.Fatalf("a spent point fails nothing: %v", err)
}
}
@@ -0,0 +1,40 @@
//go:build !faultinject
package main
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
// goBuildTag is a `go build -tags …` flag (build.sh's own `--print-tags` option is not one).
var goBuildTag = regexp.MustCompile(`\s-tags[ =]`)
// D63: the fault switch exists only in a `-tags faultinject` test build. In this (the release) build an armed fault
// file injects nothing, and neither build.sh nor the Dockerfile passes a build tag.
// RED-PROOF: build the release build with the switch (drop the !faultinject line from faultpoint_off.go and the
// faultinject line from faultpoint_on.go) → the armed point fails → FAILS.
func TestD63_TheReleaseBuildHasNoFaultSwitch(t *testing.T) {
dir := t.TempDir()
t.Setenv("FELHOM_FAULT_DIR", dir)
for _, p := range []string{"defwrite-after-compose", "stop-radicale"} {
if err := os.WriteFile(filepath.Join(dir, p), []byte("0 5\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := faultPoint(p); err != nil {
t.Fatalf("the release build injected a fault at %s: %v", p, err)
}
}
for _, f := range []string{"../../build.sh", "../../Dockerfile"} {
b, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
if goBuildTag.MatchString(string(b)) || strings.Contains(string(b), "faultinject") {
t.Fatalf("%s passes a build tag — the release image could carry the fault switch", f)
}
}
}
@@ -0,0 +1,9 @@
//go:build !faultinject
package main
// faultPoint is the D63 live-proof fault switch (operator ruling 2026-10-29: prove R-1256 H4 and H5's failed stop live,
// with a fault switch in a TEST build only). In every build without the `faultinject` tag — the release image among
// them: build.sh and the Dockerfile pass no build tags — it does nothing and reads nothing. Pinned by
// TestD63_TheReleaseBuildHasNoFaultSwitch; the switch itself is faultpoint_on.go.
func faultPoint(string) error { return nil }
@@ -0,0 +1,64 @@
//go:build faultinject
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
)
// faultPoint (TEST BUILDS ONLY — `go build -tags faultinject`; never the release image, faultpoint_off.go) fails a named
// point on demand, for the D63 live proofs of R-1256 H4 (a definition write that fails after the compose was written)
// and H5 (a put-back whose stop fails). Armed by a file <dir>/<point> holding "<skip> <fail>": the next <skip> passes go
// through, the <fail> after them fail, and the file is removed when both are spent. <dir> is $FELHOM_FAULT_DIR, else
// /opt/docker/felhom-controller/faults (the controller's data volume). A file that does not parse fails nothing.
var faultMu sync.Mutex
func faultPoint(point string) error {
faultMu.Lock()
defer faultMu.Unlock()
dir := os.Getenv("FELHOM_FAULT_DIR")
if dir == "" {
dir = "/opt/docker/felhom-controller/faults"
}
p := filepath.Join(dir, point)
data, err := os.ReadFile(p)
if err != nil {
return nil
}
f := strings.Fields(string(data))
if len(f) != 2 {
log.Printf("[WARN] [faultinject] %s: %q does not parse as \"<skip> <fail>\" — nothing injected", p, strings.TrimSpace(string(data)))
return nil
}
skip, e1 := strconv.Atoi(f[0])
fail, e2 := strconv.Atoi(f[1])
if e1 != nil || e2 != nil || skip < 0 || fail < 0 {
return nil
}
write := func() {
if skip == 0 && fail == 0 {
_ = os.Remove(p)
return
}
_ = os.WriteFile(p, []byte(fmt.Sprintf("%d %d\n", skip, fail)), 0o600)
}
if skip > 0 {
skip--
write()
log.Printf("[WARN] [faultinject] %s: passed (skip left %d, fail left %d)", point, skip, fail)
return nil
}
if fail > 0 {
fail--
write()
log.Printf("[ERROR] [faultinject] %s: FAULT INJECTED (fail left %d)", point, fail)
return fmt.Errorf("FAULT INJECTED at %s (D63 test build)", point)
}
return nil
}
@@ -0,0 +1,50 @@
package main
import (
"context"
"errors"
"log"
"net/http"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
)
// hostOOMSource (R-528) is the 30-s dead-app scan's host-side OOM counter: one agent call per scan (5 s cap). An agent
// without the route (404) is not asked again for hostOOMRetryAfter, and said once; any other failure is a nil answer
// for this scan only (Docker's flag still works). Pinned by TestR528_HostOOMSource*.
const hostOOMRetryAfter = 10 * time.Minute
func hostOOMSource(ask func(context.Context) (agentapi.GuestOOMKillsState, error), logger *log.Logger, now func() time.Time) func() map[string]int64 {
var mu sync.Mutex
var until time.Time
said := false
return func() map[string]int64 {
mu.Lock()
defer mu.Unlock()
if now().Before(until) {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
st, err := ask(ctx)
if err != nil {
var se *agentapi.StatusError
if errors.As(err, &se) && se.Code == http.StatusNotFound {
until = now().Add(hostOOMRetryAfter)
if !said {
logger.Printf("[INFO] [deadapp] the agent has no OOM counter route (R-528) — Docker's OOM flag only; asked again in %s", hostOOMRetryAfter)
said = true
}
return nil
}
logger.Printf("[WARN] [deadapp] the host OOM counter could not be read: %v — Docker's OOM flag only for this scan", err)
return nil
}
if !st.Available {
return nil
}
return st.Containers
}
}
+18 -2
View File
@@ -1035,6 +1035,9 @@ func main() {
logger.Printf("[WARN] [deadapp] agent client init failed (%v) — the boot grace cannot learn of a crash boot; 90 s", err)
} else {
crashProbe = crashboot.AgentProbe(ac.CrashGuard)
// R-528: the 30-s scan also asks the host for the kernel's per-container OOM counter (Docker's own flag can
// stay false inside an LXC guest). TestR528_HostOOMSourceIsWiredAtStartup.
stackMgr.SetHostOOMKills(hostOOMSource(ac.GuestOOMKills, logger, time.Now))
}
}
bootGrace := crashboot.New(startTime, crashProbe, logger)
@@ -1094,8 +1097,8 @@ func main() {
logger.Printf("[WARN] [deadapp] OOM scan failed: %v", oerr)
} else {
for _, o := range ooms {
logger.Printf("[WARN] [deadapp] %s: container %s was OOM-killed (started %s; kills %d, limit %s, peak %s)", o.Stack, o.Container, o.StartedAt, o.Kills, o.MemLimit, o.Peak)
notifier.NotifyAppOOM(o.Stack, o.Container, o.StartedAt, o.Kills, o.MemLimit, o.Peak)
logger.Printf("[WARN] [deadapp] %s: container %s was OOM-killed (started %s; kills %d, limit %s, peak %s; seen by %s)", o.Stack, o.Container, o.StartedAt, o.Kills, o.MemLimit, o.Peak, o.Source)
notifier.NotifyAppOOMFrom(o.Stack, o.Container, o.StartedAt, o.Kills, o.MemLimit, o.Peak, o.Source)
}
}
// v0.269.0 (`09` §3 decision 28, R-667): a crash loop or an OOM storm is STOPPED by the box.
@@ -1400,6 +1403,13 @@ func main() {
return nil
})
// R-1256 H8: a restore copy no hold, no pending put-back and no running restore needs goes after 14 days. Every 6
// hours — a copy outliving its age by hours costs disk, never data. TestR1256_H8_TheSweepIsWiredAtStartup.
sched.Every("restore-copy-sweep", 6*time.Hour, func(ctx context.Context) error {
backupMgr.SweepUnusedRestoreCopies(time.Now())
return nil
})
// Tier 2: off-drive copy of each HDD app's recovery unit + userdata (auto-enabled, auto-target).
// Runs after the DB dump so it copies a fresh unit.
backupMgr.SetTier2Notifier(func(stackName, destLabel string, dur time.Duration, err error) {
@@ -3181,6 +3191,9 @@ func (a *stackAdapter) ListDeployedStacks() []backup.StackSummary {
}
func (a *stackAdapter) StopStack(name string) error {
if err := faultPoint("stop-" + name); err != nil { // D63: a no-op outside a -tags faultinject test build
return err
}
return a.mgr.StopStack(name)
}
@@ -3425,6 +3438,9 @@ func (a *stackAdapter) RecreateStackDefinitionFromUnit(name, composeSrcDir strin
return fmt.Errorf("restoring %s from unit: %w", fname, err)
}
}
if err := faultPoint("defwrite-after-compose"); err != nil { // D63: a no-op outside a -tags faultinject test build
return err
}
// R-1057 (v0.314.0): the gate as the backup recorded it — the unit's app.yaml, read BEFORE the write.
gate := stacks.ReadGateAtBackup(filepath.Join(composeSrcDir, "app.yaml"))
if err := a.mgr.PersistUnitRedeployConfigGate(name, fullEnv, gate); err != nil {
@@ -0,0 +1,13 @@
package main
import "testing"
// TestR1256_H8_TheSweepIsWiredAtStartup — R-1256 H8: an unused restore copy goes only through
// SweepUnusedRestoreCopies, so main.go must CALL it (the seam-built-but-never-wired class).
// RED-PROOF: delete the `restore-copy-sweep` job — this fails.
func TestR1256_H8_TheSweepIsWiredAtStartup(t *testing.T) {
lines, _, _ := slice4CallLines(t)
if len(lines["SweepUnusedRestoreCopies"]) == 0 {
t.Fatal("SweepUnusedRestoreCopies is never called — an unused restore copy would stay on the drive for ever")
}
}
@@ -0,0 +1,60 @@
package main
import (
"context"
"errors"
"io"
"log"
"net/http"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
)
// R-528: the scan's host counter source. An old agent (404) is asked again only after hostOOMRetryAfter; a transient
// failure is retried at the next scan; the counters pass through.
// RED-PROOF: drop the 404 back-off → an old agent is asked at every 30-s scan (calls 3, not 1) → FAILS.
func TestR528_HostOOMSourceBacksOffAnOldAgent(t *testing.T) {
clock := time.Date(2026, 10, 28, 23, 0, 0, 0, time.UTC)
calls := 0
var answer error = &agentapi.StatusError{Path: "/guest/oom-kills", Code: http.StatusNotFound}
src := hostOOMSource(func(context.Context) (agentapi.GuestOOMKillsState, error) {
calls++
if answer != nil {
return agentapi.GuestOOMKillsState{}, answer
}
return agentapi.GuestOOMKillsState{Available: true, Containers: map[string]int64{"id": 2}}, nil
}, log.New(io.Discard, "", 0), func() time.Time { return clock })
for i := 0; i < 3; i++ {
if got := src(); got != nil {
t.Fatalf("an old agent: %v", got)
}
clock = clock.Add(30 * time.Second)
}
if calls != 1 {
t.Fatalf("an old agent was asked %d times in 90 s, want 1", calls)
}
clock = clock.Add(hostOOMRetryAfter)
answer = nil
if got := src(); got["id"] != 2 || calls != 2 {
t.Fatalf("after the back-off, an upgraded agent: %v (calls %d)", got, calls)
}
answer = errors.New("timeout")
if got := src(); got != nil || calls != 3 {
t.Fatalf("a transient failure: %v", got)
}
answer = nil
if got := src(); got["id"] != 2 || calls != 4 {
t.Fatalf("the next scan after a transient failure must ask again: %v (calls %d)", got, calls)
}
}
// The source is wired into the stack manager at startup (the seam-built-but-never-wired class).
// RED-PROOF: delete the SetHostOOMKills line in main.go → FAILS.
func TestR528_HostOOMSourceIsWiredAtStartup(t *testing.T) {
lines, _, _ := slice4CallLines(t)
if len(lines["SetHostOOMKills"]) == 0 || len(lines["hostOOMSource"]) == 0 {
t.Fatal("the host OOM counter is never wired — the 30-s scan would read Docker's flag only (R-528)")
}
}
+13 -1
View File
@@ -1071,6 +1071,18 @@ func (c *Client) RemoveNetStorage(ctx context.Context, name string) error {
// status, even on a non-2xx (so callers like FormatDisk can read a 403 refusal body). A transport or
// envelope-parse failure is still an error; an `ok:false` business refusal is NOT (the data carries it).
func (c *Client) postWithStatus(ctx context.Context, path string, body any) (apiResponse, int, error) {
return c.postWithStatusOn(c.hc, ctx, path, body)
}
// postWithStatusTimeout is postWithStatus with its own overall timeout (the escrow claim and confirm, R-1254). It shares
// the pinned transport.
func (c *Client) postWithStatusTimeout(ctx context.Context, path string, body any, timeout time.Duration) (apiResponse, int, error) {
hc := *c.hc
hc.Timeout = timeout
return c.postWithStatusOn(&hc, ctx, path, body)
}
func (c *Client) postWithStatusOn(hc *http.Client, ctx context.Context, path string, body any) (apiResponse, int, error) {
var env apiResponse
buf, err := json.Marshal(body)
if err != nil {
@@ -1083,7 +1095,7 @@ func (c *Client) postWithStatus(ctx context.Context, path string, body any) (api
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", "application/json")
start := time.Now()
resp, err := c.hc.Do(req)
resp, err := hc.Do(req)
if err != nil {
logx.Debugf(c.logger, "[agentapi] POST %s failed after %dms: %v", path, time.Since(start).Milliseconds(), err)
return env, 0, fmt.Errorf("agentapi: POST %s: %w", path, err)
+10 -2
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"net/http"
"time"
)
// Controller-driven escrow ceremony client methods (v0.127.0, agent ≥ v0.88.0). The claim call
@@ -108,8 +109,15 @@ type EscrowClaim struct {
//
// R-1151: the claim asks the agent to keep the new package until the household typed the code back
// (`defer_upload`). An agent older than 0.160.0 ignores the body and uploads as before.
// escrowCallTimeout bounds the claim and the confirm (R-1254 H2). The agent may take its whole upload timeout (30 s)
// plus its question to the hub (15 s) before it answers; the client's own 15 s used to abandon the call while the
// agent went on — a claim abandoned that way could still store the package, and its code reached nobody. 60 s
// outlasts both (TestR1254_ClaimAndConfirmOutwaitTheAgentsUpload; the agent side: escrowUploadTimeout,
// escrowHoldTimeout in felhom-agent internal/localapi/escrow_ceremony.go — if either grows, this must too).
var escrowCallTimeout = 60 * time.Second
func (c *Client) EscrowCeremonyClaim(ctx context.Context) (EscrowClaim, int, error) {
env, status, err := c.postWithStatus(ctx, "/escrow/ceremony/claim", map[string]bool{"defer_upload": true})
env, status, err := c.postWithStatusTimeout(ctx, "/escrow/ceremony/claim", map[string]bool{"defer_upload": true}, escrowCallTimeout)
if err != nil {
return EscrowClaim{}, status, err
}
@@ -141,7 +149,7 @@ func (c *Client) EscrowCeremonyClaim(ctx context.Context) (EscrowClaim, int, err
// match (still waiting), 410 the window ran out, 404 nothing waits (superseded, restarted), 502 the hub failed (still
// waiting). The words are part of the recovery code — never logged.
func (c *Client) EscrowCeremonyConfirm(ctx context.Context, words []string) (int, error) {
env, status, err := c.postWithStatus(ctx, "/escrow/ceremony/confirm", map[string][]string{"words": words})
env, status, err := c.postWithStatusTimeout(ctx, "/escrow/ceremony/confirm", map[string][]string{"words": words}, escrowCallTimeout)
if err != nil {
return status, err
}
+31
View File
@@ -0,0 +1,31 @@
package agentapi
import (
"context"
"encoding/json"
"fmt"
)
// GuestOOMKillsState mirrors the agent's GET /guest/oom-kills (R-528, agent ≥ the R-528 commit): the kernel's oom_kill
// counter of each of THIS guest's containers, read on the host — Docker's own OOMKilled flag can stay false inside an
// LXC guest. Containers maps the full 64-hex container id to the count for the container's current run.
// Available=false: the guest has no cgroup folder the host can read. An agent that predates the route answers 404 (a
// *StatusError) — the controller then keeps Docker's flag only.
type GuestOOMKillsState struct {
VMID int `json:"vmid"`
Available bool `json:"available"`
Containers map[string]int64 `json:"containers"`
}
// GuestOOMKills calls GET /guest/oom-kills.
func (c *Client) GuestOOMKills(ctx context.Context) (GuestOOMKillsState, error) {
var out GuestOOMKillsState
body, err := c.get(ctx, "/guest/oom-kills")
if err != nil {
return out, err
}
if err := json.Unmarshal(body, &out); err != nil {
return out, fmt.Errorf("agentapi: decode /guest/oom-kills: %w", err)
}
return out, nil
}
@@ -0,0 +1,42 @@
package agentapi
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// R-1254 H2: the agent's claim/confirm may take its whole upload timeout (30 s) plus the hub question (15 s) before it
// answers. The controller's agent client gives up at 15 s — and a claim the controller abandons can still complete on
// the agent: the hub then holds a package whose code nobody saw. The two calls carry their own, longer timeout.
// RED-PROOF: send claim/confirm through the plain 15 s client again → the slow agent below times them out → FAILS.
func TestR1254_ClaimAndConfirmOutwaitTheAgentsUpload(t *testing.T) {
if escrowCallTimeout < 45*time.Second+10*time.Second {
t.Fatalf("escrowCallTimeout %s must outlast the agent's upload (30 s) + hub question (15 s) with room", escrowCallTimeout)
}
mux := http.NewServeMux()
mux.HandleFunc("POST /escrow/ceremony/claim", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(300 * time.Millisecond)
_, _ = w.Write([]byte(`{"ok":true,"data":{"recovery_code":"a-b-c"}}`))
})
mux.HandleFunc("POST /escrow/ceremony/confirm", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(300 * time.Millisecond)
_, _ = w.Write([]byte(`{"ok":true,"data":{"uploaded":true}}`))
})
s := httptest.NewTLSServer(mux)
defer s.Close()
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
c.hc.Timeout = 100 * time.Millisecond // the client's own short timeout, scaled down
old := escrowCallTimeout
escrowCallTimeout = 5 * time.Second
defer func() { escrowCallTimeout = old }()
if claim, st, err := c.EscrowCeremonyClaim(context.Background()); err != nil || st != http.StatusOK || claim.Code != "a-b-c" {
t.Fatalf("a slow claim was abandoned: %+v %d %v", claim, st, err)
}
if st, err := c.EscrowCeremonyConfirm(context.Background(), []string{"a", "c"}); err != nil || st != http.StatusOK {
t.Fatalf("a slow confirm was abandoned: %d %v", st, err)
}
}
+11 -6
View File
@@ -94,7 +94,9 @@ func (r *Router) keptDataAtInstall(w http.ResponseWriter, req *http.Request, nam
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: data.UseOff, Data: data})
return true
}
if st := r.backupMgr.RestoreStatus(); st.Running || r.backupMgr.IsRunning() {
// R-1256 H6: every operation — a restore, a backup run, a second-drive copy — not only the first two; and the
// load itself begins in the same step as this question (startKeptLoad → ErrRestoreBusy).
if busy, _ := r.backupMgr.RestoreBusy(); busy != "" {
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.msgLang(lang, "api.kept.busy")})
return true
}
@@ -104,7 +106,11 @@ func (r *Router) keptDataAtInstall(w http.ResponseWriter, req *http.Request, nam
}
r.logger.Printf("[INFO] [api] Deploy %s: USE MY KEPT DATA — loading from %s (tier %d, %s) under the kept files %v", name, src, cp.c.Tier, cp.c.Time.UTC().Format(time.RFC3339), old)
r.dropLeftoverHold(name, "use-kept-data") // R-704: this is a new install of an app that is not installed
r.startKeptLoad(name, hdd, cp.c, lang, nil)
if err := r.startKeptLoad(name, hdd, cp.c, lang, nil); err != nil {
r.logger.Printf("[WARN] [api] Deploy %s: use my kept data REFUSED at its start: %v", name, err)
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.msgLang(lang, "api.kept.busy")})
return true
}
writeJSON(w, http.StatusAccepted, apiResponse{OK: true, Message: r.msgLang(lang, "kept.load.started", display)})
return true
default:
@@ -120,7 +126,7 @@ func (r *Router) keptCopyName(lang string, c backup.KeptCopy) string {
// startKeptLoad runs the load and, when it succeeded, the app's after_load. then(ok) runs last. The
// off-site copy goes through LoadKeptOffsite (download the unit alone, judge it, then the same restore).
func (r *Router) startKeptLoad(name, drive string, c backup.KeptCopy, lang string, then func(ok bool)) {
func (r *Router) startKeptLoad(name, drive string, c backup.KeptCopy, lang string, then func(ok bool)) error {
display := name
if st, ok := r.stackMgr.GetStack(name); ok && st.Meta.DisplayName != "" {
display = st.Meta.DisplayName
@@ -128,10 +134,9 @@ func (r *Router) startKeptLoad(name, drive string, c backup.KeptCopy, lang strin
okMsg := func(error) string { return r.msgLang(lang, "kept.load.done", display) }
failMsg := func(err error) string { return r.msgLang(lang, "kept.load.failed", display, err) }
if c.Tier == backup.KeptTierOffsite {
r.backupMgr.LoadKeptOffsite(name, drive, c, nil, okMsg, failMsg, r.keptAfterLoad(name, then))
return
return r.backupMgr.LoadKeptOffsite(name, drive, c, nil, okMsg, failMsg, r.keptAfterLoad(name, then))
}
r.backupMgr.LoadKeptApp(name, c.UnitDir, okMsg, failMsg, r.keptAfterLoad(name, then))
return r.backupMgr.LoadKeptApp(name, c.UnitDir, nil, okMsg, failMsg, r.keptAfterLoad(name, then))
}
// keptAfterLoad runs the app's after_load when the load succeeded, then then(ok).
+44 -1
View File
@@ -162,7 +162,10 @@ func TestR691_InstallChoiceNamesTheOffsiteCopy(t *testing.T) {
// copy, restic and the unit restore behind their seams — no Docker).
// COMPANION RED-PROOF: remove the dropLeftoverHold call from keptDataAtInstall → the hold is still there
// after the 202 and the first assertion fails; remove it from deployStack → the AST check fails.
func TestR704_AFreshInstallDropsALeftoverHold(t *testing.T) {
// keptInstallRig is an installable app with old data on its drive and an off-site copy of its unit; done receives
// once per unit restore the load reaches.
func keptInstallRig(t *testing.T) (*Router, *settings.Settings, string, chan struct{}) {
t.Helper()
dir := t.TempDir()
drive := filepath.Join(dir, "drive")
cfg := &config.Config{}
@@ -234,6 +237,11 @@ func TestR704_AFreshInstallDropsALeftoverHold(t *testing.T) {
return backup.UnitRestoreResult{}, nil
})
r := &Router{stackMgr: m, backupMgr: bm, sett: sett, logger: log.New(io.Discard, "", 0)}
return r, sett, drive, done
}
func TestR704_AFreshInstallDropsALeftoverHold(t *testing.T) {
r, sett, drive, done := keptInstallRig(t)
// A leftover update hold from an earlier install (the demo-hp shape): the new install drops it.
_ = sett.SetRestoreHold(settings.RestoreHold{Stack: "cloudapp", At: "2026-09-13T19:56:32Z", Reason: settings.HoldReasonUpdateFailed})
@@ -287,3 +295,38 @@ func TestR704_AFreshInstallDropsALeftoverHold(t *testing.T) {
t.Fatalf("deployStack must call dropLeftoverHold before DeployStack (drop=%v deploy=%v)", dropAt, deployAt)
}
}
// R-1256 H6: the install-time „use my kept data" asks about EVERY operation — a second-drive copy too, not only a
// restore record and the backup run — and the load begins in the same step as the question.
// RED-PROOF: restore the RestoreStatus/IsRunning-only check and LoadKept*'s plain BeginRestoreOp → 202 and the load
// runs beside the copy → FAILS.
func TestR1256_H6_AKeptLoadAtInstallIsRefusedWhileASecondDriveCopyRuns(t *testing.T) {
r, _, drive, done := keptInstallRig(t)
end := backup.MarkTier2CopyForTest("someapp")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/stacks/cloudapp/deploy?lang=en", nil)
r.keptDataAtInstall(w, req, "cloudapp", drive, "use")
end()
if w.Code != http.StatusConflict {
t.Fatalf("a load during a second-drive copy: code %d, want 409 — body %s", w.Code, w.Body.String())
}
select {
case <-done:
t.Fatal("the load ran beside the second-drive copy")
case <-time.After(300 * time.Millisecond):
}
if st := r.backupMgr.RestoreStatus(); st.Running {
t.Fatal("a refused load left a restore op recorded")
}
// The copy ended: the same press loads.
w = httptest.NewRecorder()
r.keptDataAtInstall(w, httptest.NewRequest(http.MethodPost, "/api/stacks/cloudapp/deploy?lang=en", nil), "cloudapp", drive, "use")
if w.Code != http.StatusAccepted {
t.Fatalf("after the copy: code %d — body %s", w.Code, w.Body.String())
}
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("the load never reached the unit restore")
}
}
+6
View File
@@ -771,6 +771,12 @@ func (r *Router) actionStack(w http.ResponseWriter, req *http.Request, action, n
// above, so nothing is recorded for an action that was never going to happen.
if desired, ok := desiredStateForAction(action); ok {
if derr := r.stackMgr.SetDesiredState(name, desired); derr != nil {
if errors.Is(derr, stacks.ErrStackNotFound) {
// no app by that name on this box (e.g. the bootstrap-managed controller): a 404, not a save failure
r.logger.Printf("[WARN] [api] %s for %s refused: no such app", action, name)
writeJSON(w, stackOpStatusFor(derr), apiResponse{OK: false, Error: r.errText(req, derr)})
return
}
r.logger.Printf("[ERROR] [api] %s for %s refused: could not record desired state: %v", action, name, derr)
writeJSON(w, http.StatusInternalServerError, apiResponse{
OK: false,
@@ -0,0 +1,29 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// Found walking the capability map's B2 („App lifecycle") on 9202, 2026-10-28 night: a Stop of a name that is no app
// on this box (`felhom-controller` — bootstrap-managed, not a stack) answered 500 „…az alkalmazás beállításai nem
// menthetők." — the desired-state write's „not found" read as a save failure. It is a 404 now, before any intent is
// recorded. RED-PROOF: drop the ErrStackNotFound branch in actionStack → 500 → FAILS.
func TestAnActionOnANameThatIsNoAppIsA404(t *testing.T) {
r, _, _, _ := newSlice4Router(t)
for _, action := range []string{"stop", "start"} {
w := httptest.NewRecorder()
r.actionStack(w, httptest.NewRequest("POST", "/api/stacks/nosuchapp/"+action, nil), action, "nosuchapp")
var resp apiResponse
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if w.Code != http.StatusNotFound {
t.Fatalf("%s of a name that is no app: %d %q — want 404", action, w.Code, resp.Error)
}
if strings.Contains(strings.ToLower(resp.Error), "mentheto") || strings.Contains(resp.Error, "menthetők") {
t.Fatalf("%s: the answer still blames a save: %q", action, resp.Error)
}
}
}
@@ -0,0 +1,185 @@
package backup
import (
"errors"
"os"
"path/filepath"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// D62 (operator ruling 2026-10-29): an unused restore copy is kept 30 days; the household sees each copy (app, the
// restore's date, size, the day it goes) and may delete one earlier. A copy a hold or a pending put-back needs is never
// deleted, by the sweep or by hand.
// RED-PROOF: with the age at 14 days the 20-day copy is swept → FAILS; at 30 days only the 31-day copy goes.
func TestD62_AnUnusedCopyIsKept30Days(t *testing.T) {
m, _ := newOffboxManager(t)
now := time.Now()
day29 := r1256Copy(t, m, "a", 29*24*time.Hour, now)
day20 := r1256Copy(t, m, "b", 20*24*time.Hour, now)
day31 := r1256Copy(t, m, "c", 31*24*time.Hour, now)
got := m.SweepUnusedRestoreCopies(now)
if len(got) != 1 || got[0] != day31 {
t.Fatalf("swept %v — want only the 31-day copy %s", got, day31)
}
if !r1256Exists(day29) || !r1256Exists(day20) {
t.Fatal("a copy younger than 30 days was swept")
}
if restoreCopyUnusedAge != 30*24*time.Hour {
t.Fatalf("restoreCopyUnusedAge = %s, D62 says 30 days", restoreCopyUnusedAge)
}
}
// The list shows each copy with its restore date, its size and the day it goes; a needed copy has no such day and
// says why. RED-PROOF: drop the hold check from restoreCopyNeededFor → the held copy is offered → FAILS.
func TestD62_TheListShowsDateSizeAndWhyACopyIsNeeded(t *testing.T) {
m, sett := newOffboxManager(t)
now := time.Now().UTC().Truncate(time.Second)
free := r1256Copy(t, m, "free", 2*24*time.Hour, now)
r1256Copy(t, m, "held", 2*24*time.Hour, now)
r1256Copy(t, m, "waiting", 2*24*time.Hour, now)
r1256Copy(t, m, "named", 2*24*time.Hour, now)
if err := sett.SetRestoreHold(settings.RestoreHold{Stack: "held", At: now.Format(time.RFC3339), Reason: settings.HoldReasonRestoreMixed}); err != nil {
t.Fatal(err)
}
if err := sett.SetRestoreHold(settings.RestoreHold{Stack: "waiting", At: now.Format(time.RFC3339), Reason: settings.HoldReasonPutBackPending}); err != nil {
t.Fatal(err)
}
if err := m.appStop.Begin("unit-restore:named", ReasonUnitRestore, []string{"named"}); err != nil {
t.Fatal(err)
}
list := m.RestoreCopies()
if len(list) != 4 {
t.Fatalf("listed %d copies, want 4: %+v", len(list), list)
}
want := map[string]string{"free": "", "held": RestoreCopyNeededHold, "waiting": RestoreCopyNeededPutBack, "named": RestoreCopyNeededPutBack}
for _, c := range list {
if c.Needed != want[c.App] {
t.Errorf("%s: needed %q, want %q", c.App, c.Needed, want[c.App])
}
if !c.At.Equal(now.Add(-2 * 24 * time.Hour)) {
t.Errorf("%s: restore date %s, want the stamp's %s", c.App, c.At, now.Add(-2*24*time.Hour))
}
if c.SizeBytes != int64(len("LIVE")) {
t.Errorf("%s: size %d", c.App, c.SizeBytes)
}
if c.Needed == "" && !c.DeleteOn.Equal(c.At.Add(30*24*time.Hour)) {
t.Errorf("%s: deleted on %s, want 30 days after the restore", c.App, c.DeleteOn)
}
if c.Needed != "" && !c.DeleteOn.IsZero() {
t.Errorf("%s: a needed copy shows a delete day %s — the sweep never takes it", c.App, c.DeleteOn)
}
if c.App == "free" && c.Path != free {
t.Errorf("path %s, want %s", c.Path, free)
}
}
}
// A held or waiting copy cannot be deleted by hand. RED-PROOF: drop the Needed check in DeleteRestoreCopy → FAILS.
func TestD62_AHeldCopyCannotBeDeleted(t *testing.T) {
m, sett := newOffboxManager(t)
now := time.Now()
for _, tc := range []struct{ app, reason string }{
{"held", settings.HoldReasonRestoreMixed},
{"waiting", settings.HoldReasonPutBackPending},
{"failed", settings.HoldReasonRestoreFailed},
} {
dir := r1256Copy(t, m, tc.app, time.Hour, now)
if err := sett.SetRestoreHold(settings.RestoreHold{Stack: tc.app, At: now.Format(time.RFC3339), Reason: tc.reason}); err != nil {
t.Fatal(err)
}
if _, err := m.DeleteRestoreCopy(tc.app, filepath.Base(dir)); !errors.Is(err, ErrRestoreCopyNeeded) {
t.Errorf("%s: err %v, want ErrRestoreCopyNeeded", tc.app, err)
}
if !r1256Exists(dir) {
t.Errorf("%s: a held copy was deleted", tc.app)
}
}
// a copy a cut restore names (the marker) is needed too
named := r1256Copy(t, m, "named", time.Hour, now)
if err := m.appStop.Begin("unit-restore:named", ReasonUnitRestore, []string{"named"}); err != nil {
t.Fatal(err)
}
if _, err := m.DeleteRestoreCopy("named", filepath.Base(named)); !errors.Is(err, ErrRestoreCopyNeeded) || !r1256Exists(named) {
t.Errorf("a copy the marker names: err %v, exists %v", err, r1256Exists(named))
}
// an unreadable marker offers nothing
mustWrite(t, m.appStop.path, "{not json")
free := r1256Copy(t, m, "free", time.Hour, now)
if _, err := m.DeleteRestoreCopy("free", filepath.Base(free)); !errors.Is(err, ErrRestoreCopyNeeded) || !r1256Exists(free) {
t.Errorf("an unreadable marker: err %v, exists %v", err, r1256Exists(free))
}
}
// A delete removes ONLY that copy: the app's other copy, another app's copy and anything outside the copy tree stay;
// a name that is not one path element, or a link in the copy's place, is refused.
// RED-PROOF: remove the validCopyName check → "../.." reaches outside → FAILS; remove the Lstat link check → the
// link is followed/removed → FAILS.
func TestD62_ADeleteRemovesOnlyThatCopy(t *testing.T) {
m, _ := newOffboxManager(t)
now := time.Now()
one := r1256Copy(t, m, "app", 3*time.Hour, now)
two := r1256Copy(t, m, "app", 2*time.Hour, now)
other := r1256Copy(t, m, "other", 3*time.Hour, now)
got, err := m.DeleteRestoreCopy("app", filepath.Base(one))
if err != nil {
t.Fatal(err)
}
if got.Path != one || got.SizeBytes != int64(len("LIVE")) || got.App != "app" {
t.Fatalf("returned %+v", got)
}
if r1256Exists(one) || !r1256Exists(two) || !r1256Exists(other) {
t.Fatalf("one gone=%v, two kept=%v, other kept=%v", !r1256Exists(one), r1256Exists(two), r1256Exists(other))
}
// path guard
outside := filepath.Join(m.namespaceRoot(m.systemDataPath), "backups", "primary", "keep.txt")
mustWrite(t, outside, "UNIT")
for _, bad := range [][2]string{{"app", ".."}, {"..", "primary"}, {"app", "../../primary"}, {"", "x"}, {"app", ""}, {"app/x", "y"}} {
if _, err := m.DeleteRestoreCopy(bad[0], bad[1]); !errors.Is(err, ErrRestoreCopyNotFound) {
t.Errorf("DeleteRestoreCopy(%q,%q) = %v, want not found", bad[0], bad[1], err)
}
}
if !r1256Exists(outside) || !r1256Exists(two) {
t.Fatal("a bad name reached outside the copy")
}
// a link in a copy's place is not a copy
target := t.TempDir()
mustWrite(t, filepath.Join(target, "precious"), "X")
link := filepath.Join(filepath.Dir(two), "20260101T000000Z")
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
if _, err := m.DeleteRestoreCopy("app", "20260101T000000Z"); !errors.Is(err, ErrRestoreCopyNotFound) {
t.Errorf("a link: %v, want not found", err)
}
if !r1256Exists(filepath.Join(target, "precious")) {
t.Fatal("the link's target was removed")
}
if _, err := m.DeleteRestoreCopy("app", "20990101T000000Z"); !errors.Is(err, ErrRestoreCopyNotFound) {
t.Errorf("a missing copy: %v", err)
}
}
// While a backup or restore runs, a delete is refused and nothing moves. RED-PROOF: drop the busy check → FAILS.
func TestD62_NoDeleteWhileAnOperationRuns(t *testing.T) {
m, _ := newOffboxManager(t)
c := r1256Copy(t, m, "app", time.Hour, time.Now())
m.BeginRestoreOp("restore", "other")
if _, err := m.DeleteRestoreCopy("app", filepath.Base(c)); !errors.Is(err, ErrRestoreCopyBusy) || !r1256Exists(c) {
t.Fatalf("during a restore: err %v, exists %v", err, r1256Exists(c))
}
m.EndRestoreOp(true, "ok")
if err := m.acquireRunning(); err != nil {
t.Fatalf("could not take the backup single-flight: %v", err)
}
if _, err := m.DeleteRestoreCopy("app", filepath.Base(c)); !errors.Is(err, ErrRestoreCopyBusy) || !r1256Exists(c) {
t.Fatalf("during a backup run: err %v, exists %v", err, r1256Exists(c))
}
m.ReleaseRunningForTest()
if _, err := m.DeleteRestoreCopy("app", filepath.Base(c)); err != nil || r1256Exists(c) {
t.Fatalf("control: with nothing running the delete goes through: %v", err)
}
}
+26 -5
View File
@@ -301,8 +301,12 @@ func (m *Manager) downloadKeptOffsiteUnit(ctx context.Context, app, drive string
// LoadKeptOffsite is LoadKeptApp from the off-site copy c: download the unit alone, judge it, then
// prepare (nil = nothing; a dated folder's files move back here), then the one unit restore, then
// after(ok). A download failure or a refusal never reaches prepare, so the kept files stay as they were.
func (m *Manager) LoadKeptOffsite(app, drive string, c KeptCopy, prepare func() error, okMsg, failMsg func(err error) string, after func(ok bool)) {
m.BeginRestoreOp("restore", app)
// ErrRestoreBusy (nothing started, after not called) when another operation runs (R-1256 H6).
func (m *Manager) LoadKeptOffsite(app, drive string, c KeptCopy, prepare func() error, okMsg, failMsg func(err error) string, after func(ok bool)) error {
if busy, _ := m.TryBeginRestoreOp("restore", app); busy != "" {
m.logger.Printf("[WARN] [backup] kept load %s REFUSED: a %s operation runs (R-1256)", app, busy)
return ErrRestoreBusy
}
go func() {
start := time.Now()
fail := func(err error) {
@@ -345,6 +349,7 @@ func (m *Manager) LoadKeptOffsite(app, drive string, c KeptCopy, prepare func()
after(true)
}
}()
return nil
}
// RemovedUnitOnDrive is the start-fresh hook (stacks.SetKeptUnitFinder): the removed app's OWN unit when
@@ -370,11 +375,26 @@ func (m *Manager) PrimaryUnitHome(app, drive string) string {
// LoadKeptApp runs the load as a restore operation the backup pages already follow (the poll banner):
// Begin → RestoreFromRecoveryUnitAt(app, unitDir) → End, then after(ok) in the same goroutine (the
// after_load command, the kept folder's tidy-up). The caller has checked RestoreStatus/IsRunning.
func (m *Manager) LoadKeptApp(app, unitDir string, okMsg, failMsg func(err error) string, after func(ok bool)) {
m.BeginRestoreOp("restore", app)
// after_load command, the kept folder's tidy-up). prepare (nil = nothing) runs first, inside the operation — a dated
// folder's files move back there. R-1256 H6: the op is begun in the same step as the check for every other operation;
// a refusal returns ErrRestoreBusy with nothing started and nothing moved.
func (m *Manager) LoadKeptApp(app, unitDir string, prepare func() error, okMsg, failMsg func(err error) string, after func(ok bool)) error {
if busy, _ := m.TryBeginRestoreOp("restore", app); busy != "" {
m.logger.Printf("[WARN] [backup] kept load %s REFUSED: a %s operation runs (R-1256)", app, busy)
return ErrRestoreBusy
}
go func() {
start := time.Now()
if prepare != nil {
if perr := prepare(); perr != nil {
m.logger.Printf("[ERROR] [backup] kept load %s: preparing the kept files FAILED: %v — nothing was loaded", app, perr)
m.EndRestoreOp(false, failMsg(perr))
if after != nil {
after(false)
}
return
}
}
res, err := m.keptUnitRestore()(app, unitDir)
if errors.Is(err, ErrRestoreNotStarted) {
m.keptLoadNotStarted(app, err, after)
@@ -395,6 +415,7 @@ func (m *Manager) LoadKeptApp(app, unitDir string, okMsg, failMsg func(err error
after(true)
}
}()
return nil
}
// keptLoadNotStarted (R-1270): the kept data is loaded but the app did not start in time. Never „done": the record says
@@ -548,6 +548,25 @@ func (m *Manager) holdAppAfterMixedRestore(stack string, replayErr error, live *
}
}
// restartAtLiveDefinition (R-1256 H4) is the failure branch of the definition write, taken while no data has moved.
// The writer writes the compose before the app's settings, so a failure can leave the snapshot's compose in the
// stack dir: the LIVE definition is written back first, and only then is the app started. When the write-back fails
// too, the app is held stopped (restore_mixed) — a start would run the snapshot's definition over the live data.
// Pinned by TestR1256_H4_*.
func (m *Manager) restartAtLiveDefinition(stack string, live *liveDefinition, cause error, restartStack func() error) {
if live != nil {
if err := m.stackProvider.RecreateStackDefinitionFromUnit(stack, live.dir, live.env); err != nil {
m.logger.Printf("[ERROR] [offbox] %s: the definition write failed (%v) and writing the live definition back failed too: %v — holding the app stopped", stack, cause, err)
m.holdAppAfterMixedRestore(stack, cause, nil)
return
}
m.logger.Printf("[INFO] [offbox] %s: the definition write failed (%v); the live definition is written back before the start", stack, cause)
}
if sErr := restartStack(); sErr != nil {
m.logger.Printf("[WARN] [offbox] %s: restart after a failed definition write also failed: %v", stack, sErr)
}
}
// SetRestoreHoldNotify wires the operator notification for a held app (cmd/controller/main.go).
func (m *Manager) SetRestoreHoldNotify(fn func(stack string, replayErr, rollbackErr error)) {
m.restoreHoldNotify = fn
@@ -1005,16 +1024,12 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
// yet — a failure here restarts the app as it was.
if defineFromSnapshot {
if err := m.stackProvider.RecreateStackDefinitionFromUnit(stack, scratchCompose, snapEnv); err != nil {
if sErr := restartStack(); sErr != nil {
m.logger.Printf("[WARN] [offbox] %s: restart after a failed definition write also failed: %v", stack, sErr)
}
m.restartAtLiveDefinition(stack, liveDef, err, restartStack)
return res, fmt.Errorf("restoring %s: writing the snapshot's definition failed: %w", stack, err)
}
} else if len(credOverlay) > 0 {
if err := m.stackProvider.(secretValueSetter).SetStackSecretValues(stack, credOverlay); err != nil {
if sErr := restartStack(); sErr != nil {
m.logger.Printf("[WARN] [offbox] %s: restart after a failed password write also failed: %v", stack, sErr)
}
m.restartAtLiveDefinition(stack, liveDef, err, restartStack)
return res, fmt.Errorf("restoring %s: writing the backup's database password failed: %w", stack, err)
}
m.logger.Printf("[INFO] [offbox] %s: the backup's database password(s) %v written into the app's settings before the data (R-1106)", stack, credNames(credOverlay))
@@ -226,8 +226,13 @@ func (m *Manager) putBackAfterFailedRestore(stack string, cause error, live *liv
m.holdAppAfterMixedRestore(stack, cause, live)
return util.MsgError(holdKey, stack)
}
// R-1256 H5: a put-back under an app that could not be stopped could mix the copy with what the app writes.
// Nothing is poured; the copy is kept and the app held (D20: a put-back that fails holds and keeps its copy).
if err := m.stackProvider.StopStack(stack); err != nil {
m.logger.Printf("[WARN] [offbox] %s: stopping the app before the put-back failed: %v", stack, err)
c.keepIfAny()
m.logger.Printf("[ERROR] [offbox] %s: stopping the app before the put-back FAILED: %v — nothing is put back; the copy stays at %s; holding the app", stack, err, c.dirOrNone())
m.holdAppAfterMixedRestore(stack, cause, live)
return util.MsgError(holdKey, stack)
}
if volumesMoved {
if err := m.putBack(stack, c); err != nil {
@@ -239,7 +244,8 @@ func (m *Manager) putBackAfterFailedRestore(stack string, cause error, live *liv
}
if live != nil {
if err := m.stackProvider.RecreateStackDefinitionFromUnit(stack, live.dir, live.env); err != nil {
m.logger.Printf("[ERROR] [offbox] %s: writing the live definition back FAILED: %v — holding the app", stack, err)
c.keepIfAny() // R-1256 H5 (D20): a put-back that fails keeps its copy for support
m.logger.Printf("[ERROR] [offbox] %s: writing the live definition back FAILED: %v — the copy stays at %s; holding the app", stack, err, c.dirOrNone())
m.holdAppAfterMixedRestore(stack, cause, live)
return util.MsgError(holdKey, stack)
}
@@ -249,12 +255,19 @@ func (m *Manager) putBackAfterFailedRestore(stack string, cause error, live *liv
m.restorePutBackNotify(stack, cause)
}
if err := restart(); err != nil {
m.logger.Printf("[ERROR] [offbox] %s: start after the put-back failed: %v (the copy stays at %s)", stack, err, c.dirOrNone())
m.logger.Printf("[ERROR] [offbox] %s: start after the put-back failed: %v (the live volumes and definition are back; the copy %s is removed)", stack, err, c.dirOrNone())
return util.MsgError("err.backup.restore_failed_put_back_not_started", stack)
}
return putBackMessage(stack, filesPlaced)
}
// keepIfAny marks the copy as kept for support (no copy: nothing to keep).
func (c *liveVolumeCopy) keepIfAny() {
if c != nil {
c.keep = true
}
}
func (c *liveVolumeCopy) dirOrNone() string {
if c == nil {
return "(no volume copy)"
+47
View File
@@ -62,6 +62,53 @@ type RestoreOpStatus struct {
func (m *Manager) BeginRestoreOp(op, stack string) {
m.mu.Lock()
defer m.mu.Unlock()
m.beginRestoreOpLocked(op, stack)
}
// Why a new restore may not begin now (R-1256 H6). "" = it may.
const (
RestoreBusyRestore = "restore" // a restore op is in flight (the record the pages follow)
RestoreBusyBackup = "backup" // the single-flight backup/restore run is held (the night run never records an op)
RestoreBusyMirror = "mirror" // a second-drive copy runs, or one a previous process lost may still run
)
// TryBeginRestoreOp is BeginRestoreOp that refuses — in the SAME step, under m.mu — while another restore op, a
// backup run or a second-drive copy runs. R-1256 H6: the pages asked (RestoreStatus, IsRunning,
// Tier2CopyBlocksRestore) and recorded the op later; a mirror that began in between ran beside the restore. The mirror
// asks under the same m.mu (beginTier2Run), so one of the two always sees the other. Lock order m.mu → tier2Copies,
// as in beginTier2Run. Returns the busy reason ("" = begun) and, for a mirror, its app ("" when unknown).
// Pinned by TestR1256_H6_*.
func (m *Manager) TryBeginRestoreOp(op, stack string) (busy, mirrorApp string) {
m.mu.Lock()
defer m.mu.Unlock()
if busy, app := m.restoreBusyLocked(); busy != "" {
return busy, app
}
m.beginRestoreOpLocked(op, stack)
return "", ""
}
// RestoreBusy is TryBeginRestoreOp's question alone — for a refusal asked early, before a request is read further.
func (m *Manager) RestoreBusy() (busy, mirrorApp string) {
m.mu.Lock()
defer m.mu.Unlock()
return m.restoreBusyLocked()
}
func (m *Manager) restoreBusyLocked() (string, string) {
if m.opRunning {
return RestoreBusyRestore, m.opStack
}
if m.running {
return RestoreBusyBackup, ""
}
if app, blocked := m.Tier2CopyBlocksRestore(); blocked {
return RestoreBusyMirror, app
}
return "", ""
}
func (m *Manager) beginRestoreOpLocked(op, stack string) {
m.opRunning = true
m.opName = op
m.opStack = stack
@@ -0,0 +1,358 @@
package backup
import (
"context"
"errors"
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// R-1256 (review round 2, HUNT-1): four restore edges — H4, H5, H6 and H8, each pinned below.
//
// H4: an off-site restore writes the snapshot's definition before any data moves. The writer
// (stackAdapter.RecreateStackDefinitionFromUnit) writes the compose FIRST and the app's settings after, so a
// failure can come back with the snapshot's compose already in the stack dir. The failure branch used to start
// the app at once — on the snapshot's (older) compose, over the live data. Now the live definition is written
// back first; only then is the app started. When the write-back fails too, the app is held, never started.
// r1256DefFailProvider fails the FIRST definition write (the snapshot's), after recording it — as the real
// writer does when its compose write succeeded and its settings write did not.
type r1256DefFailProvider struct {
*r893Provider
failWrites int // how many writes fail, from the first
failFrom int // when set, every write from this one (1-based) on fails
}
func (p *r1256DefFailProvider) RecreateStackDefinitionFromUnit(name, composeDir string, env map[string]string) error {
if err := p.r893Provider.RecreateStackDefinitionFromUnit(name, composeDir, env); err != nil {
return err
}
if len(p.defs) <= p.failWrites || (p.failFrom > 0 && len(p.defs) >= p.failFrom) {
return errors.New("app.yaml write failed")
}
return nil
}
func r1256DefFailFixture(t *testing.T, failWrites int) (*Manager, *r1256DefFailProvider, *int) {
t.Helper()
m, vp, _, notified, _ := r893FixtureA(t, true, 0)
p := &r1256DefFailProvider{r893Provider: vp, failWrites: failWrites}
m.SetStackProvider(p)
return m, p, notified
}
// RED-PROOF: remove the live write-back from the definition-write failure branch (restartStack alone) → one
// definition write (the snapshot's), then the start → FAILS.
func TestR1256_H4_AFailedDefinitionWriteStartsTheAppAtItsLiveDefinition(t *testing.T) {
m, p, notified := r1256DefFailFixture(t, 1)
_, err := m.ReconstituteFromOffsite(context.Background(), "immich", false)
if err == nil {
t.Fatal("a failed definition write must be surfaced")
}
if len(p.defs) != 2 || !strings.Contains(strings.Join(p.defs[1], " "), "postgres:18-alpine") {
t.Fatalf("definition writes %v — want the snapshot's, then the LIVE one written back", p.defs)
}
if !p.fullStarted {
t.Fatalf("the app must run again at its live definition — calls %v", p.calls)
}
// The write-back comes BEFORE the start.
lastRecreate, lastStart := -1, -1
for i, c := range p.calls {
switch c {
case "recreate":
lastRecreate = i
case "start":
lastStart = i
}
}
if lastRecreate > lastStart {
t.Fatalf("the app was started before the live definition was written back — calls %v", p.calls)
}
if held, _ := m.RestoreHoldFor("immich"); held {
t.Fatal("the live definition is back: the app must not be held")
}
if *notified != 0 {
t.Errorf("hold notices %d, want 0", *notified)
}
}
// The write-back fails too: the stack dir may still hold the snapshot's compose, so the app is NOT started; it is
// held (restore_mixed: the definition moved and could not be put back) and the operator is told once.
// RED-PROOF: start the app when the write-back fails → fullStarted → FAILS.
func TestR1256_H4_AFailedWriteBackHoldsTheApp(t *testing.T) {
m, p, notified := r1256DefFailFixture(t, 2)
_, err := m.ReconstituteFromOffsite(context.Background(), "immich", false)
if err == nil {
t.Fatal("a failed definition write must be surfaced")
}
if p.fullStarted {
t.Fatalf("the app was STARTED although its live definition could not be written back — calls %v", p.calls)
}
held, _ := m.RestoreHoldFor("immich")
if !held {
t.Fatal("no hold: every start path would start the app on the snapshot's definition")
}
if k := m.HoldKind("immich"); k != settings.HoldReasonRestoreMixed {
t.Errorf("hold kind %q, want %q", k, settings.HoldReasonRestoreMixed)
}
if *notified != 1 {
t.Errorf("the operator must be told once, got %d", *notified)
}
}
// ---- H5: a put-back whose stop fails must not go on; `volume rm -f` errors count; a failed put-back keeps its copy.
// r1256StopFailProvider fails the Nth StopStack (1-based).
type r1256StopFailProvider struct {
*r893Provider
failAt, n int
}
func (p *r1256StopFailProvider) StopStack(name string) error {
p.n++
err := p.r893Provider.StopStack(name)
if p.n == p.failAt {
return errors.New("compose down: timeout")
}
return err
}
// The off-site put-back: the replay fails after the snapshot's volume replaced the live one; the stop before the
// put-back fails. Pouring the copy back under a running app could mix it with what the app writes, so nothing is
// poured, the copy is KEPT, and the app is held.
// RED-PROOF: restore the „(continuing)" WARN in putBackAfterFailedRestore → the copy is poured and the app started
// → FAILS.
func TestR1256_H5_APutBackWhoseStopFailsDoesNotPourAndKeepsTheCopy(t *testing.T) {
m, vp, rig, _, notified, putBack := r893PutBackFixture(t)
p := &r1256StopFailProvider{r893Provider: vp, failAt: 2} // 1 = the restore's stop, 2 = the put-back's
m.SetStackProvider(p)
_, err := m.ReconstituteFromOffsite(context.Background(), "immich", false)
if err == nil {
t.Fatal("a failed restore must be surfaced")
}
if p.n < 2 {
t.Fatalf("the put-back's stop never ran (stops %d) — the fixture did not reach the put-back", p.n)
}
if len(rig.poured) != 0 || len(rig.removed) != 0 {
t.Fatalf("the copy was poured (%v) / volumes removed (%v) after a FAILED stop", rig.poured, rig.removed)
}
if vp.fullStarted {
t.Fatalf("the app was started after a failed put-back — calls %v", vp.calls)
}
if held, _ := m.RestoreHoldFor("immich"); !held {
t.Fatal("a put-back that could not run must hold the app (D20)")
}
if left := undoCopies(t, rig.undoRoot); len(left) == 0 {
t.Fatal("the copy must be KEPT after a failed put-back (D20)")
}
if *notified != 1 || *putBack != 0 {
t.Errorf("hold notices %d (want 1), put-back notices %d (want 0)", *notified, *putBack)
}
}
// The live definition cannot be written back after the volumes were poured: the app is held and the copy is KEPT
// (D20: a put-back that fails keeps its copy for support).
// RED-PROOF: drop `c.keep = true` from the definition write-back failure branch → the copy is removed → FAILS.
func TestR1256_H5_AFailedDefinitionWriteBackKeepsTheCopy(t *testing.T) {
m, vp, rig, _, _, _ := r893PutBackFixture(t)
// A version change, so a live definition exists to be written back; the second write (the write-back) fails.
if err := os.WriteFile(vp.composePath, []byte(vtCompose("18")), 0o644); err != nil {
t.Fatal(err)
}
vp.livePins = ParseComposeImages(vp.composePath)
p := &r1256DefFailProvider{r893Provider: vp, failWrites: 0}
p.failFrom = 2
m.SetStackProvider(p)
_, err := m.ReconstituteFromOffsite(context.Background(), "immich", false)
if err == nil {
t.Fatal("a failed restore must be surfaced")
}
if len(p.defs) < 2 {
t.Fatalf("the write-back never ran: writes %v", p.defs)
}
if held, _ := m.RestoreHoldFor("immich"); !held {
t.Fatal("a failed write-back must hold the app")
}
if left := undoCopies(t, rig.undoRoot); len(left) == 0 {
t.Fatal("the copy must be KEPT after a failed put-back (D20)")
}
}
// The crash put-back (a restore cut by a power loss): the stop before pouring fails → an error (the guard then
// holds the app and keeps the copy, R-1251 H2), nothing poured.
// RED-PROOF: restore the „(continuing)" WARN in PutBackInterruptedRestore → nil error, the copy poured → FAILS.
func TestR1256_H5_ACrashPutBackWhoseStopFailsRefuses(t *testing.T) {
dir := t.TempDir()
undo := filepath.Join(dir, "backups", "restore-undo", "app", "20261028T000000Z")
mustWrite(t, filepath.Join(undo, "undo.json"), `{"stack":"app","copied":["app_data"],"definition":false}`)
mustWrite(t, filepath.Join(undo, "app_data.tar"), "LIVE")
poured := 0
cp := NewCrashPutBack(func(string) error { return errors.New("compose down: timeout") }, nil, log.New(io.Discard, "", 0))
cp.volReplay = func(string, string) (int, error) { poured++; return 1, nil }
if err := cp.PutBackInterruptedRestore("app", undo); err == nil {
t.Fatal("a put-back whose stop failed must return an error")
}
if poured != 0 {
t.Fatal("the copy was poured back under an app that could not be stopped")
}
if _, err := os.Stat(filepath.Join(undo, "app_data.tar")); err != nil {
t.Fatal("the copy must stay on disk")
}
}
// restoreDockerVolumesFrom: a `docker volume rm -f` that fails (the volume is still in use) used to be ignored; the
// `volume create` that follows is a no-op on an existing volume, so the tar was unpacked ON TOP of the old content —
// a merge. Now that volume counts as failed and is not touched.
// RED-PROOF: ignore the rm error again → the create and the tar run for the volume → FAILS.
func TestR1256_H5_AVolumeThatCannotBeRemovedIsNotMergedInto(t *testing.T) {
bin := t.TempDir()
calls := filepath.Join(bin, "calls.log")
stub := "#!/bin/sh\necho \"$@\" >> " + calls + "\n" +
"case \"$*\" in 'compose version --short') echo v2.29.7;; 'volume rm -f app_busy') echo 'volume is in use' >&2; exit 1;; esac\nexit 0\n"
if err := os.WriteFile(filepath.Join(bin, "docker"), []byte(stub), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
dump := t.TempDir()
for _, n := range []string{"app_busy.tar", "app_free.tar"} {
mustWrite(t, filepath.Join(dump, n), "tar")
}
m := &Manager{logger: log.New(io.Discard, "", 0)}
n, err := m.restoreDockerVolumesFrom("app", dump)
if err == nil || n != 1 {
t.Fatalf("restored %d, err %v — want 1 and an error naming app_busy", n, err)
}
if !strings.Contains(err.Error(), "app_busy") {
t.Errorf("the error must name the volume: %v", err)
}
b, _ := os.ReadFile(calls)
for _, l := range strings.Split(string(b), "\n") {
if (strings.HasPrefix(l, "volume create") || strings.HasPrefix(l, "run ")) && strings.Contains(l, "app_busy") {
t.Fatalf("the volume that could not be removed was written into: %q", l)
}
}
}
// ---- H6: a second-drive mirror must not start between the page's refusal and the restore's own record.
// The page asked (nothing ran), then a mirror began, then the restore began: the begin itself refuses, records
// nothing, and the mirror — asked under the same lock — refuses once a restore is recorded.
// RED-PROOF: make TryBeginRestoreOp record the op without asking (the old BeginRestoreOp) → the restore begins beside
// the mirror → FAILS.
func TestR1256_H6_ARestoreCannotBeginBesideAMirrorThatStartedAfterTheCheck(t *testing.T) {
tier2ResetForTest()
defer tier2ResetForTest()
m, _ := newOffboxManager(t)
if busy, _ := m.RestoreBusy(); busy != "" {
t.Fatalf("fixture: nothing runs, got %q", busy)
}
// the window: the page's check passed; now a mirror begins
if !m.beginTier2Run("immich") {
t.Fatal("fixture: the mirror must begin while no restore is recorded")
}
if busy, app := m.TryBeginRestoreOp("restore", "immich"); busy != RestoreBusyMirror || app != "immich" {
t.Fatalf("the restore began beside a running mirror: busy=%q app=%q", busy, app)
}
if m.RestoreStatus().Running {
t.Fatal("a refused begin left a restore op recorded")
}
tier2CopyEnd("immich")
if busy, _ := m.TryBeginRestoreOp("restore", "immich"); busy != "" {
t.Fatalf("with the mirror done the restore must begin, got %q", busy)
}
if m.beginTier2Run("immich") {
t.Fatal("a mirror began while the restore of the same app is recorded")
}
// a second restore is refused while the first runs; the backup run refuses too
if busy, _ := m.TryBeginRestoreOp("restore", "other"); busy != RestoreBusyRestore {
t.Fatalf("a second restore began beside the first: %q", busy)
}
m.EndRestoreOp(true, "ok")
if err := m.AcquireRunningForTest(); err != nil {
t.Fatal(err)
}
if busy, _ := m.TryBeginRestoreOp("restore", "immich"); busy != RestoreBusyBackup {
t.Fatalf("a restore began beside a backup run: %q", busy)
}
m.ReleaseRunningForTest()
}
// ---- H8: an unused restore copy is cleaned up; a copy a hold or a pending put-back needs is never touched.
func r1256Copy(t *testing.T, m *Manager, app string, age time.Duration, now time.Time) string {
t.Helper()
dir := filepath.Join(restoreUndoDir(m.namespaceRoot(m.systemDataPath), app), now.Add(-age).UTC().Format(restoreCopyStampLayout))
mustWrite(t, filepath.Join(dir, app+"_data.tar"), "LIVE")
return dir
}
func r1256Exists(p string) bool { _, err := os.Stat(p); return err == nil }
// RED-PROOF: drop the hold check (or the marker check) from SweepUnusedRestoreCopies → the held / named copy is removed
// → FAILS; drop the age check → the young copy is removed → FAILS.
func TestR1256_H8_OnlyAnUnusedOldCopyIsRemoved(t *testing.T) {
m, sett := newOffboxManager(t)
now := time.Now()
old := 40 * 24 * time.Hour
unused := r1256Copy(t, m, "unused", old, now)
young := r1256Copy(t, m, "young", 24*time.Hour, now)
held := r1256Copy(t, m, "held", old, now)
waiting := r1256Copy(t, m, "waiting", old, now)
named := r1256Copy(t, m, "named", old, now)
if err := sett.SetRestoreHold(settings.RestoreHold{Stack: "held", At: now.Format(time.RFC3339), Reason: settings.HoldReasonRestoreMixed}); err != nil {
t.Fatal(err)
}
if err := sett.SetRestoreHold(settings.RestoreHold{Stack: "waiting", At: now.Format(time.RFC3339), Reason: settings.HoldReasonPutBackPending}); err != nil {
t.Fatal(err)
}
// a restore cut by a power loss: the marker (no longer active after a failed recovery's End) still names the copy
if err := m.appStop.Begin("unit-restore:named", ReasonUnitRestore, []string{"named"}); err != nil {
t.Fatal(err)
}
if err := m.appStop.SetUndo("named", named); err != nil {
t.Fatal(err)
}
got := m.SweepUnusedRestoreCopies(now)
if len(got) != 1 || got[0] != unused || r1256Exists(unused) {
t.Fatalf("removed %v — want exactly the unused old copy %s", got, unused)
}
for _, keep := range []string{young, held, waiting, named} {
if !r1256Exists(keep) {
t.Errorf("a needed copy was removed: %s", keep)
}
}
if r1256Exists(filepath.Dir(unused)) {
t.Error("the emptied per-app folder must go too")
}
}
// While a restore runs nothing is removed; a marker that cannot be read stops the sweep.
// RED-PROOF: drop the RestoreBusy check → the old copy goes during a restore → FAILS.
func TestR1256_H8_NothingIsRemovedDuringARestoreOrWithAnUnreadableMarker(t *testing.T) {
m, _ := newOffboxManager(t)
now := time.Now()
c := r1256Copy(t, m, "app", 40*24*time.Hour, now)
m.BeginRestoreOp("restore", "other")
if got := m.SweepUnusedRestoreCopies(now); len(got) != 0 || !r1256Exists(c) {
t.Fatalf("removed %v during a restore", got)
}
m.EndRestoreOp(true, "ok")
mustWrite(t, m.appStop.path, "{not json")
if got := m.SweepUnusedRestoreCopies(now); len(got) != 0 || !r1256Exists(c) {
t.Fatalf("removed %v with an unreadable marker", got)
}
if err := os.Remove(m.appStop.path); err != nil {
t.Fatal(err)
}
if got := m.SweepUnusedRestoreCopies(now); len(got) != 1 {
t.Fatalf("control: with nothing in the way the old copy goes, removed %v", got)
}
}
+8 -2
View File
@@ -224,8 +224,14 @@ func (m *Manager) restoreDockerVolumesFrom(stackName, dumpDir string) (int, erro
m.logger.Printf("[INFO] [backup] Restoring Docker volume %s for %s", volName, stackName)
// Remove existing volume (ignore errors — may not exist)
dockerexec.Command("docker", "volume", "rm", "-f", volName).Run()
// Remove the existing volume. `rm -f` of a missing volume succeeds, so an error means the volume EXISTS and
// could not be removed (still in use). R-1256 H5: it used to be ignored — the `volume create` below is a no-op
// on an existing volume, and the tar was unpacked ON TOP of the old content (a merge). That volume fails now.
if out, err := dockerexec.Command("docker", "volume", "rm", "-f", volName).CombinedOutput(); err != nil {
m.logger.Printf("[ERROR] [backup] Failed to remove volume %s before the restore: %s — %v (not written into)", volName, strings.TrimSpace(string(out)), err)
failed = append(failed, volName)
continue
}
// Create fresh volume — WITH the labels compose gives its own volumes (R-658, v0.268.0). A bare
// `volume create` made a volume the update's undo (until v0.267.0) could not find, and one
@@ -0,0 +1,182 @@
package backup
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// D62 (operator ruling 2026-10-29; `07` §6.3): the household sees its restore copies in the kept-copies list
// („Megőrzött másolatok") on the restore page — the app, the date of the restore the copy protects, its size, and the
// day the box deletes it by itself (restoreCopyUnusedAge after the restore) — and may delete one earlier with
// „Törlés most", after a warning and the app's name typed.
//
// A copy a hold or a pending put-back needs is NEVER deletable (the same three states the sweep leaves alone, R-1256
// H8): the row says why in one line and the button is off; the handler refuses it too. While any backup or restore
// operation runs on the box, a delete is refused (409). The check and the removal are ONE step under m.mu, so a
// restore cannot begin in between (the R-1256 H6 shape). Pinned by TestD62_*.
// Why a restore copy is needed — the row's one line, and the refusal's reason. "" = not needed: deletable.
const (
RestoreCopyNeededHold = "hold" // the app is held (a failed put-back keeps its copy, D20) — support decides
RestoreCopyNeededPutBack = "putback" // a put-back waits for its drive (R-1251) or a cut restore names it (D22)
RestoreCopyNeededUnknown = "unknown" // the app-stop marker cannot be read: nothing is offered (fail closed)
)
var (
ErrRestoreCopyNotFound = errors.New("restore copy not found")
ErrRestoreCopyNeeded = errors.New("restore copy is needed by a hold or a pending put-back")
ErrRestoreCopyBusy = errors.New("a backup or restore operation runs")
)
// RestoreCopy is one restore copy (<namespace>/backups/restore-undo/<app>/<stamp>).
type RestoreCopy struct {
App string
Stamp string // the folder's name; with App, the copy's identity on the page
At time.Time // when the restore began (the stamp; the folder's time when the stamp does not parse)
SizeBytes int64
Path string
Needed string // "" or one of RestoreCopyNeeded*
DeleteOn time.Time // when the sweep removes it; zero when Needed != "" (a needed copy is never swept)
}
// restoreCopyAt is a copy's restore time: its stamp, or the folder's time when the stamp does not parse.
func restoreCopyAt(dir, name string) (time.Time, bool) {
if at, err := time.Parse(restoreCopyStampLayout, name); err == nil {
return at, true
}
fi, err := os.Stat(dir)
if err != nil {
return time.Time{}, false
}
return fi.ModTime(), true
}
// restoreCopyNeededFor is why app's copies are needed ("" = deletable). named is appStopNamedApps' answer, ok its ok.
func (m *Manager) restoreCopyNeededFor(app string, named map[string]bool, ok bool) string {
if !ok || m.settings == nil {
return RestoreCopyNeededUnknown
}
if named[app] {
return RestoreCopyNeededPutBack
}
if h, held := m.settings.GetRestoreHold(app); held {
if h.Reason == settings.HoldReasonPutBackPending {
return RestoreCopyNeededPutBack
}
return RestoreCopyNeededHold
}
return ""
}
func dirSize(dir string) int64 {
var n int64
_ = filepath.WalkDir(dir, func(_ string, d fs.DirEntry, err error) error {
if err == nil && d.Type().IsRegular() {
if fi, e := d.Info(); e == nil {
n += fi.Size()
}
}
return nil
})
return n
}
// RestoreCopies lists every restore copy on the box's drives, oldest first.
func (m *Manager) RestoreCopies() []RestoreCopy {
named, ok := m.appStopNamedApps()
var out []RestoreCopy
for _, nsRoot := range m.primaryUnitRoots() {
base := filepath.Join(nsRoot, "backups", "restore-undo")
apps, err := os.ReadDir(base)
if err != nil {
continue
}
for _, a := range apps {
if !a.IsDir() {
continue
}
app := a.Name()
needed := m.restoreCopyNeededFor(app, named, ok)
copies, err := os.ReadDir(filepath.Join(base, app))
if err != nil {
continue
}
for _, c := range copies {
if !c.IsDir() {
continue
}
dir := filepath.Join(base, app, c.Name())
at, ok := restoreCopyAt(dir, c.Name())
if !ok {
continue
}
rc := RestoreCopy{App: app, Stamp: c.Name(), At: at, SizeBytes: dirSize(dir), Path: dir, Needed: needed}
if needed == "" {
rc.DeleteOn = at.Add(restoreCopyUnusedAge)
}
out = append(out, rc)
}
}
}
sort.SliceStable(out, func(i, j int) bool { return out[i].At.Before(out[j].At) })
return out
}
// validCopyName is a single path element: no separator, not empty, not "." or "..".
func validCopyName(s string) bool {
return s != "" && s != "." && s != ".." && !strings.ContainsAny(s, `/\`) && filepath.Base(s) == s
}
// DeleteRestoreCopy removes ONE restore copy, named by its app and its folder's name, and nothing else (the path
// guard: both names are single path elements, the folder is a real directory — not a link — directly under
// <namespace>/backups/restore-undo/<app>/). Refused while any backup or restore operation runs (ErrRestoreCopyBusy)
// and for a copy a hold or a pending put-back needs (ErrRestoreCopyNeeded). The check and the removal hold m.mu, so
// no restore can begin in between. Returns the copy as it was (for the log line and the operator event).
func (m *Manager) DeleteRestoreCopy(app, stamp string) (RestoreCopy, error) {
if !validCopyName(app) || !validCopyName(stamp) {
return RestoreCopy{}, ErrRestoreCopyNotFound
}
m.mu.Lock()
defer m.mu.Unlock()
if busy, _ := m.restoreBusyLocked(); busy != "" {
return RestoreCopy{}, ErrRestoreCopyBusy
}
var found *RestoreCopy
for _, nsRoot := range m.primaryUnitRoots() {
appDir := filepath.Join(nsRoot, "backups", "restore-undo", app)
dir := filepath.Join(appDir, stamp)
fi, err := os.Lstat(dir)
if err != nil || !fi.IsDir() || fi.Mode()&os.ModeSymlink != 0 {
continue
}
if filepath.Dir(dir) != appDir || filepath.Dir(appDir) != filepath.Join(nsRoot, "backups", "restore-undo") {
continue
}
at, ok := restoreCopyAt(dir, stamp)
if !ok {
continue
}
found = &RestoreCopy{App: app, Stamp: stamp, At: at, SizeBytes: dirSize(dir), Path: dir}
break
}
if found == nil {
return RestoreCopy{}, ErrRestoreCopyNotFound
}
named, ok := m.appStopNamedApps()
if found.Needed = m.restoreCopyNeededFor(app, named, ok); found.Needed != "" {
return *found, ErrRestoreCopyNeeded
}
if err := os.RemoveAll(found.Path); err != nil {
return *found, fmt.Errorf("removing the restore copy %s: %w", found.Path, err)
}
_ = os.Remove(filepath.Dir(found.Path)) // the per-app folder, when it is now empty
return *found, nil
}
@@ -0,0 +1,130 @@
package backup
import (
"encoding/json"
"os"
"path/filepath"
"sort"
"time"
)
// R-1256 H8 (review round 2, HUNT-1): a restore copy (<namespace>/backups/restore-undo/<app>/<stamp>) that nothing
// will ever use again used to stay on the drive for good. A copy is KEPT on purpose in three states — a put-back that
// failed (the app is held, D20), a put-back waiting for its drive (the putback_pending hold, R-1251) and a restore cut
// by a power loss (the app-stop marker names it, D22) — and for 30 minutes after a restore while the box watches for a
// stable start (R-1270). Every one of those is still needed. A copy outside all of them — the app runs again, or was
// removed, and nothing names the copy — is disk the household cannot see or use.
//
// The rule (the age: operator ruling D62, 2026-10-29 — 30 days, and the household may delete a copy earlier with
// „Törlés most", restore_copies.go): a copy is removed only
// when ALL of these hold, and the sweep removes NOTHING when it cannot answer one of them:
// - it is older than restoreCopyUnusedAge (its folder's stamp; the folder's time when the stamp does not parse);
// - the app has no restore hold of any kind (held apps keep every copy — support decides);
// - the app-stop marker does not name the app (a pending put-back), and the marker is readable;
// - no restore op is in flight on the box (a new restore's copy and the stable-start watcher are left alone).
//
// Run every 6 hours from main.go (TestR1256_H8_TheSweepIsWiredAtStartup). Pinned by TestR1256_H8_*.
// restoreCopyUnusedAge is how long an unused copy stays after its restore (D62: 30 days; TestD62_AnUnusedCopyIsKept30Days).
const restoreCopyUnusedAge = 30 * 24 * time.Hour
// restoreCopyStampLayout is copyLiveVolumes' folder name.
const restoreCopyStampLayout = "20060102T150405Z"
// SweepUnusedRestoreCopies removes the restore copies no hold, no pending put-back and no running restore needs, and
// older than restoreCopyUnusedAge. Returns the folders removed.
func (m *Manager) SweepUnusedRestoreCopies(now time.Time) []string {
if busy, _ := m.RestoreBusy(); busy == RestoreBusyRestore {
m.logger.Printf("[INFO] [backup] restore-copy sweep skipped: a restore runs")
return nil
}
named, ok := m.appStopNamedApps()
if !ok {
m.logger.Printf("[WARN] [backup] restore-copy sweep skipped: the app-stop marker cannot be read — a copy it names must not go")
return nil
}
var removed []string
for _, nsRoot := range m.primaryUnitRoots() {
base := filepath.Join(nsRoot, "backups", "restore-undo")
apps, err := os.ReadDir(base)
if err != nil {
continue
}
for _, a := range apps {
if !a.IsDir() {
continue
}
app := a.Name()
if named[app] {
continue
}
if m.settings == nil {
return removed // no hold store: a held app cannot be told apart
}
if _, held := m.settings.GetRestoreHold(app); held {
continue
}
appDir := filepath.Join(base, app)
copies, err := os.ReadDir(appDir)
if err != nil {
continue
}
sort.Slice(copies, func(i, j int) bool { return copies[i].Name() < copies[j].Name() })
for _, c := range copies {
if !c.IsDir() {
continue
}
dir := filepath.Join(appDir, c.Name())
at, err := time.Parse(restoreCopyStampLayout, c.Name())
if err != nil {
fi, sErr := os.Stat(dir)
if sErr != nil {
continue
}
at = fi.ModTime()
}
if now.Sub(at) < restoreCopyUnusedAge {
continue
}
if err := os.RemoveAll(dir); err != nil {
m.logger.Printf("[WARN] [backup] %s: the unused restore copy %s could not be removed: %v", app, dir, err)
continue
}
m.logger.Printf("[INFO] [backup] %s: the unused restore copy %s (from %s) is removed — no hold, no pending put-back, older than %s (R-1256)",
app, dir, at.UTC().Format(time.RFC3339), restoreCopyUnusedAge)
removed = append(removed, dir)
}
_ = os.Remove(appDir) // the per-app folder, when it is now empty
}
}
return removed
}
// appStopNamedApps reads the app-stop marker without side effects: the apps of an ACTIVE marker and every app its Undo
// names. ok=false when the marker exists and cannot be read — the caller then touches nothing.
func (m *Manager) appStopNamedApps() (map[string]bool, bool) {
named := map[string]bool{}
if m.appStop == nil || m.appStop.path == "" {
return named, true
}
data, err := os.ReadFile(m.appStop.path)
if os.IsNotExist(err) {
return named, true
}
if err != nil {
return nil, false
}
var mk AppStopMarker
if err := json.Unmarshal(data, &mk); err != nil {
return nil, false
}
for app := range mk.Undo {
named[app] = true
}
if mk.Active {
for _, app := range mk.Stacks {
named[app] = true
}
}
return named, true
}
@@ -195,9 +195,11 @@ func (p *CrashPutBack) PutBackInterruptedRestore(stack, undoDir string) error {
if j.Stack != stack {
return fmt.Errorf("the restore copy at %s belongs to %q, not %q", undoDir, j.Stack, stack)
}
// R-1256 H5: never pour the copy back under an app that could not be stopped — a refusal here is a failed
// put-back: the guard holds the app and keeps the copy (R-1251 H2).
if p.stop != nil {
if err := p.stop(stack); err != nil {
p.logger.Printf("[WARN] [appstop] %s: stopping before the put-back failed: %v (continuing)", stack, err)
return fmt.Errorf("stopping %s before the put-back: %w", stack, err)
}
}
mgr := &Manager{logger: p.logger, volumeReplayFrom: p.volReplay, undoVolumeRemoveFn: p.volRemove}
+14
View File
@@ -2634,6 +2634,19 @@
"kept.delete.type": "To delete it, type: %s",
"kept.delete.mismatch": "The name you typed does not match (%s), so nothing was deleted.",
"kept.deleted": "The kept data of %s is deleted.",
"restore_copy.title": "Copies from before a restore",
"restore_copy.hint": "When you restore an app, the box first saves the app’s data as it was. It deletes that copy by itself 30 days after the restore.",
"restore_copy.restored_on": "restore on %s",
"restore_copy.auto_delete": "automatically deleted on %s",
"restore_copy.needed.hold": "Cannot be deleted: the app is stopped, and this copy is its way back. Support decides about it.",
"restore_copy.needed.putback": "Cannot be deleted: an interrupted restore puts the data back from this copy.",
"restore_copy.needed.unknown": "Cannot be deleted now: the box cannot tell whether this copy is still needed.",
"restore_copy.delete": "Delete now",
"restore_copy.warning": "This is the copy of %s’s data from before the restore on %s. If you delete it, the restore can no longer be undone, and this copy cannot be brought back.",
"restore_copy.app_busy": "Cannot be deleted now: an operation runs on the app. Try again when it has ended.",
"restore_copy.not_found": "This copy is no longer there, so nothing was deleted.",
"restore_copy.failed": "The copy could not be deleted: %s",
"flash.restore_copy.deleted": "The copy of %s from before its restore is deleted.",
"kept.page.title": "Kept data",
"kept.page.back": "Back",
"kept.page.intro": "Data that removed apps left on the drives. You can look at it, load it into a new install, or delete it. The server never deletes any of it by itself.",
@@ -2672,6 +2685,7 @@
"api.escrow.confirm_expired": "The time to type the new code back has run out — your previous code still works. Start the process again.",
"api.escrow.confirm_lost": "The new code no longer waits for confirmation — your previous code still works. Start the process again.",
"api.escrow.confirm_store_failed": "The new code could not be saved — your previous code still works. Try again.",
"api.escrow.confirm_store_unknown": "We cannot tell yet whether the new code was saved — the server did not answer in time. Keep both codes (the old one and the new one) and try again.",
"api.escrow.confirm_words_mismatch": "The words do not match — check the code you wrote down.",
"api.share.form_invalid": "Invalid form — reload the page.",
"api.share.too_many_attempts": "Too many failed attempts, try again in 1 minute",
+14
View File
@@ -2622,6 +2622,19 @@
"kept.delete.type": "A törléshez írd be: %s",
"kept.delete.mismatch": "A beírt név nem egyezik (%s), ezért nem töröltünk semmit.",
"kept.deleted": "A(z) %s megőrzött adatai törölve.",
"restore_copy.title": "Visszaállítás előtti másolatok",
"restore_copy.hint": "Visszaállításkor a doboz előbb elmenti az alkalmazás akkori adatait. Ezt a másolatot a visszaállítás után 30 nappal magától törli.",
"restore_copy.restored_on": "visszaállítás: %s",
"restore_copy.auto_delete": "automatikusan törlődik: %s",
"restore_copy.needed.hold": "Nem törölhető: az alkalmazás le van állítva, és ez a másolat az út vissza. Az ügyfélszolgálat dönt róla.",
"restore_copy.needed.putback": "Nem törölhető: egy félbeszakadt visszaállítás ebből a másolatból teszi vissza az adatokat.",
"restore_copy.needed.unknown": "Most nem törölhető: a doboz nem tudja eldönteni, kell-e még ez a másolat.",
"restore_copy.delete": "Törlés most",
"restore_copy.warning": "Ez a(z) %s adatainak másolata a %s-i visszaállítás előttről. Ha törlöd, a visszaállítás többé nem vonható vissza, és ez a másolat nem hozható vissza.",
"restore_copy.app_busy": "Most nem törölhető: az alkalmazáson éppen egy művelet fut. Próbáld újra, ha véget ért.",
"restore_copy.not_found": "Ez a másolat már nincs meg, ezért nem töröltünk semmit.",
"restore_copy.failed": "A másolatot nem sikerült törölni: %s",
"flash.restore_copy.deleted": "A(z) %s visszaállítás előtti másolata törölve.",
"kept.page.title": "Megőrzött adatok",
"kept.page.back": "Vissza",
"kept.page.intro": "Az eltávolított alkalmazások lemezen maradt adatai. Megnézheted őket, betöltheted egy új telepítésbe, vagy törölheted. Magától a szerver soha nem töröl közülük semmit.",
@@ -2660,6 +2673,7 @@
"api.escrow.confirm_expired": "Lejárt az idő az új kód visszaírására — a régi kódod továbbra is érvényes. Indítsd újra a folyamatot.",
"api.escrow.confirm_lost": "Az új kód már nem vár megerősítésre — a régi kódod továbbra is érvényes. Indítsd újra a folyamatot.",
"api.escrow.confirm_store_failed": "Az új kódot nem sikerült elmenteni — a régi kódod továbbra is érvényes. Próbáld újra.",
"api.escrow.confirm_store_unknown": "Nem tudjuk biztosan, hogy az új kód mentése sikerült-e — a központ nem válaszolt időben. Őrizd meg mindkét kódot (a régit és az újat is), és próbáld újra.",
"api.escrow.confirm_words_mismatch": "A megadott szavak nem egyeznek — ellenőrizd a felírt kódot.",
"api.share.form_invalid": "Érvénytelen űrlap — töltsd újra az oldalt.",
"api.share.too_many_attempts": "Túl sok sikertelen próbálkozás, próbáld újra 1 perc múlva",
@@ -0,0 +1,34 @@
package notify
import (
"encoding/json"
"strings"
"testing"
)
// D62 — restore_copy_deleted: info (the hub's vocabulary), the operator message only (no household sentence — the hub
// bars the type from customers too), and the details carry who, app, the restore's date and the size.
func TestD62_RestoreCopyDeletedEvent(t *testing.T) {
n := &Notifier{}
var et, sev, msg, cust string
var det []byte
n.pushFn = func(eventType, severity, message, customer string, details interface{}) {
et, sev, msg, cust = eventType, severity, message, customer
det, _ = json.Marshal(details)
}
n.NotifyRestoreCopyDeleted(RestoreCopyDeletedDetails{App: "vikunja", RestoreAt: "2026-10-29T08:00:00Z", SizeBytes: 4096,
Who: "the household (dashboard sign-in) from 192.168.0.5", Path: "/x/backups/restore-undo/vikunja/20261029T080000Z"})
if et != "restore_copy_deleted" || sev != "info" || cust != "" {
t.Fatalf("type=%q severity=%q customer=%q", et, sev, cust)
}
for _, want := range []string{"vikunja", "2026-10-29T08:00:00Z", "4096", "the household"} {
if !strings.Contains(msg, want) {
t.Fatalf("message %q lacks %q", msg, want)
}
}
for _, want := range []string{`"app":"vikunja"`, `"restore_at":"2026-10-29T08:00:00Z"`, `"size_bytes":4096`, `"who":"the household`} {
if !strings.Contains(string(det), want) {
t.Fatalf("details %s lack %s", det, want)
}
}
}
+28 -1
View File
@@ -163,6 +163,8 @@ type UpdateDetails struct {
type AppDetails struct {
StackName string `json:"stack_name,omitempty"`
DisplayName string `json:"display_name,omitempty"`
// Source (app_oom only, R-528): what saw the kill — "docker" (State.OOMKilled) or "cgroup" (the host's kernel counter).
Source string `json:"source,omitempty"`
}
// CrossDriveDetails holds structured data for cross-drive backup events.
@@ -804,6 +806,12 @@ func (n *Notifier) NotifyAppStartFailures(apps []AppRunState) {
// that stays OOM-marked does not repeat. "warning" — the hub vocabulary (R-329). Operator-only
// hub-side (hub >= v0.114.0 registers it): the household sees the dashboard tag.
func (n *Notifier) NotifyAppOOM(stack, container, startedAt string, kills int64, memLimit, peak string) {
n.NotifyAppOOMFrom(stack, container, startedAt, kills, memLimit, peak, "")
}
// NotifyAppOOMFrom is NotifyAppOOM with the source that saw the kill (R-528: "docker" or "cgroup"), carried in the
// event's details. One event per container run, whichever source saw it.
func (n *Notifier) NotifyAppOOMFrom(stack, container, startedAt string, kills int64, memLimit, peak, source string) {
key := container + "|" + startedAt
n.mu.Lock()
if n.oomSeen == nil {
@@ -816,7 +824,7 @@ func (n *Notifier) NotifyAppOOM(stack, container, startedAt string, kills int64,
if first {
n.emit("app_oom", "warning",
fmt.Sprintf("Alkalmazás memóriája elfogyott: %s (%s) — egy folyamatát a memóriakorlát leállította", stack, container),
AppDetails{StackName: stack, DisplayName: container})
AppDetails{StackName: stack, DisplayName: container, Source: source})
}
if storm {
n.logger.Printf("[ERROR] [notify] %s: container %s OOM STORM — %d kills in %d min (limit %s, peak %s)",
@@ -1577,6 +1585,25 @@ func (n *Notifier) NotifyAppDefinitionRefused(app, detail string) {
n.pushEventBoth("app_definition_refused", "warning", msg, "", AppDefinitionRefusedDetails{App: app, Refused: detail})
}
// RestoreCopyDeletedDetails is restore_copy_deleted's record: who deleted which app's copy, the restore it protected,
// and its size.
type RestoreCopyDeletedDetails struct {
App string `json:"app"`
RestoreAt string `json:"restore_at"` // RFC 3339, UTC
SizeBytes int64 `json:"size_bytes"`
Who string `json:"who"`
Path string `json:"path"`
}
// NotifyRestoreCopyDeleted (D62, operator 2026-10-29): the household deleted a restore copy with „Törlés most" before
// its 30 days. Info, operator-only on the hub (registered in both of its lists, hub D62): an audit line for support,
// never a household mail. Pinned by TestD62_RestoreCopyDeletedEvent.
func (n *Notifier) NotifyRestoreCopyDeleted(d RestoreCopyDeletedDetails) {
msg := fmt.Sprintf("%s: the restore copy from before the restore of %s (%d bytes) was deleted by hand — %s. That restore can no longer be undone from this box.",
d.App, d.RestoreAt, d.SizeBytes, d.Who)
n.pushEventBoth("restore_copy_deleted", "info", msg, "", d)
}
// healthSeverity is the event severity a health status would be sent at (R-647): the disabled path
// names what it drops, and "warn" is a health STATUS, not a severity the hub knows.
func healthSeverity(status string) string {
+4 -1
View File
@@ -3,6 +3,8 @@ package stacks
import (
"fmt"
"path/filepath"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
)
// Desired-state values for AppConfig.DesiredState (R-166, decision D-b).
@@ -57,7 +59,8 @@ func (m *Manager) SetDesiredState(name, desired string) error {
stack, ok := m.GetStack(name)
if !ok {
return fmt.Errorf("stack %q not found", name)
// a KIND (404 at the API) and the household's sentence — not a save failure
return util.MsgErrorf(ErrStackNotFound, "err.stacks.alkalmazas_nem_talalhato", name)
}
stackDir := filepath.Dir(stack.ComposePath)
+2
View File
@@ -284,6 +284,8 @@ type Manager struct {
// R-514: last OOM scan (oom.go), for the dashboard.
oomMu sync.Mutex
oomCache map[string][]string
// hostOOMKills (R-528) is the host agent's per-container OOM counter; nil = Docker's flag only (SetHostOOMKills).
hostOOMKills func() map[string]int64
// R-513: FileBrowser admin-password seams (filebrowser_password.go); nil/"" in production.
fbHTTP fbHTTPDo
fbBaseURL string
+47 -4
View File
@@ -27,6 +27,21 @@ type OOMContainer struct {
Kills int64
// MemLimit / Peak are the cgroup's memory.max and memory.peak, as "<n>M" (or "max"); "" unread.
MemLimit, Peak string
// Source names what flagged it: OOMSourceDocker (State.OOMKilled), OOMSourceCgroup (R-528 — only the host's
// per-container kernel counter saw it; Docker's flag stayed false), or "docker,cgroup" (both).
Source string
}
const (
OOMSourceDocker = "docker"
OOMSourceCgroup = "cgroup"
)
// SetHostOOMKills wires the host-side OOM counter (R-528): container id → the kernel's oom_kill for the container's
// CURRENT run (its cgroup scope is new at each start), read by the host's agent. nil, or a nil answer (no agent, an
// agent without the route, a failed call) = Docker's flag only, as before. INIT-ONLY (main.go).
func (m *Manager) SetHostOOMKills(fn func() map[string]int64) {
m.hostOOMKills = fn
}
// ScanOOMKilled inspects the containers of every deployed, running-ish stack in ONE docker call and
@@ -52,20 +67,45 @@ func (m *Manager) ScanOOMKilled() ([]OOMContainer, error) {
return nil, nil
}
sort.Strings(names)
args := append([]string{"inspect", "-f", "{{.Name}}|{{.State.OOMKilled}}|{{.State.StartedAt}}"}, names...)
args := append([]string{"inspect", "-f", "{{.Name}}|{{.State.OOMKilled}}|{{.State.StartedAt}}|{{.Id}}"}, names...)
out, err := m.execCommand("docker", args...)
if err != nil && strings.TrimSpace(out) == "" {
return nil, err
}
// R-528: the host's counter, asked once per scan (an HTTPS call to the agent); nil = Docker's flag only.
var host map[string]int64
if m.hostOOMKills != nil {
host = m.hostOOMKills()
}
var found []OOMContainer
hostKills := map[int]int64{}
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
f := strings.SplitN(strings.TrimSpace(line), "|", 3)
if len(f) != 3 || f[1] != "true" {
f := strings.SplitN(strings.TrimSpace(line), "|", 4)
if len(f) < 3 {
continue
}
id := ""
if len(f) == 4 {
id = strings.TrimSpace(f[3])
}
hk, counted := host[id]
source := ""
switch {
case f[1] == "true" && id != "" && counted && hk > 0:
source = OOMSourceDocker + "," + OOMSourceCgroup // both saw it
case f[1] == "true":
source = OOMSourceDocker
case id != "" && counted && hk > 0:
source = OOMSourceCgroup
default:
continue
}
cname := strings.TrimPrefix(f[0], "/")
if st, ok := owner[cname]; ok {
found = append(found, OOMContainer{Stack: st, Container: cname, StartedAt: f[2], Kills: -1})
if counted && id != "" {
hostKills[len(found)] = hk
}
found = append(found, OOMContainer{Stack: st, Container: cname, StartedAt: f[2], Kills: -1, Source: source})
}
}
// R-636: only for the containers already flagged — one `docker exec` each, and the flagged set is
@@ -75,6 +115,9 @@ func (m *Manager) ScanOOMKilled() ([]OOMContainer, error) {
out, _ := m.execCommand("docker", "exec", found[i].Container, "cat",
"/sys/fs/cgroup/memory.events", "/sys/fs/cgroup/memory.max", "/sys/fs/cgroup/memory.peak")
found[i].Kills, found[i].MemLimit, found[i].Peak = parseCgroupMemory(out)
if hk, ok := hostKills[i]; ok && hk > found[i].Kills {
found[i].Kills = hk // the host's count is the kernel's own; the in-container read can be -1 (no cat)
}
}
m.setOOMCache(found)
return found, nil
@@ -0,0 +1,84 @@
package stacks
import (
"io"
"log"
"strings"
"testing"
)
// R-528 — inside a Felhom LXC guest Docker can leave State.OOMKilled false after a memory-limit kill (measured
// 2026-09-15/16). The host's agent reads the kernel's own per-container counter (GET /guest/oom-kills); a counter above
// 0 for the RUNNING container's id is an OOM kill in this run, whatever Docker says. The scan flags it with
// Source "cgroup" and the host's count, and the existing app_oom line (once per run) fires for it.
const r528ID = "4f5e6d7c8b9a0f1e2d3c4b5a69788796a5b4c3d2e1f00112233445566778899a"
// RED-PROOF: ignore the host counter in ScanOOMKilled (only `OOMKilled == true` flags) → the killed container is not
// seen → FAILS.
func TestR528_AHostCounterAboveZeroIsAnOOMKillWhateverDockerSays(t *testing.T) {
var execd []string
m := &Manager{logger: log.New(io.Discard, "", 0), stacks: map[string]*Stack{
"hog": {Name: "hog", Deployed: true, Containers: []ContainerInfo{{Name: "hog", State: StateRunning}}},
"bookstack": {Name: "bookstack", Deployed: true, Containers: []ContainerInfo{{Name: "bookstack", State: StateRunning}}},
}}
m.execFn = func(name string, args ...string) (string, error) {
if args[0] == "exec" {
execd = append(execd, args[1])
return "oom_kill 1\n67108864\n67100000\n", nil
}
if !strings.Contains(strings.Join(args, " "), "{{.Id}}") {
t.Errorf("the inspect must ask for the container id: %v", args)
}
return "/bookstack|false|t0|" + strings.Repeat("0", 64) + "\n/hog|false|2026-10-28T23:00:00Z|" + r528ID + "\n", nil
}
m.SetHostOOMKills(func() map[string]int64 {
return map[string]int64{r528ID: 2, strings.Repeat("0", 64): 0}
})
ooms, err := m.ScanOOMKilled()
if err != nil {
t.Fatal(err)
}
if len(ooms) != 1 || ooms[0].Stack != "hog" || ooms[0].Container != "hog" {
t.Fatalf("the host-counted kill was not seen: %+v", ooms)
}
if ooms[0].Source != OOMSourceCgroup || ooms[0].Kills != 2 || ooms[0].StartedAt != "2026-10-28T23:00:00Z" {
t.Fatalf("want source cgroup, the host's count 2 and the run's start: %+v", ooms[0])
}
if ooms[0].MemLimit != "64M" {
t.Errorf("limit/peak are still read for the flagged container: %+v", ooms[0])
}
if len(execd) != 1 || execd[0] != "hog" {
t.Errorf("exec must reach the flagged container only, got %v", execd)
}
}
// Docker's flag alone still flags (Source docker); no host source (an old agent, 9202 without an agent) = today's
// behaviour; a host counter of 0 never flags.
func TestR528_WithoutAHostCounterTheScanIsAsBefore(t *testing.T) {
m := &Manager{logger: log.New(io.Discard, "", 0), stacks: map[string]*Stack{
"a": {Name: "a", Deployed: true, Containers: []ContainerInfo{{Name: "a", State: StateRunning}}},
"b": {Name: "b", Deployed: true, Containers: []ContainerInfo{{Name: "b", State: StateRunning}}},
}}
m.execFn = func(name string, args ...string) (string, error) {
if args[0] == "exec" {
return "oom_kill 3\n", nil
}
return "/a|true|s1|" + strings.Repeat("a", 64) + "\n/b|false|s2|" + strings.Repeat("b", 64) + "\n", nil
}
ooms, _ := m.ScanOOMKilled()
if len(ooms) != 1 || ooms[0].Stack != "a" || ooms[0].Source != OOMSourceDocker || ooms[0].Kills != 3 {
t.Fatalf("no host source: %+v", ooms)
}
m.SetHostOOMKills(func() map[string]int64 { return map[string]int64{strings.Repeat("b", 64): 0} })
ooms, _ = m.ScanOOMKilled()
if len(ooms) != 1 || ooms[0].Stack != "a" {
t.Fatalf("a host counter of 0 flagged a container: %+v", ooms)
}
// Both saw it: the source names both, the host's count wins over a lower in-container read.
m.SetHostOOMKills(func() map[string]int64 { return map[string]int64{strings.Repeat("a", 64): 7} })
ooms, _ = m.ScanOOMKilled()
if len(ooms) != 1 || ooms[0].Source != OOMSourceDocker+","+OOMSourceCgroup || ooms[0].Kills != 7 {
t.Fatalf("both sources: %+v", ooms)
}
}
@@ -0,0 +1,233 @@
package web
import (
"encoding/json"
"html/template"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// D62 (operator ruling 2026-10-29): restore copies in the kept-copies list with „Törlés most". Real settings, manager,
// disk and a hub stand-in that records the event — the handler is the endpoint the page's form calls.
type d62Fixture struct {
s *Server
sett *settings.Settings
nsRoot string
events chan map[string]interface{}
}
func newD62Fixture(t *testing.T) *d62Fixture {
t.Helper()
tmp := t.TempDir()
lg := log.New(io.Discard, "", 0)
sett, err := settings.Load(filepath.Join(tmp, "settings.json"), lg)
if err != nil {
t.Fatal(err)
}
cfg := &config.Config{}
cfg.Paths.DataDir = tmp
cfg.Paths.SystemDataPath = filepath.Join(tmp, "sys")
events := make(chan map[string]interface{}, 8)
hub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/event" {
var ev map[string]interface{}
_ = json.NewDecoder(r.Body).Decode(&ev)
events <- ev
}
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(hub.Close)
n := notify.New(hub.URL, "test-key", "c1", sett, lg, false)
s := &Server{cfg: cfg, settings: sett, backupMgr: backup.NewManager(cfg, sett, lg), notifier: n, logger: lg}
return &d62Fixture{s: s, sett: sett, nsRoot: backup.NamespaceRootFor(cfg.Paths.SystemDataPath, cfg.Paths.SystemDataPath), events: events}
}
// plant writes one restore copy of app taken at `at`; returns its folder.
func (f *d62Fixture) plant(t *testing.T, app string, at time.Time) string {
t.Helper()
dir := filepath.Join(backup.RestoreUndoDir(f.nsRoot, app), at.UTC().Format("20060102T150405Z"))
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, app+"_data.tar"), []byte("LIVE-DATA"), 0o600); err != nil {
t.Fatal(err)
}
return dir
}
func (f *d62Fixture) post(form string) (int, map[string]interface{}) {
rr := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/backup/restore-copy/delete", strings.NewReader(form))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.RemoteAddr = "192.168.0.5:5555"
f.s.restoreCopyDeleteHandler(rr, req)
var body map[string]interface{}
_ = json.Unmarshal(rr.Body.Bytes(), &body)
return rr.Code, body
}
func exists(p string) bool { _, err := os.Stat(p); return err == nil }
// The list shows the restore's date, the size and „automatikusan törlődik: <30 days later>"; a held copy shows why and
// offers no warning. RED-PROOF: drop the auto-delete part from restoreCopyRows → FAILS.
func TestD62_TheListShowsTheDateAndTheAutoDeleteDay(t *testing.T) {
f := newD62Fixture(t)
at := time.Date(2026, 10, 29, 8, 0, 0, 0, time.UTC)
f.plant(t, "vikunja", at)
f.plant(t, "immich", at)
if err := f.sett.SetRestoreHold(settings.RestoreHold{Stack: "immich", At: at.Format(time.RFC3339), Reason: settings.HoldReasonRestoreMixed}); err != nil {
t.Fatal(err)
}
rows := f.s.restoreCopyRows("hu")
if len(rows) != 2 {
t.Fatalf("rows %+v", rows)
}
var free, held restoreCopyRow
for _, r := range rows {
if r.App == "vikunja" {
free = r
} else {
held = r
}
}
for _, want := range []string{"visszaállítás: 2026. 10. 29. 09:00", "9 B", "automatikusan törlődik: 2026. 11. 28."} {
if !strings.Contains(free.Line, want) {
t.Errorf("free row line %q lacks %q", free.Line, want)
}
}
if !strings.Contains(free.Warning, "Ez a(z) vikunja adatainak másolata a 2026. 10. 29-i visszaállítás előttről") ||
!strings.Contains(free.Warning, "a visszaállítás többé nem vonható vissza, és ez a másolat nem hozható vissza") {
t.Errorf("warning %q", free.Warning)
}
if free.NeededText != "" || free.TypePrompt == "" {
t.Errorf("free row: needed %q prompt %q", free.NeededText, free.TypePrompt)
}
if held.NeededText == "" || held.Warning != "" || strings.Contains(held.Line, "automatikusan") {
t.Errorf("held row offers a delete or an auto-delete day: %+v", held)
}
en := f.s.restoreCopyRows("en")
for _, r := range en {
if r.App == "vikunja" && (!strings.Contains(r.Line, "automatically deleted on 2026-11-28") ||
!strings.Contains(r.Warning, "This is the copy of vikunja’s data from before the restore on 2026-10-29. If you delete it, the restore can no longer be undone, and this copy cannot be brought back.")) {
t.Errorf("English row %+v", r)
}
}
}
// The page: a deletable copy has the form (app, stamp, the typed name, CSRF); a held one has the button OFF and the
// reason, and no form. RED-PROOF: render the form for a needed copy too → FAILS.
func TestD62_ThePageOffersTheFormOnlyForADeletableCopy(t *testing.T) {
d := i18nLayoutData("backups-restore", "Visszaállítás a mentésből")
d["Backup"] = i18nBackupStatus(false, false)
d["RestoreCopies"] = []restoreCopyRow{
{App: "vikunja", Stamp: "20261029T080000Z", Name: "Vikunja", Line: "L1", Warning: "W1", TypePrompt: "P1"},
{App: "immich", Stamp: "20261020T080000Z", Name: "Immich", Line: "L2", NeededText: "HELD-REASON"},
}
d["CSRFField"] = template.HTML(`<input type="hidden" name="_csrf" value="TOKEN-D62">`)
html := renderBackupPage(t, "backups_restore", d)
for _, want := range []string{"Visszaállítás előtti másolatok", `action="/backup/restore-copy/delete"`, `name="app" value="vikunja"`,
`name="stamp" value="20261029T080000Z"`, `name="confirm"`, "W1", "P1", "Törlés most", `data-restore-copy-off="true"`, "HELD-REASON"} {
if !strings.Contains(html, want) {
t.Errorf("page lacks %q", want)
}
}
if strings.Contains(html, `name="app" value="immich"`) || strings.Contains(html, `data-restore-copy="immich/`) {
t.Error("a held copy got a delete form")
}
form := html[strings.Index(html, `action="/backup/restore-copy/delete"`):]
if !strings.Contains(form[:strings.Index(form, "</form>")], `value="TOKEN-D62"`) {
t.Error("the delete form carries no CSRF field")
}
none := i18nLayoutData("backups-restore", "Visszaállítás a mentésből")
none["Backup"] = i18nBackupStatus(false, false)
if html := renderBackupPage(t, "backups_restore", none); strings.Contains(html, "Megőrzött másolatok") || strings.Contains(html, "restore-copy") {
t.Error("a page with no copy shows the section")
}
}
// A wrong typed name is refused (400) and nothing is deleted. RED-PROOF: drop the name check → FAILS.
func TestD62_AWrongNameIsRefused(t *testing.T) {
f := newD62Fixture(t)
c := f.plant(t, "vikunja", time.Now().Add(-time.Hour))
for _, typed := range []string{"", "vikunj", "immich", "VIKUNJA "} {
code, body := f.post("app=vikunja&stamp=" + filepath.Base(c) + "&confirm=" + typed)
if code != http.StatusBadRequest || body["ok"] != false || !exists(c) {
t.Fatalf("typed %q: code %d body %v exists %v", typed, code, body, exists(c))
}
}
}
// A held copy is refused by the handler too (409, the reason), and stays. RED-PROOF: drop the Needed check in the
// handler AND in DeleteRestoreCopy → FAILS (either alone holds it).
func TestD62_AHeldCopyIsRefusedByTheHandler(t *testing.T) {
f := newD62Fixture(t)
c := f.plant(t, "immich", time.Now().Add(-time.Hour))
if err := f.sett.SetRestoreHold(settings.RestoreHold{Stack: "immich", At: time.Now().Format(time.RFC3339), Reason: settings.HoldReasonPutBackPending}); err != nil {
t.Fatal(err)
}
code, body := f.post("app=immich&stamp=" + filepath.Base(c) + "&confirm=immich")
if code != http.StatusConflict || !exists(c) || !strings.Contains(body["error"].(string), "félbeszakadt visszaállítás") {
t.Fatalf("code %d body %v exists %v", code, body, exists(c))
}
}
// While a restore runs, a delete is 409 with a sentence, and nothing moves. RED-PROOF: drop the busy check → FAILS.
func TestD62_NoDeleteWhileAnOperationRuns(t *testing.T) {
f := newD62Fixture(t)
c := f.plant(t, "vikunja", time.Now().Add(-time.Hour))
f.s.backupMgr.BeginRestoreOp("restore", "other")
code, body := f.post("app=vikunja&stamp=" + filepath.Base(c) + "&confirm=vikunja")
if code != http.StatusConflict || !exists(c) || body["error"] == "" {
t.Fatalf("code %d body %v exists %v", code, body, exists(c))
}
f.s.backupMgr.EndRestoreOp(true, "ok")
}
// The right name: the copy is gone, ONLY it (the app's other copy and another app's copy stay), the answer leads back
// to the page with the flash, and the operator event carries who, app, the restore's date and size.
// RED-PROOF: drop the NotifyRestoreCopyDeleted call → no event → FAILS.
func TestD62_ADeleteRemovesOnlyThatCopyAndStoresTheEvent(t *testing.T) {
f := newD62Fixture(t)
at := time.Date(2026, 10, 29, 8, 0, 0, 0, time.UTC)
c := f.plant(t, "vikunja", at)
sibling := f.plant(t, "vikunja", at.Add(time.Hour))
other := f.plant(t, "immich", at)
code, body := f.post("app=vikunja&stamp=" + filepath.Base(c) + "&confirm=vikunja")
if code != http.StatusOK || body["ok"] != true || !strings.Contains(body["redirect"].(string), "flash.restore_copy.deleted") {
t.Fatalf("code %d body %v", code, body)
}
if exists(c) || !exists(sibling) || !exists(other) {
t.Fatalf("gone=%v sibling=%v other=%v", !exists(c), exists(sibling), exists(other))
}
select {
case ev := <-f.events:
if ev["event_type"] != "restore_copy_deleted" || ev["severity"] != "info" {
t.Fatalf("event %v", ev)
}
det, _ := json.Marshal(ev["details"])
for _, want := range []string{`"app":"vikunja"`, `"restore_at":"2026-10-29T08:00:00Z"`, `"size_bytes":9`, `"who":"the household (dashboard sign-in) from 192.168.0.5"`} {
if !strings.Contains(string(det), want) {
t.Errorf("details %s lack %s", det, want)
}
}
case <-time.After(5 * time.Second):
t.Fatal("no restore_copy_deleted event reached the hub")
}
// a second press: the copy is gone → 404, no second event
if code, _ := f.post("app=vikunja&stamp=" + filepath.Base(c) + "&confirm=vikunja"); code != http.StatusNotFound {
t.Fatalf("second press code %d", code)
}
}
@@ -352,6 +352,12 @@ func (s *Server) escrowConfirmAPIHandler(w http.ResponseWriter, r *http.Request)
escrowJSON(w, http.StatusGone, map[string]any{"restart": true}, s.msg(r, "api.escrow.confirm_expired"))
case http.StatusNotFound:
escrowJSON(w, http.StatusGone, map[string]any{"restart": true}, s.msg(r, "api.escrow.confirm_lost"))
case http.StatusGatewayTimeout, 0:
// R-1254 H2: the agent could not learn which package the hub holds (504), or this call itself failed (0 — it may
// have timed out while the agent still stored the package). Either way the new package MAY be on the hub: no
// promise about the previous code. Only 502 (the hub holds another package) keeps the store-failed sentence.
s.logger.Printf("[WARN] [web] escrow confirm outcome unknown (status %d)", status)
escrowJSON(w, http.StatusGatewayTimeout, nil, s.msg(r, "api.escrow.confirm_store_unknown"))
default:
s.logger.Printf("[WARN] [web] escrow confirm failed (status %d)", status)
escrowJSON(w, http.StatusBadGateway, nil, s.msg(r, "api.escrow.confirm_store_failed"))
+17 -4
View File
@@ -1346,6 +1346,7 @@ func (s *Server) backupsRestoreHandler(w http.ResponseWriter, r *http.Request) {
// D25: second-drive copies whose source is gone — kept until the household removes one.
rows := s.keptCopyRows(s.langFor(r))
data["KeptCopies"] = rows
data["RestoreCopies"] = s.restoreCopyRows(s.langFor(r)) // D62: restore copies in the same list
for _, k := range rows {
if k.Restorable {
data["KeptUnitRestorable"] = true // D47: the confirm script only when a row offers „Visszaállítás"
@@ -1894,7 +1895,10 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
}
s.logger.Printf("[WARN] [web] Restore requested (async): stack=%s, snapshot=%s from %s", stackName, snapshotID, r.RemoteAddr)
s.backupMgr.BeginRestoreOp("restore", stackName)
if msg, ok := s.beginRestoreOp("restore", stackName); !ok {
http.Redirect(w, r, "/backups/restore?flash_error="+url.QueryEscape(msg), http.StatusFound)
return
}
go func() {
start := time.Now()
// Phase 2b: restore from the app's recovery unit (recovers secrets from the guest, fail-closed
@@ -2236,7 +2240,10 @@ func (s *Server) backupTier2RestoreHandler(w http.ResponseWriter, r *http.Reques
}
s.logger.Printf("[WARN] [web] Tier-2 file restore requested (async): stack=%s from %s", stackName, r.RemoteAddr)
s.backupMgr.BeginRestoreOp("tier2-restore", stackName)
if msg, ok := s.beginRestoreOp("tier2-restore", stackName); !ok {
http.Redirect(w, r, "/backups/apps?flash_error="+url.QueryEscape(msg), http.StatusFound)
return
}
go func() {
n, err := s.backupMgr.RestoreTier2Files(stackName)
if err != nil {
@@ -2311,7 +2318,10 @@ func (s *Server) backupTier2UnitRestoreHandler(w http.ResponseWriter, r *http.Re
// (R-538), which is what left such an app with no way back from the second drive.
if s.backupMgr.HasDriveFileLegs(stackName) {
s.logger.Printf("[WARN] [web] Tier-2 WHOLE restore requested (async, files + database): stack=%s from %s", stackName, r.RemoteAddr)
s.backupMgr.BeginRestoreOp("tier2-whole-restore", stackName)
if msg, ok := s.beginRestoreOp("tier2-whole-restore", stackName); !ok {
http.Redirect(w, r, "/backups/apps?flash_error="+url.QueryEscape(msg), http.StatusFound)
return
}
go func() {
start := time.Now()
res, err := s.backupMgr.RestoreTier2Whole(stackName)
@@ -2346,7 +2356,10 @@ func (s *Server) backupTier2UnitRestoreHandler(w http.ResponseWriter, r *http.Re
}
s.logger.Printf("[WARN] [web] Tier-2 UNIT restore requested (async, OVERWRITES live data): stack=%s from %s", stackName, r.RemoteAddr)
s.backupMgr.BeginRestoreOp("tier2-unit-restore", stackName)
if msg, ok := s.beginRestoreOp("tier2-unit-restore", stackName); !ok {
http.Redirect(w, r, "/backups/apps?flash_error="+url.QueryEscape(msg), http.StatusFound)
return
}
go func() {
start := time.Now()
res, err := s.backupMgr.RestoreTier2Unit(stackName)
@@ -255,6 +255,21 @@ func i18nCasesB() []i18nCase {
d["KeptUnitRestorable"] = true
return d
}},
// D62 (2026-10-29): restore copies in the kept-copies list — one deletable (the warning and the typed name), one a
// hold needs (the button off, the reason). A NEW case on a box without off-site (the second call site), so no
// existing fixture changes. Line, Warning, TypePrompt and NeededText are composed in Go in the request's language;
// the fixture carries the same data in both renders.
{"backups_restore_restore_copies", "backups_restore", func() map[string]interface{} {
d := i18nLayoutData("backups-restore", "Visszaállítás a mentésből")
d["Backup"] = i18nBackupStatus(false, false)
d["OffboxConfigured"] = false
d["RestoreCopies"] = []restoreCopyRow{
{App: "vikunja", Stamp: "20261029T080000Z", Name: "Vikunja", Line: "2026-10-29 09:00 · 12 MB · 2026-11-28",
Warning: "Vikunja 2026-10-29", TypePrompt: "Vikunja"},
{App: "immich", Stamp: "20261020T080000Z", Name: "Immich Photos", Line: "2026-10-20 10:00 · 2 GB", NeededText: "hold: support decides"},
}
return d
}},
{"backups_restore_known_empty", "backups_restore", func() map[string]interface{} {
d := i18nLayoutData("backups-restore", "Visszaállítás a mentésből")
d["Backup"] = i18nBackupStatus(false, false)
+139 -1
View File
@@ -1,12 +1,16 @@
package web
import (
"encoding/json"
"errors"
"net/http"
"net/url"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
)
// D25 (operator ruling 2026-10-21; R-1175 + R-1181): the restore page lists the second-drive copies whose source is
@@ -106,7 +110,10 @@ func (s *Server) keptUnitRestoreHandler(w http.ResponseWriter, r *http.Request)
return
}
s.logger.Printf("[WARN] [web] Restore of %s from a set-aside unit requested (async): %s from %s", k.Source, k.Path, r.RemoteAddr)
s.backupMgr.BeginRestoreOp("restore", k.Source)
if msg, ok := s.beginRestoreOp("restore", k.Source); !ok {
http.Redirect(w, r, "/backups/restore?flash_error="+url.QueryEscape(msg), http.StatusFound)
return
}
go func() {
app, res, err := s.backupMgr.RestoreSetAsideUnit(id)
if err != nil {
@@ -118,3 +125,134 @@ func (s *Server) keptUnitRestoreHandler(w http.ResponseWriter, r *http.Request)
}()
http.Redirect(w, r, "/backups/restore?"+flashQuery("flash", "flash.restore.started"), http.StatusFound)
}
// ── D62 (operator ruling 2026-10-29): restore copies in the same list ───────────────────────────────────────────────
//
// Each restore copy shows the app, the date of the restore it protects, its size and the day the box deletes it by
// itself (30 days after the restore). „Törlés most" deletes it earlier: a warning that names the app and the date,
// and the app's name typed. A copy a hold or a pending put-back needs shows why in one line and has no button — and
// the handler refuses it too. Pinned by TestD62_* (web).
// restoreCopyRow is one restore copy on the page.
type restoreCopyRow struct {
App string
Stamp string
Name string // the app's display name — what the household types
Line string // „visszaállítás: <date> · <size> · automatikusan törlődik: <date>"
NeededText string // why it cannot be deleted; "" = the button is offered
Warning string // the confirm step's sentence (app and date)
TypePrompt string
}
// restoreCopyAppName is the app's display name, or its slug when the app is gone.
func (s *Server) restoreCopyAppName(app string) string {
if s.stackMgr != nil {
if st, ok := s.stackMgr.GetStack(app); ok && st.Meta.DisplayName != "" {
return st.Meta.DisplayName
}
}
return app
}
func (s *Server) restoreCopyRows(lang string) []restoreCopyRow {
if s.backupMgr == nil {
return nil
}
var out []restoreCopyRow
for _, c := range s.backupMgr.RestoreCopies() {
name := s.restoreCopyAppName(c.App)
at := c.At.In(getTimezone())
line := s.msgLang(lang, "restore_copy.restored_on", fmtLocalTime(lang, c.At)) + " · " +
i18n.LocalizeDecimals(lang, backup.HumanizeBytes(c.SizeBytes))
row := restoreCopyRow{App: c.App, Stamp: c.Stamp, Name: name}
if c.Needed == "" {
line += " · " + s.msgLang(lang, "restore_copy.auto_delete", i18n.FormatDate(lang, c.DeleteOn.In(getTimezone())))
row.Warning = s.msgLang(lang, "restore_copy.warning", name, strings.TrimSuffix(i18n.FormatDate(lang, at), "."))
row.TypePrompt = s.msgLang(lang, "kept.delete.type", name)
} else {
row.NeededText = s.msgLang(lang, "restore_copy.needed."+c.Needed)
}
row.Line = line
out = append(out, row)
}
return out
}
// restoreCopyJSON is the delete's answer: ok with the page to go to, or the refusal's sentence.
func restoreCopyJSON(w http.ResponseWriter, code int, ok bool, msg, redirect string) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": ok, "error": msg, "redirect": redirect})
}
// restoreCopyDeleteHandler is „Törlés most": POST /backup/restore-copy/delete (app, stamp, confirm). JSON answer:
// 200 deleted; 400 the typed name does not match; 404 no such copy; 409 the copy is needed, or an operation runs on
// the box or the app; 500 the removal failed. CSRF as every state-changing form (CsrfProtect).
func (s *Server) restoreCopyDeleteHandler(w http.ResponseWriter, r *http.Request) {
lang := s.langFor(r)
if s.backupMgr == nil {
restoreCopyJSON(w, http.StatusServiceUnavailable, false, s.msgLang(lang, "flash.offbox.mgr_unreachable"), "")
return
}
_ = r.ParseForm()
app, stamp := strings.TrimSpace(r.FormValue("app")), strings.TrimSpace(r.FormValue("stamp"))
var found *backup.RestoreCopy
for _, c := range s.backupMgr.RestoreCopies() {
if c.App == app && c.Stamp == stamp {
c := c
found = &c
break
}
}
if found == nil {
s.logger.Printf("[WARN] [web] restore-copy delete REFUSED: %q/%q is not a listed copy (from %s)", app, stamp, clientIP(r))
restoreCopyJSON(w, http.StatusNotFound, false, s.msgLang(lang, "restore_copy.not_found"), "")
return
}
if found.Needed != "" {
s.logger.Printf("[WARN] [web] restore-copy delete REFUSED for %s: the copy is needed (%s)", found.Path, found.Needed)
restoreCopyJSON(w, http.StatusConflict, false, s.msgLang(lang, "restore_copy.needed."+found.Needed), "")
return
}
name := s.restoreCopyAppName(app)
if strings.TrimSpace(r.FormValue("confirm")) != name {
s.logger.Printf("[WARN] [web] restore-copy delete REFUSED for %s: the typed confirmation did not match", found.Path)
restoreCopyJSON(w, http.StatusBadRequest, false, s.msgLang(lang, "kept.delete.mismatch", name), "")
return
}
if s.stackMgr != nil && s.stackMgr.IsUpdating(app) {
s.logger.Printf("[WARN] [web] restore-copy delete REFUSED for %s: an update of %s runs", found.Path, app)
restoreCopyJSON(w, http.StatusConflict, false, s.msgLang(lang, "restore_copy.app_busy"), "")
return
}
who := "the household (dashboard sign-in) from " + clientIP(r)
c, err := s.backupMgr.DeleteRestoreCopy(app, stamp)
switch {
case errors.Is(err, backup.ErrRestoreCopyBusy):
busy, mirror := s.backupMgr.RestoreBusy()
msg, blocked := s.restoreBusyMessage(busy, mirror)
if !blocked {
msg = s.msgLang(lang, "restore_copy.app_busy")
}
s.logger.Printf("[WARN] [web] restore-copy delete REFUSED for %s: a backup or restore operation runs", found.Path)
restoreCopyJSON(w, http.StatusConflict, false, msg, "")
return
case errors.Is(err, backup.ErrRestoreCopyNeeded):
restoreCopyJSON(w, http.StatusConflict, false, s.msgLang(lang, "restore_copy.needed."+c.Needed), "")
return
case errors.Is(err, backup.ErrRestoreCopyNotFound):
restoreCopyJSON(w, http.StatusNotFound, false, s.msgLang(lang, "restore_copy.not_found"), "")
return
case err != nil:
s.logger.Printf("[ERROR] [web] restore-copy delete of %s failed: %v", found.Path, err)
restoreCopyJSON(w, http.StatusInternalServerError, false, s.msgLang(lang, "restore_copy.failed", err.Error()), "")
return
}
restoreAt := c.At.UTC().Format(time.RFC3339)
s.logger.Printf("[WARN] [web] restore copy DELETED by hand (D62): app=%s restore_at=%s size=%d path=%s who=%s",
app, restoreAt, c.SizeBytes, c.Path, who)
if s.notifier != nil {
s.notifier.NotifyRestoreCopyDeleted(notify.RestoreCopyDeletedDetails{App: app, RestoreAt: restoreAt, SizeBytes: c.SizeBytes, Who: who, Path: c.Path})
}
restoreCopyJSON(w, http.StatusOK, true, "", "/backups/restore?"+flashQuery("flash", "flash.restore_copy.deleted", name))
}
+13 -6
View File
@@ -217,16 +217,23 @@ func (s *Server) keptLoadHandler(w http.ResponseWriter, r *http.Request) {
_, err := s.stackMgr.RestoreKeptFiles(it)
return err
}
// R-1256 H6: the load begins in the same step as the check for every other operation; a refusal moves nothing
// (the dated folder's files move back inside the operation, as prepare).
var lErr error
if c.Tier == backup.KeptTierOffsite {
s.logger.Printf("[INFO] [web] kept LOAD %s: %s from the off-site snapshot %s (from %s)", it.App, it.Path, c.SnapshotID, r.RemoteAddr)
s.backupMgr.LoadKeptOffsite(app, it.Drive, c, moveBack, okMsg, failMsg, after)
lErr = s.backupMgr.LoadKeptOffsite(app, it.Drive, c, moveBack, okMsg, failMsg, after)
} else {
if err := moveBack(); err != nil {
s.keptRedirect(w, r, "flash_error", s.errText(r, err))
return
}
s.logger.Printf("[INFO] [web] kept LOAD %s: %s from %s (from %s)", it.App, it.Path, c.UnitDir, r.RemoteAddr)
s.backupMgr.LoadKeptApp(app, c.UnitDir, okMsg, failMsg, after)
lErr = s.backupMgr.LoadKeptApp(app, c.UnitDir, moveBack, okMsg, failMsg, after)
}
if lErr != nil {
msg, _ := s.restoreOpBlocked()
if msg == "" { // the other operation ended in the meantime
msg, _ = s.restoreBusyMessage(backup.RestoreBusyRestore, "")
}
s.keptRedirect(w, r, "flash_error", msg)
return
}
http.Redirect(w, r, "/backups/restore?"+flashQuery("flash", "flash.restore.started"), http.StatusFound)
}
+20 -5
View File
@@ -450,7 +450,10 @@ func (s *Server) offboxRestoreHandler(w http.ResponseWriter, r *http.Request) {
return
}
full := mode == "full"
s.backupMgr.BeginRestoreOp("offbox-restore", app)
if msg, ok := s.beginRestoreOp("offbox-restore", app); !ok {
offboxRedirectTo(w, r, restoreWizardPath(app), msg, true)
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
@@ -538,7 +541,10 @@ func (s *Server) offboxReconstituteHandler(w http.ResponseWriter, r *http.Reques
// data"; this one answers "yes, into a different place than the backup recorded". One checkbox
// carrying both would be the two-decisions-one-button shape R-48 removed from this surface.
ackPlacement := r.FormValue("ack_placement") == "1"
s.backupMgr.BeginRestoreOp("offbox-reconstitute", app)
if msg, ok := s.beginRestoreOp("offbox-reconstitute", app); !ok {
offboxRedirectTo(w, r, restoreWizardPath(app), msg, true)
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute)
defer cancel()
@@ -678,7 +684,10 @@ func (s *Server) offboxPlaceHandler(w http.ResponseWriter, r *http.Request) {
offboxRedirectTo(w, r, restoreWizardPath(app), offsiteScratchIncompleteKey, true)
return
}
s.backupMgr.BeginRestoreOp("offbox-place", app)
if msg, ok := s.beginRestoreOp("offbox-place", app); !ok {
offboxRedirectTo(w, r, restoreWizardPath(app), msg, true)
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
@@ -711,7 +720,10 @@ func (s *Server) sharesRestoreHandler(w http.ResponseWriter, r *http.Request) {
offboxRedirectTo(w, r, "/backups/restore", msg, true)
return
}
s.backupMgr.BeginRestoreOp("shares-restore", backup.SharesDisplayName)
if msg, ok := s.beginRestoreOp("shares-restore", backup.SharesDisplayName); !ok {
offboxRedirectTo(w, r, "/backups/restore", msg, true)
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
@@ -737,7 +749,10 @@ func (s *Server) sharesPlaceHandler(w http.ResponseWriter, r *http.Request) {
offboxRedirectTo(w, r, "/backups/restore", msg, true)
return
}
s.backupMgr.BeginRestoreOp("shares-place", backup.SharesDisplayName)
if msg, ok := s.beginRestoreOp("shares-place", backup.SharesDisplayName); !ok {
offboxRedirectTo(w, r, "/backups/restore", msg, true)
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
@@ -93,7 +93,9 @@ func TestR1151_ConfirmAnswersInTheBoxLanguage(t *testing.T) {
{http.StatusGone, http.StatusGone, true, "Lejárt az idő", "has run out"},
{http.StatusNotFound, http.StatusGone, true, "már nem vár megerősítésre", "no longer waits"},
{http.StatusBadGateway, http.StatusBadGateway, false, "nem sikerült elmenteni", "could not be saved"},
{0, http.StatusBadGateway, false, "nem sikerült elmenteni", "could not be saved"},
// R-1254 H2 (2026-10-28): a failed call (0) or an agent that cannot learn the outcome (504) promises nothing.
{0, http.StatusGatewayTimeout, false, "Nem tudjuk biztosan", "cannot tell yet"},
{http.StatusGatewayTimeout, http.StatusGatewayTimeout, false, "Nem tudjuk biztosan", "cannot tell yet"},
}
for _, c := range cases {
for _, lang := range []string{"hu", "en"} {
@@ -0,0 +1,48 @@
package web
import (
"errors"
"net/http"
"strings"
"testing"
)
// R-1254 H2 (the household's sentence): a confirm whose upload answer is late or lost must not promise „your previous
// code still works" — the hub may hold the new package. Since agent 0.168.0 the agent asks the hub and answers 502 only
// when the hub holds ANOTHER package; 504 = unknown. A controller-side transport failure (status 0 — the call itself
// timed out while the agent may still store) is unknown too.
// RED-PROOF: drop the 504/0 case from escrowConfirmAPIHandler → the default sentence („a régi kódod továbbra is
// érvényes") is shown → FAILS.
func TestR1254_AnUnknownConfirmOutcomePromisesNothing(t *testing.T) {
for _, tc := range []struct {
name string
status int
}{{"agent 504", http.StatusGatewayTimeout}, {"transport failure", 0}} {
t.Run(tc.name, func(t *testing.T) {
h := newEscrowWizardHarness(t)
h.configureOffbox(t, "pending")
h.agent.confirmStatus, h.agent.confirmErr = tc.status, errors.New("late")
for _, lang := range []string{"hu", "en"} {
w := postConfirm(t, h.s, lang, `{"words":["ketto","negy"]}`)
low := strings.ToLower(w.Body.String())
// ASCII fragments (the accented forms carry these stems): „régi kódod továbbra is érvényes" / „still works".
if strings.Contains(low, "tovabbra is") || strings.Contains(low, "továbbra is") || strings.Contains(low, "still works") {
t.Fatalf("%s: an unknown outcome promised the previous code: %s", lang, w.Body.String())
}
if !strings.Contains(low, "mindket") && !strings.Contains(low, "mindkét") && !strings.Contains(low, "both") {
t.Fatalf("%s: the sentence must tell the household to keep both codes: %s", lang, w.Body.String())
}
}
if ceremonyStamped(t, h) {
t.Fatal("an unknown outcome stamped the ceremony completed")
}
})
}
// Control: the agent says the hub holds another package (502) → the old sentence, now true.
h := newEscrowWizardHarness(t)
h.configureOffbox(t, "pending")
h.agent.confirmStatus, h.agent.confirmErr = http.StatusBadGateway, errors.New("not stored")
if w := postConfirm(t, h.s, "en", `{"words":["ketto","negy"]}`); !strings.Contains(w.Body.String(), "still works") {
t.Fatalf("502 must keep the store-failed sentence: %s", w.Body.String())
}
}
@@ -0,0 +1,60 @@
package web
import (
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
)
// R-1256 H6: every restore handler records its op through beginRestoreOp, which asks about every other operation in
// the same step (backup.TryBeginRestoreOp). A handler that calls BeginRestoreOp directly re-opens the window between
// the early refusal and the record. RED-PROOF: put one `s.backupMgr.BeginRestoreOp(...)` back → FAILS, naming the file.
func TestR1256_H6_NoHandlerBeginsARestoreUnchecked(t *testing.T) {
files, _ := filepath.Glob("*.go")
for _, f := range files {
if strings.HasSuffix(f, "_test.go") {
continue
}
src, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
fs := token.NewFileSet()
af, err := parser.ParseFile(fs, f, src, 0)
if err != nil {
t.Fatal(err)
}
ast.Inspect(af, func(n ast.Node) bool {
if sel, ok := n.(*ast.SelectorExpr); ok && sel.Sel.Name == "BeginRestoreOp" {
t.Errorf("%s: a direct BeginRestoreOp — begin through s.beginRestoreOp (R-1256 H6)", fs.Position(sel.Pos()))
}
return true
})
}
}
// The handler's begin refuses with the household's sentence when a second-drive copy began after the early check.
func TestR1256_H6_TheBeginRefusesWithTheCopySentence(t *testing.T) {
s, _, _ := newOffboxWebServer(t)
if _, blocked := s.restoreOpBlocked(); blocked {
t.Fatal("fixture: nothing runs")
}
done := backup.MarkTier2CopyForTest("immich")
msg, ok := s.beginRestoreOp("offbox-reconstitute", "immich")
done()
if ok || !strings.Contains(msg, "második meghajtóra") {
t.Fatalf("the begin must refuse with the copy named; ok=%v %q", ok, msg)
}
if s.backupMgr.RestoreStatus().Running {
t.Fatal("a refused begin recorded a restore")
}
if _, ok := s.beginRestoreOp("offbox-reconstitute", "immich"); !ok {
t.Fatal("after the copy the begin must work")
}
}
+32 -10
View File
@@ -209,28 +209,50 @@ func (s *Server) restoreOpBlocked() (string, bool) {
if s.backupMgr == nil {
return "", false
}
if st := s.backupMgr.RestoreStatus(); restoreOpInFlight(st) {
busy, app := s.backupMgr.RestoreBusy()
return s.restoreBusyMessage(busy, app)
}
// beginRestoreOp records a restore op in the SAME step as the check for every other operation (R-1256 H6): the early
// restoreOpBlocked answer can be stale by the time a handler has read its form, and a second-drive copy that began in
// between ran beside the restore. A refusal returns the same sentence as restoreOpBlocked; the caller redirects with it
// and starts nothing. Every restore handler begins through here — TestR1256_H6_NoHandlerBeginsARestoreUnchecked.
func (s *Server) beginRestoreOp(op, stack string) (string, bool) {
busy, app := s.backupMgr.TryBeginRestoreOp(op, stack)
if busy == "" {
return "", true
}
if s.logger != nil {
s.logger.Printf("[WARN] [restore] %s of %s refused at its start — a %s operation runs (R-1256)", op, stack, busy)
}
msg, _ := s.restoreBusyMessage(busy, app)
return msg, false
}
// restoreBusyMessage is the household's sentence for a RestoreBusy reason ("" = not busy).
func (s *Server) restoreBusyMessage(busy, app string) (string, bool) {
switch busy {
case "":
return "", false
case backup.RestoreBusyRestore:
subject := "Egy visszaállítási művelet"
if st.Stack != "" {
subject = "Egy visszaállítási művelet (" + st.Stack + ")"
if app != "" {
subject = "Egy visszaállítási művelet (" + app + ")"
}
return subject + " már fut, ezért most nem indítható újabb. Az állapotát ezen az oldalon " +
"követheted; amint befejeződik, újra indíthatsz visszaállítást.", true
}
if s.backupMgr.IsRunning() {
case backup.RestoreBusyBackup:
return "Egy mentési művelet már fut, ezért most nem indítható visszaállítás. Az állapotát " +
"ezen az oldalon követheted; amint befejeződik, újra indíthatsz visszaállítást.", true
}
// The stray-copy fix: a second-drive copy (rsync --delete) runs now, or one a previous controller process lost may
// still run — a restore beside it could lose the files it brings back (R-1180's other direction).
if app, blocked := s.backupMgr.Tier2CopyBlocksRestore(); blocked {
default:
// The stray-copy fix: a second-drive copy (rsync --delete) runs now, or one a previous controller process lost
// may still run — a restore beside it could lose the files it brings back (R-1180's other direction).
if s.logger != nil {
s.logger.Printf("[WARN] [restore] restore refused — a second-drive copy is running or may still run (app=%q)", app)
}
return "Éppen fut a második meghajtóra készülő mentési másolat, ezért most nem indítható visszaállítás. " +
"Néhány perc múlva próbáld újra.", true
}
return "", false
}
// backupsRestoreWizardHandler renders GET /backups/restore/app?name=<app> — the single entry the
+3
View File
@@ -844,6 +844,9 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// D25 (2026-10-21): „Eltávolítás" of ONE kept second-drive copy whose source is gone.
case path == "/backup/kept/remove" && r.Method == http.MethodPost:
s.keptCopyRemoveHandler(w, r)
// D62 (2026-10-29): „Törlés most" of ONE restore copy (typed name; never a copy a hold or a put-back needs).
case path == "/backup/restore-copy/delete" && r.Method == http.MethodPost:
s.restoreCopyDeleteHandler(w, r)
// D47 (2026-10-25): „Visszaállítás" from a set-aside recovery unit of an earlier install.
case path == "/backup/kept/restore" && r.Method == http.MethodPost:
s.keptUnitRestoreHandler(w, r)
@@ -429,9 +429,9 @@ function submitRestore() {
{{/* D25 + D47: the kept copies (second-drive copies whose source is gone; set-aside units of an earlier install). Shown
inside the off-site card when the box has off-site, and after it when it has none — before 2026-10-25 it sat only inside
the off-site block, so a box without off-site never listed its kept copies. One body, two call sites. */}}
{{define "kept_copies_section"}}{{if .KeptCopies}}
{{define "kept_copies_section"}}{{if or .KeptCopies .RestoreCopies}}
<div class="backup-tier-divider" style="margin-top:1.5rem"></div>
<h3 style="margin-bottom:.5rem">{{T "backups_restore.megorzott_masolatok"}}</h3>
<h3 style="margin-bottom:.5rem">{{T "backups_restore.megorzott_masolatok"}}</h3>{{if .KeptCopies}}
<p class="form-hint" style="margin-bottom:.75rem">{{T "backups_restore.megorzott_masolatok_hint"}}</p>
<div class="app-row-list">
{{range .KeptCopies}}
@@ -467,4 +467,43 @@ the off-site block, so a box without off-site never listed its kept copies. One
});
}
</script>
{{end}}{{end}}
{{end}}{{if .RestoreCopies}}{{/* D62 (operator ruling 2026-10-29): restore copies — the app, the restore's date, the size, the day the box deletes
it; „Törlés most" after the warning and the app's name typed. A needed copy: the button is off, the reason in one line. */}}
<h4 style="margin:1rem 0 .5rem">{{T "restore_copy.title"}}</h4>
<p class="form-hint" style="margin-bottom:.75rem">{{T "restore_copy.hint"}}</p>
<div class="app-row-list" data-restore-copies="true">
{{range .RestoreCopies}}
{{template "app_list_row" dict "Slug" .App "Name" .Name "Secondary" .Line}}{{if .NeededText}}
<button type="button" class="btn btn-xs btn-danger-outline" disabled data-restore-copy-off="true">{{T "restore_copy.delete"}}</button>{{end}}
{{template "app_list_row_end"}}{{if .NeededText}}
<p class="form-hint" data-restore-copy-needed="true">{{.NeededText}}</p>{{else}}
<details class="kept-delete" data-restore-copy="{{.App}}/{{.Stamp}}">
<summary class="btn btn-xs btn-danger-outline">{{T "restore_copy.delete"}}</summary>
<form method="POST" action="/backup/restore-copy/delete" class="kept-delete-form" onsubmit="return deleteRestoreCopy(event, this)">
{{$.CSRFField}}
<input type="hidden" name="app" value="{{.App}}">
<input type="hidden" name="stamp" value="{{.Stamp}}">
<p class="kept-delete-text">{{.Warning}}</p>
<label class="form-hint">{{.TypePrompt}}</label>
<input type="text" name="confirm" autocomplete="off" required>
<button type="submit" class="btn btn-sm btn-danger">{{T "restore_copy.delete"}}</button>
<p class="kept-delete-text" data-restore-copy-error="true" hidden></p>
</form>
</details>{{end}}
{{end}}
</div>
<script>
function deleteRestoreCopy(ev, f){
ev.preventDefault();
var err = f.querySelector('[data-restore-copy-error]');
fetch(f.action, {method: 'POST', headers: Object.assign({'Accept': 'application/json'}, csrfHeaders()), body: new URLSearchParams(new FormData(f))})
.then(function(r){ return r.json(); })
.then(function(d){
if (d.ok) { location.href = d.redirect; return; }
err.textContent = d.error || ''; err.hidden = false;
})
.catch(function(e){ err.textContent = '{{T "layout.halozati_hiba"}} ' + e; err.hidden = false; });
return false;
}
</script>
{{end}}{{end}}{{end}}
@@ -0,0 +1,915 @@
<!DOCTYPE html>
<html lang="hu" class="no-js">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Visszaállítás a mentésből — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
function felhomConfirm(el,question,onYes){
if(!el||el.dataset.fcOpen)return;
el.dataset.fcOpen='1';
var wrap=document.createElement('span');wrap.className='inline-confirm';
var q=document.createElement('span');q.className='inline-confirm-q';q.textContent=question;
var yes=document.createElement('button');yes.type='button';yes.className='btn btn-xs btn-danger';yes.textContent='Igen';
var no=document.createElement('button');no.type='button';no.className='btn btn-xs btn-outline';no.textContent='Mégse';
wrap.appendChild(q);wrap.appendChild(yes);wrap.appendChild(no);
el.style.display='none';el.parentNode.insertBefore(wrap,el.nextSibling);
function close(){wrap.remove();el.style.display='';delete el.dataset.fcOpen;}
no.addEventListener('click',close);
yes.addEventListener('click',function(){close();onYes();});
}
document.addEventListener('click',function(e){
var btn=e.target.closest?e.target.closest('[data-confirm]'):null;
if(!btn)return;
e.preventDefault();
felhomConfirm(btn,btn.getAttribute('data-confirm'),function(){
var form=btn.closest('form');
if(form){if(form.requestSubmit)form.requestSubmit(btn);else form.submit();}
});
});
document.addEventListener('click',function(e){
var btn=e.target.closest?e.target.closest('.nav-group-toggle'):null;
if(!btn)return;
var group=btn.closest('.nav-group');
if(!group)return;
var willOpen=!group.classList.contains('is-open');
document.querySelectorAll('.nav-group.is-open').forEach(function(g){
g.classList.remove('is-open');
var t=g.querySelector('.nav-group-toggle');
if(t)t.setAttribute('aria-expanded','false');
});
if(willOpen){group.classList.add('is-open');btn.setAttribute('aria-expanded','true');}
});
function showAlert(msg){var o=document.createElement('div');o.className='modal-overlay';o.id='alert-modal';o.addEventListener('click',function(e){if(e.target===o)o.remove();});var c=document.createElement('div');c.className='modal-card';c.innerHTML='<h3>Üzenet</h3><pre style="white-space:pre-wrap;word-break:break-word;background:var(--bg-2);padding:.75rem;border-radius:.375rem;font-size:.85rem;max-height:60vh;overflow-y:auto;user-select:text;cursor:text">'+msg.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')+'</pre><div class="modal-actions"><button class="btn btn-primary" onclick="document.getElementById(\'alert-modal\').remove()">OK</button></div>';o.appendChild(c);document.body.appendChild(o);}
</script>
</head>
<body>
<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">
<symbol id="i-hard-drive" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 16h.01" /> <path d="M2.212 11.577a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" /> <path d="M21.946 12.013H2.054" /> <path d="M6 16h.01" /></symbol>
<symbol id="i-cpu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20v2" /> <path d="M12 2v2" /> <path d="M17 20v2" /> <path d="M17 2v2" /> <path d="M2 12h2" /> <path d="M2 17h2" /> <path d="M2 7h2" /> <path d="M20 12h2" /> <path d="M20 17h2" /> <path d="M20 7h2" /> <path d="M7 20v2" /> <path d="M7 2v2" /> <rect x="4" y="4" width="16" height="16" rx="2" /> <rect x="8" y="8" width="8" height="8" rx="1" /></symbol>
<symbol id="i-memory-stick" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 12v-2" /> <path d="M12 18v-2" /> <path d="M16 12v-2" /> <path d="M16 18v-2" /> <path d="M2 11h1.5" /> <path d="M20 18v-2" /> <path d="M20.5 11H22" /> <path d="M4 18v-2" /> <path d="M8 12v-2" /> <path d="M8 18v-2" /> <rect x="2" y="6" width="20" height="10" rx="2" /></symbol>
<symbol id="i-thermometer" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0Z" /></symbol>
<symbol id="i-triangle-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" /> <path d="M12 9v4" /> <path d="M12 17h.01" /></symbol>
<symbol id="i-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2" /> <path d="M7 11V7a5 5 0 0 1 10 0v4" /></symbol>
<symbol id="i-cloud" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" /></symbol>
<symbol id="i-square" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" /></symbol>
<symbol id="i-rotate-cw" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" /> <path d="M21 3v5h-5" /></symbol>
<symbol id="i-file-text" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" /> <path d="M14 2v5a1 1 0 0 0 1 1h5" /> <path d="M10 9H8" /> <path d="M16 13H8" /> <path d="M16 17H8" /></symbol>
<symbol id="i-external-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6" /> <path d="M10 14 21 3" /> <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></symbol>
<symbol id="i-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /></symbol>
<symbol id="i-server" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="8" x="2" y="2" rx="2" ry="2" /> <rect width="20" height="8" x="2" y="14" rx="2" ry="2" /> <line x1="6" x2="6.01" y1="6" y2="6" /> <line x1="6" x2="6.01" y1="18" y2="18" /></symbol>
<symbol id="i-share" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3" /> <circle cx="6" cy="12" r="3" /> <circle cx="18" cy="19" r="3" /> <line x1="8.59" x2="15.42" y1="13.51" y2="17.49" /> <line x1="15.41" x2="8.59" y1="6.51" y2="10.49" /></symbol>
<symbol id="i-layout-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="7" x="3" y="3" rx="1" /> <rect width="7" height="7" x="14" y="3" rx="1" /> <rect width="7" height="7" x="14" y="14" rx="1" /> <rect width="7" height="7" x="3" y="14" rx="1" /></symbol>
<symbol id="i-rocket" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91 0z" /> <path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z" /> <path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" /> <path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" /></symbol>
<symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" /> <circle cx="12" cy="12" r="3" /></symbol>
<symbol id="i-bell" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.268 21a2 2 0 0 0 3.464 0" /> <path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" /></symbol>
<symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18" /> <path d="m6 6 12 12" /></symbol>
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5" /></symbol>
<symbol id="i-pencil" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" /> <path d="m15 5 4 4" /></symbol>
<symbol id="i-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /> <path d="M12 16v-4" /> <path d="M12 8h.01" /></symbol>
<symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> <path d="m7 10 5 5 5-5" /></symbol>
<symbol id="i-upload" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12" /> <path d="m17 8-5-5-5 5" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /></symbol>
<symbol id="i-trash-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 11v6" /> <path d="M14 11v6" /> <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /> <path d="M3 6h18" /> <path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></symbol>
<symbol id="i-wrench" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z" /></symbol>
<symbol id="i-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /> <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /></symbol>
<symbol id="i-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21 21-4.34-4.34" /> <circle cx="11" cy="11" r="8" /></symbol>
<symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14" /> <path d="M12 5v14" /></symbol>
<symbol id="i-chevron-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6" /></symbol>
<symbol id="i-play" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z" /></symbol>
<symbol id="i-pause" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="14" y="3" width="5" height="18" rx="1" /> <rect x="5" y="3" width="5" height="18" rx="1" /></symbol>
<symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h16" /> <path d="M4 6h16" /> <path d="M4 18h16" /></symbol>
</svg>
<header class="mobile-topbar">
<a href="/" class="mobile-topbar-logo"><img src="/static/felhom-logo.svg?v=0.247.0" alt="Felhom.eu"></a>
<button type="button" class="nav-burger" aria-expanded="false" aria-controls="sidebar" aria-label="Menü">
<svg class="ico"><use href="#i-menu"/></svg>
</button>
</header>
<div class="nav-backdrop" hidden></div>
<nav class="sidebar" id="sidebar">
<div class="sidebar-header">
<img src="/static/felhom-logo.svg?v=0.247.0" alt="Felhom.eu" class="sidebar-logo">
</div>
<ul class="nav-links">
<li><a href="/launcher" class=""><svg class="ico"><use href="#i-rocket"/></svg>Indítópult</a></li>
<li><a href="/dashboard" class=""><svg class="ico"><use href="#i-layout-grid"/></svg>Vezérlőpult</a></li>
<li><a href="/stacks" class=""><svg class="ico"><use href="#i-cloud"/></svg>Alkalmazások</a></li>
<li class="nav-group">
<button type="button" class="nav-group-toggle" aria-expanded="false" aria-controls="nav-group-storage"><svg class="ico"><use href="#i-hard-drive"/></svg>Tárhely<svg class="ico nav-chevron"><use href="#i-chevron-down"/></svg></button>
<ul id="nav-group-storage" class="nav-links nav-links-sub nav-links-nested">
<li><a href="/storage" class="">Meghajtók</a></li>
<li><a href="/storage/network" class="">Hálózati tárhely</a></li>
</ul>
</li>
<li class="nav-group is-open">
<button type="button" class="nav-group-toggle active" aria-expanded="true" aria-controls="nav-group-backups"><svg class="ico"><use href="#i-shield"/></svg>Biztonsági mentés<svg class="ico nav-chevron"><use href="#i-chevron-down"/></svg></button>
<ul id="nav-group-backups" class="nav-links nav-links-sub nav-links-nested">
<li><a href="/backups" class="">Áttekintés</a></li>
<li><a href="/backups/remote" class="">Távoli mentés</a></li>
<li><a href="/backups/apps" class="">Alkalmazások</a></li>
<li><a href="/backups/restore" class="active">Visszaállítás</a></li>
</ul>
</li>
<li class="nav-group">
<button type="button" class="nav-group-toggle" aria-expanded="false" aria-controls="nav-group-sharing"><svg class="ico"><use href="#i-share"/></svg>Megosztás<svg class="ico nav-chevron"><use href="#i-chevron-down"/></svg></button>
<ul id="nav-group-sharing" class="nav-links nav-links-sub nav-links-nested">
<li><a href="/sharing" class="">Hálózati megosztás</a></li>
</ul>
</li>
<li><a href="/monitoring" class=""><svg class="ico"><use href="#i-cpu"/></svg>Rendszermonitor</a></li>
<li><a href="/debug" class=""><svg class="ico"><use href="#i-wrench"/></svg>Hibakeresés</a></li>
</ul>
<div class="sidebar-bottom">
<div class="nav-group-label">Beállítások</div>
<ul class="nav-links nav-links-sub">
<li><a href="/settings" class=""><svg class="ico"><use href="#i-settings"/></svg>Rendszer</a></li>
<li><a href="/settings/notifications" class=""><svg class="ico"><use href="#i-bell"/></svg>Értesítések</a></li>
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
<main class="content">
<div class="page-header">
<h2>Biztonsági mentés — Visszaállítás</h2>
<span class="domain-badge">example.hu</span>
</div>
<div id="restore-banner" class="flash" style="display:none"></div>
<div class="backup-section-card">
<h3>Visszaállítás</h3>
<div class="restore-section">
<div class="restore-form-row">
<label class="restore-label">Alkalmazás:</label>
<select id="restore-app" class="restore-select" onchange="onRestoreAppChange()">
<option value="">— Válassz —</option>
<option value="privatebin" data-has-hdd="" data-has-db="" data-has-volumes=""></option>
</select>
</div>
<div class="restore-form-row">
<label class="restore-label">Pillanatkép:</label>
<select id="restore-snapshot" class="restore-select" onchange="onRestoreConfirmChange()">
<option value="">— Válassz alkalmazást —</option>
</select>
</div>
<div id="restore-type-info" class="restore-info" style="display:none;margin-bottom:0.5rem">
</div>
<div id="restore-no-snapshots" class="restore-warning" style="display:none;">
Még nincs mentés felhasználói adattal.
</div>
<div class="restore-warning">
<svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg> A visszaállítás felülírja az alkalmazás jelenlegi adatait a kiválasztott mentés állapotával.
Az alkalmazás a folyamat során automatikusan leáll és újraindul.
</div>
<div class="restore-confirm">
<label>
<input type="checkbox" id="restore-confirm-cb" onchange="onRestoreConfirmChange()">
Megértettem, visszaállítás indítása.
</label>
</div>
<div class="restore-actions">
<button type="button" class="btn btn-sm btn-danger" id="restore-btn" disabled onclick="submitRestore()">Visszaállítás indítása</button>
</div>
<div style="margin-top: 1rem; text-align: center; border-top: 1px solid var(--line); padding-top: 1rem;">
<a href="/import" class="btn btn-sm btn-outline">Importálás mentett csomagból (.fab)</a>
</div>
</div>
</div>
<div class="backup-tier-divider" style="margin-top:1.5rem"></div>
<h3 style="margin-bottom:.5rem">Megőrzött másolatok</h3>
<h4 style="margin:1rem 0 .5rem">Visszaállítás előtti másolatok</h4>
<p class="form-hint" style="margin-bottom:.75rem">Visszaállításkor a doboz előbb elmenti az alkalmazás akkori adatait. Ezt a másolatot a visszaállítás után 30 nappal magától törli.</p>
<div class="app-row-list" data-restore-copies="true">
<div class="app-row">
<img class="app-row-icon" src="/static/assets/vikunja-logo.svg" alt="" data-fallback="/static/app-placeholder.svg"
onerror="if(!this.dataset.step){this.dataset.step='1';this.src='\/static\/assets\/vikunja-logo.png';}else if(this.dataset.fallback&&this.dataset.step==='1'){this.dataset.step='2';this.src=this.dataset.fallback;}else{this.onerror=null;this.style.visibility='hidden';}">
<div class="app-row-text">
<span class="app-row-name">Vikunja</span>
<span class="app-row-secondary">2026-10-29 09:00 · 12 MB · 2026-11-28</span>
</div>
<div class="app-row-actions">
</div>
</div>
<details class="kept-delete" data-restore-copy="vikunja/20261029T080000Z">
<summary class="btn btn-xs btn-danger-outline">Törlés most</summary>
<form method="POST" action="/backup/restore-copy/delete" class="kept-delete-form" onsubmit="return deleteRestoreCopy(event, this)">
<input type="hidden" name="app" value="vikunja">
<input type="hidden" name="stamp" value="20261029T080000Z">
<p class="kept-delete-text">Vikunja 2026-10-29</p>
<label class="form-hint">Vikunja</label>
<input type="text" name="confirm" autocomplete="off" required>
<button type="submit" class="btn btn-sm btn-danger">Törlés most</button>
<p class="kept-delete-text" data-restore-copy-error="true" hidden></p>
</form>
</details>
<div class="app-row">
<img class="app-row-icon" src="/static/assets/immich-logo.svg" alt="" data-fallback="/static/app-placeholder.svg"
onerror="if(!this.dataset.step){this.dataset.step='1';this.src='\/static\/assets\/immich-logo.png';}else if(this.dataset.fallback&&this.dataset.step==='1'){this.dataset.step='2';this.src=this.dataset.fallback;}else{this.onerror=null;this.style.visibility='hidden';}">
<div class="app-row-text">
<span class="app-row-name">Immich Photos</span>
<span class="app-row-secondary">2026-10-20 10:00 · 2 GB</span>
</div>
<div class="app-row-actions">
<button type="button" class="btn btn-xs btn-danger-outline" disabled data-restore-copy-off="true">Törlés most</button>
</div>
</div>
<p class="form-hint" data-restore-copy-needed="true">hold: support decides</p>
</div>
<script>
function deleteRestoreCopy(ev, f){
ev.preventDefault();
var err = f.querySelector('[data-restore-copy-error]');
fetch(f.action, {method: 'POST', headers: Object.assign({'Accept': 'application/json'}, csrfHeaders()), body: new URLSearchParams(new FormData(f))})
.then(function(r){ return r.json(); })
.then(function(d){
if (d.ok) { location.href = d.redirect; return; }
err.textContent = d.error || ''; err.hidden = false;
})
.catch(function(e){ err.textContent = 'Hálózati hiba: ' + e; err.hidden = false; });
return false;
}
</script>
<div class="backup-section-card">
<h3>Hordozható mentéscsomag (.fab)</h3>
<p class="form-hint" style="margin:-0.25rem 0 1rem">Hordozható pillanatfelvétel — bárhol tárolhatod, és bármikor visszatöltheted egy meghajtóról. A folyamatos védelmet az 1–3. szintű mentés adja.</p>
<p class="form-hint">Nincs telepített alkalmazás.</p>
</div>
<script>
(function(){
var banner = document.getElementById('restore-banner');
if (!banner) return;
var sawRunning = false;
function opLabel(op){ return op === 'tier2-restore' ? 'Fájl-visszaállítás'
: op === 'offbox-restore' ? 'Távoli visszaállítás' : 'Visszaállítás'; }
function render(st){
if (st.running) {
sawRunning = true;
banner.className = 'flash';
banner.style.display = 'block';
banner.textContent = opLabel(st.op) + ' folyamatban' + (st.stack ? ': ' + st.stack : '') + '…';
return;
}
if (st.last && (sawRunning || st.last_recent)) {
banner.style.display = 'block';
if (st.last.ok) {
banner.className = 'flash flash-success';
banner.textContent = st.last.message || (opLabel(st.last.op) + ' kész.');
} else {
banner.className = 'flash flash-error';
banner.textContent = st.last.message || (opLabel(st.last.op) + ' sikertelen.');
}
}
}
function poll(){
fetch('/api/backup/restore-status', {headers: {'Accept':'application/json'}})
.then(function(r){ return r.json(); })
.then(function(j){ if (j && j.data) render(j.data); })
.catch(function(){});
}
poll();
setInterval(poll, 3000);
})();
var fabQueue = [];
function fabSetStatus(msg){ document.getElementById('fab-dl-status').textContent = msg; }
function fabPassword(){ var el = document.getElementById('fab-dl-password'); return el ? el.value : ''; }
function fabDownload(btn){
var stack = btn.getAttribute('data-stack');
fetch('/api/export/download/estimate?stack=' + encodeURIComponent(stack))
.then(function(r){ return r.json(); })
.then(function(j){
if (!j.ok) { fabSetStatus('Hiba: ' + (j.error || 'a becslés sikertelen')); return; }
var size = (j.data && j.data.total_size_human) || '?';
felhomConfirm(btn, 'A csomag becsült mérete: ' + size + '. Elindítod a letöltést? (Pillanatfelvétel a mostani állapotról.)', function(){
fabStart(stack, null);
});
})
.catch(function(){ fabSetStatus('Hiba: a becslés nem érhető el.'); });
}
function confirmDeleteVerifyCopy(btn){
var path = btn.getAttribute('data-copy-path') || '';
felhomConfirm(btn, 'Biztosan törlöd ezt az ellenőrző másolatot? ' + path + ' — a tényleges adataid változatlanok maradnak.', function(){
felhomConfirm(btn, 'UTOLSÓ MEGERŐSÍTÉS: a másolat véglegesen törlődik.', function(){
var f = btn.closest('form');
if (f) { if (f.requestSubmit) f.requestSubmit(); else f.submit(); }
});
});
}
function fabStart(stack, next){
fetch('/api/export/download/start', {method:'POST', headers:Object.assign({'Content-Type':'application/json'}, csrfHeaders()), body: JSON.stringify({stack_name: stack, password: fabPassword()})})
.then(function(r){ return r.json(); })
.then(function(j){
if (!j.ok) { fabSetStatus('Hiba (' + stack + '): ' + (j.error || 'az export nem indult el')); if (next) next(); return; }
fabSetStatus('Csomag készítése: ' + stack + '…');
fabPoll(stack, next);
})
.catch(function(){ fabSetStatus('Hiba: az export nem indult el.'); if (next) next(); });
}
function fabPoll(stack, next){
var iv = setInterval(function(){
fetch('/api/export/status').then(function(r){ return r.json(); }).then(function(j){
if (!j || j.running || !j.done) return;
clearInterval(iv);
if (j.error) { fabSetStatus('Hiba (' + stack + '): ' + j.error); if (next) next(); return; }
var base = (j.output_path || '').split('/').pop();
if (!base) { fabSetStatus('Hiba: a csomag útvonala hiányzik.'); if (next) next(); return; }
fabSetStatus('Letöltés: ' + base + (j.output_size ? ' (' + j.output_size + ')' : ''));
window.location.href = '/api/export/download?file=' + encodeURIComponent(base);
if (next) setTimeout(next, 3000);
}).catch(function(){});
}, 2000);
}
function fabDownloadAll(){
var btns = document.querySelectorAll('.fab-dl-btn');
fabQueue = Array.prototype.map.call(btns, function(b){ return b.getAttribute('data-stack'); });
fabRunQueue();
}
function fabRunQueue(){
var stack = fabQueue.shift();
if (!stack) { fabSetStatus('Minden csomag elkészült.'); return; }
fabStart(stack, fabRunQueue);
}
var huDays = ['vasárnap', 'hétfő', 'kedd', 'szerda', 'csütörtök', 'péntek', 'szombat'];
function formatSnapshot(s) {
var t = new Date(s.time);
var pad = function(n) { return n < 10 ? '0' + n : '' + n; };
var label = fhIsoDate(t.getFullYear() + '-' + pad(t.getMonth()+1) + '-' + pad(t.getDate())) +
' ' + huDays[t.getDay()] + ' ' + pad(t.getHours()) + ':' + pad(t.getMinutes()) +
' (' + s.short_id + ')';
var tierLabel = s.tier === 2 ? '2. szint' : '1. szint';
if (s.drive_label) {
label += ' — ' + tierLabel + ', ' + s.drive_label;
} else {
label += ' — ' + tierLabel;
}
return label;
}
function onRestoreAppChange() {
var sel = document.getElementById('restore-app');
var appName = sel.value;
var snapSel = document.getElementById('restore-snapshot');
var noSnaps = document.getElementById('restore-no-snapshots');
var typeInfo = document.getElementById('restore-type-info');
document.getElementById('restore-confirm-cb').checked = false;
document.getElementById('restore-btn').disabled = true;
noSnaps.style.display = 'none';
typeInfo.style.display = 'none';
if (!appName) {
snapSel.innerHTML = '<option value="">— Válassz alkalmazást —</option>';
return;
}
var opt = sel.options[sel.selectedIndex];
var hasHDD = opt.getAttribute('data-has-hdd') === 'true';
var hasDB = opt.getAttribute('data-has-db') === 'true';
var hasVolumes = opt.getAttribute('data-has-volumes') === 'true';
if (hasHDD || hasVolumes) {
typeInfo.innerHTML = 'Teljes visszaállítás: adatbázis + konfiguráció + felhasználói adatok a kiválasztott pillanatképből.';
typeInfo.className = 'restore-info';
} else if (hasDB) {
typeInfo.innerHTML = 'Adatbázis és konfiguráció visszaállítása — az alkalmazásnak nincs külön felhasználói adata.';
typeInfo.className = 'restore-info restore-info-partial';
} else {
typeInfo.innerHTML = 'Csak konfiguráció visszaállítása (compose fájlok, beállítások).';
typeInfo.className = 'restore-info restore-info-partial';
}
typeInfo.style.display = 'block';
snapSel.innerHTML = '<option value="">— Betöltés... —</option>';
fetch('/api/backup/snapshots?stack=' + encodeURIComponent(appName))
.then(function(r) { return r.json(); })
.then(function(data) {
snapSel.innerHTML = '<option value="">— Válassz —</option>';
if (data.ok && data.data && data.data.length > 0) {
var tier1 = data.data.filter(function(s) { return s.tier !== 2; });
var tier2 = data.data.filter(function(s) { return s.tier === 2; });
if (tier1.length > 0) {
var grp1 = document.createElement('optgroup');
grp1.label = '1. szint — Helyi mentés (ajánlott)';
tier1.forEach(function(s) {
var o = document.createElement('option');
o.value = s.short_id;
o.textContent = formatSnapshot(s);
grp1.appendChild(o);
});
snapSel.appendChild(grp1);
}
if (tier2.length > 0) {
var grp2 = document.createElement('optgroup');
grp2.label = '2. szint — Másodlagos másolat';
tier2.forEach(function(s) {
var o = document.createElement('option');
o.value = s.short_id;
o.textContent = formatSnapshot(s);
grp2.appendChild(o);
});
snapSel.appendChild(grp2);
}
} else {
snapSel.innerHTML = '<option value="">— Nincs elérhető mentés —</option>';
noSnaps.style.display = 'block';
}
});
}
function onRestoreConfirmChange() {
var cb = document.getElementById('restore-confirm-cb');
var app = document.getElementById('restore-app').value;
var snap = document.getElementById('restore-snapshot').value;
document.getElementById('restore-btn').disabled = !(cb.checked && app && snap);
}
function submitRestore() {
var app = document.getElementById('restore-app').value;
var snap = document.getElementById('restore-snapshot').value;
if (!app || !snap) return;
var btn = document.getElementById('restore-btn');
btn.disabled = true;
btn.textContent = 'Visszaállítás folyamatban...';
var form = document.createElement('form');
form.method = 'POST';
form.action = '/backup/restore';
var fc = document.createElement('input');
fc.type = 'hidden'; fc.name = '_csrf';
fc.value = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
form.appendChild(fc);
var f1 = document.createElement('input');
f1.type = 'hidden'; f1.name = 'stack_name'; f1.value = app;
form.appendChild(f1);
var f2 = document.createElement('input');
f2.type = 'hidden'; f2.name = 'snapshot_id'; f2.value = snap;
form.appendChild(f2);
document.body.appendChild(form);
form.submit();
}
</script>
</main>
<script>
(function(){
var burger=document.querySelector('.nav-burger');
var sidebar=document.getElementById('sidebar');
var backdrop=document.querySelector('.nav-backdrop');
if(!burger||!sidebar||!backdrop)return;
function setOpen(open){
sidebar.classList.toggle('is-open',open);
document.body.classList.toggle('nav-open',open);
backdrop.hidden=!open;
burger.setAttribute('aria-expanded',open?'true':'false');
}
burger.addEventListener('click',function(){setOpen(!sidebar.classList.contains('is-open'));});
backdrop.addEventListener('click',function(){setOpen(false);});
document.addEventListener('keydown',function(e){
if(e.key==='Escape'&&sidebar.classList.contains('is-open'))setOpen(false);
});
})();
document.addEventListener('click', function(e) {
if (e.target.closest('a, button, .btn, input, select, textarea, .app-row-actions, .stack-detail-actions')) return;
var card = e.target.closest('[data-href]');
if (card) window.location.href = card.dataset.href;
});
async function checkBeforeDeploy(e, name) {
try {
var resp = await fetch('/api/stacks/' + name);
var data = await resp.json();
if (data.ok && data.data && data.data.deployed) {
e.preventDefault();
showAlert('Ez az alkalmazás már telepítve van.');
window.location.reload();
return false;
}
} catch(err) {}
return true;
}
async function syncTemplates() {
const btn = document.getElementById('sync-btn');
const toast = document.getElementById('sync-toast');
if (!btn) return;
const origText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '↻ Frissítés...';
btn.classList.add('loading');
try {
const resp = await fetch('/api/sync', {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders())
});
const data = await resp.json();
if (toast) {
toast.textContent = data.ok ? (data.message || 'Sablonok frissítve') : ('Hiba: ' + (data.error || 'Ismeretlen hiba'));
toast.className = 'sync-toast ' + (data.ok ? 'sync-toast-ok' : 'sync-toast-err');
toast.style.display = 'block';
setTimeout(function() { toast.style.display = 'none'; }, 5000);
}
if (data.ok && data.data && (data.data.new_apps && data.data.new_apps.length > 0 || data.data.updated && data.data.updated.length > 0)) {
setTimeout(function() { window.location.reload(); }, 1500);
}
} catch (err) {
if (toast) {
toast.textContent = 'Hálózati hiba: ' + err.message;
toast.className = 'sync-toast sync-toast-err';
toast.style.display = 'block';
setTimeout(function() { toast.style.display = 'none'; }, 5000);
}
}
btn.innerHTML = origText;
btn.disabled = false;
btn.classList.remove('loading');
}
async function stackAction(event, name, action) {
const btn = event.currentTarget;
const origText = btn.textContent;
btn.disabled = true;
btn.textContent = 'Folyamatban...';
btn.classList.add('loading');
try {
const resp = await fetch('/api/stacks/' + name + '/' + action, {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders())
});
const data = await resp.json();
if (!data.ok) {
showAlert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
btn.textContent = origText;
btn.disabled = false;
btn.classList.remove('loading');
return;
}
if (action === 'update') {
followUpdate(name, btn);
return;
}
window.location.reload();
} catch (err) {
showAlert('Hálózati hiba: ' + err.message);
btn.textContent = origText;
btn.disabled = false;
btn.classList.remove('loading');
}
}
function followUpdate(name, btn) {
var started = Date.now();
var timer = setInterval(async function() {
if (Date.now() - started > 30 * 60 * 1000) { clearInterval(timer); window.location.reload(); return; }
try {
var r = await fetch('/api/stacks/' + name);
var d = await r.json();
if (!d.ok || !d.data) return;
if (d.data.update_phase_label) btn.textContent = d.data.update_phase_label;
if (!d.data.updating) { clearInterval(timer); window.location.reload(); }
} catch (e) {}
}, 3000);
}
async function deleteOrphanStack(name) {
var modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.id = 'delete-modal';
modal.innerHTML = '<div class="modal-card"><h3>Betöltés...</h3></div>';
modal.addEventListener('click', function(e) { if (e.target === modal) closeDeleteModal(); });
document.body.appendChild(modal);
try {
var resp = await fetch('/api/stacks/' + name + '/hdd-data');
var data = await resp.json();
var hddInfo = '';
var checkboxHTML = '';
if (data.ok && data.data && data.data.has_hdd_data) {
hddInfo = '<div class="modal-hdd-info"><strong>Felhasználói adatok a merevlemezen:</strong>';
data.data.hdd_paths.forEach(function(p) {
hddInfo += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
});
hddInfo += '</div>';
checkboxHTML = '<label class="modal-checkbox"><input type="checkbox" id="delete-hdd-check"> Felhasználói adatok törlése a merevlemezről</label>';
}
if (data.ok && data.data && data.data.userdata_kept && data.data.userdata_kept.length > 0) {
var udPaths = '';
data.data.userdata_kept.forEach(function(p) { udPaths += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>'; });
hddInfo += '<div class="modal-section" data-userdata-kept="true"><strong>A fájljaid ezekben a mappákban megmaradnak — a fájlböngészőben látod őket:</strong>' + udPaths + '</div>';
}
modal.querySelector('.modal-card').innerHTML =
'<h3>Alkalmazás törlése: ' + name + '</h3>' +
'<p style="color:var(--text-2);font-size:.9rem;margin-bottom:.75rem">Ez a művelet eltávolítja a konténereket, a köteteket és a konfigurációs fájlokat.</p>' +
'<div class="alert alert-warning" style="margin-bottom:.75rem">Ez a művelet nem visszavonható!</div>' +
hddInfo + checkboxHTML +
'<div class="modal-actions">' +
'<button class="btn btn-outline" onclick="closeDeleteModal()">Mégsem</button>' +
'<button class="btn btn-danger" id="confirm-delete-btn" onclick="confirmDelete(\'' + name + '\')">Törlés</button>' +
'</div>';
} catch (err) {
modal.querySelector('.modal-card').innerHTML =
'<h3>Hiba</h3><p style="color:var(--text-2)">Nem sikerült lekérni az adatokat: ' + err.message + '</p>' +
'<div class="modal-actions"><button class="btn btn-outline" onclick="closeDeleteModal()">Bezárás</button></div>';
}
}
function closeDeleteModal() {
var modal = document.getElementById('delete-modal');
if (modal) modal.remove();
}
async function confirmDelete(name) {
var btn = document.getElementById('confirm-delete-btn');
var checkbox = document.getElementById('delete-hdd-check');
var removeHDD = checkbox ? checkbox.checked : false;
btn.disabled = true;
btn.textContent = 'Törlés folyamatban...';
try {
var resp = await fetch('/api/stacks/' + name, {
method: 'DELETE',
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify({remove_hdd_data: removeHDD})
});
var data = await resp.json();
if (data.ok) {
var modal = document.getElementById('delete-modal');
var removedInfo = '';
if (data.data && data.data.hdd_paths_removed && data.data.hdd_paths_removed.length > 0) {
removedInfo = '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Törölt adatok: ' + data.data.hdd_paths_removed.join(', ') + '</p>';
}
var preservedInfo = '';
if (data.data && data.data.hdd_paths_preserved && data.data.hdd_paths_preserved.length > 0) {
preservedInfo = '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Megőrzött adatok: ' + data.data.hdd_paths_preserved.join(', ') + '</p>';
}
if (data.data && data.data.hdd_note) {
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">' + data.data.hdd_note + '</p>';
}
if (data.data && data.data.userdata_kept && data.data.userdata_kept.length > 0) {
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem" data-userdata-kept="true">A fájljaid ezekben a mappákban megmaradnak — a fájlböngészőben látod őket: ' + data.data.userdata_kept.join(', ') + '</p>';
}
modal.querySelector('.modal-card').innerHTML =
'<h3>Sikeresen törölve!</h3>' +
'<p style="color:var(--text-2)">Az alkalmazás (' + name + ') törölve lett.</p>' +
removedInfo + preservedInfo +
'<div class="modal-actions"><button class="btn btn-primary" onclick="window.location.href=\'/stacks\'">Bezárás</button></div>';
} else {
showAlert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
btn.disabled = false;
btn.textContent = 'Törlés';
}
} catch (err) {
showAlert('Hálózati hiba: ' + err.message);
btn.disabled = false;
btn.textContent = 'Törlés';
}
}
async function removeStack(name) {
var modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.id = 'remove-modal';
modal.innerHTML = '<div class="modal-card"><h3>Betöltés...</h3></div>';
modal.addEventListener('click', function(e) { if (e.target === modal) closeRemoveModal(); });
document.body.appendChild(modal);
try {
var [hddResp, backupResp] = await Promise.all([
fetch('/api/stacks/' + name + '/hdd-data').then(function(r) { return r.json(); }),
fetch('/api/stacks/' + name + '/backup-data').then(function(r) { return r.json(); })
]);
var sections = '';
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
if (keepOnly) {
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.</div>';
}
sections += '<div class="modal-section">' +
'<strong>Mindig törlődik:</strong>' +
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
'<li>Docker kötetek (adatbázis, alkalmazás konfiguráció)</li>' +
'<li>Telepítési konfiguráció (app.yaml)</li>' +
'<li>Másodlagos mentés ütemezése</li>' +
'</ul></div>';
var hddCheckbox = '';
if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) {
var hddPaths = '';
hddResp.data.hdd_paths.forEach(function(p) {
hddPaths += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
});
sections += '<div class="modal-section">' +
'<strong>Felhasználói adatok a merevlemezen:</strong>' + hddPaths +
'<label class="modal-checkbox"><input type="checkbox" id="remove-hdd-check"> Felhasználói adatok törlése</label>' +
'<div class="alert alert-info" style="margin-top:.5rem;font-size:.8rem" id="remove-hdd-keep-warning">' +
'Ha újratelepíted az alkalmazást, az adatokat újra importálnod kell, mivel az adatbázis törlődik. A megtartott adatok a továbbiakban NEM lesznek automatikusan mentve.' +
'</div></div>';
hddCheckbox = '\x3Cscript>document.getElementById("remove-hdd-check").addEventListener("change",function(){document.getElementById("remove-hdd-keep-warning").style.display=this.checked?"none":"";});<\/script>';
}
var backupCheckbox = '';
if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) {
var bkPaths = '';
backupResp.data.backup_paths.forEach(function(p) {
if (p.exists) bkPaths += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
});
if (bkPaths) {
sections += '<div class="modal-section">' +
'<strong>Mentési adatok:</strong>' + bkPaths +
'<label class="modal-checkbox"><input type="checkbox" id="remove-backup-check"> Mentési adatok törlése</label>' +
'<div class="alert alert-info" style="margin-top:.5rem;font-size:.8rem">' +
'Az éjszakai restic pillanatképek nem törölhetők egyenként — a megőrzési szabályok szerint automatikusan elavulnak.' +
'</div></div>';
}
}
if (hddResp.ok && hddResp.data && hddResp.data.userdata_kept && hddResp.data.userdata_kept.length > 0) {
var udPaths = '';
hddResp.data.userdata_kept.forEach(function(p) { udPaths += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>'; });
sections += '<div class="modal-section" data-userdata-kept="true"><strong>A fájljaid ezekben a mappákban megmaradnak — a fájlböngészőben látod őket:</strong>' + udPaths + '</div>';
}
modal.querySelector('.modal-card').innerHTML =
'<h3>Alkalmazás eltávolítása: ' + name + '</h3>' +
'<p style="color:var(--text-2);font-size:.9rem;margin-bottom:.75rem">Az alkalmazás visszaáll "Nincs telepítve" állapotba. A sablon megmarad, újratelepíthető.</p>' +
'<div class="alert alert-warning" style="margin-bottom:.75rem">Ez a művelet nem visszavonható!</div>' +
sections +
'<div class="modal-actions">' +
'<button class="btn btn-outline" onclick="closeRemoveModal()">Mégsem</button>' +
'<button class="btn btn-danger" id="confirm-remove-btn" onclick="confirmRemoveStack(\'' + name + '\')">Eltávolítás</button>' +
'</div>' + hddCheckbox;
} catch (err) {
modal.querySelector('.modal-card').innerHTML =
'<h3>Hiba</h3><p style="color:var(--text-2)">Nem sikerült lekérni az adatokat: ' + err.message + '</p>' +
'<div class="modal-actions"><button class="btn btn-outline" onclick="closeRemoveModal()">Bezárás</button></div>';
}
}
function closeRemoveModal() {
var modal = document.getElementById('remove-modal');
if (modal) modal.remove();
}
async function confirmRemoveStack(name) {
var btn = document.getElementById('confirm-remove-btn');
var hddCheck = document.getElementById('remove-hdd-check');
var backupCheck = document.getElementById('remove-backup-check');
var removeHDD = hddCheck ? hddCheck.checked : false;
var removeBackups = backupCheck ? backupCheck.checked : false;
btn.disabled = true;
btn.textContent = 'Eltávolítás folyamatban...';
try {
var resp = await fetch('/api/stacks/' + name + '/remove', {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify({remove_hdd_data: removeHDD, remove_backups: removeBackups})
});
var data = await resp.json();
if (data.ok) {
var modal = document.getElementById('remove-modal');
var removedInfo = '';
if (data.data && data.data.hdd_paths_removed && data.data.hdd_paths_removed.length > 0) {
removedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Törölt adatok: ' + data.data.hdd_paths_removed.join(', ') + '</p>';
}
if (data.data && data.data.backup_paths_removed && data.data.backup_paths_removed.length > 0) {
removedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Törölt mentések: ' + data.data.backup_paths_removed.join(', ') + '</p>';
}
var preservedInfo = '';
if (data.data && data.data.hdd_paths_preserved && data.data.hdd_paths_preserved.length > 0) {
preservedInfo = '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Megőrzött adatok: ' + data.data.hdd_paths_preserved.join(', ') + '</p>';
}
if (data.data && data.data.hdd_note) {
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">' + data.data.hdd_note + '</p>';
}
if (data.data && data.data.userdata_kept && data.data.userdata_kept.length > 0) {
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem" data-userdata-kept="true">A fájljaid ezekben a mappákban megmaradnak — a fájlböngészőben látod őket: ' + data.data.userdata_kept.join(', ') + '</p>';
}
if (data.data && data.data.backup_paths_refused && data.data.backup_paths_refused.length > 0) {
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Nem törölt mentések: ' + data.data.backup_paths_refused.join('; ') + '</p>';
}
modal.querySelector('.modal-card').innerHTML =
'<h3>Sikeresen eltávolítva!</h3>' +
'<p style="color:var(--text-2)">Az alkalmazás (' + name + ') eltávolítva. Újratelepíthető a Telepítés gombbal.</p>' +
removedInfo + preservedInfo +
'<div class="modal-actions"><button class="btn btn-primary" onclick="window.location.href=\'/stacks\'">Bezárás</button></div>';
} else {
showAlert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
btn.disabled = false;
btn.textContent = 'Eltávolítás';
}
} catch (err) {
showAlert('Hálózati hiba: ' + err.message);
btn.disabled = false;
btn.textContent = 'Eltávolítás';
}
}
</script>
</body>
</html>
+11
View File
@@ -5,6 +5,7 @@
"api.escrow.confirm_expired": "BORN AS A KEY (R-1151, 2026-10-19) -- a NEW sentence, never a Go literal: the wizard's typed-back confirm answer (agent >= 0.160.0 keeps the new package until the words come back). Pinned by TestR1151_ConfirmAnswersInTheBoxLanguage.",
"api.escrow.confirm_lost": "BORN AS A KEY (R-1151, 2026-10-19) -- a NEW sentence, never a Go literal: the wizard's typed-back confirm answer (agent >= 0.160.0 keeps the new package until the words come back). Pinned by TestR1151_ConfirmAnswersInTheBoxLanguage.",
"api.escrow.confirm_store_failed": "BORN AS A KEY (R-1151, 2026-10-19) -- a NEW sentence, never a Go literal: the wizard's typed-back confirm answer (agent >= 0.160.0 keeps the new package until the words come back). Pinned by TestR1151_ConfirmAnswersInTheBoxLanguage.",
"api.escrow.confirm_store_unknown": "BORN AS A KEY (R-1254, 2026-10-28 night) -- a NEW sentence, never a Go literal: the confirm's outcome is unknown (agent 504 or the call itself failed) -- no promise about the previous code. Pinned by TestR1254_AnUnknownConfirmOutcomePromisesNothing and TestR1151_ConfirmAnswersInTheBoxLanguage.",
"waiting.title": "BORN AS A KEY (R-554, 2026-10-12) -- the title of the page an unconfigured box shows while it retries its configuration, replacing the deleted setup wizard. Pinned by internal/bootstrap TestWaitForConfig_ServesWaitingPageThenStarts.",
"waiting.body": "BORN AS A KEY (R-554, 2026-10-12) -- the body of that waiting page. Pinned by internal/bootstrap TestWaitForConfig_ServesWaitingPageThenStarts.",
"recovery_info.body": "BORN AS A KEY (R-554, 2026-10-12, `09` §3 decision 222) -- the NEW text of recovery-info.txt, pointing at the dashboard's Restore page and the recovery code instead of the deleted setup wizard. The operator approves the Hungarian before release. Pinned by internal/recovery TestRecoveryInfo_PointsAtDashboardNotWizard.",
@@ -309,6 +310,16 @@
"kept.reason.drive_away": "BORN AS A KEY (D25, operator ruling 2026-10-21; R-1175/R-1181) -- kept second-drive copies: their list line, reasons, the emptied-folder note and the „Eltávolítás” flashes. Pinned by internal/web/kept_copies_test.go and internal/backup/r1175_shares_kept_test.go.",
"kept.reason.drive_gone": "BORN AS A KEY (D25, operator ruling 2026-10-21; R-1175/R-1181) -- kept second-drive copies: their list line, reasons, the emptied-folder note and the „Eltávolítás” flashes. Pinned by internal/web/kept_copies_test.go and internal/backup/r1175_shares_kept_test.go.",
"kept.reason.folder_missing": "BORN AS A KEY (D25, operator ruling 2026-10-21; R-1175/R-1181) -- kept second-drive copies: their list line, reasons, the emptied-folder note and the „Eltávolítás” flashes. Pinned by internal/web/kept_copies_test.go and internal/backup/r1175_shares_kept_test.go.",
"flash.restore_copy.deleted": "BORN AS A KEY (D62, operator ruling 2026-10-29) -- restore copies in the kept-copies list: the line, the warning, the delete's refusals and flash. Pinned by internal/web/d62_restore_copies_test.go.",
"restore_copy.app_busy": "BORN AS A KEY (D62, operator ruling 2026-10-29) -- restore copies in the kept-copies list: the line, the warning, the delete's refusals and flash. Pinned by internal/web/d62_restore_copies_test.go.",
"restore_copy.auto_delete": "BORN AS A KEY (D62, operator ruling 2026-10-29) -- restore copies in the kept-copies list: the line, the warning, the delete's refusals and flash. Pinned by internal/web/d62_restore_copies_test.go.",
"restore_copy.failed": "BORN AS A KEY (D62, operator ruling 2026-10-29) -- restore copies in the kept-copies list: the line, the warning, the delete's refusals and flash. Pinned by internal/web/d62_restore_copies_test.go.",
"restore_copy.not_found": "BORN AS A KEY (D62, operator ruling 2026-10-29) -- restore copies in the kept-copies list: the line, the warning, the delete's refusals and flash. Pinned by internal/web/d62_restore_copies_test.go.",
"restore_copy.restored_on": "BORN AS A KEY (D62, operator ruling 2026-10-29) -- restore copies in the kept-copies list: the line, the warning, the delete's refusals and flash. Pinned by internal/web/d62_restore_copies_test.go.",
"restore_copy.warning": "BORN AS A KEY (D62, operator ruling 2026-10-29) -- restore copies in the kept-copies list: the line, the warning, the delete's refusals and flash. Pinned by internal/web/d62_restore_copies_test.go.",
"restore_copy.needed.hold": "BORN AS A KEY (D62, operator ruling 2026-10-29) -- restore copies in the kept-copies list: the line, the warning, the delete's refusals and flash. Pinned by internal/web/d62_restore_copies_test.go.",
"restore_copy.needed.putback": "BORN AS A KEY (D62, operator ruling 2026-10-29) -- restore copies in the kept-copies list: the line, the warning, the delete's refusals and flash. Pinned by internal/web/d62_restore_copies_test.go.",
"restore_copy.needed.unknown": "BORN AS A KEY (D62, operator ruling 2026-10-29) -- restore copies in the kept-copies list: the line, the warning, the delete's refusals and flash. Pinned by internal/web/d62_restore_copies_test.go.",
"note.tier2.emptied_kept": "BORN AS A KEY (D25, operator ruling 2026-10-21; R-1175/R-1181) -- kept second-drive copies: their list line, reasons, the emptied-folder note and the „Eltávolítás” flashes. Pinned by internal/web/kept_copies_test.go and internal/backup/r1175_shares_kept_test.go.",
"flash.kept.removed": "BORN AS A KEY (D25, operator ruling 2026-10-21; R-1175/R-1181) -- kept second-drive copies: their list line, reasons, the emptied-folder note and the „Eltávolítás” flashes. Pinned by internal/web/kept_copies_test.go and internal/backup/r1175_shares_kept_test.go.",
"flash.kept.needs_confirmation": "BORN AS A KEY (D25, operator ruling 2026-10-21; R-1175/R-1181) -- kept second-drive copies: their list line, reasons, the emptied-folder note and the „Eltávolítás” flashes. Pinned by internal/web/kept_copies_test.go and internal/backup/r1175_shares_kept_test.go.",