Files
timebank-cc-public/app/Console/Commands/UpgradeVerify.php
T

332 lines
14 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Models\Admin;
use App\Models\AdminRole;
use App\Models\Bank;
use App\Models\Organization;
use App\Models\OrganizationRole;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Schema;
use Spatie\Permission\Models\Role;
/**
* Read-only check of the areas an upgrade can break, per installation.
*
* Run it before and after every upgrade step, locally and on each server (references/plans/LARAVEL_UPGRADE_PLAN.md,
* section 4.2). It changes nothing: it reads configuration, the database, Elasticsearch and the queues, and opens
* one connection to test Reverb. Exit code 1 when a check fails; warnings do not fail.
*/
class UpgradeVerify extends Command
{
protected $signature = 'upgrade:verify {--only= : Comma-separated areas: framework, profiles, permissions, white-label, platform, payments, search, background}';
protected $description = 'Read-only check of profiles, permissions, white-label settings, payments, search and background services';
/** Tables the application's database user may not UPDATE or DELETE (as in deploy.sh). */
public const IMMUTABLE_TABLES = ['transactions', 'transaction_types', 'poll_ledger'];
public const QUEUES = ['high', 'messages', 'default', 'emails', 'mailing', 'low'];
/** @var array<int, array{string, string, string}> area, status (ok|warn|fail), message */
protected array $results = [];
public function handle(): int
{
$areas = [
'framework' => fn () => $this->checkFramework(),
'profiles' => fn () => $this->checkProfiles(),
'permissions' => fn () => $this->checkPermissions(),
'white-label' => fn () => $this->checkWhiteLabel(),
'platform' => fn () => $this->checkPlatform(),
'payments' => fn () => $this->checkPayments(),
'search' => fn () => $this->checkSearch(),
'background' => fn () => $this->checkBackground(),
];
$only = array_filter(array_map('trim', explode(',', (string) $this->option('only'))));
foreach ($areas as $area => $check) {
if ($only && !in_array($area, $only, true)) {
continue;
}
try {
$check();
} catch (\Throwable $e) {
$this->fail_($area, 'check crashed: ' . get_class($e) . ': ' . $e->getMessage());
}
}
$symbols = ['ok' => '<fg=green>✓</>', 'warn' => '<fg=yellow>!</>', 'fail' => '<fg=red>✗</>'];
$current = null;
foreach ($this->results as [$area, $status, $message]) {
if ($area !== $current) {
$this->newLine();
$this->line("<options=bold>{$area}</>");
$current = $area;
}
$this->line(" {$symbols[$status]} {$message}");
}
$counts = array_count_values(array_column($this->results, 1));
$this->newLine();
$this->line(sprintf('%d ok, %d warnings, %d failed', $counts['ok'] ?? 0, $counts['warn'] ?? 0, $counts['fail'] ?? 0));
return ($counts['fail'] ?? 0) > 0 ? self::FAILURE : self::SUCCESS;
}
protected function ok(string $area, string $message): void
{
$this->results[] = [$area, 'ok', $message];
}
protected function warn_(string $area, string $message): void
{
$this->results[] = [$area, 'warn', $message];
}
protected function fail_(string $area, string $message): void
{
$this->results[] = [$area, 'fail', $message];
}
protected function checkFramework(): void
{
$this->ok('framework', 'Laravel ' . app()->version() . ', PHP ' . PHP_VERSION);
Artisan::call('config:merge', ['--all' => true, '--dry-run' => true]);
$output = Artisan::output();
if (preg_match_all('/Found (\d+) new configuration key/', $output, $m)) {
$this->warn_('framework', array_sum($m[1]) . ' new config keys are waiting: run php artisan config:merge --all');
} else {
$this->ok('framework', 'config files have every key of their templates');
}
}
protected function checkProfiles(): void
{
$guards = array_keys(config('auth.guards', []));
$missing = array_diff(['web', 'organization', 'bank', 'admin'], $guards);
$missing
? $this->fail_('profiles', 'guards missing: ' . implode(', ', $missing))
: $this->ok('profiles', 'guards web, organization, bank and admin are configured');
foreach ([User::class, Organization::class, Bank::class, Admin::class] as $model) {
$count = $model::count();
$name = class_basename($model);
if ($count === 0) {
$this->warn_('profiles', "no {$name} profiles in the database");
continue;
}
$model::query()->first()
? $this->ok('profiles', "{$name}: {$count} profiles, loading works")
: $this->fail_('profiles', "{$name}: could not load a profile");
}
foreach (['getActiveProfile', 'getActiveProfileType', 'timebank_config', 'platform_name', 'theme_email_logo'] as $helper) {
if (!function_exists($helper)) {
$this->fail_('profiles', "helper {$helper}() is missing");
}
}
}
protected function checkPermissions(): void
{
// organization_roles is created by `php artisan database:update`, not by a migration
$organizationRoles = collect();
if (Schema::hasTable('organization_roles')) {
$organizationRoles = OrganizationRole::all()->keyBy('name');
} else {
$this->warn_('permissions', 'table organization_roles is missing: run php artisan database:update');
}
$definitions = [
'Admin' => AdminRole::all()->keyBy('name'),
'Organization' => $organizationRoles,
'Bank' => $organizationRoles,
];
$scoped = 0;
$legacy = [];
$mismatched = [];
$undefined = [];
foreach (Role::with('permissions')->get() as $role) {
$parts = explode('\\', $role->name);
if (count($parts) !== 3 || !isset($definitions[$parts[0]])) {
continue; // base roles without a scope
}
if (preg_match(AdminRolesMigrate::LEGACY_PATTERN, $role->name)) {
$legacy[] = $role->name;
continue;
}
$scoped++;
$definition = $definitions[$parts[0]]->get($parts[2]);
if (!$definition) {
$undefined[] = $role->name;
continue;
}
$expected = collect($definition->permissions ?? [])->sort()->values()->all();
$actual = $role->permissions->pluck('name')->sort()->values()->all();
if ($expected !== $actual) {
$mismatched[] = $role->name;
}
}
if ($mismatched) {
$this->fail_('permissions', count($mismatched) . " scoped roles differ from their definition: " . implode(', ', array_slice($mismatched, 0, 5))
. ' (re-sync: see AGENTS.md, Roles and permissions)');
} else {
$this->ok('permissions', "{$scoped} scoped roles match admin_roles / organization_roles");
}
if ($legacy) {
$this->warn_('permissions', count($legacy) . ' legacy roles: ' . implode(', ', array_slice($legacy, 0, 5))
. ' (replace them: php artisan admin-roles:migrate --dry-run, then without --dry-run)');
}
if ($undefined) {
$this->warn_('permissions', count($undefined) . ' scoped roles have no definition: ' . implode(', ', array_slice($undefined, 0, 5)));
}
}
protected function checkWhiteLabel(): void
{
$name = (string) config('app.timebank_config');
// The platform folder's config.php replaces config/<TIMEBANK_CONFIG>.php (PlatformServiceProvider)
$file = is_file(platform_path('config.php')) ? 'platform/config.php' : "config/{$name}.php";
if ($name === '' || ($file !== 'platform/config.php' && !is_file(config_path("{$name}.php")))) {
$this->fail_('white-label', "platform config config/{$name}.php does not exist (TIMEBANK_CONFIG), and there is no platform/config.php");
return;
}
$this->ok('white-label', "platform config: {$file}");
$platform = platform_name();
if ($platform === '' || $platform === 'Example Timebank') {
$this->warn_('white-label', "platform name is '{$platform}': set platform_translations in {$file}");
} else {
$this->ok('white-label', "platform name: {$platform}");
}
theme_color('brand')
? $this->ok('white-label', 'theme ' . config('themes.active') . ': brand colour ' . theme_color('brand'))
: $this->fail_('white-label', 'theme ' . config('themes.active') . ' has no brand colour');
foreach (['local_pages', 'android_app'] as $key) {
timebank_config($key) === null
? $this->warn_('white-label', "setting {$key} is missing: run php artisan config:merge {$name}")
: $this->ok('white-label', "setting {$key} is present");
}
}
/** The platform folder and its overrides: the same checks as php artisan platform:check. */
protected function checkPlatform(): void
{
foreach (app(\App\Services\PlatformOverrides::class)->check() as $finding) {
match ($finding['level']) {
'fail' => $this->fail_('platform', $finding['message']),
'warn' => $this->warn_('platform', $finding['message']),
default => $this->ok('platform', $finding['message']),
};
}
}
protected function checkPayments(): void
{
$database = (string) DB::connection()->getDatabaseName();
$grants = collect(DB::select('SHOW GRANTS FOR CURRENT_USER()'))->map(fn ($row) => (string) array_values((array) $row)[0]);
$allowed = [];
foreach ($grants as $grant) {
if (!preg_match('/^GRANT (.+?) ON (\S+)\.(\S+) TO /i', $grant, $m)) {
continue;
}
$privileges = strtoupper($m[1]);
$db = str_replace(['`', '\\_', '\\%'], ['', '_', '%'], $m[2]);
$table = trim($m[3], '`');
$dbMatches = $db === '*' || $db === $database || fnmatch(str_replace('%', '*', $db), $database);
if (!$dbMatches) {
continue;
}
$writes = str_contains($privileges, 'ALL PRIVILEGES')
|| preg_match('/\b(UPDATE|DELETE)\b/', $privileges);
if (!$writes) {
continue;
}
foreach (self::IMMUTABLE_TABLES as $immutable) {
if ($table === '*' || $table === $immutable) {
$allowed[$immutable] = true;
}
}
}
$allowed
? $this->fail_('payments', 'the database user ' . DB::selectOne('SELECT CURRENT_USER() AS u')->u
. ' may UPDATE or DELETE ' . implode(', ', array_keys($allowed)) . ': financial records are not protected')
: $this->ok('payments', 'transactions, transaction_types and poll_ledger cannot be changed or deleted by the app user');
}
protected function checkSearch(): void
{
$models = ['User', 'Organization', 'Bank', 'Post', 'Call', 'Tag', 'Transaction'];
foreach ($models as $model) {
$class = "App\\Models\\{$model}";
$raw = $class::search('')->take(1)->raw();
$total = (int) data_get($raw, 'hits.total.value', data_get($raw, 'hits.total', 0));
$index = (new $class)->searchableAs();
if ($total > 0) {
$this->ok('search', "{$model}: index {$index} answers, " . ($total >= 10000 ? '10000+' : $total) . ' documents');
} elseif ($this->hasSearchableRows($class)) {
$this->fail_('search', "{$model}: index {$index} is empty while the table has rows to index: re-index (scripts/re-index-search.sh)");
} elseif ($class::count() > 0) {
$this->ok('search', "{$model}: index {$index} answers (none of the rows may be indexed now, e.g. all expired)");
} else {
$this->ok('search', "{$model}: index {$index} answers (no rows yet)");
}
}
}
/**
* Whether any row belongs in the index. Calls and posts index only some rows (shouldBeSearchable(): e.g. not
* expired), so an empty index is right when none qualifies; on 2026-10-19 every call on the dev site had expired.
*/
protected function hasSearchableRows(string $class): bool
{
if (!method_exists($class, 'shouldBeSearchable') || (new \ReflectionMethod($class, 'shouldBeSearchable'))->getDeclaringClass()->getName() !== $class) {
return $class::count() > 0;
}
foreach ($class::query()->lazy() as $row) {
if ($row->shouldBeSearchable()) {
return true;
}
}
return false;
}
protected function checkBackground(): void
{
foreach (self::QUEUES as $queue) {
$size = Queue::size($queue);
$size > 500
? $this->warn_('background', "queue {$queue}: {$size} jobs waiting: are the workers running with --queue=" . implode(',', self::QUEUES) . '?')
: $this->ok('background', "queue {$queue}: {$size} jobs waiting");
}
if (config('broadcasting.default') !== 'reverb') {
$this->ok('background', 'broadcasting is not Reverb: not checked');
return;
}
$host = (string) config('broadcasting.connections.reverb.options.host', '127.0.0.1');
$port = (int) config('broadcasting.connections.reverb.options.port', 8080);
$socket = @fsockopen($host, $port, $errno, $error, 2);
if ($socket) {
fclose($socket);
$this->ok('background', "Reverb answers on {$host}:{$port}");
} else {
$this->fail_('background', "Reverb does not answer on {$host}:{$port} ({$error})");
}
}
}