263 lines
9.5 KiB
PHP
263 lines
9.5 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Hosting\Provisioning;
|
|
|
|
use App\Exceptions\Hosting\ProvisioningException;
|
|
use App\Models\Customer;
|
|
use App\Models\IpPool;
|
|
use App\Models\VmTemplate;
|
|
use App\Services\Hosting\DTO\CustomerProvisionData;
|
|
use App\Services\Hosting\Plesk\PleskClient;
|
|
use App\Services\Hosting\Proxmox\ProxmoxClient;
|
|
use App\Services\Hosting\Traefik\TraefikGenerator;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class ProvisioningService
|
|
{
|
|
public function __construct(
|
|
private readonly ProxmoxClient $proxmox,
|
|
private readonly PleskClient $plesk,
|
|
private readonly TraefikGenerator $traefik,
|
|
private readonly IpAddressAllocator $ipAllocator,
|
|
private readonly VmidReservationService $vmidReservation,
|
|
) {}
|
|
|
|
public function provision(Customer $customer, CustomerProvisionData $data): Customer
|
|
{
|
|
$rollback = new ProvisioningRollback($this->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]);
|
|
}
|
|
}
|