Files

377 lines
15 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Jobs\VirtualMachine\Provision;
use App\Http\Facade\SignRSA;
use App\Http\Services\Telegram;
use App\Models\Addon;
use App\Models\ModuleVmware;
use App\Models\Service;
use App\Models\ServiceAddon;
use App\Models\vCenter;
use App\Services\VirtualMachine\VmProvisionLogger;
use App\Services\VmProvisionService;
use App\Services\VsphereHostSshService;
use Carbon\Carbon;
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\Http;
use Illuminate\Support\Facades\Log;
/**
* UpdateVmConfigJob
*
* Applies customer-selected CPU/RAM addons (from addons_meta on the OrderItem)
* to the vSphere VM via updateVMConfig.ps1. Runs right after clone, while the
* VM is still powered off — this is also what CreateVirtualMachineJob relies
* on to power the VM on before the next step.
*
* Skipped only if product base cpu/ram AND addon cpu/ram are all zero → just power on, dispatch AssignIpToGuestJob.
*
* PS1 on vSphere host must accept:
* $server_name, $cpu, $memoryGB, $vcenter_host, $vcenter_user, $vcenter_password
* and call Connect-VIServer before Get-VM.
*
* Flow (order-creation path, reservationId set):
* [1] Load ModuleVmware → order → orderItems.first() → product
* [2] Resolve addon cpu/ram from addons_meta (code field saved by OrderService)
* [3] If no cpu/ram addon → power on, dispatch AssignIpToGuestJob
* [4] Load vCenter credentials (same pattern as ChangePasswordViaVsphereJob)
* [5] Compute total: base (product.cpu/ram) + addon qty
* [6] Power off → SSH → vSphere → updateVMConfig.ps1 → power on
* [7] Assert SUCCESS → dispatch AssignIpToGuestJob
*
* Manual-override path (admin panel, reservationId null): same power-off →
* resize → power-on, but only updates DB — no further job is dispatched.
*/
class UpdateVmConfigJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 300;
public int $backoff = 30;
public function __construct(
public readonly int $moduleVmwareId,
public readonly string $vmId,
public readonly string $osFamily,
public readonly ?int $cpuOverride = null,
public readonly ?int $ramOverride = null,
public readonly ?int $reservationId = null,
public readonly ?int $reservedIpPoolId = null,
) {}
public function handle(VsphereHostSshService $ssh, VmProvisionLogger $logger, Telegram $telegram): void
{
$logger->for('UpdateVmConfigJob');
$logger->step('UpdateVmConfigJob started', [
'module_vmware_id' => $this->moduleVmwareId,
'vm_id' => $this->vmId,
]);
$moduleVmware = ModuleVmware::with(['order.orderItems.product', 'vcenter'])->find($this->moduleVmwareId);
if (!$moduleVmware) {
Log::error('UpdateVmConfigJob: ModuleVmware not found', [
'module_vmware_id' => $this->moduleVmwareId,
'vm_id' => $this->vmId,
]);
$this->nextStep();
return;
}
$isManualOverride = $this->cpuOverride !== null || $this->ramOverride !== null;
$addonCpu = $addonRamGb = 0;
$product = null;
if (!$isManualOverride) {
$orderItem = $moduleVmware->order?->orderItems?->first();
$product = $orderItem?->product;
$addonsMeta = is_array($orderItem?->addons_meta) ? $orderItem->addons_meta : [];
[$addonCpu, $addonRamGb] = $this->resolveAddonCpuRam($addonsMeta);
$baseCpu = (int) ($product?->cpu ?? 0);
$baseRam = (int) ($product?->ram ?? 0);
if ($baseCpu === 0 && $baseRam === 0 && $addonCpu === 0 && $addonRamGb === 0) {
$logger->step('no cpu/ram to apply (product+addon) — powering on and skipping resize', [
'module_vmware_id' => $this->moduleVmwareId,
'vm_id' => $this->vmId,
]);
$vcenterForPowerOn = $moduleVmware->vcenter ?? vCenter::first();
if ($vcenterForPowerOn) {
$this->powerCycle($vcenterForPowerOn, (string) $moduleVmware->vmware_id, $logger, powerOff: false);
}
$this->nextStep();
return;
}
}
// Load vCenter credentials — same pattern as ChangePasswordViaVsphereJob
$vcenter = $moduleVmware->vcenter ?? vCenter::first();
if (!$vcenter) {
$msg = "UpdateVmConfigJob: no vCenter record found for server [{$moduleVmware->name}]";
Log::error($msg, ['module_vmware_id' => $this->moduleVmwareId, 'server_name' => $moduleVmware->name]);
$telegram->SendMessage("🚨 <b>UpdateVmConfigJob: Lỗi</b>\n⚠️ Không tìm thấy vCenter\n🖥️ Server: <code>{$moduleVmware->name}</code>\n🕒 " . Carbon::now()->format('d/m/Y H:i:s'));
throw new \RuntimeException($msg);
}
$vcenterHost = $vcenter->host;
$vcenterUser = $vcenter->user;
$vcenterPass = SignRSA::DecryptFunc($vcenter->password);
$serverName = (string) $moduleVmware->name;
if ($isManualOverride) {
$totalCpu = $this->cpuOverride ?? (int) $moduleVmware->cpu;
$totalRam = $this->ramOverride ?? (int) ($moduleVmware->ram_mb / 1024);
} else {
$totalCpu = ((int) ($product?->cpu ?? 0)) + $addonCpu;
$totalRam = ((int) ($product?->ram ?? 0)) + $addonRamGb;
}
$logger->step('calling updateVMConfig.ps1', [
'module_vmware_id' => $this->moduleVmwareId,
'server_name' => $serverName,
'vcenter_host' => $vcenterHost,
'manual_override' => $isManualOverride,
'total_cpu' => $totalCpu,
'total_ram_gb' => $totalRam,
]);
// Power off before resizing (both the create-flow addon path and admin
// manual overrides) — hot CPU/RAM resize isn't reliable across guest
// OSes and is what caused Set-VM to reject the config as invalid.
$this->powerCycle($vcenter, (string) $moduleVmware->vmware_id, $logger, powerOff: true);
try {
$output = $ssh->updateVmConfig(
$serverName,
$totalCpu,
$totalRam,
$vcenterHost,
$vcenterUser,
$vcenterPass,
);
} finally {
$this->powerCycle($vcenter, (string) $moduleVmware->vmware_id, $logger, powerOff: false);
}
Log::info('UpdateVmConfigJob: PS1 output', [
'module_vmware_id' => $this->moduleVmwareId,
'server_name' => $serverName,
'output' => trim($output),
]);
if (!str_contains(strtoupper(trim($output)), 'SUCCESS')) {
$msg = "updateVMConfig.ps1 failed for server [{$serverName}]. Output: " . trim($output);
Log::error('UpdateVmConfigJob: script returned FAIL', [
'module_vmware_id' => $this->moduleVmwareId,
'server_name' => $serverName,
'total_cpu' => $totalCpu,
'total_ram_gb' => $totalRam,
'output' => trim($output),
'attempt' => $this->attempts(),
'max_tries' => $this->tries,
]);
$telegram->SendMessage(
"⚠️ <b>UpdateVmConfigJob thất bại</b>\n" .
"🖥️ Server: <code>{$serverName}</code>\n" .
"🔁 Lần thử: {$this->attempts()}/{$this->tries}\n" .
"💻 CPU: {$totalCpu} | RAM: {$totalRam} GB\n" .
"📋 Output: <code>" . htmlspecialchars(trim($output) ?: '(trống)') . "</code>\n" .
"🕒 " . Carbon::now()->format('d/m/Y H:i:s')
);
throw new \RuntimeException($msg);
}
$logger->success('VM config updated', [
'module_vmware_id' => $this->moduleVmwareId,
'vm_id' => $this->vmId,
'total_cpu' => $totalCpu,
'total_ram_gb' => $totalRam,
'output' => trim($output),
]);
if ($isManualOverride) {
$moduleVmware->update(['cpu' => $totalCpu, 'ram_mb' => $totalRam * 1024]);
$this->syncServiceAddons($moduleVmware, $totalCpu, $totalRam, $logger);
return;
}
$this->nextStep();
}
public function failed(\Throwable $e): void
{
Log::error('UpdateVmConfigJob: all retries exhausted', [
'module_vmware_id' => $this->moduleVmwareId,
'vm_id' => $this->vmId,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
ModuleVmware::where('id', $this->moduleVmwareId)->update(['status' => 0]);
Service::where('vm_id', $this->moduleVmwareId)->update(['status' => 'failed']);
try {
$telegram = app(Telegram::class);
$vm = ModuleVmware::with(['order.customer'])->find($this->moduleVmwareId);
$customer = $vm?->order?->customer;
$telegram->SendMessage(
"🚨 <b>UpdateVmConfigJob: Thất bại hoàn toàn</b>\n" .
"━━━━━━━━━━━━━━━━━━━━\n" .
"🖥️ VM ID: <code>{$this->vmId}</code>\n" .
"📦 Server: <code>" . ($vm?->name ?? 'N/A') . "</code>\n" .
"👤 Khách hàng: " . ($customer ? trim($customer->firstname . ' ' . $customer->lastname) : 'N/A') . "\n" .
"📧 Email: " . ($customer?->email ?? 'N/A') . "\n" .
"❌ Lỗi: <code>" . htmlspecialchars($e->getMessage()) . "</code>\n" .
"⚡ Trạng thái: VM đã bị đánh dấu inactive\n" .
"🕒 " . Carbon::now()->format('d/m/Y H:i:s') . "\n" .
"━━━━━━━━━━━━━━━━━━━━\n" .
"⚠️ Vui lòng kiểm tra và xử lý thủ công!"
);
} catch (\Throwable $te) {
Log::warning('UpdateVmConfigJob: could not send Telegram failure notification', [
'error' => $te->getMessage(),
]);
}
}
// ─────────────────────────────────────────────────────────────────────────
/**
* Resolve cpu/ram qty from addons_meta.
* Uses 'code' field saved by OrderService::resolveAddons() — no extra DB query needed.
* Falls back to Addon::find() if 'code' is missing (legacy meta without code).
*
* @param array<int, array{addon_id?:int, qty?:int, code?:string}> $addonsMeta
* @return array{int, int} [cpu_count, ram_gb]
*/
private function resolveAddonCpuRam(array $addonsMeta): array
{
$cpu = 0;
$ram = 0;
foreach ($addonsMeta as $meta) {
$qty = (int) ($meta['qty'] ?? 0);
$code = strtolower((string) ($meta['code'] ?? ''));
// Fallback: look up code from DB if not in meta (old orders)
if ($code === '' && isset($meta['addon_id'])) {
$addon = Addon::find($meta['addon_id']);
$code = strtolower((string) ($addon?->code ?? ''));
}
if ($code === 'cpu') {
$cpu += $qty;
} elseif ($code === 'ram') {
$ram += $qty;
}
}
return [$cpu, $ram];
}
/**
* Power off/on the VM via vCenter REST and wait (up to 30s) for the state to land.
* Hot-add isn't reliable for CPU/RAM resize on every guest OS, so we
* power-cycle around the resize (and around the power-on-only skip path)
* to guarantee the config applies and the VM ends up running either way.
*/
private function powerCycle(vCenter $vcenter, string $vmwareId, VmProvisionLogger $logger, bool $powerOff): void
{
if ($vmwareId === '') {
return;
}
$service = new \App\Http\Services\vCenter();
if (!$service->loginServer($vcenter)) {
Log::warning('UpdateVmConfigJob: cannot login to vCenter for power cycle', ['vmware_id' => $vmwareId]);
return;
}
$action = $powerOff ? 'stop' : 'start';
$targetState = $powerOff ? 'POWERED_OFF' : 'POWERED_ON';
Http::withoutVerifying()
->withHeaders(['vmware-api-session-id' => $service->token])
->post("https://{$service->apiUri}/rest/vcenter/vm/{$vmwareId}/power/{$action}");
for ($i = 0; $i < 15; $i++) {
sleep(2);
$state = Http::withoutVerifying()
->withHeaders(['vmware-api-session-id' => $service->token])
->get("https://{$service->apiUri}/rest/vcenter/vm/{$vmwareId}")
->json('value.power_state');
if ($state === $targetState) {
$logger->step("power cycle reached {$targetState}", ['vmware_id' => $vmwareId]);
return;
}
}
$logger->step("power cycle timed out waiting for {$targetState}", ['vmware_id' => $vmwareId]);
}
/**
* Reflect the new CPU/RAM on the service's addon rows (service_addons.addon_value)
* so billing/admin views match what's actually running on the VM.
*/
private function syncServiceAddons(ModuleVmware $moduleVmware, int $totalCpu, int $totalRam, VmProvisionLogger $logger): void
{
$service = Service::where('vm_id', $moduleVmware->id)->first();
if (!$service) {
$logger->step('syncServiceAddons: no service found for vm_id — skipping', [
'module_vmware_id' => $moduleVmware->id,
]);
return;
}
ServiceAddon::updateOrCreate(
['service_id' => $service->id, 'addon_type' => 'cpu'],
['addon_value' => (string) $totalCpu],
);
ServiceAddon::updateOrCreate(
['service_id' => $service->id, 'addon_type' => 'ram'],
['addon_value' => (string) ($totalRam * 1024)],
);
$logger->step('service_addons synced', [
'service_id' => $service->id,
'total_cpu' => $totalCpu,
'total_ram_mb' => $totalRam * 1024,
]);
}
private function nextStep(): void
{
AssignIpToGuestJob::dispatch(
$this->moduleVmwareId,
$this->vmId,
$this->osFamily,
VmProvisionService::TEMPLATE_PASSWORD,
$this->reservationId,
$this->reservedIpPoolId,
);
}
}