proxmox, $this->plesk, $this->traefik); try { return DB::transaction(function () use ($customer, $data, $rollback) { $this->updateStep($customer, 'reserving_vmid', 'pending'); $vmid = $data->vmid ?? $customer->vmid ?? $this->reserveVmid($customer); $customer->update(['vmid' => $vmid]); $this->vmidReservation->activate($vmid, $customer); $pool = $this->resolveIpPool($customer); $gateway = $pool->gateway ?? config('hosting.network.gateway'); $cidr = $pool->cidr ?? (int) config('hosting.network.cidr'); $this->updateStep($customer, 'allocating_ip'); $ip = $data->ip ?? $this->ipAllocator->allocateFromPool($pool); $customer->update(['ip_address' => $ip, 'ip_pool_id' => $pool->id]); $publicIp = null; if (! $data->behind_traefik) { $publicIp = $data->public_ip ?? $this->ipAllocator->allocatePublicIp(); $customer->update(['public_ip' => $publicIp]); } $ipConfig0 = $this->proxmox->buildIpConfig($ip, $gateway, $cidr); $ipConfig1 = null; if ($publicIp) { $publicPool = IpPool::query()->where('type', 'public')->where('is_active', true)->first(); $ipConfig1 = $this->proxmox->buildIpConfig( $publicIp, $publicPool?->gateway, $publicPool?->cidr ?? 32, ); } $vmName = $this->vmName($data->customer_name, $vmid); $this->updateStep($customer, 'creating_vm'); $templateVmid = $this->resolveTemplateVmid($customer); $this->proxmox->createVM( vmid: $vmid, name: $vmName, cpu: $data->cpu, ramMb: $data->ram, diskGb: $data->disk, ipConfig: $ipConfig0, templateVmid: $templateVmid, ); if ($ipConfig1) { $this->proxmox->setCloudInitIps($vmid, $ipConfig0, $ipConfig1, $vmName); } $rollback->mark('proxmox_vm_created', ['vmid' => $vmid]); $customer->load('devices'); if ($customer->devices->isNotEmpty()) { $this->updateStep($customer, 'applying_devices'); $this->proxmox->applyDevices($vmid, $customer->devices); } $this->updateStep($customer, 'starting_vm'); $this->proxmox->startVM($vmid); $rollback->mark('proxmox_vm_started', ['vmid' => $vmid]); if ($customer->attached_iso) { $this->updateStep($customer, 'mounting_iso'); $this->proxmox->mountIso($vmid, $customer->attached_iso); } if ($data->requiresTraefik()) { $subdomain = $data->subdomain(); $this->updateStep($customer, 'creating_dns'); $this->plesk->createARecord($data->domain, $subdomain, $ip); $rollback->mark('plesk_dns', [ 'domain' => $data->domain, 'subdomain' => $subdomain, ]); $this->updateStep($customer, 'configuring_traefik'); $this->traefik->addCustomerRoute($data->domain, $ip); $rollback->mark('traefik_route', ['domain' => $data->domain]); $this->updateStep($customer, 'reloading_traefik'); $this->traefik->reload(); } try { $live = $this->proxmox->normalizeLiveStatus($this->proxmox->getVMStatus($vmid)); $customer->update([ 'status' => 'active', 'provisioning_step' => 'completed', 'error_message' => null, 'proxmox_status' => $live['status'], 'proxmox_uptime' => $live['uptime'], 'proxmox_status_at' => now(), ]); } catch (\Throwable) { $customer->update([ 'status' => 'active', 'provisioning_step' => 'completed', 'error_message' => null, ]); } Log::info('Provisioning completed', [ 'customer_id' => $customer->id, 'vmid' => $vmid, 'domain' => $data->domain, 'ip' => $ip, 'public_ip' => $publicIp, ]); return $customer->fresh(); }); } catch (\Throwable $e) { Log::error('Provisioning failed', [ 'customer_id' => $customer->id, 'step' => $customer->provisioning_step, 'error' => $e->getMessage(), ]); $rollback->execute(); $customer->update([ 'status' => 'failed', 'error_message' => $e->getMessage(), ]); if ($e instanceof ProvisioningException) { throw $e; } throw new ProvisioningException( $e->getMessage(), step: $customer->provisioning_step, previous: $e, ); } } public function deprovision(Customer $customer): void { if ($customer->domain && $customer->behind_traefik) { try { $this->traefik->removeCustomerRoute($customer->domain); $this->traefik->reload(); } catch (\Throwable $e) { Log::warning('Deprovision: Traefik cleanup failed', ['error' => $e->getMessage()]); } try { $data = CustomerProvisionData::fromCustomer($customer); $this->plesk->deleteARecord($customer->domain, $data->subdomain()); } catch (\Throwable $e) { Log::warning('Deprovision: DNS cleanup failed', ['error' => $e->getMessage()]); } } if ($customer->vmid) { $this->proxmox->deleteVM((int) $customer->vmid); } $customer->update([ 'status' => 'failed', 'provisioning_step' => 'deprovisioned', 'error_message' => null, ]); } private function resolveIpPool(Customer $customer): IpPool { if ($customer->ip_pool_id) { $pool = IpPool::query()->find($customer->ip_pool_id); if ($pool && $pool->is_active) { return $pool; } } return IpPool::query() ->where('type', 'private') ->where('is_active', true) ->orderBy('id') ->firstOrFail(); } private function reserveVmid(Customer $customer): int { if ($customer->vmid) { $vmid = (int) $customer->vmid; if ($this->vmidReservation->isVmidBlocked($vmid)) { return $vmid; } } $vmid = $this->vmidReservation->reserveForCustomer($customer); if ($this->proxmox->vmExists($vmid)) { throw new ProvisioningException( "VMID {$vmid} already exists in Proxmox.", step: 'reserving_vmid', ); } return $vmid; } private function vmName(string $customerName, int $vmid): string { $slug = preg_replace('/[^a-z0-9-]/', '-', strtolower($customerName)) ?: 'customer'; return substr("host-{$slug}-{$vmid}", 0, 63); } private function resolveTemplateVmid(Customer $customer): ?int { if ($customer->provision_mode === 'empty') { return null; } $fromDb = VmTemplate::query()->where('is_active', true)->orderBy('id')->value('proxmox_template_vmid'); $fromEnv = config('hosting.proxmox.template_vmid'); return $fromDb ? (int) $fromDb : ($fromEnv ? (int) $fromEnv : null); } private function updateStep(Customer $customer, string $step, ?string $status = null): void { $payload = ['provisioning_step' => $step]; if ($status !== null) { $payload['status'] = $status; } $customer->update($payload); Log::info('Provisioning step', ['customer_id' => $customer->id, 'step' => $step]); } }