Files
portal-api/app/Jobs/VirtualMachine/Provision/Concerns/ManagesIpReservation.php
baodev0andClaude Opus 5.5 46aa138eec feat(admin): IP pool allocation, billing exports, service attachments
- IP pool: sheet import (xlsx/csv) matched by address, bulk allocate/revoke
  with notification email (ip_allocated/ip_revoked), ip_pool_logs history.
- Orders/invoices: Excel export on branded template (BrandedReportWriter),
  shared billing filters, order item service info in list responses.
- Web design quote Excel export; activity log export.
- Service attachments with copy-to-invoice; deployment invoice attachments.
- Customer email attachments stored on disk and attached by path.
- Admin password change endpoint; user.fullname backfill migration;
  orders.user_id backfill from logs.
- Disable deploy workflow until CI/CD is set up.
- Ignore stored customer email and service attachment uploads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:58:36 +07:00

193 lines
7.1 KiB
PHP

<?php
namespace App\Jobs\VirtualMachine\Provision\Concerns;
use App\Models\IpPool;
use App\Models\ModuleIpAddress;
use App\Models\IpPoolLog;
use App\Models\ModuleVmware;
use App\Models\Service;
use App\Models\VirtualMachines;
use App\Services\VirtualMachine\VmProvisionLogger;
use Illuminate\Support\Facades\DB;
trait ManagesIpReservation
{
/**
* Reserve a free IP from the pool inside a DB transaction with row-level locks.
*
* Strategy:
* 1. Lock all existing ModuleIpAddress rows for the network → prevents double-pick
* 2. Lock the chosen IpPool row → prevents concurrent reservations
* 3. Create a pending ModuleIpAddress (status=0) to hold the slot
*
* Returns the pending ModuleIpAddress record, or null if pool is exhausted.
*/
/**
* @return array{reservation:ModuleIpAddress,ip_pool_id:int,gateway:string,cidr:int}|null
*/
private function reserveIp(
int $networkId,
int $hostId,
string $dns,
): ?array {
return DB::transaction(function () use ($networkId, $hostId, $dns) {
// ip_pools.status is the single source of truth for availability.
$ipPool = IpPool::where('module_network_id', $networkId)
->whereIn('status', ['free', 'reserved'])
->lockForUpdate()
->inRandomOrder()
->first();
if (!$ipPool) {
return null;
}
// Hold the pool slot
$ipPool->update(['status' => 'reserved']);
$cidr = (int) ($ipPool->cidr ?: 24);
$gateway = (string) ($ipPool->gateway ?? '');
$netmask = $ipPool->netmask ?: $this->cidrToNetmask($cidr);
// module_ip_address mirrors ip_pools — update the existing synced row
// (created when the IP was added to ip_pools) instead of inserting a new one.
$reservation = ModuleIpAddress::where('ip', $ipPool->ip_address)
->where('module_network_id', $networkId)
->lockForUpdate()
->first();
$attrs = [
'vm_name' => '__pending__',
'module_host_id' => $hostId,
'module_network_id' => $networkId,
'ip' => $ipPool->ip_address,
'gateway' => $gateway,
'netmask' => $netmask,
'dns' => $dns,
'status' => 0, // 0 = pending/reserved, not yet confirmed
];
if ($reservation) {
$reservation->update($attrs);
} else {
$reservation = ModuleIpAddress::create($attrs);
}
return [
'reservation' => $reservation,
'ip_pool_id' => $ipPool->id,
'gateway' => $gateway,
'cidr' => $cidr,
];
});
}
/**
* Load a reservation that was created upstream (e.g. by CreateVirtualMachineJob).
* Returns the same shape as reserveIp() so the rest of the flow is identical.
*
* @return array{reservation:ModuleIpAddress,ip_pool_id:int,gateway:string,cidr:int}|null
*/
private function loadReservation(int $reservationId, ?int $ipPoolId, VmProvisionLogger $logger): ?array
{
$reservation = ModuleIpAddress::find($reservationId);
if (!$reservation) {
$logger->error('upstream reservation missing', [
'reservation_id' => $reservationId,
]);
return null;
}
$ipPool = $ipPoolId ? IpPool::find($ipPoolId) : null;
$cidr = (int) ($ipPool->cidr ?? 24);
$gateway = (string) ($reservation->gateway ?: ($ipPool->gateway ?? ''));
return [
'reservation' => $reservation,
'ip_pool_id' => $ipPoolId ?? 0,
'gateway' => $gateway,
'cidr' => $cidr ?: 24,
];
}
/**
* Release a failed reservation by resetting the mirrored ModuleIpAddress row.
* This returns the IP to the available pool.
*/
private function releaseIp(ModuleIpAddress $reservation, ?int $ipPoolId, VmProvisionLogger $logger): void
{
try {
$reservation->update(['vm_name' => null, 'status' => 0]);
if ($ipPoolId) {
IpPool::where('id', $ipPoolId)->update(['status' => 'free']);
}
$logger->step('IP reservation released', ['ip' => $reservation->ip]);
} catch (\Throwable $e) {
$logger->warning('could not release IP reservation', [
'ip' => $reservation->ip,
'error' => $e->getMessage(),
]);
}
}
/**
* Confirm a successful IP assignment:
* - Update ModuleIpAddress: status → 1 (assigned), vm_name → real name
* - Update ModuleVmware: ip, dns, module_ip_address FK
* - Update VirtualMachines: ip, ip_pool_id FK (matched by vmId)
*
* IP is only written to DB AFTER guest-side verification passes.
*/
private function persistIp(
ModuleIpAddress $reservation,
ModuleVmware $moduleVmware,
?int $ipPoolId,
?int $serviceId,
): void {
DB::transaction(function () use ($reservation, $moduleVmware, $ipPoolId, $serviceId) {
$reservation->update([
'vm_name' => $moduleVmware->name ?? ('VM-' . $this->vmId),
'status' => 1, // assigned
]);
$moduleVmware->update([
'ip' => $reservation->ip,
'dns' => $reservation->dns,
'module_ip_address' => $reservation->id,
]);
// Sync to virtual_machines — matched by vSphere vmId
VirtualMachines::where('vm_id', $this->vmId)->update([
'ip' => $reservation->ip,
'ip_pool_id' => $ipPoolId,
]);
// Flip ip_pools row to assigned and link service
if ($ipPoolId) {
$statusFrom = (string) IpPool::whereKey($ipPoolId)->value('status');
IpPool::where('id', $ipPoolId)->update([
'status' => 'assigned',
'service_id' => $serviceId,
]);
IpPoolLog::recordVm($ipPoolId, IpPoolLog::VM_ASSIGNED, $statusFrom, $moduleVmware, 'Tạo VM');
}
// Touch services row so server_name reflects the provisioned VM.
// Note: services.vm_id is an FK to module_vmware.id (already set by VmServiceSyncService) — do not overwrite with vSphere vmId string.
if ($serviceId) {
Service::where('id', $serviceId)->update([
'server_name' => $moduleVmware->name,
]);
}
});
}
/** Convert CIDR prefix length to dotted-decimal netmask (e.g. 24 → 255.255.255.0). */
private function cidrToNetmask(int $cidr): string
{
$cidr = max(0, min(32, $cidr));
return long2ip(-1 << (32 - $cidr));
}
}