From a5e1c461a0df32291e1959f8d34157c0fb2a37fc Mon Sep 17 00:00:00 2001 From: Su Yang Date: Mon, 31 Aug 2026 04:05:07 +0800 Subject: [PATCH] fix: reconcile separately mounted SQLite plugin trees --- docker-entrypoint-sqlite.sh | 102 +++++++++++++++++++++++++---- tests/test-entrypoint-reconcile.sh | 84 +++++++++++++++++++++++- 2 files changed, 173 insertions(+), 13 deletions(-) diff --git a/docker-entrypoint-sqlite.sh b/docker-entrypoint-sqlite.sh index 43258b6..4a52aad 100755 --- a/docker-entrypoint-sqlite.sh +++ b/docker-entrypoint-sqlite.sh @@ -72,30 +72,110 @@ if [ -d "$src_content" ] && [ -d "$DOCROOT" ]; then managed_plugin="sqlite-database-integration" managed_src="$src_mu_plugins/$managed_plugin" managed_dst="$dst_mu_plugins/$managed_plugin" - managed_tmp="$dst_mu_plugins/.${managed_plugin}.new.$$" managed_previous="$dst_mu_plugins/.${managed_plugin}.previous" + managed_in_place_marker="${managed_previous}.in-place" + + clear_managed_directory() { + find "$1" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + + } if [ -d "$src_mu_plugins" ]; then mkdir -p "$dst_mu_plugins" + # An in-place replacement is used when the managed directory is itself a + # mount point and therefore cannot be renamed. Restore its saved contents + # before retrying if the previous container stopped during that operation. + if [ -e "$managed_in_place_marker" ] || [ -L "$managed_in_place_marker" ]; then + if [ -L "$managed_in_place_marker" ] \ + || [ ! -f "$managed_in_place_marker" ] \ + || [ ! -d "$managed_previous" ] \ + || [ -L "$managed_previous" ] \ + || [ ! -d "$managed_dst" ] \ + || [ -L "$managed_dst" ]; then + echo >&2 "sqlite: invalid interrupted in-place replacement state" + exit 1 + fi + echo >&2 "sqlite: restoring interrupted in-place mu-plugin replacement" + if clear_managed_directory "$managed_dst" \ + && cp -a "$managed_previous/." "$managed_dst/"; then + rm -f -- "$managed_in_place_marker" + rm -rf -- "$managed_previous" + else + echo >&2 "sqlite: could not restore interrupted mu-plugin replacement" + exit 1 + fi + fi + # Recover a replacement interrupted after the old tree was moved aside. if [ ! -e "$managed_dst" ] && [ ! -L "$managed_dst" ] && [ -e "$managed_previous" ]; then - mv "$managed_previous" "$managed_dst" + if [ -d "$managed_previous" ] && [ ! -L "$managed_previous" ]; then + mv "$managed_previous" "$managed_dst" + else + echo >&2 "sqlite: invalid interrupted mu-plugin replacement state" + exit 1 + fi + elif { [ -e "$managed_dst" ] || [ -L "$managed_dst" ]; } \ + && { [ -e "$managed_previous" ] || [ -L "$managed_previous" ]; }; then + # The live rename completed but cleanup did not. The live directory is + # authoritative because a normal rename is atomic. + rm -rf -- "$managed_previous" fi - if [ -d "$managed_src" ] && { [ ! -d "$managed_dst" ] || ! diff -qr "$managed_src" "$managed_dst" >/dev/null 2>&1; }; then + if [ -d "$managed_src" ] \ + && { [ -L "$managed_dst" ] || [ ! -d "$managed_dst" ] || ! diff -qr "$managed_src" "$managed_dst" >/dev/null 2>&1; }; then echo >&2 "sqlite: replacing managed mu-plugin directory $managed_plugin" - rm -rf "$managed_tmp" - cp -a "$managed_src" "$managed_tmp" - rm -rf "$managed_previous" + managed_tmp="$(mktemp -d "$dst_mu_plugins/.${managed_plugin}.new.XXXXXX")" + cp -a "$managed_src/." "$managed_tmp/" + chmod --reference="$managed_src" "$managed_tmp" + rm -rf -- "$managed_previous" + rm -f -- "$managed_in_place_marker" + managed_in_place=false + managed_moved_previous=false if [ -e "$managed_dst" ] || [ -L "$managed_dst" ]; then - mv "$managed_dst" "$managed_previous" + if mv "$managed_dst" "$managed_previous"; then + managed_moved_previous=true + elif [ -d "$managed_dst" ] \ + && [ ! -L "$managed_dst" ] \ + && [ ! -e "$managed_previous" ] \ + && [ ! -L "$managed_previous" ]; then + echo >&2 "sqlite: managed mu-plugin directory cannot be renamed; reconciling it in place" + mkdir "$managed_previous" + if cp -a "$managed_dst/." "$managed_previous/"; then + : > "$managed_in_place_marker" + managed_in_place=true + else + rm -rf -- "$managed_tmp" "$managed_previous" + exit 1 + fi + else + rm -rf -- "$managed_tmp" + echo >&2 "sqlite: could not move or safely reconcile managed mu-plugin directory" + exit 1 + fi fi - if mv "$managed_tmp" "$managed_dst"; then - rm -rf "$managed_previous" + + if [ "$managed_in_place" = true ]; then + if clear_managed_directory "$managed_dst" \ + && cp -a "$managed_tmp/." "$managed_dst/"; then + rm -rf -- "$managed_tmp" "$managed_previous" + rm -f -- "$managed_in_place_marker" + else + echo >&2 "sqlite: in-place mu-plugin replacement failed; restoring previous contents" + if clear_managed_directory "$managed_dst" \ + && cp -a "$managed_previous/." "$managed_dst/"; then + rm -rf -- "$managed_previous" + rm -f -- "$managed_in_place_marker" + else + echo >&2 "sqlite: rollback failed; saved contents remain at $managed_previous" + fi + rm -rf -- "$managed_tmp" + exit 1 + fi + elif mv "$managed_tmp" "$managed_dst"; then + rm -rf -- "$managed_previous" else - rm -rf "$managed_tmp" - if [ -e "$managed_previous" ]; then + rm -rf -- "$managed_tmp" + if [ "$managed_moved_previous" = true ] && [ -e "$managed_previous" ]; then mv "$managed_previous" "$managed_dst" fi exit 1 diff --git a/tests/test-entrypoint-reconcile.sh b/tests/test-entrypoint-reconcile.sh index a4584b7..9ea4aaf 100755 --- a/tests/test-entrypoint-reconcile.sh +++ b/tests/test-entrypoint-reconcile.sh @@ -14,7 +14,18 @@ dst_content="${docroot}/wp-content" src_plugin="${src_content}/mu-plugins/sqlite-database-integration" dst_plugin="${dst_content}/mu-plugins/sqlite-database-integration" +assert_no_reconcile_artifacts() { + test -z "$( + find "${dst_content}/mu-plugins" -mindepth 1 -maxdepth 1 \ + \( -name '.sqlite-database-integration.new.*' \ + -o -name '.sqlite-database-integration.previous' \ + -o -name '.sqlite-database-integration.previous.in-place' \) \ + -print -quit + )" +} + mkdir -p "${stub_bin}" "${src_plugin}" "${dst_plugin}" "${dst_content}/database" +chmod 0751 "${src_plugin}" printf '#!/usr/bin/env bash\nexit 0\n' > "${stub_bin}/docker-ensure-installed.sh" chmod +x "${stub_bin}/docker-ensure-installed.sh" printf "#!/usr/bin/env bash\ntest \"\${SQLITE_WORDPRESS_SITE_URL_UPDATE_TOKEN_RESOLVED:-}\" = \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n" > "${stub_bin}/assert-site-url-token" @@ -66,6 +77,7 @@ test ! -L "${docroot}/tool-update-site-url.php" test "$(stat -c '%a' "${docroot}/tool-update-site-url.php")" = '644' grep -Fxq 'outside recovery tool must remain unchanged' "${fixture_root}/outside-recovery-tool" diff -qr "${src_plugin}" "${dst_plugin}" +test "$(stat -c '%a' "${dst_plugin}")" = "$(stat -c '%a' "${src_plugin}")" test ! -e "${dst_plugin}/stale.php" test -f "${dst_content}/mu-plugins/custom.php" @@ -84,8 +96,76 @@ grep -Fxq 'outside loader must remain unchanged' "${fixture_root}/outside-loader test -f "${dst_content}/database/.ht.sqlite" test ! -e "${recovery_state_file}" test ! -e "${recovery_lock_file}" -test ! -e "${dst_content}/mu-plugins/.sqlite-database-integration.previous" -test ! -e "${dst_content}/mu-plugins/.sqlite-database-integration.new.$$" +assert_no_reconcile_artifacts + +# A persisted symlink must be replaced even when its target already has exactly +# the source contents. A plain diff dereferences the link and would otherwise +# incorrectly leave a root-managed symlink in place. +matching_external_plugin="${fixture_root}/matching-external-plugin" +cp -a "${src_plugin}" "${matching_external_plugin}" +rm -rf "${dst_plugin}" +ln -s "${matching_external_plugin}" "${dst_plugin}" +PATH="${stub_bin}:${PATH}" \ + WORDPRESS_PREPARE_DIR="${prepare_dir}" \ + WORDPRESS_DOCROOT="${docroot}" \ + APACHE_RUN_USER="$(id -u)" \ + APACHE_RUN_GROUP="$(id -g)" \ + bash "${repo_root}/docker-entrypoint-sqlite.sh" true +test ! -L "${dst_plugin}" +diff -qr "${src_plugin}" "${dst_plugin}" +diff -qr "${src_plugin}" "${matching_external_plugin}" +assert_no_reconcile_artifacts + +# Simulate the EBUSY returned when the managed directory is a separate bind or +# named-volume mount. The fallback must keep the mounted root directory while +# making its contents exactly match the image-managed source. +real_mv="$(command -v mv)" +# The generated wrapper must expand these variables when it runs, not while the +# fixture is being written. +# shellcheck disable=SC2016 +printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'if [ "${1:-}" = "${SQLITE_TEST_MV_EBUSY_PATH:-}" ]; then' \ + ' echo "mv: cannot move mount point: Device or resource busy" >&2' \ + ' exit 32' \ + 'fi' \ + 'exec "${SQLITE_TEST_REAL_MV}" "$@"' \ + > "${stub_bin}/mv" +chmod +x "${stub_bin}/mv" +printf 'new mounted integration file\n' > "${src_plugin}/current.php" +printf 'stale mounted integration file\n' > "${dst_plugin}/stale.php" +mounted_root_inode="$(stat -c '%i' "${dst_plugin}")" +PATH="${stub_bin}:${PATH}" \ + SQLITE_TEST_MV_EBUSY_PATH="${dst_plugin}" \ + SQLITE_TEST_REAL_MV="${real_mv}" \ + WORDPRESS_PREPARE_DIR="${prepare_dir}" \ + WORDPRESS_DOCROOT="${docroot}" \ + APACHE_RUN_USER="$(id -u)" \ + APACHE_RUN_GROUP="$(id -g)" \ + bash "${repo_root}/docker-entrypoint-sqlite.sh" true +test "$(stat -c '%i' "${dst_plugin}")" = "${mounted_root_inode}" +diff -qr "${src_plugin}" "${dst_plugin}" +test -f "${dst_content}/mu-plugins/custom.php" +assert_no_reconcile_artifacts + +# Recover a process interruption during the in-place copy before comparing the +# live tree with the packaged source. The artifact assertion uses a wildcard so +# it checks the child entrypoint's unique staging name, not the parent's PID. +managed_previous="${dst_content}/mu-plugins/.sqlite-database-integration.previous" +managed_marker="${managed_previous}.in-place" +mkdir "${managed_previous}" +cp -a "${src_plugin}/." "${managed_previous}/" +printf 'in-place\n' > "${managed_marker}" +find "${dst_plugin}" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + +printf 'partial interrupted copy\n' > "${dst_plugin}/partial.php" +PATH="${stub_bin}:${PATH}" \ + WORDPRESS_PREPARE_DIR="${prepare_dir}" \ + WORDPRESS_DOCROOT="${docroot}" \ + APACHE_RUN_USER="$(id -u)" \ + APACHE_RUN_GROUP="$(id -g)" \ + bash "${repo_root}/docker-entrypoint-sqlite.sh" true +diff -qr "${src_plugin}" "${dst_plugin}" +assert_no_reconcile_artifacts # An exact enabled start preserves the one-shot latch, so restarting the same # recovery configuration cannot reopen an authorization that was already used.