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; } }