diff --git a/app/Jobs/VirtualMachine/Provision/AssignIpToGuestJob.php b/app/Jobs/VirtualMachine/Provision/AssignIpToGuestJob.php index dd13d6c..88c586c 100644 --- a/app/Jobs/VirtualMachine/Provision/AssignIpToGuestJob.php +++ b/app/Jobs/VirtualMachine/Provision/AssignIpToGuestJob.php @@ -96,6 +96,9 @@ class AssignIpToGuestJob implements ShouldQueue $vcenterUser = $vcenter->user; $vcenterPass = SignRSA::DecryptFunc($vcenter->password); + // Guest OS default admin user per OS family + $guestUser = strtoupper($this->osFamily) === 'WINDOWS' ? 'Administrator' : 'root'; + // ─── Attempt loop ──────────────────────────────────────────────────── for ($attempt = 1; $attempt <= self::MAX_ATTEMPTS; $attempt++) { @@ -129,6 +132,8 @@ class AssignIpToGuestJob implements ShouldQueue vcenterPassword: $vcenterPass, guestPassword: $this->guestPassword, osFamily: $this->osFamily, + dns: $dns, + guestUser: $guestUser, ); // [6] If assign failed → release and try next IP @@ -151,6 +156,7 @@ class AssignIpToGuestJob implements ShouldQueue vcenterPassword: $vcenterPass, guestPassword: $this->guestPassword, osFamily: $this->osFamily, + guestUser: $guestUser, ); // [8] If verification failed → release and try next IP @@ -329,10 +335,11 @@ class AssignIpToGuestJob implements ShouldQueue // Micro-helpers // ───────────────────────────────────────────────────────────────────────── - /** PS1 scripts output exactly "SUCCESS" or "FAIL". */ + /** PS1 scripts return JSON: {"status":"ok"} or {"status":"error","message":"..."} */ private function isSuccess(string $output): bool { - return str_contains(strtoupper(trim($output)), 'SUCCESS'); + $data = json_decode(trim($output), true); + return is_array($data) && ($data['status'] ?? '') === 'ok'; } /** Convert dotted-decimal netmask to CIDR prefix length (e.g. 255.255.255.0 → 24). */ diff --git a/app/Jobs/VirtualMachine/Provision/ChangePasswordViaVsphereJob.php b/app/Jobs/VirtualMachine/Provision/ChangePasswordViaVsphereJob.php index 14430b2..c05c300 100644 --- a/app/Jobs/VirtualMachine/Provision/ChangePasswordViaVsphereJob.php +++ b/app/Jobs/VirtualMachine/Provision/ChangePasswordViaVsphereJob.php @@ -9,7 +9,6 @@ use App\Models\ModuleVmware; use App\Models\vCenter; use App\Services\VmProvisionService; use App\Services\VsphereHostSshService; -use App\Jobs\VirtualMachine\Provision\AssignIpToGuestJob; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -88,13 +87,8 @@ class ChangePasswordViaVsphereJob implements ShouldQueue // [7] Save encrypted + plain password (IP will be set later by AssignIpToGuestJob) $provision->updatePassword($this->moduleVmwareId, $newPassword); - // [8] Assign real IP into guest OS using the new password - AssignIpToGuestJob::dispatch( - $this->moduleVmwareId, - $this->vmId, - $this->osFamily, - $newPassword, // guest_password — MUST use new password, not template - ); + // [8] Send VM credentials email (AssignIpToGuestJob bypassed temporarily) + SendVmCredentialsEmailJob::dispatch($this->moduleVmwareId); Log::info('ChangePasswordViaVsphereJob: completed', [ 'module_vmware_id' => $this->moduleVmwareId, diff --git a/app/Jobs/VirtualMachine/Provision/SendVmCredentialsEmailJob.php b/app/Jobs/VirtualMachine/Provision/SendVmCredentialsEmailJob.php index 88bed1f..0f79934 100644 --- a/app/Jobs/VirtualMachine/Provision/SendVmCredentialsEmailJob.php +++ b/app/Jobs/VirtualMachine/Provision/SendVmCredentialsEmailJob.php @@ -9,22 +9,24 @@ use App\Jobs\Mail\Admin\AdminOrderNotificationJob; use App\Jobs\Mail\SendInformationVirtualMachine; use App\Models\Invoice; use App\Models\ModuleVmware; +use App\Models\Service; use App\Models\Setting; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; /** * SendVmCredentialsEmailJob * - * Sends the final VM credential email to the customer with an attached PDF invoice. - * Always loads fresh data from DB so it gets the real IP and - * the final password saved by ChangePasswordJob. - * - * This is step [11] in the provisioning flow. + * Final step in the VM provisioning chain: + * [1] Activate service: services.status → 'active', link services.vm_id → module_vmware.id + * [2] Activate VM: module_vmware.status → 1, link module_vmware.service_id → services.id + * [3] Send credential email to customer with PDF invoice + * [4] Notify admin via AdminOrderNotificationJob */ class SendVmCredentialsEmailJob implements ShouldQueue { @@ -39,7 +41,11 @@ class SendVmCredentialsEmailJob implements ShouldQueue public function handle(Telegram $telegram): void { - $vm = ModuleVmware::with(['customer', 'os.osGroup', 'order.orderItems'])->find($this->moduleVmwareId); + $vm = ModuleVmware::with([ + 'customer', + 'os.osGroup', + 'order.orderItems.service', + ])->find($this->moduleVmwareId); if (!$vm) { $msg = "SendVmCredentialsEmailJob: ModuleVmware #{$this->moduleVmwareId} not found"; @@ -57,13 +63,19 @@ class SendVmCredentialsEmailJob implements ShouldQueue return; } + // ── [1 & 2] Activate service + VM records ──────────────────────────── + + $orderItem = $vm->order?->orderItems?->first(); + $service = $orderItem?->service; + + $this->activateService($vm, $service); + + // ── [3] Build email payload ─────────────────────────────────────────── + $setting = Setting::where('key', 'setting')->first(); $settingValue = $setting ? json_decode((string) $setting->value) : null; - // Resolve billing info from order items - $orderItem = $vm->order?->orderItems?->first(); - $months = (int) ($orderItem?->months ?? 0); - + $months = (int) ($orderItem?->months ?? 0); $osGroupName = $vm->os?->osGroup?->name ?? ''; $isWindows = str_contains(strtolower($osGroupName), 'windows'); $osUsername = $isWindows ? 'Administrator' : 'root'; @@ -80,15 +92,14 @@ class SendVmCredentialsEmailJob implements ShouldQueue 'service_port' => $vm->port, 'service_password' => $vm->plain_password ?? '(xem tài khoản quản lý)', 'os_name' => $vm->os - ? (($osGroupName . ' ' . $vm->os->version)) + ? ($osGroupName . ' ' . $vm->os->version) : 'Unknown OS', 'service_due_exp' => $vm->next_due_date ? date('d/m/Y', (int) $vm->next_due_date) : 'N/A', ]; - // Resolve invoice ID — PDF is generated inside SendInformationVirtualMachine::handle() - // to avoid passing large binary blobs through the queue payload + // PDF is generated inside the mail job — never pass binary blobs through queue payload $invoice = Invoice::where('order_id', $vm->order_id)->first(); SendInformationVirtualMachine::dispatch($emailData, $customer, $invoice?->id); @@ -97,10 +108,11 @@ class SendVmCredentialsEmailJob implements ShouldQueue 'module_vmware_id' => $this->moduleVmwareId, 'customer_email' => $customer->email, 'ip' => $vm->ip, - 'invoice_id' => $invoice?->id, + 'invoice_id' => $invoice?->id, ]); - // Notify admin with VM credentials after successful provisioning + // ── [4] Admin notification ──────────────────────────────────────────── + $order = $vm->order; AdminOrderNotificationJob::dispatch([ 'customer_name' => $customer->fullname ?? '', @@ -142,7 +154,46 @@ class SendVmCredentialsEmailJob implements ShouldQueue } } - // ------------------------------------------------------------------------- + // ───────────────────────────────────────────────────────────────────────── + + /** + * Activate the Service record and link it bidirectionally with ModuleVmware. + * + * services: status → 'active', vm_id → module_vmware.id + * module_vmware: status → 1, service_id → services.id + */ + private function activateService(ModuleVmware $vm, ?Service $service): void + { + DB::transaction(function () use ($vm, $service) { + if ($service) { + $service->update([ + 'status' => 'active', + 'vm_id' => $vm->id, + 'server_name' => $service->server_name ?: ($vm->hostname ?? $vm->name), + ]); + + $vm->update([ + 'status' => 1, + 'service_id' => $service->id, + ]); + + Log::info('SendVmCredentialsEmailJob: service activated', [ + 'module_vmware_id' => $vm->id, + 'service_id' => $service->id, + 'ip' => $vm->ip, + ]); + } else { + // Service record not found — still activate the VM so it is not stuck at pending + $vm->update(['status' => 1]); + + Log::warning('SendVmCredentialsEmailJob: no Service record found, activated module_vmware only', [ + 'module_vmware_id' => $vm->id, + ]); + } + }); + } + + // ───────────────────────────────────────────────────────────────────────── private function billingLabel(?int $months): string { diff --git a/app/Services/VsphereHostSshService.php b/app/Services/VsphereHostSshService.php index 2654e07..05d5179 100644 --- a/app/Services/VsphereHostSshService.php +++ b/app/Services/VsphereHostSshService.php @@ -91,8 +91,10 @@ final class VsphereHostSshService * Assign a static IP to the VM guest OS via PS1 script. * Calls assignIPLinux.ps1 or assignIPWindows.ps1 in C:\CLISave\AssignIP\. * - * Script params: vm_id ip gateway cidr vcenter_host vcenter_user vcenter_password guest_password - * Script output: SUCCESS | FAIL + * Windows script params (7): VMName IP MaskBits Gateway DNS GuestUser GuestPassword + * Linux script params (9): VCenter VCUser VCPass VMName IP Gateway DNS GuestUser GuestPassword + * + * Script output: JSON {"status":"ok"} or {"status":"error","message":"..."} */ public function assignIp( string $vmId, @@ -104,24 +106,43 @@ final class VsphereHostSshService string $vcenterPassword, string $guestPassword, string $osFamily, + string $dns, + string $guestUser, ): string { - $script = strtoupper($osFamily) === VmProvisionService::OS_WINDOWS - ? self::SCRIPT_ASSIGN_WINDOWS - : self::SCRIPT_ASSIGN_LINUX; + $isWindows = strtoupper($osFamily) === VmProvisionService::OS_WINDOWS; + $script = $isWindows ? self::SCRIPT_ASSIGN_WINDOWS : self::SCRIPT_ASSIGN_LINUX; - $remoteCmd = sprintf( - 'cd %s && powershell -ExecutionPolicy Bypass -NonInteractive -File %s %s %s %s %s %s %s %s %s', - self::ASSIGN_DIR, - $script, - $this->warg($vmId), - $this->warg($ip), - $this->warg($gateway), - $this->warg($cidr), - $this->warg($vcenterHost), - $this->warg($vcenterUser), - $this->warg($vcenterPassword), - $this->warg($guestPassword), - ); + 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 Gateway DNS GuestUser GuestPassword + $remoteCmd = sprintf( + 'cd %s && powershell -ExecutionPolicy Bypass -NonInteractive -File %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($gateway), + $this->warg($dns), + $this->warg($guestUser), + $this->warg($guestPassword), + ); + } Log::info('VsphereHostSshService: executing assignIp', [ 'vm_id' => $vmId, @@ -145,8 +166,10 @@ final class VsphereHostSshService * Verify that an IP is active inside the VM guest OS. * Calls verifyIPLinux.ps1 or verifyIPWindows.ps1 in C:\CLISave\AssignIP\. * - * Script params: vm_id ip vcenter_host vcenter_user vcenter_password guest_password - * Script output: SUCCESS | FAIL + * Windows script params (4): VMName IP GuestUser GuestPassword + * Linux script params (7): VCenter VCUser VCPass VMName IP GuestUser GuestPassword + * + * Script output: JSON {"status":"ok"} or {"status":"error","message":"..."} */ public function verifyIpInGuest( string $vmId, @@ -156,22 +179,37 @@ final class VsphereHostSshService string $vcenterPassword, string $guestPassword, string $osFamily, + string $guestUser, ): string { - $script = strtoupper($osFamily) === VmProvisionService::OS_WINDOWS - ? self::SCRIPT_VERIFY_WINDOWS - : self::SCRIPT_VERIFY_LINUX; + $isWindows = strtoupper($osFamily) === VmProvisionService::OS_WINDOWS; + $script = $isWindows ? self::SCRIPT_VERIFY_WINDOWS : self::SCRIPT_VERIFY_LINUX; - $remoteCmd = sprintf( - 'cd %s && powershell -ExecutionPolicy Bypass -NonInteractive -File %s %s %s %s %s %s %s', - self::ASSIGN_DIR, - $script, - $this->warg($vmId), - $this->warg($ip), - $this->warg($vcenterHost), - $this->warg($vcenterUser), - $this->warg($vcenterPassword), - $this->warg($guestPassword), - ); + 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), + ); + } Log::info('VsphereHostSshService: executing verifyIpInGuest', [ 'vm_id' => $vmId, diff --git a/artisan b/artisan index 39a4f1a..67a3329 100644 --- a/artisan +++ b/artisan @@ -1,57 +1,3 @@ -[2026-05-14 16:11:33] local.WARNING: AssignIpToGuestJob: assignIp FAIL {"ip":"103.90.224.21","attempt":1,"output":"Welcome to VMware PowerCLI! - -Log in to a vCenter Server or ESX host: Connect-VIServer -To find out what commands are available, type: Get-VICommand -To show searchable help for all PowerCLI commands: Get-PowerCLIHelp -Once you've connected, display all virtual machines: Get-VM -If you need more help, visit the PowerCLI community: Get-PowerCLICommunity - - Copyright (C) VMware, Inc. All rights reserved. - - -{ - - \"status\": \"error\", - - \"message\": \"5/14/2026 4:15:00 PM\\tGet-VM\\t\\tVM with name \\u0027vm-3296\\u0027 was not found using the specified filter(s).\\t\" - -}"} -[2026-05-14 16:13:07] local.WARNING: AssignIpToGuestJob: assignIp FAIL {"ip":"103.90.224.51","attempt":2,"output":"Welcome to VMware PowerCLI! - -Log in to a vCenter Server or ESX host: Connect-VIServer -To find out what commands are available, type: Get-VICommand -To show searchable help for all PowerCLI commands: Get-PowerCLIHelp -Once you've connected, display all virtual machines: Get-VM -If you need more help, visit the PowerCLI community: Get-PowerCLICommunity - - Copyright (C) VMware, Inc. All rights reserved. - - -{ - - \"status\": \"error\", - - \"message\": \"5/14/2026 4:16:33 PM\\tGet-VM\\t\\tVM with name \\u0027vm-3296\\u0027 was not found using the specified filter(s).\\t\" - -}"} -[2026-05-14 16:14:39] local.WARNING: AssignIpToGuestJob: assignIp FAIL {"ip":"103.90.224.56","attempt":3,"output":"Welcome to VMware PowerCLI! - -Log in to a vCenter Server or ESX host: Connect-VIServer -To find out what commands are available, type: Get-VICommand -To show searchable help for all PowerCLI commands: Get-PowerCLIHelp -Once you've connected, display all virtual machines: Get-VM -If you need more help, visit the PowerCLI community: Get-PowerCLICommunity - - Copyright (C) VMware, Inc. All rights reserved. - - -{ - - \"status\": \"error\", - - \"message\": \"5/14/2026 4:18:06 PM\\tGet-VM\\t\\tVM with name \\u0027vm-3296\\u0027 was not found using the specified filter(s).\\t\" - -}"} #!/usr/bin/env php /etc/resolv.conf +"@ + + 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 + +} catch { + Disconnect-VIServer -Confirm:$false | Out-Null + + [pscustomobject]@{ + status = "error" + message = $_.Exception.Message + } | ConvertTo-Json -Depth 3 +} diff --git a/powershell/assignIPWindows.ps1 b/powershell/assignIPWindows.ps1 new file mode 100644 index 0000000..8bdee80 --- /dev/null +++ b/powershell/assignIPWindows.ps1 @@ -0,0 +1,48 @@ +param( + [string]$VMName, + [string]$IP, + [int]$MaskBits, + [string]$Gateway, + [string]$DNS, + [string]$GuestUser, + [string]$GuestPassword +) + +$sec = ConvertTo-SecureString $GuestPassword -AsPlainText -Force +$cred = New-Object pscredential($GuestUser, $sec) + +try { + $vm = Get-VM -Name $VMName -ErrorAction Stop + + if ($vm.PowerState -ne 'PoweredOn') { + [pscustomobject]@{ status="error"; message="VM is powered off" } | ConvertTo-Json + exit + } + + if ($vm.ExtensionData.Guest.ToolsStatus -notin @('toolsOk','toolsOld')) { + [pscustomobject]@{ status="error"; message="VMware Tools not ready" } | ConvertTo-Json + exit + } + + $script = @" +`$adapter = Get-NetAdapter | Where-Object { `$_.Status -eq 'Up' } | Select-Object -First 1 +Remove-NetIPAddress -InterfaceIndex `$adapter.ifIndex -Confirm:`$false -ErrorAction SilentlyContinue +Remove-NetRoute -InterfaceIndex `$adapter.ifIndex -Confirm:`$false -ErrorAction SilentlyContinue +New-NetIPAddress -InterfaceIndex `$adapter.ifIndex -IPAddress "$IP" -PrefixLength $MaskBits -DefaultGateway "$Gateway" +Set-DnsClientServerAddress -InterfaceIndex `$adapter.ifIndex -ServerAddresses "$DNS" +"@ + + $res = Invoke-VMScript -VM $vm -ScriptText $script -GuestCredential $cred -ScriptType PowerShell + + [pscustomobject]@{ + status="ok" + message="IP updated" + ip=$IP + } | ConvertTo-Json + +} catch { + [pscustomobject]@{ + status="error" + message=$_.Exception.Message + } | ConvertTo-Json +}