diff --git a/.env.example b/.env.example index 62e1ebc..6fa2783 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,11 @@ RSA_PREVIOUS_PUBLIC= #Config VMware template default credential VMWARE_TEMPLATE_PASSWORD= +PORTAL_MONITOR_URL=http://portal-monitor-api:8080 +PORTAL_MONITOR_API_KEY= +PORTAL_MONITOR_TIMEOUT=5 +PORTAL_MONITOR_CONFIG_API_KEY= + #Config vCenter/ESXi TLS + jump host SSH verification (see config/vmware.php) VCENTER_TLS_VERIFY=true VCENTER_CA_BUNDLE= @@ -36,7 +41,6 @@ VCENTER_SSH_KNOWN_HOSTS= PLESK_TLS_VERIFY=true PLESK_CA_BUNDLE= - BROADCAST_DRIVER=pusher CACHE_DRIVER=redis FILESYSTEM_DISK=public @@ -88,4 +92,4 @@ FRONTEND_URL=https://portal.test CLIENT_API_ACCESS_KEY= CLIENT_API_ACCESS_SECRET= -WMKS_HOST=10.10.10.120 \ No newline at end of file +WMKS_HOST=10.10.10.120 diff --git a/app/Http/Controllers/Api/Internal/PortalMonitorVCenterController.php b/app/Http/Controllers/Api/Internal/PortalMonitorVCenterController.php new file mode 100644 index 0000000..7b038e2 --- /dev/null +++ b/app/Http/Controllers/Api/Internal/PortalMonitorVCenterController.php @@ -0,0 +1,48 @@ +where('status', 1) + ->orderBy('id') + ->first(); + + if (!$vcenter) { + return response()->json([ + 'error' => true, + 'message' => 'Active vCenter configuration is unavailable', + ], 503); + } + + $username = trim((string) $vcenter->user); + $password = SignRSA::DecryptFunc($vcenter->password); + $host = trim((string) $vcenter->host); + $port = 443; + + if ($host === '' || $port < 1 || $username === '' || $password === '') { + return response()->json([ + 'error' => true, + 'message' => 'Active vCenter configuration is incomplete', + ], 503); + } + + return response()->json([ + 'success' => true, + 'data' => [ + 'host' => $host, + 'port' => $port, + 'username' => $username, + 'password' => $password, + ], + ])->header('Cache-Control', 'no-store, private'); + } +} diff --git a/app/Http/Controllers/Api/Service/Concerns/ManagesVmMetrics.php b/app/Http/Controllers/Api/Service/Concerns/ManagesVmMetrics.php new file mode 100644 index 0000000..58c3ba8 --- /dev/null +++ b/app/Http/Controllers/Api/Service/Concerns/ManagesVmMetrics.php @@ -0,0 +1,41 @@ +input('product'); + if (!$productId) { + return response()->json(['success' => false, 'message' => 'Thiếu product id.'], 422); + } + + $vm = $this->resolveVmById($productId, $request, requireActiveService: false); + if ($vm instanceof JsonResponse) { + return $vm; + } + + try { + $inventory = VirtualMachines::where('vm_id', $vm->vmware_id)->first(['ram_mb', 'cpu']); + $metrics = $metricsService->get((string) $vm->vmware_id, $inventory?->ram_mb, $inventory?->cpu); + } catch (RuntimeException $exception) { + $status = $exception->getCode() === 404 ? 404 : 502; + + return response()->json([ + 'success' => false, + 'message' => $status === 404 + ? 'Chưa có dữ liệu giám sát cho VM.' + : 'Không thể tải dữ liệu giám sát VM.', + ], $status); + } + + return response()->json(['success' => true, 'data' => $metrics]); + } +} diff --git a/app/Http/Controllers/Api/Service/VmController.php b/app/Http/Controllers/Api/Service/VmController.php index fc7aeba..9f1c333 100644 --- a/app/Http/Controllers/Api/Service/VmController.php +++ b/app/Http/Controllers/Api/Service/VmController.php @@ -12,6 +12,7 @@ use Illuminate\Support\Facades\Cache; use App\Http\Controllers\Api\Service\Concerns\ManagesVmPower; use App\Http\Controllers\Api\Service\Concerns\ManagesVmNetwork; use App\Http\Controllers\Api\Service\Concerns\ManagesVmAccess; +use App\Http\Controllers\Api\Service\Concerns\ManagesVmMetrics; class VmController extends Controller { @@ -19,6 +20,7 @@ class VmController extends Controller use ManagesVmPower; use ManagesVmNetwork; use ManagesVmAccess; + use ManagesVmMetrics; public function __construct(protected ActivityLogger $activityLogger) {} // ------------------------------------------------------------------------- diff --git a/app/Http/Controllers/admin/VCenterManagerController.php b/app/Http/Controllers/admin/VCenterManagerController.php index 55749e2..48a31c6 100644 --- a/app/Http/Controllers/admin/VCenterManagerController.php +++ b/app/Http/Controllers/admin/VCenterManagerController.php @@ -6,12 +6,18 @@ use App\Http\Controllers\Controller; use App\Mail\VmSuspendedMail; use App\Models\ModuleVmware; use App\Models\VirtualMachines; +use App\Services\VmMetricsService; use Illuminate\Http\Request; use App\Support\TlsHttpClientFactory; use Illuminate\Support\Facades\Mail; +use RuntimeException; class VCenterManagerController extends Controller { + public function __construct(private readonly VmMetricsService $vmMetrics) + { + } + public function powerState(Request $request) { $vmId = (int) $request->input('vm_id'); @@ -264,54 +270,22 @@ class VCenterManagerController extends Controller return response()->json(['error' => true, 'message' => 'VM is not powered on'], 400); } - $vcenter = $vm->datacenter?->vCenter ?? $vm->host?->datacenter?->vCenter; + try { + $stats = $this->vmMetrics->get($vmwareId, $vm->ram_mb, $vm->cpu); + } catch (RuntimeException $exception) { + $status = $exception->getCode() === 404 ? 404 : 502; - if (!$vcenter) { - return response()->json(['error' => true, 'message' => 'vCenter not found'], 400); - } - - $vCenterService = new \App\Http\Services\vCenter(); - - if (!$vCenterService->loginServer($vcenter)) { - return response()->json(['error' => true, 'message' => 'Cannot login to vCenter'], 400); - } - - $result = $vCenterService->GetVMStats($vmwareId); - - if ($result['error'] ?? true) { return response()->json([ 'error' => true, - 'message' => $result['message'] ?? 'Failed to fetch VM stats', - ], 400); + 'message' => $status === 404 + ? 'VM metrics are unavailable' + : 'Unable to fetch VM metrics', + ], $status); } - $stats = $result['data']; - $cpuPercent = $stats['cpu']['used_percent'] ?? 0; - $memPercent = $stats['memory']['usage_percent'] ?? 0; - - // ponytail: overall_status is a CPU/RAM usage threshold heuristic, not real vCenter - // health/alarms — that needs the SOAP EventManager/AlarmManager, not wired up in this - // codebase (REST Automation API only). Upgrade path: add a SOAP client and read - // triggeredAlarmState off the VM's ManagedEntity if per-alarm detail is ever needed. - $overallStatus = match (true) { - $cpuPercent >= 90 || $memPercent >= 90 => 'red', - $cpuPercent >= 75 || $memPercent >= 75 => 'yellow', - default => 'green', - }; - return response()->json([ 'error' => false, - 'data' => [ - 'overall_status' => $overallStatus, - 'cpu_usage_percent' => round($cpuPercent, 1), - 'mem_usage_percent' => round($memPercent, 1), - 'mem_usage_mb' => $stats['memory']['used_MiB'] ?? 0, - 'mem_total_mb' => $stats['memory']['size_MiB'] ?? 0, - // ponytail: no uptime source wired up (would need SOAP PerfManager - // sys.uptime.latest); frontend renders "—" when null. - 'uptime_seconds' => null, - 'alarms' => [], - ], + 'data' => $stats, ]); } diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 3576cc4..d01dcc2 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -87,6 +87,7 @@ class Kernel extends HttpKernel 'client.access-token' => \App\Http\Middleware\ClientAccessTokenCheck::class, 'perm' => \App\Http\Middleware\CheckPermission::class, 'restrict.local' => \App\Http\Middleware\RestrictToLocalEnvironment::class, + 'portal-monitor.service' => \App\Http\Middleware\AuthenticatePortalMonitor::class, 'turnstile' => \App\Http\Middleware\VerifyTurnstile::class, ]; } diff --git a/app/Http/Middleware/AuthenticatePortalMonitor.php b/app/Http/Middleware/AuthenticatePortalMonitor.php new file mode 100644 index 0000000..e47f9d0 --- /dev/null +++ b/app/Http/Middleware/AuthenticatePortalMonitor.php @@ -0,0 +1,27 @@ +header('X-Portal-Monitor-Key'); + + if (!is_string($expected) + || $expected === '' + || !is_string($provided) + || !hash_equals($expected, $provided)) { + return response()->json([ + 'error' => true, + 'message' => 'Unauthorized', + ], 401); + } + + return $next($request); + } +} diff --git a/app/Services/PortalMonitorClient.php b/app/Services/PortalMonitorClient.php new file mode 100644 index 0000000..434a100 --- /dev/null +++ b/app/Services/PortalMonitorClient.php @@ -0,0 +1,55 @@ +get($vmwareId, 'metrics', 'VM metrics are unavailable'); + } + + public function vmStorage(string $vmwareId): array + { + return $this->get($vmwareId, 'storage', 'VM storage is unavailable'); + } + + private function get(string $vmwareId, string $resource, string $notFoundMessage): array + { + $url = rtrim((string) config('portal-monitor.url'), '/'); + $apiKey = (string) config('portal-monitor.api_key'); + + if ($url === '' || $apiKey === '') { + throw new RuntimeException('Portal Monitor is not configured'); + } + + try { + $response = Http::acceptJson() + ->withHeaders(['X-API-Key' => $apiKey]) + ->timeout((int) config('portal-monitor.timeout', 5)) + ->get($url . '/api/v1/vsphere/vms/' . rawurlencode($vmwareId) . '/' . $resource); + } catch (ConnectionException) { + throw new RuntimeException('Portal Monitor is unavailable'); + } + + if ($response->status() === 404) { + throw new RuntimeException($notFoundMessage, 404); + } + + if (!$response->successful()) { + throw new RuntimeException('Portal Monitor request failed'); + } + + $data = $response->json('data'); + + if (!is_array($data)) { + throw new RuntimeException('Portal Monitor returned an invalid response'); + } + + return $data; + } +} diff --git a/app/Services/VmMetricsService.php b/app/Services/VmMetricsService.php new file mode 100644 index 0000000..f1d8653 --- /dev/null +++ b/app/Services/VmMetricsService.php @@ -0,0 +1,41 @@ +portalMonitor->vmMetrics($vmwareId); + $storage = $this->portalMonitor->vmStorage($vmwareId); + $provisionedDiskBytes = collect($storage['disks'] ?? [])->sum( + fn ($disk) => is_array($disk) ? (int) ($disk['capacity_bytes'] ?? 0) : 0 + ); + $diskSummary = is_array($storage['summary'] ?? null) ? $storage['summary'] : []; + + return [ + 'cpu_usage_percent' => $stats['cpu_usage_percent'] ?? null, + 'cpu_cores' => $cpuCores, + 'cpu_mhz' => $stats['cpu_mhz'] ?? null, + 'cpu_ready_ms' => $stats['cpu_ready_ms'] ?? null, + 'mem_usage_percent' => $stats['guest_memory_usage_percent'] ?? null, + 'mem_usage_mb' => $stats['memory_active_mb'] ?? null, + 'mem_consumed_mb' => $stats['memory_consumed_mb'] ?? null, + 'mem_total_mb' => $memoryTotalMb, + 'disk_read_bps' => $stats['disk_read_bps'] ?? null, + 'disk_write_bps' => $stats['disk_write_bps'] ?? null, + 'disk_latency_ms' => $stats['disk_latency_ms'] ?? null, + 'disk_total_bytes' => $diskSummary['total_bytes'] ?? ($provisionedDiskBytes ?: null), + 'disk_used_bytes' => $diskSummary['used_bytes'] ?? null, + 'disk_usage_percent' => $diskSummary['usage_percent'] ?? null, + 'network_rx_bps' => $stats['network_rx_bps'] ?? null, + 'network_tx_bps' => $stats['network_tx_bps'] ?? null, + 'timestamp' => $stats['timestamp'] ?? null, + 'stale' => (bool) ($stats['stale'] ?? false), + ]; + } +} diff --git a/config/portal-monitor.php b/config/portal-monitor.php new file mode 100644 index 0000000..83b7a7c --- /dev/null +++ b/config/portal-monitor.php @@ -0,0 +1,8 @@ + env('PORTAL_MONITOR_URL', 'http://portal-monitor-api:8080'), + 'api_key' => env('PORTAL_MONITOR_API_KEY'), + 'timeout' => (int) env('PORTAL_MONITOR_TIMEOUT', 5), + 'config_api_key' => env('PORTAL_MONITOR_CONFIG_API_KEY'), +]; diff --git a/routes/api.php b/routes/api.php index 46bc0d0..ecb087a 100644 --- a/routes/api.php +++ b/routes/api.php @@ -2,6 +2,7 @@ use App\Http\Controllers\Api\Admin\Auth\AuthController; use App\Http\Controllers\Api\Admin\Setting\SettingController as AdminSettingController; +use App\Http\Controllers\Api\Internal\PortalMonitorVCenterController; use Illuminate\Support\Facades\Route; /* @@ -40,6 +41,9 @@ Route::prefix('v1') Route::get('settings/public', [AdminSettingController::class, 'public']); }); +Route::get('v1/internal/portal-monitor/vcenter', PortalMonitorVCenterController::class) + ->middleware(['portal-monitor.service', 'throttle:30,1']); + Route::any('{any}', function () { return response()->json( [ diff --git a/routes/client.php b/routes/client.php index 16ea1cf..1260163 100644 --- a/routes/client.php +++ b/routes/client.php @@ -298,6 +298,7 @@ Route::prefix('v1/system') // VM power & network operations Route::controller(VmController::class)->group(function () { Route::get('vm-power-state', 'powerState')->name('vm.power-state'); + Route::get('vm-metrics', 'metrics')->name('vm.metrics'); Route::post('vm-power-on', 'powerOn')->name('vm.power-on'); Route::post('vm-power-off', 'powerOff')->name('vm.power-off'); Route::get('vm-ip-options', 'ipOptions')->name('vm.ip-options'); diff --git a/tests/Feature/Api/Admin/VCenterManagerVmStatsTest.php b/tests/Feature/Api/Admin/VCenterManagerVmStatsTest.php new file mode 100644 index 0000000..4df9c02 --- /dev/null +++ b/tests/Feature/Api/Admin/VCenterManagerVmStatsTest.php @@ -0,0 +1,162 @@ + 'Full Administrator', 'guard_name' => 'admin']); + $this->admin = User::factory()->create(); + $this->admin->assignRole('Full Administrator'); + + config()->set('portal-monitor.url', 'http://portal-monitor-api:8080'); + config()->set('portal-monitor.api_key', 'test-api-key'); + config()->set('portal-monitor.timeout', 3); + } + + private function datacenterId(): int + { + $vcenter = vCenter::create(['host' => 'vcenter.test']); + + return ModuleDatacenter::create([ + 'name' => 'DC-01', + 'datacenter_id' => 'datacenter-1', + 'vcenter_id' => $vcenter->id, + ])->id; + } + + public function test_stats_resolves_database_id_and_returns_portal_monitor_metrics(): void + { + $vm = VirtualMachines::create([ + 'uuid' => '8d804daf-c856-4a99-b749-fb97060b84f0', + 'vm_id' => 'vm-217209', + 'name' => 'web-01', + 'power_state' => 'POWERED_ON', + 'ram_mb' => 8192, + 'datacenter_id' => $this->datacenterId(), + ]); + + Http::fake([ + 'http://portal-monitor-api:8080/api/v1/vsphere/vms/vm-217209/metrics' => Http::response([ + 'success' => true, + 'data' => [ + 'cpu_usage_percent' => 12.5, + 'cpu_mhz' => 420.25, + 'cpu_ready_ms' => 1.5, + 'memory_active_mb' => 2048.5, + 'memory_consumed_mb' => 3072.5, + 'guest_memory_usage_percent' => 25.1, + 'disk_read_bps' => 1024, + 'disk_write_bps' => 2048, + 'disk_latency_ms' => 4.2, + 'network_rx_bps' => 4096, + 'network_tx_bps' => 8192, + 'timestamp' => '2026-10-17T08:00:00Z', + 'stale' => true, + ], + ]), + 'http://portal-monitor-api:8080/api/v1/vsphere/vms/vm-217209/storage' => Http::response([ + 'success' => true, + 'data' => [ + 'summary' => [ + 'total_bytes' => 161061273600, + 'used_bytes' => 107374182400, + 'free_bytes' => 53687091200, + 'usage_percent' => 66.6667, + ], + 'disks' => [ + ['capacity_bytes' => 53687091200], + ['capacity_bytes' => 107374182400], + ], + ], + ]), + ]); + + $this->actingAs($this->admin, 'admin') + ->getJson('/api/v1/administrator/vcenter-manager/vm/stats?vm_id=' . $vm->id) + ->assertOk() + ->assertJsonPath('data.cpu_usage_percent', 12.5) + ->assertJsonPath('data.mem_usage_mb', 2048.5) + ->assertJsonPath('data.mem_consumed_mb', 3072.5) + ->assertJsonPath('data.mem_total_mb', 8192) + ->assertJsonPath('data.disk_read_bps', 1024) + ->assertJsonPath('data.disk_total_bytes', 161061273600) + ->assertJsonPath('data.disk_used_bytes', 107374182400) + ->assertJsonPath('data.disk_usage_percent', 66.6667) + ->assertJsonPath('data.timestamp', '2026-10-17T08:00:00Z') + ->assertJsonPath('data.stale', true); + + Http::assertSent(fn (Request $request) => + $request->url() === 'http://portal-monitor-api:8080/api/v1/vsphere/vms/vm-217209/metrics' + && $request->hasHeader('X-API-Key', 'test-api-key') + ); + Http::assertSent(fn (Request $request) => + $request->url() === 'http://portal-monitor-api:8080/api/v1/vsphere/vms/vm-217209/storage' + && $request->hasHeader('X-API-Key', 'test-api-key') + ); + } + + public function test_stats_returns_sanitized_gateway_error(): void + { + $vm = VirtualMachines::create([ + 'uuid' => '796ae79b-b8b9-4638-9be2-d1b3b39325f2', + 'vm_id' => 'vm-217210', + 'name' => 'web-02', + 'power_state' => 'POWERED_ON', + 'datacenter_id' => $this->datacenterId(), + ]); + + Http::fake([ + '*' => Http::response([ + 'error' => ['message' => 'upstream-secret-detail'], + ], 500), + ]); + + $this->actingAs($this->admin, 'admin') + ->getJson('/api/v1/administrator/vcenter-manager/vm/stats?vm_id=' . $vm->id) + ->assertStatus(502) + ->assertExactJson([ + 'error' => true, + 'message' => 'Unable to fetch VM metrics', + ]) + ->assertDontSee('upstream-secret-detail'); + } + + public function test_stats_does_not_call_monitor_for_powered_off_vm(): void + { + $vm = VirtualMachines::create([ + 'uuid' => 'f33f3f6c-dd0a-45f7-82f7-4c61a0d4e750', + 'vm_id' => 'vm-217211', + 'name' => 'web-03', + 'power_state' => 'POWERED_OFF', + 'datacenter_id' => $this->datacenterId(), + ]); + + Http::fake(); + + $this->actingAs($this->admin, 'admin') + ->getJson('/api/v1/administrator/vcenter-manager/vm/stats?vm_id=' . $vm->id) + ->assertStatus(400); + + Http::assertNothingSent(); + } +} diff --git a/tests/Feature/Api/Internal/PortalMonitorVCenterTest.php b/tests/Feature/Api/Internal/PortalMonitorVCenterTest.php new file mode 100644 index 0000000..63df925 --- /dev/null +++ b/tests/Feature/Api/Internal/PortalMonitorVCenterTest.php @@ -0,0 +1,58 @@ +set('portal-monitor.config_api_key', 'service-key'); + + $this->getJson('/api/v1/internal/portal-monitor/vcenter') + ->assertUnauthorized() + ->assertJsonMissingPath('data'); + } + + public function test_returns_active_vcenter_credentials_from_module_vcenter(): void + { + config()->set('portal-monitor.config_api_key', 'service-key'); + + vCenter::query()->create([ + 'host' => '10.0.0.20', + 'user' => 'monitor@example.local', + 'password' => SignRSA::EncryptFunc('secret-value'), + 'status' => 1, + ]); + + $this->withHeader('X-Portal-Monitor-Key', 'service-key') + ->getJson('/api/v1/internal/portal-monitor/vcenter') + ->assertOk() + ->assertHeader('Cache-Control', 'no-store, private') + ->assertJsonPath('data.host', '10.0.0.20') + ->assertJsonPath('data.port', 443) + ->assertJsonPath('data.username', 'monitor@example.local') + ->assertJsonPath('data.password', 'secret-value'); + } + + public function test_returns_sanitized_error_when_no_active_vcenter_exists(): void + { + config()->set('portal-monitor.config_api_key', 'service-key'); + + $this->withHeader('X-Portal-Monitor-Key', 'service-key') + ->getJson('/api/v1/internal/portal-monitor/vcenter') + ->assertStatus(503) + ->assertJson([ + 'error' => true, + 'message' => 'Active vCenter configuration is unavailable', + ]); + } +} diff --git a/tests/Feature/Api/Service/VmMetricsTest.php b/tests/Feature/Api/Service/VmMetricsTest.php new file mode 100644 index 0000000..336ed90 --- /dev/null +++ b/tests/Feature/Api/Service/VmMetricsTest.php @@ -0,0 +1,77 @@ +create(['status' => 1]); + $vm = ModuleVmware::create([ + 'customer_id' => $customer->id, + 'vmware_id' => 'vm-217208', + 'ram_mb' => 2048, + 'status' => 1, + ]); + + config()->set('portal-monitor.url', 'http://portal-monitor-api:8080'); + config()->set('portal-monitor.api_key', 'test-api-key'); + Http::fake([ + '*/api/v1/vsphere/vms/vm-217208/metrics' => Http::response([ + 'success' => true, + 'data' => ['cpu_usage_percent' => 15.5, 'timestamp' => '2026-10-17T04:00:00Z'], + ]), + '*/api/v1/vsphere/vms/vm-217208/storage' => Http::response([ + 'success' => true, + 'data' => [ + 'disks' => [['capacity_bytes' => 10737418240]], + 'summary' => [ + 'total_bytes' => 10420744192, + 'used_bytes' => 5787086848, + 'usage_percent' => 55.5343, + ], + ], + ]), + ]); + + $this->actingAs($customer, 'client') + ->withHeaders(['x-access-token' => $this->buildClientAccessToken()]) + ->getJson('/v1/system/vm-metrics?product=' . $vm->vmware_id) + ->assertOk() + ->assertJsonPath('success', true) + ->assertJsonPath('data.cpu_usage_percent', 15.5) + ->assertJsonPath('data.mem_total_mb', 2048) + ->assertJsonPath('data.disk_used_bytes', 5787086848) + ->assertJsonPath('data.disk_total_bytes', 10420744192) + ->assertJsonPath('data.disk_usage_percent', 55.5343); + } + + public function test_customer_cannot_read_another_customers_vm_metrics(): void + { + $owner = Customer::factory()->create(['status' => 1]); + $attacker = Customer::factory()->create(['status' => 1]); + $vm = ModuleVmware::create([ + 'customer_id' => $owner->id, + 'vmware_id' => 'vm-217209', + 'status' => 1, + ]); + Http::fake(); + + $this->actingAs($attacker, 'client') + ->withHeaders(['x-access-token' => $this->buildClientAccessToken()]) + ->getJson('/v1/system/vm-metrics?product=' . $vm->vmware_id) + ->assertNotFound(); + + Http::assertNothingSent(); + } +}