initial commit
This commit is contained in:
591
app/Services/Hosting/Proxmox/ProxmoxClient.php
Normal file
591
app/Services/Hosting/Proxmox/ProxmoxClient.php
Normal file
@@ -0,0 +1,591 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Hosting\Proxmox;
|
||||
|
||||
use App\Exceptions\Hosting\ProxmoxException;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ProxmoxClient
|
||||
{
|
||||
private ?PendingRequest $http = null;
|
||||
|
||||
private function http(): PendingRequest
|
||||
{
|
||||
if ($this->http !== null) {
|
||||
return $this->http;
|
||||
}
|
||||
|
||||
$token = config('hosting.proxmox.token');
|
||||
if (empty($token)) {
|
||||
throw new ProxmoxException('PROXMOX_TOKEN is not configured.');
|
||||
}
|
||||
|
||||
$verify = filter_var(config('hosting.proxmox.verify_ssl'), FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
return $this->http = Http::baseUrl(rtrim(config('hosting.proxmox.url'), '/').'/api2/json')
|
||||
->withHeaders([
|
||||
'Authorization' => str_starts_with($token, 'PVEAPIToken=') ? $token : 'PVEAPIToken='.$token,
|
||||
])
|
||||
->acceptJson()
|
||||
->timeout((int) config('hosting.proxmox.timeout', 120))
|
||||
->when(! $verify, fn (PendingRequest $request) => $request->withoutVerifying());
|
||||
}
|
||||
|
||||
public function getNextVmid(): int
|
||||
{
|
||||
$response = $this->request('GET', '/cluster/nextid');
|
||||
|
||||
return (int) $response['data'];
|
||||
}
|
||||
|
||||
public function vmExists(int $vmid): bool
|
||||
{
|
||||
try {
|
||||
$this->getVMStatus($vmid);
|
||||
|
||||
return true;
|
||||
} catch (ProxmoxException $e) {
|
||||
if (str_contains($e->getMessage(), 'does not exist') || $e->getCode() === 404) {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function createVM(
|
||||
int $vmid,
|
||||
string $name,
|
||||
int $cpu,
|
||||
int $ramMb,
|
||||
int $diskGb,
|
||||
string $ipConfig,
|
||||
?int $templateVmid = null,
|
||||
): void {
|
||||
if ($this->vmExists($vmid)) {
|
||||
throw new ProxmoxException("VM {$vmid} already exists.", step: 'proxmox_create');
|
||||
}
|
||||
|
||||
$node = config('hosting.proxmox.node');
|
||||
$storage = config('hosting.proxmox.storage');
|
||||
$bridge = config('hosting.proxmox.bridge');
|
||||
$templateVmid ??= config('hosting.proxmox.template_vmid') ? (int) config('hosting.proxmox.template_vmid') : null;
|
||||
|
||||
Log::info('Proxmox: creating VM', compact('vmid', 'name', 'cpu', 'ramMb', 'diskGb'));
|
||||
|
||||
if ($templateVmid) {
|
||||
$this->cloneFromTemplateVmid((int) $templateVmid, $vmid, $name);
|
||||
$this->resizeVmIfNeeded($vmid, $cpu, $ramMb, $diskGb);
|
||||
$this->configureCloudInit($vmid, $ipConfig, $name);
|
||||
} else {
|
||||
$this->createVmFromScratch($vmid, $name, $cpu, $ramMb, $diskGb, $ipConfig, $node, $storage, $bridge);
|
||||
}
|
||||
}
|
||||
|
||||
public function setCloudInitIps(int $vmid, string $ipconfig0, ?string $ipconfig1, string $name): void
|
||||
{
|
||||
$this->configureCloudInit($vmid, $ipconfig0, $name, $ipconfig1);
|
||||
}
|
||||
|
||||
public function node(): string
|
||||
{
|
||||
return (string) config('hosting.proxmox.node');
|
||||
}
|
||||
|
||||
public function startVM(int $vmid): void
|
||||
{
|
||||
$this->powerAction($vmid, 'start');
|
||||
}
|
||||
|
||||
public function shutdownVM(int $vmid, int $timeout = 60): void
|
||||
{
|
||||
$this->powerAction($vmid, 'shutdown', ['timeout' => $timeout]);
|
||||
}
|
||||
|
||||
public function stopVM(int $vmid): void
|
||||
{
|
||||
$this->powerAction($vmid, 'stop');
|
||||
}
|
||||
|
||||
public function rebootVM(int $vmid, int $timeout = 60): void
|
||||
{
|
||||
$this->powerAction($vmid, 'reboot', ['timeout' => $timeout]);
|
||||
}
|
||||
|
||||
public function resetVM(int $vmid): void
|
||||
{
|
||||
$this->powerAction($vmid, 'reset');
|
||||
}
|
||||
|
||||
public function powerAction(int $vmid, string $action, array $params = []): void
|
||||
{
|
||||
$node = $this->node();
|
||||
Log::info('Proxmox: power action', ['vmid' => $vmid, 'action' => $action]);
|
||||
$this->request('POST', "/nodes/{$node}/qemu/{$vmid}/status/{$action}", $params);
|
||||
}
|
||||
|
||||
public function getVmConfig(int $vmid): array
|
||||
{
|
||||
$node = $this->node();
|
||||
$response = $this->request('GET', "/nodes/{$node}/qemu/{$vmid}/config");
|
||||
|
||||
return $response['data'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{volid: string, label: string, size: int}>
|
||||
*/
|
||||
public function listIsos(?string $storage = null): array
|
||||
{
|
||||
$storage ??= config('hosting.proxmox.iso_storage', 'local');
|
||||
$node = $this->node();
|
||||
|
||||
$response = $this->request('GET', "/nodes/{$node}/storage/{$storage}/content", [
|
||||
'content' => 'iso',
|
||||
]);
|
||||
|
||||
$items = $response['data'] ?? [];
|
||||
|
||||
return collect($items)
|
||||
->filter(fn ($item) => is_array($item) && ($item['content'] ?? '') === 'iso')
|
||||
->map(fn (array $item) => [
|
||||
'volid' => (string) ($item['volid'] ?? ''),
|
||||
'label' => (string) ($item['volid'] ?? $item['name'] ?? 'unknown'),
|
||||
'size' => (int) ($item['size'] ?? 0),
|
||||
])
|
||||
->filter(fn (array $item) => $item['volid'] !== '')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function mountIso(int $vmid, string $isoVolid, ?string $device = null): void
|
||||
{
|
||||
$device ??= config('hosting.proxmox.iso_device', 'ide2');
|
||||
$node = $this->node();
|
||||
|
||||
if (! str_contains($isoVolid, ':')) {
|
||||
$storage = config('hosting.proxmox.iso_storage', 'local');
|
||||
$isoVolid = "{$storage}:iso/{$isoVolid}";
|
||||
}
|
||||
|
||||
Log::info('Proxmox: mounting ISO', ['vmid' => $vmid, 'iso' => $isoVolid, 'device' => $device]);
|
||||
|
||||
$this->request('PUT', "/nodes/{$node}/qemu/{$vmid}/config", [
|
||||
$device => "{$isoVolid},media=cdrom",
|
||||
'boot' => "order={$device};scsi0",
|
||||
]);
|
||||
}
|
||||
|
||||
public function unmountIso(int $vmid, ?string $device = null): void
|
||||
{
|
||||
$device ??= config('hosting.proxmox.iso_device', 'ide2');
|
||||
$node = $this->node();
|
||||
|
||||
Log::info('Proxmox: unmounting ISO', ['vmid' => $vmid, 'device' => $device]);
|
||||
|
||||
$this->request('PUT', "/nodes/{$node}/qemu/{$vmid}/config", [
|
||||
'delete' => $device,
|
||||
'boot' => 'order=scsi0',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{port: string, ticket: string, cert: ?string, node: string, vmid: int}
|
||||
*/
|
||||
public function createVncProxy(int $vmid): array
|
||||
{
|
||||
$node = $this->node();
|
||||
|
||||
$response = $this->request('POST', "/nodes/{$node}/qemu/{$vmid}/vncproxy", [
|
||||
'websocket' => 1,
|
||||
]);
|
||||
|
||||
$data = $response['data'] ?? [];
|
||||
|
||||
return [
|
||||
'port' => (string) ($data['port'] ?? ''),
|
||||
'ticket' => (string) ($data['ticket'] ?? ''),
|
||||
'cert' => isset($data['cert']) ? (string) $data['cert'] : null,
|
||||
'node' => $node,
|
||||
'vmid' => $vmid,
|
||||
];
|
||||
}
|
||||
|
||||
public function buildVncWebSocketUrl(int $vmid, string $port, string $ticket): string
|
||||
{
|
||||
$base = rtrim(config('hosting.proxmox.console_ws_url') ?: config('hosting.proxmox.url'), '/');
|
||||
$parsed = parse_url($base);
|
||||
$scheme = ($parsed['scheme'] ?? 'https') === 'https' ? 'wss' : 'ws';
|
||||
$host = $parsed['host'] ?? 'localhost';
|
||||
$portNum = $parsed['port'] ?? (($parsed['scheme'] ?? 'https') === 'https' ? 8006 : 80);
|
||||
$node = $this->node();
|
||||
|
||||
return sprintf(
|
||||
'%s://%s:%s/api2/json/nodes/%s/qemu/%d/vncwebsocket?port=%s&vncticket=%s',
|
||||
$scheme,
|
||||
$host,
|
||||
$portNum,
|
||||
$node,
|
||||
$vmid,
|
||||
urlencode($port),
|
||||
urlencode($ticket),
|
||||
);
|
||||
}
|
||||
|
||||
public function normalizeLiveStatus(array $status): array
|
||||
{
|
||||
return [
|
||||
'status' => (string) ($status['status'] ?? 'unknown'),
|
||||
'uptime' => (int) ($status['uptime'] ?? 0),
|
||||
'cpu' => (float) ($status['cpu'] ?? 0),
|
||||
'mem' => (int) ($status['mem'] ?? 0),
|
||||
'maxmem' => (int) ($status['maxmem'] ?? 0),
|
||||
'disk' => (int) ($status['disk'] ?? 0),
|
||||
'maxdisk' => (int) ($status['maxdisk'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
public function waitForTask(string $upid, int $maxSeconds = 600): void
|
||||
{
|
||||
$node = $this->node();
|
||||
$deadline = time() + $maxSeconds;
|
||||
|
||||
while (time() < $deadline) {
|
||||
$response = $this->request('GET', '/nodes/'.$node.'/tasks/'.rawurlencode($upid).'/status');
|
||||
$status = $response['data']['status'] ?? '';
|
||||
if ($status === 'stopped') {
|
||||
$exit = $response['data']['exitstatus'] ?? 'OK';
|
||||
if ($exit !== 'OK') {
|
||||
throw new ProxmoxException('Proxmox task failed: '.$exit, step: 'proxmox_task');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
sleep(2);
|
||||
}
|
||||
|
||||
throw new ProxmoxException('Proxmox task timeout: '.$upid, step: 'proxmox_task');
|
||||
}
|
||||
|
||||
public function createSnapshot(int $vmid, string $name): void
|
||||
{
|
||||
$node = $this->node();
|
||||
$response = $this->request('POST', "/nodes/{$node}/qemu/{$vmid}/snapshot", [
|
||||
'snapname' => $name,
|
||||
]);
|
||||
if (! empty($response['data'])) {
|
||||
$this->waitForTask($response['data']);
|
||||
}
|
||||
}
|
||||
|
||||
public function rollbackSnapshot(int $vmid, string $name): void
|
||||
{
|
||||
$node = $this->node();
|
||||
$response = $this->request('POST', "/nodes/{$node}/qemu/{$vmid}/snapshot/{$name}/rollback");
|
||||
if (! empty($response['data'])) {
|
||||
$this->waitForTask($response['data']);
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteSnapshot(int $vmid, string $name): void
|
||||
{
|
||||
$node = $this->node();
|
||||
$this->request('DELETE', "/nodes/{$node}/qemu/{$vmid}/snapshot/{$name}");
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{name: string, description: string}>
|
||||
*/
|
||||
public function listSnapshots(int $vmid): array
|
||||
{
|
||||
$node = $this->node();
|
||||
$response = $this->request('GET', "/nodes/{$node}/qemu/{$vmid}/snapshot");
|
||||
|
||||
return collect($response['data'] ?? [])
|
||||
->filter(fn ($item) => is_array($item))
|
||||
->map(fn (array $item) => [
|
||||
'name' => (string) ($item['name'] ?? ''),
|
||||
'description' => (string) ($item['description'] ?? ''),
|
||||
])
|
||||
->filter(fn ($item) => $item['name'] !== '')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function startBackup(int $vmid, string $storage): string
|
||||
{
|
||||
$node = $this->node();
|
||||
$response = $this->request('POST', "/nodes/{$node}/vzdump", [
|
||||
'vmid' => $vmid,
|
||||
'storage' => $storage,
|
||||
'mode' => 'snapshot',
|
||||
'compress' => 'zstd',
|
||||
]);
|
||||
|
||||
return (string) ($response['data'] ?? '');
|
||||
}
|
||||
|
||||
public function getFirewallRules(int $vmid): array
|
||||
{
|
||||
$node = $this->node();
|
||||
$response = $this->request('GET', "/nodes/{$node}/qemu/{$vmid}/firewall/rules");
|
||||
|
||||
return $response['data'] ?? [];
|
||||
}
|
||||
|
||||
public function setFirewallRules(int $vmid, array $rules): void
|
||||
{
|
||||
$node = $this->node();
|
||||
// Replace all rules: delete existing then add - simplified: enable firewall + pos rules via PUT pos
|
||||
$this->request('PUT', "/nodes/{$node}/qemu/{$vmid}/firewall/options", ['enable' => 1]);
|
||||
foreach ($rules as $pos => $rule) {
|
||||
$this->request('POST', "/nodes/{$node}/qemu/{$vmid}/firewall/rules", array_merge($rule, ['pos' => $pos]));
|
||||
}
|
||||
}
|
||||
|
||||
public function uploadIsoToStorage(string $storage, string $filename, string $localPath): void
|
||||
{
|
||||
$node = $this->node();
|
||||
$client = $this->http();
|
||||
$response = $client->attach(
|
||||
'content',
|
||||
file_get_contents($localPath),
|
||||
$filename
|
||||
)->post("/nodes/{$node}/storage/{$storage}/upload", [
|
||||
'content' => 'iso',
|
||||
'filename' => $filename,
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new ProxmoxException('ISO upload failed: '.$response->body(), step: 'proxmox_upload');
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteStorageFile(string $storage, string $volid): void
|
||||
{
|
||||
$node = $this->node();
|
||||
$this->request('DELETE', "/nodes/{$node}/storage/{$storage}/content/".urlencode($volid));
|
||||
}
|
||||
|
||||
public function cloneFromTemplateVmid(int $templateVmid, int $newVmid, string $name): void
|
||||
{
|
||||
$node = $this->node();
|
||||
$response = $this->request('POST', "/nodes/{$node}/qemu/{$templateVmid}/clone", [
|
||||
'newid' => $newVmid,
|
||||
'name' => $name,
|
||||
'full' => 1,
|
||||
'target' => $node,
|
||||
]);
|
||||
if (! empty($response['data'])) {
|
||||
$this->waitForTask($response['data']);
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteVM(int $vmid): void
|
||||
{
|
||||
if (! $this->vmExists($vmid)) {
|
||||
Log::warning('Proxmox: VM already absent, skipping delete', ['vmid' => $vmid]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$status = $this->getVMStatus($vmid);
|
||||
if (($status['status'] ?? '') === 'running') {
|
||||
$this->stopVM($vmid);
|
||||
$this->waitUntilStopped($vmid);
|
||||
}
|
||||
} catch (ProxmoxException) {
|
||||
// continue with delete attempt
|
||||
}
|
||||
|
||||
$node = config('hosting.proxmox.node');
|
||||
Log::info('Proxmox: deleting VM', ['vmid' => $vmid]);
|
||||
$this->request('DELETE', "/nodes/{$node}/qemu/{$vmid}");
|
||||
}
|
||||
|
||||
public function getVMStatus(int $vmid): array
|
||||
{
|
||||
$node = config('hosting.proxmox.node');
|
||||
$response = $this->request('GET', "/nodes/{$node}/qemu/{$vmid}/status/current");
|
||||
|
||||
return $response['data'] ?? [];
|
||||
}
|
||||
|
||||
public function buildIpConfig(string $ip, ?string $gateway = null, ?int $cidr = null): string
|
||||
{
|
||||
$cidr ??= (int) config('hosting.network.cidr');
|
||||
$gateway ??= config('hosting.network.gateway');
|
||||
|
||||
return "ip={$ip}/{$cidr},gw={$gateway}";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<int, \App\Models\VmDevice> $devices
|
||||
*/
|
||||
public function applyDevices(int $vmid, iterable $devices): void
|
||||
{
|
||||
$node = config('hosting.proxmox.node');
|
||||
$storage = config('hosting.proxmox.storage');
|
||||
$params = [];
|
||||
|
||||
foreach ($devices as $device) {
|
||||
$config = $device->config ?? [];
|
||||
$slot = $device->slot;
|
||||
|
||||
match ($device->type) {
|
||||
\App\Models\VmDevice::TYPE_DISK => $params[$slot ?? 'scsi1'] = sprintf(
|
||||
'%s:%d',
|
||||
$config['storage'] ?? $storage,
|
||||
(int) ($config['size_gb'] ?? 10),
|
||||
),
|
||||
\App\Models\VmDevice::TYPE_NETWORK => $params[$slot ?? 'net1'] = sprintf(
|
||||
'%s,bridge=%s',
|
||||
$config['model'] ?? 'virtio',
|
||||
$config['bridge'] ?? config('hosting.proxmox.bridge'),
|
||||
),
|
||||
\App\Models\VmDevice::TYPE_USB => $params[$slot ?? 'usb0'] = $config['host_id'] ?? 'host=0',
|
||||
\App\Models\VmDevice::TYPE_PCI => $params[$slot ?? 'hostpci0'] = $config['address'] ?? '',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
$params = array_filter($params, fn ($v) => $v !== '' && $v !== null);
|
||||
|
||||
if ($params === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
Log::info('Proxmox: applying VM devices', ['vmid' => $vmid, 'devices' => array_keys($params)]);
|
||||
$this->request('PUT', "/nodes/{$node}/qemu/{$vmid}/config", $params);
|
||||
}
|
||||
|
||||
public function updateVmResources(int $vmid, int $cpu, int $ramMb, int $diskGb): void
|
||||
{
|
||||
$this->resizeVmIfNeeded($vmid, $cpu, $ramMb, $diskGb);
|
||||
}
|
||||
|
||||
private function resizeVmIfNeeded(int $vmid, int $cpu, int $ramMb, int $diskGb): void
|
||||
{
|
||||
$node = config('hosting.proxmox.node');
|
||||
|
||||
$this->request('PUT', "/nodes/{$node}/qemu/{$vmid}/config", [
|
||||
'cores' => $cpu,
|
||||
'memory' => $ramMb,
|
||||
]);
|
||||
|
||||
// Disk resize depends on storage layout; best-effort grow on scsi0
|
||||
try {
|
||||
$this->request('PUT', "/nodes/{$node}/qemu/{$vmid}/resize", [
|
||||
'disk' => 'scsi0',
|
||||
'size' => $diskGb.'G',
|
||||
]);
|
||||
} catch (ProxmoxException $e) {
|
||||
Log::warning('Proxmox: disk resize skipped', ['vmid' => $vmid, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
private function configureCloudInit(int $vmid, string $ipConfig, string $name, ?string $ipConfig1 = null): void
|
||||
{
|
||||
$node = config('hosting.proxmox.node');
|
||||
$bridge = config('hosting.proxmox.bridge');
|
||||
|
||||
$params = [
|
||||
'name' => $name,
|
||||
'ipconfig0' => $ipConfig,
|
||||
'net0' => "virtio,bridge={$bridge}",
|
||||
];
|
||||
|
||||
if ($ipConfig1) {
|
||||
$publicBridge = config('hosting.proxmox.public_bridge', 'vmbr1');
|
||||
$params['ipconfig1'] = $ipConfig1;
|
||||
$params['net1'] = "virtio,bridge={$publicBridge}";
|
||||
}
|
||||
|
||||
$this->request('PUT', "/nodes/{$node}/qemu/{$vmid}/config", $params);
|
||||
}
|
||||
|
||||
private function createVmFromScratch(
|
||||
int $vmid,
|
||||
string $name,
|
||||
int $cpu,
|
||||
int $ramMb,
|
||||
int $diskGb,
|
||||
string $ipConfig,
|
||||
string $node,
|
||||
string $storage,
|
||||
string $bridge,
|
||||
): void {
|
||||
$this->request('POST', "/nodes/{$node}/qemu", [
|
||||
'vmid' => $vmid,
|
||||
'name' => $name,
|
||||
'cores' => $cpu,
|
||||
'memory' => $ramMb,
|
||||
'ostype' => 'l26',
|
||||
'scsihw' => 'virtio-scsi-pci',
|
||||
'scsi0' => "{$storage}:{$diskGb}",
|
||||
'ide2' => "{$storage}:cloudinit",
|
||||
'boot' => 'order=scsi0',
|
||||
'net0' => "virtio,bridge={$bridge}",
|
||||
'ipconfig0' => $ipConfig,
|
||||
'agent' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
private function waitUntilStopped(int $vmid, int $maxSeconds = 60): void
|
||||
{
|
||||
$deadline = time() + $maxSeconds;
|
||||
|
||||
while (time() < $deadline) {
|
||||
$status = $this->getVMStatus($vmid);
|
||||
if (($status['status'] ?? '') !== 'running') {
|
||||
return;
|
||||
}
|
||||
sleep(2);
|
||||
}
|
||||
|
||||
throw new ProxmoxException("Timeout waiting for VM {$vmid} to stop.", step: 'proxmox_stop');
|
||||
}
|
||||
|
||||
private function request(string $method, string $uri, array $data = []): array
|
||||
{
|
||||
try {
|
||||
$client = $this->http();
|
||||
$response = match (strtoupper($method)) {
|
||||
'GET' => $client->get($uri, $data),
|
||||
'POST' => $client->asForm()->post($uri, $data),
|
||||
'PUT' => $client->asForm()->put($uri, $data),
|
||||
'DELETE' => $client->delete($uri, $data),
|
||||
default => throw new ProxmoxException("Unsupported HTTP method: {$method}"),
|
||||
};
|
||||
|
||||
if ($response->failed()) {
|
||||
$message = $response->json('errors') ?? $response->json('message') ?? $response->body();
|
||||
throw new ProxmoxException(
|
||||
'Proxmox API error: '.(is_string($message) ? $message : json_encode($message)),
|
||||
step: 'proxmox_api',
|
||||
context: ['uri' => $uri, 'status' => $response->status()],
|
||||
code: $response->status(),
|
||||
);
|
||||
}
|
||||
|
||||
return $response->json() ?? [];
|
||||
} catch (ConnectionException $e) {
|
||||
throw new ProxmoxException(
|
||||
'Proxmox connection timeout or unreachable: '.$e->getMessage(),
|
||||
step: 'proxmox_connection',
|
||||
previous: $e,
|
||||
);
|
||||
} catch (RequestException $e) {
|
||||
throw new ProxmoxException(
|
||||
'Proxmox HTTP error: '.$e->getMessage(),
|
||||
step: 'proxmox_http',
|
||||
previous: $e,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
192
app/Services/Hosting/Proxmox/VmManagementService.php
Normal file
192
app/Services/Hosting/Proxmox/VmManagementService.php
Normal file
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Hosting\Proxmox;
|
||||
|
||||
use App\Enums\VmPowerAction;
|
||||
use App\Exceptions\Hosting\ProxmoxException;
|
||||
use App\Exceptions\Hosting\ProvisioningException;
|
||||
use App\Models\Customer;
|
||||
use App\Models\User;
|
||||
use App\Models\VmActivityLog;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class VmManagementService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProxmoxClient $proxmox,
|
||||
) {}
|
||||
|
||||
public function assertManageable(Customer $vm): void
|
||||
{
|
||||
if (! $vm->vmid) {
|
||||
throw new ProvisioningException('VM ist noch nicht in Proxmox provisioniert.', step: 'vm_not_ready');
|
||||
}
|
||||
|
||||
if ($vm->status !== 'active') {
|
||||
throw new ProvisioningException('VM ist nicht aktiv (Status: '.$vm->status.').', step: 'vm_not_active');
|
||||
}
|
||||
}
|
||||
|
||||
public function refreshLiveStatus(Customer $vm): array
|
||||
{
|
||||
$this->assertManageable($vm);
|
||||
|
||||
$raw = $this->proxmox->getVMStatus((int) $vm->vmid);
|
||||
$normalized = $this->proxmox->normalizeLiveStatus($raw);
|
||||
|
||||
$vm->update([
|
||||
'proxmox_status' => $normalized['status'],
|
||||
'proxmox_uptime' => $normalized['uptime'],
|
||||
'proxmox_status_at' => now(),
|
||||
]);
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
public function power(Customer $vm, VmPowerAction $action, User $user): void
|
||||
{
|
||||
$this->assertManageable($vm);
|
||||
|
||||
$live = $this->refreshLiveStatus($vm);
|
||||
$isRunning = ($live['status'] ?? '') === 'running';
|
||||
|
||||
if ($action->requiresRunning() && ! $isRunning) {
|
||||
throw new ProxmoxException('VM läuft nicht – Aktion nicht möglich.', step: 'power_state');
|
||||
}
|
||||
|
||||
if ($action->requiresStopped() && $isRunning) {
|
||||
throw new ProxmoxException('VM läuft bereits.', step: 'power_state');
|
||||
}
|
||||
|
||||
try {
|
||||
match ($action) {
|
||||
VmPowerAction::Start => $this->proxmox->startVM((int) $vm->vmid),
|
||||
VmPowerAction::Shutdown => $this->proxmox->shutdownVM((int) $vm->vmid),
|
||||
VmPowerAction::Stop => $this->proxmox->stopVM((int) $vm->vmid),
|
||||
VmPowerAction::Reboot => $this->proxmox->rebootVM((int) $vm->vmid),
|
||||
VmPowerAction::Reset => $this->proxmox->resetVM((int) $vm->vmid),
|
||||
};
|
||||
|
||||
$this->log($vm, $user, 'power.'.$action->value, 'success');
|
||||
$this->refreshLiveStatus($vm);
|
||||
} catch (\Throwable $e) {
|
||||
$this->log($vm, $user, 'power.'.$action->value, 'failed', $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function mountIso(Customer $vm, string $isoVolid, User $user): void
|
||||
{
|
||||
$this->assertManageable($vm);
|
||||
|
||||
$available = collect($this->proxmox->listIsos())->pluck('volid');
|
||||
if (! $available->contains($isoVolid)) {
|
||||
throw new ProxmoxException('ISO nicht im Storage gefunden.', step: 'iso_invalid');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->proxmox->mountIso((int) $vm->vmid, $isoVolid);
|
||||
$vm->update(['attached_iso' => $isoVolid]);
|
||||
$this->log($vm, $user, 'iso.mount', 'success', null, ['iso' => $isoVolid]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->log($vm, $user, 'iso.mount', 'failed', $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function unmountIso(Customer $vm, User $user): void
|
||||
{
|
||||
$this->assertManageable($vm);
|
||||
|
||||
try {
|
||||
$this->proxmox->unmountIso((int) $vm->vmid);
|
||||
$vm->update(['attached_iso' => null]);
|
||||
$this->log($vm, $user, 'iso.unmount', 'success');
|
||||
} catch (\Throwable $e) {
|
||||
$this->log($vm, $user, 'iso.unmount', 'failed', $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{token: string, ws_url: string, vmid: int, node: string, expires_at: int}
|
||||
*/
|
||||
public function createConsoleSession(Customer $vm, User $user): array
|
||||
{
|
||||
$this->assertManageable($vm);
|
||||
|
||||
$live = $this->refreshLiveStatus($vm);
|
||||
if (($live['status'] ?? '') !== 'running') {
|
||||
throw new ProxmoxException('Konsole nur bei laufender VM verfügbar. Bitte VM starten.', step: 'console_not_running');
|
||||
}
|
||||
|
||||
$proxy = $this->proxmox->createVncProxy((int) $vm->vmid);
|
||||
|
||||
if ($proxy['port'] === '' || $proxy['ticket'] === '') {
|
||||
throw new ProxmoxException('VNC-Proxy konnte nicht erstellt werden.', step: 'console_proxy');
|
||||
}
|
||||
|
||||
$token = Str::random(64);
|
||||
$proxmoxWsUrl = $this->proxmox->buildVncWebSocketUrl((int) $vm->vmid, $proxy['port'], $proxy['ticket']);
|
||||
$expiresAt = now()->addMinutes(5)->timestamp;
|
||||
|
||||
$wsUrl = $proxmoxWsUrl;
|
||||
if (config('hosting.console.proxy_enabled') && config('hosting.console.proxy_ws_url')) {
|
||||
$wsUrl = rtrim(config('hosting.console.proxy_ws_url'), '/').'/'.$token;
|
||||
}
|
||||
|
||||
Cache::put($this->consoleCacheKey($token), [
|
||||
'customer_id' => $vm->id,
|
||||
'user_id' => $user->id,
|
||||
'ws_url' => $proxmoxWsUrl,
|
||||
'ticket' => $proxy['ticket'],
|
||||
'port' => $proxy['port'],
|
||||
'vmid' => $vm->vmid,
|
||||
'node' => $proxy['node'],
|
||||
'cert' => $proxy['cert'],
|
||||
], 300);
|
||||
|
||||
$this->log($vm, $user, 'console.open', 'success');
|
||||
|
||||
return [
|
||||
'token' => $token,
|
||||
'ws_url' => $wsUrl,
|
||||
'vmid' => (int) $vm->vmid,
|
||||
'node' => $proxy['node'],
|
||||
'expires_at' => $expiresAt,
|
||||
];
|
||||
}
|
||||
|
||||
public function getConsoleSession(string $token, User $user): array
|
||||
{
|
||||
$data = Cache::get($this->consoleCacheKey($token));
|
||||
|
||||
if (! $data) {
|
||||
throw new ProxmoxException('Konsole-Session abgelaufen oder ungültig.', step: 'console_expired');
|
||||
}
|
||||
|
||||
if ($data['user_id'] !== $user->id && ! $user->isAdmin()) {
|
||||
throw new ProxmoxException('Kein Zugriff auf diese Konsole.', step: 'console_forbidden');
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function consoleCacheKey(string $token): string
|
||||
{
|
||||
return 'vm_console:'.$token;
|
||||
}
|
||||
|
||||
private function log(Customer $vm, User $user, string $action, string $status, ?string $message = null, array $meta = []): void
|
||||
{
|
||||
VmActivityLog::query()->create([
|
||||
'customer_id' => $vm->id,
|
||||
'user_id' => $user->id,
|
||||
'action' => $action,
|
||||
'status' => $status,
|
||||
'message' => $message,
|
||||
'meta' => $meta ?: null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user