Files
ignacionelson 0671848bfa Read settings written before the columns they name existed
Reported by @apps3000 in #1770. Upgrading a container from 2.0 or 2.1
with external storage configured restart-loops, and says the database is
unreachable while the database is fine.

A row hydrated from the database does not get the model's column
defaults — only a new model does. So a row written before
external_storage_settings.provider existed reads that column as null,
and the enum match in isConfigured() throws UnhandledMatchError.

That would be a small bug anywhere else. It is not here, because
PlatformServiceProvider::boot() reads these settings on every process
boot, and boot happens before `artisan migrate` runs. During an upgrade
the code is new and the schema is still old, so every artisan command in
that window dies — including `projectsend:update`, the one that would
have added the column. Reordering the entrypoint or using a lighter
readiness probe does not help for that reason; the crash is in the
bootstrap, not in the probe.

current() now applies the model's declared defaults to any column the
hydrated row does not have. That closes the window for every column with
a default rather than for the one where it was found, and goes inert the
moment the schema is current. The match in isConfigured() is left total
on purpose: a default arm would swallow a real unhandled case, and the
invariant it needs now holds at the one place the row is read.

The probe's message is the other half. It boots the whole application,
so it fails both when the database is absent and when the application
cannot start, and it reported the second as the first — sending an
operator off checking credentials that were never wrong. It now prints
the error it actually hit and says which of the two it looks like.

Verified end to end against a 2.1-shaped database: `artisan migrate`
dies with UnhandledMatchError before the change and completes after it,
leaving the row reading as S3 with its bucket intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QmyH342d8MuW3pDuE9mbtS
2026-09-10 17:23:26 -03:00

150 lines
5.4 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Platform\Settings;
use Illuminate\Database\Eloquent\Model;
/**
* Admin-configured external storage backend — S3-compatible or Google
* Cloud Storage, see StorageProvider — editable from the Storage
* settings page. Single row (id 1 in practice, never
* enforced) — same reasoning as MailProviderSettings: `secret` needs real
* Eloquent encryption, which the generic settings table can't offer
* per-key.
*
* @property int $id
* @property bool $active
* @property StorageProvider $provider
* @property bool $use_instance_role
* @property string|null $key
* @property string|null $secret
* @property string|null $key_file
* @property string|null $bucket
* @property string|null $region
* @property string|null $endpoint
* @property bool $use_path_style
* @property string|null $root
*/
class ExternalStorageSettings extends Model
{
protected $table = 'external_storage_settings';
protected $fillable = [
'active',
'provider',
'use_instance_role',
'key',
'secret',
'key_file',
'bucket',
'region',
'endpoint',
'use_path_style',
'root',
];
/**
* current() builds this with firstOrNew(), which does not apply the
* column defaults — so on an install that has never opened the
* Storage screen, `provider` would be null and the match in
* isConfigured() would throw rather than answer. Defaults here are
* what make an unsaved row a coherent object.
*
* @var array<string, mixed>
*/
protected $attributes = [
'active' => false,
'provider' => 's3',
'use_instance_role' => false,
'use_path_style' => false,
];
protected function casts(): array
{
return [
'active' => 'boolean',
'provider' => StorageProvider::class,
'use_instance_role' => 'boolean',
'secret' => 'encrypted',
'key_file' => 'encrypted',
'use_path_style' => 'boolean',
];
}
public static function current(): self
{
$settings = static::query()->firstOrNew([]);
// Column defaults — the $attributes array above — apply to a NEW
// model, never to one hydrated from a row. So a row written by an
// older release, before one of these columns existed, reads that
// column as null however sensible its default is.
//
// That matters here more than it would anywhere else, because
// PlatformServiceProvider::boot() reads these settings on every
// process boot — and boot happens BEFORE `artisan migrate` runs.
// For the length of an upgrade the code is new and the schema is
// still old, and every artisan command in that window, including
// the one that would run the migrations, boots through here.
//
// A null `provider` made the match in isConfigured() throw
// UnhandledMatchError, which the official image's readiness probe
// reported to the operator as "database unreachable" — on a
// perfectly reachable database, in a container that then
// restart-looped without ever reaching the migration that would
// have fixed it (#1770, upgrading from 2.0/2.1 with external
// storage configured).
//
// Applying the defaults to a hydrated row closes that window for
// every column that has one, rather than for the single column
// where it was found. Inert on any install whose schema is current.
foreach ((new self)->getAttributes() as $column => $default) {
if (! array_key_exists($column, $settings->getAttributes())) {
$settings->setAttribute($column, $default);
}
}
return $settings;
}
/**
* Active isn't enough on its own — an admin could flip the toggle
* before ever filling in real credentials (a blank bucket/key would
* silently misroute every new upload to a broken disk).
*/
public function isConfigured(): bool
{
if (! $this->active || ! $this->filled('bucket')) {
return false;
}
// What counts as "filled in" is per provider, because the two
// authenticate with different things entirely: S3 wants a key and
// a secret, GCS wants a service account key file.
//
// The match is deliberately left total rather than given a default
// arm: current() guarantees a provider even on a row older than the
// column, and a default arm here would quietly swallow a genuinely
// unhandled case instead of naming it.
//
// Unless S3 is being asked to authenticate as the machine it is
// running on, in which case there is no credential to fill in at
// all and demanding one would leave the disk permanently
// "unconfigured" — which fails silently, by leaving every new
// upload on the local disk rather than by reporting anything.
return match ($this->provider) {
StorageProvider::S3 => $this->use_instance_role || ($this->filled('key') && $this->filled('secret')),
StorageProvider::Gcs => $this->filled('key_file'),
};
}
private function filled(string $attribute): bool
{
$value = $this->{$attribute};
return is_string($value) && $value !== '';
}
}