Files
ladill-hosting/app/Jobs/ProvisionHostingOrderJob.php
T
isaaccladandCursor e251a4cf60
Deploy Ladill Hosting / deploy (push) Failing after 17s
Initial Ladill Hosting app with Gitea deploy pipeline.
Shared web hosting extracted from the platform monolith, with CI deploy
to /var/www/ladill-hosting matching Bird/Domains/Email.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 16:24:20 +00:00

404 lines
17 KiB
PHP

<?php
namespace App\Jobs;
use App\Exceptions\ContaboApiException;
use App\Models\CustomerHostingOrder;
use App\Models\HostedSite;
use App\Models\HostingAccount;
use App\Models\HostingNode;
use App\Models\ProvisioningQueueItem;
use App\Services\Hosting\HostingResourcePolicyService;
use App\Services\Hosting\NodeCapacityService;
use App\Services\Hosting\Providers\ContaboProvider;
use App\Services\Hosting\ServerOrderService;
use App\Services\Hosting\ServerAgentBootstrapService;
use App\Services\Hosting\ServerAgentInstallerService;
use App\Services\Hosting\Providers\SharedNodeProvider;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Str;
class ProvisionHostingOrderJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 300;
public function __construct(
public CustomerHostingOrder $order
) {}
public function handle(
NodeCapacityService $capacityService,
HostingResourcePolicyService $resourcePolicyService,
SharedNodeProvider $sharedProvider,
ContaboProvider $contaboProvider,
ServerOrderService $serverOrders,
ServerAgentBootstrapService $serverAgentBootstrap,
ServerAgentInstallerService $serverAgentInstaller,
): void {
if ($this->order->status !== CustomerHostingOrder::STATUS_APPROVED) {
return;
}
$this->order->markAsProvisioning();
$product = $this->order->product;
$queueItem = $this->getOrCreateQueueItem();
try {
$queueItem->markAsProcessing();
if ($product->isSharedHosting()) {
$this->provisionSharedHosting($capacityService, $resourcePolicyService, $sharedProvider, $queueItem);
} elseif ($product->isVps()) {
$this->provisionVps($contaboProvider, $serverOrders, $serverAgentBootstrap, $serverAgentInstaller, $queueItem);
} elseif ($product->isDedicated()) {
$this->provisionDedicated($contaboProvider, $serverOrders, $serverAgentBootstrap, $serverAgentInstaller, $queueItem);
} else {
throw new \Exception("Unknown product category: {$product->category}");
}
$queueItem->markAsCompleted();
} catch (\Throwable $e) {
if ($this->shouldHoldForAdmin($e)) {
$message = $e->getMessage();
$queueItem->markAsFailed($message);
$this->order->fresh()->holdForAdminProvisioning(
$message,
$e instanceof ContaboApiException ? $e->httpStatus : null,
);
return;
}
$queueItem->markAsFailed($e->getMessage());
if ($queueItem->canRetry()) {
$this->release($this->backoff);
} else {
$this->order->markAsFailed($e->getMessage());
}
throw $e;
}
}
private function shouldHoldForAdmin(\Throwable $e): bool
{
if ($e instanceof ContaboApiException) {
return $e->isPaymentRequired();
}
$product = $this->order->product;
if ($product?->requiresContabo() !== true) {
return false;
}
return ContaboApiException::messageIndicatesPaymentRequired($e->getMessage());
}
private function provisionSharedHosting(
NodeCapacityService $capacityService,
HostingResourcePolicyService $resourcePolicyService,
SharedNodeProvider $sharedProvider,
ProvisioningQueueItem $queueItem
): void {
$queueItem->addLog('Finding available shared hosting node...');
$product = $this->order->product;
$requestedDiskGb = (int) ($product->disk_gb ?? 0);
$segment = $product->preferredNodeSegment();
$node = $capacityService->findAvailableNodeForProduct($product);
if (!$node) {
throw new \Exception('No available shared hosting nodes. Please provision a new node.');
}
$queueItem->addLog("Selected node: {$node->name} ({$node->ip_address}) [segment={$segment}, requested_disk={$requestedDiskGb}GB]");
// Generate username
$username = $this->generateUsername($this->order->domain_name);
$password = Str::password(16, true, true, false, false);
$queueItem->addLog("Creating account: {$username}");
$resourceLimits = $resourcePolicyService->defaultLimitsForProduct($product);
// Create account on node via SSH
$result = $sharedProvider->createAccountOnNode($node, [
'username' => $username,
'password' => $password,
'domain' => $this->order->domain_name,
'email' => $this->order->user->email ?: $this->order->guest_email,
'disk_quota_mb' => max($requestedDiskGb, 1) * 1024,
'bandwidth_mb' => (($product->bandwidth_gb ?? 100) > 0 ? $product->bandwidth_gb : 100) * 1024,
]);
// Create hosting account record
$account = HostingAccount::create([
'user_id' => $this->order->user_id,
'hosting_node_id' => $node->id,
'hosting_product_id' => $product->id,
'username' => $username,
'primary_domain' => $this->order->domain_name,
'type' => HostingAccount::TYPE_SHARED,
'status' => HostingAccount::STATUS_ACTIVE,
'home_directory' => $result['home_directory'] ?? "/home/{$username}",
'allocated_disk_gb' => $requestedDiskGb,
'disk_used_bytes' => 0,
'bandwidth_used_bytes' => 0,
'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,
'uploads_restricted' => false,
'is_flagged' => false,
'warning_count' => 0,
'suspended_at' => null,
'provisioned_at' => now(),
'metadata' => [
'assigned_duration_months' => $this->order->getCycleLengthInMonths(),
'initial_password' => $password,
'provisioned_from_order_id' => $this->order->id,
'provisioned_on_node' => $node->name,
],
'resource_limits' => array_merge([
'disk_gb' => $requestedDiskGb,
'bandwidth_gb' => $product->bandwidth_gb,
'max_domains' => $product->max_domains,
'max_databases' => $product->max_databases,
], $resourceLimits),
]);
HostedSite::create([
'hosting_account_id' => $account->id,
'domain_id' => $this->order->domain_id,
'domain' => $this->order->domain_name,
'document_root' => ($result['document_root'] ?? "/home/{$username}/public_html"),
'type' => 'primary',
'status' => 'active',
'php_version' => '8.4',
'ssl_enabled' => false,
]);
$sharedProvider->applyAccountResourceProfile($account, false);
$node = $capacityService->refreshNodeResourceTotals($node);
$capacityService->checkNode($node);
// Install WordPress if it's a WordPress hosting plan
$wpCredentials = [];
if ($product->type === 'wordpress') {
$queueItem->addLog('Installing WordPress...');
$docRoot = $result['document_root'] ?? "/home/{$username}/public_html";
$wpCredentials = $sharedProvider->installWordPress($node, $username, $this->order->domain_name, [
'document_root' => $docRoot,
'admin_email' => $this->order->user->email ?: $this->order->guest_email,
'site_title' => $this->order->domain_name,
]);
}
$queueItem->addLog('Provisioning completed successfully.');
// Update order with provisioning details
$orderMeta = array_merge($this->order->meta ?? [], [
'provisioned_on_node' => $node->name,
'node_segment' => $node->segment,
'requested_disk_gb' => $requestedDiskGb,
'initial_password' => $password,
]);
// Add WordPress credentials if installed
if (!empty($wpCredentials)) {
$orderMeta['wp_admin_user'] = $wpCredentials['admin_user'] ?? 'admin';
$orderMeta['wp_admin_password'] = $wpCredentials['admin_password'] ?? null;
$orderMeta['wp_admin_email'] = $wpCredentials['admin_email'] ?? null;
$orderMeta['wp_db_name'] = $wpCredentials['db_name'] ?? null;
$orderMeta['wp_db_user'] = $wpCredentials['db_user'] ?? null;
$orderMeta['wp_db_password'] = $wpCredentials['db_password'] ?? null;
}
$this->order->markAsActive([
'hosting_node_id' => $node->id,
'hosting_account_id' => $account->id,
'server_username' => $username,
'server_ip' => $node->ip_address,
'control_panel_url' => "https://{$this->order->domain_name}/cpanel",
'expires_at' => $this->order->calculateExpiryDate(),
'meta' => $orderMeta,
]);
}
private function provisionVps(ContaboProvider $contaboProvider, ServerOrderService $serverOrders, ServerAgentBootstrapService $serverAgentBootstrap, ServerAgentInstallerService $serverAgentInstaller, ProvisioningQueueItem $queueItem): void
{
$queueItem->addLog('Provisioning VPS via Contabo API...');
$product = $this->order->product;
if (!$product->contabo_product_id) {
throw new \Exception('Product does not have a Contabo product ID configured.');
}
$provisioning = $this->serverProvisioningConfig($serverOrders, 'VPS');
if ((bool) ($provisioning['panel_snapshot']['managed_stack_candidate'] ?? false)) {
$bootstrap = $serverAgentBootstrap->ensureBootstrap($this->order->fresh());
$provisioning = $serverAgentInstaller->augmentProvisioningConfig($this->order->fresh(), $provisioning, $bootstrap);
$queueItem->addLog('Managed stack bootstrap and Ladill server agent install prepared.');
}
$result = $contaboProvider->createInstance($provisioning);
$queueItem->addLog("VPS created with instance ID: {$result['instance_id']}");
foreach ($provisioning['manual_follow_up'] ?? [] as $item) {
$queueItem->addLog("Manual follow-up required for {$item}.");
}
$this->order->markAsActive([
'server_ip' => $result['ip_address'] ?? null,
'server_username' => (string) ($provisioning['default_user'] ?? 'root'),
'control_panel_url' => $this->serverControlPanelUrl($provisioning),
'expires_at' => $this->order->calculateExpiryDate(),
'meta' => array_merge($this->order->meta ?? [], [
'contabo_instance_id' => $result['instance_id'],
'server_panel_runtime' => $provisioning['panel_snapshot'] ?? data_get($this->order->meta, 'server_panel_runtime', []),
'server_panel' => [
'panel' => $provisioning['panel'] ?? null,
'instance_status' => $result['status'] ?? null,
'power_status' => 'on',
'region' => $result['region'] ?? null,
'datacenter' => $result['datacenter'] ?? null,
'image' => $result['image'] ?? null,
'last_synced_at' => now()->toIso8601String(),
],
]),
]);
if ((bool) ($provisioning['panel_snapshot']['managed_stack_candidate'] ?? false)) {
$serverAgentBootstrap->ensureBootstrap($this->order->fresh());
}
}
private function provisionDedicated(ContaboProvider $contaboProvider, ServerOrderService $serverOrders, ServerAgentBootstrapService $serverAgentBootstrap, ServerAgentInstallerService $serverAgentInstaller, ProvisioningQueueItem $queueItem): void
{
$queueItem->addLog('Provisioning Dedicated Server via Contabo API...');
$product = $this->order->product;
if (!$product->contabo_product_id) {
throw new \Exception('Product does not have a Contabo product ID configured.');
}
$provisioning = $this->serverProvisioningConfig($serverOrders, 'Dedicated');
if ((bool) ($provisioning['panel_snapshot']['managed_stack_candidate'] ?? false)) {
$bootstrap = $serverAgentBootstrap->ensureBootstrap($this->order->fresh());
$provisioning = $serverAgentInstaller->augmentProvisioningConfig($this->order->fresh(), $provisioning, $bootstrap);
$queueItem->addLog('Managed stack bootstrap and Ladill server agent install prepared.');
}
$result = $contaboProvider->createInstance($provisioning);
$queueItem->addLog("Dedicated server created with instance ID: {$result['instance_id']}");
foreach ($provisioning['manual_follow_up'] ?? [] as $item) {
$queueItem->addLog("Manual follow-up required for {$item}.");
}
$this->order->markAsActive([
'server_ip' => $result['ip_address'] ?? null,
'server_username' => (string) ($provisioning['default_user'] ?? 'root'),
'control_panel_url' => $this->serverControlPanelUrl($provisioning),
'expires_at' => $this->order->calculateExpiryDate(),
'meta' => array_merge($this->order->meta ?? [], [
'contabo_instance_id' => $result['instance_id'],
'server_panel_runtime' => $provisioning['panel_snapshot'] ?? data_get($this->order->meta, 'server_panel_runtime', []),
'server_panel' => [
'panel' => $provisioning['panel'] ?? null,
'instance_status' => $result['status'] ?? null,
'power_status' => 'on',
'region' => $result['region'] ?? null,
'datacenter' => $result['datacenter'] ?? null,
'image' => $result['image'] ?? null,
'last_synced_at' => now()->toIso8601String(),
],
]),
]);
if ((bool) ($provisioning['panel_snapshot']['managed_stack_candidate'] ?? false)) {
$serverAgentBootstrap->ensureBootstrap($this->order->fresh());
}
}
/**
* @return array<string, mixed>
*/
private function serverProvisioningConfig(ServerOrderService $serverOrders, string $prefix): array
{
$meta = (array) ($this->order->meta ?? []);
$serverOrder = (array) ($meta['server_order'] ?? []);
$selection = (array) ($serverOrder['selection'] ?? []);
$passwordEncrypted = $serverOrder['server_password_encrypted'] ?? null;
$config = $serverOrders->provisioningConfig(
$this->order->product,
$selection,
"{$prefix}-{$this->order->id}-{$this->order->domain_name}",
$this->order->billing_cycle
);
if (is_string($passwordEncrypted) && $passwordEncrypted !== '') {
$config['root_password'] = decrypt($passwordEncrypted);
}
return $config;
}
/**
* @param array<string, mixed> $provisioning
*/
private function serverControlPanelUrl(array $provisioning): ?string
{
if (($provisioning['panel'] ?? null) !== 'ladill') {
return null;
}
return route('hosting.server-panel.show', $this->order);
}
private function getOrCreateQueueItem(): ProvisioningQueueItem
{
$existing = ProvisioningQueueItem::where('customer_hosting_order_id', $this->order->id)->first();
if ($existing) {
return $existing;
}
$provider = match ($this->order->product->category) {
'shared' => ProvisioningQueueItem::PROVIDER_SHARED_NODE,
'vps' => ProvisioningQueueItem::PROVIDER_CONTABO_VPS,
'dedicated' => ProvisioningQueueItem::PROVIDER_CONTABO_DEDICATED,
default => ProvisioningQueueItem::PROVIDER_SHARED_NODE,
};
return ProvisioningQueueItem::create([
'customer_hosting_order_id' => $this->order->id,
'status' => ProvisioningQueueItem::STATUS_QUEUED,
'provider' => $provider,
]);
}
private function generateUsername(string $domain): string
{
// Remove TLD and special characters
$base = preg_replace('/[^a-z0-9]/', '', strtolower(explode('.', $domain)[0]));
$base = substr($base, 0, 8);
// Add random suffix to ensure uniqueness
return $base . Str::random(4);
}
}