*/ 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, label: string, description: string} */ public function checkDatabase(): array { $label = 'Database'; $okDescription = 'Connection established'; try { $ok = $this->repository->checkDatabaseConnectivity(); return [ 'status' => $ok ? 'ok' : 'fail', 'name' => 'Database connectivity', 'message' => $ok ? 'connection established' : 'select 1 did not return expected value', 'label' => $label, 'description' => $ok ? $okDescription : 'Database did not return the expected response', ]; } catch (\Throwable $e) { return [ 'status' => 'fail', 'name' => 'Database connectivity', 'message' => 'check failed', 'label' => $label, 'description' => 'Database check could not be executed', ]; } } /** * @return array{status: string, name: string, message: string, label: string, description: string} */ public function checkDatabaseSchema(): array { $label = 'Database schema'; 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), 'label' => $label, 'description' => 'Required core tables are missing', ]; } return [ 'status' => 'ok', 'name' => 'Database schema', 'message' => sprintf('%d core tables present', count(self::REQUIRED_TABLES)), 'label' => $label, 'description' => 'All required core tables are present', ]; } catch (\Throwable $e) { return [ 'status' => 'fail', 'name' => 'Database schema', 'message' => 'check failed', 'label' => $label, 'description' => 'Schema check could not be executed', ]; } } /** * @return array{status: string, name: string, message: string, label: string, description: string} */ public function checkStorageWriteability(): array { $label = 'Storage'; 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', 'label' => $label, 'description' => 'Storage directory is missing', ]; } if (!is_writable($storagePath)) { return [ 'status' => 'fail', 'name' => 'Storage writeability', 'message' => 'storage directory not writable', 'label' => $label, 'description' => 'Storage directory is 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', 'label' => $label, 'description' => 'Storage write probe failed', ]; } @unlink($probeFile); return [ 'status' => 'ok', 'name' => 'Storage writeability', 'message' => 'storage path is writable', 'label' => $label, 'description' => 'Storage path is writable', ]; } catch (\Throwable $e) { return [ 'status' => 'fail', 'name' => 'Storage writeability', 'message' => 'check failed', 'label' => $label, 'description' => 'Storage check could not be executed', ]; } } /** * @return array{status: string, name: string, message: string, label: string, description: string} */ public function checkRbacBaseline(): array { $label = 'Permissions'; 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), 'label' => $label, 'description' => 'Baseline permissions are missing', ]; } return [ 'status' => 'ok', 'name' => 'RBAC baseline', 'message' => sprintf('%d baseline permissions active', count(self::REQUIRED_PERMISSIONS)), 'label' => $label, 'description' => 'Baseline permissions are present', ]; } catch (\Throwable $e) { return [ 'status' => 'fail', 'name' => 'RBAC baseline', 'message' => 'check failed', 'label' => $label, 'description' => 'Permission check could not be executed', ]; } } /** * @return array{status: string, name: string, message: string, label: string, description: string} */ public function checkAdminRoleAssignment(): array { $label = 'Administrators'; try { $count = $this->repository->countAdminUsers(); if ($count <= 0) { return [ 'status' => 'fail', 'name' => 'Admin role assignment', 'message' => 'no active user assigned to Admin/Administrator role', 'label' => $label, 'description' => 'No active user has the administrator role', ]; } return [ 'status' => 'ok', 'name' => 'Admin role assignment', 'message' => sprintf('%d admin user(s) assigned', $count), 'label' => $label, 'description' => 'At least one administrator is active', ]; } catch (\Throwable $e) { return [ 'status' => 'fail', 'name' => 'Admin role assignment', 'message' => 'check failed', 'label' => $label, 'description' => 'Administrator check could not be executed', ]; } } /** * @return array{status: string, name: string, message: string, label: string, description: string} */ public function checkSchedulerHeartbeat(): array { $label = 'Scheduler'; try { $status = $this->repository->getSchedulerStatus(); if ($status === null) { return [ 'status' => 'warn', 'name' => 'Scheduler heartbeat', 'message' => 'no scheduler runtime status row found yet', 'label' => $label, 'description' => 'Scheduler has not reported 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', 'label' => $label, 'description' => 'Scheduler heartbeat is empty', ]; } $seconds = max(0, time() - strtotime($heartbeat . ' UTC')); $detail = "last heartbeat {$seconds}s ago (result={$result}" . ($errorCode !== '' ? ", error={$errorCode}" : '') . ')'; $stale = $seconds > self::SCHEDULER_STALE_THRESHOLD_SECONDS; return [ 'status' => $stale ? 'warn' : 'ok', 'name' => 'Scheduler heartbeat', 'message' => $detail, 'label' => $label, 'description' => $stale ? 'Scheduler heartbeat is outdated' : 'Scheduler is running on schedule', ]; } catch (\Throwable $e) { return [ 'status' => 'fail', 'name' => 'Scheduler heartbeat', 'message' => 'check failed', 'label' => $label, 'description' => 'Scheduler check could not be executed', ]; } } }