diff --git a/controller/internal/api/r839_hdd_subpath_test.go b/controller/internal/api/r839_hdd_subpath_test.go new file mode 100644 index 0000000..65c7b40 --- /dev/null +++ b/controller/internal/api/r839_hdd_subpath_test.go @@ -0,0 +1,67 @@ +package api + +import ( + "io" + "log" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// R-839: a deploy whose HDD_PATH names a folder INSIDE a registered drive is refused at the API, so +// the boot sweep can never again hold an app for asking a folder whether it is a mountpoint. Through +// the real deployStack handler. PATH is emptied so a regression that lets the deploy proceed can +// never reach a real docker (tests that reach real docker act on DooPlex). +func TestR839_DeployRefusesASubPathOfADrive(t *testing.T) { + t.Setenv("PATH", "") + lg := log.New(io.Discard, "", 0) + root := t.TempDir() + sett, err := settings.Load(filepath.Join(root, "settings.json"), lg) + if err != nil { + t.Fatal(err) + } + const drive = "/mnt/felhom-drives/scratch_hdd" + if err := sett.AddStoragePath(settings.StoragePath{Path: drive, Label: "HDD", Schedulable: true}); err != nil { + t.Fatal(err) + } + cfg := &config.Config{} + cfg.Paths.StacksDir = filepath.Join(root, "stacks") + cfg.Stacks.ComposeCommand = "docker compose" + if err := os.MkdirAll(cfg.Paths.StacksDir, 0o755); err != nil { + t.Fatal(err) + } + m, err := stacks.NewManager(cfg, lg) + if err != nil { + t.Fatal(err) + } + r := &Router{stackMgr: m, cfg: cfg, sett: sett, logger: lg} + r.classifyFSPath = func(string) string { return "" } + + deploy := func(hdd string) (int, string) { + w := httptest.NewRecorder() + body := `{"values":{"HDD_PATH":"` + hdd + `"}}` + r.deployStack(w, httptest.NewRequest(http.MethodPost, "/api/stacks/paperless-ngx/deploy", strings.NewReader(body)), "paperless-ngx") + return w.Code, w.Body.String() + } + + code, body := deploy(drive + "/userdata/paperless-ngx") + if code != http.StatusConflict || !strings.Contains(body, "nem egy mappa") || !strings.Contains(body, drive) { + t.Fatalf("R-839: a folder inside a drive must be refused naming the drive; got %d %s", code, body) + } + // Controls: the drive itself and an unregistered path are not this gate's business. + for _, ok := range []string{drive, drive + "/", "/srv/elsewhere"} { + if _, b := deploy(ok); strings.Contains(b, "nem egy mappa") { + t.Errorf("R-839: %q must not be refused as a folder inside a drive: %s", ok, b) + } + } + if d, in := sett.DriveOfSubPath(drive + "x/userdata"); in { + t.Errorf("a sibling path sharing the prefix (%s) is not inside the drive", d) + } +} diff --git a/controller/internal/api/router.go b/controller/internal/api/router.go index 6b2a21b..b5e0312 100644 --- a/controller/internal/api/router.go +++ b/controller/internal/api/router.go @@ -509,6 +509,14 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri return } + // R-839: HDD_PATH must name the drive itself, never a folder inside it — see + // settings.DriveOfSubPath. The deploy page only offers drive roots; this guards every other caller. + if drive, inside := r.sett.DriveOfSubPath(body.Values["HDD_PATH"]); inside { + r.logger.Printf("[WARN] [api] Deploy refused for %s: HDD_PATH %s is a folder inside the drive %s, not the drive (R-839)", name, body.Values["HDD_PATH"], drive) + writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.msg(req, "api.deploy.hdd_path_inside_drive", drive)}) + return + } + // `09` §3 decision 36: the app's drive folder already holds its old data → the household chooses. if handled := r.keptDataAtInstall(w, req, name, body.Values["HDD_PATH"], body.KeptData); handled { return diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index 95336ff..7cf9302 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -30,6 +30,7 @@ "api.config.applied": "The configuration is applied — the controller is restarting.", "api.config.unchanged": "The configuration is unchanged — no restart is needed.", "api.deploy.netstorage_unreachable": "The network storage you picked cannot be reached from the apps right now, so the install cannot start. Try again in a few minutes, or tell your operator.", + "api.deploy.hdd_path_inside_drive": "The data location of an app must be a drive, not a folder inside a drive. Choose this one: %s.", "api.deploy.no_space": "There is not enough free space to install: only %.0f GB is free, and the system keeps %.0f GB in reserve to protect the core services (controller, proxy). Free some space, or add more storage.", "api.deploy.not_installable": "This app cannot be installed right now.", "api.deploy.started": "The install has started – the card shows how it is going", diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index 381db6e..c830846 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -26,6 +26,7 @@ "api.config.applied": "Konfiguráció alkalmazva — a vezérlő újraindul.", "api.config.unchanged": "A konfiguráció változatlan — nincs szükség újraindításra.", "api.deploy.netstorage_unreachable": "A kiválasztott hálózati tárhely jelenleg nem érhető el az alkalmazások környezetéből — a telepítés nem indítható. Próbálja újra pár perc múlva, vagy jelezze az üzemeltetőnek.", + "api.deploy.hdd_path_inside_drive": "Az alkalmazás adatainak helye egy meghajtó lehet, nem egy mappa a meghajtón belül. Válaszd ezt: %s.", "api.deploy.no_space": "Nincs elég szabad tárhely a telepítéshez: csak %.0f GB szabad, és a rendszer %.0f GB tartalékot tart fenn az alapszolgáltatások (vezérlő, proxy) védelmében. Szabadítson fel helyet, vagy bővítse a tárhelyet.", "api.deploy.not_installable": "Ez az alkalmazás jelenleg nem telepíthető.", "api.deploy.started": "Telepítés elindítva – az állapot a kártyán követhető", diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go index f364205..d244fea 100644 --- a/controller/internal/settings/settings.go +++ b/controller/internal/settings/settings.go @@ -2119,6 +2119,29 @@ func (s *Settings) RefuseAsAppNamespace(path string) (bool, string) { return false, "" } +// DriveOfSubPath reports the registered storage path that `path` lies strictly INSIDE (R-839). An app's +// HDD_PATH names a drive root — the namespace root, the userdata dir and the backups tree are all +// derived from it — so a value one or more segments below a registered drive is never valid: the boot +// sweep's mountpoint check then asks about a folder rather than the drive, and HOLDS the app after +// every restart (measured on 9202: `/userdata/paperless-ngx`, written by a drill harness through +// the deploy API, which accepted it because RefuseAsAppNamespace matches by prefix). Exact matches and +// unregistered paths return ok=false. Pinned by TestR839_DeployRefusesASubPathOfADrive. +func (s *Settings) DriveOfSubPath(path string) (string, bool) { + path = strings.TrimRight(strings.TrimSpace(path), "/") + if path == "" || s == nil { + return "", false + } + s.mu.RLock() + defer s.mu.RUnlock() + for _, sp := range s.StoragePaths { + root := strings.TrimRight(sp.Path, "/") + if root != "" && strings.HasPrefix(path, root+"/") { + return sp.Path, true + } + } + return "", false +} + // Refusal reasons for RefuseAsAppNamespace. Hungarian, adult tone, no emoji — these reach the customer // through the deploy/migrate error surfaces. They name the storage class and what to do instead, never // an internal path or field name. diff --git a/controller/scripts/i18n_go_keys.json b/controller/scripts/i18n_go_keys.json index f706499..787d56b 100644 --- a/controller/scripts/i18n_go_keys.json +++ b/controller/scripts/i18n_go_keys.json @@ -136,6 +136,7 @@ "err.backup.unit_version_mismatch": "BORN AS A KEY, v0.275.0 (R-696, `07` §6.6 which version a restore brings back) -- a NEW sentence, never a Go literal. Pinned by internal/backup/a_version_travel_test.go / internal/web/a_version_travel_test.go.", "deploy.login_from_backup": "BORN AS A KEY, v0.275.0 (R-694) -- a NEW sentence, never a Go literal. Pinned by internal/web/r694_restored_login_test.go.", "flash.offbox.enabled_all": "v0.283.0 decision 50: born as a key (the one-press off-site offer)", + "api.deploy.hdd_path_inside_drive": "R-839: born as a key (deploy refuses an HDD_PATH inside a registered drive); pinned by TestR839_DeployRefusesASubPathOfADrive", "flash.offbox.clear_needs_confirmation": "R-729/R-545: born as a key (the household's off-site target clear press); pinned by TestR729_ClearHandler_*", "flash.offbox.clear_hub_tier": "R-729/R-545: born as a key (the household's off-site target clear press); pinned by TestR729_ClearHandler_*", "flash.offbox.clear_busy": "R-729/R-545: born as a key (the household's off-site target clear press); pinned by TestR729_ClearHandler_*",