From e3bb775ac5da296784436590865f6de9bdc3a73b Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sat, 17 Oct 2026 12:49:34 +0200 Subject: [PATCH] FileBrowser is published only after its default login is checked (R-1126); the memory refusal says what to do (R-1125) R-1126 (D11 = A): FileBrowser starts off traefik-public, on its own network felhom-filebrowser; the controller joins it there, checks this run's default login, and only then puts FileBrowser on traefik-public (files. served). FileBrowser's own admin-password setting re-applies at every start (1.5.6 cmd/user.go:50-57, measured on 9202), so it cannot carry the box's password without overwriting the household's own. Tests red-proved. R-1125 (D10 = A): err.stacks.not_enough_memory says the memory is held by the installed apps and to remove one or use a machine with more memory (HU + EN, reworded on purpose). TestR1125_NotEnoughMemorySaysWhatToDo, red-proved. Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- controller/README.md | 6 +- controller/internal/i18n/locales/en.json | 2 +- controller/internal/i18n/locales/hu.json | 2 +- controller/internal/infra/infra.go | 16 ++- controller/internal/infra/infra_test.go | 19 ++++ .../internal/stacks/filebrowser_gate.go | 104 ++++++++++++++++++ .../stacks/filebrowser_password_test.go | 52 +++++++++ controller/internal/stacks/infra.go | 6 +- controller/internal/stacks/manager.go | 3 + .../stacks/r553_deploy_error_kinds_test.go | 27 ++++- controller/internal/web/handlers.go | 5 + controller/scripts/i18n_go_keys.json | 6 +- 12 files changed, 231 insertions(+), 17 deletions(-) create mode 100644 controller/internal/stacks/filebrowser_gate.go diff --git a/controller/README.md b/controller/README.md index c003b19..2343884 100644 --- a/controller/README.md +++ b/controller/README.md @@ -876,6 +876,10 @@ Each app can define rich metadata in `.felhom.yml`: - `app_info`: tagline, use_cases, first_steps, prerequisites, default_creds, docs_url, add_people, `guide` (v0.315.0 — `checked_version`, `sections[]`, `faq[]`; see „The catalog's user guide") - `optional_config`: groups of post-deploy configurable env vars (e.g., API keys for metadata providers) - `resources`: mem_request, mem_limit, pi_compatible, needs_hdd, hungarian_ui + — `mem_request` feeds the install check (`stacks.memoryVerdict`): installed apps' requests + the new one > guest + memory − `reserved_memory_mb` (384) → refused, `err.stacks.not_enough_memory` (v0.327.0, R-1125: says the memory is held + by the installed apps, and to remove one or use a machine with more memory). Since catalog 2026-10-17 the requests are + the measured typical use, so an 8 GB box no longer takes the whole household set. - `initial_credentials` (v0.84.0): for apps that **auto-generate a first-login password into a file** at first boot (vs taking it from a deploy field) — e.g. Crafty → `/crafty/app/config/default-creds.txt`. Fields: `file` (path inside the container), `format` (`json`|`regex`|`plain`), optional `container` @@ -1089,7 +1093,7 @@ cloudflared is only deployed when a tunnel token is configured. **Triggers**: a > **Mount prerequisite (Section-G):** the controller writes these stacks under `/opt/docker/stacks` *inside its container*, but `docker compose up` runs on the **guest** Docker daemon. The golden's controller-bootstrap (`felhom-agent` `build-golden.sh`) therefore bind-mounts that path **same-path** (`-v /opt/docker/stacks:/opt/docker/stacks`) so the daemon resolves every relative bind source — without it, all bind-mounted stacks (base infra and customer apps) silently break. -**FileBrowser admin password (v0.243.0, R-513).** FileBrowser used to accept `admin`/`admin` on every box. After `ensureFileBrowser`, every tick until decided, `Manager.EnsureFileBrowserAdminPassword` (`internal/stacks/filebrowser_password.go`) probes `http://filebrowser:80/api/auth/login` with admin/admin — **once per FileBrowser run since v0.326.0 (R-977)**: the probe repeats whenever FileBrowser's container StartedAt changes, so a re-created data volume that starts on the default again is reset at its first tick; a run on any other password changes nothing: **200** → a generated `password:16` is set through FileBrowser's API (never the `auth.adminPassword` config key — measured to overwrite a hand-set password on every start), verified both ways, and stored AES-encrypted in `settings.json` (`filebrowser_admin_state: generated`); **401** → `operator`, never touched. The FileBrowser app page shows user `admin` and the password behind the R-254 reveal (`POST /apps/filebrowser/initial-credentials/reveal`), or — `operator` — „Ezt a jelszót te állítottad be…" with „Új jelszó beállítása" (v0.320.0, the file browser's CLI with the server stopped; the `auth.adminPassword` key / `FILEBROWSER_ADMIN_PASSWORD` env re-apply on every start and are still never used). **v0.321.0 (R-1101):** in `generated`, the page first asks FileBrowser whether the stored password still logs in (`FileBrowserStoredPasswordRefused`; only a 401/403 counts); refused → „A Fájlkezelőben megváltoztattad a jelszót…" and the same press. The rendered config switches off FileBrowser's own update notice (`server.disableUpdateCheck`) and its „Install app" prompt (`frontend.disablePWAInstall`). +**FileBrowser admin password (v0.243.0, R-513).** FileBrowser used to accept `admin`/`admin` on every box. After `ensureFileBrowser`, every tick until decided, `Manager.EnsureFileBrowserAdminPassword` (`internal/stacks/filebrowser_password.go`) **v0.327.0 (R-1126, D11 = A): FileBrowser starts OFF `traefik-public`** — on its own network `felhom-filebrowser`, which the controller joins — and is put on `traefik-public` (so `files.` is served) only after the check below has passed for THIS run (`stacks.ensureFileBrowserPublic`; also run right after `web.SyncFileBrowserMounts` re-creates it). FileBrowser's own admin-password setting cannot replace this: it re-applies at every start (1.5.6 `cmd/user.go:50-57`, measured), which would overwrite the household's own password. The check probes `http://filebrowser:80/api/auth/login` with admin/admin — **once per FileBrowser run since v0.326.0 (R-977)**: the probe repeats whenever FileBrowser's container StartedAt changes, so a re-created data volume that starts on the default again is reset at its first tick; a run on any other password changes nothing: **200** → a generated `password:16` is set through FileBrowser's API (never the `auth.adminPassword` config key — measured to overwrite a hand-set password on every start), verified both ways, and stored AES-encrypted in `settings.json` (`filebrowser_admin_state: generated`); **401** → `operator`, never touched. The FileBrowser app page shows user `admin` and the password behind the R-254 reveal (`POST /apps/filebrowser/initial-credentials/reveal`), or — `operator` — „Ezt a jelszót te állítottad be…" with „Új jelszó beállítása" (v0.320.0, the file browser's CLI with the server stopped; the `auth.adminPassword` key / `FILEBROWSER_ADMIN_PASSWORD` env re-apply on every start and are still never used). **v0.321.0 (R-1101):** in `generated`, the page first asks FileBrowser whether the stored password still logs in (`FileBrowserStoredPasswordRefused`; only a 401/403 counts); refused → „A Fájlkezelőben megváltoztattad a jelszót…" and the same press. The rendered config switches off FileBrowser's own update notice (`server.disableUpdateCheck`) and its „Install app" prompt (`frontend.disablePWAInstall`). **Controller routing + the wildcard cert anchor (`wireController` → `RenderControllerRoute`, v0.41.1 / v0.42.1).** filebrowser self-registers with traefik via Docker labels + `traefik-public` membership baked into its compose; the controller can't (it's started by the golden bootstrap *before* `traefik-public` exists, and the v2 `bootstrap.json` carries no domain — that comes from the hub pull). So `EnsureBaseStack` wires the controller **post-pull**: it `docker network connect traefik-public felhom-controller` and writes a traefik file-provider route `dynamic/controller.yml` (`Host(felhom.) → http://felhom-controller:8080`, write-if-changed). When DNS-01 ACME is configured, that route is **also the wildcard-cert anchor**: its router-level `tls.domains: *.` makes traefik **proactively obtain the wildcard `*.` + apex via Cloudflare DNS-01 at startup** (an entrypoint-level `http.tls.domains` does *not* trigger issuance in traefik v3 — only a router-level `tls.domains` does). Every other router then serves that one real wildcard cert by SNI — no per-app `certresolver` labels. This is what lets a LAN client reach the box directly at `*.` with the real cert (the `felhom-agent` split-horizon resolver depends on it). diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index e7967cf..ae6066c 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -1412,7 +1412,7 @@ "err.stacks.migracio_mar_folyamatban": "a migration is already running", "err.stacks.migracios_naplo_irasa": "writing the migration log: %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.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.not_enough_memory": "There is not enough memory for this app next to the apps already installed: it needs %d MB, %d MB is free (%d MB in total, %d MB of it held by the installed apps, %d MB for the system). Remove an app you do not need, or use a machine with more memory.", "err.stacks.ugyfel_adatok_ellenorzese_sikertelen": "checking your own files failed: %s", "err.stacks.ugyfel_adatok_osszefesulese_sikertelen": "merging your own files failed: %s", "err.stacks.ujratelepites_sikertelen": "the reinstall failed (%s): %s", diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index 195a368..50ffeee 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -1403,7 +1403,7 @@ "err.stacks.migracio_mar_folyamatban": "migráció már folyamatban", "err.stacks.migracios_naplo_irasa": "migrációs napló írása: %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.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.not_enough_memory": "Nincs elég memória ehhez az alkalmazáshoz a már telepítettek mellett: kell %d MB, szabad %d MB (összesen %d MB, ebből %d MB-ot a telepített alkalmazások foglalnak, %d MB a rendszeré). Távolíts el egy alkalmazást, amelyre nincs szükséged, vagy használj több memóriájú gépet.", "err.stacks.ugyfel_adatok_ellenorzese_sikertelen": "ügyfél-adatok ellenőrzése sikertelen: %s", "err.stacks.ugyfel_adatok_osszefesulese_sikertelen": "ügyfél-adatok összefésülése sikertelen: %s", "err.stacks.ujratelepites_sikertelen": "újratelepítés sikertelen (%s): %s", diff --git a/controller/internal/infra/infra.go b/controller/internal/infra/infra.go index c0a5354..28ac063 100644 --- a/controller/internal/infra/infra.go +++ b/controller/internal/infra/infra.go @@ -188,6 +188,10 @@ func RenderCloudflared(d CloudflaredData) (map[string]FileSpec, error) { // RenderFileBrowserCompose returns FileBrowser's docker-compose.yml for the given domain and storage // volume-mount lines. Ported verbatim from internal/web/handlers.go (the single source of truth now // lives here so the pinned image can't diverge between bring-up and the web storage-sync path). +// FileBrowserNetwork is FileBrowser's own network (R-1126): the controller joins it to check the default login before +// FileBrowser is put on traefik-public. +const FileBrowserNetwork = "felhom-filebrowser" + func RenderFileBrowserCompose(domain string, storageMounts []string, groupAdd ...int) string { // R-691 (v0.275.0): supplementary groups — the owning groups of kept folders the view must READ (a // 0770 folder another user owns, e.g. nextcloud's www-data). Their binds are `:ro`. Absent → the @@ -225,8 +229,12 @@ services: volumes: - filebrowser_data:/home/filebrowser/data - ./config.yaml:/home/filebrowser/config.yaml:ro%s + # R-1126 (D11 = A): NOT on traefik-public at start. The box joins it to traefik-public only after it has checked + # THIS run's default login is refused (stacks.ensureFileBrowserPublic), so files. never answers with + # FileBrowser's default password — a re-created container always starts unreachable from outside. The box reaches + # it for that check over its own network. networks: - - traefik-public + - %s deploy: resources: limits: @@ -249,9 +257,9 @@ volumes: filebrowser_data: networks: - traefik-public: - external: true -`, domain, FileBrowserImage, groupSection, storageSection, domain) + %s: + name: %s +`, domain, FileBrowserImage, groupSection, storageSection, FileBrowserNetwork, domain, FileBrowserNetwork, FileBrowserNetwork) } // RenderControllerRoute returns a traefik file-provider dynamic config routing the controller's own diff --git a/controller/internal/infra/infra_test.go b/controller/internal/infra/infra_test.go index e643d06..94ddbc5 100644 --- a/controller/internal/infra/infra_test.go +++ b/controller/internal/infra/infra_test.go @@ -247,3 +247,22 @@ func TestFileBrowserRender(t *testing.T) { t.Errorf("filebrowser compose missing the umask-002 entrypoint wrapper: %q", compose) } } + +// R-1126 (D11 = A): FileBrowser starts OFF traefik-public — the box joins it there only after this run's default login +// is checked — and on its own network, which the controller joins for that check. The Traefik labels stay, so the route +// appears the moment the box joins it. RED-PROOF: the service back on traefik-public → this fails. +func TestFileBrowserRender_R1126_NotPublicAtStart(t *testing.T) { + compose := RenderFileBrowserCompose("demo-felhom.eu", nil) + svc := compose[strings.Index(compose, " filebrowser:"):strings.Index(compose, "\nvolumes:")] + nets := svc[strings.Index(svc, " networks:"):] + nets = nets[:strings.Index(nets, " deploy:")] + if strings.Contains(nets, "traefik-public") { + t.Fatalf("FileBrowser starts on traefik-public — it would be served before its default login is checked:\n%s", nets) + } + if !strings.Contains(nets, "- "+FileBrowserNetwork) || !strings.Contains(compose, "name: "+FileBrowserNetwork) { + t.Fatalf("FileBrowser must sit on %s (named), got:\n%s", FileBrowserNetwork, compose) + } + if !strings.Contains(compose, "traefik.docker.network=traefik-public") || !strings.Contains(compose, "Host(`files.demo-felhom.eu`)") { + t.Fatal("the Traefik labels must stay, so joining traefik-public is all that publishes it") + } +} diff --git a/controller/internal/stacks/filebrowser_gate.go b/controller/internal/stacks/filebrowser_gate.go new file mode 100644 index 0000000..ed4a900 --- /dev/null +++ b/controller/internal/stacks/filebrowser_gate.go @@ -0,0 +1,104 @@ +package stacks + +import ( + "strings" + + "gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec" + "gitea.dooplex.hu/admin/felhom-controller/internal/infra" +) + +// R-1126 (operator decision D11 = A, 2026-10-17): FileBrowser is never reachable from outside on its default password. +// +// WHY THIS SHAPE. The tunnel runs before the claim (the claim mail links https://felhom.), and files. is +// published through it. A new FileBrowser database starts on admin/admin. FileBrowser's own admin-password setting +// (config `auth.adminPassword` / env FILEBROWSER_ADMIN_PASSWORD) is RE-APPLIED AT EVERY START (gtstef/filebrowser +// 1.5.6-stable backend/cmd/user.go:50-57, called from cmd/root.go:144 each start) — measured on scratch 9202: a password +// the household set in FileBrowser was replaced at the next restart. So the box cannot hand FileBrowser its password up +// front without overwriting the household's own (R-513's reason, re-proven). Instead FileBrowser starts OFF the +// traefik-public network (infra.RenderFileBrowserCompose) and the box joins it there only after it has checked THIS run: +// the default login refused, or replaced and verified (EnsureFileBrowserAdminPassword sets fbProbedRun). A re-created +// container — the only way a new database appears — always starts unreachable from outside, whoever re-created it. +// Pinned by TestFileBrowserGate_*. + +// fbNetOn reports whether container is on network (test seam: fbNetOnFn; a test FileBrowser and no seam → false). +func (m *Manager) fbNetOn(container, network string) bool { + if m.fbNetOnFn != nil { + return m.fbNetOnFn(container, network) + } + if m.fbHTTP != nil || m.fbBaseURL != "" { + return false + } + return containerOnNetwork(container, network) +} + +// fbNetConnect attaches container to network (test seam: fbNetConnectFn; a test FileBrowser and no seam → no-op). +func (m *Manager) fbNetConnect(container, network string) error { + if m.fbNetConnectFn != nil { + return m.fbNetConnectFn(container, network) + } + if m.fbHTTP != nil || m.fbBaseURL != "" { + return nil + } + out, err := dockerexec.Command("docker", "network", "connect", network, container).CombinedOutput() + if err != nil && !strings.Contains(string(out), "already exists") { + return &netConnectError{out: strings.TrimSpace(string(out)), err: err} + } + return nil +} + +type netConnectError struct { + out string + err error +} + +func (e *netConnectError) Error() string { return e.out + ": " + e.err.Error() } + +// joinFileBrowserNetwork puts the controller on FileBrowser's own network, so the default-login check reaches it before +// FileBrowser is public. Missing network (FileBrowser not deployed yet) → nothing, the next round tries again. +func (m *Manager) joinFileBrowserNetwork() { + if m.fbNetOn(controllerContainer, infra.FileBrowserNetwork) { + return + } + if err := m.fbNetConnect(controllerContainer, infra.FileBrowserNetwork); err != nil { + if m.isDebug() { + m.logger.Printf("[DEBUG] [infra] filebrowser: controller not joined to %s yet: %v", infra.FileBrowserNetwork, err) + } + return + } + m.logger.Printf("[INFO] [infra] connected %s to %s (the FileBrowser default-login check, R-1126)", controllerContainer, infra.FileBrowserNetwork) +} + +// ensureFileBrowserPublic joins FileBrowser to traefik-public only when THIS run was checked and its default login is +// refused. Anything less — the run unknown, not checked yet, the check failed — leaves it unreachable from outside. +func (m *Manager) ensureFileBrowserPublic() { + run := m.fbStartedAt() + if run == "" || run != m.fbProbedRun { + return + } + if m.fbNetOn(fileBrowserContainer, traefikNetwork) { + return + } + if err := m.fbNetConnect(fileBrowserContainer, traefikNetwork); err != nil { + m.logger.Printf("[WARN] [infra] filebrowser: could not join %s (files. stays unreachable): %v", traefikNetwork, err) + return + } + m.logger.Printf("[INFO] [infra] filebrowser: default login refused for this run — joined %s, files. served (R-1126)", traefikNetwork) +} + +// EnsureFileBrowserGate is the whole check outside the base-stack round — called after web.SyncFileBrowserMounts +// re-creates FileBrowser, so a household's storage change does not leave the file browser offline until the next round. +func (m *Manager) EnsureFileBrowserGate() { + m.infraMu.Lock() + defer m.infraMu.Unlock() + m.fileBrowserGateLocked() +} + +// fileBrowserGateLocked runs join → check → publish. The caller holds infraMu. +func (m *Manager) fileBrowserGateLocked() { + m.joinFileBrowserNetwork() + if err := m.EnsureFileBrowserAdminPassword(); err != nil { + // R-513: retried every round until decided. Logged, never fatal to the base stack. + m.logger.Printf("[WARN] [infra] %v", err) + } + m.ensureFileBrowserPublic() +} diff --git a/controller/internal/stacks/filebrowser_password_test.go b/controller/internal/stacks/filebrowser_password_test.go index f643a12..6016291 100644 --- a/controller/internal/stacks/filebrowser_password_test.go +++ b/controller/internal/stacks/filebrowser_password_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "path/filepath" + "strings" "sync" "testing" @@ -244,3 +245,54 @@ func TestFileBrowserAdmin_R977_NewRunIsWaitedForInTheSameTick(t *testing.T) { t.Fatalf("state %q, want generated", s) } } + +// R-1126 (D11 = A): files. is published (FileBrowser joined to traefik-public) only AFTER this run's default +// login was checked; a check that cannot finish leaves it unpublished; a later run already published is not touched. +// RED-PROOF (2026-10-17): ensureFileBrowserPublic without the „this run was checked" test → the unreachable case fails. +func TestFileBrowserGate_PublishedOnlyAfterTheCheck(t *testing.T) { + var events []string + fb := &fakeFB{password: "admin"} + inner := fb.handler() + down := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if down { + w.WriteHeader(http.StatusBadGateway) + return + } + if r.Method == http.MethodPut { + events = append(events, "password replaced") + } + inner.ServeHTTP(w, r) + })) + defer srv.Close() + m, _, _ := fbManager(t, srv.URL) + run := "run-1" + m.fbStarted = func() string { return run } + nets := map[string]bool{} + m.fbNetOnFn = func(c, n string) bool { return nets[c+"@"+n] } + m.fbNetConnectFn = func(c, n string) error { + nets[c+"@"+n] = true + events = append(events, "connect "+c+" "+n) + return nil + } + + // A new run whose FileBrowser does not answer: nothing is published. + down = true + m.fileBrowserGateLocked() + if nets["filebrowser@traefik-public"] { + t.Fatal("published while this run's default login was never checked") + } + // It answers, on its default: replaced first, published after. + down = false + m.fileBrowserGateLocked() + want := []string{"connect felhom-controller felhom-filebrowser", "password replaced", "connect filebrowser traefik-public"} + if strings.Join(events, " | ") != strings.Join(want, " | ") { + t.Fatalf("order:\n got %v\n want %v", events, want) + } + // A new run (a restart) on the box's own password: checked, nothing changes, nothing connected twice. + run = "run-2" + m.fileBrowserGateLocked() + if len(events) != 3 { + t.Fatalf("a checked restart did more: %v", events) + } +} diff --git a/controller/internal/stacks/infra.go b/controller/internal/stacks/infra.go index 596cde9..cf12a1e 100644 --- a/controller/internal/stacks/infra.go +++ b/controller/internal/stacks/infra.go @@ -94,9 +94,9 @@ func (m *Manager) EnsureBaseStack() error { if err := m.ensureFileBrowser(filepath.Join(base, "filebrowser")); err != nil { errs = append(errs, fmt.Sprintf("filebrowser: %v", err)) - } else if err := m.EnsureFileBrowserAdminPassword(); err != nil { - // R-513: one-time; retried every tick until decided. Logged, never fatal to the base stack. - m.logger.Printf("[WARN] [infra] %v", err) + } else { + // R-513 + R-977 + R-1126: check this run's default login, then (only then) publish files.. + m.fileBrowserGateLocked() } // samba (LAN network-sharing, R-7) — conditional deploy, same shape as cloudflared: only when the diff --git a/controller/internal/stacks/manager.go b/controller/internal/stacks/manager.go index 5424a0c..55f2322 100644 --- a/controller/internal/stacks/manager.go +++ b/controller/internal/stacks/manager.go @@ -283,6 +283,9 @@ type Manager struct { // was last probed (R-977 — once per FileBrowser run). Touched only under infraMu (the base-stack tick). fbStarted func() string fbProbedRun string + // fbNetOnFn / fbNetConnectFn replace the docker network reads and connects of the R-1126 gate in tests. + fbNetOnFn func(container, network string) bool + fbNetConnectFn func(container, network string) error // fbSetPassword replaces the stop / CLI / start sequence of ResetFileBrowserAdminPassword in tests; nil in production. fbSetPassword func(ctx context.Context, password string) error // execFn replaces execCommand's process boundary in tests; nil in production. diff --git a/controller/internal/stacks/r553_deploy_error_kinds_test.go b/controller/internal/stacks/r553_deploy_error_kinds_test.go index 2bf8388..28b43ee 100644 --- a/controller/internal/stacks/r553_deploy_error_kinds_test.go +++ b/controller/internal/stacks/r553_deploy_error_kinds_test.go @@ -133,8 +133,10 @@ func TestR553_MemoryRefusalIsWrappedWithItsKind(t *testing.T) { 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)" + // R-1125 (v0.327.0): reworded — the memory is held by the installed apps, and the sentence says what to do. + const wantHU = "Nincs elég memória ehhez az alkalmazáshoz a már telepítettek mellett: kell 900 MB, szabad 100 MB " + + "(összesen 2048 MB, ebből 1436 MB-ot a telepített alkalmazások foglalnak, 512 MB a rendszeré). " + + "Távolíts el egy alkalmazást, amelyre nincs szükséged, vagy használj több memóriájú gépet." if e.Error() != wantHU { t.Errorf("the Hungarian refusal moved:\n got %q\nwant %q", e.Error(), wantHU) } @@ -154,3 +156,24 @@ func TestR553_MemoryRefusalIsWrappedWithItsKind(t *testing.T) { t.Error("the memory refusal is back to a bare error: its kind is gone (R-553)") } } + +// R-1125 (operator decision D10 = A): the install check counts the apps' real memory, so a household meets this refusal +// more often. It must say, in both languages, that the memory is held by the apps already installed and what to do. +// RED-PROOF (2026-10-17): the old sentence („Szükséges … Elérhető …", no advice) fails every check below. +func TestR1125_NotEnoughMemorySaysWhatToDo(t *testing.T) { + e := util.MsgErrorf(ErrNotEnoughMemory, "err.stacks.not_enough_memory", 600, 200, 3712, 3310, 384) + for _, c := range []struct { + lang string + want []string + }{ + {"hu", []string{"a már telepítettek mellett", "kell 600 MB", "szabad 200 MB", "Távolíts el egy alkalmazást", "több memóriájú gépet"}}, + {"en", []string{"next to the apps already installed", "needs 600 MB", "200 MB is free", "Remove an app", "a machine with more memory"}}, + } { + got := util.ErrText(c.lang, e) + for _, w := range c.want { + if !strings.Contains(got, w) { + t.Errorf("%s: the refusal does not say %q: %q", c.lang, w, got) + } + } + } +} diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 191a605..ef20414 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -3884,6 +3884,11 @@ func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) { s.logger.Printf("[ERROR] [web] Failed to bring up FileBrowser: %s — %v", string(out), err) } else if changed { s.logger.Printf("[INFO] [web] FileBrowser mounts synced (recreated) — %d storage path(s), config updated", len(paths)) + // R-1126: a re-created FileBrowser starts off traefik-public; check its default login and publish it now, not + // at the next base-stack round (in the background: the check may wait ~20 s for FileBrowser to answer). + if s.stackMgr != nil { + go s.stackMgr.EnsureFileBrowserGate() + } } else { s.logger.Printf("[INFO] [web] FileBrowser sync — no config/compose change, ensured running without recreate (%d storage path(s))", len(paths)) } diff --git a/controller/scripts/i18n_go_keys.json b/controller/scripts/i18n_go_keys.json index 16bf52b..e4e27f4 100644 --- a/controller/scripts/i18n_go_keys.json +++ b/controller/scripts/i18n_go_keys.json @@ -170,6 +170,7 @@ "err.backup.restore_drive_gone": "BORN AS A KEY (R-362) -- names the drive a restore could not reach instead of a raw permission error; a NEW sentence, never a Go literal. Pinned in both languages by TestR362_DetachedDriveIsNamed.", "backup.status.version_skip": "BORN AS A KEY (R-645, `09` decision 142) -- the backups-page line for an app the night backup skips because it is not running its pinned version; a NEW sentence, never a Go literal. Pinned in both languages by TestR645_VersionSkipSentence and TestR645_BackupRowSaysTheNightBackupSkipsIt.", "restore.refuse.files.second_drive_whole": "BORN AS A KEY (R-675) -- the unit-restore refusal names the second drive's WHOLE restore (decision 26); a NEW sentence, never a Go literal. Pinned in both languages by TestR675_RefusalNamesTheWholeCopy.", + "err.stacks.not_enough_memory": "R-1125 (v0.327.0, operator decision D10 = A) -- REWORDED on purpose: the install check now counts the apps' real memory, so a household meets this refusal more often; it now says the memory is held by the apps already installed and what to do (remove an app, or a machine with more memory). Byte parity with the base literal cannot hold. Pinned by TestR1125_NotEnoughMemorySaysWhatToDo.", "settings.pw_too_short": "R-983 (v0.324.0) -- REWORDED on purpose: the fixed „8\" became a %d verb carrying the claim's minimum (claim.go claimMinPassword), because the settings page now enforces that one minimum; byte parity with the base literal cannot hold. Pinned in both languages by TestR983_SettingsPasswordMinimumIsTheClaims.", "flash.login.password_changed": "R-516 item 12 -- REWORDED on purpose: the formal „Kérjük, jelentkezzen be\" became the product's te-form „Jelentkezz be\"; the row is about exactly these bytes, so byte parity with the base literal cannot hold. Pinned by TestR516_FormalFormsAreGone.", "flash.backup.window_invalid_time": "R-516 (2026-10-06) -- REWORDED on purpose: the formal („ön\") verb forms in this sentence became the product's te-form; the row is about exactly these bytes, so byte parity with the base literal cannot hold. Pinned by TestR516_WidenedFormalFormsAreGone.", @@ -1026,11 +1027,6 @@ ], "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."