205 lines
7.5 KiB
PHP
205 lines
7.5 KiB
PHP
<?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(),
|
||
]);
|
||
}
|
||
}
|
||
}
|