Traefik v3.7.17 (GHSA-xv5x-242w-7hf7); R-1347 a restore forgets the update-held flag; R-1348 a failed put-back at start-up mails; R-1339, R-1351, R-1323 truthful start-up lines and one lock over FileBrowser's compose. Commit only.
gates / gates (push) Successful in 29s

This commit is contained in:
admin committed 2026-11-02 23:14:16 +01:00
1 parent a90b62323f
commit c4b6844d34
19 files changed
+569 -9

No files matched your search

@@ -1,6 +1,14 @@
package web
import "testing"
import (
"bytes"
"log"
"os"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// T4: skipFileBrowserPath skips ONLY an external drive path (under StableParentDir) that is not a live
// mountpoint. A mounted external path and any system/local path are never skipped.
@@ -49,3 +57,66 @@ func TestFbNeedsRecreate(t *testing.T) {
}
}
}
// R-1339: a per-app folder under a MOUNTED drive (/mnt/felhom-drives/x/userdata/<app>) is never a mountpoint itself. It is
// shown through the drive's own bind, so it still gets no bind and no skeleton of its own (a skeleton there would write
// userdata/ inside the app's folder), and the log no longer says its drive is „not mounted". A drive that really is
// absent keeps its line, and a folder of such a drive names the absent drive.
//
// RED-PROOF (2026-11-02): before the fix the sub-folder logged „drive …/userdata/nextcloud not mounted" (nine lines at every
// start on 9202, scratch_hdd a live mountpoint) and this fails on that line.
func TestR1339_AFolderOfAMountedDriveIsNotReportedAsUnmounted(t *testing.T) {
sub := StableParentDir + "/scratch_hdd/userdata/nextcloud"
mounted := StableParentDir + "/scratch_hdd"
absent := StableParentDir + "/usb"
absentSub := absent + "/userdata/romm"
isMount := func(p string) bool { return p == mounted }
var buf bytes.Buffer
var skeletons []string
mounts, cfg := buildFileBrowserPaths([]settings.StoragePath{{Path: sub}, {Path: mounted}, {Path: absent}, {Path: absentSub}}, fbPathDeps{
isMount: isMount,
ensureSkeleton: func(p string) error { skeletons = append(skeletons, p); return nil },
logger: log.New(&buf, "", 0),
})
logged := buf.String()
if strings.Contains(logged, sub) {
t.Errorf("a folder of a mounted drive is reported: %q", logged)
}
if !strings.Contains(logged, "drive "+absent+" not mounted") {
t.Errorf("a drive that is really absent keeps its line: %q", logged)
}
if !strings.Contains(logged, "drive "+absent+" not mounted") || strings.Count(logged, "not mounted") != 2 || !strings.Contains(logged, absentSub) {
t.Errorf("a folder of an absent drive is reported, naming that drive: %q", logged)
}
// The behaviour stays: only the mounted drive itself gets a skeleton and a bind.
if len(skeletons) != 1 || skeletons[0] != mounted {
t.Errorf("skeletons %v — only the mounted drive gets one", skeletons)
}
if len(mounts) != 1 || !strings.Contains(mounts[0], ":/srv/scratch_hdd") {
t.Errorf("mounts %v — only the mounted drive's userdata is bound", mounts)
}
if len(cfg) != 4 {
t.Errorf("the source list keeps every registered path: %v", cfg)
}
}
// R-1351: the web sync's compose call runs inside the stack lock it shares with the base stack's bring-up. The lock's
// behaviour is TestR1351_TheTwoFileBrowserStartPathsNeverOverlap (internal/stacks); this pins the web side's use of it.
func TestR1351_TheWebSyncTakesTheLockBeforeCompose(t *testing.T) {
b, err := os.ReadFile("handlers.go")
if err != nil {
t.Fatal(err)
}
src := string(b)
start := strings.Index(src, "func (s *Server) syncFileBrowserMounts(")
if start < 0 {
t.Fatal("syncFileBrowserMounts not found")
}
body := src[start:]
lock := strings.Index(body, "s.stackMgr.LockFileBrowserStack()")
down := strings.Index(body, `"docker", "compose", "down", "-v"`)
up := strings.Index(body, `[]string{"compose", "up", "-d", "--remove-orphans"}`)
if lock < 0 || down < 0 || up < 0 || lock > down || lock > up {
t.Fatalf("the lock must come before both compose calls (lock %d, down %d, up %d)", lock, down, up)
}
}
+25 -1
View File
@@ -3769,6 +3769,15 @@ func skipFileBrowserPath(path string, isMount func(string) bool) bool {
return strings.HasPrefix(path, StableParentDir+"/") && !isMount(path)
}
// fbDriveRoot is the drive a path under StableParentDir lives on (…/scratch_hdd/userdata/x → …/scratch_hdd).
func fbDriveRoot(path string) string {
rest := strings.TrimPrefix(path, StableParentDir+"/")
if i := strings.Index(rest, "/"); i >= 0 {
rest = rest[:i]
}
return StableParentDir + "/" + rest
}
func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) {
// R-695 (v0.275.0): SINGLE-FLIGHT. A request is covered by any sync that STARTS reading the state
// after the request was made: a caller that waited behind a running sync returns as soon as one
@@ -3917,6 +3926,13 @@ func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) {
}
s.fbForceMu.Unlock()
// R-1351: the base stack's bring-up may run `compose up` on this same stack in the same second (a guest boot); the loser
// logged „Conflict. The container name … is already in use". One lock over both compose paths.
if s.stackMgr != nil {
unlock := s.stackMgr.LockFileBrowserStack()
defer unlock()
}
// If sources changed and caller requested a DB reset (restore flow),
// nuke the data volume so FileBrowser re-reads config.yaml from scratch.
// Normal operations skip this to preserve user accounts, permissions, and share links.
@@ -4015,8 +4031,16 @@ func buildFileBrowserPaths(paths []settings.StoragePath, d fbPathDeps) (storageM
// FileBrowser this pass. It returns on the next sync after reconnect. Matches planDriveGates'
// external-only rule (system paths, not under StableParentDir, are never skipped).
if skipFileBrowserPath(sp.Path, d.isMount) {
// R-1339: a per-app folder (<drive>/userdata/<app>) is never a mountpoint itself. Under a MOUNTED drive it is shown
// through the drive's own bind, so it gets no bind of its own and there is nothing to report: the old „not mounted"
// line was false nine times at every start on a box whose drive worked. Pinned by TestR1339_*.
if d.logger != nil {
d.logger.Printf("[INFO] [web] FileBrowser: drive %s not mounted — skipping userdata skeleton", sp.Path)
switch root := fbDriveRoot(sp.Path); {
case root == sp.Path:
d.logger.Printf("[INFO] [web] FileBrowser: drive %s not mounted — skipping userdata skeleton", sp.Path)
case !d.isMount(root):
d.logger.Printf("[INFO] [web] FileBrowser: drive %s not mounted — skipping its folder %s", root, sp.Path)
}
}
continue
}