*/ public function runAll(): array { return [ $this->checkDatabase(), $this->checkDatabaseSchema(), $this->checkStorageWriteability(), $this->checkRbacBaseline(), $this->checkAdminRoleAssignment(), $this->checkSchedulerHeartbeat(), ]; } /** * @return array{status: string, name: string, message: string} */ public function checkDatabase(): array { try { $ok = $this->repository->checkDatabaseConnectivity(); return [ 'status' => $ok ? 'ok' : 'fail', 'name' => 'Database connectivity', 'message' => $ok ? 'connection established' : 'select 1 did not return expected value', ]; } catch (\Throwable $e) { return [ 'status' => 'fail', 'name' => 'Database connectivity', 'message' => 'check failed', ]; } } /** * @return array{status: string, name: string, message: string} */ public function checkDatabaseSchema(): array { try { $present = $this->repository->listPresentTables(self::REQUIRED_TABLES); sort($present, SORT_STRING); $missing = array_values(array_diff(self::REQUIRED_TABLES, $present)); if ($missing) { return [ 'status' => 'fail', 'name' => 'Database schema', 'message' => 'missing tables: ' . implode(', ', $missing), ]; } return [ 'status' => 'ok', 'name' => 'Database schema', 'message' => sprintf('%d core tables present', count(self::REQUIRED_TABLES)), ]; } catch (\Throwable $e) { return [ 'status' => 'fail', 'name' => 'Database schema', 'message' => 'check failed', ]; } } /** * @return array{status: string, name: string, message: string} */ public function checkStorageWriteability(): array { try { $storagePath = defined('APP_STORAGE_PATH') && APP_STORAGE_PATH ? rtrim((string) APP_STORAGE_PATH, '/') : rtrim(dirname(__DIR__, 3) . '/storage', '/'); if (!is_dir($storagePath)) { return [ 'status' => 'fail', 'name' => 'Storage writeability', 'message' => 'storage directory not found', ]; } if (!is_writable($storagePath)) { return [ 'status' => 'fail', 'name' => 'Storage writeability', 'message' => 'storage directory not writable', ]; } $probeFile = $storagePath . '/.doctor-write-probe-' . uniqid('', true); $written = @file_put_contents($probeFile, 'ok'); if ($written === false) { return [ 'status' => 'fail', 'name' => 'Storage writeability', 'message' => 'write probe failed', ]; } @unlink($probeFile); return [ 'status' => 'ok', 'name' => 'Storage writeability', 'message' => 'storage path is writable', ]; } catch (\Throwable $e) { return [ 'status' => 'fail', 'name' => 'Storage writeability', 'message' => 'check failed', ]; } } /** * @return array{status: string, name: string, message: string} */ public function checkRbacBaseline(): array { try { $present = $this->repository->listActivePermissionKeys(self::REQUIRED_PERMISSIONS); sort($present, SORT_STRING); $missing = array_values(array_diff(self::REQUIRED_PERMISSIONS, $present)); if ($missing) { return [ 'status' => 'fail', 'name' => 'RBAC baseline', 'message' => 'missing active permissions: ' . implode(', ', $missing), ]; } return [ 'status' => 'ok', 'name' => 'RBAC baseline', 'message' => sprintf('%d baseline permissions active', count(self::REQUIRED_PERMISSIONS)), ]; } catch (\Throwable $e) { return [ 'status' => 'fail', 'name' => 'RBAC baseline', 'message' => 'check failed', ]; } } /** * @return array{status: string, name: string, message: string} */ public function checkAdminRoleAssignment(): array { try { $count = $this->repository->countAdminUsers(); if ($count <= 0) { return [ 'status' => 'fail', 'name' => 'Admin role assignment', 'message' => 'no active user assigned to Admin/Administrator role', ]; } return [ 'status' => 'ok', 'name' => 'Admin role assignment', 'message' => sprintf('%d admin user(s) assigned', $count), ]; } catch (\Throwable $e) { return [ 'status' => 'fail', 'name' => 'Admin role assignment', 'message' => 'check failed', ]; } } /** * @return array{status: string, name: string, message: string} */ public function checkSchedulerHeartbeat(): array { try { $status = $this->repository->getSchedulerStatus(); if ($status === null) { return [ 'status' => 'warn', 'name' => 'Scheduler heartbeat', 'message' => 'no scheduler runtime status row found yet', ]; } $heartbeat = $status['last_heartbeat_at']; $result = $status['last_result']; $errorCode = $status['last_error_code']; if ($heartbeat === '') { return [ 'status' => 'warn', 'name' => 'Scheduler heartbeat', 'message' => 'scheduler heartbeat is empty', ]; } $seconds = max(0, time() - strtotime($heartbeat . ' UTC')); $detail = "last heartbeat {$seconds}s ago (result={$result}" . ($errorCode !== '' ? ", error={$errorCode}" : '') . ')'; return [ 'status' => $seconds > self::SCHEDULER_STALE_THRESHOLD_SECONDS ? 'warn' : 'ok', 'name' => 'Scheduler heartbeat', 'message' => $detail, ]; } catch (\Throwable $e) { return [ 'status' => 'fail', 'name' => 'Scheduler heartbeat', 'message' => 'check failed', ]; } } }