mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
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>
268 lines
12 KiB
Bash
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 "$@"
|