R-839: the deploy API refuses an HDD_PATH that is a folder inside a registered drive
Diagnosed: 9202's paperless-ngx app.yaml named <drive>/userdata/paperless-ngx, written by the 2026-09-22 drill harness through POST /api/stacks/<n>/deploy, which accepted it (RefuseAsAppNamespace matches by prefix). The boot sweep then asked a folder whether it was a mountpoint and HELD the app. The deploy page offers drive roots only, so a household could not reach this; every other caller is now refused with a sentence naming the drive (settings.DriveOfSubPath). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ő",
|
||||
|
||||
@@ -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: `<drive>/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.
|
||||
|
||||
@@ -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_*",
|
||||
|
||||
Reference in New Issue
Block a user