1
0
Files
breadcrumb-the-shire/lib/Console/Runner/Doctor/DoctorRunner.php
fs 7121732fcf refactor(cli)!: hard-cut legacy scripts and standardize console runtime
- remove legacy bin/module-*.php and bin/doctor.php entry scripts

- move module/doctor execution into class-based runners under lib/Console

- add stable JSON output for doctor and module:sync

- introduce bin/dev for init/up/down/logs/console/qa workflow

- update DI wiring, phpstan scan config, tests, and docs to new CLI contract
2026-04-01 17:14:20 +02:00

318 lines
11 KiB
PHP

<?php
declare(strict_types=1);
namespace MintyPHP\Console\Runner\Doctor;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Bootstrap\EnvValidator;
use MintyPHP\Console\Support\CliAppBootstrap;
use MintyPHP\DB;
use MintyPHP\Module\Audit\Service\SystemAuditService;
use MintyPHP\Service\Access\AuthorizationService;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Access\UiAccessService;
use MintyPHP\Service\Auth\AuthService;
use Throwable;
final class DoctorRunner implements DoctorRunnerInterface
{
public function run(): array
{
error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED);
ini_set('display_errors', '0');
/** @var AppContainer $container */
$container = CliAppBootstrap::bootstrap('cli', 'bin/console doctor');
$startedAt = microtime(true);
$results = [];
$failCount = 0;
$warnCount = 0;
$runCheck = static function (string $name, callable $check) use (&$results, &$failCount, &$warnCount): void {
try {
$result = $check();
} catch (Throwable $throwable) {
$result = [
'status' => 'fail',
'message' => $throwable->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(CliAppBootstrap::projectRoot() . '/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}" : '') . ')',
];
});
$okCount = count($results) - $warnCount - $failCount;
$exitCode = $failCount > 0 ? 1 : 0;
$status = $failCount > 0 ? 'fail' : ($warnCount > 0 ? 'warn' : 'ok');
$durationMs = max(0, (int) round((microtime(true) - $startedAt) * 1000));
try {
app(SystemAuditService::class)->record(
'cli.command',
$exitCode === 0 ? 'success' : 'failed',
[
'metadata' => [
'command' => 'bin/console doctor',
'exit_code' => $exitCode,
'duration_ms' => $durationMs,
'result' => $status,
'warn_count' => $warnCount,
'fail_count' => $failCount,
],
]
);
} catch (Throwable) {
// fail-open
} finally {
DB::close();
}
return [
'command' => 'doctor',
'status' => $status,
'checks' => $results,
'ok_count' => $okCount,
'warn_count' => $warnCount,
'fail_count' => $failCount,
'exit_code' => $exitCode,
'duration_ms' => $durationMs,
];
}
}