- 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:
Sven Steinert
2026-04-24 17:11:39 +02:00
parent 2c1949bf1e
commit 118809bfae
13 changed files with 672 additions and 126 deletions

View File

@@ -100,7 +100,7 @@ final class SPP_Admin_Page
'settings_saved' => __('Settings saved.', 'support-provisioning-portal'),
'template_saved' => __('Template saved.', 'support-provisioning-portal'),
'template_removed' => __('Template removed from new provisioning.', 'support-provisioning-portal'),
'template_error' => __('Template could not be saved. Check the fields and confirm the VMID exists as a Proxmox QEMU template on the configured node.', 'support-provisioning-portal'),
'template_error' => __('Template could not be saved. Check the fields and confirm the selected Proxmox VM or LXC template exists on the configured node.', 'support-provisioning-portal'),
'user_access_saved' => __('User rights saved.', 'support-provisioning-portal'),
'manager_required' => __('At least one user must keep the Manage user rights permission.', 'support-provisioning-portal'),
];
@@ -169,6 +169,14 @@ final class SPP_Admin_Page
<span>Node</span>
<input name="spp_proxmox_node" type="text" value="<?php echo esc_attr(get_option('spp_proxmox_node', 'pve-01')); ?>">
</label>
<label>
<span>LXC rootfs storage</span>
<input name="spp_lxc_rootfs_storage" type="text" value="<?php echo esc_attr(get_option('spp_lxc_rootfs_storage', '')); ?>" placeholder="local-lvm">
</label>
<label>
<span>LXC network bridge</span>
<input name="spp_lxc_bridge" type="text" value="<?php echo esc_attr(get_option('spp_lxc_bridge', 'vmbr0')); ?>" placeholder="vmbr0">
</label>
<h2><?php echo esc_html__('RAM Contingents', 'support-provisioning-portal'); ?></h2>
<p class="description"><?php echo esc_html__('Set 0 for unlimited. Active allocations include provisioning, stopped, running, and deleting deployments.', 'support-provisioning-portal'); ?></p>
<label>
@@ -187,7 +195,7 @@ final class SPP_Admin_Page
private function render_template_management(): void
{
$approved_templates = $this->repository->admin_templates();
$active_proxmox_ids = $this->repository->active_proxmox_template_ids();
$active_template_keys = $this->repository->active_template_identity_keys();
$proxmox_error = null;
try {
@@ -220,7 +228,7 @@ final class SPP_Admin_Page
<div class="spp-template-row-head">
<div>
<strong><?php echo esc_html((string) $template['name']); ?></strong>
<span><?php echo esc_html(sprintf('PVE VMID %d', (int) $template['proxmoxTemplateId'])); ?></span>
<span><?php echo esc_html($this->template_identity_label($template)); ?></span>
</div>
<?php if (!empty($template['isActive'])) : ?>
<span class="spp-badge RUNNING"><?php echo esc_html__('Active', 'support-provisioning-portal'); ?></span>
@@ -229,12 +237,24 @@ final class SPP_Admin_Page
<?php endif; ?>
</div>
<div class="spp-template-fields">
<input type="hidden" name="spp_provisioning_type" value="<?php echo esc_attr((string) $template['provisioningType']); ?>">
<label>Name
<input name="spp_template_name" type="text" required maxlength="160" value="<?php echo esc_attr((string) $template['name']); ?>">
</label>
<label>PVE template VMID
<input name="spp_proxmox_template_id" type="number" min="1" required value="<?php echo esc_attr((string) $template['proxmoxTemplateId']); ?>">
<label>Type
<input type="text" readonly value="<?php echo esc_attr($this->template_type_label((string) $template['provisioningType'])); ?>">
</label>
<?php if ((string) $template['provisioningType'] === 'lxc') : ?>
<input type="hidden" name="spp_proxmox_template_id" value="0">
<label>LXC template ref
<input name="spp_proxmox_template_ref" type="text" readonly required value="<?php echo esc_attr((string) $template['proxmoxTemplateRef']); ?>">
</label>
<?php else : ?>
<input type="hidden" name="spp_proxmox_template_ref" value="">
<label>PVE template VMID
<input name="spp_proxmox_template_id" type="number" min="1" required value="<?php echo esc_attr((string) $template['proxmoxTemplateId']); ?>">
</label>
<?php endif; ?>
<label>OS type
<?php $this->render_os_type_select((string) $template['osType']); ?>
</label>
@@ -271,17 +291,27 @@ final class SPP_Admin_Page
<p class="spp-error"><?php echo esc_html($proxmox_error); ?></p>
<?php elseif (empty($proxmox_templates)) : ?>
<div class="spp-panel spp-admin-notice">
<p><?php echo esc_html__('No QEMU templates were returned by Proxmox for the configured node.', 'support-provisioning-portal'); ?></p>
<p><?php echo esc_html__('No QEMU VM or LXC templates were returned by Proxmox for the configured node.', 'support-provisioning-portal'); ?></p>
</div>
<?php else : ?>
<div class="spp-pve-template-grid">
<?php foreach ($proxmox_templates as $template) : ?>
<?php $is_imported = in_array((int) $template['vmId'], $active_proxmox_ids, true); ?>
<?php
$provisioning_type = $this->normalise_template_type((string) ($template['provisioningType'] ?? 'qemu'));
$template_ref = (string) ($template['templateRef'] ?? '');
$is_imported = in_array(
$this->template_identity_key($provisioning_type, (int) ($template['vmId'] ?? 0), $template_ref),
$active_template_keys,
true
);
?>
<form class="spp-pve-template" method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<input type="hidden" name="action" value="spp_save_template">
<input type="hidden" name="spp_template_action" value="import">
<input type="hidden" name="spp_provisioning_type" value="<?php echo esc_attr($provisioning_type); ?>">
<input type="hidden" name="spp_template_name" value="<?php echo esc_attr((string) $template['name']); ?>">
<input type="hidden" name="spp_proxmox_template_id" value="<?php echo esc_attr((string) $template['vmId']); ?>">
<input type="hidden" name="spp_proxmox_template_id" value="<?php echo esc_attr((string) ($template['vmId'] ?? 0)); ?>">
<input type="hidden" name="spp_proxmox_template_ref" value="<?php echo esc_attr($template_ref); ?>">
<input type="hidden" name="spp_cpu_cores" value="<?php echo esc_attr((string) $template['cpuCores']); ?>">
<input type="hidden" name="spp_memory_mb" value="<?php echo esc_attr((string) $template['memoryMb']); ?>">
<input type="hidden" name="spp_disk_gb" value="<?php echo esc_attr((string) $template['diskGb']); ?>">
@@ -289,13 +319,18 @@ final class SPP_Admin_Page
<div class="spp-template-row-head">
<div>
<strong><?php echo esc_html((string) $template['name']); ?></strong>
<span><?php echo esc_html(sprintf('PVE VMID %d', (int) $template['vmId'])); ?></span>
<span><?php echo esc_html($this->template_identity_label([
'provisioningType' => $provisioning_type,
'proxmoxTemplateId' => (int) ($template['vmId'] ?? 0),
'proxmoxTemplateRef' => $template_ref,
])); ?></span>
</div>
<?php if ($is_imported) : ?>
<span class="spp-badge RUNNING"><?php echo esc_html__('Imported', 'support-provisioning-portal'); ?></span>
<?php endif; ?>
</div>
<div class="spp-meta">
<span>Type<strong><?php echo esc_html($this->template_type_label($provisioning_type)); ?></strong></span>
<span>CPU<strong><?php echo esc_html((string) $template['cpuCores']); ?> cores</strong></span>
<span>Memory<strong><?php echo esc_html((string) $template['memoryMb']); ?> MB</strong></span>
<span>Disk<strong><?php echo esc_html((string) $template['diskGb']); ?> GB</strong></span>
@@ -309,7 +344,7 @@ final class SPP_Admin_Page
<input name="spp_default_ttl_hours" type="number" min="1" max="720" value="168" required>
</label>
<label>Description
<textarea name="spp_template_description" rows="2" required><?php echo esc_textarea(sprintf('Imported from Proxmox template VMID %d.', (int) $template['vmId'])); ?></textarea>
<textarea name="spp_template_description" rows="2" required><?php echo esc_textarea($provisioning_type === 'lxc' ? sprintf('Imported from Proxmox LXC template %s.', $template_ref) : sprintf('Imported from Proxmox template VMID %d.', (int) $template['vmId'])); ?></textarea>
</label>
<button class="button button-primary" type="submit"><?php echo esc_html__('Import Template', 'support-provisioning-portal'); ?></button>
<?php endif; ?>
@@ -318,10 +353,12 @@ final class SPP_Admin_Page
</div>
<?php endif; ?>
<h3><?php echo esc_html__('Add Template Manually', 'support-provisioning-portal'); ?></h3>
<h3><?php echo esc_html__('Add QEMU Template Manually', 'support-provisioning-portal'); ?></h3>
<form class="spp-template-row" method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<input type="hidden" name="action" value="spp_save_template">
<input type="hidden" name="spp_template_action" value="import">
<input type="hidden" name="spp_provisioning_type" value="qemu">
<input type="hidden" name="spp_proxmox_template_ref" value="">
<?php wp_nonce_field('spp_save_template'); ?>
<div class="spp-template-fields">
<label>Name
@@ -464,6 +501,8 @@ final class SPP_Admin_Page
update_option('spp_proxmox_token_secret', $token_secret);
}
update_option('spp_proxmox_node', sanitize_text_field($this->posted_string('spp_proxmox_node')));
update_option('spp_lxc_rootfs_storage', $this->sanitize_proxmox_identifier($this->posted_string('spp_lxc_rootfs_storage')));
update_option('spp_lxc_bridge', $this->sanitize_proxmox_identifier($this->posted_string('spp_lxc_bridge')));
update_option('spp_quota_user_memory_mb', max(0, absint($this->posted_string('spp_quota_user_memory_mb'))));
update_option('spp_quota_global_memory_mb', max(0, absint($this->posted_string('spp_quota_global_memory_mb'))));
@@ -495,7 +534,11 @@ final class SPP_Admin_Page
$this->redirect_to_admin_page('template_error');
}
if (!$this->proxmox_template_exists((int) $data['proxmox_template_id'])) {
if (!$this->proxmox_template_exists(
(string) $data['provisioning_type'],
(int) $data['proxmox_template_id'],
(string) $data['proxmox_template_ref']
)) {
$this->redirect_to_admin_page('template_error');
}
@@ -579,7 +622,13 @@ final class SPP_Admin_Page
private function posted_string(string $key): string
{
return isset($_POST[$key]) ? (string) wp_unslash($_POST[$key]) : '';
if (!isset($_POST[$key])) {
return '';
}
$value = wp_unslash($_POST[$key]);
return is_scalar($value) ? (string) $value : '';
}
private function sanitize_proxmox_base_url(string $value): string
@@ -593,11 +642,30 @@ final class SPP_Admin_Page
return wp_parse_url($url, PHP_URL_SCHEME) === 'https' ? $url : '';
}
private function proxmox_template_exists(int $vm_id): bool
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 proxmox_template_exists(string $provisioning_type, int $vm_id, string $template_ref): bool
{
$provisioning_type = $this->normalise_template_type($provisioning_type);
try {
foreach ($this->proxmox->list_templates() as $template) {
if ((int) $template['vmId'] === $vm_id) {
$candidate_type = $this->normalise_template_type((string) ($template['provisioningType'] ?? 'qemu'));
if ($candidate_type !== $provisioning_type) {
continue;
}
if ($candidate_type === 'lxc' && (string) ($template['templateRef'] ?? '') === $template_ref) {
return true;
}
if ($candidate_type === 'qemu' && (int) ($template['vmId'] ?? 0) === $vm_id) {
return true;
}
}
@@ -614,15 +682,25 @@ final class SPP_Admin_Page
private function posted_template_data(): ?array
{
$name = sanitize_text_field($this->posted_string('spp_template_name'));
$provisioning_type = $this->normalise_template_type($this->posted_string('spp_provisioning_type'));
$proxmox_template_id = absint($this->posted_string('spp_proxmox_template_id'));
$proxmox_template_ref = sanitize_text_field($this->posted_string('spp_proxmox_template_ref'));
$description = sanitize_textarea_field($this->posted_string('spp_template_description'));
if ($name === '' || $proxmox_template_id < 1 || $description === '') {
if ($name === '' || $description === '') {
return null;
}
if ($provisioning_type === 'qemu' && $proxmox_template_id < 1) {
return null;
}
if ($provisioning_type === 'lxc' && $proxmox_template_ref === '') {
return null;
}
return [
'template_key' => 'pve-template-' . $proxmox_template_id . '-' . sanitize_title($name),
'template_key' => $this->template_identity_key($provisioning_type, $proxmox_template_id, $proxmox_template_ref) . '-' . sanitize_title($name),
'name' => $name,
'description' => $description,
'os_type' => $this->posted_os_type(),
@@ -630,7 +708,9 @@ final class SPP_Admin_Page
'memory_mb' => max(128, absint($this->posted_string('spp_memory_mb'))),
'disk_gb' => max(1, absint($this->posted_string('spp_disk_gb'))),
'default_ttl_hours' => max(1, min(720, absint($this->posted_string('spp_default_ttl_hours')))),
'provisioning_type' => $provisioning_type,
'proxmox_template_id' => $proxmox_template_id,
'proxmox_template_ref' => $proxmox_template_ref,
'is_active' => $this->posted_string('spp_is_active') === '1',
];
}
@@ -642,6 +722,37 @@ final class SPP_Admin_Page
return in_array($os_type, ['LINUX', 'WINDOWS', 'APPLIANCE', 'OTHER'], true) ? $os_type : 'OTHER';
}
/**
* @param array<string, mixed> $template
*/
private function template_identity_label(array $template): string
{
$type = $this->normalise_template_type((string) ($template['provisioningType'] ?? 'qemu'));
if ($type === 'lxc') {
return 'LXC ' . (string) ($template['proxmoxTemplateRef'] ?? $template['templateRef'] ?? '');
}
return sprintf('PVE VMID %d', (int) ($template['proxmoxTemplateId'] ?? $template['vmId'] ?? 0));
}
private function template_type_label(string $type): string
{
return $this->normalise_template_type($type) === 'lxc' ? 'LXC container' : 'QEMU VM';
}
private function template_identity_key(string $provisioning_type, int $proxmox_template_id, string $proxmox_template_ref): string
{
return $this->normalise_template_type($provisioning_type) === 'lxc'
? 'lxc:' . $proxmox_template_ref
: 'qemu:' . $proxmox_template_id;
}
private function normalise_template_type(string $type): string
{
return strtolower($type) === 'lxc' ? 'lxc' : 'qemu';
}
/**
* @return array<int, int>
*/