fix(deploy): make the view's upper base before installing the view
`ensure_media_view` ran `install` on a host where the documented base directory
did not exist, and `install` refuses by design ("put it on the SSD first") — so
tonight's cutover would have died at the restart step with nobody beside the
box. `media-view.sh ensure-base` now makes the documented directory, and the
deployer calls it first; two seams cover it (the subcommand's behaviour, and the
ordering invariant inside `ensure_media_view`).
Docs flip with the deploy: the view is live on Dankserver (both media servers
verified resolving real media through it), so every "never been deployed" claim
in the view, Plex, Jellyfin and AGENTS pages is corrected, and three laws join
them — a container restart discards the in-flight library scan and nothing
resumes it; Jellyfin 12.4 authenticates only the `MediaBrowser Token` form, so a
401 says nothing about the key until the `ApiKeys` table is read; and Plex
answers 32400 over TLS only, which makes a plaintext health probe read as dead.
This commit is contained in:
1 parent
0336f42e2a
commit
6d79709cc0
6 files changed
+77
-26
No files matched your search
@@ -860,6 +860,6 @@ history.
|
|||||||
| Benchmarking (only when ordered) | **Stop and mask the service first**, bench, unmask, restart, verify health. |
|
| 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`. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| Real secrets | **Never commit** — host `.env` files; `.env.example` holds placeholders only. |
|
||||||
@@ -1212,6 +1212,10 @@ ensure_media_view() {
|
|||||||
# scripts/wait-for-mount.sh, which waits and then refuses the start. A
|
# scripts/wait-for-mount.sh, which waits and then refuses the start. A
|
||||||
# relayout must be followed by `media-view.sh reap`
|
# relayout must be followed by `media-view.sh reap`
|
||||||
# (see the Media Relayout page), which this is not.
|
# (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
|
run_privileged bash scripts/media-view.sh install
|
||||||
bash scripts/media-view.sh verify
|
bash scripts/media-view.sh verify
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-1
@@ -60,7 +60,7 @@ die() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
usage() {
|
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
|
exit 2
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +98,22 @@ require_upper_base() {
|
|||||||
|| die "refused: upper base $upper_base does not exist (put it on the SSD first)"
|
|| 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() {
|
do_plan() {
|
||||||
printf 'lowerdir=%s\nupperdir=%s\nworkdir=%s\nmountpoint=%s\nunit=%s/%s\n' \
|
printf 'lowerdir=%s\nupperdir=%s\nworkdir=%s\nmountpoint=%s\nunit=%s/%s\n' \
|
||||||
"$lower" "$upper" "$work" "$VIEW" "$UNIT_DIR" "$UNIT"
|
"$lower" "$upper" "$work" "$VIEW" "$UNIT_DIR" "$UNIT"
|
||||||
@@ -234,6 +250,7 @@ do_status() {
|
|||||||
case "$cmd" in
|
case "$cmd" in
|
||||||
plan) do_plan ;;
|
plan) do_plan ;;
|
||||||
verify) do_verify ;;
|
verify) do_verify ;;
|
||||||
|
ensure-base) do_ensure_base ;;
|
||||||
install) do_install ;;
|
install) do_install ;;
|
||||||
reap) do_reap ;;
|
reap) do_reap ;;
|
||||||
status) do_status ;;
|
status) do_status ;;
|
||||||
|
|||||||
@@ -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
|
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
|
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
|
is the [[Media Metadata View]] overlay, live since the 2026-10-31 cutover:
|
||||||
array, and `scripts/media-view.sh status` reports `NOT MOUNTED` (2026-10-31).
|
`/var/lib/media-meta/view:/data`, read-write.
|
||||||
|
|
||||||
`Local metadata` — save metadata to media folders — is enabled, and it is the
|
`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
|
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
|
written once on the NVMe upper layer are files [[Plex]] can read too — and since
|
||||||
`/data` is bound read-only every item update logs the error below and no sidecar
|
the 2026-10-31 cutover that is what happens: Jellyfin is the fleet's only
|
||||||
is written; Jellyfin becomes the fleet's only metadata writer with the deploy
|
metadata writer, and every sidecar it saves is an SSD byte the array never sees.
|
||||||
that raises the view.
|
|
||||||
|
|
||||||
**Failure signature:** `System.IO.IOException: Read-only file system :
|
**Failure signature:** `System.IO.IOException: Read-only file system :
|
||||||
'/data/Movies/<film>/movie.nfo'` means the container is binding the raw array
|
'/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
|
instead of the view — always a fault on this host since the 2026-10-31 cutover.
|
||||||
never been installed (`scripts/media-view.sh status`). Once it is deployed, the
|
`scripts/media-view.sh status` says which: the quadlet drifted back to the raw
|
||||||
same line is a fault: the quadlet drifted or the mount died.
|
array, or the mount died.
|
||||||
|
|
||||||
## Ports & access
|
## 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
|
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
|
`Scan Media Library` to `Cancelling`, and once the runner holds `Cancelling` every
|
||||||
later `204` is accepted and then discarded — the status code stops meaning
|
later `204` is accepted and then discarded — the status code stops meaning
|
||||||
anything. The fix is `systemctl --user restart jellyfin.service` (never
|
anything. The fix is `systemctl --user restart jellyfin.service`, one POST after
|
||||||
`deploy.sh`: that runs the never-tested metadata-view cutover), one POST after
|
|
||||||
health answers, and then believe `ScheduledTasks` State at 130 % CPU, not the
|
health answers, and then believe `ScheduledTasks` State at 130 % CPU, not the
|
||||||
response code.
|
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
|
merged door are one directory — and so the mount table grows no new `/mnt` entry
|
||||||
next to the array.
|
next to the array.
|
||||||
|
|
||||||
**Not deployed on Dankserver — verified 2026-10-31.** `scripts/media-view.sh
|
**Deployed on Dankserver — 2026-10-31, both servers.** `plex` binds
|
||||||
status` answers `view: /var/lib/media-meta/view NOT MOUNTED`,
|
`/var/lib/media-meta/view:/mnt/entropy:ro` and `jellyfin` binds
|
||||||
`/var/lib/media-meta` does not exist, and the installed quadlets under
|
`/var/lib/media-meta/view:/data:rw`; `findmnt` reports the overlay with
|
||||||
`~/.config/containers/systemd/` still carry the pre-view binds — `/mnt/entropy:/data:ro`
|
`lowerdir=/mnt/entropy upperdir=/var/lib/media-meta/upper`, and each container
|
||||||
for [[Jellyfin]], `/mnt/entropy:/mnt/entropy:ro` for [[Plex]]. Everything below
|
was proven to resolve a real media file through it — `podman exec <svc> ls -la
|
||||||
is the shape git describes, not the shape the servers run. The cutover is an
|
<its data path>` is the whole proof, because both servers keep the paths their
|
||||||
ordinary deploy: `deploy.sh dankserver --service jellyfin|plex` runs
|
databases recorded. Two service-scoped deploys, each gated by
|
||||||
`ensure_media_view` (`install` then `verify`) before it restarts the container,
|
`media-view.sh verify` and the Playwright suite.
|
||||||
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
|
Moving a bind means restarting the server, and **a restart discards that
|
||||||
`reap` has nothing to do — `media-apply.py` prints that follow-up only when the
|
server's in-flight library scan** — Jellyfin's 7 h 25 m walk ended
|
||||||
view really is a mountpoint.
|
`"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
|
## 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`. |
|
| 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`. |
|
| 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. |
|
| `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. |
|
| `systemctl status` finds no such unit | Look under the escaped name, `var-lib-media\x2dmeta-view.mount`, not the repo file name. |
|
||||||
|
|
||||||
## Rollback
|
## Rollback
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
# 4. both media units bind the VIEW and refuse to start without it;
|
# 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
|
# 5. only those two units touch the view — the LLM and library tenants keep the
|
||||||
# raw volume, so the blast radius is two containers;
|
# 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.
|
# 7. the planner routes a change to these files to both media services.
|
||||||
|
|
||||||
load helpers
|
load helpers
|
||||||
@@ -110,6 +111,30 @@ UNIT="$REPO_ROOT/dankserver/host/systemd/media-meta-view.mount"
|
|||||||
done
|
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" {
|
@test "the planner routes the view files to both media services" {
|
||||||
run python3 "$REPO_ROOT/scripts/deploy-plan.py" --target dankserver --paths \
|
run python3 "$REPO_ROOT/scripts/deploy-plan.py" --target dankserver --paths \
|
||||||
scripts/media-view.sh dankserver/host/systemd/media-meta-view.mount
|
scripts/media-view.sh dankserver/host/systemd/media-meta-view.mount
|
||||||
|
|||||||
Reference in new issue
Block a user