From 5d3ed57b3217bf11d0d5422120cd0d698be7ae94 Mon Sep 17 00:00:00 2001 From: baodev0 Date: Tue, 21 Jul 2026 21:37:28 +0700 Subject: [PATCH] feat: Enhance vCenter integration with resource pool and folder support - Added methods to ResourceService for fetching resource pools and updated existing methods to include datacenter filtering. - Modified CreateVirtualMachine to accept folder and resource pool parameters from product data. - Updated various jobs to synchronize resource pools and include them in the provisioning process. - Introduced new ModuleResourcePool model and migration for resource pool management. - Enhanced existing models (ModuleDatastore, ModuleNetwork, Os) to include relationships with vCenter and resource pools. - Added netmask field to IpPool and updated related logic for IP reservation. - Improved error handling and logging in VM creation and IP reservation processes. - Updated API routes to include sync functionality for vCenter. --- .../Api/Admin/IpPool/IpPoolController.php | 109 +++++++++++++++++- .../Api/Admin/OsManager/OsController.php | 100 ++++++++++------ .../Api/Admin/OsManager/OsGroupController.php | 30 +++-- .../Api/Admin/VCenter/VCenterController.php | 28 ++++- .../Api/Client/ClientController.php | 3 +- app/Http/Services/vCenter.php | 16 ++- app/Http/Services/vCenter/ResourceService.php | 57 ++++++--- .../vCenter/VirtualMachineService.php | 10 +- .../Create/CreateVirtualMachineJob.php | 30 ++--- .../Provision/AssignIpToGuestJob.php | 40 ++++--- .../VirtualMachine/Sync/SyncAllPackage.php | 1 + .../VirtualMachine/Sync/SyncDatacenter.php | 2 +- .../VirtualMachine/Sync/SyncDatastore.php | 8 +- app/Jobs/VirtualMachine/Sync/SyncHost.php | 1 + app/Jobs/VirtualMachine/Sync/SyncNetwork.php | 9 +- .../VirtualMachine/Sync/SyncResourcePool.php | 92 +++++++++++++++ app/Models/IpPool.php | 1 + app/Models/ModuleDatastore.php | 6 + app/Models/ModuleHost.php | 4 + app/Models/ModuleNetwork.php | 6 + app/Models/ModuleResourcePool.php | 31 +++++ app/Models/Os.php | 18 +++ app/Services/Api/Client/ClientAuthService.php | 2 +- .../Dto/VmInfrastructureContext.php | 2 + .../VmActiveProviderService.php | 24 ++-- .../VmInfrastructureResolver.php | 44 ++++--- .../VirtualMachine/VmIpReservationService.php | 32 ++--- ...00_add_vcenter_id_to_datastore_network.php | 39 +++++++ ...1_140000_add_netmask_to_ip_pools_table.php | 23 ++++ ...0000_add_module_network_id_to_os_table.php | 27 +++++ ...4634_create_module_resource_pool_table.php | 32 +++++ ...esource_pool_and_folder_id_to_os_table.php | 32 +++++ routes/api.php | 1 + 33 files changed, 708 insertions(+), 152 deletions(-) create mode 100644 app/Jobs/VirtualMachine/Sync/SyncResourcePool.php create mode 100644 app/Models/ModuleResourcePool.php create mode 100644 database/migrations/2026_07_21_130000_add_vcenter_id_to_datastore_network.php create mode 100644 database/migrations/2026_07_21_140000_add_netmask_to_ip_pools_table.php create mode 100644 database/migrations/2026_07_21_150000_add_module_network_id_to_os_table.php create mode 100644 database/migrations/2026_07_21_204634_create_module_resource_pool_table.php create mode 100644 database/migrations/2026_07_21_204645_add_resource_pool_and_folder_id_to_os_table.php diff --git a/app/Http/Controllers/Api/Admin/IpPool/IpPoolController.php b/app/Http/Controllers/Api/Admin/IpPool/IpPoolController.php index 0a8acd1..bdd6733 100644 --- a/app/Http/Controllers/Api/Admin/IpPool/IpPoolController.php +++ b/app/Http/Controllers/Api/Admin/IpPool/IpPoolController.php @@ -117,6 +117,8 @@ class IpPoolController extends Controller 'ip_address' => 'required|string', 'module_network_id' => 'required|integer|exists:module_network,id', 'gateway' => 'nullable|string|max:45', + 'cidr' => 'nullable|integer|min:0|max:32', + 'netmask' => 'nullable|string|max:45', ]); $networkId = (int) $request->module_network_id; @@ -155,15 +157,35 @@ class IpPoolController extends Controller ], 422); } + $hostId = ModuleNetwork::find($networkId)?->module_host_id; + $netmask = $request->netmask ?: '255.255.255.0'; + DB::beginTransaction(); try { $created = []; foreach ($ips as $ip) { - $created[] = IpPool::create([ + $pool = IpPool::create([ 'ip_address' => $ip, 'module_network_id' => $networkId, 'gateway' => $request->gateway, + 'cidr' => $request->cidr, + 'netmask' => $netmask, ]); + + // Mirror into module_ip_address so both tables stay in sync. + // Reservation logic (VmIpReservationService/AssignIpToGuestJob) treats + // ip_pools.status as the source of truth and just updates this row. + ModuleIpAddress::updateOrCreate( + ['ip' => $ip, 'module_network_id' => $networkId], + [ + 'module_host_id' => $hostId, + 'gateway' => $request->gateway, + 'netmask' => $netmask, + 'status' => 0, + ] + ); + + $created[] = $pool; } DB::commit(); @@ -186,6 +208,8 @@ class IpPoolController extends Controller 'ip_address' => 'required|string|max:45', 'module_network_id' => 'required|integer|exists:module_network,id', 'gateway' => 'nullable|string|max:45', + 'cidr' => 'nullable|integer|min:0|max:32', + 'netmask' => 'nullable|string|max:45', ]); if (!filter_var($request->ip_address, FILTER_VALIDATE_IP)) { @@ -205,9 +229,30 @@ class IpPoolController extends Controller ], 422); } + $oldIp = $pool->ip_address; + $oldNetworkId = $pool->module_network_id; + $netmask = $request->netmask ?: '255.255.255.0'; + DB::beginTransaction(); try { - $pool->update($request->only(['ip_address', 'module_network_id', 'gateway'])); + $pool->update([ + ...$request->only(['ip_address', 'module_network_id', 'gateway', 'cidr']), + 'netmask' => $netmask, + ]); + + $hostId = ModuleNetwork::find($pool->module_network_id)?->module_host_id; + + // Keep the module_ip_address mirror in sync with the new ip/network/host/gateway/netmask. + ModuleIpAddress::where('ip', $oldIp) + ->where('module_network_id', $oldNetworkId) + ->update([ + 'ip' => $pool->ip_address, + 'module_network_id' => $pool->module_network_id, + 'module_host_id' => $hostId, + 'gateway' => $pool->gateway, + 'netmask' => $pool->netmask, + ]); + DB::commit(); return response()->json(['error' => false, 'data' => $pool->fresh('network')]); @@ -238,6 +283,10 @@ class IpPoolController extends Controller ], 422); } + ModuleIpAddress::where('ip', $pool->ip_address) + ->where('module_network_id', $pool->module_network_id) + ->delete(); + $pool->delete(); return response()->json(['error' => false, 'message' => 'Đã xoá IP Pool thành công']); @@ -270,6 +319,12 @@ class IpPoolController extends Controller ], 422); } + foreach ($pools as $p) { + ModuleIpAddress::where('ip', $p->ip_address) + ->where('module_network_id', $p->module_network_id) + ->delete(); + } + $deleted = IpPool::whereIn('id', $ids)->delete(); return response()->json([ @@ -279,6 +334,56 @@ class IpPoolController extends Controller ]); } + /** + * Backfill/repair the module_ip_address mirror for every ip_pools row. + * Existing mirror rows only get gateway/netmask refreshed — status/vm_name + * (assigned VM state) is left untouched. Missing rows are created with + * status derived from the current ip_pools.status. + */ + public function sync(): JsonResponse + { + $created = 0; + $updated = 0; + $hostByNet = ModuleNetwork::pluck('module_host_id', 'id'); + + IpPool::chunkById(500, function ($pools) use (&$created, &$updated, $hostByNet) { + foreach ($pools as $pool) { + $hostId = $hostByNet[$pool->module_network_id] ?? null; + $netmask = $pool->netmask ?: '255.255.255.0'; + + $mia = ModuleIpAddress::where('ip', $pool->ip_address) + ->where('module_network_id', $pool->module_network_id) + ->first(); + + if ($mia) { + $mia->update([ + 'module_host_id' => $hostId, + 'gateway' => $pool->gateway, + 'netmask' => $netmask, + ]); + $updated++; + } else { + ModuleIpAddress::create([ + 'ip' => $pool->ip_address, + 'module_network_id' => $pool->module_network_id, + 'module_host_id' => $hostId, + 'gateway' => $pool->gateway, + 'netmask' => $netmask, + 'status' => $pool->status === 'assigned' ? 1 : 0, + ]); + $created++; + } + } + }); + + return response()->json([ + 'error' => false, + 'message' => "Đã đồng bộ: tạo mới {$created}, cập nhật {$updated}", + 'created' => $created, + 'updated' => $updated, + ]); + } + public function networks(): JsonResponse { $networks = ModuleNetwork::select(['id', 'name', 'type', 'network', 'module_host_id']) diff --git a/app/Http/Controllers/Api/Admin/OsManager/OsController.php b/app/Http/Controllers/Api/Admin/OsManager/OsController.php index 5610ef0..9db7274 100644 --- a/app/Http/Controllers/Api/Admin/OsManager/OsController.php +++ b/app/Http/Controllers/Api/Admin/OsManager/OsController.php @@ -7,27 +7,37 @@ use App\Models\Os; use App\Models\OsGroup; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Validation\Rule; class OsController extends Controller { public function store(Request $request): JsonResponse { $validated = $request->validate([ - 'version' => 'required|string|max:100', - 'os_group_id' => 'required|integer|exists:os_group,id', - 'template_id' => 'nullable|string|max:255', - 'module_host_id' => 'nullable|integer|exists:module_host,id', - 'datastore_id' => 'nullable|integer|exists:module_datastore,id', - 'is_active' => 'boolean', + 'version' => 'required|string|max:100', + 'os_group_id' => 'required|integer|exists:os_group,id', + 'template_id' => 'nullable|string|max:255', + 'module_host_id' => 'nullable|integer|exists:module_host,id', + 'datastore_id' => 'nullable|integer|exists:module_datastore,id', + 'module_network_id' => 'nullable|integer|exists:module_network,id', + 'resource_pool_id' => 'nullable|integer|exists:module_resource_pool,id', + 'folder_id' => [ + 'nullable', 'integer', + Rule::exists('module_folder', 'id')->where('type', 'VIRTUAL_MACHINE'), + ], + 'is_active' => 'boolean', ]); $os = Os::create([ - 'version' => $validated['version'], - 'os_group_id' => $validated['os_group_id'], - 'template_id' => $validated['template_id'] ?? null, - 'module_host_id' => $validated['module_host_id'] ?? null, - 'datastore_id' => $validated['datastore_id'] ?? null, - 'status' => ($validated['is_active'] ?? true) ? 1 : 0, + 'version' => $validated['version'], + 'os_group_id' => $validated['os_group_id'], + 'template_id' => $validated['template_id'] ?? null, + 'module_host_id' => $validated['module_host_id'] ?? null, + 'datastore_id' => $validated['datastore_id'] ?? null, + 'module_network_id' => $validated['module_network_id'] ?? null, + 'resource_pool_id' => $validated['resource_pool_id'] ?? null, + 'folder_id' => $validated['folder_id'] ?? null, + 'status' => ($validated['is_active'] ?? true) ? 1 : 0, ]); return response()->json([ @@ -46,21 +56,30 @@ class OsController extends Controller } $validated = $request->validate([ - 'version' => 'sometimes|required|string|max:100', - 'os_group_id' => 'sometimes|integer|exists:os_group,id', - 'template_id' => 'nullable|string|max:255', - 'module_host_id' => 'nullable|integer|exists:module_host,id', - 'datastore_id' => 'nullable|integer|exists:module_datastore,id', - 'is_active' => 'boolean', + 'version' => 'sometimes|required|string|max:100', + 'os_group_id' => 'sometimes|integer|exists:os_group,id', + 'template_id' => 'nullable|string|max:255', + 'module_host_id' => 'nullable|integer|exists:module_host,id', + 'datastore_id' => 'nullable|integer|exists:module_datastore,id', + 'module_network_id' => 'nullable|integer|exists:module_network,id', + 'resource_pool_id' => 'nullable|integer|exists:module_resource_pool,id', + 'folder_id' => [ + 'nullable', 'integer', + Rule::exists('module_folder', 'id')->where('type', 'VIRTUAL_MACHINE'), + ], + 'is_active' => 'boolean', ]); $os->update([ - 'version' => $validated['version'] ?? $os->version, - 'os_group_id' => $validated['os_group_id'] ?? $os->os_group_id, - 'template_id' => array_key_exists('template_id', $validated) ? $validated['template_id'] : $os->template_id, - 'module_host_id' => array_key_exists('module_host_id', $validated) ? $validated['module_host_id'] : $os->module_host_id, - 'datastore_id' => array_key_exists('datastore_id', $validated) ? $validated['datastore_id'] : $os->datastore_id, - 'status' => isset($validated['is_active']) ? ($validated['is_active'] ? 1 : 0) : $os->status, + 'version' => $validated['version'] ?? $os->version, + 'os_group_id' => $validated['os_group_id'] ?? $os->os_group_id, + 'template_id' => array_key_exists('template_id', $validated) ? $validated['template_id'] : $os->template_id, + 'module_host_id' => array_key_exists('module_host_id', $validated) ? $validated['module_host_id'] : $os->module_host_id, + 'datastore_id' => array_key_exists('datastore_id', $validated) ? $validated['datastore_id'] : $os->datastore_id, + 'module_network_id' => array_key_exists('module_network_id', $validated) ? $validated['module_network_id'] : $os->module_network_id, + 'resource_pool_id' => array_key_exists('resource_pool_id', $validated) ? $validated['resource_pool_id'] : $os->resource_pool_id, + 'folder_id' => array_key_exists('folder_id', $validated) ? $validated['folder_id'] : $os->folder_id, + 'status' => isset($validated['is_active']) ? ($validated['is_active'] ? 1 : 0) : $os->status, ]); return response()->json([ @@ -87,22 +106,31 @@ class OsController extends Controller private function formatOs(Os $os): array { - $os->loadMissing('host.datacenter.vCenter', 'datastore'); + $os->loadMissing('host.datacenter.vCenter', 'datastore', 'network', 'resourcePool', 'folder'); $host = $os->host; $datastore = $os->datastore; + $network = $os->network; + $resourcePool = $os->resourcePool; + $folder = $os->folder; return [ - 'id' => $os->id, - 'version' => $os->version, - 'template_id' => $os->template_id, - 'is_active' => (bool) $os->status, - 'os_group_id' => $os->os_group_id, - 'module_host_id' => $os->module_host_id, - 'datastore_id' => $os->datastore_id, - 'datastore_name' => $datastore?->name, - 'host_name' => $host ? $host->name : null, - 'datacenter_name' => $host?->datacenter?->name, - 'vcenter_name' => $host?->datacenter?->vCenter?->host, + 'id' => $os->id, + 'version' => $os->version, + 'template_id' => $os->template_id, + 'is_active' => (bool) $os->status, + 'os_group_id' => $os->os_group_id, + 'module_host_id' => $os->module_host_id, + 'datastore_id' => $os->datastore_id, + 'module_network_id' => $os->module_network_id, + 'resource_pool_id' => $os->resource_pool_id, + 'folder_id' => $os->folder_id, + 'datastore_name' => $datastore?->name, + 'network_name' => $network?->name, + 'resource_pool_name' => $resourcePool?->name, + 'folder_name' => $folder?->name, + 'host_name' => $host ? $host->name : null, + 'datacenter_name' => $host?->datacenter?->name, + 'vcenter_name' => $host?->datacenter?->vCenter?->host, ]; } } diff --git a/app/Http/Controllers/Api/Admin/OsManager/OsGroupController.php b/app/Http/Controllers/Api/Admin/OsManager/OsGroupController.php index eec2267..bab38db 100644 --- a/app/Http/Controllers/Api/Admin/OsManager/OsGroupController.php +++ b/app/Http/Controllers/Api/Admin/OsManager/OsGroupController.php @@ -16,7 +16,7 @@ class OsGroupController extends Controller public function index(Request $request): JsonResponse { $query = OsGroup::withCount('os') - ->with(['os' => fn($q) => $q->with('host.datacenter.vCenter')->orderBy('version')]) + ->with(['os' => fn($q) => $q->with(['host.datacenter.vCenter', 'datastore', 'network'])->orderBy('version')]) ->when( $request->get('filter'), fn($q, $f) => $q->where('name', 'like', "%{$f}%") @@ -50,7 +50,7 @@ class OsGroupController extends Controller public function show(int $id): JsonResponse { $group = OsGroup::withCount('os') - ->with(['os' => fn($q) => $q->with('host.datacenter.vCenter')->orderBy('version')]) + ->with(['os' => fn($q) => $q->with(['host.datacenter.vCenter', 'datastore', 'network'])->orderBy('version')]) ->find($id); if (! $group) { @@ -141,18 +141,24 @@ class OsGroupController extends Controller private function formatOs(\App\Models\Os $os): array { - $host = $os->relationLoaded('host') ? $os->host : null; + $host = $os->relationLoaded('host') ? $os->host : null; + $datastore = $os->relationLoaded('datastore') ? $os->datastore : null; + $network = $os->relationLoaded('network') ? $os->network : null; return [ - 'id' => $os->id, - 'version' => $os->version, - 'template_id' => $os->template_id, - 'is_active' => (bool) $os->status, - 'os_group_id' => $os->os_group_id, - 'module_host_id' => $os->module_host_id, - 'host_name' => $host?->name, - 'datacenter_name' => $host?->datacenter?->name, - 'vcenter_name' => $host?->datacenter?->vCenter?->host, + 'id' => $os->id, + 'version' => $os->version, + 'template_id' => $os->template_id, + 'is_active' => (bool) $os->status, + 'os_group_id' => $os->os_group_id, + 'module_host_id' => $os->module_host_id, + 'datastore_id' => $os->datastore_id, + 'module_network_id' => $os->module_network_id, + 'datastore_name' => $datastore?->name, + 'network_name' => $network?->name, + 'host_name' => $host?->name, + 'datacenter_name' => $host?->datacenter?->name, + 'vcenter_name' => $host?->datacenter?->vCenter?->host, ]; } } diff --git a/app/Http/Controllers/Api/Admin/VCenter/VCenterController.php b/app/Http/Controllers/Api/Admin/VCenter/VCenterController.php index 100a20b..50cb8bb 100644 --- a/app/Http/Controllers/Api/Admin/VCenter/VCenterController.php +++ b/app/Http/Controllers/Api/Admin/VCenter/VCenterController.php @@ -9,6 +9,7 @@ use App\Models\ModuleDatastore; use App\Models\ModuleFolder; use App\Models\ModuleHost; use App\Models\ModuleNetwork; +use App\Models\ModuleResourcePool; use App\Models\vCenter; use App\Models\VirtualMachines; use App\Http\Services\vCenter as ServiceVCenter; @@ -148,6 +149,7 @@ class VCenterController extends Controller 'datastore' => ['name' => 'sometimes|string|max:255', 'datastore' => 'sometimes|string|max:255', 'type' => 'sometimes|nullable|string|max:255', 'space' => 'sometimes|nullable|integer', 'status' => 'sometimes|integer|in:0,1'], 'network' => ['name' => 'sometimes|string|max:255', 'network' => 'sometimes|string|max:255', 'type' => 'sometimes|nullable|string|max:255'], 'folder' => ['name' => 'sometimes|string|max:255', 'folder' => 'sometimes|string|max:255', 'type' => 'sometimes|nullable|string|max:255'], + 'resource_pool' => ['name' => 'sometimes|string|max:255', 'resource_pool' => 'sometimes|string|max:255', 'status' => 'sometimes|integer|in:0,1'], 'virtual_machine' => ['name' => 'sometimes|string|max:255', 'vm_id' => 'sometimes|string|max:255'], default => [], }; @@ -172,7 +174,7 @@ class VCenterController extends Controller $childCheck = match ($type) { 'vcenter' => $model->datacenters()->exists(), 'datacenter' => ModuleHost::where('module_datacenter_id', $id)->exists(), - 'host' => $model->datastore()->exists() || $model->network()->exists(), + 'host' => $model->datastore()->exists() || $model->network()->exists() || $model->resourcePool()->exists(), default => false, }; @@ -191,7 +193,7 @@ class VCenterController extends Controller public function tree(Request $request): JsonResponse { $vcenters = vCenter::with(['datacenters'])->get(); - $allHosts = ModuleHost::with(['datastore', 'network'])->get()->groupBy('module_datacenter_id'); + $allHosts = ModuleHost::with(['datastore', 'network', 'resourcePool'])->get()->groupBy('module_datacenter_id'); $allFolders = ModuleFolder::all()->groupBy('module_datacenter_id'); // VMs with a host assignment — grouped by host DB id $allVMs = VirtualMachines::with('vmware')->whereNotNull('host_id')->get()->groupBy('host_id'); @@ -219,6 +221,7 @@ class VCenterController extends Controller 'network' => $net->network, 'net_type' => $net->type, 'module_host_id' => $net->module_host_id, + 'status' => $net->status, ], ]); @@ -265,6 +268,20 @@ class VCenterController extends Controller ], ]); + $resourcePools = ($host->resourcePool ?? collect()) + ->unique('resource_pool') + ->map(fn($rp) => [ + 'id' => 'resource_pool-' . $rp->id, + 'name' => $rp->name, + 'type' => 'resource_pool', + 'meta' => [ + 'db_id' => $rp->id, + 'resource_pool' => $rp->resource_pool, + 'status' => $rp->status, + 'module_host_id' => $rp->module_host_id, + ], + ]); + return [ 'id' => 'host-' . $host->id, 'name' => $host->name, @@ -277,9 +294,10 @@ class VCenterController extends Controller 'iso_path' => $host->iso_path, 'module_datacenter_id' => $host->module_datacenter_id, 'vm_count' => $hostVMs->count(), + 'status' => $host->status, ], - // VMs first, then Datastores — correct vSphere order - 'children' => array_values($hostVMs->concat($datastores)->toArray()), + // VMs first, then Datastores, then Resource Pools — correct vSphere order + 'children' => array_values($hostVMs->concat($datastores)->concat($resourcePools)->toArray()), ]; }); @@ -334,6 +352,7 @@ class VCenterController extends Controller 'db_id' => $dc->id, 'datacenter_id' => $dc->datacenter_id, 'vcenter_id' => $dc->vcenter_id, + 'status' => $dc->status, ], // vSphere order: Hosts → Networks → Folders → Orphan VMs 'children' => array_values($dcHosts->concat($dcNetworks)->concat($dcFolders)->concat($dcOrphanVMs)->toArray()), @@ -361,6 +380,7 @@ class VCenterController extends Controller 'datastore' => ModuleDatastore::findOrFail($id), 'network' => ModuleNetwork::findOrFail($id), 'folder' => ModuleFolder::findOrFail($id), + 'resource_pool' => ModuleResourcePool::findOrFail($id), 'virtual_machine' => VirtualMachines::findOrFail($id), default => abort(404, 'Unknown node type'), }; diff --git a/app/Http/Controllers/Api/Client/ClientController.php b/app/Http/Controllers/Api/Client/ClientController.php index 66181a3..ca2e484 100644 --- a/app/Http/Controllers/Api/Client/ClientController.php +++ b/app/Http/Controllers/Api/Client/ClientController.php @@ -406,7 +406,8 @@ class ClientController extends Controller $customer->password = Hash::make($newPassword); $customer->save(); - \Illuminate\Support\Facades\Mail::to($customer->email)->send( + \Illuminate\Support\Facades\Mail::to($customer->email)->later( + now()->addSeconds(5), new \App\Mail\Customer\PasswordResetByAdmin([ 'fullname' => $customer->fullname, 'email' => $customer->email, diff --git a/app/Http/Services/vCenter.php b/app/Http/Services/vCenter.php index 971b791..e9c9d7f 100644 --- a/app/Http/Services/vCenter.php +++ b/app/Http/Services/vCenter.php @@ -102,6 +102,10 @@ class vCenter { return $this->resource->getAllFolder($datacenter); } + public function getAllResourcePool($host = '', $datacenter = '') + { + return $this->resource->getAllResourcePool($host, $datacenter); + } public function getAllDataStore($host = '') { return $this->resource->getAllDataStore($host); @@ -110,17 +114,17 @@ class vCenter { return $this->resource->loadDatastore($host_id); } - public function loadNetWork($data = [], $host_id = '') + public function loadNetWork($data = [], $host_id = '', $datacenter_id = '') { - return $this->resource->loadNetWork($host_id); + return $this->resource->loadNetWork($host_id, $datacenter_id); } - public function getAllNetwork($host = '') + public function getAllNetwork($host = '', $datacenter = '') { - return $this->resource->getNetwork($host); + return $this->resource->getNetwork($host, $datacenter); } - public function getNetwork($host = ''): array|bool|string + public function getNetwork($host = '', $datacenter = ''): array|bool|string { - return $this->resource->getNetwork($host); + return $this->resource->getNetwork($host, $datacenter); } // ─── Templates ─────────────────────────────────────────────────────────── diff --git a/app/Http/Services/vCenter/ResourceService.php b/app/Http/Services/vCenter/ResourceService.php index f3d5dab..6850cff 100644 --- a/app/Http/Services/vCenter/ResourceService.php +++ b/app/Http/Services/vCenter/ResourceService.php @@ -59,10 +59,8 @@ class ResourceService ]) ->get("https://{$this->client->apiUri}/rest/vcenter/folder", [ 'filter.datacenters.1' => $datacenter, - 'filter.type' => "VIRTUAL_MACHINE", - 'filter.names' => "vm" ])->json(); - + if (!isset($response['type'])) { return $response['value']; } @@ -71,11 +69,37 @@ class ResourceService return "Login_Again"; } - public function getAllDataStore($host = '') + public function getAllResourcePool($host = '', $datacenter = '') + { + if ($this->client->checkApiExits()) { + $params = []; + if (!empty($host)) { + $params['filter.hosts'] = $host; + } + if (!empty($datacenter)) { + $params['filter.datacenters'] = $datacenter; + } + + $response = Http::withoutVerifying() + ->withHeaders([ + 'vmware-api-session-id' => $this->client->token + ]) + ->get("https://{$this->client->apiUri}/rest/vcenter/resource-pool", $params)->json(); + + if (!isset($response['type'])) { + return $response['value']; + } + return false; + } + return "Login_Again"; + } + + public function getAllDataStore($host = '', $datacenter = '') { Log::info('[vCenter] getAllDataStore call', [ 'api' => $this->client->apiUri, - 'host_filter' => $host + 'host_filter' => $host, + 'datacenter_filter' => $datacenter ]); if (!$this->client->checkApiExits()) { @@ -87,6 +111,9 @@ class ResourceService if (!empty($host)) { $params['filter.hosts'] = $host; } + if (!empty($datacenter)) { + $params['filter.datacenters'] = $datacenter; + } $response = Http::withoutVerifying() ->withHeaders([ @@ -107,13 +134,14 @@ class ResourceService return false; } - public function loadDatastore($host_id = '') + public function loadDatastore($host_id = '', $datacenter_id = '') { Log::info('[vCenter] loadDatastore()', [ - 'esxi_host' => $host_id + 'esxi_host' => $host_id, + 'datacenter' => $datacenter_id ]); - $allDatastore = $this->getAllDataStore($host_id); + $allDatastore = $this->getAllDataStore($host_id, $datacenter_id); if ($allDatastore === "Login_Again") { Log::warning('[vCenter] Token expired when getAllDataStore'); @@ -132,13 +160,16 @@ class ResourceService return $allDatastore; } - public function getAllNetwork($host = '') + public function getAllNetwork($host = '', $datacenter = '') { if ($this->client->checkApiExits()) { $params = []; if (!empty($host)) { $params['filter.hosts'] = $host; } + if (!empty($datacenter)) { + $params['filter.datacenters'] = $datacenter; + } $response = Http::withoutVerifying() ->withHeaders([ @@ -154,14 +185,14 @@ class ResourceService return "Login_Again"; } - public function loadNetWork($host_id = '') + public function loadNetWork($host_id = '', $datacenter_id = '') { - return $this->getAllNetwork($host_id); + return $this->getAllNetwork($host_id, $datacenter_id); } - public function getNetwork($host = ''): array|bool|string + public function getNetwork($host = '', $datacenter = ''): array|bool|string { - return $this->getAllNetwork($host); + return $this->getAllNetwork($host, $datacenter); } public function GetFolderByHost($datacenter = '') diff --git a/app/Http/Services/vCenter/VirtualMachineService.php b/app/Http/Services/vCenter/VirtualMachineService.php index e7d94f3..c156f77 100644 --- a/app/Http/Services/vCenter/VirtualMachineService.php +++ b/app/Http/Services/vCenter/VirtualMachineService.php @@ -95,9 +95,9 @@ class VirtualMachineService 'source' => $template, 'placement' => [ 'datacenter' => $datacenter->datacenter_id, - 'folder' => $providerSettings->defaultFolder(), + 'folder' => $product_data['folder'] ?? $providerSettings->defaultFolder(), 'host' => $host->host_id, - 'resource_pool' => $providerSettings->defaultResourcePool(), + 'resource_pool' => $product_data['resource_pool'] ?? $providerSettings->defaultResourcePool(), 'datastore' => $datastoreId, ], 'power_on' => false, @@ -159,6 +159,12 @@ class VirtualMachineService return ['error' => false, 'data' => $vmId]; } + Log::error('VM clone failed on vCenter', [ + 'status' => $response->status(), + 'request' => $body, + 'response' => $responseData ?? $response->body(), + ]); + return ['error' => true, 'data' => $responseData['value'] ?? $response->body()]; } catch (Exception $e) { Log::error('Exception when creating VM', ['message' => $e->getMessage()]); diff --git a/app/Jobs/VirtualMachine/Create/CreateVirtualMachineJob.php b/app/Jobs/VirtualMachine/Create/CreateVirtualMachineJob.php index 3428e02..7b4dd55 100644 --- a/app/Jobs/VirtualMachine/Create/CreateVirtualMachineJob.php +++ b/app/Jobs/VirtualMachine/Create/CreateVirtualMachineJob.php @@ -119,22 +119,26 @@ class CreateVirtualMachineJob implements ShouldQueue } $vmLogger->step('VM clone started', [ - 'order_item_id' => $this->item->id, - 'product_id' => $product->id, - 'template' => $infra->template, - 'host_id' => $infra->host->id, - 'datastore_id' => $infra->datastore?->id, - 'vcenter_id' => $infra->vcenter->id, + 'order_item_id' => $this->item->id, + 'product_id' => $product->id, + 'template' => $infra->template, + 'host_id' => $infra->host->id, + 'datastore_id' => $infra->datastore?->id, + 'vcenter_id' => $infra->vcenter->id, + 'folder' => $infra->folderId, + 'resource_pool' => $infra->resourcePoolId, ]); $result = $vCenter->CreateVirtualMachine([ - 'item' => $this->item, - 'host' => $infra->host, - 'datacenter' => $infra->datacenter, - 'vcenter' => $infra->vcenter, - 'template' => $infra->template, - 'addon' => $addonData, - 'datastore' => $infra->datastore, + 'item' => $this->item, + 'host' => $infra->host, + 'datacenter' => $infra->datacenter, + 'vcenter' => $infra->vcenter, + 'template' => $infra->template, + 'addon' => $addonData, + 'datastore' => $infra->datastore, + 'folder' => $infra->folderId, + 'resource_pool' => $infra->resourcePoolId, ]); if (is_array($result) && ($result['error'] ?? false)) { diff --git a/app/Jobs/VirtualMachine/Provision/AssignIpToGuestJob.php b/app/Jobs/VirtualMachine/Provision/AssignIpToGuestJob.php index b8d5e62..36143e4 100644 --- a/app/Jobs/VirtualMachine/Provision/AssignIpToGuestJob.php +++ b/app/Jobs/VirtualMachine/Provision/AssignIpToGuestJob.php @@ -90,12 +90,14 @@ class AssignIpToGuestJob implements ShouldQueue return; } - // DNS + network selector still come from product template; gateway + CIDR - // are now read from the chosen ip_pools row at reservation time. + // Network preferably comes from the OS's own binding (module_network_id); + // falls back to the product's ipAddress template for legacy OS entries + // that were never assigned a network. Gateway + CIDR are read from the + // chosen ip_pools row at reservation time. $orderItem = $moduleVmware->order?->orderItems?->first(); $osModel = $orderItem?->os_id ? Os::with('osGroup.host')->find($orderItem->os_id) : null; - $networkId = $orderItem?->product?->ipAddress?->module_network_id ?? 1; + $networkId = $osModel?->module_network_id ?? $orderItem?->product?->ipAddress?->module_network_id ?? 1; $dns = $orderItem?->product?->ipAddress?->dns ?? '8.8.8.8'; $hostId = $osModel?->osGroup?->host?->id ?? 1; $serviceId = Service::where('vm_id', $this->moduleVmwareId)->value('id'); @@ -334,15 +336,9 @@ class AssignIpToGuestJob implements ShouldQueue string $dns, ): ?array { return DB::transaction(function () use ($networkId, $hostId, $dns) { - // Lock existing rows to prevent another job from picking the same IP - $usedIps = ModuleIpAddress::where('module_network_id', $networkId) - ->lockForUpdate() - ->pluck('ip') - ->toArray(); - + // ip_pools.status is the single source of truth for availability. $ipPool = IpPool::where('module_network_id', $networkId) ->whereIn('status', ['free', 'reserved']) - ->whereNotIn('ip_address', $usedIps) ->lockForUpdate() ->inRandomOrder() ->first(); @@ -356,10 +352,16 @@ class AssignIpToGuestJob implements ShouldQueue $cidr = (int) ($ipPool->cidr ?: 24); $gateway = (string) ($ipPool->gateway ?? ''); - $netmask = $this->cidrToNetmask($cidr); + $netmask = $ipPool->netmask ?: $this->cidrToNetmask($cidr); - // Create a pending reservation — acts as a distributed lock on this IP - $reservation = ModuleIpAddress::create([ + // module_ip_address mirrors ip_pools — update the existing synced row + // (created when the IP was added to ip_pools) instead of inserting a new one. + $reservation = ModuleIpAddress::where('ip', $ipPool->ip_address) + ->where('module_network_id', $networkId) + ->lockForUpdate() + ->first(); + + $attrs = [ 'vm_name' => '__pending__', 'module_host_id' => $hostId, 'module_network_id' => $networkId, @@ -368,7 +370,13 @@ class AssignIpToGuestJob implements ShouldQueue 'netmask' => $netmask, 'dns' => $dns, 'status' => 0, // 0 = pending/reserved, not yet confirmed - ]); + ]; + + if ($reservation) { + $reservation->update($attrs); + } else { + $reservation = ModuleIpAddress::create($attrs); + } return [ 'reservation' => $reservation, @@ -408,13 +416,13 @@ class AssignIpToGuestJob implements ShouldQueue } /** - * Release a failed reservation by deleting the pending ModuleIpAddress row. + * Release a failed reservation by resetting the mirrored ModuleIpAddress row. * This returns the IP to the available pool. */ private function releaseIp(ModuleIpAddress $reservation, ?int $ipPoolId, VmProvisionLogger $logger): void { try { - $reservation->delete(); + $reservation->update(['vm_name' => null, 'status' => 0]); if ($ipPoolId) { IpPool::where('id', $ipPoolId)->update(['status' => 'free']); } diff --git a/app/Jobs/VirtualMachine/Sync/SyncAllPackage.php b/app/Jobs/VirtualMachine/Sync/SyncAllPackage.php index 8cc3327..76c36f2 100644 --- a/app/Jobs/VirtualMachine/Sync/SyncAllPackage.php +++ b/app/Jobs/VirtualMachine/Sync/SyncAllPackage.php @@ -34,6 +34,7 @@ class SyncAllPackage implements ShouldQueue SyncDatastore::dispatch(); SyncNetwork::dispatch(); SyncFolder::dispatch(); + SyncResourcePool::dispatch(); SyncVirtualMachine::dispatch(); } } diff --git a/app/Jobs/VirtualMachine/Sync/SyncDatacenter.php b/app/Jobs/VirtualMachine/Sync/SyncDatacenter.php index 96d127a..ed5d68d 100644 --- a/app/Jobs/VirtualMachine/Sync/SyncDatacenter.php +++ b/app/Jobs/VirtualMachine/Sync/SyncDatacenter.php @@ -93,7 +93,7 @@ class SyncDatacenter implements ShouldQueue ModuleDatacenter::updateOrCreate( ['datacenter_id' => $_item['datacenter']], - ['name' => $_item['name'], 'vcenter_id' => $item->id] + ['name' => $_item['name'], 'vcenter_id' => $item->id, 'status' => 1] ); } } diff --git a/app/Jobs/VirtualMachine/Sync/SyncDatastore.php b/app/Jobs/VirtualMachine/Sync/SyncDatastore.php index 7176289..0d086bb 100644 --- a/app/Jobs/VirtualMachine/Sync/SyncDatastore.php +++ b/app/Jobs/VirtualMachine/Sync/SyncDatastore.php @@ -100,8 +100,8 @@ class SyncDatastore implements ShouldQueue 'vcenter_host_id' => $hostItem->host_id ]); - // Use ResourceService to get datastores - $datastore = $resourceService->loadDatastore($hostItem->host_id); + // Use ResourceService to get datastores, scoped to this host AND datacenter + $datastore = $resourceService->loadDatastore($hostItem->host_id, $_item->datacenter_id); if ($datastore === "Login_Again") { $this->jobLog('Token expired → relogin'); @@ -109,7 +109,7 @@ class SyncDatastore implements ShouldQueue if ($token) { // Retry once - $datastore = $resourceService->loadDatastore($hostItem->host_id); + $datastore = $resourceService->loadDatastore($hostItem->host_id, $_item->datacenter_id); } else { $this->jobLog('Relogin failed'); continue; @@ -135,6 +135,8 @@ class SyncDatastore implements ShouldQueue 'name' => $_subItem['name'], 'type' => $_subItem['type'], 'space' => floor($_subItem['free_space'] / 1073741824), + 'vcenter_id' => $item->id, + 'status' => 1, ] ); } diff --git a/app/Jobs/VirtualMachine/Sync/SyncHost.php b/app/Jobs/VirtualMachine/Sync/SyncHost.php index 0e65ab2..c77cdc5 100644 --- a/app/Jobs/VirtualMachine/Sync/SyncHost.php +++ b/app/Jobs/VirtualMachine/Sync/SyncHost.php @@ -84,6 +84,7 @@ class SyncHost implements ShouldQueue 'name' => $_subItem['name'], 'module_datacenter_id' => $_item['id'], 'template' => $template[0] ?? null, + 'status' => 1, ] ); } diff --git a/app/Jobs/VirtualMachine/Sync/SyncNetwork.php b/app/Jobs/VirtualMachine/Sync/SyncNetwork.php index c8406ae..048405d 100644 --- a/app/Jobs/VirtualMachine/Sync/SyncNetwork.php +++ b/app/Jobs/VirtualMachine/Sync/SyncNetwork.php @@ -38,6 +38,7 @@ class SyncNetwork implements ShouldQueue */ public function handle() { + $networkCount = 0; foreach ($this->data as $item) { if (!$item->token) { @@ -62,13 +63,13 @@ class SyncNetwork implements ShouldQueue if ($allHost) { foreach ($allHost as $hostItem) { - $network = $this->vCenter->loadNetWork($item, $hostItem->host_id); + $network = $this->vCenter->loadNetWork($item, $hostItem->host_id, $_item->datacenter_id); if ($network === "Login_Again") { $token = $this->vCenter->loginServer($item); if ($token) { vCenter::where('id', $item->id)->update(['token' => $token]); $this->vCenter->token = $token; - $network = $this->vCenter->loadNetWork($item, $hostItem->host_id); + $network = $this->vCenter->loadNetWork($item, $hostItem->host_id, $_item->datacenter_id); } } @@ -82,8 +83,11 @@ class SyncNetwork implements ShouldQueue [ 'name' => $_subItem['name'], 'type' => $_subItem['type'], + 'vcenter_id' => $item->id, + 'status' => 1, ] ); + $networkCount++; } } } @@ -94,6 +98,7 @@ class SyncNetwork implements ShouldQueue (new \App\Http\Services\Telegram())->SendJobRunInfo('SyncNetwork', 'Success', [ 'message' => 'Sync network completed successfully', 'synced_vcenters' => $this->data->count(), + 'synced_network_count' => $networkCount, ]); } } diff --git a/app/Jobs/VirtualMachine/Sync/SyncResourcePool.php b/app/Jobs/VirtualMachine/Sync/SyncResourcePool.php new file mode 100644 index 0000000..5b2fdd7 --- /dev/null +++ b/app/Jobs/VirtualMachine/Sync/SyncResourcePool.php @@ -0,0 +1,92 @@ +data = vCenter::select('id', 'host', 'user', 'password', 'token')->get(); + $this->vCenter = new ServiceVCenter; + $this->telegram = new Telegram(); + } + + public function handle() + { + $resourcePoolCount = 0; + + foreach ($this->data as $item) { + $token = $this->vCenter->loginServer($item); + + if (!$token) { + continue; + } + + $dataCenter = ModuleDatacenter::where('vcenter_id', $item->id) + ->select('id', 'datacenter_id') + ->get(); + + foreach ($dataCenter as $_item) { + $allHost = ModuleHost::where('module_datacenter_id', $_item->id) + ->select('id', 'name', 'host_id') + ->get(); + + foreach ($allHost as $hostItem) { + $resourcePool = $this->vCenter->getAllResourcePool($hostItem->host_id, $_item->datacenter_id); + + if ($resourcePool === 'Login_Again') { + $token = $this->vCenter->loginServer($item); + if ($token) { + $resourcePool = $this->vCenter->getAllResourcePool($hostItem->host_id, $_item->datacenter_id); + } else { + continue; + } + } + + if (!is_array($resourcePool)) { + continue; + } + + foreach ($resourcePool as $_subItem) { + ModuleResourcePool::updateOrCreate( + [ + 'resource_pool' => $_subItem['resource_pool'], + 'module_host_id' => $hostItem->id, + ], + [ + 'name' => $_subItem['name'], + 'vcenter_id' => $item->id, + 'status' => 1, + ] + ); + $resourcePoolCount++; + } + } + } + } + + $this->telegram->SendJobRunInfo('SyncResourcePool', 'Success', [ + 'message' => 'Sync resource pool completed successfully', + 'synced_vcenters' => $this->data->count(), + 'synced_resource_pool_count' => $resourcePoolCount, + ]); + } +} diff --git a/app/Models/IpPool.php b/app/Models/IpPool.php index 7abac30..9dd1c44 100644 --- a/app/Models/IpPool.php +++ b/app/Models/IpPool.php @@ -17,6 +17,7 @@ class IpPool extends Model 'ip_address', 'gateway', 'cidr', + 'netmask', 'module_network_id', 'status', 'service_id', diff --git a/app/Models/ModuleDatastore.php b/app/Models/ModuleDatastore.php index 1fb8d68..a027c2c 100644 --- a/app/Models/ModuleDatastore.php +++ b/app/Models/ModuleDatastore.php @@ -17,6 +17,7 @@ class ModuleDatastore extends Model 'type', 'space', 'module_host_id', + 'vcenter_id', 'status' ]; @@ -24,4 +25,9 @@ class ModuleDatastore extends Model { return $this->belongsTo(\App\Models\ModuleHost::class,'module_host_id','id'); } + + public function vcenter() + { + return $this->belongsTo(\App\Models\vCenter::class, 'vcenter_id', 'id'); + } } diff --git a/app/Models/ModuleHost.php b/app/Models/ModuleHost.php index a0f38eb..2f7c6f8 100644 --- a/app/Models/ModuleHost.php +++ b/app/Models/ModuleHost.php @@ -33,4 +33,8 @@ class ModuleHost extends Model { return $this->hasMany(\App\Models\ModuleNetwork::class); } + public function resourcePool() + { + return $this->hasMany(\App\Models\ModuleResourcePool::class); + } } diff --git a/app/Models/ModuleNetwork.php b/app/Models/ModuleNetwork.php index c177a70..138b799 100644 --- a/app/Models/ModuleNetwork.php +++ b/app/Models/ModuleNetwork.php @@ -16,6 +16,7 @@ class ModuleNetwork extends Model 'type', 'network', 'module_host_id', + 'vcenter_id', 'status', ]; @@ -23,6 +24,11 @@ class ModuleNetwork extends Model { return $this->belongsTo(\App\Models\ModuleHost::class,'module_host_id','id'); } + + public function vcenter() + { + return $this->belongsTo(\App\Models\vCenter::class, 'vcenter_id', 'id'); + } public function ipAddresses() { return $this->hasMany(\App\Models\ModuleIpAddress::class, 'module_network_id'); diff --git a/app/Models/ModuleResourcePool.php b/app/Models/ModuleResourcePool.php new file mode 100644 index 0000000..6857816 --- /dev/null +++ b/app/Models/ModuleResourcePool.php @@ -0,0 +1,31 @@ +belongsTo(\App\Models\ModuleHost::class, 'module_host_id', 'id'); + } + + public function vcenter() + { + return $this->belongsTo(\App\Models\vCenter::class, 'vcenter_id', 'id'); + } +} diff --git a/app/Models/Os.php b/app/Models/Os.php index c5954a7..577767f 100644 --- a/app/Models/Os.php +++ b/app/Models/Os.php @@ -17,6 +17,9 @@ class Os extends Model 'template_id', 'module_host_id', 'datastore_id', + 'module_network_id', + 'resource_pool_id', + 'folder_id', 'status', ]; @@ -34,6 +37,21 @@ class Os extends Model { return $this->belongsTo(\App\Models\ModuleDatastore::class, 'datastore_id'); } + + public function network() + { + return $this->belongsTo(\App\Models\ModuleNetwork::class, 'module_network_id'); + } + + public function resourcePool() + { + return $this->belongsTo(\App\Models\ModuleResourcePool::class, 'resource_pool_id'); + } + + public function folder() + { + return $this->belongsTo(\App\Models\ModuleFolder::class, 'folder_id'); + } public function getStatus() { return match ($this->attributes['status']){ diff --git a/app/Services/Api/Client/ClientAuthService.php b/app/Services/Api/Client/ClientAuthService.php index 985de37..624732a 100644 --- a/app/Services/Api/Client/ClientAuthService.php +++ b/app/Services/Api/Client/ClientAuthService.php @@ -155,7 +155,7 @@ class ClientAuthService protected function sendVerifyEmail($client, string $code): void { try { - Mail::to($client->email)->send(new VerifyAccountMail([ + Mail::to($client->email)->later(now()->addSeconds(5), new VerifyAccountMail([ 'fullname' => $client->fullname, 'email' => $client->email, 'verify_code' => $code, diff --git a/app/Services/VirtualMachine/Dto/VmInfrastructureContext.php b/app/Services/VirtualMachine/Dto/VmInfrastructureContext.php index c0dbb3c..76763da 100644 --- a/app/Services/VirtualMachine/Dto/VmInfrastructureContext.php +++ b/app/Services/VirtualMachine/Dto/VmInfrastructureContext.php @@ -23,6 +23,8 @@ class VmInfrastructureContext public readonly int $vmPort, public readonly ?ModuleDatastore $datastore = null, public readonly ?ModuleNetwork $network = null, + public readonly string $folderId = '', + public readonly string $resourcePoolId = '', ) { } } diff --git a/app/Services/VirtualMachine/VmActiveProviderService.php b/app/Services/VirtualMachine/VmActiveProviderService.php index cfdfc06..ce9a031 100644 --- a/app/Services/VirtualMachine/VmActiveProviderService.php +++ b/app/Services/VirtualMachine/VmActiveProviderService.php @@ -43,11 +43,11 @@ class VmActiveProviderService /** type → [modelClass, parentColumn|null (null = global), childTypes[]] */ private const MAP = [ - self::TYPE_VCENTER => [vCenter::class, null, [self::TYPE_DATACENTER]], + self::TYPE_VCENTER => [vCenter::class, null, [self::TYPE_DATACENTER, self::TYPE_DATASTORE, self::TYPE_NETWORK]], self::TYPE_DATACENTER => [ModuleDatacenter::class, 'vcenter_id', [self::TYPE_HOST]], - self::TYPE_HOST => [ModuleHost::class, 'module_datacenter_id', [self::TYPE_DATASTORE, self::TYPE_NETWORK]], - self::TYPE_DATASTORE => [ModuleDatastore::class, 'module_host_id', []], - self::TYPE_NETWORK => [ModuleNetwork::class, 'module_host_id', []], + self::TYPE_HOST => [ModuleHost::class, 'module_datacenter_id', []], + self::TYPE_DATASTORE => [ModuleDatastore::class, 'vcenter_id', []], + self::TYPE_NETWORK => [ModuleNetwork::class, 'vcenter_id', []], ]; public function __construct(private readonly VmProviderSettingsService $providerSettings) @@ -77,16 +77,16 @@ class VmActiveProviderService ->first(); } - public function activeDatastore(int $hostId): ?ModuleDatastore + public function activeDatastore(int $vcenterId): ?ModuleDatastore { - return ModuleDatastore::where('module_host_id', $hostId) + return ModuleDatastore::where('vcenter_id', $vcenterId) ->where('status', 1) ->first(); } - public function activeNetwork(int $hostId): ?ModuleNetwork + public function activeNetwork(int $vcenterId): ?ModuleNetwork { - return ModuleNetwork::where('module_host_id', $hostId) + return ModuleNetwork::where('vcenter_id', $vcenterId) ->where('status', 1) ->first(); } @@ -113,14 +113,14 @@ class VmActiveProviderService throw new RuntimeException("Datacenter active id={$datacenter->id} chưa có Host nào active."); } - $datastore = $this->activeDatastore($host->id); + $datastore = $this->activeDatastore($vcenter->id); if (!$datastore) { - throw new RuntimeException("Host active id={$host->id} chưa có Datastore nào active."); + throw new RuntimeException("vCenter active id={$vcenter->id} chưa có Datastore nào active."); } - $network = $this->activeNetwork($host->id); + $network = $this->activeNetwork($vcenter->id); if (!$network) { - throw new RuntimeException("Host active id={$host->id} chưa có Network nào active."); + throw new RuntimeException("vCenter active id={$vcenter->id} chưa có Network nào active."); } return compact('vcenter', 'datacenter', 'host', 'datastore', 'network'); diff --git a/app/Services/VirtualMachine/VmInfrastructureResolver.php b/app/Services/VirtualMachine/VmInfrastructureResolver.php index 7c8731d..6ef3fef 100644 --- a/app/Services/VirtualMachine/VmInfrastructureResolver.php +++ b/app/Services/VirtualMachine/VmInfrastructureResolver.php @@ -10,25 +10,29 @@ use App\Services\VirtualMachine\Dto\VmInfrastructureContext; use RuntimeException; /** - * Resolves the provisioning target by walking the ACTIVE chain only: + * Resolves the provisioning target by walking the ACTIVE chain: * * active vCenter → active Datacenter → active Host → active Datastore + Network * - * Product / OS bindings are now informational only — the host/datacenter/vcenter - * configured per-product no longer drive VM creation. The whole platform clones - * onto whatever single chain is currently marked status=1. + * Product/OS host binding is informational only — it no longer drives VM + * creation. Datastore, network, resource pool and folder are exceptions: if + * the OS has datastore_id / module_network_id / resource_pool_id / folder_id + * set, they override the active chain / global default (an OS can target a + * different datastore/IP pool/resource pool/folder than whatever happens to + * be "active" or configured as the system-wide default). */ class VmInfrastructureResolver { public function __construct( private readonly VmProvisionLogger $logger, private readonly VmActiveProviderService $activeProvider, + private readonly VmProviderSettingsService $providerSettings, ) { } public function resolve(OrderItem $item, $product): VmInfrastructureContext { - $os = Os::with('osGroup')->find($item->os_id); + $os = Os::with(['osGroup', 'network', 'datastore', 'resourcePool', 'folder'])->find($item->os_id); if (!$os) { throw new RuntimeException( @@ -36,15 +40,25 @@ class VmInfrastructureResolver ); } - $chain = $this->activeProvider->resolveChain(); + $chain = $this->activeProvider->resolveChain(); + $network = $os->network ?? $chain['network']; + $datastore = $os->datastore ?? $chain['datastore']; + $folderId = $os->folder?->folder ?? $this->providerSettings->defaultFolder(); + $resourcePoolId = $os->resourcePool?->resource_pool ?? $this->providerSettings->defaultResourcePool(); $this->logger->step('Infrastructure resolved from active chain', [ - 'order_item_id' => $item->id, - 'vcenter_id' => $chain['vcenter']->id, - 'datacenter_id' => $chain['datacenter']->id, - 'host_id' => $chain['host']->id, - 'datastore_id' => $chain['datastore']->id, - 'network_id' => $chain['network']->id, + 'order_item_id' => $item->id, + 'vcenter_id' => $chain['vcenter']->id, + 'datacenter_id' => $chain['datacenter']->id, + 'host_id' => $chain['host']->id, + 'datastore_id' => $datastore->id, + 'datastore_from_os' => $os->datastore !== null, + 'network_id' => $network->id, + 'network_from_os' => $os->network !== null, + 'folder_id' => $folderId, + 'folder_from_os' => $os->folder !== null, + 'resource_pool_id' => $resourcePoolId, + 'resource_pool_from_os' => $os->resourcePool !== null, ]); $osGroupName = $os->osGroup?->name ?? ''; @@ -59,8 +73,10 @@ class VmInfrastructureResolver isWindows: $isWindows, vmUser: $isWindows ? 'administrator' : 'root', vmPort: $isWindows ? 3389 : 22, - datastore: $chain['datastore'], - network: $chain['network'], + datastore: $datastore, + network: $network, + folderId: $folderId, + resourcePoolId: $resourcePoolId, ); } } diff --git a/app/Services/VirtualMachine/VmIpReservationService.php b/app/Services/VirtualMachine/VmIpReservationService.php index 1924a04..29b011f 100644 --- a/app/Services/VirtualMachine/VmIpReservationService.php +++ b/app/Services/VirtualMachine/VmIpReservationService.php @@ -24,18 +24,9 @@ class VmIpReservationService public function reserve(int $networkId, int $hostId, string $dns): ?array { return DB::transaction(function () use ($networkId, $hostId, $dns) { - // Exclude IPs that have any active reservation (real VM or pending). - // Also accept status='reserved' in ip_pools so stale reservations - // (ip_pool row stuck as 'reserved' with no matching module_ip_addresses row) - // are recoverable without manual DB cleanup. - $allUsedIps = ModuleIpAddress::where('module_network_id', $networkId) - ->lockForUpdate() - ->pluck('ip') - ->toArray(); - + // ip_pools.status is the single source of truth for availability. $ipPool = IpPool::where('module_network_id', $networkId) ->whereIn('status', ['free', 'reserved']) - ->whereNotIn('ip_address', $allUsedIps) ->lockForUpdate() ->inRandomOrder() ->first(); @@ -48,9 +39,16 @@ class VmIpReservationService $cidr = (int) ($ipPool->cidr ?: 24); $gateway = (string) ($ipPool->gateway ?? ''); - $netmask = $this->cidrToNetmask($cidr); + $netmask = $ipPool->netmask ?: $this->cidrToNetmask($cidr); - $reservation = ModuleIpAddress::create([ + // module_ip_address mirrors ip_pools — update the existing synced row + // (created when the IP was added to ip_pools) instead of inserting a new one. + $reservation = ModuleIpAddress::where('ip', $ipPool->ip_address) + ->where('module_network_id', $networkId) + ->lockForUpdate() + ->first(); + + $attrs = [ 'vm_name' => '__pending__', 'module_host_id' => $hostId, 'module_network_id' => $networkId, @@ -59,7 +57,13 @@ class VmIpReservationService 'netmask' => $netmask, 'dns' => $dns, 'status' => 0, - ]); + ]; + + if ($reservation) { + $reservation->update($attrs); + } else { + $reservation = ModuleIpAddress::create($attrs); + } return [ 'reservation' => $reservation, @@ -73,7 +77,7 @@ class VmIpReservationService public function release(ModuleIpAddress $reservation, ?int $ipPoolId): void { try { - $reservation->delete(); + $reservation->update(['vm_name' => null, 'status' => 0]); if ($ipPoolId) { IpPool::where('id', $ipPoolId)->update(['status' => 'free']); } diff --git a/database/migrations/2026_07_21_130000_add_vcenter_id_to_datastore_network.php b/database/migrations/2026_07_21_130000_add_vcenter_id_to_datastore_network.php new file mode 100644 index 0000000..382e813 --- /dev/null +++ b/database/migrations/2026_07_21_130000_add_vcenter_id_to_datastore_network.php @@ -0,0 +1,39 @@ +foreignId('vcenter_id')->nullable()->after('module_host_id') + ->constrained('module_vcenter')->nullOnDelete(); + }); + + Schema::table('module_network', function (Blueprint $table) { + $table->foreignId('vcenter_id')->nullable()->after('module_host_id') + ->constrained('module_vcenter')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('module_datastore', function (Blueprint $table) { + $table->dropConstrainedForeignId('vcenter_id'); + }); + + Schema::table('module_network', function (Blueprint $table) { + $table->dropConstrainedForeignId('vcenter_id'); + }); + } +}; diff --git a/database/migrations/2026_07_21_140000_add_netmask_to_ip_pools_table.php b/database/migrations/2026_07_21_140000_add_netmask_to_ip_pools_table.php new file mode 100644 index 0000000..e1508c5 --- /dev/null +++ b/database/migrations/2026_07_21_140000_add_netmask_to_ip_pools_table.php @@ -0,0 +1,23 @@ +string('netmask')->nullable()->after('cidr'); + }); + } + } + + public function down(): void + { + Schema::table('ip_pools', function (Blueprint $table) { + $table->dropColumn('netmask'); + }); + } +}; diff --git a/database/migrations/2026_07_21_150000_add_module_network_id_to_os_table.php b/database/migrations/2026_07_21_150000_add_module_network_id_to_os_table.php new file mode 100644 index 0000000..d1cebb9 --- /dev/null +++ b/database/migrations/2026_07_21_150000_add_module_network_id_to_os_table.php @@ -0,0 +1,27 @@ +unsignedBigInteger('module_network_id')->nullable()->after('datastore_id'); + $table->foreign('module_network_id') + ->references('id') + ->on('module_network') + ->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('os', function (Blueprint $table) { + $table->dropForeign(['module_network_id']); + $table->dropColumn('module_network_id'); + }); + } +}; diff --git a/database/migrations/2026_07_21_204634_create_module_resource_pool_table.php b/database/migrations/2026_07_21_204634_create_module_resource_pool_table.php new file mode 100644 index 0000000..dbb0e77 --- /dev/null +++ b/database/migrations/2026_07_21_204634_create_module_resource_pool_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('name', 255)->nullable(); + $table->string('resource_pool', 255)->nullable(); + $table->foreignId('module_host_id')->index()->constrained('module_host')->nullable(); + $table->foreignId('vcenter_id')->nullable()->constrained('module_vcenter')->nullOnDelete(); + $table->tinyInteger('status')->default(1); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('module_resource_pool'); + } +}; diff --git a/database/migrations/2026_07_21_204645_add_resource_pool_and_folder_id_to_os_table.php b/database/migrations/2026_07_21_204645_add_resource_pool_and_folder_id_to_os_table.php new file mode 100644 index 0000000..c4958e9 --- /dev/null +++ b/database/migrations/2026_07_21_204645_add_resource_pool_and_folder_id_to_os_table.php @@ -0,0 +1,32 @@ +foreignId('resource_pool_id')->nullable() + ->constrained('module_resource_pool')->nullOnDelete(); + $table->foreignId('folder_id')->nullable() + ->constrained('module_folder')->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('os', function (Blueprint $table) { + $table->dropConstrainedForeignId('resource_pool_id'); + $table->dropConstrainedForeignId('folder_id'); + }); + } +}; diff --git a/routes/api.php b/routes/api.php index 5173aff..79d3d7c 100644 --- a/routes/api.php +++ b/routes/api.php @@ -581,6 +581,7 @@ Route::prefix('v1') Route::get('networks', 'networks')->middleware('perm:module-vcenter.view'); Route::get('/', 'index')->middleware('perm:module-vcenter.view'); Route::post('/', 'store')->middleware('perm:module-vcenter.manager'); + Route::post('sync', 'sync')->middleware('perm:module-vcenter.manager'); Route::delete('bulk', 'bulkDelete')->middleware('perm:module-vcenter.manager'); Route::get('{id}', 'show')->middleware('perm:module-vcenter.view'); Route::put('{id}', 'update')->middleware('perm:module-vcenter.manager');