Deploy Ladill Hosting / deploy (push) Successful in 47s
Treat ns_auto domains as managed DNS, publish subdomain A records via PowerDNS without failing the UX when the local DNS registry is missing, and promote real linked domains over pending-setup.local placeholders. Co-authored-by: Cursor <cursoragent@cursor.com>
410 lines
17 KiB
PHP
410 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
|
|
$primaryDomain = \App\Services\Hosting\PlaceholderPrimaryDomainService::isPlaceholderDomain($this->order->domain_name)
|
|
? null
|
|
: $this->order->domain_name;
|
|
|
|
$account = HostingAccount::create([
|
|
'user_id' => $this->order->user_id,
|
|
'hosting_node_id' => $node->id,
|
|
'hosting_product_id' => $product->id,
|
|
'username' => $username,
|
|
'primary_domain' => $primaryDomain,
|
|
'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),
|
|
]);
|
|
|
|
if ($primaryDomain) {
|
|
HostedSite::create([
|
|
'hosting_account_id' => $account->id,
|
|
'domain_id' => $this->order->domain_id,
|
|
'domain' => $primaryDomain,
|
|
'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);
|
|
}
|
|
}
|