- 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:
@@ -148,6 +148,8 @@
|
||||
return '<span class="spp-badge OWNER">OWNER</span>';
|
||||
};
|
||||
|
||||
const resourceTypeLabel = (type) => type === "lxc" ? "LXC" : "VM";
|
||||
|
||||
const actionButton = (permission, action, label, id, className = "", disabled = false) => {
|
||||
if (!can(permission)) {
|
||||
return "";
|
||||
@@ -195,7 +197,7 @@
|
||||
<div class="spp-header">
|
||||
<div>
|
||||
<h2 class="spp-title">Support Provisioning Portal</h2>
|
||||
<p class="spp-subtitle">Template-based VM provisioning for support work.</p>
|
||||
<p class="spp-subtitle">Template-based VM and LXC provisioning for support work.</p>
|
||||
${quotaLine()}
|
||||
</div>
|
||||
<div class="spp-tabs">
|
||||
@@ -215,7 +217,7 @@
|
||||
<div class="spp-panel">
|
||||
<table class="spp-table">
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Status</th><th>Access</th><th>Template</th><th>IP addresses</th><th>Expires</th><th></th></tr>
|
||||
<tr><th>Name</th><th>Status</th><th>Access</th><th>Type</th><th>Template</th><th>IP addresses</th><th>Expires</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${state.deployments.map((deployment) => `
|
||||
@@ -223,6 +225,7 @@
|
||||
<td><strong>${escapeHtml(deployment.name)}</strong></td>
|
||||
<td>${statusBadge(deployment.status)}</td>
|
||||
<td>${accessLabel(deployment)}</td>
|
||||
<td>${resourceTypeLabel(deployment.provisioningType)}</td>
|
||||
<td>${escapeHtml(deployment.templateName)}</td>
|
||||
<td>${ipList(deployment.ipAddresses)}</td>
|
||||
<td>${dateTime(deployment.expiresAt)}</td>
|
||||
@@ -243,6 +246,7 @@
|
||||
<p>${escapeHtml(template.description)}</p>
|
||||
<div class="spp-meta">
|
||||
<span>OS<strong>${template.osType}</strong></span>
|
||||
<span>Type<strong>${resourceTypeLabel(template.provisioningType)}</strong></span>
|
||||
<span>CPU<strong>${template.cpuCores} cores</strong></span>
|
||||
<span>Memory<strong>${template.memoryMb} MB</strong></span>
|
||||
<span>Disk<strong>${template.diskGb} GB</strong></span>
|
||||
@@ -263,7 +267,7 @@
|
||||
<form class="spp-form" id="spp-create-form">
|
||||
<label>Template
|
||||
<select class="spp-select" name="templateId" required>
|
||||
${state.templates.map((template) => `<option value="${template.id}">${escapeHtml(template.name)}</option>`).join("")}
|
||||
${state.templates.map((template) => `<option value="${template.id}">[${resourceTypeLabel(template.provisioningType)}] ${escapeHtml(template.name)}</option>`).join("")}
|
||||
</select>
|
||||
</label>
|
||||
<label>Deployment name
|
||||
@@ -313,9 +317,10 @@
|
||||
</div>
|
||||
<div class="spp-meta">
|
||||
<span>Template<strong>${escapeHtml(deployment.templateName)}</strong></span>
|
||||
<span>Type<strong>${resourceTypeLabel(deployment.provisioningType)}</strong></span>
|
||||
<span>Requested by<strong>${escapeHtml(deployment.requestedByName)}</strong></span>
|
||||
<span>Access<strong>${deployment.accessType ? escapeHtml(deployment.accessType) : "Owner"}</strong></span>
|
||||
<span>Proxmox VM ID<strong>${deployment.proxmoxVmId || "Pending"}</strong></span>
|
||||
<span>Proxmox ID<strong>${deployment.proxmoxResourceId || deployment.proxmoxVmId || "Pending"}</strong></span>
|
||||
<span>IP addresses<strong>${ipList(deployment.ipAddresses)}</strong></span>
|
||||
<span>CPU<strong>${deployment.cpuCores} cores</strong></span>
|
||||
<span>Memory<strong>${deployment.memoryMb} MB</strong></span>
|
||||
|
||||
@@ -6,7 +6,7 @@ if (!defined('ABSPATH')) {
|
||||
|
||||
final class SPP_Activator
|
||||
{
|
||||
private const DB_VERSION = '0.6.0';
|
||||
private const DB_VERSION = '0.7.0';
|
||||
|
||||
public static function activate(): void
|
||||
{
|
||||
@@ -15,6 +15,8 @@ final class SPP_Activator
|
||||
|
||||
add_option('spp_proxmox_mode', 'mock');
|
||||
add_option('spp_proxmox_node', 'pve-01');
|
||||
add_option('spp_lxc_rootfs_storage', '');
|
||||
add_option('spp_lxc_bridge', 'vmbr0');
|
||||
add_option('spp_mock_next_vm_id', 10000);
|
||||
add_option('spp_quota_user_memory_mb', 0);
|
||||
add_option('spp_quota_global_memory_mb', 0);
|
||||
@@ -63,7 +65,9 @@ final class SPP_Activator
|
||||
memory_mb int unsigned NOT NULL,
|
||||
disk_gb int unsigned NOT NULL,
|
||||
default_ttl_hours int unsigned NOT NULL,
|
||||
provisioning_type varchar(16) NOT NULL DEFAULT 'qemu',
|
||||
proxmox_template_id int unsigned NOT NULL,
|
||||
proxmox_template_ref varchar(255) NULL,
|
||||
is_active tinyint(1) NOT NULL DEFAULT 1,
|
||||
created_at datetime NOT NULL,
|
||||
updated_at datetime NOT NULL,
|
||||
@@ -75,6 +79,7 @@ final class SPP_Activator
|
||||
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
name varchar(160) NOT NULL,
|
||||
status varchar(32) NOT NULL,
|
||||
provisioning_type varchar(16) NOT NULL DEFAULT 'qemu',
|
||||
proxmox_vm_id int unsigned DEFAULT NULL,
|
||||
ip_addresses longtext NULL,
|
||||
error_message text NULL,
|
||||
@@ -92,6 +97,10 @@ final class SPP_Activator
|
||||
|
||||
$wpdb->query("ALTER TABLE {$deployments} MODIFY expires_at datetime NULL");
|
||||
|
||||
self::add_column_if_missing($templates, 'provisioning_type', "ALTER TABLE {$templates} ADD COLUMN provisioning_type varchar(16) NOT NULL DEFAULT 'qemu' AFTER default_ttl_hours");
|
||||
self::add_column_if_missing($templates, 'proxmox_template_ref', "ALTER TABLE {$templates} ADD COLUMN proxmox_template_ref varchar(255) NULL AFTER proxmox_template_id");
|
||||
self::add_column_if_missing($deployments, 'provisioning_type', "ALTER TABLE {$deployments} ADD COLUMN provisioning_type varchar(16) NOT NULL DEFAULT 'qemu' AFTER status");
|
||||
|
||||
$ip_column = $wpdb->get_var($wpdb->prepare("SHOW COLUMNS FROM {$deployments} LIKE %s", 'ip_addresses'));
|
||||
if ($ip_column === null) {
|
||||
$wpdb->query("ALTER TABLE {$deployments} ADD COLUMN ip_addresses longtext NULL AFTER proxmox_vm_id");
|
||||
@@ -138,6 +147,16 @@ final class SPP_Activator
|
||||
return $wpdb->prefix . 'spp_' . $name;
|
||||
}
|
||||
|
||||
private static function add_column_if_missing(string $table, string $column, string $sql): void
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$exists = $wpdb->get_var($wpdb->prepare("SHOW COLUMNS FROM {$table} LIKE %s", $column));
|
||||
if ($exists === null) {
|
||||
$wpdb->query($sql);
|
||||
}
|
||||
}
|
||||
|
||||
private static function seed_templates(): void
|
||||
{
|
||||
global $wpdb;
|
||||
@@ -154,7 +173,9 @@ final class SPP_Activator
|
||||
'memory_mb' => 2048,
|
||||
'disk_gb' => 24,
|
||||
'default_ttl_hours' => 72,
|
||||
'provisioning_type' => 'qemu',
|
||||
'proxmox_template_id' => 9001,
|
||||
'proxmox_template_ref' => null,
|
||||
],
|
||||
[
|
||||
'template_key' => 'windows-support-client',
|
||||
@@ -165,7 +186,9 @@ final class SPP_Activator
|
||||
'memory_mb' => 8192,
|
||||
'disk_gb' => 80,
|
||||
'default_ttl_hours' => 48,
|
||||
'provisioning_type' => 'qemu',
|
||||
'proxmox_template_id' => 9002,
|
||||
'proxmox_template_ref' => null,
|
||||
],
|
||||
[
|
||||
'template_key' => 'linux-utility-vm',
|
||||
@@ -176,7 +199,9 @@ final class SPP_Activator
|
||||
'memory_mb' => 2048,
|
||||
'disk_gb' => 32,
|
||||
'default_ttl_hours' => 168,
|
||||
'provisioning_type' => 'qemu',
|
||||
'proxmox_template_id' => 9003,
|
||||
'proxmox_template_ref' => null,
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
@@ -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>
|
||||
*/
|
||||
|
||||
@@ -19,7 +19,7 @@ final class SPP_Expiration_Service
|
||||
|
||||
if (!empty($deployment['proxmox_vm_id']) && $deployment['status'] !== 'STOPPED') {
|
||||
try {
|
||||
$this->proxmox->stop_vm((int) $deployment['proxmox_vm_id']);
|
||||
$this->proxmox->stop_instance((string) ($deployment['provisioning_type'] ?? 'qemu'), (int) $deployment['proxmox_vm_id']);
|
||||
} catch (Throwable $error) {
|
||||
$stop_error = $error->getMessage();
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -10,7 +10,10 @@ final class SPP_Mock_Proxmox_Client implements SPP_Proxmox_Client
|
||||
{
|
||||
return [
|
||||
[
|
||||
'provisioningType' => 'qemu',
|
||||
'vmId' => 9001,
|
||||
'templateRef' => '',
|
||||
'storage' => '',
|
||||
'name' => 'Turnkey PBX Test Appliance',
|
||||
'cpuCores' => 2,
|
||||
'memoryMb' => 2048,
|
||||
@@ -18,7 +21,10 @@ final class SPP_Mock_Proxmox_Client implements SPP_Proxmox_Client
|
||||
'status' => 'stopped',
|
||||
],
|
||||
[
|
||||
'provisioningType' => 'qemu',
|
||||
'vmId' => 9002,
|
||||
'templateRef' => '',
|
||||
'storage' => '',
|
||||
'name' => 'Windows Support Client',
|
||||
'cpuCores' => 4,
|
||||
'memoryMb' => 8192,
|
||||
@@ -26,51 +32,80 @@ final class SPP_Mock_Proxmox_Client implements SPP_Proxmox_Client
|
||||
'status' => 'stopped',
|
||||
],
|
||||
[
|
||||
'provisioningType' => 'qemu',
|
||||
'vmId' => 9003,
|
||||
'templateRef' => '',
|
||||
'storage' => '',
|
||||
'name' => 'Linux Utility VM',
|
||||
'cpuCores' => 2,
|
||||
'memoryMb' => 2048,
|
||||
'diskGb' => 32,
|
||||
'status' => 'stopped',
|
||||
],
|
||||
[
|
||||
'provisioningType' => 'lxc',
|
||||
'vmId' => 0,
|
||||
'templateRef' => 'local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst',
|
||||
'storage' => 'local',
|
||||
'name' => 'debian-12-standard',
|
||||
'cpuCores' => 1,
|
||||
'memoryMb' => 1024,
|
||||
'diskGb' => 8,
|
||||
'status' => 'available',
|
||||
],
|
||||
[
|
||||
'provisioningType' => 'lxc',
|
||||
'vmId' => 0,
|
||||
'templateRef' => 'local:vztmpl/ubuntu-24.04-standard_24.04-2_amd64.tar.zst',
|
||||
'storage' => 'local',
|
||||
'name' => 'ubuntu-24.04-standard',
|
||||
'cpuCores' => 1,
|
||||
'memoryMb' => 1024,
|
||||
'diskGb' => 8,
|
||||
'status' => 'available',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function clone_vm(array $input): array
|
||||
public function provision_instance(array $input): array
|
||||
{
|
||||
$next_id = (int) get_option('spp_mock_next_vm_id', 10000);
|
||||
update_option('spp_mock_next_vm_id', $next_id + 1, false);
|
||||
update_option('spp_mock_vm_status_' . $next_id, 'stopped', false);
|
||||
update_option('spp_mock_vm_type_' . $next_id, $this->normalise_type((string) ($input['provisioning_type'] ?? 'qemu')), false);
|
||||
update_option('spp_mock_vm_tags_' . $next_id, is_array($input['tags'] ?? null) ? array_values($input['tags']) : [], false);
|
||||
update_option('spp_mock_vm_ips_' . $next_id, [sprintf('192.0.2.%d', (($next_id - 10000) % 200) + 10)], false);
|
||||
|
||||
return ['vm_id' => $next_id];
|
||||
}
|
||||
|
||||
public function start_vm(int $vm_id): void
|
||||
public function start_instance(string $type, int $vm_id): void
|
||||
{
|
||||
$this->ensure_vm($vm_id);
|
||||
update_option('spp_mock_vm_status_' . $vm_id, 'running', false);
|
||||
}
|
||||
|
||||
public function stop_vm(int $vm_id): void
|
||||
public function stop_instance(string $type, int $vm_id): void
|
||||
{
|
||||
$this->ensure_vm($vm_id);
|
||||
update_option('spp_mock_vm_status_' . $vm_id, 'stopped', false);
|
||||
}
|
||||
|
||||
public function delete_vm(int $vm_id): void
|
||||
public function delete_instance(string $type, int $vm_id): void
|
||||
{
|
||||
$this->ensure_vm($vm_id);
|
||||
delete_option('spp_mock_vm_status_' . $vm_id);
|
||||
delete_option('spp_mock_vm_type_' . $vm_id);
|
||||
delete_option('spp_mock_vm_tags_' . $vm_id);
|
||||
delete_option('spp_mock_vm_ips_' . $vm_id);
|
||||
}
|
||||
|
||||
public function get_status(int $vm_id): string
|
||||
public function get_status(string $type, int $vm_id): string
|
||||
{
|
||||
return (string) get_option('spp_mock_vm_status_' . $vm_id, 'unknown');
|
||||
}
|
||||
|
||||
public function get_ip_addresses(int $vm_id): array
|
||||
public function get_ip_addresses(string $type, int $vm_id): array
|
||||
{
|
||||
$ips = get_option('spp_mock_vm_ips_' . $vm_id, []);
|
||||
|
||||
@@ -80,7 +115,12 @@ final class SPP_Mock_Proxmox_Client implements SPP_Proxmox_Client
|
||||
private function ensure_vm(int $vm_id): void
|
||||
{
|
||||
if (get_option('spp_mock_vm_status_' . $vm_id, null) === null) {
|
||||
throw new RuntimeException('Mock Proxmox VM does not exist.');
|
||||
throw new RuntimeException('Mock Proxmox resource does not exist.');
|
||||
}
|
||||
}
|
||||
|
||||
private function normalise_type(string $type): string
|
||||
{
|
||||
return strtolower($type) === 'lxc' ? 'lxc' : 'qemu';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,8 @@ final class SPP_Plugin
|
||||
'token_id' => (string) get_option('spp_proxmox_token_id', ''),
|
||||
'token_secret' => (string) get_option('spp_proxmox_token_secret', ''),
|
||||
'node' => (string) get_option('spp_proxmox_node', ''),
|
||||
'lxc_rootfs_storage' => (string) get_option('spp_lxc_rootfs_storage', ''),
|
||||
'lxc_bridge' => (string) get_option('spp_lxc_bridge', 'vmbr0'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -157,6 +157,7 @@ final class SPP_Repository
|
||||
$wpdb->insert($table, [
|
||||
'name' => $name,
|
||||
'status' => 'STOPPED',
|
||||
'provisioning_type' => $this->normalise_template_type((string) ($template['provisioning_type'] ?? 'qemu')),
|
||||
'proxmox_vm_id' => $vm_id,
|
||||
'ip_addresses' => wp_json_encode(array_values($ip_addresses)),
|
||||
'expires_at' => $expires_at,
|
||||
@@ -169,6 +170,7 @@ final class SPP_Repository
|
||||
$deployment_id = (int) $wpdb->insert_id;
|
||||
$this->audit('DEPLOYMENT_CREATED', 'deployment', $deployment_id, $actor_id, [
|
||||
'template_id' => (int) $template['id'],
|
||||
'provisioning_type' => $this->normalise_template_type((string) ($template['provisioning_type'] ?? 'qemu')),
|
||||
'proxmox_vm_id' => $vm_id,
|
||||
'ip_addresses' => $ip_addresses,
|
||||
'ttl_hours' => $ttl_hours,
|
||||
@@ -439,15 +441,11 @@ final class SPP_Repository
|
||||
|
||||
$table = SPP_Activator::table('templates');
|
||||
$now = current_time('mysql');
|
||||
$template_key = $this->unique_template_key((string) $data['template_key'], (int) $data['proxmox_template_id']);
|
||||
$existing_id = (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT id FROM {$table} WHERE proxmox_template_id = %d OR template_key = %s ORDER BY proxmox_template_id = %d DESC LIMIT 1",
|
||||
(int) $data['proxmox_template_id'],
|
||||
$template_key,
|
||||
(int) $data['proxmox_template_id']
|
||||
)
|
||||
);
|
||||
$provisioning_type = $this->normalise_template_type((string) ($data['provisioning_type'] ?? 'qemu'));
|
||||
$proxmox_template_id = $provisioning_type === 'qemu' ? absint($data['proxmox_template_id']) : 0;
|
||||
$proxmox_template_ref = $provisioning_type === 'lxc' ? sanitize_text_field((string) ($data['proxmox_template_ref'] ?? '')) : null;
|
||||
$template_key = $this->unique_template_key((string) $data['template_key'], $provisioning_type, $proxmox_template_id, (string) $proxmox_template_ref);
|
||||
$existing_id = $this->template_existing_id($provisioning_type, $proxmox_template_id, (string) $proxmox_template_ref, $template_key);
|
||||
|
||||
$row = [
|
||||
'template_key' => $template_key,
|
||||
@@ -458,7 +456,9 @@ final class SPP_Repository
|
||||
'memory_mb' => max(128, absint($data['memory_mb'])),
|
||||
'disk_gb' => max(1, absint($data['disk_gb'])),
|
||||
'default_ttl_hours' => max(1, min(720, absint($data['default_ttl_hours']))),
|
||||
'proxmox_template_id' => absint($data['proxmox_template_id']),
|
||||
'provisioning_type' => $provisioning_type,
|
||||
'proxmox_template_id' => $proxmox_template_id,
|
||||
'proxmox_template_ref' => $proxmox_template_ref,
|
||||
'is_active' => 1,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
@@ -474,7 +474,9 @@ final class SPP_Repository
|
||||
}
|
||||
|
||||
$this->audit($action, 'template', $template_id, $actor_id, [
|
||||
'provisioning_type' => (string) $row['provisioning_type'],
|
||||
'proxmox_template_id' => (int) $row['proxmox_template_id'],
|
||||
'proxmox_template_ref' => (string) ($row['proxmox_template_ref'] ?? ''),
|
||||
'name' => (string) $row['name'],
|
||||
]);
|
||||
|
||||
@@ -489,6 +491,9 @@ final class SPP_Repository
|
||||
global $wpdb;
|
||||
|
||||
$table = SPP_Activator::table('templates');
|
||||
$provisioning_type = $this->normalise_template_type((string) ($data['provisioning_type'] ?? 'qemu'));
|
||||
$proxmox_template_id = $provisioning_type === 'qemu' ? absint($data['proxmox_template_id']) : 0;
|
||||
$proxmox_template_ref = $provisioning_type === 'lxc' ? sanitize_text_field((string) ($data['proxmox_template_ref'] ?? '')) : null;
|
||||
$row = [
|
||||
'name' => sanitize_text_field((string) $data['name']),
|
||||
'description' => sanitize_textarea_field((string) $data['description']),
|
||||
@@ -497,7 +502,9 @@ final class SPP_Repository
|
||||
'memory_mb' => max(128, absint($data['memory_mb'])),
|
||||
'disk_gb' => max(1, absint($data['disk_gb'])),
|
||||
'default_ttl_hours' => max(1, min(720, absint($data['default_ttl_hours']))),
|
||||
'proxmox_template_id' => absint($data['proxmox_template_id']),
|
||||
'provisioning_type' => $provisioning_type,
|
||||
'proxmox_template_id' => $proxmox_template_id,
|
||||
'proxmox_template_ref' => $proxmox_template_ref,
|
||||
'is_active' => empty($data['is_active']) ? 0 : 1,
|
||||
'updated_at' => current_time('mysql'),
|
||||
];
|
||||
@@ -505,7 +512,9 @@ final class SPP_Repository
|
||||
$wpdb->update($table, $row, ['id' => $id]);
|
||||
|
||||
$this->audit('TEMPLATE_UPDATED', 'template', $id, $actor_id, [
|
||||
'provisioning_type' => (string) $row['provisioning_type'],
|
||||
'proxmox_template_id' => (int) $row['proxmox_template_id'],
|
||||
'proxmox_template_ref' => (string) ($row['proxmox_template_ref'] ?? ''),
|
||||
'name' => (string) $row['name'],
|
||||
'is_active' => (int) $row['is_active'],
|
||||
]);
|
||||
@@ -529,16 +538,28 @@ final class SPP_Repository
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, int>
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function active_proxmox_template_ids(): array
|
||||
public function active_template_identity_keys(): array
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$table = SPP_Activator::table('templates');
|
||||
$ids = $wpdb->get_col("SELECT proxmox_template_id FROM {$table} WHERE is_active = 1");
|
||||
$rows = $wpdb->get_results(
|
||||
"SELECT provisioning_type, proxmox_template_id, proxmox_template_ref FROM {$table} WHERE is_active = 1",
|
||||
ARRAY_A
|
||||
);
|
||||
$keys = [];
|
||||
|
||||
return array_values(array_map('intval', is_array($ids) ? $ids : []));
|
||||
foreach (is_array($rows) ? $rows : [] as $row) {
|
||||
$keys[] = $this->template_identity_key(
|
||||
(string) ($row['provisioning_type'] ?? 'qemu'),
|
||||
(int) ($row['proxmox_template_id'] ?? 0),
|
||||
(string) ($row['proxmox_template_ref'] ?? '')
|
||||
);
|
||||
}
|
||||
|
||||
return array_values(array_unique($keys));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -617,17 +638,58 @@ final class SPP_Repository
|
||||
return is_array($row) ? $this->template_dto($row) : null;
|
||||
}
|
||||
|
||||
private function unique_template_key(string $raw_key, int $proxmox_template_id): string
|
||||
private function unique_template_key(string $raw_key, string $provisioning_type, int $proxmox_template_id, string $proxmox_template_ref): string
|
||||
{
|
||||
$key = sanitize_title($raw_key);
|
||||
|
||||
if ($key === '') {
|
||||
$key = 'pve-template-' . $proxmox_template_id;
|
||||
$key = $this->template_identity_key($provisioning_type, $proxmox_template_id, $proxmox_template_ref);
|
||||
}
|
||||
|
||||
return substr($key, 0, 80);
|
||||
}
|
||||
|
||||
private function template_existing_id(string $provisioning_type, int $proxmox_template_id, string $proxmox_template_ref, string $template_key): int
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$table = SPP_Activator::table('templates');
|
||||
|
||||
if ($provisioning_type === 'lxc') {
|
||||
return (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT id FROM {$table}
|
||||
WHERE (provisioning_type = 'lxc' AND proxmox_template_ref = %s) OR template_key = %s
|
||||
LIMIT 1",
|
||||
$proxmox_template_ref,
|
||||
$template_key
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT id FROM {$table}
|
||||
WHERE (provisioning_type = 'qemu' AND proxmox_template_id = %d) OR template_key = %s
|
||||
LIMIT 1",
|
||||
$proxmox_template_id,
|
||||
$template_key
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $metadata
|
||||
*/
|
||||
@@ -661,7 +723,9 @@ final class SPP_Repository
|
||||
'memoryMb' => (int) $row['memory_mb'],
|
||||
'diskGb' => (int) $row['disk_gb'],
|
||||
'defaultTtlHours' => (int) $row['default_ttl_hours'],
|
||||
'provisioningType' => $this->normalise_template_type((string) ($row['provisioning_type'] ?? 'qemu')),
|
||||
'proxmoxTemplateId' => (int) $row['proxmox_template_id'],
|
||||
'proxmoxTemplateRef' => (string) ($row['proxmox_template_ref'] ?? ''),
|
||||
'isActive' => (int) $row['is_active'] === 1,
|
||||
];
|
||||
}
|
||||
@@ -676,6 +740,7 @@ final class SPP_Repository
|
||||
'id' => (int) $row['id'],
|
||||
'name' => (string) $row['name'],
|
||||
'status' => (string) $row['status'],
|
||||
'provisioningType' => $this->normalise_template_type((string) ($row['provisioning_type'] ?? 'qemu')),
|
||||
'templateName' => (string) $row['template_name'],
|
||||
'requestedById' => (int) $row['requested_by'],
|
||||
'requestedByName' => (string) $row['requested_by_name'],
|
||||
@@ -695,6 +760,8 @@ final class SPP_Repository
|
||||
return array_merge($this->deployment_summary_dto($row), [
|
||||
'templateId' => (int) $row['template_id'],
|
||||
'proxmoxVmId' => isset($row['proxmox_vm_id']) ? (int) $row['proxmox_vm_id'] : null,
|
||||
'proxmoxResourceType' => $this->normalise_template_type((string) ($row['provisioning_type'] ?? 'qemu')),
|
||||
'proxmoxResourceId' => isset($row['proxmox_vm_id']) ? (int) $row['proxmox_vm_id'] : null,
|
||||
'cpuCores' => (int) $row['cpu_cores'],
|
||||
'memoryMb' => (int) $row['memory_mb'],
|
||||
'diskGb' => (int) $row['disk_gb'],
|
||||
|
||||
@@ -222,22 +222,27 @@ final class SPP_REST_Controller
|
||||
return $quota_error;
|
||||
}
|
||||
|
||||
$provisioning_type = $this->normalise_template_type((string) ($template['provisioning_type'] ?? 'qemu'));
|
||||
|
||||
try {
|
||||
$clone = $this->proxmox->clone_vm([
|
||||
$instance = $this->proxmox->provision_instance([
|
||||
'provisioning_type' => $provisioning_type,
|
||||
'template_vm_id' => (int) $template['proxmox_template_id'],
|
||||
'lxc_template_ref' => (string) ($template['proxmox_template_ref'] ?? ''),
|
||||
'name' => $name,
|
||||
'cpu_cores' => (int) $template['cpu_cores'],
|
||||
'memory_mb' => (int) $template['memory_mb'],
|
||||
'disk_gb' => (int) $template['disk_gb'],
|
||||
'tags' => $this->deployment_tags(wp_get_current_user()),
|
||||
]);
|
||||
|
||||
$vm_id = (int) $clone['vm_id'];
|
||||
$vm_id = (int) $instance['vm_id'];
|
||||
$deployment = $this->repository->create_deployment(
|
||||
$template,
|
||||
$name,
|
||||
$ttl_hours,
|
||||
$vm_id,
|
||||
$this->safe_ip_addresses($vm_id),
|
||||
$this->safe_ip_addresses($provisioning_type, $vm_id),
|
||||
get_current_user_id()
|
||||
);
|
||||
} catch (Throwable $error) {
|
||||
@@ -278,17 +283,17 @@ final class SPP_REST_Controller
|
||||
|
||||
public function start_deployment(WP_REST_Request $request): WP_REST_Response|WP_Error
|
||||
{
|
||||
return $this->apply_lifecycle_action((int) $request['id'], 'RUNNING', 'DEPLOYMENT_STARTED', 'start_vm');
|
||||
return $this->apply_lifecycle_action((int) $request['id'], 'RUNNING', 'DEPLOYMENT_STARTED', 'start');
|
||||
}
|
||||
|
||||
public function stop_deployment(WP_REST_Request $request): WP_REST_Response|WP_Error
|
||||
{
|
||||
return $this->apply_lifecycle_action((int) $request['id'], 'STOPPED', 'DEPLOYMENT_STOPPED', 'stop_vm');
|
||||
return $this->apply_lifecycle_action((int) $request['id'], 'STOPPED', 'DEPLOYMENT_STOPPED', 'stop');
|
||||
}
|
||||
|
||||
public function delete_deployment(WP_REST_Request $request): WP_REST_Response|WP_Error
|
||||
{
|
||||
return $this->apply_lifecycle_action((int) $request['id'], 'DELETED', 'DEPLOYMENT_DELETED', 'delete_vm');
|
||||
return $this->apply_lifecycle_action((int) $request['id'], 'DELETED', 'DEPLOYMENT_DELETED', 'delete');
|
||||
}
|
||||
|
||||
public function prolong_deployment(WP_REST_Request $request): WP_REST_Response|WP_Error
|
||||
@@ -351,13 +356,16 @@ final class SPP_REST_Controller
|
||||
}
|
||||
|
||||
if (empty($record['proxmox_vm_id'])) {
|
||||
return new WP_Error('spp_missing_vm_id', 'Deployment is missing a Proxmox VM id.', ['status' => 409]);
|
||||
return new WP_Error('spp_missing_vm_id', 'Deployment is missing a Proxmox resource id.', ['status' => 409]);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->repository->update_deployment_ips(
|
||||
$id,
|
||||
$this->proxmox->get_ip_addresses((int) $record['proxmox_vm_id']),
|
||||
$this->proxmox->get_ip_addresses(
|
||||
(string) ($record['provisioning_type'] ?? 'qemu'),
|
||||
(int) $record['proxmox_vm_id']
|
||||
),
|
||||
get_current_user_id()
|
||||
);
|
||||
} catch (Throwable $error) {
|
||||
@@ -442,11 +450,11 @@ final class SPP_REST_Controller
|
||||
return new WP_Error('spp_not_found', 'Deployment not found.', ['status' => 404]);
|
||||
}
|
||||
|
||||
if ($method === 'delete_vm' && !$this->user_can_delete_deployment($id)) {
|
||||
if ($method === 'delete' && !$this->user_can_delete_deployment($id)) {
|
||||
return new WP_Error('spp_forbidden', 'Only the owner or a deployment manager can delete this deployment.', ['status' => 403]);
|
||||
}
|
||||
|
||||
if ($method === 'start_vm' && $record['status'] === 'EXPIRED') {
|
||||
if ($method === 'start' && $record['status'] === 'EXPIRED') {
|
||||
return new WP_Error(
|
||||
'spp_expired_deployment',
|
||||
'This deployment is expired. Prolong its TTL before starting it again.',
|
||||
@@ -455,27 +463,33 @@ final class SPP_REST_Controller
|
||||
}
|
||||
|
||||
if (empty($record['proxmox_vm_id'])) {
|
||||
return new WP_Error('spp_missing_vm_id', 'Deployment is missing a Proxmox VM id.', ['status' => 409]);
|
||||
return new WP_Error('spp_missing_vm_id', 'Deployment is missing a Proxmox resource id.', ['status' => 409]);
|
||||
}
|
||||
|
||||
$provisioning_type = $this->normalise_template_type((string) ($record['provisioning_type'] ?? 'qemu'));
|
||||
|
||||
try {
|
||||
$this->proxmox->{$method}((int) $record['proxmox_vm_id']);
|
||||
if ($method === 'start_vm') {
|
||||
if ($method === 'start') {
|
||||
$this->proxmox->start_instance($provisioning_type, (int) $record['proxmox_vm_id']);
|
||||
$this->repository->update_deployment_status_and_ips(
|
||||
$id,
|
||||
$status,
|
||||
$this->safe_ip_addresses((int) $record['proxmox_vm_id']),
|
||||
$this->safe_ip_addresses($provisioning_type, (int) $record['proxmox_vm_id']),
|
||||
$audit_action,
|
||||
get_current_user_id()
|
||||
);
|
||||
} elseif ($method === 'stop') {
|
||||
$this->proxmox->stop_instance($provisioning_type, (int) $record['proxmox_vm_id']);
|
||||
$this->repository->update_deployment_status($id, $status, $audit_action, get_current_user_id());
|
||||
} else {
|
||||
$this->proxmox->delete_instance($provisioning_type, (int) $record['proxmox_vm_id']);
|
||||
$this->repository->update_deployment_status($id, $status, $audit_action, get_current_user_id());
|
||||
}
|
||||
} catch (Throwable $error) {
|
||||
return new WP_Error('spp_proxmox_error', $error->getMessage(), ['status' => 502]);
|
||||
}
|
||||
|
||||
if ($method === 'delete_vm') {
|
||||
if ($method === 'delete') {
|
||||
return rest_ensure_response(['deleted' => true]);
|
||||
}
|
||||
|
||||
@@ -553,12 +567,32 @@ final class SPP_REST_Controller
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function safe_ip_addresses(int $vm_id): array
|
||||
private function safe_ip_addresses(string $provisioning_type, int $vm_id): array
|
||||
{
|
||||
try {
|
||||
return $this->proxmox->get_ip_addresses($vm_id);
|
||||
return $this->proxmox->get_ip_addresses($provisioning_type, $vm_id);
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function deployment_tags(WP_User $user): array
|
||||
{
|
||||
$login = $user->user_login !== '' ? $user->user_login : $user->display_name;
|
||||
$user_tag = strtolower(sanitize_title($login));
|
||||
|
||||
if ($user_tag === '') {
|
||||
$user_tag = (string) $user->ID;
|
||||
}
|
||||
|
||||
return ['support-portal', 'user-' . $user_tag];
|
||||
}
|
||||
|
||||
private function normalise_template_type(string $type): string
|
||||
{
|
||||
return strtolower($type) === 'lxc' ? 'lxc' : 'qemu';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,26 +7,26 @@ if (!defined('ABSPATH')) {
|
||||
interface SPP_Proxmox_Client
|
||||
{
|
||||
/**
|
||||
* @return array<int, array{vmId:int,name:string,cpuCores:int,memoryMb:int,diskGb:int,status:string}>
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function list_templates(): array;
|
||||
|
||||
/**
|
||||
* @param array<string, int|string> $input
|
||||
* @param array<string, mixed> $input
|
||||
* @return array{vm_id:int}
|
||||
*/
|
||||
public function clone_vm(array $input): array;
|
||||
public function provision_instance(array $input): array;
|
||||
|
||||
public function start_vm(int $vm_id): void;
|
||||
public function start_instance(string $type, int $vm_id): void;
|
||||
|
||||
public function stop_vm(int $vm_id): void;
|
||||
public function stop_instance(string $type, int $vm_id): void;
|
||||
|
||||
public function delete_vm(int $vm_id): void;
|
||||
public function delete_instance(string $type, int $vm_id): void;
|
||||
|
||||
public function get_status(int $vm_id): string;
|
||||
public function get_status(string $type, int $vm_id): string;
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function get_ip_addresses(int $vm_id): array;
|
||||
public function get_ip_addresses(string $type, int $vm_id): array;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<?php
|
||||
/**
|
||||
* Plugin Name: Support Provisioning Portal
|
||||
* Description: Internal self-service portal for provisioning standardized Proxmox VE VMs.
|
||||
* Version: 0.6.0
|
||||
* Description: Internal self-service portal for provisioning standardized Proxmox VE VMs and LXC containers.
|
||||
* Version: 0.7.0
|
||||
* Author: Internal Support
|
||||
* Requires PHP: 8.0
|
||||
* Requires at least: 6.2
|
||||
@@ -13,7 +13,7 @@ if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
define('SPP_VERSION', '0.6.0');
|
||||
define('SPP_VERSION', '0.7.0');
|
||||
define('SPP_PLUGIN_FILE', __FILE__);
|
||||
define('SPP_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
||||
define('SPP_PLUGIN_URL', plugin_dir_url(__FILE__));
|
||||
|
||||
Reference in New Issue
Block a user