302 lines
12 KiB
PHP
302 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\VirtualMachine\Rebuild;
|
|
|
|
use App\DTOs\VirtualMachine\Rebuild\RebuildContext;
|
|
use App\Http\Facade\SignRSA;
|
|
use App\Http\Services\Telegram;
|
|
use App\Jobs\VirtualMachine\Rebuild\RebuildVirtualMachineJob;
|
|
use App\Models\Customer;
|
|
use App\Models\ModuleHost;
|
|
use App\Models\ModuleVmware;
|
|
use App\Models\Notification;
|
|
use App\Models\Os;
|
|
use App\Models\ReinstallLog;
|
|
use App\Models\Service;
|
|
use App\Models\ServiceAddon;
|
|
use App\Models\vCenter;
|
|
use Illuminate\Support\Facades\Log;
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* VmRebuildService
|
|
*
|
|
* Orchestrator/entry point for the rebuild subsystem. Builds the
|
|
* RebuildContext from the existing VM + chosen new OS and dispatches
|
|
* the first rebuild job.
|
|
*
|
|
* Completely isolated from VmProvisionService / VmInfrastructureResolver
|
|
* and from the CreateVirtualMachineJob chain.
|
|
*/
|
|
final class VmRebuildService
|
|
{
|
|
public const OS_LINUX = 'LINUX';
|
|
public const OS_WINDOWS = 'WINDOWS';
|
|
|
|
/**
|
|
* Default password baked into rebuild templates. Mirrors the create-flow
|
|
* template password constant but is duplicated intentionally to keep this
|
|
* subsystem standalone.
|
|
*/
|
|
public const TEMPLATE_PASSWORD = 'TGS@123';
|
|
|
|
/**
|
|
* Build a fresh RebuildContext from current DB state.
|
|
*
|
|
* @param int|null $newVmIdOverride Pass when the new VM has already been cloned
|
|
* and we need context for a downstream job.
|
|
*/
|
|
public function buildContext(
|
|
int $moduleVmwareId,
|
|
int $newOsId,
|
|
?string $newVmIdOverride = null,
|
|
): RebuildContext {
|
|
$vm = ModuleVmware::with([
|
|
'vCenter',
|
|
'ipAddress',
|
|
'order.orderItems.product',
|
|
])->find($moduleVmwareId);
|
|
|
|
if (!$vm) {
|
|
throw new RuntimeException("Rebuild: ModuleVmware #{$moduleVmwareId} not found");
|
|
}
|
|
|
|
$service = Service::where('vm_id', $vm->id)->first();
|
|
|
|
$os = Os::with(['host.datacenter.vCenter', 'osGroup.host.datacenter.vCenter'])
|
|
->find($newOsId);
|
|
|
|
if (!$os) {
|
|
throw new RuntimeException("Rebuild: target OS #{$newOsId} not found");
|
|
}
|
|
|
|
$orderItem = $vm->order?->orderItems?->first();
|
|
$product = $orderItem?->product;
|
|
|
|
// Resolve host / datacenter / vcenter for the chosen OS.
|
|
$host = $os->host ?? $os->osGroup?->host;
|
|
$datacenter = $host?->datacenter;
|
|
$vcenter = $datacenter?->vCenter;
|
|
|
|
if ((!$host || !$datacenter || !$vcenter) && $product && $product->module_host_id) {
|
|
$productHost = ModuleHost::with('datacenter.vCenter')->find($product->module_host_id);
|
|
$host = $host ?? $productHost;
|
|
$datacenter = $datacenter ?? $productHost?->datacenter;
|
|
$vcenter = $vcenter ?? $productHost?->datacenter?->vCenter;
|
|
}
|
|
|
|
// Final fallback: original VM's vCenter (IP must stay on the same fabric).
|
|
$vcenter = $vcenter ?? $vm->vCenter ?? vCenter::find($vm->vcenter_id);
|
|
|
|
if (!$vcenter || !$host || !$datacenter) {
|
|
throw new RuntimeException(
|
|
"Rebuild: cannot resolve host/datacenter/vcenter for OS #{$newOsId}"
|
|
);
|
|
}
|
|
|
|
$osGroupName = $os->osGroup?->name ?? '';
|
|
$isWindows = str_contains(strtolower($osGroupName), 'windows');
|
|
|
|
// Base specs come from the original product so the VM keeps its
|
|
// CPU/RAM/disk after rebuild.
|
|
$baseCpu = max(1, (int) ($product->cpu ?? 1));
|
|
$baseRam = max(1, (int) ($product->ram ?? 1));
|
|
$baseDisk = max(20, (int) ($product->disk ?? 20));
|
|
|
|
// Existing IP must be preserved verbatim — pull from module_vmware
|
|
// first, then fall back to the linked ModuleIpAddress record.
|
|
$ip = (string) ($vm->ip ?? $vm->ipAddress?->ip ?? '');
|
|
$gateway = (string) ($vm->ipAddress?->gateway ?? '');
|
|
$netmask = (string) ($vm->ipAddress?->netmask ?? '');
|
|
$dns = (string) ($vm->dns ?? $vm->ipAddress?->dns ?? '8.8.8.8');
|
|
$cidr = $netmask !== '' ? $this->netmaskToCidr($netmask) : 24;
|
|
|
|
if ($ip === '') {
|
|
throw new RuntimeException(
|
|
"Rebuild: ModuleVmware #{$moduleVmwareId} has no IP — cannot reuse"
|
|
);
|
|
}
|
|
|
|
// Addon totals for reinstall step (CPU/RAM/Disk delta on top of base).
|
|
$addonTotals = $this->collectAddonTotals($service);
|
|
|
|
return new RebuildContext(
|
|
moduleVmwareId: $vm->id,
|
|
serviceId: $service?->id,
|
|
customerId: (int) $vm->customer_id,
|
|
newOsId: (int) $os->id,
|
|
oldVmId: (string) $vm->vmware_id,
|
|
newVmId: $newVmIdOverride,
|
|
vmName: (string) ($vm->name ?? ''),
|
|
transientName: $this->makeTransientName((string) ($vm->name ?? ''), $vm->id),
|
|
ip: $ip,
|
|
gateway: $gateway,
|
|
netmask: $netmask,
|
|
cidr: $cidr,
|
|
dns: $dns,
|
|
moduleIpAddressId: $vm->module_ip_address ? (int) $vm->module_ip_address : null,
|
|
vcenterId: (int) $vcenter->id,
|
|
hostId: (int) $host->id,
|
|
isWindows: $isWindows,
|
|
osFamily: $isWindows ? self::OS_WINDOWS : self::OS_LINUX,
|
|
guestUser: $isWindows ? 'Administrator' : 'root',
|
|
template: $os->template_id,
|
|
datacenterRef: $datacenter->datacenter_id ?? null,
|
|
hostRef: $host->host_id ?? null,
|
|
baseCpu: $baseCpu,
|
|
baseRam: $baseRam,
|
|
baseDisk: $baseDisk,
|
|
addonTotals: $addonTotals,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Entry point used by the controller — kicks off the queue chain and
|
|
* returns the ReinstallLog row that tracks this rebuild from end to end.
|
|
*
|
|
* @param string|null $customPassword Optional customer-supplied credential.
|
|
* When set, the rebuild applies this
|
|
* exact password instead of generating
|
|
* a random one.
|
|
* @param string $initiator 'client' | 'admin' | 'system'
|
|
* @param string|null $note Free-form note to record on the log row.
|
|
*/
|
|
public function dispatchRebuild(
|
|
int $moduleVmwareId,
|
|
int $newOsId,
|
|
int $initiatorCustomerId,
|
|
?string $customPassword = null,
|
|
string $initiator = 'client',
|
|
?string $note = null,
|
|
): ReinstallLog {
|
|
// Build context once for validation, then let the job rebuild it on its own.
|
|
$ctx = $this->buildContext($moduleVmwareId, $newOsId);
|
|
|
|
if ((int) $ctx->customerId !== $initiatorCustomerId) {
|
|
throw new RuntimeException('Rebuild: customer mismatch — refusing to dispatch.');
|
|
}
|
|
|
|
$log = $this->openLog($ctx, $initiatorCustomerId, $customPassword, $initiator, $note);
|
|
|
|
Log::info('VmRebuildService: dispatching rebuild', [
|
|
'module_vmware_id' => $ctx->moduleVmwareId,
|
|
'service_id' => $ctx->serviceId,
|
|
'old_vm_id' => $ctx->oldVmId,
|
|
'new_os_id' => $ctx->newOsId,
|
|
'ip' => $ctx->ip,
|
|
'reinstall_log_id' => $log->id,
|
|
]);
|
|
|
|
RebuildVirtualMachineJob::dispatch($ctx->moduleVmwareId, $ctx->newOsId, $log->id)
|
|
->onQueue('default');
|
|
|
|
if ($initiator === 'client') {
|
|
$customerName = Customer::find($initiatorCustomerId)?->fullname ?? "Khách hàng #{$initiatorCustomerId}";
|
|
Notification::notifyAdmins([
|
|
'type' => 'vps_rebuild_requested',
|
|
'title' => 'Khách hàng vừa yêu cầu rebuild VPS',
|
|
'body' => "{$customerName} vừa rebuild VPS {$ctx->vmName} ({$ctx->ip})",
|
|
'link' => '/rebuild-logs',
|
|
'data' => [
|
|
'module_vmware_id' => $ctx->moduleVmwareId,
|
|
'reinstall_log_id' => $log->id,
|
|
],
|
|
]);
|
|
}
|
|
|
|
(new Telegram)->SendRebuildStepNotification(
|
|
$ctx->vmName,
|
|
$ctx->ip,
|
|
'Bắt đầu rebuild',
|
|
true,
|
|
"Khởi tạo bởi: {$initiator}",
|
|
);
|
|
|
|
return $log;
|
|
}
|
|
|
|
/**
|
|
* Create the ReinstallLog row that the rest of the pipeline updates.
|
|
* Any older 'pending' rows for the same VM (created by legacy paths) are
|
|
* marked as superseded so admin views show a single active record.
|
|
*/
|
|
private function openLog(
|
|
RebuildContext $ctx,
|
|
int $customerId,
|
|
?string $customPassword,
|
|
string $initiator,
|
|
?string $note,
|
|
): ReinstallLog {
|
|
ReinstallLog::query()
|
|
->where('product_id', $ctx->moduleVmwareId)
|
|
->where('product_type', ReinstallLog::PRODUCT_TYPE_VPS)
|
|
->whereIn('status', [ReinstallLog::STATUS_PENDING, ReinstallLog::STATUS_IN_PROGRESS])
|
|
->update([
|
|
'status' => ReinstallLog::STATUS_FAILED,
|
|
'note' => 'Superseded by newer rebuild request',
|
|
'updated_at' => now(),
|
|
]);
|
|
|
|
return ReinstallLog::create([
|
|
'product_id' => $ctx->moduleVmwareId,
|
|
'product_type' => ReinstallLog::PRODUCT_TYPE_VPS,
|
|
'custome_id' => $customerId,
|
|
'service_id' => $ctx->serviceId,
|
|
'os_id' => $ctx->newOsId,
|
|
'template_id' => is_string($ctx->template) ? $ctx->template : null,
|
|
'ip_address' => $ctx->ip,
|
|
'old_vmware_id' => $ctx->oldVmId,
|
|
'status' => ReinstallLog::STATUS_PENDING,
|
|
'initiator' => $initiator,
|
|
'note' => $note,
|
|
'password' => $customPassword ? SignRSA::EncryptFunc($customPassword) : null,
|
|
'plain_password' => null, // filled when password is actually applied
|
|
'password_supplied_by_customer' => $customPassword !== null,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @return array{cpu:int,ram:int,disk:int,bandwidth:int}
|
|
*/
|
|
private function collectAddonTotals(?Service $service): array
|
|
{
|
|
$totals = ['cpu' => 0, 'ram' => 0, 'disk' => 0, 'bandwidth' => 0];
|
|
if (!$service) {
|
|
return $totals;
|
|
}
|
|
|
|
$addons = ServiceAddon::where('service_id', $service->id)->get();
|
|
foreach ($addons as $addon) {
|
|
$type = strtolower((string) $addon->addon_type);
|
|
$val = (int) $addon->addon_value;
|
|
if (array_key_exists($type, $totals)) {
|
|
// Base specs were inserted as ServiceAddon rows by the create
|
|
// flow with price=0 and value=base. Treat the *first* row per
|
|
// type as base (overwrite), and any extras as additive.
|
|
$totals[$type] = $totals[$type] === 0 ? $val : $totals[$type] + $val;
|
|
}
|
|
}
|
|
|
|
return $totals;
|
|
}
|
|
|
|
private function makeTransientName(string $vmName, int $moduleVmwareId): string
|
|
{
|
|
$base = $vmName !== '' ? $vmName : ('VM-' . $moduleVmwareId);
|
|
$suffix = '-rb-' . substr((string) time(), -6);
|
|
// Keep total length sane for vCenter (<= 80)
|
|
return substr($base, 0, 80 - strlen($suffix)) . $suffix;
|
|
}
|
|
|
|
private function netmaskToCidr(string $netmask): int
|
|
{
|
|
$bits = 0;
|
|
foreach (explode('.', $netmask) as $octet) {
|
|
$bits += substr_count(decbin((int) $octet), '1');
|
|
}
|
|
return $bits > 0 ? $bits : 24;
|
|
}
|
|
}
|