Files
portal-api/app/Http/Controllers/Api/Service/VmController.php
T
2026-09-27 11:42:40 +07:00

582 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Http\Controllers\Api\Service;
use App\Actions\VM\ChangePortVMAction;
use App\Actions\VM\PowerOffVMAction;
use App\Actions\VM\PowerOnVMAction;
use App\Http\Controllers\Api\Service\Concerns\ResolvesVm;
use App\Http\Controllers\Controller;
use App\Http\Services\vCenter;
use App\Jobs\VirtualMachine\ChangeIpVMJob;
use App\Jobs\VirtualMachine\ChangeVmPasswordJob;
use App\Models\IpPool;
use App\Models\ModuleVmware;
use App\Models\Os;
use App\Models\Service;
use App\Services\ActivityLogger;
use App\Services\VirtualMachine\VmIpReservationService;
use App\Services\VmProvisionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class VmController extends Controller
{
use ResolvesVm;
public function __construct(protected ActivityLogger $activityLogger) {}
// -------------------------------------------------------------------------
// Power ON
// -------------------------------------------------------------------------
/**
* POST /v1/system/vm-power-on
*
* Body: { "product": <ModuleVmware.id|vmware_id|service_uuid> }
*/
public function powerOn(Request $request, PowerOnVMAction $action): JsonResponse
{
$productId = $request->input('product');
if (!$productId) {
return response()->json(['success' => false, 'message' => 'Thiếu product id.'], 422);
}
$vm = $this->resolveVmById($productId, $request);
if ($vm instanceof JsonResponse) {
return $vm;
}
$lock = $this->acquireVmLock($vm);
if (!$lock) {
return $this->vmLocked();
}
try {
$result = $action->execute($vm);
} finally {
$lock->release();
}
$this->activityLogger->log(
event: 'power_on',
logName: 'VM',
description: "Customer {$request->user()->email} bật VM: {$vm->name} (ID: {$vm->vmware_id})",
subject: $vm,
);
return $this->vmResponse($result, 'Đã gửi lệnh bật VM.');
}
// -------------------------------------------------------------------------
// Power OFF
// -------------------------------------------------------------------------
/**
* POST /v1/system/vm-power-off
*
* Body: { "product": <ModuleVmware.id|vmware_id|service_uuid> }
*/
public function powerOff(Request $request, PowerOffVMAction $action): JsonResponse
{
$productId = $request->input('product');
if (!$productId) {
return response()->json(['success' => false, 'message' => 'Thiếu product id.'], 422);
}
$vm = $this->resolveVmById($productId, $request);
if ($vm instanceof JsonResponse) {
return $vm;
}
$lock = $this->acquireVmLock($vm);
if (!$lock) {
return $this->vmLocked();
}
try {
$result = $action->execute($vm);
} finally {
$lock->release();
}
$this->activityLogger->log(
event: 'power_off',
logName: 'VM',
description: "Customer {$request->user()->email} tắt VM: {$vm->name} (ID: {$vm->vmware_id})",
subject: $vm,
);
return $this->vmResponse($result, 'Đã gửi lệnh tắt VM.');
}
// -------------------------------------------------------------------------
// Change IP
// -------------------------------------------------------------------------
/**
* GET /v1/system/vm-ip-options
*
* Query: server_name string Service.server_name
* Lists free IP pool entries in the same network as the VM's current IP.
*/
public function ipOptions(Request $request): JsonResponse
{
$validated = $request->validate([
'server_name' => ['required', 'string', 'max:255'],
]);
$service = $this->findOwnedServiceByServerName($validated['server_name'], $request);
if ($service instanceof JsonResponse) {
return $service;
}
$vm = $service->orderItem?->order?->vmWare?->first();
if (!$vm) {
return response()->json(['success' => false, 'message' => 'Không tìm thấy VM cho dịch vụ này.'], 404);
}
$networkId = $vm->ipAddress?->module_network_id;
if (!$networkId) {
return response()->json(['success' => false, 'message' => 'Không xác định được mạng của VM.'], 422);
}
$ips = app(VmIpReservationService::class)->freeIpsInNetwork($networkId);
return response()->json([
'success' => true,
'data' => $ips->map(fn (IpPool $ip) => [
'ip_address' => $ip->ip_address,
'gateway' => $ip->gateway,
])->values(),
]);
}
/**
* POST /v1/system/vm-change-ip
*
* Body:
* server_name string Service.server_name (e.g. "xasdf.com.vnx")
* ip_address string New IP to assign — must be a free IP from the VM's network pool
*/
/**
* Dispatches ChangeIpVMJob — returns immediately, runs async.
*/
public function changeIp(Request $request, VmIpReservationService $ipReservation): JsonResponse
{
$validated = $request->validate([
'server_name' => ['required', 'string', 'max:255'],
'ip_address' => ['required', 'ip'],
'dns' => ['nullable', 'ip'],
'guest_user' => ['required', 'string', 'max:255'],
'guest_password' => ['required', 'string', 'max:255'],
]);
$service = $this->findOwnedServiceByServerName($validated['server_name'], $request);
if ($service instanceof JsonResponse) {
return $service;
}
$vm = $service->orderItem?->order?->vmWare?->first();
if (!$vm) {
return response()->json(['success' => false, 'message' => 'Không tìm thấy VM cho dịch vụ này.'], 404);
}
$networkId = $vm->ipAddress?->module_network_id;
if (!$networkId) {
return response()->json(['success' => false, 'message' => 'Không xác định được mạng của VM.'], 422);
}
$reservation = $ipReservation->reserveInNetwork($networkId, $validated['ip_address'], $service->id);
if (!$reservation) {
return response()->json(['success' => false, 'message' => 'IP không thuộc pool khả dụng hoặc đã được sử dụng.'], 422);
}
$oldIpPoolId = IpPool::where('module_network_id', $networkId)
->where('service_id', $service->id)
->where('status', 'assigned')
->where('id', '!=', $reservation['ip_pool_id'])
->value('id');
// Auto-detect OS type from the service's order item OS group
$osGroup = strtolower($service->orderItem?->os?->osGroup?->name ?? '');
$osType = (str_contains($osGroup, 'linux') || str_contains($osGroup, 'ubuntu') || str_contains($osGroup, 'centos'))
? 'linux'
: 'windows';
$dns = $validated['dns'] ?? '8.8.8.8';
// ponytail: don't release — lock expires after 5 min while the async job runs
$lock = $this->acquireVmLock($vm, 300);
if (!$lock) {
$ipReservation->releaseByPoolId($reservation['ip_pool_id']);
return $this->vmLocked();
}
ChangeIpVMJob::dispatch(
$vm->id,
$validated['ip_address'],
$reservation['gateway'],
$osType,
$dns,
$validated['guest_user'],
$validated['guest_password'],
$reservation['ip_pool_id'],
$oldIpPoolId,
);
$this->activityLogger->log(
event: 'change_ip',
logName: 'VM',
description: "Customer {$request->user()->email} yêu cầu đổi IP VM: {$vm->name} → {$validated['ip_address']}",
subject: $vm,
newValues: ['ip_address' => $validated['ip_address'], 'gateway' => $reservation['gateway'], 'os_type' => $osType],
);
return response()->json([
'success' => true,
'message' => 'Yêu cầu đổi IP đã được ghi nhận. Hệ thống sẽ xử lý trong vài phút.',
]);
}
/**
* Look up a Service by server_name, verify ownership and active status.
* Returns JsonResponse on error, Service on success.
*/
private function findOwnedServiceByServerName(string $serverName, Request $request): Service|JsonResponse
{
$customer = $request->user();
// VM chain: Service → orderItem → order → vmWare (same as ServiceClientDetailResource)
$service = Service::with([
'orderItem.order.vmWare.vCenter',
'orderItem.order.vmWare.ipAddress.host.datacenter.vCenter',
'orderItem.os.osGroup',
])
->where('server_name', $serverName)
->where('customer_id', $customer->id)
->first();
if (!$service) {
return response()->json(['success' => false, 'message' => 'Không tìm thấy dịch vụ.'], 404);
}
if ($service->status !== 'active') {
return response()->json(['success' => false, 'message' => 'Dịch vụ không ở trạng thái hoạt động, không thể thao tác.'], 403);
}
return $service;
}
// -------------------------------------------------------------------------
// Change Port (RDP)
// -------------------------------------------------------------------------
/**
* POST /v1/system/vm-change-port
*
* Body:
* product int|string ModuleVmware.id|vmware_id|service_uuid
* new_port int New RDP port (1024–65535)
*/
public function changePort(Request $request, ChangePortVMAction $action): JsonResponse
{
$validated = $request->validate([
'product' => ['required'],
'new_port' => ['required', 'integer', 'min:1024', 'max:65535'],
]);
$vm = $this->resolveVmById($validated['product'], $request);
if ($vm instanceof JsonResponse) {
return $vm;
}
$lock = $this->acquireVmLock($vm);
if (!$lock) {
return $this->vmLocked();
}
try {
$result = $action->execute($vm, (string) $validated['new_port']);
} finally {
$lock->release();
}
// changeRDPPortWindows returns array on success/failure or string on login fail
if ($result === 'Login_Again' || $result === 'Login_Fail') {
return response()->json([
'success' => false,
'message' => 'Không thể đăng nhập vào vCenter.',
], 400);
}
if (is_array($result)) {
$ok = isset($result['success'])
? (bool) $result['success']
: !($result['error'] ?? true);
if ($ok) {
$this->activityLogger->log(
event: 'change_port',
logName: 'VM',
description: "Customer {$request->user()->email} đổi cổng RDP VM: {$vm->name} → port {$validated['new_port']}",
subject: $vm,
newValues: ['new_port' => $validated['new_port']],
);
}
return response()->json([
'success' => $ok,
'message' => $ok ? 'Đổi cổng RDP thành công.' : ($result['message'] ?? 'Đổi cổng thất bại.'),
'data' => $result,
], $ok ? 200 : 400);
}
\Illuminate\Support\Facades\Log::error('changePort: unexpected vCenter response', [
'vm_id' => $vm->id,
'result' => $result,
]);
return response()->json([
'success' => false,
'message' => 'Phản hồi không hợp lệ từ vCenter.',
], 400);
}
// -------------------------------------------------------------------------
// Console Ticket
// -------------------------------------------------------------------------
public function consoleTicket(Request $request, vCenter $serviceVCenter): JsonResponse
{
$productId = $request->get('product');
if (!$productId) {
return response()->json(['error' => true, 'message' => 'Please choose product']);
}
$vm = $this->resolveVmById($productId, $request);
if ($vm instanceof JsonResponse) {
return $vm;
}
$result = $this->getConsoleTicket($serviceVCenter, $vm);
if ($result instanceof JsonResponse) {
return $result;
}
$this->activityLogger->log(
event: 'console_access',
logName: 'VM',
description: "Customer {$request->user()->email} truy cập console VM: {$vm->name} (ID: {$vm->vmware_id})",
subject: $vm,
newValues: ['product_id' => $productId, 'vm_name' => $vm->name],
);
return response()->json([
'error' => false,
'data' => $result['data'],
]);
}
// -------------------------------------------------------------------------
// Restart VM
// -------------------------------------------------------------------------
public function restart(Request $request, vCenter $serviceVCenter): JsonResponse
{
$productId = $request->input('product');
if (!$productId) {
return response()->json(['error' => true, 'message' => 'Thiếu product id'], 422);
}
$vm = $this->resolveVmById($productId, $request);
if ($vm instanceof JsonResponse) {
return $vm;
}
$lock = $this->acquireVmLock($vm);
if (!$lock) {
return $this->vmLocked();
}
try {
$result = $serviceVCenter->restartVM($vm, $vm->vmware_id);
} finally {
$lock->release();
}
if (is_string($result) || ($result['error'] ?? true)) {
return response()->json([
'error' => true,
'message' => is_string($result) ? $result : ($result['data'] ?? 'Không thể restart VM'),
], 400);
}
$this->activityLogger->log(
event: 'restart',
logName: 'VM',
description: "Customer {$request->user()->email} restart VM: {$vm->name} (ID: {$vm->vmware_id})",
subject: $vm,
);
return response()->json([
'error' => false,
'message' => 'Đang restart máy ảo vui lòng chờ!',
'data' => $result['data'] ?? 'success',
]);
}
// -------------------------------------------------------------------------
// Change Password
// -------------------------------------------------------------------------
/**
* POST /v1/system/vm-change-password
*
* Body: { product, current_password, new_password }
* Dispatches ChangeVmPasswordJob — returns immediately, runs async.
*/
public function changePassword(Request $request): JsonResponse
{
$validated = $request->validate([
'product' => ['required'],
'current_password' => ['required', 'string'],
'new_password' => ['required', 'string', 'min:8'],
]);
$vm = $this->resolveVmById($validated['product'], $request);
if ($vm instanceof JsonResponse) {
return $vm;
}
// ponytail: don't release — lock expires after 5 min while the async job runs
$lock = $this->acquireVmLock($vm, 300);
if (!$lock) {
return $this->vmLocked();
}
// Detect OS family from VM's os_id record
$os = $vm->os_id ? Os::with('osGroup')->find($vm->os_id) : null;
$osGroupName = strtolower($os?->osGroup?->name ?? '');
$osFamily = str_contains($osGroupName, 'windows')
? VmProvisionService::OS_WINDOWS
: VmProvisionService::OS_LINUX;
ChangeVmPasswordJob::dispatch(
$vm->id,
$vm->vmware_id,
$osFamily,
$validated['new_password'],
$validated['current_password'],
);
$this->activityLogger->log(
event: 'change_password',
logName: 'VM',
description: "Customer {$request->user()->email} yêu cầu đổi mật khẩu VM: {$vm->name}",
subject: $vm,
newValues: ['os_family' => $osFamily],
);
return response()->json([
'success' => true,
'message' => 'Yêu cầu đổi mật khẩu đã được ghi nhận. Hệ thống sẽ xử lý trong vài phút.',
]);
}
// -------------------------------------------------------------------------
// Power State
// -------------------------------------------------------------------------
/**
* GET /v1/system/vm-power-state?product={id|uuid|vmware_id}
*
* Calls vSphere REST API directly: GET /rest/vcenter/vm/{vmware_id}/power
* Returns: { success: true, data: { state: "POWERED_ON"|"POWERED_OFF"|"SUSPENDED", clean_power_off: bool } }
*/
public function powerState(Request $request, vCenter $serviceVCenter): 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;
}
$result = $serviceVCenter->getVmPowerState($vm, $vm->vmware_id);
if ($result === 'Login_Again') {
return response()->json(['success' => false, 'message' => 'Không thể đăng nhập vào vCenter.'], 400);
}
if (is_array($result) && ($result['error'] ?? true)) {
return response()->json([
'success' => false,
'message' => $result['data'] ?? 'Không thể lấy trạng thái VM.',
], 400);
}
return response()->json([
'success' => true,
'data' => [
'state' => $result['data']['state'] ?? null,
'clean_power_off' => $result['data']['clean_power_off'] ?? null,
],
]);
}
// -------------------------------------------------------------------------
// Shared helpers
// -------------------------------------------------------------------------
/** Try to acquire a per-VM operation lock. Returns null if already locked. */
private function acquireVmLock(ModuleVmware $vm, int $ttl = 60): ?\Illuminate\Contracts\Cache\Lock
{
$lock = Cache::lock("vm_op:{$vm->id}", $ttl);
return $lock->get() ? $lock : null;
}
private function vmLocked(): JsonResponse
{
return response()->json([
'success' => false,
'message' => 'VM đang có thao tác đang xử lý, vui lòng chờ rồi thử lại.',
], 429);
}
/**
* Normalise the array|string return value from Power ON/OFF actions.
*/
private function vmResponse(array|string $result, string $successMessage): JsonResponse
{
if ($result === 'Login_Again') {
return response()->json([
'success' => false,
'message' => 'Không thể đăng nhập vào vCenter.',
], 400);
}
if (is_string($result)) {
return response()->json(['success' => false, 'message' => $result], 400);
}
if ($result['error'] ?? true) {
return response()->json([
'success' => false,
'message' => $result['data'] ?? 'Thao tác thất bại.',
'data' => $result,
], 400);
}
return response()->json([
'success' => true,
'message' => $successMessage,
'data' => $result['data'] ?? 'success',
]);
}
}