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=