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
+29
View File
@@ -1,3 +1,32 @@
## v0.264.0 — the undo reaches the fleet, and the household is TOLD, in its own language (2026-09-23, `09` §6.4 parts 2–3)
**MinAgent: 0.131.0** (unchanged). **Needs hub v0.120.0** (deployed first). New strings: yes (hu + en).
- **Two new events (`09` §3 decision 15).** `app_update_undone` (warning): the update failed and the
box put the previous version and its data back — sent once, at the end of a successful undo.
`app_update_held` (error): the update, or its undo, failed and the app is held — sent once, also
when the hold could not be saved (the operator must hear exactly that). Details:
`{app, stack_name, from, to, at, copy_tier, copy_date, copy_holds}`. The held mail's line is the
hold sentence in the household's language (`RestoreHoldForLang`). Wired by
`Manager.SetUpdateEventSink` in main.go (pinned by `TestUpdateEventSinkIsWiredAtStartup`).
- **On by default, and on for existing boxes.** Both joined `DefaultEnabledEvents`; a box that already
has a list gets them once, add-only (`app_update_events_seeded`). Two new toggles on the
notifications page — without them, the next page save would push a list without the new types to
the hub and undo the hub's own migration. The parity fixture for that page was regenerated; the
measured diff is exactly the two toggles.
- **R-606 — every update sentence in the reader's language.** `UpdateError` is stored as a key + args
(`update.error.*`, `update.refusal.*`); phase labels (`update.phase.*`), the undo line and prefix,
the hold sentence (`hold.update.*`) and `UpdateCopyHolds` (`hold.copy_holds.*`) all render per
reader on both pages and in `GET /api/stacks/<name>`. The stored Hungarian is byte-identical
(parity gate green); a sentence stored by an older version renders as stored.
- **R-646 — the startup pass.** `BackfillAppliedMeta` records `applied-meta/.felhom.yml` for every
deployed, pinned app that is CURRENT with the catalog and has no record; a behind app is skipped
and named in the log (its pinned version's file is gone — guessing it is worse). Idempotent; never
overwrites.
- **R-620 — a disabled notifier says what it drops.** One WARN per event type per process
(`notifier disabled (no hub configured): DROPPED event <type> …`), then DEBUG.
- Red-proofs (REPORT): nine, each seen failing.
## v0.263.2 — the undo keeps the probe of the PINNED version, recorded when it was pinned (2026-09-23, R-637)
**MinAgent: 0.131.0** (unchanged). No new strings.
+12
View File
@@ -650,6 +650,16 @@ the old version back; a power cut while undoing resumes the undo. Reasoning and
`felhom.eu/documentation/architecture/09-update-architecture.md` §6.1a,
`felhom.eu/documentation/audits/undo-bakeoff-2026-09-23/`.
**The household is told (v0.264.0).** An undone update sends `app_update_undone` (warning) ONCE; an
update that ends held sends `app_update_held` (error) ONCE — also when the hold itself could not be
saved. Details `{app, stack_name, from, to, at, copy_tier, copy_date, copy_holds}`. Both are in
`DefaultEnabledEvents` and on the notifications page; an existing box gets them once, add-only
(`app_update_events_seeded`). Needs hub v0.120.0 (allow-list, mail entries in hu and en, per-app
cooldown). Every update sentence — the phase label, the refusals, the failure lines, the hold
sentence and its prefix — is stored as a key + args and rendered in the READER's language (R-606);
the Hungarian stored text is unchanged. At startup, an app already CURRENT with the catalog gets its
`applied-meta/` record (R-646); a behind app is skipped by name, never guessed.
**Start/restart never answer "completed" (v0.263.0, R-642)** — they answer what was requested and the
state the containers are in at that moment; whether the app works is the health probe's to say.
@@ -2420,6 +2430,8 @@ The controller pushes structured events to the Hub's `/api/v1/event` endpoint. T
| `app_deployed` | info | New app deployed via API |
| `app_removed` | info | App removed via API |
| `app_start_failed` | **warning** | A DEPLOYED app is not running (fix-3) — fired ONCE per running→down transition. **Customer-switchable („Alkalmazás nem fut"), OFF by default; the OPERATOR is e-mailed regardless.** Was `warn` until v0.223.0 — see the severity note below |
| `app_update_undone` | warning | v0.264.0 — a guarded update failed and the box put the previous version and its data back. Once per app per failed step. Household ON by default |
| `app_update_held` | **error** | v0.264.0 — a guarded update (or its undo) failed and the app is held until a restore. The mail's line is the hold sentence in the household's language. Household ON by default |
| `disaster_recovery_started` | warning | DR restore begins |
| `disaster_recovery_completed` | info/error | DR restore finishes (success/partial) |
+33
View File
@@ -52,6 +52,7 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
catalogsync "gitea.dooplex.hu/admin/felhom-controller/internal/sync"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
"gitea.dooplex.hu/admin/felhom-controller/internal/web"
)
@@ -463,6 +464,9 @@ func main() {
// in the same boot. It reads and writes FILES only — no container is started, stopped or touched.
// An app it cannot pin confidently is left UNPINNED and keeps pre-v0.235.0 behaviour, loudly.
stackMgr.AdoptPins()
// v0.264.0 (R-646): record the pinned version's .felhom.yml for apps pinned before v0.263.2 that are
// current with the catalog — the probe their first undo will judge them by. Files only.
stackMgr.BackfillAppliedMeta()
// --- Start the catalog syncer, AFTER adoption ---
// ORDERING IS LOAD-BEARING. Start() fires an immediate sync in a goroutine. Started at its
@@ -600,6 +604,35 @@ func main() {
quiesceLoop.SetTierNotifier(quiesceTierNotifier{n: notifier})
}
// v0.264.0 (`09` §3 decision 15, §6.4 part 2): an undone or held app update is TOLD — one household
// mail + an operator event each. The held sentence is the hold's OWN sentence, rendered by the backup
// side in whichever language is asked for, so the mail says exactly what the page says. Pinned by
// TestUpdateEventSinkIsWiredAtStartup — a seam built and never wired is this project's commonest
// defect, and this one would fail silently: no event, no mail, no error.
stackMgr.SetUpdateEventSink(func(ev stacks.UpdateEvent) {
switch ev.Kind {
case stacks.UpdateEventUndone:
notifier.NotifyAppUpdateUndone(ev.App, ev.From, ev.To, ev.At)
case stacks.UpdateEventHeld:
d := notify.AppUpdateDetails{App: ev.App, StackName: ev.App, From: ev.From, To: ev.To,
At: ev.At.UTC().Format(time.RFC3339), CopyTier: ev.CopyTier}
if !ev.CopyDate.IsZero() {
d.CopyDate = ev.CopyDate.UTC().Format(time.RFC3339)
}
if backupMgr != nil && ev.CopyTier > 0 {
d.CopyHolds = backupMgr.UpdateCopyHolds(ev.App, ev.CopyTier)
}
notifier.NotifyAppUpdateHeld(d, func(lang string) string {
if ev.HoldRecorded && backupMgr != nil {
if held, why := backupMgr.RestoreHoldForLang(ev.App, lang); held {
return why
}
}
return util.Text(lang, "update.error.hold_unsaved")
})
}
})
// R-166 §2.4: report an interrupted app-data operation to the operator, HERE, because the
// recovery itself had to run before the boot reconciler (line ~236) and the notifier does not
// exist until this line. An interrupted operation means the controller died mid-backup and that
@@ -0,0 +1,23 @@
package main
import "testing"
// v0.264.0. The update-event sink and the R-646 backfill are both CALLED from main.go (an AST walk —
// a comment naming them would not count). A sink built and never wired would fail silently: no event,
// no mail, no error — this project's commonest defect, four recorded instances.
//
// COMPANION RED-PROOF (REPORT.md): comment out the SetUpdateEventSink call in main.go — this fails.
func TestUpdateEventSinkIsWiredAtStartup(t *testing.T) {
lines, _, _ := slice4CallLines(t)
if len(lines["SetUpdateEventSink"]) == 0 {
t.Fatal("SetUpdateEventSink is never called — an undone or held update would tell nobody")
}
for _, callee := range []string{"NotifyAppUpdateUndone", "NotifyAppUpdateHeld", "RestoreHoldForLang"} {
if len(lines[callee]) == 0 {
t.Errorf("main.go must call %s inside the sink", callee)
}
}
if len(lines["BackfillAppliedMeta"]) == 0 || len(lines["AdoptPins"]) == 0 || lines["BackfillAppliedMeta"][0] < lines["AdoptPins"][0] {
t.Error("BackfillAppliedMeta (R-646) must be called, after AdoptPins")
}
}
+17 -1
View File
@@ -19,6 +19,7 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
cf "gitea.dooplex.hu/admin/felhom-controller/internal/cloudflare"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"gitea.dooplex.hu/admin/felhom-controller/internal/integrations"
"gitea.dooplex.hu/admin/felhom-controller/internal/metrics"
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
@@ -373,12 +374,27 @@ func (r *Router) rescanStacks(w http.ResponseWriter, req *http.Request) {
})
}
func (r *Router) getStack(w http.ResponseWriter, _ *http.Request, name string) {
func (r *Router) getStack(w http.ResponseWriter, req *http.Request, name string) {
stack, ok := r.stackMgr.GetStack(name)
if !ok {
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: "stack not found: " + name})
return
}
// v0.264.0 (R-606): the page polls this for the Update button's label and the outcome. Rendered in
// the reader's language; a Hungarian reader gets exactly the bytes it always got. GetStack returns a
// COPY, so nothing here touches the manager's own state.
if lang := r.langFor(req); lang != i18n.Default {
stack.UpdatePhaseLabel = stacks.UpdatePhaseLabelIn(lang, stack.UpdatePhase)
stack.UpdateError = stack.UpdateErrorIn(lang)
if stack.HoldReason != "" && r.backupMgr != nil {
if held, why := r.backupMgr.RestoreHoldForLang(name, lang); held && why != "" {
stack.HoldReason = why
if stack.UpdateErrorKey == "" && stack.UpdatePhase == stacks.UpdatePhaseFailed {
stack.UpdateError = why // a held update's UpdateError IS the hold sentence
}
}
}
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: stack})
}
@@ -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 ""
}
+38 -1
View File
@@ -1356,6 +1356,7 @@
"event.app_deploy_started": "App install started: %s",
"event.app_deployed": "App installed: %s",
"event.app_removed": "App removed: %s",
"event.app_update_undone": "The update of %s at %s did not work. The box put back the previous version and its data automatically — nothing was lost, and there is nothing you need to do.",
"event.backup_target_absent": "The whole-system backup drive is not available: %s (%s)",
"event.backup_target_restored": "The whole-system backup drive is available again: %s (%s)",
"event.controller_started": "Controller started (%s)",
@@ -1477,6 +1478,15 @@
"func.time.tomorrow_at": "tomorrow %s",
"func.time.yesterday": "yesterday",
"health.no_probe_container": "No health check ran: no matching container.",
"hold.copy_holds.db_only": "holds only the settings and the database, not the files",
"hold.copy_holds.files": "holds the settings, the database and the files",
"hold.copy_holds.volumes": "holds the settings, the database and the data volumes",
"hold.update.sentence": "The update of %s at %s did not succeed, and the app did not start on the new version. The app stays stopped for safety, so that its data is not damaged. It can be restored on the Backups page from this backup: %s, %s — this copy %s.",
"hold.update.sentence_legacy": "The update of %s at %s did not succeed, and the app did not start on the new version. The app stays stopped for safety, so that its data is not damaged. It can be restored on the Backups page from the backup of %s.",
"hold.update.sentence_tier": "The update of %s at %s did not succeed, and the app did not start on the new version. The app stays stopped for safety, so that its data is not damaged. It can be restored on the Backups page from this backup: %s, %s.",
"hold.update.tier.1": "own drive",
"hold.update.tier.2": "second drive",
"hold.update.tier.3": "remote backup",
"hold.update.undo_failed": "The update did not succeed, and the automatic undo did not either.",
"hold.update.undo_state.half": "Putting the data back stopped part-way, so the data is in a mixed state; the copy taken before the update is kept.",
"hold.update.undo_state.not_started": "The data is back as it was before the update, but the previous version did not start.",
@@ -1808,6 +1818,8 @@
"settings_notifications.alkalmazas_email": "App e-mail",
"settings_notifications.alkalmazas_email_engedelyezese": "Allow app e-mail",
"settings_notifications.alkalmazas_nem_fut": "App not running",
"settings_notifications.app_update_held": "Update failed, the app is stopped",
"settings_notifications.app_update_undone": "Update undone (the app runs on its previous version)",
"settings_notifications.az_alkalmazasok_a_felhom_on": "Apps can send e-mail through Felhom (for example password resets, invitations),\n without setting up a separate e-mail provider. Each app’s sender address is its own\n <em>&lt;app&gt;@felhom.eu</em> address.",
"settings_notifications.az_ertesitesek_a_kozponti_rendszeren": "Notifications work through the central system, which is not turned on right now.",
"settings_notifications.beallitasok_ertesitesek": "Settings — Notifications",
@@ -2344,7 +2356,32 @@
"tier2_config.nincs_elerheto_off_drive_cel": "No off-drive target available",
"tier2_config.nincs_masik_adatmeghajto_automatikus_cel": "No other data drive — the automatic target is the internal SSD (database/configuration only). Add a 2nd\n data drive to back up all data off-drive too.",
"tier2_config.vissza_a_mentesekhez": "← Back to backups",
"update.error.backup_failed": "The update did not start, because the backup before the update did not succeed: %v. The app keeps running unchanged.",
"update.error.backup_no_unit": "The update did not start: the backup before the update ran, but no fresh copy that can be restored was made. The app keeps running unchanged.",
"update.error.dump_failed": "The update did not start, because the database snapshot was not made: %v. The app keeps running unchanged.",
"update.error.hold_unsaved": "The update did not succeed and the app was stopped, but saving that it is stopped did not succeed. Do not start it again — contact us.",
"update.error.interrupted": "The update was interrupted, because the controller restarted before the new version started. The app keeps running on its previous version.",
"update.error.journal_failed": "The update did not start: the update's journal cannot be saved. The app keeps running unchanged.",
"update.error.no_guards": "The update cannot start: the safety check before an update is not available on this server.",
"update.error.pin_failed": "The update did not start: the description of the new version cannot be read. The app keeps running unchanged.",
"update.error.pull_failed": "Downloading the new version did not succeed, so the update did not happen. The app keeps running on its previous version.",
"update.phase.backing-up": "Making a backup before the update…",
"update.phase.checking": "Checking…",
"update.phase.copying": "Copying the data before the update…",
"update.phase.done": "Updated",
"update.phase.failed": "The update did not succeed",
"update.phase.pinning": "Downloading the new version…",
"update.phase.pulling": "Downloading the new version…",
"update.phase.safety-dump": "Database snapshot…",
"update.phase.starting": "Starting the new version…",
"update.phase.undoing": "Putting the previous version back…",
"update.phase.undone": "Put back to the previous version"
"update.phase.undone": "Put back to the previous version",
"update.phase.verifying": "Checking that it works…",
"update.refusal.already": "An update of %s is already in progress.",
"update.refusal.busy": "The update cannot start right now: a backup or restore is running. Try again when it has finished.",
"update.refusal.deploying": "%s is still being installed — the update can start after that.",
"update.refusal.disk": "Not enough free space for the update: %.1f GB free, and at least %.0f GB is needed to download the new version.",
"update.refusal.migrating": "The update cannot start right now: data is being moved.",
"update.refusal.no_backup": "%s cannot be updated, because it has no backup it could be restored from, and no new backup can be made of it now. Check on the Backups page that the app's drive is available — after that the update can start.",
"update.refusal.not_deployed": "The app is not installed, so it cannot be updated."
}
+38 -1
View File
@@ -1347,6 +1347,7 @@
"event.app_deploy_started": "Alkalmazás telepítése elindult: %s",
"event.app_deployed": "Alkalmazás telepítve: %s",
"event.app_removed": "Alkalmazás eltávolítva: %s",
"event.app_update_undone": "A(z) %s frissítése %s-kor nem sikerült. A doboz automatikusan visszaállította az előző változatot és az adatokat — semmi nem veszett el, nincs teendőd.",
"event.backup_target_absent": "A rendszermentés meghajtója nem érhető el: %s (%s)",
"event.backup_target_restored": "A rendszermentés meghajtója újra elérhető: %s (%s)",
"event.controller_started": "Controller elindult (%s)",
@@ -1465,6 +1466,15 @@
"func.time.tomorrow_at": "holnap %s",
"func.time.yesterday": "tegnap",
"health.no_probe_container": "Nem futott egészségellenőrzés: nincs hozzá tartozó konténer.",
"hold.copy_holds.db_only": "csak a beállításokat és az adatbázist tartalmazza, a fájlokat nem",
"hold.copy_holds.files": "a beállításokat, az adatbázist és a fájlokat tartalmazza",
"hold.copy_holds.volumes": "a beállításokat, az adatbázist és az adatköteteket tartalmazza",
"hold.update.sentence": "A(z) %s frissítése %s-kor nem sikerült, és az alkalmazás nem indult el az új verzióval. Az alkalmazás biztonsági okból leállítva marad, hogy az adatai ne sérüljenek. Visszaállítható a Mentések oldalon ebből a biztonsági mentésből: %s, %s — ez a másolat %s.",
"hold.update.sentence_legacy": "A(z) %s frissítése %s-kor nem sikerült, és az alkalmazás nem indult el az új verzióval. Az alkalmazás biztonsági okból leállítva marad, hogy az adatai ne sérüljenek. Visszaállítható a(z) %s-i biztonsági mentésből a Mentések oldalon.",
"hold.update.sentence_tier": "A(z) %s frissítése %s-kor nem sikerült, és az alkalmazás nem indult el az új verzióval. Az alkalmazás biztonsági okból leállítva marad, hogy az adatai ne sérüljenek. Visszaállítható a Mentések oldalon ebből a biztonsági mentésből: %s, %s.",
"hold.update.tier.1": "saját meghajtó",
"hold.update.tier.2": "második meghajtó",
"hold.update.tier.3": "távoli mentés",
"hold.update.undo_failed": "A frissítés nem sikerült, és az automatikus visszaállítás sem.",
"hold.update.undo_state.half": "Az adatok visszamásolása félbeszakadt, ezért az adatok vegyes állapotban vannak; a frissítés előtti másolat megmaradt.",
"hold.update.undo_state.not_started": "Az adatok a frissítés előtti állapotba kerültek vissza, de az előző változat nem indult el.",
@@ -1796,6 +1806,8 @@
"settings_notifications.alkalmazas_email": "Alkalmazás-email",
"settings_notifications.alkalmazas_email_engedelyezese": "Alkalmazás-email engedélyezése",
"settings_notifications.alkalmazas_nem_fut": "Alkalmazás nem fut",
"settings_notifications.app_update_held": "Frissítés sikertelen, az alkalmazás leállítva",
"settings_notifications.app_update_undone": "Frissítés visszavonva (az alkalmazás a korábbi változattal fut)",
"settings_notifications.az_alkalmazasok_a_felhom_on": "Az alkalmazások a Felhom-on keresztül küldhetnek emailt (pl. jelszó-visszaállítás, meghívók),\n külön email-szolgáltató beállítása nélkül. A feladó címe minden alkalmazásnál a saját\n <em>&lt;alkalmazás&gt;@felhom.eu</em> címe lesz.",
"settings_notifications.az_ertesitesek_a_kozponti_rendszeren": "Az értesítések a központi rendszeren keresztül működnek, ami jelenleg nincs bekapcsolva.",
"settings_notifications.beallitasok_ertesitesek": "Beállítások — Értesítések",
@@ -2332,7 +2344,32 @@
"tier2_config.nincs_elerheto_off_drive_cel": "Nincs elérhető off-drive cél",
"tier2_config.nincs_masik_adatmeghajto_automatikus_cel": "Nincs másik adatmeghajtó — automatikus cél a belső SSD (csak DB/konfiguráció). Egy 2.\n adatmeghajtó hozzáadásával a teljes adat is off-drive menthető.",
"tier2_config.vissza_a_mentesekhez": "← Vissza a mentésekhez",
"update.error.backup_failed": "A frissítés nem indult el, mert a frissítés előtti biztonsági mentés nem sikerült: %v. Az alkalmazás változatlanul fut tovább.",
"update.error.backup_no_unit": "A frissítés nem indult el: a frissítés előtti mentés lefutott, de nem jött létre friss, visszaállítható másolat. Az alkalmazás változatlanul fut tovább.",
"update.error.dump_failed": "A frissítés nem indult el, mert az adatbázis pillanatkép nem készült el: %v. Az alkalmazás változatlanul fut tovább.",
"update.error.hold_unsaved": "A frissítés nem sikerült, az alkalmazás le lett állítva, de a leállítás rögzítése nem sikerült. Ne indítsd újra — vedd fel velünk a kapcsolatot.",
"update.error.interrupted": "A frissítés megszakadt, mert a vezérlő újraindult, mielőtt az új verzió elindult volna. Az alkalmazás a korábbi verzióval fut tovább.",
"update.error.journal_failed": "A frissítés nem indult el: a frissítés naplója nem menthető. Az alkalmazás változatlanul fut tovább.",
"update.error.no_guards": "A frissítés nem indítható: a frissítés előtti biztonsági ellenőrzés nem érhető el ezen a szerveren.",
"update.error.pin_failed": "A frissítés nem indult el: az új verzió leírása nem olvasható be. Az alkalmazás változatlanul fut tovább.",
"update.error.pull_failed": "Az új verzió letöltése nem sikerült, ezért a frissítés elmaradt. Az alkalmazás a korábbi verzióval fut tovább.",
"update.phase.backing-up": "Biztonsági mentés készül a frissítés előtt…",
"update.phase.checking": "Ellenőrzés…",
"update.phase.copying": "Az adatok másolása a frissítés előtt…",
"update.phase.done": "Frissítve",
"update.phase.failed": "A frissítés nem sikerült",
"update.phase.pinning": "Új verzió letöltése…",
"update.phase.pulling": "Új verzió letöltése…",
"update.phase.safety-dump": "Adatbázis pillanatkép…",
"update.phase.starting": "Indítás az új verzióval…",
"update.phase.undoing": "Visszaállítás az előző változatra…",
"update.phase.undone": "Visszaállítva az előző változatra"
"update.phase.undone": "Visszaállítva az előző változatra",
"update.phase.verifying": "Működés ellenőrzése…",
"update.refusal.already": "A(z) %s frissítése már folyamatban van.",
"update.refusal.busy": "A frissítés most nem indítható: mentés/visszaállítás folyamatban. Próbáld újra, ha befejeződött.",
"update.refusal.deploying": "A(z) %s telepítése még folyamatban van — a frissítés utána indítható.",
"update.refusal.disk": "Nincs elég szabad hely a frissítéshez: %.1f GB szabad, az új verzió letöltéséhez legalább %.0f GB szükséges.",
"update.refusal.migrating": "A frissítés most nem indítható: adatáthelyezés folyamatban.",
"update.refusal.no_backup": "A(z) %s nem frissíthető, mert nincs olyan biztonsági mentése, amelyből vissza lehetne állítani, és most új mentés sem készíthető róla. Ellenőrizd a Mentések oldalon, hogy az alkalmazás meghajtója elérhető-e — utána a frissítés elindítható.",
"update.refusal.not_deployed": "Az alkalmazás nincs telepítve, ezért nem frissíthető."
}
+73
View File
@@ -42,6 +42,7 @@ type Notifier struct {
mu sync.Mutex
prevHealthStatus string // tracks previous health check status for change detection
droppedSeen map[string]bool // R-620: event types a DISABLED notifier has already said it dropped
// oomSeen (R-514) remembers container runs already reported as OOM-killed.
oomSeen map[string]bool
@@ -267,6 +268,7 @@ func (n *Notifier) pushEventBoth(eventType, severity, message, messageCustomer s
return
}
if !n.enabled {
n.dropped(eventType, severity)
return
}
@@ -357,6 +359,7 @@ func (n *Notifier) pushEventBoth(eventType, severity, message, messageCustomer s
// Detects both degradation (ok→warn, ok→fail, warn→fail) and recovery (fail→ok, warn→ok, fail→warn).
func (n *Notifier) NotifyHealthChange(status string, issues, warnings []string) {
if !n.enabled {
n.dropped("health_change", status)
return
}
@@ -1056,6 +1059,7 @@ type notifyRequest struct {
// No local cooldown — Hub handles cooldowns.
func (n *Notifier) Notify(eventType, severity, message, details string) {
if !n.enabled {
n.dropped(eventType, severity)
return
}
@@ -1138,3 +1142,72 @@ func (n *Notifier) NotifyWholeGuestBackupRecovered(tier, message string) {
n.PushEvent("whole_guest_backup_recovered", "info", message,
WholeGuestBackupDetails{Tier: tier})
}
// dropped is R-620: a DISABLED notifier (no hub configured) says what it drops. Once per event type per
// process at WARN — naming the type and the severity — and DEBUG for every repeat, so a box whose hub
// configuration is absent or broken leaves a greppable trace where the alarm vanished, instead of one
// INFO line at its last start. Measured 2026-09-21 on guest 9202: an update night's whole event half
// was unmeasurable because every event vanished without a word.
func (n *Notifier) dropped(eventType, severity string) {
n.mu.Lock()
if n.droppedSeen == nil {
n.droppedSeen = map[string]bool{}
}
first := !n.droppedSeen[eventType]
n.droppedSeen[eventType] = true
n.mu.Unlock()
if n.logger == nil {
return
}
if first {
n.logger.Printf("[WARN] notifier disabled (no hub configured): DROPPED event %s (severity %s) — further %s events are logged at DEBUG only", eventType, severity, eventType)
return
}
if n.debug {
n.logger.Printf("[DEBUG] notifier disabled: dropped event %s (severity %s)", eventType, severity)
}
}
// AppUpdateDetails is the payload of app_update_undone / app_update_held (v0.264.0). `stack_name` is
// what the hub's per-app cooldown keys on (R-389's register), so two apps on one night are two mails.
type AppUpdateDetails struct {
App string `json:"app"`
StackName string `json:"stack_name"`
From map[string]string `json:"from,omitempty"`
To map[string]string `json:"to,omitempty"`
At string `json:"at"`
CopyTier int `json:"copy_tier,omitempty"`
CopyDate string `json:"copy_date,omitempty"`
CopyHolds string `json:"copy_holds,omitempty"`
}
// budapestMinute renders a time as the household reads it everywhere else (the hold sentence, the
// page): Europe/Budapest, minute precision.
func budapestMinute(t time.Time) string {
if loc, err := time.LoadLocation("Europe/Budapest"); err == nil {
t = t.In(loc)
}
return t.Format("2006-01-02 15:04")
}
// NotifyAppUpdateUndone (v0.264.0, `09` §3 decision 15): the box put a failed update back by itself.
// Severity warning: nothing was lost and nothing needs doing, but the household asked for a new version
// and did not get it. The sentence is ONE bundle key rendered twice (R-558).
func (n *Notifier) NotifyAppUpdateUndone(app string, from, to map[string]string, at time.Time) {
when := budapestMinute(at)
n.pushEventMsg("app_update_undone", "warning", "event.app_update_undone",
AppUpdateDetails{App: app, StackName: app, From: from, To: to, At: at.UTC().Format(time.RFC3339)}, app, when)
}
// NotifyAppUpdateHeld (v0.264.0): the update — or its undo — failed and the app is HELD STOPPED.
// `sentence` renders the hold's OWN sentence in a language (backup.RestoreHoldForLang, wired in
// main.go), so the mail says exactly what the page says: what happened, which copy brings it back,
// and what that copy holds. Severity error: the household must act (a restore).
func (n *Notifier) NotifyAppUpdateHeld(d AppUpdateDetails, sentence func(lang string) string) {
hu := sentence(i18n.Default)
household := ""
if lang := n.boxLang(); lang != i18n.Default {
household = sentence(lang)
}
n.pushEventBoth("app_update_held", "error", hu, household, d)
}
@@ -0,0 +1,104 @@
package notify
import (
"bytes"
"log"
"path/filepath"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// v0.264.0 — the two update events on the wire, and R-620.
type pushRec struct {
typ, sev, msg, cust string
details interface{}
}
func recNotifier(t *testing.T, lang string) (*Notifier, *[]pushRec) {
t.Helper()
var buf bytes.Buffer
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(&buf, "", 0))
if err != nil {
t.Fatal(err)
}
if lang != "hu" {
if err := sett.SetLanguage(lang); err != nil {
t.Fatal(err)
}
}
n := &Notifier{settings: sett, logger: log.New(&buf, "", 0)}
var got []pushRec
n.pushFn = func(typ, sev, msg, cust string, d interface{}) { got = append(got, pushRec{typ, sev, msg, cust, d}) }
return n, &got
}
func TestUpdateEvents_UndoneWireText(t *testing.T) {
at := time.Date(2026, 9, 23, 10, 3, 0, 0, time.UTC) // 12:03 Budapest
for _, tc := range []struct{ lang, cust string }{
{"hu", ""},
{"en", "The update of docmost at 2026-09-23 12:03 did not work. The box put back the previous version and its data automatically — nothing was lost, and there is nothing you need to do."},
} {
n, got := recNotifier(t, tc.lang)
n.NotifyAppUpdateUndone("docmost", map[string]string{"docmost": "a:1"}, map[string]string{"docmost": "a:2"}, at)
if len(*got) != 1 {
t.Fatalf("%s: %d events", tc.lang, len(*got))
}
r := (*got)[0]
if r.typ != "app_update_undone" || r.sev != "warning" {
t.Errorf("%s: type/severity = %s/%s", tc.lang, r.typ, r.sev)
}
if r.msg != "A(z) docmost frissítése 2026-09-23 12:03-kor nem sikerült. A doboz automatikusan visszaállította az előző változatot és az adatokat — semmi nem veszett el, nincs teendőd." {
t.Errorf("%s: the wire message must be the Hungarian, got %q", tc.lang, r.msg)
}
if r.cust != tc.cust {
t.Errorf("%s: household copy = %q", tc.lang, r.cust)
}
if d, ok := r.details.(AppUpdateDetails); !ok || d.StackName != "docmost" || d.App != "docmost" || d.From["docmost"] != "a:1" {
t.Errorf("%s: details must carry stack_name (the hub's per-app cooldown key) and the step, got %+v", tc.lang, r.details)
}
}
}
func TestUpdateEvents_HeldCarriesTheHoldSentenceInBothLanguages(t *testing.T) {
sentence := func(lang string) string { return "HOLD-" + lang }
n, got := recNotifier(t, "en")
n.NotifyAppUpdateHeld(AppUpdateDetails{App: "romm", StackName: "romm", CopyTier: 1}, sentence)
r := (*got)[0]
if r.typ != "app_update_held" || r.sev != "error" || r.msg != "HOLD-hu" || r.cust != "HOLD-en" {
t.Errorf("got %+v", r)
}
n2, got2 := recNotifier(t, "hu")
n2.NotifyAppUpdateHeld(AppUpdateDetails{App: "romm", StackName: "romm"}, sentence)
if r := (*got2)[0]; r.cust != "" {
t.Errorf("a Hungarian household sends no second copy (R-558), got %q", r.cust)
}
}
// R-620. COMPANION RED-PROOF (REPORT.md): delete the n.dropped call in pushEventBoth — zero WARN
// lines, and this test fails.
func TestR620_DisabledNotifierSaysWhatItDrops(t *testing.T) {
var buf bytes.Buffer
n := New("", "", "c1", nil, log.New(&buf, "", 0), false) // no hub → disabled
buf.Reset()
n.PushEvent("app_update_undone", "warning", "x", nil)
n.PushEvent("app_update_undone", "warning", "y", nil)
n.PushEvent("backup_failed", "error", "z", nil)
warns := 0
for _, l := range strings.Split(buf.String(), "\n") {
if strings.Contains(l, "[WARN]") && strings.Contains(l, "DROPPED event") {
warns++
}
}
if warns != 2 {
t.Fatalf("want exactly TWO WARN lines (one per event type), got %d:\n%s", warns, buf.String())
}
for _, want := range []string{"DROPPED event app_update_undone (severity warning)", "DROPPED event backup_failed (severity error)"} {
if !strings.Contains(buf.String(), want) {
t.Errorf("missing %q in:\n%s", want, buf.String())
}
}
}
@@ -0,0 +1,48 @@
package settings
import (
"io"
"log"
"os"
"path/filepath"
"testing"
)
// v0.264.0 — the one-time, ADD-ONLY seed of the two update event types into a household's stored prefs.
//
// COMPANION RED-PROOF (REPORT.md): drop the seedAppUpdateEvents call from Load — the first assertion fails.
func TestSeedAppUpdateEvents_AddOnlyOnce(t *testing.T) {
p := filepath.Join(t.TempDir(), "settings.json")
if err := os.WriteFile(p, []byte(`{"notifications":{"email":"h@example.hu","enabled_events":["backup_failed"]}}`), 0o600); err != nil {
t.Fatal(err)
}
lg := log.New(io.Discard, "", 0)
s, err := Load(p, lg)
if err != nil {
t.Fatal(err)
}
ev := s.Notifications.EnabledEvents
has := func(list []string, x string) bool {
for _, e := range list {
if e == x {
return true
}
}
return false
}
if !has(ev, "backup_failed") || !has(ev, "app_update_undone") || !has(ev, "app_update_held") {
t.Fatalf("the household's choice must be kept and both types added, got %v", ev)
}
// The household opts out of one; a restart must NOT add it back.
s.Notifications.EnabledEvents = []string{"backup_failed", "app_update_held"}
if err := s.save(); err != nil {
t.Fatal(err)
}
s2, err := Load(p, lg)
if err != nil {
t.Fatal(err)
}
if has(s2.Notifications.EnabledEvents, "app_update_undone") {
t.Errorf("a later opt-out must stick — the seed runs once, got %v", s2.Notifications.EnabledEvents)
}
}
+31
View File
@@ -75,6 +75,9 @@ type Settings struct {
// existing customer's stored prefs. Persisted so a later opt-out sticks (the 3a-fix getter append
// re-enabled it on every read — this replaces it).
OffboxEnlargeNoticeSeeded bool `json:"offbox_enlarge_notice_seeded,omitempty"`
// AppUpdateEventsSeeded (v0.264.0) guards the ONE-TIME add of app_update_undone / app_update_held
// into an existing household's stored prefs (seedAppUpdateEvents).
AppUpdateEventsSeeded bool `json:"app_update_events_seeded,omitempty"`
// HubEscrowIdentityPresent (v0.199.0, R-204 item 4 / R-193) caches the report ACK's
// `escrow.identity_blob_present` — whether the HUB is holding a sealed recovery package for this
@@ -604,6 +607,10 @@ var DefaultEnabledEvents = []string{
"expected_backup_missed",
"expected_dbdump_missed",
"offbox_enlarge_blocked", // 3a-fix (warning-class): remote enlargement refused by the quota gate
// v0.264.0 (`09` §3 decision 15): the household is told ONCE when an update is undone, and when it
// (or its undo) failed and the app is held. On by default; seeded into existing prefs once below.
"app_update_undone",
"app_update_held",
}
// PendingEvent is an event queued for the next Hub push cycle.
@@ -697,6 +704,7 @@ func Load(path string, logger *log.Logger) (*Settings, error) {
}
s.migrateResticToRsync()
s.seedOffboxEnlargeNotice()
s.seedAppUpdateEvents()
return s, nil
}
@@ -718,6 +726,29 @@ func (s *Settings) seedOffboxEnlargeNotice() {
}
}
// seedAppUpdateEvents runs ONCE (guarded by AppUpdateEventsSeeded, v0.264.0) — the
// seedOffboxEnlargeNotice shape. A household whose stored prefs predate app_update_undone /
// app_update_held gets both appended: they could not have switched off a type that did not exist.
// ADD-ONLY, never removes a choice; persisted, so a LATER opt-out sticks. The hub runs the same one-time
// add on its side — BOTH are needed, because this list is pushed to the hub on every save of the
// notification page and would otherwise take the types away again.
func (s *Settings) seedAppUpdateEvents() {
if s.AppUpdateEventsSeeded {
return
}
s.AppUpdateEventsSeeded = true
if s.Notifications != nil && s.Notifications.EnabledEvents != nil {
s.Notifications.EnabledEvents = appendIfAbsent(s.Notifications.EnabledEvents, "app_update_undone")
s.Notifications.EnabledEvents = appendIfAbsent(s.Notifications.EnabledEvents, "app_update_held")
if s.log != nil {
s.log.Printf("[INFO] [settings] app update events added to the household's notification prefs (one-time, add-only)")
}
}
if err := s.save(); err != nil && s.log != nil {
s.log.Printf("[ERROR] [settings] Failed to save the app-update-events seed: %v", err)
}
}
// migrateResticToRsync converts any cross-drive backup configs using restic to rsync.
// Called once during Load() before the mutex is exposed.
func (s *Settings) migrateResticToRsync() {
+7
View File
@@ -154,6 +154,12 @@ type Stack struct {
UpdatePhase string `json:"update_phase,omitempty"`
UpdatePhaseLabel string `json:"update_phase_label,omitempty"`
UpdateError string `json:"update_error,omitempty"`
// UpdateErrorKey / UpdateErrorArgs (v0.264.0, R-606) are UpdateError as a bundle key + arguments,
// so a page renders it in the READER's language (UpdateErrorIn). UpdateError stays the Hungarian —
// what the API, the logs and older readers have always had. Empty key = an old or composed sentence,
// rendered as stored, never as an empty line.
UpdateErrorKey string `json:"-"`
UpdateErrorArgs []interface{} `json:"-"`
// updateHeld (R-480, v0.240.0) — the last update ended HELD, so UpdateError is the hold's own
// sentence („… leállítva marad"). It is shown only while that hold is in force; fillHoldReason.
updateHeld bool
@@ -243,6 +249,7 @@ type Manager struct {
undoCopier volumeCopier
updateUndoHealthFn func(ctx context.Context, name string, timeout time.Duration, meta *Metadata) (bool, string)
probeRunFn func(t probeTarget) *HealthProbeResult // the health wait's network probe; nil ⇒ runChecks
updateEventSink func(UpdateEvent) // v0.264.0: the notifier; nil ⇒ no events
updateMemoryFn func(newReqMB, newLimitMB, releasedReqMB, releasedLimitMB int) (refusal error, warning string)
updateDiskFreeFn func() (freeGiB float64, ok bool)
updateNowFn func() time.Time
+84
View File
@@ -401,6 +401,7 @@ func (m *Manager) tryUndo(ctx context.Context, name, dir, why string, entry *upd
_ = m.RefreshStatus()
m.clearJournal(name)
m.finishUpdate(name, UpdatePhaseUndone, "")
m.emitUpdateEvent(UpdateEventUndone, name, entry, UpdateRestorePoint{}, true)
m.logger.Printf("[INFO] [stacks] update %s: UNDONE in %s — the previous version is running on the data from before the update (%s)", name, m.now().Sub(start).Round(time.Second), detail)
return ""
}
@@ -424,3 +425,86 @@ func (m *Manager) recordUpdateUndone(name, dir string, u *UpdateUndone) {
}
m.mu.Unlock()
}
// ── The household and the operator are TOLD (v0.264.0, `09` §3 decision 15, §6.4 part 2) ─────────
// Update event kinds — the hub event types, one register for both repos (hub allowedEventTypes).
const (
UpdateEventUndone = "app_update_undone" // the box put the previous version back — warning
UpdateEventHeld = "app_update_held" // the update (or its undo) failed and the app is HELD — error
)
// UpdateEvent is what the update job hands to the notifier. The stacks package composes no sentence
// for it: the notifier renders the undone line from a bundle key, and the held line is the hold's own
// sentence, rendered by the backup side in each language (main.go wires both).
type UpdateEvent struct {
Kind string
App string
From, To map[string]string
At time.Time
CopyTier int
CopyDate time.Time
// HoldRecorded is false when the hold could not be persisted — the event still goes (the operator
// must hear about exactly that), and the household sentence is then update.error.hold_unsaved.
HoldRecorded bool
}
// SetUpdateEventSink wires the notifier (main.go). INIT-ONLY. Nil = no events (tests, setup mode).
// Pinned by TestUpdateEventSinkIsWiredAtStartup (an AST walk of main.go).
func (m *Manager) SetUpdateEventSink(fn func(UpdateEvent)) {
m.mu.Lock()
m.updateEventSink = fn
m.mu.Unlock()
}
// emitUpdateEvent sends ONE event for an undone or held update. Called exactly once per outcome by
// tryUndo (undone) and failAndHold (held) — the job never retries (decision 15), so one outcome is one
// event; the hub's per-app cooldown is the second belt against a storm (R-629).
func (m *Manager) emitUpdateEvent(kind, name string, entry *updateJournalEntry, rp UpdateRestorePoint, holdRecorded bool) {
m.mu.RLock()
sink := m.updateEventSink
m.mu.RUnlock()
if sink == nil {
return
}
ev := UpdateEvent{Kind: kind, App: name, At: m.now(), CopyTier: rp.Tier, CopyDate: rp.ProvenAt, HoldRecorded: holdRecorded}
if entry != nil {
ev.From, ev.To = entry.PrevPin, entry.NewPin
}
m.logger.Printf("[INFO] [stacks] update %s: event %s (hold recorded: %v)", name, kind, holdRecorded)
sink(ev)
}
// BackfillAppliedMeta is R-646's startup pass (v0.264.0), beside AdoptPins. An app pinned before
// v0.263.2 has no applied-meta record, so its FIRST undo would judge the old version with whatever
// .felhom.yml the catalog sync last put in place. For an app that is CURRENT with the catalog, that file
// IS the pinned version's own — so it is recorded now. An app that is Behind or Unknown is skipped and
// NAMED: its pinned version's file is already gone, and guessing it would be worse than saying so.
// Idempotent; never overwrites an existing record. Returns (recorded, skipped) app names.
func (m *Manager) BackfillAppliedMeta() (recorded, skipped []string) {
for _, st := range m.GetStacks() {
if !st.Deployed || st.AppConfig == nil || len(st.AppConfig.PinnedImages) == 0 {
continue
}
dir := filepath.Dir(st.ComposePath)
if _, err := os.Stat(filepath.Join(dir, appliedMetaDir, ".felhom.yml")); err == nil {
continue // already recorded — never overwritten
}
if CatalogOrder(st) != UpdateOrderCurrent {
skipped = append(skipped, st.Name)
continue
}
b, err := os.ReadFile(filepath.Join(dir, ".felhom.yml"))
if err == nil {
err = storeAppliedMeta(dir, b)
}
if err != nil {
m.logger.Printf("[WARN] [stacks] applied-meta backfill: %s: %v", st.Name, err)
skipped = append(skipped, st.Name)
continue
}
recorded = append(recorded, st.Name)
}
m.logger.Printf("[INFO] [stacks] applied-meta backfill (R-646): recorded %d %v; skipped %d %v — not current with the catalog, their pinned version's .felhom.yml is no longer on the box", len(recorded), recorded, len(skipped), skipped)
return recorded, skipped
}
+78 -39
View File
@@ -9,6 +9,7 @@ import (
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
)
@@ -352,26 +353,26 @@ func (m *Manager) UpdatePreflight(name string) *UpdateRefusal {
return m.refuseUpdate(name, "not_found", fmt.Sprintf("stack %q not found", name), "no such stack")
}
if !st.Deployed {
return m.refuseUpdate(name, "not_deployed", MsgUpdateNotDeployed, "not deployed")
return m.refuseUpdateErr(name, "not_deployed", util.MsgError("update.refusal.not_deployed"), "not deployed")
}
g := m.guards()
if g == nil {
return m.refuseUpdate(name, "guards_unwired", MsgUpdateNoGuards, "no UpdateGuards wired — fail closed")
return m.refuseUpdateErr(name, "guards_unwired", util.MsgError("update.error.no_guards"), "no UpdateGuards wired — fail closed")
}
if st.Deploying {
return m.refuseUpdate(name, "deploying", fmt.Sprintf(MsgUpdateDeployingFmt, name), "a deploy is in progress")
return m.refuseUpdateErr(name, "deploying", util.MsgError("update.refusal.deploying", name), "a deploy is in progress")
}
if st.Updating {
return m.refuseUpdate(name, "updating", fmt.Sprintf(MsgUpdateAlreadyFmt, name), "an update is already in progress")
return m.refuseUpdateErr(name, "updating", util.MsgError("update.refusal.already", name), "an update is already in progress")
}
if held, why := g.HoldFor(name); held {
return m.refuseUpdate(name, "held", why, "the app is held")
}
if busy, why := g.Busy(name); busy {
return m.refuseUpdate(name, "busy", MsgUpdateBusy, why)
return m.refuseUpdateErr(name, "busy", util.MsgError("update.refusal.busy"), why)
}
if m.IsMigrating() {
return m.refuseUpdate(name, "migrating", MsgUpdateMigrating, "a data migration is running")
return m.refuseUpdateErr(name, "migrating", util.MsgError("update.refusal.migrating"), "a data migration is running")
}
// v0.261.0 — the other half of the self-update lock. The controller's swap restarts this process;
// starting an app update into that is how an update loses its own supervisor mid-flight. TRANSIENT:
@@ -408,7 +409,7 @@ func (m *Manager) UpdatePreflight(name string) *UpdateRefusal {
// with a copy but no way to back up is refused too.
if canBackUp, why := g.CanBackUp(name); !canBackUp {
if _, found, seen := g.RestorePoints(context.Background(), name, nil); !found {
return m.refuseUpdate(name, "no_backup", fmt.Sprintf(MsgUpdateNoBackupFmt, name),
return m.refuseUpdateErr(name, "no_backup", util.MsgError("update.refusal.no_backup", name),
fmt.Sprintf("no copy on any tier (found: %s) and no backup can be taken now: %s", describeRestorePoints(m.now(), seen), why))
}
m.logger.Printf("[WARN] [stacks] update %s: no backup can be taken now (%s) — an existing copy must carry the update", name, why)
@@ -421,7 +422,7 @@ func (m *Manager) UpdatePreflight(name string) *UpdateRefusal {
case !known:
m.logger.Printf("[WARN] [stacks] update %s: free space on the Docker data root is unreadable — proceeding without the %.0f GB floor", name, updateDiskFloorGiB)
case free < updateDiskFloorGiB:
return m.refuseUpdate(name, "disk", fmt.Sprintf(MsgUpdateDiskFmt, free, updateDiskFloorGiB),
return m.refuseUpdateErr(name, "disk", util.MsgError("update.refusal.disk", free, updateDiskFloorGiB),
fmt.Sprintf("%.2f GiB free on the Docker data root, floor %.0f GiB (fixed floor — image size unknown)", free, updateDiskFloorGiB))
}
return nil
@@ -478,7 +479,7 @@ func (m *Manager) StartGuardedUpdate(name string) error {
// A second press between the preflight and here is the race this lock closes.
if s.Updating || s.Deploying {
m.mu.Unlock()
return m.refuseUpdate(name, "updating", fmt.Sprintf(MsgUpdateAlreadyFmt, name), "lost the race for the Updating flag")
return m.refuseUpdateErr(name, "updating", util.MsgError("update.refusal.already", name), "lost the race for the Updating flag")
}
s.Updating, s.UpdateError, s.updateHeld = true, "", false
s.UpdatePhase, s.UpdatePhaseLabel = UpdatePhaseChecking, UpdatePhaseLabel(UpdatePhaseChecking)
@@ -552,17 +553,49 @@ func (m *Manager) setUpdatePhase(name, phase string) {
m.mu.Unlock()
}
// finishUpdate is the ONE place Updating goes false. msg is the customer sentence on failure.
// finishUpdate is the ONE place Updating goes false. msg is the customer sentence on failure — a
// finished one (the hold's own sentence, or none). A sentence the job owns goes through finishUpdateKey.
func (m *Manager) finishUpdate(name, phase, msg string) {
m.finishUpdateKey(name, phase, "", msg)
}
// finishUpdateKey is finishUpdate with the sentence as a bundle KEY (v0.264.0, R-606): UpdateError
// keeps the Hungarian (byte-identical to the MsgUpdate* literal it replaced), and the key + args ride
// beside it for the page. key "" = `plain` is a finished sentence and is stored as it is.
func (m *Manager) finishUpdateKey(name, phase, key, plain string, args ...interface{}) {
msg := plain
if key != "" {
msg = util.Text(i18n.Default, key, args...)
}
m.mu.Lock()
if s, ok := m.stacks[name]; ok {
s.Updating = false
s.UpdatePhase, s.UpdatePhaseLabel = phase, UpdatePhaseLabel(phase)
s.UpdateError = msg
s.UpdateError, s.UpdateErrorKey, s.UpdateErrorArgs = msg, key, args
}
m.mu.Unlock()
}
// UpdatePhaseLabelIn is a phase's label in lang (v0.264.0, R-606). Hungarian is the updatePhaseLabels
// map itself; another language reads `update.phase.<phase>` and falls back to the Hungarian.
func UpdatePhaseLabelIn(lang, phase string) string {
if lang == i18n.Default || phase == "" {
return UpdatePhaseLabel(phase)
}
if b, err := i18n.Shared(); err == nil && b.Has(lang, "update.phase."+phase) {
return b.Msg(lang, "update.phase."+phase)
}
return UpdatePhaseLabel(phase)
}
// UpdateErrorIn is the stack's update sentence in lang (v0.264.0, R-606).
func (s Stack) UpdateErrorIn(lang string) string {
if s.UpdateErrorKey == "" || lang == i18n.Default {
return s.UpdateError
}
return util.Text(lang, s.UpdateErrorKey, s.UpdateErrorArgs...)
}
func (m *Manager) updateCompose(dir string, env []string, args ...string) (string, error) {
if m.updateComposeFn != nil {
return m.updateComposeFn(dir, env, args...)
@@ -608,18 +641,18 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) {
dir := filepath.Dir(st.ComposePath)
g := m.guards()
entry := updateJournalEntry{StartedAt: start}
fail := func(msg, detail string) {
fail := func(key, detail string, args ...interface{}) {
m.logger.Printf("[ERROR] [stacks] update %s FAILED in phase %s after %s — nothing was moved: %s", name, entry.Phase, m.now().Sub(start).Round(time.Millisecond), detail)
m.clearJournal(name)
m.finishUpdate(name, UpdatePhaseFailed, msg)
m.finishUpdateKey(name, UpdatePhaseFailed, key, "", args...)
}
if !m.enterUpdatePhase(name, &entry, UpdatePhaseChecking) {
m.finishUpdate(name, UpdatePhaseFailed, MsgUpdateJournalFailed)
m.finishUpdateKey(name, UpdatePhaseFailed, "update.error.journal_failed", "")
return
}
if g == nil {
fail(MsgUpdateNoGuards, "no UpdateGuards wired")
fail("update.error.no_guards", "no UpdateGuards wired")
return
}
// R-475: the precondition is a copy on ANY tier, chosen in the order 2, 1, 3, and the age rule
@@ -633,17 +666,17 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) {
} else {
m.logger.Printf("[INFO] [stacks] update %s: no usable copy on any tier — younger than %s and not older than this install's deploy (%s) (found: %s) — backing up first", name, maxAge, fmtDeployTime(deployedAt), describeRestorePoints(start, seen))
if !m.enterUpdatePhase(name, &entry, UpdatePhaseBackingUp) {
fail(MsgUpdateJournalFailed, "journal write failed")
fail("update.error.journal_failed", "journal write failed")
return
}
if err := g.BackupNow(ctx, name); err != nil {
fail(fmt.Sprintf(MsgUpdateBackupFailFmt, err), "pre-update backup: "+err.Error())
fail("update.error.backup_failed", "pre-update backup: "+err.Error(), err.Error())
return
}
now := m.now()
rp, ok, seen = g.RestorePoints(ctx, name, usableRestorePoint(now, maxAge, deployedAt))
if !ok {
fail(MsgUpdateBackupNoUnit, fmt.Sprintf("after the backup there is still no copy younger than %s on any tier (found: %s)", maxAge, describeRestorePoints(now, seen)))
fail("update.error.backup_no_unit", fmt.Sprintf("after the backup there is still no copy younger than %s on any tier (found: %s)", maxAge, describeRestorePoints(now, seen)))
return
}
m.logger.Printf("[INFO] [stacks] update %s: precondition met after the backup — %s copy from %s", name, updateTierName(rp.Tier), rp.ProvenAt.UTC().Format(time.RFC3339))
@@ -653,12 +686,12 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) {
// SAFETY DUMP BEFORE THE PIN MOVES — "a minute ago", before any migration can have run.
if !m.enterUpdatePhase(name, &entry, UpdatePhaseSafetyDump) {
fail(MsgUpdateJournalFailed, "journal write failed")
fail("update.error.journal_failed", "journal write failed")
return
}
paths, err := g.SafetyDump(ctx, name)
if err != nil {
fail(fmt.Sprintf(MsgUpdateDumpFailFmt, err), "safety dump: "+err.Error())
fail("update.error.dump_failed", "safety dump: "+err.Error(), err.Error())
return
}
m.logger.Printf("[INFO] [stacks] update %s: safety dump done (%d file(s)) %v", name, len(paths), paths)
@@ -668,9 +701,9 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) {
undoVols, perr := m.planUndoCopies(name)
if perr != nil {
if se, ok := perr.(*undoSpaceError); ok {
fail(undoMsg("err.stacks.update_undo_space", se.need, se.free, updateDiskFloorGiB), "undo copy: "+perr.Error())
fail("err.stacks.update_undo_space", "undo copy: "+perr.Error(), se.need, se.free, updateDiskFloorGiB)
} else {
fail(undoMsg("err.stacks.update_undo_copy_failed"), "undo copy plan: "+perr.Error())
fail("err.stacks.update_undo_copy_failed", "undo copy plan: "+perr.Error())
}
return
}
@@ -679,11 +712,11 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) {
// at any later instant can put it back (Scenario G).
prevLive, err := os.ReadFile(st.ComposePath)
if err != nil {
fail(MsgUpdatePinFailed, "reading the live compose file: "+err.Error())
fail("update.error.pin_failed", "reading the live compose file: "+err.Error())
return
}
if err := os.WriteFile(filepath.Join(dir, preUpdateComposeFile), prevLive, 0o644); err != nil {
fail(MsgUpdateJournalFailed, "saving the pre-update compose copy: "+err.Error())
fail("update.error.journal_failed", "saving the pre-update compose copy: "+err.Error())
return
}
entry.PrevCompose = filepath.Join(dir, preUpdateComposeFile)
@@ -711,12 +744,12 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) {
}
if !m.enterUpdatePhase(name, &entry, UpdatePhasePinning) {
m.removePreUpdateCopies(dir)
fail(MsgUpdateJournalFailed, "journal write failed")
fail("update.error.journal_failed", "journal write failed")
return
}
if err := m.advancePinToCatalog(name, dir); err != nil {
m.pinBack(name, dir, entry)
fail(MsgUpdatePinFailed, "advancing the pin: "+err.Error())
fail("update.error.pin_failed", "advancing the pin: "+err.Error())
return
}
if cfg := LoadAppConfig(dir); cfg != nil {
@@ -726,7 +759,7 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) {
env := m.stackEnv(dir)
if !m.enterUpdatePhase(name, &entry, UpdatePhasePulling) {
m.pinBack(name, dir, entry)
fail(MsgUpdateJournalFailed, "journal write failed")
fail("update.error.journal_failed", "journal write failed")
return
}
if _, err := m.updateCompose(dir, env, "pull"); err != nil {
@@ -734,7 +767,7 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) {
// state is the old pin and the old file — put both back.
m.pinBack(name, dir, entry)
m.logger.Printf("[ERROR] [stacks] update %s: pull failed — pin and definition PUT BACK; the app was not touched. Docker said: %v", name, err)
fail(MsgUpdatePullFailed, "pull failed: "+err.Error())
fail("update.error.pull_failed", "pull failed: "+err.Error())
return
}
@@ -743,7 +776,7 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) {
// start again.
if !m.enterUpdatePhase(name, &entry, UpdatePhaseCopying) {
m.pinBack(name, dir, entry)
fail(MsgUpdateJournalFailed, "journal write failed")
fail("update.error.journal_failed", "journal write failed")
return
}
if err := m.makeUndoCopies(name, dir, env, undoVols, &entry); err != nil {
@@ -753,7 +786,7 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) {
if _, uerr := m.updateCompose(dir, m.stackEnv(dir), "up", "-d", "--remove-orphans"); uerr != nil {
m.logger.Printf("[ERROR] [stacks] update %s: restarting the previous version after the failed copy also failed: %v", name, uerr)
}
fail(undoMsg("err.stacks.update_undo_copy_failed"), "undo copy: "+err.Error())
fail("err.stacks.update_undo_copy_failed", "undo copy: "+err.Error())
return
}
@@ -842,19 +875,25 @@ func (m *Manager) failAndHold(ctx context.Context, name, dir string, env []strin
m.logger.Printf("[ERROR] [stacks] update %s: stopping the failed app also failed: %v", name, err)
}
}
msg := MsgUpdateHoldUnsaved
holdWhy := ""
if g := m.guards(); g == nil {
m.logger.Printf("[ERROR] [stacks] update %s: no UpdateGuards — the hold CANNOT be recorded", name)
} else if err := g.HoldAfterFailedUpdate(name, m.now(), rp, undoState); err != nil {
m.logger.Printf("[ERROR] [stacks] update %s: %v", name, err)
} else if _, why := g.HoldFor(name); why != "" {
msg = why
} else if _, w := g.HoldFor(name); w != "" {
holdWhy = w
m.markUpdateHeld(name)
}
_ = m.RefreshStatus()
m.clearJournal(name)
m.removePreUpdateCopies(dir)
m.finishUpdate(name, UpdatePhaseFailed, msg)
if holdWhy == "" {
m.finishUpdateKey(name, UpdatePhaseFailed, "update.error.hold_unsaved", "")
} else {
// The hold's own sentence (the page renders it per reader through RestoreHoldForLang).
m.finishUpdate(name, UpdatePhaseFailed, holdWhy)
}
m.emitUpdateEvent(UpdateEventHeld, name, entry, rp, holdWhy != "")
}
// pinBack restores the pin, the stored definition and the live file from the journaled copies, and
@@ -1147,12 +1186,12 @@ func (m *Manager) RecoverUpdates() []string {
case UpdatePhaseChecking, UpdatePhaseBackingUp, UpdatePhaseSafetyDump:
m.logger.Printf("[WARN] [stacks] update recovery: %s was interrupted in %s (started %s) — nothing had moved; dropping it", name, e.Phase, e.StartedAt.Format(time.RFC3339))
m.clearJournal(name)
m.finishUpdate(name, UpdatePhaseFailed, MsgUpdateInterrupted)
m.finishUpdateKey(name, UpdatePhaseFailed, "update.error.interrupted", "")
case UpdatePhasePinning, UpdatePhasePulling:
m.logger.Printf("[WARN] [stacks] update recovery: %s was interrupted in %s (started %s) — nothing had run; putting the pin back", name, e.Phase, e.StartedAt.Format(time.RFC3339))
m.pinBack(name, dir, e)
m.clearJournal(name)
m.finishUpdate(name, UpdatePhaseFailed, MsgUpdateInterrupted)
m.finishUpdateKey(name, UpdatePhaseFailed, "update.error.interrupted", "")
case UpdatePhaseCopying:
// v0.263.0: the app was STOPPED for the copy and nothing new ran. The partial copies go, the
// pin goes back, and the previous version is started again.
@@ -1163,7 +1202,7 @@ func (m *Manager) RecoverUpdates() []string {
m.logger.Printf("[ERROR] [stacks] update recovery: %s: starting the previous version failed: %v", name, err)
}
m.clearJournal(name)
m.finishUpdate(name, UpdatePhaseFailed, MsgUpdateInterrupted)
m.finishUpdateKey(name, UpdatePhaseFailed, "update.error.interrupted", "")
case UpdatePhaseUndoing:
// v0.263.0: a power cut DURING the undo. Resumed like `starting` — the undo runs again from
// the copies (still there: they are removed only after the undo succeeded) and then probes.
@@ -1205,14 +1244,14 @@ func (m *Manager) ResumeInterruptedUpdates(ctx context.Context) int {
for _, name := range names {
st, ok := m.GetStack(name)
if !ok {
m.finishUpdate(name, UpdatePhaseFailed, MsgUpdateInterrupted)
m.finishUpdateKey(name, UpdatePhaseFailed, "update.error.interrupted", "")
continue
}
m.updateJournalMu.Lock()
e, ok := m.readUpdateJournal().Updates[name]
m.updateJournalMu.Unlock()
if !ok {
m.finishUpdate(name, UpdatePhaseFailed, MsgUpdateInterrupted)
m.finishUpdateKey(name, UpdatePhaseFailed, "update.error.interrupted", "")
continue
}
provenAt, _ := time.Parse(time.RFC3339, e.ProvenCopyAt)
@@ -0,0 +1,188 @@
package stacks
import (
"os"
"path/filepath"
"sync"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
)
// v0.264.0 — the update's outcome is TOLD (`09` §3 decision 15): one event per undone update, one per
// held update, handed to the sink main.go wires to the notifier. And R-646 / R-606 on the stacks side.
type eventRec struct {
mu sync.Mutex
evs []UpdateEvent
}
func (r *eventRec) sink(ev UpdateEvent) { r.mu.Lock(); r.evs = append(r.evs, ev); r.mu.Unlock() }
func (r *eventRec) list() []UpdateEvent {
r.mu.Lock()
defer r.mu.Unlock()
return append([]UpdateEvent(nil), r.evs...)
}
// COMPANION RED-PROOF (REPORT.md): delete the emitUpdateEvent call from tryUndo — no event is sent,
// and this test fails on the count.
func TestUpdateEvents_AnUndoneUpdateIsToldOnce(t *testing.T) {
m, _, _, _, _ := newUndoManager(t)
rec := &eventRec{}
m.SetUpdateEventSink(rec.sink)
if err := m.StartGuardedUpdate("nextcloud"); err != nil {
t.Fatal(err)
}
if st := waitUpdateDone(t, m, "nextcloud"); st.UpdatePhase != UpdatePhaseUndone {
t.Fatalf("setup: phase %q", st.UpdatePhase)
}
evs := rec.list()
if len(evs) != 1 || evs[0].Kind != UpdateEventUndone || evs[0].App != "nextcloud" {
t.Fatalf("an undone update must produce exactly ONE %s event, got %+v", UpdateEventUndone, evs)
}
if evs[0].From["web"] != "nextcloud:31.0.14-apache" || evs[0].To["web"] != "nextcloud:34.0.1-apache" {
t.Errorf("the event must name the step (from → to), got %+v → %+v", evs[0].From, evs[0].To)
}
}
// COMPANION RED-PROOF (REPORT.md): delete the emitUpdateEvent call from failAndHold — a held update
// sends nothing, and this test fails on the count.
func TestUpdateEvents_AHeldUpdateIsToldOnce(t *testing.T) {
m, _, _, _, fc := newUndoManager(t)
fc.cutOff = true // the undo fails → HOLD
rec := &eventRec{}
m.SetUpdateEventSink(rec.sink)
if err := m.StartGuardedUpdate("nextcloud"); err != nil {
t.Fatal(err)
}
waitUpdateDone(t, m, "nextcloud")
evs := rec.list()
if len(evs) != 1 || evs[0].Kind != UpdateEventHeld || !evs[0].HoldRecorded {
t.Fatalf("a held update must produce exactly ONE recorded %s event, got %+v", UpdateEventHeld, evs)
}
if evs[0].CopyTier != UpdateTierSecondDrive || evs[0].CopyDate.IsZero() {
t.Errorf("the held event must name the copy the hold points at, got tier %d date %v", evs[0].CopyTier, evs[0].CopyDate)
}
}
// A hold that could not be SAVED still tells the operator — that is exactly what they must hear.
func TestUpdateEvents_AnUnsavedHoldIsStillTold(t *testing.T) {
m, _, g, _, fc := newUndoManager(t)
fc.cutOff = true
g.holdErr = os.ErrPermission
rec := &eventRec{}
m.SetUpdateEventSink(rec.sink)
if err := m.StartGuardedUpdate("nextcloud"); err != nil {
t.Fatal(err)
}
st := waitUpdateDone(t, m, "nextcloud")
evs := rec.list()
if len(evs) != 1 || evs[0].Kind != UpdateEventHeld || evs[0].HoldRecorded {
t.Fatalf("an unsaved hold must still send ONE held event, marked unrecorded; got %+v", evs)
}
if st.UpdateError != MsgUpdateHoldUnsaved || st.UpdateErrorKey != "update.error.hold_unsaved" {
t.Errorf("err=%q key=%q", st.UpdateError, st.UpdateErrorKey)
}
}
// R-646. COMPANION RED-PROOF (REPORT.md): make BackfillAppliedMeta skip the storeAppliedMeta call —
// the current app then has no record and this test fails.
func TestR646_BackfillRecordsOnlyAppsCurrentWithTheCatalog(t *testing.T) {
m, dir, _, _, _ := newUndoManager(t) // nextcloud: pinned 31, catalog 34 → Behind
if err := os.RemoveAll(filepath.Join(dir, appliedMetaDir)); err != nil {
t.Fatal(err)
}
// A second app, CURRENT: installed == catalog.
cur := filepath.Join(m.cfg.Paths.StacksDir, "vikunja")
if err := os.MkdirAll(cur, 0o755); err != nil {
t.Fatal(err)
}
mustWrite(t, filepath.Join(cur, "docker-compose.yml"), "services:\n web:\n image: vikunja/vikunja:2.3.0\n")
mustWrite(t, filepath.Join(cur, ".felhom.yml"), undoMetaOld)
mustWrite(t, filepath.Join(cur, "app.yaml"), "deployed: true\nenv: {}\npinned_images:\n web: vikunja/vikunja:2.3.0\n")
m.mu.Lock()
m.stacks["vikunja"] = &Stack{Name: "vikunja", Deployed: true, ComposePath: filepath.Join(cur, "docker-compose.yml"),
AppConfig: LoadAppConfig(cur),
CatalogImages: map[string]string{"web": "vikunja/vikunja:2.3.0"}}
m.stacks["vikunja"].AppConfig.InstalledImages = map[string]InstalledImage{"web": {Ref: "vikunja/vikunja:2.3.0"}}
m.stacks["nextcloud"].CatalogImages = map[string]string{"web": "nextcloud:34.0.1-apache"}
m.stacks["nextcloud"].AppConfig.InstalledImages = map[string]InstalledImage{"web": {Ref: "nextcloud:31.0.14-apache"}}
m.mu.Unlock()
recorded, skipped := m.BackfillAppliedMeta()
if len(recorded) != 1 || recorded[0] != "vikunja" || len(skipped) != 1 || skipped[0] != "nextcloud" {
t.Fatalf("recorded=%v skipped=%v — want the current app recorded and the behind app skipped by name", recorded, skipped)
}
if _, err := os.Stat(filepath.Join(cur, appliedMetaDir, ".felhom.yml")); err != nil {
t.Errorf("the current app must have its record: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, appliedMetaDir, ".felhom.yml")); err == nil {
t.Error("a BEHIND app must get no record — its pinned version's file is gone, and guessing it is worse")
}
// Idempotent, and never overwrites.
mustWrite(t, filepath.Join(cur, appliedMetaDir, ".felhom.yml"), "marker: kept\n")
if rec2, _ := m.BackfillAppliedMeta(); len(rec2) != 0 {
t.Errorf("a second pass must record nothing, recorded %v", rec2)
}
if b, _ := os.ReadFile(filepath.Join(cur, appliedMetaDir, ".felhom.yml")); string(b) != "marker: kept\n" {
t.Errorf("an existing record must never be overwritten, got %q", b)
}
}
// R-606 — the Hungarian is byte-identical to the literals the keys replaced, and the other language is
// really the other language.
func TestR606_UpdateSentencesHungarianUnchangedAndTranslated(t *testing.T) {
for key, lit := range map[string]string{
"update.error.interrupted": MsgUpdateInterrupted,
"update.error.pull_failed": MsgUpdatePullFailed,
"update.error.pin_failed": MsgUpdatePinFailed,
"update.error.journal_failed": MsgUpdateJournalFailed,
"update.error.backup_no_unit": MsgUpdateBackupNoUnit,
"update.error.hold_unsaved": MsgUpdateHoldUnsaved,
"update.error.no_guards": MsgUpdateNoGuards,
"update.error.backup_failed": MsgUpdateBackupFailFmt,
"update.error.dump_failed": MsgUpdateDumpFailFmt,
"update.refusal.busy": MsgUpdateBusy,
"update.refusal.disk": MsgUpdateDiskFmt,
"update.refusal.no_backup": MsgUpdateNoBackupFmt,
} {
b, _ := i18n.Shared()
if got := b.Msg(i18n.Default, key); got != lit {
t.Errorf("%s: Hungarian changed:\n got %q\nwant %q", key, got, lit)
}
if en := b.Msg("en", key); en == "" || en == lit {
t.Errorf("%s: no English", key)
}
}
for ph, lbl := range updatePhaseLabels {
if got := UpdatePhaseLabelIn(i18n.Default, ph); got != lbl {
t.Errorf("phase %s: Hungarian label %q, want %q", ph, got, lbl)
}
if en := UpdatePhaseLabelIn("en", ph); en == lbl {
t.Errorf("phase %s: no English label", ph)
}
}
}
// R-606 — a stored update sentence renders in the reader's language; an old stored one (no key) renders
// as stored, never empty.
//
// COMPANION RED-PROOF (REPORT.md): make UpdateErrorIn return s.UpdateError always — the English
// assertion fails.
func TestR606_UpdateErrorRendersInTheReadersLanguage(t *testing.T) {
m, _, _, _, _ := newUndoManager(t)
m.finishUpdateKey("nextcloud", UpdatePhaseFailed, "update.error.backup_failed", "", "disk full")
st, _ := m.GetStack("nextcloud")
if st.UpdateError != util.Text(i18n.Default, "update.error.backup_failed", "disk full") {
t.Errorf("UpdateError must stay the Hungarian, got %q", st.UpdateError)
}
en := st.UpdateErrorIn("en")
if en != "The update did not start, because the backup before the update did not succeed: disk full. The app keeps running unchanged." {
t.Errorf("English = %q", en)
}
old := Stack{UpdateError: "egy régi, kulcs nélküli mondat"}
if old.UpdateErrorIn("en") != "egy régi, kulcs nélküli mondat" {
t.Error("a sentence stored by an older version must render as stored, never as an empty line")
}
}
+21
View File
@@ -128,6 +128,12 @@ func (s *Server) templateFuncMap() template.FuncMap {
return "off"
}
},
// v0.264.0 (R-606): the update path's sentences, rendered per reader. These are the HUNGARIAN
// forms — exactly what the templates printed before (.UpdatePhaseLabel / .UpdateError /
// .HoldReason); localeFuncs overrides all three for another language.
"updatePhaseText": func(st interface{}) string { return stackOf(st).UpdatePhaseLabel },
"updateErrorText": func(st interface{}) string { return stackOf(st).UpdateError },
"holdText": func(st interface{}) string { return stackOf(st).HoldReason },
"stateLabel": func(state stacks.ContainerState) string {
switch state {
case stacks.StateRunning:
@@ -491,3 +497,18 @@ func fmtRFC3339Local(s string) string {
}
return t.In(getTimezone()).Format("2006-01-02 15:04")
}
// stackOf lets the update-path template funcs take a stack the way each page holds it (app_info: a
// *stacks.Stack; stacks: a stacks.Stack in a range). Anything else is an empty stack — the func then
// prints an empty string, which a render test would see.
func stackOf(v interface{}) stacks.Stack {
switch st := v.(type) {
case stacks.Stack:
return st
case *stacks.Stack:
if st != nil {
return *st
}
}
return stacks.Stack{}
}
+4 -1
View File
@@ -1524,7 +1524,7 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus, lang string
// the customer's data may not be intact. Measured 2026-08-22: after a failed MariaDB replay
// the app reported `health=healthy, running=true, restarts=0` while its schema-version table
// held zero rows.
if held, why := s.backupMgr.RestoreHoldFor(app.StackName); held {
if held, why := s.backupMgr.RestoreHoldForLang(app.StackName, lang); held {
row.Status = "red"
row.StatusText = why
row.RestoreHeld = true
@@ -2656,6 +2656,9 @@ func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Req
// regardless: processOperator consults operatorOn, the address and a cooldown, and never the
// customer's preferences. This toggle governs the customer leg only.
"app_start_failed",
// v0.264.0: the update outcomes (default ON). A type the page cannot render is a type every
// Save drops — so they are listed here AND carry a checkbox.
"app_update_undone", "app_update_held",
"storage_reconnected", "health_recovered",
} {
if r.FormValue("event_"+evt) == "on" {
+20
View File
@@ -313,6 +313,26 @@ func (s *Server) localeFuncs(lang string) template.FuncMap {
"stateLabel": func(state stacks.ContainerState) string {
return b.Msg(lang, stateLabelKey(state))
},
// v0.264.0 (R-606): the update path in the reader's language. A stored sentence with no key (an
// older version's, or a composed one) renders as it is — never as an empty line.
"updatePhaseText": func(st interface{}) string {
stk := stackOf(st)
if l := stacks.UpdatePhaseLabelIn(lang, stk.UpdatePhase); l != "" && l != stacks.UpdatePhaseLabel(stk.UpdatePhase) {
return l
}
return stk.UpdatePhaseLabel // no translation for this phase: the stored label, never an empty line
},
"updateErrorText": func(st interface{}) string { return stackOf(st).UpdateErrorIn(lang) },
"holdText": func(st interface{}) string {
stk := stackOf(st)
if stk.HoldReason == "" || s.backupMgr == nil {
return stk.HoldReason
}
if held, why := s.backupMgr.RestoreHoldForLang(stk.Name, lang); held && why != "" {
return why
}
return stk.HoldReason
},
"timeAgo": func(t time.Time) string {
if t.IsZero() {
return "–"
@@ -0,0 +1,96 @@
package web
import (
"html"
"io"
"log"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
)
// R-606 (v0.264.0) — every sentence of the update path reaches the page in the READER's language, on
// BOTH pages that carry it (the app page and the apps list): the phase label, the update error and the
// hold sentence. Asserted both ways — the English present AND the Hungarian gone — because a page
// carrying both would pass a presence check (the composed-sentence class, R-573/R-590/R-596/R-598).
//
// COMPANION RED-PROOF (REPORT.md): make localeFuncs' updateErrorText return .UpdateError — the English
// page carries the Hungarian sentence and this test fails.
func TestR606_UpdateSentencesFollowTheReader(t *testing.T) {
s := i18nTestServer(t)
b := backup.NewManager(s.cfg, s.settings, log.New(io.Discard, "", 0))
s.backupMgr = b
at := time.Date(2026, 9, 23, 8, 0, 0, 0, time.UTC)
if err := b.HoldAfterFailedUpdateHolding("kimai", at, at.Add(-6*time.Hour), backup.UpdateTierLocal, b.UpdateCopyHolds("kimai", backup.UpdateTierLocal), ""); err != nil {
t.Fatal(err)
}
_, holdHU := b.RestoreHoldForLang("kimai", "hu")
errStack := i18nStack("gokapi", "Go Kapi", stacks.StateRunning, true)
errStack.UpdateErrorKey, errStack.UpdateErrorArgs = "update.error.pull_failed", nil
errStack.UpdateError = stacks.MsgUpdatePullFailed
upd := i18nStack("romm", "Rom Manager", stacks.StateRunning, true)
upd.Updating, upd.UpdatePhase, upd.UpdatePhaseLabel = true, stacks.UpdatePhaseCopying, stacks.UpdatePhaseLabel(stacks.UpdatePhaseCopying)
held := i18nStack("kimai", "Kimai Time", stacks.StateStopped, true)
held.HoldReason = holdHU
pages := []i18nCase{
{"r606_list", "stacks", func() map[string]interface{} {
d := i18nLayoutData("stacks", "Alkalmazások")
d["Stacks"] = []stacks.Stack{errStack, upd, held}
for _, k := range []string{"Subdomains", "MissingStorage", "NetworkStubs", "NetworkWarnings", "StorageLabels"} {
d[k] = map[string]string{}
}
return d
}},
{"r606_app_error", "app_info", func() map[string]interface{} {
d := i18nLayoutData("stacks", "Go Kapi")
st := errStack
d["Stack"], d["Meta"], d["AppInfo"] = &st, st.Meta, st.Meta.AppInfo
return d
}},
{"r606_app_held", "app_info", func() map[string]interface{} {
d := i18nLayoutData("stacks", "Kimai")
st := held
d["Stack"], d["Meta"], d["AppInfo"] = &st, st.Meta, st.Meta.AppInfo
return d
}},
{"r606_app_updating", "app_info", func() map[string]interface{} {
d := i18nLayoutData("stacks", "RomM")
st := upd
d["Stack"], d["Meta"], d["AppInfo"] = &st, st.Meta, st.Meta.AppInfo
return d
}},
}
type want struct{ en, hu string }
sentences := map[string][]want{
"r606_list": {
{util.Text("en", "update.error.pull_failed"), stacks.MsgUpdatePullFailed},
{"Copying the data before the update…", "Az adatok másolása a frissítés előtt…"},
{"own drive", "saját meghajtó"},
{"holds the settings, the database and the data volumes", "a beállításokat, az adatbázist és az adatköteteket tartalmazza"},
},
"r606_app_error": {{util.Text("en", "update.error.pull_failed"), stacks.MsgUpdatePullFailed}},
"r606_app_held": {{"The update of kimai at 2026-09-23 10:00 did not succeed", "A(z) kimai frissítése 2026-09-23 10:00-kor nem sikerült"}},
"r606_app_updating": {{"Copying the data before the update…", "Az adatok másolása a frissítés előtt…"}},
}
for _, c := range pages {
en := html.UnescapeString(renderI18nCase(t, s, "en", c))
hu := html.UnescapeString(renderI18nCase(t, s, "hu", c))
for _, w := range sentences[c.name] {
if !strings.Contains(en, w.en) {
t.Errorf("%s: the English page lacks %q", c.name, w.en)
}
if strings.Contains(en, w.hu) {
t.Errorf("%s: the Hungarian %q must be GONE from the English page", c.name, w.hu)
}
if !strings.Contains(hu, w.hu) {
t.Errorf("%s: the Hungarian page lacks %q", c.name, w.hu)
}
}
}
}
@@ -29,11 +29,11 @@
</div>
{{if .Stack.Updating}}
<div class="alert alert-info" style="margin-top:1rem" data-update-phase="{{.Stack.UpdatePhase}}">{{.Stack.UpdatePhaseLabel}}</div>
<div class="alert alert-info" style="margin-top:1rem" data-update-phase="{{.Stack.UpdatePhase}}">{{updatePhaseText .Stack}}</div>
{{else if .Stack.HoldReason}}
<div class="alert alert-error" style="margin-top:1rem" data-held="true">{{.Stack.HoldReason}} <a href="/backups/apps" class="btn btn-sm btn-outline">{{T "app_info.mentesek"}}</a></div>
<div class="alert alert-error" style="margin-top:1rem" data-held="true">{{holdText .Stack}} <a href="/backups/apps" class="btn btn-sm btn-outline">{{T "app_info.mentesek"}}</a></div>
{{else if .Stack.UpdateError}}
<div class="alert alert-warning" style="margin-top:1rem" data-update-error="true">{{.Stack.UpdateError}}</div>
<div class="alert alert-warning" style="margin-top:1rem" data-update-error="true">{{updateErrorText .Stack}}</div>
{{else if .UpdateUndoneLine}}
<div class="alert alert-info" style="margin-top:1rem" data-update-undone="true">{{.UpdateUndoneLine}}</div>
{{end}}
@@ -74,6 +74,14 @@
<input type="checkbox" name="event_app_start_failed" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "app_start_failed"}}checked{{end}}{{end}}{{end}}>
<span class="toggle-label">{{T "settings_notifications.alkalmazas_nem_fut"}}</span>
</label>
<label class="toggle">
<input type="checkbox" name="event_app_update_undone" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "app_update_undone"}}checked{{end}}{{end}}{{end}}>
<span class="toggle-label">{{T "settings_notifications.app_update_undone"}}</span>
</label>
<label class="toggle">
<input type="checkbox" name="event_app_update_held" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "app_update_held"}}checked{{end}}{{end}}{{end}}>
<span class="toggle-label">{{T "settings_notifications.app_update_held"}}</span>
</label>
</div>
</div>
<div class="form-group">
@@ -91,17 +91,17 @@
a green Frissítés beside a crash loop. An app being updated offers no lifecycle
button; a held app offers none that would start it, only the way back. */}}
{{if .Updating}}
<span class="tag tag-progress" data-update-phase="{{.UpdatePhase}}"><span class="dot"></span>{{.UpdatePhaseLabel}}</span>
<span class="tag tag-progress" data-update-phase="{{.UpdatePhase}}"><span class="dot"></span>{{updatePhaseText .}}</span>
{{else if .HoldReason}}
<div class="alert alert-error" data-held="true">{{.HoldReason}} <a href="/backups/apps" class="btn btn-sm btn-outline">{{T "stacks.mentesek"}}</a></div>
<div class="alert alert-error" data-held="true">{{holdText .}} <a href="/backups/apps" class="btn btn-sm btn-outline">{{T "stacks.mentesek"}}</a></div>
{{if not .Orphaned}}<button class="btn btn-danger" onclick="removeStack('{{.Name}}')">{{T "common.eltavolitas"}}</button>{{end}}
{{else if isOperational .State}}
{{if .UpdateError}}<div class="alert alert-warning" data-update-error="true">{{.UpdateError}}</div>{{end}}
{{if .UpdateError}}<div class="alert alert-warning" data-update-error="true">{{updateErrorText .}}</div>{{end}}
{{if not .Orphaned}}<button class="btn btn-success" onclick="stackAction(event, '{{.Name}}', 'update')">{{T "stacks.frissites"}}</button>{{end}}
<button class="btn btn-warning" onclick="stackAction(event, '{{.Name}}', 'restart')">{{T "common.ujrainditas"}}</button>
<button class="btn btn-danger" onclick="stackAction(event, '{{.Name}}', 'stop')">{{T "stacks.leallitas"}}</button>
{{else}}
{{if .UpdateError}}<div class="alert alert-warning" data-update-error="true">{{.UpdateError}}</div>{{end}}
{{if .UpdateError}}<div class="alert alert-warning" data-update-error="true">{{updateErrorText .}}</div>{{end}}
<button class="btn btn-success" onclick="stackAction(event, '{{.Name}}', 'start')">{{T "stacks.inditas"}}</button>
{{if not .Orphaned}}<button class="btn btn-danger" onclick="removeStack('{{.Name}}')">{{T "common.eltavolitas"}}</button>{{end}}
{{end}}
@@ -240,6 +240,14 @@
<input type="checkbox" name="event_app_start_failed" >
<span class="toggle-label">Alkalmazás nem fut</span>
</label>
<label class="toggle">
<input type="checkbox" name="event_app_update_undone" >
<span class="toggle-label">Frissítés visszavonva (az alkalmazás a korábbi változattal fut)</span>
</label>
<label class="toggle">
<input type="checkbox" name="event_app_update_held" >
<span class="toggle-label">Frissítés sikertelen, az alkalmazás leállítva</span>
</label>
</div>
</div>
<div class="form-group">
+51 -2
View File
@@ -58,7 +58,10 @@
"page.title.storage": "slice 1 -- pinned by TestHandlerTitleKeysMatchHungarianTitle",
"page.title.storage_attach": "slice 1 -- pinned by TestHandlerTitleKeysMatchHungarianTitle",
"page.title.storage_init": "slice 1 -- pinned by TestHandlerTitleKeysMatchHungarianTitle",
"page.title.storage_network": "slice 1 -- pinned by TestHandlerTitleKeysMatchHungarianTitle"
"page.title.storage_network": "slice 1 -- pinned by TestHandlerTitleKeysMatchHungarianTitle",
"event.app_update_undone": "BORN AS A KEY, v0.264.0 (R-606 / the update events) -- a NEW sentence, never a Go literal.",
"settings_notifications.app_update_undone": "BORN AS A KEY, v0.264.0 (R-606 / the update events) -- a NEW sentence, never a Go literal.",
"settings_notifications.app_update_held": "BORN AS A KEY, v0.264.0 (R-606 / the update events) -- a NEW sentence, never a Go literal."
},
"flash.share.already_on": "A megosztás már be van kapcsolva.",
"flash.share.enable_failed": "A megosztás bekapcsolása nem sikerült.",
@@ -1018,5 +1021,51 @@
"backup.guest.agent_unconfigured": "A host-ügynök nincs konfigurálva ezen a gépen.",
"backup.guest.agent_unreachable": "A host-ügynök jelenleg nem elérhető.",
"backup.guest.err.unavailable": "a rendszermentés nem érhető el ezen a gépen",
"backup.guest.err.in_progress": "mentés már folyamatban van"
"backup.guest.err.in_progress": "mentés már folyamatban van",
"hold.update.sentence": [
"A(z) %s frissítése %s-kor nem sikerült, és az alkalmazás nem indult el az új verzióval. ",
"Az alkalmazás biztonsági okból leállítva marad, hogy az adatai ne sérüljenek. ",
"Visszaállítható a Mentések oldalon ebből a biztonsági mentésből: %s, %s — ez a másolat %s."
],
"hold.update.sentence_tier": [
"A(z) %s frissítése %s-kor nem sikerült, és az alkalmazás nem indult el az új verzióval. ",
"Az alkalmazás biztonsági okból leállítva marad, hogy az adatai ne sérüljenek. ",
"Visszaállítható a Mentések oldalon ebből a biztonsági mentésből: %s, %s."
],
"hold.update.sentence_legacy": [
"A(z) %s frissítése %s-kor nem sikerült, és az alkalmazás nem indult el az új verzióval. ",
"Az alkalmazás biztonsági okból leállítva marad, hogy az adatai ne sérüljenek. ",
"Visszaállítható a(z) %s-i biztonsági mentésből a Mentések oldalon."
],
"hold.update.tier.1": "saját meghajtó",
"hold.update.tier.2": "második meghajtó",
"hold.update.tier.3": "távoli mentés",
"hold.copy_holds.db_only": "csak a beállításokat és az adatbázist tartalmazza, a fájlokat nem",
"hold.copy_holds.volumes": "a beállításokat, az adatbázist és az adatköteteket tartalmazza",
"hold.copy_holds.files": "a beállításokat, az adatbázist és a fájlokat tartalmazza",
"update.error.no_guards": "A frissítés nem indítható: a frissítés előtti biztonsági ellenőrzés nem érhető el ezen a szerveren.",
"update.refusal.not_deployed": "Az alkalmazás nincs telepítve, ezért nem frissíthető.",
"update.refusal.deploying": "A(z) %s telepítése még folyamatban van — a frissítés utána indítható.",
"update.refusal.already": "A(z) %s frissítése már folyamatban van.",
"update.refusal.busy": "A frissítés most nem indítható: mentés/visszaállítás folyamatban. Próbáld újra, ha befejeződött.",
"update.refusal.migrating": "A frissítés most nem indítható: adatáthelyezés folyamatban.",
"update.refusal.no_backup": "A(z) %s nem frissíthető, mert nincs olyan biztonsági mentése, amelyből vissza lehetne állítani, és most új mentés sem készíthető róla. Ellenőrizd a Mentések oldalon, hogy az alkalmazás meghajtója elérhető-e — utána a frissítés elindítható.",
"update.refusal.disk": "Nincs elég szabad hely a frissítéshez: %.1f GB szabad, az új verzió letöltéséhez legalább %.0f GB szükséges.",
"update.error.backup_failed": "A frissítés nem indult el, mert a frissítés előtti biztonsági mentés nem sikerült: %v. Az alkalmazás változatlanul fut tovább.",
"update.error.backup_no_unit": "A frissítés nem indult el: a frissítés előtti mentés lefutott, de nem jött létre friss, visszaállítható másolat. Az alkalmazás változatlanul fut tovább.",
"update.error.dump_failed": "A frissítés nem indult el, mert az adatbázis pillanatkép nem készült el: %v. Az alkalmazás változatlanul fut tovább.",
"update.error.pin_failed": "A frissítés nem indult el: az új verzió leírása nem olvasható be. Az alkalmazás változatlanul fut tovább.",
"update.error.journal_failed": "A frissítés nem indult el: a frissítés naplója nem menthető. Az alkalmazás változatlanul fut tovább.",
"update.error.pull_failed": "Az új verzió letöltése nem sikerült, ezért a frissítés elmaradt. Az alkalmazás a korábbi verzióval fut tovább.",
"update.error.interrupted": "A frissítés megszakadt, mert a vezérlő újraindult, mielőtt az új verzió elindult volna. Az alkalmazás a korábbi verzióval fut tovább.",
"update.error.hold_unsaved": "A frissítés nem sikerült, az alkalmazás le lett állítva, de a leállítás rögzítése nem sikerült. Ne indítsd újra — vedd fel velünk a kapcsolatot.",
"update.phase.checking": "Ellenőrzés…",
"update.phase.backing-up": "Biztonsági mentés készül a frissítés előtt…",
"update.phase.safety-dump": "Adatbázis pillanatkép…",
"update.phase.pinning": "Új verzió letöltése…",
"update.phase.pulling": "Új verzió letöltése…",
"update.phase.starting": "Indítás az új verzióval…",
"update.phase.verifying": "Működés ellenőrzése…",
"update.phase.done": "Frissítve",
"update.phase.failed": "A frissítés nem sikerült"
}