fix(deploy): make the view's upper base before installing the view #113

Merged
dank merged 1 commits from fix/media-view-ensure-base into main 2026-10-31 19:36:53 -07:00
6 changed files with 77 additions and 26 deletions
Showing only changes of commit 6d79709cc0 - Show all commits

No files matched your search

+1 -1
View File
@@ -860,6 +860,6 @@ history.
| Benchmarking (only when ordered) | **Stop and mask the service first**, bench, unmask, restart, verify health. |
| Deleting a file | **Move it to trash** — `.AGENTTRASH/` (in-repo) or `~/.AGENTTRASH/YYYYMMDD-topic`. Never `rm`. |
| Relayout the movie library | **Census → plan → read the exceptions → `scripts/media-apply.py`** — renames only, journal before every move; never hand-rename, never copy. |
| Making media metadata visible to both media servers | **Bind `/var/lib/media-meta/view`** — the overlay `scripts/media-view.sh` installs (array below, NVMe above). Never write the array; never delete through the view. **Not yet deployed on Dankserver: the live quadlets still bind the array read-only — see [[Media Metadata View]].** |
| Making media metadata visible to both media servers | **Bind `/var/lib/media-meta/view`** — the overlay `scripts/media-view.sh` installs (array below, NVMe above). Never write the array; never delete through the view. **Deployed on Dankserver 2026-10-31 — both media servers bind it; a restart for any reason discards the in-flight library scan, so re-trigger the refresh — see [[Media Metadata View]].** |
| Tests exist | **Run them** — verify tests pass after every change. Do not commit failing tests. |
| Real secrets | **Never commit** — host `.env` files; `.env.example` holds placeholders only. |
+4
View File
@@ -1212,6 +1212,10 @@ ensure_media_view() {
# scripts/wait-for-mount.sh, which waits and then refuses the start. A
# relayout must be followed by `media-view.sh reap`
# (see the Media Relayout page), which this is not.
# A fresh host has no upper base, and `install` refuses to invent one — so
# without this the media deploy dies on a rebuilt machine with
# "put it on the SSD first" (measured 2026-10-31).
run_privileged bash scripts/media-view.sh ensure-base
run_privileged bash scripts/media-view.sh install
bash scripts/media-view.sh verify
}
+18 -1
View File
@@ -60,7 +60,7 @@ die() {
}
usage() {
printf 'usage: %s plan|verify|install|reap|status [options]\n' "$0" >&2
printf 'usage: %s plan|ensure-base|verify|install|reap|status [options]\n' "$0" >&2
exit 2
}
@@ -98,6 +98,22 @@ require_upper_base() {
|| die "refused: upper base $upper_base does not exist (put it on the SSD first)"
}
# The SSD placement of the metadata layer is a human choice, so `install` refuses
# to invent a base directory: a mistyped --upper-base must not quietly create a
# tree on whatever filesystem the caller landed on. The deployer owns the
# documented location, so it calls this first — measured 2026-10-31, when a
# fresh-host `install` died with "put it on the SSD first" and nothing in git
# would have created it.
do_ensure_base() {
if [ -d "$upper_base" ]; then
printf 'base: %s exists\n' "$upper_base"
return 0
fi
install -d -m 0755 "$upper_base" 2>/dev/null \
|| die "refused: cannot create $upper_base — it lives outside the checkout, run this as root"
printf 'base: created %s\n' "$upper_base"
}
do_plan() {
printf 'lowerdir=%s\nupperdir=%s\nworkdir=%s\nmountpoint=%s\nunit=%s/%s\n' \
"$lower" "$upper" "$work" "$VIEW" "$UNIT_DIR" "$UNIT"
@@ -234,6 +250,7 @@ do_status() {
case "$cmd" in
plan) do_plan ;;
verify) do_verify ;;
ensure-base) do_ensure_base ;;
install) do_install ;;
reap) do_reap ;;
status) do_status ;;
+9 -11
View File
@@ -42,21 +42,20 @@ public vhost `jelly.franksdank.cloud` behind the [[Anubis]] guard chain →
Library folders point at `/data` paths: stable across container recreation and
identical to the raw array layout below. Which side of that path the container
is on is the open question on this host — the installed unit binds the read-only
array, and `scripts/media-view.sh status` reports `NOT MOUNTED` (2026-10-31).
is the [[Media Metadata View]] overlay, live since the 2026-10-31 cutover:
`/var/lib/media-meta/view:/data`, read-write.
`Local metadata` — save metadata to media folders — is enabled, and it is the
reason the view exists: `movie.nfo`, `tvshow.nfo`, `episode.nfo` and artwork
written once on the NVMe upper layer are files [[Plex]] can read too. While
`/data` is bound read-only every item update logs the error below and no sidecar
is written; Jellyfin becomes the fleet's only metadata writer with the deploy
that raises the view.
written once on the NVMe upper layer are files [[Plex]] can read too — and since
the 2026-10-31 cutover that is what happens: Jellyfin is the fleet's only
metadata writer, and every sidecar it saves is an SSD byte the array never sees.
**Failure signature:** `System.IO.IOException: Read-only file system :
'/data/Movies/<film>/movie.nfo'` means the container is binding the raw array
instead of the view. On Dankserver that is today's expected state — the view has
never been installed (`scripts/media-view.sh status`). Once it is deployed, the
same line is a fault: the quadlet drifted or the mount died.
instead of the view — always a fault on this host since the 2026-10-31 cutover.
`scripts/media-view.sh status` says which: the quadlet drifted back to the raw
array, or the mount died.
## Ports & access
@@ -212,8 +211,7 @@ receives `Authorization: MediaBrowser Token=` with nothing after it (`\\"` answe
One accepted refresh is the whole procedure. A second one while a scan runs moves
`Scan Media Library` to `Cancelling`, and once the runner holds `Cancelling` every
later `204` is accepted and then discarded — the status code stops meaning
anything. The fix is `systemctl --user restart jellyfin.service` (never
`deploy.sh`: that runs the never-tested metadata-view cutover), one POST after
anything. The fix is `systemctl --user restart jellyfin.service`, one POST after
health answers, and then believe `ScheduledTasks` State at 130 % CPU, not the
response code.
@@ -20,18 +20,22 @@ The mount point is not a volume and holds nothing while unmounted. It sits insid
merged door are one directory — and so the mount table grows no new `/mnt` entry
next to the array.
**Not deployed on Dankserver — verified 2026-10-31.** `scripts/media-view.sh
status` answers `view: /var/lib/media-meta/view NOT MOUNTED`,
`/var/lib/media-meta` does not exist, and the installed quadlets under
`~/.config/containers/systemd/` still carry the pre-view binds — `/mnt/entropy:/data:ro`
for [[Jellyfin]], `/mnt/entropy:/mnt/entropy:ro` for [[Plex]]. Everything below
is the shape git describes, not the shape the servers run. The cutover is an
ordinary deploy: `deploy.sh dankserver --service jellyfin|plex` runs
`ensure_media_view` (`install` then `verify`) before it restarts the container,
so it must not be fired through the middle of a library scan. Until that deploy
lands there is no upper layer, so no relayout can leave ghost metadata and
`reap` has nothing to do — `media-apply.py` prints that follow-up only when the
view really is a mountpoint.
**Deployed on Dankserver — 2026-10-31, both servers.** `plex` binds
`/var/lib/media-meta/view:/mnt/entropy:ro` and `jellyfin` binds
`/var/lib/media-meta/view:/data:rw`; `findmnt` reports the overlay with
`lowerdir=/mnt/entropy upperdir=/var/lib/media-meta/upper`, and each container
was proven to resolve a real media file through it — `podman exec <svc> ls -la
<its data path>` is the whole proof, because both servers keep the paths their
databases recorded. Two service-scoped deploys, each gated by
`media-view.sh verify` and the Playwright suite.
Moving a bind means restarting the server, and **a restart discards that
server's in-flight library scan** — Jellyfin's 7 h 25 m walk ended
`"Status": "Aborted"` in `data/ScheduledTasks/<task-id>.js` and nothing resumes
it. Renames made on the array while the view was unmounted are invisible to both
watchers too, so the additive index has to be asked for by hand; the door is an
API key, and the row in the failure table below says what to check when it
refuses.
## Shape
@@ -122,6 +126,9 @@ the defaults are the host values above.
| Jellyfin log: `Read-only file system : '/data/Movies/…/movie.nfo'` | The container is binding the raw array, not the view — the quadlet drifted back or the view is gone. `media-view.sh status`. |
| A folder appears in a media server with artwork/`.nfo` but no video | Ghost metadata from a relayout. `media-view.sh reap`. |
| `verify` prints `refused: …/view is not a mountpoint` | A plain directory is sitting where the overlay belongs. That is the fail-closed guard working; `install` on the box. |
| `install` prints `refused: upper base /var/lib/media-meta does not exist (put it on the SSD first)` | A fresh host. `install` deliberately never creates the base — a mistyped `--upper-base` must not build a metadata tree on the wrong filesystem — so `scripts/media-view.sh ensure-base` makes the documented one, which the deployer calls first. |
| A media server's scan record reads `"Status": "Aborted"` | Its container restarted (a cutover, an image bump, an OOM) and the walk was discarded; systemd's `Restart=always` restarts the *server*, never the scan. Trigger a refresh — see [[Media Relayout]]. |
| The Jellyfin API answers 401 to every token header | Almost always the header **form**, not the key: 12.4 authenticates only `Authorization: MediaBrowser Token="…"` and refuses a bare `X-Emby-Token` for every key, valid or not — and one collapsed backslash in the curl config produces the same 401. `[[Jellyfin]]` owns the block that works. Before concluding anything, prove the key against the server's own table: `select count(*) from ApiKeys where AccessToken = ?` on `jellyfin.db` opened `mode=ro&immutable=1`. |
| `systemctl status` finds no such unit | Look under the escaped name, `var-lib-media\x2dmeta-view.mount`, not the repo file name. |
## Rollback
+26 -1
View File
@@ -10,7 +10,8 @@
# 4. both media units bind the VIEW and refuse to start without it;
# 5. only those two units touch the view — the LLM and library tenants keep the
# raw volume, so the blast radius is two containers;
# 6. the deployer brings the view up BEFORE it restarts either server;
# 6. the deployer creates the upper base and brings the view up BEFORE it
# restarts either server;
# 7. the planner routes a change to these files to both media services.
load helpers
@@ -110,6 +111,30 @@ UNIT="$REPO_ROOT/dankserver/host/systemd/media-meta-view.mount"
done
}
@test "ensure-base creates a base directory that install refuses to invent" {
base="$BATS_TEST_TMPDIR/fresh-base"
run bash "$VIEW" ensure-base --upper-base "$base"
[ "$status" -eq 0 ]
[ -d "$base" ]
# The deployer runs this on every media deploy, so a second call must be a
# no-op and must say so rather than recreate anything.
run bash "$VIEW" ensure-base --upper-base "$base"
[ "$status" -eq 0 ]
[[ "${lines[0]}" == *"exists"* ]]
}
@test "the media deployer creates the upper base before it installs the view" {
body="$(sed -n '/^ensure_media_view()/,/^\}/p' "$REPO_ROOT/scripts/deploy-remote.sh")"
[ -n "$body" ]
base="$(printf '%s\n' "$body" | grep -n 'media-view.sh ensure-base' | head -1 | cut -d: -f1)"
inst="$(printf '%s\n' "$body" | grep -n 'media-view.sh install' | head -1 | cut -d: -f1)"
# Order is the whole test: install refuses a missing base, so an installer
# that runs first dies on a rebuilt host (measured 2026-10-31).
[ -n "$base" ]
[ -n "$inst" ]
[ "$base" -lt "$inst" ]
}
@test "the planner routes the view files to both media services" {
run python3 "$REPO_ROOT/scripts/deploy-plan.py" --target dankserver --paths \
scripts/media-view.sh dankserver/host/systemd/media-meta-view.mount