78c894234c
gates / gates (push) Successful in 46s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
327 lines
11 KiB
Go
327 lines
11 KiB
Go
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
|
|
}
|