Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 95 additions & 11 deletions docker-entrypoint-sqlite.sh
Original file line number Diff line number Diff line change
Expand Up @@ -72,30 +72,114 @@ 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
# Removing the marker commits the live tree. Keep the backup
# recoverable until that commit record is gone.
rm -f -- "$managed_in_place_marker"
rm -rf -- "$managed_tmp" "$managed_previous"
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
# The restored tree becomes authoritative once the marker is
# removed; cleanup of its backup may safely resume on restart.
rm -f -- "$managed_in_place_marker"
rm -rf -- "$managed_previous"
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
Expand Down
155 changes: 153 additions & 2 deletions tests/test-entrypoint-reconcile.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,21 @@ src_content="${prepare_dir}/wp-content"
dst_content="${docroot}/wp-content"
src_plugin="${src_content}/mu-plugins/sqlite-database-integration"
dst_plugin="${dst_content}/mu-plugins/sqlite-database-integration"
managed_previous="${dst_content}/mu-plugins/.sqlite-database-integration.previous"
managed_marker="${managed_previous}.in-place"

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"
Expand Down Expand Up @@ -66,6 +79,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"

Expand All @@ -84,8 +98,145 @@ 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

# Emulate interruption immediately after the marker is removed but before its
# recovery backup is deleted. The live copy is already complete, so the next
# startup must treat it as authoritative and finish cleanup without requiring
# manual removal of an invalid marker.
real_rm="$(command -v rm)"
cleanup_fail_file="${fixture_root}/cleanup-failed-once"
# 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' \
'set -Eeuo pipefail' \
'target="${SQLITE_TEST_RM_FAIL_PATH:-}"' \
'marker="${SQLITE_TEST_RM_MARKER_PATH:-}"' \
'fail_file="${SQLITE_TEST_RM_FAIL_ONCE_FILE:-}"' \
'matched=false' \
'filtered=()' \
'for argument in "$@"; do' \
' if [ -n "${target}" ] && [ "${argument}" = "${target}" ]; then' \
' matched=true' \
' else' \
' filtered+=("${argument}")' \
' fi' \
'done' \
'if [ "${matched}" = true ] && [ ! -e "${marker}" ] && [ -e "${target}" ] && [ ! -e "${fail_file}" ]; then' \
' "${SQLITE_TEST_REAL_RM}" "${filtered[@]}"' \
' : > "${fail_file}"' \
' exit 73' \
'fi' \
'exec "${SQLITE_TEST_REAL_RM}" "$@"' \
> "${stub_bin}/rm"
chmod +x "${stub_bin}/rm"

printf 'cleanup-order integration file\n' > "${src_plugin}/current.php"
if PATH="${stub_bin}:${PATH}" \
SQLITE_TEST_MV_EBUSY_PATH="${dst_plugin}" \
SQLITE_TEST_REAL_MV="${real_mv}" \
SQLITE_TEST_RM_FAIL_PATH="${managed_previous}" \
SQLITE_TEST_RM_MARKER_PATH="${managed_marker}" \
SQLITE_TEST_RM_FAIL_ONCE_FILE="${cleanup_fail_file}" \
SQLITE_TEST_REAL_RM="${real_rm}" \
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; then
echo "injected post-commit cleanup failure unexpectedly succeeded" >&2
exit 1
fi
test -f "${cleanup_fail_file}"
test ! -e "${managed_marker}"
test -d "${managed_previous}"
diff -qr "${src_plugin}" "${dst_plugin}"

PATH="${stub_bin}:${PATH}" \
SQLITE_TEST_MV_EBUSY_PATH="${dst_plugin}" \
SQLITE_TEST_REAL_MV="${real_mv}" \
SQLITE_TEST_RM_FAIL_PATH="${managed_previous}" \
SQLITE_TEST_RM_MARKER_PATH="${managed_marker}" \
SQLITE_TEST_RM_FAIL_ONCE_FILE="${cleanup_fail_file}" \
SQLITE_TEST_REAL_RM="${real_rm}" \
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 ! -e "${managed_previous}"
diff -qr "${src_plugin}" "${dst_plugin}"
assert_no_reconcile_artifacts
rm -f -- "${stub_bin}/rm"

# 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.
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.
Expand Down