fix(deploy): recreate the container, keep the root password secret, forget failures

- update.sh recreated the app container only at the very end, so an update that
  changes the PHP runtime would have installed dependencies and migrated inside
  the old one.
- install.sh randomised DB_PASSWORD but left DB_ROOT_PASSWORD at the value
  committed in .env.example — the same root credential on every installation,
  reachable from any container on the compose network.
- Settings cached the fallback after a database failure, so one blip could
  leave a hidden site publicly visible until someone cleared the cache. Only a
  successful read is cached now, proven by a test that drops the table and
  brings it back.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 23:25:06 +02:00
parent 773ef4bd5f
commit 0219e3c987
3 changed files with 23 additions and 5 deletions

View File

@ -20,25 +20,32 @@ final class Settings
public static function get(string $key, mixed $default = null): mixed
{
$raw = Cache::rememberForever(self::CACHE_PREFIX.$key, function () use ($key) {
$cacheKey = self::CACHE_PREFIX.$key;
if (Cache::has($cacheKey)) {
$raw = Cache::get($cacheKey);
} else {
try {
$row = DB::table('app_settings')->where('key', $key)->value('value');
} catch (QueryException $e) {
// The gate reads a setting on every request, so an unreachable
// or not-yet-migrated table would take the whole site down —
// including during a deploy where new code runs before migrate.
// Fall back to the caller's default instead, and say so.
// Fall back to the caller's default, but do NOT cache that:
// caching a failure would keep a hidden site publicly visible
// long after the database came back.
Log::warning('app_settings unavailable, using default', [
'key' => $key, 'error' => $e->getMessage(),
]);
return '__missing__';
return $default;
}
// Sentinel: distinguishes "stored null" from "never stored", so a
// missing row does not get re-queried on every request.
return $row === null ? '__missing__' : $row;
});
$raw = $row ?? '__missing__';
Cache::forever($cacheKey, $raw);
}
return $raw === '__missing__' ? $default : json_decode($raw, true);
}

View File

@ -133,6 +133,9 @@ else
set_env APP_KEY "base64:$(rand_b64 32)"
set_env DB_PASSWORD "$(rand_hex 24)"
# Otherwise every installation shares the value from .env.example, reachable
# from any container on the compose network.
set_env DB_ROOT_PASSWORD "$(rand_hex 24)"
set_env VPN_CONFIG_KEY "$(rand_b64 32)"
set_env REVERB_APP_ID "clupilot"

View File

@ -68,6 +68,14 @@ after="$(git rev-parse HEAD)"
if ! git diff --quiet "$before" "$after" -- docker/ 2>/dev/null; then
log "Rebuilding the image"
docker compose build --quiet app
# Recreate now, not at the end: everything below runs INSIDE this container,
# and an update that changes the PHP runtime would otherwise install and
# migrate under the old one.
docker compose up -d --force-recreate app
for _ in $(seq 1 30); do
in_app php -v >/dev/null 2>&1 && break
sleep 2
done
fi
# vendor/ and node_modules/ live in the bind mount, so they shadow whatever the