Implement cgroup v2 limits and sync

This commit is contained in:
root
2026-01-28 00:50:06 +02:00
parent cc1196a390
commit 74180e8696
11 changed files with 630 additions and 93 deletions

View File

@@ -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.

View File

@@ -1 +1 @@
VERSION=0.9-rc8
VERSION=0.9-rc9

View File

@@ -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,
];

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Agent\AgentClient;
use Exception;
use Illuminate\Console\Command;
class JabaliSyncCgroups extends Command
{
public function __construct(private AgentClient $agent)
{
parent::__construct();
}
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'jabali:sync-cgroups {--user=}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Sync user processes into Jabali cgroups';
/**
* Execute the console command.
*/
public function handle(): int
{
$user = (string) ($this->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;
}
}

View File

@@ -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

View File

@@ -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', [

View File

@@ -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.');

View File

@@ -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,
];
}

View File

@@ -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

View File

@@ -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();

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Services\Agent\AgentClient;
use Tests\TestCase;
class JabaliSyncCgroupsTest extends TestCase
{
public function test_syncs_all_processes(): void
{
$this->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;
}
}