Files
portal-api/app/Jobs/VirtualMachine/Provision/AssignIpToGuestJob.php
T

336 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Jobs\VirtualMachine\Provision;
use App\Http\Facade\SignRSA;
use App\Models\IpPool;
use App\Models\ModuleIpAddress;
use App\Models\ModuleVmware;
use App\Models\Os;
use App\Models\vCenter;
use App\Services\VsphereHostSshService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* AssignIpToGuestJob
*
* Assigns a real static IP to the VM guest OS and verifies it is active.
* Only persists the IP to DB after successful guest-side verification.
*
* Flow:
* [1] Resolve network config from order item → product → ipAddress template
* [2] for attempt 1..MAX_ATTEMPTS:
* [3] Pick a free IP with DB transaction + lock (reserveIp)
* [4] Pre-check: ping IP — if reachable → release and try next
* [5] Call assignIPLinux.ps1 / assignIPWindows.ps1 via SSH
* [6] On FAIL → release IP, try next
* [7] Call verifyIPLinux.ps1 / verifyIPWindows.ps1 via SSH
* [8] On FAIL → release IP, try next
* [9] SUCCESS → persistIp() → dispatch SendVmCredentialsEmailJob
* [10] All attempts exhausted → mark VM inactive (status=0)
*/
class AssignIpToGuestJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/** All retries handled internally — no Laravel-level retry needed */
public int $tries = 1;
/** 5 attempts × ~90 s each + overhead */
public int $timeout = 600;
private const MAX_ATTEMPTS = 5;
public function __construct(
public readonly int $moduleVmwareId,
public readonly string $vmId,
public readonly string $osFamily, // VmProvisionService::OS_LINUX | OS_WINDOWS
public readonly string $guestPassword, // New password from ChangePasswordViaVsphereJob
) {}
// ─────────────────────────────────────────────────────────────────────────
public function handle(VsphereHostSshService $ssh): void
{
Log::info('AssignIpToGuestJob: start', [
'module_vmware_id' => $this->moduleVmwareId,
'vm_id' => $this->vmId,
'os_family' => $this->osFamily,
]);
// Load ModuleVmware with the relations needed to derive network config
$moduleVmware = ModuleVmware::with([
'order.orderItems.product.ipAddress',
'vcenter',
])->find($this->moduleVmwareId);
if (!$moduleVmware) {
Log::error('AssignIpToGuestJob: ModuleVmware not found', [
'module_vmware_id' => $this->moduleVmwareId,
]);
return;
}
// Resolve network config: product template IP → gateway, netmask, dns, networkId
$orderItem = $moduleVmware->order?->orderItems?->first();
$osModel = $orderItem?->os_id ? Os::with('osGroup.host')->find($orderItem->os_id) : null;
$networkId = $orderItem?->product?->ipAddress?->module_network_id ?? 1;
$gateway = $orderItem?->product?->ipAddress?->gateway ?? '0.0.0.0';
$netmask = $orderItem?->product?->ipAddress?->netmask ?? '255.255.255.0';
$dns = $orderItem?->product?->ipAddress?->dns ?? '8.8.8.8';
$hostId = $osModel?->osGroup?->host?->id ?? 1;
$cidr = (string) $this->netmaskToCidr($netmask);
// vCenter credentials
$vcenter = $moduleVmware->vcenter ?? vCenter::first();
$vcenterHost = $vcenter->host;
$vcenterUser = $vcenter->user;
$vcenterPass = SignRSA::DecryptFunc($vcenter->password);
// ─── Attempt loop ────────────────────────────────────────────────────
for ($attempt = 1; $attempt <= self::MAX_ATTEMPTS; $attempt++) {
Log::info('AssignIpToGuestJob: attempt', [
'attempt' => $attempt,
'module_vmware_id' => $this->moduleVmwareId,
'network_id' => $networkId,
]);
// [3] Reserve a free IP with transaction + row lock
$reservation = $this->reserveIp($networkId, $hostId, $gateway, $netmask, $dns);
if (!$reservation) {
Log::error('AssignIpToGuestJob: no free IPs in pool', [
'network_id' => $networkId,
'attempt' => $attempt,
]);
break; // No point retrying — no IPs available
}
$ip = $reservation->ip;
// [4] Pre-check: skip IPs that are already reachable (in use by another host)
if ($this->isIpReachable($ip)) {
Log::warning('AssignIpToGuestJob: IP reachable (likely in use), skipping', [
'ip' => $ip,
'attempt' => $attempt,
]);
$this->releaseIp($reservation);
continue;
}
// [5] Assign IP into VM guest OS via PS1 script
$assignOutput = $ssh->assignIp(
vmId: $this->vmId,
ip: $ip,
gateway: $gateway,
cidr: $cidr,
vcenterHost: $vcenterHost,
vcenterUser: $vcenterUser,
vcenterPassword: $vcenterPass,
guestPassword: $this->guestPassword,
osFamily: $this->osFamily,
);
// [6] If assign failed → release and try next IP
if (!$this->isSuccess($assignOutput)) {
Log::warning('AssignIpToGuestJob: assignIp FAIL', [
'ip' => $ip,
'attempt' => $attempt,
'output' => trim($assignOutput),
]);
$this->releaseIp($reservation);
continue;
}
// [7] Verify IP is active inside guest OS via PS1 script
$verifyOutput = $ssh->verifyIpInGuest(
vmId: $this->vmId,
ip: $ip,
vcenterHost: $vcenterHost,
vcenterUser: $vcenterUser,
vcenterPassword: $vcenterPass,
guestPassword: $this->guestPassword,
osFamily: $this->osFamily,
);
// [8] If verification failed → release and try next IP
if (!$this->isSuccess($verifyOutput)) {
Log::warning('AssignIpToGuestJob: verifyIpInGuest FAIL', [
'ip' => $ip,
'attempt' => $attempt,
'output' => trim($verifyOutput),
]);
$this->releaseIp($reservation);
continue;
}
// [9] SUCCESS — persist confirmed IP to DB
$this->persistIp($reservation, $moduleVmware);
Log::info('AssignIpToGuestJob: IP assigned and verified', [
'module_vmware_id' => $this->moduleVmwareId,
'vm_id' => $this->vmId,
'ip' => $ip,
'attempt' => $attempt,
]);
// Dispatch credential email now that we have the real IP
SendVmCredentialsEmailJob::dispatch($this->moduleVmwareId);
return;
}
// [10] All attempts exhausted
Log::error('AssignIpToGuestJob: all attempts failed — marking VM inactive', [
'module_vmware_id' => $this->moduleVmwareId,
'vm_id' => $this->vmId,
'attempts' => self::MAX_ATTEMPTS,
]);
ModuleVmware::where('id', $this->moduleVmwareId)->update(['status' => 0]);
}
public function failed(\Throwable $e): void
{
Log::error('AssignIpToGuestJob: job failed with exception', [
'module_vmware_id' => $this->moduleVmwareId,
'vm_id' => $this->vmId,
'error' => $e->getMessage(),
]);
ModuleVmware::where('id', $this->moduleVmwareId)->update(['status' => 0]);
}
// ─────────────────────────────────────────────────────────────────────────
// assignIp / verifyIp helpers
// ─────────────────────────────────────────────────────────────────────────
/**
* 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.
*/
private function reserveIp(
int $networkId,
int $hostId,
string $gateway,
string $netmask,
string $dns,
): ?ModuleIpAddress {
return DB::transaction(function () use ($networkId, $hostId, $gateway, $netmask, $dns) {
// Lock existing rows to prevent another job from picking the same IP
$usedIps = ModuleIpAddress::where('module_network_id', $networkId)
->lockForUpdate()
->pluck('ip')
->toArray();
$ipPool = IpPool::where('module_network_id', $networkId)
->whereNotIn('ip_address', $usedIps)
->lockForUpdate()
->inRandomOrder()
->first();
if (!$ipPool) {
return null;
}
// Create a pending reservation — acts as a distributed lock on this IP
return ModuleIpAddress::create([
'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
]);
});
}
/**
* Release a failed reservation by deleting the pending ModuleIpAddress row.
* This returns the IP to the available pool.
*/
private function releaseIp(ModuleIpAddress $reservation): void
{
try {
$reservation->delete();
Log::info('AssignIpToGuestJob: IP reservation released', ['ip' => $reservation->ip]);
} catch (\Throwable $e) {
Log::warning('AssignIpToGuestJob: 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
*
* IP is only written to DB AFTER guest-side verification passes.
*/
private function persistIp(ModuleIpAddress $reservation, ModuleVmware $moduleVmware): void
{
DB::transaction(function () use ($reservation, $moduleVmware) {
$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,
]);
});
}
// ─────────────────────────────────────────────────────────────────────────
// Micro-helpers
// ─────────────────────────────────────────────────────────────────────────
/**
* Ping the IP from the Laravel host (1 packet, 1 s timeout).
* Returns true if reachable → the IP is already in use by something else.
*/
private function isIpReachable(string $ip): bool
{
exec(sprintf('ping -c 1 -W 1 %s 2>/dev/null', escapeshellarg($ip)), $output, $exitCode);
return $exitCode === 0;
}
/** PS1 scripts output exactly "SUCCESS" or "FAIL". */
private function isSuccess(string $output): bool
{
return str_contains(strtoupper(trim($output)), 'SUCCESS');
}
/** Convert dotted-decimal netmask to CIDR prefix length (e.g. 255.255.255.0 → 24). */
private function netmaskToCidr(string $netmask): int
{
$bits = 0;
foreach (explode('.', $netmask) as $octet) {
$bits += substr_count(decbin((int) $octet), '1');
}
return $bits;
}
}