Add VM rebuild pipeline with isolated job chain and admin log API
This commit is contained in:
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user