Add VM rebuild pipeline with isolated job chain and admin log API

This commit is contained in:
baodev0
2026-05-29 18:19:38 +07:00
parent 7ae237ee9d
commit 9c68273b4a
27 changed files with 2391 additions and 199 deletions
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Services\VirtualMachine\Rebuild;
use App\DTOs\VirtualMachine\Rebuild\RebuildContext;
use App\Http\Services\vCenter as VCenterService;
use App\Models\ModuleVmware;
use App\Models\vCenter as VCenterModel;
use Illuminate\Support\Facades\Log;
use RuntimeException;
/**
* VmRebuildAddonService
*
* Reapplies CPU / RAM / Disk addons attached to the existing service onto
* the freshly cloned VM. Uses vCenter's existing Upgrade* primitives but is
* fully independent from VmAddonResolver / CreateVirtualMachineJob.
*/
final class VmRebuildAddonService
{
public function __construct(private readonly VCenterService $vCenter)
{
}
public function reinstall(RebuildContext $ctx): void
{
if ($ctx->newVmId === null) {
throw new RuntimeException('Rebuild addons: newVmId is null');
}
$vcenter = VCenterModel::find($ctx->vcenterId);
if (!$vcenter || !$this->vCenter->loginServer($vcenter)) {
throw new RuntimeException(
"Rebuild addons: cannot login to vCenter #{$ctx->vcenterId}"
);
}
// Resolve the totals we want the rebuilt VM to end up with.
// Base spec ensures CPU/RAM/Disk parity even if there are no addons.
$targetCpu = max($ctx->baseCpu, (int) ($ctx->addonTotals['cpu'] ?? 0));
$targetRam = max($ctx->baseRam, (int) ($ctx->addonTotals['ram'] ?? 0));
$targetDisk = max($ctx->baseDisk, (int) ($ctx->addonTotals['disk'] ?? 0));
Log::info('VmRebuildAddonService: applying specs', [
'module_vmware_id' => $ctx->moduleVmwareId,
'new_vm_id' => $ctx->newVmId,
'cpu' => $targetCpu,
'ram' => $targetRam,
'disk' => $targetDisk,
]);
if ($targetCpu > $ctx->baseCpu) {
$res = $this->vCenter->UpgradeCPUVirtualMachine($ctx->newVmId, $targetCpu);
$this->ensureOk($res, 'UpgradeCPU');
}
if ($targetRam > $ctx->baseRam) {
$res = $this->vCenter->UpgradeMemoryVirtualMachine($ctx->newVmId, $targetRam);
$this->ensureOk($res, 'UpgradeMemory');
}
if ($targetDisk > $ctx->baseDisk) {
// UpgradeDiskVirtualMachine expects a "product" with vmware_id —
// build a transient ModuleVmware-like object pointing at the new VM.
$shim = new ModuleVmware();
$shim->vmware_id = $ctx->newVmId;
$res = $this->vCenter->UpgradeDiskVirtualMachine($shim, $targetDisk);
$this->ensureOk($res, 'UpgradeDisk');
}
Log::info('VmRebuildAddonService: addons reinstalled', [
'module_vmware_id' => $ctx->moduleVmwareId,
'new_vm_id' => $ctx->newVmId,
]);
}
private function ensureOk(array|string $result, string $op): void
{
if (is_string($result)) {
throw new RuntimeException("Rebuild addons: {$op} returned '{$result}'");
}
if (($result['error'] ?? false)) {
throw new RuntimeException(
"Rebuild addons: {$op} failed — " . json_encode($result),
);
}
}
}
@@ -0,0 +1,204 @@
<?php
declare(strict_types=1);
namespace App\Services\VirtualMachine\Rebuild;
use App\DTOs\VirtualMachine\Rebuild\RebuildContext;
use App\Http\Services\vCenter as VCenterService;
use App\Models\ModuleDatastore;
use App\Models\vCenter as VCenterModel;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use RuntimeException;
/**
* VmRebuildCloneService
*
* Clones a fresh VM from the chosen OS template and powers it on.
* Uses the vCenter REST API directly — never dispatches CreateVirtualMachineJob.
*/
final class VmRebuildCloneService
{
private const CLONE_TIMEOUT = 300;
private const POWER_ON_TIMEOUT = 30;
private const TOOLS_POLL_DELAY = 10;
private const TOOLS_MAX_ATTEMPTS = 30;
/**
* Extra grace after VMware Tools reports an IP. VMware Tools advertises
* the guest IP as soon as the kernel finishes networking, but
* Invoke-VMScript / guest ops daemon needs another ~20–40 s before it
* actually accepts script invocations on a fresh clone. Without this
* delay assignIP/verifyIP scripts often return "FAIL" on the first run.
*/
private const TOOLS_READY_GRACE_SECONDS = 45;
public function __construct(private readonly VCenterService $vCenter)
{
}
/**
* Clone a new VM from the OS template specified in $ctx.
*
* @return string vSphere vm_id of the newly cloned VM
*/
public function clone(RebuildContext $ctx): string
{
$vcenter = VCenterModel::find($ctx->vcenterId);
if (!$vcenter || !$this->vCenter->loginServer($vcenter)) {
throw new RuntimeException("Rebuild clone: cannot login to vCenter #{$ctx->vcenterId}");
}
$datastore = ModuleDatastore::where('module_host_id', $ctx->hostId)->first();
$datastoreRef = $datastore?->datastore ?: 'datastore-3004';
$body = [
'spec' => [
'name' => $ctx->transientName,
'source' => $ctx->template,
'placement' => [
'datacenter' => $ctx->datacenterRef,
'folder' => 'group-v1008',
'host' => $ctx->hostRef,
'resource_pool' => 'resgroup-3002',
'datastore' => $datastoreRef,
],
'power_on' => false,
'fallback_to_datastore_default_policy' => true,
],
];
Log::info('VmRebuildCloneService: clone start', [
'module_vmware_id' => $ctx->moduleVmwareId,
'transient_name' => $ctx->transientName,
'template' => $ctx->template,
]);
$response = Http::withoutVerifying()
->timeout(self::CLONE_TIMEOUT)
->connectTimeout(30)
->withHeaders([
'vmware-api-session-id' => $this->vCenter->token,
'Content-Type' => 'application/json',
])
->post(
"https://{$this->vCenter->apiUri}/rest/vcenter/vm?action=clone",
$body,
);
$data = $response->json();
if (!$response->successful() || isset($data['type'])) {
throw new RuntimeException(
'Rebuild clone failed: ' . json_encode($data ?: $response->body())
);
}
$newVmId = (string) ($data['value'] ?? '');
if ($newVmId === '' || in_array($newVmId, ['unknown', 'UNKNOWN'], true)) {
throw new RuntimeException('Rebuild clone: empty vm_id returned by vCenter');
}
Log::info('VmRebuildCloneService: clone success', [
'module_vmware_id' => $ctx->moduleVmwareId,
'new_vm_id' => $newVmId,
]);
return $newVmId;
}
/**
* Power on the freshly cloned VM and wait for VMware Tools to report
* a guest IP — without this, IP assignment scripts cannot run.
*/
public function powerOnAndWaitTools(RebuildContext $ctx): void
{
if ($ctx->newVmId === null) {
throw new RuntimeException('powerOnAndWaitTools: newVmId is null');
}
$vcenter = VCenterModel::find($ctx->vcenterId);
if (!$vcenter || !$this->vCenter->loginServer($vcenter)) {
throw new RuntimeException("Rebuild powerOn: cannot login to vCenter #{$ctx->vcenterId}");
}
$powerResp = Http::withoutVerifying()
->timeout(self::POWER_ON_TIMEOUT)
->withHeaders(['vmware-api-session-id' => $this->vCenter->token])
->post("https://{$this->vCenter->apiUri}/rest/vcenter/vm/{$ctx->newVmId}/power/start");
if (!$powerResp->successful() && $powerResp->status() !== 400) {
// 400 = already powered on; treat as OK.
throw new RuntimeException(
"Rebuild powerOn failed: status={$powerResp->status()} body={$powerResp->body()}"
);
}
Log::info('VmRebuildCloneService: powered on, waiting for tools', [
'new_vm_id' => $ctx->newVmId,
]);
for ($i = 1; $i <= self::TOOLS_MAX_ATTEMPTS; $i++) {
$info = Http::withoutVerifying()
->timeout(10)
->withHeaders(['vmware-api-session-id' => $this->vCenter->token])
->get("https://{$this->vCenter->apiUri}/rest/vcenter/vm/{$ctx->newVmId}/guest/identity")
->json();
$guestIp = trim((string) ($info['value']['ip_address'] ?? ''));
if ($guestIp !== '' && $guestIp !== '0.0.0.0') {
Log::info('VmRebuildCloneService: tools ready — applying grace', [
'new_vm_id' => $ctx->newVmId,
'guest_ip' => $guestIp,
'attempt' => $i,
'grace_seconds' => self::TOOLS_READY_GRACE_SECONDS,
]);
// Give the guest ops daemon time to accept Invoke-VMScript.
sleep(self::TOOLS_READY_GRACE_SECONDS);
return;
}
sleep(self::TOOLS_POLL_DELAY);
}
throw new RuntimeException(
"Rebuild powerOn: VMware Tools never became ready on new VM {$ctx->newVmId}"
);
}
/**
* Best-effort: power off the OLD VM so it stops responding on the shared IP
* before we reassign it inside the guest of the new VM. We do NOT delete it
* here — deletion is the very last step, after full validation.
*/
public function powerOffOldVm(RebuildContext $ctx): void
{
if ($ctx->oldVmId === '') {
return;
}
$vcenter = VCenterModel::find($ctx->vcenterId);
if (!$vcenter || !$this->vCenter->loginServer($vcenter)) {
Log::warning('VmRebuildCloneService: powerOff old VM skipped — vCenter login failed', [
'old_vm_id' => $ctx->oldVmId,
]);
return;
}
try {
Http::withoutVerifying()
->timeout(30)
->withHeaders(['vmware-api-session-id' => $this->vCenter->token])
->post("https://{$this->vCenter->apiUri}/rest/vcenter/vm/{$ctx->oldVmId}/power/stop");
Log::info('VmRebuildCloneService: old VM powered off', [
'old_vm_id' => $ctx->oldVmId,
]);
} catch (\Throwable $e) {
// Non-fatal — guest may already be off, or vCenter may flake.
Log::warning('VmRebuildCloneService: powerOff old VM failed', [
'old_vm_id' => $ctx->oldVmId,
'error' => $e->getMessage(),
]);
}
}
}
@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
namespace App\Services\VirtualMachine\Rebuild;
use App\DTOs\VirtualMachine\Rebuild\RebuildContext;
use App\Http\Facade\SignRSA;
use App\Models\IpPool;
use App\Models\ModuleVmware;
use App\Models\Os;
use App\Models\vCenter as VCenterModel;
use App\Services\VsphereHostSshService;
use Illuminate\Support\Facades\Log;
use RuntimeException;
/**
* VmRebuildIpService
*
* Mirrors the production AssignIpToGuestJob flow 1:1 for the rebuild path —
* uses the SAME VsphereHostSshService helpers, which means the SAME PS1
* scripts on the vSphere host:
*
* C:\CLISave\AssignIP\assignIP{Linux|Windows}.ps1
* C:\CLISave\AssignIP\verifyIP{Linux|Windows}.ps1
*
* Differences vs create flow (hard rules, per the rebuild spec):
* - NEVER reserve a new IP
* - NEVER mutate ip_pools / module_ip_address ownership
* - The IP, gateway, cidr, dns are read from the EXISTING service:
* gateway/cidr ← IpPool row whose ip_address == current VM IP
* dns ← product.ipAddress.dns (same as AssignIpToGuestJob)
* hostId ← osGroup.host.id of the NEW OS (same as AssignIpToGuestJob)
*
* In-place retry loop because we MUST reuse the same IP — we can't fall
* through to a different IP like AssignIpToGuestJob does.
*/
final class VmRebuildIpService
{
private const MAX_ATTEMPTS = 4;
private const ATTEMPT_DELAY = 25;
public function __construct(private readonly VsphereHostSshService $ssh)
{
}
public function assignAndVerify(RebuildContext $ctx): void
{
if ($ctx->newVmId === null) {
throw new RuntimeException('Rebuild IP: newVmId is null');
}
if ($ctx->ip === '') {
throw new RuntimeException('Rebuild IP: missing existing IP in context');
}
// Load ModuleVmware + relations EXACTLY like AssignIpToGuestJob does
// so we read parameters from the same DB locations.
$moduleVmware = ModuleVmware::with([
'order.orderItems.product.ipAddress',
'vcenter',
])->find($ctx->moduleVmwareId);
if (!$moduleVmware) {
throw new RuntimeException(
"Rebuild IP: ModuleVmware #{$ctx->moduleVmwareId} not found"
);
}
// DNS comes from product.ipAddress.dns — same source AssignIpToGuestJob uses.
$orderItem = $moduleVmware->order?->orderItems?->first();
$dns = $orderItem?->product?->ipAddress?->dns
?: ($ctx->dns ?: '8.8.8.8');
// Host id is osGroup.host.id of the NEW OS — same source AssignIpToGuestJob uses
// (it loads Os->osGroup->host for whatever OS is on the order item).
$osModel = Os::with('osGroup.host')->find($ctx->newOsId);
$hostId = $osModel?->osGroup?->host?->id ?? $ctx->hostId ?? 1;
// Gateway + CIDR come from the ip_pools row already assigned to this
// service — same DB source the create flow reads at reservation time.
// For rebuild we never reserve; we just look it up by ip_address.
$ipPool = IpPool::where('ip_address', $ctx->ip)->first();
$gateway = (string) ($ipPool?->gateway ?? $ctx->gateway);
$cidr = (string) ((int) ($ipPool?->cidr ?? $ctx->cidr ?: 24));
if ($gateway === '') {
throw new RuntimeException(
"Rebuild IP: cannot resolve gateway for IP {$ctx->ip} (no ip_pools row and context empty)"
);
}
// vCenter credentials — identical decode path as AssignIpToGuestJob.
$vcenter = $moduleVmware->vcenter
?? VCenterModel::find($ctx->vcenterId)
?? VCenterModel::first();
if (!$vcenter) {
throw new RuntimeException('Rebuild IP: no vCenter record available');
}
$vcenterHost = (string) $vcenter->host;
$vcenterUser = (string) $vcenter->user;
$vcenterPass = SignRSA::DecryptFunc($vcenter->password);
// Guest OS default admin user per OS family — same convention.
$guestUser = strtoupper($ctx->osFamily) === 'WINDOWS' ? 'Administrator' : 'root';
Log::info('VmRebuildIpService: assigning existing IP to new VM', [
'module_vmware_id' => $ctx->moduleVmwareId,
'new_vm_id' => $ctx->newVmId,
'ip' => $ctx->ip,
'gateway' => $gateway,
'cidr' => $cidr,
'dns' => $dns,
'host_id' => $hostId,
'os_family' => $ctx->osFamily,
'guest_user' => $guestUser,
]);
$lastAssignOutput = '';
$lastVerifyOutput = '';
for ($attempt = 1; $attempt <= self::MAX_ATTEMPTS; $attempt++) {
// [4] Call assignIP{Linux|Windows}.ps1 — IDENTICAL invocation
// to AssignIpToGuestJob, same VsphereHostSshService::assignIp().
$lastAssignOutput = $this->ssh->assignIp(
vmId: $ctx->newVmId,
ip: $ctx->ip,
gateway: $gateway,
cidr: $cidr,
vcenterHost: $vcenterHost,
vcenterUser: $vcenterUser,
vcenterPassword: $vcenterPass,
guestPassword: VmRebuildService::TEMPLATE_PASSWORD,
osFamily: $ctx->osFamily,
dns: $dns,
guestUser: $guestUser,
);
if (!$this->isSuccess($lastAssignOutput)) {
Log::warning('VmRebuildIpService: assignIp FAIL', [
'ip' => $ctx->ip,
'attempt' => $attempt,
'new_vm_id' => $ctx->newVmId,
'output' => trim($lastAssignOutput),
]);
if ($attempt < self::MAX_ATTEMPTS) {
sleep(self::ATTEMPT_DELAY);
continue;
}
throw new RuntimeException(
'Rebuild IP: assign script failed after ' . self::MAX_ATTEMPTS
. ' attempts — last output: ' . trim($lastAssignOutput),
);
}
if (strtoupper($ctx->osFamily) === VmRebuildService::OS_WINDOWS) {
Log::info('VmRebuildIpService: Windows IP assigned', [
'module_vmware_id' => $ctx->moduleVmwareId,
'new_vm_id' => $ctx->newVmId,
'ip' => $ctx->ip,
'attempt' => $attempt,
]);
return;
}
// [6] Call verifyIP{Linux|Windows}.ps1 — same as AssignIpToGuestJob.
$lastVerifyOutput = $this->ssh->verifyIpInGuest(
vmId: $ctx->newVmId,
ip: $ctx->ip,
vcenterHost: $vcenterHost,
vcenterUser: $vcenterUser,
vcenterPassword: $vcenterPass,
guestPassword: VmRebuildService::TEMPLATE_PASSWORD,
osFamily: $ctx->osFamily,
guestUser: $guestUser,
);
if ($this->isSuccess($lastVerifyOutput)) {
Log::info('VmRebuildIpService: IP assigned and verified', [
'module_vmware_id' => $ctx->moduleVmwareId,
'new_vm_id' => $ctx->newVmId,
'ip' => $ctx->ip,
'attempt' => $attempt,
]);
return;
}
Log::warning('VmRebuildIpService: verifyIpInGuest FAIL', [
'ip' => $ctx->ip,
'attempt' => $attempt,
'new_vm_id' => $ctx->newVmId,
'output' => trim($lastVerifyOutput),
]);
if ($attempt < self::MAX_ATTEMPTS) {
sleep(self::ATTEMPT_DELAY);
}
}
throw new RuntimeException(
'Rebuild IP: verify script failed after ' . self::MAX_ATTEMPTS
. ' attempts — last output: ' . trim($lastVerifyOutput),
);
}
/** PS1 scripts return JSON {"status":"ok"} or plain SUCCESS — same parser as AssignIpToGuestJob. */
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';
}
}
@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace App\Services\VirtualMachine\Rebuild;
use App\DTOs\VirtualMachine\Rebuild\RebuildContext;
use App\Http\Facade\SignRSA;
use App\Models\ModuleVmware;
use App\Models\ReinstallLog;
use App\Models\vCenter as VCenterModel;
use App\Services\VsphereHostSshService;
use Illuminate\Support\Facades\Log;
use RuntimeException;
/**
* VmRebuildPasswordService
*
* Generates a new random password for the rebuilt VM and pushes it through
* VsphereHostSshService::changePassword(). Uses the same low-level SSH helper
* as the create flow but never dispatches ChangePasswordViaVsphereJob.
*
* The new credential is persisted on the EXISTING ModuleVmware row — the
* service identity is preserved across rebuild.
*/
final class VmRebuildPasswordService
{
public function __construct(private readonly VsphereHostSshService $ssh)
{
}
/**
* @param int|null $reinstallLogId If supplied, the customer-supplied
* password (if any) is pulled from this
* log row and the same row is updated
* with the credential that was applied.
* @return string the plaintext password actually applied
*/
public function rotate(RebuildContext $ctx, ?int $reinstallLogId = null): string
{
if ($ctx->newVmId === null) {
throw new RuntimeException('Rebuild password: newVmId is null');
}
$vcenter = VCenterModel::find($ctx->vcenterId);
if (!$vcenter) {
throw new RuntimeException("Rebuild password: vCenter #{$ctx->vcenterId} not found");
}
// Prefer a customer-supplied credential when one was provided.
$newPassword = null;
$log = $reinstallLogId ? ReinstallLog::find($reinstallLogId) : null;
if ($log && $log->password_supplied_by_customer && $log->password) {
try {
$newPassword = SignRSA::DecryptFunc($log->password);
} catch (\Throwable $e) {
$newPassword = null;
}
}
if (!is_string($newPassword) || $newPassword === '') {
$newPassword = $this->generatePassword(16);
}
$output = $this->ssh->changePassword(
$ctx->newVmId,
$newPassword,
$ctx->osFamily,
(string) $vcenter->host,
(string) $vcenter->user,
SignRSA::DecryptFunc($vcenter->password),
);
if (!str_contains(strtoupper(trim($output)), 'SUCCESS')) {
throw new RuntimeException(
"Rebuild password: changePassword script failed — " . trim($output),
);
}
$encrypted = SignRSA::EncryptFunc($newPassword);
ModuleVmware::where('id', $ctx->moduleVmwareId)->update([
'password' => $encrypted,
'plain_password' => $newPassword,
]);
if ($log) {
$log->update([
'password' => $encrypted,
'plain_password' => $newPassword,
]);
}
Log::info('VmRebuildPasswordService: password rotated', [
'module_vmware_id' => $ctx->moduleVmwareId,
'new_vm_id' => $ctx->newVmId,
'reinstall_log_id' => $log?->id,
]);
return $newPassword;
}
private function generatePassword(int $length): string
{
$sets = [
'ABCDEFGHJKLMNPQRSTUVWXYZ',
'abcdefghjkmnpqrstuvwxyz',
'23456789',
'!@#$*',
];
$password = '';
$allChars = implode('', $sets);
foreach ($sets as $set) {
$password .= $set[random_int(0, strlen($set) - 1)];
}
for ($i = count($sets); $i < $length; $i++) {
$password .= $allChars[random_int(0, strlen($allChars) - 1)];
}
$chars = str_split($password);
for ($i = count($chars) - 1; $i > 0; $i--) {
$j = random_int(0, $i);
[$chars[$i], $chars[$j]] = [$chars[$j], $chars[$i]];
}
return implode('', $chars);
}
}
@@ -0,0 +1,276 @@
<?php
declare(strict_types=1);
namespace App\Services\VirtualMachine\Rebuild;
use App\DTOs\VirtualMachine\Rebuild\RebuildContext;
use App\Http\Facade\SignRSA;
use App\Jobs\VirtualMachine\Rebuild\RebuildVirtualMachineJob;
use App\Models\ModuleHost;
use App\Models\ModuleVmware;
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');
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;
}
}
@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace App\Services\VirtualMachine\Rebuild;
use App\DTOs\VirtualMachine\Rebuild\RebuildContext;
use App\Http\Services\vCenter as VCenterService;
use App\Models\ModuleVmware;
use App\Models\Service;
use App\Models\vCenter as VCenterModel;
use App\Models\VirtualMachines;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* VmRebuildSwapService
*
* After the new VM has passed every validation step, this service:
* 1. Performs a final health check against the new VM in vCenter.
* 2. Atomically swaps DB references from the old vmware_id to the new one.
* 3. Deletes the old VM from vCenter — and ONLY then.
*
* If health check fails, nothing is swapped and the old VM is preserved
* untouched so a manual recovery is possible.
*/
final class VmRebuildSwapService
{
public function __construct(private readonly VCenterService $vCenter)
{
}
/**
* Health-check the new VM. Throws on failure (no rollback needed — old VM
* still owns the DB).
*/
public function healthCheck(RebuildContext $ctx): void
{
if ($ctx->newVmId === null) {
throw new \RuntimeException('Rebuild swap: newVmId is null');
}
$vcenter = VCenterModel::find($ctx->vcenterId);
if (!$vcenter || !$this->vCenter->loginServer($vcenter)) {
throw new \RuntimeException(
"Rebuild swap: cannot login to vCenter #{$ctx->vcenterId}"
);
}
$detail = $this->vCenter->GetDetailVirtualMachine($ctx->newVmId);
if (($detail['error'] ?? true) === true) {
throw new \RuntimeException(
'Rebuild swap: GetDetailVirtualMachine failed — ' . json_encode($detail)
);
}
$power = $detail['data']['power_state'] ?? null;
if ($power !== 'POWERED_ON') {
throw new \RuntimeException(
"Rebuild swap: new VM not POWERED_ON (got '{$power}')"
);
}
$ipInfo = $this->vCenter->GetIPAddressVirtualMachine($ctx->newVmId);
$guestIp = (string) ($ipInfo['data']['ip_address'] ?? '');
if ($guestIp === '') {
throw new \RuntimeException('Rebuild swap: new VM reports no guest IP');
}
// Soft check — guest may take a moment to settle on the new IP, but
// it should at least be reporting *something* on the right subnet.
Log::info('VmRebuildSwapService: health check passed', [
'module_vmware_id' => $ctx->moduleVmwareId,
'new_vm_id' => $ctx->newVmId,
'guest_ip' => $guestIp,
'expected_ip' => $ctx->ip,
'power_state' => $power,
]);
}
/**
* Atomically swap DB references:
* - module_vmware.vmware_id → new vSphere id
* - module_vmware.os_id → new OS id
* - virtual_machines.vm_id → new vSphere id (if a row exists)
*
* Preserved: services row (incl. vm_id FK), order_item_id, customer_id,
* ModuleIpAddress, ServiceAddons.
*/
public function swapDatabaseReferences(RebuildContext $ctx): void
{
if ($ctx->newVmId === null) {
throw new \RuntimeException('Rebuild swap: newVmId is null');
}
DB::transaction(function () use ($ctx) {
ModuleVmware::where('id', $ctx->moduleVmwareId)->update([
'vmware_id' => $ctx->newVmId,
'os_id' => $ctx->newOsId,
'status' => 1,
]);
VirtualMachines::where('vm_id', $ctx->oldVmId)->update([
'vm_id' => $ctx->newVmId,
'ip' => $ctx->ip,
]);
if ($ctx->serviceId) {
// Keep the existing Service intact — just make sure it stays
// marked active and its server_name reflects the VM.
Service::where('id', $ctx->serviceId)->update([
'status' => 'active',
]);
}
});
Log::info('VmRebuildSwapService: DB references swapped', [
'module_vmware_id' => $ctx->moduleVmwareId,
'old_vm_id' => $ctx->oldVmId,
'new_vm_id' => $ctx->newVmId,
]);
}
/**
* Delete the old VM from vCenter. Best-effort: the DB is already swapped,
* so a leftover ghost VM is recoverable manually and must not throw past
* the queue.
*/
public function deleteOldVm(RebuildContext $ctx): void
{
if ($ctx->oldVmId === '') {
return;
}
try {
$vcenter = VCenterModel::find($ctx->vcenterId);
if (!$vcenter || !$this->vCenter->loginServer($vcenter)) {
Log::error('VmRebuildSwapService: cannot login to delete old VM', [
'old_vm_id' => $ctx->oldVmId,
]);
return;
}
// Force power-off before delete to avoid "VM is running" rejection.
$this->vCenter->PowerOffVirtualMachine($ctx->oldVmId);
$res = $this->vCenter->DeleteVirtualMachine($ctx->oldVmId);
Log::info('VmRebuildSwapService: old VM deleted', [
'old_vm_id' => $ctx->oldVmId,
'result' => $res,
]);
} catch (Throwable $e) {
Log::error('VmRebuildSwapService: deleteOldVm threw', [
'old_vm_id' => $ctx->oldVmId,
'error' => $e->getMessage(),
]);
}
}
}