mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 00:55:07 +00:00
0671848bfa
Reported by @apps3000 in #1770. Upgrading a container from 2.0 or 2.1 with external storage configured restart-loops, and says the database is unreachable while the database is fine. A row hydrated from the database does not get the model's column defaults — only a new model does. So a row written before external_storage_settings.provider existed reads that column as null, and the enum match in isConfigured() throws UnhandledMatchError. That would be a small bug anywhere else. It is not here, because PlatformServiceProvider::boot() reads these settings on every process boot, and boot happens before `artisan migrate` runs. During an upgrade the code is new and the schema is still old, so every artisan command in that window dies — including `projectsend:update`, the one that would have added the column. Reordering the entrypoint or using a lighter readiness probe does not help for that reason; the crash is in the bootstrap, not in the probe. current() now applies the model's declared defaults to any column the hydrated row does not have. That closes the window for every column with a default rather than for the one where it was found, and goes inert the moment the schema is current. The match in isConfigured() is left total on purpose: a default arm would swallow a real unhandled case, and the invariant it needs now holds at the one place the row is read. The probe's message is the other half. It boots the whole application, so it fails both when the database is absent and when the application cannot start, and it reported the second as the first — sending an operator off checking credentials that were never wrong. It now prints the error it actually hit and says which of the two it looks like. Verified end to end against a 2.1-shaped database: `artisan migrate` dies with UnhandledMatchError before the change and completes after it, leaving the row reading as S3 with its bucket intact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QmyH342d8MuW3pDuE9mbtS
114 lines
5.4 KiB
Bash
Executable File
114 lines
5.4 KiB
Bash
Executable File
#!/bin/sh
|
|
set -e
|
|
|
|
# First-boot bootstrap for the official image. Adapted from
|
|
# docker/app/entrypoint.sh, with the differences that matter in production:
|
|
#
|
|
# - The database is external and may not be up yet, so migrations wait
|
|
# for it instead of failing the container into a restart loop.
|
|
# - APP_KEY is generated once and persisted, because a key that changes
|
|
# between restarts invalidates every session and makes every encrypted
|
|
# column unreadable.
|
|
# - Nothing is bind-mounted, so there is no uid juggling to do.
|
|
#
|
|
# Deliberately NOT run: `config:cache`. It stops .env from being read, which
|
|
# silently disables TRUSTED_PROXIES — every visitor then appears to come
|
|
# from the proxy, the login rate limiter treats all users as one attacker,
|
|
# and the download log records the wrong address. See INSTALL.md.
|
|
|
|
cd /var/www/html
|
|
|
|
# storage/ is the declared volume, so anything that must outlive the
|
|
# container lives there. Recreate the tree first: a bind-mounted host
|
|
# directory arrives empty, unlike a named volume which Docker seeds from
|
|
# the image.
|
|
mkdir -p storage/app/files \
|
|
storage/framework/cache \
|
|
storage/framework/sessions \
|
|
storage/framework/views \
|
|
storage/logs
|
|
chown -R www-data:www-data storage
|
|
|
|
# .env is kept on that volume and symlinked into place, so a generated
|
|
# APP_KEY survives container replacement. A key that changes between
|
|
# restarts invalidates every session and makes every encrypted column
|
|
# permanently unreadable — silently, since nothing errors at boot.
|
|
#
|
|
# Operators who set APP_KEY (and the rest) in the environment need none of
|
|
# this: Laravel reads the environment directly and it wins over .env.
|
|
if [ ! -f storage/.env ]; then
|
|
cp .env.example storage/.env
|
|
chown www-data:www-data storage/.env
|
|
fi
|
|
#
|
|
# The symlink is owned by the runtime user, not by root who creates it:
|
|
# fs.protected_symlinks lets a process follow a symlink in a world-writable
|
|
# sticky directory only when it owns the link (or the directory). The image
|
|
# no longer leaves /var/www/html in that state, so this is the second lock
|
|
# on the same door — cheap, and it is what keeps the failure from coming
|
|
# back silently if that directory's mode ever drifts.
|
|
[ -L .env ] || ln -sf storage/.env .env
|
|
chown -h www-data:www-data .env
|
|
|
|
if [ -z "$APP_KEY" ] && ! grep -q '^APP_KEY=base64:' storage/.env; then
|
|
echo "projectsend: generating APP_KEY (first boot, persisted to the storage volume)"
|
|
su-exec www-data php artisan key:generate --force --no-interaction
|
|
fi
|
|
|
|
# Wait for the database. `migrate` against a database still starting up is
|
|
# the single most common first-run failure, and a bare failure here would
|
|
# restart-loop the container with a stack trace instead of a clear message.
|
|
#
|
|
# The probe boots the whole application, so it fails for two quite different
|
|
# reasons: the database really is not there yet, or it is there and the
|
|
# application could not start. Both used to be reported as the first one,
|
|
# which sent an operator off checking credentials that were never wrong
|
|
# (#1770). The last failure is kept and printed, so whichever it was is on
|
|
# screen instead of being guessed at.
|
|
if [ "$1" = "supervisord" ] || [ "$1" = "/usr/bin/supervisord" ]; then
|
|
i=0
|
|
until probe_error=$(su-exec www-data php artisan db:show --quiet 2>&1); do
|
|
i=$((i + 1))
|
|
if [ "$i" -ge 60 ]; then
|
|
echo "projectsend: gave up waiting for the database after 60s." >&2
|
|
echo "projectsend: the last attempt failed with:" >&2
|
|
printf '%s\n' "$probe_error" | tail -n 20 >&2
|
|
echo "projectsend:" >&2
|
|
echo "projectsend: if that names the database host, the connection or the credentials," >&2
|
|
echo "projectsend: check DB_HOST, DB_DATABASE, DB_USERNAME and DB_PASSWORD." >&2
|
|
echo "projectsend: if it is an application error, the database is fine and this is a" >&2
|
|
echo "projectsend: bug — please report it at https://github.com/projectsend/projectsend/issues" >&2
|
|
exit 1
|
|
fi
|
|
[ "$i" = 1 ] && echo "projectsend: waiting for the database..."
|
|
sleep 1
|
|
done
|
|
|
|
# One command, shared with update.sh and with the dev image: migrate,
|
|
# ensure the roles, relink storage, clear the compiled caches, restart
|
|
# the workers, record the version applied. See UpdateInstallation for
|
|
# why the order is what it is, and why maintenance mode is not part of
|
|
# it (`artisan down` would write its flag onto this container's
|
|
# persisted storage volume and outlive the container that wrote it).
|
|
su-exec www-data php artisan projectsend:update
|
|
|
|
# Provisioning defaults, before the account they govern exists. A
|
|
# policy an operator wants on from the start has to be written in this
|
|
# boot or not at all: the only other writer is whoever administers the
|
|
# installation, and the line below is what creates them. Seeds only a
|
|
# setting nobody has ever stored, so it never argues with an
|
|
# administrator who changed it later.
|
|
su-exec www-data php artisan projectsend:seed-settings
|
|
|
|
# Unattended provisioning: create the first administrator from the
|
|
# environment. Without these, the web setup screen prompts instead.
|
|
if [ -n "$ADMIN_EMAIL" ] && [ -n "$ADMIN_PASSWORD" ]; then
|
|
su-exec www-data php artisan projectsend:admin --if-none \
|
|
--name="${ADMIN_NAME:-Administrator}" \
|
|
--email="$ADMIN_EMAIL" \
|
|
--password="$ADMIN_PASSWORD"
|
|
fi
|
|
fi
|
|
|
|
exec "$@"
|