diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index a0aad6d..72a4dc0 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -2,6 +2,7 @@ namespace App\Console; +use App\Jobs\VirtualMachine\Sync\SyncAllPackage; use Illuminate\Console\Scheduling\Schedule; use Illuminate\Foundation\Console\Kernel as ConsoleKernel; @@ -44,6 +45,11 @@ class Kernel extends ConsoleKernel ->everyFiveMinutes() ->withoutOverlapping(); + /* Sync all VM packages */ + $schedule->job(new SyncAllPackage()) + ->everyThreeHours() + ->withoutOverlapping(); + /* Mark overdue unpaid invoices */ $schedule->command('invoices:check-overdue') ->timezone("Asia/Ho_Chi_Minh") diff --git a/app/Http/Controllers/admin/VCenterManagerController.php b/app/Http/Controllers/admin/VCenterManagerController.php index 10ae696..830c245 100644 --- a/app/Http/Controllers/admin/VCenterManagerController.php +++ b/app/Http/Controllers/admin/VCenterManagerController.php @@ -739,6 +739,31 @@ class VCenterManagerController extends Controller ], 400); } + public function updateConfig(Request $request) + { + $request->validate([ + 'vm_name' => 'required|string', + 'cpu' => 'required|integer|min:1', + 'ram' => 'required|integer|min:1', + ]); + + $vmware = ModuleVmware::where('name', $request->input('vm_name'))->first(); + + if (!$vmware) { + return response()->json(['error' => true, 'message' => 'VM not found'], 404); + } + + \App\Jobs\VirtualMachine\Provision\UpdateVmConfigJob::dispatch( + $vmware->id, + (string) $vmware->vmware_id, + '', + (int) $request->input('cpu'), + (int) $request->input('ram'), + ); + + return response()->json(['error' => false, 'message' => 'Đã gửi yêu cầu cập nhật cấu hình VM']); + } + public function deleteVM(Request $request) { $request->validate([ diff --git a/app/Jobs/VirtualMachine/Provision/UpdateVmConfigJob.php b/app/Jobs/VirtualMachine/Provision/UpdateVmConfigJob.php index 989b421..490b840 100644 --- a/app/Jobs/VirtualMachine/Provision/UpdateVmConfigJob.php +++ b/app/Jobs/VirtualMachine/Provision/UpdateVmConfigJob.php @@ -9,6 +9,7 @@ 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\VsphereHostSshService; @@ -18,6 +19,7 @@ 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; /** @@ -46,13 +48,15 @@ class UpdateVmConfigJob implements ShouldQueue use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public int $tries = 3; - public int $timeout = 180; + 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 function handle(VsphereHostSshService $ssh, VmProvisionLogger $logger, Telegram $telegram): void @@ -75,19 +79,25 @@ class UpdateVmConfigJob implements ShouldQueue return; } - $orderItem = $moduleVmware->order?->orderItems?->first(); - $product = $orderItem?->product; - $addonsMeta = is_array($orderItem?->addons_meta) ? $orderItem->addons_meta : []; + $isManualOverride = $this->cpuOverride !== null || $this->ramOverride !== null; + $addonCpu = $addonRamGb = 0; + $product = null; - [$addonCpu, $addonRamGb] = $this->resolveAddonCpuRam($addonsMeta); + if (!$isManualOverride) { + $orderItem = $moduleVmware->order?->orderItems?->first(); + $product = $orderItem?->product; + $addonsMeta = is_array($orderItem?->addons_meta) ? $orderItem->addons_meta : []; - if ($addonCpu === 0 && $addonRamGb === 0) { - $logger->step('no cpu/ram addon — skipping UpdateVmConfigJob', [ - 'module_vmware_id' => $this->moduleVmwareId, - 'vm_id' => $this->vmId, - ]); - $this->nextStep(); - return; + [$addonCpu, $addonRamGb] = $this->resolveAddonCpuRam($addonsMeta); + + if ($addonCpu === 0 && $addonRamGb === 0) { + $logger->step('no cpu/ram addon — skipping UpdateVmConfigJob', [ + 'module_vmware_id' => $this->moduleVmwareId, + 'vm_id' => $this->vmId, + ]); + $this->nextStep(); + return; + } } // Load vCenter credentials — same pattern as ChangePasswordViaVsphereJob @@ -105,27 +115,42 @@ class UpdateVmConfigJob implements ShouldQueue $vcenterPass = SignRSA::DecryptFunc($vcenter->password); $serverName = (string) $moduleVmware->name; - $totalCpu = ((int) ($product?->cpu ?? 0)) + $addonCpu; - $totalRam = ((int) ($product?->ram ?? 0)) + $addonRamGb; + + 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, - 'addon_cpu' => $addonCpu, - 'addon_ram_gb' => $addonRamGb, + 'manual_override' => $isManualOverride, 'total_cpu' => $totalCpu, 'total_ram_gb' => $totalRam, ]); - $output = $ssh->updateVmConfig( - $serverName, - $totalCpu, - $totalRam, - $vcenterHost, - $vcenterUser, - $vcenterPass, - ); + if ($isManualOverride) { + $this->powerCycle($vcenter, (string) $moduleVmware->vmware_id, $logger, powerOff: true); + } + + try { + $output = $ssh->updateVmConfig( + $serverName, + $totalCpu, + $totalRam, + $vcenterHost, + $vcenterUser, + $vcenterPass, + ); + } finally { + if ($isManualOverride) { + $this->powerCycle($vcenter, (string) $moduleVmware->vmware_id, $logger, powerOff: false); + } + } Log::info('UpdateVmConfigJob: PS1 output', [ 'module_vmware_id' => $this->moduleVmwareId, @@ -157,7 +182,7 @@ class UpdateVmConfigJob implements ShouldQueue throw new \RuntimeException($msg); } - $logger->success('VM config updated via addon script', [ + $logger->success('VM config updated', [ 'module_vmware_id' => $this->moduleVmwareId, 'vm_id' => $this->vmId, 'total_cpu' => $totalCpu, @@ -165,6 +190,12 @@ class UpdateVmConfigJob implements ShouldQueue 'output' => trim($output), ]); + if ($isManualOverride) { + $moduleVmware->update(['cpu' => $totalCpu, 'ram_mb' => $totalRam * 1024]); + $this->syncServiceAddons($moduleVmware, $totalCpu, $totalRam, $logger); + return; + } + $this->nextStep(); } @@ -240,6 +271,80 @@ class UpdateVmConfigJob implements ShouldQueue return [$cpu, $ram]; } + /** + * Power off/on the VM via vCenter REST and wait (up to 30s) for the state to land. + * Used only for manual admin overrides — hot-add isn't reliable for RAM on every guest OS, + * so we power-cycle around the resize to guarantee it applies. + */ + 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 { ChangePasswordViaVsphereJob::dispatch( diff --git a/routes/api.php b/routes/api.php index b30659e..33dcb95 100644 --- a/routes/api.php +++ b/routes/api.php @@ -596,6 +596,7 @@ Route::prefix('v1') Route::post('vm/restart', 'restart')->middleware('perm:module-vcenter.edit'); Route::post('vm/snapshot', 'snapshot')->middleware('perm:module-vcenter.edit'); Route::post('vm/suspend', 'suspend')->middleware('perm:module-vcenter.edit'); + Route::post('vm/update-config', 'updateConfig')->middleware('perm:module-vcenter.edit'); Route::post('vm/delete', 'deleteVM')->middleware('perm:module-vcenter.edit'); Route::get('vms/powered-off', 'getPoweredOffVms')->middleware('perm:module-vcenter.view'); });