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,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(),
]);
}
}
}