169 lines
5.7 KiB
PHP
169 lines
5.7 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Proxmox;
|
|
|
|
use App\Models\Host;
|
|
use Illuminate\Http\Client\PendingRequest;
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
/**
|
|
* Real Proxmox VE REST client (read-only in A: capacity + reachability). Token
|
|
* auth over the WireGuard tunnel; the PVE certificate is self-signed so TLS
|
|
* verification is disabled (traffic already runs inside the encrypted tunnel).
|
|
* Not unit-tested (live I/O). The automation token itself is bootstrapped via
|
|
* SSH `pveum` (see CreateAutomationToken), not here.
|
|
*/
|
|
class HttpProxmoxClient implements ProxmoxClient
|
|
{
|
|
private string $baseUrl = '';
|
|
|
|
private string $token = '';
|
|
|
|
public function forHost(Host $host): static
|
|
{
|
|
$this->baseUrl = 'https://'.$host->wg_ip.':8006/api2/json';
|
|
$this->token = (string) $host->api_token_ref;
|
|
|
|
return $this;
|
|
}
|
|
|
|
private function http(): PendingRequest
|
|
{
|
|
return Http::withoutVerifying()
|
|
->withHeaders(['Authorization' => 'PVEAPIToken='.$this->token])
|
|
->baseUrl($this->baseUrl)
|
|
->timeout(15);
|
|
}
|
|
|
|
public function listNodes(): array
|
|
{
|
|
return $this->http()->get('/nodes')->throw()->json('data', []);
|
|
}
|
|
|
|
public function nodeStatus(string $node): array
|
|
{
|
|
return $this->http()->get("/nodes/{$node}/status")->throw()->json('data', []);
|
|
}
|
|
|
|
public function nodeStorage(string $node): array
|
|
{
|
|
return $this->http()->get("/nodes/{$node}/storage")->throw()->json('data', []);
|
|
}
|
|
|
|
public function nextVmid(): int
|
|
{
|
|
return (int) $this->http()->get('/cluster/nextid')->throw()->json('data');
|
|
}
|
|
|
|
public function cloneVm(string $node, int $templateVmid, int $newVmid, string $name): string
|
|
{
|
|
return (string) $this->http()->asForm()
|
|
->post("/nodes/{$node}/qemu/{$templateVmid}/clone", ['newid' => $newVmid, 'name' => $name, 'full' => 1])
|
|
->throw()->json('data');
|
|
}
|
|
|
|
public function setCloudInit(string $node, int $vmid, array $params): void
|
|
{
|
|
// PUT is the (synchronous) VM config-update operation in Proxmox.
|
|
$this->http()->asForm()->put("/nodes/{$node}/qemu/{$vmid}/config", $params)->throw();
|
|
}
|
|
|
|
public function resizeDisk(string $node, int $vmid, string $disk, string $size): void
|
|
{
|
|
$this->http()->asForm()->put("/nodes/{$node}/qemu/{$vmid}/resize", ['disk' => $disk, 'size' => $size])->throw();
|
|
}
|
|
|
|
public function startVm(string $node, int $vmid): string
|
|
{
|
|
return (string) $this->http()->asForm()
|
|
->post("/nodes/{$node}/qemu/{$vmid}/status/start")->throw()->json('data');
|
|
}
|
|
|
|
public function vmStatus(string $node, int $vmid): array
|
|
{
|
|
return $this->http()->get("/nodes/{$node}/qemu/{$vmid}/status/current")->throw()->json('data', []);
|
|
}
|
|
|
|
public function vmExists(string $node, int $vmid): bool
|
|
{
|
|
return $this->http()->get("/nodes/{$node}/qemu/{$vmid}/status/current")->successful();
|
|
}
|
|
|
|
public function guestAgentPing(string $node, int $vmid): bool
|
|
{
|
|
return $this->http()->asForm()->post("/nodes/{$node}/qemu/{$vmid}/agent/ping")->successful();
|
|
}
|
|
|
|
public function guestExec(string $node, int $vmid, string $command): array
|
|
{
|
|
// Wrap compound commands (cd/&&/pipes/redirects/env) in an explicit shell
|
|
// — the agent execs a program directly. Proxmox wants the argv as REPEATED
|
|
// `command=` fields (not indexed command[0]=…), so build the body by hand.
|
|
$body = collect(['/bin/sh', '-c', $command])
|
|
->map(fn ($arg) => 'command='.rawurlencode($arg))
|
|
->implode('&');
|
|
|
|
$pid = $this->http()
|
|
->withBody($body, 'application/x-www-form-urlencoded')
|
|
->post("/nodes/{$node}/qemu/{$vmid}/agent/exec")
|
|
->throw()->json('data.pid');
|
|
|
|
// Poll exec-status until the command has exited.
|
|
for ($i = 0; $i < 600; $i++) {
|
|
$status = $this->http()
|
|
->get("/nodes/{$node}/qemu/{$vmid}/agent/exec-status", ['pid' => $pid])
|
|
->throw()->json('data', []);
|
|
|
|
if (($status['exited'] ?? 0)) {
|
|
return [
|
|
'exitcode' => (int) ($status['exitcode'] ?? 0),
|
|
'out-data' => (string) ($status['out-data'] ?? ''),
|
|
];
|
|
}
|
|
|
|
usleep(500000);
|
|
}
|
|
|
|
return ['exitcode' => 255, 'out-data' => 'guest exec timed out'];
|
|
}
|
|
|
|
public function taskStatus(string $node, string $upid): array
|
|
{
|
|
return $this->http()->get("/nodes/{$node}/tasks/{$upid}/status")->throw()->json('data', []);
|
|
}
|
|
|
|
public function applyFirewall(string $node, int $vmid, array $rules): void
|
|
{
|
|
foreach ($rules as $rule) {
|
|
$this->http()->asForm()->post("/nodes/{$node}/qemu/{$vmid}/firewall/rules", $rule)->throw();
|
|
}
|
|
// Default-deny inbound so only the explicit ACCEPT rules (80/443) are open.
|
|
$this->http()->asForm()->put("/nodes/{$node}/qemu/{$vmid}/firewall/options", [
|
|
'enable' => 1,
|
|
'policy_in' => 'DROP',
|
|
'policy_out' => 'ACCEPT',
|
|
])->throw();
|
|
}
|
|
|
|
public function createBackupJob(string $node, int $vmid, string $schedule): string
|
|
{
|
|
// Deterministic job id → creating it twice (crash/retry) is idempotent.
|
|
$jobId = 'clupilot-'.$vmid;
|
|
|
|
$response = $this->http()->asForm()->post('/cluster/backup', [
|
|
'id' => $jobId,
|
|
'vmid' => $vmid,
|
|
'schedule' => $schedule,
|
|
'storage' => 'local',
|
|
'mode' => 'snapshot',
|
|
'enabled' => 1,
|
|
]);
|
|
|
|
if ($response->failed() && ! str_contains($response->body(), 'already')) {
|
|
$response->throw();
|
|
}
|
|
|
|
return $jobId;
|
|
}
|
|
}
|