Extract Ladill Servers as standalone app at servers.ladill.com.
VPS and dedicated server ordering, managed panels, SSO, billing, and launcher integration — forked from hosting infrastructure with server-focused routes and dashboard. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,190 @@
|
||||
<?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 = $account->loadedMailboxes();
|
||||
$freeAllowance = $account->freeEmailAllowance();
|
||||
$mailboxCount = $mailboxes->count();
|
||||
$paidCount = $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', 'sites']);
|
||||
|
||||
if (! HostingAccount::tracksLocalMailboxes()) {
|
||||
throw ValidationException::withMessages([
|
||||
'local_part' => 'Mailbox creation is managed in Ladill Email.',
|
||||
]);
|
||||
}
|
||||
|
||||
$account->loadMissing(['mailboxes']);
|
||||
|
||||
$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());
|
||||
|
||||
return [
|
||||
'mailbox' => $mailbox->fresh(['domain']),
|
||||
'usage' => $this->usageSummary($account->fresh(['product'])),
|
||||
'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());
|
||||
}
|
||||
|
||||
public function syncAddonInvoice(HostingAccount $account): ?HostingBillingInvoice
|
||||
{
|
||||
$account->loadMissing(['product']);
|
||||
$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;
|
||||
}
|
||||
}
|
||||
@@ -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,201 @@
|
||||
<?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
|
||||
{
|
||||
$accountsQuery = HostingAccount::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('status', HostingAccount::STATUS_ACTIVE)
|
||||
->with(['product']);
|
||||
|
||||
if (HostingAccount::tracksLocalMailboxes()) {
|
||||
$accountsQuery->with(['mailboxes:id,hosting_account_id,is_paid']);
|
||||
}
|
||||
|
||||
$accounts = $accountsQuery->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']);
|
||||
|
||||
$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->loadedMailboxes()->count();
|
||||
if (HostingAccount::tracksLocalMailboxes() && $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() : ''
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user