$input */ public function startHostingNode(array $input, ?User $actor = null): ContaboInfrastructureProvision { $this->assertEnabled(); $keys = $this->sshKeys->generate(); $placeholderIp = (string) config('infrastructure.contabo.placeholder_ip', '169.254.254.254'); $node = HostingNode::create([ 'name' => (string) $input['name'], 'hostname' => (string) $input['hostname'], 'ip_address' => $placeholderIp, 'type' => (string) ($input['type'] ?? HostingNode::TYPE_SHARED), 'role' => (string) ($input['role'] ?? HostingNode::ROLE_PRIMARY), 'primary_hosting_node_id' => $input['primary_hosting_node_id'] ?? null, 'segment' => (string) ($input['segment'] ?? HostingNode::SEGMENT_GENERAL), 'provider' => HostingNode::PROVIDER_CONTABO, 'region' => (string) ($input['region'] ?? config('infrastructure.contabo.default_region', 'EU')), 'cpu_cores' => $input['cpu_cores'] ?? null, 'ram_mb' => $input['ram_mb'] ?? null, 'disk_gb' => $input['disk_gb'] ?? null, 'bandwidth_tb' => $input['bandwidth_tb'] ?? null, 'max_accounts' => (int) ($input['max_accounts'] ?? 100), 'oversell_ratio' => (float) ($input['oversell_ratio'] ?? 3), 'ssh_port' => 22, 'ssh_private_key' => $keys['private_key'], 'status' => HostingNode::STATUS_PROVISIONING, ]); $provision = $this->createProvision( purpose: ContaboInfrastructureProvision::PURPOSE_HOSTING_NODE, name: (string) $input['name'], productId: (string) ($input['contabo_product_id'] ?? config('infrastructure.contabo.hosting_product_id', 'V97')), region: (string) ($input['region'] ?? config('infrastructure.contabo.default_region', 'EU')), imageId: (string) ($input['image_id'] ?? config('infrastructure.contabo.default_image_id')), payload: $input, keys: $keys, hostingNodeId: $node->id, actor: $actor, ); ProvisionContaboInfrastructureJob::dispatch($provision); return $provision; } /** * @param array $input */ public function startEmailServer(array $input, ?User $actor = null): ContaboInfrastructureProvision { $this->assertEnabled(); $isExtension = ($input['role'] ?? EmailServer::ROLE_PRIMARY) === EmailServer::ROLE_EXTENSION; $keys = $this->sshKeys->generate(); $mailHost = (string) ($input['mail_host'] ?? config('mailinfra.mail_host', 'mail.ladill.com')); $placeholderIp = (string) config('infrastructure.contabo.placeholder_ip', '169.254.254.254'); $mailDbPassword = $isExtension ? null : Str::password(32); $primary = null; if ($isExtension) { $primary = EmailServer::findOrFail($input['primary_email_server_id']); } $server = EmailServer::create([ 'name' => (string) $input['name'], 'hostname' => (string) ($input['hostname'] ?? $mailHost), 'ip_address' => $placeholderIp, 'mail_host' => $mailHost, 'role' => $isExtension ? EmailServer::ROLE_EXTENSION : EmailServer::ROLE_PRIMARY, 'primary_email_server_id' => $primary?->id, 'provider' => EmailServer::PROVIDER_CONTABO, 'region' => (string) ($input['region'] ?? config('infrastructure.contabo.default_region', 'EU')), 'cpu_cores' => $input['cpu_cores'] ?? null, 'ram_mb' => $input['ram_mb'] ?? null, 'disk_gb' => $input['disk_gb'] ?? null, 'max_domains' => $input['max_domains'] ?? 5000, 'max_mailboxes' => $input['max_mailboxes'] ?? 50000, 'db_host' => $isExtension ? $primary?->db_host : config('infrastructure.mail_stack.db_host'), 'db_port' => $primary?->db_port ?? 3306, 'db_database' => config('infrastructure.mail_stack.db_database', 'mail'), 'db_username' => $isExtension ? $primary?->db_username : config('infrastructure.mail_stack.db_username', 'mailuser'), 'db_password' => $isExtension ? $primary?->db_password : $mailDbPassword, 'ssh_host' => null, 'ssh_user' => 'root', 'ssh_port' => 22, 'ssh_private_key' => $keys['private_key'], 'status' => EmailServer::STATUS_PROVISIONING, 'is_default' => ! $isExtension && (bool) ($input['is_default'] ?? false), ]); if ($isExtension && $primary) { $this->emailPool->syncExtensionFromPrimary($server, $primary); } $purpose = $isExtension ? ContaboInfrastructureProvision::PURPOSE_EMAIL_EXTENSION : ContaboInfrastructureProvision::PURPOSE_EMAIL_PRIMARY; $productId = $isExtension ? (string) ($input['contabo_product_id'] ?? config('infrastructure.contabo.mail_extension_product_id', 'V94')) : (string) ($input['contabo_product_id'] ?? config('infrastructure.contabo.mail_primary_product_id', 'V97')); $provision = $this->createProvision( purpose: $purpose, name: (string) $input['name'], productId: $productId, region: (string) ($input['region'] ?? config('infrastructure.contabo.default_region', 'EU')), imageId: (string) ($input['image_id'] ?? config('infrastructure.contabo.default_image_id')), payload: array_merge($input, [ 'mail_host' => $mailHost, 'primary_ip' => $primary?->ip_address, 'primary_email_server_id' => $primary?->id, ]), keys: $keys, emailServerId: $server->id, mailDbPassword: $mailDbPassword, actor: $actor, ); ProvisionContaboInfrastructureJob::dispatch($provision); return $provision; } public function createContaboInstance(ContaboInfrastructureProvision $provision): void { $provision->update(['status' => ContaboInfrastructureProvision::STATUS_CREATING]); $provision->addLog('Creating Contabo compute instance…'); $userData = $this->cloudInit->build($provision); $result = $this->contabo->createInstance([ 'product_id' => $provision->contabo_product_id, 'region' => $provision->region, 'image_id' => $provision->image_id, 'display_name' => 'ladill-'.$provision->purpose.'-'.$provision->id, 'user_data' => $userData, 'default_user' => 'root', ]); $provision->update([ 'contabo_instance_id' => $result['instance_id'], 'status' => ContaboInfrastructureProvision::STATUS_WAITING_IP, 'instance_created_at' => now(), ]); if ($provision->hosting_node_id) { HostingNode::whereKey($provision->hosting_node_id)->update([ 'provider_instance_id' => $result['instance_id'], 'cpu_cores' => $result['cpu_cores'] ?? null, 'ram_mb' => $result['ram_mb'] ?? null, 'disk_gb' => isset($result['disk_mb']) ? (int) ceil($result['disk_mb'] / 1024) : null, ]); } if ($provision->email_server_id) { EmailServer::whereKey($provision->email_server_id)->update([ 'provider_instance_id' => $result['instance_id'], 'cpu_cores' => $result['cpu_cores'] ?? null, 'ram_mb' => $result['ram_mb'] ?? null, 'disk_gb' => isset($result['disk_mb']) ? (int) ceil($result['disk_mb'] / 1024) : null, ]); } $provision->addLog('Instance '.$result['instance_id'].' created; waiting for IP address.'); } public function pollInstanceIp(ContaboInfrastructureProvision $provision): bool { if (! $provision->contabo_instance_id) { return false; } $instance = $this->contabo->getInstance($provision->contabo_instance_id); $ip = $instance['ip_address'] ?? null; if (! is_string($ip) || $ip === '' || $ip === '0.0.0.0') { return false; } $provision->update([ 'status' => ContaboInfrastructureProvision::STATUS_BOOTSTRAPPING, 'ip_assigned_at' => now(), ]); $provision->addLog("Instance IP assigned: {$ip}"); if ($provision->hosting_node_id) { HostingNode::whereKey($provision->hosting_node_id)->update([ 'ip_address' => $ip, 'ipv6_address' => $instance['ipv6_address'] ?? null, ]); } if ($provision->email_server_id) { EmailServer::whereKey($provision->email_server_id)->update([ 'ip_address' => $ip, 'ipv6_address' => $instance['ipv6_address'] ?? null, 'ssh_host' => $ip, ]); // Note: db_host is intentionally NOT set to the server's own IP. // The mail DB is centralized; primaries keep the central db_host. } return true; } /** * @param array $callback */ public function completeFromCallback(ContaboInfrastructureProvision $provision, array $callback): void { if ($provision->status === ContaboInfrastructureProvision::STATUS_COMPLETED) { return; } $provision->addLog('Bootstrap completion callback received.'); if ($provision->email_server_id) { $server = EmailServer::find($provision->email_server_id); if ($server?->isPrimary()) { EmailServer::whereKey($server->id)->update([ // Mail DB is centralized — point at the central host, not the server IP. 'db_host' => config('infrastructure.mail_stack.db_host'), 'ssh_host' => $server->ip_address, ]); $this->emailPool->syncExtensionCredentials($server->fresh()); } if ($server?->isExtension() && $server->primary) { $this->mailNfs->refreshPrimaryExports($server->primary); } } $this->activateResources($provision); $provision->update([ 'status' => ContaboInfrastructureProvision::STATUS_COMPLETED, 'completed_at' => now(), ]); $provision->addLog('Provisioning completed successfully.'); } public function activateResources(ContaboInfrastructureProvision $provision): void { if ($provision->hosting_node_id) { HostingNode::whereKey($provision->hosting_node_id)->update([ 'status' => HostingNode::STATUS_ACTIVE, ]); } if ($provision->email_server_id) { EmailServer::whereKey($provision->email_server_id)->update([ 'status' => EmailServer::STATUS_ACTIVE, ]); } } public function retry(ContaboInfrastructureProvision $provision): void { if ($provision->status !== ContaboInfrastructureProvision::STATUS_FAILED) { return; } $provision->update([ 'status' => ContaboInfrastructureProvision::STATUS_PENDING, 'error_message' => null, 'failed_at' => null, ]); $provision->addLog('Retry requested.'); ProvisionContaboInfrastructureJob::dispatch($provision); } /** * @param array $payload * @param array{public_key: string, private_key: string} $keys */ private function createProvision( string $purpose, string $name, string $productId, string $region, ?string $imageId, array $payload, array $keys, ?int $hostingNodeId = null, ?int $emailServerId = null, ?string $mailDbPassword = null, ?User $actor = null, ): ContaboInfrastructureProvision { return ContaboInfrastructureProvision::create([ 'purpose' => $purpose, 'status' => ContaboInfrastructureProvision::STATUS_PENDING, 'name' => $name, 'contabo_product_id' => $productId, 'region' => $region, 'image_id' => $imageId ?: null, 'completion_token' => Str::random(48), 'payload' => $payload, 'log' => [], 'ssh_public_key' => $keys['public_key'], 'ssh_private_key' => $keys['private_key'], 'generated_mail_db_password' => $mailDbPassword, 'hosting_node_id' => $hostingNodeId, 'email_server_id' => $emailServerId, 'created_by' => $actor?->id, ]); } private function assertEnabled(): void { if (! $this->isEnabled()) { throw new \RuntimeException('Contabo infrastructure provisioning is not configured. Set CONTABO_* credentials in .env.'); } } }