#!/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 "$@"