v0.75.0: gate userdata MkdirAll on a live mountpoint (no writes into an absent drive)
Belt (ensureUserdataMounts) + FileBrowser sync skip ensure/mount when an external drive root is not a live mountpoint -> no 'mkdir userdata: permission denied' + no rootfs-shadow during a drive-absent window. System/local path never gated. Reuses system.IsMountPoint; matches planDriveGates external-only rule. T1-T4 + red-proofs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ import (
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/crypto"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
||||
)
|
||||
|
||||
// ContainerState represents the current state of a container.
|
||||
@@ -97,6 +98,9 @@ type Manager struct {
|
||||
backupRunning func() bool // mutual exclusion with the backup orchestrator (Change 3)
|
||||
migDoneHook func(*MigrationJob) // fired on successful completion (decommission policy lives in caller)
|
||||
testSeams *migSeams // nil in production; tests inject fakes
|
||||
// isMountPoint reports whether a path is a live mountpoint; defaults to system.IsMountPoint.
|
||||
// Injectable so the userdata-belt drive-absent gate is testable (a t.TempDir is never a real mount).
|
||||
isMountPoint func(string) bool
|
||||
}
|
||||
|
||||
// NewManager creates a new stack manager.
|
||||
@@ -116,10 +120,11 @@ func NewManager(cfg *config.Config, logger *log.Logger) (*Manager, error) {
|
||||
}
|
||||
|
||||
return &Manager{
|
||||
cfg: cfg,
|
||||
logger: logger,
|
||||
composeCmd: composeCmd,
|
||||
stacks: make(map[string]*Stack),
|
||||
cfg: cfg,
|
||||
logger: logger,
|
||||
composeCmd: composeCmd,
|
||||
stacks: make(map[string]*Stack),
|
||||
isMountPoint: system.IsMountPoint,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -130,6 +135,15 @@ func (m *Manager) ensureUserdataMounts(stackDir string, env []string) {
|
||||
if userdataPath == "" {
|
||||
return
|
||||
}
|
||||
// Drive-absent gate: an external drive root (not the system/local path) that isn't currently a live
|
||||
// mountpoint means the drive is detached. Creating ${USERDATA_PATH}/... now would write app data onto
|
||||
// the guest ROOTFS, shadowed when the drive returns (data-integrity + rootfs-fill hazard). Skip — the
|
||||
// app is held by the drive gate (planDriveGates). The system/local path is legitimately not a
|
||||
// mountpoint, so it is never gated.
|
||||
if hdd := envLookup(env, "HDD_PATH"); hdd != "" && hdd != m.sysDataPath && !m.isMountPoint(hdd) {
|
||||
m.logger.Printf("[INFO] [stacks] userdata belt: drive %s not mounted — skipping ensure (held by drive gate)", hdd)
|
||||
return
|
||||
}
|
||||
composePath := filepath.Join(stackDir, "docker-compose.yml")
|
||||
for _, src := range ParseComposeUserdataMounts(composePath, userdataPath) {
|
||||
if err := appbackup.EnsureUserdataDir(src); err != nil {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// T1: the deploy belt SKIPS ensure when an EXTERNAL drive root is not a live mountpoint (drive absent).
|
||||
// Creating ${USERDATA_PATH}/... then would land app data on the rootfs, shadowed when the drive returns.
|
||||
// Companion red-proof: removing the gate in ensureUserdataMounts makes the dir get created → this fails.
|
||||
func TestEnsureUserdataMounts_SkipsAbsentExternalDrive(t *testing.T) {
|
||||
m := newMigManager(t, "") // sysDataPath = /mnt/sys_drive
|
||||
m.isMountPoint = func(string) bool { return false } // external drive is NOT mounted
|
||||
stackDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(stackDir, "docker-compose.yml"), []byte(beltCompose), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ud := filepath.Join(t.TempDir(), "userdata")
|
||||
env := []string{"HDD_PATH=/mnt/felhom-drives/flash", "USERDATA_PATH=" + ud} // external, != sysDataPath
|
||||
|
||||
m.ensureUserdataMounts(stackDir, env)
|
||||
|
||||
if _, err := os.Stat(filepath.Join(ud, "media", "movies")); err == nil {
|
||||
t.Fatal("belt MUST NOT create userdata dirs when the external drive is absent (rootfs-shadow hazard)")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(ud, "downloads")); err == nil {
|
||||
t.Fatal("belt MUST NOT create userdata dirs when the external drive is absent")
|
||||
}
|
||||
}
|
||||
|
||||
// T2: the belt ENSURES the bind-source dirs when the external drive IS a live mountpoint.
|
||||
func TestEnsureUserdataMounts_EnsuresWhenMounted(t *testing.T) {
|
||||
m := newMigManager(t, "")
|
||||
m.isMountPoint = func(string) bool { return true } // drive mounted
|
||||
stackDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(stackDir, "docker-compose.yml"), []byte(beltCompose), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ud := filepath.Join(t.TempDir(), "userdata")
|
||||
env := []string{"HDD_PATH=/mnt/felhom-drives/flash", "USERDATA_PATH=" + ud}
|
||||
|
||||
m.ensureUserdataMounts(stackDir, env)
|
||||
|
||||
for _, p := range []string{filepath.Join(ud, "media", "movies"), filepath.Join(ud, "downloads")} {
|
||||
if fi, err := os.Stat(p); err != nil || !fi.IsDir() {
|
||||
t.Errorf("belt should create %s when the drive is mounted (%v)", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// T3: the SYSTEM/local path (HDD_PATH == sysDataPath) is NEVER gated — the SSD path is legitimately not
|
||||
// a mountpoint, so ensure must always run there (must-not-over-gate).
|
||||
func TestEnsureUserdataMounts_SystemPathNeverSkipped(t *testing.T) {
|
||||
m := newMigManager(t, "") // sysDataPath = /mnt/sys_drive
|
||||
m.isMountPoint = func(string) bool { return false } // SSD path is not a mountpoint — but must not gate
|
||||
stackDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(stackDir, "docker-compose.yml"), []byte(beltCompose), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ud := filepath.Join(t.TempDir(), "userdata")
|
||||
env := []string{"HDD_PATH=" + m.sysDataPath, "USERDATA_PATH=" + ud} // system path
|
||||
|
||||
m.ensureUserdataMounts(stackDir, env)
|
||||
|
||||
if fi, err := os.Stat(filepath.Join(ud, "media", "movies")); err != nil || !fi.IsDir() {
|
||||
t.Errorf("belt must ALWAYS ensure on the system/local path (not a mountpoint, but never gated): %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user