fix(deploy): make the view's upper base before installing the view #113
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