#!/usr/bin/env php $results */ $results = []; $failCount = 0; $warnCount = 0; /** * @param callable():array{status: string, message: string} $check */ $runCheck = static function (string $name, callable $check) use (&$results, &$failCount, &$warnCount): void { try { $result = $check(); } catch (Throwable $exception) { $result = [ 'status' => 'fail', 'message' => $exception->getMessage(), ]; } $status = strtolower(trim((string) ($result['status'] ?? 'fail'))); if (!in_array($status, ['ok', 'warn', 'fail'], true)) { $status = 'fail'; } $message = trim((string) ($result['message'] ?? '')); if ($message === '') { $message = 'no details'; } if ($status === 'fail') { $failCount++; } elseif ($status === 'warn') { $warnCount++; } $results[] = [ 'status' => $status, 'name' => $name, 'message' => $message, ]; }; $runCheck('Environment validation', static function (): array { EnvValidator::validate(); return [ 'status' => 'ok', 'message' => 'all required env keys and formats are valid', ]; }); $runCheck('App container bootstrap', static function () use ($container): array { if (!$container instanceof AppContainer) { return [ 'status' => 'fail', 'message' => 'registerContainer.php did not return AppContainer', ]; } app(AuthService::class); app(AuthorizationService::class); app(UiAccessService::class); return [ 'status' => 'ok', 'message' => 'core services resolved successfully', ]; }); $runCheck('Database connectivity', static function (): array { $pong = DB::selectValue('select 1'); if ((int) $pong !== 1) { return [ 'status' => 'fail', 'message' => 'select 1 did not return expected value', ]; } return [ 'status' => 'ok', 'message' => 'connection established', ]; }); $runCheck('Database schema basics', static function (): array { $requiredTables = [ 'users', 'roles', 'permissions', 'user_roles', 'role_permissions', 'tenants', 'departments', 'settings', 'scheduler_runtime_status', ]; $rows = DB::select( 'select table_name from information_schema.tables where table_schema = database() and table_name in (???)', $requiredTables ); $present = []; foreach ((array) $rows as $row) { $table = (string) (($row['tables']['table_name'] ?? $row['table_name'] ?? '')); if ($table !== '') { $present[] = $table; } } $present = array_values(array_unique($present)); sort($present, SORT_STRING); $missing = array_values(array_diff($requiredTables, $present)); if ($missing) { return [ 'status' => 'fail', 'message' => 'missing tables: ' . implode(', ', $missing), ]; } return [ 'status' => 'ok', 'message' => sprintf('%d core tables present', count($requiredTables)), ]; }); $runCheck('Storage path writeability', static function (): array { $storagePath = defined('APP_STORAGE_PATH') && APP_STORAGE_PATH ? rtrim((string) APP_STORAGE_PATH, '/') : rtrim(dirname(__DIR__) . '/storage', '/'); if (!is_dir($storagePath)) { return [ 'status' => 'fail', 'message' => "storage directory not found: {$storagePath}", ]; } if (!is_writable($storagePath)) { return [ 'status' => 'fail', 'message' => "storage directory not writable: {$storagePath}", ]; } $probeFile = $storagePath . '/.doctor-write-probe-' . uniqid('', true); $written = @file_put_contents($probeFile, 'ok'); if ($written === false) { return [ 'status' => 'fail', 'message' => "write probe failed in: {$storagePath}", ]; } @unlink($probeFile); return [ 'status' => 'ok', 'message' => "storage path is writable ({$storagePath})", ]; }); $runCheck('RBAC baseline permissions', static function (): array { $requiredPermissions = [ PermissionService::USERS_VIEW, PermissionService::TENANTS_VIEW, PermissionService::DEPARTMENTS_VIEW, PermissionService::ROLES_VIEW, PermissionService::PERMISSIONS_VIEW, PermissionService::SETTINGS_VIEW, ]; $rows = DB::select( 'select `key` from permissions where active = 1 and `key` in (???)', $requiredPermissions ); $present = []; foreach ((array) $rows as $row) { $key = (string) (($row['permissions']['key'] ?? $row['key'] ?? '')); if ($key !== '') { $present[] = $key; } } $present = array_values(array_unique($present)); sort($present, SORT_STRING); $missing = array_values(array_diff($requiredPermissions, $present)); if ($missing) { return [ 'status' => 'fail', 'message' => 'missing active permissions: ' . implode(', ', $missing), ]; } return [ 'status' => 'ok', 'message' => sprintf('%d baseline permissions active', count($requiredPermissions)), ]; }); $runCheck('Admin role assignment', static function (): array { $count = (int) (DB::selectValue( 'select count(distinct ur.user_id) from user_roles ur join roles r on r.id = ur.role_id and r.active = 1 where r.description in (?, ?) or r.id = 1', 'Admin', 'Administrator' ) ?? 0); if ($count <= 0) { return [ 'status' => 'fail', 'message' => 'no active user assigned to Admin/Administrator role', ]; } return [ 'status' => 'ok', 'message' => sprintf('%d admin user(s) assigned', $count), ]; }); $runCheck('Scheduler heartbeat', static function (): array { $row = DB::selectOne('select last_heartbeat_at, last_result, last_error_code from scheduler_runtime_status where id = 1 limit 1'); $status = is_array($row) ? ($row['scheduler_runtime_status'] ?? $row) : null; if (!is_array($status)) { return [ 'status' => 'warn', 'message' => 'no scheduler runtime status row found yet', ]; } $heartbeat = trim((string) ($status['last_heartbeat_at'] ?? '')); $result = trim((string) ($status['last_result'] ?? 'unknown')); $errorCode = trim((string) ($status['last_error_code'] ?? '')); if ($heartbeat === '') { return [ 'status' => 'warn', 'message' => 'scheduler heartbeat is empty', ]; } $seconds = time() - strtotime($heartbeat . ' UTC'); if ($seconds < 0) { $seconds = 0; } if ($seconds > 300) { return [ 'status' => 'warn', 'message' => "last heartbeat {$seconds}s ago (result={$result}" . ($errorCode !== '' ? ", error={$errorCode}" : '') . ')', ]; } return [ 'status' => 'ok', 'message' => "last heartbeat {$seconds}s ago (result={$result}" . ($errorCode !== '' ? ", error={$errorCode}" : '') . ')', ]; }); fwrite(STDOUT, "Minty Doctor Report\n"); fwrite(STDOUT, "===================\n"); foreach ($results as $item) { $statusLabel = strtoupper($item['status']); fwrite(STDOUT, sprintf("[%s] %s: %s\n", $statusLabel, $item['name'], $item['message'])); } $okCount = count($results) - $warnCount - $failCount; fwrite(STDOUT, sprintf("\nSummary: ok=%d warn=%d fail=%d\n", $okCount, $warnCount, $failCount)); $exitCode = $failCount > 0 ? 1 : 0; $resultLabel = $failCount > 0 ? 'fail' : ($warnCount > 0 ? 'warn' : 'ok'); try { app(SystemAuditService::class)->record( 'cli.command', $failCount > 0 ? 'failed' : 'success', [ 'metadata' => [ 'command' => 'bin/doctor.php', 'exit_code' => $exitCode, 'duration_ms' => max(0, (int) round((microtime(true) - $startedAt) * 1000)), 'result' => $resultLabel, 'warn_count' => $warnCount, 'fail_count' => $failCount, ], ] ); } catch (Throwable) { // fail-open } DB::close(); exit($exitCode);