An app whose Tier-2 copy holds no file legs but a full recovery-unit mirror - 45 of the 53 catalog templates - was told to press a button on a DIFFERENT page. Since R-102 the data it is asking for is restorable from the copy it is looking at. New POST /backup/tier2/unit-restore and backupTier2UnitRestoreHandler: same guards, same restoreOpBlocked() refusal (R-351b), same async shape as the file restore beside it, plus a fail-closed pre-flight so the app is never stopped for a mirror that could not be opened. The outcome reuses unitRestoreOutcomeMsg and adds which copy overwrote the live data. The row offers the action where the refusal was, in a danger style, as a SEPARATE button. The two are not merged: one adds what is missing, the other overwrites. The confirm carries that difference in words and names the copy's date - and says so differently when that date is only an ATTEMPT (R-101). It is built from named Go constants rather than assembled inside an HTML attribute, so a test can assert it verbatim; fmtTimeStr now delegates to a package-level fmtRFC3339Local so the confirm and the outcome cannot render the same date two ways. tier2NoCoverageMsg is NARROWED to the case that remains - no legs and no openable unit - and still names the route that works. tier2UnitNotCoveredMsg is NOT deleted: it is appended where the FILE restore ran and is still exactly true of it. Tests C1-C2 and D1-D6 plus four more. Red-proofs: C1 (widen CanRestore to include HasUnit -> the unit-only cases fail), D6 (drop EndRestoreOp from the handler goroutine -> 'the restore never published a result').
This commit is contained in:
@@ -1187,6 +1187,23 @@ type AppBackupRow struct {
|
||||
Tier2StatusBadge string // "Sikeres", "Hiba", "Fut...", "—"
|
||||
Tier2SizeHuman string
|
||||
|
||||
// R-102/R-103 — the SECOND predicate, and it is a second field on purpose.
|
||||
//
|
||||
// Tier2UnitRestorable the copy holds an OPENABLE recovery unit (manifest present and parseable)
|
||||
// → the destructive „Teljes visszaállítás" action is offered
|
||||
// Tier2CopyDate the RFC3339 stamp of the copy that action would write over live data with
|
||||
// Tier2CopyDateProven false = that stamp is only an ATTEMPT clock, never a proven copy (R-101)
|
||||
//
|
||||
// Tier2UnitRestorable is NOT derived from Tier2LastStatus, Tier2SizeHuman or anything else on this
|
||||
// row: it is computed from what the copy on disk actually holds, because a row that says a backup
|
||||
// succeeded is not evidence that the package inside it can be opened.
|
||||
Tier2UnitRestorable bool
|
||||
Tier2CopyDate string
|
||||
Tier2CopyDateProven bool
|
||||
// Tier2UnitConfirm is the assembled destructive-confirm sentence (tier2UnitConfirmMsg). Built in
|
||||
// Go, not in the attribute, so it is one named string a test can assert verbatim.
|
||||
Tier2UnitConfirm string
|
||||
|
||||
// Drive disconnected — app's home drive is currently disconnected
|
||||
DriveDisconnected bool
|
||||
// Tier2 destination drive is currently disconnected (backup paused, not failed)
|
||||
@@ -1404,6 +1421,16 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackup
|
||||
row.Tier2LastError = cd.LastError
|
||||
row.Tier2LastWarning = cd.LastWarning
|
||||
row.Tier2SizeHuman = cd.LastSizeHuman
|
||||
// R-102: ask the COPY, not the config. Tier2RestoreCoverage stats the recorded copy
|
||||
// and reads its manifest, and it raises the same refusals the restore itself would —
|
||||
// a disconnected destination, a pre-v2 layout — so an offer is never rendered for a
|
||||
// copy the action would refuse. On any refusal the action is simply not offered; the
|
||||
// row keeps rendering everything else it already showed.
|
||||
if cov, covErr := s.backupMgr.Tier2RestoreCoverage(app.StackName); covErr == nil {
|
||||
row.Tier2UnitRestorable = cov.CanRestoreUnit()
|
||||
row.Tier2CopyDate, row.Tier2CopyDateProven = cov.Tier2CopyDate()
|
||||
row.Tier2UnitConfirm = tier2UnitConfirmMsg(row.Tier2CopyDate, row.Tier2CopyDateProven)
|
||||
}
|
||||
switch cd.LastStatus {
|
||||
case "ok":
|
||||
row.Tier2StatusBadge = "Sikeres"
|
||||
@@ -1580,18 +1607,102 @@ const monitoringIntegritySchedule = "Hetente, kimarado ellenorzest potol"
|
||||
// verbatim by tests — a silent edit to either is the way an honest message drifts back into a
|
||||
// comforting one.
|
||||
const (
|
||||
// tier2NoCoverageMsg is shown when this app's data cannot come from the secondary copy at all.
|
||||
// It NAMES the action that works rather than leaving a dead end: the keep-side recovery-unit
|
||||
// restore on /backups/restore, which does restore named volumes and DB dumps (proven live,
|
||||
// Campaign 9 A2). It also states plainly that no outage was taken, because the previous behaviour
|
||||
// took one.
|
||||
// tier2NoCoverageMsg is shown when this app's data cannot come from the secondary copy AT ALL —
|
||||
// no file legs AND no openable recovery unit in the copy. It NAMES the action that works rather
|
||||
// than leaving a dead end: the keep-side recovery-unit restore on /backups/restore, which does
|
||||
// restore named volumes and DB dumps (proven live, Campaign 9 A2). It also states plainly that no
|
||||
// outage was taken, because the previous behaviour took one.
|
||||
//
|
||||
// R-103 NARROWED IT. Until v0.229.0 this same sentence was also shown to the far commoner case —
|
||||
// no file legs but a full unit mirror sitting in the copy — and it sent those customers to a
|
||||
// button on another page for data that is now restorable on the page they are already looking at.
|
||||
// tier2UnitAvailableMsg is that case now.
|
||||
tier2NoCoverageMsg = "Ennek az alkalmazásnak az adatai nem ebből a másolatból állíthatók vissza — az alkalmazás nem állt le. Használd a Visszaállítás indítása gombot a Biztonsági mentés → Visszaállítás oldalon."
|
||||
|
||||
// tier2UnitNotCoveredMsg is appended wherever the restore DID run, so a clean result never reads
|
||||
// as a clean bill of health for data the operation never opened.
|
||||
// tier2UnitAvailableMsg (R-103) — the copy holds no restorable FILES, but it does hold an openable
|
||||
// recovery unit, so the answer is the action beside this one, not a different page. It names the
|
||||
// button by its own label and says why the two differ, because the difference is the whole reason
|
||||
// they are not one button: this one overwrites.
|
||||
tier2UnitAvailableMsg = "Ennek az alkalmazásnak az adatai nem fájlokban, hanem az alkalmazás saját adatbázisában és köteteiben vannak — az alkalmazás nem állt le. Ezeket a mellette lévő „Teljes visszaállítás a másolatból” gombbal tudod visszahozni ugyanerről a másolatról. Figyelem: az a művelet FELÜLÍRJA a jelenlegi adatokat, míg ez a gomb csak a hiányzó fájlokat pótolja."
|
||||
|
||||
// tier2UnitNotCoveredMsg is appended wherever the FILE restore DID run, so a clean result never
|
||||
// reads as a clean bill of health for data the operation never opened.
|
||||
//
|
||||
// R-103: it is NOT deleted now that the unit is restorable. It is appended where the FILE restore
|
||||
// ran, and it is still exactly true of that restore — the file merge still never opens the
|
||||
// database or the named volumes. Deleting it would let a clean file-restore result read as a clean
|
||||
// bill of health for data the operation did not look at, which is the sentence it exists to
|
||||
// prevent.
|
||||
tier2UnitNotCoveredMsg = "Az alkalmazás adatbázisa és belső kötetei nem tartoznak ebbe a visszaállításba."
|
||||
|
||||
// The Tier-2 UNIT restore's outcome suffix (R-102, Scenario E). The outcome names WHICH copy was
|
||||
// just written over the app's live data — an action that overwrites must say what it overwrote
|
||||
// with, in the sentence the customer is left holding.
|
||||
tier2UnitRestoreSourceMsgFmt = "A visszaállítás forrása a második meghajtón lévő másolat volt (%s)."
|
||||
|
||||
// …and the R-101 variant. CopyLastRun is the ATTEMPT clock: it advances on a FAILED Tier-2 run
|
||||
// too. Where no success has ever been recorded for this app, the date shown is evidence that a
|
||||
// copy was attempted and nothing more, and the sentence must not present it as evidence of a copy.
|
||||
tier2UnitRestoreSourceUnprovenMsgFmt = "A visszaállítás forrása a második meghajtón lévő másolat volt (%s — ez az utolsó mentési kísérlet ideje; azt nem tudjuk igazolni, hogy az sikeres volt)."
|
||||
|
||||
// R-102/R-103 — the DESTRUCTIVE CONFIRM, in pieces, and in Go rather than in the template.
|
||||
//
|
||||
// It lives here for the same reason the R-353 outcome strings do: this sentence is the only thing
|
||||
// standing between a customer and the loss of everything they made since the copy was taken, and a
|
||||
// silent edit to it is how a warning drifts into a reassurance. Named constants can be asserted
|
||||
// verbatim by a test; a sentence assembled inside an HTML attribute cannot, and R-364 makes
|
||||
// grepping accented Hungarian out of rendered markup an unreliable check on top of that.
|
||||
//
|
||||
// The three parts are three separate obligations:
|
||||
// Base — what this action DOES: it overwrites, including the database and internal volumes.
|
||||
// Date — WHICH copy it overwrites with. Two forms, because a stamp that only records an
|
||||
// ATTEMPT must not be presented as the date of a copy (R-101).
|
||||
// Contrast — how it differs from the additive button beside it. The register's own requirement:
|
||||
// a destructive operation reached from a non-destructive surface must carry the
|
||||
// difference in the confirm, not rely on the customer inferring it from a label.
|
||||
tier2UnitActionLabel = "Teljes visszaállítás a másolatból"
|
||||
|
||||
tier2UnitConfirmBase = "Ez a művelet FELÜLÍRJA az alkalmazás jelenlegi adatait – az adatbázisát és a belső köteteit is – a második meghajtón lévő másolattal. Ami a másolat óta keletkezett, elveszik."
|
||||
|
||||
tier2UnitConfirmDateFmt = " A másolat kelte: %s."
|
||||
|
||||
tier2UnitConfirmDateUnprovenFmt = " A másolat kelte: %s – ez az utolsó mentési kísérlet ideje, azt nem tudjuk igazolni, hogy sikeres volt."
|
||||
|
||||
tier2UnitConfirmContrast = " A mellette lévő „Fájlok visszaállítása” ezzel szemben csak a hiányzó fájlokat pótolja, és semmit nem ír felül. Az alkalmazás a művelet idejére leáll."
|
||||
)
|
||||
|
||||
// tier2UnitConfirmMsg assembles the destructive confirm for one app's Tier-2 unit restore. Pure, so
|
||||
// the wording is unit-testable; the date is rendered by the SAME helper the rest of the surface uses.
|
||||
//
|
||||
// A copy with no recorded date at all still gets a confirm — it just cannot name one. Dropping the
|
||||
// whole confirm because a date is missing would remove the warning and keep the destruction.
|
||||
func tier2UnitConfirmMsg(copyDate string, proven bool) string {
|
||||
msg := tier2UnitConfirmBase
|
||||
if copyDate != "" {
|
||||
if proven {
|
||||
msg += fmt.Sprintf(tier2UnitConfirmDateFmt, fmtRFC3339Local(copyDate))
|
||||
} else {
|
||||
msg += fmt.Sprintf(tier2UnitConfirmDateUnprovenFmt, fmtRFC3339Local(copyDate))
|
||||
}
|
||||
}
|
||||
return msg + tier2UnitConfirmContrast
|
||||
}
|
||||
|
||||
// tier2UnitSourceMsg renders the "which copy" clause for the Tier-2 unit restore's outcome, or "" if
|
||||
// no date is recorded at all. It asks Tier2CopyDate — the SAME resolver the surface uses to pick the
|
||||
// date it puts in the confirm — so the sentence the customer approves and the sentence they are left
|
||||
// with cannot name different copies.
|
||||
func tier2UnitSourceMsg(cov backup.Tier2Coverage) string {
|
||||
date, proven := cov.Tier2CopyDate()
|
||||
if date == "" {
|
||||
return ""
|
||||
}
|
||||
if proven {
|
||||
return fmt.Sprintf(tier2UnitRestoreSourceMsgFmt, fmtRFC3339Local(date))
|
||||
}
|
||||
return fmt.Sprintf(tier2UnitRestoreSourceUnprovenMsgFmt, fmtRFC3339Local(date))
|
||||
}
|
||||
|
||||
// backupTier2RestoreHandler (C2, closes F2) restores an app's MISSING user files in place from its
|
||||
// recorded Tier-2 copy — additive-only: existing live files are never overwritten and nothing is
|
||||
// ever deleted (see backup.RestoreTier2Files). Same handler shape as backupRestoreHandler.
|
||||
@@ -1626,10 +1737,19 @@ func (s *Server) backupTier2RestoreHandler(w http.ResponseWriter, r *http.Reques
|
||||
// about data the restore never examined, at the exact moment they pressed it BECAUSE data was
|
||||
// missing. Only the no-coverage case is pre-flighted; every other refusal keeps its existing async
|
||||
// path so this change cannot alter behaviour anywhere else.
|
||||
//
|
||||
// R-103 SPLIT THE REFUSAL IN TWO. „no files to restore" has two different answers now, and giving
|
||||
// both customers the same sentence is what sent one of them to another page for data that is
|
||||
// restorable on this one.
|
||||
cov, covErr := s.backupMgr.Tier2RestoreCoverage(stackName)
|
||||
if covErr == nil && !cov.CanRestore() {
|
||||
s.logger.Printf("[WARN] [web] Tier-2 file restore refused up front: stack=%s has no restorable subtree in its copy (unit_present=%v) — app NOT stopped", stackName, cov.HasUnit)
|
||||
http.Redirect(w, r, "/backups/apps?flash_error="+url.QueryEscape(tier2NoCoverageMsg), http.StatusFound)
|
||||
msg := tier2NoCoverageMsg
|
||||
if cov.CanRestoreUnit() {
|
||||
msg = tier2UnitAvailableMsg
|
||||
}
|
||||
s.logger.Printf("[WARN] [web] Tier-2 file restore refused up front: stack=%s has no restorable subtree in its copy (unit_present=%v unit_restorable=%v) — app NOT stopped",
|
||||
stackName, cov.HasUnit, cov.CanRestoreUnit())
|
||||
http.Redirect(w, r, "/backups/apps?flash_error="+url.QueryEscape(msg), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1667,6 +1787,84 @@ func (s *Server) backupTier2RestoreHandler(w http.ResponseWriter, r *http.Reques
|
||||
http.Redirect(w, r, "/backups/apps?flash="+url.QueryEscape("Fájl-visszaállítás elindult — az állapot itt frissül."), http.StatusFound)
|
||||
}
|
||||
|
||||
// backupTier2UnitRestoreHandler (R-102/R-103) restores an app IN FULL from the recovery unit mirrored
|
||||
// onto the SECOND DRIVE — the app's database dumps and named-volume tars, not just its loose files.
|
||||
//
|
||||
// It is the destructive twin of backupTier2RestoreHandler above and shares its shape deliberately:
|
||||
// same guards, same single-writer refusal, same async goroutine, same status banner. What it does not
|
||||
// share is its promise. The file restore only ever ADDS what is missing; this one OVERWRITES the
|
||||
// app's live data with the copy's. The template's confirm carries that difference in words, and the
|
||||
// two actions stay two buttons for exactly that reason (§8: they are different promises).
|
||||
//
|
||||
// The pre-flight refusal is the fail-closed gate: a `recovery-unit/` directory that exists is not a
|
||||
// package. Refusing here means the app is never stopped for a mirror that could not have been read.
|
||||
func (s *Server) backupTier2UnitRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
stackName := r.FormValue("stack_name")
|
||||
|
||||
if stackName == "" {
|
||||
http.Redirect(w, r, "/backups/apps?flash_error=Hi%C3%A1nyz%C3%B3+param%C3%A9terek", http.StatusFound)
|
||||
return
|
||||
}
|
||||
// Same F2-defense as both restores beside it: a stack name is a single segment, never a path.
|
||||
if !validStackName(stackName) {
|
||||
s.logger.Printf("[WARN] [web] Tier-2 unit restore rejected: invalid stack_name %q from %s", stackName, r.RemoteAddr)
|
||||
http.Redirect(w, r, "/backups/apps?flash_error=%C3%89rv%C3%A9nytelen+alkalmaz%C3%A1sn%C3%A9v", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if s.backupMgr == nil {
|
||||
http.Redirect(w, r, "/backups/apps?flash_error=Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva", http.StatusFound)
|
||||
return
|
||||
}
|
||||
// R-351b (Scenario H): a second press — by button or by a direct POST — must not start a second
|
||||
// run. restoreOpBlocked() and not IsRunning(), because the concurrency flag is only taken inside
|
||||
// the goroutine, after this handler has already returned.
|
||||
if msg, blocked := s.restoreOpBlocked(); blocked {
|
||||
http.Redirect(w, r, "/backups/apps?flash_error="+url.QueryEscape(msg), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Pre-flight, before any op is begun and before the app is stopped: does this copy hold a unit
|
||||
// this restore can actually open? Only the no-unit case is pre-flighted; every other refusal keeps
|
||||
// its existing async path, so this cannot alter behaviour anywhere else.
|
||||
cov, covErr := s.backupMgr.Tier2RestoreCoverage(stackName)
|
||||
if covErr != nil {
|
||||
s.logger.Printf("[WARN] [web] Tier-2 unit restore refused up front: stack=%s: %v — app NOT stopped", stackName, covErr)
|
||||
http.Redirect(w, r, "/backups/apps?flash_error="+url.QueryEscape(covErr.Error()), http.StatusFound)
|
||||
return
|
||||
}
|
||||
if !cov.CanRestoreUnit() {
|
||||
s.logger.Printf("[WARN] [web] Tier-2 unit restore refused up front: stack=%s has no openable unit in its copy (unit_present=%v) — app NOT stopped", stackName, cov.HasUnit)
|
||||
http.Redirect(w, r, "/backups/apps?flash_error="+url.QueryEscape(tier2NoCoverageMsg), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
go func() {
|
||||
start := time.Now()
|
||||
res, err := s.backupMgr.RestoreTier2Unit(stackName)
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] [web] Tier-2 unit restore failed (async): stack=%s: %v", stackName, err)
|
||||
s.backupMgr.EndRestoreOp(false, "Teljes visszaállítás sikertelen: "+err.Error())
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] [web] Tier-2 unit restore completed (async): stack=%s in %s (volumes %d/%d, dbs %d/%d)",
|
||||
stackName, time.Since(start), res.VolumesReplayed, res.ManifestVolumes, res.DBsReplayed, res.ManifestDBs)
|
||||
// The outcome sentence is yesterday's — unitRestoreOutcomeMsg, unchanged, because what came
|
||||
// back is the same fact whichever unit it came out of, and a second wording of it would be a
|
||||
// second thing to keep honest. What IS added is which copy it came from and how old that copy
|
||||
// is: this action overwrote the customer's live data, and the sentence they are left with has
|
||||
// to say what it overwrote it with (Scenario E).
|
||||
msg := unitRestoreOutcomeMsg(stackName, res)
|
||||
if src := tier2UnitSourceMsg(cov); src != "" {
|
||||
msg += " " + src
|
||||
}
|
||||
s.backupMgr.EndRestoreOp(true, msg)
|
||||
}()
|
||||
http.Redirect(w, r, "/backups/apps?flash="+url.QueryEscape("Teljes visszaállítás elindult — az állapot itt frissül."), http.StatusFound)
|
||||
}
|
||||
|
||||
// settingsBaseData is the shared identity block used by every settings-family subpage
|
||||
// (D1 split: /settings, /settings/notifications, /settings/security, /storage).
|
||||
func (s *Server) settingsBaseData(page, title string) map[string]interface{} {
|
||||
|
||||
Reference in New Issue
Block a user