Initial Ladill Hosting app with Gitea deploy pipeline.
Deploy Ladill Hosting / deploy (push) Failing after 17s
Deploy Ladill Hosting / deploy (push) Failing after 17s
Shared web hosting extracted from the platform monolith, with CI deploy to /var/www/ladill-hosting matching Bird/Domains/Email. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,403 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user