From 23a496f511581b316cd86d2b8204348ff418c870 Mon Sep 17 00:00:00 2001 From: pbao9 Date: Fri, 6 Mar 2026 17:29:52 +0700 Subject: [PATCH] feat: Implement vCenter management APIs, console ticket retrieval, password encryption, and background synchronization. --- .../Commands/EncryptVCenterPasswords.php | 53 ++++ .../Api/Admin/VCenter/VCenterController.php | 255 ++++++++++++++++-- .../Resources/Api/Admin/ProductResource.php | 1 + app/Http/Services/vCenter.php | 1 - .../VirtualMachine/Sync/SyncDatacenter.php | 12 +- app/Jobs/VirtualMachine/Sync/SyncFolder.php | 32 ++- app/Jobs/VirtualMachine/Sync/SyncHost.php | 20 +- app/Models/vCenter.php | 2 + ..._02_111537_update_column_invoice_table.php | 3 +- routes/api.php | 5 + 10 files changed, 332 insertions(+), 52 deletions(-) create mode 100644 app/Console/Commands/EncryptVCenterPasswords.php diff --git a/app/Console/Commands/EncryptVCenterPasswords.php b/app/Console/Commands/EncryptVCenterPasswords.php new file mode 100644 index 0000000..1bf17f1 --- /dev/null +++ b/app/Console/Commands/EncryptVCenterPasswords.php @@ -0,0 +1,53 @@ +get(['id', 'password']); + $fixed = 0; + + foreach ($rows as $row) { + if (!$row->password) continue; + + // Try to decrypt as Laravel encrypted cast (from previous migration) + $plainPassword = null; + try { + $plainPassword = Crypt::decryptString($row->password); + $this->info("vCenter #{$row->id}: detected Laravel encrypted password, will re-encrypt with SignRSA"); + } catch (\Exception) { + // Not Laravel encrypted — check if it's already SignRSA or plain text + $decrypted = SignRSA::DecryptFunc($row->password); + if ($decrypted) { + $this->info("vCenter #{$row->id}: already SignRSA encrypted, skipping"); + continue; + } + // Plain text password + $plainPassword = $row->password; + $this->info("vCenter #{$row->id}: detected plain text password, will encrypt with SignRSA"); + } + + if ($plainPassword) { + $encrypted = SignRSA::EncryptFunc($plainPassword); + DB::table('module_vcenter') + ->where('id', $row->id) + ->update(['password' => $encrypted]); + $fixed++; + $this->info("vCenter #{$row->id}: re-encrypted successfully"); + } + } + + $this->info("Done. Fixed {$fixed} password(s)."); + return 0; + } +} diff --git a/app/Http/Controllers/Api/Admin/VCenter/VCenterController.php b/app/Http/Controllers/Api/Admin/VCenter/VCenterController.php index 7c262b3..0c325b5 100644 --- a/app/Http/Controllers/Api/Admin/VCenter/VCenterController.php +++ b/app/Http/Controllers/Api/Admin/VCenter/VCenterController.php @@ -3,28 +3,166 @@ namespace App\Http\Controllers\Api\Admin\VCenter; use App\Http\Controllers\Controller; +use App\Jobs\VirtualMachine\Sync\SyncAllPackage; +use App\Models\ModuleDatacenter; +use App\Models\ModuleDatastore; use App\Models\ModuleFolder; use App\Models\ModuleHost; +use App\Models\ModuleNetwork; use App\Models\vCenter; +use App\Models\VirtualMachines; +use App\Http\Services\vCenter as ServiceVCenter; +use App\Http\Facade\SignRSA; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; class VCenterController extends Controller { + public function store(Request $request): JsonResponse + { + $request->validate([ + 'host' => 'required|string|max:255', + 'user' => 'required|string|max:255', + 'password' => 'required|string|max:255', + ]); + + $vc = vCenter::create([ + 'host' => $request->input('host'), + 'user' => $request->input('user'), + 'password' => SignRSA::EncryptFunc($request->input('password')), + 'status' => 1, + ]); + + return response()->json(['error' => false, 'data' => $vc], 201); + } + + public function consoleTicket(Request $request, ServiceVCenter $serviceVCenter): JsonResponse + { + $vmDbId = $request->integer('vm_id'); + if (!$vmDbId) { + return response()->json(['error' => true, 'message' => 'Thiếu vm_id'], 422); + } + + $vm = VirtualMachines::with('datacenter.vCenter')->find($vmDbId); + if (!$vm) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy VM'], 404); + } + + $vcenter = $vm->datacenter?->vCenter ?? null; + if (!$vcenter) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy thông tin vCenter'], 404); + } + + if (!$serviceVCenter->loginServer($vcenter)) { + return response()->json(['error' => true, 'message' => 'Không thể đăng nhập vào vCenter'], 500); + } + + $ticket = $serviceVCenter->GetConsoleTickets($vm->vm_id); + + if ($ticket === 'Login_Again') { + if ($serviceVCenter->loginServer($vcenter)) { + $ticket = $serviceVCenter->GetConsoleTickets($vm->vm_id); + } else { + return response()->json(['error' => true, 'message' => 'Không thể đăng nhập lại vào vCenter'], 500); + } + } + + if (is_string($ticket)) { + return response()->json(['error' => true, 'message' => "Lấy ticket thất bại: $ticket"], 500); + } + + if ($ticket['error']) { + return response()->json(['error' => true, 'message' => $ticket['data']], 500); + } + + return response()->json([ + 'error' => false, + 'data' => $ticket['data'], + 'host' => $vcenter->host, + 'vmware-api-session-id' => $serviceVCenter->token, + ])->cookie('vmware-api-session-id', $serviceVCenter->token, 60, '/', null, false, false); + } + + public function sync(): JsonResponse + { + dispatch(new SyncAllPackage())->delay(now()->addSeconds(5)); + + return response()->json([ + 'error' => false, + 'message' => 'Sync job dispatched, running in background.', + ]); + } + + public function updateNode(Request $request, string $type, int $id): JsonResponse + { + $model = $this->resolveModel($type, $id); + + $rules = match ($type) { + 'vcenter' => ['host' => 'sometimes|string|max:255', 'user' => 'sometimes|string|max:255', 'password' => 'sometimes|string|max:255', 'status' => 'sometimes|integer|in:0,1'], + 'datacenter' => ['name' => 'sometimes|string|max:255', 'datacenter_id' => 'sometimes|string|max:255'], + 'host' => ['name' => 'sometimes|string|max:255', 'host_id' => 'sometimes|string|max:255', 'template' => 'sometimes|nullable|string|max:500', 'console' => 'sometimes|nullable|string|max:500', 'iso_path' => 'sometimes|nullable|string|max:500'], + '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'], + 'virtual_machine' => ['name' => 'sometimes|string|max:255', 'vm_id' => 'sometimes|string|max:255'], + default => [], + }; + + $request->validate($rules); + + $data = $request->only(array_keys($rules)); + + if ($type === 'vcenter' && isset($data['password'])) { + $data['password'] = SignRSA::EncryptFunc($data['password']); + } + + $model->update($data); + + return response()->json(['error' => false, 'data' => $model]); + } + + public function destroyNode(string $type, int $id): JsonResponse + { + $model = $this->resolveModel($type, $id); + + $childCheck = match ($type) { + 'vcenter' => $model->datacenters()->exists(), + 'datacenter' => ModuleHost::where('module_datacenter_id', $id)->exists(), + 'host' => $model->datastore()->exists() || $model->network()->exists(), + default => false, + }; + + if ($childCheck) { + return response()->json([ + 'error' => true, + 'message' => 'Không thể xoá vì vẫn còn dữ liệu liên kết bên dưới.', + ], 422); + } + + $model->delete(); + + return response()->json(['error' => false, 'message' => 'Đã xoá thành công.']); + } + public function tree(Request $request): JsonResponse { - $vcenters = vCenter::with(['datacenters'])->get(); - $allHosts = ModuleHost::with(['datastore', 'network'])->get()->groupBy('module_datacenter_id'); + $vcenters = vCenter::with(['datacenters'])->get(); + $allHosts = ModuleHost::with(['datastore', 'network'])->get()->groupBy('module_datacenter_id'); $allFolders = ModuleFolder::all()->groupBy('module_datacenter_id'); + // VMs with a host assignment — grouped by host DB id + $allVMs = VirtualMachines::whereNotNull('host_id')->get()->groupBy('host_id'); + // VMs without a host assignment (synced before host_id was tracked) — grouped by datacenter DB id + $orphanVMs = VirtualMachines::whereNull('host_id')->get()->groupBy('datacenter_id'); - $tree = $vcenters->map(function ($vc) use ($allHosts, $allFolders) { + $tree = $vcenters->map(function ($vc) use ($allHosts, $allFolders, $allVMs, $orphanVMs) { $dcGroups = ($vc->datacenters ?? collect())->groupBy('datacenter_id'); - $datacenters = $dcGroups->map(function ($dcGroup) use ($allHosts, $allFolders) { + $datacenters = $dcGroups->map(function ($dcGroup) use ($allHosts, $allFolders, $allVMs, $orphanVMs) { $dc = $dcGroup->first(); $dcIds = $dcGroup->pluck('id'); $uniqueHosts = $dcIds->flatMap(fn($id) => $allHosts->get($id) ?? collect()) ->unique('host_id')->values(); + // Networks — deduplicated at datacenter level $dcNetworks = $uniqueHosts ->flatMap(fn($host) => $host->network ?? collect()) ->unique('network') @@ -32,10 +170,36 @@ class VCenterController extends Controller 'id' => 'network-' . $net->id, 'name' => $net->name, 'type' => 'network', - 'meta' => ['db_id' => $net->id, 'network_id' => $net->network, 'net_type' => $net->type], + 'meta' => [ + 'db_id' => $net->id, + 'network' => $net->network, + 'net_type' => $net->type, + 'module_host_id' => $net->module_host_id, + ], ]); - $dcHosts = $uniqueHosts->map(function ($host) { + // Hosts — VMs placed under their host, then Datastores + $dcHosts = $uniqueHosts->map(function ($host) use ($allVMs) { + $hostVMs = ($allVMs->get($host->id) ?? collect()) + ->map(fn($vm) => [ + 'id' => 'virtual_machine-' . $vm->id, + 'name' => $vm->name, + 'type' => 'virtual_machine', + 'meta' => [ + 'db_id' => $vm->id, + 'uuid' => $vm->uuid, + 'vm_id' => $vm->vm_id, + 'power_state' => $vm->power_state, + 'folder' => $vm->folder, + 'cpu' => $vm->cpu, + 'ram_mb' => $vm->ram_mb, + 'is_template' => $vm->is_template, + 'is_system' => $vm->is_system, + 'last_synced_at' => $vm->last_synced_at, + ], + 'children' => [], + ]); + $datastores = ($host->datastore ?? collect()) ->unique('datastore') ->map(fn($ds) => [ @@ -43,11 +207,13 @@ class VCenterController extends Controller 'name' => $ds->name, 'type' => 'datastore', 'meta' => [ - 'db_id' => $ds->id, - 'datastore_id' => $ds->datastore, - 'store_type' => $ds->type, - 'space_gb' => $ds->space ? round($ds->space / 1024, 1) : null, - 'status' => $ds->status, + 'db_id' => $ds->id, + 'datastore' => $ds->datastore, + 'store_type' => $ds->type, + 'space' => $ds->space, + 'space_gb' => $ds->space ? round($ds->space / 1024, 1) : null, + 'status' => $ds->status, + 'module_host_id' => $ds->module_host_id, ], ]); @@ -55,11 +221,21 @@ class VCenterController extends Controller 'id' => 'host-' . $host->id, 'name' => $host->name, 'type' => 'host', - 'meta' => ['db_id' => $host->id, 'host_id' => $host->host_id, 'console' => $host->console], - 'children' => array_values($datastores->toArray()), + 'meta' => [ + 'db_id' => $host->id, + 'host_id' => $host->host_id, + 'template' => $host->template, + 'console' => $host->console, + 'iso_path' => $host->iso_path, + 'module_datacenter_id' => $host->module_datacenter_id, + 'vm_count' => $hostVMs->count(), + ], + // VMs first, then Datastores — correct vSphere order + 'children' => array_values($hostVMs->concat($datastores)->toArray()), ]; }); + // Folders — datacenter level $dcFolders = $dcIds ->flatMap(fn($id) => $allFolders->get($id) ?? collect()) ->unique('folder') @@ -67,15 +243,48 @@ class VCenterController extends Controller 'id' => 'folder-' . $f->id, 'name' => $f->name, 'type' => 'folder', - 'meta' => ['db_id' => $f->id, 'folder_id' => $f->folder, 'folder_type' => $f->type], + 'meta' => [ + 'db_id' => $f->id, + 'folder' => $f->folder, + 'folder_type' => $f->type, + 'module_datacenter_id' => $f->module_datacenter_id, + ], + ]); + + // Orphan VMs — no host_id, grouped directly under datacenter + $dcOrphanVMs = $dcIds + ->flatMap(fn($id) => $orphanVMs->get($id) ?? collect()) + ->unique('id') + ->map(fn($vm) => [ + 'id' => 'virtual_machine-' . $vm->id, + 'name' => $vm->name, + 'type' => 'virtual_machine', + 'meta' => [ + 'db_id' => $vm->id, + 'uuid' => $vm->uuid, + 'vm_id' => $vm->vm_id, + 'power_state' => $vm->power_state, + 'folder' => $vm->folder, + 'cpu' => $vm->cpu, + 'ram_mb' => $vm->ram_mb, + 'is_template' => $vm->is_template, + 'is_system' => $vm->is_system, + 'last_synced_at' => $vm->last_synced_at, + ], + 'children' => [], ]); return [ 'id' => 'datacenter-' . $dc->id, 'name' => $dc->name, 'type' => 'datacenter', - 'meta' => ['db_id' => $dc->id, 'datacenter_id' => $dc->datacenter_id], - 'children' => array_values($dcFolders->concat($dcNetworks)->concat($dcHosts)->toArray()), + 'meta' => [ + 'db_id' => $dc->id, + 'datacenter_id' => $dc->datacenter_id, + 'vcenter_id' => $dc->vcenter_id, + ], + // vSphere order: Hosts → Networks → Folders → Orphan VMs + 'children' => array_values($dcHosts->concat($dcNetworks)->concat($dcFolders)->concat($dcOrphanVMs)->toArray()), ]; })->values(); @@ -90,4 +299,18 @@ class VCenterController extends Controller return response()->json(['error' => false, 'data' => $tree->values()]); } + + private function resolveModel(string $type, int $id) + { + return match ($type) { + 'vcenter' => vCenter::findOrFail($id), + 'datacenter' => ModuleDatacenter::findOrFail($id), + 'host' => ModuleHost::findOrFail($id), + 'datastore' => ModuleDatastore::findOrFail($id), + 'network' => ModuleNetwork::findOrFail($id), + 'folder' => ModuleFolder::findOrFail($id), + 'virtual_machine' => VirtualMachines::findOrFail($id), + default => abort(404, 'Unknown node type'), + }; + } } diff --git a/app/Http/Resources/Api/Admin/ProductResource.php b/app/Http/Resources/Api/Admin/ProductResource.php index 3a48a70..a14f4b2 100644 --- a/app/Http/Resources/Api/Admin/ProductResource.php +++ b/app/Http/Resources/Api/Admin/ProductResource.php @@ -17,6 +17,7 @@ class ProductResource extends JsonResource 'id' => $this->id, 'uuid' => $this->uuid, 'name' => $this->name, + 'slug' => $this->slug, 'product_type' => $this->product_type, 'category' => $this->whenLoaded('category', fn() => $this->category?->name), 'cpu' => $this->cpu, diff --git a/app/Http/Services/vCenter.php b/app/Http/Services/vCenter.php index aef52cb..474b003 100644 --- a/app/Http/Services/vCenter.php +++ b/app/Http/Services/vCenter.php @@ -148,7 +148,6 @@ class vCenter ->get("https://$this->apiUri/rest/vcenter/folder", [ 'filter.datacenters.1' => $datacenter, 'filter.type' => "VIRTUAL_MACHINE", - 'filter.names' => "vm" ])->json(); if (!isset($response['type'])) { return $response['value']; diff --git a/app/Jobs/VirtualMachine/Sync/SyncDatacenter.php b/app/Jobs/VirtualMachine/Sync/SyncDatacenter.php index 1641112..96d127a 100644 --- a/app/Jobs/VirtualMachine/Sync/SyncDatacenter.php +++ b/app/Jobs/VirtualMachine/Sync/SyncDatacenter.php @@ -91,14 +91,10 @@ class SyncDatacenter implements ShouldQueue continue; // skip malformed items } - $exists = ModuleDatacenter::where('datacenter_id', $_item['datacenter'])->first(); - if (!$exists) { - ModuleDatacenter::create([ - 'name' => $_item['name'], - 'datacenter_id' => $_item['datacenter'], - 'vcenter_id' => $item->id - ]); - } + ModuleDatacenter::updateOrCreate( + ['datacenter_id' => $_item['datacenter']], + ['name' => $_item['name'], 'vcenter_id' => $item->id] + ); } } // Notify success via Telegram diff --git a/app/Jobs/VirtualMachine/Sync/SyncFolder.php b/app/Jobs/VirtualMachine/Sync/SyncFolder.php index 8401dab..76b8e73 100644 --- a/app/Jobs/VirtualMachine/Sync/SyncFolder.php +++ b/app/Jobs/VirtualMachine/Sync/SyncFolder.php @@ -63,22 +63,26 @@ class SyncFolder implements ShouldQueue vCenter::where('id', $item->id)->update(['token' => $token]); $this->vCenter->token = $token; } - } - $folder = $this->vCenter->getAllFolder($_item['datacenter_id']); - - if ($folder) { - foreach ($folder as $_subItem) { - if (!ModuleFolder::where('folder', $_subItem['folder'])->first()) { - ModuleFolder::create([ - 'name' => $_subItem['name'], - 'folder' => $_subItem['folder'], - 'type' => $_subItem['type'], - 'module_datacenter_id' => $_item['id'] - ]); - } - } + $folder = $this->vCenter->getAllFolder($_item['datacenter_id']); } + if (!is_array($folder)) { + continue; + } + + $syncedFolderIds = []; + foreach ($folder as $_subItem) { + $record = ModuleFolder::updateOrCreate( + ['folder' => $_subItem['folder'], 'module_datacenter_id' => $_item['id']], + ['name' => $_subItem['name'], 'type' => $_subItem['type']] + ); + $syncedFolderIds[] = $record->id; + } + + // Remove stale folders no longer in vCenter + ModuleFolder::where('module_datacenter_id', $_item['id']) + ->whereNotIn('id', $syncedFolderIds) + ->delete(); } } diff --git a/app/Jobs/VirtualMachine/Sync/SyncHost.php b/app/Jobs/VirtualMachine/Sync/SyncHost.php index ad47e24..0e65ab2 100644 --- a/app/Jobs/VirtualMachine/Sync/SyncHost.php +++ b/app/Jobs/VirtualMachine/Sync/SyncHost.php @@ -77,17 +77,15 @@ class SyncHost implements ShouldQueue // Use TemplateService $template = $templateService->getAllTemplate(); - if ($host) { - foreach ($host as $_subItem) { - if (!ModuleHost::where('host_id', $_subItem['host'])->exists()) { - ModuleHost::create([ - 'name' => $_subItem['name'], - 'host_id' => $_subItem['host'], - 'module_datacenter_id' => $_item['id'], - 'template' => $template[0] ?? null, - ]); - } - } + foreach ($host as $_subItem) { + ModuleHost::updateOrCreate( + ['host_id' => $_subItem['host']], + [ + 'name' => $_subItem['name'], + 'module_datacenter_id' => $_item['id'], + 'template' => $template[0] ?? null, + ] + ); } } } diff --git a/app/Models/vCenter.php b/app/Models/vCenter.php index d65d035..6b101f7 100644 --- a/app/Models/vCenter.php +++ b/app/Models/vCenter.php @@ -18,6 +18,8 @@ class vCenter extends Model 'token', 'status' ]; + + protected $casts = []; public function datacenters() { return $this->hasMany(\App\Models\ModuleDatacenter::class, 'vcenter_id'); diff --git a/database/migrations/2026_03_02_111537_update_column_invoice_table.php b/database/migrations/2026_03_02_111537_update_column_invoice_table.php index cc26cea..2673222 100644 --- a/database/migrations/2026_03_02_111537_update_column_invoice_table.php +++ b/database/migrations/2026_03_02_111537_update_column_invoice_table.php @@ -12,8 +12,7 @@ return new class extends Migration { public function up(): void { Schema::table('invoice', function (Blueprint $table) { - // - $table->enum('reminder_step', [InvoiceStep::STEP_1->value, InvoiceStep::STEP_2->value, InvoiceStep::STEP_3->value, InvoiceStep::STEP_4->value, InvoiceStep::STEP_5->value])->default(InvoiceStep::STEP_1->value)->after('auto_renew'); + }); } diff --git a/routes/api.php b/routes/api.php index cdb52de..b3d4c85 100644 --- a/routes/api.php +++ b/routes/api.php @@ -72,6 +72,11 @@ Route::prefix('v1') // vCenter Route::prefix('vcenter')->group(function () { Route::get('tree', [VCenterController::class, 'tree']); + Route::get('console-ticket', [VCenterController::class, 'consoleTicket']); + Route::post('/', [VCenterController::class, 'store']); + Route::post('sync', [VCenterController::class, 'sync']); + Route::put('node/{type}/{id}', [VCenterController::class, 'updateNode']); + Route::delete('node/{type}/{id}', [VCenterController::class, 'destroyNode']); }); }); });