v0.205.0 — a run that skipped an app the customer selected is not successful (R-234)
gates / gates (push) Successful in 21s
gates / gates (push) Successful in 21s
THE VERDICT. The R-203 block already said "a warning beside a success is read as a success" and applied it to ONE of the two shapes it describes: an app missing a declared mandatory FOLDER made the run incomplete, while an app skipped ENTIRELY still reported ok. Both do now. Which skips count, decided by measurement: selected+deployed with no recovery unit YES; selected but NOT deployed no (named, with what to do — a box left amber by an app somebody removed is a status nobody reads); disconnected/decommissioned drive no (own signal); nothing selected no. LastSuccess and SnapshotCount still record what WAS captured. THE FILED MECHANISM WAS NOT THE MEASURED CAUSE, and saying so is the point. §3 stated that toggling an app on leaves it without a bundle so the first run skips it. Measured on demo-hp: the run's own pre-dump phase calls captureAllRecoveryUnits for every DEPLOYED stack, through admitApp, before the push — a unit moved aside was RECREATED and the run reported ok. That state does not survive a run. What actually produced the 2026-08-06 sequence: the manual run was dropped by the single-flight while an earlier run was still going. runOffboxBackup returned nil, the handler had already answered "A tavoli mentes elindult", and the card then showed the PREVIOUS run's green verdict — read as covering the app just selected. The decision is now taken synchronously in the handler and a dropped request says so. The nightly path still returns nil on purpose: nobody asked, and it retries. §7.3 measured before deciding: CaptureRecoveryUnit writes a few KB of compose + manifest, only ENUMERATES dumps rather than creating them, is idempotent and does NOT stop the app — and already runs inside the off-site run. So there is no wait to remove for a deployed app and NOTHING was built. 28 packages ok, 9/9 gates. Four red-proofs, each asserted to have applied. Fixture note: the shared provider's ListDeployedStacks returned nil, so Scenario A first passed for the wrong reason; fixed with an opt-in deployed set that defaults to nil.
This commit is contained in:
@@ -1,91 +1,115 @@
|
||||
# REPORT — v0.204.0: the restore list is keyed on the store (R-237), and the size gate stops refusing in silence (R-238)
|
||||
# REPORT — v0.205.0: a backup that skipped an app the customer chose is not „Rendben" (R-234)
|
||||
|
||||
2026-08-06. Controller **v0.203.0 → v0.204.0**. MinAgent unchanged (**0.127.0**) — nothing here needs
|
||||
a new agent capability. `felhom-agent` untouched.
|
||||
2026-08-06. Controller **v0.204.0 → v0.205.0**. MinAgent unchanged (**0.127.0**). `felhom-agent`,
|
||||
`app-catalog-felhom.eu` untouched. No hub change.
|
||||
|
||||
## R-238 — classified: a HARNESS ARTIFACT, with a real residue that is fixed
|
||||
## The correction that outranks the task
|
||||
|
||||
The two runs diverge at one parameter, and the state at that point is quoted rather than inferred:
|
||||
§3 stated the mechanism as established: *"the off-site copy sends the local recovery bundle an app
|
||||
already has; switching an app on does not create one, so the first run after the toggle finds nothing
|
||||
to send."* **Measured on demo-hp, that is not what the code does.**
|
||||
|
||||
- `POST /backup/offbox/restore` with `mode=full` and **no** `confirm=1` is **step 1 of a deliberate
|
||||
two-step** (`offbox_handlers.go:314`). It computes size + headroom via `OffboxRestorePrepareFull`,
|
||||
**starts no job**, and redirects to
|
||||
`restoreWizardPath(app) + "&full_prep=<app>&full_size=<size>"`.
|
||||
- `deriveWizardStep` (`restore_wizard.go`) reveals the commit **only** when
|
||||
`in.FullPrepApp == in.App`, sourced from `?full_prep=`.
|
||||
- The endpoint-level driver posted step 1 and then re-fetched the wizard **without** that parameter.
|
||||
The pure function therefore returned the **intent** step — correctly. `restore-status.last == null`
|
||||
is likewise **correct**: step 1 starts no job by design.
|
||||
- The operator's browser run followed the redirect, saw the confirm, pressed „Igen" twice, and the
|
||||
restore completed.
|
||||
The off-site run's own **pre-dump phase** calls `captureAllRecoveryUnits()`, which writes a unit for
|
||||
**every deployed stack** — through `admitApp` — *before* the per-app push loop. I moved
|
||||
`calibre-web`'s unit aside (never deleted) on demo-hp and triggered a run through the real endpoint:
|
||||
|
||||
**So the button is not dead, and the wizard was not re-keyed.** The precedence rules exist so a stale
|
||||
`?full_prep=` can never resurrect a commit button mid-restore, and they were left alone.
|
||||
```
|
||||
BEFORE : units = calibre-web, opengist, privatebin status=ok snapshots=9
|
||||
moved : calibre-web unit → .calibre-web.R234-aside
|
||||
RUN : POST /backup/offbox/run → 302, finished ~90 s, status=ok
|
||||
AFTER : status=ok snapshots=9 warning=(none)
|
||||
unit : /mnt/sys_drive/felhom-data/backups/primary/calibre-web → RECREATED
|
||||
```
|
||||
|
||||
**The residue, which is real whoever triggers it:** neither branch of step 1 wrote anything to the
|
||||
log. `offboxRedirectTo` only flashes to the page. A customer refused a disaster restore — **including
|
||||
a refusal by the headroom gate** — left **no trace on the box at all**. Fixed: the refusal logs
|
||||
`[WARN] … full-restore preparation REFUSED for <app> (no job started): <err>`, the success logs
|
||||
`[INFO] … full-restore prepared for <app> (size N) — awaiting the customer's confirm; no restore has
|
||||
started`, and the concurrent-op refusal logs too.
|
||||
So "selected but no bundle yet" **does not survive a run** for a deployed app. The filed mechanism
|
||||
could not have produced the 2026-08-06 sequence.
|
||||
|
||||
## R-237 — the restore list, rebuilt on the store
|
||||
**What did.** `POST /backup/offbox/run` launched the run in a goroutine and immediately answered
|
||||
„A távoli mentés elindult". Inside, `acquireRunning` refused (a run was already in flight) and
|
||||
`runOffboxBackup` returned **nil** — no error, no signal. The card then showed the **previous** run's
|
||||
„✓ Rendben · 1 pillanatkép", read as covering the app just selected. It did not: the restore refused
|
||||
for that app minutes later, and a third run carried it. That is R-234's real cause, and it is the same
|
||||
family — a skip that reached a log and not a verdict.
|
||||
|
||||
`buildOffsiteRestoreRows` (new, pure) merges `OffsiteInventoryList` (the repository's own snapshot
|
||||
tags — the existing R-193 reader) with the installed set. `resolveOffsiteRestoreApp` replaces
|
||||
`resolveWizardApp`'s toggle requirement.
|
||||
## Live validation (§12)
|
||||
|
||||
Every §7 case, and the Hungarian as rendered:
|
||||
| # | what | observable |
|
||||
|---|---|---|
|
||||
| 1 | selected app with no unit → run | the run **recreated the unit** and reported ok — the measurement above. The verdict change is exercised by the run-level tests, because production cannot easily be held in that state |
|
||||
| 2 | counters intact | `snapshot_count` 9 → 9, `last_success` advanced to `2026-08-06T19:34:27Z` — what was captured is still recorded |
|
||||
| 3 | the third-run behaviour arriving sooner | **not applicable as filed**: there is no wait for a deployed app; the unit is created by the run itself (§7.3) |
|
||||
| 4 | a box with nothing selected | unchanged — Scenario D is pinned by test, and demo-hp's other apps stayed `ok` |
|
||||
|
||||
| case | rendered |
|
||||
|---|---|
|
||||
| snapshot present, app not installed | restore offered + „Nincs telepítve — a visszaállítás előbb újratelepíti." |
|
||||
| installed, no snapshot | „Nincs mentése a távoli tárolóban — nincs mit visszaállítani." |
|
||||
| store unreadable | „Nem tudjuk elolvasni a távoli tárolót, ezért **nem tudjuk, mi van benne**. Ez nem azt jelenti, hogy üres — próbáld újra később, vagy jelezd az üzemeltetőnek." + „Nem tudjuk, van-e mentése — a tárolót nem sikerült elolvasni." **and the action stays offered** |
|
||||
| no target yet | „A távoli tároló kapcsolódási adatai még nem érkeztek meg ehhez a géphez, ezért még nem tudjuk megmutatni, mi van benne. Ez magától rendeződik." |
|
||||
| store empty | „A távoli tároló üres — nincs mit visszaállítani." |
|
||||
| app under a different name | **not guessed** — it lists under the tag the store holds, and if nothing is installed under that name the row says so. No fuzzy matching. |
|
||||
**Access note:** demo-hp's dashboard password had been changed by the customer claim, so I reset it
|
||||
through the documented `--print-reset-code` escape hatch (code streamed file→file, extracted by shape,
|
||||
shredded; the new password is a 24-char generated value in a `0600` file on DooPlex). That is a
|
||||
deliberate change to a Tier-0 demo box, recorded here.
|
||||
|
||||
Wizard refusals also changed: „Ehhez az alkalmazáshoz nincs mentés a távoli tárolóban." and, when the
|
||||
store could not be read, „Nem tudjuk elolvasni a távoli tárolót, ezért nem tudjuk, van-e benne mentés
|
||||
ehhez az alkalmazáshoz." Both log an INFO naming the app and the store state.
|
||||
## §7.2 — which skips count, as decided
|
||||
|
||||
`felhom-offbox` and `_shares` are excluded from the app list.
|
||||
| skip | counts? | why |
|
||||
|---|---|---|
|
||||
| selected + **deployed**, no recovery unit | **YES** → `incomplete` | the app the customer chose is not in the snapshot at all |
|
||||
| selected but **not deployed** | **no** — named, with what to do | a box left amber forever by an app somebody removed is a status nobody reads |
|
||||
| drive disconnected / decommissioned | **no** | it has its own card and its own signal; re-reporting it here would double-count |
|
||||
| nothing selected at all | **no** | unchanged: the existing „nincs mentésre jelölt alkalmazás" notice |
|
||||
|
||||
## Tests
|
||||
## §7.3 — the measurement, and the decision it forced
|
||||
|
||||
`CaptureRecoveryUnit` writes `compose/` (docker-compose.yml, .felhom.yml, app.yaml) + `manifest.json`
|
||||
— **a few KB**, per `admission.go`'s own note — and **enumerates** the DB/volume dumps already present
|
||||
rather than creating them. It is idempotent (skips all writes when current) and **does not stop the
|
||||
app**; the stopping work belongs to the separate dump flow.
|
||||
|
||||
**Decision: nothing to build.** The cheap, non-stopping capture already runs for every deployed stack
|
||||
inside the off-site run's pre-dump phase, through `admitApp`. For a deployed app there is no wait to
|
||||
remove, and the §7.3 branch that would have added an inline capture would have duplicated it.
|
||||
|
||||
## §7.4 — every changed Hungarian string
|
||||
|
||||
- „Ezek az alkalmazások NEM kerültek be a távoli mentésbe, mert még nincs helyi mentési egységük: %s. A következő mentés általában már elkészíti — ha a második futás után is itt szerepelnek, szólj az üzemeltetőnek."
|
||||
- „Ezek az alkalmazások ki vannak jelölve távoli mentésre, de nincsenek telepítve, ezért nem menthetők: %s. Ha már nincs rájuk szükséged, vedd ki a kijelölésüket a Távoli mentés oldalon."
|
||||
- „Már fut egy távoli mentés — ez a kérés nem indított újat. A most látható eredmény még a korábbi futásé; várd meg, míg ez befejeződik."
|
||||
- **sibling, extended so both read alike:** „Figyelmeztetés: a(z) %s alkalmazás egyes adatmappái nem kerültek a távoli mentésbe: %s. **Ellenőrizd, hogy a mappák megvannak-e a meghajtón; ha igen és ez a következő mentés után is látszik, szólj az üzemeltetőnek.**"
|
||||
|
||||
The replaced sentence was: „Figyelmeztetés: %d alkalmazásnak nincs elérhető mentése, ezek kimaradtak: %s" — a count with no next step, reading the same whether the customer must act or simply wait.
|
||||
|
||||
## Tests and red-proofs
|
||||
|
||||
`go build` · `go vet` · `go test ./...` → **28 packages ok**. `controller_gates.py --fast` → **9/9 OK**.
|
||||
|
||||
New: `offsite_restore_list_test.go` (7 tests — the truth table, the rebuilt box, unreadable-is-unknown,
|
||||
no-target, empty, and two rendered-page tests) and `offbox_restore_silence_test.go` (handler-level,
|
||||
because the silence was in the handler).
|
||||
New: `internal/backup/offbox_verdict_r234_test.go` (Scenarios A, C, D, F — run-level) and
|
||||
`internal/web/offbox_run_inflight_test.go` (handler-level).
|
||||
|
||||
## Red-proofs — each mutation asserted to have applied before the result was trusted
|
||||
|
||||
| # | mutation | result |
|
||||
| # | mutation (each asserted to have applied) | result |
|
||||
|---|---|---|
|
||||
| RP-1 | store rows dropped from the builder — the list keyed back on installed apps (the original defect) | `TruthTable` + `RebuiltBox_SeesItsSnapshots` **FAIL** |
|
||||
| RP-2 | `state` forced to `known` **and** `StoreUnknown` forced false (both guards) | `UnreadableStoreIsUnknownNotEmpty` + `NoTargetIsItsOwnState` **FAIL** |
|
||||
| RP-3 | both size-gate log lines removed | `FullPrepareRefusal_IsNotSilent` **FAIL** — „wrote NOTHING to the log" |
|
||||
| RP-1 | drop `unprotected` from the verdict — the defect itself | `SkippedSelectedAppIsIncomplete` **FAIL** — „LastStatus = ok, want incomplete" |
|
||||
| RP-2 | remove the classification switch; count **every** skip | `SelectedButUndeployedIsNamedNotCounted` **FAIL** — a removed app turns the box amber |
|
||||
| RP-3 | stop adding skipped apps to the operator signal | `SkippedSelectedAppIsIncomplete` **FAIL** — „operator signal … got map[]" |
|
||||
| RP-4 | remove the synchronous in-flight check in the handler | `InFlightRequestIsNotReportedAsStarted` **FAIL** |
|
||||
|
||||
All three restored; suite green again afterwards.
|
||||
All restored; `grep -c RED-PROOF` = 0 in both files afterwards; suite green again.
|
||||
|
||||
**Fixture note:** the shared `offbox3aProvider.ListDeployedStacks()` returned nil, so my first run of
|
||||
Scenario A passed *for the wrong reason* and F passed vacuously. Fixed with an **opt-in** `deployed`
|
||||
map that defaults to nil, so no existing fixture's behaviour moves.
|
||||
|
||||
## Files
|
||||
|
||||
- new `internal/web/offsite_restore_list.go`, `internal/web/offsite_restore_list_test.go`,
|
||||
`internal/web/offbox_restore_silence_test.go`
|
||||
- `internal/web/restore_wizard.go` (gate on the store, not the toggle)
|
||||
- `internal/web/offbox_handlers.go` (three log lines)
|
||||
- `internal/web/handlers.go` (wire the rows)
|
||||
- `internal/web/templates/backups_restore.html` (the list + the state wording)
|
||||
- `internal/web/backups_split_test.go` (fixture: the new data contract)
|
||||
- `internal/backup/offbox_inventory.go` (`ErrNoOffsiteTargetSentinel`, so the no-target case is
|
||||
constructible from another package's table test)
|
||||
- `CHANGELOG.md`, `controller/README.md`
|
||||
- `internal/backup/offbox.go` — `ErrOffboxRunInFlight`, `offboxWholeUnitGap`, the skip classification,
|
||||
`missingUnprotected`/`missingNotDeployed`, `stackDeployed`, `driveUnavailableFor`, the verdict, the
|
||||
messages
|
||||
- `internal/backup/offbox_capture.go` — the sibling message
|
||||
- `internal/backup/backup.go` — `AcquireRunningForTest`/`ReleaseRunningForTest` (test-only seams)
|
||||
- `internal/web/offbox_handlers.go` — the synchronous single-flight refusal
|
||||
- new `internal/backup/offbox_verdict_r234_test.go`, `internal/web/offbox_run_inflight_test.go`
|
||||
- `internal/backup/offbox_3a_test.go` — opt-in deployed set
|
||||
- `CHANGELOG.md`, `controller/README.md`, `CONTEXT.md`
|
||||
|
||||
## Not done here, deliberately
|
||||
## Observations, not acted on
|
||||
|
||||
- **R-236 is diagnosed, not fixed** — the fix's shape depends on the diagnosis, and this arc has twice
|
||||
shipped a fix aimed at the wrong half of a defect.
|
||||
- The R-193 unlock listing still shows the `felhom-offbox` marker as if it were an app. Noticed while
|
||||
reusing its reader; **out of scope and not touched.**
|
||||
- **The zero-selection notice reads „Sikeres — nincs mentésre jelölt alkalmazás"** while the status is
|
||||
`ok`. It is honest, but "Sikeres" beside "nothing is covered" is the same rhetorical shape this
|
||||
session is about, one notch weaker. Not touched: Scenario D forbids changing that path.
|
||||
- **`res.missing` is still used verbatim** for the no-silent-success error text (every app missing →
|
||||
`error`). Correct as-is, and deliberately left, since that path already refuses loudly.
|
||||
|
||||
Reference in New Issue
Block a user