From 78c894234c6a1983141a46535cbf860d5a5d2556 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Thu, 15 Oct 2026 09:25:43 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- CHANGELOG.md | 26 ++ controller/internal/stacks/delete.go | 82 ++++- .../internal/stacks/delete_r1027_test.go | 72 ++++ controller/internal/stacks/deploy.go | 5 + controller/internal/stacks/installed.go | 73 ++++ controller/internal/stacks/manager.go | 37 +- controller/internal/stacks/media_library.go | 326 ++++++++++++++++++ .../internal/stacks/media_library_test.go | 171 +++++++++ controller/internal/stacks/metadata.go | 8 + .../internal/stacks/restart_real_test.go | 105 ++++++ controller/internal/system/mountdead_linux.go | 56 +++ controller/internal/system/mountdead_other.go | 6 + controller/internal/system/mountdead_test.go | 26 ++ 13 files changed, 984 insertions(+), 9 deletions(-) create mode 100644 controller/internal/stacks/delete_r1027_test.go create mode 100644 controller/internal/stacks/media_library.go create mode 100644 controller/internal/stacks/media_library_test.go create mode 100644 controller/internal/stacks/restart_real_test.go create mode 100644 controller/internal/system/mountdead_linux.go create mode 100644 controller/internal/system/mountdead_other.go create mode 100644 controller/internal/system/mountdead_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f2401c..71fc3bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,29 @@ +## Unreleased → v0.313.0 (2026-10-15, in progress) — the media build, part 1: a real restart, media apps on every drive + +Not released yet (bake → vouch → floor, `09` §3 decision 247). Design: felhom.eu `architecture/15-media-library.md` §2, +§3, §7–§10. + +- **A real restart (R-1026).** `RestartStack` runs `up -d --force-recreate` with the stack's environment, reads every + container's start time before and after, logs what started again, and FAILS when nothing started again (it logged + „restarted successfully" in 0.1 s over unchanged containers). `TestRestartStack_R1026_*` (red-proved: without + `--force-recreate` the start time never moves). +- **Media apps on every drive (R-1023, decision 245).** A template with `media_library:` (`folder:`, `read_only:`) gets + one bind per PRESENT enrolled drive — `/userdata/media[/]` at the same path — in + `docker-compose.override.yml` (ours, header-marked; compose merges it itself), rewritten before EVERY `compose up`. An + absent drive (not a mountpoint) or a dead one (`shutdown` in `/proc/self/mountinfo`, no disk I/O — `15` §10 K4) is + never bound and never gets a folder. Network shares are never media drives. A template with the marker still runs on + a controller that ignores it (non-strict YAML; its own one-drive line stays). `TestMediaLibrary_*` (red-proved: an + absent drive bound when the skip is dropped; a removal would see the binds if the override were read). +- **`system.MountIsShutdown`** — the dead-drive check from `/proc/self/mountinfo` (top of a stacked mount decides). + `TestMountIsShutdown_K4` (the Tester 1 lines). +- **„Delete my data" never deletes a read-only or shared bind (R-1027)** — a `:ro` bind or anything in a drive's + `userdata/media` (or legacy `media`) is preserved and logged; the removal dialog no longer offers it. + `TestRemoveStack_R1027_NeverDeletesSharedOrReadOnly` (red-proved). +- **The safety list knows the userdata layout (R-1032)** — `ProtectedHDDPaths` adds `userdata`, every skeleton folder + in it, `media/torrents`, `media/transmission`, `import`. `TestProtectedHDDPaths_R1032_UserdataLayout` (red-proved). +- **The wizard does not ask a media app for a drive (R-1031)** — HDD_PATH (the app's own config) goes on the default + drive, else the first present one. `TestR1031_MediaAppIsNotAskedForADrive` (red-proved). + ## Unreleased (2026-10-15) — the shared rule file; no code change - `.claude/rules/unprompted-work.md` §3 item 6: **bake first** — a controller release is released only when its golden is diff --git a/controller/internal/stacks/delete.go b/controller/internal/stacks/delete.go index 1352d03..a0f32ce 100644 --- a/controller/internal/stacks/delete.go +++ b/controller/internal/stacks/delete.go @@ -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 + // `/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 — `/userdata/media` (or the +// legacy `/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. diff --git a/controller/internal/stacks/delete_r1027_test.go b/controller/internal/stacks/delete_r1027_test.go new file mode 100644 index 0000000..74bf8cc --- /dev/null +++ b/controller/internal/stacks/delete_r1027_test.go @@ -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") + } +} diff --git a/controller/internal/stacks/deploy.go b/controller/internal/stacks/deploy.go index 13ad844..eb29f84 100644 --- a/controller/internal/stacks/deploy.go +++ b/controller/internal/stacks/deploy.go @@ -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 diff --git a/controller/internal/stacks/installed.go b/controller/internal/stacks/installed.go index 566fdc6..a21f14b 100644 --- a/controller/internal/stacks/installed.go +++ b/controller/internal/stacks/installed.go @@ -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 +} diff --git a/controller/internal/stacks/manager.go b/controller/internal/stacks/manager.go index 40963d1..a1c265e 100644 --- a/controller/internal/stacks/manager.go +++ b/controller/internal/stacks/manager.go @@ -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 diff --git a/controller/internal/stacks/media_library.go b/controller/internal/stacks/media_library.go new file mode 100644 index 0000000..f526613 --- /dev/null +++ b/controller/internal/stacks/media_library.go @@ -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 /userdata/media/ (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 `/userdata/media[/]` 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/` (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 +} diff --git a/controller/internal/stacks/media_library_test.go b/controller/internal/stacks/media_library_test.go new file mode 100644 index 0000000..36edbfb --- /dev/null +++ b/controller/internal/stacks/media_library_test.go @@ -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") + } +} diff --git a/controller/internal/stacks/metadata.go b/controller/internal/stacks/metadata.go index 1894cbb..9d51a44 100644 --- a/controller/internal/stacks/metadata.go +++ b/controller/internal/stacks/metadata.go @@ -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 `/userdata/media[/]` 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) } diff --git a/controller/internal/stacks/restart_real_test.go b/controller/internal/stacks/restart_real_test.go new file mode 100644 index 0000000..f2b822e --- /dev/null +++ b/controller/internal/stacks/restart_real_test.go @@ -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()) + } +} diff --git a/controller/internal/system/mountdead_linux.go b/controller/internal/system/mountdead_linux.go new file mode 100644 index 0000000..4e03570 --- /dev/null +++ b/controller/internal/system/mountdead_linux.go @@ -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) +} diff --git a/controller/internal/system/mountdead_other.go b/controller/internal/system/mountdead_other.go new file mode 100644 index 0000000..ade10b3 --- /dev/null +++ b/controller/internal/system/mountdead_other.go @@ -0,0 +1,6 @@ +//go:build !linux + +package system + +// MountIsShutdown is linux-only; elsewhere nothing is ever dead. +func MountIsShutdown(_ string) bool { return false } diff --git a/controller/internal/system/mountdead_test.go b/controller/internal/system/mountdead_test.go new file mode 100644 index 0000000..93ef990 --- /dev/null +++ b/controller/internal/system/mountdead_test.go @@ -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) + } + } +}