Refactor VM provisioning pipeline into focused services and add IP pool
management
This commit is contained in:
@@ -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.';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user