- Mail campaign: mẫu nhắc gia hạn thanh toán QR, chọn dịch vụ + chu kỳ từng dòng (renewal_services) - Hosting: nhiều server Plesk cho một gói (service_plan_servers), chuyển server khi server chết - Admin xem/sửa giấy tờ định danh khách, tuỳ chọn gia hạn cho khách, nhật ký cấp phát dịch vụ - Đánh dấu mẫu email dịch vụ là non-marketing Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
261 lines
10 KiB
PHP
261 lines
10 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Jobs\VirtualMachine\Create;
|
||
|
||
use App\Http\Services\vCenter;
|
||
use App\Jobs\VirtualMachine\Provision\UpdateVmConfigJob;
|
||
use App\Exceptions\VmProvisionException;
|
||
use App\Models\OrderItem;
|
||
use App\Models\Service;
|
||
use App\Models\VmProvisionLock;
|
||
use App\Services\ActivityLogger;
|
||
use App\Services\VirtualMachine\VmAddonResolver;
|
||
use App\Services\VirtualMachine\VmInfrastructureResolver;
|
||
use App\Services\VirtualMachine\VmIpReservationService;
|
||
use App\Services\VirtualMachine\VmModuleService;
|
||
use App\Services\VirtualMachine\VmNotificationService;
|
||
use App\Services\VirtualMachine\VmProvisionRefund;
|
||
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 RuntimeException;
|
||
use Throwable;
|
||
use App\Support\ServiceProvisionLog;
|
||
|
||
class CreateVirtualMachineJob implements ShouldQueue
|
||
{
|
||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||
|
||
/** VM clone can take 5–8 minutes */
|
||
public int $timeout = 600;
|
||
|
||
/** No auto-retry — VM errors need manual review */
|
||
public int $tries = 1;
|
||
|
||
public function __construct(
|
||
public OrderItem $item,
|
||
public bool $isRetry = false,
|
||
) {
|
||
}
|
||
|
||
public function handle(
|
||
vCenter $vCenter,
|
||
VmProvisionLogger $vmLogger,
|
||
VmInfrastructureResolver $infraResolver,
|
||
VmAddonResolver $addonResolver,
|
||
VmModuleService $moduleService,
|
||
VmServiceSyncService $serviceSync,
|
||
VmNotificationService $notification,
|
||
VmVcenterErrorTranslator $errorTranslator,
|
||
VmIpReservationService $ipReservation,
|
||
VmProvisionRefund $refund,
|
||
): bool {
|
||
$reservation = null;
|
||
$ipPoolId = null;
|
||
$lock = null;
|
||
|
||
$vmLogger->for('CreateVirtualMachineJob');
|
||
|
||
try {
|
||
$customerId = $this->item->order->customer_id;
|
||
$lock = VmProvisionLock::updateOrCreate(
|
||
['order_item_id' => $this->item->id],
|
||
['customer_id' => $customerId, 'status' => 'running'],
|
||
);
|
||
|
||
ServiceProvisionLog::forOrderItem($this->item->id, 'provision_started', $this->isRetry ? 'Bắt đầu khởi tạo lại máy chủ' : 'Bắt đầu khởi tạo máy chủ', ['is_retry' => $this->isRetry]);
|
||
|
||
$vmLogger->step('Create VM job started', [
|
||
'order_item_id' => $this->item->id,
|
||
'is_retry' => $this->isRetry,
|
||
]);
|
||
|
||
$product = $this->item->getProductAttribute();
|
||
|
||
if (!$product) {
|
||
throw new RuntimeException("Không tìm thấy sản phẩm từ OrderItem #{$this->item->id}");
|
||
}
|
||
|
||
$infra = $infraResolver->resolve($this->item, $product);
|
||
$addonData = $addonResolver->resolve($this->item);
|
||
|
||
// Reserve IP BEFORE clone so VM name on vCenter, ModuleVmware.name
|
||
// and services.server_name all carry the format {ip}_{server_name}.
|
||
// Network comes from the active chain (active host → active network);
|
||
// product->ipAddress is no longer consulted for network selection.
|
||
if (!$infra->network) {
|
||
throw new RuntimeException("Active host id={$infra->host->id} chưa có network active.");
|
||
}
|
||
$networkId = $infra->network->id;
|
||
$dns = $product->ipAddress->dns ?? '8.8.8.8';
|
||
|
||
$picked = $ipReservation->reserve($networkId, $infra->host->id, $dns);
|
||
|
||
if (!$picked) {
|
||
throw new RuntimeException(
|
||
"Không còn IP free trong pool (network_id={$networkId}) để tạo VM."
|
||
);
|
||
}
|
||
|
||
$reservation = $picked['reservation'];
|
||
$ipPoolId = $picked['ip_pool_id'];
|
||
$reservedIp = $reservation->ip;
|
||
$originalName = $this->item->server_name ?? ('VM-' . $this->item->id);
|
||
$combinedName = "{$reservedIp}_{$originalName}";
|
||
|
||
$this->item->server_name = $combinedName;
|
||
|
||
ServiceProvisionLog::forOrderItem($this->item->id, 'provision_step', 'Đã giữ IP và bắt đầu clone VM', ['ip' => $reservedIp]);
|
||
|
||
$vmLogger->step('IP reserved for VM', [
|
||
'order_item_id' => $this->item->id,
|
||
'ip' => $reservedIp,
|
||
'ip_pool_id' => $ipPoolId,
|
||
'server_name' => $combinedName,
|
||
]);
|
||
|
||
if (!$vCenter->loginServer($infra->vcenter)) {
|
||
throw new RuntimeException("Login vào vCenter thất bại với host: {$infra->vcenter->host}");
|
||
}
|
||
|
||
$vmLogger->step('VM clone started', [
|
||
'order_item_id' => $this->item->id,
|
||
'product_id' => $product->id,
|
||
'template' => $infra->template,
|
||
'host_id' => $infra->host->id,
|
||
'datastore_id' => $infra->datastore?->id,
|
||
'vcenter_id' => $infra->vcenter->id,
|
||
'folder' => $infra->folderId,
|
||
'resource_pool' => $infra->resourcePoolId,
|
||
]);
|
||
|
||
$result = $vCenter->CreateVirtualMachine([
|
||
'item' => $this->item,
|
||
'product' => $product,
|
||
'host' => $infra->host,
|
||
'datacenter' => $infra->datacenter,
|
||
'vcenter' => $infra->vcenter,
|
||
'template' => $infra->template,
|
||
'addon' => $addonData,
|
||
'datastore' => $infra->datastore,
|
||
'folder' => $infra->folderId,
|
||
'resource_pool' => $infra->resourcePoolId,
|
||
]);
|
||
|
||
if (is_array($result) && ($result['error'] ?? false)) {
|
||
throw new VmProvisionException(
|
||
$errorTranslator->translate($result, $this->item->server_name),
|
||
$errorTranslator->detail($result),
|
||
);
|
||
}
|
||
|
||
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,
|
||
'vm_id' => $vmId,
|
||
]);
|
||
|
||
$moduleVmware = $moduleService->create($this->item, $infra, $vmId, [
|
||
'cpu' => (int) $product->cpu + $addonData['cpu'],
|
||
'ram_mb' => ((int) $product->ram + $addonData['ram']) * 1024,
|
||
'disk_gb' => (int) $product->disk + $addonData['disk'],
|
||
]);
|
||
|
||
$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,
|
||
]);
|
||
|
||
// VM is left powered off after clone. UpdateVmConfigJob owns power-on:
|
||
// if the order has a CPU/RAM addon it powers off (no-op, already off),
|
||
// resizes, then powers on; otherwise it just powers on. Either way it
|
||
// dispatches AssignIpToGuestJob next, which needs the VM running.
|
||
UpdateVmConfigJob::dispatch(
|
||
$moduleVmware->id,
|
||
(string) $vmId,
|
||
$infra->isWindows ? VmProvisionService::OS_WINDOWS : VmProvisionService::OS_LINUX,
|
||
reservationId: $reservation->id,
|
||
reservedIpPoolId: $ipPoolId,
|
||
)->onQueue('default');
|
||
|
||
$vmLogger->success('UpdateVmConfigJob 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,
|
||
]);
|
||
|
||
$lock?->update(['status' => 'success']);
|
||
|
||
return true;
|
||
} catch (Throwable $e) {
|
||
if ($reservation) {
|
||
$ipReservation->release($reservation, $ipPoolId);
|
||
}
|
||
|
||
$lock?->update(['status' => 'failed']);
|
||
Service::where('order_item_id', $this->item->id)
|
||
->whereIn('status', ['pending', 'provisioning'])
|
||
->update(['status' => 'failed']);
|
||
|
||
$adminDetail = $e instanceof VmProvisionException ? $e->adminDetail : $e->getMessage();
|
||
|
||
ServiceProvisionLog::forOrderItem($this->item->id, 'provision_failed', 'Khởi tạo máy chủ thất bại — ' . $adminDetail, ['stage' => 'create_vm']);
|
||
|
||
$vmLogger->exception('Create VM job failed', $e, [
|
||
'order_item_id' => $this->item->id,
|
||
'is_retry' => $this->isRetry,
|
||
'admin_detail' => $adminDetail,
|
||
]);
|
||
|
||
$failedService = Service::where('order_item_id', $this->item->id)->first();
|
||
if ($failedService) {
|
||
app(ActivityLogger::class)->log(
|
||
'provision_failed',
|
||
'service',
|
||
'Khởi tạo máy ảo thất bại — ' . $adminDetail,
|
||
$failedService,
|
||
);
|
||
}
|
||
|
||
if (!$this->isRetry) {
|
||
$refunded = $refund->refund($this->item, $e->getMessage());
|
||
$notification->failed($this->item, $e->getMessage() . ($refunded ? ' (đã tự động hoàn tiền cho khách)' : ''));
|
||
}
|
||
|
||
return false;
|
||
}
|
||
}
|
||
}
|