From 7c05b59708a9a9611e0e265cbfd08f92362851de Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Fri, 18 Sep 2026 11:44:30 +0200 Subject: [PATCH] =?UTF-8?q?v0.253.0=20=E2=80=94=20errors=20carry=20the=20k?= =?UTF-8?q?ey=20of=20the=20sentence=20they=20are=20(R-557=20slice=202=20re?= =?UTF-8?q?lease=20B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 179 Hungarian sentences were built deep inside a package with fmt.Errorf and printed by whoever caught them: too late to translate where they are shown, too early where they are made. Every one now carries its key across that gap. ZERO Hungarian error literals remain. util.MsgError does three things at once, each earned: - Error() is the Hungarian, byte for byte, so every un-converted printer is unchanged; - errors.Is answers for the kind AND for a wrapped cause (KindErrorf dropped the cause); - an error ARGUMENT renders recursively, so "formázás sikertelen: %w" translates whole. A foreign error — restic, docker, ssh, the stdlib — prints verbatim. It is not ours. 76 display sites go through errText, and TestNoErrErrorInPageOutput convicts any that do not. memoryVerdict returns an error rather than a sentence, so the deploy's 409 and the household's language come from one value; UpdateRefusal gained a Cause to carry it. Plurals, one rule, stated once: a key with .one/.other takes its COUNT first. Not a per-call-site flag — the producer somebody forgot would read "3 app is not running". The guard caught a real key collision (alert.deadapp.one) the day the rule landed. TWO DEFECTS FOUND IN MY OWN TOOLING, recorded rather than quietly fixed. The bulk converter silently dropped multi-line concatenations, damaging 7 producers — and the parity gate could not see it, because every surviving fragment WAS a real base literal while the CALL had lost text; two behaviour tests caught it. And the counting script was case-sensitive, so it said "0 left" while five remained. MinAgent: 0.131.0 (unchanged). No hub release needed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- CHANGELOG.md | 47 +++ CONTEXT.md | 4 +- REPORT.md | 82 ++++- controller/README.md | 14 +- controller/internal/api/geo.go | 6 +- controller/internal/api/i18n_api.go | 7 + controller/internal/api/router.go | 40 +-- controller/internal/appexport/crypto.go | 3 +- controller/internal/appexport/export.go | 5 +- controller/internal/appexport/fabplan_test.go | 6 +- controller/internal/appexport/restore.go | 7 +- controller/internal/backup/offbox.go | 4 +- .../internal/backup/offbox_reconstitute.go | 54 ++- .../internal/backup/offbox_recovery_cli.go | 1 + controller/internal/backup/offbox_restore.go | 32 +- .../internal/backup/offbox_verify_copies.go | 10 +- .../internal/backup/r403_hollow_test.go | 4 +- controller/internal/backup/restore_unit.go | 3 +- controller/internal/backup/shares_restore.go | 23 +- controller/internal/backup/tier2.go | 3 +- controller/internal/backup/tier2_restore.go | 20 +- controller/internal/backup/update_guard.go | 21 +- controller/internal/i18n/i18n.go | 38 ++- controller/internal/i18n/i18n_test.go | 72 ++++ controller/internal/i18n/locales/en.json | 187 +++++++++- controller/internal/i18n/locales/hu.json | 179 +++++++++- controller/internal/integrations/manager.go | 18 +- .../integrations/onlyoffice_filebrowser.go | 17 +- .../integrations/onlyoffice_nextcloud.go | 9 +- controller/internal/notify/notifier.go | 9 +- controller/internal/selfupdate/updater.go | 15 +- controller/internal/settings/settings.go | 3 +- controller/internal/settings/smb.go | 26 +- controller/internal/stacks/deploy.go | 39 +-- controller/internal/stacks/deploy_errors.go | 25 +- controller/internal/stacks/mailenv.go | 5 +- controller/internal/stacks/manager.go | 4 +- controller/internal/stacks/migrate.go | 57 ++-- .../stacks/r553_deploy_error_kinds_test.go | 39 ++- controller/internal/stacks/samba.go | 9 +- controller/internal/stacks/update.go | 25 +- controller/internal/stacks/update_test.go | 28 +- controller/internal/util/msgerr.go | 151 ++++++++ controller/internal/util/msgerr_test.go | 139 ++++++++ .../internal/web/agent_disk_handlers.go | 20 +- .../web/agent_host_metrics_handler.go | 4 +- controller/internal/web/alerts.go | 4 +- controller/internal/web/backup_handlers.go | 6 +- controller/internal/web/backup_page_state.go | 8 +- .../internal/web/backup_target_offer.go | 10 +- controller/internal/web/backups_split_test.go | 4 +- .../internal/web/claim_code_naming_test.go | 4 +- controller/internal/web/disk_health_test.go | 10 +- controller/internal/web/handler_debug.go | 14 +- controller/internal/web/handler_export.go | 8 +- .../internal/web/handler_export_download.go | 8 +- controller/internal/web/i18n_cases_b_test.go | 2 +- controller/internal/web/i18n_flash_test.go | 54 +++ controller/internal/web/i18n_web.go | 37 ++ controller/internal/web/intermediary.go | 10 +- .../internal/web/netstorage_handlers.go | 6 +- controller/internal/web/no_raw_err_test.go | 91 +++++ controller/internal/web/offbox_handlers.go | 3 +- .../internal/web/r400_debug_routes_test.go | 2 +- .../internal/web/r553_alert_placement_test.go | 2 +- controller/internal/web/recovery_test.go | 8 +- controller/internal/web/server.go | 2 +- controller/internal/web/sharing_handlers.go | 32 +- controller/internal/web/storage_handlers.go | 70 ++-- controller/scripts/i18n_go_keys.json | 321 +++++++++++++++++- 70 files changed, 1853 insertions(+), 377 deletions(-) create mode 100644 controller/internal/util/msgerr.go create mode 100644 controller/internal/util/msgerr_test.go create mode 100644 controller/internal/web/no_raw_err_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 063f60c..5847891 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,50 @@ +## v0.253.0 — Errors carry the key of the sentence they are (2026-09-18, R-557 slice 2 release B) + +**MinAgent: 0.131.0** (unchanged). **No hub release needed.** Every Hungarian sentence is byte-identical +to the literal it replaced, measured by `i18n_go_parity.py`, and nothing on the wire moved. + +179 Hungarian sentences were built deep inside a package with `fmt.Errorf` and printed by whoever +caught them. They could not be translated where they are SHOWN (by then they are a finished string) +nor where they are MADE (that code has no request and no language). **Every one of them now carries +its key across that gap, and zero Hungarian error literals remain.** + +`util.MsgError` does three things at once, and each was earned: + +1. **`Error()` returns the Hungarian, byte for byte.** Every un-converted printer — a log line, a + `%v`, a third-party wrapper — keeps printing exactly what it printed before. That is what made + converting 179 producers safe without converting all their printers in the same commit. +2. **`errors.Is` still answers, for the kind AND for a wrapped cause.** `Unwrap() []error` returns + both. The predecessor `KindErrorf` returned the kind alone and dropped the cause; a converted + producer usually wraps one, and a caller testing for it would have silently stopped matching. +3. **An error ARGUMENT is rendered recursively**, so `„formázás sikertelen: %w"` translates the whole + chain. An inner error that is not ours (restic, docker, ssh, the stdlib) prints itself — that text + is not written here and is not translated here (R-553's rule). + +- **Every display path goes through `errText`** (76 sites in `internal/web` and `internal/api`), and + `TestNoErrErrorInPageOutput` keeps it that way. A `strings.Contains(err.Error(), …)` is a + COMPARISON and is deliberately untouched — the four English ones are R-569. +- **The memory refusal is now an error where it is made.** `memoryVerdict` returns + `util.MsgErrorf(ErrNotEnoughMemory, …)` instead of a sentence a caller had to wrap, so the API's + 409 and the household's language come from one value. `UpdateRefusal` gained a `Cause` so the same + error survives to the API through the update path. +- **Plurals, one rule, stated once:** a key that carries `.one`/`.other` forms in a language is a + plural key and its FIRST parameter is the count (`i18n.Bundle.form`). Hungarian never carries them + — it does not inflect after a numeral — so a Hungarian render is unchanged at every count. Not a + per-call-site flag: the one producer somebody forgot would read „3 app is not running" with nothing + to catch it. `TestPluralFirstArgIsNumeric` pins that every plural value really does take its count + first; `TestNoOrdinaryKeyEndsInAPluralSuffix` pins that `.one`/`.other` stay reserved — a real + collision (`alert.deadapp.one`) was caught by it on the day the rule landed. + +**Two defects this release found in its own tooling, both recorded rather than quietly fixed:** + +- The bulk converter **silently dropped the continuation of a multi-line concatenation**, damaging 7 + producers. The parity gate did not catch it: every surviving fragment WAS a real base-commit + literal, so its question was answered yes while the call had lost text. Two behaviour tests + (`TestR356_ScenarioC`, `TestR379_ScenarioA`) caught it, because they assert the sentence a customer + reads. All 7 rebuilt as joined keys. +- The counting script was **case-sensitive**, so it reported "0 error literals left" while five + remained. The R-565 shape, in the instrument. Re-measured; the five are converted. + ## v0.252.0 — The sentences the program writes follow the language (2026-09-18, R-557 slice 2 release A) **MinAgent: 0.131.0** (unchanged). **No hub release needed.** Nothing a Hungarian household reads diff --git a/CONTEXT.md b/CONTEXT.md index 888fb4b..4ea1b91 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -7,7 +7,9 @@ > > Ask Claude Code: "Please update CONTEXT.md with what we did today" -Last updated: 2026-09-18 (v0.252.0 — localisation slice 2 release A: the sentences the program builds follow the language) +Last updated: 2026-09-18 (v0.253.0 — localisation slice 2 release B: errors carry a key) + +> **2026-09-18 — v0.253.0 (R-557 slice 2 release B).** All **179** Hungarian error messages carry a key (`util.MsgError`): `Error()` is still the Hungarian byte for byte, `errors.Is` answers for the kind AND the wrapped cause, and an inner error argument renders recursively. 76 display sites go through `errText`, pinned by `TestNoErrErrorInPageOutput`. `memoryVerdict` returns an error, `UpdateRefusal` gained a `Cause`. Plurals: a key with `.one`/`.other` takes its count FIRST (bundle rule, not a call-site flag) — the guard caught a real key collision. **Two tooling defects found and recorded:** the bulk converter dropped multi-line concatenations (7 producers; the parity gate could not see it, two behaviour tests could), and my counter was case-sensitive and under-reported. 705 literals left, 0 of them errors. > **2026-09-18 — v0.252.0 (R-557 slice 2 release A).** The sentences the program BUILDS now follow the language: 226 Go literals converted (flash lines, page data, API answers, alert banners, 237 country names, the four app-named page titles — R-566 closed). Flash travels as a KEY in the redirect URL with `fa` parameters; an old link's prose is still shown verbatim. New gate `i18n_go_parity.py` refuses any key whose Hungarian is not byte-identical to the base commit (7 467 literals frozen; 3 decoys). Wire goldens freeze the report warnings and the event messages the hub MAILS — those stay Hungarian until slice 3 (R-558). Remaining: 894 literals, 176 of them `fmt.Errorf` (release B); persisted text (release C, §16 option 1). New rows R-572/R-573/R-574. diff --git a/REPORT.md b/REPORT.md index ef56bb0..123f14b 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,6 +1,7 @@ -# REPORT — localisation slice 2, release A (controller v0.252.0, R-557) +# REPORT — localisation slice 2, releases A and B (controller v0.252.0 + v0.253.0, R-557) **2026-09-18 · base commit `736f54b49610` (v0.251.0) · MinAgent 0.131.0, unchanged · no hub release.** +**Two releases this session: A (the sentences shown now) and B (the 179 error messages).** Architecture read first and named: `felhom.eu/documentation/architecture/10-localisation.md` §1 (parity), §2 (mechanism), §5 (gates), §9 (the R-553 signals), §10 (this slice); `07-backup-architecture.md` for what the hub reads from a box; `05-hub-architecture.md` for what the hub composes itself. @@ -113,3 +114,82 @@ new `go-parity`**. `python3 scripts/test_gate_decoys.py` — all 23 decoys behav Endpoint-level on demo-hp guest 9201 (no browser on DooPlex — `claude-in-chrome` is not available here). Evidence: `felhom.eu/documentation/audits/i18n-slice2-2026-09-18/A/live/`. + + +--- + +# Release B — v0.253.0: errors carry a key + +**179 Hungarian error literals converted; ZERO remain.** `util.MsgError` carries the key from the +package that makes the error to the handler that prints it. + +## 1. The mechanism, and the three things it had to do at once + +| property | why it is not optional | pinned by | +|---|---|---| +| `Error()` is the Hungarian, byte for byte | every un-converted printer (a log line, a `%v`, a third-party wrapper) keeps printing what it printed. Without it, 179 producers could not be converted before all their printers were. | `TestMsgErrorKeepsKindAndHuText` | +| `errors.Is` answers for the kind **and** for a wrapped cause | `KindErrorf` returned the kind alone and dropped the cause; a converted producer usually wraps one | `TestMsgErrorUnwrapsTheCauseToo` | +| an error ARGUMENT renders recursively | `„formázás sikertelen: %w"` is a sentence wrapping a sentence; both halves are ours | `TestErrTextRendersAWrappedMessageErrorToo` | +| a FOREIGN error prints verbatim | restic, docker, ssh and the stdlib are not ours to translate (R-553's rule) | `TestErrTextFallsBackVerbatim` | + +76 display sites in `internal/web` and `internal/api` now go through `errText`; +`TestNoErrErrorInPageOutput` examines 316 display-sink lines and convicts any that do not. A +`strings.Contains(err.Error(), …)` is a COMPARISON and stays untouched — the four English ones are +R-569. + +`memoryVerdict` returns an ERROR rather than a sentence, so the deploy's 409 and the household's +language come from one value; `UpdateRefusal` gained a `Cause` so that error survives the update path +to the API. + +## 2. Plurals — one rule, stated once + +**A key that carries `.one`/`.other` forms in a language is a plural key, and its FIRST parameter is +the count** (`i18n.Bundle.form`). Hungarian never carries them, so a Hungarian render is unchanged at +every count. Deliberately NOT a per-call-site flag: the producer somebody forgot would read „3 app is +not running" with nothing to catch it, and the bundle is where a translator works. + +`TestPluralFirstArgIsNumeric` pins that every plural value really takes its count first. +`TestNoOrdinaryKeyEndsInAPluralSuffix` pins that the two suffixes stay reserved — **it caught a real +collision the day the rule landed** (`alert.deadapp.one`, the ONE dead app, would have been read as +the singular of a key that does not exist). Renamed to `alert.deadapp.single`. + +## 3. Two defects release B found in its OWN tooling + +Both are recorded here rather than quietly fixed, because each is a shape that will recur. + +**(a) The bulk converter silently dropped a multi-line concatenation.** +`fmt.Errorf("a: "+ "b: %s", x)` kept only `"a: "` and lost the rest with its arguments — 7 producers +damaged. **The parity gate did NOT catch it**, and the reason matters: every surviving fragment WAS a +real base-commit literal, so the gate's question ("is this text real?") was answered *yes* while the +CALL had lost text. What caught it was two behaviour tests that assert the sentence a customer reads +(`TestR356_ScenarioC_UndeployedAppIsStillRefused`, `TestR379_ScenarioA_RollbackSucceeds_AppComesBack`). +All 7 were rebuilt as joined keys and the six wrong keys pruned from both bundles. **The lesson: a +structural gate over the TEXT cannot see a defect in the CALL; only a test that renders the sentence +can.** + +**(b) My own counting script was case-sensitive**, so it reported "0 error literals left" while five +remained („occ parancs sikertelen", „hub hiba", „OnlyOffice aldomain nem ismert" ×2). That is the +R-565 shape inside the measuring instrument. Re-measured with `re.I`; the five are converted. The +ASCII-fragment search that found them is in §5 below, with its controls. + +## 4. Red-proofs + +Seven planted breaks, each seen to convict and then reverted (full log: +`audits/i18n-slice2-2026-09-18/redproofs.txt`, entries 8–14). Plus the two live catches above, which +are not red-proofs — nobody planted them. + +## 5. The "no Hungarian error left" claim, with controls + +- **Negative control:** `fmt.Errorf("…qzxvkjq` → 0 hits (the fragment is in no file). +- **Instrument works:** the same ASCII search run against the release-B BASE commit finds + `sikertelen` 12× in `migrate.go` and `nincs` 7× in `offbox_restore.go`. +- **The claim, now:** an ASCII search for `sikertelen|sikeres|telepitve|mentes|nincs|kotelezo|hiba|folyamatban` + inside `fmt.Errorf`/`errors.New`, **case-insensitively**, and a separate accented-letter search: + **0 hits each**, outside a comment in `internal/util/msgerr.go` that quotes an example. + +## 6. What is left after B + +**705 Hungarian literals**, none of them errors: the country TABLE (113, not on the wire and not +translated on purpose), `handler_debug.go` (R-574), the notifier's 27 wire messages (R-558), +`funcmap.go`'s two un-overridden helpers (R-572), the text a background run PERSISTS (release C), and +the R-570 producer. Release C's default stands: written in the box's language at write time. diff --git a/controller/README.md b/controller/README.md index f1df837..dcdf892 100644 --- a/controller/README.md +++ b/controller/README.md @@ -3614,7 +3614,7 @@ without reaching `Images()`. --- -### 18. Dashboard language (i18n) (v0.247.0–v0.252.0) +### 18. Dashboard language (i18n) (v0.247.0–v0.253.0) Design: `felhom.eu/documentation/architecture/10-localisation.md`. Inventory: `felhom.eu/documentation/audits/I18N-INVENTORY-2026-09-17.md`. @@ -3624,7 +3624,8 @@ sidebar footer of every dashboard page. Every template is converted (v0.247.0 th apps and settings, v0.249.0 backups, v0.250.0 storage, sharing, sign-in, claim, guest share, catch-all, debug). Since **v0.252.0** the sentences the program BUILDS follow the language too — flash lines, page data, the JSON the page's script reads, the alert banners, the country names, and the four page titles -built around an app name. Still Hungarian: `fmt.Errorf` messages (release B), text persisted by a +built around an app name. Since **v0.253.0** every error message carries its key too, so a refusal made deep in a package is +rendered in the household's language by whoever prints it. Still Hungarian: text persisted by a background run (release C), catalog copy (slice 5), and **everything the hub reads** (see below). - **Bundles:** `internal/i18n/locales/hu.json` (authoritative, every key) and `en.json`, embedded. @@ -3670,6 +3671,15 @@ background run (release C), catalog copy (slice 5), and **everything the hub rea controller's own sentence when it has no entry (`FormatCustomerEmail`), so translating them here would change an e-mail nobody asked to change. They follow the language in slice 3 (R-558). Pinned by wire goldens in `internal/monitor` and `internal/notify`. +- **Errors (v0.253.0):** `util.MsgError(key, args…)` / `util.MsgErrorf(kind, key, args…)` carry the + bundle key from the package that MAKES the error to the handler that PRINTS it. `Error()` returns the + Hungarian byte for byte (so an un-converted printer is unchanged); `Unwrap() []error` returns the kind + and any wrapped cause, so `errors.Is` answers for both; an error ARGUMENT renders recursively, and a + foreign error (restic, docker, ssh, stdlib) prints verbatim. Display ends call `s.errText(r, err)` / + `r.errText(req, err)` — `TestNoErrErrorInPageOutput` convicts any sink that does not. +- **Plurals (v0.253.0):** a key that carries `.one`/`.other` in a language is a plural key and its FIRST + parameter is the count (`i18n.Bundle.form`). Hungarian has one form at every count. `.one`/`.other` + are RESERVED suffixes — `TestNoOrdinaryKeyEndsInAPluralSuffix`. - **Report:** the hub report carries `"language"` (always present). No hub release reads it yet. - **Tools and gates:** `scripts/i18n_extract.py` converts a template (moves each Hungarian run into hu.json, leaves a marker); `scripts/i18n_missing_gate.py` (in `controller_gates.py`) checks keys diff --git a/controller/internal/api/geo.go b/controller/internal/api/geo.go index b63995d..d804e46 100644 --- a/controller/internal/api/geo.go +++ b/controller/internal/api/geo.go @@ -68,7 +68,7 @@ func (r *Router) geoUpdateSettings(w http.ResponseWriter, req *http.Request) { if err := r.sett.SetGeoRestriction(geo); err != nil { r.logger.Printf("[ERROR] [api] Failed to save geo settings: %v", err) - writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()}) + writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)}) return } @@ -157,7 +157,7 @@ func (r *Router) geoSetAppOverride(w http.ResponseWriter, req *http.Request, app override := &settings.AppGeoOverride{AllowedCountries: body.AllowedCountries} if err := r.sett.SetGeoAppOverride(appName, override); err != nil { r.logger.Printf("[ERROR] [api] Failed to save geo override for %s: %v", appName, err) - writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()}) + writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)}) return } @@ -183,7 +183,7 @@ func (r *Router) geoRemoveAppOverride(w http.ResponseWriter, req *http.Request, if err := r.sett.RemoveGeoAppOverride(appName); err != nil { r.logger.Printf("[ERROR] [api] Failed to remove geo override for %s: %v", appName, err) - writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()}) + writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)}) return } diff --git a/controller/internal/api/i18n_api.go b/controller/internal/api/i18n_api.go index e76995a..8e7215d 100644 --- a/controller/internal/api/i18n_api.go +++ b/controller/internal/api/i18n_api.go @@ -4,6 +4,7 @@ import ( "net/http" "gitea.dooplex.hu/admin/felhom-controller/internal/i18n" + "gitea.dooplex.hu/admin/felhom-controller/internal/util" ) // ── The JSON answers follow the language too (v0.252.0, slice 2 — R-557) ─────────────────────── @@ -66,6 +67,12 @@ func (r *Router) msgLang(lang, key string, a ...interface{}) string { return b.Msgf(lang, key, a...) } +// errText renders an error in the request's language: its bundle message when it carries one, its own +// text otherwise (restic, docker, ssh and the Go stdlib print verbatim — that text is not ours). +func (r *Router) errText(req *http.Request, err error) string { + return util.ErrText(r.langFor(req), err) +} + // countryName returns a country's name in lang, falling back to the name the cloudflare table // carries when the bundle has no entry for the code. // diff --git a/controller/internal/api/router.go b/controller/internal/api/router.go index b444d75..f820f08 100644 --- a/controller/internal/api/router.go +++ b/controller/internal/api/router.go @@ -358,11 +358,11 @@ func (r *Router) listStacks(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: r.stackMgr.GetStacks()}) } -func (r *Router) rescanStacks(w http.ResponseWriter, _ *http.Request) { +func (r *Router) rescanStacks(w http.ResponseWriter, req *http.Request) { r.logger.Printf("[INFO] [api] Manual stack rescan requested") if err := r.stackMgr.ScanStacks(); err != nil { r.logger.Printf("[ERROR] [api] Stack rescan failed: %v", err) - writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()}) + writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)}) return } stackCount := len(r.stackMgr.GetStacks()) @@ -382,10 +382,10 @@ func (r *Router) getStack(w http.ResponseWriter, _ *http.Request, name string) { writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: stack}) } -func (r *Router) getDeployFields(w http.ResponseWriter, _ *http.Request, name string) { +func (r *Router) getDeployFields(w http.ResponseWriter, req *http.Request, name string) { meta, appCfg, err := r.stackMgr.GetDeployFields(name) if err != nil { - writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: err.Error()}) + writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: r.errText(req, err)}) return } @@ -490,7 +490,7 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri if err != nil { r.logger.Printf("[ERROR] [api] Deploy failed for %s: %v", name, err) // R-553 — the status code comes from the refusal's KIND (deployStatusFor), never from its words. - writeJSON(w, deployStatusFor(err), apiResponse{OK: false, Error: err.Error()}) + writeJSON(w, deployStatusFor(err), apiResponse{OK: false, Error: r.errText(req, err)}) return } @@ -698,7 +698,7 @@ func (r *Router) actionStack(w http.ResponseWriter, req *http.Request, action, n if strings.Contains(err.Error(), "not found") { status = http.StatusNotFound } - writeJSON(w, status, apiResponse{OK: false, Error: err.Error()}) + writeJSON(w, status, apiResponse{OK: false, Error: r.errText(req, err)}) return } @@ -738,7 +738,7 @@ func (r *Router) updateOptionalConfig(w http.ResponseWriter, req *http.Request, if err := r.stackMgr.UpdateOptionalConfig(name, body.Values); err != nil { r.logger.Printf("[ERROR] [api] Optional config update failed for %s: %v", name, err) - writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()}) + writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)}) return } @@ -783,7 +783,7 @@ func (r *Router) toggleIntegration(w http.ResponseWriter, req *http.Request, pro state, err := r.integrationMgr.Toggle(req.Context(), provider, target, body.Enabled) if err != nil { r.logger.Printf("[ERROR] [api] Integration toggle failed for %s:%s: %v", provider, target, err) - writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()}) + writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)}) return } @@ -807,22 +807,22 @@ func (r *Router) getStackLogs(w http.ResponseWriter, req *http.Request, name str output, err := r.stackMgr.GetLogs(name, lines) if err != nil { - writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()}) + writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)}) return } writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]string{"logs": output}}) } -func (r *Router) getStackHDDData(w http.ResponseWriter, _ *http.Request, name string) { +func (r *Router) getStackHDDData(w http.ResponseWriter, req *http.Request, name string) { resp, err := r.stackMgr.GetStackHDDData(name) if err != nil { - writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: err.Error()}) + writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: r.errText(req, err)}) return } writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp}) } -func (r *Router) getStackBackupData(w http.ResponseWriter, _ *http.Request, name string) { +func (r *Router) getStackBackupData(w http.ResponseWriter, req *http.Request, name string) { if name == "" { writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid stack name"}) return @@ -840,7 +840,7 @@ func (r *Router) getStackBackupData(w http.ResponseWriter, _ *http.Request, name resp, err := r.stackMgr.GetStackBackupData(name, nsRoot, mirrors) if err != nil { - writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: err.Error()}) + writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: r.errText(req, err)}) return } writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp}) @@ -901,7 +901,7 @@ func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name stri if strings.Contains(err.Error(), "not deployed") || strings.Contains(err.Error(), "still running") { status = http.StatusConflict } - writeJSON(w, status, apiResponse{OK: false, Error: err.Error()}) + writeJSON(w, status, apiResponse{OK: false, Error: r.errText(req, err)}) return } @@ -982,7 +982,7 @@ func (r *Router) deleteStack(w http.ResponseWriter, req *http.Request, name stri if strings.Contains(err.Error(), "not orphaned") || strings.Contains(err.Error(), "still running") { status = http.StatusConflict } - writeJSON(w, status, apiResponse{OK: false, Error: err.Error()}) + writeJSON(w, status, apiResponse{OK: false, Error: r.errText(req, err)}) return } @@ -1166,7 +1166,7 @@ func (r *Router) metricsSystem(w http.ResponseWriter, req *http.Request) { samples, err := r.metricsStore.QuerySystemMetrics(from, to, resolution) if err != nil { r.logger.Printf("[ERROR] [api] Failed to query system metrics: %v", err) - writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()}) + writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)}) return } @@ -1194,7 +1194,7 @@ func (r *Router) metricsSystem(w http.ResponseWriter, req *http.Request) { }}) } -func (r *Router) metricsContainerSummary(w http.ResponseWriter, _ *http.Request) { +func (r *Router) metricsContainerSummary(w http.ResponseWriter, req *http.Request) { if r.metricsStore == nil { writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: []interface{}{}}) return @@ -1203,7 +1203,7 @@ func (r *Router) metricsContainerSummary(w http.ResponseWriter, _ *http.Request) summary, err := r.metricsStore.QueryContainerSummary() if err != nil { r.logger.Printf("[ERROR] [api] Failed to query container summary: %v", err) - writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()}) + writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)}) return } @@ -1222,7 +1222,7 @@ func (r *Router) metricsContainer(w http.ResponseWriter, req *http.Request, name samples, err := r.metricsStore.QueryContainerMetrics(name, from, to, resolution) if err != nil { r.logger.Printf("[ERROR] [api] Failed to query container metrics for %s: %v", name, err) - writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()}) + writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)}) return } @@ -1342,7 +1342,7 @@ func (r *Router) selfupdateTrigger(w http.ResponseWriter, req *http.Request) { return } if err := r.updater.TriggerUpdate("manual"); err != nil { - writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: err.Error()}) + writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.errText(req, err)}) return } r.logger.Println("[INFO] [api] Manual self-update triggered") diff --git a/controller/internal/appexport/crypto.go b/controller/internal/appexport/crypto.go index efd4158..d8c6ea4 100644 --- a/controller/internal/appexport/crypto.go +++ b/controller/internal/appexport/crypto.go @@ -8,6 +8,7 @@ import ( "crypto/sha256" "errors" "fmt" + "gitea.dooplex.hu/admin/felhom-controller/internal/util" "io" "os" @@ -221,7 +222,7 @@ func DecryptFile(inputPath, outputPath, password string) error { } if !hmac.Equal(mac.Sum(nil), storedMAC) { os.Remove(outputPath) - return errors.New("jelszó hibás vagy a fájl sérült") + return util.MsgError("err.appexport.jelszo_hibas_vagy_a_fajl_serult") } return out.Sync() diff --git a/controller/internal/appexport/export.go b/controller/internal/appexport/export.go index b2783c1..cc85c9d 100644 --- a/controller/internal/appexport/export.go +++ b/controller/internal/appexport/export.go @@ -6,6 +6,7 @@ import ( "compress/gzip" "context" "fmt" + "gitea.dooplex.hu/admin/felhom-controller/internal/util" "io" "log" "os" @@ -696,7 +697,7 @@ func (e *Exporter) exportHDDData(req ExportRequest, dataDir string, manifest *Ma // Renaming can't help either (the import couldn't map the new name), so the only honest // outcome is a loud failure. if prev, dup := claimed[subdir]; dup { - return fmt.Errorf("két adatkönyvtár azonos névvel végződik (%q: %s és %s) — a csomag nem tudná megkülönböztetni őket", subdir, prev, mount) + return util.MsgError("err.appexport.ket_adatkonyvtar_azonos_nevvel_vegzodik_es", subdir, prev, mount) } claimed[subdir] = mount @@ -845,7 +846,7 @@ func assertBundleDataComplete(tmpDir string, manifest *Manifest) error { // bundle. A needs_hdd app with NO data of any kind is a hollow bundle by definition; refuse it // loudly so a future discovery gap can never again ship silently. if manifest.NeedsHDD && !manifest.HasHDDData && !manifest.HasVolumeData { - return fmt.Errorf("a mentés nem tartalmaz alkalmazásadatot (0 adatkönyvtár, 0 kötet egy adattárolós alkalmazásnál)") + return util.MsgError("err.appexport.a_mentes_nem_tartalmaz_alkalmazasadatot_0") } return nil } diff --git a/controller/internal/appexport/fabplan_test.go b/controller/internal/appexport/fabplan_test.go index b6e1e2d..7b5e36d 100644 --- a/controller/internal/appexport/fabplan_test.go +++ b/controller/internal/appexport/fabplan_test.go @@ -22,13 +22,13 @@ type fabProv struct { mounts []string } -func (p *fabProv) GetStackHDDPath(string) string { return p.hddPath } -func (p *fabProv) GetImportRoot() string { return "" } // R-75: no import binds in this fixture +func (p *fabProv) GetStackHDDPath(string) string { return p.hddPath } +func (p *fabProv) GetImportRoot() string { return "" } // R-75: no import binds in this fixture // R-203: these fixtures use ENROLLED drive paths, where the namespace root IS the drive path. // Delegating keeps that identity explicit rather than hardcoding it. func (p *fabProv) GetStackNamespaceRoot(name string) string { return p.GetStackHDDPath(name) } -func (p *fabProv) GetStackHDDMounts(string) []string { return p.mounts } +func (p *fabProv) GetStackHDDMounts(string) []string { return p.mounts } func (p *fabProv) GetStackClassifiedBinds(string) ([]appbackup.ClassifiedBind, bool) { return p.binds, p.has } diff --git a/controller/internal/appexport/restore.go b/controller/internal/appexport/restore.go index 7fc2af3..ef0ae2c 100644 --- a/controller/internal/appexport/restore.go +++ b/controller/internal/appexport/restore.go @@ -5,6 +5,7 @@ import ( "compress/gzip" "context" "fmt" + "gitea.dooplex.hu/admin/felhom-controller/internal/util" "io" "os" "os/exec" @@ -168,7 +169,7 @@ func ReadManifestFromEncryptedFAB(fabPath, password string) (*Manifest, error) { defer os.Remove(tmpPath) if err := DecryptFile(fabPath, tmpPath, password); err != nil { - return nil, fmt.Errorf("visszafejtés sikertelen: %w", err) + return nil, util.MsgError("err.appexport.visszafejtes_sikertelen", err) } return ReadManifestFromFAB(tmpPath) @@ -689,7 +690,7 @@ func validateBundleData(tmpDir string, manifest *Manifest) error { } fi, err := os.Stat(filepath.Join(tmpDir, "data", "volumes", v+".tar")) if err != nil || fi.Size() == 0 { - return fmt.Errorf("a(z) %q kötet adata hiányzik a csomagból", v) + return util.MsgError("err.appexport.a_z_kotet_adata_hianyzik_a", v) } } for _, s := range manifest.HDDSubdirs { @@ -698,7 +699,7 @@ func validateBundleData(tmpDir string, manifest *Manifest) error { } fi, err := os.Stat(filepath.Join(tmpDir, "data", "hdd", s+".tar")) if err != nil || fi.Size() == 0 { - return fmt.Errorf("a(z) %q adatkönyvtár tartalma hiányzik a csomagból", s) + return util.MsgError("err.appexport.a_z_adatkonyvtar_tartalma_hianyzik_a", s) } } return nil diff --git a/controller/internal/backup/offbox.go b/controller/internal/backup/offbox.go index 49361f6..332f63e 100644 --- a/controller/internal/backup/offbox.go +++ b/controller/internal/backup/offbox.go @@ -354,7 +354,7 @@ func (m *Manager) resetOrphanedRepo(ctx context.Context, base, env []string, rea // unless the repo is currently orphaned. It builds the base/env and runs the move-aside + re-init. func (m *Manager) ResetOrphanedRepo(ctx context.Context) error { if !m.OffboxOrphaned() { - return fmt.Errorf("az offsite tároló nincs elárvult állapotban") + return util.MsgError("err.backup.az_offsite_tarolo_nincs_elarvult_allapotban") } t := m.settings.GetOffboxTarget() base, env := m.offboxBaseArgs(t) @@ -932,7 +932,7 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error // run refuses. m.offboxPruneOnly(ctx, base, env) m.offboxRecordStats(ctx, base, env) // the prune may have brought the size back down — refresh - runErr = util.KindErrorf(ErrOffsiteQuota, "A távoli mentés túllépte a tárhelykeretet (%d/%d GB) — törölj régi mentéseket vagy kérj nagyobb keretet.", usedGB, quota) + runErr = util.MsgErrorf(ErrOffsiteQuota, "err.backup.offsite_over_quota", usedGB, quota) } else { // R-43/R-44 (v0.148.0) — THE COHERENCE PRE-PHASE. Refresh the DB/volume dumps and the recovery // units BEFORE capturing, so the snapshot restic is about to write is an internally coherent diff --git a/controller/internal/backup/offbox_reconstitute.go b/controller/internal/backup/offbox_reconstitute.go index 1d7b7fc..a6905a4 100644 --- a/controller/internal/backup/offbox_reconstitute.go +++ b/controller/internal/backup/offbox_reconstitute.go @@ -3,6 +3,7 @@ package backup import ( "context" "fmt" + "gitea.dooplex.hu/admin/felhom-controller/internal/util" "os" "os/exec" "path/filepath" @@ -213,7 +214,7 @@ func (m *Manager) writeSafetyDump(ctx context.Context, stackName, nsRoot string) } dbs, err := discover(ctx) if err != nil { - return safetyDumpSet{}, fmt.Errorf("a biztonsági mentés előtt nem sikerült felderíteni az adatbázisokat: %w", err) + return safetyDumpSet{}, util.MsgError("err.backup.a_biztonsagi_mentes_elott_nem_sikerult", err) } var mine []DiscoveredDB for _, db := range dbs { @@ -227,7 +228,7 @@ func (m *Manager) writeSafetyDump(ctx context.Context, stackName, nsRoot string) dumpDir := AppDBDumpPath(nsRoot, stackName) if err := os.MkdirAll(dumpDir, 0755); err != nil { - return safetyDumpSet{}, fmt.Errorf("a biztonsági mentés könyvtára nem hozható létre: %w", err) + return safetyDumpSet{}, util.MsgError("err.backup.a_biztonsagi_mentes_konyvtara_nem_hozhato", err) } set := safetyDumpSet{Stamp: time.Now().UTC().Format("20060102T150405Z")} for _, db := range mine { @@ -248,7 +249,7 @@ func (m *Manager) writeSafetyDump(ctx context.Context, stackName, nsRoot string) safe := filepath.Join(dumpDir, fmt.Sprintf("%s%s-%s-%s.sql", preRestoreDumpPrefix, set.Stamp, stackName, db.DBType)) res := m.dumpForSafety(ctx, db, safe) if res.Error != nil { - return safetyDumpSet{}, fmt.Errorf("a jelenlegi adatbázis biztonsági mentése sikertelen (%s): %w — a visszaállítás nem indult el", db.ContainerName, res.Error) + return safetyDumpSet{}, util.MsgError("err.backup.a_jelenlegi_adatbazis_biztonsagi_mentese_sikertelen", db.ContainerName, res.Error) } // EVERY file, not just the first — R-379, and the reason is on safetyDumpSet. set.Files = append(set.Files, safetyDumpFile{DB: db, Path: safe}) @@ -440,7 +441,7 @@ func (m *Manager) rollbackSafetyDump(ctx context.Context, stack string, set safe } live, dErr := discover(ctx) if dErr != nil { - return fmt.Errorf("a visszavonás előtt nem sikerült felderíteni az adatbázisokat: %w", dErr) + return util.MsgError("err.backup.a_visszavonas_elott_nem_sikerult_felderiteni", dErr) } liveFor := func(want DiscoveredDB) (DiscoveredDB, bool) { for _, db := range live { @@ -453,13 +454,13 @@ func (m *Manager) rollbackSafetyDump(ctx context.Context, stack string, set safe for _, f := range set.Files { if _, sErr := os.Stat(f.Path); sErr != nil { - return fmt.Errorf("a visszavonáshoz szükséges mentés nem található (%s): %w", filepath.Base(f.Path), sErr) + return util.MsgError("err.backup.a_visszavonashoz_szukseges_mentes_nem_talalhato", filepath.Base(f.Path), sErr) } target, ok := liveFor(f.DB) if !ok { // Fail closed: pouring an undo into a container we cannot identify is worse than saying // we could not do it. - return fmt.Errorf("a(z) %s adatbázis-tárolója nem található a visszavonáshoz", f.DB.ContainerName) + return util.MsgError("err.backup.a_z_adatbazis_taroloja_nem_talalhato", f.DB.ContainerName) } if target.ContainerID != f.DB.ContainerID { m.logger.Printf("[DEBUG] [offbox] %s: %s was re-created during the restore (%s → %s) — rolling back into the live container", @@ -467,7 +468,7 @@ func (m *Manager) rollbackSafetyDump(ctx context.Context, stack string, set safe } m.logger.Printf("[INFO] [offbox] %s: rolling back to the pre-restore state from %s", stack, filepath.Base(f.Path)) if err := imp(ctx, target, f.Path); err != nil { - return fmt.Errorf("a korábbi állapot visszaállítása sikertelen (%s): %w", target.ContainerName, err) + return util.MsgError("err.backup.a_korabbi_allapot_visszaallitasa_sikertelen", target.ContainerName, err) } } m.logger.Printf("[INFO] [offbox] %s: rollback complete — %d database(s) returned to the pre-restore state", stack, len(set.Files)) @@ -505,7 +506,7 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack return res, fmt.Errorf("stack provider not configured") } if err := m.acquireRunning(); err != nil { - return res, fmt.Errorf("egy másik mentési/visszaállítási művelet már fut") + return res, util.MsgError("err.backup.egy_masik_mentesi_visszaallitasi_muvelet_mar") } defer m.releaseRunning() @@ -514,7 +515,7 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack return res, err } if _, sErr := os.Stat(scratch); sErr != nil { - return res, fmt.Errorf("nincs előkészített teljes visszaállítás — futtass előbb egy teljes visszaállítást") + return res, util.MsgError("err.backup.nincs_elokeszitett_teljes_visszaallitas_futtass_elobb") } id, paths, err := m.offboxLatestSnapshot(ctx, stack) if err != nil { @@ -541,12 +542,9 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack // above is untouched for the 13 apps that DO have a drive to get wrong. if !m.isStackDeployed(stack) { if rec := m.recordedPlacementFromScratch(scratch); rec.Known() { - return res, fmt.Errorf("a(z) %s nincs telepítve, ezért nincs hová visszaállítani az adatait. "+ - "A mentése szerint az adatai itt voltak: %s. Telepítsd újra az alkalmazást (Alkalmazások) "+ - "ugyanerre a helyre, utána ez a visszaállítás működni fog", stack, rec.Drive) + return res, util.MsgError("err.backup.not_installed_known_drive", stack, rec.Drive) } - return res, fmt.Errorf("a(z) %s nincs telepítve, ezért nincs hová visszaállítani az adatait — "+ - "telepítsd újra az alkalmazást (Alkalmazások), utána ez a visszaállítás működni fog", stack) + return res, util.MsgError("err.backup.not_installed", stack) } // The destination is resolved by the SAME rule the capture side used to write this snapshot // (CaptureRecoveryUnit → GetAppDrivePath): the app's drive if it has one, the system data path @@ -558,8 +556,7 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack // installed, but the box cannot name its own data root (systemDataPath unset). Saying // "nincs telepítve" here would send the customer to reinstall an app that is already // running, and the real fault would stay invisible. - return res, fmt.Errorf("a(z) %s telepítve van, de a vezérlő nem tudja megállapítani, hová tartoznak az adatai "+ - "(nincs beállítva rendszer-adatterület). Ellenőrizd a tárhely beállításait (Tárhely), utána indítsd újra a visszaállítást", stack) + return res, util.MsgError("err.backup.no_data_root", stack) } liveNs := m.namespaceRoot(hdd) @@ -609,7 +606,7 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack // unit-only restore was run) refuses with ZERO copies. for _, pl := range placements { if _, sErr := os.Stat(pl.src); sErr != nil { - return res, fmt.Errorf("a teljes visszaállítás hiányos (%s nincs meg) — futtass előbb egy teljes visszaállítást", filepath.Base(pl.src)) + return res, util.MsgError("err.backup.a_teljes_visszaallitas_hianyos_nincs_meg", filepath.Base(pl.src)) } } @@ -622,7 +619,7 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack } } if scratchUnit == "" { - return res, fmt.Errorf("a pillanatképben nincs mentési egység — a visszaállítás nem indítható") + return res, util.MsgError("err.backup.a_pillanatkepben_nincs_mentesi_egyseg_a") } scratchDumpDir := filepath.Join(scratchUnit, "db-dumps") man := readManifest(filepath.Join(scratchUnit, "manifest.json")) @@ -684,13 +681,13 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack if hasDB { if _, sErr := os.Stat(safety); sErr != nil { // Fail-closed: never replay when the undo is not verifiably on disk. - return res, fmt.Errorf("a biztonsági mentés nem található a lemezen — a visszaállítás biztonsági okból nem indult el") + return res, util.MsgError("err.backup.a_biztonsagi_mentes_nem_talalhato_a") } // Fail-closed (R-47): the app HAS a database but no compose service can be identified to // start alone for the replay. The only alternative would be to start everything and replay // into the race that produced H4 — refusing with the live app untouched is the better outcome. if len(dbServices) == 0 { - return res, fmt.Errorf("Az adatbázis-szolgáltatás nem azonosítható a(z) %s alkalmazásban — a visszaállítás biztonsági okból nem indult el.", stack) + return res, util.MsgError("err.backup.az_adatbazis_szolgaltatas_nem_azonosithato_a", stack) } } @@ -699,7 +696,7 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack // it used to leave the app down with nothing on disk recording that it was owed a restart — and a // full offsite restore is a LONG window, so this is the shape most likely to be interrupted. if err := m.appStop.Begin("offbox-reconstitute:"+stack, ReasonOffboxReconstitute, []string{stack}); err != nil { - return res, fmt.Errorf("a(z) %s leállítása előtti jelölő nem menthető: %w", stack, err) + return res, util.MsgError("err.backup.a_z_leallitasa_elotti_jelolo_nem", stack, err) } // restartStack starts the app and clears the marker ONLY when the start actually succeeded — a // failed start leaves the marker so the next startup retries. Every bring-up below goes through @@ -744,7 +741,7 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack if sErr := restartStack(); sErr != nil { m.logger.Printf("[WARN] [offbox] %s: restart after failed placement also failed: %v", stack, sErr) } - return res, fmt.Errorf("a(z) %s fájljainak visszaállítása sikertelen: %w", stack, cErr) + return res, util.MsgError("err.backup.a_z_fajljainak_visszaallitasa_sikertelen", stack, cErr) } res.FilesPlaced += n } @@ -770,7 +767,7 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack if sErr := restartStack(); sErr != nil { m.logger.Printf("[WARN] [offbox] %s: restart after failed volume replay also failed: %v", stack, sErr) } - return res, fmt.Errorf("a(z) %s adatkötetének visszaállítása sikertelen: %w", stack, vErr) + return res, util.MsgError("err.backup.a_z_adatkotetenek_visszaallitasa_sikertelen", stack, vErr) } // --- DATABASE ------------------------------------------------------------------------------- @@ -785,7 +782,7 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack if sErr := restartStack(); sErr != nil { m.logger.Printf("[WARN] [offbox] %s: full start after failed DB-only start also failed: %v", stack, sErr) } - return res, fmt.Errorf("a(z) %s adatbázis-szolgáltatásának indítása sikertelen: %w", stack, err) + return res, util.MsgError("err.backup.a_z_adatbazis_szolgaltatasanak_inditasa_sikertelen", stack, err) } n, iErr := m.reimportDBDumpsFrom(ctx, stack, scratchDumpDir) res.DBsReplayed = n @@ -813,9 +810,7 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack // R-383: the undo copy is DESCRIBED FROM DISK, never from the path alone. See // undoCopyPhrase — this sentence used to assert the file existed in exactly the // branch where a missing file is one of the two causes. - return res, fmt.Errorf("a(z) %s adatbázisának visszaállítása sikertelen, és a korábbi állapot visszatöltése sem sikerült. "+ - "Az alkalmazást biztonsági okból LEÁLLÍTVA hagytuk, hogy az adatai ne sérüljenek tovább. "+ - "Vedd fel velünk a kapcsolatot — %s", stack, undoCopyPhrase(safetySet)) + return res, util.MsgError("err.backup.db_restore_and_rollback_failed", stack, undoCopyPhrase(safetySet)) } res.RolledBack = true if sErr := restartStack(); sErr != nil { @@ -824,12 +819,11 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack // Says BOTH things. A message that reported only the failure would leave the customer // believing their data was gone when it is back — the omission of a GAIN is as // misleading as the omission of a loss. - return res, fmt.Errorf("a(z) %s adatbázisának visszaállítása sikertelen — az adataid visszakerültek a visszaállítás előtti állapotba, "+ - "az alkalmazás fut tovább. Ha újra megpróbálnád, előbb vedd fel velünk a kapcsolatot", stack) + return res, util.MsgError("err.backup.db_restore_failed_rolled_back", stack) } } if err := restartStack(); err != nil { - return res, fmt.Errorf("a(z) %s újraindítása sikertelen a fájlok visszaállítása után: %w", stack, err) + return res, util.MsgError("err.backup.a_z_ujrainditasa_sikertelen_a_fajlok", stack, err) } // R-475: an update hold may now name the OFF-SITE copy, and this is the route back it names. The // unit restore clears it in RestoreFromRecoveryUnitAt; this path never went through that function, diff --git a/controller/internal/backup/offbox_recovery_cli.go b/controller/internal/backup/offbox_recovery_cli.go index b8adaa5..a2a98ce 100644 --- a/controller/internal/backup/offbox_recovery_cli.go +++ b/controller/internal/backup/offbox_recovery_cli.go @@ -137,6 +137,7 @@ func RunRecoveryCheck(d RecoveryCheckDeps) int { // refused — a password is present and DIFFERS. Installing would clobber the key this box's CURRENT // repository is encrypted under, so it is refused. No force option is offered here: that // decision needs a human who knows which history they intend to keep. +// // RecoverInstallOutcome names the terminal states of a recovery+install. Distinct values because // "it did nothing", "it refused" and "it installed" are different facts and a caller — CLI or web — // must be able to say which happened without parsing prose. diff --git a/controller/internal/backup/offbox_restore.go b/controller/internal/backup/offbox_restore.go index 83ca481..be616e4 100644 --- a/controller/internal/backup/offbox_restore.go +++ b/controller/internal/backup/offbox_restore.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "gitea.dooplex.hu/admin/felhom-controller/internal/util" "os" "os/exec" "path/filepath" @@ -134,7 +135,7 @@ func (m *Manager) offboxLatestSnapshot(ctx context.Context, stack string) (id st Paths []string `json:"paths"` } if json.Unmarshal(out, &snaps) != nil || len(snaps) == 0 { - return "", nil, fmt.Errorf("offbox: nincs pillanatkép a(z) %s alkalmazáshoz", stack) + return "", nil, util.MsgError("err.backup.offbox_nincs_pillanatkep_a_z_alkalmazashoz", stack) } best := 0 for i := 1; i < len(snaps); i++ { @@ -165,7 +166,7 @@ func (m *Manager) offboxSnapshotSize(ctx context.Context, id string) (int64, err TotalSize int64 `json:"total_size"` } if json.Unmarshal(out, &st) != nil || st.TotalSize <= 0 { - return 0, fmt.Errorf("offbox: a(z) %s pillanatkép mérete ismeretlen", id) + return 0, util.MsgError("err.backup.offbox_a_z_pillanatkep_merete_ismeretlen", id) } return st.TotalSize, nil } @@ -280,9 +281,7 @@ func (m *Manager) offboxScratchDirIn(stack string, rootFor func(string) string, // drives are physically fine and still mounted, it is their REGISTRATION that the destroyed guest // took with it — and until v0.207.0 it said only that a drive was missing, which reads like data // loss and offers nothing to do. - return "", "", fmt.Errorf("nincs regisztrált adatmeghajtó, ezért nincs hová visszaállítani — " + - "a meghajtók megvannak, csak újra kell csatolni őket a Tárhely → Meghajtók oldalon, utána " + - "ez a visszaállítás működni fog") + return "", "", util.MsgError("err.backup.no_registered_drive") } // HasRestoreDestination reports whether an offsite restore has anywhere on this box to write. @@ -347,7 +346,7 @@ func (m *Manager) RestoreOffboxScratch(ctx context.Context, stack string, full b } unitPath := offboxUnitPathOf(paths, stack) if unitPath == "" { - return fmt.Errorf("a(z) %s pillanatképében nincs mentési egység — a visszaállítás nem indítható", stack) + return util.MsgError("err.backup.a_z_pillanatkepeben_nincs_mentesi_egyseg", stack) } scratch, nsRoot, err := m.offboxRestoreScratchDir(stack) if err != nil { @@ -611,7 +610,7 @@ func mapOffsiteRestorePaths(snapPaths []string, stack, scratch, liveNsRoot strin } } if oldNs == "" { - return nil, fmt.Errorf("a pillanatképben nincs mentési egység (backups/primary/%s)", stack) + return nil, util.MsgError("err.backup.a_pillanatkepben_nincs_mentesi_egyseg_backups", stack) } out := make([]placement, 0, len(snapPaths)) for _, p := range snapPaths { @@ -619,17 +618,17 @@ func mapOffsiteRestorePaths(snapPaths []string, stack, scratch, liveNsRoot strin // catches p == oldNs (the namespace root itself — F-3a-3), which would otherwise map to a junk // placement nesting the whole old namespace under the live root. if !strings.HasPrefix(p, oldNs+"/") { - return nil, fmt.Errorf("a pillanatkép egy útvonala a névtéren kívülre mutat: %s", p) + return nil, util.MsgError("err.backup.a_pillanatkep_egy_utvonala_a_nevteren", p) } rel := strings.TrimPrefix(p, oldNs+"/") for _, seg := range strings.Split(rel, "/") { if seg == ".." { - return nil, fmt.Errorf("a pillanatkép egy útvonala érvénytelen (..): %s", p) + return nil, util.MsgError("err.backup.a_pillanatkep_egy_utvonala_ervenytelen", p) } } isUnit := rel == "backups/primary/"+stack if !isUnit && (rel == "backups" || strings.HasPrefix(rel, "backups/")) { - return nil, fmt.Errorf("nem-egység útvonal a fenntartott backups zónában: %s", p) + return nil, util.MsgError("err.backup.nem_egyseg_utvonal_a_fenntartott_backups", p) } out = append(out, placement{ src: filepath.Join(scratch, p), // SP-3.1: abs source reconstructed under the target @@ -662,7 +661,7 @@ func (m *Manager) PlaceOffsiteRestore(ctx context.Context, stack string) error { return fmt.Errorf("invalid stack name") } if err := m.acquireRunning(); err != nil { - return fmt.Errorf("egy másik mentési/visszaállítási művelet már fut") + return util.MsgError("err.backup.egy_masik_mentesi_visszaallitasi_muvelet_mar") } defer m.releaseRunning() @@ -671,7 +670,7 @@ func (m *Manager) PlaceOffsiteRestore(ctx context.Context, stack string) error { return err } if _, sErr := os.Stat(scratch); sErr != nil { - return fmt.Errorf("nincs előkészített teljes visszaállítás — futtass előbb egy teljes visszaállítást") + return util.MsgError("err.backup.nincs_elokeszitett_teljes_visszaallitas_futtass_elobb") } id, paths, err := m.offboxLatestSnapshot(ctx, stack) if err != nil { @@ -684,14 +683,13 @@ func (m *Manager) PlaceOffsiteRestore(ctx context.Context, stack string) error { // one, system data path otherwise). For the 13 needs_hdd apps nothing changes; for the 40 that // were never offered a drive the old test was permanently true and this merge was unreachable. if !m.isStackDeployed(stack) { - return fmt.Errorf("a(z) %s nincs telepítve — előbb állítsd helyre az alkalmazást, utána az adatokat", stack) + return util.MsgError("err.backup.a_z_nincs_telepitve_elobb_allitsd", stack) } hdd := strings.TrimSpace(m.GetAppDrivePath(stack)) if hdd == "" { // Installed, but the box cannot name its own data root. Distinct reason ⇒ distinct sentence: // telling the customer to reinstall a running app would hide the real fault. - return fmt.Errorf("a(z) %s telepítve van, de a vezérlő nem tudja megállapítani, hová tartoznak az adatai "+ - "(nincs beállítva rendszer-adatterület). Ellenőrizd a tárhely beállításait (Tárhely), utána indítsd újra a visszaállítást", stack) + return util.MsgError("err.backup.no_data_root", stack) } liveNs := m.namespaceRoot(hdd) // F-3a-1b: headroom gate — a missing-only merge copies at most the scratch size; refuse before any @@ -707,7 +705,7 @@ func (m *Manager) PlaceOffsiteRestore(ctx context.Context, stack string) error { // unit-only restore, userdata srcs absent) refuses with ZERO copies, making "no partial writes" true. for _, pl := range placements { if _, sErr := os.Stat(pl.src); sErr != nil { - return fmt.Errorf("a teljes visszaállítás hiányos (%s nincs meg) — futtass előbb egy teljes visszaállítást", filepath.Base(pl.src)) + return util.MsgError("err.backup.a_teljes_visszaallitas_hianyos_nincs_meg", filepath.Base(pl.src)) } } copier := m.placeCopier() @@ -721,7 +719,7 @@ func (m *Manager) PlaceOffsiteRestore(ctx context.Context, stack string) error { } n, cErr := copier(pl.src, pl.dst) if cErr != nil { - return fmt.Errorf("a(z) %s helyreállítása sikertelen: %w", stack, cErr) // scratch KEPT for retry + return util.MsgError("err.backup.a_z_helyreallitasa_sikertelen", stack, cErr) // scratch KEPT for retry } placed += n } diff --git a/controller/internal/backup/offbox_verify_copies.go b/controller/internal/backup/offbox_verify_copies.go index e8be0e8..c8e55cd 100644 --- a/controller/internal/backup/offbox_verify_copies.go +++ b/controller/internal/backup/offbox_verify_copies.go @@ -1,7 +1,7 @@ package backup import ( - "fmt" + "gitea.dooplex.hu/admin/felhom-controller/internal/util" "os" "path/filepath" "sort" @@ -114,7 +114,7 @@ func (m *Manager) ListOffsiteRestoreCopies() []OffsiteRestoreCopy { // walk the delete out of the sandbox. func (m *Manager) DeleteOffsiteRestoreCopy(stack string) error { if !isSafeStackName(stack) { - return fmt.Errorf("érvénytelen alkalmazásnév") + return util.MsgError("err.backup.ervenytelen_alkalmazasnev") } for _, drive := range m.offsiteRestoreDriveRoots() { root := m.offsiteRestoreRootFor(drive) @@ -131,15 +131,15 @@ func (m *Manager) DeleteOffsiteRestoreCopy(stack string) error { cleanRoot := filepath.Clean(root) + string(filepath.Separator) if !strings.HasPrefix(cleanTarget+string(filepath.Separator), cleanRoot) { m.logger.Printf("[WARN] [offbox] refusing to delete verification copy outside %s: %s", root, cleanTarget) - return fmt.Errorf("a törlés útvonala kívül esik az ellenőrző mappán") + return util.MsgError("err.backup.a_torles_utvonala_kivul_esik_az") } if err := os.RemoveAll(cleanTarget); err != nil { - return fmt.Errorf("a másolat törlése nem sikerült: %w", err) + return util.MsgError("err.backup.a_masolat_torlese_nem_sikerult", err) } m.logger.Printf("[INFO] [offbox] deleted verification copy: %s", cleanTarget) return nil } - return fmt.Errorf("nincs ilyen ellenőrző másolat") + return util.MsgError("err.backup.nincs_ilyen_ellenorzo_masolat") } // OffsiteRestoreScratchPath exposes WHERE a verification restore for stack would land, so the UI can diff --git a/controller/internal/backup/r403_hollow_test.go b/controller/internal/backup/r403_hollow_test.go index 6613bb3..3ff1c70 100644 --- a/controller/internal/backup/r403_hollow_test.go +++ b/controller/internal/backup/r403_hollow_test.go @@ -35,8 +35,8 @@ func r403Unit(t *testing.T, dbDumps, volDumps []string, extra map[string]string) // demo-hp: `"db_dumps": []`, `"volume_dumps": null`. func TestR403_ManifestWithNoDumpsIsHollow(t *testing.T) { for _, tc := range []struct { - name string - dbs, vols []string + name string + dbs, vols []string }{ {"both empty slices", []string{}, []string{}}, {"both nil (the measured shape: db_dumps [] and volume_dumps null)", nil, nil}, diff --git a/controller/internal/backup/restore_unit.go b/controller/internal/backup/restore_unit.go index 6be7bb0..477020d 100644 --- a/controller/internal/backup/restore_unit.go +++ b/controller/internal/backup/restore_unit.go @@ -2,6 +2,7 @@ package backup import ( "fmt" + "gitea.dooplex.hu/admin/felhom-controller/internal/util" "os" "path/filepath" "strings" @@ -390,7 +391,7 @@ func (m *Manager) RestoreFromRecoveryUnitAtWith(stackName, unitDir string, opt U hasDumps := hasReplayableDump(dbDumpDir) if hasDumps && len(dbServices) == 0 { m.logger.Printf("[ERROR] [backup] Restore REFUSED for %s: a .sql dump exists but no database service is identifiable in the unit's compose", stackName) - return res, fmt.Errorf("Az adatbázis-szolgáltatás nem azonosítható a(z) %s alkalmazásban — a visszaállítás biztonsági okból nem indult el.", stackName) + return res, util.MsgError("err.backup.az_adatbazis_szolgaltatas_nem_azonosithato_a", stackName) } // Stop, restore named-volume data, recreate the definition, replay the DB with ONLY the database diff --git a/controller/internal/backup/shares_restore.go b/controller/internal/backup/shares_restore.go index afb1533..f8c55b5 100644 --- a/controller/internal/backup/shares_restore.go +++ b/controller/internal/backup/shares_restore.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "gitea.dooplex.hu/admin/felhom-controller/internal/util" "os" "os/exec" "path/filepath" @@ -59,7 +60,7 @@ func defaultSharesPassdbRestore(tar []byte) error { // exists (the F-6C-1 ownership-fidelity lesson). func (m *Manager) sharesRestoreScratchDir() (scratch, nsRoot string, err error) { if m.settings == nil { - return "", "", fmt.Errorf("nincs elérhető adatmeghajtó a visszaállításhoz") + return "", "", util.MsgError("err.backup.nincs_elerheto_adatmeghajto_a_visszaallitashoz") } pick := func(networkOK bool) (string, string, bool) { for _, sp := range m.settings.GetSchedulableStoragePaths() { @@ -78,7 +79,7 @@ func (m *Manager) sharesRestoreScratchDir() (scratch, nsRoot string, err error) m.logger.Printf("[WARN] [shares] restore scratch on network storage — ownership fidelity not guaranteed under squash") return s, nr, nil } - return "", "", fmt.Errorf("nincs elérhető adatmeghajtó a visszaállításhoz") + return "", "", util.MsgError("err.backup.nincs_elerheto_adatmeghajto_a_visszaallitashoz") } // RestoreSharesScratch restores the latest `_shares` snapshot into the scratch dir. Non-destructive: @@ -97,19 +98,19 @@ func (m *Manager) RestoreSharesScratch(ctx context.Context) error { } defer m.releaseRunning() if !m.OffboxConfigured() { - return fmt.Errorf("a távoli mentés nincs beállítva") + return util.MsgError("err.backup.a_tavoli_mentes_nincs_beallitva") } scratch, nsRoot, err := m.sharesRestoreScratchDir() if err != nil { return err } if free := m.offboxFree()(nsRoot); free > 0 && free < offboxUnitOnlyFreeFloor { - return fmt.Errorf("Nincs elég szabad hely a visszaállításhoz (%s szükséges, %s szabad).", + return util.MsgError("err.backup.nincs_eleg_szabad_hely_a_visszaallitashoz", humanizeBytes(offboxUnitOnlyFreeFloor), humanizeBytes(free)) } id, _, err := m.offboxLatestSnapshot(ctx, SharesPseudoStack) if err != nil { - return fmt.Errorf("nincs visszaállítható megosztás-mentés: %w", err) + return util.MsgError("err.backup.nincs_visszaallithato_megosztas_mentes", err) } if err := os.MkdirAll(scratch, 0o755); err != nil { return fmt.Errorf("restore dir: %w", err) @@ -121,7 +122,7 @@ func (m *Manager) RestoreSharesScratch(ctx context.Context) error { m.unlockStale(rctx, base, env) out, rerr := m.resticStep(rctx, env, base, "restore:"+SharesPseudoStack, "restore", id, "--target", scratch) if rerr != nil { - return fmt.Errorf("a megosztások visszaállítása sikertelen: %w: %s", rerr, truncate(out)) + return util.MsgError("err.backup.a_megosztasok_visszaallitasa_sikertelen", rerr, truncate(out)) } m.logger.Printf("[INFO] [shares] restored snapshot %s → %s", id, scratch) return nil @@ -197,10 +198,10 @@ func (m *Manager) readSharesManifestFrom(scratch string) (SharesManifest, error) p := filepath.Join(scratchJoin(scratch, m.sharesPayloadDir()), sharesManifestName) blob, err := os.ReadFile(p) if err != nil { - return mf, fmt.Errorf("a mentésben nincs megosztás-leíró: %w", err) + return mf, util.MsgError("err.backup.a_mentesben_nincs_megosztas_leiro", err) } if err := json.Unmarshal(blob, &mf); err != nil { - return mf, fmt.Errorf("a megosztás-leíró olvashatatlan: %w", err) + return mf, util.MsgError("err.backup.a_megosztas_leiro_olvashatatlan", err) } return mf, nil } @@ -212,7 +213,7 @@ func (m *Manager) readSharesManifestFrom(scratch string) (SharesManifest, error) func (m *Manager) PlaceSharesRestore(ctx context.Context) (SharesRestoreResult, error) { var res SharesRestoreResult if err := m.acquireRunning(); err != nil { - return res, fmt.Errorf("egy másik mentési/visszaállítási művelet már fut") + return res, util.MsgError("err.backup.egy_masik_mentesi_visszaallitasi_muvelet_mar") } defer m.releaseRunning() @@ -221,7 +222,7 @@ func (m *Manager) PlaceSharesRestore(ctx context.Context) (SharesRestoreResult, return res, err } if _, sErr := os.Stat(scratch); sErr != nil { - return res, fmt.Errorf("nincs előkészített visszaállítás — futtass előbb egy megosztás-visszaállítást") + return res, util.MsgError("err.backup.nincs_elokeszitett_visszaallitas_futtass_elobb_egy") } mf, err := m.readSharesManifestFrom(scratch) if err != nil { @@ -248,7 +249,7 @@ func (m *Manager) PlaceSharesRestore(ctx context.Context) (SharesRestoreResult, if _, sErr := os.Stat(src); sErr == nil { n, cErr := copier(src, sh.Path) if cErr != nil { - return res, fmt.Errorf("a(z) „%s” megosztás fájljainak visszaállítása sikertelen: %w", sh.Name, cErr) + return res, util.MsgError("err.backup.a_z_megosztas_fajljainak_visszaallitasa_sikertelen", sh.Name, cErr) } res.FilesRestored += n res.SharesPlaced = append(res.SharesPlaced, sh.Name) diff --git a/controller/internal/backup/tier2.go b/controller/internal/backup/tier2.go index 5e24acc..7f7fe29 100644 --- a/controller/internal/backup/tier2.go +++ b/controller/internal/backup/tier2.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "gitea.dooplex.hu/admin/felhom-controller/internal/util" "os" "os/exec" "path/filepath" @@ -36,7 +37,7 @@ var ( // errTier2NetworkOnly (F-6C-1): the only off-disk candidate is a NAS network share, which can never // be a tier-2 target — rsync -og chowns under a root_squash export and a wrong-owner restore is // silently broken. The message IS the customer-facing reason. - errTier2NetworkOnly = errors.New("Hálózati tároló nem lehet a 2. mentés célja — a fájltulajdonos-adatok megőrzése ott nem garantálható.") + errTier2NetworkOnly = util.MsgError("err.backup.halozati_tarolo_nem_lehet_a_2") ) // appDataDirNames resolves the app's real appdata dir name(s) under hddPath from its compose HDD diff --git a/controller/internal/backup/tier2_restore.go b/controller/internal/backup/tier2_restore.go index 23a497c..c731c88 100644 --- a/controller/internal/backup/tier2_restore.go +++ b/controller/internal/backup/tier2_restore.go @@ -2,8 +2,8 @@ package backup import ( "context" - "errors" "fmt" + "gitea.dooplex.hu/admin/felhom-controller/internal/util" "os" "os/exec" "path/filepath" @@ -25,26 +25,26 @@ import ( // Refusal reasons (customer-readable — they surface verbatim in the flash message). var ( - errNoTier2Copy = errors.New("nincs másodlagos fájlmásolat ehhez az alkalmazáshoz") - errTier2DriveGone = errors.New("a másodlagos meghajtó nincs csatlakoztatva") - errLiveDriveGone = errors.New("az alkalmazás meghajtója nincs csatlakoztatva") - errLiveDriveDecommed = errors.New("az alkalmazás meghajtója le van szerelve") + errNoTier2Copy = util.MsgError("err.backup.nincs_masodlagos_fajlmasolat_ehhez_az_alkalmazashoz") + errTier2DriveGone = util.MsgError("err.backup.a_masodlagos_meghajto_nincs_csatlakoztatva") + errLiveDriveGone = util.MsgError("err.backup.az_alkalmazas_meghajtoja_nincs_csatlakoztatva") + errLiveDriveDecommed = util.MsgError("err.backup.az_alkalmazas_meghajtoja_le_van_szerelve") // errTier2OldLayout (3b, §7-G2): the recorded copy predates the v2 relpath-mirroring layout (no // marker). Refuse rather than read a flat layout we no longer understand — safe, because tier-2 // restore is missing-file recovery and the live data still exists in that scenario. - errTier2OldLayout = errors.New("A 2. mentés régi formátumú — futtass előbb egy új másodlagos mentést.") + errTier2OldLayout = util.MsgError("err.backup.a_2_mentes_regi_formatumu_futtass") // ErrTier2NoRestorableData (C9-F1) — this app HAS a Tier-2 copy, but that copy contains no subtree // this restore can read: its data lives entirely in Docker named volumes, which are captured into // recovery-unit/ (db-dumps + volume-dumps) and NEVER read by this path. 43 of the 53 catalog apps // are in this class. Exported so the handler can refuse BEFORE stopping the app and name the action // that does work, instead of taking an outage and reporting "no missing files". - ErrTier2NoRestorableData = errors.New("ennek az alkalmazásnak az adatai nem ebből a másolatból állíthatók vissza") + ErrTier2NoRestorableData = util.MsgError("err.backup.ennek_az_alkalmazasnak_az_adatai_nem") // ErrTier2NoUnitInCopy (R-102) — the recorded Tier-2 copy holds no OPENABLE recovery unit: either // recovery-unit/ is absent, or it is a directory without a readable manifest.json. Exported so the // handler can refuse before beginning any op. FAIL CLOSED is the whole point of the second half: // a directory that exists is not a package, and reading a half-copied mirror as if it were one is // how a restore would overwrite live data with nothing. - ErrTier2NoUnitInCopy = errors.New("a másodlagos másolatban nincs megnyitható mentési egység ehhez az alkalmazáshoz") + ErrTier2NoUnitInCopy = util.MsgError("err.backup.a_masodlagos_masolatban_nincs_megnyithato_mentesi") ) // Tier2Coverage says what a Tier-2 restore can and cannot return for one app — the asymmetry C9-F1 @@ -441,10 +441,10 @@ func (m *Manager) RestoreTier2Files(stackName string) (filesRestored int, err er } if copyErr != nil { - return filesRestored, fmt.Errorf("fájlmásolás sikertelen: %w", copyErr) + return filesRestored, util.MsgError("err.backup.fajlmasolas_sikertelen", copyErr) } if startErr != nil { - return filesRestored, fmt.Errorf("%d fájl visszaállítva, de az alkalmazás újraindítása sikertelen: %w", filesRestored, startErr) + return filesRestored, util.MsgError("err.backup.fajl_visszaallitva_de_az_alkalmazas_ujrainditasa", filesRestored, startErr) } // Privacy: count + duration only — customer file names never at INFO. m.logger.Printf("[INFO] [backup] Tier-2 file restore completed for %s: %d file(s) restored (%s)", diff --git a/controller/internal/backup/update_guard.go b/controller/internal/backup/update_guard.go index 6b8dabd..167aeca 100644 --- a/controller/internal/backup/update_guard.go +++ b/controller/internal/backup/update_guard.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "gitea.dooplex.hu/admin/felhom-controller/internal/util" "os" "path/filepath" "time" @@ -312,7 +313,7 @@ func (m *Manager) UpdateBusy(stackName string) (bool, string) { // ErrUpdateBackupNoUnit is returned when a "back up first" run completed but the app still has no // openable Tier-2 unit — typically Tier 2 is switched off for the app or has no second target. -var ErrUpdateBackupNoUnit = errors.New("a frissítés előtti mentés lefutott, de nem jött létre visszaállítható másolat") +var ErrUpdateBackupNoUnit = util.MsgError("err.backup.a_frissites_elotti_mentes_lefutott_de") // RunAppBackupNow runs THIS app's backup legs now, in the nightly order, and then its Tier-2 copy: // database dump(s) → volume dump (if the app has named volumes) → recovery-unit capture → Tier-2 @@ -327,7 +328,7 @@ func (m *Manager) RunAppBackupNow(ctx context.Context, stackName string) error { return fmt.Errorf("stack provider not configured") } if m.migrationActive() { - return fmt.Errorf("adatáthelyezés folyamatban — a mentés most nem indítható") + return util.MsgError("err.backup.adatathelyezes_folyamatban_a_mentes_most_nem") } if err := m.acquireRunning(); err != nil { return err @@ -341,13 +342,13 @@ func (m *Manager) RunAppBackupNow(ctx context.Context, stackName string) error { drivePath := m.GetAppDrivePath(stackName) if drivePath == "" || !filepath.IsAbs(drivePath) { - return fmt.Errorf("az alkalmazás meghajtója nem határozható meg") + return util.MsgError("err.backup.az_alkalmazas_meghajtoja_nem_hatarozhato_meg") } if m.settings != nil && (m.settings.IsDisconnected(drivePath) || m.settings.IsDecommissioned(drivePath)) { - return fmt.Errorf("az alkalmazás meghajtója nem elérhető (%s)", drivePath) + return util.MsgError("err.backup.az_alkalmazas_meghajtoja_nem_elerheto", drivePath) } if !m.admitApp(stackName) { - return fmt.Errorf("nincs elég szabad hely a mentéshez a(z) %s meghajtón", drivePath) + return util.MsgError("err.backup.nincs_eleg_szabad_hely_a_menteshez", drivePath) } nsRoot = m.namespaceRoot(drivePath) @@ -359,7 +360,7 @@ func (m *Manager) RunAppBackupNow(ctx context.Context, stackName string) error { } dbs, err := discover(ctx) if err != nil { - return fmt.Errorf("adatbázis-felderítés sikertelen: %w", err) + return util.MsgError("err.backup.adatbazis_felderites_sikertelen", err) } dumped := 0 for _, db := range dbs { @@ -368,7 +369,7 @@ func (m *Manager) RunAppBackupNow(ctx context.Context, stackName string) error { } res := DumpOne(ctx, db, AppDBDumpPath(nsRoot, stackName), m.logger, m.isDebug()) if res.Error != nil { - return fmt.Errorf("adatbázis-mentés sikertelen (%s): %w", db.ContainerName, res.Error) + return util.MsgError("err.backup.adatbazis_mentes_sikertelen", db.ContainerName, res.Error) } dumped++ m.logger.Printf("[INFO] [backup] update pre-backup for %s: database dump OK (%s, %s)", stackName, db.ContainerName, humanizeBytes(res.Size)) @@ -380,13 +381,13 @@ func (m *Manager) RunAppBackupNow(ctx context.Context, stackName string) error { dump = m.DumpAppVolumesSafe } if err := dump(stackName); err != nil { - return fmt.Errorf("kötetmentés sikertelen: %w", err) + return util.MsgError("err.backup.kotetmentes_sikertelen", err) } m.logger.Printf("[INFO] [backup] update pre-backup for %s: volume dump OK", stackName) } if err := m.CaptureRecoveryUnit(stackName); err != nil { - return fmt.Errorf("a mentési egység rögzítése sikertelen: %w", err) + return util.MsgError("err.backup.a_mentesi_egyseg_rogzitese_sikertelen", err) } m.logger.Printf("[INFO] [backup] update pre-backup for %s: recovery unit captured (%d database dump(s))", stackName, dumped) return nil @@ -437,7 +438,7 @@ func (m *Manager) updatePreBackupTail(stackName, nsRoot string, now time.Time) { func (m *Manager) WriteUpdateSafetyDump(ctx context.Context, stackName string) ([]string, error) { nsRoot := m.AppNamespaceRoot(stackName) if nsRoot == "" { - return nil, fmt.Errorf("az alkalmazás mentési helye nem határozható meg") + return nil, util.MsgError("err.backup.az_alkalmazas_mentesi_helye_nem_hatarozhato") } set, err := m.writeSafetyDump(ctx, stackName, nsRoot) if err != nil { diff --git a/controller/internal/i18n/i18n.go b/controller/internal/i18n/i18n.go index 9eb0df5..b108e7c 100644 --- a/controller/internal/i18n/i18n.go +++ b/controller/internal/i18n/i18n.go @@ -140,7 +140,43 @@ func (b *Bundle) Msg(lang, key string) string { // compares the verbs with the indexes stripped, so a reordered English still has to use the same // verbs on the same values. func (b *Bundle) Msgf(lang, key string, a ...interface{}) string { - return fmt.Sprintf(b.Msg(lang, key), a...) + return fmt.Sprintf(b.form(lang, key, a), a...) +} + +// form picks the message a Msgf call should use, applying ONE plural rule, stated here and nowhere +// else (v0.253.0, slice 2 release B): +// +// A KEY THAT CARRIES `.one`/`.other` FORMS IN A LANGUAGE IS A PLURAL KEY, AND ITS FIRST PARAMETER +// IS THE COUNT. +// +// Hungarian never carries those forms — it does not inflect a noun after a numeral — so a Hungarian +// render is `Msg(key)` exactly as before, byte for byte, whatever the count. English carries them +// where the noun changes ("1 app is", "3 apps are"), and gets them with no change at any call site: +// a handler, an alert and an error all go through Msgf. +// +// Deliberately not a per-call-site flag. A flag would have to be set at every producer of every +// count message, in three packages, and the one that was forgotten would read "3 app is not running" +// with nothing to catch it. The bundle is where a translator works, so the bundle is where the fact +// that English needs two forms belongs. TestPluralFirstArgIsNumeric pins the other half of the rule: +// every `.one`/`.other` value's first verb is a numeric one, so "first parameter is the count" is +// true of the values as written and not only of the ones anybody happened to check. +func (b *Bundle) form(lang, key string, a []interface{}) string { + lang = Normalize(lang) + if len(a) == 0 { + return b.Msg(lang, key) + } + n, ok := a[0].(int) + if !ok { + return b.Msg(lang, key) + } + suffix := ".other" + if n == 1 { + suffix = ".one" + } + if v, found := b.msgs[lang][key+suffix]; found { + return v + } + return b.Msg(lang, key) } // Plural picks a count-dependent form and formats n into it (%d). Hungarian does not inflect a noun diff --git a/controller/internal/i18n/i18n_test.go b/controller/internal/i18n/i18n_test.go index 0ed1e5c..ac93385 100644 --- a/controller/internal/i18n/i18n_test.go +++ b/controller/internal/i18n/i18n_test.go @@ -170,3 +170,75 @@ func TestBundleParametersMatchAcrossLanguages(t *testing.T) { } func stripActions(s string) string { return regexp.MustCompile(`\{\{.*?\}\}`).ReplaceAllString(s, "") } + +// TestPluralFirstArgIsNumeric — the other half of the plural rule Bundle.form states. +// +// The rule is "a key with .one/.other forms takes its count first". That is only safe if the VALUES +// are written that way, so this reads every plural form in every language and requires its first +// printf verb to be a numeric one. A translator who writes "%s apps are running" would otherwise +// produce `%!d(string=…)` at run time, on a page, in front of a household. +func TestPluralFirstArgIsNumeric(t *testing.T) { + b, err := Load() + if err != nil { + t.Fatal(err) + } + firstVerb := regexp.MustCompile(`%(?:\[\d+\])?[-+#0-9.]*[a-zA-Z]`) + seen := 0 + for _, lang := range Supported { + for _, k := range b.Keys(lang) { + if !strings.HasSuffix(k, ".one") && !strings.HasSuffix(k, ".other") { + continue + } + seen++ + v := b.msgs[lang][k] + m := firstVerb.FindString(stripActions(v)) + if m == "" { + t.Errorf("%s %q is a plural form with no parameter at all: %q", lang, k, v) + continue + } + if !strings.HasSuffix(m, "d") && !strings.HasSuffix(m, "f") { + t.Errorf("%s %q takes %q first, but a plural form's first parameter is the COUNT: %q", + lang, k, m, v) + } + // And the base key must exist in Hungarian: the fallback for every language without the + // split, and the sentence a Hungarian household reads. + base := strings.TrimSuffix(strings.TrimSuffix(k, ".one"), ".other") + if _, ok := b.msgs[Default][base]; !ok { + t.Errorf("%s %q has no Hungarian base key %q", lang, k, base) + } + } + } + if seen == 0 { + t.Fatal("no plural forms were examined — the suffix test no longer matches the bundle") + } + t.Logf("examined %d plural forms", seen) +} + +// TestNoOrdinaryKeyEndsInAPluralSuffix — `.one` and `.other` are RESERVED by the plural rule +// (Bundle.form). A key that merely ends in one of them would be read as the singular or plural form +// of a key that does not exist, and the sentence would silently change with a count. +// +// It was not a hypothetical: `alert.deadapp.one` (the ONE dead app, as against the grouped banner) +// was exactly that collision, caught by TestPluralFirstArgIsNumeric on the day the rule landed and +// renamed to `alert.deadapp.single`. This is the guard that keeps the next one from shipping. +func TestNoOrdinaryKeyEndsInAPluralSuffix(t *testing.T) { + b, err := Load() + if err != nil { + t.Fatal(err) + } + for _, lang := range Supported { + for _, k := range b.Keys(lang) { + for _, suffix := range []string{".one", ".other"} { + if !strings.HasSuffix(k, suffix) { + continue + } + base := strings.TrimSuffix(k, suffix) + // A REAL plural form's base is a Hungarian key. A collision's base is nothing. + if _, ok := b.msgs[Default][base]; !ok { + t.Errorf("%s %q ends in %q but %q is no key — `.one`/`.other` are reserved for "+ + "plural forms; rename it (for example to %q)", lang, k, suffix, base, base+".single") + } + } + } + } +} diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index 36b0d15..63714c2 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -1998,8 +1998,8 @@ "alert.link.monitoring": "System monitor", "alert.link.update": "Update", "alert.deadapp.group": "%d installed apps are not running — look at the system monitor", - "alert.deadapp.one": "An installed app is not running: %s", - "alert.deadapp.one_state": "An installed app is not running: %s (%s)", + "alert.deadapp.single": "An installed app is not running: %s", + "alert.deadapp.single_state": "An installed app is not running: %s (%s)", "alert.storage.disconnected": "A drive is disconnected: %s (%s)", "alert.hub.disabled": "The hub connection is off — central monitoring is not running", "alert.hub.unreachable": "The hub cannot be reached — last error: %s", @@ -2015,5 +2015,186 @@ "disk.err.init_in_progress": "a drive is already being initialised", "disk.err.protected": "this drive is protected (system / backup) — erasing it needs an operator signature", "disk.err.wipe_failed": "the erase failed: %s", - "disk.err.attach_unavailable": "This drive cannot be attached right now — it may already be registered, or it may have been unplugged. Reload the page and look at Storage → Drives." + "disk.err.attach_unavailable": "This drive cannot be attached right now — it may already be registered, or it may have been unplugged. Reload the page and look at Storage → Drives.", + "err.stacks.migracio_mar_folyamatban": "a migration is already running", + "err.stacks.alkalmazas_nem_talalhato": "app not found: %s", + "err.stacks.ismeretlen_migracios_hatokor": "unknown migration scope: %s", + "err.stacks.migracios_naplo_irasa": "writing the migration log: %s", + "err.stacks.biztonsagi_mentes_folyamatban_probald_ujra": "a backup is running, try again", + "err.stacks.az_rsync_nem_erheto_el_a": "rsync is not available on this system", + "err.stacks.a_cel_es_a_forras_tarolo": "the source and the target storage are the same", + "err.stacks.a_celtarolo_nem_elerheto_vagy_nem": "the target storage is unreachable or cannot be picked", + "err.stacks.utkozes_a_celtarolon_mar_letezik_ezeknek": "clash on the target storage — these apps already have data there: %s", + "err.stacks.nincs_eleg_hely_a_celtarolon_kb": "not enough space on the target storage (about %d GB needed, %.1f GB free)", + "err.stacks.ismeretlen_migracios_fazis": "unknown migration phase: %q", + "err.stacks.alkalmazas_leallitasa_sikertelen": "the app could not be stopped (%s): %s", + "err.stacks.masolas_sikertelen_appdata": "the copy failed (%s app data): %s", + "err.stacks.masolas_sikertelen_mentes": "the copy failed (%s backup): %s", + "err.stacks.ugyfel_adatok_osszefesulese_sikertelen": "merging your own files failed: %s", + "err.stacks.ellenorzes_sikertelen_appdata": "the check failed (%s app data): %s", + "err.stacks.ellenorzes_sikertelen_mentes": "the check failed (%s backup): %s", + "err.stacks.ugyfel_adatok_ellenorzese_sikertelen": "checking your own files failed: %s", + "err.stacks.ujratelepites_sikertelen": "the reinstall failed (%s): %s", + "err.stacks.forras_torlese_sikertelen_appdata": "removing the source failed (%s app data): %s", + "err.stacks.forras_torlese_sikertelen_mentes": "removing the source failed (%s backup): %s", + "err.stacks.forras_nevter_olvasasa_sikertelen": "reading the source folder failed: %s", + "err.stacks.forras_torlese_sikertelen": "removing the source failed (%s): %s", + "err.stacks.cleanup_gate_a_z_egyseg_nincs": "cleanup gate: unit %q is not verified (state=%s)", + "err.stacks.cleanup_gate_a_z_alkalmazas_nincs": "cleanup gate: app %q is not reinstalled (state=%s)", + "err.stacks.az_alkalmazas_nem_indult_el_az": "the app did not start on the new storage", + "err.stacks.fuggoben_levo_atvitel_maradt_pl": "%d transfers are still pending, for example %q", + "err.stacks.a_forrasfajlnak_nincs_azonos_masolata_a": "the source file has no identical copy on the target: %s", + "err.stacks.az_aldomain_nem_lehet_ures": "the subdomain cannot be empty", + "err.stacks.az_aldomain_csak_kisbetuket_szamokat_es": "the subdomain may hold only lower-case letters, digits and hyphens, and cannot start or end with a hyphen", + "err.stacks.a_z_aldomain_foglalt_rendszer_szamara": "the subdomain %q is reserved for the system", + "err.stacks.a_z_aldomain_mar_hasznalatban_van": "the subdomain %q is already used by another app", + "err.stacks.a_beallitasok_nem_erhetok_el": "the settings are not reachable", + "err.stacks.a_halozati_megosztas_nincs_bekapcsolva": "network sharing is not turned on", + "err.stacks.a_megosztas_szolgaltatas_inditasa_sikertelen": "the sharing service could not be started: %s", + "err.stacks.a_megosztas_leallitasa_sikertelen": "the sharing service could not be stopped: %s", + "err.stacks.a_z_alkalmazas_nem_tamogatja_az": "the app %q cannot send email", + "err.stacks.a_z_alkalmazas_nincs_telepitve": "the app %q is not installed", + "err.backup.a_biztonsagi_mentes_elott_nem_sikerult": "the databases could not be found before the backup: %s", + "err.backup.a_biztonsagi_mentes_konyvtara_nem_hozhato": "the backup folder could not be created: %s", + "err.backup.a_jelenlegi_adatbazis_biztonsagi_mentese_sikertelen": "backing up the current database failed (%s): %s — the restore did not start", + "err.backup.a_visszavonas_elott_nem_sikerult_felderiteni": "the databases could not be found before the undo: %s", + "err.backup.a_visszavonashoz_szukseges_mentes_nem_talalhato": "the backup the undo needs is missing (%s): %s", + "err.backup.a_z_adatbazis_taroloja_nem_talalhato": "the database container for %s is missing, so the undo cannot run", + "err.backup.a_korabbi_allapot_visszaallitasa_sikertelen": "putting the earlier state back failed (%s): %s", + "err.backup.egy_masik_mentesi_visszaallitasi_muvelet_mar": "another backup or restore is already running", + "err.backup.nincs_elokeszitett_teljes_visszaallitas_futtass_elobb": "no full restore is prepared — run a full restore first", + "err.backup.a_teljes_visszaallitas_hianyos_nincs_meg": "the full restore is incomplete (%s is missing) — run a full restore first", + "err.backup.a_pillanatkepben_nincs_mentesi_egyseg_a": "the snapshot holds no backup unit — the restore cannot start", + "err.backup.a_biztonsagi_mentes_nem_talalhato_a": "the backup is not on the disk — the restore did not start, for safety", + "err.backup.az_adatbazis_szolgaltatas_nem_azonosithato_a": "The database service cannot be identified in %s — the restore did not start, for safety.", + "err.backup.a_z_leallitasa_elotti_jelolo_nem": "the marker written before stopping %s could not be saved: %s", + "err.backup.a_z_fajljainak_visszaallitasa_sikertelen": "putting back the files of %s failed: %s", + "err.backup.a_z_adatkotetenek_visszaallitasa_sikertelen": "putting back the data volume of %s failed: %s", + "err.backup.a_z_adatbazis_szolgaltatasanak_inditasa_sikertelen": "starting the database service of %s failed: %s", + "err.backup.a_z_ujrainditasa_sikertelen_a_fajlok": "%s could not be restarted after its files were put back: %s", + "err.backup.offbox_nincs_pillanatkep_a_z_alkalmazashoz": "off-site: there is no snapshot for %s", + "err.backup.offbox_a_z_pillanatkep_merete_ismeretlen": "off-site: the size of snapshot %s is unknown", + "err.backup.a_z_pillanatkepeben_nincs_mentesi_egyseg": "the snapshot of %s holds no backup unit — the restore cannot start", + "err.backup.a_pillanatkepben_nincs_mentesi_egyseg_backups": "the snapshot holds no backup unit (backups/primary/%s)", + "err.backup.a_pillanatkep_egy_utvonala_a_nevteren": "a path in the snapshot points outside its own folder: %s", + "err.backup.a_pillanatkep_egy_utvonala_ervenytelen": "a path in the snapshot is not valid (..): %s", + "err.backup.nem_egyseg_utvonal_a_fenntartott_backups": "a non-unit path inside the reserved backups area: %s", + "err.backup.a_z_nincs_telepitve_elobb_allitsd": "%s is not installed — put the app back first, then its data", + "err.backup.a_z_helyreallitasa_sikertelen": "recovering %s failed: %s", + "err.backup.nincs_elerheto_adatmeghajto_a_visszaallitashoz": "no data drive is available for the restore", + "err.backup.a_tavoli_mentes_nincs_beallitva": "the remote backup is not set up", + "err.backup.nincs_eleg_szabad_hely_a_visszaallitashoz": "There is not enough free space for the restore (%s needed, %s free).", + "err.backup.nincs_visszaallithato_megosztas_mentes": "there is no share backup to restore: %s", + "err.backup.a_megosztasok_visszaallitasa_sikertelen": "restoring the shares failed: %s: %s", + "err.backup.a_mentesben_nincs_megosztas_leiro": "the backup holds no share description: %s", + "err.backup.a_megosztas_leiro_olvashatatlan": "the share description cannot be read: %s", + "err.backup.nincs_elokeszitett_visszaallitas_futtass_elobb_egy": "no restore is prepared — run a share restore first", + "err.backup.a_z_megosztas_fajljainak_visszaallitasa_sikertelen": "putting back the files of share “%s” failed: %s", + "err.backup.a_frissites_elotti_mentes_lefutott_de": "the pre-update backup ran, but no restorable copy was made", + "err.backup.adatathelyezes_folyamatban_a_mentes_most_nem": "data is being moved — the backup cannot start right now", + "err.backup.az_alkalmazas_meghajtoja_nem_hatarozhato_meg": "the app’s drive cannot be determined", + "err.backup.az_alkalmazas_meghajtoja_nem_elerheto": "the app’s drive cannot be reached (%s)", + "err.backup.nincs_eleg_szabad_hely_a_menteshez": "not enough free space for the backup on drive %s", + "err.backup.adatbazis_felderites_sikertelen": "finding the databases failed: %s", + "err.backup.adatbazis_mentes_sikertelen": "the database backup failed (%s): %s", + "err.backup.kotetmentes_sikertelen": "the volume backup failed: %s", + "err.backup.a_mentesi_egyseg_rogzitese_sikertelen": "recording the backup unit failed: %s", + "err.backup.az_alkalmazas_mentesi_helye_nem_hatarozhato": "the app’s backup location cannot be determined", + "err.backup.nincs_masodlagos_fajlmasolat_ehhez_az_alkalmazashoz": "there is no second file copy for this app", + "err.backup.a_masodlagos_meghajto_nincs_csatlakoztatva": "the second drive is not connected", + "err.backup.az_alkalmazas_meghajtoja_nincs_csatlakoztatva": "the app’s drive is not connected", + "err.backup.az_alkalmazas_meghajtoja_le_van_szerelve": "the app’s drive is decommissioned", + "err.backup.a_2_mentes_regi_formatumu_futtass": "The second backup is in the old format — run a new second backup first.", + "err.backup.ennek_az_alkalmazasnak_az_adatai_nem": "this app’s data cannot be restored from this copy", + "err.backup.a_masodlagos_masolatban_nincs_megnyithato_mentesi": "the second copy holds no openable backup unit for this app", + "err.backup.fajlmasolas_sikertelen": "copying the files failed: %s", + "err.backup.fajl_visszaallitva_de_az_alkalmazas_ujrainditasa": "%d files were put back, but the app could not be restarted: %s", + "err.backup.ervenytelen_alkalmazasnev": "that app name is not valid", + "err.backup.a_torles_utvonala_kivul_esik_az": "the path to delete is outside the check folder", + "err.backup.a_masolat_torlese_nem_sikerult": "the copy could not be deleted: %s", + "err.backup.nincs_ilyen_ellenorzo_masolat": "there is no such check copy", + "err.backup.az_offsite_tarolo_nincs_elarvult_allapotban": "the off-site store is not orphaned", + "err.backup.halozati_tarolo_nem_lehet_a_2": "Network storage cannot be the target of the second backup — file ownership cannot be guaranteed there.", + "err.web.ervenytelen_csatlakoztatasi_nev_csak_betuk_szamok": "that mount name is not valid (only letters, digits, _ and - are allowed)", + "err.web.nem_tamogatott_fajlrendszer_ext4_vagy_xfs": "unsupported file system: %q (use ext4 or xfs)", + "err.web.formazas_sikertelen": "formatting failed: %s", + "err.web.formazas_kesz_de_az_uj_fajlrendszer": "the format finished, but the new file-system id cannot be resolved — reload the page and use Attach", + "err.web.csatlakoztatas_sikertelen": "mounting failed: %s", + "err.web.a_kivalasztott_meghajtohoz_nem_talalhato_fajlrendszer": "no file-system id was found for the drive you picked (only a drive that already has a file system can be attached)", + "err.web.a_formazas_allapota_nem_kerdezheto_le": "the format status cannot be read: %s", + "err.web.formazas_sikertelen_2": "formatting failed: %s", + "err.web.a_formazas_nem_indult_el_az": "the format did not start on the device (%s)", + "err.web.leszereles_visszavonasa_sikertelen": "undoing the decommission failed: %s", + "err.web.utemezhetoseg_visszaallitasa_sikertelen": "restoring the schedulable flag failed: %s", + "err.web.regisztracio_sikertelen": "registering it failed: %s", + "err.web.ez_az_egyetlen_hasznalhato_tarhely_a": "this is the only usable storage — the decommission is refused (add another drive, or set a different default, first)", + "err.web.nyilvantartas_frissitese_sikertelen": "updating the register failed: %s", + "err.web.a_meghajto_leszerelese_sikertelen": "decommissioning the drive failed: %s", + "err.settings.a_kiszolgalo_neve_nem_lehet_ures": "the server name cannot be empty", + "err.settings.a_kiszolgalo_neve_legfeljebb_15_karakter": "the server name can be at most 15 characters", + "err.settings.a_kiszolgalo_neve_csak_betut_szamot": "the server name may hold only letters, digits, hyphens and underscores", + "err.settings.a_megosztas_neve_nem_lehet_ures": "the share name cannot be empty", + "err.settings.a_megosztas_neve_legfeljebb_15_karakter": "the share name can be at most 15 characters", + "err.settings.a_megosztas_neve_nem_tartalmazhat_perjelet": "the share name cannot hold a slash or a dot", + "err.settings.a_megosztas_neve_nem_kezdodhet_alahuzassal": "the share name cannot start with an underscore — those names are reserved for the system", + "err.settings.a_megosztas_neve_csak_betut_szamot": "the share name may hold only letters, digits, hyphens and underscores", + "err.settings.mar_letezik_nevu_megosztas": "a share named “%s” already exists", + "err.settings.a_z_megosztas_a_rendszer_resze": "the share “%s” is part of the system and cannot be deleted", + "err.settings.nincs_nevu_megosztas": "there is no share named “%s”", + "err.settings.a_halozati_tarhely_nem_lehet_a": "network storage cannot be the target of the system backup: %s", + "err.integrations.nincs_kezelo_a_z_integraciohoz": "there is no handler for the %s integration", + "err.integrations.a_szolgaltato_alkalmazas_nincs_telepitve": "the provider app (%s) is not installed", + "err.integrations.a_szolgaltato_alkalmazas_nem_fut": "the provider app (%s) is not running", + "err.integrations.a_celalkalmazas_nincs_telepitve": "the target app (%s) is not installed", + "err.integrations.a_celalkalmazas_nem_fut": "the target app (%s) is not running", + "err.integrations.integracio_alkalmazasa_sikertelen": "applying the integration failed: %s", + "err.integrations.integracio_allapotanak_mentese_sikertelen": "saving the integration state failed: %s", + "err.integrations.szolgaltato_stack_nem_talalhato": "provider stack %q not found", + "err.integrations.onlyoffice_jwt_secret_nincs_beallitva_telepitsd": "the OnlyOffice JWT_SECRET is not set — reinstall the app", + "err.integrations.filebrowser_config_olvasasi_hiba": "the File manager config could not be read: %s", + "err.integrations.config_irasi_hiba": "the config could not be written: %s", + "err.integrations.config_atnevezesi_hiba": "the config could not be renamed: %s", + "err.integrations.config_olvasasi_hiba": "the config could not be read: %s", + "err.integrations.onlyoffice_jwt_secret_nincs_beallitva": "the OnlyOffice JWT_SECRET is not set", + "err.selfupdate.hianyos_registry_hitelesito_adatok_felhasznalonev_es": "incomplete registry credentials (the user name and the token are needed together)", + "err.selfupdate.frissites_mar_folyamatban": "An update is already running", + "err.selfupdate.dev_verzio_nem_frissitheto": "A dev build cannot be updated", + "err.selfupdate.mentes_fut_probalja_kesobb": "A backup is running, try again later", + "err.selfupdate.a_frissites_nem_erheto_el_nincs": "The update is not available (there is no host agent)", + "err.selfupdate.nincs_elerheto_frissites": "No update is available", + "err.selfupdate.docker_pull_hianyos_registry_hitelesito_adatok": "docker pull: incomplete registry credentials (the user name and the token are needed together)", + "err.appexport.visszafejtes_sikertelen": "decryption failed: %s", + "err.appexport.a_z_kotet_adata_hianyzik_a": "the data of volume %q is missing from the package", + "err.appexport.a_z_adatkonyvtar_tartalma_hianyzik_a": "the contents of data folder %q are missing from the package", + "err.appexport.ket_adatkonyvtar_azonos_nevvel_vegzodik_es": "two data folders end in the same name (%q: %s and %s) — the package could not tell them apart", + "err.appexport.a_mentes_nem_tartalmaz_alkalmazasadatot_0": "the backup holds no app data (0 data folders, 0 volumes for an app that stores data)", + "err.appexport.jelszo_hibas_vagy_a_fajl_serult": "wrong password, or the file is damaged", + "err.web.a_meghajtok_listaja_nem_olvashato": "the list of drives cannot be read", + "err.web.a_meghajto_nem_talalhato": "the drive was not found", + "err.web.ez_a_mappa_nem_oszthato_meg": "This folder cannot be shared.", + "err.notify.hub_nem_konfiguralt": "the hub is not configured", + "err.notify.hub_elerhetetlen": "the hub cannot be reached: %s", + "err.stacks.field_required_password": "the %q field has to be filled in — use the Generate button, or type a password", + "err.stacks.field_required": "the %q (%s) field has to be filled in", + "err.backup.offsite_over_quota": "The remote backup went over its storage limit (%d/%d GB) — delete old backups, or ask for a bigger limit.", + "err.stacks.not_enough_memory": "There is not enough memory to install this app. Needs %d MB, %d MB is free (%d MB in total, %d MB of it used, %d MB reserved for the system)", + "err.stacks.memory_overcommit_warning": "At peak, the apps together may need more memory than there is. In normal use this causes no trouble.", + "err.backup.not_installed_known_drive": "%s is not installed, so there is nowhere to put its data back. The backup says its data was here: %s. Install the app again (Apps) in the same place, and this restore will work", + "err.backup.not_installed": "%s is not installed, so there is nowhere to put its data back — install the app again (Apps), and this restore will work", + "err.backup.no_data_root": "%s is installed, but the controller cannot tell where its data belongs (no system data area is set). Check the storage settings (Storage), then start the restore again", + "err.backup.no_registered_drive": "no data drive is registered, so there is nowhere to restore to — the drives are still there, they only need attaching again on the Storage → Drives page, and this restore will work", + "err.backup.db_restore_and_rollback_failed": "putting back the database of %s failed, and the earlier state could not be restored either. We have left the app STOPPED, for safety, so your data cannot be damaged further. Get in touch with us — %s", + "err.backup.db_restore_failed_rolled_back": "putting back the database of %s failed — your data is back as it was before the restore, and the app is still running. If you want to try again, get in touch with us first", + "alert.deadapp.group.one": "%d installed app is not running — look at the system monitor", + "alert.deadapp.group.other": "%d installed apps are not running — look at the system monitor", + "alert.overflow.one": "+ %d more warning", + "alert.overflow.other": "+ %d more warnings", + "err.stacks.fuggoben_levo_atvitel_maradt_pl.one": "%d transfer is still pending, for example %q", + "err.stacks.fuggoben_levo_atvitel_maradt_pl.other": "%d transfers are still pending, for example %q", + "err.backup.fajl_visszaallitva_de_az_alkalmazas_ujrainditasa.one": "%d file was put back, but the app could not be restarted: %s", + "err.backup.fajl_visszaallitva_de_az_alkalmazas_ujrainditasa.other": "%d files were put back, but the app could not be restarted: %s", + "err.integrations.onlyoffice_no_subdomain": "the OnlyOffice subdomain is not known", + "err.integrations.occ_failed": "the occ command failed (%s): %s (output: %s)", + "err.integrations.occ_disable_failed": "occ app:disable failed: %s (output: %s)", + "err.notify.hub_error": "hub error (%d): %s" } diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index 5df73aa..03108ce 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -1995,8 +1995,8 @@ "alert.link.monitoring": "Rendszermonitor", "alert.link.update": "Frissítés", "alert.deadapp.group": "%d telepített alkalmazás nem fut — nézze meg a rendszermonitort", - "alert.deadapp.one": "Telepített alkalmazás nem fut: %s", - "alert.deadapp.one_state": "Telepített alkalmazás nem fut: %s (%s)", + "alert.deadapp.single": "Telepített alkalmazás nem fut: %s", + "alert.deadapp.single_state": "Telepített alkalmazás nem fut: %s (%s)", "alert.storage.disconnected": "Meghajtó leválasztva: %s (%s)", "alert.hub.disabled": "Hub kapcsolat kikapcsolva — a központi monitoring nem aktív", "alert.hub.unreachable": "Hub nem elérhető — utolsó hiba: %s", @@ -2012,5 +2012,178 @@ "disk.err.init_in_progress": "már folyamatban van egy meghajtó-inicializálás", "disk.err.protected": "a meghajtó védett (rendszer/biztonsági mentés) — törlés csak operátori aláírással", "disk.err.wipe_failed": "törlés sikertelen: %s", - "disk.err.attach_unavailable": "Ez a meghajtó most nem csatolható — lehet, hogy már regisztrálva van, vagy időközben lecsatolódott. Frissítsd az oldalt, és nézd meg a Tárhely → Meghajtók listát." + "disk.err.attach_unavailable": "Ez a meghajtó most nem csatolható — lehet, hogy már regisztrálva van, vagy időközben lecsatolódott. Frissítsd az oldalt, és nézd meg a Tárhely → Meghajtók listát.", + "err.stacks.migracio_mar_folyamatban": "migráció már folyamatban", + "err.stacks.alkalmazas_nem_talalhato": "alkalmazás nem található: %s", + "err.stacks.ismeretlen_migracios_hatokor": "ismeretlen migrációs hatókör: %s", + "err.stacks.migracios_naplo_irasa": "migrációs napló írása: %s", + "err.stacks.biztonsagi_mentes_folyamatban_probald_ujra": "biztonsági mentés folyamatban, próbáld újra", + "err.stacks.az_rsync_nem_erheto_el_a": "az rsync nem érhető el a rendszeren", + "err.stacks.a_cel_es_a_forras_tarolo": "a cél és a forrás tároló megegyezik", + "err.stacks.a_celtarolo_nem_elerheto_vagy_nem": "a céltároló nem elérhető vagy nem választható", + "err.stacks.utkozes_a_celtarolon_mar_letezik_ezeknek": "ütközés a céltárolón — már létezik ezeknek az alkalmazásoknak az adata: %s", + "err.stacks.nincs_eleg_hely_a_celtarolon_kb": "nincs elég hely a céltárolón (kb. %d GB szükséges, %.1f GB szabad)", + "err.stacks.ismeretlen_migracios_fazis": "ismeretlen migrációs fázis: %q", + "err.stacks.alkalmazas_leallitasa_sikertelen": "alkalmazás leállítása sikertelen (%s): %s", + "err.stacks.masolas_sikertelen_appdata": "másolás sikertelen (%s appdata): %s", + "err.stacks.masolas_sikertelen_mentes": "másolás sikertelen (%s mentés): %s", + "err.stacks.ugyfel_adatok_osszefesulese_sikertelen": "ügyfél-adatok összefésülése sikertelen: %s", + "err.stacks.ellenorzes_sikertelen_appdata": "ellenőrzés sikertelen (%s appdata): %s", + "err.stacks.ellenorzes_sikertelen_mentes": "ellenőrzés sikertelen (%s mentés): %s", + "err.stacks.ugyfel_adatok_ellenorzese_sikertelen": "ügyfél-adatok ellenőrzése sikertelen: %s", + "err.stacks.ujratelepites_sikertelen": "újratelepítés sikertelen (%s): %s", + "err.stacks.forras_torlese_sikertelen_appdata": "forrás törlése sikertelen (%s appdata): %s", + "err.stacks.forras_torlese_sikertelen_mentes": "forrás törlése sikertelen (%s mentés): %s", + "err.stacks.forras_nevter_olvasasa_sikertelen": "forrás névtér olvasása sikertelen: %s", + "err.stacks.forras_torlese_sikertelen": "forrás törlése sikertelen (%s): %s", + "err.stacks.cleanup_gate_a_z_egyseg_nincs": "cleanup gate: a(z) %q egység nincs ellenőrizve (állapot=%s)", + "err.stacks.cleanup_gate_a_z_alkalmazas_nincs": "cleanup gate: a(z) %q alkalmazás nincs újratelepítve (állapot=%s)", + "err.stacks.az_alkalmazas_nem_indult_el_az": "az alkalmazás nem indult el az új tárhelyen", + "err.stacks.fuggoben_levo_atvitel_maradt_pl": "%d függőben lévő átvitel maradt, pl. %q", + "err.stacks.a_forrasfajlnak_nincs_azonos_masolata_a": "a forrásfájlnak nincs azonos másolata a célon: %s", + "err.stacks.az_aldomain_nem_lehet_ures": "az aldomain nem lehet üres", + "err.stacks.az_aldomain_csak_kisbetuket_szamokat_es": "az aldomain csak kisbetűket, számokat és kötőjelet tartalmazhat, és nem kezdődhet/végződhet kötőjellel", + "err.stacks.a_z_aldomain_foglalt_rendszer_szamara": "a(z) %q aldomain foglalt rendszer számára", + "err.stacks.a_z_aldomain_mar_hasznalatban_van": "a(z) %q aldomain már használatban van egy másik alkalmazásban", + "err.stacks.a_beallitasok_nem_erhetok_el": "a beállítások nem érhetők el", + "err.stacks.a_halozati_megosztas_nincs_bekapcsolva": "a hálózati megosztás nincs bekapcsolva", + "err.stacks.a_megosztas_szolgaltatas_inditasa_sikertelen": "a megosztás szolgáltatás indítása sikertelen: %s", + "err.stacks.a_megosztas_leallitasa_sikertelen": "a megosztás leállítása sikertelen: %s", + "err.stacks.a_z_alkalmazas_nem_tamogatja_az": "a(z) %q alkalmazás nem támogatja az email-küldést", + "err.stacks.a_z_alkalmazas_nincs_telepitve": "a(z) %q alkalmazás nincs telepítve", + "err.backup.a_biztonsagi_mentes_elott_nem_sikerult": "a biztonsági mentés előtt nem sikerült felderíteni az adatbázisokat: %s", + "err.backup.a_biztonsagi_mentes_konyvtara_nem_hozhato": "a biztonsági mentés könyvtára nem hozható létre: %s", + "err.backup.a_jelenlegi_adatbazis_biztonsagi_mentese_sikertelen": "a jelenlegi adatbázis biztonsági mentése sikertelen (%s): %s — a visszaállítás nem indult el", + "err.backup.a_visszavonas_elott_nem_sikerult_felderiteni": "a visszavonás előtt nem sikerült felderíteni az adatbázisokat: %s", + "err.backup.a_visszavonashoz_szukseges_mentes_nem_talalhato": "a visszavonáshoz szükséges mentés nem található (%s): %s", + "err.backup.a_z_adatbazis_taroloja_nem_talalhato": "a(z) %s adatbázis-tárolója nem található a visszavonáshoz", + "err.backup.a_korabbi_allapot_visszaallitasa_sikertelen": "a korábbi állapot visszaállítása sikertelen (%s): %s", + "err.backup.egy_masik_mentesi_visszaallitasi_muvelet_mar": "egy másik mentési/visszaállítási művelet már fut", + "err.backup.nincs_elokeszitett_teljes_visszaallitas_futtass_elobb": "nincs előkészített teljes visszaállítás — futtass előbb egy teljes visszaállítást", + "err.backup.a_teljes_visszaallitas_hianyos_nincs_meg": "a teljes visszaállítás hiányos (%s nincs meg) — futtass előbb egy teljes visszaállítást", + "err.backup.a_pillanatkepben_nincs_mentesi_egyseg_a": "a pillanatképben nincs mentési egység — a visszaállítás nem indítható", + "err.backup.a_biztonsagi_mentes_nem_talalhato_a": "a biztonsági mentés nem található a lemezen — a visszaállítás biztonsági okból nem indult el", + "err.backup.az_adatbazis_szolgaltatas_nem_azonosithato_a": "Az adatbázis-szolgáltatás nem azonosítható a(z) %s alkalmazásban — a visszaállítás biztonsági okból nem indult el.", + "err.backup.a_z_leallitasa_elotti_jelolo_nem": "a(z) %s leállítása előtti jelölő nem menthető: %s", + "err.backup.a_z_fajljainak_visszaallitasa_sikertelen": "a(z) %s fájljainak visszaállítása sikertelen: %s", + "err.backup.a_z_adatkotetenek_visszaallitasa_sikertelen": "a(z) %s adatkötetének visszaállítása sikertelen: %s", + "err.backup.a_z_adatbazis_szolgaltatasanak_inditasa_sikertelen": "a(z) %s adatbázis-szolgáltatásának indítása sikertelen: %s", + "err.backup.a_z_ujrainditasa_sikertelen_a_fajlok": "a(z) %s újraindítása sikertelen a fájlok visszaállítása után: %s", + "err.backup.offbox_nincs_pillanatkep_a_z_alkalmazashoz": "offbox: nincs pillanatkép a(z) %s alkalmazáshoz", + "err.backup.offbox_a_z_pillanatkep_merete_ismeretlen": "offbox: a(z) %s pillanatkép mérete ismeretlen", + "err.backup.a_z_pillanatkepeben_nincs_mentesi_egyseg": "a(z) %s pillanatképében nincs mentési egység — a visszaállítás nem indítható", + "err.backup.a_pillanatkepben_nincs_mentesi_egyseg_backups": "a pillanatképben nincs mentési egység (backups/primary/%s)", + "err.backup.a_pillanatkep_egy_utvonala_a_nevteren": "a pillanatkép egy útvonala a névtéren kívülre mutat: %s", + "err.backup.a_pillanatkep_egy_utvonala_ervenytelen": "a pillanatkép egy útvonala érvénytelen (..): %s", + "err.backup.nem_egyseg_utvonal_a_fenntartott_backups": "nem-egység útvonal a fenntartott backups zónában: %s", + "err.backup.a_z_nincs_telepitve_elobb_allitsd": "a(z) %s nincs telepítve — előbb állítsd helyre az alkalmazást, utána az adatokat", + "err.backup.a_z_helyreallitasa_sikertelen": "a(z) %s helyreállítása sikertelen: %s", + "err.backup.nincs_elerheto_adatmeghajto_a_visszaallitashoz": "nincs elérhető adatmeghajtó a visszaállításhoz", + "err.backup.a_tavoli_mentes_nincs_beallitva": "a távoli mentés nincs beállítva", + "err.backup.nincs_eleg_szabad_hely_a_visszaallitashoz": "Nincs elég szabad hely a visszaállításhoz (%s szükséges, %s szabad).", + "err.backup.nincs_visszaallithato_megosztas_mentes": "nincs visszaállítható megosztás-mentés: %s", + "err.backup.a_megosztasok_visszaallitasa_sikertelen": "a megosztások visszaállítása sikertelen: %s: %s", + "err.backup.a_mentesben_nincs_megosztas_leiro": "a mentésben nincs megosztás-leíró: %s", + "err.backup.a_megosztas_leiro_olvashatatlan": "a megosztás-leíró olvashatatlan: %s", + "err.backup.nincs_elokeszitett_visszaallitas_futtass_elobb_egy": "nincs előkészített visszaállítás — futtass előbb egy megosztás-visszaállítást", + "err.backup.a_z_megosztas_fajljainak_visszaallitasa_sikertelen": "a(z) „%s” megosztás fájljainak visszaállítása sikertelen: %s", + "err.backup.a_frissites_elotti_mentes_lefutott_de": "a frissítés előtti mentés lefutott, de nem jött létre visszaállítható másolat", + "err.backup.adatathelyezes_folyamatban_a_mentes_most_nem": "adatáthelyezés folyamatban — a mentés most nem indítható", + "err.backup.az_alkalmazas_meghajtoja_nem_hatarozhato_meg": "az alkalmazás meghajtója nem határozható meg", + "err.backup.az_alkalmazas_meghajtoja_nem_elerheto": "az alkalmazás meghajtója nem elérhető (%s)", + "err.backup.nincs_eleg_szabad_hely_a_menteshez": "nincs elég szabad hely a mentéshez a(z) %s meghajtón", + "err.backup.adatbazis_felderites_sikertelen": "adatbázis-felderítés sikertelen: %s", + "err.backup.adatbazis_mentes_sikertelen": "adatbázis-mentés sikertelen (%s): %s", + "err.backup.kotetmentes_sikertelen": "kötetmentés sikertelen: %s", + "err.backup.a_mentesi_egyseg_rogzitese_sikertelen": "a mentési egység rögzítése sikertelen: %s", + "err.backup.az_alkalmazas_mentesi_helye_nem_hatarozhato": "az alkalmazás mentési helye nem határozható meg", + "err.backup.nincs_masodlagos_fajlmasolat_ehhez_az_alkalmazashoz": "nincs másodlagos fájlmásolat ehhez az alkalmazáshoz", + "err.backup.a_masodlagos_meghajto_nincs_csatlakoztatva": "a másodlagos meghajtó nincs csatlakoztatva", + "err.backup.az_alkalmazas_meghajtoja_nincs_csatlakoztatva": "az alkalmazás meghajtója nincs csatlakoztatva", + "err.backup.az_alkalmazas_meghajtoja_le_van_szerelve": "az alkalmazás meghajtója le van szerelve", + "err.backup.a_2_mentes_regi_formatumu_futtass": "A 2. mentés régi formátumú — futtass előbb egy új másodlagos mentést.", + "err.backup.ennek_az_alkalmazasnak_az_adatai_nem": "ennek az alkalmazásnak az adatai nem ebből a másolatból állíthatók vissza", + "err.backup.a_masodlagos_masolatban_nincs_megnyithato_mentesi": "a másodlagos másolatban nincs megnyitható mentési egység ehhez az alkalmazáshoz", + "err.backup.fajlmasolas_sikertelen": "fájlmásolás sikertelen: %s", + "err.backup.fajl_visszaallitva_de_az_alkalmazas_ujrainditasa": "%d fájl visszaállítva, de az alkalmazás újraindítása sikertelen: %s", + "err.backup.ervenytelen_alkalmazasnev": "érvénytelen alkalmazásnév", + "err.backup.a_torles_utvonala_kivul_esik_az": "a törlés útvonala kívül esik az ellenőrző mappán", + "err.backup.a_masolat_torlese_nem_sikerult": "a másolat törlése nem sikerült: %s", + "err.backup.nincs_ilyen_ellenorzo_masolat": "nincs ilyen ellenőrző másolat", + "err.backup.az_offsite_tarolo_nincs_elarvult_allapotban": "az offsite tároló nincs elárvult állapotban", + "err.backup.halozati_tarolo_nem_lehet_a_2": "Hálózati tároló nem lehet a 2. mentés célja — a fájltulajdonos-adatok megőrzése ott nem garantálható.", + "err.web.ervenytelen_csatlakoztatasi_nev_csak_betuk_szamok": "érvénytelen csatlakoztatási név (csak betűk, számok, _ és - engedélyezett)", + "err.web.nem_tamogatott_fajlrendszer_ext4_vagy_xfs": "nem támogatott fájlrendszer: %q (ext4 vagy xfs)", + "err.web.formazas_sikertelen": "formázás sikertelen: %s", + "err.web.formazas_kesz_de_az_uj_fajlrendszer": "formázás kész, de az új fájlrendszer-azonosító nem feloldható — frissítsen és használja a Csatolás funkciót", + "err.web.csatlakoztatas_sikertelen": "csatlakoztatás sikertelen: %s", + "err.web.a_kivalasztott_meghajtohoz_nem_talalhato_fajlrendszer": "a kiválasztott meghajtóhoz nem található fájlrendszer-azonosító (csak fájlrendszerrel rendelkező meghajtó csatolható)", + "err.web.a_formazas_allapota_nem_kerdezheto_le": "a formázás állapota nem kérdezhető le: %s", + "err.web.formazas_sikertelen_2": "formázás sikertelen: %s", + "err.web.a_formazas_nem_indult_el_az": "a formázás nem indult el az eszközön (%s)", + "err.web.leszereles_visszavonasa_sikertelen": "leszerelés visszavonása sikertelen: %s", + "err.web.utemezhetoseg_visszaallitasa_sikertelen": "ütemezhetőség visszaállítása sikertelen: %s", + "err.web.regisztracio_sikertelen": "regisztráció sikertelen: %s", + "err.web.ez_az_egyetlen_hasznalhato_tarhely_a": "ez az egyetlen használható tárhely — a leszerelés megtagadva (előbb adj hozzá vagy állíts be másik alapértelmezett meghajtót)", + "err.web.nyilvantartas_frissitese_sikertelen": "nyilvántartás frissítése sikertelen: %s", + "err.web.a_meghajto_leszerelese_sikertelen": "a meghajtó leszerelése sikertelen: %s", + "err.settings.a_kiszolgalo_neve_nem_lehet_ures": "a kiszolgáló neve nem lehet üres", + "err.settings.a_kiszolgalo_neve_legfeljebb_15_karakter": "a kiszolgáló neve legfeljebb 15 karakter lehet", + "err.settings.a_kiszolgalo_neve_csak_betut_szamot": "a kiszolgáló neve csak betűt, számot, kötőjelet és aláhúzást tartalmazhat", + "err.settings.a_megosztas_neve_nem_lehet_ures": "a megosztás neve nem lehet üres", + "err.settings.a_megosztas_neve_legfeljebb_15_karakter": "a megosztás neve legfeljebb 15 karakter lehet", + "err.settings.a_megosztas_neve_nem_tartalmazhat_perjelet": "a megosztás neve nem tartalmazhat perjelet vagy pontot", + "err.settings.a_megosztas_neve_nem_kezdodhet_alahuzassal": "a megosztás neve nem kezdődhet aláhúzással — ezek a nevek a rendszernek vannak fenntartva", + "err.settings.a_megosztas_neve_csak_betut_szamot": "a megosztás neve csak betűt, számot, kötőjelet és aláhúzást tartalmazhat", + "err.settings.mar_letezik_nevu_megosztas": "már létezik „%s” nevű megosztás", + "err.settings.a_z_megosztas_a_rendszer_resze": "a(z) „%s” megosztás a rendszer része, nem törölhető", + "err.settings.nincs_nevu_megosztas": "nincs „%s” nevű megosztás", + "err.settings.a_halozati_tarhely_nem_lehet_a": "a hálózati tárhely nem lehet a rendszermentés célja: %s", + "err.integrations.nincs_kezelo_a_z_integraciohoz": "nincs kezelő a(z) %s integrációhoz", + "err.integrations.a_szolgaltato_alkalmazas_nincs_telepitve": "a szolgáltató alkalmazás (%s) nincs telepítve", + "err.integrations.a_szolgaltato_alkalmazas_nem_fut": "a szolgáltató alkalmazás (%s) nem fut", + "err.integrations.a_celalkalmazas_nincs_telepitve": "a célalkalmazás (%s) nincs telepítve", + "err.integrations.a_celalkalmazas_nem_fut": "a célalkalmazás (%s) nem fut", + "err.integrations.integracio_alkalmazasa_sikertelen": "integráció alkalmazása sikertelen: %s", + "err.integrations.integracio_allapotanak_mentese_sikertelen": "integráció állapotának mentése sikertelen: %s", + "err.integrations.szolgaltato_stack_nem_talalhato": "szolgáltató stack %q nem található", + "err.integrations.onlyoffice_jwt_secret_nincs_beallitva_telepitsd": "OnlyOffice JWT_SECRET nincs beállítva — telepítsd újra az alkalmazást", + "err.integrations.filebrowser_config_olvasasi_hiba": "FileBrowser config olvasási hiba: %s", + "err.integrations.config_irasi_hiba": "config írási hiba: %s", + "err.integrations.config_atnevezesi_hiba": "config átnevezési hiba: %s", + "err.integrations.config_olvasasi_hiba": "config olvasási hiba: %s", + "err.integrations.onlyoffice_jwt_secret_nincs_beallitva": "OnlyOffice JWT_SECRET nincs beállítva", + "err.selfupdate.hianyos_registry_hitelesito_adatok_felhasznalonev_es": "hiányos registry hitelesítő adatok (felhasználónév és token együtt szükséges)", + "err.selfupdate.frissites_mar_folyamatban": "Frissítés már folyamatban", + "err.selfupdate.dev_verzio_nem_frissitheto": "Dev verzió nem frissíthető", + "err.selfupdate.mentes_fut_probalja_kesobb": "Mentés fut, próbálja később", + "err.selfupdate.a_frissites_nem_erheto_el_nincs": "A frissítés nem érhető el (nincs gazda-ügynök)", + "err.selfupdate.nincs_elerheto_frissites": "Nincs elérhető frissítés", + "err.selfupdate.docker_pull_hianyos_registry_hitelesito_adatok": "docker pull: hiányos registry hitelesítő adatok (felhasználónév és token együtt szükséges)", + "err.appexport.visszafejtes_sikertelen": "visszafejtés sikertelen: %s", + "err.appexport.a_z_kotet_adata_hianyzik_a": "a(z) %q kötet adata hiányzik a csomagból", + "err.appexport.a_z_adatkonyvtar_tartalma_hianyzik_a": "a(z) %q adatkönyvtár tartalma hiányzik a csomagból", + "err.appexport.ket_adatkonyvtar_azonos_nevvel_vegzodik_es": "két adatkönyvtár azonos névvel végződik (%q: %s és %s) — a csomag nem tudná megkülönböztetni őket", + "err.appexport.a_mentes_nem_tartalmaz_alkalmazasadatot_0": "a mentés nem tartalmaz alkalmazásadatot (0 adatkönyvtár, 0 kötet egy adattárolós alkalmazásnál)", + "err.appexport.jelszo_hibas_vagy_a_fajl_serult": "jelszó hibás vagy a fájl sérült", + "err.web.a_meghajtok_listaja_nem_olvashato": "a meghajtók listája nem olvasható", + "err.web.a_meghajto_nem_talalhato": "a meghajtó nem található", + "err.web.ez_a_mappa_nem_oszthato_meg": "Ez a mappa nem osztható meg.", + "err.notify.hub_nem_konfiguralt": "hub nem konfigurált", + "err.notify.hub_elerhetetlen": "hub elérhetetlen: %s", + "err.stacks.field_required_password": "a(z) %q mező kitöltése kötelező — használja a Generálás gombot vagy írjon be egy jelszót", + "err.stacks.field_required": "a(z) %q (%s) mező kitöltése kötelező", + "err.backup.offsite_over_quota": "A távoli mentés túllépte a tárhelykeretet (%d/%d GB) — törölj régi mentéseket vagy kérj nagyobb keretet.", + "err.stacks.not_enough_memory": "Nincs elég memória az alkalmazás telepítéséhez. Szükséges: %d MB, Elérhető: %d MB (összesen: %d MB, ebből %d MB használt, %d MB rendszer számára fenntartva)", + "err.stacks.memory_overcommit_warning": "Az alkalmazások csúcsterhelése meghaladhatja a rendelkezésre álló memóriát. Normál használat mellett ez nem okoz problémát.", + "err.backup.not_installed_known_drive": "a(z) %s nincs telepítve, ezért nincs hová visszaállítani az adatait. A mentése szerint az adatai itt voltak: %s. Telepítsd újra az alkalmazást (Alkalmazások) ugyanerre a helyre, utána ez a visszaállítás működni fog", + "err.backup.not_installed": "a(z) %s nincs telepítve, ezért nincs hová visszaállítani az adatait — telepítsd újra az alkalmazást (Alkalmazások), utána ez a visszaállítás működni fog", + "err.backup.no_data_root": "a(z) %s telepítve van, de a vezérlő nem tudja megállapítani, hová tartoznak az adatai (nincs beállítva rendszer-adatterület). Ellenőrizd a tárhely beállításait (Tárhely), utána indítsd újra a visszaállítást", + "err.backup.no_registered_drive": "nincs regisztrált adatmeghajtó, ezért nincs hová visszaállítani — a meghajtók megvannak, csak újra kell csatolni őket a Tárhely → Meghajtók oldalon, utána ez a visszaállítás működni fog", + "err.backup.db_restore_and_rollback_failed": "a(z) %s adatbázisának visszaállítása sikertelen, és a korábbi állapot visszatöltése sem sikerült. Az alkalmazást biztonsági okból LEÁLLÍTVA hagytuk, hogy az adatai ne sérüljenek tovább. Vedd fel velünk a kapcsolatot — %s", + "err.backup.db_restore_failed_rolled_back": "a(z) %s adatbázisának visszaállítása sikertelen — az adataid visszakerültek a visszaállítás előtti állapotba, az alkalmazás fut tovább. Ha újra megpróbálnád, előbb vedd fel velünk a kapcsolatot", + "err.integrations.onlyoffice_no_subdomain": "OnlyOffice aldomain nem ismert", + "err.integrations.occ_failed": "occ parancs sikertelen (%s): %s (kimenet: %s)", + "err.integrations.occ_disable_failed": "occ app:disable sikertelen: %s (kimenet: %s)", + "err.notify.hub_error": "hub hiba (%d): %s" } diff --git a/controller/internal/integrations/manager.go b/controller/internal/integrations/manager.go index 7dfea3f..add656a 100644 --- a/controller/internal/integrations/manager.go +++ b/controller/internal/integrations/manager.go @@ -2,7 +2,7 @@ package integrations import ( "context" - "fmt" + "gitea.dooplex.hu/admin/felhom-controller/internal/util" "log" "path/filepath" "sync" @@ -87,7 +87,7 @@ func (m *Manager) Toggle(ctx context.Context, provider, target string, enable bo handler, ok := m.handlers[key] if !ok { - return settings.IntegrationState{}, fmt.Errorf("nincs kezelő a(z) %s integrációhoz", key) + return settings.IntegrationState{}, util.MsgError("err.integrations.nincs_kezelo_a_z_integraciohoz", key) } ac, err := m.buildApplyContext(provider, target) @@ -108,10 +108,10 @@ func (m *Manager) Toggle(ctx context.Context, provider, target string, enable bo }()) } if !pOk || !provStack.Deployed { - return state, fmt.Errorf("a szolgáltató alkalmazás (%s) nincs telepítve", provider) + return state, util.MsgError("err.integrations.a_szolgaltato_alkalmazas_nincs_telepitve", provider) } if provStack.State != stacks.StateRunning { - return state, fmt.Errorf("a szolgáltató alkalmazás (%s) nem fut", provider) + return state, util.MsgError("err.integrations.a_szolgaltato_alkalmazas_nem_fut", provider) } // Validate: target must be deployed and running (filebrowser is infra, always present) @@ -126,10 +126,10 @@ func (m *Manager) Toggle(ctx context.Context, provider, target string, enable bo }()) } if !tOk || !tgtStack.Deployed { - return state, fmt.Errorf("a célalkalmazás (%s) nincs telepítve", target) + return state, util.MsgError("err.integrations.a_celalkalmazas_nincs_telepitve", target) } if tgtStack.State != stacks.StateRunning { - return state, fmt.Errorf("a célalkalmazás (%s) nem fut", target) + return state, util.MsgError("err.integrations.a_celalkalmazas_nem_fut", target) } } @@ -144,7 +144,7 @@ func (m *Manager) Toggle(ctx context.Context, provider, target string, enable bo state.LastError = err.Error() state.EnabledAt = time.Now().UTC().Format(time.RFC3339) _ = m.sett.SetIntegrationState(key, state) - return state, fmt.Errorf("integráció alkalmazása sikertelen: %w", err) + return state, util.MsgError("err.integrations.integracio_alkalmazasa_sikertelen", err) } if m.isDebug() { m.logger.Printf("[DEBUG] [integrations] Toggle: Apply succeeded for %s in %v", key, time.Since(start)) @@ -168,7 +168,7 @@ func (m *Manager) Toggle(ctx context.Context, provider, target string, enable bo } if err := m.sett.SetIntegrationState(key, state); err != nil { - return state, fmt.Errorf("integráció állapotának mentése sikertelen: %w", err) + return state, util.MsgError("err.integrations.integracio_allapotanak_mentese_sikertelen", err) } return state, nil } @@ -222,7 +222,7 @@ func (m *Manager) buildApplyContext(provider, target string) (*ApplyContext, err provStack, ok := m.stacks.GetStack(provider) if !ok { m.logger.Printf("[WARN] [integrations] Failed to build context for %s:%s: provider stack not found", provider, target) - return nil, fmt.Errorf("szolgáltató stack %q nem található", provider) + return nil, util.MsgError("err.integrations.szolgaltato_stack_nem_talalhato", provider) } // Load decrypted env from provider's app.yaml diff --git a/controller/internal/integrations/onlyoffice_filebrowser.go b/controller/internal/integrations/onlyoffice_filebrowser.go index 5567297..58364fb 100644 --- a/controller/internal/integrations/onlyoffice_filebrowser.go +++ b/controller/internal/integrations/onlyoffice_filebrowser.go @@ -2,6 +2,7 @@ package integrations import ( "fmt" + "gitea.dooplex.hu/admin/felhom-controller/internal/util" "os" "path/filepath" "strings" @@ -14,7 +15,7 @@ func (h *OnlyOfficeFileBrowserHandler) Apply(ac *ApplyContext) error { jwtSecret := ac.ProviderEnv["JWT_SECRET"] if jwtSecret == "" { ac.Logger.Printf("[ERROR] [integrations] OnlyOffice-FileBrowser apply: JWT_SECRET not set") - return fmt.Errorf("OnlyOffice JWT_SECRET nincs beállítva — telepítsd újra az alkalmazást") + return util.MsgError("err.integrations.onlyoffice_jwt_secret_nincs_beallitva_telepitsd") } subdomain := ac.ProviderEnv["SUBDOMAIN"] @@ -23,7 +24,7 @@ func (h *OnlyOfficeFileBrowserHandler) Apply(ac *ApplyContext) error { } if subdomain == "" { ac.Logger.Printf("[ERROR] [integrations] OnlyOffice-FileBrowser apply: subdomain unknown") - return fmt.Errorf("OnlyOffice aldomain nem ismert") + return util.MsgError("err.integrations.onlyoffice_no_subdomain") } configPath := filepath.Join(ac.StacksDir, "filebrowser", "config.yaml") @@ -32,7 +33,7 @@ func (h *OnlyOfficeFileBrowserHandler) Apply(ac *ApplyContext) error { configData, err := os.ReadFile(configPath) if err != nil { ac.Logger.Printf("[ERROR] [integrations] OnlyOffice-FileBrowser apply: %v", err) - return fmt.Errorf("FileBrowser config olvasási hiba: %w", err) + return util.MsgError("err.integrations.filebrowser_config_olvasasi_hiba", err) } // Remove any existing integrations section, then append the new one @@ -49,12 +50,12 @@ func (h *OnlyOfficeFileBrowserHandler) Apply(ac *ApplyContext) error { tmpPath := configPath + ".tmp" if err := os.WriteFile(tmpPath, []byte(configStr), 0644); err != nil { ac.Logger.Printf("[ERROR] [integrations] OnlyOffice-FileBrowser apply: %v", err) - return fmt.Errorf("config írási hiba: %w", err) + return util.MsgError("err.integrations.config_irasi_hiba", err) } if err := os.Rename(tmpPath, configPath); err != nil { _ = os.Remove(tmpPath) ac.Logger.Printf("[ERROR] [integrations] OnlyOffice-FileBrowser apply: %v", err) - return fmt.Errorf("config átnevezési hiba: %w", err) + return util.MsgError("err.integrations.config_atnevezesi_hiba", err) } ac.Logger.Printf("[INFO] [integrations] FileBrowser config updated with OnlyOffice integration") @@ -71,7 +72,7 @@ func (h *OnlyOfficeFileBrowserHandler) Revoke(ac *ApplyContext) error { return nil } ac.Logger.Printf("[ERROR] [integrations] OnlyOffice-FileBrowser revoke: %v", err) - return fmt.Errorf("config olvasási hiba: %w", err) + return util.MsgError("err.integrations.config_olvasasi_hiba", err) } cleaned := removeIntegrationsSection(string(configData)) @@ -84,12 +85,12 @@ func (h *OnlyOfficeFileBrowserHandler) Revoke(ac *ApplyContext) error { tmpPath := configPath + ".tmp" if err := os.WriteFile(tmpPath, []byte(cleaned), 0644); err != nil { ac.Logger.Printf("[ERROR] [integrations] OnlyOffice-FileBrowser revoke: %v", err) - return fmt.Errorf("config írási hiba: %w", err) + return util.MsgError("err.integrations.config_irasi_hiba", err) } if err := os.Rename(tmpPath, configPath); err != nil { _ = os.Remove(tmpPath) ac.Logger.Printf("[ERROR] [integrations] OnlyOffice-FileBrowser revoke: %v", err) - return fmt.Errorf("config átnevezési hiba: %w", err) + return util.MsgError("err.integrations.config_atnevezesi_hiba", err) } ac.Logger.Printf("[INFO] [integrations] FileBrowser config cleaned — OnlyOffice integration removed") diff --git a/controller/internal/integrations/onlyoffice_nextcloud.go b/controller/internal/integrations/onlyoffice_nextcloud.go index ac7ce5a..fde8807 100644 --- a/controller/internal/integrations/onlyoffice_nextcloud.go +++ b/controller/internal/integrations/onlyoffice_nextcloud.go @@ -3,6 +3,7 @@ package integrations import ( "context" "fmt" + "gitea.dooplex.hu/admin/felhom-controller/internal/util" "os/exec" "strings" "time" @@ -15,7 +16,7 @@ func (h *OnlyOfficeNextcloudHandler) Apply(ac *ApplyContext) error { jwtSecret := ac.ProviderEnv["JWT_SECRET"] if jwtSecret == "" { ac.Logger.Printf("[ERROR] [integrations] OnlyOffice-Nextcloud apply: JWT_SECRET not set") - return fmt.Errorf("OnlyOffice JWT_SECRET nincs beállítva") + return util.MsgError("err.integrations.onlyoffice_jwt_secret_nincs_beallitva") } subdomain := ac.ProviderEnv["SUBDOMAIN"] @@ -24,7 +25,7 @@ func (h *OnlyOfficeNextcloudHandler) Apply(ac *ApplyContext) error { } if subdomain == "" { ac.Logger.Printf("[ERROR] [integrations] OnlyOffice-Nextcloud apply: subdomain unknown") - return fmt.Errorf("OnlyOffice aldomain nem ismert") + return util.MsgError("err.integrations.onlyoffice_no_subdomain") } publicURL := fmt.Sprintf("https://%s.%s", subdomain, ac.Domain) @@ -75,7 +76,7 @@ func (h *OnlyOfficeNextcloudHandler) Apply(ac *ApplyContext) error { continue } ac.Logger.Printf("[ERROR] [integrations] OnlyOffice-Nextcloud apply: occ %s failed: %v", cmd.args[len(cmd.args)-1], err) - return fmt.Errorf("occ parancs sikertelen (%s): %v (kimenet: %s)", cmd.args[len(cmd.args)-1], err, strings.TrimSpace(string(out))) + return util.MsgError("err.integrations.occ_failed", cmd.args[len(cmd.args)-1], err, strings.TrimSpace(string(out))) } ac.Logger.Printf("[DEBUG] [integrations] Nextcloud occ %s: ok", strings.Join(cmd.args[7:], " ")) } @@ -100,7 +101,7 @@ func (h *OnlyOfficeNextcloudHandler) Revoke(ac *ApplyContext) error { return nil } ac.Logger.Printf("[ERROR] [integrations] OnlyOffice-Nextcloud revoke: occ app:disable failed: %v", err) - return fmt.Errorf("occ app:disable sikertelen: %v (kimenet: %s)", err, strings.TrimSpace(outStr)) + return util.MsgError("err.integrations.occ_disable_failed", err, strings.TrimSpace(outStr)) } ac.Logger.Printf("[INFO] [integrations] OnlyOffice integration revoked from Nextcloud") diff --git a/controller/internal/notify/notifier.go b/controller/internal/notify/notifier.go index 9e20ed0..b341519 100644 --- a/controller/internal/notify/notifier.go +++ b/controller/internal/notify/notifier.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "gitea.dooplex.hu/admin/felhom-controller/internal/util" "io" "log" "net/http" @@ -780,7 +781,7 @@ type preferencesRequest struct { // Synchronous — returns error for the handler to display to the user. func (n *Notifier) SyncPreferences(email string, enabledEvents []string, cooldownHours int) error { if !n.enabled { - return fmt.Errorf("hub nem konfigurált") + return util.MsgError("err.notify.hub_nem_konfiguralt") } payload := preferencesRequest{ @@ -809,13 +810,13 @@ func (n *Notifier) SyncPreferences(email string, enabledEvents []string, cooldow resp, err := n.httpClient.Do(req) if err != nil { - return fmt.Errorf("hub elérhetetlen: %w", err) + return util.MsgError("err.notify.hub_elerhetetlen", err) } defer resp.Body.Close() if resp.StatusCode >= 400 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) - return fmt.Errorf("hub hiba (%d): %s", resp.StatusCode, string(body)) + return util.MsgError("err.notify.hub_error", resp.StatusCode, string(body)) } if n.debug { @@ -872,7 +873,7 @@ func (n *Notifier) SendTest() error { // Used by the debug page for event testing with configurable type/severity. func (n *Notifier) PushTestEventSync(eventType, severity, message string) (statusCode int, err error) { if !n.enabled { - return 0, fmt.Errorf("hub nem konfigurált") + return 0, util.MsgError("err.notify.hub_nem_konfiguralt") } payload := eventRequest{ diff --git a/controller/internal/selfupdate/updater.go b/controller/internal/selfupdate/updater.go index a92343c..8e08891 100644 --- a/controller/internal/selfupdate/updater.go +++ b/controller/internal/selfupdate/updater.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "gitea.dooplex.hu/admin/felhom-controller/internal/util" "io" "log" "net/http" @@ -219,7 +220,7 @@ func (u *Updater) registryBaseURL() string { func (u *Updater) queryRegistry() (string, error) { anonymous := u.RegistryAnonymous() if !anonymous && (u.gitCfg.Username == "" || u.gitCfg.Token == "") { - return "", fmt.Errorf("hiányos registry hitelesítő adatok (felhasználónév és token együtt szükséges)") + return "", util.MsgError("err.selfupdate.hianyos_registry_hitelesito_adatok_felhasznalonev_es") } // Registry V2: GET /v2///tags/list @@ -498,26 +499,26 @@ func (u *Updater) TriggerUpdate(initiatedBy string) error { if u.updateRunning { u.mu.Unlock() u.dbg("TriggerUpdate: rejected — update already running") - return fmt.Errorf("Frissítés már folyamatban") + return util.MsgError("err.selfupdate.frissites_mar_folyamatban") } // Dev version check if _, err := ParseVersion(u.currentVer); err != nil { u.mu.Unlock() - return fmt.Errorf("Dev verzió nem frissíthető") + return util.MsgError("err.selfupdate.dev_verzio_nem_frissitheto") } // Backup running check if u.backupRunning != nil && u.backupRunning() { u.mu.Unlock() - return fmt.Errorf("Mentés fut, próbálja később") + return util.MsgError("err.selfupdate.mentes_fut_probalja_kesobb") } // Agent reachable check — the host agent performs the swap; without it there is no update path // (the old in-container docker-compose flow is removed). if u.agent == nil { u.mu.Unlock() - return fmt.Errorf("A frissítés nem érhető el (nincs gazda-ügynök)") + return util.MsgError("err.selfupdate.a_frissites_nem_erheto_el_nincs") } u.updateRunning = true @@ -529,7 +530,7 @@ func (u *Updater) TriggerUpdate(initiatedBy string) error { u.mu.Lock() u.updateRunning = false u.mu.Unlock() - return fmt.Errorf("Nincs elérhető frissítés") + return util.MsgError("err.selfupdate.nincs_elerheto_frissites") } targetVersion := result.LatestVersion @@ -736,7 +737,7 @@ func (u *Updater) performUpdate(targetVersion, targetImage, previousImage, initi func (u *Updater) pullImage(targetImage string) error { hasCreds := u.gitCfg.Username != "" && u.gitCfg.Token != "" if !hasCreds && (u.gitCfg.Username != "" || u.gitCfg.Token != "") { - return fmt.Errorf("docker pull: hiányos registry hitelesítő adatok (felhasználónév és token együtt szükséges)") + return util.MsgError("err.selfupdate.docker_pull_hianyos_registry_hitelesito_adatok") } host := registryHost(u.cfg.Image) if hasCreds { diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go index 3009557..db16635 100644 --- a/controller/internal/settings/settings.go +++ b/controller/internal/settings/settings.go @@ -3,6 +3,7 @@ package settings import ( "encoding/json" "fmt" + "gitea.dooplex.hu/admin/felhom-controller/internal/util" "log" "os" "path/filepath" @@ -1499,7 +1500,7 @@ func (s *Settings) SetBackupTarget(path string) error { return fmt.Errorf("storage path %q not found", path) } if s.StoragePaths[idx].IsNetwork() { - return fmt.Errorf("a hálózati tárhely nem lehet a rendszermentés célja: %s", path) + return util.MsgError("err.settings.a_halozati_tarhely_nem_lehet_a", path) } for i := range s.StoragePaths { s.StoragePaths[i].BackupTarget = i == idx diff --git a/controller/internal/settings/smb.go b/controller/internal/settings/smb.go index 7c041ee..abf0695 100644 --- a/controller/internal/settings/smb.go +++ b/controller/internal/settings/smb.go @@ -1,7 +1,7 @@ package settings import ( - "fmt" + "gitea.dooplex.hu/admin/felhom-controller/internal/util" "regexp" "strings" "time" @@ -52,13 +52,13 @@ var nbNameRe = regexp.MustCompile(`^[A-Za-z0-9_](?:[A-Za-z0-9_-]{0,13}[A-Za-z0-9 func ValidateSMBServerName(name string) error { name = strings.TrimSpace(name) if name == "" { - return fmt.Errorf("a kiszolgáló neve nem lehet üres") + return util.MsgError("err.settings.a_kiszolgalo_neve_nem_lehet_ures") } if len(name) > 15 { - return fmt.Errorf("a kiszolgáló neve legfeljebb 15 karakter lehet") + return util.MsgError("err.settings.a_kiszolgalo_neve_legfeljebb_15_karakter") } if !nbNameRe.MatchString(name) { - return fmt.Errorf("a kiszolgáló neve csak betűt, számot, kötőjelet és aláhúzást tartalmazhat") + return util.MsgError("err.settings.a_kiszolgalo_neve_csak_betut_szamot") } return nil } @@ -69,13 +69,13 @@ func ValidateSMBServerName(name string) error { func ValidateSMBShareName(name string) error { name = strings.TrimSpace(name) if name == "" { - return fmt.Errorf("a megosztás neve nem lehet üres") + return util.MsgError("err.settings.a_megosztas_neve_nem_lehet_ures") } if len(name) > 15 { - return fmt.Errorf("a megosztás neve legfeljebb 15 karakter lehet") + return util.MsgError("err.settings.a_megosztas_neve_legfeljebb_15_karakter") } if strings.ContainsAny(name, `/\.`) { - return fmt.Errorf("a megosztás neve nem tartalmazhat perjelet vagy pontot") + return util.MsgError("err.settings.a_megosztas_neve_nem_tartalmazhat_perjelet") } // RESERVED NAMESPACE (R-7b). The backup engines key the shares source by the pseudo-stack „_shares" // — a restic tag, a tier-2 dest root and a status record. nbNameRe below starts with [A-Za-z0-9_], @@ -84,10 +84,10 @@ func ValidateSMBShareName(name string) error { // whole leading-underscore space keeps future system keys collision-free too. Validation runs on // ADD only, so an already-registered share is never invalidated retroactively. if strings.HasPrefix(name, "_") { - return fmt.Errorf("a megosztás neve nem kezdődhet aláhúzással — ezek a nevek a rendszernek vannak fenntartva") + return util.MsgError("err.settings.a_megosztas_neve_nem_kezdodhet_alahuzassal") } if !nbNameRe.MatchString(name) { - return fmt.Errorf("a megosztás neve csak betűt, számot, kötőjelet és aláhúzást tartalmazhat") + return util.MsgError("err.settings.a_megosztas_neve_csak_betut_szamot") } return nil } @@ -169,7 +169,7 @@ func (s *Settings) AddSMBShare(share SMBShare) error { defer s.mu.Unlock() for _, ex := range s.SMBShares { if strings.EqualFold(ex.Name, share.Name) { - return fmt.Errorf("már létezik „%s” nevű megosztás", share.Name) + return util.MsgError("err.settings.mar_letezik_nevu_megosztas", share.Name) } } if share.CreatedAt == "" { @@ -193,7 +193,7 @@ func (s *Settings) RemoveSMBShare(name string) error { defer s.mu.Unlock() for _, ex := range s.SMBShares { if strings.EqualFold(ex.Name, name) && ex.System { - return fmt.Errorf("a(z) „%s” megosztás a rendszer része, nem törölhető", ex.Name) + return util.MsgError("err.settings.a_z_megosztas_a_rendszer_resze", ex.Name) } } var kept []SMBShare @@ -206,7 +206,7 @@ func (s *Settings) RemoveSMBShare(name string) error { kept = append(kept, ex) } if !found { - return fmt.Errorf("nincs „%s” nevű megosztás", name) + return util.MsgError("err.settings.nincs_nevu_megosztas", name) } s.SMBShares = kept if s.log != nil { @@ -225,5 +225,5 @@ func (s *Settings) SetSMBShareOffsite(name string, offsite bool) error { return s.save() } } - return fmt.Errorf("nincs „%s” nevű megosztás", name) + return util.MsgError("err.settings.nincs_nevu_megosztas", name) } diff --git a/controller/internal/stacks/deploy.go b/controller/internal/stacks/deploy.go index c99bfe6..2e0c59e 100644 --- a/controller/internal/stacks/deploy.go +++ b/controller/internal/stacks/deploy.go @@ -45,13 +45,13 @@ var subdomainRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`) // validateSubdomain checks that a subdomain is DNS-safe. func validateSubdomain(s string) error { if s == "" { - return fmt.Errorf("az aldomain nem lehet üres") + return util.MsgError("err.stacks.az_aldomain_nem_lehet_ures") } if len(s) > 63 { return fmt.Errorf("az aldomain legfeljebb 63 karakter lehet") } if !subdomainRe.MatchString(s) { - return fmt.Errorf("az aldomain csak kisbetűket, számokat és kötőjelet tartalmazhat, és nem kezdődhet/végződhet kötőjellel") + return util.MsgError("err.stacks.az_aldomain_csak_kisbetuket_szamokat_es") } return nil } @@ -240,9 +240,9 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) { // Slice 4: the block moved into memoryVerdict so the guarded update applies the SAME check with // the SAME wording. Behaviour here is unchanged — same inputs, same log line, same refusal text. refusal, deployWarning := m.memoryVerdict(ParseMemoryMB(meta.Resources.MemRequest), ParseMemoryMB(meta.Resources.MemLimit), 0, 0) - if refusal != "" { + if refusal != nil { clearDeploying() - return "", util.KindError(ErrNotEnoughMemory, refusal) + return "", refusal } // Debug: log received values (redact passwords/secrets) @@ -280,11 +280,11 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) { } if reservedSubdomains[value] { clearDeploying() - return "", fmt.Errorf("a(z) %q aldomain foglalt rendszer számára", value) + return "", util.MsgError("err.stacks.a_z_aldomain_foglalt_rendszer_szamara", value) } if m.SubdomainInUse(value, req.StackName) { clearDeploying() - return "", fmt.Errorf("a(z) %q aldomain már használatban van egy másik alkalmazásban", value) + return "", util.MsgError("err.stacks.a_z_aldomain_mar_hasznalatban_van", value) } case "secret": @@ -308,7 +308,7 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) { value = userVal } else { clearDeploying() - return "", util.KindErrorf(ErrRequiredField, "a(z) %q mező kitöltése kötelező — használja a Generálás gombot vagy írjon be egy jelszót", field.Label) + return "", util.MsgErrorf(ErrRequiredField, "err.stacks.field_required_password", field.Label) } default: @@ -323,7 +323,7 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) { // Validate required fields if field.Required && value == "" { clearDeploying() - return "", util.KindErrorf(ErrRequiredField, "a(z) %q (%s) mező kitöltése kötelező", field.Label, field.EnvVar) + return "", util.MsgErrorf(ErrRequiredField, "err.stacks.field_required", field.Label, field.EnvVar) } // Validate path fields exist on the host filesystem @@ -1197,9 +1197,14 @@ func randomAlphanumeric(length int) (string, error) { // update replaces the app's own current request, so counting both would refuse an update that fits. // A deploy releases nothing and passes 0, 0. // -// Returns the refusal (the deploy's own Hungarian wording, "" = admitted) and the soft overcommit -// warning. An unreadable memory reading admits with a WARN, exactly as the deploy always has. -func (m *Manager) memoryVerdict(newReqMB, newLimitMB, releasedReqMB, releasedLimitMB int) (refusal, warning string) { +// Returns the refusal (nil = admitted) and the soft overcommit warning. +// +// v0.253.0 (R-557): the refusal is an ERROR rather than a sentence, and it carries BOTH its kind +// (ErrNotEnoughMemory, so api.deployStatusFor still answers 409) and its message key (so the +// household reads it in its own language). One value where there used to be a sentence plus a +// wrapper at each call site. An unreadable memory reading admits with a WARN, exactly as the deploy +// always has. +func (m *Manager) memoryVerdict(newReqMB, newLimitMB, releasedReqMB, releasedLimitMB int) (refusal error, warning string) { reservedMB := m.cfg.System.ReservedMemoryMB totalMB, usedMB, memErr := system.GetMemoryMB() // F1: the controller container cannot read the guest's RAM cap from /proc (no lxcfs) or its own @@ -1216,7 +1221,7 @@ func (m *Manager) memoryVerdict(newReqMB, newLimitMB, releasedReqMB, releasedLim } if memErr != nil { m.logger.Printf("[WARN] [stacks] Cannot read system memory: %v — skipping memory check", memErr) - return "", "" + return nil, "" } usedMB -= releasedReqMB if usedMB < 0 { @@ -1229,10 +1234,7 @@ func (m *Manager) memoryVerdict(newReqMB, newLimitMB, releasedReqMB, releasedLim // Hard block: committed + new request exceeds usable memory if newReqMB > 0 && usedMB+newReqMB > usableMB { - return fmt.Sprintf( - "Nincs elég memória az alkalmazás telepítéséhez. "+ - "Szükséges: %d MB, Elérhető: %d MB "+ - "(összesen: %d MB, ebből %d MB használt, %d MB rendszer számára fenntartva)", + return util.MsgErrorf(ErrNotEnoughMemory, "err.stacks.not_enough_memory", newReqMB, usableMB-usedMB, totalMB, @@ -1245,8 +1247,7 @@ func (m *Manager) memoryVerdict(newReqMB, newLimitMB, releasedReqMB, releasedLim _, currentLimitMB := m.CommittedMemory() currentLimitMB -= releasedLimitMB if newLimitMB > 0 && currentLimitMB+newLimitMB > totalMB { - warning = "Az alkalmazások csúcsterhelése meghaladhatja a rendelkezésre álló memóriát. " + - "Normál használat mellett ez nem okoz problémát." + warning = msgHU("err.stacks.memory_overcommit_warning") } - return "", warning + return nil, warning } diff --git a/controller/internal/stacks/deploy_errors.go b/controller/internal/stacks/deploy_errors.go index 4525909..1ead34f 100644 --- a/controller/internal/stacks/deploy_errors.go +++ b/controller/internal/stacks/deploy_errors.go @@ -1,6 +1,10 @@ package stacks -import "errors" +import ( + "errors" + + "gitea.dooplex.hu/admin/felhom-controller/internal/i18n" +) // R-553 — the deploy path's refusals carry a KIND, so the API can pick its status code without // reading the customer's Hungarian sentence. The sentences themselves are unchanged (a test pins @@ -19,3 +23,22 @@ var ( // ErrNotEnoughMemory — the memory verdict refused the deploy (API: 400). ErrNotEnoughMemory = errors.New("not enough memory") ) + +// msgHU renders a bundle message in Hungarian. +// +// It exists for the few strings this package returns as TEXT rather than as an error — the soft +// overcommit warning is the only one today. Those are display strings with no error to carry a key, +// so the copy lives in the bundle (a translator sees it, and scripts/i18n_go_parity.py pins it) while +// the value handed back is still Hungarian. **The consequence, stated rather than hidden: such a +// string renders Hungarian on an English page.** Filed as R-575; the fix is for the caller to carry +// the key the way Alert and UpdateRefusal do, not for this helper to guess a language it cannot know. +func msgHU(key string, args ...interface{}) string { + b, err := i18n.Shared() + if err != nil { + return key + } + if len(args) == 0 { + return b.Msg(i18n.Default, key) + } + return b.Msgf(i18n.Default, key, args...) +} diff --git a/controller/internal/stacks/mailenv.go b/controller/internal/stacks/mailenv.go index ccb5433..de55890 100644 --- a/controller/internal/stacks/mailenv.go +++ b/controller/internal/stacks/mailenv.go @@ -2,6 +2,7 @@ package stacks import ( "fmt" + "gitea.dooplex.hu/admin/felhom-controller/internal/util" "path/filepath" ) @@ -115,11 +116,11 @@ func (m *Manager) SetAppEmailEnabled(name string, enabled bool) error { stackDir := filepath.Dir(stack.ComposePath) meta := LoadMetadata(stackDir) if !meta.HasSMTPMapping() { - return fmt.Errorf("a(z) %q alkalmazás nem támogatja az email-küldést", name) + return util.MsgError("err.stacks.a_z_alkalmazas_nem_tamogatja_az", name) } appCfg := LoadAppConfig(stackDir) if appCfg == nil || !appCfg.Deployed { - return fmt.Errorf("a(z) %q alkalmazás nincs telepítve", name) + return util.MsgError("err.stacks.a_z_alkalmazas_nincs_telepitve", name) } if appCfg.EmailEnabled == enabled { return nil // no change diff --git a/controller/internal/stacks/manager.go b/controller/internal/stacks/manager.go index 04d838b..05bfbc8 100644 --- a/controller/internal/stacks/manager.go +++ b/controller/internal/stacks/manager.go @@ -210,7 +210,7 @@ type Manager struct { // this and nothing else: it used to be sent beside the 202 that merely accepted the request, so // an install interrupted five seconds later stayed on the timeline as a completed one. deployDoneHook func(name string, ok bool, detail string) - testSeams *migSeams // nil in production; tests inject fakes + testSeams *migSeams // nil in production; tests inject fakes // R-51: docker restart policies for DOWN members of mixed stacks. Keyed by // containerName+"|"+state so a transitioned or recreated container re-reads rather than // answering from a stale entry; pruned every refresh to the live container set. Guarded by mu @@ -229,7 +229,7 @@ type Manager struct { updateGuards UpdateGuards // init-only, SetUpdateGuards; nil ⇒ every update is REFUSED updateComposeFn func(dir string, env []string, args ...string) (string, error) updateHealthFn func(ctx context.Context, name string, timeout time.Duration) (bool, string) - updateMemoryFn func(newReqMB, newLimitMB, releasedReqMB, releasedLimitMB int) (refusal, warning string) + updateMemoryFn func(newReqMB, newLimitMB, releasedReqMB, releasedLimitMB int) (refusal error, warning string) updateDiskFreeFn func() (freeGiB float64, ok bool) updateNowFn func() time.Time updateJournalMu sync.Mutex diff --git a/controller/internal/stacks/migrate.go b/controller/internal/stacks/migrate.go index 959b75f..2d584bd 100644 --- a/controller/internal/stacks/migrate.go +++ b/controller/internal/stacks/migrate.go @@ -23,6 +23,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "gitea.dooplex.hu/admin/felhom-controller/internal/util" "io" "io/fs" "log" @@ -163,7 +164,7 @@ func (m *Manager) acquireMigrating() error { m.migrateMu.Lock() defer m.migrateMu.Unlock() if m.migrating { - return fmt.Errorf("migráció már folyamatban") + return util.MsgError("err.stacks.migracio_mar_folyamatban") } m.migrating = true return nil @@ -242,12 +243,12 @@ func (m *Manager) startMigration(scope, sourcePath, appName, targetPath string, case "app": cfg := m.LoadAppConfigByName(appName) if cfg == nil { - return "", fmt.Errorf("alkalmazás nem található: %s", appName) + return "", util.MsgError("err.stacks.alkalmazas_nem_talalhato", appName) } j.Source, j.SourceNS = m.appSourceNS(cfg) apps = []string{appName} default: - return "", fmt.Errorf("ismeretlen migrációs hatókör: %s", scope) + return "", util.MsgError("err.stacks.ismeretlen_migracios_hatokor", scope) } j.TargetNS = appbackup.NamespaceRoot(j.Target, m.inGuest(j.Target)) @@ -266,7 +267,7 @@ func (m *Manager) startMigration(scope, sourcePath, appName, targetPath string, j.UpdatedAt = time.Now().UTC() m.setJob(j) if err := m.writeJournal(j); err != nil { - return "", fmt.Errorf("migrációs napló írása: %w", err) + return "", util.MsgError("err.stacks.migracios_naplo_irasa", err) } launched = true @@ -359,18 +360,18 @@ func (m *Manager) appsOnDrive(sourcePath string) []string { // migValidate runs the pre-flight checks. Any failure aborts before any side-effect. func (m *Manager) migValidate(j *MigrationJob) error { if m.backupRunning != nil && m.backupRunning() { - return fmt.Errorf("biztonsági mentés folyamatban, próbáld újra") + return util.MsgError("err.stacks.biztonsagi_mentes_folyamatban_probald_ujra") } if m.testSeams == nil { // real rsync is only required when not using injected copy/verify seams if _, err := exec.LookPath("rsync"); err != nil { - return fmt.Errorf("az rsync nem érhető el a rendszeren") + return util.MsgError("err.stacks.az_rsync_nem_erheto_el_a") } } if j.Target == j.Source { - return fmt.Errorf("a cél és a forrás tároló megegyezik") + return util.MsgError("err.stacks.a_cel_es_a_forras_tarolo") } if m.settings == nil || !m.settings.IsStoragePathSchedulable(j.Target) { - return fmt.Errorf("a céltároló nem elérhető vagy nem választható") + return util.MsgError("err.stacks.a_celtarolo_nem_elerheto_vagy_nem") } // App-dir collision: refuse if any of the app's resolved appdata dir(s) already exists at target. var collide []string @@ -383,7 +384,7 @@ func (m *Manager) migValidate(j *MigrationJob) error { } } if len(collide) > 0 { - return fmt.Errorf("ütközés a céltárolón — már létezik ezeknek az alkalmazásoknak az adata: %s", strings.Join(collide, ", ")) + return util.MsgError("err.stacks.utkozes_a_celtarolon_mar_letezik_ezeknek", strings.Join(collide, ", ")) } // Free-space check (best-effort; GetDiskUsage is nil on non-linux). need := m.migSourceSize(j) @@ -391,7 +392,7 @@ func (m *Manager) migValidate(j *MigrationJob) error { if du := system.GetDiskUsage(j.Target); du != nil { avail := int64(du.AvailGB * 1e9) if need > avail { - return fmt.Errorf("nincs elég hely a céltárolón (kb. %d GB szükséges, %.1f GB szabad)", need/1_000_000_000, du.AvailGB) + return util.MsgError("err.stacks.nincs_eleg_hely_a_celtarolon_kb", need/1_000_000_000, du.AvailGB) } } return nil @@ -451,7 +452,7 @@ func (m *Manager) runMigration(ctx context.Context, j *MigrationJob) { case PhaseDone, PhaseAborted: return default: - err = fmt.Errorf("ismeretlen migrációs fázis: %q", j.Phase) + err = util.MsgError("err.stacks.ismeretlen_migracios_fazis", j.Phase) } if err != nil { m.migAbort(j, err) @@ -484,7 +485,7 @@ func (m *Manager) migAbort(j *MigrationJob, cause error) { func (m *Manager) migStop(j *MigrationJob) error { for _, app := range j.Apps { if err := m.doStop(app); err != nil { - return fmt.Errorf("alkalmazás leállítása sikertelen (%s): %w", app, err) + return util.MsgError("err.stacks.alkalmazas_leallitasa_sikertelen", app, err) } } return nil @@ -513,13 +514,13 @@ func (m *Manager) migCopy(ctx context.Context, j *MigrationJob) error { } if err := m.copySubtree(ctx, j, src, appbackup.AppDataDir(j.TargetNS, name)); err != nil { u.Error = err.Error() - return fmt.Errorf("másolás sikertelen (%s appdata): %w", app, err) + return util.MsgError("err.stacks.masolas_sikertelen_appdata", app, err) } } // the app's recovery unit (db-dumps + volume-dumps + compose + manifest) if err := m.copySubtree(ctx, j, appbackup.RecoveryUnitPath(j.SourceNS, app), appbackup.RecoveryUnitPath(j.TargetNS, app)); err != nil { u.Error = err.Error() - return fmt.Errorf("másolás sikertelen (%s mentés): %w", app, err) + return util.MsgError("err.stacks.masolas_sikertelen_mentes", app, err) } u.State = UnitCopied _ = m.persistJob(j) @@ -530,7 +531,7 @@ func (m *Manager) migCopy(ctx context.Context, j *MigrationJob) error { j.CurrentApp = "" if err := walkMerge(m.logger, j.SourceNS, j.TargetNS, m.appDataSkipSet(j), false, func(b int64) { j.BytesDone += b }); err != nil { u.Error = err.Error() - return fmt.Errorf("ügyfél-adatok összefésülése sikertelen: %w", err) + return util.MsgError("err.stacks.ugyfel_adatok_osszefesulese_sikertelen", err) } u.State = UnitCopied _ = m.persistJob(j) @@ -557,12 +558,12 @@ func (m *Manager) migVerify(ctx context.Context, j *MigrationJob) error { for _, name := range m.ResolveAppDataDirNames(app) { if err := m.verifySubtree(ctx, appbackup.AppDataDir(j.SourceNS, name), appbackup.AppDataDir(j.TargetNS, name)); err != nil { u.Error = err.Error() - return fmt.Errorf("ellenőrzés sikertelen (%s appdata): %w", app, err) + return util.MsgError("err.stacks.ellenorzes_sikertelen_appdata", app, err) } } if err := m.verifySubtree(ctx, appbackup.RecoveryUnitPath(j.SourceNS, app), appbackup.RecoveryUnitPath(j.TargetNS, app)); err != nil { u.Error = err.Error() - return fmt.Errorf("ellenőrzés sikertelen (%s mentés): %w", app, err) + return util.MsgError("err.stacks.ellenorzes_sikertelen_mentes", app, err) } u.State = UnitVerified _ = m.persistJob(j) @@ -573,7 +574,7 @@ func (m *Manager) migVerify(ctx context.Context, j *MigrationJob) error { // assert-only merge walk: every source file has a content-identical counterpart at target. if err := walkMerge(m.logger, j.SourceNS, j.TargetNS, m.appDataSkipSet(j), true, nil); err != nil { u.Error = err.Error() - return fmt.Errorf("ügyfél-adatok ellenőrzése sikertelen: %w", err) + return util.MsgError("err.stacks.ugyfel_adatok_ellenorzese_sikertelen", err) } u.State = UnitVerified _ = m.persistJob(j) @@ -602,7 +603,7 @@ func (m *Manager) migFlipRedeploy(j *MigrationJob) error { _ = m.persistJob(j) if err := m.doFlipRedeploy(app, j.Target); err != nil { u.Error = err.Error() - return fmt.Errorf("újratelepítés sikertelen (%s): %w", app, err) + return util.MsgError("err.stacks.ujratelepites_sikertelen", app, err) } u.State = UnitRedeployed _ = m.persistJob(j) @@ -623,11 +624,11 @@ func (m *Manager) migCleanup(j *MigrationJob) error { } for _, name := range m.ResolveAppDataDirNames(app) { if err := os.RemoveAll(appbackup.AppDataDir(j.SourceNS, name)); err != nil { - return fmt.Errorf("forrás törlése sikertelen (%s appdata): %w", app, err) + return util.MsgError("err.stacks.forras_torlese_sikertelen_appdata", app, err) } } if err := os.RemoveAll(appbackup.RecoveryUnitPath(j.SourceNS, app)); err != nil { - return fmt.Errorf("forrás törlése sikertelen (%s mentés): %w", app, err) + return util.MsgError("err.stacks.forras_torlese_sikertelen_mentes", app, err) } u.State = UnitCleaned _ = m.persistJob(j) @@ -638,11 +639,11 @@ func (m *Manager) migCleanup(j *MigrationJob) error { // Remove every remaining child of the source namespace (the non-app/customer content). entries, err := os.ReadDir(j.SourceNS) if err != nil && !os.IsNotExist(err) { - return fmt.Errorf("forrás névtér olvasása sikertelen: %w", err) + return util.MsgError("err.stacks.forras_nevter_olvasasa_sikertelen", err) } for _, e := range entries { if err := os.RemoveAll(filepath.Join(j.SourceNS, e.Name())); err != nil { - return fmt.Errorf("forrás törlése sikertelen (%s): %w", e.Name(), err) + return util.MsgError("err.stacks.forras_torlese_sikertelen", e.Name(), err) } } u.State = UnitCleaned @@ -657,12 +658,12 @@ func (m *Manager) migCleanup(j *MigrationJob) error { func (m *Manager) migCleanupAllowed(j *MigrationJob) error { for key, u := range j.Units { if stateRank(u.State) < stateRank(UnitVerified) { - return fmt.Errorf("cleanup gate: a(z) %q egység nincs ellenőrizve (állapot=%s)", key, u.State) + return util.MsgError("err.stacks.cleanup_gate_a_z_egyseg_nincs", key, u.State) } } for _, app := range j.Apps { if stateRank(j.Units[app].State) < stateRank(UnitRedeployed) { - return fmt.Errorf("cleanup gate: a(z) %q alkalmazás nincs újratelepítve (állapot=%s)", app, j.Units[app].State) + return util.MsgError("err.stacks.cleanup_gate_a_z_alkalmazas_nincs", app, j.Units[app].State) } } return nil @@ -749,7 +750,7 @@ func (m *Manager) doFlipRedeploy(name, target string) error { return err } if !m.waitHealthy(name) { - return fmt.Errorf("az alkalmazás nem indult el az új tárhelyen") + return util.MsgError("err.stacks.az_alkalmazas_nem_indult_el_az") } return nil } @@ -879,7 +880,7 @@ func rsyncVerify(ctx context.Context, src, dst string) error { } } if len(pending) > 0 { - return fmt.Errorf("%d függőben lévő átvitel maradt, pl. %q", len(pending), pending[0]) + return util.MsgError("err.stacks.fuggoben_levo_atvitel_maradt_pl", len(pending), pending[0]) } return nil } @@ -979,7 +980,7 @@ func walkMerge(lg *log.Logger, srcNS, dstNS string, skip map[string]bool, assert return nil // dedup / already present } if assertOnly { - return fmt.Errorf("a forrásfájlnak nincs azonos másolata a célon: %s", rel) + return util.MsgError("err.stacks.a_forrasfajlnak_nincs_azonos_masolata_a", rel) } out := dst if pathExists(dst) { diff --git a/controller/internal/stacks/r553_deploy_error_kinds_test.go b/controller/internal/stacks/r553_deploy_error_kinds_test.go index 83bc352..bdbe7fd 100644 --- a/controller/internal/stacks/r553_deploy_error_kinds_test.go +++ b/controller/internal/stacks/r553_deploy_error_kinds_test.go @@ -10,6 +10,7 @@ import ( "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/util" ) // r553Manager builds a real Manager over a temp stacks dir holding one app fixture, so DeployStack's @@ -116,22 +117,40 @@ func TestR553_AlreadyDeployedCarriesKindAndKeepsItsWords(t *testing.T) { } } -// The memory refusal is a string built by memoryVerdict from the HOST's real memory, so a unit test -// cannot make it fire. Its kind is therefore pinned where it is attached — at the one line that turns -// that string into an error. Source-level on purpose, like TestDeployAcceptance_DoesNotClaimTheAppIsInstalled: -// the defect this guards is a call written the old way, not a wrong value. The status mapping itself -// is covered by TestR553_Deploy_DecisionSurvivesWordingChange with a translated memory message. +// The memory refusal is built by memoryVerdict from the HOST's real memory, so a unit test cannot +// make it fire. Two halves therefore, and v0.253.0 (R-557) turned the weaker one into the stronger: +// +// - BEHAVIOUR: the value memoryVerdict now returns is an ERROR it builds itself, so the test can +// build the same value and check it — the kind is reachable by errors.Is AND the sentence is the +// one the customer used to read, byte for byte, in Hungarian. That is no longer a source check. +// - SOURCE: memoryVerdict still hands that error back rather than a sentence. The defect this +// guards is a call written the old way, and only the source can show that. func TestR553_MemoryRefusalIsWrappedWithItsKind(t *testing.T) { + // The exact value the producer returns, for a 900 MB request that does not fit. + e := util.MsgErrorf(ErrNotEnoughMemory, "err.stacks.not_enough_memory", 900, 100, 2048, 1436, 512) + + if !errors.Is(e, ErrNotEnoughMemory) { + t.Error("the memory refusal does not carry ErrNotEnoughMemory — the API would answer 500 " + + "for a refusal the customer can act on") + } + const wantHU = "Nincs elég memória az alkalmazás telepítéséhez. Szükséges: 900 MB, Elérhető: 100 MB " + + "(összesen: 2048 MB, ebből 1436 MB használt, 512 MB rendszer számára fenntartva)" + if e.Error() != wantHU { + t.Errorf("the Hungarian refusal moved:\n got %q\nwant %q", e.Error(), wantHU) + } + if en := util.ErrText("en", e); en == wantHU || en == "" { + t.Errorf("the refusal has no English of its own: %q", en) + } + src, err := os.ReadFile("deploy.go") if err != nil { t.Fatal(err) } body := string(src) - if !strings.Contains(body, `util.KindError(ErrNotEnoughMemory, refusal)`) { - t.Error("the memory refusal no longer carries ErrNotEnoughMemory — the API would answer 500 " + - "for a refusal the customer can act on, and translating the sentence would hide it completely") + if !strings.Contains(body, `util.MsgErrorf(ErrNotEnoughMemory, "err.stacks.not_enough_memory"`) { + t.Error("memoryVerdict no longer builds its refusal with ErrNotEnoughMemory and its key") } - if strings.Contains(body, `errors.New(refusal)`) { - t.Error("the memory refusal is back to a bare errors.New: its kind is gone (R-553)") + if strings.Contains(body, `errors.New(refusal)`) || strings.Contains(body, `fmt.Errorf(refusal)`) { + t.Error("the memory refusal is back to a bare error: its kind is gone (R-553)") } } diff --git a/controller/internal/stacks/samba.go b/controller/internal/stacks/samba.go index 4a74766..225a42f 100644 --- a/controller/internal/stacks/samba.go +++ b/controller/internal/stacks/samba.go @@ -2,6 +2,7 @@ package stacks import ( "fmt" + "gitea.dooplex.hu/admin/felhom-controller/internal/util" "net" "os" "os/exec" @@ -249,11 +250,11 @@ func (m *Manager) SetSMBPassword(password string) error { m.infraMu.Lock() defer m.infraMu.Unlock() if m.settings == nil { - return fmt.Errorf("a beállítások nem érhetők el") + return util.MsgError("err.stacks.a_beallitasok_nem_erhetok_el") } smb := m.settings.GetSMBSettings() if !smb.Enabled { - return fmt.Errorf("a hálózati megosztás nincs bekapcsolva") + return util.MsgError("err.stacks.a_halozati_megosztas_nincs_bekapcsolva") } dir := m.sambaDir() data := m.sambaRenderData(smb) @@ -264,7 +265,7 @@ func (m *Manager) SetSMBPassword(password string) error { // safe: `security = user` + `map to guest = never` means nothing is reachable until this lands. if !m.sambaIsRunning() { if err := m.sambaUp(dir); err != nil { - return fmt.Errorf("a megosztás szolgáltatás indítása sikertelen: %w", err) + return util.MsgError("err.stacks.a_megosztas_szolgaltatas_inditasa_sikertelen", err) } } if err := m.sambaSetPassword(password); err != nil { @@ -310,7 +311,7 @@ func (m *Manager) DisableSamba() error { return nil // never deployed — nothing to stop } if _, err := m.composeExec(dir, "down"); err != nil { - return fmt.Errorf("a megosztás leállítása sikertelen: %w", err) + return util.MsgError("err.stacks.a_megosztas_leallitasa_sikertelen", err) } m.logger.Printf("[INFO] [samba] stack stopped (passdb volume and all shared folders kept)") return nil diff --git a/controller/internal/stacks/update.go b/controller/internal/stacks/update.go index d439e04..d4d12c7 100644 --- a/controller/internal/stacks/update.go +++ b/controller/internal/stacks/update.go @@ -257,9 +257,21 @@ func fillHoldReason(g UpdateGuards, st *Stack) { type UpdateRefusal struct { Reason string Message string + // Cause carries the refusal as an ERROR when the producer made one (v0.253.0, R-557). A + // util.MsgError there knows its bundle key, so `api.Router.errText` renders the refusal in the + // household's language; Message stays the Hungarian fallback for every refusal built from a + // literal. Unwrap is what lets errors.Is and util.AsMsg see through this wrapper. + Cause error } -func (r *UpdateRefusal) Error() string { return r.Message } +func (r *UpdateRefusal) Error() string { + if r.Cause != nil { + return r.Cause.Error() + } + return r.Message +} + +func (r *UpdateRefusal) Unwrap() error { return r.Cause } func (m *Manager) now() time.Time { if m.updateNowFn != nil { @@ -273,6 +285,13 @@ func (m *Manager) refuseUpdate(name, reason, msg, detail string) *UpdateRefusal return &UpdateRefusal{Reason: reason, Message: msg} } +// refuseUpdateErr is refuseUpdate for a refusal the producer already built as an error — it keeps the +// error whole, so its kind and its message key both survive to the API. +func (m *Manager) refuseUpdateErr(name, reason string, cause error, detail string) *UpdateRefusal { + m.logger.Printf("[ERROR] [stacks] update %s REFUSED (%s): %s", name, reason, detail) + return &UpdateRefusal{Reason: reason, Message: cause.Error(), Cause: cause} +} + // UpdatePreflight runs every CHEAP refusal (slice 4 Part 1 + the precondition's existence), in order, // and returns the first. Nothing is moved and nothing is recorded by it. The router calls it before // recording the customer's intent, so an update that was never going to happen records nothing. @@ -350,8 +369,8 @@ func (m *Manager) updateMemoryRefusal(name string, st *Stack) *UpdateRefusal { if verdict == nil { verdict = m.memoryVerdict } - if refusal, _ := verdict(newReq, newLim, oldReq, oldLim); refusal != "" { - return m.refuseUpdate(name, "memory", refusal, fmt.Sprintf("new_req=%dMB replacing %dMB does not fit", newReq, oldReq)) + if refusal, _ := verdict(newReq, newLim, oldReq, oldLim); refusal != nil { + return m.refuseUpdateErr(name, "memory", refusal, fmt.Sprintf("new_req=%dMB replacing %dMB does not fit", newReq, oldReq)) } return nil } diff --git a/controller/internal/stacks/update_test.go b/controller/internal/stacks/update_test.go index 0e7f884..9a97966 100644 --- a/controller/internal/stacks/update_test.go +++ b/controller/internal/stacks/update_test.go @@ -20,22 +20,22 @@ import ( var slice4T0 = time.Date(2026, 9, 13, 10, 0, 0, 0, time.UTC) type fakeGuards struct { - mu sync.Mutex - calls []string - held bool - holdWhy string - busy bool + mu sync.Mutex + calls []string + held bool + holdWhy string + busy bool // points are the copies the backup side holds, in tier order (R-475); pointsAfterBackup replaces // them when BackupNow succeeds (nil = the backup changed nothing). points []UpdateRestorePoint pointsAfterBackup []UpdateRestorePoint cannotBackUp bool - backupErr error - dumpErr error - holdErr error - holdRP UpdateRestorePoint - pinAtDump string - stackDir string + backupErr error + dumpErr error + holdErr error + holdRP UpdateRestorePoint + pinAtDump string + stackDir string } func (f *fakeGuards) note(c string) { f.mu.Lock(); f.calls = append(f.calls, c); f.mu.Unlock() } @@ -129,7 +129,7 @@ func newSlice4Manager(t *testing.T) (*Manager, string, *fakeGuards, *composeRec) m.updateGuards = g m.updateComposeFn = c.fn m.updateHealthFn = func(context.Context, string, time.Duration) (bool, string) { return true, "fake healthy" } - m.updateMemoryFn = func(int, int, int, int) (string, string) { return "", "" } + m.updateMemoryFn = func(int, int, int, int) (error, string) { return nil, "" } m.updateDiskFreeFn = func() (float64, bool) { return 50, true } m.updateNowFn = func() time.Time { return slice4T0 } // R-457: the SAME clock the age check reads m.execFn = func(string, ...string) (string, error) { return "", nil } @@ -338,8 +338,8 @@ func TestSlice4_D_CheapRefusals(t *testing.T) { if err := os.WriteFile(filepath.Join(catDir, ".felhom.yml"), []byte("resources:\n mem_request: 900M\n"), 0o644); err != nil { panic(err) } - m.updateMemoryFn = func(newReq, _, _, _ int) (string, string) { - return fmt.Sprintf("Nincs elég memória (%d MB)", newReq), "" + m.updateMemoryFn = func(newReq, _, _, _ int) (error, string) { + return fmt.Errorf("Nincs elég memória (%d MB)", newReq), "" } }, "memory", "Nincs elég memória (900 MB)"}, {"disk", func(m *Manager, _ *fakeGuards, _ string) { diff --git a/controller/internal/util/msgerr.go b/controller/internal/util/msgerr.go new file mode 100644 index 0000000..36578e8 --- /dev/null +++ b/controller/internal/util/msgerr.go @@ -0,0 +1,151 @@ +package util + +import ( + "errors" + "fmt" + "gitea.dooplex.hu/admin/felhom-controller/internal/i18n" +) + +// ── An error that carries the KEY of the sentence it is, not only the sentence ──────────────────── +// +// Localisation slice 2 release B (R-557). 179 Hungarian sentences in this product are built deep +// inside a package with `fmt.Errorf` and printed by whoever catches them — a page, a JSON answer, a +// flash. They cannot be translated where they are SHOWN, because by then they are a finished string; +// and they must not be translated where they are MADE, because that code has no request and no +// language. So the error carries its key and the display end renders it. +// +// return util.MsgErrorf(stacks.ErrRequiredField, "deploy.field_required", label) +// ... +// http.Error(w, s.errText(r, err), 400) // the household's language +// if errors.Is(err, stacks.ErrRequiredField) { … } // still true — R-553's signal is intact +// +// THREE PROPERTIES, each earned: +// +// 1. `Error()` RETURNS THE HUNGARIAN TEXT, byte for byte what `fmt.Errorf` produced. Every printer +// that has not been converted — a log line, a third-party wrapper, `%v` in a Printf — keeps +// printing exactly what it printed before. That is what makes converting 179 producers safe +// without converting all their printers in the same commit, and it is what the parity gate +// measures. +// 2. `errors.Is` STILL WORKS, for the kind AND for a wrapped cause. `Unwrap() []error` returns the +// kind first and then any error among the arguments, so `errors.Is(err, ErrRequiredField)` and +// `errors.Is(err, os.ErrNotExist)` are both answerable. The predecessor `KindErrorf` returned the +// kind alone and dropped the cause; this does not. +// 3. AN ERROR ARGUMENT IS RENDERED RECURSIVELY. `fmt.Errorf("formázás sikertelen: %w", err)` is a +// sentence wrapping a sentence. Passing the inner error as an ARGUMENT (not as pre-rendered text) +// lets `Text(lang, …)` translate the whole chain — the outer in the household's language and the +// inner too, when the inner also carries a key. An inner error with no key prints itself, which +// is the right answer for a restic or docker message that is not ours to translate. + +// MsgErrorf builds an error whose message is the bundle's `key` filled with `args`. +// +// `kind` may be nil. When it is not, it is the sentinel `errors.Is` tests — the same contract +// KindErrorf established (R-553), and the reason a converted producer does not break a decision. +func MsgErrorf(kind error, key string, args ...interface{}) error { + return &msgError{kind: kind, key: key, args: args} +} + +// MsgError is MsgErrorf with no kind, for a producer nothing branches on. +func MsgError(key string, args ...interface{}) error { + return &msgError{key: key, args: args} +} + +type msgError struct { + kind error + key string + args []interface{} +} + +// Error returns the Hungarian sentence — the default language, and the bytes the literal carried. +func (e *msgError) Error() string { return e.Text(i18n.Default) } + +// Text renders the message in lang, rendering any error argument that also carries a key. +func (e *msgError) Text(lang string) string { + b, err := i18n.Shared() + if err != nil { + return e.key + } + if len(e.args) == 0 { + return b.Msg(lang, e.key) + } + out := make([]interface{}, len(e.args)) + for i, a := range e.args { + if inner, ok := a.(error); ok { + out[i] = ErrText(lang, inner) + continue + } + out[i] = a + } + return b.Msgf(lang, e.key, out...) +} + +// Key returns the bundle key, so a caller can name it without rendering it. +func (e *msgError) Key() string { return e.key } + +// Args returns the message's parameters as strings, for a carrier that can only hold text — the +// `fa` parameters of a flash in a redirect URL. +// +// An ERROR argument is rendered in HUNGARIAN here, deliberately and with a known cost: the carrier is +// written by one request and read by another, so the writer cannot know the reader's language, and a +// nested foreign sentence (restic, docker) is not translatable anyway. The OUTER message still +// follows the reader's language, which is the sentence that carries the meaning. +func (e *msgError) Args() []string { + out := make([]string, 0, len(e.args)) + for _, a := range e.args { + if inner, ok := a.(error); ok { + out = append(out, ErrText(i18n.Default, inner)) + continue + } + out = append(out, fmt.Sprintf("%v", a)) + } + return out +} + +// Unwrap returns the kind FIRST and then every error among the arguments, so `errors.Is` answers for +// the decision signal and for the cause alike. +func (e *msgError) Unwrap() []error { + var out []error + if e.kind != nil { + out = append(out, e.kind) + } + for _, a := range e.args { + if inner, ok := a.(error); ok && inner != nil { + out = append(out, inner) + } + } + return out +} + +// Msg is the interface a message-carrying error satisfies. Declared so a caller can test for the +// capability rather than for this concrete type — the point is "does this error know its key", not +// "was it built by this constructor". +type Msg interface { + error + Text(lang string) string + Key() string + Args() []string +} + +// AsMsg reports whether err (or anything it wraps) carries a message key, and returns it. +func AsMsg(err error) (Msg, bool) { + var m Msg + if errors.As(err, &m) { + return m, true + } + return nil, false +} + +// ErrText is the one function every display end calls: the error's sentence in lang. +// +// An error that carries a key is rendered in lang. **Anything else is returned verbatim** — a restic +// message, a docker message, an ssh message, a Go stdlib error. That is not a gap: those sentences +// are not written here and are not translated here (10-localisation.md §9, the rule R-553 +// established). A customer seeing restic's own English is seeing what restic said. +func ErrText(lang string, err error) string { + if err == nil { + return "" + } + if m, ok := AsMsg(err); ok { + return m.Text(lang) + } + return err.Error() +} diff --git a/controller/internal/util/msgerr_test.go b/controller/internal/util/msgerr_test.go new file mode 100644 index 0000000..29d0656 --- /dev/null +++ b/controller/internal/util/msgerr_test.go @@ -0,0 +1,139 @@ +package util + +import ( + "errors" + "fmt" + "os" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/i18n" +) + +// Localisation slice 2 release B (R-557), scenarios S3 and its wrong case. +// +// A message-carrying error has to do three things at once, and each one is a different failure if it +// is missing: print the Hungarian it always printed (parity), still satisfy the decision a caller +// makes on it (R-553), and render in the reader's language at the display end (the point). + +// a real key from the bundle, with one parameter, so nothing here is a fixture of itself +const oneParamKey = "flash.offbox.config_invalid" + +var errTestKind = errors.New("test-kind") + +func TestMsgErrorKeepsKindAndHuText(t *testing.T) { + b, err := i18n.Load() + if err != nil { + t.Fatal(err) + } + e := MsgErrorf(errTestKind, oneParamKey, "a port nem szam") + + // 1. parity: Error() is the Hungarian sentence, byte for byte what fmt.Errorf produced. + want := fmt.Sprintf(b.Msg(i18n.Default, oneParamKey), "a port nem szam") + if e.Error() != want { + t.Errorf("Error() is not the Hungarian sentence\n got %q\n want %q", e.Error(), want) + } + // And an un-converted printer — a log line, a %v — sees exactly that. + if got := fmt.Sprintf("%v", e); got != want { + t.Errorf("%%v printed %q, want %q", got, want) + } + + // 2. the decision still reads: R-553's whole point. + if !errors.Is(e, errTestKind) { + t.Error("errors.Is no longer finds the kind — every status-code decision built on R-553 breaks") + } + if errors.Is(e, os.ErrNotExist) { + t.Error("errors.Is matched an unrelated sentinel") + } + + // 3. the display end renders in the reader's language. + if en := ErrText("en", e); en == want || en == "" { + t.Errorf("English render did not happen: %q", en) + } + if hu := ErrText(i18n.Default, e); hu != want { + t.Errorf("Hungarian render moved:\n got %q\n want %q", hu, want) + } +} + +// TestMsgErrorUnwrapsTheCauseToo — the predecessor (KindErrorf) returned the kind ALONE from Unwrap, +// so a wrapped cause was unreachable. A converted producer often wraps one (`%w` on an os or docker +// error), and a caller that tested for it would have silently stopped matching. +func TestMsgErrorUnwrapsTheCauseToo(t *testing.T) { + cause := fmt.Errorf("open /x: %w", os.ErrNotExist) + e := MsgErrorf(errTestKind, oneParamKey, cause) + + if !errors.Is(e, errTestKind) { + t.Error("the kind is unreachable") + } + if !errors.Is(e, os.ErrNotExist) { + t.Error("the wrapped CAUSE is unreachable — this is what KindErrorf lost") + } +} + +func TestErrTextFallsBackVerbatim(t *testing.T) { + // The wrong case in S3: an error from restic, docker, ssh or the stdlib is not ours to translate. + for _, e := range []error{ + errors.New("repository is already locked by PID 1234"), + fmt.Errorf("Error response from daemon: No such container: x"), + os.ErrPermission, + } { + for _, lang := range []string{"hu", "en"} { + if got := ErrText(lang, e); got != e.Error() { + t.Errorf("%s: a foreign error was rewritten\n got %q\n want %q", lang, got, e.Error()) + } + } + } + if got := ErrText("en", nil); got != "" { + t.Errorf("a nil error must render empty, got %q", got) + } +} + +// TestErrTextRendersAWrappedMessageErrorToo — a sentence wrapping a sentence. Both halves are ours, +// so BOTH follow the language; that only works because the inner error is passed as an ARGUMENT +// rather than as pre-rendered text. +func TestErrTextRendersAWrappedMessageErrorToo(t *testing.T) { + inner := MsgError("flash.offbox.app_missing") + outer := MsgError(oneParamKey, inner) + + hu, en := ErrText("hu", outer), ErrText("en", outer) + if hu == en { + t.Fatalf("the wrapped chain did not follow the language at all: %q", hu) + } + b, _ := i18n.Load() + if innerHU := b.Msg("hu", "flash.offbox.app_missing"); !contains(hu, innerHU) { + t.Errorf("the Hungarian render lost the inner sentence\n got %q\n want it to contain %q", hu, innerHU) + } + if innerEN := b.Msg("en", "flash.offbox.app_missing"); !contains(en, innerEN) { + t.Errorf("the English render kept the Hungarian inner sentence\n got %q\n want it to contain %q", en, innerEN) + } +} + +// TestAsMsgSeesThroughAFmtWrapper — a converted error that something else wrapped with `%w` still +// answers AsMsg, so a printer further out does not lose the key. +func TestAsMsgSeesThroughAFmtWrapper(t *testing.T) { + e := fmt.Errorf("context: %w", MsgError("flash.offbox.app_missing")) + m, ok := AsMsg(e) + if !ok { + t.Fatal("AsMsg lost the key through a fmt wrapper") + } + if m.Key() != "flash.offbox.app_missing" { + t.Errorf("wrong key: %q", m.Key()) + } + // The OUTER text is fmt's, and it is what a caller printing the whole chain sees — errText + // renders the innermost message it can find, which is the sentence a customer needs. Stated so + // nobody reads this as the outer text being dropped by accident: a `%w` wrapper that adds + // Hungarian of its own should itself be a MsgError, and release B converts those. + if ErrText("en", e) == e.Error() { + t.Error("the English render was identical to the Hungarian chain") + } +} + +func contains(s, sub string) bool { + return len(sub) > 0 && len(s) >= len(sub) && (func() bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false + })() +} diff --git a/controller/internal/web/agent_disk_handlers.go b/controller/internal/web/agent_disk_handlers.go index cc3ed77..e031f45 100644 --- a/controller/internal/web/agent_disk_handlers.go +++ b/controller/internal/web/agent_disk_handlers.go @@ -108,13 +108,13 @@ func writeDiskJSON(w http.ResponseWriter, status int, ok bool, errMsg string, da func (s *Server) agentDisksListHandler(w http.ResponseWriter, r *http.Request) { client, err := s.agentClient() if err != nil { - writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil) + writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil) return } resp, err := client.Disks(r.Context()) if err != nil { s.logger.Printf("[ERROR] [web] disk list via agent failed: %v", err) - writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil) + writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, err), nil) return } // Deterministic order: the agent's storage view iterates a Go map (unordered), so the list would @@ -141,13 +141,13 @@ func (s *Server) agentDisksListHandler(w http.ResponseWriter, r *http.Request) { func (s *Server) agentDiskCandidatesHandler(w http.ResponseWriter, r *http.Request) { client, err := s.agentClient() if err != nil { - writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil) + writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil) return } resp, err := client.ListCandidates(r.Context()) if err != nil { s.logger.Printf("[ERROR] [web] disk candidates via agent failed: %v", err) - writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil) + writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, err), nil) return } writeDiskJSON(w, http.StatusOK, true, "", mergeAttachCandidates(resp, s.attachableStores())) @@ -225,12 +225,12 @@ func (s *Server) agentDiskAssignHandler(w http.ResponseWriter, r *http.Request) } client, err := s.agentClient() if err != nil { - writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil) + writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil) return } if err := client.AssignDisk(r.Context(), req.UUID, req.Where, req.FSType, req.Options); err != nil { s.logger.Printf("[ERROR] [web] disk assign via agent failed: %v", err) - writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil) + writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, err), nil) return } writeDiskJSON(w, http.StatusOK, true, "", map[string]interface{}{ @@ -253,13 +253,13 @@ func (s *Server) agentDiskEjectHandler(w http.ResponseWriter, r *http.Request) { } client, err := s.agentClient() if err != nil { - writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil) + writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil) return } resp, err := client.EjectDisk(r.Context(), req.Where) if err != nil { s.logger.Printf("[ERROR] [web] disk eject via agent failed: %v", err) - writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil) + writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, err), nil) return } writeDiskJSON(w, http.StatusOK, true, "", resp) @@ -285,7 +285,7 @@ func (s *Server) agentDiskFormatHandler(w http.ResponseWriter, r *http.Request) } client, err := s.agentClient() if err != nil { - writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil) + writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil) return } resp, err := client.FormatDisk(r.Context(), req.Device, req.FSType, req.Confirmed, req.DurableID) @@ -301,7 +301,7 @@ func (s *Server) agentDiskFormatHandler(w http.ResponseWriter, r *http.Request) } if err != nil { s.logger.Printf("[ERROR] [web] disk format via agent failed: %v", err) - writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil) + writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, err), nil) return } writeDiskJSON(w, http.StatusOK, true, "", resp) diff --git a/controller/internal/web/agent_host_metrics_handler.go b/controller/internal/web/agent_host_metrics_handler.go index f513d25..3787ef1 100644 --- a/controller/internal/web/agent_host_metrics_handler.go +++ b/controller/internal/web/agent_host_metrics_handler.go @@ -28,13 +28,13 @@ func (s *Server) ServeHostMetricsAPI(w http.ResponseWriter, r *http.Request) { client, err := s.agentClient() if err != nil { // Unprovisioned guest / no local API configured — the UI shows "host metrics unavailable". - writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil) + writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil) return } resp, err := client.HostMetrics(r.Context()) if err != nil { s.logger.Printf("[ERROR] [web] host metrics via agent failed: %v", err) - writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil) + writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, err), nil) return } // The agent enumerates storages via `pvesm` in a non-deterministic order, so #host-storage-bars diff --git a/controller/internal/web/alerts.go b/controller/internal/web/alerts.go index 12ce9a7..1657689 100644 --- a/controller/internal/web/alerts.go +++ b/controller/internal/web/alerts.go @@ -176,9 +176,9 @@ func buildDeadAppAlerts(dead []DeadApp) []Alert { if name == "" { name = d.Name } - key, args := "alert.deadapp.one", []interface{}{name} + key, args := "alert.deadapp.single", []interface{}{name} if d.State != "" { - key, args = "alert.deadapp.one_state", []interface{}{name, d.State} + key, args = "alert.deadapp.single_state", []interface{}{name, d.State} } alerts = append(alerts, Alert{ ID: "deadapp-" + simpleHash(d.Name), diff --git a/controller/internal/web/backup_handlers.go b/controller/internal/web/backup_handlers.go index 6e0f7e3..f27820f 100644 --- a/controller/internal/web/backup_handlers.go +++ b/controller/internal/web/backup_handlers.go @@ -307,7 +307,7 @@ func (s *Server) handleBackupTriggerAPI(w http.ResponseWriter, r *http.Request) return } s.logger.Printf("[ERROR] [web] backup trigger failed: %v", err) - writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil) + writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, err), nil) return } s.logger.Printf("[INFO] [web] manual whole-guest backup triggered (quiesce loop)") @@ -318,12 +318,12 @@ func (s *Server) handleBackupTriggerAPI(w http.ResponseWriter, r *http.Request) func (s *Server) handleBackupStatusAPI(w http.ResponseWriter, r *http.Request) { client, err := s.agentClient() if err != nil { - writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil) + writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil) return } st, err := client.BackupStatus(r.Context()) if err != nil { - writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil) + writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, err), nil) return } writeDiskJSON(w, http.StatusOK, true, "", map[string]any{ diff --git a/controller/internal/web/backup_page_state.go b/controller/internal/web/backup_page_state.go index 309752e..63cd91a 100644 --- a/controller/internal/web/backup_page_state.go +++ b/controller/internal/web/backup_page_state.go @@ -59,9 +59,11 @@ func tier3State(configured, toggled bool, escrowState string) string { // // It is pure, and it takes tier3State's OWN vocabulary rather than re-deriving the state, so the row // and the sentence can never disagree: -// "active" → the copy exists and runs → „védi" -// "escrow_pending" → enabled, paused for the code → „védené … szünetel" + the route to fix it -// "off"/"unconfig" → no off-site at all → say so, and name both ways out +// +// "active" → the copy exists and runs → „védi" +// "escrow_pending" → enabled, paused for the code → „védené … szünetel" + the route to fix it +// "off"/"unconfig" → no off-site at all → say so, and name both ways out +// // The link is returned separately so the template renders a real anchor and the copy gate sees plain // text; empty note means render nothing (an app whose data is in its volumes needs no sentence). func driveFilesNoteFor(hasDriveFileLegs bool, tier3State string, tier2Configured bool) (note, linkHref, linkText string) { diff --git a/controller/internal/web/backup_target_offer.go b/controller/internal/web/backup_target_offer.go index e1a8605..14ead12 100644 --- a/controller/internal/web/backup_target_offer.go +++ b/controller/internal/web/backup_target_offer.go @@ -3,7 +3,7 @@ package web import ( "context" "encoding/json" - "errors" + "gitea.dooplex.hu/admin/felhom-controller/internal/util" "net/http" "strings" @@ -11,8 +11,8 @@ import ( ) var ( - errAgentUnreadable = errors.New("a meghajtók listája nem olvasható") - errDriveNotFound = errors.New("a meghajtó nem található") + errAgentUnreadable = util.MsgError("err.web.a_meghajtok_listaja_nem_olvashato") + errDriveNotFound = util.MsgError("err.web.a_meghajto_nem_talalhato") ) // BackupTargetState is the customer-facing answer to "does the whole-system backup survive a disk @@ -283,13 +283,13 @@ func (s *Server) handleBackupTargetAssign(w http.ResponseWriter, r *http.Request // that register/attach already navigate. raw, err := s.rawMountForStable(r.Context(), agent, stable) if err != nil { - writeDiskJSON(w, http.StatusBadRequest, false, err.Error(), nil) + writeDiskJSON(w, http.StatusBadRequest, false, s.errText(r, err), nil) return } res, err := agent.SetBackupTarget(r.Context(), raw) if err != nil { s.logger.Printf("[WARN] [web] backup-target assign %s (raw %s): %v", stable, raw, err) - writeDiskJSON(w, http.StatusBadGateway, false, "a mentési cél beállítása nem sikerült: "+err.Error(), nil) + writeDiskJSON(w, http.StatusBadGateway, false, "a mentési cél beállítása nem sikerült: "+s.errText(r, err), nil) return } // Record the INTENT only after the agent accepted, so our flag can never claim a target the agent diff --git a/controller/internal/web/backups_split_test.go b/controller/internal/web/backups_split_test.go index 29bef99..6b14ebf 100644 --- a/controller/internal/web/backups_split_test.go +++ b/controller/internal/web/backups_split_test.go @@ -34,8 +34,8 @@ func splitTestData() map[string]interface{} { {App: "calibre-web", DisplayName: "Calibre-Web", InStore: true, Installed: true, Enabled: true}, }, "OffsiteStoreState": string(offsiteStoreKnown), - "OffboxQuotaPct": 0, - "GuestBackup": map[string]interface{}{"Available": false, "Note": "n/a"}, + "OffboxQuotaPct": 0, + "GuestBackup": map[string]interface{}{"Available": false, "Note": "n/a"}, } } diff --git a/controller/internal/web/claim_code_naming_test.go b/controller/internal/web/claim_code_naming_test.go index 7c9e2d7..3ea4374 100644 --- a/controller/internal/web/claim_code_naming_test.go +++ b/controller/internal/web/claim_code_naming_test.go @@ -55,11 +55,11 @@ func TestClaimPage_BothBranchesNameTheSameSecretTheSameWay(t *testing.T) { html := claimPageHTML(t, isReset) if strings.Contains(html, "isszaállító kód") { - t.Errorf("[%s branch] the retired name „Visszaállító kód" + + t.Errorf("[%s branch] the retired name „Visszaállító kód"+ "\" is still on the page — it collides with the escrow „Helyreállítási kód", branch) } if !strings.Contains(html, "eállító kód") { - t.Errorf("[%s branch] the page no longer names the secret „Beállító kód" + + t.Errorf("[%s branch] the page no longer names the secret „Beállító kód"+ "\" at all", branch) } } diff --git a/controller/internal/web/disk_health_test.go b/controller/internal/web/disk_health_test.go index eca6e16..25415ae 100644 --- a/controller/internal/web/disk_health_test.go +++ b/controller/internal/web/disk_health_test.go @@ -655,12 +655,12 @@ func TestDiskAlertDecision_Table(t *testing.T) { {"escalate Warn→Fail", &diskRecord{Verdict: int(agentapi.DiskVerdictWarn), Alerted: true, AlertedVerdict: int(agentapi.DiskVerdictWarn), AlertedAt: base}, agentapi.DiskVerdictFail, 8, true, false}, {"steady Fail, no growth, no time", alertedFail(352, time.Hour), agentapi.DiskVerdictFail, 352, false, false}, - {"exactly 2x but only 23h", alertedFail(64, 23 * time.Hour), agentapi.DiskVerdictFail, 128, false, false}, - {"exactly 2x at exactly 24h", alertedFail(64, 24 * time.Hour), agentapi.DiskVerdictFail, 128, true, true}, - {"25h but only 1.9x", alertedFail(64, 25 * time.Hour), agentapi.DiskVerdictFail, 121, false, false}, - {"improved Fail→Warn is silent", alertedFail(352, 48 * time.Hour), agentapi.DiskVerdictWarn, 8, false, false}, + {"exactly 2x but only 23h", alertedFail(64, 23*time.Hour), agentapi.DiskVerdictFail, 128, false, false}, + {"exactly 2x at exactly 24h", alertedFail(64, 24*time.Hour), agentapi.DiskVerdictFail, 128, true, true}, + {"25h but only 1.9x", alertedFail(64, 25*time.Hour), agentapi.DiskVerdictFail, 121, false, false}, + {"improved Fail→Warn is silent", alertedFail(352, 48*time.Hour), agentapi.DiskVerdictWarn, 8, false, false}, {"Fail from heat (0 sectors) never re-alerts on the doubling rule", - alertedFail(0, 48 * time.Hour), agentapi.DiskVerdictFail, 0, false, false}, + alertedFail(0, 48*time.Hour), agentapi.DiskVerdictFail, 0, false, false}, } for _, c := range cases { emit, worsened := diskAlertDecision(c.prev, c.v, c.sectors, base) diff --git a/controller/internal/web/handler_debug.go b/controller/internal/web/handler_debug.go index af13a98..9bb6bb1 100644 --- a/controller/internal/web/handler_debug.go +++ b/controller/internal/web/handler_debug.go @@ -385,7 +385,7 @@ func (s *Server) debugTestEvent(w http.ResponseWriter, r *http.Request) { statusCode, err := s.notifier.PushTestEventSync(req.EventType, req.Severity, fmt.Sprintf("Teszt esemény: %s (%s)", req.EventType, req.Severity)) if err != nil { - writeDebugJSON(w, http.StatusOK, false, err.Error(), map[string]interface{}{ + writeDebugJSON(w, http.StatusOK, false, s.errText(r, err), map[string]interface{}{ "hub_status": statusCode, }) return @@ -579,7 +579,7 @@ func (s *Server) debugHubPush(w http.ResponseWriter, r *http.Request) { err := s.debugCallbacks.TriggerHubReportPush() latency := time.Since(start).Milliseconds() if err != nil { - writeDebugJSON(w, http.StatusOK, false, err.Error(), map[string]interface{}{"latency_ms": latency}) + writeDebugJSON(w, http.StatusOK, false, s.errText(r, err), map[string]interface{}{"latency_ms": latency}) return } writeDebugJSON(w, http.StatusOK, true, "Hub jelentés elküldve", @@ -597,7 +597,7 @@ func (s *Server) debugHubConnectivity(w http.ResponseWriter, r *http.Request) { "latency_ms": latency, } if err != nil { - writeDebugJSON(w, http.StatusOK, false, err.Error(), data) + writeDebugJSON(w, http.StatusOK, false, s.errText(r, err), data) return } writeDebugJSON(w, http.StatusOK, true, @@ -611,7 +611,7 @@ func (s *Server) debugPreferencesSync(w http.ResponseWriter, r *http.Request) { } prefs := s.settings.GetNotificationPrefs() if err := s.notifier.SyncPreferences(prefs.Email, prefs.EnabledEvents, prefs.CooldownHours); err != nil { - writeDebugJSON(w, http.StatusOK, false, err.Error(), nil) + writeDebugJSON(w, http.StatusOK, false, s.errText(r, err), nil) return } writeDebugJSON(w, http.StatusOK, true, "Preferenciák szinkronizálva", nil) @@ -628,7 +628,7 @@ func (s *Server) debugGiteaConnectivity(w http.ResponseWriter, r *http.Request) "latency_ms": latency, } if err != nil { - writeDebugJSON(w, http.StatusOK, false, err.Error(), data) + writeDebugJSON(w, http.StatusOK, false, s.errText(r, err), data) return } writeDebugJSON(w, http.StatusOK, true, @@ -646,7 +646,7 @@ func (s *Server) debugTelemetry(w http.ResponseWriter, r *http.Request) { telemetry, err := s.debugCallbacks.GetTelemetryPreview() latency := time.Since(start).Milliseconds() if err != nil { - writeDebugJSON(w, http.StatusOK, false, err.Error(), map[string]interface{}{"latency_ms": latency}) + writeDebugJSON(w, http.StatusOK, false, s.errText(r, err), map[string]interface{}{"latency_ms": latency}) return } @@ -776,7 +776,7 @@ func (s *Server) debugAgentLogs(w http.ResponseWriter, r *http.Request) { }) return } - writeDebugJSON(w, http.StatusOK, false, err.Error(), nil) + writeDebugJSON(w, http.StatusOK, false, s.errText(r, err), nil) return } writeDebugJSON(w, http.StatusOK, true, "", map[string]interface{}{ diff --git a/controller/internal/web/handler_export.go b/controller/internal/web/handler_export.go index 2204cb8..83ceaff 100644 --- a/controller/internal/web/handler_export.go +++ b/controller/internal/web/handler_export.go @@ -152,7 +152,7 @@ func (s *Server) apiExportEstimate(w http.ResponseWriter, r *http.Request) { if err != nil { s.logger.Printf("[ERROR] [web] Export estimate failed for %s: %v", stackName, err) s.logger.Printf("[DEBUG] [web] apiExportEstimate error: %v", err) - jsonError(w, err.Error(), http.StatusInternalServerError) + jsonError(w, s.errText(r, err), http.StatusInternalServerError) return } @@ -215,7 +215,7 @@ func (s *Server) apiExportStart(w http.ResponseWriter, r *http.Request) { if err != nil { s.logger.Printf("[ERROR] [web] Export start failed for %s: %v", req.StackName, err) s.logger.Printf("[DEBUG] [web] apiExportStart error: %v", err) - jsonError(w, err.Error(), http.StatusConflict) + jsonError(w, s.errText(r, err), http.StatusConflict) return } @@ -311,7 +311,7 @@ func (s *Server) apiExportManifest(w http.ResponseWriter, r *http.Request) { if err != nil { s.logger.Printf("[DEBUG] [web] apiExportManifest: error: %v", err) - jsonError(w, err.Error(), http.StatusBadRequest) + jsonError(w, s.errText(r, err), http.StatusBadRequest) return } @@ -360,7 +360,7 @@ func (s *Server) apiImportStart(w http.ResponseWriter, r *http.Request) { if err != nil { s.logger.Printf("[ERROR] [web] Import start failed for %s: %v", req.Path, err) s.logger.Printf("[DEBUG] [web] apiImportStart error: %v", err) - jsonError(w, err.Error(), http.StatusConflict) + jsonError(w, s.errText(r, err), http.StatusConflict) return } diff --git a/controller/internal/web/handler_export_download.go b/controller/internal/web/handler_export_download.go index 04c5980..f2bfa30 100644 --- a/controller/internal/web/handler_export_download.go +++ b/controller/internal/web/handler_export_download.go @@ -58,13 +58,13 @@ func (s *Server) apiExportDownloadEstimate(w http.ResponseWriter, r *http.Reques return } if err := os.MkdirAll(s.fabDownloadDir(), 0755); err != nil { - jsonError(w, err.Error(), http.StatusInternalServerError) + jsonError(w, s.errText(r, err), http.StatusInternalServerError) return } est, err := s.appExporter.EstimateExport(stackName, s.fabDownloadRoot()) if err != nil { s.logger.Printf("[ERROR] [web] download-export estimate failed for %s: %v", stackName, err) - jsonError(w, err.Error(), http.StatusInternalServerError) + jsonError(w, s.errText(r, err), http.StatusInternalServerError) return } jsonResponse(w, map[string]interface{}{"ok": true, "data": est}) @@ -95,7 +95,7 @@ func (s *Server) apiExportDownloadStart(w http.ResponseWriter, r *http.Request) // Opportunistic TTL sweep so an abandoned bundle never outlives fabDownloadTTL by much. sweepFabDownloads(s.fabDownloadDir(), time.Now(), fabDownloadTTL, s.logger) if err := os.MkdirAll(s.fabDownloadDir(), 0755); err != nil { - jsonError(w, err.Error(), http.StatusInternalServerError) + jsonError(w, s.errText(r, err), http.StatusInternalServerError) return } // A concurrent export/import gets the exporter's own busy answer (single-flight). @@ -108,7 +108,7 @@ func (s *Server) apiExportDownloadStart(w http.ResponseWriter, r *http.Request) OptInExcluded: req.OptInExcluded, }); err != nil { s.logger.Printf("[ERROR] [web] download-export start failed for %s: %v", req.StackName, err) - jsonError(w, err.Error(), http.StatusConflict) + jsonError(w, s.errText(r, err), http.StatusConflict) return } s.logger.Printf("[INFO] [web] download-export started for %s (staging dir)", req.StackName) diff --git a/controller/internal/web/i18n_cases_b_test.go b/controller/internal/web/i18n_cases_b_test.go index 28b67e5..1661561 100644 --- a/controller/internal/web/i18n_cases_b_test.go +++ b/controller/internal/web/i18n_cases_b_test.go @@ -15,7 +15,7 @@ type m = map[string]interface{} func i18nBackupStatus(running bool, withResults bool) m { at := i18nFixtureTime() b := m{"DBDumpSchedule": "02:30", "NextDBDump": at, "Running": running, - "AppDataInfo": []m{{"StackName": "privatebin"}}, + "AppDataInfo": []m{{"StackName": "privatebin"}}, "DiscoveredDBs": []m{{"StackName": "kimai"}}, } if withResults { diff --git a/controller/internal/web/i18n_flash_test.go b/controller/internal/web/i18n_flash_test.go index f3a78fe..5fbebc5 100644 --- a/controller/internal/web/i18n_flash_test.go +++ b/controller/internal/web/i18n_flash_test.go @@ -4,6 +4,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "strconv" "strings" "testing" @@ -163,3 +164,56 @@ func flashSentence(t *testing.T, location string) string { } return "" } + +// TestPluralEnglish — localisation slice 2 release B (R-557), scenario S4. +// +// English needs two forms where the noun or the verb changes with the count; Hungarian does not +// inflect after a numeral and therefore has ONE form, which is the form it already had. So this test +// asserts two different things about the same message: that English changes with the count, and that +// Hungarian does NOT — the second is the parity half, and it is the one that would fail silently. +// +// It goes through the real producer (buildDeadAppAlerts → GetAlerts), not through the bundle, so it +// also pins that the alert path carries the count as its first parameter, which is what the plural +// rule in i18n.Bundle.form depends on. +func TestPluralEnglish(t *testing.T) { + dead := func(n int) []DeadApp { + out := make([]DeadApp, 0, n) + for i := 0; i < n; i++ { + out = append(out, DeadApp{Name: "app" + strconv.Itoa(i), DisplayName: "App " + strconv.Itoa(i)}) + } + return out + } + // Above deadAppGroupThreshold the banner collapses to ONE counted sentence — that is the message + // with a count in it. 4 and 7 are both "other"; there is no "one" case for the grouped banner by + // construction, so the singular is checked on the bundle directly below. + am := NewAlertManager(nil) + am.SetDeadAppAlerts(dead(4)) + hu4 := am.GetAlerts("hu")[0].Message + en4 := am.GetAlerts("en")[0].Message + am.SetDeadAppAlerts(dead(7)) + hu7 := am.GetAlerts("hu")[0].Message + en7 := am.GetAlerts("en")[0].Message + + if hu4 != "4 telepített alkalmazás nem fut — nézze meg a rendszermonitort" { + t.Errorf("the Hungarian sentence moved: %q", hu4) + } + if hu7 != "7 telepített alkalmazás nem fut — nézze meg a rendszermonitort" { + t.Errorf("the Hungarian sentence moved: %q", hu7) + } + if !strings.Contains(en4, "4 installed apps are not running") { + t.Errorf("English plural: %q", en4) + } + if !strings.Contains(en7, "7 installed apps are not running") { + t.Errorf("English plural: %q", en7) + } + + // The singular, straight off the bundle: same key, count 1. + b := flashServer(t).i18n + if got := b.Msgf("en", "alert.deadapp.group", 1); !strings.Contains(got, "1 installed app is not running") { + t.Errorf("English singular: %q", got) + } + // And Hungarian at 1 is the SAME single form — no .one, no change. + if got := b.Msgf("hu", "alert.deadapp.group", 1); got != "1 telepített alkalmazás nem fut — nézze meg a rendszermonitort" { + t.Errorf("Hungarian must have one form at every count: %q", got) + } +} diff --git a/controller/internal/web/i18n_web.go b/controller/internal/web/i18n_web.go index eb1a9d5..e6a2644 100644 --- a/controller/internal/web/i18n_web.go +++ b/controller/internal/web/i18n_web.go @@ -10,6 +10,7 @@ import ( "gitea.dooplex.hu/admin/felhom-controller/internal/i18n" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" + "gitea.dooplex.hu/admin/felhom-controller/internal/util" ) // ── i18n (v0.247.0) — the dashboard in more than one language ────────────────────────────────── @@ -80,6 +81,27 @@ func (s *Server) msgN(r *http.Request, key string, n int) string { return b.Plural(s.langFor(r), key, n) } +// ── Errors on a page (v0.253.0, slice 2 release B) ───────────────────────────────────────────── +// +// An error is MADE deep in a package that has no request, and PRINTED by a handler that has one. +// util.MsgError carries the key across that gap; errText is the handler side of it. +// +// A plain error — restic, docker, ssh, the Go stdlib — prints verbatim, in both languages, because +// that sentence is not ours (10-localisation.md §9). So this is safe to put in front of EVERY +// err.Error() on a display path, converted or not, and that is exactly what release B does. + +// errText renders an error in the request's language: its bundle message when it carries one, its own +// text otherwise. +func (s *Server) errText(r *http.Request, err error) string { + return util.ErrText(s.langFor(r), err) +} + +// errTextLang is errText for a language already resolved (a background run, or a handler that +// resolved it once). +func (s *Server) errTextLang(lang string, err error) string { + return util.ErrText(lang, err) +} + // ── Flash lines (v0.252.0) ───────────────────────────────────────────────────────────────────── // // A flash travels to the page INSIDE THE REDIRECT URL (`?flash=…`), so it is rendered by a DIFFERENT @@ -93,6 +115,21 @@ func (s *Server) msgN(r *http.Request, key string, n int) string { // shown verbatim, exactly as it was before. That also covers a hand-typed `?flash=