From 74180e86966fa19270eb98efd9ec78b0945d89c6 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 28 Jan 2026 00:50:06 +0200 Subject: [PATCH] Implement cgroup v2 limits and sync --- README.md | 2 +- VERSION | 2 +- app/Console/Commands/CollectUserUsage.php | 17 +- app/Console/Commands/JabaliSyncCgroups.php | 60 +++ app/Filament/Admin/Pages/ServerSettings.php | 28 +- app/Services/Agent/AgentClient.php | 17 + app/Services/System/ResourceLimitService.php | 5 + bin/jabali-agent | 502 +++++++++++++++---- install.sh | 33 ++ routes/console.php | 7 + tests/Feature/JabaliSyncCgroupsTest.php | 50 ++ 11 files changed, 630 insertions(+), 93 deletions(-) create mode 100644 app/Console/Commands/JabaliSyncCgroups.php create mode 100644 tests/Feature/JabaliSyncCgroupsTest.php diff --git a/README.md b/README.md index 59bfcaa..2e1b15e 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A modern web hosting control panel for WordPress and general PHP hosting. Built with Laravel 12, Filament v5, Livewire 4, and Tailwind CSS v4. -Version: 0.9-rc8 (release candidate) +Version: 0.9-rc9 (release candidate) This is a release candidate. Expect rapid iteration and breaking changes until 1.0. diff --git a/VERSION b/VERSION index 2e30def..f78734f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -VERSION=0.9-rc8 +VERSION=0.9-rc9 diff --git a/app/Console/Commands/CollectUserUsage.php b/app/Console/Commands/CollectUserUsage.php index a8dd30f..39048f5 100644 --- a/app/Console/Commands/CollectUserUsage.php +++ b/app/Console/Commands/CollectUserUsage.php @@ -49,10 +49,13 @@ class CollectUserUsage extends Command $bandwidthTotal = $this->getBandwidthTotal($agent, $user); $resourceStats = $this->getUserResourceStats($agent, $user); $cpuPercent = (int) round($resourceStats['cpu_percent'] ?? 0); + $cpuUsageTotal = (int) ($resourceStats['cpu_usage_usec_total'] ?? 0); $memoryBytes = (int) ($resourceStats['memory_bytes'] ?? 0); $diskIoTotal = (int) ($resourceStats['disk_io_total_bytes'] ?? 0); $lastBandwidthTotal = (int) UserSetting::getForUser($user->id, 'bandwidth_total_bytes', 0); $lastDiskIoTotal = (int) UserSetting::getForUser($user->id, 'disk_io_total_bytes', 0); + $lastCpuTotal = (int) UserSetting::getForUser($user->id, 'cpu_usage_usec_total', 0); + $lastCpuAt = (int) UserSetting::getForUser($user->id, 'cpu_usage_captured_at', 0); $bandwidthDelta = $bandwidthTotal >= $lastBandwidthTotal ? $bandwidthTotal - $lastBandwidthTotal : $bandwidthTotal; @@ -60,6 +63,14 @@ class CollectUserUsage extends Command ? $diskIoTotal - $lastDiskIoTotal : $diskIoTotal; + if ($cpuUsageTotal > 0 && $lastCpuAt > 0) { + $elapsed = $capturedAt->timestamp - $lastCpuAt; + if ($elapsed > 0) { + $delta = $cpuUsageTotal >= $lastCpuTotal ? $cpuUsageTotal - $lastCpuTotal : $cpuUsageTotal; + $cpuPercent = (int) round(($delta / ($elapsed * 1_000_000)) * 100); + } + } + $this->storeMetric($user->id, 'disk_bytes', $diskBytes, $capturedAt); $this->storeMetric($user->id, 'mail_bytes', $mailBytes, $capturedAt); $this->storeMetric($user->id, 'database_bytes', $dbBytes, $capturedAt); @@ -70,6 +81,8 @@ class CollectUserUsage extends Command UserSetting::setForUser($user->id, 'bandwidth_total_bytes', $bandwidthTotal); UserSetting::setForUser($user->id, 'disk_io_total_bytes', $diskIoTotal); + UserSetting::setForUser($user->id, 'cpu_usage_usec_total', $cpuUsageTotal); + UserSetting::setForUser($user->id, 'cpu_usage_captured_at', $capturedAt->timestamp); $this->line("Collected usage for {$user->username}"); } @@ -119,7 +132,7 @@ class CollectUserUsage extends Command } /** - * @return array{cpu_percent: float, memory_bytes: int, disk_io_total_bytes: int} + * @return array{cpu_percent: float, cpu_usage_usec_total: int, memory_bytes: int, disk_io_total_bytes: int} */ protected function getUserResourceStats(AgentClient $agent, User $user): array { @@ -131,6 +144,7 @@ class CollectUserUsage extends Command if ($result['success'] ?? false) { return [ 'cpu_percent' => (float) ($result['cpu_percent'] ?? 0), + 'cpu_usage_usec_total' => (int) ($result['cpu_usage_usec_total'] ?? 0), 'memory_bytes' => (int) ($result['memory_bytes'] ?? 0), 'disk_io_total_bytes' => (int) ($result['disk_io_total_bytes'] ?? 0), ]; @@ -141,6 +155,7 @@ class CollectUserUsage extends Command return [ 'cpu_percent' => 0.0, + 'cpu_usage_usec_total' => 0, 'memory_bytes' => 0, 'disk_io_total_bytes' => 0, ]; diff --git a/app/Console/Commands/JabaliSyncCgroups.php b/app/Console/Commands/JabaliSyncCgroups.php new file mode 100644 index 0000000..06f16e5 --- /dev/null +++ b/app/Console/Commands/JabaliSyncCgroups.php @@ -0,0 +1,60 @@ +option('user') ?? ''); + + try { + $result = $user !== '' + ? $this->agent->cgroupSyncUserProcesses($user) + : $this->agent->cgroupSyncAllProcesses(); + } catch (Exception $e) { + $this->error($e->getMessage()); + + return self::FAILURE; + } + + if (! ($result['success'] ?? false)) { + $this->error($result['error'] ?? 'Unknown error'); + + return self::FAILURE; + } + + $moved = (int) ($result['moved'] ?? 0); + $this->info("Synced cgroups, moved {$moved} process(es)."); + + return self::SUCCESS; + } +} diff --git a/app/Filament/Admin/Pages/ServerSettings.php b/app/Filament/Admin/Pages/ServerSettings.php index c122658..8630f66 100644 --- a/app/Filament/Admin/Pages/ServerSettings.php +++ b/app/Filament/Admin/Pages/ServerSettings.php @@ -8,7 +8,9 @@ use App\Filament\Admin\Widgets\Settings\DnssecTable; use App\Filament\Admin\Widgets\Settings\NotificationLogTable; use App\Filament\Concerns\HasPageTour; use App\Models\DnsSetting; +use App\Models\UserResourceLimit; use App\Services\Agent\AgentClient; +use App\Services\System\ResourceLimitService; use BackedEnum; use Exception; use Filament\Actions\Action; @@ -197,6 +199,7 @@ class ServerSettings extends Page implements HasActions, HasForms $this->quotaData = [ 'quotas_enabled' => (bool) ($settings['quotas_enabled'] ?? false), 'default_quota_mb' => (int) ($settings['default_quota_mb'] ?? 5120), + 'resource_limits_enabled' => (bool) ($settings['resource_limits_enabled'] ?? true), ]; $this->fileManagerData = [ @@ -419,6 +422,10 @@ class ServerSettings extends Page implements HasActions, HasForms ->numeric() ->placeholder('5120') ->helperText(__('Default disk quota for new users (5120 MB = 5 GB)')), + Toggle::make('quotaData.resource_limits_enabled') + ->label(__('Enable CPU/Memory/IO Limits')) + ->helperText(__('Apply cgroup limits from hosting packages (CloudLinux-style)')) + ->columnSpanFull(), ]), Actions::make([ FormAction::make('saveQuotaSettings') @@ -834,9 +841,11 @@ class ServerSettings extends Page implements HasActions, HasForms { $data = $this->quotaData; $wasEnabled = (bool) DnsSetting::get('quotas_enabled', false); + $wasLimitsEnabled = (bool) DnsSetting::get('resource_limits_enabled', true); DnsSetting::set('quotas_enabled', $data['quotas_enabled'] ? '1' : '0'); DnsSetting::set('default_quota_mb', (string) $data['default_quota_mb']); + DnsSetting::set('resource_limits_enabled', ! empty($data['resource_limits_enabled']) ? '1' : '0'); DnsSetting::clearCache(); if ($data['quotas_enabled'] && ! $wasEnabled) { @@ -850,9 +859,24 @@ class ServerSettings extends Page implements HasActions, HasForms } catch (Exception $e) { Notification::make()->title(__('Settings saved'))->body(__('Warning: Could not enable quota system.'))->warning()->send(); } - } else { - Notification::make()->title(__('Quota settings saved'))->success()->send(); } + + if (! empty($data['resource_limits_enabled']) && ! $wasLimitsEnabled) { + $limits = UserResourceLimit::query()->where('is_active', true)->get(); + foreach ($limits as $limit) { + app(ResourceLimitService::class)->apply($limit); + } + } + + if (empty($data['resource_limits_enabled']) && $wasLimitsEnabled) { + try { + $this->getAgent()->send('cgroup.clear_all_limits', []); + } catch (Exception $e) { + Notification::make()->title(__('Settings saved'))->body(__('Warning: Could not clear cgroup limits.'))->warning()->send(); + } + } + + Notification::make()->title(__('Quota settings saved'))->success()->send(); } public function saveFileManagerSettings(): void diff --git a/app/Services/Agent/AgentClient.php b/app/Services/Agent/AgentClient.php index a0c48a1..d56ec8f 100644 --- a/app/Services/Agent/AgentClient.php +++ b/app/Services/Agent/AgentClient.php @@ -1371,6 +1371,23 @@ class AgentClient ]); } + public function cgroupSyncUserProcesses(string $username): array + { + return $this->send('cgroup.sync_user_processes', [ + 'username' => $username, + ]); + } + + public function cgroupSyncAllProcesses(): array + { + return $this->send('cgroup.sync_all_processes'); + } + + public function cgroupClearAllLimits(): array + { + return $this->send('cgroup.clear_all_limits'); + } + public function databasePersistTuning(string $name, string $value): array { return $this->send('database.persist_tuning', [ diff --git a/app/Services/System/ResourceLimitService.php b/app/Services/System/ResourceLimitService.php index f00bbe1..6c4b3d2 100644 --- a/app/Services/System/ResourceLimitService.php +++ b/app/Services/System/ResourceLimitService.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Services\System; +use App\Models\DnsSetting; use App\Models\UserResourceLimit; use App\Services\Agent\AgentClient; use RuntimeException; @@ -12,6 +13,10 @@ class ResourceLimitService { public function apply(UserResourceLimit $limit): void { + if (! DnsSetting::get('resource_limits_enabled', true)) { + return; + } + $user = $limit->user; if (! $user) { throw new RuntimeException('User not found for resource limit.'); diff --git a/bin/jabali-agent b/bin/jabali-agent index 26704d0..24ce547 100755 --- a/bin/jabali-agent +++ b/bin/jabali-agent @@ -549,6 +549,9 @@ function handleAction(array $request): array 'geo.apply_rules' => geoApplyRules($params), 'cgroup.apply_user_limits' => cgroupApplyUserLimits($params), 'cgroup.clear_user_limits' => cgroupClearUserLimits($params), + 'cgroup.sync_user_processes' => cgroupSyncUserProcesses($params), + 'cgroup.sync_all_processes' => cgroupSyncAllProcesses($params), + 'cgroup.clear_all_limits' => cgroupClearAllUserLimits($params), 'database.persist_tuning' => databasePersistTuning($params), 'server.export_config' => serverExportConfig($params), 'server.import_config' => serverImportConfig($params), @@ -815,6 +818,11 @@ function createUser(array $params): array logger("Warning: Failed to create Redis user for $username: " . ($redisResult['error'] ?? 'Unknown error')); } + $cgroupResult = ensureUserCgroup($username); + if (!($cgroupResult['success'] ?? false)) { + logger("Warning: Failed to initialize cgroup for $username: " . ($cgroupResult['error'] ?? 'Unknown error')); + } + logger("Created user $username with home directory $homeDir"); return [ @@ -1032,6 +1040,8 @@ function deleteUser(array $params): array } } + cgroupRemoveUser($username); + // Reload services exec('postmap ' . escapeshellarg(POSTFIX_VIRTUAL_DOMAINS) . ' 2>/dev/null'); exec('postmap ' . escapeshellarg(POSTFIX_VIRTUAL_MAILBOXES) . ' 2>/dev/null'); @@ -3048,6 +3058,313 @@ function getRootBlockDevice(): ?string return $resolvedPath ?: null; } +function isCgroupV2Available(): bool +{ + return is_file('/sys/fs/cgroup/cgroup.controllers'); +} + +function getJabaliCgroupRoot(): string +{ + return '/sys/fs/cgroup/jabali.slice'; +} + +function enableCgroupControllers(string $path, array $controllers): void +{ + $controllersFile = $path . '/cgroup.controllers'; + $subtreeFile = $path . '/cgroup.subtree_control'; + + if (!is_readable($controllersFile) || !is_writable($subtreeFile)) { + return; + } + + $available = preg_split('/\s+/', trim(file_get_contents($controllersFile))); + $toEnable = []; + foreach ($controllers as $controller) { + if (in_array($controller, $available, true)) { + $toEnable[] = '+' . $controller; + } + } + + if (!empty($toEnable)) { + @file_put_contents($subtreeFile, implode(' ', $toEnable)); + } +} + +function ensureJabaliCgroupRoot(): array +{ + if (!isCgroupV2Available()) { + return ['success' => false, 'error' => 'cgroup v2 is not available']; + } + + $root = getJabaliCgroupRoot(); + if (!is_dir($root)) { + exec('systemctl start jabali.slice 2>/dev/null', $output, $code); + } + + if (!is_dir($root)) { + return ['success' => false, 'error' => 'Failed to initialize jabali.slice']; + } + + enableCgroupControllers($root, ['cpu', 'memory', 'io']); + + return ['success' => true, 'path' => $root]; +} + +function getUserCgroupPath(string $username): string +{ + return getJabaliCgroupRoot() . '/user-' . $username; +} + +function ensureUserCgroup(string $username): array +{ + $rootResult = ensureJabaliCgroupRoot(); + if (!($rootResult['success'] ?? false)) { + return $rootResult; + } + + $path = getUserCgroupPath($username); + if (!is_dir($path)) { + mkdir($path, 0755, true); + } + + if (!is_dir($path)) { + return ['success' => false, 'error' => 'Failed to create user cgroup']; + } + + return ['success' => true, 'path' => $path]; +} + +function writeCgroupValue(string $path, string $file, string $value): bool +{ + $target = $path . '/' . $file; + if (!is_writable($target)) { + return false; + } + + return file_put_contents($target, $value) !== false; +} + +function getBlockDeviceId(?string $device): ?string +{ + if (!$device) { + return null; + } + + exec('lsblk -no MAJ:MIN ' . escapeshellarg($device) . ' 2>/dev/null', $output, $code); + $id = trim($output[0] ?? ''); + + return $id !== '' ? $id : null; +} + +function cgroupWriteCpuMax(string $path, int $cpuPercent): void +{ + $period = 100000; + + if ($cpuPercent <= 0) { + writeCgroupValue($path, 'cpu.max', 'max ' . $period); + return; + } + + $quota = (int) round($period * ($cpuPercent / 100)); + $quota = max($quota, 1000); + writeCgroupValue($path, 'cpu.max', $quota . ' ' . $period); +} + +function cgroupWriteMemoryMax(string $path, int $memoryMb): void +{ + if ($memoryMb <= 0) { + writeCgroupValue($path, 'memory.max', 'max'); + return; + } + + $bytes = $memoryMb * 1024 * 1024; + writeCgroupValue($path, 'memory.max', (string) $bytes); +} + +function cgroupWriteIoMax(string $path, int $ioMb): void +{ + $device = getRootBlockDevice(); + $deviceId = getBlockDeviceId($device); + + if (!$deviceId) { + return; + } + + if ($ioMb <= 0) { + writeCgroupValue($path, 'io.max', $deviceId . ' rbps=max wbps=max'); + return; + } + + $bytes = $ioMb * 1024 * 1024; + writeCgroupValue($path, 'io.max', $deviceId . ' rbps=' . $bytes . ' wbps=' . $bytes); +} + +function moveUserProcessesToCgroup(int $uid, string $cgroupPath): int +{ + $moved = 0; + foreach (glob('/proc/[0-9]*') as $procPath) { + $statusFile = $procPath . '/status'; + if (!is_readable($statusFile)) { + continue; + } + + $status = file($statusFile, FILE_IGNORE_NEW_LINES); + if (!$status) { + continue; + } + + $matchesUid = false; + foreach ($status as $line) { + if (str_starts_with($line, 'Uid:')) { + $parts = preg_split('/\s+/', trim($line)); + $matchesUid = isset($parts[1]) && (int) $parts[1] === $uid; + break; + } + } + + if (!$matchesUid) { + continue; + } + + $pid = basename($procPath); + if (!ctype_digit($pid)) { + continue; + } + + if (@file_put_contents($cgroupPath . '/cgroup.procs', $pid) !== false) { + $moved++; + } + } + + return $moved; +} + +function cgroupSyncUserProcesses(array $params): array +{ + $username = $params['username'] ?? ''; + if (!validateUsername($username)) { + return ['success' => false, 'error' => 'Invalid username format']; + } + + $userInfo = posix_getpwnam($username); + if (!$userInfo) { + return ['success' => false, 'error' => 'User not found']; + } + + $cgroup = ensureUserCgroup($username); + if (!($cgroup['success'] ?? false)) { + return $cgroup; + } + + $moved = moveUserProcessesToCgroup((int) $userInfo['uid'], $cgroup['path']); + + return ['success' => true, 'moved' => $moved]; +} + +function cgroupSyncAllProcesses(array $params): array +{ + $rootResult = ensureJabaliCgroupRoot(); + if (!($rootResult['success'] ?? false)) { + return $rootResult; + } + + $movedTotal = 0; + foreach (glob('/home/*', GLOB_ONLYDIR) as $homeDir) { + $username = basename($homeDir); + if (!validateUsername($username)) { + continue; + } + + $result = cgroupSyncUserProcesses(['username' => $username]); + if ($result['success'] ?? false) { + $movedTotal += (int) ($result['moved'] ?? 0); + } + } + + return ['success' => true, 'moved' => $movedTotal]; +} + +function cgroupClearAllUserLimits(array $params): array +{ + $rootResult = ensureJabaliCgroupRoot(); + if (!($rootResult['success'] ?? false)) { + return $rootResult; + } + + $cleared = 0; + foreach (glob(getJabaliCgroupRoot() . '/user-*') as $cgroupPath) { + if (!is_dir($cgroupPath)) { + continue; + } + + cgroupWriteCpuMax($cgroupPath, 0); + cgroupWriteMemoryMax($cgroupPath, 0); + cgroupWriteIoMax($cgroupPath, 0); + $cleared++; + } + + return ['success' => true, 'cleared' => $cleared]; +} + +function cgroupRemoveUser(string $username): void +{ + $path = getUserCgroupPath($username); + if (is_dir($path)) { + @rmdir($path); + } +} + +function readCgroupStatValue(string $path, string $key): ?int +{ + $file = $path . '/cpu.stat'; + if (!is_readable($file)) { + return null; + } + + $lines = file($file, FILE_IGNORE_NEW_LINES); + if (!$lines) { + return null; + } + + foreach ($lines as $line) { + if (str_starts_with($line, $key . ' ')) { + $parts = explode(' ', trim($line)); + return isset($parts[1]) ? (int) $parts[1] : null; + } + } + + return null; +} + +function readCgroupIoTotal(string $path): int +{ + $file = $path . '/io.stat'; + if (!is_readable($file)) { + return 0; + } + + $total = 0; + $lines = file($file, FILE_IGNORE_NEW_LINES); + if (!$lines) { + return 0; + } + + foreach ($lines as $line) { + if (!str_contains($line, 'rbytes=') && !str_contains($line, 'wbytes=')) { + continue; + } + + if (preg_match('/rbytes=(\d+)/', $line, $m)) { + $total += (int) $m[1]; + } + if (preg_match('/wbytes=(\d+)/', $line, $m)) { + $total += (int) $m[1]; + } + } + + return $total; +} + function cgroupApplyUserLimits(array $params): array { $username = $params['username'] ?? ''; @@ -3055,46 +3372,28 @@ function cgroupApplyUserLimits(array $params): array return ['success' => false, 'error' => 'Invalid username format']; } - exec('id -u ' . escapeshellarg($username) . ' 2>/dev/null', $uidOutput, $uidCode); - if ($uidCode !== 0) { + if (!posix_getpwnam($username)) { return ['success' => false, 'error' => 'User not found']; } - $uid = trim($uidOutput[0] ?? ''); - $slice = "user-{$uid}.slice"; - $cpu = isset($params['cpu_limit_percent']) ? (int) $params['cpu_limit_percent'] : 0; $memory = isset($params['memory_limit_mb']) ? (int) $params['memory_limit_mb'] : 0; $io = isset($params['io_limit_mb']) ? (int) $params['io_limit_mb'] : 0; - $properties = []; - if ($cpu > 0) { - $properties[] = "CPUQuota={$cpu}%"; - } - if ($memory > 0) { - $properties[] = "MemoryMax={$memory}M"; - } - if ($io > 0) { - $device = getRootBlockDevice(); - if ($device) { - $properties[] = "IOReadBandwidthMax={$device} {$io}M"; - $properties[] = "IOWriteBandwidthMax={$device} {$io}M"; - } + $cgroup = ensureUserCgroup($username); + if (!($cgroup['success'] ?? false)) { + return $cgroup; } - if (empty($properties)) { - return cgroupClearUserLimits(['username' => $username]); - } + $path = $cgroup['path']; - $command = 'systemctl set-property ' . escapeshellarg($slice) . ' ' . implode(' ', array_map('escapeshellarg', $properties)) . ' 2>&1'; - exec($command, $output, $code); - if ($code !== 0) { - return ['success' => false, 'error' => implode("\n", $output)]; - } + cgroupWriteCpuMax($path, $cpu); + cgroupWriteMemoryMax($path, $memory); + cgroupWriteIoMax($path, $io); - exec('systemctl daemon-reload 2>&1'); + cgroupSyncUserProcesses(['username' => $username]); - return ['success' => true, 'message' => 'Resource limits applied']; + return ['success' => true, 'message' => 'Resource limits applied', 'path' => $path]; } function cgroupClearUserLimits(array $params): array @@ -3104,22 +3403,21 @@ function cgroupClearUserLimits(array $params): array return ['success' => false, 'error' => 'Invalid username format']; } - exec('id -u ' . escapeshellarg($username) . ' 2>/dev/null', $uidOutput, $uidCode); - if ($uidCode !== 0) { + if (!posix_getpwnam($username)) { return ['success' => false, 'error' => 'User not found']; } - $uid = trim($uidOutput[0] ?? ''); - $slice = "user-{$uid}.slice"; - - exec('systemctl revert ' . escapeshellarg($slice) . ' 2>&1', $output, $code); - exec('systemctl daemon-reload 2>&1'); - - if ($code !== 0) { - return ['success' => false, 'error' => implode("\n", $output)]; + $cgroup = ensureUserCgroup($username); + if (!($cgroup['success'] ?? false)) { + return $cgroup; } - return ['success' => true, 'message' => 'Resource limits cleared']; + $path = $cgroup['path']; + cgroupWriteCpuMax($path, 0); + cgroupWriteMemoryMax($path, 0); + cgroupWriteIoMax($path, 0); + + return ['success' => true, 'message' => 'Resource limits cleared', 'path' => $path]; } function databasePersistTuning(array $params): array @@ -23745,71 +24043,99 @@ function usageUserResources(array $params): array $uid = (int) $userInfo['uid']; - $cpuTotal = 0.0; + $cpuPercent = 0.0; + $cpuUsageUsec = null; $memoryBytes = 0; + $diskIoTotal = 0; - exec("ps -u " . escapeshellarg($username) . " -o %cpu=,rss= 2>/dev/null", $psOut, $psCode); - if ($psCode === 0) { - foreach ($psOut as $line) { - $parts = preg_split('/\\s+/', trim($line)); - if (count($parts) < 2) { - continue; + $cgroupPath = getUserCgroupPath($username); + if (is_dir($cgroupPath)) { + $memoryCurrent = @file_get_contents($cgroupPath . '/memory.current'); + if ($memoryCurrent !== false) { + $memoryBytes = (int) trim($memoryCurrent); + } + + $cpuUsageUsec = readCgroupStatValue($cgroupPath, 'usage_usec'); + $diskIoTotal = readCgroupIoTotal($cgroupPath); + } + + if ($cpuUsageUsec === null || $memoryBytes === 0) { + $cpuTotal = 0.0; + $rssTotal = 0; + + exec("ps -u " . escapeshellarg($username) . " -o %cpu=,rss= 2>/dev/null", $psOut, $psCode); + if ($psCode === 0) { + foreach ($psOut as $line) { + $parts = preg_split('/\\s+/', trim($line)); + if (count($parts) < 2) { + continue; + } + $cpuTotal += (float) $parts[0]; + $rssTotal += (int) $parts[1] * 1024; } - $cpuTotal += (float) $parts[0]; - $memoryBytes += (int) $parts[1] * 1024; + } + + $cpuPercent = round($cpuTotal, 2); + if ($memoryBytes === 0) { + $memoryBytes = $rssTotal; } } - $diskRead = 0; - $diskWrite = 0; + if ($diskIoTotal === 0) { + $diskRead = 0; + $diskWrite = 0; - foreach (glob('/proc/[0-9]*') as $procPath) { - $statusFile = $procPath . '/status'; - if (!is_readable($statusFile)) { - continue; - } - $status = file($statusFile, FILE_IGNORE_NEW_LINES); - if (!$status) { - continue; - } + foreach (glob('/proc/[0-9]*') as $procPath) { + $statusFile = $procPath . '/status'; + if (!is_readable($statusFile)) { + continue; + } + $status = file($statusFile, FILE_IGNORE_NEW_LINES); + if (!$status) { + continue; + } - $matchesUid = false; - foreach ($status as $line) { - if (str_starts_with($line, 'Uid:')) { - $parts = preg_split('/\\s+/', trim($line)); - $matchesUid = isset($parts[1]) && (int) $parts[1] === $uid; - break; + $matchesUid = false; + foreach ($status as $line) { + if (str_starts_with($line, 'Uid:')) { + $parts = preg_split('/\\s+/', trim($line)); + $matchesUid = isset($parts[1]) && (int) $parts[1] === $uid; + break; + } + } + + if (!$matchesUid) { + continue; + } + + $ioFile = $procPath . '/io'; + if (!is_readable($ioFile)) { + continue; + } + + $ioLines = file($ioFile, FILE_IGNORE_NEW_LINES); + if (!$ioLines) { + continue; + } + + foreach ($ioLines as $line) { + if (str_starts_with($line, 'read_bytes:')) { + $diskRead += (int) trim(substr($line, 11)); + } elseif (str_starts_with($line, 'write_bytes:')) { + $diskWrite += (int) trim(substr($line, 12)); + } } } - if (!$matchesUid) { - continue; - } - - $ioFile = $procPath . '/io'; - if (!is_readable($ioFile)) { - continue; - } - - $ioLines = file($ioFile, FILE_IGNORE_NEW_LINES); - if (!$ioLines) { - continue; - } - - foreach ($ioLines as $line) { - if (str_starts_with($line, 'read_bytes:')) { - $diskRead += (int) trim(substr($line, 11)); - } elseif (str_starts_with($line, 'write_bytes:')) { - $diskWrite += (int) trim(substr($line, 12)); - } - } + $diskIoTotal = $diskRead + $diskWrite; } return [ 'success' => true, - 'cpu_percent' => round($cpuTotal, 2), + 'cpu_percent' => $cpuPercent, + 'cpu_usage_usec_total' => $cpuUsageUsec, 'memory_bytes' => $memoryBytes, - 'disk_io_total_bytes' => $diskRead + $diskWrite, + 'disk_io_total_bytes' => $diskIoTotal, ]; } diff --git a/install.sh b/install.sh index 8a13fcf..0ffece4 100755 --- a/install.sh +++ b/install.sh @@ -2519,6 +2519,33 @@ SERVICE log "Jabali Agent service configured" } +setup_cgroup_limits() { + header "Setting Up cgroup v2 Resource Limits" + + if [[ ! -f /sys/fs/cgroup/cgroup.controllers ]]; then + warn "cgroup v2 not detected - resource limits will be disabled" + return + fi + + cat > /etc/systemd/system/jabali.slice << 'SLICE' +[Slice] +Delegate=yes +CPUAccounting=yes +MemoryAccounting=yes +IOAccounting=yes +SLICE + + systemctl daemon-reload + systemctl enable jabali.slice 2>/dev/null || true + systemctl start jabali.slice 2>/dev/null || true + + if [[ -w /sys/fs/cgroup/jabali.slice/cgroup.subtree_control ]]; then + echo "+cpu +memory +io" > /sys/fs/cgroup/jabali.slice/cgroup.subtree_control || true + fi + + log "cgroup v2 slice configured" +} + setup_queue_service() { header "Setting Up Jabali Queue Worker" @@ -2891,6 +2918,11 @@ uninstall() { rm -f /etc/systemd/system/jabali-queue.service rm -rf /etc/systemd/system/jabali-queue.service.d + systemctl stop jabali.slice 2>/dev/null || true + rm -f /etc/systemd/system/jabali.slice + rm -rf /sys/fs/cgroup/jabali.slice/user-* 2>/dev/null || true + rmdir /sys/fs/cgroup/jabali.slice 2>/dev/null || true + local services=( nginx php-fpm @@ -3221,6 +3253,7 @@ main() { configure_redis setup_jabali setup_agent_service + setup_cgroup_limits setup_queue_service setup_scheduler_cron setup_logrotate diff --git a/routes/console.php b/routes/console.php index 7bbedfe..365e8c5 100644 --- a/routes/console.php +++ b/routes/console.php @@ -73,6 +73,13 @@ Schedule::command('jabali:collect-user-usage') ->runInBackground() ->appendOutputTo(storage_path('logs/user-usage.log')); +// Cgroup Sync - runs every minute to keep user processes assigned to cgroups +Schedule::command('jabali:sync-cgroups') + ->everyMinute() + ->withoutOverlapping() + ->runInBackground() + ->appendOutputTo(storage_path('logs/cgroup-sync.log')); + // Audit Log Rotation - runs daily to prune old audit logs (default: 90 days retention) Schedule::call(function () { $deleted = AuditLog::prune(); diff --git a/tests/Feature/JabaliSyncCgroupsTest.php b/tests/Feature/JabaliSyncCgroupsTest.php new file mode 100644 index 0000000..603d262 --- /dev/null +++ b/tests/Feature/JabaliSyncCgroupsTest.php @@ -0,0 +1,50 @@ +app->instance(AgentClient::class, new FakeAgentClient([ + 'success' => true, + 'moved' => 4, + ])); + + $this->artisan('jabali:sync-cgroups') + ->expectsOutput('Synced cgroups, moved 4 process(es).') + ->assertExitCode(0); + } + + public function test_syncs_single_user(): void + { + $this->app->instance(AgentClient::class, new FakeAgentClient([ + 'success' => true, + 'moved' => 1, + ])); + + $this->artisan('jabali:sync-cgroups --user=testuser') + ->expectsOutput('Synced cgroups, moved 1 process(es).') + ->assertExitCode(0); + } +} + +class FakeAgentClient extends AgentClient +{ + public function __construct(private array $result) {} + + public function cgroupSyncUserProcesses(string $username): array + { + return $this->result; + } + + public function cgroupSyncAllProcesses(): array + { + return $this->result; + } +}