diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 8c7e85b..bff1e59 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,24 +1,63 @@ #!/bin/sh -# Sync image-baked plugins into the /config (datadir) plugins directory on every -# start. /config is a PVC that overlays the image, so plugins baked into the -# image are invisible until copied in here. Removing any existing versioned dir -# of the same plugin first lets the image version win across restarts/downgrades. +# Sync image-baked plugins into the /config (datadir) plugins directory on +# start. /config is an RWX volume shared across replicas, so plugins baked +# into the image are invisible until copied in here -- and this must be safe +# when several replicas start (or restart) at the same instant: +# - skip entirely once the correct version is already in place, so the +# steady state (almost every start) never touches the shared volume; +# - install a new/changed version via copy-to-staging + atomic rename, so +# no reader (another replica, or this container's own jellyfin process) +# ever observes a partially-written plugin directory. A replica that +# loses the rename race just discards its own copy -- that's success, +# not an error; +# - drop stale, differently-versioned copies of the same plugin afterwards +# so they don't shadow the current one. Best-effort: another replica may +# already be doing, or have finished, the same cleanup. +# Deliberately no locking: a lock held by a replica that dies mid-sync would +# wedge every future start on this volume, which is worse than the race it +# would prevent. set -eu BAKED_DIR=/usr/share/jellyfin/plugins-baked PLUGIN_DIR=/config/plugins +STAGING_DIR="$PLUGIN_DIR/.sync-tmp" +REPLICA=$(hostname) if [ -d "$BAKED_DIR" ]; then - mkdir -p "$PLUGIN_DIR" + mkdir -p "$PLUGIN_DIR" "$STAGING_DIR" for src in "$BAKED_DIR"/*; do [ -d "$src" ] || continue - name=$(basename "$src") # e.g. "LDAP Authentication_22.0.0.0" + name=$(basename "$src") # e.g. "LDAP Authentication_24.0.0.0" base=${name%_*} # plugin name without the trailing _ + target="$PLUGIN_DIR/$name" + + if [ ! -d "$target" ]; then + # Build the new version privately (keyed by this replica's own + # hostname, so concurrent replicas never share a staging path), then + # move it into place in one atomic rename. mv -T fails with + # "Directory not empty" if another replica's rename already won -- + # that's fine, our copy just becomes garbage we discard. + staging="$STAGING_DIR/$REPLICA.$name" + rm -rf "$staging" + cp -a "$src" "$staging" + if mv_err=$(mv -T "$staging" "$target" 2>&1); then + : + elif [ -d "$target" ]; then + rm -rf "$staging" + else + echo "docker-entrypoint: failed to install plugin $name: $mv_err" >&2 + exit 1 + fi + fi + + # Remove any other version of this plugin so it can't shadow the one + # above. Another replica may be racing the same cleanup, or have already + # finished it -- an entry that's already gone is success, not an error. for existing in "$PLUGIN_DIR/$base"_*; do - [ -e "$existing" ] && rm -rf "$existing" + [ -e "$existing" ] || continue + [ "$existing" = "$target" ] && continue + rm -rf "$existing" 2>/dev/null || true done - rm -rf "$PLUGIN_DIR/$name" - cp -a "$src" "$PLUGIN_DIR/$name" done fi