Media build part 1 (unreleased): real restart (R-1026), media apps on every PRESENT drive via an owned compose override (R-1023), dead-drive check from mountinfo, removal never deletes shared/:ro binds (R-1027), safety list knows userdata (R-1032), no drive question for media apps (R-1031)
gates / gates (push) Successful in 46s

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
admin committed 2026-10-15 09:25:43 +02:00
1 parent 331be17e1e
commit 78c894234c
13 files changed
+984 -9

No files matched your search

+78 -4
View File
@@ -241,7 +241,7 @@ func ProtectedHDDPaths(hddPath string) map[string]bool {
if hddPath == "" {
return nil
}
return map[string]bool{
out := map[string]bool{
// Model A: the in-guest drive mount IS the felhom-data namespace root, so backups/ and
// appdata/ sit directly under it (no felhom-data segment).
hddPath: true,
@@ -258,6 +258,44 @@ func ProtectedHDDPaths(hddPath string) map[string]bool {
filepath.Join(hddPath, felhomDataDir, "appdata"): true,
filepath.Join(hddPath, felhomDataDir, "backups"): true,
}
// R-1032 (v0.313.0): the userdata layout (v0.171.0+). The list above named only the pre-userdata folders, so
// `<drive>/userdata` and every skeleton folder in it — the family's shared media library first — was unprotected.
// Every carry-list entry, its media download folders, and the userdata root itself.
ud := filepath.Join(hddPath, "userdata")
out[ud] = true
for _, d := range append(appbackup.UserdataSkeletonCarry(), "media/torrents", "media/transmission", appbackup.ImportDirName) {
out[filepath.Join(ud, filepath.FromSlash(d))] = true
}
return out
}
// isSharedLibraryPath (R-1027): a path inside a drive's shared media library — `<drive>/userdata/media` (or the
// legacy `<drive>/media`) or anything under it. The library belongs to the household, never to one app: a removal
// with „delete my data" never deletes it. Measured 2026-10-14 on Tester 1: removing a reader Plex deleted drive A's
// whole `userdata/media`. Pinned by TestRemoveStack_R1027_NeverDeletesSharedOrReadOnly.
func isSharedLibraryPath(p, hddPath string) bool {
if hddPath == "" {
return false
}
p = filepath.Clean(p)
for _, lib := range []string{filepath.Join(hddPath, "userdata", "media"), filepath.Join(hddPath, "media")} {
if p == lib || strings.HasPrefix(p, lib+string(filepath.Separator)) {
return true
}
}
return false
}
// keepOnRemoval says why a bind found by ParseComposeHDDMounts must survive a removal with „delete my data" (R-1027):
// a read-only bind (the app only READS it — it is someone else's data) or a path in the shared media library.
func keepOnRemoval(p, hddPath string, readOnly map[string]bool) string {
switch {
case readOnly[filepath.Clean(p)]:
return "a read-only bind — the app only reads it"
case isSharedLibraryPath(p, hddPath):
return "the shared media library — it is the household's, not the app's"
}
return ""
}
// DeleteStack removes an orphaned stack: stops containers, removes volumes,
@@ -351,6 +389,7 @@ func (m *Manager) DeleteStack(name string, removeHDDData bool) (*DeleteResponse,
// Step 4: Handle HDD data
protected := ProtectedHDDPaths(hddPath)
readOnly := ParseComposeHDDReadOnly(stack.ComposePath, hddPath)
for _, mount := range hddMounts {
// Safety: never delete protected top-level dirs
cleanPath := filepath.Clean(mount)
@@ -358,6 +397,11 @@ func (m *Manager) DeleteStack(name string, removeHDDData bool) (*DeleteResponse,
m.logger.Printf("[WARN] Refusing to delete protected HDD path: %s", cleanPath)
continue
}
if why := keepOnRemoval(cleanPath, hddPath, readOnly); why != "" && removeHDDData {
m.logger.Printf("[WARN] [stacks] DeleteStack %s: NOT deleting %s — %s (R-1027)", name, cleanPath, why)
resp.HDDPathsPreserved = append(resp.HDDPathsPreserved, cleanPath)
continue
}
if _, err := os.Stat(cleanPath); os.IsNotExist(err) {
if m.isDebug() {
@@ -441,6 +485,7 @@ func (m *Manager) GetStackHDDData(name string) (*HDDDataResponse, error) {
m.logger.Printf("[DEBUG] [stacks] GetStackHDDData %s: found %d raw HDD mounts from compose", name, len(mounts))
}
readOnly := ParseComposeHDDReadOnly(stack.ComposePath, hddPath)
for _, mount := range mounts {
cleanPath := filepath.Clean(mount)
@@ -448,6 +493,10 @@ func (m *Manager) GetStackHDDData(name string) (*HDDDataResponse, error) {
if protected != nil && protected[cleanPath] {
continue
}
// R-1027: a shared or read-only bind is never deleted, so it is never offered for deletion either.
if keepOnRemoval(cleanPath, hddPath, readOnly) != "" {
continue
}
hddItem := HDDPath{
Path: cleanPath,
@@ -714,12 +763,18 @@ func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemo
// Step 4: Handle HDD data
protected := ProtectedHDDPaths(hddPath)
readOnly := ParseComposeHDDReadOnly(stack.ComposePath, hddPath)
for _, mount := range hddMounts {
cleanPath := filepath.Clean(mount)
if protected != nil && protected[cleanPath] {
m.logger.Printf("[WARN] Refusing to delete protected HDD path: %s", cleanPath)
continue
}
if why := keepOnRemoval(cleanPath, hddPath, readOnly); why != "" && removeHDDData {
m.logger.Printf("[WARN] [stacks] RemoveStack %s: NOT deleting %s — %s (R-1027)", name, cleanPath, why)
resp.HDDPathsPreserved = append(resp.HDDPathsPreserved, cleanPath)
continue
}
if _, err := os.Stat(cleanPath); os.IsNotExist(err) {
if m.isDebug() {
@@ -981,13 +1036,25 @@ func ExportDataMounts(composePath, hddPath, nsRoot string) []string {
// ParseComposeHDDMounts reads a docker-compose.yml and extracts host paths
// that reference the HDD path from volume bind mounts.
func ParseComposeHDDMounts(composePath, hddPath string) []string {
mounts, _ := parseComposeHDDMountModes(composePath, hddPath)
return mounts
}
// ParseComposeHDDReadOnly is the set of HDD bind sources ParseComposeHDDMounts finds that are mounted `:ro` (R-1027).
func ParseComposeHDDReadOnly(composePath, hddPath string) map[string]bool {
_, ro := parseComposeHDDMountModes(composePath, hddPath)
return ro
}
func parseComposeHDDMountModes(composePath, hddPath string) ([]string, map[string]bool) {
readOnly := map[string]bool{}
if hddPath == "" {
return nil
return nil, readOnly
}
data, err := os.ReadFile(composePath)
if err != nil {
return nil
return nil, readOnly
}
var mounts []string
@@ -1033,6 +1100,13 @@ func ParseComposeHDDMounts(composePath, hddPath string) []string {
if cleanPath != cleanHDD && !strings.HasPrefix(cleanPath, cleanHDD+string(filepath.Separator)) {
continue
}
if len(parts) == 3 {
for _, opt := range strings.Split(parts[2], ",") {
if strings.TrimSpace(opt) == "ro" {
readOnly[cleanPath] = true
}
}
}
if !seen[cleanPath] {
seen[cleanPath] = true
mounts = append(mounts, cleanPath)
@@ -1040,7 +1114,7 @@ func ParseComposeHDDMounts(composePath, hddPath string) []string {
}
log.Printf("[INFO] [stacks] ParseComposeHDDMounts: found %d HDD mounts for %s", len(mounts), composePath)
return mounts
return mounts, readOnly
}
// getDirSizeHuman returns a human-readable size string for a directory using du.