initial commit
This commit is contained in:
86
app/Services/Hosting/Provisioning/DeprovisionService.php
Normal file
86
app/Services/Hosting/Provisioning/DeprovisionService.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Hosting\Provisioning;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\User;
|
||||
use App\Models\VmActivityLog;
|
||||
use App\Services\Hosting\Proxmox\ProxmoxClient;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class DeprovisionService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProvisioningService $provisioning,
|
||||
private readonly ProxmoxClient $proxmox,
|
||||
private readonly VmidReservationService $vmidReservation,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Endgültige Löschung (WHMCS Terminate / Kunde nicht verlängert).
|
||||
*/
|
||||
public function terminatePermanently(Customer $customer, ?User $actor = null): void
|
||||
{
|
||||
Log::info('Permanent deprovision started', ['customer_id' => $customer->id]);
|
||||
|
||||
$this->deleteBackups($customer);
|
||||
|
||||
$customerId = $customer->id;
|
||||
$vmid = $customer->vmid;
|
||||
|
||||
if ($actor) {
|
||||
VmActivityLog::query()->create([
|
||||
'customer_id' => $customerId,
|
||||
'user_id' => $actor->id,
|
||||
'action' => 'terminate.permanent',
|
||||
'status' => 'success',
|
||||
]);
|
||||
}
|
||||
|
||||
$this->provisioning->deprovision($customer);
|
||||
|
||||
if ($vmid) {
|
||||
$this->vmidReservation->scheduleRelease((int) $vmid, $customer);
|
||||
}
|
||||
|
||||
$customer->devices()->delete();
|
||||
$customer->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* VM entfernen, Kundeneintrag bleibt (Support-Fall).
|
||||
*/
|
||||
public function removeVmOnly(Customer $customer, ?User $actor = null): void
|
||||
{
|
||||
$this->provisioning->deprovision($customer);
|
||||
|
||||
if ($customer->vmid) {
|
||||
$this->vmidReservation->scheduleRelease((int) $customer->vmid, $customer);
|
||||
}
|
||||
|
||||
$customer->update([
|
||||
'status' => 'failed',
|
||||
'provisioning_step' => 'deprovisioned',
|
||||
'vmid' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function deleteBackups(Customer $customer): void
|
||||
{
|
||||
if (! $customer->vmid) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! config('hosting.backups.enabled', false)) {
|
||||
Log::info('Backup deletion skipped (PBS not enabled)', ['vmid' => $customer->vmid]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// PBS-Integration folgt in Phase 2, wenn API-Rechte auf inett-PBS vorhanden
|
||||
Log::warning('PBS backup purge pending implementation', [
|
||||
'vmid' => $customer->vmid,
|
||||
'storage' => config('hosting.backups.pbs_storage'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
162
app/Services/Hosting/Provisioning/IpAddressAllocator.php
Normal file
162
app/Services/Hosting/Provisioning/IpAddressAllocator.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Hosting\Provisioning;
|
||||
|
||||
use App\Enums\IpPoolType;
|
||||
use App\Exceptions\Hosting\ProvisioningException;
|
||||
use App\Models\Customer;
|
||||
use App\Models\IpPool;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class IpAddressAllocator
|
||||
{
|
||||
public function allocateFromPool(?IpPool $pool = null, ?string $preferred = null): string
|
||||
{
|
||||
$pool ??= $this->defaultPrivatePool();
|
||||
|
||||
return $this->allocate($pool, $preferred);
|
||||
}
|
||||
|
||||
public function allocatePublicIp(?IpPool $pool = null, ?string $preferred = null): string
|
||||
{
|
||||
$pool ??= IpPool::query()
|
||||
->where('type', IpPoolType::Public)
|
||||
->where('is_active', true)
|
||||
->first() ?? $this->bootstrapPublicPool();
|
||||
|
||||
if ($pool->type !== IpPoolType::Public) {
|
||||
throw new ProvisioningException('Selected pool is not a public IP pool.', step: 'ip_allocation');
|
||||
}
|
||||
|
||||
return $this->allocate($pool, $preferred, 'public_ip');
|
||||
}
|
||||
|
||||
public function allocate(IpPool $pool, ?string $preferred = null, string $column = 'ip_address'): string
|
||||
{
|
||||
return DB::transaction(function () use ($pool, $preferred, $column) {
|
||||
IpPool::query()->whereKey($pool->id)->lockForUpdate()->first();
|
||||
|
||||
if ($preferred !== null) {
|
||||
if (! $pool->containsIp($preferred)) {
|
||||
throw new ProvisioningException(
|
||||
"IP {$preferred} is outside pool {$pool->name}.",
|
||||
step: 'ip_allocation',
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->isIpUsed($preferred, $pool, $column)) {
|
||||
throw new ProvisioningException(
|
||||
"IP address {$preferred} is already in use.",
|
||||
step: 'ip_allocation',
|
||||
);
|
||||
}
|
||||
|
||||
return $preferred;
|
||||
}
|
||||
|
||||
$start = ip2long($pool->start_ip);
|
||||
$end = ip2long($pool->end_ip);
|
||||
|
||||
if ($start === false || $end === false || $start > $end) {
|
||||
throw new ProvisioningException('Invalid IP pool range.', step: 'ip_allocation');
|
||||
}
|
||||
|
||||
$used = $this->usedIpsInPool($pool, $column);
|
||||
|
||||
for ($long = $start; $long <= $end; $long++) {
|
||||
if (! isset($used[$long])) {
|
||||
return long2ip($long);
|
||||
}
|
||||
}
|
||||
|
||||
throw new ProvisioningException("No free IPs in pool {$pool->name}.", step: 'ip_allocation');
|
||||
}, 3);
|
||||
}
|
||||
|
||||
private function defaultPrivatePool(): IpPool
|
||||
{
|
||||
$pool = IpPool::query()
|
||||
->where('type', IpPoolType::Private)
|
||||
->where('is_active', true)
|
||||
->orderBy('id')
|
||||
->first();
|
||||
|
||||
if ($pool) {
|
||||
return $pool;
|
||||
}
|
||||
|
||||
return $this->bootstrapPoolFromConfig(IpPoolType::Private);
|
||||
}
|
||||
|
||||
private function bootstrapPublicPool(): IpPool
|
||||
{
|
||||
return IpPool::query()->firstOrCreate(
|
||||
['name' => 'Öffentlich 185.45.149.x', 'type' => IpPoolType::Public],
|
||||
[
|
||||
'start_ip' => config('hosting.public_network.ip_pool_start'),
|
||||
'end_ip' => config('hosting.public_network.ip_pool_end'),
|
||||
'gateway' => config('hosting.public_network.gateway'),
|
||||
'cidr' => config('hosting.public_network.cidr'),
|
||||
'is_active' => true,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function bootstrapPoolFromConfig(IpPoolType $type): IpPool
|
||||
{
|
||||
return IpPool::query()->firstOrCreate(
|
||||
['name' => 'Privat 10.32.0.0/24', 'type' => IpPoolType::Private],
|
||||
[
|
||||
'start_ip' => config('hosting.network.ip_pool_start'),
|
||||
'end_ip' => config('hosting.network.ip_pool_end'),
|
||||
'gateway' => config('hosting.network.gateway'),
|
||||
'cidr' => config('hosting.network.cidr'),
|
||||
'is_active' => true,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function usedIpsInPool(IpPool $pool, string $column): array
|
||||
{
|
||||
$query = Customer::query()
|
||||
->where('ip_pool_id', $pool->id)
|
||||
->whereIn('status', ['pending', 'active']);
|
||||
|
||||
if ($column === 'public_ip') {
|
||||
return $query->whereNotNull('public_ip')
|
||||
->pluck('public_ip')
|
||||
->merge(
|
||||
Customer::query()
|
||||
->whereIn('status', ['pending', 'active'])
|
||||
->whereNotNull('public_ip')
|
||||
->pluck('public_ip')
|
||||
)
|
||||
->unique()
|
||||
->map(fn (string $ip) => ip2long($ip))
|
||||
->filter()
|
||||
->flip()
|
||||
->all();
|
||||
}
|
||||
|
||||
return $query->whereNotNull('ip_address')
|
||||
->pluck('ip_address')
|
||||
->map(fn (string $ip) => ip2long($ip))
|
||||
->filter()
|
||||
->flip()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function isIpUsed(string $ip, IpPool $pool, string $column): bool
|
||||
{
|
||||
$check = Customer::query()
|
||||
->whereIn('status', ['pending', 'active']);
|
||||
|
||||
if ($column === 'public_ip') {
|
||||
return (clone $check)->where('public_ip', $ip)->exists()
|
||||
|| (clone $check)->where('ip_address', $ip)->exists();
|
||||
}
|
||||
|
||||
return (clone $check)->where('ip_address', $ip)->exists()
|
||||
|| (clone $check)->where('public_ip', $ip)->exists();
|
||||
}
|
||||
}
|
||||
69
app/Services/Hosting/Provisioning/ProvisioningRollback.php
Normal file
69
app/Services/Hosting/Provisioning/ProvisioningRollback.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Hosting\Provisioning;
|
||||
|
||||
use App\Services\Hosting\Plesk\PleskClient;
|
||||
use App\Services\Hosting\Proxmox\ProxmoxClient;
|
||||
use App\Services\Hosting\Traefik\TraefikGenerator;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ProvisioningRollback
|
||||
{
|
||||
private array $completed = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly ProxmoxClient $proxmox,
|
||||
private readonly PleskClient $plesk,
|
||||
private readonly TraefikGenerator $traefik,
|
||||
) {}
|
||||
|
||||
public function mark(string $step, array $context = []): void
|
||||
{
|
||||
$this->completed[] = ['step' => $step, 'context' => $context];
|
||||
}
|
||||
|
||||
public function execute(): void
|
||||
{
|
||||
Log::warning('Provisioning rollback started', ['steps' => count($this->completed)]);
|
||||
|
||||
foreach (array_reverse($this->completed) as $entry) {
|
||||
try {
|
||||
match ($entry['step']) {
|
||||
'traefik_route' => $this->rollbackTraefik($entry['context']),
|
||||
'plesk_dns' => $this->rollbackDns($entry['context']),
|
||||
'proxmox_vm_started', 'proxmox_vm_created' => $this->rollbackVm($entry['context']),
|
||||
default => null,
|
||||
};
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Rollback step failed', [
|
||||
'step' => $entry['step'],
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function rollbackTraefik(array $context): void
|
||||
{
|
||||
if (! empty($context['domain'])) {
|
||||
$this->traefik->removeCustomerRoute($context['domain']);
|
||||
Log::info('Rollback: Traefik route removed', $context);
|
||||
}
|
||||
}
|
||||
|
||||
private function rollbackDns(array $context): void
|
||||
{
|
||||
if (! empty($context['domain']) && isset($context['subdomain'])) {
|
||||
$this->plesk->deleteARecord($context['domain'], $context['subdomain']);
|
||||
Log::info('Rollback: Plesk DNS removed', $context);
|
||||
}
|
||||
}
|
||||
|
||||
private function rollbackVm(array $context): void
|
||||
{
|
||||
if (! empty($context['vmid'])) {
|
||||
$this->proxmox->deleteVM((int) $context['vmid']);
|
||||
Log::info('Rollback: Proxmox VM deleted', $context);
|
||||
}
|
||||
}
|
||||
}
|
||||
262
app/Services/Hosting/Provisioning/ProvisioningService.php
Normal file
262
app/Services/Hosting/Provisioning/ProvisioningService.php
Normal file
@@ -0,0 +1,262 @@
|
||||
<?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]);
|
||||
}
|
||||
}
|
||||
104
app/Services/Hosting/Provisioning/VmidReservationService.php
Normal file
104
app/Services/Hosting/Provisioning/VmidReservationService.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Hosting\Provisioning;
|
||||
|
||||
use App\Exceptions\Hosting\ProvisioningException;
|
||||
use App\Models\Customer;
|
||||
use App\Models\VmidReservation;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class VmidReservationService
|
||||
{
|
||||
public function reserveForCustomer(Customer $customer): int
|
||||
{
|
||||
return DB::transaction(function () use ($customer) {
|
||||
$vmid = $this->findNextAvailableVmid();
|
||||
|
||||
VmidReservation::query()->create([
|
||||
'vmid' => $vmid,
|
||||
'customer_id' => $customer->id,
|
||||
'status' => 'reserved',
|
||||
]);
|
||||
|
||||
return $vmid;
|
||||
});
|
||||
}
|
||||
|
||||
public function activate(int $vmid, Customer $customer): void
|
||||
{
|
||||
VmidReservation::query()
|
||||
->where('vmid', $vmid)
|
||||
->where('customer_id', $customer->id)
|
||||
->update([
|
||||
'status' => 'active',
|
||||
'release_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function scheduleRelease(int $vmid, ?Customer $customer = null): void
|
||||
{
|
||||
$hours = (int) config('hosting.vmid.release_after_hours', 48);
|
||||
|
||||
$query = VmidReservation::query()->where('vmid', $vmid);
|
||||
|
||||
if ($customer) {
|
||||
$query->where('customer_id', $customer->id);
|
||||
}
|
||||
|
||||
$query->update([
|
||||
'status' => 'pending_release',
|
||||
'release_at' => now()->addHours($hours),
|
||||
]);
|
||||
}
|
||||
|
||||
public function releaseDue(): int
|
||||
{
|
||||
$count = 0;
|
||||
|
||||
$due = VmidReservation::query()
|
||||
->where('status', 'pending_release')
|
||||
->where('release_at', '<=', now())
|
||||
->get();
|
||||
|
||||
foreach ($due as $reservation) {
|
||||
$reservation->update([
|
||||
'status' => 'released',
|
||||
'released_at' => now(),
|
||||
]);
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
public function isVmidBlocked(int $vmid): bool
|
||||
{
|
||||
return VmidReservation::query()
|
||||
->where('vmid', $vmid)
|
||||
->whereIn('status', ['reserved', 'active', 'pending_release'])
|
||||
->exists();
|
||||
}
|
||||
|
||||
private function findNextAvailableVmid(): int
|
||||
{
|
||||
$start = (int) config('hosting.vmid.range_start', 2000);
|
||||
$end = (int) config('hosting.vmid.range_end', 2999);
|
||||
|
||||
$blocked = VmidReservation::query()
|
||||
->whereIn('status', ['reserved', 'active', 'pending_release'])
|
||||
->pluck('vmid')
|
||||
->flip()
|
||||
->all();
|
||||
|
||||
for ($vmid = $start; $vmid <= $end; $vmid++) {
|
||||
if (! isset($blocked[$vmid])) {
|
||||
return $vmid;
|
||||
}
|
||||
}
|
||||
|
||||
throw new ProvisioningException(
|
||||
"No free VMID in range {$start}-{$end}.",
|
||||
step: 'reserving_vmid',
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user