- Added template typing so approved templates can represent either QEMU VMs or LXC containers.
- Added LXC template discovery from Proxmox storage `vztmpl` content in the admin template manager. - Added live LXC container provisioning through the Proxmox API with configurable rootfs storage and optional DHCP bridge. - Routed start, stop, delete, expiration, status, and IP refresh operations through typed Proxmox VM/LXC API paths. - Added Proxmox tags to newly created VMs and containers, including a sanitized per-user tag for easier PVE administration. - Updated the admin and portal UI to show VM versus LXC template/deployment types and generic Proxmox resource IDs. - Added schema upgrades for template provisioning type, LXC template references, and deployment resource type. - Documented LXC setup, storage permissions, and the new Proxmox settings.
This commit is contained in:
@@ -6,18 +6,81 @@ if (!defined('ABSPATH')) {
|
||||
|
||||
final class SPP_Http_Proxmox_Client implements SPP_Proxmox_Client
|
||||
{
|
||||
/** @var array{base_url:string, token_id:string, token_secret:string, node:string} */
|
||||
/** @var array{base_url:string, token_id:string, token_secret:string, node:string, lxc_rootfs_storage:string, lxc_bridge:string} */
|
||||
private array $options;
|
||||
|
||||
/**
|
||||
* @param array{base_url:string, token_id:string, token_secret:string, node:string} $options
|
||||
* @param array{base_url:string, token_id:string, token_secret:string, node:string, lxc_rootfs_storage?:string, lxc_bridge?:string} $options
|
||||
*/
|
||||
public function __construct(array $options)
|
||||
{
|
||||
$this->options = $options;
|
||||
$this->options = array_merge([
|
||||
'lxc_rootfs_storage' => '',
|
||||
'lxc_bridge' => 'vmbr0',
|
||||
], $options);
|
||||
}
|
||||
|
||||
public function list_templates(): array
|
||||
{
|
||||
$templates = array_merge(
|
||||
$this->list_qemu_templates(),
|
||||
$this->list_lxc_templates()
|
||||
);
|
||||
|
||||
usort(
|
||||
$templates,
|
||||
static function (array $left, array $right): int {
|
||||
$type_compare = strcmp((string) $left['provisioningType'], (string) $right['provisioningType']);
|
||||
|
||||
return $type_compare !== 0
|
||||
? $type_compare
|
||||
: strcasecmp((string) $left['name'], (string) $right['name']);
|
||||
}
|
||||
);
|
||||
|
||||
return $templates;
|
||||
}
|
||||
|
||||
public function provision_instance(array $input): array
|
||||
{
|
||||
return $this->normalise_type((string) ($input['provisioning_type'] ?? 'qemu')) === 'lxc'
|
||||
? $this->create_lxc_container($input)
|
||||
: $this->clone_qemu_vm($input);
|
||||
}
|
||||
|
||||
public function start_instance(string $type, int $vm_id): void
|
||||
{
|
||||
$this->request($this->guest_path($type, $vm_id) . '/status/start', 'POST');
|
||||
}
|
||||
|
||||
public function stop_instance(string $type, int $vm_id): void
|
||||
{
|
||||
$this->request($this->guest_path($type, $vm_id) . '/status/stop', 'POST');
|
||||
}
|
||||
|
||||
public function delete_instance(string $type, int $vm_id): void
|
||||
{
|
||||
$this->request($this->guest_path($type, $vm_id), 'DELETE');
|
||||
}
|
||||
|
||||
public function get_status(string $type, int $vm_id): string
|
||||
{
|
||||
$data = $this->request($this->guest_path($type, $vm_id) . '/status/current', 'GET');
|
||||
|
||||
return is_array($data) && isset($data['status']) ? (string) $data['status'] : 'unknown';
|
||||
}
|
||||
|
||||
public function get_ip_addresses(string $type, int $vm_id): array
|
||||
{
|
||||
return $this->normalise_type($type) === 'lxc'
|
||||
? $this->get_lxc_ip_addresses($vm_id)
|
||||
: $this->get_qemu_ip_addresses($vm_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function list_qemu_templates(): array
|
||||
{
|
||||
$data = $this->request("/nodes/{$this->options['node']}/qemu", 'GET');
|
||||
$vms = is_array($data) ? $data : [];
|
||||
@@ -29,7 +92,10 @@ final class SPP_Http_Proxmox_Client implements SPP_Proxmox_Client
|
||||
}
|
||||
|
||||
$templates[] = [
|
||||
'provisioningType' => 'qemu',
|
||||
'vmId' => (int) $vm['vmid'],
|
||||
'templateRef' => '',
|
||||
'storage' => '',
|
||||
'name' => isset($vm['name']) && $vm['name'] !== '' ? (string) $vm['name'] : 'template-' . (int) $vm['vmid'],
|
||||
'cpuCores' => max(1, (int) ($vm['cpus'] ?? 1)),
|
||||
'memoryMb' => $this->bytes_to_mb((int) ($vm['maxmem'] ?? 0)),
|
||||
@@ -38,15 +104,83 @@ final class SPP_Http_Proxmox_Client implements SPP_Proxmox_Client
|
||||
];
|
||||
}
|
||||
|
||||
usort(
|
||||
$templates,
|
||||
static fn(array $left, array $right): int => strcasecmp((string) $left['name'], (string) $right['name'])
|
||||
);
|
||||
return $templates;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function list_lxc_templates(): array
|
||||
{
|
||||
try {
|
||||
$storages = $this->request("/nodes/{$this->options['node']}/storage", 'GET');
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$storages = is_array($storages) ? $storages : [];
|
||||
$templates = [];
|
||||
|
||||
foreach ($storages as $storage) {
|
||||
if (!is_array($storage) || empty($storage['storage'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($storage['enabled']) && (int) $storage['enabled'] !== 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = is_array($storage['content'] ?? null)
|
||||
? implode(',', $storage['content'])
|
||||
: (string) ($storage['content'] ?? '');
|
||||
|
||||
if ($content !== '' && !str_contains(strtolower($content), 'vztmpl')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$storage_id = (string) $storage['storage'];
|
||||
|
||||
try {
|
||||
$items = $this->request(
|
||||
add_query_arg(['content' => 'vztmpl'], "/nodes/{$this->options['node']}/storage/" . rawurlencode($storage_id) . '/content'),
|
||||
'GET'
|
||||
);
|
||||
} catch (Throwable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (is_array($items) ? $items : [] as $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$volid = (string) ($item['volid'] ?? $item['volume'] ?? '');
|
||||
if ($volid === '' || (isset($item['content']) && (string) $item['content'] !== 'vztmpl')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$templates[] = [
|
||||
'provisioningType' => 'lxc',
|
||||
'vmId' => 0,
|
||||
'templateRef' => $volid,
|
||||
'storage' => $storage_id,
|
||||
'name' => $this->template_name_from_volid($volid),
|
||||
'cpuCores' => 1,
|
||||
'memoryMb' => 1024,
|
||||
'diskGb' => 8,
|
||||
'status' => 'available',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $templates;
|
||||
}
|
||||
|
||||
public function clone_vm(array $input): array
|
||||
/**
|
||||
* @param array<string, mixed> $input
|
||||
* @return array{vm_id:int}
|
||||
*/
|
||||
private function clone_qemu_vm(array $input): array
|
||||
{
|
||||
$vm_id = (int) $this->request('/cluster/nextid', 'GET');
|
||||
$template_id = (int) $input['template_vm_id'];
|
||||
@@ -60,34 +194,46 @@ final class SPP_Http_Proxmox_Client implements SPP_Proxmox_Client
|
||||
$this->request("/nodes/{$this->options['node']}/qemu/{$vm_id}/config", 'PUT', [
|
||||
'cores' => (int) $input['cpu_cores'],
|
||||
'memory' => (int) $input['memory_mb'],
|
||||
'tags' => $this->tags_string($input['tags'] ?? []),
|
||||
]);
|
||||
|
||||
return ['vm_id' => $vm_id];
|
||||
}
|
||||
|
||||
public function start_vm(int $vm_id): void
|
||||
/**
|
||||
* @param array<string, mixed> $input
|
||||
* @return array{vm_id:int}
|
||||
*/
|
||||
private function create_lxc_container(array $input): array
|
||||
{
|
||||
$this->request("/nodes/{$this->options['node']}/qemu/{$vm_id}/status/start", 'POST');
|
||||
$rootfs_storage = $this->sanitize_proxmox_identifier((string) $this->options['lxc_rootfs_storage']);
|
||||
if ($rootfs_storage === '') {
|
||||
throw new RuntimeException('Missing LXC rootfs storage configuration.');
|
||||
}
|
||||
|
||||
$vm_id = (int) $this->request('/cluster/nextid', 'GET');
|
||||
$body = [
|
||||
'vmid' => $vm_id,
|
||||
'hostname' => $this->sanitize_hostname((string) $input['name']),
|
||||
'ostemplate' => (string) $input['lxc_template_ref'],
|
||||
'cores' => (int) $input['cpu_cores'],
|
||||
'memory' => (int) $input['memory_mb'],
|
||||
'rootfs' => $rootfs_storage . ':' . max(1, (int) $input['disk_gb']),
|
||||
'unprivileged' => 1,
|
||||
'tags' => $this->tags_string($input['tags'] ?? []),
|
||||
];
|
||||
|
||||
$bridge = $this->sanitize_proxmox_identifier((string) $this->options['lxc_bridge']);
|
||||
if ($bridge !== '') {
|
||||
$body['net0'] = 'name=eth0,bridge=' . $bridge . ',ip=dhcp';
|
||||
}
|
||||
|
||||
$this->request("/nodes/{$this->options['node']}/lxc", 'POST', $body);
|
||||
|
||||
return ['vm_id' => $vm_id];
|
||||
}
|
||||
|
||||
public function stop_vm(int $vm_id): void
|
||||
{
|
||||
$this->request("/nodes/{$this->options['node']}/qemu/{$vm_id}/status/stop", 'POST');
|
||||
}
|
||||
|
||||
public function delete_vm(int $vm_id): void
|
||||
{
|
||||
$this->request("/nodes/{$this->options['node']}/qemu/{$vm_id}", 'DELETE');
|
||||
}
|
||||
|
||||
public function get_status(int $vm_id): string
|
||||
{
|
||||
$data = $this->request("/nodes/{$this->options['node']}/qemu/{$vm_id}/status/current", 'GET');
|
||||
|
||||
return is_array($data) && isset($data['status']) ? (string) $data['status'] : 'unknown';
|
||||
}
|
||||
|
||||
public function get_ip_addresses(int $vm_id): array
|
||||
private function get_qemu_ip_addresses(int $vm_id): array
|
||||
{
|
||||
$data = $this->request("/nodes/{$this->options['node']}/qemu/{$vm_id}/agent/network-get-interfaces", 'GET');
|
||||
$interfaces = is_array($data) && isset($data['result']) && is_array($data['result']) ? $data['result'] : [];
|
||||
@@ -115,6 +261,91 @@ final class SPP_Http_Proxmox_Client implements SPP_Proxmox_Client
|
||||
return array_values(array_unique($ips));
|
||||
}
|
||||
|
||||
private function get_lxc_ip_addresses(int $vm_id): array
|
||||
{
|
||||
$data = $this->request("/nodes/{$this->options['node']}/lxc/{$vm_id}/interfaces", 'GET');
|
||||
$interfaces = is_array($data) ? $data : [];
|
||||
$ips = [];
|
||||
|
||||
foreach ($interfaces as $interface) {
|
||||
if (!is_array($interface)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (['inet', 'inet6'] as $key) {
|
||||
if (empty($interface[$key])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ip = preg_replace('/\/\d+$/', '', (string) $interface[$key]);
|
||||
if ($ip === null || $ip === '127.0.0.1' || $ip === '::1' || str_starts_with($ip, 'fe80:')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ips[] = $ip;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($ips));
|
||||
}
|
||||
|
||||
private function guest_path(string $type, int $vm_id): string
|
||||
{
|
||||
$guest_type = $this->normalise_type($type) === 'lxc' ? 'lxc' : 'qemu';
|
||||
|
||||
return "/nodes/{$this->options['node']}/{$guest_type}/{$vm_id}";
|
||||
}
|
||||
|
||||
private function normalise_type(string $type): string
|
||||
{
|
||||
return strtolower($type) === 'lxc' ? 'lxc' : 'qemu';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $tags
|
||||
*/
|
||||
private function tags_string($tags): string
|
||||
{
|
||||
if (!is_array($tags)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$clean = [];
|
||||
foreach ($tags as $tag) {
|
||||
$tag = strtolower(sanitize_title((string) $tag));
|
||||
if ($tag !== '') {
|
||||
$clean[] = substr($tag, 0, 40);
|
||||
}
|
||||
}
|
||||
|
||||
return implode(';', array_values(array_unique($clean)));
|
||||
}
|
||||
|
||||
private function sanitize_hostname(string $name): string
|
||||
{
|
||||
$hostname = strtolower(sanitize_title($name));
|
||||
$hostname = preg_replace('/[^a-z0-9-]/', '-', $hostname);
|
||||
$hostname = trim((string) $hostname, '-');
|
||||
|
||||
return substr($hostname !== '' ? $hostname : 'spp-lxc', 0, 63);
|
||||
}
|
||||
|
||||
private function sanitize_proxmox_identifier(string $value): string
|
||||
{
|
||||
$value = sanitize_text_field($value);
|
||||
$value = preg_replace('/[^A-Za-z0-9_.-]/', '', $value);
|
||||
|
||||
return $value === null ? '' : $value;
|
||||
}
|
||||
|
||||
private function template_name_from_volid(string $volid): string
|
||||
{
|
||||
$name = basename(str_replace('\\', '/', $volid));
|
||||
$name = preg_replace('/\.(tar\.zst|tar\.xz|tar\.gz|tar)$/', '', $name);
|
||||
|
||||
return $name !== null && $name !== '' ? $name : $volid;
|
||||
}
|
||||
|
||||
private function bytes_to_mb(int $bytes): int
|
||||
{
|
||||
if ($bytes < 1) {
|
||||
|
||||
Reference in New Issue
Block a user