Files
projectsend/app/Modules/Platform/Updates/UpdateInstallation.php
T
Ignacio Nelson ed0d36de25 Reduce a manual update to one command that asks first (#1628)
Updating a server install cost nine artisan invocations plus a PHP-FPM
reload, written out in three places that had already drifted apart. One
of those steps is silently fatal to skip: with opcache.validate_timestamps
off — what production guides recommend and what our own image ships — the
database moves to the new version while every visitor keeps being served
the old code, and artisan reports the new version throughout.

`sudo ./update.sh` is now the whole procedure. It asks whether to check
GitHub, asks whether to download the release and verifies the checksum
published beside it, and asks whether there is a backup — offering to dump
the database when the answer is no. Then it takes the site down, replaces
the files, runs the update, reloads PHP-FPM, restarts the worker and
brings the site back. The application still has no self-updater: nothing
is fetched or applied unless somebody runs this and answers yes.

Underneath it is `php artisan projectsend:update`, which is everything an
update does that needs no root — and now the only definition of it. Both
container entrypoints call it instead of carrying their own copy of the
sequence, so the two paths cannot drift again.

Three findings worth keeping in the record, all from rehearsing rather
than reasoning:

  - queue:restart has to come last. It writes its signal into the cache,
    so clearing the cache afterwards deletes it and the worker runs old
    code forever.
  - optimize:clear is not safe to recommend. It runs cache:clear, which
    on Redis is FLUSHDB — harmless on the default two-database layout,
    but on a single-database Redis it takes the sessions and the queue
    with it. The compiled caches are cleared individually instead.
  - update.sh overwrites itself mid-run, because the zip contains it and
    bash reads its own script lazily by byte offset. It re-execs from a
    temporary copy before touching anything.

And when the reload is skipped anyway, the application now says so:
projectsend:update records the version it applied, and any staff page
compares that with what the running process actually compiled. The same
check catches the mirror image — new files in place, update never run.

Rehearsed end to end against real installs: a container upgrade (69 to 73
migrations, key and data intact, healthy), a scripted update on a real
nginx + php-fpm install with OPcache pinned (web process moved 2.1.0 to
2.1.1), the skipped-reload case (banner appears naming both versions, and
clears on reload), the refusals (downgrade, non-release zip, truncated
zip, URL passed to --zip, non-root), a database taken down mid-update
(site comes back out of maintenance mode by itself), and a real download
of the published 2.0.0 zip with its checksum verified.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:29:20 -03:00

229 lines
8.8 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Platform\Updates;
use App\Modules\Identity\Permissions\EnsureSystemRoles;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Console\OutputStyle;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\Facades\Artisan;
use Throwable;
/**
* Everything that has to happen after new code lands, and that the
* application can do to itself.
*
* One definition, three callers: the two container entrypoints run it on
* every boot, and update.sh runs it after unpacking a release on a server
* somebody administers by hand. Before this existed the sequence was
* written out in five places — both entrypoints, INSTALL.md, UPDATE.md and
* a code block in the dashboard — and they had already drifted: only the
* documents mentioned the queue, only the container relinked storage, and
* none of them agreed on which caches to clear.
*
* The order is the design. Each constraint below cost something to find:
*
* - migrate first, because everything after it assumes tables exist;
* - queue:restart last, because it writes its signal *into the cache*,
* so anything that clears the cache afterwards deletes the signal and
* leaves a worker running the old code forever;
* - the caches are cleared one command at a time rather than through
* optimize:clear, which also runs cache:clear — and Laravel's Redis
* cache store implements that as FLUSHDB. On the default config the
* cache sits on its own database and that is harmless; on a managed
* Redis offering one database, or any install that pointed cache and
* sessions at the same one, an update would sign every user out and
* delete the queue. Nothing here needs the data cache dropped:
* Settings::set() already forgets its own key, and cached values whose
* shape changes get a new key. A human who suspects a stale value has
* `php artisan cache:clear`, which UPDATE.md points at.
*
* What it deliberately does not do is maintenance mode. `php artisan down`
* writes storage/framework/maintenance.php, and in the official image
* storage/ is the persisted volume — a container that died between `down`
* and `up` would come back down, and stay down through every recreation.
* update.sh owns maintenance mode, where a trap can guarantee the site
* comes back. This is why the command is safe to run on every boot.
*/
class UpdateInstallation
{
public function __construct(
private readonly Application $app,
private readonly EnsureSystemRoles $roles,
private readonly Settings $settings,
) {}
/**
* @return array{
* from: string,
* to: string,
* migrated: bool,
* cleared: list<string>,
* rewarmed: list<string>,
* warnings: list<string>,
* ok: bool,
* }
*/
public function run(?OutputStyle $output = null): array
{
$running = (string) config('projectsend.version');
$result = [
'from' => '',
'to' => $running,
'migrated' => false,
'cleared' => [],
'rewarmed' => [],
'warnings' => [],
'ok' => false,
];
// Caught rather than left to surface as a stack trace: the most
// likely failure here is a database that is unreachable or refusing
// the credentials, and forty frames of Laravel internals is a worse
// answer to that than one sentence naming it.
try {
$migrated = $this->artisan('migrate', ['--force' => true], $output) === 0;
} catch (Throwable $exception) {
$result['warnings'][] = 'The database migration failed: '.$exception->getMessage();
$result['warnings'][] = 'Nothing else was changed.';
return $result;
}
if (! $migrated) {
$result['warnings'][] = 'The database migration failed. Nothing else was changed.';
return $result;
}
$result['migrated'] = true;
$result['from'] = $this->previouslyApplied();
$this->roles->ensure();
// Not parity with the old entrypoint line — a fix. The release zip
// ships no public/storage symlink (the build refuses symlinks
// outright, they do not survive zipping), so an installation that
// followed UPDATE.md's "unpack beside it and swap the directories"
// advice loses the link entirely, and nothing in the documented
// sequence ever put it back.
$this->artisan('storage:link', ['--force' => true], $output);
// Read before anything is cleared: this is the only moment the
// question "was this installation using the optional caches?" can
// still be answered.
$warm = $this->warmCaches();
foreach (['config:clear', 'clear-compiled', 'event:clear', 'route:clear', 'view:clear'] as $command) {
if ($this->artisan($command, [], $output) === 0) {
$result['cleared'][] = $command;
}
}
if ($warm['config']) {
$result['warnings'][] = 'A cached configuration was found and cleared. Do not run config:cache on this'
.' application — it stops TRUSTED_PROXIES from being read at all. See INSTALL.md.';
}
foreach ($this->cachesToRewarm($warm) as $command) {
// A route table that will not compile is a slower site; a
// failed update is a broken one. Never fatal.
if ($this->artisan($command, [], $output) === 0) {
$result['rewarmed'][] = $command;
continue;
}
$result['warnings'][] = "{$command} failed, so that cache is not in place. The site runs without it.";
}
$this->settings->set(Setting::AppliedVersion, $running);
$this->settings->set(Setting::AppliedVersionAt, now()->toIso8601String());
// Last, and after every cache operation above — see the class
// docblock. A worker only learns to exit by reading this signal.
$this->artisan('queue:restart', [], $output);
$result['ok'] = true;
return $result;
}
/**
* What the previous run of this recorded, or '' when there was none.
*
* Swallows failures on purpose: by this point the schema is current,
* but a cache store that is momentarily unreachable must not fail a
* container boot over a line of reporting.
*/
private function previouslyApplied(): string
{
try {
$applied = $this->settings->get(Setting::AppliedVersion);
return is_string($applied) ? $applied : '';
} catch (Throwable) {
return '';
}
}
/**
* Which optional caches this installation had in place.
*
* Asked through the framework's own path accessors rather than the
* filenames: `routes-v7.php` is an internal that changes with major
* versions. And by file_exists() rather than $app->routesAreCached(),
* which memoises at bootstrap and would still answer true after
* route:clear ran in this same process.
*
* @return array{route: bool, event: bool, config: bool}
*/
protected function warmCaches(): array
{
return [
'route' => file_exists($this->app->getCachedRoutesPath()),
'event' => file_exists($this->app->getCachedEventsPath()),
'config' => file_exists($this->app->getCachedConfigPath()),
];
}
/**
* Views have no honest signal of their own — storage/framework/views
* fills up from ordinary traffic, cached deliberately or not. So the
* route and event caches stand in for the set: their presence means
* this installation followed INSTALL.md's "Making it faster", which
* lists all three together. A container caches none of them and so
* rebuilds nothing, which keeps boot as fast as it is today.
*
* config:cache is never rebuilt, at any time, for any installation.
*
* @param array{route: bool, event: bool, config: bool} $warm
* @return list<string>
*/
private function cachesToRewarm(array $warm): array
{
if (! $warm['route'] && ! $warm['event']) {
return [];
}
return ['route:cache', 'event:cache', 'view:cache'];
}
/**
* Protected so a test can watch the sequence without running it. The
* ordering constraints in run() are invisible in its result and
* catastrophic when wrong, and an ordered list of calls is the only
* thing that can assert them.
*
* @param array<string, mixed> $parameters
*/
protected function artisan(string $command, array $parameters = [], ?OutputStyle $output = null): int
{
return Artisan::call($command, $parameters, $output);
}
}