Media build part 1 (unreleased): real restart (R-1026), media apps on every PRESENT drive via an owned compose override (R-1023), dead-drive check from mountinfo, removal never deletes shared/:ro binds (R-1027), safety list knows userdata (R-1032), no drive question for media apps (R-1031)
gates / gates (push) Successful in 46s
gates / gates (push) Successful in 46s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user