feat: VM metrics via portal-monitor integration

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
baodev0
2026-10-17 13:04:35 +07:00
co-authored by Claude Sonnet 5.5
parent 0b08689ea4
commit 9ed77a85fd
15 changed files with 546 additions and 43 deletions
+6 -2
View File
@@ -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
WMKS_HOST=10.10.10.120
@@ -0,0 +1,48 @@
<?php
namespace App\Http\Controllers\Api\Internal;
use App\Http\Controllers\Controller;
use App\Http\Facade\SignRSA;
use App\Models\vCenter;
use Illuminate\Http\JsonResponse;
class PortalMonitorVCenterController extends Controller
{
public function __invoke(): JsonResponse
{
$vcenter = vCenter::query()
->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');
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Api\Service\Concerns;
use App\Models\VirtualMachines;
use App\Services\VmMetricsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use RuntimeException;
trait ManagesVmMetrics
{
public function metrics(Request $request, VmMetricsService $metricsService): JsonResponse
{
$productId = $request->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]);
}
}
@@ -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) {}
// -------------------------------------------------------------------------
@@ -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,
]);
}
+1
View File
@@ -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,
];
}
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class AuthenticatePortalMonitor
{
public function handle(Request $request, Closure $next)
{
$expected = config('portal-monitor.config_api_key');
$provided = $request->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);
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace App\Services;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class PortalMonitorClient
{
public function vmMetrics(string $vmwareId): array
{
return $this->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;
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Services;
class VmMetricsService
{
public function __construct(private readonly PortalMonitorClient $portalMonitor)
{
}
public function get(string $vmwareId, ?int $memoryTotalMb = null, ?int $cpuCores = null): array
{
$stats = $this->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),
];
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
return [
'url' => 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'),
];
+4
View File
@@ -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(
[
+1
View File
@@ -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');
@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Admin;
use App\Models\ModuleDatacenter;
use App\Models\User;
use App\Models\VirtualMachines;
use App\Models\vCenter;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class VCenterManagerVmStatsTest extends TestCase
{
use RefreshDatabase;
private User $admin;
protected function setUp(): void
{
parent::setUp();
Role::firstOrCreate(['name' => '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();
}
}
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Internal;
use App\Http\Facade\SignRSA;
use App\Models\vCenter;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PortalMonitorVCenterTest extends TestCase
{
use RefreshDatabase;
public function test_rejects_requests_without_service_key(): void
{
config()->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',
]);
}
}
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Service;
use App\Models\Customer;
use App\Models\ModuleVmware;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class VmMetricsTest extends TestCase
{
use RefreshDatabase;
public function test_customer_can_read_metrics_for_owned_vm(): void
{
$customer = Customer::factory()->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();
}
}