Snapshot of main at 9f39a6e1e (2026-11-02)
This commit is contained in:
commit
6ccf1792d7
2580 files changed
+1040202
No files matched your search
@@ -0,0 +1,331 @@
|
||||
<?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})");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user