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. 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. 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); $bandwidthTotal = $this->getBandwidthTotal($agent, $user);
$resourceStats = $this->getUserResourceStats($agent, $user); $resourceStats = $this->getUserResourceStats($agent, $user);
$cpuPercent = (int) round($resourceStats['cpu_percent'] ?? 0); $cpuPercent = (int) round($resourceStats['cpu_percent'] ?? 0);
$cpuUsageTotal = (int) ($resourceStats['cpu_usage_usec_total'] ?? 0);
$memoryBytes = (int) ($resourceStats['memory_bytes'] ?? 0); $memoryBytes = (int) ($resourceStats['memory_bytes'] ?? 0);
$diskIoTotal = (int) ($resourceStats['disk_io_total_bytes'] ?? 0); $diskIoTotal = (int) ($resourceStats['disk_io_total_bytes'] ?? 0);
$lastBandwidthTotal = (int) UserSetting::getForUser($user->id, 'bandwidth_total_bytes', 0); $lastBandwidthTotal = (int) UserSetting::getForUser($user->id, 'bandwidth_total_bytes', 0);
$lastDiskIoTotal = (int) UserSetting::getForUser($user->id, 'disk_io_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 $bandwidthDelta = $bandwidthTotal >= $lastBandwidthTotal
? $bandwidthTotal - $lastBandwidthTotal ? $bandwidthTotal - $lastBandwidthTotal
: $bandwidthTotal; : $bandwidthTotal;
@@ -60,6 +63,14 @@ class CollectUserUsage extends Command
? $diskIoTotal - $lastDiskIoTotal ? $diskIoTotal - $lastDiskIoTotal
: $diskIoTotal; : $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, 'disk_bytes', $diskBytes, $capturedAt);
$this->storeMetric($user->id, 'mail_bytes', $mailBytes, $capturedAt); $this->storeMetric($user->id, 'mail_bytes', $mailBytes, $capturedAt);
$this->storeMetric($user->id, 'database_bytes', $dbBytes, $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, 'bandwidth_total_bytes', $bandwidthTotal);
UserSetting::setForUser($user->id, 'disk_io_total_bytes', $diskIoTotal); 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}"); $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 protected function getUserResourceStats(AgentClient $agent, User $user): array
{ {
@@ -131,6 +144,7 @@ class CollectUserUsage extends Command
if ($result['success'] ?? false) { if ($result['success'] ?? false) {
return [ return [
'cpu_percent' => (float) ($result['cpu_percent'] ?? 0), '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), 'memory_bytes' => (int) ($result['memory_bytes'] ?? 0),
'disk_io_total_bytes' => (int) ($result['disk_io_total_bytes'] ?? 0), 'disk_io_total_bytes' => (int) ($result['disk_io_total_bytes'] ?? 0),
]; ];
@@ -141,6 +155,7 @@ class CollectUserUsage extends Command
return [ return [
'cpu_percent' => 0.0, 'cpu_percent' => 0.0,
'cpu_usage_usec_total' => 0,
'memory_bytes' => 0, 'memory_bytes' => 0,
'disk_io_total_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\Admin\Widgets\Settings\NotificationLogTable;
use App\Filament\Concerns\HasPageTour; use App\Filament\Concerns\HasPageTour;
use App\Models\DnsSetting; use App\Models\DnsSetting;
use App\Models\UserResourceLimit;
use App\Services\Agent\AgentClient; use App\Services\Agent\AgentClient;
use App\Services\System\ResourceLimitService;
use BackedEnum; use BackedEnum;
use Exception; use Exception;
use Filament\Actions\Action; use Filament\Actions\Action;
@@ -197,6 +199,7 @@ class ServerSettings extends Page implements HasActions, HasForms
$this->quotaData = [ $this->quotaData = [
'quotas_enabled' => (bool) ($settings['quotas_enabled'] ?? false), 'quotas_enabled' => (bool) ($settings['quotas_enabled'] ?? false),
'default_quota_mb' => (int) ($settings['default_quota_mb'] ?? 5120), 'default_quota_mb' => (int) ($settings['default_quota_mb'] ?? 5120),
'resource_limits_enabled' => (bool) ($settings['resource_limits_enabled'] ?? true),
]; ];
$this->fileManagerData = [ $this->fileManagerData = [
@@ -419,6 +422,10 @@ class ServerSettings extends Page implements HasActions, HasForms
->numeric() ->numeric()
->placeholder('5120') ->placeholder('5120')
->helperText(__('Default disk quota for new users (5120 MB = 5 GB)')), ->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([ Actions::make([
FormAction::make('saveQuotaSettings') FormAction::make('saveQuotaSettings')
@@ -834,9 +841,11 @@ class ServerSettings extends Page implements HasActions, HasForms
{ {
$data = $this->quotaData; $data = $this->quotaData;
$wasEnabled = (bool) DnsSetting::get('quotas_enabled', false); $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('quotas_enabled', $data['quotas_enabled'] ? '1' : '0');
DnsSetting::set('default_quota_mb', (string) $data['default_quota_mb']); DnsSetting::set('default_quota_mb', (string) $data['default_quota_mb']);
DnsSetting::set('resource_limits_enabled', ! empty($data['resource_limits_enabled']) ? '1' : '0');
DnsSetting::clearCache(); DnsSetting::clearCache();
if ($data['quotas_enabled'] && ! $wasEnabled) { if ($data['quotas_enabled'] && ! $wasEnabled) {
@@ -850,9 +859,24 @@ class ServerSettings extends Page implements HasActions, HasForms
} catch (Exception $e) { } catch (Exception $e) {
Notification::make()->title(__('Settings saved'))->body(__('Warning: Could not enable quota system.'))->warning()->send(); 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 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 public function databasePersistTuning(string $name, string $value): array
{ {
return $this->send('database.persist_tuning', [ return $this->send('database.persist_tuning', [

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Services\System; namespace App\Services\System;
use App\Models\DnsSetting;
use App\Models\UserResourceLimit; use App\Models\UserResourceLimit;
use App\Services\Agent\AgentClient; use App\Services\Agent\AgentClient;
use RuntimeException; use RuntimeException;
@@ -12,6 +13,10 @@ class ResourceLimitService
{ {
public function apply(UserResourceLimit $limit): void public function apply(UserResourceLimit $limit): void
{ {
if (! DnsSetting::get('resource_limits_enabled', true)) {
return;
}
$user = $limit->user; $user = $limit->user;
if (! $user) { if (! $user) {
throw new RuntimeException('User not found for resource limit.'); 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), 'geo.apply_rules' => geoApplyRules($params),
'cgroup.apply_user_limits' => cgroupApplyUserLimits($params), 'cgroup.apply_user_limits' => cgroupApplyUserLimits($params),
'cgroup.clear_user_limits' => cgroupClearUserLimits($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), 'database.persist_tuning' => databasePersistTuning($params),
'server.export_config' => serverExportConfig($params), 'server.export_config' => serverExportConfig($params),
'server.import_config' => serverImportConfig($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')); 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"); logger("Created user $username with home directory $homeDir");
return [ return [
@@ -1032,6 +1040,8 @@ function deleteUser(array $params): array
} }
} }
cgroupRemoveUser($username);
// Reload services // Reload services
exec('postmap ' . escapeshellarg(POSTFIX_VIRTUAL_DOMAINS) . ' 2>/dev/null'); exec('postmap ' . escapeshellarg(POSTFIX_VIRTUAL_DOMAINS) . ' 2>/dev/null');
exec('postmap ' . escapeshellarg(POSTFIX_VIRTUAL_MAILBOXES) . ' 2>/dev/null'); exec('postmap ' . escapeshellarg(POSTFIX_VIRTUAL_MAILBOXES) . ' 2>/dev/null');
@@ -3048,6 +3058,313 @@ function getRootBlockDevice(): ?string
return $resolvedPath ?: null; 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 function cgroupApplyUserLimits(array $params): array
{ {
$username = $params['username'] ?? ''; $username = $params['username'] ?? '';
@@ -3055,46 +3372,28 @@ function cgroupApplyUserLimits(array $params): array
return ['success' => false, 'error' => 'Invalid username format']; return ['success' => false, 'error' => 'Invalid username format'];
} }
exec('id -u ' . escapeshellarg($username) . ' 2>/dev/null', $uidOutput, $uidCode); if (!posix_getpwnam($username)) {
if ($uidCode !== 0) {
return ['success' => false, 'error' => 'User not found']; 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; $cpu = isset($params['cpu_limit_percent']) ? (int) $params['cpu_limit_percent'] : 0;
$memory = isset($params['memory_limit_mb']) ? (int) $params['memory_limit_mb'] : 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; $io = isset($params['io_limit_mb']) ? (int) $params['io_limit_mb'] : 0;
$properties = []; $cgroup = ensureUserCgroup($username);
if ($cpu > 0) { if (!($cgroup['success'] ?? false)) {
$properties[] = "CPUQuota={$cpu}%"; return $cgroup;
}
if ($memory > 0) {
$properties[] = "MemoryMax={$memory}M";
}
if ($io > 0) {
$device = getRootBlockDevice();
if ($device) {
$properties[] = "IOReadBandwidthMax={$device} {$io}M";
$properties[] = "IOWriteBandwidthMax={$device} {$io}M";
}
} }
if (empty($properties)) { $path = $cgroup['path'];
return cgroupClearUserLimits(['username' => $username]);
}
$command = 'systemctl set-property ' . escapeshellarg($slice) . ' ' . implode(' ', array_map('escapeshellarg', $properties)) . ' 2>&1'; cgroupWriteCpuMax($path, $cpu);
exec($command, $output, $code); cgroupWriteMemoryMax($path, $memory);
if ($code !== 0) { cgroupWriteIoMax($path, $io);
return ['success' => false, 'error' => implode("\n", $output)];
}
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 function cgroupClearUserLimits(array $params): array
@@ -3104,22 +3403,21 @@ function cgroupClearUserLimits(array $params): array
return ['success' => false, 'error' => 'Invalid username format']; return ['success' => false, 'error' => 'Invalid username format'];
} }
exec('id -u ' . escapeshellarg($username) . ' 2>/dev/null', $uidOutput, $uidCode); if (!posix_getpwnam($username)) {
if ($uidCode !== 0) {
return ['success' => false, 'error' => 'User not found']; return ['success' => false, 'error' => 'User not found'];
} }
$uid = trim($uidOutput[0] ?? ''); $cgroup = ensureUserCgroup($username);
$slice = "user-{$uid}.slice"; if (!($cgroup['success'] ?? false)) {
return $cgroup;
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)];
} }
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 function databasePersistTuning(array $params): array
@@ -23745,8 +24043,25 @@ function usageUserResources(array $params): array
$uid = (int) $userInfo['uid']; $uid = (int) $userInfo['uid'];
$cpuTotal = 0.0; $cpuPercent = 0.0;
$cpuUsageUsec = null;
$memoryBytes = 0; $memoryBytes = 0;
$diskIoTotal = 0;
$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); exec("ps -u " . escapeshellarg($username) . " -o %cpu=,rss= 2>/dev/null", $psOut, $psCode);
if ($psCode === 0) { if ($psCode === 0) {
@@ -23756,10 +24071,17 @@ function usageUserResources(array $params): array
continue; continue;
} }
$cpuTotal += (float) $parts[0]; $cpuTotal += (float) $parts[0];
$memoryBytes += (int) $parts[1] * 1024; $rssTotal += (int) $parts[1] * 1024;
} }
} }
$cpuPercent = round($cpuTotal, 2);
if ($memoryBytes === 0) {
$memoryBytes = $rssTotal;
}
}
if ($diskIoTotal === 0) {
$diskRead = 0; $diskRead = 0;
$diskWrite = 0; $diskWrite = 0;
@@ -23805,11 +24127,15 @@ function usageUserResources(array $params): array
} }
} }
$diskIoTotal = $diskRead + $diskWrite;
}
return [ return [
'success' => true, 'success' => true,
'cpu_percent' => round($cpuTotal, 2), 'cpu_percent' => $cpuPercent,
'cpu_usage_usec_total' => $cpuUsageUsec,
'memory_bytes' => $memoryBytes, '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" 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() { setup_queue_service() {
header "Setting Up Jabali Queue Worker" header "Setting Up Jabali Queue Worker"
@@ -2891,6 +2918,11 @@ uninstall() {
rm -f /etc/systemd/system/jabali-queue.service rm -f /etc/systemd/system/jabali-queue.service
rm -rf /etc/systemd/system/jabali-queue.service.d 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=( local services=(
nginx nginx
php-fpm php-fpm
@@ -3221,6 +3253,7 @@ main() {
configure_redis configure_redis
setup_jabali setup_jabali
setup_agent_service setup_agent_service
setup_cgroup_limits
setup_queue_service setup_queue_service
setup_scheduler_cron setup_scheduler_cron
setup_logrotate setup_logrotate

View File

@@ -73,6 +73,13 @@ Schedule::command('jabali:collect-user-usage')
->runInBackground() ->runInBackground()
->appendOutputTo(storage_path('logs/user-usage.log')); ->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) // Audit Log Rotation - runs daily to prune old audit logs (default: 90 days retention)
Schedule::call(function () { Schedule::call(function () {
$deleted = AuditLog::prune(); $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;
}
}