From 7ae237ee9dbd477befc40541b918a501a93c36dd Mon Sep 17 00:00:00 2001 From: baodev0 Date: Fri, 29 May 2026 12:03:35 +0700 Subject: [PATCH] Refactor VM provisioning pipeline into focused services and add IP pool management --- .claude/settings.local.json | 3 +- app/Console/Commands/Test.php | 72 -- app/Console/Commands/TestAddonUpdate.php | 91 --- app/Console/Commands/TestFullVMProcess.php | 91 --- app/Console/Commands/TestVMInfo.php | 149 ---- app/Console/Commands/TestVMSave.php | 80 --- .../Api/Admin/Product/ProductController.php | 7 + .../Resources/Api/Admin/ProductResource.php | 1 + .../Create/CreateVirtualMachineJob.php | 647 ++++-------------- .../Provision/AssignIpToGuestJob.php | 151 ++-- .../Provision/ChangePasswordViaVsphereJob.php | 15 +- .../Provision/WaitVmReadyJob.php | 2 - app/Models/IpPool.php | 22 + app/Models/VirtualMachines.php | 9 +- .../Dto/VmInfrastructureContext.php | 24 + .../VirtualMachine/VmAddonResolver.php | 47 ++ .../VmInfrastructureResolver.php | 89 +++ .../VirtualMachine/VmModuleService.php | 48 ++ .../VirtualMachine/VmNotificationService.php | 112 +++ .../VirtualMachine/VmPowerService.php | 35 + .../VirtualMachine/VmProvisionLogger.php | 109 +++ .../VirtualMachine/VmServiceSyncService.php | 43 ++ .../VmVcenterErrorTranslator.php | 35 + app/Services/VsphereHostSshService.php | 145 ++-- ...025_07_30_160731_create_ip_pools_table.php | 13 + ...000001_add_thumbnail_to_products_table.php | 23 + ...5_29_000001_add_uuid_to_ip_pools_table.php | 28 + ..._add_ip_pool_to_virtual_machines_table.php | 28 + ..._29_110056_update_column_ip_pool_table.php | 40 ++ database/seeders/IpPoolSeeder.php | 50 +- powershell/assignIPLinux.ps1 | 161 +++-- ...SqzjtQvtEqoeDlIk3iBKmUntmNEJ1GM4RdvHx6.jpg | Bin 0 -> 37529 bytes 32 files changed, 1170 insertions(+), 1200 deletions(-) delete mode 100644 app/Console/Commands/Test.php delete mode 100644 app/Console/Commands/TestAddonUpdate.php delete mode 100644 app/Console/Commands/TestFullVMProcess.php delete mode 100644 app/Console/Commands/TestVMInfo.php delete mode 100644 app/Console/Commands/TestVMSave.php create mode 100644 app/Services/VirtualMachine/Dto/VmInfrastructureContext.php create mode 100644 app/Services/VirtualMachine/VmAddonResolver.php create mode 100644 app/Services/VirtualMachine/VmInfrastructureResolver.php create mode 100644 app/Services/VirtualMachine/VmModuleService.php create mode 100644 app/Services/VirtualMachine/VmNotificationService.php create mode 100644 app/Services/VirtualMachine/VmPowerService.php create mode 100644 app/Services/VirtualMachine/VmProvisionLogger.php create mode 100644 app/Services/VirtualMachine/VmServiceSyncService.php create mode 100644 app/Services/VirtualMachine/VmVcenterErrorTranslator.php create mode 100644 database/migrations/2026_05_29_000001_add_thumbnail_to_products_table.php create mode 100644 database/migrations/2026_05_29_000001_add_uuid_to_ip_pools_table.php create mode 100644 database/migrations/2026_05_29_000002_add_ip_pool_to_virtual_machines_table.php create mode 100644 database/migrations/2026_05_29_110056_update_column_ip_pool_table.php create mode 100644 storage/app/public/system-config/VqSqzjtQvtEqoeDlIk3iBKmUntmNEJ1GM4RdvHx6.jpg diff --git a/.claude/settings.local.json b/.claude/settings.local.json index b7de7ef..8387fff 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -31,7 +31,8 @@ "Bash(redis-cli --no-auth-warning keys \"*\")", "Bash(redis-cli --no-auth-warning llen \"the_gioi_so_horizon:queue:default\")", "Bash(redis-cli --no-auth-warning lrange \"the_gioi_so_horizon:queue:default\" 0 4)", - "Bash(redis-cli *)" + "Bash(redis-cli *)", + "Bash(echo \"exit=$?\")" ] } } diff --git a/app/Console/Commands/Test.php b/app/Console/Commands/Test.php deleted file mode 100644 index b6d42d7..0000000 --- a/app/Console/Commands/Test.php +++ /dev/null @@ -1,72 +0,0 @@ -login('root', 'TGS@123')) { - // exit('Login Failed'); - // } - $username = "administrator"; - $password = 'TGS@123'; - $client = new WinRMClient('10.10.10.173', 'administrator', 'Thegioiso@123**$$8899TgTk2'); - // $output = $client->execute_command("net user \"$username\" \"$password\""); - $output = $client->execute_command("dir c:\\"); - echo $output; - // $client = new WinRMClient('192.168.1.100', 'username', 'password'); - // Rename HostName - // echo $ssh->exec("hostnamectl set-hostname NguyenKhoi_Dev"); - - // Cập nhật IP tĩnh - // $ssh->exec("nmcli con mod ens192 ipv4.address 103.42.58.37/24;"); - // $ssh->exec("nmcli con mod ens192 ipv4.gateway 103.42.58.1;"); - - // Resize disk Centos 7 - // $ssh->exec("sudo pvcreate /dev/sda2;"); - // $ssh->exec("sudo vgextend /dev/centos /dev/sda2;"); - // $ssh->exec("sudo lvextend /dev/centos/root /dev/sda2"); - // $ssh->exec("xfs_growfs /dev/centos/root"); - - // Resize disk Centos 8 - // $ssh->exec("sudo growpart /dev/sda 3"); - // $ssh->exec("sudo pvresize /dev/sda3"); - // $ssh->exec("sudo lvresize -l+100%FREE --resizefs /dev/mapper/ubuntu--vg-ubuntu--lv"); - - // 'growpart /dev/sda 3; pvresize /dev/sda3; lvresize -l+100%FREE --resizefs /dev/mapper/ubuntu--vg-ubuntu--lv; - - // Change Pasword - // $ssh->exec("echo \"root:TGS@123\" | chpasswd"); - - // echo $ssh->exec("sudo tee /sys/class/block/sda/device/rescan"); - // echo $ssh->exec("lsblk"); - } catch (Exception $exception) { - echo $exception->getMessage(); - } - } -} diff --git a/app/Console/Commands/TestAddonUpdate.php b/app/Console/Commands/TestAddonUpdate.php deleted file mode 100644 index ea66a58..0000000 --- a/app/Console/Commands/TestAddonUpdate.php +++ /dev/null @@ -1,91 +0,0 @@ -argument('vm_id'); - $cpuAddon = (int) $this->option('cpu'); - $ramAddon = (int) $this->option('ram'); - $diskAddon = (int) $this->option('disk'); - $vcenterId = (int) $this->option('vcenter'); - - $this->info("🧪 Testing addon update for VM: {$vmId}"); - $this->info("Addon config: CPU={$cpuAddon}, RAM={$ramAddon}GB, Disk={$diskAddon}GB"); - - try { - // Lấy vCenter model - $vcenterModel = VcenterModel::find($vcenterId); - if (!$vcenterModel) { - $this->error("❌ Không tìm thấy vCenter với ID: {$vcenterId}"); - return 1; - } - - $this->info("📡 Connecting to vCenter: {$vcenterModel->host}"); - - // Khởi tạo vCenter service - $vCenter = new vCenter(); - - // Login to vCenter - if (!$vCenter->loginServer($vcenterModel)) { - $this->error("❌ Không thể login vào vCenter"); - return 1; - } - - $this->info("✅ Login thành công"); - - // Test addon update - $addon = [ - 'cpu' => $cpuAddon, - 'ram' => $ramAddon, - 'disk' => $diskAddon - ]; - - $result = $vCenter->testAddonUpdate($vmId, $addon, 1, 1); - - if ($result['error']) { - $this->error("❌ Test thất bại: " . $result['message']); - return 1; - } - - $this->info("✅ Test thành công: " . $result['message']); - $this->info("📋 Kiểm tra logs để xem chi tiết"); - - return 0; - - } catch (\Exception $e) { - $this->error("❌ Exception: " . $e->getMessage()); - Log::error("TestAddonUpdate command error: " . $e->getMessage()); - return 1; - } - } -} \ No newline at end of file diff --git a/app/Console/Commands/TestFullVMProcess.php b/app/Console/Commands/TestFullVMProcess.php deleted file mode 100644 index 1e94f66..0000000 --- a/app/Console/Commands/TestFullVMProcess.php +++ /dev/null @@ -1,91 +0,0 @@ -argument('order_id'); - - $this->info("Testing full VM process for Order #{$orderId}"); - - try { - // Tìm order - $order = Order::with(['orderItems.addons', 'customer'])->find($orderId); - if (!$order) { - $this->error("Order #{$orderId} not found!"); - return 1; - } - - $this->info("Found order: {$order->order_num}"); - $this->info("Customer: {$order->customer->email}"); - $this->info("Order items count: " . $order->orderItems->count()); - - // Tìm invoice - $invoice = Invoice::where('order_id', $orderId)->first(); - if (!$invoice) { - $this->error("Invoice for Order #{$orderId} not found!"); - return 1; - } - - $this->info("Found invoice: #{$invoice->id}"); - $this->info("Current VM ID: " . ($invoice->vm_id ?? 'NULL')); - - // Test từng order item - foreach ($order->orderItems as $item) { - $this->info("\n--- Testing Order Item #{$item->id} ---"); - $this->info("Product Type: {$item->product_type}"); - $this->info("Server Name: {$item->server_name}"); - $this->info("Addons count: " . $item->addons->count()); - - if ($item->addons->count() > 0) { - $this->info("Addons:"); - foreach ($item->addons as $addon) { - $this->info(" - {$addon->addonItems->name}: {$addon->qty} {$addon->addonItems->prefix}"); - } - } - - // Dispatch job để test - $this->info("Dispatching CreateVirtualMachineJob..."); - CreateVirtualMachineJob::dispatch($item); - - $this->info("Job dispatched successfully!"); - } - - $this->info("\n✅ Full VM process test completed!"); - $this->info("Check logs for detailed information about the process."); - - return 0; - - } catch (\Exception $e) { - $this->error("Error: " . $e->getMessage()); - Log::error('TestFullVMProcess error: ' . $e->getMessage()); - return 1; - } - } -} \ No newline at end of file diff --git a/app/Console/Commands/TestVMInfo.php b/app/Console/Commands/TestVMInfo.php deleted file mode 100644 index 836615b..0000000 --- a/app/Console/Commands/TestVMInfo.php +++ /dev/null @@ -1,149 +0,0 @@ -argument('invoice_id'); - - $this->info("Testing VM info for Invoice #{$invoiceId}"); - - try { - // Tìm invoice - $invoice = Invoice::with(['invoiceItem.addons', 'invoiceItem.os.osGroup', 'customer'])->find($invoiceId); - if (!$invoice) { - $this->error("Invoice #{$invoiceId} not found!"); - return 1; - } - - $this->info("Found invoice: #{$invoice->id}"); - $this->info("VM ID: " . ($invoice->vm_id ?? 'NULL')); - - if (!$invoice->vm_id) { - $this->error("Invoice #{$invoiceId} doesn't have VM ID!"); - return 1; - } - - // Lấy thông tin vCenter - $orderItem = $invoice->invoiceItem->first(); - if (!$orderItem) { - $this->error("No order item found for invoice #{$invoiceId}!"); - return 1; - } - - $product = $orderItem->getProductAttribute(); - if (!$product) { - $this->error("No product found for order item #{$orderItem->id}!"); - return 1; - } - - $host = $product->host; - $datacenter = $host?->datacenter; - $vcenter = $datacenter?->vcenter; - - if (!$vcenter) { - $this->error("No vCenter information found!"); - return 1; - } - - $this->info("vCenter Host: {$vcenter->host}"); - $this->info("vCenter User: {$vcenter->user}"); - - // Test vCenter service - $vCenterService = new vCenter(); - - $this->info("Attempting to login to vCenter..."); - if (!$vCenterService->loginServer($vcenter)) { - $this->error("Failed to login to vCenter!"); - return 1; - } - - $this->info("✅ Successfully logged in to vCenter"); - - // Test getting VM details - $this->info("Getting VM details..."); - $vmDetail = $vCenterService->GetDetailVirtualMachine($invoice->vm_id); - - if ($vmDetail['error'] === false) { - $this->info("✅ Successfully got VM details"); - $this->info("VM Name: " . ($vmDetail['data']['name'] ?? 'Unknown')); - $this->info("VM Version: " . ($vmDetail['data']['version'] ?? 'Unknown')); - } else { - $this->error("❌ Failed to get VM details: " . ($vmDetail['message'] ?? 'Unknown error')); - return 1; - } - - // Test getting power status - $this->info("Getting power status..."); - $powerStatus = $vCenterService->CheckVirtualMachine($invoice->vm_id); - - if ($powerStatus['error'] === false) { - $this->info("✅ Successfully got power status"); - $this->info("Power State: " . ($powerStatus['data']['state'] ?? 'Unknown')); - } else { - $this->warn("⚠️ Failed to get power status: " . ($powerStatus['message'] ?? 'Unknown error')); - } - - // Test getting IP address - $this->info("Getting IP address..."); - $ipInfo = $vCenterService->GetIPAddressVirtualMachine($invoice->vm_id); - - if ($ipInfo['error'] === false) { - $this->info("✅ Successfully got IP info"); - $this->info("IP Info data structure:"); - $this->line(json_encode($ipInfo['data'], JSON_PRETTY_PRINT)); - - if (!empty($ipInfo['data']['ip_addresses'])) { - $ipAddresses = $ipInfo['data']['ip_addresses']; - $this->info("IP Addresses type: " . gettype($ipAddresses)); - $this->info("IP Addresses value: " . json_encode($ipAddresses)); - - if (is_array($ipAddresses)) { - $this->info("IP Addresses: " . implode(', ', $ipAddresses)); - } else { - $this->info("IP Addresses: " . $ipAddresses); - } - } else { - $this->info("No IP addresses found"); - } - } else { - $this->warn("⚠️ Failed to get IP info: " . ($ipInfo['message'] ?? 'Unknown error')); - } - - // Display full VM data - $this->info("\n=== Full VM Data ==="); - $this->line(json_encode($vmDetail['data'], JSON_PRETTY_PRINT)); - - return 0; - - } catch (\Exception $e) { - $this->error("Error: " . $e->getMessage()); - Log::error('TestVMInfo error: ' . $e->getMessage()); - return 1; - } - } -} \ No newline at end of file diff --git a/app/Console/Commands/TestVMSave.php b/app/Console/Commands/TestVMSave.php deleted file mode 100644 index 7d7d67a..0000000 --- a/app/Console/Commands/TestVMSave.php +++ /dev/null @@ -1,80 +0,0 @@ -argument('order_id'); - $vmId = $this->argument('vm_id'); - - $this->info("Testing VM ID save for Order #{$orderId} with VM ID: {$vmId}"); - - try { - // Tìm order - $order = Order::find($orderId); - if (!$order) { - $this->error("Order #{$orderId} not found!"); - return 1; - } - - $this->info("Found order: {$order->order_num}"); - - // Tìm invoice tương ứng - $invoice = Invoice::where('order_id', $orderId)->first(); - if (!$invoice) { - $this->error("Invoice for Order #{$orderId} not found!"); - return 1; - } - - $this->info("Found invoice: #{$invoice->id}"); - - // Lưu vm_id - $oldVmId = $invoice->vm_id; - $invoice->update(['vm_id' => $vmId]); - - $this->info("✅ Successfully updated VM ID:"); - $this->info(" Old VM ID: " . ($oldVmId ?? 'NULL')); - $this->info(" New VM ID: {$vmId}"); - - // Verify - $invoice->refresh(); - if ($invoice->vm_id === $vmId) { - $this->info("✅ Verification successful - VM ID saved correctly"); - } else { - $this->error("❌ Verification failed - VM ID not saved correctly"); - return 1; - } - - return 0; - - } catch (\Exception $e) { - $this->error("Error: " . $e->getMessage()); - Log::error('TestVMSave error: ' . $e->getMessage()); - return 1; - } - } -} \ No newline at end of file diff --git a/app/Http/Controllers/Api/Admin/Product/ProductController.php b/app/Http/Controllers/Api/Admin/Product/ProductController.php index 2638cb4..910496b 100644 --- a/app/Http/Controllers/Api/Admin/Product/ProductController.php +++ b/app/Http/Controllers/Api/Admin/Product/ProductController.php @@ -64,6 +64,7 @@ class ProductController extends Controller 'product_categories_id' => 'nullable|integer|exists:product_categories,id', 'product_type' => 'nullable|string', 'category' => 'nullable|string|max:255', + 'thumbnail' => 'nullable|string|max:2048', 'is_active' => 'boolean', 'setup_fee' => 'nullable|numeric|min:0', 'cpu' => 'nullable|numeric|min:0', @@ -93,6 +94,7 @@ class ProductController extends Controller 'slug' => Str::slug($validated['name']), 'product_categories_id' => $validated['product_categories_id'] ?? null, 'product_type' => $productType, + 'thumbnail' => ($validated['thumbnail'] ?? null) ?: null, 'is_active' => $validated['is_active'] ?? true, 'setup_fee' => $validated['setup_fee'] ?? 0, 'cpu' => $validated['cpu'] ?? null, @@ -123,6 +125,7 @@ class ProductController extends Controller 'name' => 'sometimes|required|string|max:255', 'product_categories_id' => 'nullable|integer|exists:product_categories,id', 'product_type' => 'nullable|string', + 'thumbnail' => 'nullable|string|max:2048', 'is_active' => 'boolean', 'setup_fee' => 'nullable|numeric|min:0', 'cpu' => 'nullable|numeric|min:0', @@ -153,6 +156,10 @@ class ProductController extends Controller $validated['product_type'] = $category?->type; } + if (array_key_exists('thumbnail', $validated) && $validated['thumbnail'] === '') { + $validated['thumbnail'] = null; + } + // Handle OS associations via product_os table $osIds = $validated['os_ids'] ?? null; unset($validated['os_ids']); diff --git a/app/Http/Resources/Api/Admin/ProductResource.php b/app/Http/Resources/Api/Admin/ProductResource.php index b9d82cc..d84a1c3 100644 --- a/app/Http/Resources/Api/Admin/ProductResource.php +++ b/app/Http/Resources/Api/Admin/ProductResource.php @@ -22,6 +22,7 @@ class ProductResource extends JsonResource 'uuid' => $this->uuid, 'name' => $this->name, 'slug' => $this->slug, + 'thumbnail' => $this->thumbnail, 'product_type' => $this->product_type, 'page_type' => $this->resolvePageType($this->product_type), 'product_categories_id' => $this->whenLoaded('category', fn() => $this->category?->id), diff --git a/app/Jobs/VirtualMachine/Create/CreateVirtualMachineJob.php b/app/Jobs/VirtualMachine/Create/CreateVirtualMachineJob.php index de83283..ac63b5b 100644 --- a/app/Jobs/VirtualMachine/Create/CreateVirtualMachineJob.php +++ b/app/Jobs/VirtualMachine/Create/CreateVirtualMachineJob.php @@ -1,570 +1,175 @@ item = $item; - $this->isRetry = $isRetry; + public function __construct( + public OrderItem $item, + public bool $isRetry = false, + ) { } - /** - * Execute the job. - */ - public function handle(vCenter $vCenter, Telegram $telegram) - { + public function handle( + vCenter $vCenter, + VmProvisionLogger $vmLogger, + VmInfrastructureResolver $infraResolver, + VmAddonResolver $addonResolver, + VmPowerService $powerService, + VmModuleService $moduleService, + VmServiceSyncService $serviceSync, + VmNotificationService $notification, + VmVcenterErrorTranslator $errorTranslator, + ): bool { try { + $vmLogger->step('Create VM job started', [ + 'order_item_id' => $this->item->id, + 'is_retry' => $this->isRetry, + ]); + $product = $this->item->getProductAttribute(); if (!$product) { - $errorMessage = "Không tìm thấy sản phẩm từ OrderItem #{$this->item->id}"; - Log::warning($errorMessage); - $telegram->SendErrorMessage($errorMessage, 'CreateVirtualMachineJob - Missing Product'); - - if (!$this->isRetry) { - $this->sendFailureNotification($errorMessage); - $this->writeCustomerFailureLog($errorMessage); - } - return false; + throw new RuntimeException("Không tìm thấy sản phẩm từ OrderItem #{$this->item->id}"); } - $osModel = Os::with(['host.datacenter.vCenter', 'osGroup.host.datacenter.vCenter'])->find($this->item->os_id); + $infra = $infraResolver->resolve($this->item, $product); + $addonData = $addonResolver->resolve($this->item); - if (!$osModel) { - $errorMessage = "Không tìm thấy OS id={$this->item->os_id} trong OrderItem #{$this->item->id}. os_id không hợp lệ."; - Log::error($errorMessage); - $telegram->SendErrorMessage([ - 'error' => $errorMessage, - 'os_id' => $this->item->os_id, - 'order_item_id' => $this->item->id, - 'product_id' => $product->id, - ], 'CreateVirtualMachineJob - Invalid OS ID'); - if (!$this->isRetry) { - $this->sendFailureNotification($errorMessage); - $this->writeCustomerFailureLog($errorMessage); - } - return; + if (!$vCenter->loginServer($infra->vcenter)) { + throw new RuntimeException("Login vào vCenter thất bại với host: {$infra->vcenter->host}"); } - $template = $osModel->template_id; - - // Ưu tiên 1: host gán trực tiếp trên OS - $host = $osModel->host; - $datacenter = $host?->datacenter; - $vcenter = $datacenter?->vCenter; - - // Ưu tiên 2: host từ osGroup (cũ) - if (!$host || !$datacenter || !$vcenter) { - $groupHost = $osModel->osGroup?->host; - $host = $host ?? $groupHost; - $datacenter = $datacenter ?? $groupHost?->datacenter; - $vcenter = $vcenter ?? $groupHost?->datacenter?->vCenter; - - if ($groupHost) { - Log::info('CreateVirtualMachineJob: dùng host từ osGroup làm fallback', [ - 'os_id' => $osModel->id, - 'os_group_id' => $osModel->os_group_id, - 'host_id' => $groupHost->id, - ]); - } - } - - // Ưu tiên 3: host từ sản phẩm (cấu hình tab Provisioning) - if ((!$host || !$datacenter || !$vcenter) && $product->module_host_id) { - $productHost = ModuleHost::with('datacenter.vCenter')->find($product->module_host_id); - if ($productHost) { - $host = $host ?? $productHost; - $datacenter = $datacenter ?? $productHost->datacenter; - $vcenter = $vcenter ?? $productHost->datacenter?->vCenter; - - Log::info('CreateVirtualMachineJob: dùng host từ sản phẩm làm fallback', [ - 'product_id' => $product->id, - 'host_id' => $productHost->id, - 'datacenter_id' => $productHost->datacenter?->id, - ]); - } - } - - if (!$vcenter || !$host || !$datacenter) { - $errorMessage = "Thiếu thông tin host/datacenter/vcenter từ sản phẩm ID #{$product->id}. " - . "Vui lòng cấu hình vCenter trong tab Provisioning của sản phẩm, " - . "hoặc gán host cho OS group của OS được chọn."; - Log::error($errorMessage); - $telegram->SendErrorMessage([ - 'error' => $errorMessage, - 'product_id' => $product->id, - 'order_item_id' => $this->item->id, - 'os_id' => $this->item->os_id, - 'os_group_name' => $osModel->osGroup?->name, - 'product_module_host_id' => $product->module_host_id, - 'host_id' => $host?->id, - 'datacenter_id' => $datacenter?->id, - 'vcenter_id' => $vcenter?->id, - ], 'CreateVirtualMachineJob - Missing Infrastructure'); - - if (!$this->isRetry) { - $this->sendFailureNotification($errorMessage); - $this->writeCustomerFailureLog($errorMessage); - } - return; - } - - // Login to vCenter - if (!$vCenter->loginServer($vcenter)) { - $errorMessage = "Login vào vCenter thất bại với host: {$vcenter->host}"; - Log::error($errorMessage); - $telegram->SendErrorMessage([ - 'error' => $errorMessage, - 'vcenter_host' => $vcenter->host, - 'vcenter_user' => $vcenter->user, - 'product_id' => $product->id, - 'order_item_id' => $this->item->id - ], 'CreateVirtualMachineJob - vCenter Login Failed'); - - if (!$this->isRetry) { - $this->sendFailureNotification($errorMessage); - $this->writeCustomerFailureLog($errorMessage); - } - return false; - } - - // Lấy thông tin addon (CPU, RAM) - $addonData = [ - 'cpu' => 0, - 'ram' => 0, - 'disk' => 0 - ]; - - // Lấy addon từ order item - if ($this->item->addons) { - foreach ($this->item->addons as $orderAddon) { - $addon = $orderAddon->addonItems; - if (!$addon) - continue; - - $addonName = strtolower(trim($addon->name)); - $quantity = (int) $orderAddon->qty; - - switch ($addonName) { - case 'cpu': - $addonData['cpu'] += $quantity; - break; - case 'ram': - $addonData['ram'] += $quantity; - break; - case 'disk': - case 'ssd': - $addonData['disk'] += $quantity; - break; - } - } - } - - // Xác định OS family để chọn username và luồng cấu hình phù hợp - $osGroupName = $osModel->osGroup?->name ?? ''; - $isWindows = str_contains(strtolower($osGroupName), 'windows'); - $vmUser = $isWindows ? 'administrator' : 'root'; - $vmPort = $isWindows ? 3389 : 22; + $vmLogger->step('VM clone started', [ + 'order_item_id' => $this->item->id, + 'product_id' => $product->id, + 'template' => $infra->template, + 'host_id' => $infra->host->id, + 'vcenter_id' => $infra->vcenter->id, + ]); $result = $vCenter->CreateVirtualMachine([ - 'item' => $this->item, - 'host' => $host, - 'datacenter' => $datacenter, - 'vcenter' => $vcenter, - 'template' => $template, - 'addon' => $addonData, + 'item' => $this->item, + 'host' => $infra->host, + 'datacenter' => $infra->datacenter, + 'vcenter' => $infra->vcenter, + 'template' => $infra->template, + 'addon' => $addonData, ]); - Log::info('CreateVirtualMachineJob - Result: ' . json_encode($result)); + if (is_array($result) && ($result['error'] ?? false)) { - $errorMessage = $this->translateVcenterError($result); - Log::warning($errorMessage); - $telegram->SendErrorMessage([ - 'error' => $result['error'] ?? 'Unknown error', - 'result' => $result, - 'product_id' => $product->id, - 'order_item_id' => $this->item->id, - 'host_name' => $host?->name, - 'datacenter_name' => $datacenter?->name, - 'vcenter_host' => $vcenter?->host - ], 'CreateVirtualMachineJob - VM Creation Failed'); - - if (!$this->isRetry) { - $this->sendFailureNotification($errorMessage); - $this->writeCustomerFailureLog($errorMessage); - } - return false; - } elseif ($result === 'Login_Again') { - $errorMessage = 'Tạo máy ảo thất bại: cần đăng nhập lại.'; - Log::error($errorMessage); - $telegram->SendErrorMessage([ - 'error' => $errorMessage, - 'product_id' => $product->id, - 'order_item_id' => $this->item->id, - 'vcenter_host' => $vcenter?->host - ], 'CreateVirtualMachineJob - vCenter Re-login Required'); - - if (!$this->isRetry) { - $this->sendFailureNotification($errorMessage); - $this->writeCustomerFailureLog($errorMessage); - } - return false; - } else { - Log::info('Tạo máy ảo thành công: ' . json_encode($result)); - - // Lấy vm_id từ kết quả - $vmId = is_array($result) ? ($result['data'] ?? 'unknown') : $result; - - if ($vmId !== 'unknown' && $vmId !== 'UNKNOWN') { - try { - // vm_id được lưu vào tbl.services qua ModuleVmwareObserver::created() - $startResult = $this->startVirtualMachineDirectly($vCenter, $vmId); - - // VM started — SSH vào vSphere host để đổi mật khẩu qua PS1 script - - try { - $customer = $this->item->order->customer; - $serverName = $this->item->server_name ?? 'VM-' . $vmId; - - $months = (int) ($this->item->months ?? 0); - $firstPayment = (int) $this->item->price * ($months ?: 1); - $nextDueDate = $months > 0 ? now()->addMonths($months)->timestamp : null; - - // Create ModuleVmware without IP — AssignIpToGuestJob will - // pick, assign, and verify the real IP later, then write it to DB. - $moduleVmware = ModuleVmware::create([ - 'name' => $serverName, - 'module_ip_address' => null, // set by AssignIpToGuestJob - 'ip' => null, // set by AssignIpToGuestJob - 'port' => $vmPort, - 'order_id' => $this->item->order->id, - 'first_payment' => $firstPayment, - 'next_payment' => $firstPayment, - 'next_due_date' => $nextDueDate, - 'next_invoice' => $nextDueDate, - 'user' => $vmUser, - 'password' => VmProvisionService::TEMPLATE_PASSWORD, - 'plain_password' => VmProvisionService::TEMPLATE_PASSWORD, - 'hostname' => $serverName, - 'vmware_id' => $vmId, - 'vcenter_id' => $vcenter->id, - 'os_id' => $this->item->os_id ?? 1, - 'customer_id' => $customer->id, - 'status' => 1, - ]); - - // Sync services table - $this->syncServiceRecord($moduleVmware); - - // Step 1 of guest provisioning: change template password. - // On success ChangePasswordViaVsphereJob dispatches AssignIpToGuestJob, - // which handles IP selection, assignment, verification, and email. - ChangePasswordViaVsphereJob::dispatch( - $moduleVmware->id, - $vmId, - $isWindows ? VmProvisionService::OS_WINDOWS : VmProvisionService::OS_LINUX, - ); - - Log::info('ChangePasswordViaVsphereJob dispatched', [ - 'module_vmware_id' => $moduleVmware->id, - 'vm_id' => $vmId, - 'os_family' => $isWindows ? 'WINDOWS' : 'LINUX', - ]); - } catch (\Exception $e) { - Log::error('❌ Lỗi khi lưu dữ liệu VM và IP address: ' . $e->getMessage(), [ - 'vmware_id' => $vmId, - 'order_item_id' => $this->item->id, - 'error' => $e->getMessage() - ]); - } - - } catch (\Exception $e) { - Log::error('❌ Lỗi khi lưu dữ liệu VM hoặc start VM: ' . $e->getMessage(), [ - 'vm_id' => $vmId, - 'order_id' => $this->item->order->id, - 'trace' => $e->getTraceAsString() - ]); - } - } - - $telegram->SendJobRunInfo( - 'CreateVirtualMachineJob', - 'success', - [ - 'product_id' => $product->id, - 'order_item_id' => $this->item->id, - 'result' => $result, - 'addon_applied' => !empty($addonData) && array_sum($addonData) > 0, - 'vm_id_saved' => $vmId !== 'unknown' && $vmId !== 'UNKNOWN', - 'vm_started' => isset($startResult) && is_array($startResult) && $startResult['error'] === false - ] + throw new RuntimeException( + $errorTranslator->translate($result, $this->item->server_name) ); - $this->writeCustomerSuccessLog(); - - try { - $order = $this->item->order; - $customer = $order->customer; - broadcast(new \App\Events\OrderProvisioned( - orderId: $order->id, - orderNum: $order->order_num, - customerName: trim($customer->firstname . ' ' . $customer->lastname), - vmId: (string) $vmId, - success: true, - message: 'Máy ảo đã được khởi tạo thành công cho khách hàng ' . trim($customer->firstname . ' ' . $customer->lastname) . ' (Đơn #' . $order->order_num . ')', - )); - } catch (\Throwable $e) { - Log::warning('Không thể broadcast OrderProvisioned: ' . $e->getMessage()); - } - - return true; } - } catch (\Throwable $e) { - $errorMessage = 'Lỗi khi tạo máy ảo (Job): ' . $e->getMessage(); - Log::error($errorMessage, [ - 'exception' => $e, + if ($result === 'Login_Again') { + throw new RuntimeException('Tạo máy ảo thất bại: cần đăng nhập lại vCenter.'); + } + + $vmId = is_array($result) ? ($result['data'] ?? null) : $result; + + if (!$vmId || in_array($vmId, ['unknown', 'UNKNOWN'], true)) { + throw new RuntimeException('Không lấy được vm_id sau khi tạo máy ảo.'); + } + + $vmLogger->success('VM clone success', [ 'order_item_id' => $this->item->id, - 'trace' => $e->getTraceAsString() + 'vm_id' => $vmId, ]); - $telegram->SendErrorMessage([ - 'error' => $e->getMessage(), - 'file' => $e->getFile(), - 'line' => $e->getLine(), + $vmLogger->step('VM start started', ['vm_id' => $vmId]); + + $startResult = $powerService->start($vCenter, $vmId); + + if (is_array($startResult) && ($startResult['error'] ?? false)) { + throw new RuntimeException( + 'Start VM thất bại: ' . json_encode($startResult['data'] ?? null) + ); + } + if ($startResult === 'Login_Again') { + throw new RuntimeException('Start VM thất bại: cần đăng nhập lại vCenter.'); + } + + $vmLogger->success('VM start success', ['vm_id' => $vmId]); + + $moduleVmware = $moduleService->create($this->item, $infra, $vmId); + + $vmLogger->success('ModuleVmware created', [ + 'module_vmware_id' => $moduleVmware->id, + 'vm_id' => $vmId, + ]); + + $serviceSync->sync($moduleVmware, $this->item); + + $vmLogger->success('Service synced', [ + 'module_vmware_id' => $moduleVmware->id, + 'order_item_id' => $this->item->id, + ]); + + AssignIpToGuestJob::dispatch( + $moduleVmware->id, + (string) $vmId, + $infra->isWindows ? VmProvisionService::OS_WINDOWS : VmProvisionService::OS_LINUX, + VmProvisionService::TEMPLATE_PASSWORD, + )->onQueue('default'); + + $vmLogger->success('AssignIpToGuestJob dispatched', [ + 'module_vmware_id' => $moduleVmware->id, + 'vm_id' => $vmId, + 'os_family' => $infra->isWindows ? 'WINDOWS' : 'LINUX', + ]); + + $notification->success($this->item, $moduleVmware, (string) $vmId); + + $vmLogger->success('Create VM job success', [ + 'order_item_id' => $this->item->id, + 'module_vmware_id' => $moduleVmware->id, + 'vm_id' => $vmId, + ]); + + return true; + } catch (Throwable $e) { + $vmLogger->exception('Create VM job failed', $e, [ 'order_item_id' => $this->item->id, - 'trace' => substr($e->getTraceAsString(), 0, 500) // Giới hạn độ dài trace - ], 'CreateVirtualMachineJob - Exception'); + 'is_retry' => $this->isRetry, + ]); if (!$this->isRetry) { - $this->sendFailureNotification($errorMessage); - $this->writeCustomerFailureLog($errorMessage); + $notification->failed($this->item, $e->getMessage()); } + return false; } } - - private function writeCustomerSuccessLog(): void - { - try { - $order = $this->item->order; - $customer = $order?->customer; - - if (!$order || !$customer) { - return; - } - - CustomerLog::create([ - 'agent' => 'system', - 'client_ip' => null, - 'customer_id' => $customer->id, - 'description' => 'Khởi tạo dịch vụ thành công cho đơn hàng #' . $order->order_num - . ' (product_id: ' . $this->item->product_id . ', os_id: ' . $this->item->os_id . ').', - ]); - } catch (\Throwable $e) { - Log::warning('Không thể ghi log thành công khởi tạo VM: ' . $e->getMessage()); - } - } - - private function writeCustomerFailureLog(string $errorMessage): void - { - try { - $order = $this->item->order; - $customer = $order?->customer; - - if (!$order || !$customer) { - return; - } - - CustomerLog::create([ - 'agent' => 'system', - 'client_ip' => null, - 'customer_id' => $customer->id, - 'description' => 'Khởi tạo dịch vụ thất bại cho đơn hàng #' . $order->order_num - . ' (product_id: ' . $this->item->product_id . ', os_id: ' . $this->item->os_id . ').' - . ' Lỗi: ' . $errorMessage . '. ' - . 'Nếu có lỗi xảy ra khi khởi tạo dịch vụ quý khách nhấn khởi tạo tại đây, hoặc quý khách có thể liên hệ với kỹ thuật support@tgs.com.vn', - ]); - } catch (\Throwable $e) { - Log::warning('Không thể ghi log thất bại khởi tạo VM: ' . $e->getMessage()); - } - } - - /** - * Start VM directly using vCenter API - */ - private function startVirtualMachineDirectly(vCenter $vCenter, string $vmId): array|string - { - try { - // Kiểm tra xem đã có session chưa - $check = $vCenter->checkApiExits(); - if (!$check) { - return 'Login_Again'; - } - - // Gọi API start VM trực tiếp - $response = Http::withoutVerifying() - ->withHeaders([ - 'vmware-api-session-id' => $vCenter->token - ]) - ->post("https://{$vCenter->apiUri}/rest/vcenter/vm/{$vmId}/power/start"); - - if ($response->status() === 200) { - return [ - 'error' => false, - 'data' => 'success' - ]; - } - - return [ - 'error' => true, - 'data' => $response->body() - ]; - } catch (\Exception $e) { - Log::error('Lỗi khi start VM: ' . $e->getMessage()); - return [ - 'error' => true, - 'data' => $e->getMessage() - ]; - } - } - - - - /** - * Send failure notification to customer - */ - private function sendFailureNotification(string $errorMessage): void - { - try { - $customer = $this->item->order->customer; - $product = $this->item->getProductAttribute(); - - VMCreationFailedJob::dispatch([ - 'customer_email' => $customer->email, - 'customer_name' => trim($customer->firstname . ' ' . $customer->lastname), - 'product_name' => $product?->name ?? 'Unknown Product', - 'order_id' => $this->item->order->id, - 'order_num' => $this->item->order->order_num, - 'error_message' => $errorMessage, - 'order_item_id' => $this->item->id - ]); - } catch (\Throwable $e) { - Log::error('Không thể gửi email thông báo lỗi VM: ' . $e->getMessage()); - } - } - - /** - * Sync module_vmware info → services table. - * Link: module_vmware.order_id → order_items.order_id → services.order_item_id - */ - private function syncServiceRecord(ModuleVmware $moduleVmware): void - { - try { - $orderItem = OrderItem::where('order_id', $moduleVmware->order_id)->first(); - - if (!$orderItem) { - Log::warning('syncServiceRecord: không tìm thấy OrderItem', [ - 'order_id' => $moduleVmware->order_id, - 'module_vmware_id' => $moduleVmware->id, - ]); - return; - } - - $months = (int) ($orderItem->months ?? 0); - $updated = Service::where('order_item_id', $orderItem->id) - ->update([ - 'vm_id' => $moduleVmware->id, - 'status' => 'active', - 'expires_at' => $months > 0 ? now()->addMonths($months) : null, - ]); - - Log::info('syncServiceRecord: service synced', [ - 'module_vmware_id' => $moduleVmware->id, - 'vmware_id' => $moduleVmware->vmware_id, - 'order_item_id' => $orderItem->id, - 'rows_updated' => $updated, - ]); - } catch (\Throwable $e) { - Log::error('syncServiceRecord: exception — ' . $e->getMessage(), [ - 'module_vmware_id' => $moduleVmware->id, - 'order_id' => $moduleVmware->order_id, - ]); - } - } - - /** - * Gửi email thông báo VM đã khởi tạo thành công cho khách hàng. - * Gọi ngay sau khi tạo module_vmware — cho cả Linux lẫn Windows. - */ - /** - * Dịch error từ vCenter API sang tiếng Việt dễ hiểu. - */ - private function translateVcenterError(array $result): string - { - $data = $result['data'] ?? []; - $errorType = is_array($data) ? ($data['error_type'] ?? '') : ''; - $serverName = $this->item->server_name ?? ''; - - $map = [ - 'ALREADY_EXISTS' => "Tên máy chủ \"{$serverName}\" đã tồn tại trong hệ thống. Vui lòng liên hệ hỗ trợ để được xử lý hoặc chọn tên máy chủ khác.", - 'NOT_FOUND' => 'Không tìm thấy template hoặc tài nguyên trên vCenter. Vui lòng liên hệ hỗ trợ.', - 'RESOURCE_INACCESSIBLE' => 'Tài nguyên vCenter không khả dụng. Vui lòng thử lại sau hoặc liên hệ hỗ trợ.', - 'INVALID_ARGUMENT' => 'Thông tin cấu hình máy chủ không hợp lệ. Vui lòng liên hệ hỗ trợ.', - 'UNABLE_TO_ALLOCATE_RESOURCE' => 'Không đủ tài nguyên để khởi tạo máy chủ. Vui lòng liên hệ hỗ trợ.', - 'TIMED_OUT' => 'Yêu cầu khởi tạo máy chủ hết thời gian chờ. Vui lòng thử lại sau.', - ]; - - if ($errorType && isset($map[$errorType])) { - return $map[$errorType]; - } - - // Fallback: không để lộ JSON thô ra email khách hàng - return "Không thể khởi tạo máy chủ ảo lúc này. Chúng tôi đã ghi nhận và sẽ xử lý trong thời gian sớm nhất. Vui lòng liên hệ hỗ trợ nếu cần."; - } - - private function getBillingCycleLabel(?int $months): string - { - return match ($months) { - 1 => 'Hàng tháng', - 3 => '3 Tháng', - 6 => '6 Tháng', - 12 => 'Hàng năm', - 24 => '2 Năm', - default => $months . ' Tháng', - }; - } } diff --git a/app/Jobs/VirtualMachine/Provision/AssignIpToGuestJob.php b/app/Jobs/VirtualMachine/Provision/AssignIpToGuestJob.php index 88c586c..1cf24d5 100644 --- a/app/Jobs/VirtualMachine/Provision/AssignIpToGuestJob.php +++ b/app/Jobs/VirtualMachine/Provision/AssignIpToGuestJob.php @@ -9,8 +9,11 @@ use App\Models\IpPool; use App\Models\ModuleIpAddress; use App\Models\ModuleVmware; use App\Models\Os; +use App\Models\Service; +use App\Models\VirtualMachines; use App\Http\Services\vCenter as vCenterService; use App\Models\vCenter; +use App\Services\VmProvisionService; use App\Services\VsphereHostSshService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -24,7 +27,8 @@ 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. + * Only persists the IP to DB (module_vmware + virtual_machines) after + * successful guest-side verification, then dispatches ChangePasswordViaVsphereJob. * * Flow: * [1] Resolve network config from order item → product → ipAddress template @@ -34,7 +38,7 @@ use Illuminate\Support\Facades\Log; * [5] On FAIL → release IP, try next * [6] Call verifyIPLinux.ps1 / verifyIPWindows.ps1 via SSH * [7] On FAIL → release IP, try next - * [8] SUCCESS → persistIp() → dispatch SendVmCredentialsEmailJob + * [8] SUCCESS → persistIp() → dispatch ChangePasswordViaVsphereJob * [9] All attempts exhausted → mark VM inactive (status=0) */ class AssignIpToGuestJob implements ShouldQueue @@ -52,8 +56,8 @@ class AssignIpToGuestJob implements ShouldQueue 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 readonly string $osFamily, // VmProvisionService::OS_LINUX | OS_WINDOWS + public readonly string $guestPassword = VmProvisionService::TEMPLATE_PASSWORD, ) {} // ───────────────────────────────────────────────────────────────────────── @@ -79,16 +83,15 @@ class AssignIpToGuestJob implements ShouldQueue return; } - // Resolve network config: product template IP → gateway, netmask, dns, networkId + // DNS + network selector still come from product template; gateway + CIDR + // are now read from the chosen ip_pools row at reservation time. $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); + $dns = $orderItem?->product?->ipAddress?->dns ?? '8.8.8.8'; + $hostId = $osModel?->osGroup?->host?->id ?? 1; + $serviceId = Service::where('vm_id', $this->moduleVmwareId)->value('id'); // vCenter credentials $vcenter = $moduleVmware->vcenter ?? vCenter::first(); @@ -109,9 +112,9 @@ class AssignIpToGuestJob implements ShouldQueue ]); // [3] Reserve a free IP with transaction + row lock - $reservation = $this->reserveIp($networkId, $hostId, $gateway, $netmask, $dns); + $picked = $this->reserveIp($networkId, $hostId, $dns); - if (!$reservation) { + if (!$picked) { Log::error('AssignIpToGuestJob: no free IPs in pool', [ 'network_id' => $networkId, 'attempt' => $attempt, @@ -119,9 +122,13 @@ class AssignIpToGuestJob implements ShouldQueue break; // No point retrying — no IPs available } - $ip = $reservation->ip; + $reservation = $picked['reservation']; + $ipPoolId = $picked['ip_pool_id']; + $ip = $reservation->ip; + $gateway = (string) $picked['gateway']; + $cidr = (string) $picked['cidr']; - // [5] Assign IP into VM guest OS via PS1 script + // [4] Assign IP into VM guest OS via PS1 script (Linux uses assignIPLinux.ps1) $assignOutput = $ssh->assignIp( vmId: $this->vmId, ip: $ip, @@ -136,18 +143,18 @@ class AssignIpToGuestJob implements ShouldQueue guestUser: $guestUser, ); - // [6] If assign failed → release and try next IP + // [5] 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); + $this->releaseIp($reservation, $ipPoolId); continue; } - // [7] Verify IP is active inside guest OS via PS1 script + // [6] Verify IP is active inside guest OS via PS1 script $verifyOutput = $ssh->verifyIpInGuest( vmId: $this->vmId, ip: $ip, @@ -159,19 +166,19 @@ class AssignIpToGuestJob implements ShouldQueue guestUser: $guestUser, ); - // [8] If verification failed → release and try next IP + // [7] 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); + $this->releaseIp($reservation, $ipPoolId); continue; } - // [9] SUCCESS — persist confirmed IP to DB - $this->persistIp($reservation, $moduleVmware); + // [8] SUCCESS — persist confirmed IP to module_vmware + virtual_machines + ip_pools + $this->persistIp($reservation, $moduleVmware, $ipPoolId, $serviceId); Log::info('AssignIpToGuestJob: IP assigned and verified', [ 'module_vmware_id' => $this->moduleVmwareId, @@ -180,13 +187,17 @@ class AssignIpToGuestJob implements ShouldQueue 'attempt' => $attempt, ]); - // Dispatch credential email now that we have the real IP - SendVmCredentialsEmailJob::dispatch($this->moduleVmwareId); + // Dispatch password change now that IP is confirmed + ChangePasswordViaVsphereJob::dispatch( + $this->moduleVmwareId, + $this->vmId, + $this->osFamily, + ); return; } - // [10] All attempts exhausted + // [9] All attempts exhausted Log::error('AssignIpToGuestJob: all attempts failed — marking VM inactive', [ 'module_vmware_id' => $this->moduleVmwareId, 'vm_id' => $this->vmId, @@ -194,9 +205,10 @@ class AssignIpToGuestJob implements ShouldQueue ]); ModuleVmware::where('id', $this->moduleVmwareId)->update(['status' => 0]); + Service::where('vm_id', $this->moduleVmwareId)->update(['status' => 'failed']); - // 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 + // Attempt to recover IP from vSphere guest identity for admin visibility only. + // Do not send credentials here: password has not been rotated yet. try { $moduleVmware = ModuleVmware::find($this->moduleVmwareId); if ($moduleVmware && !$moduleVmware->ip) { @@ -215,13 +227,8 @@ class AssignIpToGuestJob implements ShouldQueue } } } - - 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', [ + Log::error('AssignIpToGuestJob: failed to recover IP after exhausted attempts', [ 'module_vmware_id' => $this->moduleVmwareId, 'error' => $e->getMessage(), ]); @@ -237,10 +244,11 @@ class AssignIpToGuestJob implements ShouldQueue ]); ModuleVmware::where('id', $this->moduleVmwareId)->update(['status' => 0]); + Service::where('vm_id', $this->moduleVmwareId)->update(['status' => 'failed']); } // ───────────────────────────────────────────────────────────────────────── - // assignIp / verifyIp helpers + // IP reservation helpers // ───────────────────────────────────────────────────────────────────────── /** @@ -253,14 +261,15 @@ class AssignIpToGuestJob implements ShouldQueue * * Returns the pending ModuleIpAddress record, or null if pool is exhausted. */ + /** + * @return array{reservation:ModuleIpAddress,ip_pool_id:int,gateway:string,cidr:int}|null + */ private function reserveIp( int $networkId, int $hostId, - string $gateway, - string $netmask, string $dns, - ): ?ModuleIpAddress { - return DB::transaction(function () use ($networkId, $hostId, $gateway, $netmask, $dns) { + ): ?array { + return DB::transaction(function () use ($networkId, $hostId, $dns) { // Lock existing rows to prevent another job from picking the same IP $usedIps = ModuleIpAddress::where('module_network_id', $networkId) ->lockForUpdate() @@ -268,6 +277,9 @@ class AssignIpToGuestJob implements ShouldQueue ->toArray(); $ipPool = IpPool::where('module_network_id', $networkId) + ->where('status', 'free') + ->whereNotNull('gateway') + ->where('gateway', '!=', '') ->whereNotIn('ip_address', $usedIps) ->lockForUpdate() ->inRandomOrder() @@ -277,8 +289,15 @@ class AssignIpToGuestJob implements ShouldQueue return null; } + // Hold the pool slot + $ipPool->update(['status' => 'reserved']); + + $cidr = (int) ($ipPool->cidr ?: 24); + $gateway = (string) ($ipPool->gateway ?? ''); + $netmask = $this->cidrToNetmask($cidr); + // Create a pending reservation — acts as a distributed lock on this IP - return ModuleIpAddress::create([ + $reservation = ModuleIpAddress::create([ 'vm_name' => '__pending__', 'module_host_id' => $hostId, 'module_network_id' => $networkId, @@ -288,6 +307,13 @@ class AssignIpToGuestJob implements ShouldQueue 'dns' => $dns, 'status' => 0, // 0 = pending/reserved, not yet confirmed ]); + + return [ + 'reservation' => $reservation, + 'ip_pool_id' => $ipPool->id, + 'gateway' => $gateway, + 'cidr' => $cidr, + ]; }); } @@ -295,10 +321,13 @@ class AssignIpToGuestJob implements ShouldQueue * Release a failed reservation by deleting the pending ModuleIpAddress row. * This returns the IP to the available pool. */ - private function releaseIp(ModuleIpAddress $reservation): void + private function releaseIp(ModuleIpAddress $reservation, ?int $ipPoolId): void { try { $reservation->delete(); + if ($ipPoolId) { + IpPool::where('id', $ipPoolId)->update(['status' => 'free']); + } Log::info('AssignIpToGuestJob: IP reservation released', ['ip' => $reservation->ip]); } catch (\Throwable $e) { Log::warning('AssignIpToGuestJob: could not release IP reservation', [ @@ -312,12 +341,17 @@ class AssignIpToGuestJob implements ShouldQueue * Confirm a successful IP assignment: * - Update ModuleIpAddress: status → 1 (assigned), vm_name → real name * - Update ModuleVmware: ip, dns, module_ip_address FK + * - Update VirtualMachines: ip, ip_pool_id FK (matched by vmId) * * 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) { + private function persistIp( + ModuleIpAddress $reservation, + ModuleVmware $moduleVmware, + ?int $ipPoolId, + ?int $serviceId, + ): void { + DB::transaction(function () use ($reservation, $moduleVmware, $ipPoolId, $serviceId) { $reservation->update([ 'vm_name' => $moduleVmware->name ?? ('VM-' . $this->vmId), 'status' => 1, // assigned @@ -328,9 +362,38 @@ class AssignIpToGuestJob implements ShouldQueue 'dns' => $reservation->dns, 'module_ip_address' => $reservation->id, ]); + + // Sync to virtual_machines — matched by vSphere vmId + VirtualMachines::where('vm_id', $this->vmId)->update([ + 'ip' => $reservation->ip, + 'ip_pool_id' => $ipPoolId, + ]); + + // Flip ip_pools row to assigned and link service + if ($ipPoolId) { + IpPool::where('id', $ipPoolId)->update([ + 'status' => 'assigned', + 'service_id' => $serviceId, + ]); + } + + // Touch services row so server_name reflects the provisioned VM. + // Note: services.vm_id is an FK to module_vmware.id (already set by VmServiceSyncService) — do not overwrite with vSphere vmId string. + if ($serviceId) { + Service::where('id', $serviceId)->update([ + 'server_name' => $moduleVmware->name, + ]); + } }); } + /** Convert CIDR prefix length to dotted-decimal netmask (e.g. 24 → 255.255.255.0). */ + private function cidrToNetmask(int $cidr): string + { + $cidr = max(0, min(32, $cidr)); + return long2ip(-1 << (32 - $cidr)); + } + // ───────────────────────────────────────────────────────────────────────── // Micro-helpers // ───────────────────────────────────────────────────────────────────────── @@ -338,6 +401,10 @@ class AssignIpToGuestJob implements ShouldQueue /** PS1 scripts return JSON: {"status":"ok"} or {"status":"error","message":"..."} */ private function isSuccess(string $output): bool { + if (str_contains(strtoupper(trim($output)), 'SUCCESS')) { + return true; + } + $data = json_decode(trim($output), true); return is_array($data) && ($data['status'] ?? '') === 'ok'; } diff --git a/app/Jobs/VirtualMachine/Provision/ChangePasswordViaVsphereJob.php b/app/Jobs/VirtualMachine/Provision/ChangePasswordViaVsphereJob.php index c05c300..e204589 100644 --- a/app/Jobs/VirtualMachine/Provision/ChangePasswordViaVsphereJob.php +++ b/app/Jobs/VirtualMachine/Provision/ChangePasswordViaVsphereJob.php @@ -6,6 +6,7 @@ namespace App\Jobs\VirtualMachine\Provision; use App\Http\Facade\SignRSA; use App\Models\ModuleVmware; +use App\Models\Service; use App\Models\vCenter; use App\Services\VmProvisionService; use App\Services\VsphereHostSshService; @@ -22,15 +23,15 @@ use Illuminate\Support\Facades\Log; * Resets the VM guest OS password via: * Laravel → SSH (10.10.10.173) → PowerShell PS1 → Invoke-VMScript (guest OS) * - * Flow: - * [1] VM already exists, vm_id known + * Dispatched by AssignIpToGuestJob after IP is confirmed. Flow: + * [1] VM already exists, vm_id known, IP already assigned * [2] Generate random 16-char password (plain text) * [3] Load vCenter credentials from module_vcenter * [4] SSH into vSphere host * [5] Execute changePassword{Linux|Windows}.ps1 with 5 params * [6] Parse "SUCCESS" / "FAIL" from script output * [7] On SUCCESS → save encrypted password to DB - * [8] Dispatch AssignIpToGuestJob with the new guest password + * [8] Dispatch SendVmCredentialsEmailJob (IP + password both ready) * [9] On FAIL → throw exception → job retries */ class ChangePasswordViaVsphereJob implements ShouldQueue @@ -84,10 +85,13 @@ class ChangePasswordViaVsphereJob implements ShouldQueue // [6] Parse output — throw on FAIL so job retries $this->assertSuccess($output); - // [7] Save encrypted + plain password (IP will be set later by AssignIpToGuestJob) + // [7] Save encrypted + plain password (IP already assigned by AssignIpToGuestJob) $provision->updatePassword($this->moduleVmwareId, $newPassword); - // [8] Send VM credentials email (AssignIpToGuestJob bypassed temporarily) + // Provisioning complete — activate the service record + Service::where('vm_id', $this->moduleVmwareId)->update(['status' => 'active']); + + // [8] Send credentials email — IP + new password are both ready at this point SendVmCredentialsEmailJob::dispatch($this->moduleVmwareId); Log::info('ChangePasswordViaVsphereJob: completed', [ @@ -107,6 +111,7 @@ class ChangePasswordViaVsphereJob implements ShouldQueue ]); ModuleVmware::where('id', $this->moduleVmwareId)->update(['status' => 0]); + Service::where('vm_id', $this->moduleVmwareId)->update(['status' => 'failed']); } // ───────────────────────────────────────────────────────────────────────── diff --git a/app/Jobs/VirtualMachine/Provision/WaitVmReadyJob.php b/app/Jobs/VirtualMachine/Provision/WaitVmReadyJob.php index 5a6f128..564a389 100644 --- a/app/Jobs/VirtualMachine/Provision/WaitVmReadyJob.php +++ b/app/Jobs/VirtualMachine/Provision/WaitVmReadyJob.php @@ -102,9 +102,7 @@ class WaitVmReadyJob implements ShouldQueue ChangePasswordViaVsphereJob::dispatch( $this->moduleVmwareId, $this->vmId, - $guestIp, $this->osFamily, - $this->username, ); } diff --git a/app/Models/IpPool.php b/app/Models/IpPool.php index 1d898f2..7abac30 100644 --- a/app/Models/IpPool.php +++ b/app/Models/IpPool.php @@ -6,18 +6,40 @@ use App\Traits\LogsActivity; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Support\Str; class IpPool extends Model { use HasFactory, SoftDeletes, LogsActivity; protected $fillable = [ + 'uuid', 'ip_address', + 'gateway', + 'cidr', 'module_network_id', + 'status', + 'service_id', ]; + protected static function boot(): void + { + parent::boot(); + + static::creating(function (self $model) { + if (empty($model->uuid)) { + $model->uuid = (string) Str::uuid(); + } + }); + } + public function network() { return $this->belongsTo(ModuleNetwork::class, 'module_network_id', 'id'); } + + public function virtualMachines() + { + return $this->hasMany(VirtualMachines::class, 'ip_pool_id'); + } } diff --git a/app/Models/VirtualMachines.php b/app/Models/VirtualMachines.php index 5c0e3a2..ea3645c 100644 --- a/app/Models/VirtualMachines.php +++ b/app/Models/VirtualMachines.php @@ -22,7 +22,9 @@ class VirtualMachines extends Model 'is_system', 'last_synced_at', 'datacenter_id', - 'host_id' + 'host_id', + 'ip_pool_id', + 'ip', ]; public function datacenter() @@ -34,4 +36,9 @@ class VirtualMachines extends Model { return $this->belongsTo(ModuleHost::class, 'host_id'); } + + public function ipPool() + { + return $this->belongsTo(IpPool::class, 'ip_pool_id'); + } } diff --git a/app/Services/VirtualMachine/Dto/VmInfrastructureContext.php b/app/Services/VirtualMachine/Dto/VmInfrastructureContext.php new file mode 100644 index 0000000..360f78e --- /dev/null +++ b/app/Services/VirtualMachine/Dto/VmInfrastructureContext.php @@ -0,0 +1,24 @@ + 0, 'ram' => 0, 'disk' => 0]; + + if (!$item->addons) { + return $totals; + } + + foreach ($item->addons as $orderAddon) { + $addon = $orderAddon->addonItems; + if (!$addon) { + continue; + } + + $name = strtolower(trim((string) $addon->name)); + $qty = (int) $orderAddon->qty; + + switch ($name) { + case 'cpu': + $totals['cpu'] += $qty; + break; + case 'ram': + $totals['ram'] += $qty; + break; + case 'disk': + case 'ssd': + $totals['disk'] += $qty; + break; + } + } + + return $totals; + } +} diff --git a/app/Services/VirtualMachine/VmInfrastructureResolver.php b/app/Services/VirtualMachine/VmInfrastructureResolver.php new file mode 100644 index 0000000..a7040b8 --- /dev/null +++ b/app/Services/VirtualMachine/VmInfrastructureResolver.php @@ -0,0 +1,89 @@ +find($item->os_id); + + if (!$os) { + throw new RuntimeException( + "Không tìm thấy OS id={$item->os_id} trong OrderItem #{$item->id}. os_id không hợp lệ." + ); + } + + // Priority 1: host attached directly to OS + $host = $os->host; + $datacenter = $host?->datacenter; + $vcenter = $datacenter?->vCenter; + + // Priority 2: host from OS group fallback + if (!$host || !$datacenter || !$vcenter) { + $groupHost = $os->osGroup?->host; + $host = $host ?? $groupHost; + $datacenter = $datacenter ?? $groupHost?->datacenter; + $vcenter = $vcenter ?? $groupHost?->datacenter?->vCenter; + + if ($groupHost) { + $this->logger->step('Infrastructure fallback: osGroup host', [ + 'os_id' => $os->id, + 'os_group_id' => $os->os_group_id, + 'host_id' => $groupHost->id, + ]); + } + } + + // Priority 3: host from product (Provisioning tab) + if ((!$host || !$datacenter || !$vcenter) && $product && $product->module_host_id) { + $productHost = ModuleHost::with('datacenter.vCenter')->find($product->module_host_id); + if ($productHost) { + $host = $host ?? $productHost; + $datacenter = $datacenter ?? $productHost->datacenter; + $vcenter = $vcenter ?? $productHost->datacenter?->vCenter; + + $this->logger->step('Infrastructure fallback: product host', [ + 'product_id' => $product->id, + 'host_id' => $productHost->id, + 'datacenter_id' => $productHost->datacenter?->id, + ]); + } + } + + if (!$vcenter || !$host || !$datacenter) { + throw new RuntimeException( + "Thiếu thông tin host/datacenter/vcenter từ sản phẩm ID #" . ($product->id ?? 'unknown') . ". " + . "Vui lòng cấu hình vCenter trong tab Provisioning của sản phẩm, " + . "hoặc gán host cho OS group của OS được chọn." + ); + } + + $osGroupName = $os->osGroup?->name ?? ''; + $isWindows = str_contains(strtolower($osGroupName), 'windows'); + + return new VmInfrastructureContext( + os: $os, + host: $host, + datacenter: $datacenter, + vcenter: $vcenter, + template: $os->template_id, + isWindows: $isWindows, + vmUser: $isWindows ? 'administrator' : 'root', + vmPort: $isWindows ? 3389 : 22, + ); + } +} diff --git a/app/Services/VirtualMachine/VmModuleService.php b/app/Services/VirtualMachine/VmModuleService.php new file mode 100644 index 0000000..7eddf7e --- /dev/null +++ b/app/Services/VirtualMachine/VmModuleService.php @@ -0,0 +1,48 @@ +order->customer; + $serverName = $item->server_name ?? ('VM-' . $vmId); + + $months = (int) ($item->months ?? 0); + $firstPayment = (int) $item->price * ($months ?: 1); + $nextDueDate = $months > 0 ? now()->addMonths($months)->timestamp : null; + + return ModuleVmware::create([ + 'name' => $serverName, + 'module_ip_address' => null, + 'ip' => null, + 'port' => $infra->vmPort, + 'order_id' => $item->order->id, + 'first_payment' => $firstPayment, + 'next_payment' => $firstPayment, + 'next_due_date' => $nextDueDate, + 'next_invoice' => $nextDueDate, + 'user' => $infra->vmUser, + 'password' => VmProvisionService::TEMPLATE_PASSWORD, + 'plain_password' => VmProvisionService::TEMPLATE_PASSWORD, + 'hostname' => $serverName, + 'vmware_id' => $vmId, + 'vcenter_id' => $infra->vcenter->id, + 'os_id' => $item->os_id ?? 1, + 'customer_id' => $customer->id, + 'status' => 1, + ]); + } +} diff --git a/app/Services/VirtualMachine/VmNotificationService.php b/app/Services/VirtualMachine/VmNotificationService.php new file mode 100644 index 0000000..1389870 --- /dev/null +++ b/app/Services/VirtualMachine/VmNotificationService.php @@ -0,0 +1,112 @@ +writeCustomerSuccessLog($item); + $this->broadcastProvisioned($item, $vmId, true); + } + + public function failed(OrderItem $item, string $errorMessage): void + { + $this->sendFailureEmail($item, $errorMessage); + $this->writeCustomerFailureLog($item, $errorMessage); + } + + private function sendFailureEmail(OrderItem $item, string $errorMessage): void + { + try { + $customer = $item->order->customer; + $product = $item->getProductAttribute(); + + VMCreationFailedJob::dispatch([ + 'customer_email' => $customer->email, + 'customer_name' => trim($customer->firstname . ' ' . $customer->lastname), + 'product_name' => $product?->name ?? 'Unknown Product', + 'order_id' => $item->order->id, + 'order_num' => $item->order->order_num, + 'error_message' => $errorMessage, + 'order_item_id' => $item->id, + ]); + } catch (Throwable $e) { + Log::error('VmNotificationService: cannot dispatch failure email: ' . $e->getMessage()); + } + } + + private function writeCustomerSuccessLog(OrderItem $item): void + { + try { + $order = $item->order; + $customer = $order?->customer; + if (!$order || !$customer) { + return; + } + + CustomerLog::create([ + 'agent' => 'system', + 'client_ip' => null, + 'customer_id' => $customer->id, + 'description' => 'Khởi tạo dịch vụ thành công cho đơn hàng #' . $order->order_num + . ' (product_id: ' . $item->product_id . ', os_id: ' . $item->os_id . ').', + ]); + } catch (Throwable $e) { + Log::warning('VmNotificationService: cannot write success log: ' . $e->getMessage()); + } + } + + private function writeCustomerFailureLog(OrderItem $item, string $errorMessage): void + { + try { + $order = $item->order; + $customer = $order?->customer; + if (!$order || !$customer) { + return; + } + + CustomerLog::create([ + 'agent' => 'system', + 'client_ip' => null, + 'customer_id' => $customer->id, + 'description' => 'Khởi tạo dịch vụ thất bại cho đơn hàng #' . $order->order_num + . ' (product_id: ' . $item->product_id . ', os_id: ' . $item->os_id . ').' + . ' Lỗi: ' . $errorMessage . '. ' + . 'Nếu có lỗi xảy ra khi khởi tạo dịch vụ quý khách nhấn khởi tạo tại đây, hoặc quý khách có thể liên hệ với kỹ thuật support@tgs.com.vn', + ]); + } catch (Throwable $e) { + Log::warning('VmNotificationService: cannot write failure log: ' . $e->getMessage()); + } + } + + private function broadcastProvisioned(OrderItem $item, string $vmId, bool $success): void + { + try { + $order = $item->order; + $customer = $order->customer; + $name = trim($customer->firstname . ' ' . $customer->lastname); + + broadcast(new OrderProvisioned( + orderId: $order->id, + orderNum: $order->order_num, + customerName: $name, + vmId: $vmId, + success: $success, + message: 'Máy ảo đã được khởi tạo thành công cho khách hàng ' . $name . ' (Đơn #' . $order->order_num . ')', + )); + } catch (Throwable $e) { + Log::warning('VmNotificationService: cannot broadcast OrderProvisioned: ' . $e->getMessage()); + } + } +} diff --git a/app/Services/VirtualMachine/VmPowerService.php b/app/Services/VirtualMachine/VmPowerService.php new file mode 100644 index 0000000..f350d29 --- /dev/null +++ b/app/Services/VirtualMachine/VmPowerService.php @@ -0,0 +1,35 @@ +checkApiExits()) { + return 'Login_Again'; + } + + $response = Http::withoutVerifying() + ->withHeaders(['vmware-api-session-id' => $vCenter->token]) + ->post("https://{$vCenter->apiUri}/rest/vcenter/vm/{$vmId}/power/start"); + + if ($response->status() === 200) { + return ['error' => false, 'data' => 'success']; + } + + return ['error' => true, 'data' => $response->body()]; + } catch (Throwable $e) { + Log::error('VmPowerService: start VM failed: ' . $e->getMessage()); + return ['error' => true, 'data' => $e->getMessage()]; + } + } +} diff --git a/app/Services/VirtualMachine/VmProvisionLogger.php b/app/Services/VirtualMachine/VmProvisionLogger.php new file mode 100644 index 0000000..a2f455d --- /dev/null +++ b/app/Services/VirtualMachine/VmProvisionLogger.php @@ -0,0 +1,109 @@ +redact($context); + Log::info("[{$this->jobLabel()}] {$title}", $safe); + $this->telegram->SendJobRunInfo($this->jobLabel(), 'step: ' . $title, $safe); + } + + public function success(string $title, array $context = []): void + { + $safe = $this->redact($context); + Log::info("[{$this->jobLabel()}] {$title}", $safe); + $this->telegram->SendJobRunInfo($this->jobLabel(), 'success: ' . $title, $safe); + } + + public function warning(string $title, array $context = []): void + { + $safe = $this->redact($context); + Log::warning("[{$this->jobLabel()}] {$title}", $safe); + $this->telegram->SendErrorMessage([ + 'job' => $this->jobLabel(), + 'level' => 'warning', + 'title' => $title, + 'context' => $safe, + ]); + } + + public function error(string $title, array $context = []): void + { + $safe = $this->redact($context); + Log::error("[{$this->jobLabel()}] {$title}", $safe); + $this->telegram->SendErrorMessage([ + 'job' => $this->jobLabel(), + 'level' => 'error', + 'title' => $title, + 'context' => $safe, + ]); + } + + public function exception(string $title, Throwable $e, array $context = []): void + { + $safe = $this->redact($context); + Log::error("[{$this->jobLabel()}] {$title}: " . $e->getMessage(), $safe + [ + 'exception' => get_class($e), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => $e->getTraceAsString(), + ]); + $this->telegram->SendErrorMessage([ + 'job' => $this->jobLabel(), + 'level' => 'exception', + 'title' => $title, + 'error' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'context' => $safe, + 'trace' => substr($e->getTraceAsString(), 0, 500), + ]); + } + + private function jobLabel(): string + { + return self::JOB_NAME; + } + + private function redact(array $context): array + { + foreach ($context as $key => $value) { + if (is_array($value)) { + $context[$key] = $this->redact($value); + continue; + } + if (in_array(strtolower((string) $key), array_map('strtolower', self::REDACTED_KEYS), true)) { + $context[$key] = '***'; + } + } + return $context; + } +} diff --git a/app/Services/VirtualMachine/VmServiceSyncService.php b/app/Services/VirtualMachine/VmServiceSyncService.php new file mode 100644 index 0000000..3919f0c --- /dev/null +++ b/app/Services/VirtualMachine/VmServiceSyncService.php @@ -0,0 +1,43 @@ +months ?? 0); + $updated = Service::where('order_item_id', $item->id) + ->update([ + 'vm_id' => $moduleVmware->id, + 'status' => 'provisioning', + 'expires_at' => $months > 0 ? now()->addMonths($months) : null, + ]); + + Log::info('VmServiceSyncService: synced', [ + 'module_vmware_id' => $moduleVmware->id, + 'order_item_id' => $item->id, + 'rows_updated' => $updated, + ]); + } catch (Throwable $e) { + Log::error('VmServiceSyncService: exception — ' . $e->getMessage(), [ + 'module_vmware_id' => $moduleVmware->id, + 'order_item_id' => $item->id, + ]); + } + } +} diff --git a/app/Services/VirtualMachine/VmVcenterErrorTranslator.php b/app/Services/VirtualMachine/VmVcenterErrorTranslator.php new file mode 100644 index 0000000..73915a0 --- /dev/null +++ b/app/Services/VirtualMachine/VmVcenterErrorTranslator.php @@ -0,0 +1,35 @@ + 'Không tìm thấy template hoặc tài nguyên trên vCenter. Vui lòng liên hệ hỗ trợ.', + 'RESOURCE_INACCESSIBLE' => 'Tài nguyên vCenter không khả dụng. Vui lòng thử lại sau hoặc liên hệ hỗ trợ.', + 'INVALID_ARGUMENT' => 'Thông tin cấu hình máy chủ không hợp lệ. Vui lòng liên hệ hỗ trợ.', + 'UNABLE_TO_ALLOCATE_RESOURCE' => 'Không đủ tài nguyên để khởi tạo máy chủ. Vui lòng liên hệ hỗ trợ.', + 'TIMED_OUT' => 'Yêu cầu khởi tạo máy chủ hết thời gian chờ. Vui lòng thử lại sau.', + ]; + + public function translate(array $result, ?string $serverName = null): string + { + $data = $result['data'] ?? []; + $errorType = is_array($data) ? ($data['error_type'] ?? '') : ''; + + if ($errorType === 'ALREADY_EXISTS') { + return "Tên máy chủ \"{$serverName}\" đã tồn tại trong hệ thống. " + . 'Vui lòng liên hệ hỗ trợ để được xử lý hoặc chọn tên máy chủ khác.'; + } + + if ($errorType && isset(self::MESSAGES[$errorType])) { + return self::MESSAGES[$errorType]; + } + + // Avoid leaking raw JSON to customers + return 'Không thể khởi tạo máy chủ ảo lúc này. Chúng tôi đã ghi nhận và sẽ xử lý trong thời gian sớm nhất. ' + . 'Vui lòng liên hệ hỗ trợ nếu cần.'; + } +} diff --git a/app/Services/VsphereHostSshService.php b/app/Services/VsphereHostSshService.php index 2d3dcb2..c035132 100644 --- a/app/Services/VsphereHostSshService.php +++ b/app/Services/VsphereHostSshService.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace App\Services; +use App\Http\Facade\SignRSA; +use App\Models\InfraManagement; use Illuminate\Support\Facades\Log; /** @@ -19,11 +21,6 @@ use Illuminate\Support\Facades\Log; */ final class VsphereHostSshService { - private const HOST = '10.10.10.173'; - private const PORT = 22; - private const USER = 'administrator'; - private const PASS = 'Thegioiso@123**$$8899TgTk2'; - private const SCRIPT_DIR = 'C:\\CLISave'; // backslash required for Windows cmd.exe private const SCRIPT_LINUX = 'changePasswordLinux.ps1'; private const SCRIPT_WINDOWS = 'changePasswordWindows.ps1'; @@ -60,13 +57,14 @@ final class VsphereHostSshService $script = strtoupper($osFamily) === VmProvisionService::OS_WINDOWS ? self::SCRIPT_WINDOWS : self::SCRIPT_LINUX; + $powerCliVmId = $this->powerCliVmId($vmId); // Remote Windows cmd.exe command — all args double-quoted $remoteCmd = sprintf( 'cd %s && powershell -ExecutionPolicy Bypass -NonInteractive -File %s %s %s %s %s %s', self::SCRIPT_DIR, $script, - $this->warg($vmId), + $this->warg($powerCliVmId), $this->warg($newPassword), $this->warg($vcenterHost), $this->warg($vcenterUser), @@ -115,42 +113,27 @@ final class VsphereHostSshService ): string { $isWindows = strtoupper($osFamily) === VmProvisionService::OS_WINDOWS; $script = $isWindows ? self::SCRIPT_ASSIGN_WINDOWS : self::SCRIPT_ASSIGN_LINUX; + $powerCliVmId = $this->powerCliVmId($vmId); - if ($isWindows) { - // assignIPWindows.ps1: VMName IP MaskBits Gateway DNS GuestUser GuestPassword - $remoteCmd = sprintf( - 'cd %s && powershell -ExecutionPolicy Bypass -NonInteractive -File %s %s %s %s %s %s %s %s', - self::ASSIGN_DIR, - $script, - $this->warg($vmId), - $this->warg($ip), - $this->warg($cidr), - $this->warg($gateway), - $this->warg($dns), - $this->warg($guestUser), - $this->warg($guestPassword), - ); - } else { - // assignIPLinux.ps1: VCenter VCUser VCPass VMName IP MaskBits Gateway DNS GuestUser GuestPassword - $remoteCmd = sprintf( - 'cd %s && powershell -ExecutionPolicy Bypass -NonInteractive -File %s %s %s %s %s %s %s %s %s %s %s', - self::ASSIGN_DIR, - $script, - $this->warg($vcenterHost), - $this->warg($vcenterUser), - $this->warg($vcenterPassword), - $this->warg($vmId), - $this->warg($ip), - $this->warg($cidr), - $this->warg($gateway), - $this->warg($dns), - $this->warg($guestUser), - $this->warg($guestPassword), - ); - } + // assignIP{Linux|Windows}.ps1: + // VmId IP Gateway Cidr VCenterHost VCenterUser VCenterPassword GuestPassword + $remoteCmd = sprintf( + 'cd %s && powershell -ExecutionPolicy Bypass -NonInteractive -File %s %s %s %s %s %s %s %s %s', + self::ASSIGN_DIR, + $script, + $this->warg($powerCliVmId), + $this->warg($ip), + $this->warg($gateway), + $this->warg($cidr), + $this->warg($vcenterHost), + $this->warg($vcenterUser), + $this->warg($vcenterPassword), + $this->warg($guestPassword), + ); Log::info('VsphereHostSshService: executing assignIp', [ 'vm_id' => $vmId, + 'powercli_vm_id' => $powerCliVmId, 'ip' => $ip, 'script' => $script, 'os' => $osFamily, @@ -188,36 +171,25 @@ final class VsphereHostSshService ): string { $isWindows = strtoupper($osFamily) === VmProvisionService::OS_WINDOWS; $script = $isWindows ? self::SCRIPT_VERIFY_WINDOWS : self::SCRIPT_VERIFY_LINUX; + $powerCliVmId = $this->powerCliVmId($vmId); - if ($isWindows) { - // verifyIPWindows.ps1: VMName IP GuestUser GuestPassword - $remoteCmd = sprintf( - 'cd %s && powershell -ExecutionPolicy Bypass -NonInteractive -File %s %s %s %s %s', - self::ASSIGN_DIR, - $script, - $this->warg($vmId), - $this->warg($ip), - $this->warg($guestUser), - $this->warg($guestPassword), - ); - } else { - // verifyIPLinux.ps1: VCenter VCUser VCPass VMName IP GuestUser GuestPassword - $remoteCmd = sprintf( - 'cd %s && powershell -ExecutionPolicy Bypass -NonInteractive -File %s %s %s %s %s %s %s %s', - self::ASSIGN_DIR, - $script, - $this->warg($vcenterHost), - $this->warg($vcenterUser), - $this->warg($vcenterPassword), - $this->warg($vmId), - $this->warg($ip), - $this->warg($guestUser), - $this->warg($guestPassword), - ); - } + // verifyIP{Linux|Windows}.ps1: + // VmId IP VCenterHost VCenterUser VCenterPassword GuestPassword + $remoteCmd = sprintf( + 'cd %s && powershell -ExecutionPolicy Bypass -NonInteractive -File %s %s %s %s %s %s %s', + self::ASSIGN_DIR, + $script, + $this->warg($powerCliVmId), + $this->warg($ip), + $this->warg($vcenterHost), + $this->warg($vcenterUser), + $this->warg($vcenterPassword), + $this->warg($guestPassword), + ); Log::info('VsphereHostSshService: executing verifyIpInGuest', [ 'vm_id' => $vmId, + 'powercli_vm_id' => $powerCliVmId, 'ip' => $ip, 'script' => $script, 'os' => $osFamily, @@ -244,14 +216,16 @@ final class VsphereHostSshService */ private function sshExec(string $remoteCmd): string { + $ssh = $this->sshConfig(); + // Full native SSH command executed on local shell $sshCmd = sprintf( 'sshpass -p %s ssh -o StrictHostKeyChecking=no -o ConnectTimeout=%d -p %d %s@%s %s 2>&1', - escapeshellarg(self::PASS), + escapeshellarg($ssh['password']), self::CONNECT_TIMEOUT, - self::PORT, - escapeshellarg(self::USER), - escapeshellarg(self::HOST), + $ssh['port'], + escapeshellarg($ssh['username']), + escapeshellarg($ssh['host']), escapeshellarg($remoteCmd), ); @@ -273,10 +247,31 @@ final class VsphereHostSshService } throw new \RuntimeException( - 'SSH exec to ' . self::HOST . ' failed after ' . self::MAX_RETRIES . ' attempts', + 'SSH exec to ' . $ssh['host'] . ' failed after ' . self::MAX_RETRIES . ' attempts', ); } + /** + * @return array{host:string, port:int, username:string, password:string} + */ + private function sshConfig(): array + { + $infra = InfraManagement::where('type', 'jumphost') + ->where('status', true) + ->first(); + + if (!$infra) { + throw new \RuntimeException('No active jumphost configuration found in InfraManagement.'); + } + + return [ + 'host' => $infra->host, + 'port' => (int) ($infra->meta_data['port'] ?? 22), + 'username' => $infra->username, + 'password' => SignRSA::DecryptFunc($infra->password), + ]; + } + /** * Customer-initiated VM password change. * @@ -304,12 +299,13 @@ final class VsphereHostSshService $script = strtoupper($osFamily) === VmProvisionService::OS_WINDOWS ? self::SCRIPT_CHANGE_PWD_WINDOWS : self::SCRIPT_CHANGE_PWD_LINUX; + $powerCliVmId = $this->powerCliVmId($vmId); $remoteCmd = sprintf( 'cd %s && powershell -ExecutionPolicy Bypass -NonInteractive -File %s %s %s %s %s %s %s', self::CHANGE_PWD_DIR, $script, - $this->warg($vmId), + $this->warg($powerCliVmId), $this->warg($newPassword), $this->warg($vcenterHost), $this->warg($vcenterUser), @@ -343,6 +339,15 @@ final class VsphereHostSshService return '"' . str_replace('"', '\\"', $value) . '"'; } + /** + * vCenter REST returns MoRef strings like "vm-3340"; PowerCLI scripts build + * IDs as "VirtualMachine-$vmId", so keep the MoRef intact. + */ + private function powerCliVmId(string $vmId): string + { + return $vmId; + } + /** * Single-quote wrap for passwords — prevents PowerShell variable expansion on $, %, ^ etc. * Inside a single-quoted PS string, a literal single quote is escaped as ''. diff --git a/database/migrations/2025_07_30_160731_create_ip_pools_table.php b/database/migrations/2025_07_30_160731_create_ip_pools_table.php index 88ac165..2112424 100644 --- a/database/migrations/2025_07_30_160731_create_ip_pools_table.php +++ b/database/migrations/2025_07_30_160731_create_ip_pools_table.php @@ -12,10 +12,23 @@ return new class extends Migration { { Schema::create('ip_pools', function (Blueprint $table) { $table->id(); + + $table->uuid('uuid')->unique(); + $table->string('ip_address'); + $table->unsignedBigInteger('module_network_id'); $table->foreign('module_network_id')->references('id')->on('module_network'); + + $table->enum('status', ['free', 'assigned', 'reserved'])->default('free'); + + $table->unsignedBigInteger('service_id')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['module_network_id', 'status']); + $table->index('service_id'); }); } diff --git a/database/migrations/2026_05_29_000001_add_thumbnail_to_products_table.php b/database/migrations/2026_05_29_000001_add_thumbnail_to_products_table.php new file mode 100644 index 0000000..e37460c --- /dev/null +++ b/database/migrations/2026_05_29_000001_add_thumbnail_to_products_table.php @@ -0,0 +1,23 @@ +string('thumbnail')->nullable()->after('description'); + }); + } + } + + public function down(): void + { + // The base create_products migration already defines this nullable column. + // Keep rollback as a no-op so rolling back this compatibility migration + // cannot remove an existing column from fresh databases. + } +}; diff --git a/database/migrations/2026_05_29_000001_add_uuid_to_ip_pools_table.php b/database/migrations/2026_05_29_000001_add_uuid_to_ip_pools_table.php new file mode 100644 index 0000000..d8c65c5 --- /dev/null +++ b/database/migrations/2026_05_29_000001_add_uuid_to_ip_pools_table.php @@ -0,0 +1,28 @@ +uuid('uuid')->nullable()->unique()->after('id'); + }); + + // Backfill existing rows + DB::table('ip_pools')->whereNull('uuid')->orderBy('id')->each(function ($row) { + DB::table('ip_pools')->where('id', $row->id)->update(['uuid' => (string) Str::uuid()]); + }); + } + + public function down(): void + { + Schema::table('ip_pools', function (Blueprint $table) { + $table->dropColumn('uuid'); + }); + } +}; diff --git a/database/migrations/2026_05_29_000002_add_ip_pool_to_virtual_machines_table.php b/database/migrations/2026_05_29_000002_add_ip_pool_to_virtual_machines_table.php new file mode 100644 index 0000000..5acd53a --- /dev/null +++ b/database/migrations/2026_05_29_000002_add_ip_pool_to_virtual_machines_table.php @@ -0,0 +1,28 @@ +unsignedBigInteger('ip_pool_id')->nullable()->after('host_id'); + $table->string('ip', 45)->nullable()->after('ip_pool_id'); + + $table->foreign('ip_pool_id') + ->references('id') + ->on('ip_pools') + ->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('virtual_machines', function (Blueprint $table) { + $table->dropForeign(['ip_pool_id']); + $table->dropColumn(['ip_pool_id', 'ip']); + }); + } +}; diff --git a/database/migrations/2026_05_29_110056_update_column_ip_pool_table.php b/database/migrations/2026_05_29_110056_update_column_ip_pool_table.php new file mode 100644 index 0000000..1ac0037 --- /dev/null +++ b/database/migrations/2026_05_29_110056_update_column_ip_pool_table.php @@ -0,0 +1,40 @@ +string('gateway')->nullable()->after('ip_address'); + $table->unsignedTinyInteger('cidr')->default(24)->after('gateway'); + + $table + ->enum('status', ['free', 'assigned', 'reserved']) + ->default('free') + ->after('module_network_id'); + + $table->foreignId('service_id')->nullable()->after('status')->constrained('services')->nullOnDelete(); + + $table->index(['status', 'module_network_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('ip_pools', function (Blueprint $table) { + $table->dropForeign(['service_id']); + $table->dropIndex(['status', 'module_network_id']); + + $table->dropColumn(['gateway', 'cidr', 'status', 'service_id']); + }); + } +}; diff --git a/database/seeders/IpPoolSeeder.php b/database/seeders/IpPoolSeeder.php index a59d7f1..a834022 100644 --- a/database/seeders/IpPoolSeeder.php +++ b/database/seeders/IpPoolSeeder.php @@ -15,7 +15,7 @@ use Illuminate\Support\Facades\Log; * Cách cấu hình pools() bên dưới: * * // Theo CIDR: - * ['network' => 'LAN-VPS-01', 'cidr' => '103.90.224.0/26'] + * ['network' => 'LAN-VPS-01', 'cidr' => '103.90.224.0/26', 'gateway' => '103.90.224.1'] * * // Theo range: * ['network' => 'LAN-VPS-01', 'start' => '103.90.224.10', 'end' => '103.90.224.60'] @@ -26,7 +26,7 @@ use Illuminate\Support\Facades\Log; * // Có thể dùng module_network_id thay vì tên network: * ['network_id' => 1, 'cidr' => '103.90.224.0/26'] * - * IP đầu (network address) và IP cuối (broadcast) trong CIDR tự động bị loại bỏ. + * IP đầu (network address), IP cuối (broadcast), và gateway trong CIDR tự động bị loại bỏ. * Khi VM được tạo, hệ thống sẽ lấy IP đầu tiên chưa được assign trong module_ip_address. */ class IpPoolSeeder extends Seeder @@ -42,7 +42,8 @@ class IpPoolSeeder extends Seeder // --- Pool 1: VPS Public Network (module_network id=1, name="VM Network") --- [ 'network_id' => 1, // module_network.id - 'cidr' => '103.90.224.0/26', // /26 = 62 usable IPs (.1 → .62) + 'cidr' => '103.90.224.0/26', // /26, gateway .1, cấp phát .2 → .62 + 'gateway' => '103.90.224.1', ], // --- Pool 2: thêm range thủ công --- @@ -91,6 +92,10 @@ class IpPoolSeeder extends Seeder [ 'ip_address' => $ip, 'module_network_id' => $network->id, + ], + [ + 'gateway' => $pool['gateway'] ?? null, + 'cidr' => $this->resolveCidr($pool), ] ); @@ -130,20 +135,53 @@ class IpPoolSeeder extends Seeder private function generateIps(array $pool): array { if (!empty($pool['ips'])) { - return array_values(array_filter($pool['ips'], fn($ip) => filter_var($ip, FILTER_VALIDATE_IP))); + return $this->withoutGateway( + array_values(array_filter($pool['ips'], fn($ip) => filter_var($ip, FILTER_VALIDATE_IP))), + $pool + ); } if (!empty($pool['cidr'])) { - return $this->ipsFromCidr($pool['cidr']); + return $this->withoutGateway($this->ipsFromCidr($pool['cidr']), $pool); } if (!empty($pool['start']) && !empty($pool['end'])) { - return $this->ipsFromRange($pool['start'], $pool['end']); + return $this->withoutGateway($this->ipsFromRange($pool['start'], $pool['end']), $pool); } return []; } + private function resolveCidr(array $pool): int + { + if (!empty($pool['prefix'])) { + return (int) $pool['prefix']; + } + + if (!empty($pool['cidr']) && str_contains($pool['cidr'], '/')) { + return (int) explode('/', $pool['cidr'])[1]; + } + + return 24; + } + + /** + * Không đưa gateway vào IP pool để tránh cấp gateway cho VM. + * + * @param string[] $ips + * @return string[] + */ + private function withoutGateway(array $ips, array $pool): array + { + $gateway = $pool['gateway'] ?? null; + + if (!$gateway || !filter_var($gateway, FILTER_VALIDATE_IP)) { + return $ips; + } + + return array_values(array_filter($ips, fn(string $ip): bool => $ip !== $gateway)); + } + /** * Lấy tất cả usable IPs từ CIDR (bỏ network address và broadcast). * Ví dụ: 103.90.224.0/26 → .1 đến .62 diff --git a/powershell/assignIPLinux.ps1 b/powershell/assignIPLinux.ps1 index 9814e98..e4d8a34 100644 --- a/powershell/assignIPLinux.ps1 +++ b/powershell/assignIPLinux.ps1 @@ -1,85 +1,108 @@ +# assignIPLinux.ps1 +# Deploy to: C:\CLISave\AssignIP\assignIPLinux.ps1 on the vSphere Windows host +# +# Usage: powershell -ExecutionPolicy Bypass -NonInteractive -File assignIPLinux.ps1 \ +# +# +# Output: SUCCESS | FAIL (exactly one line) + param( - [string]$VCenter, - [string]$VCUser, - [string]$VCPass, - - [string]$VMName, - [string]$IP, - [int]$MaskBits, - [string]$Gateway, - [string]$DNS, - - [string]$GuestUser, - [string]$GuestPassword + [Parameter(Mandatory=$true)][string]$VmId, + [Parameter(Mandatory=$true)][string]$Ip, + [Parameter(Mandatory=$true)][string]$Gateway, + [Parameter(Mandatory=$true)][string]$Cidr, + [Parameter(Mandatory=$true)][string]$VCenterHost, + [Parameter(Mandatory=$true)][string]$VCenterUser, + [Parameter(Mandatory=$true)][string]$VCenterPassword, + [Parameter(Mandatory=$true)][string]$GuestPassword ) +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + try { - Import-Module VMware.PowerCLI -ErrorAction Stop - Set-PowerCLIConfiguration -InvalidCertificateAction Ignore -Confirm:$false | Out-Null + Import-Module VMware.VimAutomation.Core -ErrorAction Stop + Set-PowerCLIConfiguration -InvalidCertificateAction Ignore -Confirm:$false | Out-Null + Set-PowerCLIConfiguration -Scope User -ParticipateInCEIP $false -Confirm:$false | Out-Null - Connect-VIServer -Server $VCenter -User $VCUser -Password $VCPass | Out-Null + Connect-VIServer -Server $VCenterHost -User $VCenterUser -Password $VCenterPassword -Force -ErrorAction Stop | Out-Null - $vm = Get-VM -Name $VMName -ErrorAction Stop + $vm = Get-VM -Id "VirtualMachine-$VmId" -ErrorAction Stop - if ($vm.PowerState -ne 'PoweredOn') { - throw "VM is powered off" - } + # Bash script executed inside the Linux guest via VMware Tools (Invoke-VMScript) + $bashScript = @" +#!/bin/bash +set -e - if ($vm.ExtensionData.Guest.ToolsStatus -notin @('toolsOk','toolsOld')) { - throw "VMware Tools not ready" - } +# Find the first usable non-loopback interface +IFACE=`$(ip -o link show | awk -F': ' '{print `$2}' | grep -Ev '^(lo|vir|docker|br-)' | head -1) +if [ -z "`$IFACE" ]; then + echo "ERROR: no usable network interface found" >&2 + exit 1 +fi - $sec = ConvertTo-SecureString $GuestPassword -AsPlainText -Force - $cred = New-Object pscredential($GuestUser, $sec) +# Apply static IP immediately (survives until reboot without persistence layer) +ip addr flush dev `$IFACE 2>/dev/null || true +ip addr add $Ip/$Cidr dev `$IFACE +ip link set `$IFACE up +ip route del default 2>/dev/null || true +ip route add default via $Gateway dev `$IFACE - # ✅ Detect interface chuẩn (chỉ lấy 1 dòng) - $detectScript = "ip route | awk '/default/ {print \$5; exit}'" - $ifaceRes = Invoke-VMScript -VM $vm -ScriptText $detectScript -GuestCredential $cred -ScriptType Bash - $iface = ($ifaceRes.ScriptOutput.Trim() -split "`n")[0].Trim() +# Persist via NetworkManager (RHEL/CentOS/Ubuntu 20+) +if command -v nmcli &>/dev/null; then + CON_NAME=`$(nmcli -t -f NAME con show 2>/dev/null | head -1) + if [ -n "`$CON_NAME" ]; then + nmcli con mod "`$CON_NAME" \ + ipv4.method manual \ + ipv4.addresses "$Ip/$Cidr" \ + ipv4.gateway "$Gateway" 2>/dev/null || true + nmcli con up "`$CON_NAME" 2>/dev/null || true + fi +elif [ -d /etc/netplan ]; then + # Netplan fallback (Ubuntu 18+ without NM) + cat > /etc/netplan/99-static.yaml << 'YAML' +network: + version: 2 + ethernets: + IFACE_PLACEHOLDER: + dhcp4: false + addresses: [IP_PLACEHOLDER/CIDR_PLACEHOLDER] + gateway4: GATEWAY_PLACEHOLDER +YAML + sed -i "s/IFACE_PLACEHOLDER/`$IFACE/g;s|IP_PLACEHOLDER/CIDR_PLACEHOLDER|$Ip/$Cidr|g;s/GATEWAY_PLACEHOLDER/$Gateway/g" /etc/netplan/99-static.yaml + netplan apply 2>/dev/null || true +elif [ -d /etc/sysconfig/network-scripts ]; then + # RHEL/CentOS legacy ifcfg + cat > /etc/sysconfig/network-scripts/ifcfg-`$IFACE << EOF +DEVICE=`$IFACE +BOOTPROTO=static +ONBOOT=yes +IPADDR=$Ip +PREFIX=$Cidr +GATEWAY=$Gateway +EOF + ifdown `$IFACE 2>/dev/null || true + ifup `$IFACE 2>/dev/null || true +fi - if ([string]::IsNullOrWhiteSpace($iface)) { - throw "Cannot detect network interface" - } - - # ✅ CHANGE IP (clean) - $script = @" -ip addr flush dev $iface -ip addr add $IP/$MaskBits dev $iface -ip route replace default via $Gateway -echo "nameserver $DNS" > /etc/resolv.conf +echo SUCCESS "@ - Invoke-VMScript -VM $vm -ScriptText $script -GuestCredential $cred -ScriptType Bash | Out-Null - - Start-Sleep -Seconds 2 - - # ✅ VERIFY chỉ lấy IP (không dính text rác) - $verifyScript = "hostname -I | awk '{print \$1}'" - $verifyRes = Invoke-VMScript -VM $vm -ScriptText $verifyScript -GuestCredential $cred -ScriptType Bash - $actualIp = ($verifyRes.ScriptOutput.Trim() -split "`n")[0].Trim() - - if ($actualIp -eq $IP) { - $status = "ok" - $message = "IP updated successfully" - } else { - $status = "error" - $message = "IP mismatch: $actualIp" - } - - Disconnect-VIServer -Confirm:$false | Out-Null - - [pscustomobject]@{ - status = $status - ip = $actualIp - iface = $iface - message = $message - } | ConvertTo-Json -Depth 3 + $result = Invoke-VMScript -VM $vm ` + -ScriptText $bashScript ` + -GuestUser "root" ` + -GuestPassword $GuestPassword ` + -ScriptType Bash ` + -ErrorAction Stop + if ($result.ScriptOutput -match "SUCCESS") { + Write-Output "SUCCESS" + } else { + Write-Output "FAIL" + } } catch { - Disconnect-VIServer -Confirm:$false | Out-Null - - [pscustomobject]@{ - status = "error" - message = $_.Exception.Message - } | ConvertTo-Json -Depth 3 + Write-Output "FAIL" + Write-Output $_.Exception.Message +} finally { + Disconnect-VIServer -Confirm:$false -ErrorAction SilentlyContinue | Out-Null } diff --git a/storage/app/public/system-config/VqSqzjtQvtEqoeDlIk3iBKmUntmNEJ1GM4RdvHx6.jpg b/storage/app/public/system-config/VqSqzjtQvtEqoeDlIk3iBKmUntmNEJ1GM4RdvHx6.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8322426f81a22f537e894664ad0a63a917f2c44c GIT binary patch literal 37529 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 U|42bG${Y=W(GVC8A@Khu07lJ{0ssI2 literal 0 HcmV?d00001