Files
pl_mail/frankenphp/docker-entrypoint.sh
Paul LütznerandClaude Opus 5.5 ffbf7bdd9b Clean up the compose files (#160)
Compose files keep only a short header; the reasoning lives in
docs/install. truenas.compose.yaml is gone (the TrueNAS catalog app
replaces it) and so is compose.prod.yaml (compose.yaml already runs the
published prod image). compose.override.yaml.dist becomes compose.dev.yaml,
passed with -f, so there is no untracked copy that compose loads on its
own. Docs, CODESTYLE, CONTRIBUTING and the entrypoint's error text follow.

Every remaining file resolves to the same `docker compose config` as
before.

Closes #30

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:43:22 +02:00

268 lines
12 KiB
Bash

#!/bin/sh
set -e
# Export every KEY=VALUE from the generated secrets file that is not already in
# the environment. Explicit configuration always wins: an operator who manages
# secrets elsewhere never has a generated value substituted underneath them.
load_generated_secrets() {
[ -f "$1" ] || return 0
while IFS='=' read -r key value; do
case "$key" in '' | \#*) continue ;; esac
[ -n "$(printenv "$key" || true)" ] && continue
export "$key=$value"
done <"$1"
}
if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then
# ── Generated secrets ──────────────────────────────────────────────────
# A fresh install must not come up on the secrets committed to the repo.
# APP_ENCRYPTION_KEY in particular is all that stands between a stolen
# database dump and every mailbox password inside it, so an install that
# inherits the repository's value is readable by anyone holding the repo.
#
# The `secrets-init` service has normally done this already, before Postgres
# and Mercure started — they read their secrets at container-create time and
# cannot wait for us. Running it again here is free and covers a stack
# assembled without that service.
#
# All app services mount the same directory. If one is missing that volume it
# mints its own key and quietly fails to read what the others wrote —
# `app:secrets:init` below probes for exactly that and refuses to start
# rather than write data nobody can decrypt.
SECRETS_DIR="${APP_SECRETS_DIR:-/app/var/secrets}"
SECRETS_FILE="$SECRETS_DIR/generated.env"
generate-secrets
export APP_SECRETS_FILE="$SECRETS_FILE"
load_generated_secrets "$SECRETS_FILE"
# True only for a DSN that carries a password, i.e. one an operator actually
# configured.
#
# "Is it empty" is not the test, because it is never empty: compose resolves
# ${DATABASE_URL:-} against the project .env, and the .env default is a
# credential-less DSN on purpose — Doctrine reads the driver out of the
# scheme, and a blank DSN has none, which breaks the prod cache warmup during
# the image build. Treating that placeholder as operator intent is what stops
# the generated password from ever being spliced in, and Postgres then
# refuses the connection with "no password supplied".
database_url_has_password() {
case "$1" in
*://*) ;;
*) return 1 ;;
esac
_userinfo="${1#*://}"
# No "@" at all, or one that only shows up later in the path or query:
# no userinfo, so no password.
case "$_userinfo" in
*@*) _userinfo="${_userinfo%%@*}" ;;
*) return 1 ;;
esac
case "$_userinfo" in
*/*) return 1 ;;
*:*) return 0 ;;
*) return 1 ;;
esac
}
# The database password is generated too, so the connection string has to be
# assembled after the fact. Only when nothing supplied one: an operator with
# an external database sets DATABASE_URL and never reaches this.
if ! database_url_has_password "$DATABASE_URL" && [ -n "$POSTGRES_PASSWORD" ]; then
export DATABASE_URL="postgresql://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD}@${POSTGRES_HOST:-database}:5432/${POSTGRES_DB:-app}?serverVersion=${POSTGRES_VERSION:-18}&charset=${POSTGRES_CHARSET:-utf8}"
fi
# No application here. Say so, rather than trying to invent one.
#
# This used to be the Symfony skeleton's scaffolding block —
# `composer create-project symfony/skeleton tmp`, under a comment saying it
# could be deleted once the project existed. It never was. plMail HAS a
# composer.json in every image and every checkout, so the branch could only
# ever fire when something had gone wrong, and what it did then was try to
# create a fresh Symfony project on top of the user's install.
#
# It did not even get that far. $SYMFONY_VERSION and $STABILITY are unset in
# this image, so the command ran as `composer create-project
# "symfony/skeleton " --stability=""` and died with
#
# Invalid stability string "", expected one of stable, RC, beta, alpha or dev
#
# repeated by six containers in a restart loop. Nothing in that mentions the
# actual problem, and a person reading it has no reason to suspect their
# mount rather than their install.
#
# There are two ways to get here, and the message names both:
#
# - `docker compose up` with compose.dev.yaml but run from a directory
# that is not the checkout. compose.dev.yaml bind-mounts `./:/app`,
# so an empty directory becomes an empty application.
# - a local `pl_mail-dev:latest` left behind by an earlier `--build` while
# compose.dev.yaml is no longer passed. compose finds the local tag, does
# not pull, and boots the dev image — which deliberately carries no
# application code, because in dev the code arrives through the mount.
if [ ! -f composer.json ]; then
cat >&2 <<-'ERROR'
plMail: there is no application at /app — no composer.json.
This container has nothing to run. Two things cause it:
1. You are using compose.dev.yaml (the development stack) and ran
`docker compose up` somewhere other than the checkout. That
file bind-mounts ./ to /app, so an empty directory gives an
empty application. cd to the cloned repository and try again.
2. A local `pl_mail-dev:latest` image is being used without the
compose.dev.yaml, which builds it. The development image carries no
application code on purpose. Pass -f compose.dev.yaml again, or
`docker image rm pl_mail-dev:latest` and let compose pull the
published image.
To run plMail rather than develop it, use compose.yaml alone.
ERROR
exit 1
fi
# Dependencies are baked into the prod image, so none of this runs for the
# published one — vendor/ is already there. It runs for the dev stack, which
# bind-mounts the source tree over /app: a fresh clone has no vendor/, and
# every bin/console below would die on "Dependencies are missing. Try running
# composer install." Doing it here is what keeps `docker compose up` the only
# command a contributor has to run.
#
# php, the three workers and tailwind all start within milliseconds of each
# other against that one shared bind mount, so this has to be mutually
# exclusive — five concurrent installs over the same vendor/ can leave a
# corrupt autoloader behind.
#
# mkdir is the mutex, not flock. flock is advisory and does not reliably
# exclude across containers on a macOS bind mount: the lock file is created
# by the very redirect that acquires it, and two simultaneous O_CREATs over
# VirtioFS do not necessarily end up sharing lock state. Measured, not
# assumed — php and imap-supervisor both entered a flock-guarded block 17ms
# apart. mkdir is a single atomic operation that fails with EEXIST, which is
# the property actually needed here.
install_dependencies_once() {
_lock='.composer-install.lock'
_waited=0
while [ ! -f vendor/autoload_runtime.php ]; do
if mkdir "$_lock" 2>/dev/null; then
echo 'Installing PHP dependencies — first run only, this takes a few minutes...'
composer install --prefer-dist --no-progress --no-interaction
_status=$?
rmdir "$_lock" 2>/dev/null || true
return $_status
fi
[ "$_waited" -eq 0 ] && echo 'Another container is installing PHP dependencies; waiting...'
sleep 5
_waited=$((_waited + 5))
# Nothing should take this long. If it has, the container that held
# the lock died mid-install and left it behind — clear it and take
# over rather than waiting out the clock forever.
if [ "$_waited" -ge 900 ]; then
echo 'Timed out waiting for the install to finish; clearing a stale lock.' >&2
rmdir "$_lock" 2>/dev/null || true
_waited=0
fi
done
}
if [ -f composer.json ] && [ ! -f vendor/autoload_runtime.php ]; then
install_dependencies_once
fi
if grep -q ^DATABASE_URL= .env; then
# app:db:wait rather than a shell loop around `dbal:run-sql -q
# "SELECT 1"`: every attempt of that loop that missed was an uncaught
# exception, and Symfony logs those as CRITICAL — so a database a few
# seconds late, which is what this step exists to wait for, put errors
# in the admin log. The command retries quietly, once a second for a
# minute as before, and logs only when it gives up. See
# src/Command/Setup/WaitForDatabaseCommand.php.
echo 'Waiting for database to be ready...'
if ! php bin/console app:db:wait; then
exit 1
fi
echo 'The database is now ready and reachable'
# app:db:migrate, not doctrine:migrations:migrate — it runs exactly
# that, with exactly these flags, but holds a Postgres advisory lock
# across the whole run.
#
# php, imap-supervisor, messenger-worker and scheduler all reach this
# line within milliseconds of each other against one database, and all
# four read the migration ledger before any of them writes to it. One
# applies the change and commits; the others block on its table lock and
# then fail on a schema that has already moved — "column ... already
# exists". With `set -e` above, that is three services that never start.
#
# The lock cannot live in this script: pg_advisory_lock is scoped to the
# database session, so a `dbal:run-sql` that takes it hands it straight
# back on exit and leaves the migrate that follows unprotected. It has
# to be the same connection that migrates, which means one PHP process
# doing both. See src/Command/Setup/MigrateCommand.php.
if [ "$( find ./migrations -iname '*.php' -print -quit )" ]; then
php bin/console app:db:migrate --no-interaction
fi
# The secrets that need PHP: a VAPID keypair and the JWT keys. Runs
# after migrations because it also verifies that the encryption key in
# force can actually open the credentials already in the database —
# the one check that catches a container holding the wrong key before
# it writes anything.
#
# Fatal only when starting the server. A console invocation is how an
# operator FIXES a key mismatch, so refusing to run one turns a
# recoverable problem into a locked door: the container would not start,
# and neither would the command that clears the unreadable rows.
if [ "$1" = 'frankenphp' ]; then
php bin/console app:secrets:init
elif ! php bin/console app:secrets:init; then
echo 'Continuing anyway: this is a console invocation, and refusing it would'
echo 'block the very commands that repair a key mismatch.'
fi
load_generated_secrets "$SECRETS_FILE"
fi
# NO ACLs ARE SET HERE ANY MORE, and that is the fix rather than an omission.
#
# This used to end with two `setfacl -R` passes over var/cache and var/log,
# granting www-data and root access to the two directories Symfony writes.
# Nothing in the image runs as www-data, root needs no grant, and the
# Dockerfile already makes both directories writable for whichever user the
# container is given — so the passes changed nothing anybody relied on.
#
# What they cost was invisible on a developer's machine and very visible on
# a NAS. Both directories are part of the IMAGE: two thousand files of
# compiled container, templates and translations. The first time a
# container changes anything about a file from its image — an ACL counts —
# the storage driver copies that whole file into the container's own layer
# and flushes it to disk before carrying on. So every start of a new
# container rewrote the entire cache one synchronous file at a time, in
# each of the stack's containers at once, on the same pool: a second on an
# SSD, and two and a quarter minutes of silence between "Statement
# statistics are enabled" and the web server starting on spinning disks.
#
# It showed only where the container runs as root, because the passes were
# skipped for any other user — which is why the catalogue install, as uid
# 568, started in seconds from the same image.
#
# The history, since this is the third time this block has been the answer:
# it was first all of var/, which walked the mail store; then var/cache and
# var/log; now nothing. If a platform ever needs a grant here, make it in
# the Dockerfile, where it is paid once at build and not on every start.
mkdir -p var/cache var/log
fi
exec docker-php-entrypoint "$@"