Initial Ladill Hosting app with Gitea deploy pipeline.
Deploy Ladill Hosting / deploy (push) Failing after 17s

Shared web hosting extracted from the platform monolith, with CI deploy
to /var/www/ladill-hosting matching Bird/Domains/Email.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-06 16:24:20 +00:00
co-authored by Cursor
commit e251a4cf60
367 changed files with 66268 additions and 0 deletions
+464
View File
@@ -0,0 +1,464 @@
<?php
namespace App\Services\Hosting;
use App\Models\HostedSite;
use App\Models\HostedDatabase;
use App\Models\AppInstallation;
use App\Services\Hosting\Contracts\AppInstallerInterface;
use App\Services\Hosting\Providers\SharedNodeProvider;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class AppInstaller implements AppInstallerInterface
{
private SharedNodeProvider $nodeProvider;
public function __construct(SharedNodeProvider $nodeProvider)
{
$this->nodeProvider = $nodeProvider;
}
public function getSupportedApps(): array
{
return [
'wordpress' => [
'name' => 'WordPress',
'description' => 'Popular blogging and CMS platform',
'icon' => 'wordpress',
'min_php' => '7.4',
'recommended_php' => '8.2',
],
'joomla' => [
'name' => 'Joomla',
'description' => 'Flexible content management system',
'icon' => 'joomla',
'min_php' => '7.4',
'recommended_php' => '8.1',
],
'drupal' => [
'name' => 'Drupal',
'description' => 'Enterprise-grade CMS',
'icon' => 'drupal',
'min_php' => '8.1',
'recommended_php' => '8.2',
],
'opencart' => [
'name' => 'OpenCart',
'description' => 'E-commerce platform',
'icon' => 'opencart',
'min_php' => '8.0',
'recommended_php' => '8.1',
],
'magento' => [
'name' => 'Magento',
'description' => 'Enterprise e-commerce platform',
'icon' => 'magento',
'min_php' => '8.1',
'recommended_php' => '8.2',
'min_ram_mb' => 2048,
],
];
}
public function getAppVersions(string $appType): array
{
return match ($appType) {
'wordpress' => ['6.4', '6.3', '6.2'],
'joomla' => ['5.0', '4.4', '4.3'],
'drupal' => ['10.2', '10.1', '10.0'],
'opencart' => ['4.0', '3.0'],
'magento' => ['2.4.6', '2.4.5'],
default => [],
};
}
public function install(HostedSite $site, string $appType, array $config): AppInstallation
{
$account = $site->account;
$supportedApps = $this->getSupportedApps();
if (!isset($supportedApps[$appType])) {
throw new \InvalidArgumentException("Unsupported app type: {$appType}");
}
// Create database for the app
$dbName = $account->username . '_' . Str::random(4);
$dbUser = $account->username . '_' . Str::random(4);
$dbPassword = Str::password(16);
$database = HostedDatabase::create([
'hosting_account_id' => $account->id,
'hosted_site_id' => $site->id,
'name' => $dbName,
'username' => $dbUser,
'password_encrypted' => encrypt($dbPassword),
'type' => 'mysql',
'status' => 'active',
]);
$this->nodeProvider->createDatabase($database, $dbPassword);
// Create installation record
$installation = AppInstallation::create([
'hosted_site_id' => $site->id,
'app_type' => $appType,
'app_version' => $config['version'] ?? $this->getAppVersions($appType)[0],
'admin_username' => $config['admin_username'] ?? 'admin',
'admin_email' => $config['admin_email'] ?? $account->user->email,
'status' => 'installing',
'config' => [
'db_name' => $dbName,
'db_user' => $dbUser,
'site_title' => $config['site_title'] ?? $site->domain,
],
]);
try {
$result = match ($appType) {
'wordpress' => $this->installWordPress($site, $installation, $database, $dbPassword, $config),
'joomla' => $this->installJoomla($site, $installation, $database, $dbPassword, $config),
'drupal' => $this->installDrupal($site, $installation, $database, $dbPassword, $config),
'opencart' => $this->installOpenCart($site, $installation, $database, $dbPassword, $config),
'magento' => $this->installMagento($site, $installation, $database, $dbPassword, $config),
default => throw new \InvalidArgumentException("No installer for: {$appType}"),
};
$installation->update([
'status' => 'active',
'admin_path' => $result['admin_path'] ?? null,
'installed_at' => now(),
]);
$site->update([
'installed_app' => $appType,
'installed_app_version' => $installation->app_version,
]);
} catch (\Exception $e) {
Log::error("App installation failed", [
'app' => $appType,
'site' => $site->domain,
'error' => $e->getMessage(),
]);
$installation->update([
'status' => 'failed',
'config' => array_merge($installation->config ?? [], ['error' => $e->getMessage()]),
]);
throw $e;
}
return $installation;
}
public function uninstall(AppInstallation $installation): bool
{
$site = $installation->site;
$account = $site->account;
$appType = $installation->app_type;
try {
// For Node.js/Python apps, stop and remove the systemd service first
if (in_array($appType, ['nodejs', 'python'], true)) {
try {
$this->nodeProvider->removeAppService($site);
} catch (\Exception $e) {
Log::warning("Failed to remove app service during uninstall: " . $e->getMessage());
}
// Node.js specific cleanup
if ($appType === 'nodejs') {
$this->nodeProvider->executeCommand(
$account,
"rm -rf {$site->document_root}/node_modules {$site->document_root}/package.json {$site->document_root}/package-lock.json 2>&1 || true"
);
}
// Python specific cleanup
if ($appType === 'python') {
$this->nodeProvider->executeCommand(
$account,
"rm -rf {$site->document_root}/venv {$site->document_root}/__pycache__ {$site->document_root}/.venv 2>&1 || true"
);
$this->nodeProvider->executeCommand(
$account,
"find {$site->document_root} -name '*.pyc' -delete 2>&1 || true"
);
}
}
// For apps with subdirectories (Drupal with web/, Laravel with public/, Magento with pub/)
// we need to clean the parent directory too
$docRoot = $site->document_root;
if (in_array($appType, ['drupal', 'laravel', 'magento'], true)) {
// Try to clean up parent directory if it's a subdirectory installation
$parentDir = dirname($docRoot);
if ($parentDir !== "/home/{$account->username}" && $parentDir !== "/home/{$account->username}/public_html") {
$this->nodeProvider->executeCommandAsRoot(
$account,
"rm -rf " . escapeshellarg($parentDir) . " 2>&1 || true"
);
}
}
// Remove all files from document root (use root to handle permission issues)
$escapedDocRoot = escapeshellarg($docRoot);
$this->nodeProvider->executeCommandAsRoot(
$account,
"rm -rf {$escapedDocRoot}/* {$escapedDocRoot}/.[!.]* {$escapedDocRoot}/..?* 2>&1 || true"
);
// Drop database and database user if exists
$dbName = $installation->config['db_name'] ?? null;
if ($dbName) {
$database = HostedDatabase::where('name', $dbName)->first();
if ($database) {
// Drop both database and user
$dbUser = $database->username;
$this->nodeProvider->executeCommandAsRoot(
$account,
"mysql -e \"DROP DATABASE IF EXISTS \`{$dbName}\`; DROP USER IF EXISTS '{$dbUser}'@'localhost';\" 2>&1 || true"
);
$database->delete();
} else {
// Database record not found, try cleanup anyway
$this->nodeProvider->executeCommandAsRoot(
$account,
"mysql -e \"DROP DATABASE IF EXISTS \`{$dbName}\`; DROP USER IF EXISTS '{$dbName}'@'localhost';\" 2>&1 || true"
);
}
}
$installation->update(['status' => 'removed']);
$site->update(['installed_app' => null, 'installed_app_version' => null]);
Log::info("App uninstalled successfully", [
'app' => $appType,
'site' => $site->domain,
'installation_id' => $installation->id,
]);
return true;
} catch (\Exception $e) {
Log::error("Failed to uninstall app: " . $e->getMessage(), [
'app' => $appType,
'site' => $site->domain,
'installation_id' => $installation->id,
]);
throw $e;
}
}
public function update(AppInstallation $installation, ?string $version = null): bool
{
$site = $installation->site;
$account = $site->account;
$installation->update(['status' => 'updating']);
try {
if ($installation->app_type === 'wordpress') {
$this->nodeProvider->executeCommand($account,
"cd {$site->document_root} && wp core update" . ($version ? " --version={$version}" : "")
);
$this->nodeProvider->executeCommand($account, "cd {$site->document_root} && wp plugin update --all");
$this->nodeProvider->executeCommand($account, "cd {$site->document_root} && wp theme update --all");
}
$installation->update([
'status' => 'active',
'app_version' => $version ?? $installation->app_version,
'last_updated_at' => now(),
]);
return true;
} catch (\Exception $e) {
$installation->update(['status' => 'active']);
throw $e;
}
}
public function getStatus(AppInstallation $installation): array
{
$site = $installation->site;
$account = $site->account;
$status = [
'app_type' => $installation->app_type,
'version' => $installation->app_version,
'status' => $installation->status,
'installed_at' => $installation->installed_at,
];
if ($installation->app_type === 'wordpress') {
$result = $this->nodeProvider->executeCommand($account,
"cd {$site->document_root} && wp core version 2>/dev/null"
);
$status['current_version'] = trim($result['output']);
}
return $status;
}
public function backup(AppInstallation $installation): string
{
$site = $installation->site;
$account = $site->account;
$backupName = "{$installation->app_type}_{$site->domain}_" . date('Y-m-d_His');
$backupPath = "/home/{$account->username}/backups/{$backupName}";
$this->nodeProvider->executeCommand($account, "mkdir -p /home/{$account->username}/backups");
// Backup files
$this->nodeProvider->executeCommand($account,
"tar -czf {$backupPath}_files.tar.gz -C {$site->document_root} ."
);
// Backup database
$dbName = $installation->config['db_name'] ?? null;
if ($dbName) {
$this->nodeProvider->executeCommand($account,
"mysqldump {$dbName} > {$backupPath}_db.sql"
);
}
return $backupPath;
}
public function restore(AppInstallation $installation, string $backupPath): bool
{
$site = $installation->site;
$account = $site->account;
// Restore files
$this->nodeProvider->executeCommand($account,
"rm -rf {$site->document_root}/* && tar -xzf {$backupPath}_files.tar.gz -C {$site->document_root}"
);
// Restore database
$dbName = $installation->config['db_name'] ?? null;
if ($dbName && file_exists("{$backupPath}_db.sql")) {
$this->nodeProvider->executeCommand($account,
"mysql {$dbName} < {$backupPath}_db.sql"
);
}
return true;
}
private function installWordPress(HostedSite $site, AppInstallation $installation, HostedDatabase $database, string $dbPassword, array $config): array
{
$account = $site->account;
$docRoot = $site->document_root;
$adminPassword = $config['admin_password'] ?? Str::password(12);
$commands = [
"cd {$docRoot} && wp core download --version={$installation->app_version}",
"cd {$docRoot} && wp config create --dbname={$database->name} --dbuser={$database->username} --dbpass={$dbPassword} --dbhost=localhost",
"cd {$docRoot} && wp core install --url=https://{$site->domain} --title='" . addslashes($config['site_title'] ?? $site->domain) . "' --admin_user={$installation->admin_username} --admin_password={$adminPassword} --admin_email={$installation->admin_email}",
"cd {$docRoot} && wp rewrite structure '/%postname%/'",
"cd {$docRoot} && chmod -R 755 .",
"cd {$docRoot} && chmod -R 775 wp-content/uploads",
];
foreach ($commands as $cmd) {
$result = $this->nodeProvider->executeCommand($account, $cmd);
if ($result['exit_code'] !== 0) {
throw new \RuntimeException("WordPress install failed: " . $result['output']);
}
}
return [
'admin_path' => '/wp-admin',
'admin_password' => $adminPassword,
];
}
private function installJoomla(HostedSite $site, AppInstallation $installation, HostedDatabase $database, string $dbPassword, array $config): array
{
$account = $site->account;
$docRoot = $site->document_root;
$adminPassword = $config['admin_password'] ?? Str::password(12);
$commands = [
"cd {$docRoot} && curl -sL https://downloads.joomla.org/cms/joomla5/{$installation->app_version}/Joomla_{$installation->app_version}-Stable-Full_Package.zip -o joomla.zip",
"cd {$docRoot} && unzip -q joomla.zip && rm joomla.zip",
"cd {$docRoot} && php installation/joomla.php install --site-name='" . addslashes($config['site_title'] ?? $site->domain) . "' --admin-user={$installation->admin_username} --admin-username={$installation->admin_username} --admin-password={$adminPassword} --admin-email={$installation->admin_email} --db-type=mysqli --db-host=localhost --db-user={$database->username} --db-pass={$dbPassword} --db-name={$database->name} --db-prefix=jos_ --db-encryption=0",
"cd {$docRoot} && rm -rf installation",
"cd {$docRoot} && chmod -R 755 .",
"cd {$docRoot} && chmod 775 .",
"cd {$docRoot} && chmod -R 775 cache tmp administrator/cache administrator/logs",
"cd {$docRoot} && echo '<?php return [];' > administrator/cache/autoload_psr4.php && echo '<?php return [];' > cache/autoload_psr4.php",
];
foreach ($commands as $cmd) {
$this->nodeProvider->executeCommand($account, $cmd);
}
$this->nodeProvider->setWebServerGroupOwnership($account, $docRoot);
return ['admin_path' => '/administrator'];
}
private function installDrupal(HostedSite $site, AppInstallation $installation, HostedDatabase $database, string $dbPassword, array $config): array
{
$account = $site->account;
$docRoot = $site->document_root;
$adminPassword = $config['admin_password'] ?? Str::password(12);
$commands = [
"cd {$docRoot} && composer create-project drupal/recommended-project:{$installation->app_version} . --no-interaction",
"cd {$docRoot}/web && ../vendor/bin/drush site:install standard --db-url=mysql://{$database->username}:{$dbPassword}@localhost/{$database->name} --site-name='" . addslashes($config['site_title'] ?? $site->domain) . "' --account-name={$installation->admin_username} --account-pass={$adminPassword} --account-mail={$installation->admin_email} -y",
"cd {$docRoot} && chmod -R 755 .",
];
foreach ($commands as $cmd) {
$this->nodeProvider->executeCommand($account, $cmd);
}
return ['admin_path' => '/user/login'];
}
private function installOpenCart(HostedSite $site, AppInstallation $installation, HostedDatabase $database, string $dbPassword, array $config): array
{
$account = $site->account;
$docRoot = $site->document_root;
$adminPassword = $config['admin_password'] ?? Str::password(12);
$commands = [
"cd {$docRoot} && curl -sL https://github.com/opencart/opencart/releases/download/{$installation->app_version}.0.0/opencart-{$installation->app_version}.0.0.zip -o opencart.zip",
"cd {$docRoot} && unzip -q opencart.zip && mv upload/* . && rm -rf upload opencart.zip",
"cd {$docRoot} && php install/cli_install.php install --db_hostname localhost --db_username {$database->username} --db_password {$dbPassword} --db_database {$database->name} --username {$installation->admin_username} --password {$adminPassword} --email {$installation->admin_email} --http_server https://{$site->domain}/",
"cd {$docRoot} && rm -rf install",
"cd {$docRoot} && chmod -R 755 .",
];
foreach ($commands as $cmd) {
$this->nodeProvider->executeCommand($account, $cmd);
}
return ['admin_path' => '/admin'];
}
private function installMagento(HostedSite $site, AppInstallation $installation, HostedDatabase $database, string $dbPassword, array $config): array
{
$account = $site->account;
$docRoot = $site->document_root;
$adminPassword = $config['admin_password'] ?? Str::password(12);
$commands = [
"cd {$docRoot} && composer create-project --repository-url=https://repo.magento.com/ magento/project-community-edition={$installation->app_version} . --no-interaction",
"cd {$docRoot} && bin/magento setup:install --base-url=https://{$site->domain}/ --db-host=localhost --db-name={$database->name} --db-user={$database->username} --db-password={$dbPassword} --admin-firstname=Admin --admin-lastname=User --admin-email={$installation->admin_email} --admin-user={$installation->admin_username} --admin-password={$adminPassword} --language=en_US --currency=USD --timezone=UTC --use-rewrites=1",
"cd {$docRoot} && bin/magento deploy:mode:set production",
"cd {$docRoot} && chmod -R 755 .",
];
foreach ($commands as $cmd) {
$this->nodeProvider->executeCommand($account, $cmd);
}
return ['admin_path' => '/admin'];
}
}
@@ -0,0 +1,373 @@
<?php
namespace App\Services\Hosting;
use App\Models\HostingAccount;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Str;
class BrowserTerminalSessionManager
{
private const IDLE_TIMEOUT_SECONDS = 120;
public function createSession(HostingAccount $account, int $userId, string $relativePath = '/public_html', int $cols = 120, int $rows = 30): array
{
$sessionId = (string) Str::uuid();
$directory = $this->sessionDirectory($sessionId);
if (! is_dir($directory) && ! mkdir($directory, 0775, true) && ! is_dir($directory)) {
throw new \RuntimeException('Unable to create browser terminal session directory.');
}
touch($this->eventLogPath($sessionId));
touch($this->outputLogPath($sessionId));
touch($this->heartbeatPath($sessionId));
$metadata = [
'session_id' => $sessionId,
'account_id' => $account->id,
'user_id' => $userId,
'username' => $account->username,
'relative_path' => $relativePath,
'cols' => $this->normalizeColumns($cols),
'rows' => $this->normalizeRows($rows),
'status' => 'starting',
'created_at' => now()->toIso8601String(),
'last_seen_at' => now()->toIso8601String(),
'pid' => null,
'exit_code' => null,
'error' => null,
];
$this->writeMetadata($sessionId, $metadata);
$workerCommand = $this->buildWorkerCommand($sessionId);
$result = Process::timeout(10)->run(['bash', '-lc', $workerCommand]);
$pid = trim($result->output());
if ($result->failed() || $pid === '' || ! ctype_digit($pid)) {
$metadata['status'] = 'failed';
$metadata['error'] = 'Unable to start the browser terminal worker.';
$this->writeMetadata($sessionId, $metadata);
throw new \RuntimeException($metadata['error']);
}
$metadata['pid'] = (int) $pid;
$this->writeMetadata($sessionId, $metadata);
$metadata = $this->waitForWorkerStartup($sessionId, $metadata);
return $metadata;
}
public function readOutput(HostingAccount $account, int $userId, string $sessionId, int $offset = 0): array
{
$metadata = $this->sessionMetadataFor($account, $userId, $sessionId);
$offset = max(0, $offset);
$outputPath = $this->outputLogPath($sessionId);
$size = is_file($outputPath) ? filesize($outputPath) : 0;
$chunk = '';
if ($size > $offset) {
$stream = fopen($outputPath, 'rb');
if ($stream !== false) {
fseek($stream, $offset);
$chunk = (string) stream_get_contents($stream);
fclose($stream);
}
}
$this->touchHeartbeat($sessionId);
return [
'output' => $chunk,
'offset' => $size,
'status' => $metadata['status'] ?? 'closed',
'exit_code' => $metadata['exit_code'] ?? null,
'error' => $metadata['error'] ?? null,
];
}
public function appendInput(HostingAccount $account, int $userId, string $sessionId, string $input): void
{
$this->sessionMetadataFor($account, $userId, $sessionId);
$this->touchHeartbeat($sessionId);
$this->appendEvent($sessionId, [
'type' => 'input',
'data' => $input,
]);
}
public function resizeSession(HostingAccount $account, int $userId, string $sessionId, int $cols, int $rows): void
{
$this->sessionMetadataFor($account, $userId, $sessionId);
$this->touchHeartbeat($sessionId);
$this->appendEvent($sessionId, [
'type' => 'resize',
'cols' => $this->normalizeColumns($cols),
'rows' => $this->normalizeRows($rows),
]);
}
public function closeSession(HostingAccount $account, int $userId, string $sessionId): void
{
$this->sessionMetadataFor($account, $userId, $sessionId);
$this->touchHeartbeat($sessionId);
$this->appendEvent($sessionId, [
'type' => 'close',
]);
}
public function readWorkerMetadata(string $sessionId): array
{
$metadata = $this->readMetadata($sessionId);
if ($metadata === null) {
throw new \RuntimeException('Browser terminal session not found.');
}
return $metadata;
}
public function writeWorkerMetadata(string $sessionId, array $metadata): void
{
$this->writeMetadata($sessionId, $metadata);
}
public function appendWorkerOutput(string $sessionId, string $output): void
{
if ($output === '') {
return;
}
file_put_contents($this->outputLogPath($sessionId), $output, FILE_APPEND | LOCK_EX);
}
public function readWorkerEvents(string $sessionId, int &$offset): array
{
$path = $this->eventLogPath($sessionId);
if (! is_file($path)) {
return [];
}
$stream = fopen($path, 'rb');
if ($stream === false) {
return [];
}
fseek($stream, $offset);
$payload = (string) stream_get_contents($stream);
$offset = ftell($stream) ?: $offset;
fclose($stream);
$events = [];
foreach (preg_split("/\r\n|\n|\r/", $payload) as $line) {
$line = trim($line);
if ($line === '') {
continue;
}
$decoded = json_decode($line, true);
if (is_array($decoded)) {
$events[] = $decoded;
}
}
return $events;
}
public function isSessionIdle(array $metadata): bool
{
$heartbeatTime = @filemtime($this->heartbeatPath((string) ($metadata['session_id'] ?? '')));
if ($heartbeatTime === false) {
return false;
}
return (time() - $heartbeatTime) >= self::IDLE_TIMEOUT_SECONDS;
}
private function sessionMetadataFor(HostingAccount $account, int $userId, string $sessionId): array
{
$metadata = $this->readMetadata($sessionId);
if ($metadata === null) {
throw new \RuntimeException('Browser terminal session not found.');
}
if (($metadata['account_id'] ?? null) !== $account->id || ($metadata['user_id'] ?? null) !== $userId) {
throw new \RuntimeException('Browser terminal session not found.');
}
return $metadata;
}
private function appendEvent(string $sessionId, array $payload): void
{
file_put_contents(
$this->eventLogPath($sessionId),
json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES).PHP_EOL,
FILE_APPEND | LOCK_EX
);
}
private function readMetadata(string $sessionId): ?array
{
$path = $this->metadataPath($sessionId);
if (! is_file($path)) {
return null;
}
$decoded = json_decode((string) file_get_contents($path), true);
return is_array($decoded) ? $decoded : null;
}
private function writeMetadata(string $sessionId, array $metadata): void
{
file_put_contents(
$this->metadataPath($sessionId),
json_encode($metadata, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
LOCK_EX
);
}
private function metadataPath(string $sessionId): string
{
return $this->sessionDirectory($sessionId).'/meta.json';
}
private function eventLogPath(string $sessionId): string
{
return $this->sessionDirectory($sessionId).'/events.ndjson';
}
private function outputLogPath(string $sessionId): string
{
return $this->sessionDirectory($sessionId).'/output.log';
}
private function heartbeatPath(string $sessionId): string
{
return $this->sessionDirectory($sessionId).'/heartbeat';
}
private function sessionDirectory(string $sessionId): string
{
return storage_path('app/browser-terminal-sessions/'.$sessionId);
}
private function normalizeColumns(int $cols): int
{
return max(40, min($cols, 240));
}
private function normalizeRows(int $rows): int
{
return max(10, min($rows, 80));
}
private function touchHeartbeat(string $sessionId): void
{
touch($this->heartbeatPath($sessionId));
}
private function buildWorkerCommand(string $sessionId): string
{
return sprintf(
'cd %s && nohup %s artisan hosting:terminal-worker %s >> %s 2>&1 < /dev/null & echo $!',
escapeshellarg(base_path()),
escapeshellarg($this->resolvePhpCliBinary()),
escapeshellarg($sessionId),
escapeshellarg($this->outputLogPath($sessionId))
);
}
private function resolvePhpCliBinary(): string
{
$candidates = array_filter([
env('LADILL_PHP_CLI_BINARY'),
$this->isCliBinary(PHP_BINARY) ? PHP_BINARY : null,
$this->joinBinaryPath(PHP_BINDIR, 'php'),
$this->joinBinaryPath(PHP_BINDIR, 'php-cli'),
'/usr/bin/php',
'/usr/local/bin/php',
]);
foreach ($candidates as $candidate) {
if ($this->isCliBinary($candidate)) {
return $candidate;
}
}
return PHP_BINARY;
}
private function joinBinaryPath(string $directory, string $binary): string
{
return rtrim($directory, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$binary;
}
private function isCliBinary(?string $path): bool
{
if (! is_string($path) || trim($path) === '') {
return false;
}
$basename = strtolower(basename($path));
if (str_contains($basename, 'fpm') || str_contains($basename, 'cgi')) {
return false;
}
return is_file($path) && is_executable($path);
}
private function waitForWorkerStartup(string $sessionId, array $metadata): array
{
$deadline = microtime(true) + 2.5;
while (microtime(true) < $deadline) {
usleep(100000);
$current = $this->readWorkerMetadata($sessionId);
if (($current['status'] ?? 'starting') !== 'starting') {
return $current;
}
}
$metadata['status'] = 'failed';
$metadata['exit_code'] = 1;
$metadata['closed_at'] = now()->toIso8601String();
$metadata['error'] = $this->workerBootstrapError($sessionId);
$this->writeMetadata($sessionId, $metadata);
return $metadata;
}
private function workerBootstrapError(string $sessionId): string
{
$output = trim((string) @file_get_contents($this->outputLogPath($sessionId)));
if ($output !== '') {
$lines = preg_split("/\r\n|\n|\r/", $output) ?: [];
$tail = trim((string) end($lines));
if ($tail !== '') {
return 'Terminal worker failed to start: '.$tail;
}
}
return 'Terminal worker failed to start.';
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Services\Hosting\Contracts;
use App\Models\HostedSite;
use App\Models\AppInstallation;
interface AppInstallerInterface
{
public function getSupportedApps(): array;
public function getAppVersions(string $appType): array;
public function install(HostedSite $site, string $appType, array $config): AppInstallation;
public function uninstall(AppInstallation $installation): bool;
public function update(AppInstallation $installation, ?string $version = null): bool;
public function getStatus(AppInstallation $installation): array;
public function backup(AppInstallation $installation): string;
public function restore(AppInstallation $installation, string $backupPath): bool;
}
@@ -0,0 +1,36 @@
<?php
namespace App\Services\Hosting\Contracts;
interface ComputeProviderInterface
{
public function createInstance(array $config): array;
public function getInstance(string $instanceId): ?array;
public function listInstances(array $filters = []): array;
public function startInstance(string $instanceId): bool;
public function stopInstance(string $instanceId): bool;
public function rebootInstance(string $instanceId): bool;
public function deleteInstance(string $instanceId): bool;
public function reinstallInstance(string $instanceId, string $image): bool;
public function createSnapshot(string $instanceId, string $name): array;
public function listSnapshots(string $instanceId): array;
public function deleteSnapshot(string $snapshotId): bool;
public function restoreSnapshot(string $instanceId, string $snapshotId): bool;
public function getAvailableImages(): array;
public function getAvailableRegions(): array;
public function getAvailableProducts(): array;
}
@@ -0,0 +1,79 @@
<?php
namespace App\Services\Hosting\Contracts;
use App\Models\HostedDatabase;
use App\Models\HostedSite;
use App\Models\HostingAccount;
interface PanelRuntimeInterface
{
public function key(): string;
public function label(): string;
/**
* @return array<int, string>
*/
public function capabilities(): array;
public function supports(string $capability): bool;
/**
* @return array<string, mixed>
*/
public function executeCommand(HostingAccount $account, string $command, int $timeout = 600): array;
/**
* @return array<string, mixed>
*/
public function executeTerminalCommand(HostingAccount $account, string $command, string $workingDirectory, int $timeout = 300): array;
/**
* @return array<string, mixed>
*/
public function executeCommandAsRoot(HostingAccount $account, string $command): array;
/**
* @return array<string, mixed>
*/
public function getUsageStats(HostingAccount $account): array;
public function changePassword(HostingAccount $account, string $newPassword): bool;
public function installTeamMemberSshKey(HostingAccount $account, string $marker, string $publicKey): void;
public function removeTeamMemberSshKey(HostingAccount $account, string $marker): void;
/**
* @return array<string, mixed>
*/
public function createDatabase(HostedDatabase $database, string $password): array;
public function deleteDatabase(HostedDatabase $database): bool;
public function changeDatabasePassword(HostedDatabase $database, string $newPassword): bool;
/**
* @return array<string, mixed>
*/
public function addSite(HostedSite $site): array;
public function removeSite(HostedSite $site): bool;
public function requestLetsEncryptCertificate(HostedSite $site): bool;
public function ensurePhpFpmPool(HostingAccount $account): void;
public function setPhpVersion(HostedSite $site, string $version): bool;
public function changeAccountPhpVersion(HostingAccount $account, string $version): bool;
public function prepareDirectoryForUser(HostingAccount $account, string $path): void;
public function setWebServerGroupOwnership(HostingAccount $account, string $path): void;
public function removeAppService(HostedSite $site): bool;
public function restoreDefaultSiteConfig(HostingAccount $account, HostedSite $site, string $docRoot): void;
}
@@ -0,0 +1,49 @@
<?php
namespace App\Services\Hosting\Contracts;
use App\Models\HostingAccount;
use App\Models\HostedSite;
use App\Models\HostedDatabase;
interface SharedHostingProviderInterface
{
public function createAccount(HostingAccount $account): array;
public function suspendAccount(HostingAccount $account, string $reason): bool;
public function unsuspendAccount(HostingAccount $account): bool;
public function terminateAccount(HostingAccount $account): bool;
public function changePassword(HostingAccount $account, string $newPassword): bool;
public function installTeamMemberSshKey(HostingAccount $account, string $marker, string $publicKey): void;
public function removeTeamMemberSshKey(HostingAccount $account, string $marker): void;
public function addSite(HostedSite $site): array;
public function removeSite(HostedSite $site): bool;
public function requestLetsEncryptCertificate(HostedSite $site): bool;
public function createDatabase(HostedDatabase $database, string $password): array;
public function deleteDatabase(HostedDatabase $database): bool;
public function changeDatabasePassword(HostedDatabase $database, string $newPassword): bool;
public function setPhpVersion(HostedSite $site, string $version): bool;
public function changeAccountPhpVersion(HostingAccount $account, string $version): bool;
/**
* @return list<string> PHP versions that currently have a pool config for this account.
*/
public function findPhpFpmPoolVersions(HostingAccount $account): array;
public function getUsageStats(HostingAccount $account): array;
public function executeCommand(HostingAccount $account, string $command, int $timeout = 600): array;
}
@@ -0,0 +1,77 @@
<?php
namespace App\Services\Hosting;
use App\Models\HostingAccount;
use App\Notifications\HostingExpiringNotification;
/** Sends one-time hosting plan expiry reminders. */
class HostingExpiryAlertService
{
/** @return list<int> */
public function milestones(): array
{
return array_values(array_map(
'intval',
(array) config('notifications.hosting_expiry_milestones', [30, 14, 7, 3, 1])
));
}
public function checkAll(): int
{
$sent = 0;
HostingAccount::query()
->where('status', HostingAccount::STATUS_ACTIVE)
->whereNotNull('expires_at')
->where('expires_at', '>', now())
->with('user')
->each(function (HostingAccount $account) use (&$sent): void {
if ($this->checkAccount($account)) {
$sent++;
}
});
return $sent;
}
public function checkAccount(HostingAccount $account): bool
{
if (! $account->expires_at || ! $account->user || $account->expires_at->isPast()) {
return false;
}
$daysRemaining = $this->daysUntilExpiry($account);
if (! in_array($daysRemaining, $this->milestones(), true)) {
return false;
}
$metadata = is_array($account->metadata) ? $account->metadata : [];
$sent = array_map('intval', (array) ($metadata['expiry_alerts_sent'] ?? []));
if (in_array($daysRemaining, $sent, true)) {
return false;
}
$account->user->notify(new HostingExpiringNotification($account, $daysRemaining));
$metadata['expiry_alerts_sent'] = array_values(array_unique([...$sent, $daysRemaining]));
$account->update(['metadata' => $metadata]);
return true;
}
public function resetAlerts(HostingAccount $account): void
{
$metadata = is_array($account->metadata) ? $account->metadata : [];
unset($metadata['expiry_alerts_sent']);
$account->update(['metadata' => $metadata]);
}
private function daysUntilExpiry(HostingAccount $account): int
{
return (int) now()->startOfDay()->diffInDays(
$account->expires_at->copy()->startOfDay(),
false
);
}
}
@@ -0,0 +1,181 @@
<?php
namespace App\Services\Hosting;
use App\Jobs\DeactivateMailboxJob;
use App\Jobs\ProvisionMailboxJob;
use App\Services\Mail\MailboxUserProvisioner;
use App\Models\Domain;
use App\Models\HostingAccount;
use App\Models\HostingBillingInvoice;
use App\Models\Mailbox;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class HostingMailboxService
{
public const EXTRA_MAILBOX_PRICE_CEDIS = 5;
public function __construct(
protected MailboxUserProvisioner $mailboxUsers,
) {}
/**
* @return array<string, mixed>
*/
public function usageSummary(HostingAccount $account): array
{
$account->loadMissing(['product', 'mailboxes']);
$freeAllowance = $account->freeEmailAllowance();
$mailboxCount = $account->mailboxes->count();
$paidCount = $account->mailboxes->where('is_paid', true)->count();
$freeUsed = $freeAllowance === null ? $mailboxCount : min($mailboxCount, $freeAllowance);
$extraCount = max($mailboxCount - ($freeAllowance ?? $mailboxCount), 0);
return [
'mailbox_count' => $mailboxCount,
'free_allowance' => $freeAllowance,
'free_used' => $freeUsed,
'paid_count' => $paidCount,
'extra_count' => $extraCount,
'extra_price' => self::EXTRA_MAILBOX_PRICE_CEDIS,
'extra_total' => round($extraCount * self::EXTRA_MAILBOX_PRICE_CEDIS, 2),
'breakdown' => sprintf(
'Extra Emails: %d x GHS %d = GHS %.2f',
$extraCount,
self::EXTRA_MAILBOX_PRICE_CEDIS,
round($extraCount * self::EXTRA_MAILBOX_PRICE_CEDIS, 2)
),
];
}
public function createMailbox(HostingAccount $account, array $input): array
{
$account->loadMissing(['product', 'mailboxes', 'sites']);
$domain = Domain::query()
->whereKey((int) ($input['domain_id'] ?? 0))
->where('hosting_account_id', $account->id)
->first();
if (! $domain) {
throw ValidationException::withMessages([
'domain_id' => 'Select a domain linked to this hosting account.',
]);
}
$localPart = strtolower(trim((string) ($input['local_part'] ?? '')));
$address = "{$localPart}@{$domain->host}";
if (Mailbox::query()->where('address', $address)->exists()) {
throw ValidationException::withMessages([
'local_part' => 'This mailbox address already exists.',
]);
}
$summary = $this->usageSummary($account);
$freeAllowance = $summary['free_allowance'];
$isPaid = $freeAllowance !== null && $summary['mailbox_count'] >= $freeAllowance;
$mailbox = Mailbox::query()->create([
'user_id' => $account->user_id,
'website_id' => null,
'hosting_account_id' => $account->id,
'domain_id' => $domain->id,
'display_name' => (string) ($input['display_name'] ?? $localPart),
'local_part' => $localPart,
'address' => $address,
'password_hash' => Hash::make((string) $input['password']),
'quota_mb' => (int) ($input['quota_mb'] ?? Mailbox::defaultQuotaMb()),
'status' => 'provisioning',
'is_paid' => $isPaid,
'paid_at' => $isPaid ? now() : null,
'billing_type' => $isPaid ? 'subscription' : 'free',
'monthly_price' => $isPaid ? self::EXTRA_MAILBOX_PRICE_CEDIS : null,
'subscription_started_at' => $isPaid ? now() : null,
'subscription_expires_at' => $isPaid ? now()->addMonth() : null,
'subscription_status' => 'active',
'maildir_path' => sprintf('hosting/%d/%s/%s', $account->id, $domain->host, $localPart),
'credentials_issued_at' => now(),
'incoming_token' => Str::random(64),
'inbound_enabled' => true,
'outbound_enabled' => true,
'outbound_provider' => 'smtp',
'metadata' => [
'provisioned_by' => 'hosting_account',
'hosting_account_id' => $account->id,
],
]);
$this->mailboxUsers->provisionMailboxSilently($mailbox, (string) $input['password']);
ProvisionMailboxJob::dispatch($mailbox->id, encrypt((string) $input['password']));
$invoice = $this->syncAddonInvoice($account->fresh(['mailboxes']));
return [
'mailbox' => $mailbox->fresh(['domain']),
'usage' => $this->usageSummary($account->fresh(['product', 'mailboxes'])),
'invoice' => $invoice,
];
}
public function deleteMailbox(HostingAccount $account, Mailbox $mailbox): ?HostingBillingInvoice
{
if ((int) $mailbox->hosting_account_id !== (int) $account->id) {
throw ValidationException::withMessages([
'mailbox_id' => 'Mailbox does not belong to this hosting account.',
]);
}
$mailbox->update(['status' => 'deleting']);
DeactivateMailboxJob::dispatch($mailbox->id);
return $this->syncAddonInvoice($account->fresh(['mailboxes']));
}
public function syncAddonInvoice(HostingAccount $account): ?HostingBillingInvoice
{
$account->loadMissing(['product', 'mailboxes']);
$summary = $this->usageSummary($account);
if ($summary['extra_count'] === 0) {
HostingBillingInvoice::query()
->where('hosting_account_id', $account->id)
->where('invoice_type', HostingBillingInvoice::TYPE_EMAIL_ADDON)
->where('status', HostingBillingInvoice::STATUS_PENDING)
->delete();
return null;
}
return HostingBillingInvoice::query()->updateOrCreate(
[
'hosting_account_id' => $account->id,
'invoice_type' => HostingBillingInvoice::TYPE_EMAIL_ADDON,
'status' => HostingBillingInvoice::STATUS_PENDING,
],
[
'user_id' => $account->user_id,
'currency' => 'GHS',
'subtotal_amount' => $summary['extra_total'],
'credit_amount' => 0,
'total_amount' => $summary['extra_total'],
'description' => sprintf('Extra mailbox charges for %s', $account->primary_domain ?: $account->username),
'line_items' => [[
'code' => 'extra_emails',
'label' => 'Extra Emails',
'quantity' => $summary['extra_count'],
'unit_amount' => self::EXTRA_MAILBOX_PRICE_CEDIS,
'amount' => $summary['extra_total'],
'description' => $summary['breakdown'],
]],
'metadata' => [
'billing_period' => now()->format('Y-m'),
'extra_mailboxes' => $summary['extra_count'],
],
]
);
}
}
@@ -0,0 +1,86 @@
<?php
namespace App\Services\Hosting;
use App\Models\HostingAccount;
use App\Models\HostingNode;
use Illuminate\Support\Collection;
class HostingNodePoolService
{
public function poolRoot(HostingNode $node): HostingNode
{
return $node->isExtension() && $node->primary
? $node->primary
: $node;
}
/**
* @return Collection<int, HostingNode>
*/
public function poolMembers(HostingNode $node): Collection
{
$root = $this->poolRoot($node);
return HostingNode::query()
->where(function ($query) use ($root) {
$query->where('id', $root->id)
->orWhere('primary_hosting_node_id', $root->id);
})
->where('type', HostingNode::TYPE_SHARED)
->whereNotIn('status', [HostingNode::STATUS_DECOMMISSIONED])
->orderBy('role')
->get();
}
public function poolDiskGb(HostingNode $node): int
{
return (int) $this->poolMembers($node)->sum('disk_gb');
}
public function poolLogicalCapacityGb(HostingNode $node): float
{
return round($this->poolMembers($node)->sum(fn (HostingNode $m) => $m->logicalCapacityGb()), 2);
}
public function poolAllocatedDiskGb(HostingNode $node): int
{
$memberIds = $this->poolMembers($node)->pluck('id');
return (int) HostingAccount::query()
->whereIn('hosting_node_id', $memberIds)
->where('type', HostingAccount::TYPE_SHARED)
->sum('allocated_disk_gb');
}
public function poolUsedDiskGb(HostingNode $node): int
{
$memberIds = $this->poolMembers($node)->pluck('id');
$bytes = (int) HostingAccount::query()
->whereIn('hosting_node_id', $memberIds)
->where('type', HostingAccount::TYPE_SHARED)
->sum('disk_used_bytes');
return (int) ceil($bytes / 1073741824);
}
/**
* Member node with the most room for a new shared account.
*/
public function findProvisioningMember(HostingNode $poolRoot, int $requestedDiskGb = 0, ?string $segment = null): ?HostingNode
{
$members = $this->poolMembers($poolRoot)
->filter(fn (HostingNode $n) => $n->canProvision($requestedDiskGb, $segment));
if ($members->isEmpty()) {
return null;
}
return $members->sortBy(fn (HostingNode $n) => sprintf(
'%012.2f-%012d',
(float) $n->current_load_percent,
$n->used_disk_gb,
))->first();
}
}
@@ -0,0 +1,68 @@
<?php
namespace App\Services\Hosting;
use App\Jobs\ProvisionHostingOrderJob;
use App\Models\CustomerHostingOrder;
use Illuminate\Support\Facades\Log;
class HostingOrderFulfillmentService
{
/**
* Approve and queue provisioning. Upstream failures (e.g. Contabo low balance)
* are handled when {@see ProvisionHostingOrderJob} runs.
*
* @return array{fulfilled: bool, reasons: array<int, string>}
*/
public function attemptAutoFulfillment(CustomerHostingOrder $order): array
{
$order->refresh()->loadMissing('product');
if (! $this->isEligibleForAutoFulfillment($order)) {
return ['fulfilled' => false, 'reasons' => ['Order is not eligible for automatic fulfillment.']];
}
$this->approveAndQueue($order);
return ['fulfilled' => true, 'reasons' => []];
}
public function approveAndQueue(CustomerHostingOrder $order): void
{
if ($order->status === CustomerHostingOrder::STATUS_APPROVED) {
ProvisionHostingOrderJob::dispatch($order->fresh());
return;
}
$meta = $order->meta ?? [];
unset($meta['upstream_funds_hold'], $meta['upstream_funds_hold_at'], $meta['provisioning_hold']);
$order->update([
'status' => CustomerHostingOrder::STATUS_APPROVED,
'approved_at' => now(),
'approved_by' => null,
'meta' => $meta,
]);
ProvisionHostingOrderJob::dispatch($order->fresh());
Log::info('Hosting order queued for provisioning', [
'order_id' => $order->id,
'domain_name' => $order->domain_name,
]);
}
private function isEligibleForAutoFulfillment(CustomerHostingOrder $order): bool
{
return in_array($order->status, [
CustomerHostingOrder::STATUS_PENDING_APPROVAL,
CustomerHostingOrder::STATUS_PENDING_PAYMENT,
], true) || (
$order->status === CustomerHostingOrder::STATUS_APPROVED
&& $order->provisioned_at === null
&& $order->hosting_account_id === null
&& $order->vps_instance_id === null
);
}
}
@@ -0,0 +1,322 @@
<?php
namespace App\Services\Hosting;
use App\Models\CustomerHostingOrder;
use App\Models\HostingAccount;
use App\Models\HostingBillingInvoice;
use App\Models\HostingPlanChange;
use App\Models\HostingProduct;
use App\Models\User;
use App\Services\Hosting\Providers\SharedNodeProvider;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class HostingPlanChangeService
{
public function __construct(
private NodeCapacityService $capacityService,
private HostingResourcePolicyService $resourcePolicyService,
private SharedNodeProvider $sharedNodeProvider,
) {
}
/**
* @return \Illuminate\Database\Eloquent\Collection<int, HostingProduct>
*/
public function availablePlans(): \Illuminate\Database\Eloquent\Collection
{
return HostingProduct::query()
->active()
->visible()
->where('category', HostingProduct::CATEGORY_SHARED)
->ordered()
->get();
}
/**
* @return array<string, mixed>
*/
public function quote(HostingAccount $account, HostingProduct $targetProduct): array
{
$account->loadMissing(['product', 'latestOrder', 'sites', 'databases', 'node']);
$this->assertCanChange($account, $targetProduct);
$currentProduct = $account->product;
$order = $account->latestOrder;
$billingCycle = $order?->billing_cycle ?? CustomerHostingOrder::CYCLE_MONTHLY;
$cycleMonths = $order?->getCycleLengthInMonths() ?? 1;
$currentCyclePrice = $this->cyclePriceFor($currentProduct, $billingCycle);
$targetCyclePrice = $this->cyclePriceFor($targetProduct, $billingCycle);
$remaining = $this->remainingCycleState($account, $order, $cycleMonths);
$priceDelta = round($targetCyclePrice - $currentCyclePrice, 2);
$direction = $priceDelta >= 0
? HostingPlanChange::DIRECTION_UPGRADE
: HostingPlanChange::DIRECTION_DOWNGRADE;
$proratedDelta = round($priceDelta * $remaining['ratio'], 2);
$chargeAmount = $proratedDelta > 0 ? $proratedDelta : 0;
$creditAmount = $proratedDelta < 0 ? abs($proratedDelta) : 0;
return [
'direction' => $direction,
'billing_cycle' => $billingCycle,
'cycle_months' => $cycleMonths,
'remaining_days' => $remaining['days'],
'proration_ratio' => $remaining['ratio'],
'current_cycle_price' => $currentCyclePrice,
'target_cycle_price' => $targetCyclePrice,
'charge_amount' => $chargeAmount,
'credit_amount' => $creditAmount,
'net_amount' => round($chargeAmount - $creditAmount, 2),
'currency' => $targetProduct->display_currency,
'line_items' => $this->lineItems($account, $targetProduct, $direction, $chargeAmount, $creditAmount, $remaining['ratio']),
];
}
/**
* @return array{change: HostingPlanChange, invoice: HostingBillingInvoice, account: HostingAccount, quote: array<string, mixed>}
*/
public function apply(HostingAccount $account, HostingProduct $targetProduct, User $actor): array
{
$account->loadMissing(['product', 'latestOrder', 'sites', 'databases', 'node']);
$quote = $this->quote($account, $targetProduct);
$currentProduct = $account->product;
return DB::transaction(function () use ($account, $targetProduct, $actor, $quote, $currentProduct): array {
$change = HostingPlanChange::query()->create([
'user_id' => $actor->id,
'hosting_account_id' => $account->id,
'from_hosting_product_id' => $currentProduct->id,
'to_hosting_product_id' => $targetProduct->id,
'direction' => $quote['direction'],
'billing_cycle' => $quote['billing_cycle'],
'cycle_months' => $quote['cycle_months'],
'remaining_days' => $quote['remaining_days'],
'proration_ratio' => $quote['proration_ratio'],
'old_cycle_price' => $quote['current_cycle_price'],
'new_cycle_price' => $quote['target_cycle_price'],
'charge_amount' => $quote['charge_amount'],
'credit_amount' => $quote['credit_amount'],
'net_amount' => $quote['net_amount'],
'currency' => $quote['currency'],
'status' => 'applied',
'applied_at' => now(),
'metadata' => [
'actor_id' => $actor->id,
'sites_count' => $account->sites->count(),
'databases_count' => $account->databases->count(),
],
]);
$invoiceStatus = $quote['charge_amount'] > 0
? HostingBillingInvoice::STATUS_PENDING
: ($quote['credit_amount'] > 0
? HostingBillingInvoice::STATUS_CREDITED
: HostingBillingInvoice::STATUS_WAIVED);
$invoice = HostingBillingInvoice::query()->create([
'user_id' => $actor->id,
'hosting_account_id' => $account->id,
'hosting_plan_change_id' => $change->id,
'invoice_type' => HostingBillingInvoice::TYPE_PLAN_CHANGE,
'status' => $invoiceStatus,
'currency' => $quote['currency'],
'subtotal_amount' => $quote['charge_amount'],
'credit_amount' => $quote['credit_amount'],
'total_amount' => max($quote['net_amount'], 0),
'description' => sprintf(
'%s %s from %s to %s',
ucfirst($quote['direction']),
$account->primary_domain ?: $account->username,
$currentProduct->name,
$targetProduct->name
),
'paid_at' => $invoiceStatus !== HostingBillingInvoice::STATUS_PENDING ? now() : null,
'line_items' => $quote['line_items'],
'metadata' => [
'direction' => $quote['direction'],
'billing_cycle' => $quote['billing_cycle'],
'remaining_days' => $quote['remaining_days'],
],
]);
$this->applyProductToAccount($account, $targetProduct, $change, $actor);
return [
'change' => $change->fresh(['fromProduct', 'toProduct']),
'invoice' => $invoice->fresh(),
'account' => $account->fresh(['product', 'node']),
'quote' => $quote,
];
});
}
private function assertCanChange(HostingAccount $account, HostingProduct $targetProduct): void
{
if (! $account->product || ! $account->product->isSharedHosting()) {
throw ValidationException::withMessages([
'hosting_account_id' => 'Only shared hosting accounts can change packages.',
]);
}
if (! $targetProduct->isSharedHosting() || ! $targetProduct->is_active || ! $targetProduct->is_visible) {
throw ValidationException::withMessages([
'target_product_id' => 'Selected plan is not available for hosting upgrades.',
]);
}
if ((int) $account->hosting_product_id === (int) $targetProduct->id) {
throw ValidationException::withMessages([
'target_product_id' => 'Account is already on that plan.',
]);
}
$siteLimit = (int) ($targetProduct->max_domains ?? 0);
if ($siteLimit >= 0 && $account->sites->count() > $siteLimit) {
throw ValidationException::withMessages([
'target_product_id' => "The target plan supports {$siteLimit} site(s), but this account already has {$account->sites->count()}.",
]);
}
$databaseLimit = $targetProduct->max_databases;
if ($databaseLimit !== null && $databaseLimit >= 0 && $account->databases->count() > $databaseLimit) {
throw ValidationException::withMessages([
'target_product_id' => "The target plan supports {$databaseLimit} database(s), but this account already has {$account->databases->count()}.",
]);
}
$diskDelta = max((int) ($targetProduct->disk_gb ?? 0) - (int) ($account->allocated_disk_gb ?? 0), 0);
if ($diskDelta > 0 && $account->node && ! $this->capacityService->canProvision($account->node, $diskDelta)) {
throw ValidationException::withMessages([
'target_product_id' => 'The assigned server does not have enough logical capacity for this upgrade.',
]);
}
}
private function cyclePriceFor(?HostingProduct $product, string $billingCycle): float
{
if (! $product) {
return 0;
}
$price = $product->getPriceForCycle($billingCycle);
if ($price === null) {
$price = $product->getPriceForCycle(CustomerHostingOrder::CYCLE_MONTHLY);
}
return round((float) $price, 2);
}
/**
* @return array{ratio: float, days: int}
*/
private function remainingCycleState(HostingAccount $account, ?CustomerHostingOrder $order, int $cycleMonths): array
{
$periodEnd = $account->expires_at
?? $order?->expires_at
?? now()->copy()->addMonthsNoOverflow($cycleMonths);
if (! $periodEnd || $periodEnd->lte(now())) {
return ['ratio' => 1.0, 'days' => 0];
}
$periodStart = $periodEnd->copy()->subMonthsNoOverflow(max($cycleMonths, 1));
$totalSeconds = max($periodStart->diffInSeconds($periodEnd, false), 1);
$remainingSeconds = max(now()->diffInSeconds($periodEnd, false), 0);
return [
'ratio' => round(min(max($remainingSeconds / $totalSeconds, 0), 1), 4),
'days' => (int) ceil($remainingSeconds / 86400),
];
}
/**
* @return array<int, array<string, mixed>>
*/
private function lineItems(
HostingAccount $account,
HostingProduct $targetProduct,
string $direction,
float $chargeAmount,
float $creditAmount,
float $ratio
): array {
$currentProduct = $account->product;
return array_values(array_filter([
[
'code' => 'plan_change',
'label' => sprintf('%s to %s', ucfirst($direction), $targetProduct->name),
'description' => sprintf('Plan change from %s to %s.', $currentProduct?->name ?? 'Current plan', $targetProduct->name),
'amount' => round($chargeAmount, 2),
],
$creditAmount > 0 ? [
'code' => 'proration_credit',
'label' => 'Unused time credit',
'description' => sprintf('Prorated credit at %.2f%% of the current cycle.', $ratio * 100),
'amount' => round($creditAmount * -1, 2),
] : null,
]));
}
private function applyProductToAccount(HostingAccount $account, HostingProduct $targetProduct, HostingPlanChange $change, User $actor): void
{
$policyLimits = $this->resourcePolicyService->defaultLimitsForProduct($targetProduct);
$resourceLimits = array_merge((array) ($account->resource_limits ?? []), [
'disk_gb' => (int) ($targetProduct->disk_gb ?? 0),
'bandwidth_gb' => $targetProduct->bandwidth_gb,
'max_domains' => $targetProduct->max_domains,
'max_databases' => $targetProduct->max_databases,
'max_email_accounts' => $targetProduct->max_email_accounts,
'max_ftp_accounts' => $targetProduct->max_ftp_accounts,
], $policyLimits);
$metadata = array_merge((array) ($account->metadata ?? []), [
'last_plan_change_id' => $change->id,
'last_plan_change_at' => now()->toIso8601String(),
'last_plan_changed_by' => $actor->id,
]);
$account->update([
'hosting_product_id' => $targetProduct->id,
'allocated_disk_gb' => (int) ($targetProduct->disk_gb ?? 0),
'cpu_limit_percent' => $policyLimits['cpu_limit_percent'] ?? $account->cpu_limit_percent,
'memory_limit_mb' => $policyLimits['memory_limit_mb'] ?? $account->memory_limit_mb,
'process_limit' => $policyLimits['process_limit'] ?? $account->process_limit,
'io_limit_mb' => $policyLimits['io_limit_mb'] ?? $account->io_limit_mb,
'inode_limit' => $policyLimits['inode_limit'] ?? $account->inode_limit,
'resource_limits' => $resourceLimits,
'metadata' => $metadata,
]);
$account->refresh();
$activeOrder = CustomerHostingOrder::query()
->where('hosting_account_id', $account->id)
->whereIn('status', [
CustomerHostingOrder::STATUS_ACTIVE,
CustomerHostingOrder::STATUS_SUSPENDED,
CustomerHostingOrder::STATUS_PROVISIONING,
])
->latest('id')
->first();
if ($activeOrder) {
$activeOrder->update([
'hosting_product_id' => $targetProduct->id,
'currency' => $targetProduct->display_currency,
'meta' => array_merge((array) ($activeOrder->meta ?? []), [
'last_plan_change_id' => $change->id,
'last_plan_change_direction' => $change->direction,
]),
]);
}
$this->sharedNodeProvider->syncAccountPackage($account);
if ($account->node) {
$this->capacityService->refreshNodeResourceTotals($account->node);
}
}
}
@@ -0,0 +1,242 @@
<?php
namespace App\Services\Hosting;
use App\Models\HostingProduct;
use App\Services\ExchangeRateService;
use App\Services\Hosting\Providers\ContaboProvider;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class HostingPricingService
{
private array $margins;
private array $contaboBasePrices;
private ExchangeRateService $exchangeRateService;
private ContaboProvider $contaboProvider;
public function __construct(ExchangeRateService $exchangeRateService, ContaboProvider $contaboProvider)
{
$this->exchangeRateService = $exchangeRateService;
$this->contaboProvider = $contaboProvider;
$this->margins = config('hosting.pricing.margins', [
'vps' => 30,
'dedicated' => 30,
]);
$this->contaboBasePrices = config('hosting.pricing.contabo_base_prices', []);
}
public function getExchangeRate(): float
{
return $this->exchangeRateService->getUsdToGhs();
}
public function getMargin(string $category): float
{
return (float) ($this->margins[$category] ?? 30);
}
public function getContaboBasePrice(?string $productId): ?array
{
if ($productId === null) {
return null;
}
$productId = trim($productId);
if ($productId === '') {
return null;
}
$cacheKey = "contabo_product_price_{$productId}";
return Cache::remember($cacheKey, (int) config('hosting.pricing.contabo_price_cache_ttl', 3600), function () use ($productId) {
$liveProduct = $this->getLiveContaboProduct($productId);
if ($liveProduct !== null) {
return $liveProduct + ['source' => 'contabo_api'];
}
$fallback = $this->contaboBasePrices[$productId] ?? null;
return is_array($fallback) ? $fallback + ['source' => 'local_fallback'] : null;
});
}
public function calculatePrice(float $basePriceUsd, string $category): float
{
$margin = $this->getMargin($category);
$exchangeRate = $this->getExchangeRate();
$convertedPrice = $basePriceUsd * $exchangeRate;
$priceWithMargin = $convertedPrice * (1 + ($margin / 100));
// Round to nearest 5 for cleaner pricing
return round($priceWithMargin / 5) * 5;
}
public function calculateQuarterlyPrice(float $monthlyPrice): float
{
return $this->calculateTermPrice($monthlyPrice, 3, 'quarterly');
}
public function calculateSemiannualPrice(float $monthlyPrice): float
{
return $this->calculateTermPrice($monthlyPrice, 6, 'semiannual');
}
public function calculateYearlyPrice(float $monthlyPrice): float
{
return $this->calculateTermPrice($monthlyPrice, 12, 'yearly');
}
public function convertUsdRecurringOption(float $monthlyUsd, string $category): float
{
$margin = $this->getMargin($category);
$exchangeRate = $this->getExchangeRate();
return round($monthlyUsd * $exchangeRate * (1 + ($margin / 100)), 2);
}
private function calculateTermPrice(float $monthlyPrice, int $months, string $cycle): float
{
$discountPercent = (float) config("hosting.pricing.term_discounts.{$cycle}", 0);
$total = $monthlyPrice * $months * (1 - ($discountPercent / 100));
return round($total / 5) * 5;
}
public function getDynamicPriceForProduct(HostingProduct $product): array
{
// Only calculate dynamic pricing for VPS and Dedicated
if (!in_array($product->category, ['vps', 'dedicated'])) {
return [
'monthly' => (float) $product->price_monthly,
'quarterly' => (float) $product->price_quarterly,
'semiannual' => null,
'yearly' => (float) $product->price_yearly,
'currency' => $product->display_currency,
'is_dynamic' => false,
];
}
$exchangeRate = $this->getExchangeRate();
$cacheKey = "hosting_price_{$product->slug}_{$product->contabo_product_id}_{$exchangeRate}";
return Cache::remember($cacheKey, 3600, function () use ($product, $exchangeRate) {
$contaboProduct = $this->getContaboBasePrice($product->contabo_product_id);
if (!$contaboProduct) {
// Fallback to stored prices if no Contabo mapping
return [
'monthly' => (float) $product->price_monthly,
'quarterly' => (float) $product->price_quarterly,
'semiannual' => null,
'yearly' => (float) $product->price_yearly,
'currency' => $product->display_currency,
'is_dynamic' => false,
];
}
$basePriceUsd = $contaboProduct['monthly'];
$monthlyPrice = $this->calculatePrice($basePriceUsd, $product->category);
return [
'monthly' => $monthlyPrice,
'quarterly' => $this->calculateQuarterlyPrice($monthlyPrice),
'semiannual' => $this->calculateSemiannualPrice($monthlyPrice),
'yearly' => $this->calculateYearlyPrice($monthlyPrice),
'currency' => 'GHS',
'is_dynamic' => true,
'base_price_usd' => $basePriceUsd,
'exchange_rate' => $exchangeRate,
'margin_percent' => $this->getMargin($product->category),
];
});
}
public function getPricingBreakdown(HostingProduct $product): array
{
if (!in_array($product->category, ['vps', 'dedicated'])) {
return [];
}
$contaboProduct = $this->getContaboBasePrice($product->contabo_product_id);
if (!$contaboProduct) {
return [];
}
$basePriceUsd = $contaboProduct['monthly'];
$margin = $this->getMargin($product->category);
$exchangeRate = $this->getExchangeRate();
$convertedPrice = $basePriceUsd * $exchangeRate;
$marginAmount = $convertedPrice * ($margin / 100);
$finalPrice = $this->calculatePrice($basePriceUsd, $product->category);
return [
'contabo_price_usd' => $basePriceUsd,
'price_source' => (string) ($contaboProduct['source'] ?? 'unknown'),
'exchange_rate' => $exchangeRate,
'converted_ghs' => round($convertedPrice, 2),
'margin_percent' => $margin,
'margin_amount_ghs' => round($marginAmount, 2),
'final_price_ghs' => $finalPrice,
'profit_per_month_ghs' => round($finalPrice - $convertedPrice, 2),
];
}
public function getExchangeRateInfo(): array
{
return $this->exchangeRateService->getRateInfo();
}
public function refreshExchangeRate(): float
{
return $this->exchangeRateService->refreshRate();
}
private function getLiveContaboProduct(string $productId): ?array
{
try {
foreach ($this->contaboProvider->getAvailableProducts() as $product) {
if ((string) ($product['id'] ?? '') !== $productId) {
continue;
}
$monthlyUsd = $this->extractMonthlyUsdPrice($product);
if ($monthlyUsd === null || $monthlyUsd <= 0) {
return null;
}
return [
'monthly' => $monthlyUsd,
'name' => (string) ($product['name'] ?? $productId),
'cpu' => isset($product['cpu']) ? (int) $product['cpu'] : null,
'ram_gb' => isset($product['ram_gb']) ? (float) $product['ram_gb'] : null,
'disk_gb' => isset($product['disk_gb']) ? (float) $product['disk_gb'] : null,
];
}
} catch (\Throwable $exception) {
Log::warning('Contabo product price lookup failed; falling back to configured prices.', [
'product_id' => $productId,
'message' => $exception->getMessage(),
]);
}
return null;
}
private function extractMonthlyUsdPrice(array $product): ?float
{
foreach (['price_monthly', 'monthly', 'monthly_usd', 'monthlyUsd', 'monthlyPrice', 'price'] as $key) {
if (! isset($product[$key]) || ! is_numeric($product[$key])) {
continue;
}
return (float) $product[$key];
}
return null;
}
}
@@ -0,0 +1,390 @@
<?php
namespace App\Services\Hosting;
use App\Models\Domain;
use App\Models\HostingAccount;
use App\Models\HostingNode;
use App\Models\HostingPlan;
use App\Models\HostedSite;
use App\Models\ProvisioningJob;
use App\Models\User;
use App\Models\VpsInstance;
use App\Services\Hosting\Providers\ContaboProvider;
use App\Services\Hosting\Providers\SharedNodeProvider;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class HostingProvisioningService
{
public function __construct(
private ContaboProvider $contaboProvider,
private SharedNodeProvider $sharedNodeProvider,
private NodeCapacityService $nodeCapacityService,
private HostingResourcePolicyService $resourcePolicyService,
) {}
public function createSharedHostingAccount(User $user, HostingPlan $plan, string $domain): HostingAccount
{
return DB::transaction(function () use ($user, $plan, $domain) {
// Find available node
$node = $this->nodeCapacityService->findAvailableNode((int) $plan->disk_gb);
$resourceLimits = $this->resourcePolicyService->defaultLimitsForProduct(null);
if (!$node) {
throw new \RuntimeException('No available hosting nodes for shared hosting');
}
// Generate unique username
$username = $this->generateUsername($user, $domain);
// Create account record
$account = HostingAccount::create([
'user_id' => $user->id,
'hosting_plan_id' => $plan->id,
'hosting_node_id' => $node->id,
'username' => $username,
'primary_domain' => $domain,
'type' => HostingAccount::TYPE_SHARED,
'status' => HostingAccount::STATUS_PROVISIONING,
'allocated_disk_gb' => (int) $plan->disk_gb,
'cpu_limit_percent' => $resourceLimits['cpu_limit_percent'],
'memory_limit_mb' => $resourceLimits['memory_limit_mb'],
'process_limit' => $resourceLimits['process_limit'],
'io_limit_mb' => $resourceLimits['io_limit_mb'],
'inode_limit' => $resourceLimits['inode_limit'],
'resource_status' => HostingAccount::RESOURCE_STATUS_ACTIVE,
'php_version' => $plan->php_versions[0] ?? '8.2',
'resource_limits' => [
'disk_gb' => $plan->disk_gb,
'bandwidth_gb' => $plan->bandwidth_gb,
'max_domains' => $plan->max_domains,
'max_databases' => $plan->max_databases,
'cpu_limit_percent' => $resourceLimits['cpu_limit_percent'],
'memory_limit_mb' => $resourceLimits['memory_limit_mb'],
'process_limit' => $resourceLimits['process_limit'],
'io_limit_mb' => $resourceLimits['io_limit_mb'],
'inode_limit' => $resourceLimits['inode_limit'],
],
]);
// Create provisioning job
$job = ProvisioningJob::create([
'type' => 'create_shared_account',
'provisionable_type' => HostingAccount::class,
'provisionable_id' => $account->id,
'provider' => 'shared_node',
'status' => ProvisioningJob::STATUS_PENDING,
'payload' => [
'username' => $username,
'domain' => $domain,
'node_id' => $node->id,
],
]);
// Dispatch async provisioning
dispatch(function () use ($account, $job, $node, $domain) {
$this->provisionSharedAccount($account, $job, $node, $domain);
})->afterCommit();
return $account;
});
}
public function createVpsInstance(User $user, HostingPlan $plan, array $config): VpsInstance
{
return DB::transaction(function () use ($user, $plan, $config) {
$hostname = $config['hostname'] ?? 'vps-' . Str::random(8) . '.ladill.com';
$instance = VpsInstance::create([
'user_id' => $user->id,
'hosting_plan_id' => $plan->id,
'name' => $config['name'] ?? $hostname,
'hostname' => $hostname,
'provider' => 'contabo',
'region' => $config['region'] ?? 'EU',
'image' => $config['image'] ?? 'afecbb85-e2fc-46f0-9684-b46b1faf00bb',
'cpu_cores' => $plan->cpu_cores,
'ram_mb' => $plan->ram_mb,
'disk_gb' => $plan->disk_gb,
'status' => 'provisioning',
'ssh_public_key' => $config['ssh_public_key'] ?? null,
'tags' => $config['tags'] ?? [],
]);
$job = ProvisioningJob::create([
'type' => 'create_vps_instance',
'provisionable_type' => VpsInstance::class,
'provisionable_id' => $instance->id,
'provider' => 'contabo',
'status' => ProvisioningJob::STATUS_PENDING,
'payload' => [
'product_id' => $plan->contabo_product_id,
'region' => $config['region'] ?? 'EU',
'image' => $config['image'] ?? 'afecbb85-e2fc-46f0-9684-b46b1faf00bb',
'ssh_keys' => $config['ssh_keys'] ?? [],
'user_data' => $config['user_data'] ?? null,
],
]);
dispatch(function () use ($instance, $job, $config, $plan) {
$this->provisionVpsInstance($instance, $job, $config, $plan);
})->afterCommit();
return $instance;
});
}
public function addSiteToAccount(HostingAccount $account, string $domain, string $type = 'addon'): HostedSite
{
$docRoot = "/home/{$account->username}/public_html/{$domain}";
$site = HostedSite::create([
'hosting_account_id' => $account->id,
'domain' => $domain,
'document_root' => $docRoot,
'type' => $type,
'status' => 'pending',
'php_version' => $account->php_version,
]);
try {
$this->sharedNodeProvider->addSite($site);
$site->update(['status' => 'active']);
// Sync to Domain registry
$this->syncToDomainRegistry($account, $domain, $site);
} catch (\Exception $e) {
$site->update(['status' => 'disabled']);
throw $e;
}
return $site;
}
/**
* Sync hosting site to unified Domain registry
*/
private function syncToDomainRegistry(HostingAccount $account, string $host, HostedSite $site): void
{
try {
$domain = Domain::where('host', $host)->first();
if ($domain) {
// Update existing domain to add hosting
$domain->update([
'hosting_account_id' => $account->id,
'source' => $domain->email_domain_id ? 'hosting_email' : 'hosting',
'onboarding_state' => $domain->onboarding_state === 'pending_ns' ? null : $domain->onboarding_state,
]);
} else {
// Create new domain entry
Domain::create([
'user_id' => $account->user_id,
'host' => $host,
'hosting_account_id' => $account->id,
'source' => 'hosting',
'type' => 'custom',
'status' => 'verified',
'onboarding_state' => null,
'verified_at' => now(),
]);
}
Log::info('HostingProvisioningService: Synced to Domain registry', [
'domain' => $host,
'site_id' => $site->id,
]);
} catch (\Throwable $e) {
Log::warning('HostingProvisioningService: Failed to sync to Domain registry', [
'domain' => $host,
'error' => $e->getMessage(),
]);
}
}
public function suspendAccount(HostingAccount $account, string $reason): bool
{
$result = $this->sharedNodeProvider->suspendAccount($account, $reason);
if ($result) {
$account->update([
'status' => HostingAccount::STATUS_SUSPENDED,
'suspension_reason' => $reason,
'suspended_at' => now(),
]);
}
return $result;
}
public function unsuspendAccount(HostingAccount $account): bool
{
$result = $this->sharedNodeProvider->unsuspendAccount($account);
if ($result) {
$account->update([
'status' => HostingAccount::STATUS_ACTIVE,
'suspension_reason' => null,
'suspended_at' => null,
]);
}
return $result;
}
public function terminateAccount(HostingAccount $account): bool
{
$result = $this->sharedNodeProvider->terminateAccount($account);
if ($result) {
$account->node?->decrementAccountCount();
$account->update(['status' => HostingAccount::STATUS_TERMINATED]);
$account->delete();
}
return $result;
}
public function controlVps(VpsInstance $instance, string $action): bool
{
return match ($action) {
'start' => $this->contaboProvider->startInstance($instance->provider_instance_id),
'stop' => $this->contaboProvider->stopInstance($instance->provider_instance_id),
'reboot' => $this->contaboProvider->rebootInstance($instance->provider_instance_id),
default => throw new \InvalidArgumentException("Unknown action: {$action}"),
};
}
public function terminateVps(VpsInstance $instance): bool
{
if ($instance->provider_instance_id) {
$this->contaboProvider->deleteInstance($instance->provider_instance_id);
}
$instance->update(['status' => 'terminated']);
$instance->delete();
return true;
}
private function provisionSharedAccount(HostingAccount $account, ProvisioningJob $job, HostingNode $node, string $domain): void
{
$job->markRunning();
try {
$result = $this->sharedNodeProvider->createAccount($account);
$account->update([
'status' => HostingAccount::STATUS_ACTIVE,
'home_directory' => $result['home_directory'],
'provisioned_at' => now(),
]);
// Create primary site
$site = HostedSite::create([
'hosting_account_id' => $account->id,
'domain' => $domain,
'document_root' => $result['document_root'],
'type' => 'primary',
'status' => 'pending',
'php_version' => $account->php_version,
]);
$this->sharedNodeProvider->addSite($site);
$site->update(['status' => 'active']);
// Sync to Domain registry
$this->syncToDomainRegistry($account, $domain, $site);
$node->incrementAccountCount();
$job->markCompleted([
'username' => $result['username'],
'home_directory' => $result['home_directory'],
'initial_password' => $result['password'],
]);
Log::info("Shared hosting account provisioned", [
'account_id' => $account->id,
'username' => $account->username,
]);
} catch (\Exception $e) {
Log::error("Shared hosting provisioning failed", [
'account_id' => $account->id,
'error' => $e->getMessage(),
]);
$account->update(['status' => HostingAccount::STATUS_FAILED]);
$job->markFailed($e->getMessage());
}
}
private function provisionVpsInstance(VpsInstance $instance, ProvisioningJob $job, array $config, HostingPlan $plan): void
{
$job->markRunning();
try {
$rootPassword = Str::password(16);
$result = $this->contaboProvider->createInstance([
'product_id' => $plan->contabo_product_id,
'name' => $instance->name,
'region' => $config['region'] ?? 'EU',
'image_id' => $config['image'] ?? 'afecbb85-e2fc-46f0-9684-b46b1faf00bb',
'root_password' => $rootPassword,
'ssh_keys' => $config['ssh_keys'] ?? [],
'user_data' => $config['user_data'] ?? null,
]);
$instance->update([
'provider_instance_id' => $result['instance_id'],
'ip_address' => $result['ip_address'],
'ipv6_address' => $result['ipv6_address'],
'datacenter' => $result['datacenter'],
'status' => 'running',
'power_status' => 'on',
'root_password_encrypted' => encrypt($rootPassword),
'provisioned_at' => now(),
]);
$job->markCompleted([
'instance_id' => $result['instance_id'],
'ip_address' => $result['ip_address'],
'root_password' => $rootPassword,
]);
Log::info("VPS instance provisioned", [
'instance_id' => $instance->id,
'provider_id' => $result['instance_id'],
]);
} catch (\Exception $e) {
Log::error("VPS provisioning failed", [
'instance_id' => $instance->id,
'error' => $e->getMessage(),
]);
$instance->update(['status' => 'failed']);
$job->markFailed($e->getMessage());
}
}
private function generateUsername(User $user, string $domain): string
{
$base = preg_replace('/[^a-z0-9]/', '', strtolower(explode('.', $domain)[0]));
$base = substr($base, 0, 8) ?: 'user';
$username = $base;
$counter = 1;
while (HostingAccount::where('username', $username)->exists()) {
$username = $base . $counter;
$counter++;
}
return $username;
}
}
@@ -0,0 +1,242 @@
<?php
namespace App\Services\Hosting;
use App\Models\CustomerHostingOrder;
use App\Models\HostingAccount;
use App\Models\HostingBillingInvoice;
use App\Models\User;
use App\Services\Billing\PaystackService;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class HostingRenewalCheckoutService
{
public function __construct(
private PaystackService $paystack,
) {
}
/**
* @return array{months:int, amount:float, currency:string, billing_cycle:string, amount_minor:int}
*/
public function renewalQuote(HostingAccount $account, ?int $durationMonths = null): array
{
$account->loadMissing(['product', 'latestOrder']);
if (! $account->canBeRenewed()) {
throw ValidationException::withMessages([
'hosting_account_id' => 'This hosting account does not have a configured renewal duration.',
]);
}
$months = $durationMonths ?? $account->assignedDurationMonths();
$billingCycle = $this->billingCycleForMonths($months);
$amount = $this->renewalAmount($account, $months, $billingCycle);
return [
'months' => $months,
'amount' => round($amount, 2),
'currency' => $account->product?->display_currency ?? 'GHS',
'billing_cycle' => $billingCycle,
'amount_minor' => (int) round($amount * 100),
];
}
/**
* @return array<int, array{months:int, amount:float, currency:string, billing_cycle:string, label:string, current:bool}>
*/
public function availableRenewalOptions(HostingAccount $account): array
{
$account->loadMissing(['product']);
if (! $account->canBeRenewed() || ! $account->product) {
return [];
}
$currency = $account->product->display_currency ?? 'GHS';
$currentMonths = $account->assignedDurationMonths();
$cycles = [
['months' => 1, 'cycle' => CustomerHostingOrder::CYCLE_MONTHLY, 'label' => '1 Month'],
['months' => 3, 'cycle' => CustomerHostingOrder::CYCLE_QUARTERLY, 'label' => '3 Months'],
['months' => 12, 'cycle' => CustomerHostingOrder::CYCLE_YEARLY, 'label' => '1 Year'],
['months' => 24, 'cycle' => CustomerHostingOrder::CYCLE_BIENNIAL, 'label' => '2 Years'],
];
$options = [];
foreach ($cycles as $cycle) {
$amount = $this->renewalAmount($account, $cycle['months'], $cycle['cycle']);
if ($amount <= 0) {
continue;
}
$options[] = [
'months' => $cycle['months'],
'amount' => round($amount, 2),
'currency' => $currency,
'billing_cycle' => $cycle['cycle'],
'label' => $cycle['label'],
'current' => $cycle['months'] === $currentMonths,
];
}
return $options;
}
/**
* @return array{authorization_url:string, invoice:HostingBillingInvoice, quote:array{months:int, amount:float, currency:string, billing_cycle:string, amount_minor:int}}
*/
public function beginCheckout(HostingAccount $account, User $user, string $callbackUrl, ?int $durationMonths = null): array
{
$quote = $this->renewalQuote($account, $durationMonths);
$reference = 'HOST-REN-' . strtoupper(Str::random(16));
$invoice = HostingBillingInvoice::query()->create([
'user_id' => $user->id,
'hosting_account_id' => $account->id,
'invoice_type' => HostingBillingInvoice::TYPE_RENEWAL,
'status' => HostingBillingInvoice::STATUS_PENDING,
'currency' => $quote['currency'],
'subtotal_amount' => $quote['amount'],
'credit_amount' => 0,
'total_amount' => $quote['amount'],
'description' => sprintf(
'Hosting renewal for %s (%d month%s)',
$account->primary_domain ?: $account->username,
$quote['months'],
$quote['months'] === 1 ? '' : 's'
),
'provider_reference' => $reference,
'line_items' => [[
'code' => 'renewal',
'label' => 'Hosting Renewal',
'quantity' => 1,
'amount' => $quote['amount'],
'description' => sprintf('%d month renewal for %s', $quote['months'], $account->product?->name ?? 'Hosting plan'),
]],
'metadata' => [
'months' => $quote['months'],
'billing_cycle' => $quote['billing_cycle'],
],
]);
$transaction = $this->paystack->initializeTransaction([
'email' => (string) $user->email,
'amount' => $quote['amount_minor'],
'currency' => $quote['currency'],
'reference' => $reference,
'callback_url' => $callbackUrl,
'metadata' => [
'context' => 'hosting_renewal',
'hosting_account_id' => $account->id,
'invoice_id' => $invoice->id,
'user_id' => $user->id,
'months' => $quote['months'],
],
]);
$authorizationUrl = (string) ($transaction['authorization_url'] ?? '');
if ($authorizationUrl === '') {
throw ValidationException::withMessages([
'payment' => 'Paystack did not return a checkout URL for the renewal.',
]);
}
return [
'authorization_url' => $authorizationUrl,
'invoice' => $invoice,
'quote' => $quote,
];
}
public function completeCheckout(HostingAccount $account, User $user, string $reference): HostingBillingInvoice
{
$account->loadMissing(['latestOrder']);
$invoice = HostingBillingInvoice::query()
->where('hosting_account_id', $account->id)
->where('user_id', $user->id)
->where('invoice_type', HostingBillingInvoice::TYPE_RENEWAL)
->where('provider_reference', $reference)
->latest('id')
->first();
if (! $invoice) {
throw ValidationException::withMessages([
'reference' => 'Renewal invoice could not be found for this payment reference.',
]);
}
if ($invoice->status === HostingBillingInvoice::STATUS_PAID) {
return $invoice;
}
$transaction = $this->paystack->verifyTransaction($reference);
$status = strtolower((string) ($transaction['status'] ?? ''));
if ($status !== 'success') {
throw ValidationException::withMessages([
'payment' => 'Renewal payment was not successful.',
]);
}
$months = (int) data_get($invoice->metadata, 'months', $account->assignedDurationMonths() ?? 0);
if ($months < 1) {
throw ValidationException::withMessages([
'payment' => 'Renewal duration is invalid for this hosting account.',
]);
}
$paidAt = now();
$account->renew($months, [
'renewed_by_user' => $user->id,
'renewed_at' => $paidAt->toIso8601String(),
'renewal_invoice_id' => $invoice->id,
]);
if ($account->latestOrder) {
$account->latestOrder->update([
'expires_at' => $account->fresh()->expires_at,
'meta' => array_merge((array) ($account->latestOrder->meta ?? []), [
'renewal_invoice_id' => $invoice->id,
'renewed_at' => $paidAt->toIso8601String(),
]),
]);
}
$invoice->update([
'status' => HostingBillingInvoice::STATUS_PAID,
'paid_at' => $paidAt,
]);
return $invoice->fresh();
}
private function billingCycleForMonths(int $months): string
{
return match ($months) {
1 => CustomerHostingOrder::CYCLE_MONTHLY,
3 => CustomerHostingOrder::CYCLE_QUARTERLY,
12 => CustomerHostingOrder::CYCLE_YEARLY,
24 => CustomerHostingOrder::CYCLE_BIENNIAL,
default => CustomerHostingOrder::CYCLE_MONTHLY,
};
}
private function renewalAmount(HostingAccount $account, int $months, string $billingCycle): float
{
$price = $account->product?->getPriceForCycle($billingCycle);
if ($price !== null && in_array($months, [1, 3, 12, 24], true)) {
return (float) $price;
}
$monthly = (float) ($account->product?->getPriceForCycle(CustomerHostingOrder::CYCLE_MONTHLY) ?? 0);
return $monthly * $months;
}
}
@@ -0,0 +1,642 @@
<?php
namespace App\Services\Hosting;
use App\Models\CustomerHostingOrder;
use App\Models\HostingAccount;
use App\Models\HostingAccountAlert;
use App\Models\HostingProduct;
use App\Notifications\HostingResourceWarningNotification;
use App\Services\Hosting\Providers\SharedNodeProvider;
class HostingResourcePolicyService
{
public const DISK_WARNING_THRESHOLD = 90;
public const INODE_WARNING_THRESHOLD = 90;
private const AUTOMATIC_SUSPENSION_GRACE_HOURS = 24;
private const AUTOMATIC_SUSPENSION_MIN_BREACH_STREAK = 12;
public function __construct(
private SharedNodeProvider $sharedNodeProvider
) {
}
public function defaultLimitsForProduct(?HostingProduct $product): array
{
$defaults = match ($product?->type) {
HostingProduct::TYPE_WORDPRESS => [
'cpu_limit_percent' => 113,
'memory_limit_mb' => 1152,
'process_limit' => 23,
'io_limit_mb' => 12,
'inode_limit' => 450000,
],
HostingProduct::TYPE_MULTI_DOMAIN => [
'cpu_limit_percent' => 150,
'memory_limit_mb' => 1536,
'process_limit' => 30,
'io_limit_mb' => 15,
'inode_limit' => 525000,
],
default => [
'cpu_limit_percent' => 75,
'memory_limit_mb' => 768,
'process_limit' => 15,
'io_limit_mb' => 8,
'inode_limit' => 375000,
],
};
$defaults = array_merge($defaults, $this->productSpecificLimits($product));
$overrides = (array) data_get($product?->features, 'resource_limits', []);
return array_merge($defaults, array_filter($overrides, fn ($value) => $value !== null));
}
private function productSpecificLimits(?HostingProduct $product): array
{
return match ($product?->slug) {
'starter-plan' => [
'inode_limit' => 225000,
],
'basic-plan' => [
'inode_limit' => 450000,
],
'plus-plan' => [
'inode_limit' => 450000,
],
'growth-plan' => [
'inode_limit' => 750000,
],
'pro-plan' => [
'inode_limit' => 1200000,
],
default => [],
};
}
public function applyDefaultLimits(HostingAccount $account, bool $persist = true): array
{
$defaults = $this->defaultLimitsForProduct($account->product);
$resourceLimits = (array) ($account->resource_limits ?? []);
$updates = [];
foreach ($defaults as $column => $value) {
if ($account->{$column} === null) {
$updates[$column] = $value;
}
$resourceLimits[$column] = $account->{$column} ?? $value;
}
if (! isset($resourceLimits['disk_gb'])) {
$resourceLimits['disk_gb'] = $account->requestedDiskGb();
}
if ($persist) {
$updates['resource_limits'] = $resourceLimits;
$updates['resource_status'] = $account->resource_status ?: HostingAccount::RESOURCE_STATUS_ACTIVE;
if ($updates !== []) {
$account->update($updates);
$account->refresh();
}
} else {
$account->forceFill(array_merge($updates, [
'resource_limits' => $resourceLimits,
'resource_status' => $account->resource_status ?: HostingAccount::RESOURCE_STATUS_ACTIVE,
]));
}
return $resourceLimits;
}
public function summarize(HostingAccount $account): array
{
$this->applyDefaultLimits($account);
return [
'resource_status' => $account->resource_status ?: HostingAccount::RESOURCE_STATUS_ACTIVE,
'uploads_restricted' => $account->uploadsAreRestricted(),
'disk_usage_percent' => $account->diskUsagePercent(),
'inode_usage_percent' => $account->inodeUsagePercent(),
'cpu_usage_percent' => (float) ($account->cpu_usage_percent ?? 0),
'memory_used_mb' => (int) ($account->memory_used_mb ?? 0),
'process_count' => (int) ($account->process_count ?? 0),
'io_usage_mb' => $account->io_usage_mb !== null ? (float) $account->io_usage_mb : null,
'cpu_limit_percent' => (int) ($account->cpu_limit_percent ?? 0),
'memory_limit_mb' => (int) ($account->memory_limit_mb ?? 0),
'process_limit' => (int) ($account->process_limit ?? 0),
'io_limit_mb' => (int) ($account->io_limit_mb ?? 0),
'inode_limit' => (int) ($account->inode_limit ?? 0),
'warning_count' => (int) ($account->warning_count ?? 0),
'is_flagged' => (bool) ($account->is_flagged ?? false),
'last_usage_sync_at' => $account->last_usage_sync_at,
'last_warning_at' => $account->last_warning_at,
'throttled_at' => $account->throttled_at,
];
}
public function process(HostingAccount $account): array
{
$account->loadMissing(['product', 'user', 'latestOrder']);
$this->applyDefaultLimits($account);
$evaluation = $this->evaluate($account);
$breaches = $evaluation['breaches'];
$warnings = $evaluation['warnings'];
$warningMessages = $evaluation['warning_messages'];
$primaryReason = $evaluation['primary_reason'];
$this->updateBreachCounters($account, $breaches);
$account->forceFill([
'uploads_restricted' => array_key_exists('inode', $breaches) || $account->inode_count >= (int) $account->inode_limit,
'is_flagged' => $warnings !== [] || $breaches !== [],
'metadata' => $account->metadata, // Save CPU history from evaluation
]);
if ($warnings !== [] || $breaches !== []) {
$account->forceFill([
'warning_count' => (int) $account->warning_count + 1,
'last_warning_at' => now(),
'last_resource_breach_at' => now(),
]);
}
$account->save();
$account->refresh();
foreach ($warningMessages as $type => $message) {
$severity = array_key_exists($type, $breaches) ? 'critical' : 'warning';
$alertType = array_key_exists($type, $breaches)
? HostingAccountAlert::TYPE_LIMIT_EXCEEDED
: HostingAccountAlert::TYPE_WARNING;
$this->recordAlert($account, $alertType, $severity, $message, $evaluation['usage']);
}
if ($breaches !== []) {
if ($account->isThrottled()) {
if ($this->shouldAutomaticallySuspendForSustainedBreach($account, $breaches)) {
$this->suspendAccount($account, $primaryReason, false);
}
} else {
$this->throttleAccount($account, $primaryReason, false);
}
} elseif ($warnings !== []) {
$this->notifyAccount($account, 'Hosting usage warning', implode(' ', array_values($warningMessages)), true);
} else {
$this->clearWarningState($account);
}
if ($breaches === []) {
if ($this->shouldAutomaticallyUnsuspend($account)) {
$this->unsuspendAccount($account, 'Usage returned within allowed thresholds.');
} elseif ($account->isThrottled() && $account->status === HostingAccount::STATUS_ACTIVE) {
$this->restoreAccount($account, 'Usage returned within allowed thresholds.', false);
}
}
return $evaluation;
}
public function throttleAccount(HostingAccount $account, string $reason, bool $manual = true): void
{
$account->loadMissing(['user', 'latestOrder']);
if ($account->status === HostingAccount::STATUS_SUSPENDED) {
return;
}
$this->sharedNodeProvider->applyAccountResourceProfile($account, true);
$metadata = array_merge((array) ($account->metadata ?? []), [
'throttle_reason' => $reason,
'throttle_origin' => $manual ? 'manual' : 'automatic',
'throttled_at' => now()->toIso8601String(),
]);
$account->update([
'resource_status' => HostingAccount::RESOURCE_STATUS_THROTTLED,
'throttled_at' => now(),
'metadata' => $metadata,
'uploads_restricted' => $account->uploadsAreRestricted(),
]);
$this->syncLatestOrderState($account, CustomerHostingOrder::STATUS_ACTIVE, $reason);
$this->recordAlert($account, HostingAccountAlert::TYPE_THROTTLED, 'critical', $reason, $this->usageSnapshot($account));
$this->notifyAccount($account, 'Hosting account throttled', $reason, false);
}
public function restoreAccount(HostingAccount $account, ?string $reason = null, bool $manual = true): void
{
$account->loadMissing(['user', 'latestOrder']);
if ($account->status === HostingAccount::STATUS_SUSPENDED) {
return;
}
$this->sharedNodeProvider->applyAccountResourceProfile($account, false);
$metadata = (array) ($account->metadata ?? []);
$metadata['restored_at'] = now()->toIso8601String();
$metadata['restore_origin'] = $manual ? 'manual' : 'automatic';
// Clear CPU history on restore
unset($metadata['cpu_history'], $metadata['cpu_averages']);
$account->update([
'resource_status' => HostingAccount::RESOURCE_STATUS_ACTIVE,
'uploads_restricted' => false,
'is_flagged' => false,
'cpu_breach_streak' => 0,
'memory_breach_streak' => 0,
'process_breach_streak' => 0,
'io_breach_streak' => 0,
'inode_breach_streak' => 0,
'throttled_at' => null,
'metadata' => $metadata,
]);
$this->resolveAlerts($account);
if ($reason) {
$this->notifyAccount($account, 'Hosting account restored', $reason, false);
}
}
public function suspendAccount(HostingAccount $account, string $reason, bool $manual = true): void
{
$account->loadMissing(['sites', 'user', 'latestOrder']);
if ($account->status === HostingAccount::STATUS_SUSPENDED) {
return;
}
$this->sharedNodeProvider->suspendAccount($account, $reason);
$metadata = array_merge((array) ($account->metadata ?? []), [
'suspension_reason' => $reason,
'suspension_origin' => $manual ? 'manual' : 'automatic',
'suspended_at' => now()->toIso8601String(),
]);
$account->update([
'status' => HostingAccount::STATUS_SUSPENDED,
'resource_status' => HostingAccount::RESOURCE_STATUS_SUSPENDED,
'suspension_reason' => $reason,
'suspended_at' => now(),
'is_flagged' => true,
'metadata' => $metadata,
]);
$this->syncLatestOrderState($account, CustomerHostingOrder::STATUS_SUSPENDED, $reason);
$this->recordAlert($account, HostingAccountAlert::TYPE_SUSPENDED, 'critical', $reason, $this->usageSnapshot($account));
}
public function unsuspendAccount(HostingAccount $account, ?string $reason = null): void
{
$account->loadMissing(['sites', 'user', 'latestOrder']);
if ($account->status !== HostingAccount::STATUS_SUSPENDED) {
return;
}
$this->sharedNodeProvider->unsuspendAccount($account);
$this->sharedNodeProvider->applyAccountResourceProfile($account, false);
$metadata = (array) ($account->metadata ?? []);
$metadata['unsuspended_at'] = now()->toIso8601String();
$account->update([
'status' => HostingAccount::STATUS_ACTIVE,
'resource_status' => HostingAccount::RESOURCE_STATUS_ACTIVE,
'suspension_reason' => null,
'suspended_at' => null,
'uploads_restricted' => false,
'metadata' => $metadata,
]);
$this->syncLatestOrderState($account, CustomerHostingOrder::STATUS_ACTIVE, $reason);
$this->resolveAlerts($account);
}
private function evaluate(HostingAccount $account): array
{
$usage = $this->usageSnapshot($account);
$warnings = [];
$breaches = [];
$messages = [];
if ($usage['disk_usage_percent'] >= self::DISK_WARNING_THRESHOLD) {
$warnings['disk'] = true;
$messages['disk'] = sprintf(
'Disk usage is at %.1f%% of the allocated quota.',
$usage['disk_usage_percent']
);
}
if ($usage['inode_usage_percent'] >= self::INODE_WARNING_THRESHOLD) {
$warnings['inode'] = true;
$messages['inode'] = sprintf(
'Inode usage is at %.1f%% of the allowed limit.',
$usage['inode_usage_percent']
);
}
// CPU: Use time-based sustained usage, not instant spikes
if ($account->cpu_limit_percent) {
$cpuEvaluation = $this->evaluateCpuUsage($account, $usage['cpu_usage_percent'], $account->cpu_limit_percent);
if ($cpuEvaluation['is_breach']) {
$breaches['cpu'] = true;
$messages['cpu'] = $cpuEvaluation['message'];
} elseif ($cpuEvaluation['is_warning']) {
$warnings['cpu'] = true;
$messages['cpu'] = $cpuEvaluation['message'];
}
}
if ($account->memory_limit_mb && $usage['memory_used_mb'] > $account->memory_limit_mb) {
$breaches['memory'] = true;
$messages['memory'] = sprintf(
'Memory usage reached %d MB against a %d MB limit.',
$usage['memory_used_mb'],
$account->memory_limit_mb
);
}
if ($account->process_limit && $usage['process_count'] > $account->process_limit) {
$breaches['process'] = true;
$messages['process'] = sprintf(
'Process count reached %d against a %d process limit.',
$usage['process_count'],
$account->process_limit
);
}
if ($account->io_limit_mb && $usage['io_usage_mb'] !== null && $usage['io_usage_mb'] > $account->io_limit_mb) {
$breaches['io'] = true;
$messages['io'] = sprintf(
'I/O usage reached %.1f MB against a %d MB limit.',
$usage['io_usage_mb'],
$account->io_limit_mb
);
}
if ($account->inode_limit && $usage['inode_count'] > $account->inode_limit) {
$breaches['inode'] = true;
$messages['inode_limit'] = sprintf(
'Inode count reached %d against a %d inode limit.',
$usage['inode_count'],
$account->inode_limit
);
}
$primaryReason = $messages[array_key_first($breaches)] ?? implode(' ', array_values($messages)) ?: 'Resource policy triggered.';
return [
'usage' => $usage,
'warnings' => $warnings,
'breaches' => $breaches,
'warning_messages' => $messages,
'primary_reason' => $primaryReason,
];
}
private function updateBreachCounters(HostingAccount $account, array $breaches): void
{
foreach ([
'cpu',
'memory',
'process',
'io',
'inode',
] as $metric) {
$column = "{$metric}_breach_streak";
$account->{$column} = array_key_exists($metric, $breaches)
? ((int) $account->{$column}) + 1
: 0;
}
}
private function evaluateCpuUsage(HostingAccount $account, float $currentCpu, int $limit): array
{
$metadata = (array) ($account->metadata ?? []);
$cpuHistory = (array) ($metadata['cpu_history'] ?? []);
// Add current reading with timestamp
$now = now()->timestamp;
$cpuHistory[] = ['ts' => $now, 'cpu' => $currentCpu];
// Keep only last 30 minutes of data (sync runs every 5 min = ~6 readings)
$cutoff = $now - 1800; // 30 minutes
$cpuHistory = array_filter($cpuHistory, fn($r) => $r['ts'] > $cutoff);
$cpuHistory = array_values($cpuHistory);
// Calculate averages over time windows
$windows = [
'1m' => 60, // 1 minute
'5m' => 300, // 5 minutes
'15m' => 900, // 15 minutes
];
$averages = [];
foreach ($windows as $name => $seconds) {
$windowCutoff = $now - $seconds;
$windowReadings = array_filter($cpuHistory, fn($r) => $r['ts'] > $windowCutoff);
$count = count($windowReadings);
if ($count > 0) {
$sum = array_sum(array_column($windowReadings, 'cpu'));
$averages[$name] = $sum / $count;
} else {
$averages[$name] = $currentCpu;
}
}
// Save updated history
$metadata['cpu_history'] = $cpuHistory;
$metadata['cpu_averages'] = $averages;
$account->metadata = $metadata;
// Evaluation rules:
// - Warning threshold: 80% of limit, sustained for 5 minutes
// - Breach threshold: 100% of limit, sustained for 10 minutes
// - Extreme breach: 120% of limit, sustained for 5 minutes
$warningThreshold = $limit * 0.8;
$breachThreshold = $limit;
$extremeThreshold = $limit * 1.2;
$isWarning = false;
$isBreach = false;
$message = '';
// Check for extreme breach (immediate action)
if ($averages['5m'] >= $extremeThreshold) {
$isBreach = true;
$message = sprintf(
'CPU usage critically high: %.1f%% average over 5 minutes (limit: %d%%).',
$averages['5m'],
$limit
);
}
// Check for sustained breach (10+ minutes at or above limit)
elseif ($averages['15m'] >= $breachThreshold) {
$isBreach = true;
$message = sprintf(
'CPU usage sustained above limit: %.1f%% average over 15 minutes (limit: %d%%).',
$averages['15m'],
$limit
);
}
// Check for sustained warning (5+ minutes at 80%+ of limit)
elseif ($averages['5m'] >= $warningThreshold) {
$isWarning = true;
$message = sprintf(
'CPU usage elevated: %.1f%% average over 5 minutes (limit: %d%%). Monitor closely.',
$averages['5m'],
$limit
);
}
return [
'is_breach' => $isBreach,
'is_warning' => $isWarning,
'message' => $message,
'averages' => $averages,
];
}
private function usageSnapshot(HostingAccount $account): array
{
return [
'disk_used_bytes' => (int) $account->disk_used_bytes,
'disk_usage_percent' => $account->diskUsagePercent(),
'inode_count' => (int) $account->inode_count,
'inode_usage_percent' => $account->inodeUsagePercent(),
'cpu_usage_percent' => (float) ($account->cpu_usage_percent ?? 0),
'memory_used_mb' => (int) ($account->memory_used_mb ?? 0),
'process_count' => (int) ($account->process_count ?? 0),
'io_usage_mb' => $account->io_usage_mb !== null ? (float) $account->io_usage_mb : null,
];
}
private function clearWarningState(HostingAccount $account): void
{
$account->update([
'is_flagged' => false,
'uploads_restricted' => false,
'cpu_breach_streak' => 0,
'memory_breach_streak' => 0,
'process_breach_streak' => 0,
'io_breach_streak' => 0,
'inode_breach_streak' => 0,
]);
$this->resolveAlerts($account);
}
private function shouldAutomaticallyUnsuspend(HostingAccount $account): bool
{
return $account->status === HostingAccount::STATUS_SUSPENDED
&& $account->resource_status === HostingAccount::RESOURCE_STATUS_SUSPENDED
&& data_get($account->metadata, 'suspension_origin') === 'automatic';
}
private function shouldAutomaticallySuspendForSustainedBreach(HostingAccount $account, array $breaches): bool
{
if (! $account->throttled_at || $account->throttled_at->gt(now()->subHours(self::AUTOMATIC_SUSPENSION_GRACE_HOURS))) {
return false;
}
foreach (array_keys($breaches) as $metric) {
$column = "{$metric}_breach_streak";
if ((int) ($account->{$column} ?? 0) >= self::AUTOMATIC_SUSPENSION_MIN_BREACH_STREAK) {
return true;
}
}
return false;
}
private function recordAlert(HostingAccount $account, string $alertType, string $severity, string $message, array $usage): void
{
$existing = $account->alerts()
->where('alert_type', $alertType)
->where('is_resolved', false)
->latest()
->first();
if ($existing) {
$existing->update([
'severity' => $severity,
'message' => $message,
'resource_usage' => $usage,
]);
return;
}
$account->alerts()->create([
'alert_type' => $alertType,
'severity' => $severity,
'message' => $message,
'resource_usage' => $usage,
]);
}
private function resolveAlerts(HostingAccount $account): void
{
$account->alerts()
->unresolved()
->update([
'is_resolved' => true,
'resolved_at' => now(),
]);
}
private function notifyAccount(HostingAccount $account, string $subject, string $message, bool $respectCooldown): void
{
if (! $account->user) {
return;
}
if (
$respectCooldown
&& (int) $account->warning_count > 1
&& $account->last_warning_at
&& $account->last_warning_at->gt(now()->subHours(6))
) {
return;
}
$account->user->notify(new HostingResourceWarningNotification($account, $subject, $message));
}
private function syncLatestOrderState(HostingAccount $account, string $status, ?string $reason = null): void
{
$order = $account->latestOrder;
if (! $order) {
return;
}
if ($status === CustomerHostingOrder::STATUS_SUSPENDED) {
$order->suspend($reason);
return;
}
$meta = array_merge((array) ($order->meta ?? []), [
'resource_policy_note' => $reason,
]);
$order->update([
'status' => $status,
'suspended_at' => $status === CustomerHostingOrder::STATUS_SUSPENDED ? now() : null,
'meta' => $meta,
]);
}
}
@@ -0,0 +1,376 @@
<?php
namespace App\Services\Hosting;
use App\Models\HostingAccount;
use App\Models\HostingNode;
use App\Models\HostingProduct;
use App\Models\NodeCapacityAlert;
class NodeCapacityService
{
public function __construct(
private HostingNodePoolService $nodePool,
) {}
public const WARNING_THRESHOLD = 80;
public const CRITICAL_THRESHOLD = 95;
public const LOCAL_NODE_LIMIT = 50;
private const BYTES_PER_GB = 1073741824;
public function checkAllNodes(): array
{
$alerts = [];
$nodes = HostingNode::query()
->whereIn('status', [HostingNode::STATUS_ACTIVE, HostingNode::STATUS_FULL])
->where('type', HostingNode::TYPE_SHARED)
->get();
foreach ($nodes as $node) {
$alert = $this->checkNode($node);
if ($alert) {
$alerts[] = $alert;
}
}
return $alerts;
}
public function checkNode(HostingNode $node): ?NodeCapacityAlert
{
$node = $this->refreshNodeResourceTotals($node);
$capacityPercent = $this->nodePressurePercent($node);
$existingAlert = NodeCapacityAlert::where('hosting_node_id', $node->id)
->where('is_resolved', false)
->whereIn('alert_type', [
NodeCapacityAlert::TYPE_CAPACITY_WARNING,
NodeCapacityAlert::TYPE_CAPACITY_CRITICAL,
NodeCapacityAlert::TYPE_NEEDS_EXPANSION,
])
->first();
if ($capacityPercent >= self::CRITICAL_THRESHOLD) {
return $this->createOrUpdateAlert(
$node,
NodeCapacityAlert::TYPE_CAPACITY_CRITICAL,
$capacityPercent,
sprintf(
'Node %s is at %.1f%% pressure (%s). Immediate action required.',
$node->name,
$capacityPercent,
$this->dominantPressureLabel($node)
),
$existingAlert
);
}
if ($capacityPercent >= self::WARNING_THRESHOLD) {
return $this->createOrUpdateAlert(
$node,
NodeCapacityAlert::TYPE_CAPACITY_WARNING,
$capacityPercent,
sprintf(
'Node %s is at %.1f%% pressure (%s). Capacity is nearing exhaustion.',
$node->name,
$capacityPercent,
$this->dominantPressureLabel($node)
),
$existingAlert
);
}
if ($existingAlert) {
$existingAlert->update([
'is_resolved' => true,
'resolved_at' => now(),
'resolution_notes' => 'Auto-resolved: Node pressure dropped below warning threshold.',
]);
}
return null;
}
public function canProvision(HostingNode|int $node, int $requestedDiskGb): bool
{
$node = $node instanceof HostingNode ? $node : HostingNode::query()->findOrFail($node);
$node = $this->refreshNodeResourceTotals($node);
return $node->canProvision($requestedDiskGb);
}
public function checkCapacityForProduct(HostingProduct $product): array
{
$requestedDiskGb = (int) ($product->disk_gb ?? 0);
$segment = $product->preferredNodeSegment();
$nodes = $this->preparedCandidateNodes($segment)
->map(fn (HostingNode $node): array => $this->nodeSnapshot($node, $requestedDiskGb, $segment))
->values();
$selected = $this->findAvailableNodeForProduct($product);
return [
'available' => $selected !== null,
'requested_disk_gb' => $requestedDiskGb,
'segment' => $segment,
'selected_node_id' => $selected?->id,
'selected_node_name' => $selected?->name,
'nodes' => $nodes,
];
}
public function findAvailableNode(int $requestedDiskGb = 0, ?string $segment = null): ?HostingNode
{
$primaries = $this->preparedCandidateNodes($segment)
->filter(fn (HostingNode $node) => $node->isPrimary());
$bestMember = null;
$bestLoad = PHP_FLOAT_MAX;
foreach ($primaries as $primary) {
$member = $this->nodePool->findProvisioningMember($primary, $requestedDiskGb, $segment);
if (! $member) {
continue;
}
if ($this->isLocalNode($member) && $member->accountCapacityPercent() >= self::LOCAL_NODE_LIMIT) {
continue;
}
$load = (float) $member->current_load_percent;
if ($load < $bestLoad) {
$bestLoad = $load;
$bestMember = $member;
}
}
if ($bestMember) {
return $bestMember;
}
foreach ($this->preparedCandidateNodes($segment) as $node) {
if (! $node->canProvision($requestedDiskGb, $segment)) {
continue;
}
if ($this->isLocalNode($node) && $node->accountCapacityPercent() >= self::LOCAL_NODE_LIMIT) {
continue;
}
return $node;
}
return null;
}
public function findAvailableNodeForProduct(HostingProduct $product): ?HostingNode
{
return $this->findAvailableNode(
(int) ($product->disk_gb ?? 0),
$product->preferredNodeSegment()
);
}
public function needsNewNode(): bool
{
return $this->findAvailableNode() === null;
}
public function getCapacitySummary(): array
{
$nodes = HostingNode::query()
->whereIn('status', [HostingNode::STATUS_ACTIVE, HostingNode::STATUS_FULL])
->where('type', HostingNode::TYPE_SHARED)
->get()
->map(fn (HostingNode $node): HostingNode => $this->refreshNodeResourceTotals($node));
$totalDisk = $nodes->sum('disk_gb');
$totalLogicalCapacity = $nodes->sum(fn (HostingNode $node): float => $node->logicalCapacityGb());
$totalAllocated = $nodes->sum('allocated_disk_gb');
$totalUsed = $nodes->sum('used_disk_gb');
$totalAccountCapacity = $nodes->sum('max_accounts');
$totalAccounts = $nodes->sum('current_accounts');
return [
'total_nodes' => $nodes->count(),
'total_capacity' => $totalAccountCapacity,
'total_used' => $totalAccounts,
'available_slots' => max($totalAccountCapacity - $totalAccounts, 0),
'overall_percent' => $totalAccountCapacity > 0 ? round(($totalAccounts / $totalAccountCapacity) * 100, 1) : 0,
'physical_disk_gb' => $totalDisk,
'logical_disk_gb' => round($totalLogicalCapacity, 2),
'allocated_disk_gb' => $totalAllocated,
'used_disk_gb' => $totalUsed,
'logical_disk_percent' => $totalDisk > 0 ? round(($totalUsed / $totalDisk) * 100, 1) : 0,
'physical_disk_percent' => $totalDisk > 0 ? round(($totalUsed / $totalDisk) * 100, 1) : 0,
'needs_expansion' => $this->needsNewNode(),
'unresolved_alerts' => NodeCapacityAlert::unresolved()->count(),
];
}
public function refreshNodeResourceTotals(HostingNode $node): HostingNode
{
$aggregate = HostingAccount::query()
->where('hosting_node_id', $node->id)
->where('type', HostingAccount::TYPE_SHARED)
->selectRaw('COUNT(*) as account_count')
->selectRaw('COALESCE(SUM(allocated_disk_gb), 0) as allocated_disk_gb')
->selectRaw('COALESCE(SUM(disk_used_bytes), 0) as used_disk_bytes')
->first();
$currentAccounts = (int) ($aggregate->account_count ?? 0);
$allocatedDiskGb = (int) ($aggregate->allocated_disk_gb ?? 0);
$usedDiskGb = (int) ceil(((int) ($aggregate->used_disk_bytes ?? 0)) / self::BYTES_PER_GB);
$logicalCapacity = $node->disk_gb && $node->disk_gb > 0
? $node->disk_gb * max((float) ($node->oversell_ratio ?: 1), 1)
: 0;
$physicalPercent = $node->disk_gb && $node->disk_gb > 0 ? ($usedDiskGb / $node->disk_gb) * 100 : 0;
$accountPercent = $node->max_accounts && $node->max_accounts > 0 ? ($currentAccounts / $node->max_accounts) * 100 : 0;
$currentLoadPercent = round(max($physicalPercent, $accountPercent), 2);
$updates = [
'current_accounts' => $currentAccounts,
'allocated_disk_gb' => $allocatedDiskGb,
'used_disk_gb' => $usedDiskGb,
'current_load_percent' => $currentLoadPercent,
];
if (in_array($node->status, [HostingNode::STATUS_ACTIVE, HostingNode::STATUS_FULL], true)) {
$updates['status'] = $this->shouldMarkFull($node, $currentAccounts, $usedDiskGb)
? HostingNode::STATUS_FULL
: HostingNode::STATUS_ACTIVE;
}
$node->forceFill($updates);
if ($node->exists && $node->isDirty()) {
$node->save();
}
return $node->refresh();
}
private function candidateNodes(?string $segment)
{
return HostingNode::query()
->whereIn('status', [HostingNode::STATUS_ACTIVE, HostingNode::STATUS_FULL])
->where('type', HostingNode::TYPE_SHARED)
->when($segment, function ($query) use ($segment) {
$query->whereIn('segment', [$segment, HostingNode::SEGMENT_GENERAL]);
})
->get();
}
private function preparedCandidateNodes(?string $segment)
{
return $this->candidateNodes($segment)
->map(fn (HostingNode $node): HostingNode => $this->refreshNodeResourceTotals($node))
->sortBy(fn (HostingNode $node) => sprintf(
'%012.2f-%012d-%012d',
(float) $node->current_load_percent,
$node->allocated_disk_gb,
$node->current_accounts
))
->values();
}
private function shouldMarkFull(HostingNode $node, int $currentAccounts, int $usedDiskGb): bool
{
if ($node->max_accounts && $currentAccounts >= $node->max_accounts) {
return true;
}
if ($node->disk_gb && $node->disk_gb > 0) {
return $usedDiskGb >= $node->disk_gb;
}
return false;
}
private function nodePressurePercent(HostingNode $node): float
{
return round(max(
$node->physicalDiskPercent(),
$node->accountCapacityPercent()
), 2);
}
private function dominantPressureLabel(HostingNode $node): string
{
$pressures = [
'disk usage' => $node->physicalDiskPercent(),
'account slots' => $node->accountCapacityPercent(),
];
arsort($pressures);
$label = array_key_first($pressures);
return sprintf('%s %.1f%%', $label, $pressures[$label] ?? 0);
}
private function nodeSnapshot(HostingNode $node, int $requestedDiskGb, ?string $segment = null): array
{
return [
'id' => $node->id,
'name' => $node->name,
'segment' => $node->segment,
'can_provision' => $node->canProvision($requestedDiskGb, $segment),
'requested_disk_gb' => $requestedDiskGb,
'physical_disk_gb' => $node->disk_gb,
'logical_disk_gb' => $node->logicalCapacityGb(),
'allocated_disk_gb' => $node->allocated_disk_gb,
'used_disk_gb' => $node->used_disk_gb,
'available_logical_disk_gb' => $node->availableLogicalDiskGb(),
'oversell_ratio' => (float) $node->oversell_ratio,
'current_accounts' => $node->current_accounts,
'max_accounts' => $node->max_accounts,
'logical_capacity_percent' => $node->logicalCapacityPercent(),
'physical_disk_percent' => $node->physicalDiskPercent(),
'account_capacity_percent' => $node->accountCapacityPercent(),
'current_load_percent' => (float) $node->current_load_percent,
];
}
private function isLocalNode(HostingNode $node): bool
{
return $node->provider === HostingNode::PROVIDER_LOCAL
|| $node->ip_address === '127.0.0.1'
|| $node->ip_address === 'localhost';
}
private function createOrUpdateAlert(
HostingNode $node,
string $type,
float $capacityPercent,
string $message,
?NodeCapacityAlert $existingAlert
): NodeCapacityAlert {
$data = [
'hosting_node_id' => $node->id,
'alert_type' => $type,
'current_accounts' => $node->current_accounts,
'max_accounts' => $node->max_accounts ?? 0,
'capacity_percent' => $capacityPercent,
'resource_usage' => [
'logical_capacity_percent' => $node->logicalCapacityPercent(),
'physical_disk_percent' => $node->physicalDiskPercent(),
'account_capacity_percent' => $node->accountCapacityPercent(),
'allocated_disk_gb' => $node->allocated_disk_gb,
'used_disk_gb' => $node->used_disk_gb,
'logical_disk_gb' => $node->logicalCapacityGb(),
],
'message' => $message,
];
if ($existingAlert) {
$existingAlert->update($data);
return $existingAlert;
}
return NodeCapacityAlert::create($data);
}
}
@@ -0,0 +1,497 @@
<?php
namespace App\Services\Hosting;
use App\Models\HostingNode;
use App\Models\NodeCapacityAlert;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use phpseclib3\Crypt\PublicKeyLoader;
use phpseclib3\Net\SSH2;
class NodeMonitoringService
{
private ?SSH2 $ssh = null;
private bool $isLocalNode = false;
public const HEALTH_OK = 'healthy';
public const HEALTH_WARNING = 'warning';
public const HEALTH_CRITICAL = 'critical';
public const HEALTH_UNREACHABLE = 'unreachable';
public const CPU_WARNING_THRESHOLD = 75;
public const CPU_CRITICAL_THRESHOLD = 90;
public const RAM_WARNING_THRESHOLD = 80;
public const RAM_CRITICAL_THRESHOLD = 95;
public const DISK_WARNING_THRESHOLD = 80;
public const DISK_CRITICAL_THRESHOLD = 90;
public const LOAD_WARNING_MULTIPLIER = 1.5;
public const LOAD_CRITICAL_MULTIPLIER = 2.0;
public function checkAllNodes(): array
{
$results = [];
$nodes = HostingNode::whereIn('status', [
HostingNode::STATUS_ACTIVE,
HostingNode::STATUS_FULL,
HostingNode::STATUS_MAINTENANCE,
])->get();
foreach ($nodes as $node) {
$results[$node->id] = $this->checkNode($node);
}
return $results;
}
public function checkNode(HostingNode $node): array
{
$startTime = microtime(true);
try {
$ssh = $this->connect($node);
$metrics = $this->collectMetrics($ssh, $node);
$metrics['response_time_ms'] = round((microtime(true) - $startTime) * 1000);
$metrics['checked_at'] = now()->toIso8601String();
$healthStatus = $this->evaluateHealth($metrics, $node);
$metrics['health'] = $healthStatus;
$node->update([
'health_status' => $metrics,
'last_health_check_at' => now(),
]);
$this->processAlerts($node, $metrics, $healthStatus);
Log::info("Node health check completed", [
'node_id' => $node->id,
'node_name' => $node->name,
'health' => $healthStatus,
]);
return $metrics;
} catch (\Exception $e) {
$metrics = [
'health' => self::HEALTH_UNREACHABLE,
'error' => $e->getMessage(),
'checked_at' => now()->toIso8601String(),
'response_time_ms' => round((microtime(true) - $startTime) * 1000),
];
$node->update([
'health_status' => $metrics,
'last_health_check_at' => now(),
]);
$this->createUnreachableAlert($node, $e->getMessage());
Log::error("Node health check failed", [
'node_id' => $node->id,
'node_name' => $node->name,
'error' => $e->getMessage(),
]);
return $metrics;
} finally {
$this->disconnect();
}
}
private function isLocal(HostingNode $node): bool
{
return ($node->provider === 'local' || $node->ip_address === '127.0.0.1' || $node->ip_address === 'localhost')
&& blank($node->ssh_private_key);
}
private function connect(HostingNode $node): ?SSH2
{
if ($this->isLocal($node)) {
$this->isLocalNode = true;
return null;
}
$this->isLocalNode = false;
// For local nodes with SSH keys, connect to 127.0.0.1
$host = $this->isLocalIp($node) ? '127.0.0.1' : $node->ip_address;
$this->ssh = new SSH2($host, (int) ($node->ssh_port ?: 22));
$this->ssh->setTimeout(30);
// Load the private key properly for phpseclib3
$credential = $node->ssh_private_key;
if (str_contains($credential, '-----BEGIN') || str_contains($credential, 'PRIVATE KEY')) {
try {
$credential = PublicKeyLoader::load($credential);
} catch (\Throwable $e) {
throw new \RuntimeException("Invalid SSH key for node {$node->hostname}: " . $e->getMessage());
}
}
if (!$this->ssh->login('root', $credential)) {
throw new \RuntimeException("SSH authentication failed for node: {$node->hostname}");
}
return $this->ssh;
}
private function isLocalIp(HostingNode $node): bool
{
return $node->provider === 'local' || $node->ip_address === '127.0.0.1' || $node->ip_address === 'localhost';
}
private function disconnect(): void
{
if ($this->ssh) {
$this->ssh->disconnect();
$this->ssh = null;
}
$this->isLocalNode = false;
}
private function exec(?SSH2 $ssh, string $command): string
{
if ($this->isLocalNode) {
$result = Process::run($command);
return trim($result->output() . $result->errorOutput());
}
return trim($ssh->exec($command));
}
private function collectMetrics(?SSH2 $ssh, HostingNode $node): array
{
return [
'cpu' => $this->getCpuMetrics($ssh),
'memory' => $this->getMemoryMetrics($ssh),
'disk' => $this->getDiskMetrics($ssh),
'load' => $this->getLoadMetrics($ssh, $node),
'uptime' => $this->getUptime($ssh),
'processes' => $this->getProcessCount($ssh),
'services' => $this->checkServices($ssh),
'network' => $this->getNetworkMetrics($ssh),
];
}
private function getCpuMetrics(?SSH2 $ssh): array
{
$output = $this->exec($ssh, "top -bn1 | grep 'Cpu(s)' | awk '{print \$2 + \$4}'");
$usagePercent = is_numeric($output) ? (float) $output : 0;
$coresOutput = $this->exec($ssh, "nproc");
$cores = is_numeric($coresOutput) ? (int) $coresOutput : 1;
return [
'usage_percent' => round($usagePercent, 1),
'cores' => $cores,
];
}
private function getMemoryMetrics(?SSH2 $ssh): array
{
$output = $this->exec($ssh, "free -b | grep Mem");
$parts = preg_split('/\s+/', trim($output));
$total = isset($parts[1]) && is_numeric($parts[1]) ? (int) $parts[1] : 0;
$used = isset($parts[2]) && is_numeric($parts[2]) ? (int) $parts[2] : 0;
$free = isset($parts[3]) && is_numeric($parts[3]) ? (int) $parts[3] : 0;
$available = isset($parts[6]) && is_numeric($parts[6]) ? (int) $parts[6] : $free;
$usagePercent = $total > 0 ? (($total - $available) / $total) * 100 : 0;
return [
'total_bytes' => $total,
'used_bytes' => $used,
'available_bytes' => $available,
'usage_percent' => round($usagePercent, 1),
];
}
private function getDiskMetrics(?SSH2 $ssh): array
{
$output = $this->exec($ssh, "df -B1 / | tail -1");
$parts = preg_split('/\s+/', trim($output));
$total = isset($parts[1]) && is_numeric($parts[1]) ? (int) $parts[1] : 0;
$used = isset($parts[2]) && is_numeric($parts[2]) ? (int) $parts[2] : 0;
$available = isset($parts[3]) && is_numeric($parts[3]) ? (int) $parts[3] : 0;
$usagePercent = isset($parts[4]) ? (float) rtrim($parts[4], '%') : 0;
$homeOutput = $this->exec($ssh, "df -B1 /home 2>/dev/null | tail -1");
$homeParts = preg_split('/\s+/', trim($homeOutput));
$homeUsagePercent = isset($homeParts[4]) ? (float) rtrim($homeParts[4], '%') : $usagePercent;
return [
'root' => [
'total_bytes' => $total,
'used_bytes' => $used,
'available_bytes' => $available,
'usage_percent' => $usagePercent,
],
'home_usage_percent' => $homeUsagePercent,
];
}
private function getLoadMetrics(?SSH2 $ssh, HostingNode $node): array
{
$output = $this->exec($ssh, "cat /proc/loadavg");
$parts = explode(' ', $output);
$load1 = isset($parts[0]) && is_numeric($parts[0]) ? (float) $parts[0] : 0;
$load5 = isset($parts[1]) && is_numeric($parts[1]) ? (float) $parts[1] : 0;
$load15 = isset($parts[2]) && is_numeric($parts[2]) ? (float) $parts[2] : 0;
$cores = $node->cpu_cores ?: 1;
$loadPerCore = $load1 / $cores;
return [
'load_1' => $load1,
'load_5' => $load5,
'load_15' => $load15,
'load_per_core' => round($loadPerCore, 2),
'cores' => $cores,
];
}
private function getUptime(?SSH2 $ssh): array
{
$output = $this->exec($ssh, "cat /proc/uptime | awk '{print \$1}'");
$uptimeSeconds = is_numeric($output) ? (int) $output : 0;
$days = floor($uptimeSeconds / 86400);
$hours = floor(($uptimeSeconds % 86400) / 3600);
$minutes = floor(($uptimeSeconds % 3600) / 60);
return [
'seconds' => $uptimeSeconds,
'formatted' => "{$days}d {$hours}h {$minutes}m",
];
}
private function getProcessCount(?SSH2 $ssh): array
{
$total = (int) $this->exec($ssh, "ps aux | wc -l") - 1;
$running = (int) $this->exec($ssh, "ps aux | grep -c ' R'");
return [
'total' => $total,
'running' => $running,
];
}
private function checkServices(?SSH2 $ssh): array
{
// Check for PHP-FPM with dynamic version detection
$phpVersion = $this->exec($ssh, "php -v | head -n1 | cut -d' ' -f2 | cut -d'.' -f1,2");
$phpVersion = preg_match('/^\d+\.\d+$/', $phpVersion) ? $phpVersion : '8.2';
$phpFpmService = "php{$phpVersion}-fpm";
$services = ['nginx', $phpFpmService, 'mysql', 'ssh'];
$status = [];
foreach ($services as $service) {
$output = $this->exec($ssh, "systemctl is-active {$service} 2>/dev/null || echo 'inactive'");
$status[$service] = $output === 'active';
}
return $status;
}
private function getNetworkMetrics(?SSH2 $ssh): array
{
$output = $this->exec($ssh, "cat /proc/net/dev | grep -E 'eth0|ens|enp' | head -1");
$parts = preg_split('/\s+/', trim($output));
$rxBytes = isset($parts[1]) && is_numeric($parts[1]) ? (int) $parts[1] : 0;
$txBytes = isset($parts[9]) && is_numeric($parts[9]) ? (int) $parts[9] : 0;
$connections = (int) $this->exec($ssh, "ss -tun | wc -l") - 1;
return [
'rx_bytes_total' => $rxBytes,
'tx_bytes_total' => $txBytes,
'active_connections' => max(0, $connections),
];
}
private function evaluateHealth(array $metrics, HostingNode $node): string
{
$cpuUsage = $metrics['cpu']['usage_percent'] ?? 0;
$ramUsage = $metrics['memory']['usage_percent'] ?? 0;
$diskUsage = $metrics['disk']['root']['usage_percent'] ?? 0;
$loadPerCore = $metrics['load']['load_per_core'] ?? 0;
$services = $metrics['services'] ?? [];
$criticalServices = ['nginx', 'php8.2-fpm'];
foreach ($criticalServices as $service) {
if (isset($services[$service]) && !$services[$service]) {
return self::HEALTH_CRITICAL;
}
}
if (
$cpuUsage >= self::CPU_CRITICAL_THRESHOLD ||
$ramUsage >= self::RAM_CRITICAL_THRESHOLD ||
$diskUsage >= self::DISK_CRITICAL_THRESHOLD ||
$loadPerCore >= self::LOAD_CRITICAL_MULTIPLIER
) {
return self::HEALTH_CRITICAL;
}
if (
$cpuUsage >= self::CPU_WARNING_THRESHOLD ||
$ramUsage >= self::RAM_WARNING_THRESHOLD ||
$diskUsage >= self::DISK_WARNING_THRESHOLD ||
$loadPerCore >= self::LOAD_WARNING_MULTIPLIER
) {
return self::HEALTH_WARNING;
}
return self::HEALTH_OK;
}
private function processAlerts(HostingNode $node, array $metrics, string $healthStatus): void
{
$existingAlert = NodeCapacityAlert::where('hosting_node_id', $node->id)
->where('is_resolved', false)
->whereIn('alert_type', ['resource_high', 'service_down'])
->first();
if ($healthStatus === self::HEALTH_OK) {
if ($existingAlert) {
$existingAlert->update([
'is_resolved' => true,
'resolved_at' => now(),
'resolution_notes' => 'Auto-resolved: Node health returned to normal.',
]);
}
return;
}
$issues = $this->identifyIssues($metrics);
if (empty($issues)) {
return;
}
$message = "Node {$node->name} health issues: " . implode(', ', $issues);
if ($existingAlert) {
$existingAlert->update([
'alert_type' => $healthStatus === self::HEALTH_CRITICAL ? 'resource_high' : 'capacity_warning',
'resource_usage' => $metrics,
'message' => $message,
]);
} else {
NodeCapacityAlert::create([
'hosting_node_id' => $node->id,
'alert_type' => $healthStatus === self::HEALTH_CRITICAL ? 'resource_high' : 'capacity_warning',
'current_accounts' => $node->current_accounts,
'max_accounts' => $node->max_accounts ?? 0,
'capacity_percent' => $node->max_accounts > 0
? ($node->current_accounts / $node->max_accounts) * 100
: 0,
'resource_usage' => $metrics,
'message' => $message,
]);
}
}
private function identifyIssues(array $metrics): array
{
$issues = [];
$cpuUsage = $metrics['cpu']['usage_percent'] ?? 0;
if ($cpuUsage >= self::CPU_WARNING_THRESHOLD) {
$issues[] = "CPU at {$cpuUsage}%";
}
$ramUsage = $metrics['memory']['usage_percent'] ?? 0;
if ($ramUsage >= self::RAM_WARNING_THRESHOLD) {
$issues[] = "RAM at {$ramUsage}%";
}
$diskUsage = $metrics['disk']['root']['usage_percent'] ?? 0;
if ($diskUsage >= self::DISK_WARNING_THRESHOLD) {
$issues[] = "Disk at {$diskUsage}%";
}
$loadPerCore = $metrics['load']['load_per_core'] ?? 0;
if ($loadPerCore >= self::LOAD_WARNING_MULTIPLIER) {
$issues[] = "Load per core: {$loadPerCore}";
}
$services = $metrics['services'] ?? [];
foreach ($services as $service => $running) {
if (!$running) {
$issues[] = "{$service} is down";
}
}
return $issues;
}
private function createUnreachableAlert(HostingNode $node, string $error): void
{
$existingAlert = NodeCapacityAlert::where('hosting_node_id', $node->id)
->where('is_resolved', false)
->where('alert_type', 'resource_high')
->first();
$message = "Node {$node->name} is unreachable: {$error}";
if ($existingAlert) {
$existingAlert->update([
'message' => $message,
'resource_usage' => ['error' => $error],
]);
} else {
NodeCapacityAlert::create([
'hosting_node_id' => $node->id,
'alert_type' => 'resource_high',
'current_accounts' => $node->current_accounts,
'max_accounts' => $node->max_accounts ?? 0,
'capacity_percent' => $node->max_accounts > 0
? ($node->current_accounts / $node->max_accounts) * 100
: 0,
'resource_usage' => ['error' => $error],
'message' => $message,
]);
}
}
public function getNodeSummary(HostingNode $node): array
{
$healthStatus = $node->health_status ?? [];
return [
'id' => $node->id,
'name' => $node->name,
'hostname' => $node->hostname,
'ip_address' => $node->ip_address,
'status' => $node->status,
'health' => $healthStatus['health'] ?? 'unknown',
'last_check' => $node->last_health_check_at?->diffForHumans() ?? 'Never',
'cpu_usage' => $healthStatus['cpu']['usage_percent'] ?? null,
'ram_usage' => $healthStatus['memory']['usage_percent'] ?? null,
'disk_usage' => $healthStatus['disk']['root']['usage_percent'] ?? null,
'load' => $healthStatus['load']['load_1'] ?? null,
'uptime' => $healthStatus['uptime']['formatted'] ?? null,
'logical_capacity_percent' => $node->logicalCapacityPercent(),
'current_load_percent' => (float) $node->current_load_percent,
'allocated_disk_gb' => $node->allocated_disk_gb,
'logical_disk_gb' => $node->logicalCapacityGb(),
'accounts' => [
'current' => $node->current_accounts,
'max' => $node->max_accounts,
'percent' => $node->max_accounts > 0
? round(($node->current_accounts / $node->max_accounts) * 100, 1)
: 0,
],
];
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Services\Hosting;
use App\Models\CustomerHostingOrder;
use App\Models\HostingAccount;
use App\Services\Hosting\Contracts\PanelRuntimeInterface;
use App\Services\Hosting\PanelRuntimes\SharedHostingPanelRuntime;
use InvalidArgumentException;
use RuntimeException;
class PanelRuntimeResolver
{
public function __construct(
private SharedHostingPanelRuntime $sharedHostingRuntime,
) {}
public function forSubject(mixed $subject): PanelRuntimeInterface
{
if ($subject instanceof HostingAccount) {
return $this->sharedHostingRuntime;
}
if ($subject instanceof CustomerHostingOrder) {
throw new RuntimeException('Server-backed hosting panel runtime is not enabled yet for this order.');
}
throw new InvalidArgumentException('Unsupported panel runtime subject.');
}
}
@@ -0,0 +1,140 @@
<?php
namespace App\Services\Hosting\PanelRuntimes;
use App\Models\CustomerHostingOrder;
use App\Models\ServerAgent;
use App\Models\ServerTask;
use App\Services\Hosting\ServerAgentBootstrapService;
use Illuminate\Support\Str;
use RuntimeException;
class ServerAgentPanelRuntime
{
public function __construct(
private ServerAgentBootstrapService $bootstrap,
) {}
public function label(): string
{
return 'Ladill Hosting Panel';
}
public function supports(CustomerHostingOrder $order): bool
{
return (bool) data_get($order->meta, 'server_panel_runtime.managed_stack_candidate', false);
}
public function ensureAgent(CustomerHostingOrder $order): ServerAgent
{
if (! $this->supports($order)) {
throw new RuntimeException('This server image is not enabled for agent-backed Ladill hosting tasks.');
}
return $this->bootstrap->ensureBootstrap($order)['agent'];
}
public function queueDomainTask(CustomerHostingOrder $order, string $domain, ?string $documentRoot = null, ?string $phpVersion = null): ServerTask
{
return $this->queueTask($order, ServerTask::TYPE_DOMAIN_ADD, array_filter([
'domain' => strtolower($domain),
'document_root' => $documentRoot,
'php_version' => $phpVersion,
], static fn ($value): bool => $value !== null && $value !== ''));
}
public function queueSslTask(CustomerHostingOrder $order, string $domain): ServerTask
{
return $this->queueTask($order, ServerTask::TYPE_SSL_REQUEST, [
'domain' => strtolower($domain),
]);
}
public function queueDatabaseTask(CustomerHostingOrder $order, string $name): ServerTask
{
return $this->queueTask($order, ServerTask::TYPE_DATABASE_CREATE, [
'name' => Str::lower($name),
]);
}
public function queueSiteDeleteTask(CustomerHostingOrder $order, string $domain): ServerTask
{
return $this->queueTask($order, ServerTask::TYPE_SITE_DELETE, [
'domain' => strtolower($domain),
]);
}
public function queuePhpTask(CustomerHostingOrder $order, string $domain, string $phpVersion): ServerTask
{
return $this->queueTask($order, ServerTask::TYPE_PHP_CONFIGURE, [
'domain' => strtolower($domain),
'php_version' => $phpVersion,
]);
}
public function queueCronTask(
CustomerHostingOrder $order,
string $operation,
?string $name = null,
?string $expression = null,
?string $command = null,
?string $user = null,
): ServerTask {
$taskType = match ($operation) {
'list' => ServerTask::TYPE_CRON_LIST,
'upsert' => ServerTask::TYPE_CRON_UPSERT,
'remove' => ServerTask::TYPE_CRON_REMOVE,
default => throw new RuntimeException('Unsupported cron operation.'),
};
return $this->queueTask($order, $taskType, array_filter([
'name' => $name,
'expression' => $expression,
'command' => $command,
'user' => $user,
], static fn ($value): bool => $value !== null && $value !== ''));
}
public function queueLogTask(CustomerHostingOrder $order, string $target, ?string $domain = null, int $lines = 120): ServerTask
{
return $this->queueTask($order, ServerTask::TYPE_LOG_READ, [
'target' => $target,
'domain' => $domain ? strtolower($domain) : null,
'lines' => max(20, min(500, $lines)),
]);
}
public function queueFileTask(CustomerHostingOrder $order, string $operation, string $path, ?string $content = null): ServerTask
{
$taskType = match ($operation) {
'list' => ServerTask::TYPE_FILE_LIST,
'read' => ServerTask::TYPE_FILE_READ,
'write' => ServerTask::TYPE_FILE_WRITE,
default => throw new RuntimeException('Unsupported file operation.'),
};
return $this->queueTask($order, $taskType, array_filter([
'path' => $path,
'content' => $content,
], static fn ($value): bool => $value !== null));
}
private function queueTask(CustomerHostingOrder $order, string $type, array $payload): ServerTask
{
$agent = $this->ensureAgent($order);
return ServerTask::create([
'customer_hosting_order_id' => $order->id,
'server_agent_id' => $agent->id,
'type' => $type,
'status' => ServerTask::STATUS_QUEUED,
'payload' => $payload,
'progress' => 0,
'max_attempts' => 3,
'retry_backoff_seconds' => 60,
'stale_after_seconds' => 120,
'queued_at' => now(),
'available_at' => now(),
]);
}
}
@@ -0,0 +1,149 @@
<?php
namespace App\Services\Hosting\PanelRuntimes;
use App\Models\HostedDatabase;
use App\Models\HostedSite;
use App\Models\HostingAccount;
use App\Services\Hosting\Contracts\PanelRuntimeInterface;
use App\Services\Hosting\Providers\SharedNodeProvider;
class SharedHostingPanelRuntime implements PanelRuntimeInterface
{
private const CAPABILITIES = [
'files',
'databases',
'domains',
'terminal',
'php',
'ssl',
'cron',
'logs',
'apps',
'settings',
];
public function __construct(
private SharedNodeProvider $provider,
) {}
public function key(): string
{
return 'shared_hosting';
}
public function label(): string
{
return 'Ladill Hosting Panel';
}
public function capabilities(): array
{
return self::CAPABILITIES;
}
public function supports(string $capability): bool
{
return in_array($capability, self::CAPABILITIES, true);
}
public function executeCommand(HostingAccount $account, string $command, int $timeout = 600): array
{
return $this->provider->executeCommand($account, $command, $timeout);
}
public function executeTerminalCommand(HostingAccount $account, string $command, string $workingDirectory, int $timeout = 300): array
{
return $this->provider->executeTerminalCommand($account, $command, $workingDirectory, $timeout);
}
public function executeCommandAsRoot(HostingAccount $account, string $command): array
{
return $this->provider->executeCommandAsRoot($account, $command);
}
public function getUsageStats(HostingAccount $account): array
{
return $this->provider->getUsageStats($account);
}
public function changePassword(HostingAccount $account, string $newPassword): bool
{
return $this->provider->changePassword($account, $newPassword);
}
public function installTeamMemberSshKey(HostingAccount $account, string $marker, string $publicKey): void
{
$this->provider->installTeamMemberSshKey($account, $marker, $publicKey);
}
public function removeTeamMemberSshKey(HostingAccount $account, string $marker): void
{
$this->provider->removeTeamMemberSshKey($account, $marker);
}
public function createDatabase(HostedDatabase $database, string $password): array
{
return $this->provider->createDatabase($database, $password);
}
public function deleteDatabase(HostedDatabase $database): bool
{
return $this->provider->deleteDatabase($database);
}
public function changeDatabasePassword(HostedDatabase $database, string $newPassword): bool
{
return $this->provider->changeDatabasePassword($database, $newPassword);
}
public function addSite(HostedSite $site): array
{
return $this->provider->addSite($site);
}
public function removeSite(HostedSite $site): bool
{
return $this->provider->removeSite($site);
}
public function requestLetsEncryptCertificate(HostedSite $site): bool
{
return $this->provider->requestLetsEncryptCertificate($site);
}
public function ensurePhpFpmPool(HostingAccount $account): void
{
$this->provider->ensurePhpFpmPool($account);
}
public function setPhpVersion(HostedSite $site, string $version): bool
{
return $this->provider->setPhpVersion($site, $version);
}
public function changeAccountPhpVersion(HostingAccount $account, string $version): bool
{
return $this->provider->changeAccountPhpVersion($account, $version);
}
public function prepareDirectoryForUser(HostingAccount $account, string $path): void
{
$this->provider->prepareDirectoryForUser($account, $path);
}
public function setWebServerGroupOwnership(HostingAccount $account, string $path): void
{
$this->provider->setWebServerGroupOwnership($account, $path);
}
public function removeAppService(HostedSite $site): bool
{
return $this->provider->removeAppService($site);
}
public function restoreDefaultSiteConfig(HostingAccount $account, HostedSite $site, string $docRoot): void
{
$this->provider->restoreDefaultSiteConfig($account, $site, $docRoot);
}
}
@@ -0,0 +1,553 @@
<?php
namespace App\Services\Hosting\Providers;
use App\Exceptions\ContaboApiException;
use App\Services\Hosting\Contracts\ComputeProviderInterface;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class ContaboProvider implements ComputeProviderInterface
{
private string $clientId;
private string $clientSecret;
private string $apiUser;
private string $apiPassword;
private ?string $productCatalogEndpoint;
private string $baseUrl = 'https://api.contabo.com/v1';
private string $authUrl = 'https://auth.contabo.com/auth/realms/contabo/protocol/openid-connect/token';
public function __construct()
{
$this->clientId = config('hosting.contabo.client_id');
$this->clientSecret = config('hosting.contabo.client_secret');
$this->apiUser = config('hosting.contabo.api_user');
$this->apiPassword = config('hosting.contabo.api_password');
$this->productCatalogEndpoint = config('hosting.contabo.product_catalog_endpoint');
}
private function getAccessToken(): string
{
return Cache::remember('contabo_access_token', 290, function () {
$response = Http::asForm()->post($this->authUrl, [
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'username' => $this->apiUser,
'password' => $this->apiPassword,
'grant_type' => 'password',
]);
if (!$response->successful()) {
Log::error('Contabo auth failed', ['response' => $response->body()]);
throw new \RuntimeException('Failed to authenticate with Contabo API');
}
return $response->json('access_token');
});
}
private function client(): PendingRequest
{
return Http::withToken($this->getAccessToken())
->baseUrl($this->baseUrl)
->withHeaders([
'x-request-id' => Str::uuid()->toString(),
])
->timeout(60);
}
public function createInstance(array $config): array
{
$payload = [
'productId' => $config['product_id'], // e.g., V45
'region' => $config['region'] ?? 'EU',
'displayName' => $config['display_name'] ?? $config['name'] ?? 'ladill-vps-' . uniqid(),
'period' => (int) ($config['period'] ?? 1),
];
if (!empty($config['image_id'])) {
$payload['imageId'] = $config['image_id'];
}
if (!empty($config['ssh_keys'])) {
$payload['sshKeys'] = $config['ssh_keys'];
}
if (!empty($config['root_password_secret_id'])) {
$payload['rootPassword'] = (int) $config['root_password_secret_id'];
} elseif (!empty($config['root_password'])) {
$payload['rootPassword'] = $this->createPasswordSecret(
(string) ($config['display_name'] ?? $config['name'] ?? 'server-password'),
(string) $config['root_password']
);
}
if (!empty($config['user_data'])) {
$payload['userData'] = base64_encode($config['user_data']);
}
if (!empty($config['license'])) {
$payload['license'] = $config['license'];
}
if (!empty($config['default_user'])) {
$payload['defaultUser'] = $config['default_user'];
}
if (!empty($config['application_id'])) {
$payload['applicationId'] = $config['application_id'];
}
if (!empty($config['add_ons'])) {
$payload['addOns'] = $config['add_ons'];
}
$response = $this->client()->post('/compute/instances', $payload);
if (! $response->successful()) {
Log::error('Contabo create instance failed', [
'payload' => $payload,
'status' => $response->status(),
'response' => $response->body(),
]);
throw ContaboApiException::fromResponse($response->status(), (string) $response->body());
}
$data = $response->json('data.0');
return [
'instance_id' => $data['instanceId'],
'name' => $data['displayName'],
'ip_address' => $data['ipConfig']['v4']['ip'] ?? null,
'ipv6_address' => $data['ipConfig']['v6']['ip'] ?? null,
'status' => $this->mapStatus($data['status']),
'region' => $data['region'],
'datacenter' => $data['dataCenter'],
'image' => $data['imageId'],
'product_id' => $data['productId'],
'cpu_cores' => $data['cpuCores'] ?? null,
'ram_mb' => $data['ramMb'] ?? null,
'disk_mb' => $data['diskMb'] ?? null,
];
}
public function getInstance(string $instanceId): ?array
{
$response = $this->client()->get("/compute/instances/{$instanceId}");
if (!$response->successful()) {
if ($response->status() === 404) {
return null;
}
throw new \RuntimeException('Failed to get Contabo instance: ' . $response->body());
}
$data = $response->json('data.0');
return [
'instance_id' => $data['instanceId'],
'name' => $data['displayName'],
'ip_address' => $data['ipConfig']['v4']['ip'] ?? null,
'ipv6_address' => $data['ipConfig']['v6']['ip'] ?? null,
'status' => $this->mapStatus($data['status']),
'power_status' => $data['status'] === 'running' ? 'on' : 'off',
'region' => $data['region'],
'datacenter' => $data['dataCenter'],
'image' => $data['imageId'],
'product_id' => $data['productId'],
'cpu_cores' => $data['cpuCores'] ?? null,
'ram_mb' => $data['ramMb'] ?? null,
'disk_mb' => $data['diskMb'] ?? null,
'created_at' => $data['createdDate'] ?? null,
];
}
public function listInstances(array $filters = []): array
{
$query = array_filter([
'page' => $filters['page'] ?? 1,
'size' => $filters['per_page'] ?? 100,
'name' => $filters['name'] ?? null,
'region' => $filters['region'] ?? null,
'status' => $filters['status'] ?? null,
]);
$response = $this->client()->get('/compute/instances', $query);
if (!$response->successful()) {
throw new \RuntimeException('Failed to list Contabo instances: ' . $response->body());
}
$instances = [];
foreach ($response->json('data', []) as $data) {
$instances[] = [
'instance_id' => $data['instanceId'],
'name' => $data['displayName'],
'ip_address' => $data['ipConfig']['v4']['ip'] ?? null,
'status' => $this->mapStatus($data['status']),
'region' => $data['region'],
];
}
return [
'instances' => $instances,
'total' => $response->json('_pagination.totalElements', count($instances)),
];
}
public function startInstance(string $instanceId): bool
{
$response = $this->client()->post("/compute/instances/{$instanceId}/actions/start");
return $response->successful();
}
public function stopInstance(string $instanceId): bool
{
$response = $this->client()->post("/compute/instances/{$instanceId}/actions/stop");
return $response->successful();
}
public function rebootInstance(string $instanceId): bool
{
$response = $this->client()->post("/compute/instances/{$instanceId}/actions/restart");
return $response->successful();
}
public function deleteInstance(string $instanceId): bool
{
$response = $this->client()->delete("/compute/instances/{$instanceId}");
return $response->successful();
}
public function reinstallInstance(string $instanceId, string $image): bool
{
$response = $this->client()->put("/compute/instances/{$instanceId}", [
'imageId' => $image,
]);
return $response->successful();
}
public function createSnapshot(string $instanceId, string $name): array
{
$response = $this->client()->post("/compute/instances/{$instanceId}/snapshots", [
'name' => $name,
'description' => "Snapshot created by Ladill on " . now()->toDateTimeString(),
]);
if (!$response->successful()) {
throw new \RuntimeException('Failed to create snapshot: ' . $response->body());
}
$data = $response->json('data.0');
return [
'snapshot_id' => $data['snapshotId'],
'name' => $data['name'],
'status' => $data['status'] ?? 'creating',
];
}
public function listSnapshots(string $instanceId): array
{
$response = $this->client()->get("/compute/instances/{$instanceId}/snapshots");
if (!$response->successful()) {
throw new \RuntimeException('Failed to list snapshots: ' . $response->body());
}
$snapshots = [];
foreach ($response->json('data', []) as $data) {
$snapshots[] = [
'snapshot_id' => $data['snapshotId'],
'name' => $data['name'],
'status' => $data['status'] ?? 'available',
'created_at' => $data['createdDate'] ?? null,
];
}
return $snapshots;
}
public function deleteSnapshot(string $snapshotId): bool
{
$response = $this->client()->delete("/compute/snapshots/{$snapshotId}");
return $response->successful();
}
public function restoreSnapshot(string $instanceId, string $snapshotId): bool
{
$response = $this->client()->post("/compute/instances/{$instanceId}/snapshots/{$snapshotId}/rollback");
return $response->successful();
}
public function getAvailableImages(): array
{
$images = [];
$page = 1;
$pageSize = 100;
do {
$response = $this->client()->get('/compute/images', [
'page' => $page,
'size' => $pageSize,
]);
if (!$response->successful()) {
throw new \RuntimeException('Failed to get images: ' . $response->body());
}
$batch = $response->json('data', []);
foreach ($batch as $data) {
$images[] = [
'id' => $data['imageId'],
'name' => $data['name'],
'description' => $data['description'] ?? '',
'os_type' => $data['osType'] ?? 'linux',
'standard_image' => $data['standardImage'] ?? true,
];
}
$page++;
} while (count($batch) === $pageSize);
if ($images === []) {
throw new \RuntimeException('Failed to get images: empty response');
}
return $images;
}
public function getAvailableRegions(): array
{
return [
['id' => 'EU', 'name' => 'European Union', 'country' => 'DE'],
['id' => 'US-central', 'name' => 'United States Central', 'country' => 'US'],
['id' => 'US-east', 'name' => 'United States East', 'country' => 'US'],
['id' => 'US-west', 'name' => 'United States West', 'country' => 'US'],
['id' => 'SIN', 'name' => 'Singapore', 'country' => 'SG'],
['id' => 'UK', 'name' => 'United Kingdom', 'country' => 'GB'],
['id' => 'AUS', 'name' => 'Australia', 'country' => 'AU'],
['id' => 'JPN', 'name' => 'Japan', 'country' => 'JP'],
];
}
public function getAvailableApplications(): array
{
return Cache::remember('contabo_applications', 3600, function () {
$applications = [];
$page = 1;
$pageSize = 100;
do {
$response = $this->client()->get('/compute/applications', [
'page' => $page,
'size' => $pageSize,
]);
if (!$response->successful()) {
throw new \RuntimeException('Failed to get applications: ' . $response->body());
}
$batch = $response->json('data', []);
foreach ($batch as $data) {
$applications[] = [
'id' => $data['applicationId'],
'name' => $data['name'],
'description' => $data['description'] ?? '',
'version' => $data['version'] ?? null,
'url' => $data['url'] ?? null,
];
}
$page++;
} while (count($batch) === $pageSize);
return $applications;
});
}
public function getAvailableProducts(): array
{
return Cache::remember('contabo_products', 3600, function () {
$liveProducts = $this->fetchProductCatalog();
return $liveProducts !== [] ? $liveProducts : $this->fallbackProducts();
});
}
private function fetchProductCatalog(): array
{
$endpoint = trim((string) $this->productCatalogEndpoint);
if ($endpoint === '') {
return [];
}
$response = str_starts_with($endpoint, 'http')
? Http::withToken($this->getAccessToken())
->withHeaders(['x-request-id' => Str::uuid()->toString()])
->timeout(60)
->get($endpoint)
: $this->client()->get($endpoint);
if (! $response->successful()) {
Log::warning('Contabo product catalog lookup failed', [
'endpoint' => $endpoint,
'status' => $response->status(),
'response' => Str::limit($response->body(), 500),
]);
return [];
}
$rows = $response->json('data', $response->json('products', $response->json()));
if (! is_array($rows)) {
return [];
}
$products = [];
foreach ($rows as $row) {
if (! is_array($row)) {
continue;
}
$product = $this->normalizeProductCatalogRow($row);
if ($product !== null) {
$products[] = $product;
}
}
return $products;
}
private function normalizeProductCatalogRow(array $row): ?array
{
$id = (string) ($row['id'] ?? $row['productId'] ?? $row['product_id'] ?? '');
if ($id === '') {
return null;
}
$monthlyPrice = $row['price_monthly']
?? $row['monthly']
?? $row['monthly_usd']
?? $row['monthlyUsd']
?? $row['monthlyPrice']
?? $row['price']
?? null;
if (! is_numeric($monthlyPrice)) {
return null;
}
return [
'id' => $id,
'name' => (string) ($row['name'] ?? $row['product'] ?? $id),
'cpu' => isset($row['cpu']) ? (int) $row['cpu'] : (isset($row['cpuCores']) ? (int) $row['cpuCores'] : null),
'ram_gb' => isset($row['ram_gb']) ? (float) $row['ram_gb'] : (isset($row['ramGb']) ? (float) $row['ramGb'] : null),
'disk_gb' => isset($row['disk_gb']) ? (float) $row['disk_gb'] : (isset($row['diskGb']) ? (float) $row['diskGb'] : null),
'price_monthly' => (float) $monthlyPrice,
];
}
private function fallbackProducts(): array
{
return [
['id' => 'V91', 'name' => 'Cloud VPS 10 NVMe', 'cpu' => 3, 'ram_gb' => 8, 'disk_gb' => 75, 'price_monthly' => 4.99],
['id' => 'V94', 'name' => 'Cloud VPS 20 NVMe', 'cpu' => 6, 'ram_gb' => 12, 'disk_gb' => 100, 'price_monthly' => 7.00],
['id' => 'V97', 'name' => 'Cloud VPS 30 NVMe', 'cpu' => 8, 'ram_gb' => 24, 'disk_gb' => 200, 'price_monthly' => 14.00],
['id' => 'V100', 'name' => 'Cloud VPS 40 NVMe', 'cpu' => 12, 'ram_gb' => 48, 'disk_gb' => 250, 'price_monthly' => 25.00],
['id' => 'V45', 'name' => 'Legacy VPS S SSD', 'cpu' => 4, 'ram_gb' => 8, 'disk_gb' => 200, 'price_monthly' => 6.99],
['id' => 'V47', 'name' => 'Legacy VPS M SSD', 'cpu' => 6, 'ram_gb' => 16, 'disk_gb' => 400, 'price_monthly' => 12.99],
['id' => 'V46', 'name' => 'Legacy VPS L SSD', 'cpu' => 8, 'ram_gb' => 30, 'disk_gb' => 800, 'price_monthly' => 22.99],
['id' => 'V48', 'name' => 'Legacy VPS XL SSD', 'cpu' => 10, 'ram_gb' => 60, 'disk_gb' => 1600, 'price_monthly' => 44.99],
];
}
private function mapStatus(string $contaboStatus): string
{
return match ($contaboStatus) {
'running' => 'running',
'stopped' => 'stopped',
'provisioning', 'installing' => 'provisioning',
'error' => 'failed',
default => 'unknown',
};
}
public function createPasswordSecret(string $displayName, string $password): int
{
$response = $this->client()->post('/secrets', [
'name' => Str::limit($displayName, 200, '').'-password',
'value' => $password,
'type' => 'password',
]);
if (! $response->successful()) {
Log::error('Contabo secret creation failed', ['response' => $response->body()]);
throw new \RuntimeException('Failed to create Contabo password secret: '.$response->body());
}
return (int) $response->json('data.0.secretId');
}
public function generateCloudInit(array $config): string
{
$packages = $config['packages'] ?? ['curl', 'wget', 'git', 'unzip'];
$sshKeys = $config['ssh_keys'] ?? [];
$writeFiles = $config['write_files'] ?? [];
$runCommands = $config['run_commands'] ?? [];
$cloudInit = [
'#cloud-config',
'package_update: true',
'package_upgrade: true',
'packages:',
];
foreach ($packages as $package) {
$cloudInit[] = " - {$package}";
}
if (!empty($sshKeys)) {
$cloudInit[] = 'ssh_authorized_keys:';
foreach ($sshKeys as $key) {
$cloudInit[] = " - {$key}";
}
}
if (! empty($writeFiles)) {
$cloudInit[] = 'write_files:';
foreach ($writeFiles as $file) {
$cloudInit[] = ' - path: ' . ($file['path'] ?? '/tmp/ladill-file');
if (! empty($file['owner'])) {
$cloudInit[] = ' owner: ' . $file['owner'];
}
if (! empty($file['permissions'])) {
$cloudInit[] = ' permissions: "' . $file['permissions'] . '"';
}
if (! empty($file['encoding'])) {
$cloudInit[] = ' encoding: ' . $file['encoding'];
}
$cloudInit[] = ' content: |';
foreach (preg_split("/\r\n|\n|\r/", (string) ($file['content'] ?? '')) ?: [] as $contentLine) {
$cloudInit[] = ' ' . $contentLine;
}
}
}
if (!empty($runCommands)) {
$cloudInit[] = 'runcmd:';
foreach ($runCommands as $cmd) {
$cloudInit[] = " - {$cmd}";
}
}
return implode("\n", $cloudInit);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,662 @@
<?php
namespace App\Services\Hosting;
use App\Models\HostingOrder;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class ResellerClubHostingService
{
private const DEFAULT_API_URL = 'https://httpapi.com/api';
private ?string $lastSyncError = null;
private string $apiUrl;
private string $hostingEndpoint;
private string $authUserId;
private string $apiKey;
private string $customerId;
public function __construct()
{
$this->apiUrl = rtrim($this->configuredApiUrl(), '/');
$this->hostingEndpoint = trim((string) config('mailinfra.hosting_endpoint', 'singledomainhosting/linux/us'), '/');
$this->authUserId = (string) config('mailinfra.registrar_auth_userid');
$this->apiKey = (string) config('mailinfra.registrar_api_key');
$this->customerId = (string) config('mailinfra.registrar_customer_id');
}
public function isConfigured(): bool
{
return $this->authUserId !== '' && $this->apiKey !== '';
}
public function lastSyncError(): ?string
{
return $this->lastSyncError;
}
/**
* Build a URL with all parameters as query string values, including auth.
*
* @param array<string, mixed> $params
*/
private function buildQueryUrl(string $endpoint, array $params = []): string
{
$all = array_merge([
'auth-userid' => $this->authUserId,
'api-key' => $this->apiKey,
], $params);
$separator = str_contains($endpoint, '?') ? '&' : '?';
return $endpoint.$separator.http_build_query($all);
}
/**
* Authenticate a ResellerClub customer by email and password.
* Returns the customer ID on success, or null on failure.
*
* @return array{success: bool, customer_id?: string, message?: string}
*/
public function authenticateCustomer(string $email, string $password): array
{
try {
$response = Http::timeout(15)->post($this->buildQueryUrl($this->apiUrl.'/customers/authenticate.json', [
'username' => $email,
'passwd' => $password,
]));
$data = $response->json();
if ($response->failed()) {
$message = $data['message'] ?? $data['error'] ?? 'Authentication failed.';
return ['success' => false, 'message' => $message];
}
// RC returns the customer ID directly as the response body on success
$customerId = is_array($data) ? (string) ($data['customerid'] ?? '') : (string) $data;
if ($customerId === '' || $customerId === 'false') {
return ['success' => false, 'message' => 'Invalid email or password.'];
}
return ['success' => true, 'customer_id' => $customerId];
} catch (\Throwable $e) {
Log::error('HostingService: authenticateCustomer failed', ['error' => $e->getMessage()]);
return ['success' => false, 'message' => 'Could not verify ResellerClub credentials.'];
}
}
/**
* Get customer details by customer ID.
*
* @return array{success: bool, customer?: array, message?: string}
*/
public function getCustomerDetails(string $customerId): array
{
try {
$response = Http::timeout(15)->get($this->apiUrl.'/customers/details-by-id.json', [
'auth-userid' => $this->authUserId,
'api-key' => $this->apiKey,
'customer-id' => $customerId,
]);
$data = $response->json();
if ($response->failed() || ! is_array($data)) {
return ['success' => false, 'message' => 'Could not fetch customer details.'];
}
return ['success' => true, 'customer' => $data];
} catch (\Throwable $e) {
Log::error('HostingService: getCustomerDetails failed', ['error' => $e->getMessage()]);
return ['success' => false, 'message' => 'Could not fetch customer details.'];
}
}
/**
* Search hosting orders for a specific customer.
*
* @return array{success: bool, orders?: array, total?: int, message?: string}
*/
public function searchOrdersByCustomer(string $customerId, int $page = 1, int $perPage = 50): array
{
$this->lastSyncError = null;
try {
$params = [
'auth-userid' => $this->authUserId,
'api-key' => $this->apiKey,
'no-of-records' => $perPage,
'page-no' => $page,
'customer-id' => $customerId,
];
$response = Http::timeout(15)->get($this->endpoint('search'), $params);
$data = $response->json();
if ($response->failed() || ! is_array($data)) {
$message = $this->responseMessage($response, $data, 'Failed to search hosting orders.');
$this->lastSyncError = $message;
Log::warning('HostingService: hosting search unsuccessful response', [
'customer_id' => $customerId,
'status' => $response->status(),
'body' => $this->sanitizeErrorText((string) $response->body()),
]);
return ['success' => false, 'message' => $message];
}
$total = (int) ($data['recsonpage'] ?? 0);
$orders = [];
$recordCount = (int) ($data['recsindb'] ?? 0);
for ($i = 1; $i <= $total; $i++) {
if (isset($data[(string) $i])) {
$orders[] = $this->normalizeOrderData($data[(string) $i]);
}
}
return [
'success' => true,
'orders' => $orders,
'total' => $recordCount,
];
} catch (\Throwable $e) {
Log::error('HostingService: searchOrdersByCustomer failed', ['error' => $e->getMessage()]);
$message = $this->syncErrorMessage($e, 'Could not fetch hosting orders.');
$this->lastSyncError = $message;
return ['success' => false, 'message' => $message];
}
}
/**
* Sync all orders for a specific RC customer into a Ladill user account.
*
* @return int Number of orders synced.
*/
public function syncCustomerOrders(string $customerId, int $userId): int
{
$this->lastSyncError = null;
$page = 1;
$synced = 0;
do {
$result = $this->searchOrdersByCustomer($customerId, $page, 50);
if (! ($result['success'] ?? false) || empty($result['orders'])) {
break;
}
foreach ($result['orders'] as $order) {
$payload = is_array($order) ? ($order['raw'] ?? $order) : [];
$orderId = (string) ($payload['orderid'] ?? $payload['entityid'] ?? $order['order_id'] ?? '');
if ($orderId !== '') {
$details = $this->getOrderDetails($orderId);
if (($details['success'] ?? false) && is_array($details['order'] ?? null)) {
$payload = (array) ($details['order']['raw'] ?? $details['order']);
}
}
$this->syncOrderToLocal((array) $payload, $userId);
$synced++;
}
$page++;
} while ($synced < ($result['total'] ?? 0));
return $synced;
}
/**
* Search hosting orders under the reseller account.
*
* @return array{success: bool, orders?: array, total?: int, message?: string}
*/
public function searchOrders(int $page = 1, int $perPage = 25, ?string $domainName = null, ?string $status = null): array
{
try {
$params = [
'auth-userid' => $this->authUserId,
'api-key' => $this->apiKey,
'no-of-records' => $perPage,
'page-no' => $page,
];
if ($domainName) {
$params['domain-name'] = $domainName;
}
if ($status) {
$params['status'] = $status;
}
if ($this->customerId) {
$params['customer-id'] = $this->customerId;
}
$response = Http::timeout(15)->get($this->endpoint('search'), $params);
$data = $response->json();
if ($response->failed() || ! is_array($data)) {
$message = $this->responseMessage($response, $data, 'Failed to search hosting orders.');
Log::warning('HostingService: hosting search unsuccessful response', [
'status' => $response->status(),
'body' => $this->sanitizeErrorText((string) $response->body()),
]);
return ['success' => false, 'message' => $message];
}
$total = (int) ($data['recsonpage'] ?? 0);
$orders = [];
// RC returns numbered keys for each record
$recordCount = (int) ($data['recsindb'] ?? 0);
for ($i = 1; $i <= $total; $i++) {
if (isset($data[(string) $i])) {
$orders[] = $this->normalizeOrderData($data[(string) $i]);
}
}
return [
'success' => true,
'orders' => $orders,
'total' => $recordCount,
];
} catch (\Throwable $e) {
Log::error('HostingService: searchOrders failed', ['error' => $e->getMessage()]);
return ['success' => false, 'message' => 'Could not fetch hosting orders.'];
}
}
/**
* Get details of a specific hosting order by order ID.
*
* @return array{success: bool, order?: array, message?: string}
*/
public function getOrderDetails(string $orderId): array
{
try {
$response = Http::timeout(15)->get($this->endpoint('details'), [
'auth-userid' => $this->authUserId,
'api-key' => $this->apiKey,
'order-id' => $orderId,
]);
$data = $response->json();
if ($response->failed() || ! is_array($data)) {
return ['success' => false, 'message' => 'Failed to fetch order details.'];
}
return [
'success' => true,
'order' => $this->normalizeOrderData($data),
];
} catch (\Throwable $e) {
Log::error('HostingService: getOrderDetails failed', [
'order_id' => $orderId,
'error' => $e->getMessage(),
]);
return ['success' => false, 'message' => 'Could not fetch order details.'];
}
}
private function configuredApiUrl(): string
{
$value = trim((string) config('mailinfra.hosting_api_url'));
return $value !== '' ? $value : self::DEFAULT_API_URL;
}
private function endpoint(string $action): string
{
return sprintf('%s/%s/%s.json', $this->apiUrl, $this->hostingEndpoint, $action);
}
/**
* Order a new single-domain Linux hosting plan.
*
* @return array{success: bool, order_id?: string, message?: string}
*/
public function orderHosting(string $domainName, string $planId, string $customerId, int $months = 1): array
{
try {
$response = Http::timeout(30)->post($this->buildQueryUrl($this->endpoint('add'), [
'domain-name' => $domainName,
'customer-id' => $customerId,
'months' => $months,
'plan-id' => $planId,
'invoice-option' => 'NoInvoice',
]));
$data = $response->json();
if ($response->failed()) {
$message = $data['message'] ?? $data['error'] ?? 'Hosting order failed.';
Log::warning('HostingService: orderHosting failed', [
'domain' => $domainName,
'status' => $response->status(),
'response' => $data,
]);
return ['success' => false, 'message' => $message];
}
Log::info('HostingService: hosting ordered', [
'domain' => $domainName,
'order_id' => $data['entityid'] ?? null,
]);
return [
'success' => true,
'order_id' => (string) ($data['entityid'] ?? ''),
];
} catch (\Throwable $e) {
Log::error('HostingService: orderHosting exception', [
'domain' => $domainName,
'error' => $e->getMessage(),
]);
return ['success' => false, 'message' => 'Hosting order failed due to a network error.'];
}
}
/**
* Renew a hosting order.
*
* @return array{success: bool, message?: string}
*/
public function renewOrder(string $orderId, int $months = 1, string $invoiceOption = 'NoInvoice'): array
{
try {
$response = Http::timeout(30)->post($this->buildQueryUrl($this->endpoint('renew'), [
'order-id' => $orderId,
'months' => $months,
'invoice-option' => $invoiceOption,
]));
$data = $response->json();
if ($response->failed()) {
return ['success' => false, 'message' => $data['message'] ?? 'Renewal failed.'];
}
return ['success' => true];
} catch (\Throwable $e) {
Log::error('HostingService: renewOrder failed', [
'order_id' => $orderId,
'error' => $e->getMessage(),
]);
return ['success' => false, 'message' => 'Renewal failed due to a network error.'];
}
}
/**
* @return array{success: bool, message?: string}
*/
public function changePanelPassword(string $orderId, string $password): array
{
try {
$response = Http::timeout(15)->post($this->buildQueryUrl($this->endpoint('change-password'), [
'order-id' => $orderId,
'new-passwd' => $password,
]));
$data = $response->json();
if ($response->failed()) {
return ['success' => false, 'message' => $data['message'] ?? $data['error'] ?? 'Could not update the hosting panel password.'];
}
return ['success' => true];
} catch (\Throwable $e) {
Log::error('HostingService: changePanelPassword failed', [
'order_id' => $orderId,
'error' => $e->getMessage(),
]);
return ['success' => false, 'message' => 'Could not update the hosting panel password.'];
}
}
/**
* Delete (cancel) a hosting order.
*
* @return array{success: bool, message?: string}
*/
public function deleteOrder(string $orderId): array
{
try {
$response = Http::timeout(15)->post($this->buildQueryUrl($this->endpoint('delete'), [
'order-id' => $orderId,
]));
$data = $response->json();
if ($response->failed()) {
return ['success' => false, 'message' => $data['message'] ?? 'Cancellation failed.'];
}
return ['success' => true];
} catch (\Throwable $e) {
Log::error('HostingService: deleteOrder failed', [
'order_id' => $orderId,
'error' => $e->getMessage(),
]);
return ['success' => false, 'message' => 'Cancellation failed due to a network error.'];
}
}
/**
* Sync a hosting order from ResellerClub into the local database.
*/
public function syncOrderToLocal(array $rcOrder, int $userId, ?int $domainId = null): HostingOrder
{
$raw = is_array($rcOrder['raw'] ?? null) ? (array) $rcOrder['raw'] : [];
return HostingOrder::query()->updateOrCreate(
['rc_order_id' => (string) ($rcOrder['orderid'] ?? $rcOrder['order_id'] ?? $rcOrder['entityid'] ?? Arr::get($raw, 'orders.orderid') ?? Arr::get($raw, 'entity.entityid') ?? $raw['orderid'] ?? $raw['entityid'] ?? '')],
[
'user_id' => $userId,
'domain_id' => $domainId,
'domain_name' => $rcOrder['domain_name'] ?? $rcOrder['domainname'] ?? Arr::get($raw, 'entity.description') ?? Arr::get($raw, 'domainname') ?? Arr::get($raw, 'domain-name') ?? '',
'rc_customer_id' => (string) ($rcOrder['customerid'] ?? $rcOrder['customer_id'] ?? $rcOrder['customer-id'] ?? Arr::get($raw, 'entity.customerid') ?? $raw['customerid'] ?? $raw['customer-id'] ?? ''),
'plan_name' => $rcOrder['plan_name'] ?? $rcOrder['planname'] ?? $rcOrder['productcategory'] ?? Arr::get($raw, 'entitytype.entitytypename') ?? Arr::get($raw, 'entitytype.entitytypekey') ?? $raw['planname'] ?? $raw['productcategory'] ?? null,
'hosting_type' => HostingOrder::TYPE_SINGLE_DOMAIN,
'status' => $this->mapRcStatus($rcOrder['currentstatus'] ?? $rcOrder['orderstatus'] ?? $rcOrder['status'] ?? Arr::get($raw, 'entity.currentstatus') ?? Arr::get($raw, 'orders.orderstatus') ?? 'pending'),
'ip_address' => $rcOrder['ipaddress'] ?? $rcOrder['ip'] ?? $rcOrder['ip_address'] ?? Arr::get($raw, 'server.ipaddress') ?? Arr::get($raw, 'server.ip') ?? $raw['ipaddress'] ?? $raw['ip'] ?? null,
'cpanel_url' => $rcOrder['cpanelurl'] ?? $rcOrder['cpanel_url'] ?? $rcOrder['tempurl'] ?? Arr::get($raw, 'server.cpanelurl') ?? Arr::get($raw, 'server.controlpanelurl') ?? Arr::get($raw, 'server.tempurl') ?? $raw['cpanelurl'] ?? $raw['tempurl'] ?? null,
'cpanel_username' => $rcOrder['cpanelusername'] ?? $rcOrder['cpanel_username'] ?? Arr::get($raw, 'server.cpanelusername') ?? Arr::get($raw, 'server.username') ?? $raw['cpanelusername'] ?? null,
'bandwidth_mb' => $this->unsignedLimitOrNull($rcOrder['bandwidth'] ?? Arr::get($raw, 'plan.bandwidth') ?? $raw['bandwidth'] ?? null),
'disk_space_mb' => $this->unsignedLimitOrNull($rcOrder['diskspace'] ?? Arr::get($raw, 'plan.diskspace') ?? $raw['diskspace'] ?? null),
'expires_at' => $this->hostingTimestamp($rcOrder['endtime'] ?? $rcOrder['end_time'] ?? Arr::get($raw, 'orders.endtime') ?? $raw['endtime'] ?? null),
'provisioned_at' => $this->hostingTimestamp($rcOrder['creationtime'] ?? $rcOrder['creation_time'] ?? Arr::get($raw, 'orders.creationtime') ?? $raw['creationtime'] ?? null),
'meta' => $raw !== [] ? $raw : $rcOrder,
]
);
}
/**
* Sync all orders from ResellerClub for a user.
* If user has a linked RC account, only syncs that customer's orders.
* Otherwise syncs all orders under the reseller account.
*
* @return int Number of orders synced.
*/
public function syncAllOrders(int $userId, ?string $rcCustomerId = null): int
{
if ($rcCustomerId) {
return $this->syncCustomerOrders($rcCustomerId, $userId);
}
$page = 1;
$synced = 0;
do {
$result = $this->searchOrders($page, 50);
if (! ($result['success'] ?? false) || empty($result['orders'])) {
break;
}
foreach ($result['orders'] as $order) {
$this->syncOrderToLocal($order, $userId);
$synced++;
}
$page++;
} while ($synced < ($result['total'] ?? 0));
return $synced;
}
private function normalizeOrderData(array $data): array
{
return [
'order_id' => (string) ($data['orderid'] ?? $data['entityid'] ?? $data['order-id'] ?? $data['entity-id'] ?? Arr::get($data, 'orders.orderid') ?? Arr::get($data, 'entity.entityid') ?? ''),
'domain_name' => $data['domainname'] ?? $data['domain-name'] ?? $data['domain_name'] ?? $data['domain'] ?? $data['hostname'] ?? $data['description'] ?? Arr::get($data, 'entity.description') ?? '',
'customer_id' => (string) ($data['customerid'] ?? $data['customer-id'] ?? $data['customer_id'] ?? Arr::get($data, 'entity.customerid') ?? ''),
'plan_name' => $data['planname'] ?? $data['plan_name'] ?? $data['productcategory'] ?? $data['productkey'] ?? $data['description'] ?? Arr::get($data, 'entitytype.entitytypename') ?? Arr::get($data, 'entitytype.entitytypekey') ?? '',
'status' => $data['currentstatus'] ?? $data['orderstatus'] ?? $data['status'] ?? Arr::get($data, 'entity.currentstatus') ?? Arr::get($data, 'orders.orderstatus') ?? 'Unknown',
'ip_address' => $data['ipaddress'] ?? $data['ip_address'] ?? $data['ip'] ?? Arr::get($data, 'server.ipaddress') ?? Arr::get($data, 'server.ip') ?? null,
'cpanel_url' => $data['cpanelurl'] ?? $data['cpanel_url'] ?? $data['controlpanelurl'] ?? $data['tempurl'] ?? Arr::get($data, 'server.cpanelurl') ?? Arr::get($data, 'server.controlpanelurl') ?? Arr::get($data, 'server.tempurl') ?? null,
'cpanel_username' => $data['cpanelusername'] ?? $data['cpanel_username'] ?? $data['username'] ?? Arr::get($data, 'server.cpanelusername') ?? Arr::get($data, 'server.username') ?? null,
'bandwidth' => $data['bandwidth'] ?? $data['bandwidth_mb'] ?? Arr::get($data, 'plan.bandwidth') ?? null,
'disk_space' => $data['diskspace'] ?? $data['disk_space_mb'] ?? Arr::get($data, 'plan.diskspace') ?? null,
'creation_time' => $data['creationtime'] ?? $data['creation_time'] ?? Arr::get($data, 'orders.creationtime') ?? null,
'end_time' => $data['endtime'] ?? $data['end_time'] ?? $data['expirytime'] ?? Arr::get($data, 'orders.endtime') ?? null,
'raw' => $data,
];
}
private function mapRcStatus(string $rcStatus): string
{
return match (strtolower($rcStatus)) {
'active', 'active (paid)' => HostingOrder::STATUS_ACTIVE,
'suspended' => HostingOrder::STATUS_SUSPENDED,
'deleted', 'cancelled' => HostingOrder::STATUS_CANCELLED,
'expired' => HostingOrder::STATUS_EXPIRED,
'pending', 'inactive' => HostingOrder::STATUS_PENDING,
default => HostingOrder::STATUS_PENDING,
};
}
private function syncErrorMessage(\Throwable $e, string $fallback): string
{
$message = $e->getMessage();
if (str_contains($message, 'Could not resolve host')) {
return 'Could not reach the ResellerClub API host. Check server DNS/network access to httpapi.com.';
}
return $fallback;
}
/**
* @param mixed $data
*/
private function responseMessage(Response $response, mixed $data, string $fallback): string
{
if ($this->looksLikeHtmlError($response)) {
return 'The upstream provisioning service temporarily blocked or rejected the request. Please try again in a moment.';
}
if (is_array($data)) {
foreach (['message', 'error', 'description', 'msg'] as $key) {
$value = $this->sanitizeErrorText((string) ($data[$key] ?? ''));
if ($value !== '') {
return $value;
}
}
}
$rawBody = $this->sanitizeErrorText((string) $response->body());
return $rawBody !== '' ? $rawBody : $fallback;
}
private function sanitizeErrorText(string $value): string
{
$value = trim($value);
if ($value === '') {
return '';
}
if ($this->looksLikeHtmlDocument($value)) {
return 'ResellerClub temporarily blocked or rejected the request. Please try again in a moment.';
}
return trim(preg_replace('/\s+/', ' ', strip_tags($value)) ?? '');
}
private function looksLikeHtmlError(Response $response): bool
{
$contentType = strtolower((string) $response->header('Content-Type'));
$body = trim((string) $response->body());
return str_contains($contentType, 'text/html') || $this->looksLikeHtmlDocument($body);
}
private function looksLikeHtmlDocument(string $value): bool
{
$normalized = strtolower(ltrim($value));
return str_starts_with($normalized, '<!doctype html')
|| str_starts_with($normalized, '<html')
|| str_contains($normalized, '<body')
|| str_contains($normalized, '<head')
|| str_contains($normalized, '<title>')
|| str_contains($normalized, 'cloudflare');
}
private function hostingTimestamp(mixed $value): ?\Carbon\Carbon
{
if ($value === null || $value === '') {
return null;
}
if (is_numeric($value)) {
return \Carbon\Carbon::createFromTimestamp((int) $value);
}
try {
return \Carbon\Carbon::parse((string) $value);
} catch (\Throwable) {
return null;
}
}
private function unsignedLimitOrNull(mixed $value): ?int
{
if (! is_numeric($value)) {
return null;
}
$normalized = (int) $value;
return $normalized >= 0 ? $normalized : null;
}
}
@@ -0,0 +1,202 @@
<?php
namespace App\Services\Hosting;
use App\Models\CustomerHostingOrder;
use App\Models\ServerAgent;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class ServerAgentBootstrapService
{
/**
* @return array{agent: ServerAgent, bootstrap_token: string, registration_url: string, heartbeat_url: string}
*/
public function ensureBootstrap(CustomerHostingOrder $order): array
{
$order->loadMissing('serverAgent');
$meta = (array) ($order->meta ?? []);
$agentMeta = (array) ($meta['server_agent'] ?? []);
$bootstrapToken = $this->decryptBootstrapToken((string) ($agentMeta['bootstrap_token_encrypted'] ?? ''));
if ($bootstrapToken === null) {
$bootstrapToken = Str::random(64);
}
$agent = $order->serverAgent ?: new ServerAgent([
'customer_hosting_order_id' => $order->id,
'uuid' => (string) Str::uuid(),
'status' => ServerAgent::STATUS_PENDING_REGISTRATION,
]);
$agent->fill([
'bootstrap_token_hash' => hash('sha256', $bootstrapToken),
]);
$agent->save();
$agentMeta = array_merge($agentMeta, [
'agent_id' => $agent->id,
'agent_uuid' => $agent->uuid,
'bootstrap_token_encrypted' => encrypt($bootstrapToken),
'registration_url' => route('api.server-agents.register'),
'heartbeat_url' => route('api.server-agents.heartbeat'),
'task_progress_url_template' => url('/api/server-agents/tasks/{task}/progress'),
'task_complete_url_template' => url('/api/server-agents/tasks/{task}/complete'),
'task_fail_url_template' => url('/api/server-agents/tasks/{task}/fail'),
]);
$meta['server_agent'] = $agentMeta;
$meta['server_panel_runtime'] = $this->withRuntimeAgentState(
(array) ($meta['server_panel_runtime'] ?? []),
$agent
);
$order->update(['meta' => $meta]);
return [
'agent' => $agent->fresh(),
'bootstrap_token' => $bootstrapToken,
'registration_url' => $agentMeta['registration_url'],
'heartbeat_url' => $agentMeta['heartbeat_url'],
];
}
/**
* @param array<string, mixed> $attributes
* @return array{agent: ServerAgent, access_token: string}
*/
public function register(CustomerHostingOrder $order, string $bootstrapToken, array $attributes): array
{
$bootstrap = $this->ensureBootstrap($order);
$agent = $bootstrap['agent'];
if (! hash_equals((string) $agent->bootstrap_token_hash, hash('sha256', $bootstrapToken))) {
throw ValidationException::withMessages([
'bootstrap_token' => 'The bootstrap token is invalid.',
]);
}
$accessToken = Str::random(80);
$agent->fill([
'status' => ServerAgent::STATUS_ONLINE,
'hostname' => (string) ($attributes['hostname'] ?? $agent->hostname),
'agent_version' => (string) ($attributes['agent_version'] ?? $agent->agent_version),
'platform' => (string) ($attributes['platform'] ?? $agent->platform),
'capabilities' => array_values(array_unique((array) ($attributes['capabilities'] ?? []))),
'metadata' => (array) ($attributes['metadata'] ?? $agent->metadata ?? []),
'access_token_hash' => hash('sha256', $accessToken),
'access_token_issued_at' => now(),
'registered_at' => $agent->registered_at ?? now(),
'last_heartbeat_at' => now(),
'last_seen_ip' => (string) ($attributes['last_seen_ip'] ?? $agent->last_seen_ip),
]);
$agent->save();
$this->syncOrderRuntime($order->fresh(), $agent->fresh());
return [
'agent' => $agent->fresh(),
'access_token' => $accessToken,
];
}
/**
* @param array<string, mixed> $attributes
*/
public function recordHeartbeat(ServerAgent $agent, array $attributes = []): ServerAgent
{
$agent->fill([
'status' => ServerAgent::STATUS_ONLINE,
'hostname' => (string) ($attributes['hostname'] ?? $agent->hostname),
'agent_version' => (string) ($attributes['agent_version'] ?? $agent->agent_version),
'platform' => (string) ($attributes['platform'] ?? $agent->platform),
'capabilities' => array_values(array_unique((array) ($attributes['capabilities'] ?? $agent->capabilities ?? []))),
'metadata' => array_replace((array) ($agent->metadata ?? []), (array) ($attributes['metadata'] ?? [])),
'last_heartbeat_at' => now(),
'last_seen_ip' => (string) ($attributes['last_seen_ip'] ?? $agent->last_seen_ip),
]);
$agent->save();
if ($agent->relationLoaded('order')) {
$this->syncOrderRuntime($agent->order, $agent);
} else {
$this->syncOrderRuntime($agent->order()->firstOrFail(), $agent);
}
return $agent->fresh();
}
public function syncOrderRuntime(CustomerHostingOrder $order, ServerAgent $agent): void
{
$meta = (array) ($order->meta ?? []);
$meta['server_agent'] = array_merge((array) ($meta['server_agent'] ?? []), [
'agent_id' => $agent->id,
'agent_uuid' => $agent->uuid,
'status' => $agent->status,
'hostname' => $agent->hostname,
'agent_version' => $agent->agent_version,
'platform' => $agent->platform,
'capabilities' => (array) ($agent->capabilities ?? []),
'registered_at' => optional($agent->registered_at)->toIso8601String(),
'last_heartbeat_at' => optional($agent->last_heartbeat_at)->toIso8601String(),
]);
$meta['server_panel_runtime'] = $this->withRuntimeAgentState(
(array) ($meta['server_panel_runtime'] ?? []),
$agent
);
$order->update(['meta' => $meta]);
}
/**
* @param array<string, mixed> $runtime
* @return array<string, mixed>
*/
private function withRuntimeAgentState(array $runtime, ServerAgent $agent): array
{
$current = array_values(array_unique(array_merge(
(array) ($runtime['current_capabilities'] ?? []),
['Agent registration', 'Async task dispatch']
)));
if ($agent->status === ServerAgent::STATUS_ONLINE) {
$current = array_values(array_unique(array_merge($current, [
'Files',
'Databases',
'Domains',
'SSL',
'PHP',
'Cron',
'Logs',
'Site management',
])));
}
$runtime['hosting_panel_ready'] = $agent->registered_at !== null;
$runtime['future_hosting_panel_status'] = $agent->registered_at !== null ? 'agent_registered' : ($runtime['future_hosting_panel_status'] ?? 'planned');
$runtime['agent_status'] = $agent->status;
$runtime['current_capabilities'] = $current;
$runtime['secondary_message'] = $agent->registered_at !== null
? 'The server agent is registered. Managed tasks for files, databases, domains, SSL, PHP, cron, logs, and site actions are now queued through Ladill.'
: ($runtime['secondary_message'] ?? null);
return $runtime;
}
private function decryptBootstrapToken(string $encryptedToken): ?string
{
if ($encryptedToken === '') {
return null;
}
try {
$token = decrypt($encryptedToken);
} catch (\Throwable) {
return null;
}
return is_string($token) && $token !== '' ? $token : null;
}
}
@@ -0,0 +1,229 @@
<?php
namespace App\Services\Hosting;
use App\Models\CustomerHostingOrder;
use App\Services\Hosting\Providers\ContaboProvider;
class ServerAgentInstallerService
{
public function __construct(
private ContaboProvider $contaboProvider,
private ServerAgentReleaseService $releaseService,
) {}
public function augmentProvisioningConfig(CustomerHostingOrder $order, array $provisioning, array $bootstrap): array
{
if (! (bool) ($provisioning['panel_snapshot']['managed_stack_candidate'] ?? false)) {
return $provisioning;
}
$provisioning['user_data'] = $this->buildCloudInit(
$order,
$bootstrap,
(string) ($provisioning['user_data'] ?? '')
);
return $provisioning;
}
public function buildCloudInit(CustomerHostingOrder $order, array $bootstrap, string $existingCloudInit = ''): string
{
$parsed = $this->parseGeneratedCloudInit($existingCloudInit);
$release = $this->releaseService->ensureRelease();
$releaseUrl = $this->releaseService->temporaryDownloadUrl($release['version']);
$envFile = implode("\n", [
'ORDER_ID='.$order->id,
'BOOTSTRAP_TOKEN='.$bootstrap['bootstrap_token'],
'REGISTRATION_URL='.$bootstrap['registration_url'],
'HEARTBEAT_URL='.$bootstrap['heartbeat_url'],
'AGENT_RELEASE_VERSION='.$release['version'],
'',
]);
$packages = array_values(array_unique(array_merge($parsed['packages'], [
'curl',
'wget',
'jq',
'ca-certificates',
'git',
'unzip',
'zip',
])));
$runCommands = array_merge([
$this->managedStackInstallCommand($releaseUrl, $release['checksum_sha256']),
'bash -lc \'mkdir -p /etc/ladill /var/lib/ladill-server-agent /var/www /tmp/ladill-server-agent-release\'',
'bash -lc \'cd /tmp/ladill-server-agent-release && bash install.sh\'',
'bash -lc \'chmod 0600 /etc/ladill/server-agent.env\'',
$this->managedStackEnableServicesCommand(),
], $parsed['run_commands']);
return $this->contaboProvider->generateCloudInit([
'packages' => $packages,
'write_files' => [
[
'path' => '/etc/ladill/server-agent.env',
'permissions' => '0600',
'owner' => 'root:root',
'encoding' => 'b64',
'content' => base64_encode($envFile),
],
],
'run_commands' => $runCommands,
]);
}
/**
* @return array{packages: array<int, string>, run_commands: array<int, string>}
*/
private function parseGeneratedCloudInit(string $cloudInit): array
{
$packages = [];
$runCommands = [];
$section = null;
foreach (preg_split("/\r\n|\n|\r/", $cloudInit) ?: [] as $line) {
$trimmed = trim($line);
if ($trimmed === '' || $trimmed === '#cloud-config') {
continue;
}
if (! str_starts_with($line, ' ')) {
$section = match ($trimmed) {
'packages:' => 'packages',
'runcmd:' => 'runcmd',
default => null,
};
continue;
}
if ($section === 'packages' && str_starts_with($trimmed, '- ')) {
$packages[] = trim(substr($trimmed, 2));
}
if ($section === 'runcmd' && str_starts_with($trimmed, '- ')) {
$runCommands[] = trim(substr($trimmed, 2));
}
}
return [
'packages' => array_values(array_filter($packages)),
'run_commands' => array_values(array_filter($runCommands)),
];
}
private function managedStackInstallCommand(string $releaseUrl, string $checksum): string
{
$script = str_replace(
['__RELEASE_URL__', '__CHECKSUM__'],
[$releaseUrl, $checksum],
<<<'BASH'
set -e
detect_pm() {
if command -v apt-get >/dev/null 2>&1; then
echo apt-get
return 0
fi
if command -v dnf >/dev/null 2>&1; then
echo dnf
return 0
fi
if command -v yum >/dev/null 2>&1; then
echo yum
return 0
fi
if command -v zypper >/dev/null 2>&1; then
echo zypper
return 0
fi
return 1
}
install_composer_if_missing() {
if command -v composer >/dev/null 2>&1; then
return 0
fi
curl -fsSL https://getcomposer.org/installer -o /tmp/composer-setup.php
php /tmp/composer-setup.php --install-dir=/usr/local/bin --filename=composer
rm -f /tmp/composer-setup.php
}
pm="$(detect_pm || true)"
if [ -z "${pm}" ]; then
echo "Ladill managed stack currently supports apt, dnf, yum, or zypper based Linux distributions only." >&2
exit 1
fi
case "${pm}" in
apt-get)
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y nginx mariadb-server php-fpm php-cli php-mysql php-curl php-xml php-zip php-mbstring php-gd php-intl php-bcmath certbot jq curl wget ca-certificates cron composer git unzip zip bubblewrap
;;
dnf)
dnf install -y epel-release || true
dnf install -y nginx mariadb-server php-fpm php-cli php-mysqlnd certbot jq curl wget ca-certificates cronie git unzip zip bubblewrap
dnf install -y php-curl php-xml php-zip php-mbstring php-gd php-intl php-bcmath || true
;;
yum)
yum install -y epel-release || true
yum install -y nginx mariadb-server php-fpm php-cli php-mysqlnd certbot jq curl wget ca-certificates cronie git unzip zip bubblewrap
yum install -y php-curl php-xml php-zip php-mbstring php-gd php-intl php-bcmath || true
;;
zypper)
zypper --non-interactive refresh
zypper --non-interactive install -y nginx mariadb php8 php8-fpm php8-mysql certbot jq curl wget ca-certificates cron git unzip zip bubblewrap
zypper --non-interactive install -y php8-curl php8-xmlreader php8-xmlwriter php8-zip php8-mbstring php8-gd php8-intl php8-bcmath || true
;;
esac
install_composer_if_missing
mkdir -p /etc/ladill /var/lib/ladill-server-agent /var/www /tmp/ladill-server-agent-release
curl -fsSL "__RELEASE_URL__" -o /tmp/ladill-server-agent-release/agent.tar.gz
echo "__CHECKSUM__ /tmp/ladill-server-agent-release/agent.tar.gz" | sha256sum -c -
tar -xzf /tmp/ladill-server-agent-release/agent.tar.gz -C /tmp/ladill-server-agent-release
BASH
);
return 'bash -lc '.escapeshellarg($script);
}
private function managedStackEnableServicesCommand(): string
{
return 'bash -lc '.escapeshellarg(<<<'BASH'
set -e
service_exists() {
systemctl list-unit-files "$1.service" --no-legend 2>/dev/null | grep -q "^$1\.service"
}
enable_if_present() {
for service in "$@"; do
if [ -n "$service" ] && service_exists "$service"; then
systemctl enable --now "$service" >/dev/null 2>&1 || true
return 0
fi
done
return 1
}
chmod 0600 /etc/ladill/server-agent.env
systemctl daemon-reload
enable_if_present nginx
enable_if_present mariadb mysql
enable_if_present cron crond
enable_if_present php-fpm php8-fpm php8.4-fpm php8.3-fpm php8.2-fpm php8.1-fpm php8.0-fpm
enable_if_present ladill-server-agent
BASH
);
}
}
@@ -0,0 +1,117 @@
<?php
namespace App\Services\Hosting;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\URL;
use Phar;
use PharData;
use RuntimeException;
class ServerAgentReleaseService
{
public function currentVersion(): string
{
return (string) config('hosting.server_agent.release_version', '0.2.0');
}
/**
* @return array{version: string, archive_path: string, checksum_sha256: string, filename: string}
*/
public function ensureRelease(?string $version = null): array
{
$version ??= $this->currentVersion();
$releaseDirectory = storage_path('app/server-agent/releases/'.$version);
$packageDirectory = $releaseDirectory.'/package';
$archiveFilename = "ladill-server-agent-{$version}.tar.gz";
$archivePath = $releaseDirectory.'/'.$archiveFilename;
$tarPath = $releaseDirectory."/ladill-server-agent-{$version}.tar";
File::ensureDirectoryExists($packageDirectory);
File::ensureDirectoryExists($releaseDirectory);
$renderedFiles = [
'install.sh' => $this->renderInstallScript(),
'ladill-server-agent' => $this->renderAgentScript($version),
'ladill-server-agent.service' => $this->renderTemplate(resource_path('server-agent/ladill-server-agent.service.tpl')),
'manifest.json' => json_encode([
'name' => 'ladill-server-agent',
'version' => $version,
'built_at' => now()->toIso8601String(),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n",
];
foreach ($renderedFiles as $filename => $contents) {
File::put($packageDirectory.'/'.$filename, $contents);
}
if (! File::exists($archivePath)) {
if (File::exists($tarPath)) {
File::delete($tarPath);
}
try {
$phar = new PharData($tarPath);
foreach (array_keys($renderedFiles) as $filename) {
$phar->addFile($packageDirectory.'/'.$filename, $filename);
}
$phar->compress(Phar::GZ);
unset($phar);
} catch (\Throwable $e) {
throw new RuntimeException('Unable to build the server agent release archive.', previous: $e);
} finally {
if (File::exists($tarPath)) {
File::delete($tarPath);
}
}
}
if (! File::exists($archivePath)) {
throw new RuntimeException('The server agent release archive was not created.');
}
return [
'version' => $version,
'archive_path' => $archivePath,
'checksum_sha256' => hash_file('sha256', $archivePath),
'filename' => $archiveFilename,
];
}
public function temporaryDownloadUrl(?string $version = null, ?\DateTimeInterface $expiresAt = null): string
{
$version ??= $this->currentVersion();
$expiresAt ??= now()->addMinutes((int) config('hosting.server_agent.signed_release_ttl_minutes', 10080));
return URL::temporarySignedRoute('server-agent.releases.download', $expiresAt, [
'version' => $version,
]);
}
private function renderAgentScript(string $version): string
{
return str_replace(
['{{AGENT_VERSION}}', '{{HEARTBEAT_INTERVAL_SECONDS}}'],
[
$version,
(string) config('hosting.server_agent.heartbeat_interval_seconds', 15),
],
$this->renderTemplate(resource_path('server-agent/ladill-server-agent.sh.tpl'))
);
}
private function renderInstallScript(): string
{
return $this->renderTemplate(resource_path('server-agent/install.sh.tpl'));
}
private function renderTemplate(string $path): string
{
$contents = @file_get_contents($path);
if ($contents === false) {
throw new RuntimeException("Unable to load server agent release template: {$path}");
}
return $contents;
}
}
+942
View File
@@ -0,0 +1,942 @@
<?php
namespace App\Services\Hosting;
use App\Models\CustomerHostingOrder;
use App\Models\HostingProduct;
use App\Services\Hosting\Providers\ContaboProvider;
use Illuminate\Validation\ValidationException;
class ServerOrderService
{
private const INSTALL_LATER_IMAGE = '__install_later__';
private const PASSWORD_PATTERN = '/^((?=.*?[A-Z])(?=.*?[a-z]))(((?=(?:.*\d){1})(?=(?:.*[!@#$^&*?_~]){2,}))|((?=(?:.*\d){3})(?=.*?[!@#$^&*?_~]))).{8,}$/';
private const APPLICATION_NAME_MAPPINGS = [
'ipfs_node' => ['IPFS', 'ipfs'],
'flux_node' => ['Flux', 'flux', 'FluxOS'],
'horizon_node' => ['Horizen', 'horizon', 'Zen'],
'ethereum_node' => ['Ethereum', 'eth-docker'],
'bitcoin_node' => ['Bitcoin'],
];
private ?array $availableImages = null;
public function __construct(
private HostingPricingService $pricing,
private ContaboProvider $contaboProvider,
private ServerPanelCapabilityService $panelCapabilities,
) {}
public function passwordPattern(): string
{
return self::PASSWORD_PATTERN;
}
/**
* @return array<string, mixed>
*/
public function frontendPayload(HostingProduct $product): array
{
$defaults = $this->defaultSelections($product);
return [
'id' => $product->id,
'name' => $product->name,
'currency' => $product->display_currency,
'prices' => $this->baseCyclePrices($product),
'setup_fees' => $this->setupFeesByCycle($product),
'catalog' => $this->catalogForProduct($product),
'defaults' => $defaults,
'panel_snapshot' => $this->panelCapabilities->describeSelection($product, $defaults, $this->catalogForProduct($product)),
];
}
/**
* @return array<string, mixed>
*/
public function catalogForProduct(HostingProduct $product): array
{
$images = $this->imageOptions($product);
$defaultImage = $this->defaultImageOption($images)['value'] ?? null;
$defaultImageMeta = $defaultImage ? $this->findOption($images, $defaultImage) : null;
return [
'images' => $images,
'regions' => $this->optionCollection($product, (array) config('hosting.server_order.regions', [])),
'licenses' => $this->optionCollection($product, (array) config('hosting.server_order.licenses', [])),
'applications' => $this->applicationOptions($product),
'additional_ip' => $this->optionCollection($product, (array) config('hosting.server_order.additional_ip', [])),
'private_networking' => $this->optionCollection($product, (array) config('hosting.server_order.private_networking', [])),
'storage_types' => $this->optionCollection($product, (array) config('hosting.server_order.storage_types', [])),
'object_storage' => $this->optionCollection($product, (array) config('hosting.server_order.object_storage', [])),
'linux_default_users' => $this->defaultUserOptions($product, 'linux'),
'windows_default_users' => $this->defaultUserOptions($product, 'windows'),
'default_image_os_family' => $defaultImageMeta['os_family'] ?? 'linux',
];
}
/**
* @param array<string, mixed> $input
* @return array{selection: array<string, mixed>, quote: array<string, mixed>}
*/
public function selectionAndQuote(HostingProduct $product, array $input): array
{
$catalog = $this->catalogForProduct($product);
$defaults = $this->defaultSelections($product);
$billingCycle = (string) ($input['billing_cycle'] ?? CustomerHostingOrder::CYCLE_MONTHLY);
$selection = [
'image' => $this->validatedOptionValue($catalog['images'], (string) ($input['image'] ?? $defaults['image'])),
'region' => $this->validatedOptionValue($catalog['regions'], (string) ($input['region'] ?? $defaults['region'])),
'license' => $this->validatedOptionValue($catalog['licenses'], (string) ($input['license'] ?? $defaults['license'])),
'application' => $this->validatedOptionValue($catalog['applications'], (string) ($input['application'] ?? $defaults['application'])),
'additional_ip' => $this->validatedOptionValue($catalog['additional_ip'], (string) ($input['additional_ip'] ?? $defaults['additional_ip'])),
'private_networking' => $this->validatedOptionValue($catalog['private_networking'], (string) ($input['private_networking'] ?? $defaults['private_networking'])),
'storage_type' => $this->validatedOptionValue($catalog['storage_types'], (string) ($input['storage_type'] ?? $defaults['storage_type'])),
'object_storage' => $this->validatedOptionValue($catalog['object_storage'], (string) ($input['object_storage'] ?? $defaults['object_storage'])),
];
$image = $this->findOption($catalog['images'], $selection['image']);
$defaultUsers = $this->defaultUserOptions($product, (string) ($image['os_family'] ?? 'linux'));
$selection['default_user'] = $this->validatedOptionValue(
$defaultUsers,
(string) ($input['default_user'] ?? $image['default_user'] ?? array_key_first($defaultUsers))
);
$this->assertCompatibleSelections($selection, $catalog);
$quote = $this->quote($product, $selection, $billingCycle, $catalog);
return [
'selection' => $selection,
'quote' => $quote,
'selection_summary' => $this->selectionSummary($product, $selection, $catalog),
'panel_snapshot' => $this->panelCapabilities->describeSelection($product, $selection, $catalog),
];
}
/**
* @param array<string, mixed> $selection
* @param array<string, mixed>|null $catalog
* @return array<string, mixed>
*/
public function quote(HostingProduct $product, array $selection, string $billingCycle, ?array $catalog = null): array
{
$catalog ??= $this->catalogForProduct($product);
$basePrices = $this->baseCyclePrices($product);
$baseTotal = (float) ($basePrices[$billingCycle] ?? 0);
$lines = [[
'code' => 'base_plan',
'label' => $product->name,
'amount' => round($baseTotal, 2),
'automated' => true,
]];
$manualFollowUp = [];
$setupFee = $this->setupFeeForCycle($product, $billingCycle);
if ($setupFee > 0) {
$lines[] = [
'code' => 'setup_fee',
'label' => $this->setupFeeLabel($product, $billingCycle),
'amount' => round($setupFee, 2),
'automated' => true,
'one_time' => true,
];
}
foreach ([
'image' => 'images',
'region' => 'regions',
'license' => 'licenses',
'application' => 'applications',
'additional_ip' => 'additional_ip',
'private_networking' => 'private_networking',
'storage_type' => 'storage_types',
'object_storage' => 'object_storage',
] as $selectionKey => $catalogKey) {
$value = (string) ($selection[$selectionKey] ?? '');
if ($value === '') {
continue;
}
$option = $this->findOption($catalog[$catalogKey], $value);
if (! $option) {
continue;
}
$amount = (float) ($option['prices'][$billingCycle] ?? 0);
if ($amount <= 0) {
continue;
}
$lines[] = [
'code' => $selectionKey,
'label' => (string) ($option['label'] ?? $value),
'amount' => round($amount, 2),
'automated' => (bool) ($option['automated'] ?? false),
];
if (! ($option['automated'] ?? false)) {
$manualFollowUp[] = (string) ($option['label'] ?? $value);
}
}
$total = round(collect($lines)->sum('amount'), 2);
return [
'currency' => $product->display_currency,
'billing_cycle' => $billingCycle,
'months' => $this->cycleMonths($billingCycle),
'base_total' => round($baseTotal, 2),
'line_items' => $lines,
'total' => $total,
'manual_follow_up' => array_values(array_unique($manualFollowUp)),
];
}
/**
* @param array<string, mixed> $selection
* @return array<string, mixed>
*/
public function provisioningConfig(HostingProduct $product, array $selection, string $serverName, string $billingCycle): array
{
$catalog = $this->catalogForProduct($product);
$image = $this->findOption($catalog['images'], (string) ($selection['image'] ?? ''));
$license = $this->findOption($catalog['licenses'], (string) ($selection['license'] ?? ''));
$application = $this->findOption($catalog['applications'], (string) ($selection['application'] ?? ''));
$region = $this->findOption($catalog['regions'], (string) ($selection['region'] ?? ''));
$additionalIp = $this->findOption($catalog['additional_ip'], (string) ($selection['additional_ip'] ?? ''));
$privateNetworking = $this->findOption($catalog['private_networking'], (string) ($selection['private_networking'] ?? ''));
$storageType = $this->findOption($catalog['storage_types'], (string) ($selection['storage_type'] ?? ''));
$objectStorage = $this->findOption($catalog['object_storage'], (string) ($selection['object_storage'] ?? ''));
$addOns = [];
foreach ([$image, $additionalIp, $privateNetworking, $storageType] as $option) {
$addOnKey = (string) ($option['add_on'] ?? '');
if ($addOnKey !== '') {
$addOns[$addOnKey] = new \stdClass();
}
}
$manualFollowUp = [];
foreach ([$license, $application, $storageType, $objectStorage] as $option) {
if ($option && ! ($option['automated'] ?? false)) {
$manualFollowUp[] = (string) ($option['label'] ?? $option['value'] ?? 'Option');
}
}
$applicationId = $application['application_id'] ?? null;
$userData = $this->cloudInitForOption((array) $application, (string) ($selection['default_user'] ?? ($image['default_user'] ?? 'root')));
return [
'product_id' => $product->contabo_product_id,
'image_id' => ! ($image['is_install_later'] ?? false) ? ($image['image_id'] ?? $selection['image'] ?? config('hosting.vps.default_image')) : null,
'region' => (string) ($region['value'] ?? $selection['region'] ?? $product->contabo_region ?? config('hosting.vps.default_region', 'EU')),
'display_name' => $serverName,
'default_user' => (string) ($selection['default_user'] ?? ($image['default_user'] ?? 'root')),
'license' => $license['license'] ?? null,
'panel' => $license['panel'] ?? null,
'application_id' => is_string($applicationId) && $applicationId !== '' ? $applicationId : null,
'user_data' => $userData,
'period' => $this->cycleMonths($billingCycle),
'add_ons' => $addOns,
'manual_follow_up' => array_values(array_unique($manualFollowUp)),
'panel_snapshot' => $this->panelCapabilities->describeSelection($product, $selection, $catalog),
];
}
/**
* @return array<string, mixed>
*/
public function defaultSelections(HostingProduct $product): array
{
$catalog = $this->catalogForProduct($product);
$defaultImage = $catalog['images'][0]['value'] ?? null;
$defaultImageMeta = $defaultImage ? $this->findOption($catalog['images'], $defaultImage) : null;
$defaultUserOptions = $this->defaultUserOptions($product, (string) ($defaultImageMeta['os_family'] ?? 'linux'));
return [
'image' => $defaultImage,
'region' => (string) ($catalog['regions'][0]['value'] ?? 'EU'),
'license' => 'none',
'application' => 'none',
'additional_ip' => 'none',
'private_networking' => 'disabled',
'storage_type' => 'included',
'object_storage' => 'none',
'default_user' => (string) ($defaultUserOptions[0]['value'] ?? 'root'),
];
}
/**
* @param array<string, mixed> $selection
* @param array<string, mixed>|null $catalog
* @return array<int, array<string, mixed>>
*/
public function selectionSummary(HostingProduct $product, array $selection, ?array $catalog = null): array
{
$catalog ??= $this->catalogForProduct($product);
$image = $this->findOption($catalog['images'], (string) ($selection['image'] ?? ''));
$defaultUsers = $this->defaultUserOptions($product, (string) ($image['os_family'] ?? 'linux'));
return array_values(array_filter([
$this->selectionSummaryItem('image', 'Image', $this->findOption($catalog['images'], (string) ($selection['image'] ?? ''))),
$this->selectionSummaryItem('region', 'Region', $this->findOption($catalog['regions'], (string) ($selection['region'] ?? ''))),
$this->selectionSummaryItem('license', 'License', $this->findOption($catalog['licenses'], (string) ($selection['license'] ?? ''))),
$this->selectionSummaryItem('application', 'Preinstalled App', $this->findOption($catalog['applications'], (string) ($selection['application'] ?? ''))),
$this->selectionSummaryItem('default_user', 'Default User', $this->findOption($defaultUsers, (string) ($selection['default_user'] ?? '')), true),
$this->selectionSummaryItem('additional_ip', 'IP Address', $this->findOption($catalog['additional_ip'], (string) ($selection['additional_ip'] ?? ''))),
$this->selectionSummaryItem('private_networking', 'Private Networking', $this->findOption($catalog['private_networking'], (string) ($selection['private_networking'] ?? ''))),
$this->selectionSummaryItem('storage_type', 'Storage Type', $this->findOption($catalog['storage_types'], (string) ($selection['storage_type'] ?? ''))),
$this->selectionSummaryItem('object_storage', 'Object Storage', $this->findOption($catalog['object_storage'], (string) ($selection['object_storage'] ?? ''))),
]));
}
/**
* @return array<string, float>
*/
public function baseCyclePrices(HostingProduct $product): array
{
$cycles = [
CustomerHostingOrder::CYCLE_MONTHLY,
CustomerHostingOrder::CYCLE_QUARTERLY,
CustomerHostingOrder::CYCLE_SEMIANNUAL,
CustomerHostingOrder::CYCLE_YEARLY,
];
$prices = [];
foreach ($cycles as $cycle) {
$price = $product->getPriceForCycle($cycle);
if ($price !== null) {
$prices[$cycle] = (float) $price;
}
}
return $prices;
}
/**
* @return array<string, float>
*/
public function setupFeesByCycle(HostingProduct $product): array
{
$fees = [];
foreach (array_keys($this->baseCyclePrices($product)) as $cycle) {
$fees[$cycle] = $this->setupFeeForCycle($product, $cycle);
}
return $fees;
}
public function setupFeeForCycle(HostingProduct $product, string $billingCycle): float
{
if (! $product->requiresContabo()) {
return 0.0;
}
$productId = trim((string) $product->contabo_product_id);
if ($productId === '') {
return 0.0;
}
foreach ((array) config('hosting.pricing.setup_fee_rules', []) as $rule) {
if (! in_array($productId, array_map('strval', (array) ($rule['product_ids'] ?? [])), true)) {
continue;
}
if (! in_array($billingCycle, array_map('strval', (array) ($rule['billing_cycles'] ?? [])), true)) {
continue;
}
if (isset($rule['fixed_eur_by_cycle'])) {
$eurAmount = (float) ($rule['fixed_eur_by_cycle'][$billingCycle] ?? 0);
if ($eurAmount <= 0) {
return 0.0;
}
$eurToGhs = app(\App\Services\ExchangeRateService::class)->getEurToGhs();
$margin = $this->pricing->getMargin($product->category);
$ghs = $eurAmount * $eurToGhs * (1 + ($margin / 100));
return round($ghs / 5) * 5;
}
$monthlyPrice = (float) $product->getPriceForCycle(CustomerHostingOrder::CYCLE_MONTHLY);
$multiplier = (float) ($rule['monthly_price_multiplier'] ?? 1);
return round($monthlyPrice * $multiplier, 2);
}
return 0.0;
}
private function setupFeeLabel(HostingProduct $product, string $billingCycle): string
{
$productId = trim((string) $product->contabo_product_id);
foreach ((array) config('hosting.pricing.setup_fee_rules', []) as $rule) {
if (
in_array($productId, array_map('strval', (array) ($rule['product_ids'] ?? [])), true)
&& in_array($billingCycle, array_map('strval', (array) ($rule['billing_cycles'] ?? [])), true)
) {
return (string) ($rule['label'] ?? 'One-time setup fee');
}
}
return 'One-time setup fee';
}
public function cycleMonths(string $billingCycle): int
{
return match ($billingCycle) {
CustomerHostingOrder::CYCLE_MONTHLY => 1,
CustomerHostingOrder::CYCLE_QUARTERLY => 3,
CustomerHostingOrder::CYCLE_SEMIANNUAL => 6,
CustomerHostingOrder::CYCLE_YEARLY => 12,
CustomerHostingOrder::CYCLE_BIENNIAL => 24,
default => 1,
};
}
/**
* @param array<int, array<string, mixed>> $options
* @return array<string, mixed>|null
*/
private function findOption(array $options, ?string $value): ?array
{
foreach ($options as $option) {
if ((string) ($option['value'] ?? '') === (string) $value) {
return $option;
}
}
return null;
}
/**
* @param array<int, array<string, mixed>> $options
*/
private function validatedOptionValue(array $options, string $value): string
{
return $this->findOption($options, $value)['value'] ?? (string) ($options[0]['value'] ?? '');
}
/**
* @param array<string, array<string, mixed>> $options
* @return array<int, array<string, mixed>>
*/
private function optionCollection(HostingProduct $product, array $options): array
{
$items = [];
foreach ($options as $value => $option) {
if (! $this->optionIsAvailable($option, (string) $value)) {
continue;
}
$items[] = [
'value' => (string) $value,
'label' => (string) ($option['label'] ?? $value),
'description' => (string) ($option['description'] ?? ''),
'monthly_usd' => (float) ($option['monthly_usd'] ?? 0),
'prices' => $this->cyclePricesForRecurringUsd($product, (float) ($option['monthly_usd'] ?? 0)),
'license' => $option['license'] ?? null,
'panel' => $option['panel'] ?? null,
'application_id' => $option['application_id'] ?? null,
'cloud_init_preset' => $option['cloud_init_preset'] ?? null,
'add_on' => $option['add_on'] ?? null,
'compatible_os_families' => $this->normalizedOsFamilies($option['compatible_os_families'] ?? []),
'requires_image' => (bool) ($option['requires_image'] ?? false),
'requires_managed_stack_image' => (bool) ($option['requires_managed_stack_image'] ?? false),
'automated' => (bool) ($option['automated'] ?? false),
];
}
return $items;
}
/**
* @return array<int, array<string, mixed>>
*/
private function imageOptions(HostingProduct $product): array
{
if ($this->availableImages !== null) {
return $this->availableImages;
}
$images = [];
try {
foreach ($this->contaboProvider->getAvailableImages() as $image) {
$rule = $this->matchImageRule($image);
$images[] = [
'value' => (string) $image['id'],
'label' => (string) ($image['name'] ?? ($rule['label'] ?? 'Image')),
'description' => (string) ($image['description'] ?? ($rule['label'] ?? '')),
'image_id' => (string) $image['id'],
'os_family' => (string) ($rule['os_family'] ?? 'linux'),
'default_user' => (string) ($rule['default_user'] ?? 'root'),
'monthly_usd' => (float) ($rule['monthly_usd'] ?? 0),
'prices' => $this->cyclePricesForRecurringUsd($product, (float) ($rule['monthly_usd'] ?? 0)),
'automated' => true,
'add_on' => ! empty($rule['requires_custom_image_addon']) ? 'customImage' : null,
];
}
} catch (\Throwable) {
// Fall back to configured image list when the API is unavailable.
}
if ($images !== []) {
usort($images, fn (array $a, array $b): int => strcmp((string) ($a['label'] ?? ''), (string) ($b['label'] ?? '')));
return $this->availableImages = $this->prependInstallLaterImage($product, $images);
}
$fallback = [];
foreach ((array) config('hosting.server_order.fallback_images', []) as $image) {
$fallback[] = [
'value' => (string) ($image['value'] ?? ''),
'label' => (string) ($image['label'] ?? 'Image'),
'description' => (string) ($image['description'] ?? ''),
'image_id' => (string) ($image['value'] ?? ''),
'os_family' => (string) ($image['os_family'] ?? 'linux'),
'default_user' => (string) ($image['default_user'] ?? 'root'),
'monthly_usd' => (float) ($image['monthly_usd'] ?? 0),
'prices' => $this->cyclePricesForRecurringUsd($product, (float) ($image['monthly_usd'] ?? 0)),
'automated' => (bool) ($image['automated'] ?? false),
'add_on' => ! empty($image['requires_custom_image_addon']) ? 'customImage' : null,
];
}
return $this->availableImages = $this->prependInstallLaterImage($product, $fallback);
}
/**
* @param array<string, mixed> $image
* @return array<string, mixed>
*/
private function matchImageRule(array $image): array
{
$name = strtolower(trim((string) ($image['name'] ?? '')));
$standardImage = (bool) ($image['standard_image'] ?? true);
$rules = (array) config('hosting.server_order.image_pricing_rules', []);
foreach ($rules as $rule) {
$isCustomRule = (bool) ($rule['custom_image'] ?? false);
if ($isCustomRule && ! $standardImage) {
return $rule;
}
foreach ((array) ($rule['match'] ?? []) as $needle) {
if ($needle !== '' && str_contains($name, strtolower((string) $needle))) {
return $rule;
}
}
}
return [
'label' => $image['name'] ?? 'Image',
'monthly_usd' => 0,
'os_family' => str_contains(strtolower((string) ($image['os_type'] ?? 'linux')), 'win') ? 'windows' : 'linux',
'default_user' => str_contains(strtolower((string) ($image['os_type'] ?? 'linux')), 'win') ? 'administrator' : 'root',
'requires_custom_image_addon' => ! $standardImage,
];
}
/**
* @return array<int, array<string, mixed>>
*/
private function defaultUserOptions(HostingProduct $product, string $osFamily): array
{
$configured = str_starts_with($osFamily, 'win')
? (array) config('hosting.server_order.windows_default_users', [])
: (array) config('hosting.server_order.linux_default_users', []);
$options = [];
foreach ($configured as $value => $option) {
$options[] = [
'value' => (string) $value,
'label' => (string) ($option['label'] ?? $value),
];
}
return $options;
}
/**
* @return array<int, array<string, mixed>>
*/
private function applicationOptions(HostingProduct $product): array
{
$configApps = (array) config('hosting.server_order.applications', []);
$cachedIds = \Illuminate\Support\Facades\Cache::get('contabo_blockchain_app_ids', []);
if ($this->hasMissingConfiguredApplicationIds($configApps)) {
$resolvedIds = $this->resolveApplicationIdsFromCatalog($configApps);
if ($resolvedIds !== []) {
$cachedIds = array_merge($cachedIds, $resolvedIds);
\Illuminate\Support\Facades\Cache::put('contabo_blockchain_app_ids', $cachedIds, now()->addDays(7));
}
}
foreach ($configApps as $key => $option) {
$appId = $option['application_id'] ?? null;
if (($appId === null || $appId === '') && isset($cachedIds[$key])) {
$configApps[$key]['application_id'] = $cachedIds[$key]['id'];
$appId = $configApps[$key]['application_id'];
}
$preset = trim((string) ($option['cloud_init_preset'] ?? ''));
$resolvedAppId = trim((string) $appId);
if ($key !== 'none' && $resolvedAppId === '' && $preset === '') {
$configApps[$key]['allow_unresolved'] = true;
$configApps[$key]['automated'] = false;
$description = trim((string) ($configApps[$key]['description'] ?? ''));
if ($description !== '' && ! str_contains(strtolower($description), 'manual')) {
$configApps[$key]['description'] = $description.' Manual setup may be required after provisioning.';
}
}
}
return $this->optionCollection($product, $configApps);
}
/**
* @param array<string, array<string, mixed>> $configApps
*/
private function hasMissingConfiguredApplicationIds(array $configApps): bool
{
foreach ($configApps as $key => $option) {
if (! array_key_exists($key, self::APPLICATION_NAME_MAPPINGS)) {
continue;
}
if (trim((string) ($option['application_id'] ?? '')) === '') {
return true;
}
}
return false;
}
/**
* @param array<string, array<string, mixed>> $configApps
* @return array<string, array{id: string, name: string, description: string}>
*/
private function resolveApplicationIdsFromCatalog(array $configApps): array
{
try {
$applications = $this->contaboProvider->getAvailableApplications();
} catch (\Throwable) {
return [];
}
$mapped = [];
foreach (self::APPLICATION_NAME_MAPPINGS as $configKey => $searchTerms) {
if (! isset($configApps[$configKey])) {
continue;
}
if (trim((string) ($configApps[$configKey]['application_id'] ?? '')) !== '') {
continue;
}
foreach ($applications as $app) {
$name = strtolower((string) ($app['name'] ?? ''));
foreach ($searchTerms as $term) {
if ($term !== '' && str_contains($name, strtolower($term))) {
$mapped[$configKey] = [
'id' => (string) ($app['id'] ?? ''),
'name' => (string) ($app['name'] ?? $configKey),
'description' => (string) ($app['description'] ?? ''),
];
break 2;
}
}
}
}
return array_filter($mapped, static fn (array $app): bool => $app['id'] !== '');
}
/**
* @param array<int, array<string, mixed>> $images
* @return array<string, mixed>|null
*/
private function defaultImageOption(array $images): ?array
{
foreach ($images as $image) {
if (! ($image['is_install_later'] ?? false)) {
return $image;
}
}
return $images[0] ?? null;
}
/**
* @param array<int, array<string, mixed>> $images
* @return array<int, array<string, mixed>>
*/
private function prependInstallLaterImage(HostingProduct $product, array $images): array
{
array_unshift($images, [
'value' => self::INSTALL_LATER_IMAGE,
'label' => 'Install Later',
'description' => 'Provision the server first and choose the final OS installation afterward.',
'image_id' => null,
'os_family' => 'linux',
'default_user' => 'root',
'monthly_usd' => 0.0,
'prices' => $this->cyclePricesForRecurringUsd($product, 0.0),
'automated' => true,
'add_on' => null,
'is_install_later' => true,
]);
return $images;
}
/**
* @param array<string, mixed> $selection
* @param array<string, mixed> $catalog
*/
private function assertCompatibleSelections(array $selection, array $catalog): void
{
$image = $this->findOption($catalog['images'], (string) ($selection['image'] ?? ''));
$license = $this->findOption($catalog['licenses'], (string) ($selection['license'] ?? ''));
$application = $this->findOption($catalog['applications'], (string) ($selection['application'] ?? ''));
$errors = [];
if (! $this->optionSupportsImage($license, $image)) {
$errors['license'] = 'The selected control panel is not compatible with this image.';
}
if (! $this->optionSupportsImage($application, $image)) {
$errors['application'] = 'The selected preinstalled app is not compatible with this image.';
}
if ($errors !== []) {
throw ValidationException::withMessages($errors);
}
}
/**
* @param array<string, mixed>|null $option
* @param array<string, mixed>|null $image
*/
private function optionSupportsImage(?array $option, ?array $image): bool
{
if (! $option) {
return true;
}
$imageLabel = strtolower((string) ($image['label'] ?? ''));
$bundledPanel = $this->bundledPanelFromImageLabel($imageLabel);
$optionLicense = strtolower((string) ($option['license'] ?? ''));
if (($option['requires_image'] ?? false) && ($image['is_install_later'] ?? false)) {
return false;
}
if (($option['requires_managed_stack_image'] ?? false) && ! $this->imageSupportsManagedStack($image)) {
return false;
}
if ($bundledPanel !== null && (string) ($option['value'] ?? '') === 'none') {
return false;
}
if ($bundledPanel === 'cpanel' && str_starts_with($optionLicense, 'plesk')) {
return false;
}
if ($bundledPanel === 'plesk' && str_starts_with($optionLicense, 'cpanel')) {
return false;
}
$families = (array) ($option['compatible_os_families'] ?? []);
if ($families === []) {
return true;
}
$osFamily = strtolower((string) ($image['os_family'] ?? 'linux'));
foreach ($families as $family) {
if ($family !== '' && str_starts_with($osFamily, strtolower((string) $family))) {
return true;
}
}
return false;
}
/**
* @param array<string, mixed>|null $image
*/
private function imageSupportsManagedStack(?array $image): bool
{
if (! $image || ($image['is_install_later'] ?? false)) {
return false;
}
$imageLabel = strtolower((string) ($image['label'] ?? ''));
$osFamily = strtolower((string) ($image['os_family'] ?? 'linux'));
if (str_starts_with($osFamily, 'win') || $this->imageIncludesVendorPanel($imageLabel)) {
return false;
}
foreach ((array) config('hosting.server_order.managed_stack_supported_images', []) as $supported) {
$supportedOsFamily = strtolower((string) ($supported['os_family'] ?? 'linux'));
if ($supportedOsFamily !== '' && $supportedOsFamily !== $osFamily) {
continue;
}
foreach ((array) ($supported['match'] ?? []) as $needle) {
$needle = strtolower(trim((string) $needle));
if ($needle !== '' && str_contains($imageLabel, $needle)) {
return true;
}
}
}
return false;
}
private function imageIncludesVendorPanel(string $imageLabel): bool
{
return $this->bundledPanelFromImageLabel($imageLabel) !== null;
}
private function bundledPanelFromImageLabel(string $imageLabel): ?string
{
if (str_contains($imageLabel, 'cpanel') || str_contains($imageLabel, 'whm')) {
return 'cpanel';
}
if (str_contains($imageLabel, 'plesk')) {
return 'plesk';
}
return null;
}
/**
* @param array<string, mixed> $option
*/
private function optionIsAvailable(array $option, string $value): bool
{
if ($value === 'none') {
return true;
}
if (! empty($option['allow_unresolved'])) {
return true;
}
$applicationId = trim((string) ($option['application_id'] ?? ''));
$preset = trim((string) ($option['cloud_init_preset'] ?? ''));
if ($applicationId !== '' || $preset !== '') {
return true;
}
return array_key_exists('application_id', $option) || array_key_exists('cloud_init_preset', $option)
? false
: true;
}
/**
* @param mixed $families
* @return array<int, string>
*/
private function normalizedOsFamilies(mixed $families): array
{
$items = is_array($families) ? $families : [];
return array_values(array_filter(array_map(
static fn ($family): string => strtolower(trim((string) $family)),
$items
)));
}
/**
* @param array<string, mixed> $option
*/
private function cloudInitForOption(array $option, string $defaultUser): ?string
{
$preset = (string) ($option['cloud_init_preset'] ?? '');
if ($preset === '') {
return null;
}
return match ($preset) {
'webmin' => $this->contaboProvider->generateCloudInit([
'packages' => ['curl', 'wget'],
'run_commands' => [
'bash -lc \'if command -v apt-get >/dev/null 2>&1; then curl -fsSL https://raw.githubusercontent.com/webmin/webmin/master/setup-repos.sh | sh && apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y webmin; elif command -v dnf >/dev/null 2>&1; then dnf install -y perl perl-Net-SSLeay openssl perl-Encode-Detect perl-Data-Dumper && curl -fsSL https://raw.githubusercontent.com/webmin/webmin/master/setup-repos.sh | sh && dnf install -y webmin; elif command -v yum >/dev/null 2>&1; then yum install -y perl perl-Net-SSLeay openssl perl-Encode-Detect perl-Data-Dumper && curl -fsSL https://raw.githubusercontent.com/webmin/webmin/master/setup-repos.sh | sh && yum install -y webmin; fi\'',
],
]),
'webmin_lamp' => $this->contaboProvider->generateCloudInit([
'packages' => ['curl', 'wget'],
'run_commands' => [
'bash -lc \'if command -v apt-get >/dev/null 2>&1; then apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y apache2 mariadb-server php libapache2-mod-php php-mysql curl wget && curl -fsSL https://raw.githubusercontent.com/webmin/webmin/master/setup-repos.sh | sh && apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y webmin; elif command -v dnf >/dev/null 2>&1; then dnf install -y httpd mariadb-server php php-mysqlnd curl wget perl perl-Net-SSLeay openssl perl-Encode-Detect perl-Data-Dumper && curl -fsSL https://raw.githubusercontent.com/webmin/webmin/master/setup-repos.sh | sh && dnf install -y webmin; elif command -v yum >/dev/null 2>&1; then yum install -y httpd mariadb-server php php-mysqlnd curl wget perl perl-Net-SSLeay openssl perl-Encode-Detect perl-Data-Dumper && curl -fsSL https://raw.githubusercontent.com/webmin/webmin/master/setup-repos.sh | sh && yum install -y webmin; fi && (systemctl enable --now apache2 || systemctl enable --now httpd || true) && (systemctl enable --now mariadb || systemctl enable --now mysqld || true)\'',
],
]),
default => $this->contaboProvider->generateCloudInit([
'packages' => ['curl', 'wget'],
'run_commands' => [
sprintf('echo "Preset %s selected for %s"', $preset, $defaultUser),
],
]),
};
}
/**
* @param array<string, mixed>|null $option
* @return array<string, mixed>|null
*/
private function selectionSummaryItem(string $key, string $label, ?array $option, bool $priceOptional = false): ?array
{
if (! $option) {
return null;
}
$amount = (float) ($option['prices'][CustomerHostingOrder::CYCLE_MONTHLY] ?? 0);
if (! $priceOptional && ($option['label'] ?? null) === null) {
return null;
}
return [
'key' => $key,
'label' => $label,
'value' => (string) ($option['label'] ?? $option['value'] ?? ''),
'description' => (string) ($option['description'] ?? ''),
'automated' => (bool) ($option['automated'] ?? true),
'monthly_amount' => round($amount, 2),
];
}
/**
* @return array<string, float>
*/
private function cyclePricesForRecurringUsd(HostingProduct $product, float $monthlyUsd): array
{
$monthlyGhs = $monthlyUsd > 0
? $this->pricing->convertUsdRecurringOption($monthlyUsd, $product->category)
: 0.0;
return [
CustomerHostingOrder::CYCLE_MONTHLY => round($monthlyGhs, 2),
CustomerHostingOrder::CYCLE_QUARTERLY => round($monthlyGhs * 3, 2),
CustomerHostingOrder::CYCLE_SEMIANNUAL => round($monthlyGhs * 6, 2),
CustomerHostingOrder::CYCLE_YEARLY => round($monthlyGhs * 12, 2),
];
}
}
@@ -0,0 +1,181 @@
<?php
namespace App\Services\Hosting;
use App\Models\HostingProduct;
use Illuminate\Support\Str;
class ServerPanelCapabilityService
{
private const FUTURE_HOSTING_PANEL_CAPABILITIES = [
'Files',
'Databases',
'Domains',
'PHP',
'SSL',
'Cron',
'Logs',
];
/**
* @param array<string, mixed> $selection
* @param array<string, mixed> $catalog
* @return array<string, mixed>
*/
public function describeSelection(HostingProduct $product, array $selection, array $catalog): array
{
$license = $this->findOption((array) ($catalog['licenses'] ?? []), (string) ($selection['license'] ?? ''));
$image = $this->findOption((array) ($catalog['images'] ?? []), (string) ($selection['image'] ?? ''));
$licenseLabel = (string) ($license['label'] ?? 'Remote Login Only');
$panel = (string) ($license['panel'] ?? '');
$vendorLicense = (string) ($license['license'] ?? '');
$osFamily = strtolower((string) ($image['os_family'] ?? 'linux'));
$installLater = (bool) ($image['is_install_later'] ?? false);
$supportedManagedImage = $this->supportedManagedImage($image);
$linuxManagedCandidate = $panel === 'ladill'
&& ! $installLater
&& ! str_starts_with($osFamily, 'win')
&& $supportedManagedImage !== null;
$supportedManagedImageLabels = array_values(array_filter(array_map(
static fn (array $supported): string => (string) ($supported['label'] ?? ''),
(array) config('hosting.server_order.managed_stack_supported_images', [])
)));
if ($panel === 'ladill') {
return [
'mode' => 'server_manager',
'label' => $licenseLabel,
'runtime' => 'provider_api',
'is_ladill' => true,
'managed_stack_candidate' => $linuxManagedCandidate,
'managed_stack_supported_image' => $supportedManagedImage,
'managed_stack_supported_image_labels' => $supportedManagedImageLabels,
'hosting_panel_ready' => $linuxManagedCandidate,
'future_hosting_panel_status' => $linuxManagedCandidate ? 'managed_stack_supported' : 'not_available',
'current_capabilities' => [
'Power controls',
'Status sync',
'Instance details',
'SSH access',
],
'future_capabilities' => $linuxManagedCandidate ? self::FUTURE_HOSTING_PANEL_CAPABILITIES : [],
'primary_message' => $linuxManagedCandidate
? 'Ladill Server Manager will provision this server with Ladill-managed controls on the selected supported Linux image.'
: 'Ladill Server Manager is not available for the selected image.',
'secondary_message' => $linuxManagedCandidate
? 'After provisioning finishes, Server Manager can handle power, status, files, databases, domains, PHP, SSL, cron, and logs.'
: 'Choose a plain supported Linux image without cPanel or Plesk. Supported Ladill-managed images: '.implode(', ', $supportedManagedImageLabels).'.',
'manager_cta_label' => 'Open Server Manager',
'hosting_panel_target_label' => 'Ladill Hosting Panel',
'product_type' => $product->type,
];
}
if ($vendorLicense !== '') {
return [
'mode' => 'vendor_panel',
'label' => $licenseLabel,
'runtime' => 'in_server_panel',
'is_ladill' => false,
'managed_stack_candidate' => false,
'hosting_panel_ready' => false,
'future_hosting_panel_status' => 'external_panel',
'current_capabilities' => [
'Provider provisioning',
'Server login',
'Installed panel management',
],
'future_capabilities' => [],
'primary_message' => "{$licenseLabel} will be the panel running inside this server. Ladill will not replace it with the shared hosting panel.",
'secondary_message' => 'Use Ladill for order tracking and server lifecycle, then manage sites and services from the installed panel itself.',
'manager_cta_label' => 'Open Panel',
'hosting_panel_target_label' => 'Ladill Hosting Panel',
'product_type' => $product->type,
];
}
return [
'mode' => 'remote_login_only',
'label' => $licenseLabel,
'runtime' => 'ssh_only',
'is_ladill' => false,
'managed_stack_candidate' => false,
'hosting_panel_ready' => false,
'future_hosting_panel_status' => 'not_enabled',
'current_capabilities' => [
'Server login',
],
'future_capabilities' => [],
'primary_message' => 'This server will be provisioned without a Ladill-managed or commercial control panel.',
'secondary_message' => 'You will manage the machine directly over SSH or RDP until a panel or managed stack is installed separately.',
'manager_cta_label' => 'Open Access Details',
'hosting_panel_target_label' => 'Ladill Hosting Panel',
'product_type' => $product->type,
];
}
/**
* @param array<int, array<string, mixed>> $options
* @return array<string, mixed>|null
*/
private function findOption(array $options, string $value): ?array
{
foreach ($options as $option) {
if ((string) ($option['value'] ?? '') === $value) {
return $option;
}
}
return null;
}
/**
* @param array<string, mixed>|null $image
* @return array<string, mixed>|null
*/
private function supportedManagedImage(?array $image): ?array
{
if ($image === null) {
return null;
}
$value = Str::lower((string) ($image['value'] ?? ''));
$label = Str::lower((string) ($image['label'] ?? ''));
$osFamily = Str::lower((string) ($image['os_family'] ?? ''));
if ($this->imageIncludesVendorPanel($label)) {
return null;
}
foreach ((array) config('hosting.server_order.managed_stack_supported_images', []) as $supported) {
if (($supported['os_family'] ?? 'linux') !== '' && Str::lower((string) ($supported['os_family'] ?? 'linux')) !== $osFamily) {
continue;
}
$supportedValue = Str::lower((string) ($supported['value'] ?? ''));
if ($supportedValue !== '' && $supportedValue === $value) {
return $supported;
}
foreach ((array) ($supported['match'] ?? []) as $needle) {
if ($needle !== '' && Str::contains($label, Str::lower((string) $needle))) {
return $supported;
}
}
}
return null;
}
private function imageIncludesVendorPanel(string $label): bool
{
foreach (['cpanel', 'plesk', 'whm'] as $needle) {
if ($needle !== '' && Str::contains($label, $needle)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,224 @@
<?php
namespace App\Services\Hosting;
use App\Models\CustomerHostingOrder;
use App\Models\ServerAgent;
use App\Models\ServerTask;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class ServerTaskDispatchService
{
public function __construct(
private ServerTaskResultService $resultService,
) {}
/**
* @return Collection<int, ServerTask>
*/
public function claimTasks(ServerAgent $agent, int $limit = 10): Collection
{
$this->recoverStaleTasks($agent->customer_hosting_order_id);
$claimedIds = DB::transaction(function () use ($agent, $limit): array {
$taskIds = ServerTask::query()
->where('customer_hosting_order_id', $agent->customer_hosting_order_id)
->where('status', ServerTask::STATUS_QUEUED)
->where(function ($query): void {
$query->whereNull('available_at')
->orWhere('available_at', '<=', now());
})
->whereNull('lock_token')
->orderBy('id')
->limit($limit)
->lockForUpdate()
->pluck('id')
->all();
$claimed = [];
foreach ($taskIds as $taskId) {
$lockToken = Str::random(48);
$updated = ServerTask::query()
->whereKey($taskId)
->whereNull('lock_token')
->update([
'server_agent_id' => $agent->id,
'status' => ServerTask::STATUS_DISPATCHED,
'lock_token' => $lockToken,
'locked_at' => now(),
'attempt_count' => DB::raw('attempt_count + 1'),
'started_at' => DB::raw('COALESCE(started_at, CURRENT_TIMESTAMP)'),
'last_heartbeat_at' => now(),
]);
if ($updated) {
$claimed[] = $taskId;
}
}
return $claimed;
});
return ServerTask::query()->whereIn('id', $claimedIds)->orderBy('id')->get();
}
public function markProgress(ServerTask $task, string $lockToken, int $progress, ?string $message = null, array $result = []): ServerTask
{
$task = $this->assertLock($task, $lockToken);
$task->update([
'status' => ServerTask::STATUS_RUNNING,
'progress' => $progress,
'result' => $this->mergedTaskResult($task, $message, $result),
'started_at' => $task->started_at ?? now(),
'last_heartbeat_at' => now(),
'locked_at' => now(),
]);
return $task->fresh();
}
public function markCompleted(ServerTask $task, string $lockToken, ?string $message = null, array $result = []): ServerTask
{
$task = $this->assertLock($task, $lockToken);
$task->update([
'status' => ServerTask::STATUS_COMPLETED,
'progress' => 100,
'result' => $this->mergedTaskResult($task, $message, $result),
'completed_at' => now(),
'last_heartbeat_at' => now(),
'error_message' => null,
'lock_token' => null,
'locked_at' => null,
]);
return $this->resultService->apply($task->fresh());
}
/**
* @return array{task: ServerTask, requeued: bool}
*/
public function markFailed(ServerTask $task, string $lockToken, string $message, array $result = [], bool $retryable = true): array
{
$task = $this->assertLock($task, $lockToken);
$mergedResult = $this->mergedTaskResult($task, $message, $result);
if ($task->canRetry($retryable)) {
$task->update([
'status' => ServerTask::STATUS_QUEUED,
'progress' => 0,
'available_at' => now()->addSeconds(max(15, (int) $task->retry_backoff_seconds)),
'failed_at' => null,
'last_heartbeat_at' => now(),
'error_message' => $message,
'result' => array_merge($mergedResult, [
'retrying' => true,
'retry_scheduled_at' => now()->addSeconds(max(15, (int) $task->retry_backoff_seconds))->toIso8601String(),
]),
'lock_token' => null,
'locked_at' => null,
]);
return [
'task' => $task->fresh(),
'requeued' => true,
];
}
$task->update([
'status' => ServerTask::STATUS_FAILED,
'failed_at' => now(),
'last_heartbeat_at' => now(),
'error_message' => $message,
'result' => array_merge($mergedResult, ['retrying' => false]),
'lock_token' => null,
'locked_at' => null,
]);
return [
'task' => $task->fresh(),
'requeued' => false,
];
}
public function recoverStaleTasks(int|CustomerHostingOrder $order): int
{
$orderId = $order instanceof CustomerHostingOrder ? $order->id : $order;
$tasks = ServerTask::query()
->where('customer_hosting_order_id', $orderId)
->whereIn('status', [ServerTask::STATUS_DISPATCHED, ServerTask::STATUS_RUNNING])
->get();
$recovered = 0;
foreach ($tasks as $task) {
$referenceTime = $task->last_heartbeat_at ?? $task->locked_at ?? $task->updated_at;
if ($referenceTime === null) {
continue;
}
if ($referenceTime->gt(now()->subSeconds(max(30, (int) $task->stale_after_seconds)))) {
continue;
}
if ($task->canRetry()) {
$task->update([
'status' => ServerTask::STATUS_QUEUED,
'progress' => 0,
'server_agent_id' => null,
'available_at' => now()->addSeconds(max(15, (int) $task->retry_backoff_seconds)),
'error_message' => 'Recovered after agent heartbeat timeout.',
'result' => array_merge((array) ($task->result ?? []), [
'recovered_from_stale_lock' => true,
'stale_recovered_at' => now()->toIso8601String(),
]),
'lock_token' => null,
'locked_at' => null,
'last_heartbeat_at' => null,
]);
} else {
$task->update([
'status' => ServerTask::STATUS_FAILED,
'failed_at' => now(),
'error_message' => 'Task failed after stale lock recovery exhausted its retry budget.',
'result' => array_merge((array) ($task->result ?? []), [
'recovered_from_stale_lock' => true,
'stale_recovered_at' => now()->toIso8601String(),
'retrying' => false,
]),
'lock_token' => null,
'locked_at' => null,
]);
}
$recovered++;
}
return $recovered;
}
private function assertLock(ServerTask $task, string $lockToken): ServerTask
{
$task->refresh();
abort_if($task->lock_token === null, 409, 'Task lock has expired.');
abort_unless(hash_equals((string) $task->lock_token, $lockToken), 409, 'Task lock token mismatch.');
return $task;
}
/**
* @param array<string, mixed> $result
* @return array<string, mixed>
*/
private function mergedTaskResult(ServerTask $task, ?string $message, array $result): array
{
return array_filter(array_merge((array) ($task->result ?? []), $result, [
'message' => $message,
]), static fn ($value): bool => $value !== null);
}
}
@@ -0,0 +1,557 @@
<?php
namespace App\Services\Hosting;
use App\Models\Domain;
use App\Models\ServerDatabase;
use App\Models\ServerFile;
use App\Models\ServerSite;
use App\Models\ServerTask;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class ServerTaskResultService
{
public function apply(ServerTask $task): ServerTask
{
$task->loadMissing('order', 'agent');
$references = match ($task->type) {
ServerTask::TYPE_DOMAIN_ADD => $this->persistDomainTask($task),
ServerTask::TYPE_SITE_DELETE => $this->persistSiteDeleteTask($task),
ServerTask::TYPE_SSL_REQUEST => $this->persistSslTask($task),
ServerTask::TYPE_SSL_RENEW => $this->persistSslRenewTask($task),
ServerTask::TYPE_DATABASE_CREATE => $this->persistDatabaseTask($task),
ServerTask::TYPE_PHP_CONFIGURE => $this->persistPhpTask($task),
ServerTask::TYPE_CRON_LIST,
ServerTask::TYPE_CRON_UPSERT,
ServerTask::TYPE_CRON_REMOVE => $this->persistCronTask($task),
ServerTask::TYPE_LOG_READ => $this->persistLogTask($task),
ServerTask::TYPE_FILE_LIST,
ServerTask::TYPE_FILE_READ,
ServerTask::TYPE_FILE_WRITE => $this->persistFileTask($task),
default => [],
};
if ($references !== []) {
$result = (array) ($task->result ?? []);
$result['model_refs'] = array_merge((array) ($result['model_refs'] ?? []), $references);
$task->forceFill(['result' => $result])->save();
}
return $task->fresh();
}
/**
* @return array<string, mixed>
*/
private function persistDomainTask(ServerTask $task): array
{
$result = (array) ($task->result ?? []);
$domainName = Str::lower((string) ($result['domain'] ?? data_get($task->payload, 'domain', '')));
if ($domainName === '') {
return [];
}
$order = $task->order()->first();
$domain = Domain::query()->where('host', $domainName)->first();
if ($domain === null && $order !== null) {
$domain = Domain::create([
'user_id' => $order->user_id,
'host' => $domainName,
'type' => 'server',
'source' => 'server_manager',
'status' => 'pending',
'onboarding_mode' => Domain::MODE_MANUAL_DNS,
'onboarding_state' => Domain::STATE_CONNECTED,
'dns_mode' => 'manual',
'connected_at' => now(),
'verification_meta' => [
'source' => 'server_agent_task',
'customer_hosting_order_id' => $task->customer_hosting_order_id,
],
]);
} elseif ($domain !== null) {
$verificationMeta = array_merge((array) ($domain->verification_meta ?? []), [
'server_agent_task_id' => $task->id,
'customer_hosting_order_id' => $task->customer_hosting_order_id,
]);
$domain->fill([
'user_id' => $domain->user_id ?: $order?->user_id,
'source' => $domain->source ?: 'server_manager',
'connected_at' => $domain->connected_at ?? now(),
'verification_meta' => $verificationMeta,
])->save();
}
$site = ServerSite::query()->updateOrCreate(
[
'customer_hosting_order_id' => $task->customer_hosting_order_id,
'domain' => $domainName,
],
[
'server_agent_id' => $task->server_agent_id,
'latest_server_task_id' => $task->id,
'domain_id' => $domain?->id,
'document_root' => (string) ($result['document_root'] ?? data_get($task->payload, 'document_root', '')) ?: null,
'php_version' => (string) data_get($task->payload, 'php_version', '') ?: null,
'php_socket' => (string) ($result['php_socket'] ?? '') ?: null,
'status' => 'active',
'metadata' => array_filter([
'task_type' => $task->type,
'server_task_id' => $task->id,
'payload' => $task->payload,
'result' => Arr::except($result, ['message', 'model_refs']),
], static fn ($value): bool => $value !== null && $value !== []),
]
);
return array_filter([
'domain_id' => $domain?->id,
'server_site_id' => $site->id,
]);
}
/**
* @return array<string, mixed>
*/
private function persistSiteDeleteTask(ServerTask $task): array
{
$result = (array) ($task->result ?? []);
$domainName = Str::lower((string) ($result['domain'] ?? data_get($task->payload, 'domain', '')));
if ($domainName === '') {
return [];
}
$site = ServerSite::query()
->where('customer_hosting_order_id', $task->customer_hosting_order_id)
->where('domain', $domainName)
->first();
if ($site === null) {
return [];
}
$metadata = array_merge((array) ($site->metadata ?? []), [
'removed_at' => now()->toIso8601String(),
'removed_by_task_id' => $task->id,
]);
$site->fill([
'server_agent_id' => $task->server_agent_id,
'latest_server_task_id' => $task->id,
'status' => 'removed',
'ssl_status' => null,
'metadata' => $metadata,
])->save();
return [
'server_site_id' => $site->id,
];
}
/**
* @return array<string, mixed>
*/
private function persistSslTask(ServerTask $task): array
{
$result = (array) ($task->result ?? []);
$domainName = Str::lower((string) ($result['domain'] ?? data_get($task->payload, 'domain', '')));
if ($domainName === '') {
return [];
}
$site = ServerSite::query()
->where('customer_hosting_order_id', $task->customer_hosting_order_id)
->where('domain', $domainName)
->first();
if ($site === null) {
$site = ServerSite::create([
'customer_hosting_order_id' => $task->customer_hosting_order_id,
'server_agent_id' => $task->server_agent_id,
'latest_server_task_id' => $task->id,
'domain' => $domainName,
'status' => 'active',
'ssl_status' => (string) ($result['ssl'] ?? 'issued'),
'ssl_provisioned_at' => now(),
'metadata' => [
'created_from' => 'ssl.request',
'server_task_id' => $task->id,
],
]);
} else {
$site->fill([
'server_agent_id' => $task->server_agent_id,
'latest_server_task_id' => $task->id,
'ssl_status' => (string) ($result['ssl'] ?? 'issued'),
'ssl_provisioned_at' => now(),
])->save();
}
if ($site->domain_id) {
$domain = Domain::query()->find($site->domain_id);
} else {
$domain = Domain::query()->where('host', $domainName)->first();
}
if ($domain !== null) {
$domain->fill([
'ssl_status' => (string) ($result['ssl'] ?? 'issued'),
'ssl_provisioned_at' => now(),
])->save();
if ($site->domain_id === null) {
$site->forceFill(['domain_id' => $domain->id])->save();
}
}
return array_filter([
'domain_id' => $domain->id ?? null,
'server_site_id' => $site->id,
]);
}
/**
* @return array<string, mixed>
*/
private function persistSslRenewTask(ServerTask $task): array
{
$expiresAt = now()->addDays(90);
ServerSite::query()
->where('customer_hosting_order_id', $task->customer_hosting_order_id)
->whereIn('ssl_status', ['issued', 'active'])
->update([
'ssl_expires_at' => $expiresAt,
'ssl_provisioned_at' => now(),
]);
$domainIds = ServerSite::query()
->where('customer_hosting_order_id', $task->customer_hosting_order_id)
->whereNotNull('domain_id')
->pluck('domain_id');
if ($domainIds->isNotEmpty()) {
Domain::query()
->whereIn('id', $domainIds)
->where('ssl_status', 'active')
->update(['ssl_expires_at' => $expiresAt]);
}
return [];
}
/**
* @return array<string, mixed>
*/
private function persistDatabaseTask(ServerTask $task): array
{
$result = (array) ($task->result ?? []);
$databaseName = (string) ($result['database'] ?? data_get($task->payload, 'name', ''));
if ($databaseName === '') {
return [];
}
$database = ServerDatabase::query()->updateOrCreate(
[
'customer_hosting_order_id' => $task->customer_hosting_order_id,
'name' => $databaseName,
],
[
'server_agent_id' => $task->server_agent_id,
'latest_server_task_id' => $task->id,
'username' => (string) ($result['username'] ?? $databaseName) ?: null,
'password_encrypted' => isset($result['password']) && $result['password'] !== '' ? encrypt((string) $result['password']) : null,
'engine' => 'mariadb',
'status' => 'active',
'metadata' => array_filter([
'server_task_id' => $task->id,
'payload' => $task->payload,
'result' => Arr::except($result, ['message', 'model_refs', 'password']),
], static fn ($value): bool => $value !== null && $value !== []),
]
);
return [
'server_database_id' => $database->id,
];
}
/**
* @return array<string, mixed>
*/
private function persistPhpTask(ServerTask $task): array
{
$result = (array) ($task->result ?? []);
$domainName = Str::lower((string) ($result['domain'] ?? data_get($task->payload, 'domain', '')));
if ($domainName === '') {
return [];
}
$site = ServerSite::query()->updateOrCreate(
[
'customer_hosting_order_id' => $task->customer_hosting_order_id,
'domain' => $domainName,
],
[
'server_agent_id' => $task->server_agent_id,
'latest_server_task_id' => $task->id,
'php_version' => (string) ($result['php_version'] ?? data_get($task->payload, 'php_version', '')) ?: null,
'php_socket' => (string) ($result['php_socket'] ?? '') ?: null,
'status' => 'active',
'metadata' => array_filter([
'php_configured_at' => now()->toIso8601String(),
'server_task_id' => $task->id,
'result' => Arr::except($result, ['message', 'model_refs']),
], static fn ($value): bool => $value !== null && $value !== []),
]
);
return [
'server_site_id' => $site->id,
];
}
/**
* @return array<string, mixed>
*/
private function persistCronTask(ServerTask $task): array
{
$entries = array_values(array_filter((array) data_get($task->result, 'entries', []), 'is_array'));
$order = $task->order()->first();
if ($order !== null) {
$meta = (array) ($order->meta ?? []);
$panelState = (array) ($meta['server_panel'] ?? []);
$panelState['managed_cron_entries'] = $entries;
$panelState['managed_cron_synced_at'] = now()->toIso8601String();
$meta['server_panel'] = $panelState;
$order->update(['meta' => $meta]);
}
return [
'cron_entry_count' => count($entries),
];
}
/**
* @return array<string, mixed>
*/
private function persistLogTask(ServerTask $task): array
{
$result = (array) ($task->result ?? []);
$contentBase64 = (string) ($result['content_base64'] ?? '');
$content = $contentBase64 !== '' ? base64_decode($contentBase64, true) : false;
$content = is_string($content) ? $content : '';
$target = (string) ($result['target'] ?? data_get($task->payload, 'target', 'log'));
$domain = (string) ($result['domain'] ?? data_get($task->payload, 'domain', ''));
$snapshotKey = $target.($domain !== '' ? ':'.$domain : '');
$order = $task->order()->first();
if ($order !== null) {
$meta = (array) ($order->meta ?? []);
$panelState = (array) ($meta['server_panel'] ?? []);
$panelState['log_snapshots'] = array_merge((array) ($panelState['log_snapshots'] ?? []), [
$snapshotKey => [
'target' => $target,
'domain' => $domain !== '' ? $domain : null,
'lines' => (int) ($result['lines'] ?? data_get($task->payload, 'lines', 0)),
'path' => $result['path'] ?? null,
'read_at' => now()->toIso8601String(),
'content_preview' => $this->previewForContent($content),
],
]);
$meta['server_panel'] = $panelState;
$order->update(['meta' => $meta]);
}
return [
'log_snapshot_key' => $snapshotKey,
];
}
/**
* @return array<string, mixed>
*/
private function persistFileTask(ServerTask $task): array
{
$result = (array) ($task->result ?? []);
return match ($task->type) {
ServerTask::TYPE_FILE_LIST => $this->persistFileListTask($task, $result),
ServerTask::TYPE_FILE_READ => $this->persistFileReadTask($task, $result),
ServerTask::TYPE_FILE_WRITE => $this->persistFileWriteTask($task, $result),
default => [],
};
}
/**
* @param array<string, mixed> $result
* @return array<string, mixed>
*/
private function persistFileListTask(ServerTask $task, array $result): array
{
$basePath = (string) data_get($task->payload, 'path', '');
$entries = array_values(array_filter((array) ($result['entries'] ?? []), 'is_array'));
$recordIds = [];
if ($basePath !== '') {
$rootRecord = ServerFile::query()->updateOrCreate(
[
'customer_hosting_order_id' => $task->customer_hosting_order_id,
'path' => $basePath,
],
[
'server_agent_id' => $task->server_agent_id,
'latest_server_task_id' => $task->id,
'kind' => 'directory',
'exists' => true,
'metadata' => [
'entry_count' => count($entries),
'server_task_id' => $task->id,
],
'last_synced_at' => now(),
]
);
$recordIds[] = $rootRecord->id;
}
foreach ($entries as $entry) {
$path = (string) ($entry['path'] ?? '');
if ($path === '') {
continue;
}
$record = ServerFile::query()->updateOrCreate(
[
'customer_hosting_order_id' => $task->customer_hosting_order_id,
'path' => $path,
],
[
'server_agent_id' => $task->server_agent_id,
'latest_server_task_id' => $task->id,
'kind' => $this->normalizeFileKind((string) ($entry['type'] ?? 'f')),
'exists' => true,
'size_bytes' => is_numeric($entry['size'] ?? null) ? (int) $entry['size'] : null,
'metadata' => [
'listed_name' => $entry['name'] ?? basename($path),
'server_task_id' => $task->id,
],
'last_synced_at' => now(),
]
);
$recordIds[] = $record->id;
}
return [
'server_file_ids' => array_values(array_unique($recordIds)),
];
}
/**
* @param array<string, mixed> $result
* @return array<string, mixed>
*/
private function persistFileReadTask(ServerTask $task, array $result): array
{
$path = (string) ($result['path'] ?? data_get($task->payload, 'path', ''));
if ($path === '') {
return [];
}
$contentBase64 = (string) ($result['content_base64'] ?? '');
$content = $contentBase64 !== '' ? base64_decode($contentBase64, true) : false;
$content = is_string($content) ? $content : '';
$file = ServerFile::query()->updateOrCreate(
[
'customer_hosting_order_id' => $task->customer_hosting_order_id,
'path' => $path,
],
[
'server_agent_id' => $task->server_agent_id,
'latest_server_task_id' => $task->id,
'kind' => 'file',
'exists' => true,
'size_bytes' => strlen($content),
'content_hash' => $content !== '' ? hash('sha256', $content) : null,
'content_preview' => $this->previewForContent($content),
'metadata' => [
'encoding' => 'base64',
'server_task_id' => $task->id,
],
'last_synced_at' => now(),
]
);
return [
'server_file_id' => $file->id,
];
}
/**
* @param array<string, mixed> $result
* @return array<string, mixed>
*/
private function persistFileWriteTask(ServerTask $task, array $result): array
{
$path = (string) ($result['path'] ?? data_get($task->payload, 'path', ''));
if ($path === '') {
return [];
}
$content = (string) data_get($task->payload, 'content', '');
$file = ServerFile::query()->updateOrCreate(
[
'customer_hosting_order_id' => $task->customer_hosting_order_id,
'path' => $path,
],
[
'server_agent_id' => $task->server_agent_id,
'latest_server_task_id' => $task->id,
'kind' => 'file',
'exists' => true,
'size_bytes' => strlen($content),
'content_hash' => $content !== '' ? hash('sha256', $content) : null,
'content_preview' => $this->previewForContent($content),
'metadata' => [
'server_task_id' => $task->id,
'write_source' => 'server_agent',
],
'last_synced_at' => now(),
]
);
return [
'server_file_id' => $file->id,
];
}
private function normalizeFileKind(string $type): string
{
return match ($type) {
'd' => 'directory',
'l' => 'symlink',
default => 'file',
};
}
private function previewForContent(string $content): ?string
{
if ($content === '') {
return null;
}
if (preg_match('//u', $content) !== 1) {
return Str::limit(base64_encode(substr($content, 0, 2000)), 4000, '');
}
return (string) Str::of($content)
->replaceMatches('/[^\P{C}\t\r\n]/u', '')
->limit(4000, '');
}
}
@@ -0,0 +1,137 @@
<?php
namespace App\Services\Hosting\Terminal;
class LocalPtyShellSession implements ShellSession
{
/** @var resource|null */
private $process = null;
/** @var array<int, resource> */
private array $pipes = [];
public function __construct(
private string $command,
) {}
public function boot(): void
{
$spec = [
0 => ['pipe', 'r'],
1 => ['pty'],
2 => ['pty'],
];
$process = proc_open($this->command, $spec, $pipes, base_path());
if (! is_resource($process)) {
throw new \RuntimeException('Unable to start local PTY shell.');
}
$this->process = $process;
$this->pipes = $pipes;
foreach ($this->pipes as $pipe) {
stream_set_blocking($pipe, false);
}
}
public function read(): string
{
if (! $this->isAlive()) {
return '';
}
$output = '';
$stream = $this->outputStream();
if (! $stream) {
return '';
}
$read = [$stream];
$write = null;
$except = null;
$available = @stream_select($read, $write, $except, 0, 200000);
if ($available === false || $available === 0) {
return '';
}
$chunk = @stream_get_contents($stream);
if ($chunk !== false && $chunk !== '') {
$output .= $chunk;
}
return $output;
}
public function write(string $input): void
{
$stream = $this->inputStream();
if ($input === '' || ! $stream) {
return;
}
@fwrite($stream, $input);
@fflush($stream);
}
public function resize(int $cols, int $rows): void
{
// proc_open PTYs do not expose a portable runtime resize API.
}
public function isAlive(): bool
{
return is_resource($this->process) && (proc_get_status($this->process)['running'] ?? false);
}
public function close(): void
{
$input = $this->inputStream();
if ($input) {
@fwrite($input, "exit\n");
@fflush($input);
}
foreach ($this->pipes as $pipe) {
if (is_resource($pipe)) {
@fclose($pipe);
}
}
$this->pipes = [];
if (is_resource($this->process)) {
@proc_terminate($this->process);
@proc_close($this->process);
}
$this->process = null;
}
/** @return resource|null */
private function inputStream()
{
return isset($this->pipes[0]) && is_resource($this->pipes[0]) ? $this->pipes[0] : null;
}
/** @return resource|null */
private function outputStream()
{
if (isset($this->pipes[1]) && is_resource($this->pipes[1])) {
return $this->pipes[1];
}
if (isset($this->pipes[2]) && is_resource($this->pipes[2])) {
return $this->pipes[2];
}
return isset($this->pipes[0]) && is_resource($this->pipes[0]) ? $this->pipes[0] : null;
}
}
@@ -0,0 +1,97 @@
<?php
namespace App\Services\Hosting\Terminal;
use phpseclib3\Net\SSH2;
class RemoteSshShellSession implements ShellSession
{
private const STARTUP_TIMEOUT_SECONDS = 10;
private const READ_TIMEOUT_SECONDS = 0.1;
private bool $closed = false;
public function __construct(
private SSH2 $ssh,
private string $launchCommand,
private int $cols = 120,
private int $rows = 30,
) {}
public function boot(): void
{
$this->ssh->setWindowSize($this->cols, $this->rows);
$this->ssh->setTimeout(self::STARTUP_TIMEOUT_SECONDS);
$this->ssh->enablePTY();
if ($this->ssh->exec($this->launchCommand, false) !== true) {
throw new \RuntimeException('Unable to start remote terminal shell.');
}
}
public function read(): string
{
if (! $this->isAlive()) {
return '';
}
$output = '';
$this->ssh->setTimeout(self::READ_TIMEOUT_SECONDS);
while (true) {
$chunk = $this->ssh->read('', SSH2::READ_NEXT);
if ($chunk === true) {
if ($this->ssh->isTimeout()) {
break;
}
$this->closed = true;
break;
}
if (! is_string($chunk) || $chunk === '') {
break;
}
$output .= $chunk;
}
return $output;
}
public function write(string $input): void
{
if ($input === '' || ! $this->isAlive()) {
return;
}
$this->ssh->write($input);
}
public function resize(int $cols, int $rows): void
{
// phpseclib applies the PTY size during shell creation; runtime resize is left as a no-op
// to avoid injecting visible shell commands into the user session.
}
public function isAlive(): bool
{
return ! $this->closed && $this->ssh->isConnected();
}
public function close(): void
{
if ($this->ssh->isConnected()) {
try {
$this->ssh->write("exit\n");
} catch (\Throwable) {
// Ignore shutdown write failures.
}
$this->ssh->disconnect();
}
$this->closed = true;
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Services\Hosting\Terminal;
interface ShellSession
{
public function boot(): void;
public function read(): string;
public function write(string $input): void;
public function resize(int $cols, int $rows): void;
public function isAlive(): bool;
public function close(): void;
}
@@ -0,0 +1,196 @@
<?php
namespace App\Services\Hosting;
use App\Models\HostingAccount;
use App\Models\HostingProduct;
use App\Models\User;
use Illuminate\Support\Collection;
class UpsellRecommendationService
{
private const STORAGE_THRESHOLD = 70.0;
private const TRAFFIC_THRESHOLD = 70.0;
private const EMAIL_ADDON_QUANTITY = 10;
private const EMAIL_ADDON_UNIT_PRICE = 5;
private const BYTES_PER_GB = 1073741824;
/**
* @return Collection<int, array<string, mixed>>
*/
public function recommendationsForUser(User $user): Collection
{
$accounts = HostingAccount::query()
->where('user_id', $user->id)
->where('status', HostingAccount::STATUS_ACTIVE)
->with(['product', 'mailboxes:id,hosting_account_id,is_paid'])
->get();
return $accounts
->flatMap(fn (HostingAccount $account): array => $this->recommendationsForAccount($account))
->sortByDesc(fn (array $recommendation): float => (float) ($recommendation['priority_score'] ?? 0))
->values();
}
/**
* @return array<int, array<string, mixed>>
*/
public function recommendationsForAccount(HostingAccount $account): array
{
$account->loadMissing(['product', 'mailboxes']);
$recommendations = [];
$upgradeProduct = $this->nextUpgradeProduct($account);
$diskPercent = $account->diskUsagePercent();
$trafficPercent = $this->bandwidthUsagePercent($account);
$trafficTriggered = $trafficPercent !== null && $trafficPercent >= self::TRAFFIC_THRESHOLD;
$storageTriggered = $diskPercent >= self::STORAGE_THRESHOLD;
if ($upgradeProduct && ($storageTriggered || $trafficTriggered)) {
$recommendations[] = [
'id' => sprintf('account-%d-upgrade', $account->id),
'type' => 'plan_upgrade',
'trigger' => $storageTriggered && $trafficTriggered
? 'storage_and_traffic'
: ($storageTriggered ? 'storage_usage' : 'traffic_spike'),
'account_id' => $account->id,
'account_label' => $account->primary_domain ?: $account->username,
'title' => $storageTriggered && $trafficTriggered
? sprintf('%s is outgrowing its current plan', $account->primary_domain ?: $account->username)
: ($storageTriggered
? sprintf('Storage usage is high on %s', $account->primary_domain ?: $account->username)
: sprintf('Traffic is climbing on %s', $account->primary_domain ?: $account->username)),
'message' => $this->upgradeMessage($account, $upgradeProduct, $diskPercent, $trafficPercent),
'cta_label' => sprintf('Upgrade to %s', $upgradeProduct->name),
'cta_url' => route('hosting.accounts.show', $account),
'target_product_id' => $upgradeProduct->id,
'target_product_name' => $upgradeProduct->name,
'target_product_type' => $upgradeProduct->type,
'priority_score' => max($diskPercent, $trafficPercent ?? 0),
'metrics' => [
'disk_usage_percent' => round($diskPercent, 2),
'traffic_usage_percent' => $trafficPercent !== null ? round($trafficPercent, 2) : null,
],
];
}
$freeAllowance = $account->freeEmailAllowance();
$mailboxCount = $account->mailboxes->count();
if ($freeAllowance !== null && $mailboxCount > $freeAllowance) {
$extraMailboxes = $mailboxCount - $freeAllowance;
$addonAmount = self::EMAIL_ADDON_QUANTITY * self::EMAIL_ADDON_UNIT_PRICE;
$recommendations[] = [
'id' => sprintf('account-%d-email-addon', $account->id),
'type' => 'email_addon',
'trigger' => 'email_overage',
'account_id' => $account->id,
'account_label' => $account->primary_domain ?: $account->username,
'title' => sprintf('Mailbox overage on %s', $account->primary_domain ?: $account->username),
'message' => sprintf(
'This account is using %d mailbox%s on a plan with %d included. Adding %d more mailboxes would be GHS %d/month.',
$mailboxCount,
$mailboxCount === 1 ? '' : 'es',
$freeAllowance,
self::EMAIL_ADDON_QUANTITY,
$addonAmount
),
'cta_label' => 'Manage Email Add-ons',
'cta_url' => route('hosting.accounts.show', $account),
'suggested_quantity' => self::EMAIL_ADDON_QUANTITY,
'estimated_amount' => (float) $addonAmount,
'priority_score' => 50 + ($extraMailboxes * 5),
'metrics' => [
'mailbox_count' => $mailboxCount,
'free_allowance' => $freeAllowance,
'extra_mailboxes' => $extraMailboxes,
],
];
}
return $recommendations;
}
private function nextUpgradeProduct(HostingAccount $account): ?HostingProduct
{
$current = $account->product;
if (! $current) {
return null;
}
return HostingProduct::query()
->active()
->visible()
->where('category', HostingProduct::CATEGORY_SHARED)
->whereKeyNot($current->id)
->get()
->filter(function (HostingProduct $candidate) use ($current): bool {
$candidateDomains = $candidate->max_domains ?? 0;
$currentDomains = $current->max_domains ?? 0;
return ($candidate->disk_gb ?? 0) > ($current->disk_gb ?? 0)
|| ($candidate->max_email_accounts ?? 0) > ($current->max_email_accounts ?? 0)
|| ($candidateDomains === -1 && $currentDomains !== -1)
|| ($candidateDomains > $currentDomains && $currentDomains !== -1);
})
->sortBy(fn (HostingProduct $candidate): string => sprintf(
'%d-%08d-%012.2f',
$candidate->type === $current->type ? 0 : 1,
(int) ($candidate->sort_order ?? 0),
(float) ($candidate->price_monthly ?? 0)
))
->first();
}
private function bandwidthUsagePercent(HostingAccount $account): ?float
{
$limitBytes = $this->bandwidthLimitBytes($account);
if ($limitBytes === null || $limitBytes <= 0) {
return null;
}
return ($account->bandwidth_used_bytes / $limitBytes) * 100;
}
private function bandwidthLimitBytes(HostingAccount $account): ?int
{
$limitBytes = data_get($account->resource_limits, 'bandwidth_limit_bytes');
if (is_numeric($limitBytes) && (int) $limitBytes > 0) {
return (int) $limitBytes;
}
$bandwidthGb = data_get($account->resource_limits, 'bandwidth_gb');
if (is_numeric($bandwidthGb) && (int) $bandwidthGb > 0) {
return (int) $bandwidthGb * self::BYTES_PER_GB;
}
if ($account->product?->bandwidth_gb && $account->product->bandwidth_gb > 0) {
return (int) $account->product->bandwidth_gb * self::BYTES_PER_GB;
}
return null;
}
private function upgradeMessage(HostingAccount $account, HostingProduct $target, float $diskPercent, ?float $trafficPercent): string
{
$parts = [];
if ($diskPercent >= self::STORAGE_THRESHOLD) {
$parts[] = sprintf('disk usage is at %.1f%%', $diskPercent);
}
if ($trafficPercent !== null && $trafficPercent >= self::TRAFFIC_THRESHOLD) {
$parts[] = sprintf('traffic usage is at %.1f%%', $trafficPercent);
}
$reason = $parts === [] ? 'usage is increasing' : implode(' and ', $parts);
return sprintf(
'%s. Moving this account to %s gives you %s storage%s.',
ucfirst($reason),
$target->name,
$target->formatDiskSize(),
$target->formatDomainLimit() ? ' and '.$target->formatDomainLimit() : ''
);
}
}