377 lines
15 KiB
PHP
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::templatePassword(),
|
|
$this->reservationId,
|
|
$this->reservedIpPoolId,
|
|
);
|
|
}
|
|
}
|