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
gates / gates (push) Successful in 46s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
1 parent
331be17e1e
commit
78c894234c
13 files changed
+984
-9
No files matched your search
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user