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:
2026-10-15 09:25:43 +02:00
parent 331be17e1e
commit 78c894234c
13 changed files with 984 additions and 9 deletions
+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.
@@ -0,0 +1,72 @@
package stacks
import (
"path/filepath"
"testing"
)
// R-1027 — „delete my data too" must never delete a read-only bind or the shared media library. MEASURED 2026-10-14
// on Tester 1: removing a throwaway Plex (a READER, `:ro`) with „delete data" deleted drive A's whole
// `userdata/media`. The test asserts the EFFECT on a real tree: the library's files are still there, the app's own
// folder is gone.
//
// COMPANION RED-PROOF (2026-10-15): make keepOnRemoval return "" → this test fails (the library and the :ro folder are
// deleted). Restored.
func TestRemoveStack_R1027_NeverDeletesSharedOrReadOnly(t *testing.T) {
drive := t.TempDir()
compose := "services:\n" +
" app:\n" +
" image: nginx:1.27\n" +
" volumes:\n" +
" - ${HDD_PATH}/appdata/app:/data\n" +
" - " + drive + "/userdata/media:" + drive + "/userdata/media\n" + // a literal, rw, shared library (the drill shape)
" - " + drive + "/userdata/documents/scans:/scans:ro\n" + // someone else's folder, read only
" - app_cfg:/config\n" +
"volumes:\n" +
" app_cfg:\n"
m, _, _ := newR442Manager(t, "app", compose, driveAppYAML(drive), drive)
own := filepath.Join(drive, "appdata", "app", "state.db")
film := filepath.Join(drive, "userdata", "media", "movies", "Nosferatu (1922)", "Nosferatu.mkv")
scan := filepath.Join(drive, "userdata", "documents", "scans", "letter.pdf")
plantFile(t, own)
plantFile(t, film)
plantFile(t, scan)
resp, err := m.RemoveStack("app", true, nil)
if err != nil {
t.Fatalf("remove: %v", err)
}
if exists(own) {
t.Errorf("the app's OWN folder must be deleted with delete-my-data: %s still exists", own)
}
if !exists(film) {
t.Fatalf("the shared media library was DELETED by an app's removal (R-1027): %s is gone; resp=%+v", film, resp)
}
if !exists(scan) {
t.Fatalf("a read-only bind was DELETED by an app's removal (R-1027): %s is gone", scan)
}
}
// R-1032 — the safety list knows the userdata layout: the userdata root, the media library and its folders, the
// download folders. COMPANION RED-PROOF: remove the R-1032 block from ProtectedHDDPaths → this test fails. Restored.
func TestProtectedHDDPaths_R1032_UserdataLayout(t *testing.T) {
p := ProtectedHDDPaths("/mnt/felhom-drives/hdd_1")
for _, want := range []string{
"/mnt/felhom-drives/hdd_1/userdata",
"/mnt/felhom-drives/hdd_1/userdata/media",
"/mnt/felhom-drives/hdd_1/userdata/media/tv",
"/mnt/felhom-drives/hdd_1/userdata/media/movies",
"/mnt/felhom-drives/hdd_1/userdata/media/torrents",
"/mnt/felhom-drives/hdd_1/userdata/media/transmission",
"/mnt/felhom-drives/hdd_1/userdata/documents",
"/mnt/felhom-drives/hdd_1/userdata/downloads",
"/mnt/felhom-drives/hdd_1/media", // the old layout stays protected too
} {
if !p[want] {
t.Errorf("ProtectedHDDPaths does not protect %s", want)
}
}
if p["/mnt/felhom-drives/hdd_1/userdata/media/movies/Some Film"] {
t.Error("a single film folder is not a top-level folder — the list must stay a list of roots")
}
}
+5
View File
@@ -383,6 +383,11 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
} else if field.Default != "" {
value = field.Default
}
// R-1031: a media app is not asked for a drive — its own config goes on the default drive.
if value == "" && field.EnvVar == "HDD_PATH" && field.Type == "path" && meta.MediaLibrary != nil {
value = m.defaultMediaDrive()
m.logger.Printf("[INFO] [stacks] Deploy %s: a media app — HDD_PATH (its own config) on the default drive %q; the media binds cover every present drive", req.StackName, value)
}
}
// Validate required fields
+73
View File
@@ -552,3 +552,76 @@ func observationCoversTemplate(observed map[string]InstalledImage, tpl map[strin
}
return true
}
// observeStartTimes maps each compose service of the stack to its container's .State.StartedAt (R-1026). Read through
// the recorder's own process boundary (runInstalled: a context and a timeout). The format carries a literal "started"
// field so an inspect answer of another shape is ignored rather than misread as a time.
func (m *Manager) observeStartTimes(stackDir string, env []string) (map[string]string, error) {
ctx, cancel := context.WithTimeout(context.Background(), installedRecordTimeout)
defer cancel()
bin, argv := m.composeArgv("ps", "-a", "--format", "json")
psOut, err := m.runInstalled(ctx, stackDir, env, bin, argv...)
if err != nil {
return nil, fmt.Errorf("listing containers: %w", err)
}
entries, err := parseComposePS(psOut)
if err != nil {
return nil, err
}
svcOf := map[string]string{}
var ids []string
for _, e := range entries {
if e.Service == "" || e.ID == "" {
continue
}
svcOf[e.ID] = e.Service
ids = append(ids, e.ID)
}
if len(ids) == 0 {
return map[string]string{}, nil
}
sort.Strings(ids)
args := append([]string{"inspect", "--type", "container",
"--format", "{{.Id}}" + inspectSep + "started" + inspectSep + "{{.State.StartedAt}}"}, ids...)
out, err := m.runInstalled(ctx, "", nil, "docker", args...)
if err != nil {
return nil, fmt.Errorf("inspecting containers: %w", err)
}
res := map[string]string{}
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
parts := strings.Split(strings.TrimSpace(line), inspectSep)
if len(parts) != 3 || parts[1] != "started" {
continue
}
for id, svc := range svcOf {
if parts[0] == id || (len(parts[0]) > 12 && parts[0][:12] == id) {
res[svc] = parts[2]
}
}
}
return res, nil
}
// diffStartTimes: the services whose start time moved ("svc old → new"), and the ones that kept it.
func diffStartTimes(before, after map[string]string) (changed, same []string) {
svcs := make([]string, 0, len(after))
for s := range after {
svcs = append(svcs, s)
}
sort.Strings(svcs)
for _, s := range svcs {
if b, ok := before[s]; ok && b == after[s] {
same = append(same, s)
} else {
changed = append(changed, fmt.Sprintf("%s %s → %s", s, orDash(before[s]), after[s]))
}
}
return changed, same
}
func orDash(s string) string {
if s == "" {
return "-"
}
return s
}
+32 -5
View File
@@ -331,6 +331,10 @@ type Manager struct {
// 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
// mediaDrivesFn replaces the enrolled-drive read of media_library.go (tests); nil in production.
mediaDrivesFn func() []MediaDrive
// mediaEnsureDirFn replaces appbackup.EnsureUserdataDir for the per-drive media folder (tests); nil in production.
mediaEnsureDirFn func(string) error
// Samba (R-7) seams — nil in production. sambaUpFn replaces the `compose up -d` call (tests
// assert the idempotent no-op performs ZERO calls); sambaPasswdFn replaces the smbpasswd
@@ -1273,6 +1277,11 @@ func deepCopyStack(s *Stack) Stack {
sp := *s.Meta.SetupDoneProbe
cp.Meta.SetupDoneProbe = &sp
}
if s.Meta.MediaLibrary != nil {
ml := *s.Meta.MediaLibrary
ml.Services = append([]string(nil), s.Meta.MediaLibrary.Services...)
cp.Meta.MediaLibrary = &ml
}
if s.Meta.SMTPMapping != nil {
sm := *s.Meta.SMTPMapping
sm.Extra = cloneStrMap(sm.Extra)
@@ -1435,14 +1444,30 @@ func (m *Manager) RestartStack(name string) error {
dir := filepath.Dir(stack.ComposePath)
env := m.stackEnv(dir)
// Use "up -d" instead of bare "restart" so that env vars from app.yaml
// are injected and any template changes (new images, healthchecks) are
// picked up. Plain "docker compose restart" only sends SIGTERM+start
// to existing containers without re-reading the compose file or env.
if _, err := m.composeExecCustomEnv(dir, env, "up", "-d"); err != nil {
// R-1026 (v0.313.0): a REAL restart. `up -d` alone is a no-op when the compose did not change — it logged
// success in 0.1 s while every container kept its start time (Tester 1, 2026-10-14) — so it could not make an
// app see a drive that was added or came back. `--force-recreate` recreates every container of the stack, WITH
// the stack's environment (a bare `docker compose` lost every ${…} value: routes `Host(\`.\`)`, an empty
// password — `15` §10 K5-2). Anonymous volumes are carried over by compose. The start times are read before and
// after: the log says what changed, and a restart that started nothing is an ERROR, never a success.
// Pinned by TestRestartStack_R1026_RecreatesAnUnchangedCompose.
before, berr := m.observeStartTimes(dir, env)
if _, err := m.composeExecCustomEnv(dir, env, "up", "-d", "--force-recreate"); err != nil {
m.logger.Printf("[ERROR] [stacks] Stack %s restart failed after %.1fs: %v", name, time.Since(start).Seconds(), err)
return fmt.Errorf("restarting stack %s: %w", name, err)
}
after, aerr := m.observeStartTimes(dir, env)
if berr != nil || aerr != nil || len(after) == 0 {
m.logger.Printf("[WARN] [stacks] Stack %s: could not read the containers' start times (before: %v, after: %v) — the restart is not verified", name, berr, aerr)
} else if changed, same := diffStartTimes(before, after); len(changed) == 0 {
m.logger.Printf("[ERROR] [stacks] Stack %s: compose ran, but NO container started again (%s) — not a restart", name, strings.Join(same, ", "))
return fmt.Errorf("restarting stack %s: no container started again", name)
} else {
m.logger.Printf("[INFO] [stacks] Stack %s: started again: %s", name, strings.Join(changed, "; "))
if len(same) > 0 {
m.logger.Printf("[WARN] [stacks] Stack %s: these kept their start time: %s", name, strings.Join(same, ", "))
}
}
m.logger.Printf("[INFO] [stacks] Stack %s restarted successfully (took %.1fs)", name, time.Since(start).Seconds())
m.recordInstalledImages(name, dir, env)
@@ -1607,6 +1632,8 @@ func (m *Manager) composeExecCustomEnv(dir string, env []string, args ...string)
// guest-root — covers apps not in the skeleton too. Best-effort: a perms hiccup never blocks deploy.
if len(args) > 0 && args[0] == "up" {
m.ensureUserdataMounts(dir, env)
// R-1023: a media app binds every PRESENT drive — rendered here so every start sees the drives of this moment.
m.renderMediaOverride(dir, env)
}
// Log env var keys at debug level
+326
View File
@@ -0,0 +1,326 @@
package stacks
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"gopkg.in/yaml.v3"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
)
// ── Media apps on every drive (v0.313.0, R-1023; `15-media-library.md` §2, §3, §7–§10) ──────────────────────────
//
// A template marks a MEDIA app in `.felhom.yml`:
//
// media_library:
// folder: torrents # optional: only <drive>/userdata/media/<folder> (the D9 limit for a download client)
// read_only: true # a player (Plex, Jellyfin, Emby)
//
// The controller then adds, per PRESENT enrolled drive, the bind `<drive>/userdata/media[/<folder>]` at the SAME path
// inside the container. The binds live in `docker-compose.override.yml` in the stack dir, which `docker compose` merges
// by itself (no `-f` anywhere in this controller). So:
//
// - A template with the marker still runs on a controller that ignores the marker: its own one-drive
// `${USERDATA_PATH}/media…` line is untouched, and no override file is ever written.
// - ONLY present drives (`15` §10 guard 1): a bind to an absent path makes the app unstartable (K3-4: 500 „error
// while creating mount source path … permission denied"). A drive is present when it is a mountpoint AND its top
// mount is not a dead filesystem (`shutdown`, K4) — so a pulled drive is never bound, and never replaced by an
// empty folder (§10: an empty folder can read as „all files deleted").
// - A network share is never a media drive in this build (`Kind == "network"` is skipped).
// - The binds are literal host paths in a file no backup, export or removal reads: in no capture set, no `.fab`
// export, never deleted by a removal (`15` §7, §8; pinned by TestMediaLibrary_OverrideIsInNoCaptureSet and the
// R-1027 removal test).
//
// The file is rewritten before EVERY `compose up` (composeExecCustomEnv), so any start — deploy, start, restart, the
// drive notice's restart — binds exactly the drives present at that moment. Pinned by TestMediaLibrary_*.
// MediaLibrarySpec is `.felhom.yml`'s `media_library:`.
type MediaLibrarySpec struct {
// Folder limits the bind to `media/<Folder>` (qBittorrent: torrents, Transmission: transmission). Empty = the
// whole `media` folder (Sonarr, Radarr, the players).
Folder string `yaml:"folder,omitempty" json:"folder,omitempty"`
// ReadOnly binds `:ro` (the players).
ReadOnly bool `yaml:"read_only,omitempty" json:"read_only,omitempty"`
// Services names the compose services that get the binds. Empty = every service that binds `${USERDATA_PATH}/media…`.
Services []string `yaml:"services,omitempty" json:"services,omitempty"`
}
// MediaOverrideFile is the compose override the controller owns for a media app.
const MediaOverrideFile = "docker-compose.override.yml"
// mediaOverrideHeader marks the file as ours: a file without it is never touched.
const mediaOverrideHeader = "# felhom-controller media_library"
// MediaDrive is one drive the media binds are made for.
type MediaDrive struct {
Path string // the registered drive path (the felhom-data namespace root of that drive)
Present bool
Why string // why not present ("not mounted", "dead (shutdown)") — empty when present
}
// mediaDrives lists the enrolled, non-network, not decommissioned drives, each with whether it is present now. Sorted
// by path, so two renders of the same state are byte-identical.
func (m *Manager) mediaDrives() []MediaDrive {
if m.mediaDrivesFn != nil {
return m.mediaDrivesFn()
}
if m.settings == nil {
return nil
}
isMount := m.isMountPoint
if isMount == nil {
isMount = system.IsMountPoint
}
var out []MediaDrive
seen := map[string]bool{}
for _, sp := range m.settings.GetStoragePaths() {
p := filepath.Clean(sp.Path)
if sp.Kind == "network" || sp.Decommissioned || p == "" || seen[p] {
continue
}
seen[p] = true
d := MediaDrive{Path: p, Present: true}
switch {
case !isMount(p):
d.Present, d.Why = false, "not mounted"
case system.MountIsShutdown(p):
d.Present, d.Why = false, "dead (shutdown)"
}
out = append(out, d)
}
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
return out
}
// mediaBindSource is the per-drive bind source (and target) for a spec.
func mediaBindSource(drive string, spec *MediaLibrarySpec) string {
src := filepath.Join(appbackup.UserdataDir(drive), "media")
if f := strings.Trim(filepath.Clean("/"+spec.Folder), "/"); f != "" && f != "." {
src = filepath.Join(src, f)
}
return src
}
// composeServiceBinds reads each service's short-syntax volume entries, ${…} filled from env.
func composeServiceBinds(composePath string, env map[string]string) (map[string][]string, error) {
data, err := os.ReadFile(composePath)
if err != nil {
return nil, err
}
var doc struct {
Services map[string]struct {
Volumes []interface{} `yaml:"volumes"`
} `yaml:"services"`
}
if err := yaml.Unmarshal(data, &doc); err != nil {
return nil, err
}
out := map[string][]string{}
for svc, s := range doc.Services {
for _, v := range s.Volumes {
if str, ok := v.(string); ok {
out[svc] = append(out[svc], str)
}
}
}
_ = env
return out, nil
}
// bindTarget is the container side of a short-syntax bind ("src:target[:mode]"), ${…} expanded.
func bindTarget(raw string, env map[string]string) string {
parts := strings.SplitN(expandComposeVars(raw, env), ":", 3)
if len(parts) < 2 {
return ""
}
return filepath.Clean(parts[1])
}
// renderMediaOverride writes (or removes) the stack's media override for the drives present now. Returns the new file
// content ("" = no file) and whether it changed. A template without the marker gets no file; an existing file without
// our header is never touched.
func (m *Manager) renderMediaOverride(stackDir string, env []string) (string, bool) {
meta := LoadMetadata(stackDir)
path := filepath.Join(stackDir, MediaOverrideFile)
old, _ := os.ReadFile(path)
if len(old) > 0 && !strings.HasPrefix(string(old), mediaOverrideHeader) {
m.logger.Printf("[WARN] [stacks] media_library %s: %s exists and is not ours — left alone, no per-drive binds", filepath.Base(stackDir), MediaOverrideFile)
return string(old), false
}
content := ""
if meta.MediaLibrary != nil {
content = m.mediaOverrideContent(stackDir, env, meta.MediaLibrary)
}
if content == string(old) {
return content, false
}
if content == "" {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
m.logger.Printf("[WARN] [stacks] media_library %s: removing the override: %v", filepath.Base(stackDir), err)
return string(old), false
}
m.logger.Printf("[INFO] [stacks] media_library %s: no per-drive bind needed — override removed", filepath.Base(stackDir))
return "", true
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, []byte(content), 0o644); err != nil {
m.logger.Printf("[ERROR] [stacks] media_library %s: writing the override: %v", filepath.Base(stackDir), err)
return string(old), false
}
if err := os.Rename(tmp, path); err != nil {
m.logger.Printf("[ERROR] [stacks] media_library %s: writing the override: %v", filepath.Base(stackDir), err)
return string(old), false
}
return content, true
}
func (m *Manager) mediaOverrideContent(stackDir string, envList []string, spec *MediaLibrarySpec) string {
name := filepath.Base(stackDir)
env := map[string]string{}
for _, e := range envList {
if k, v, ok := strings.Cut(e, "="); ok {
env[k] = v
}
}
binds, err := composeServiceBinds(filepath.Join(stackDir, "docker-compose.yml"), env)
if err != nil {
m.logger.Printf("[WARN] [stacks] media_library %s: reading the compose: %v — no per-drive binds", name, err)
return ""
}
services := spec.Services
if len(services) == 0 {
for svc, vs := range binds {
for _, v := range vs {
if strings.HasPrefix(strings.TrimSpace(v), "${USERDATA_PATH}/media") {
services = append(services, svc)
break
}
}
}
}
sort.Strings(services)
mode := ""
if spec.ReadOnly {
mode = ":ro"
}
var b strings.Builder
var bound, skipped []string
for _, svc := range services {
have := map[string]bool{}
for _, v := range binds[svc] {
if t := bindTarget(v, env); t != "" {
have[t] = true
}
}
var lines []string
for _, d := range m.mediaDrives() {
src := mediaBindSource(d.Path, spec)
if !d.Present {
skipped = append(skipped, fmt.Sprintf("%s (%s)", d.Path, d.Why))
continue
}
if have[src] {
continue // the template's own line already binds this drive at this path
}
// Only on a PRESENT drive: the folder is made with the userdata convention, so Docker never makes it.
ensure := m.mediaEnsureDirFn
if ensure == nil {
ensure = appbackup.EnsureUserdataDir
}
if err := ensure(src); err != nil {
m.logger.Printf("[WARN] [stacks] media_library %s: cannot make %s: %v — that drive is not bound", name, src, err)
continue
}
lines = append(lines, fmt.Sprintf(" - %s:%s%s", src, src, mode))
bound = append(bound, src)
}
if len(lines) == 0 {
continue
}
fmt.Fprintf(&b, " %s:\n volumes:\n%s\n", svc, strings.Join(lines, "\n"))
}
m.logger.Printf("[INFO] [stacks] media_library %s: per-drive binds %d [%s]; drives not bound: [%s]",
name, len(bound), strings.Join(bound, ", "), strings.Join(uniq(skipped), ", "))
if b.Len() == 0 {
return ""
}
return mediaOverrideHeader + " — one bind per PRESENT drive (R-1023). Rewritten before every start; do not edit.\nservices:\n" + b.String()
}
func uniq(in []string) []string {
seen := map[string]bool{}
var out []string
for _, s := range in {
if !seen[s] {
seen[s] = true
out = append(out, s)
}
}
return out
}
// MediaApps lists the deployed stacks whose template carries `media_library:`.
func (m *Manager) MediaApps() []string {
m.mu.RLock()
defer m.mu.RUnlock()
var out []string
for name, st := range m.stacks {
if !st.Deployed || st.ComposePath == "" {
continue
}
if meta := LoadMetadata(filepath.Dir(st.ComposePath)); meta.MediaLibrary != nil {
out = append(out, name)
}
}
sort.Strings(out)
return out
}
// MediaAppsNeedingRestart renders every media app's override for the drives present NOW and returns the apps whose
// binds changed — the apps that must restart to see a drive that was added, removed or came back (the notice of
// decision 238). It writes the files; the restart itself is the household's „restart now" (RestartStack).
func (m *Manager) MediaAppsNeedingRestart() []string {
var out []string
for _, name := range m.MediaApps() {
st, ok := m.GetStack(name)
if !ok {
continue
}
dir := filepath.Dir(st.ComposePath)
if _, changed := m.renderMediaOverride(dir, m.stackEnv(dir)); changed {
out = append(out, name)
}
}
return out
}
// defaultMediaDrive is where a media app's OWN config goes (R-1031): the household's default drive when it is present,
// else the first present drive; "" when no drive is present (the deploy then refuses on the required field).
func (m *Manager) defaultMediaDrive() string {
def := ""
if m.settings != nil {
for _, sp := range m.settings.GetStoragePaths() {
if sp.IsDefault && sp.Kind != "network" && !sp.Decommissioned {
def = filepath.Clean(sp.Path)
}
}
}
first := ""
for _, d := range m.mediaDrives() {
if !d.Present {
continue
}
if d.Path == def {
return def
}
if first == "" {
first = d.Path
}
}
return first
}
@@ -0,0 +1,171 @@
package stacks
import (
"os"
"path/filepath"
"strings"
"testing"
)
// The media_library marker (R-1023, `15` §2/§10). Every test asserts the CONSEQUENCE — what the override file binds —
// for a drive set read through the mediaDrivesFn seam.
func newMediaStack(t *testing.T, name, compose, felhomYML string, drives []MediaDrive) (*Manager, string) {
t.Helper()
m, _ := newInstalledManager(t, compose, "deployed: true\nenv: {}\n")
dir := filepath.Dir(m.stacks["bookstack"].ComposePath)
if err := os.WriteFile(filepath.Join(dir, ".felhom.yml"), []byte(felhomYML), 0o644); err != nil {
t.Fatal(err)
}
m.mediaDrivesFn = func() []MediaDrive { return drives }
m.mediaEnsureDirFn = func(p string) error { return os.MkdirAll(p, 0o755) }
return m, dir
}
func readOverride(t *testing.T, dir string) string {
t.Helper()
b, err := os.ReadFile(filepath.Join(dir, MediaOverrideFile))
if os.IsNotExist(err) {
return ""
}
if err != nil {
t.Fatal(err)
}
return string(b)
}
const sonarrLike = "services:\n sonarr:\n image: x\n volumes:\n - sonarr_config:/config\n - ${USERDATA_PATH}/media:${USERDATA_PATH}/media\n"
// Guard 1 of `15` §10: bind every PRESENT drive, never an absent or dead one; the drive the template's own line already
// binds at that path is not bound twice.
//
// COMPANION RED-PROOF (2026-10-15): drop the `if !d.Present` skip in mediaOverrideContent → this test fails (the
// absent drive C and the dead drive D are bound). Restored.
func TestMediaLibrary_BindsOnlyPresentDrives(t *testing.T) {
root := t.TempDir()
A, B, C, D := filepath.Join(root, "media_a"), filepath.Join(root, "media_b"), filepath.Join(root, "media_c"), filepath.Join(root, "media_d")
drives := []MediaDrive{{Path: A, Present: true}, {Path: B, Present: true}, {Path: C, Why: "not mounted"}, {Path: D, Why: "dead (shutdown)"}}
m, dir := newMediaStack(t, "sonarr", sonarrLike, "media_library: {}\n", drives)
env := []string{"USERDATA_PATH=" + filepath.Join(A, "userdata")}
m.renderMediaOverride(dir, env)
got := readOverride(t, dir)
bm := filepath.Join(B, "userdata", "media")
if !strings.Contains(got, " - "+bm+":"+bm+"\n") {
t.Fatalf("drive B (present) is not bound at the same path:\n%s", got)
}
for _, absent := range []string{C, D} {
if strings.Contains(got, absent) {
t.Fatalf("an absent/dead drive %s is bound — the app would not start (K3-4) or would see an empty folder:\n%s", absent, got)
}
}
if strings.Contains(got, filepath.Join(A, "userdata", "media")+":") {
t.Fatalf("drive A is already bound by the template's own line; binding it twice breaks compose:\n%s", got)
}
if _, err := os.Stat(filepath.Join(C, "userdata", "media")); err == nil {
t.Fatal("a folder was made on an ABSENT drive — that is an empty folder on the root filesystem")
}
}
// A download client binds only its own folder per drive (the D9 limit); a player binds read-only.
func TestMediaLibrary_FolderAndReadOnly(t *testing.T) {
root := t.TempDir()
A, B := filepath.Join(root, "a"), filepath.Join(root, "b")
drives := []MediaDrive{{Path: A, Present: true}, {Path: B, Present: true}}
qb := "services:\n qbittorrent:\n image: x\n volumes:\n - ${USERDATA_PATH}/media/torrents:${USERDATA_PATH}/media/torrents\n - ${USERDATA_PATH}/media/torrents:/downloads\n"
m, dir := newMediaStack(t, "qbittorrent", qb, "media_library:\n folder: torrents\n", drives)
m.renderMediaOverride(dir, []string{"USERDATA_PATH=" + filepath.Join(A, "userdata")})
got := readOverride(t, dir)
bt := filepath.Join(B, "userdata", "media", "torrents")
if !strings.Contains(got, " - "+bt+":"+bt+"\n") || strings.Contains(got, filepath.Join(B, "userdata", "media")+":") {
t.Fatalf("qBittorrent must bind ONLY media/torrents of drive B:\n%s", got)
}
plex := "services:\n plex:\n image: x\n volumes:\n - plex_config:/config\n - ${USERDATA_PATH}/media:/media:ro\n"
m2, dir2 := newMediaStack(t, "plex", plex, "media_library:\n read_only: true\n", drives)
m2.renderMediaOverride(dir2, []string{"USERDATA_PATH=" + filepath.Join(A, "userdata")})
got = readOverride(t, dir2)
for _, d := range []string{A, B} { // the template binds A at /media, so A's same-path bind is new too
p := filepath.Join(d, "userdata", "media")
if !strings.Contains(got, " - "+p+":"+p+":ro\n") {
t.Fatalf("the player must bind %s read-only at the same path:\n%s", p, got)
}
}
}
// No marker → no file, and a file that is not ours is never touched. The second render of the same state changes
// nothing (no restart for nothing).
func TestMediaLibrary_NoMarkerNoFileAndIdempotent(t *testing.T) {
root := t.TempDir()
drives := []MediaDrive{{Path: filepath.Join(root, "a"), Present: true}, {Path: filepath.Join(root, "b"), Present: true}}
m, dir := newMediaStack(t, "sonarr", sonarrLike, "display_name: x\n", drives)
env := []string{"USERDATA_PATH=" + filepath.Join(root, "a", "userdata")}
if _, changed := m.renderMediaOverride(dir, env); changed || readOverride(t, dir) != "" {
t.Fatal("a template without the marker must get no override file")
}
m2, dir2 := newMediaStack(t, "sonarr", sonarrLike, "media_library: {}\n", drives)
if _, changed := m2.renderMediaOverride(dir2, env); !changed {
t.Fatal("the first render must write the file")
}
if _, changed := m2.renderMediaOverride(dir2, env); changed {
t.Fatal("a second render of the same drives must change nothing")
}
foreign := "services: {}\n"
if err := os.WriteFile(filepath.Join(dir2, MediaOverrideFile), []byte(foreign), 0o644); err != nil {
t.Fatal(err)
}
m2.renderMediaOverride(dir2, env)
if readOverride(t, dir2) != foreign {
t.Fatal("an override file that is not ours must never be overwritten")
}
}
// `15` §7/§8: the per-drive binds are in no capture set and no export — the classifier and the export read only
// docker-compose.yml, never the override. COMPANION RED-PROOF: point ParseComposeClassifiableBinds at the override file
// → this test fails. Restored.
func TestMediaLibrary_OverrideIsInNoCaptureSet(t *testing.T) {
root := t.TempDir()
A, B := filepath.Join(root, "a"), filepath.Join(root, "b")
m, dir := newMediaStack(t, "sonarr", sonarrLike, "media_library: {}\n", []MediaDrive{{Path: A, Present: true}, {Path: B, Present: true}})
m.renderMediaOverride(dir, []string{"USERDATA_PATH=" + filepath.Join(A, "userdata")})
if readOverride(t, dir) == "" {
t.Fatal("setup: the override must exist")
}
compose := filepath.Join(dir, "docker-compose.yml")
for _, b := range ParseComposeClassifiableBinds(compose) {
if strings.Contains(b.RelPath, B) || strings.Contains(b.RelPath, "media_b") {
t.Fatalf("a per-drive media bind reached the backup classifier: %+v", b)
}
}
for _, p := range ExportDataMounts(compose, A, A) {
if strings.HasPrefix(p, B) {
t.Fatalf("a per-drive media bind reached the .fab export: %s", p)
}
}
for _, p := range ParseComposeHDDMounts(compose, B) {
t.Fatalf("a removal on drive B would touch %s — the override must be invisible to removal", p)
}
}
// R-1031: the wizard does not ask a media app for a drive; an app without the marker is still asked.
// COMPANION RED-PROOF (2026-10-15): drop the MediaLibrary skip in UserFacingFields → this test fails. Restored.
func TestR1031_MediaAppIsNotAskedForADrive(t *testing.T) {
fields := []DeployField{{EnvVar: "SUBDOMAIN", Type: "subdomain"}, {EnvVar: "HDD_PATH", Type: "path", Required: true}}
media := Metadata{DeployFields: fields, MediaLibrary: &MediaLibrarySpec{}}
for _, f := range media.UserFacingFields() {
if f.EnvVar == "HDD_PATH" {
t.Fatal("a media app's wizard asks for a drive (R-1031)")
}
}
plain := Metadata{DeployFields: fields}
asked := false
for _, f := range plain.UserFacingFields() {
asked = asked || f.EnvVar == "HDD_PATH"
}
if !asked {
t.Fatal("an app without the marker must still be asked for its drive")
}
}
+8
View File
@@ -59,6 +59,9 @@ type Metadata struct {
// SetupGate (v0.280.0, `09` §3 decision 46): a fresh install is CLOSED to everyone but the household until the
// app's first setup is done — for an app whose first visitor creates the admin. See setup_gate.go.
SetupGate bool `yaml:"setup_gate,omitempty" json:"setup_gate,omitempty"`
// MediaLibrary (v0.313.0, R-1023, `15-media-library.md` §2/§3): the app is a MEDIA app — the controller adds one
// bind per present, enrolled drive of `<drive>/userdata/media[/<folder>]` at the same path. See media_library.go.
MediaLibrary *MediaLibrarySpec `yaml:"media_library,omitempty" json:"media_library,omitempty"`
// SetupDoneProbe (v0.280.0) is the app's own read-only "an admin exists" status; absent = the household's button.
SetupDoneProbe *SetupDoneProbe `yaml:"setup_done_probe,omitempty" json:"setup_done_probe,omitempty"`
// SignupBlock (v0.281.0, `09` §3 decision 47): a traefik matcher for the app's own sign-up address, closed once the
@@ -531,6 +534,11 @@ func (m *Metadata) HasDeployFields() bool {
func (m *Metadata) UserFacingFields() []DeployField {
var fields []DeployField
for _, f := range m.DeployFields {
// R-1031 (v0.313.0): a media app sees EVERY drive, so the wizard does not ask it for one; its own config goes
// on the default drive (DeployStack fills HDD_PATH). Pinned by TestR1031_MediaAppIsNotAskedForADrive.
if m.MediaLibrary != nil && f.EnvVar == "HDD_PATH" {
continue
}
if f.Type != "domain" && f.Type != "secret" {
fields = append(fields, f)
}
@@ -0,0 +1,105 @@
package stacks
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// TestRestartStack_R1026_RecreatesAnUnchangedCompose pins the CONSEQUENCE of R-1026: a restart with an unchanged
// compose starts the container again. The compose binary is a stub on PATH that „recreates" (writes a marker) only when
// it is asked to — exactly as real compose does nothing on `up -d` with an unchanged compose; the start-time read
// answers from that marker. Today's (pre-v0.313.0) RestartStack ran `up -d`: the marker is never written, the start
// time never moves, and this test fails with "no container started again".
//
// COMPANION RED-PROOF (2026-10-15): drop "--force-recreate" from RestartStack's compose call → this test fails
// („restart must succeed: … no container started again"). Restored.
func TestRestartStack_R1026_RecreatesAnUnchangedCompose(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("the stub compose binary is a shell script")
}
var logs strings.Builder
m, _ := newInstalledManager(t, "services:\n web:\n image: nginx:1.27\n", "deployed: true\nenv: {}\n")
m.logger = log.New(&logs, "", 0)
bin := t.TempDir()
marker := filepath.Join(t.TempDir(), "recreated")
script := fmt.Sprintf("#!/bin/sh\nfor a in \"$@\"; do [ \"$a\" = \"--force-recreate\" ] && touch %q; done\nexit 0\n", marker)
if err := os.WriteFile(filepath.Join(bin, "docker-compose"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
m.composeCmd = "docker-compose"
m.execFn = func(string, ...string) (string, error) { return "", nil }
started := func() string {
if _, err := os.Stat(marker); err == nil {
return "2026-10-15T09:00:05Z"
}
return "2026-10-14T20:41:00Z"
}
m.installedExecFn = func(_ context.Context, _ string, _ []string, name string, args ...string) (string, error) {
if name == "docker" && len(args) > 0 && args[0] == "compose" {
name, args = "docker-compose", args[1:]
}
switch {
case name == "docker-compose" && len(args) > 0 && args[0] == "ps":
return `{"ID":"aaa111bbb222","Name":"web-1","Service":"web"}`, nil
case name == "docker" && len(args) > 0 && args[0] == "inspect" && strings.Contains(strings.Join(args, " "), "State.StartedAt"):
return "aaa111bbb222ccc333" + inspectSep + "started" + inspectSep + started() + "\n", nil
case name == "docker" && len(args) > 0 && args[0] == "inspect":
return "aaa111bbb222" + inspectSep + "nginx:1.27" + inspectSep + "sha256:i\n", nil
case name == "docker" && len(args) > 1 && args[0] == "image":
return "sha256:i" + inspectSep + "nginx@sha256:d\n", nil
}
t.Fatalf("unexpected command: %s %v", name, args)
return "", nil
}
before := started()
if err := m.RestartStack("bookstack"); err != nil {
t.Fatalf("restart must succeed: %v\nlog:\n%s", err, logs.String())
}
if after := started(); after == before {
t.Fatalf("the container's start time did not move (%s) — the restart restarted nothing (R-1026)", after)
}
if !strings.Contains(logs.String(), "started again: web 2026-10-14T20:41:00Z → 2026-10-15T09:00:05Z") {
t.Errorf("the log must say what changed. Log:\n%s", logs.String())
}
}
// TestRestartStack_R1026_NothingStartedIsAnError: compose answers 0 but no start time moved — that is reported as a
// failure, never as „restarted successfully" (the presence-is-not-success rule).
func TestRestartStack_R1026_NothingStartedIsAnError(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("the stub compose binary is a shell script")
}
var logs strings.Builder
m, _ := newInstalledManager(t, "services:\n web:\n image: nginx:1.27\n", "deployed: true\nenv: {}\n")
m.logger = log.New(&logs, "", 0)
withFakeCompose(t, m) // exits 0 and recreates nothing
m.installedExecFn = func(_ context.Context, _ string, _ []string, name string, args ...string) (string, error) {
if name == "docker" && len(args) > 0 && args[0] == "compose" {
name, args = "docker-compose", args[1:]
}
if name == "docker-compose" {
return `{"ID":"aaa111","Name":"web-1","Service":"web"}`, nil
}
if strings.Contains(strings.Join(args, " "), "State.StartedAt") {
return "aaa111" + inspectSep + "started" + inspectSep + "2026-10-14T20:41:00Z\n", nil
}
return "", nil
}
err := m.RestartStack("bookstack")
if err == nil || !strings.Contains(err.Error(), "no container started again") {
t.Fatalf("a restart that started nothing must fail, got %v", err)
}
if strings.Contains(logs.String(), "restarted successfully") {
t.Errorf("no success line may be logged. Log:\n%s", logs.String())
}
}
@@ -0,0 +1,56 @@
//go:build linux
package system
import (
"os"
"path/filepath"
"strings"
)
// MountIsShutdown reports whether the TOP mount at path is a dead filesystem — ext4's `shutdown` super option, set when
// the device under it went away (a pulled drive). It reads /proc/self/mountinfo only: NO disk I/O, so it never hangs on
// the dead device (gates.md: health checks issue no block I/O). A timed `stat` is NOT a substitute: it answered „ok" on
// a dead drive from the cache (measured 2026-10-14, `15` §10 K4). Pinned by TestMountIsShutdown_*.
func MountIsShutdown(path string) bool {
data, err := os.ReadFile(mountinfoPath)
if err != nil {
return false
}
return mountinfoShutdown(string(data), path)
}
var mountinfoPath = "/proc/self/mountinfo"
// mountinfoShutdown: the LAST mountinfo line whose mount point is path (the top of a stack) carries `shutdown` in its
// super options (the third field after " - ").
func mountinfoShutdown(mountinfo, path string) bool {
path = filepath.Clean(path)
dead := false
found := false
for _, line := range strings.Split(mountinfo, "\n") {
f := strings.Fields(line)
if len(f) < 5 || unescapeMountinfo(f[4]) != path {
continue
}
found = true
dead = false
if i := strings.Index(line, " - "); i >= 0 {
tail := strings.Fields(line[i+3:])
if len(tail) >= 3 {
for _, o := range strings.Split(tail[2], ",") {
if o == "shutdown" {
dead = true
}
}
}
}
}
return found && dead
}
// unescapeMountinfo undoes mountinfo's octal escapes for space, tab, newline and backslash.
func unescapeMountinfo(s string) string {
r := strings.NewReplacer(`\040`, " ", `\011`, "\t", `\012`, "\n", `\134`, `\`)
return r.Replace(s)
}
@@ -0,0 +1,6 @@
//go:build !linux
package system
// MountIsShutdown is linux-only; elsewhere nothing is ever dead.
func MountIsShutdown(_ string) bool { return false }
@@ -0,0 +1,26 @@
//go:build linux
package system
import "testing"
// Lines measured on Tester 1 2026-10-14 (K4): a live ext4, a dead one (`shutdown`), and a returned drive stacked on
// top of the dead one — only the TOP mount decides.
const k4Mountinfo = `120 99 8:33 / /mnt/felhom-drives/media_a rw,relatime shared:60 master:1 - ext4 /dev/sdc1 rw
121 99 8:49 / /mnt/felhom-drives/media_b rw,relatime shared:61 master:2 - ext4 /dev/sdd1 rw,shutdown
122 99 8:65 / /mnt/felhom-drives/media_c rw,relatime shared:62 master:3 - ext4 /dev/sde1 rw,shutdown
130 122 8:81 / /mnt/felhom-drives/media_c rw,relatime shared:70 master:4 - ext4 /dev/sdf1 rw`
func TestMountIsShutdown_K4(t *testing.T) {
for path, want := range map[string]bool{
"/mnt/felhom-drives/media_a": false, // live
"/mnt/felhom-drives/media_b": true, // pulled: dead
"/mnt/felhom-drives/media_c": false, // came back ON TOP of the dead mount: the top is live
"/mnt/felhom-drives/media_c/": false,
"/mnt/felhom-drives/nothing": false, // not a mount at all: not „dead"
} {
if got := mountinfoShutdown(k4Mountinfo, path); got != want {
t.Errorf("%s: dead=%v, want %v", path, got, want)
}
}
}