Refactor VM provisioning pipeline into focused services and add IP pool

management
This commit is contained in:
baodev0
2026-05-29 12:03:35 +07:00
parent 6bb4a47bfb
commit 7ae237ee9d
32 changed files with 1170 additions and 1200 deletions
+2 -1
View File
@@ -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=$?\")"
]
}
}
-72
View File
@@ -1,72 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Http\Services\WinRMClient;
use Exception;
use Illuminate\Console\Command;
use phpseclib3\Net\SSH2;
class Test extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:test';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Command description';
/**
* Execute the console command.
*/
public function handle()
{
try {
// $ssh = new SSH2('103.42.58.37', 22, 10);
// if (!$ssh->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();
}
}
}
-91
View File
@@ -1,91 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Http\Services\vCenter;
use App\Models\Vcenter as VcenterModel;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class TestAddonUpdate extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'test:addon-update
{vm_id : VM ID to test}
{--cpu=1 : CPU addon amount}
{--ram=1 : RAM addon amount in GB}
{--disk=0 : Disk addon amount in GB}
{--vcenter=1 : vCenter ID}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Test addon update functionality for a specific VM';
/**
* Execute the console command.
*/
public function handle()
{
$vmId = $this->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;
}
}
}
@@ -1,91 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Invoice;
use App\Jobs\VirtualMachine\Create\CreateVirtualMachineJob;
use Illuminate\Support\Facades\Log;
class TestFullVMProcess extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'test:full-vm-process {order_id}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Test full VM creation process and VM ID saving';
/**
* Execute the console command.
*/
public function handle()
{
$orderId = $this->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;
}
}
}
-149
View File
@@ -1,149 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\Invoice;
use App\Http\Services\vCenter;
use Illuminate\Support\Facades\Log;
class TestVMInfo extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'test:vm-info {invoice_id}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Test getting VM information from vCenter API';
/**
* Execute the console command.
*/
public function handle()
{
$invoiceId = $this->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;
}
}
}
-80
View File
@@ -1,80 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\Invoice;
use App\Models\Order;
use Illuminate\Support\Facades\Log;
class TestVMSave extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'test:vm-save {order_id} {vm_id}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Test saving VM ID to invoice table';
/**
* Execute the console command.
*/
public function handle()
{
$orderId = $this->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;
}
}
}
@@ -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']);
@@ -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),
@@ -1,570 +1,175 @@
<?php
declare(strict_types=1);
namespace App\Jobs\VirtualMachine\Create;
use App\Http\Services\Telegram;
use App\Http\Services\vCenter;
use App\Jobs\Mail\Customer\VMCreationFailedJob;
use App\Jobs\VirtualMachine\Provision\ChangePasswordViaVsphereJob;
use App\Services\VmProvisionService;
use App\Models\ModuleHost;
use App\Jobs\VirtualMachine\Provision\AssignIpToGuestJob;
use App\Models\OrderItem;
use App\Models\Os;
use App\Models\ModuleVmware;
use App\Models\Log as CustomerLog;
use App\Models\Service;
use App\Services\VirtualMachine\VmAddonResolver;
use App\Services\VirtualMachine\VmInfrastructureResolver;
use App\Services\VirtualMachine\VmModuleService;
use App\Services\VirtualMachine\VmNotificationService;
use App\Services\VirtualMachine\VmPowerService;
use App\Services\VirtualMachine\VmProvisionLogger;
use App\Services\VirtualMachine\VmServiceSyncService;
use App\Services\VirtualMachine\VmVcenterErrorTranslator;
use App\Services\VmProvisionService;
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\Log;
use Illuminate\Support\Facades\Http;
use RuntimeException;
use Throwable;
class CreateVirtualMachineJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/** Cho phép job chạy tối đa 10 phút (VM clone có thể mất 5-8 phút) */
/** VM clone can take 5–8 minutes */
public int $timeout = 600;
/** Không retry tự động — lỗi VM cần admin kiểm tra thủ công */
/** No auto-retry — VM errors need manual review */
public int $tries = 1;
public OrderItem $item;
public bool $isRetry = false;
/**
* Create a new job instance.
*/
public function __construct(OrderItem $item, bool $isRetry = false)
{
$this->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',
};
}
}
@@ -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';
}
@@ -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']);
}
// ─────────────────────────────────────────────────────────────────────────
@@ -102,9 +102,7 @@ class WaitVmReadyJob implements ShouldQueue
ChangePasswordViaVsphereJob::dispatch(
$this->moduleVmwareId,
$this->vmId,
$guestIp,
$this->osFamily,
$this->username,
);
}
+22
View File
@@ -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');
}
}
+8 -1
View File
@@ -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');
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Services\VirtualMachine\Dto;
use App\Models\ModuleHost;
use App\Models\Os;
use App\Models\vCenter;
class VmInfrastructureContext
{
public function __construct(
public readonly Os $os,
public readonly ModuleHost $host,
public readonly mixed $datacenter,
public readonly vCenter $vcenter,
public readonly mixed $template,
public readonly bool $isWindows,
public readonly string $vmUser,
public readonly int $vmPort,
) {
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Services\VirtualMachine;
use App\Models\OrderItem;
class VmAddonResolver
{
/**
* @return array{cpu:int,ram:int,disk:int}
*/
public function resolve(OrderItem $item): array
{
$totals = ['cpu' => 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;
}
}
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Services\VirtualMachine;
use App\Models\ModuleHost;
use App\Models\OrderItem;
use App\Models\Os;
use App\Services\VirtualMachine\Dto\VmInfrastructureContext;
use RuntimeException;
class VmInfrastructureResolver
{
public function __construct(private readonly VmProvisionLogger $logger)
{
}
public function resolve(OrderItem $item, $product): VmInfrastructureContext
{
$os = Os::with(['host.datacenter.vCenter', 'osGroup.host.datacenter.vCenter'])
->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,
);
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Services\VirtualMachine;
use App\Models\ModuleVmware;
use App\Models\OrderItem;
use App\Services\VirtualMachine\Dto\VmInfrastructureContext;
use App\Services\VmProvisionService;
class VmModuleService
{
/**
* Create ModuleVmware shell. IP and final password are filled in later
* by AssignIpToGuestJob → ChangePasswordViaVsphereJob.
*/
public function create(OrderItem $item, VmInfrastructureContext $infra, string $vmId): ModuleVmware
{
$customer = $item->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,
]);
}
}
@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace App\Services\VirtualMachine;
use App\Events\OrderProvisioned;
use App\Jobs\Mail\Customer\VMCreationFailedJob;
use App\Models\Log as CustomerLog;
use App\Models\ModuleVmware;
use App\Models\OrderItem;
use Illuminate\Support\Facades\Log;
use Throwable;
class VmNotificationService
{
public function success(OrderItem $item, ModuleVmware $moduleVmware, string $vmId): void
{
$this->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());
}
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Services\VirtualMachine;
use App\Http\Services\vCenter;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
class VmPowerService
{
public function start(vCenter $vCenter, string $vmId): array|string
{
try {
if (!$vCenter->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()];
}
}
}
@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Services\VirtualMachine;
use App\Http\Services\Telegram;
use Illuminate\Support\Facades\Log;
use Throwable;
class VmProvisionLogger
{
private const JOB_NAME = 'CreateVirtualMachineJob';
private const REDACTED_KEYS = [
'password',
'plain_password',
'guest_password',
'guestPassword',
'vcenter_password',
'vcenterPassword',
'vcenter_pass',
'vcenter_user',
'token',
'secret',
];
public function __construct(private readonly Telegram $telegram)
{
}
public function step(string $title, array $context = []): void
{
$safe = $this->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;
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Services\VirtualMachine;
use App\Models\ModuleVmware;
use App\Models\OrderItem;
use App\Models\Service;
use Illuminate\Support\Facades\Log;
use Throwable;
class VmServiceSyncService
{
/**
* Sync module_vmware → services table.
* Initial status is `provisioning`. Final `active` is set after IP + password
* provisioning succeed, by downstream jobs.
*/
public function sync(ModuleVmware $moduleVmware, OrderItem $item): void
{
try {
$months = (int) ($item->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,
]);
}
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Services\VirtualMachine;
class VmVcenterErrorTranslator
{
private const MESSAGES = [
'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.',
];
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.';
}
}
+75 -70
View File
@@ -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 ''.
@@ -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');
});
}
@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
if (!Schema::hasColumn('products', 'thumbnail')) {
Schema::table('products', function (Blueprint $table) {
$table->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.
}
};
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
return new class extends Migration {
public function up(): void
{
Schema::table('ip_pools', function (Blueprint $table) {
$table->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');
});
}
};
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::table('virtual_machines', function (Blueprint $table) {
$table->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']);
});
}
};
@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('ip_pools', function (Blueprint $table) {
$table->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']);
});
}
};
+44 -6
View File
@@ -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
+92 -69
View File
@@ -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 \
# <vm_id> <ip> <gateway> <cidr> <vcenter_host> <vcenter_user> <vcenter_password> <guest_password>
#
# 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
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB