368 lines
15 KiB
PHP
368 lines
15 KiB
PHP
<?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, vCenter $vCenterService): 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]);
|
||
|
||
// Still send credential email — try to get IP from vSphere guest identity
|
||
// so the customer at least gets server info even if IP assignment failed
|
||
try {
|
||
$moduleVmware = ModuleVmware::find($this->moduleVmwareId);
|
||
if ($moduleVmware && !$moduleVmware->ip) {
|
||
$vcenter = $moduleVmware->vCenter ?? vCenter::first();
|
||
if ($vcenter && $vCenterService->loginServer($vcenter)) {
|
||
$guestInfo = $vCenterService->GetIPAddressVirtualMachine($this->vmId);
|
||
if (isset($guestInfo['error']) && $guestInfo['error'] === false) {
|
||
$guestIp = $guestInfo['data']['ip_address'] ?? null;
|
||
if ($guestIp) {
|
||
$moduleVmware->update(['ip' => $guestIp, 'status' => 1]);
|
||
Log::info('AssignIpToGuestJob: recovered IP from guest identity', [
|
||
'module_vmware_id' => $this->moduleVmwareId,
|
||
'ip' => $guestIp,
|
||
]);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
SendVmCredentialsEmailJob::dispatch($this->moduleVmwareId);
|
||
Log::info('AssignIpToGuestJob: dispatched SendVmCredentialsEmailJob after failed attempts', [
|
||
'module_vmware_id' => $this->moduleVmwareId,
|
||
]);
|
||
} catch (\Throwable $e) {
|
||
Log::error('AssignIpToGuestJob: failed to send email after exhausted attempts', [
|
||
'module_vmware_id' => $this->moduleVmwareId,
|
||
'error' => $e->getMessage(),
|
||
]);
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|