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
@@ -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.';
}
}