fix(web-app-moodle): swap the code tree only after a complete copy

The entrypoint removed the old tree before it copied the new one. A
copy that failed left the volume without a working Moodle, and every
restart removed again instead of recovering. It now copies into a
staging directory inside the volume and swaps the trees once the copy
is complete, so a failed copy leaves the old tree in place.

An image that ships an older release than the volume records no longer
replaces it and exits with a message instead. Two instances on
different releases would otherwise overwrite each other's tree.

upgrade.php reports "No upgrade needed" on stderr, so the task counted
every run on a current site as a change. It reads stderr now.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5.5 committed 2026-10-07 10:45:29 +02:00
1 parent 0ecb2bc4fc
commit 0c748f74ed
4 files changed
+41 -4

No files matched your search

+1 -1
View File
@@ -177,7 +177,7 @@ This role builds its own Moodle image from upstream Moodle source on top of the
## Updates
The image carries the Moodle release pinned as `services.moodle.app_version` in `meta/services.yml`. On start the entrypoint compares that release with the one recorded in the code volume and replaces the volume's tree with the image's when they differ. `tasks/01_manager_ops.yml` then runs `admin/cli/upgrade.php`, which migrates the database.
The image carries the Moodle release pinned as `services.moodle.app_version` in `meta/services.yml`. On start the entrypoint compares that release with the one recorded in the code volume. When the image is newer, it copies the image's tree next to the old one and swaps them once the copy is complete. An image that is older than the recorded release refuses to start. `tasks/01_manager_ops.yml` then runs `admin/cli/upgrade.php`, which migrates the database.
Plugins installed through the web interface live only in the code volume and are not carried over to the next release. Declare a plugin under `meta/addons/` so the image ships it.
@@ -18,6 +18,7 @@ set -euo pipefail
MOODLE_BOOTSTRAP_SENTINEL="${MOODLE_CODE_DIR}/.bootstrap.done"
MOODLE_BOOTSTRAP_LOCK="${MOODLE_CODE_DIR}/.bootstrap.lock"
MOODLE_BOOTSTRAP_STAGE="${MOODLE_CODE_DIR}/.bootstrap.stage"
mkdir -p "${MOODLE_DATA_DIR}"
chown -R "${MOODLE_RUNTIME_USER}:${MOODLE_RUNTIME_USER}" "${MOODLE_DATA_DIR}" || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error
@@ -31,12 +32,28 @@ moodle_code_dir_is_current() {
[ "$(cat "${MOODLE_BOOTSTRAP_SENTINEL}" 2>/dev/null)" = "${MOODLE_RELEASE}" ]
}
moodle_code_dir_is_newer() {
local recorded newest
recorded="$(cat "${MOODLE_BOOTSTRAP_SENTINEL}" 2>/dev/null)" || return 1
[ -n "${recorded}" ] || return 1
newest="$(printf '%s\n' "${recorded}" "${MOODLE_RELEASE}" | sort -V | tail -n 1)"
[ "${newest}" = "${recorded}" ] && [ "${recorded}" != "${MOODLE_RELEASE}" ]
}
moodle_bootstrap_code_dir() {
if moodle_code_dir_is_current; then
return 0
fi
find "${MOODLE_CODE_DIR}" -mindepth 1 -maxdepth 1 ! -name "${MOODLE_BOOTSTRAP_LOCK##*/}" -exec rm -rf {} +
cp -a "${MOODLE_SOURCE_DIR}/." "${MOODLE_CODE_DIR}/"
if moodle_code_dir_is_newer; then
echo "The code volume holds Moodle $(cat "${MOODLE_BOOTSTRAP_SENTINEL}"), this image ships the older ${MOODLE_RELEASE}; refusing to replace it." >&2
exit 1
fi
rm -rf "${MOODLE_BOOTSTRAP_STAGE}"
mkdir "${MOODLE_BOOTSTRAP_STAGE}"
cp -a "${MOODLE_SOURCE_DIR}/." "${MOODLE_BOOTSTRAP_STAGE}/"
find "${MOODLE_CODE_DIR}" -mindepth 1 -maxdepth 1 ! -name "${MOODLE_BOOTSTRAP_LOCK##*/}" ! -name "${MOODLE_BOOTSTRAP_STAGE##*/}" -exec rm -rf {} +
find "${MOODLE_BOOTSTRAP_STAGE}" -mindepth 1 -maxdepth 1 -exec mv -t "${MOODLE_CODE_DIR}" {} +
rmdir "${MOODLE_BOOTSTRAP_STAGE}"
chown -R "${MOODLE_RUNTIME_USER}:${MOODLE_RUNTIME_USER}" "${MOODLE_CODE_DIR}" || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error
find "${MOODLE_CODE_DIR}" -type d -exec chmod 0755 {} + || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error
find "${MOODLE_CODE_DIR}" -type f -exec chmod 0644 {} + || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error
@@ -53,7 +53,7 @@
container exec --user {{ MOODLE_RUNTIME_USER }} {{ MOODLE_CONTAINER_ADDRESS }}
php {{ MOODLE_CLI_DIR }}/upgrade.php --non-interactive
register: moodle_upgrade
changed_when: "'No upgrade needed' not in moodle_upgrade.stdout"
changed_when: "'No upgrade needed' not in moodle_upgrade.stderr"
- name: "🔑 Realign the administrator password Moodle persisted at install"
when: not (MOODLE_OIDC_ENABLED | bool)
@@ -7,6 +7,7 @@ import unittest
from pathlib import Path
from utils import PROJECT_ROOT
from utils.cache.files import read_text
ENTRYPOINT = PROJECT_ROOT / "roles/web-app-moodle/files/build/moodle-entrypoint.sh"
@@ -99,6 +100,25 @@ class TestMoodleEntrypoint(unittest.TestCase):
self.assertNotEqual(done.returncode, 0)
self.assertFalse((self.code / ".bootstrap.done").exists())
def test_a_failed_copy_keeps_the_tree_the_volume_held(self):
self.ship(LAYOUT_4)
self.start("4.5.11")
shutil.rmtree(self.source)
done = self.run_entrypoint("5.3.0")
self.assertNotEqual(done.returncode, 0)
self.assertEqual(self.shipped_files_in_the_volume(), set(LAYOUT_4))
self.assertEqual(read_text(str(self.code / ".bootstrap.done")), "4.5.11")
def test_an_image_older_than_the_recorded_release_refuses_to_replace_it(self):
self.ship(LAYOUT_5)
self.start("5.10.0")
self.ship(LAYOUT_4)
done = self.run_entrypoint("5.9.2")
self.assertNotEqual(done.returncode, 0)
self.assertIn("5.10.0", done.stderr)
self.assertEqual(self.shipped_files_in_the_volume(), set(LAYOUT_5))
self.assertEqual(read_text(str(self.code / ".bootstrap.done")), "5.10.0")
if __name__ == "__main__":
unittest.main()