controller v0.264.0: the household is told when an update is undone or held, in its language
gates / gates (push) Successful in 25s

app_update_undone / app_update_held events (09 decision 15), on by
default and seeded once on existing boxes; R-606 update sentences as
key+args rendered per reader; R-646 startup applied-meta backfill for
apps current with the catalog; R-620 a disabled notifier WARNs once per
event type. Needs hub v0.120.0.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-23 13:51:21 +02:00
parent c3a2aba0d2
commit bc278944a3
27 changed files with 1086 additions and 86 deletions
@@ -330,6 +330,13 @@ func (m *Manager) pruneUndoCopies(dumpDir, stack string) {
// on a misconfigured box. The write side logs loudly when it cannot persist (see
// holdAppAfterFailedRollback), which is where that case is caught.
func (m *Manager) RestoreHoldFor(stack string) (bool, string) {
return m.RestoreHoldForLang(stack, m.boxLang())
}
// RestoreHoldForLang is RestoreHoldFor with the sentence in lang (v0.264.0, R-606). The page and the
// household mail ask for the READER's language; every other caller takes the box's. The Hungarian is
// byte-identical to the literals it replaced (UpdateHoldFmt & co. — TestR606_HoldSentenceHungarianUnchanged).
func (m *Manager) RestoreHoldForLang(stack, lang string) (bool, string) {
if m == nil || m.settings == nil {
return false, ""
}
@@ -340,43 +347,44 @@ func (m *Manager) RestoreHoldFor(stack string) (bool, string) {
// Slice 4: one storage, two reasons. An update hold names the copy it can be restored from; a
// restore hold names nothing, because the restore it refers to already consumed the copy.
if h.Reason == settings.HoldReasonUpdateFailed {
return true, m.undoHoldPrefix(h.UndoState) + m.updateHoldSentence(stack, h)
return true, m.undoHoldPrefix(lang, h.UndoState) + updateHoldSentence(lang, stack, h)
}
when := h.At
if t, err := time.Parse(time.RFC3339, h.At); err == nil {
when = t.Format("2006-01-02 15:04")
}
return true, m.note("note.reconstitute.held", stack, when)
return true, util.Text(lang, "note.reconstitute.held", stack, when)
}
// undoHoldPrefix (v0.263.0) opens the hold sentence when the box already TRIED to undo the update and
// that failed too: what was tried, then what state the data is in. "" when no undo was attempted, so
// every hold written before v0.263.0 reads exactly as it did.
func (m *Manager) undoHoldPrefix(state string) string {
func (m *Manager) undoHoldPrefix(lang, state string) string {
switch state {
case "untouched", "half", "not_started":
return m.note("hold.update.undo_failed") + " " + m.note("hold.update.undo_state."+state) + " "
return util.Text(lang, "hold.update.undo_failed") + " " + util.Text(lang, "hold.update.undo_state."+state) + " "
case "":
return ""
}
m.logger.Printf("[WARN] [backup] unknown undo state %q on a hold — rendering the plain prefix", state)
return m.note("hold.update.undo_failed") + " "
return util.Text(lang, "hold.update.undo_failed") + " "
}
// updateHoldSentence is the update hold's own sentence (slice 4, R-475, R-479), unchanged.
func (m *Manager) updateHoldSentence(stack string, h settings.RestoreHold) string {
copyDate := m.note("note.reconstitute.copy_latest")
// updateHoldSentence is the update hold's own sentence (slice 4, R-475, R-479) in lang. Its Hungarian
// is UpdateHoldFmt / UpdateHoldTierFmt / UpdateHoldLegacyFmt byte for byte (pinned by a test).
func updateHoldSentence(lang, stack string, h settings.RestoreHold) string {
copyDate := util.Text(lang, "note.reconstitute.copy_latest")
if h.CopyDate != "" {
copyDate = fmtHoldTime(h.CopyDate)
}
// R-475: name the tier when the hold recorded one; an older hold keeps its own sentence.
if label := UpdateTierLabel(h.CopyTier); label != "" && h.CopyDate != "" {
if label := UpdateTierLabelIn(lang, h.CopyTier); label != "" && h.CopyDate != "" {
if h.CopyHolds != "" { // R-479: name what the copy holds
return fmt.Sprintf(UpdateHoldFmt, stack, fmtHoldTime(h.At), label, copyDate, h.CopyHolds)
return util.Text(lang, "hold.update.sentence", stack, fmtHoldTime(h.At), label, copyDate, copyHoldsIn(lang, h.CopyHolds))
}
return fmt.Sprintf(UpdateHoldTierFmt, stack, fmtHoldTime(h.At), label, copyDate)
return util.Text(lang, "hold.update.sentence_tier", stack, fmtHoldTime(h.At), label, copyDate)
}
return fmt.Sprintf(UpdateHoldLegacyFmt, stack, fmtHoldTime(h.At), copyDate)
return util.Text(lang, "hold.update.sentence_legacy", stack, fmtHoldTime(h.At), copyDate)
}
// holdAppAfterFailedRollback records the R-379/R-380 hold and makes sure nothing restarts the app
+11 -2
View File
@@ -45,6 +45,11 @@ func TestUndo_HoldSentenceSaysTheUndoWasTriedAndTheDataState(t *testing.T) {
}
}
// A KNOWN state before the language check — the loop above ranges over a map, so its last state is
// random (this test read "half" and failed once for exactly that reason, 2026-09-23).
if err := m.HoldAfterFailedUpdateHolding("app", at, copyAt, UpdateTierSecondDrive, "", "not_started"); err != nil {
t.Fatal(err)
}
if err := m.settings.SetLanguage("en"); err != nil {
t.Fatal(err)
}
@@ -52,7 +57,11 @@ func TestUndo_HoldSentenceSaysTheUndoWasTriedAndTheDataState(t *testing.T) {
if !strings.HasPrefix(why, "The update did not succeed, and the automatic undo did not either. The data is back as it was before the update") {
t.Errorf("an English box gets the English prefix, got %q", why)
}
if strings.Contains(why, "automatikus visszaállítás") {
t.Errorf("the Hungarian prefix must be GONE on an English box, got %q", why)
// v0.264.0 (R-606): the WHOLE sentence is English now, not only the prefix.
if !strings.Contains(why, "The update of app at 2026-09-23 10:00 did not succeed") || !strings.Contains(why, "second drive, 2026-09-23 03:30") {
t.Errorf("the hold's own sentence must be English on an English box, got %q", why)
}
if strings.Contains(why, "automatikus visszaállítás") || strings.Contains(why, "frissítése") || strings.Contains(why, "meghajtó") {
t.Errorf("no Hungarian may remain on an English box, got %q", why)
}
}
+36 -19
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
"os"
"path/filepath"
@@ -142,37 +143,53 @@ func (m *Manager) UpdateTierOrderFor(stackName string) []int {
// UpdateCopyHolds is the customer phrase for what a copy on `tier` holds for this app — the second half
// of the R-479 ruling: the hold sentence names WHAT the chosen copy holds, not only where it is.
//
// v0.264.0 (R-606): the phrase is a bundle key — it is a PROMISE ABOUT WHETHER THE HOUSEHOLD'S FILES
// COME BACK and must reach an English household in English. The value returned (and stored in the
// hold) is still the Hungarian, byte for byte; copyHoldsIn maps it back to its key at render time, so
// a hold written by any earlier version localises too.
func (m *Manager) UpdateCopyHolds(stackName string, tier int) string {
outside := m.DataOutsideUnit(stackName)
return util.Text(i18n.Default, updateCopyHoldsKey(m.DataOutsideUnit(stackName), tier))
}
func updateCopyHoldsKey(outside bool, tier int) string {
switch tier {
case UpdateTierLocal:
if outside {
return "csak a beállításokat és az adatbázist tartalmazza, a fájlokat nem"
return "hold.copy_holds.db_only"
}
return "a beállításokat, az adatbázist és az adatköteteket tartalmazza"
case UpdateTierSecondDrive:
return "hold.copy_holds.volumes"
case UpdateTierSecondDrive, UpdateTierOffsite:
if outside {
return "a beállításokat, az adatbázist és a fájlokat tartalmazza"
return "hold.copy_holds.files"
}
return "a beállításokat, az adatbázist és az adatköteteket tartalmazza"
case UpdateTierOffsite:
if outside {
return "a beállításokat, az adatbázist és a fájlokat tartalmazza"
}
return "a beállításokat, az adatbázist és az adatköteteket tartalmazza"
return "hold.copy_holds.volumes"
}
return ""
}
// UpdateTierLabel is a tier's name in the customer's hold sentence. "" for an unknown tier.
func UpdateTierLabel(tier int) string {
// copyHoldKeys are the phrases a hold may have stored; the stored value is the Hungarian.
var copyHoldKeys = []string{"hold.copy_holds.db_only", "hold.copy_holds.volumes", "hold.copy_holds.files"}
// copyHoldsIn renders a STORED copy-holds phrase in lang. An unknown phrase is returned as stored —
// never an empty clause (a hold must not lose the sentence that says what the copy holds).
func copyHoldsIn(lang, stored string) string {
for _, k := range copyHoldKeys {
if util.Text(i18n.Default, k) == stored {
return util.Text(lang, k)
}
}
return stored
}
// UpdateTierLabel is a tier's name in the customer's hold sentence (Hungarian). "" for an unknown tier.
func UpdateTierLabel(tier int) string { return UpdateTierLabelIn(i18n.Default, tier) }
// UpdateTierLabelIn is UpdateTierLabel in lang (v0.264.0, R-606).
func UpdateTierLabelIn(lang string, tier int) string {
switch tier {
case UpdateTierSecondDrive:
return "második meghajtó"
case UpdateTierLocal:
return "saját meghajtó"
case UpdateTierOffsite:
return "távoli mentés"
case UpdateTierSecondDrive, UpdateTierLocal, UpdateTierOffsite:
return util.Text(lang, fmt.Sprintf("hold.update.tier.%d", tier))
}
return ""
}