- 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

@@ -1,5 +1,18 @@
# Changelog # Changelog
## 0.7.0 - 2026-04-24
- 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.
- Hardened admin template/settings saves against array-shaped POST values.
- Kept QEMU template management usable if the Proxmox token cannot list LXC-capable storages.
- Documented LXC setup, storage permissions, and the new Proxmox settings.
## 0.6.0 - 2026-04-24 ## 0.6.0 - 2026-04-24
- Added deployment sharing with a dedicated `wp_spp_deployment_shares` table. - Added deployment sharing with a dedicated `wp_spp_deployment_shares` table.

View File

@@ -1,6 +1,6 @@
# Support Provisioning Portal # Support Provisioning Portal
Internal WordPress plugin for support staff to provision standardized Proxmox VE VMs without direct Proxmox access. Internal WordPress plugin for support staff to provision standardized Proxmox VE VMs and LXC containers without direct Proxmox access.
The application runs as a WordPress plugin and exposes both: The application runs as a WordPress plugin and exposes both:
@@ -35,12 +35,12 @@ The application runs as a WordPress plugin and exposes both:
- Audit log rows for every mutating action - Audit log rows for every mutating action
- Optional `Never expire` deployments - Optional `Never expire` deployments
- Per-user and global RAM contingents - Per-user and global RAM contingents
- IP address display for deployments when available from the mock adapter or Proxmox guest agent - IP address display for deployments when available from the mock adapter, Proxmox guest agent, or LXC interfaces
- Manual IP refresh action for deployments - Manual IP refresh action for deployments
- Non-destructive expiration: expired VMs are stopped and locked, not deleted - Non-destructive expiration: expired VMs and containers are stopped and locked, not deleted
- Plugin-owned per-user rights for portal access, lifecycle actions, settings, and user-rights management - Plugin-owned per-user rights for portal access, lifecycle actions, settings, and user-rights management
- Admin-panel user rights management for WordPress and SSO-created users - Admin-panel user rights management for WordPress and SSO-created users
- Admin-panel template management for importing QEMU templates from the configured Proxmox node - Admin-panel template management for importing QEMU VM and LXC templates from the configured Proxmox node
- Owner/private deployment visibility with explicit per-deployment sharing - Owner/private deployment visibility with explicit per-deployment sharing
- Minimal admin/frontend UI for: - Minimal admin/frontend UI for:
- deployment dashboard - deployment dashboard
@@ -108,9 +108,9 @@ Deployment creation supports either a TTL in hours or **Never expire**.
When a deployment reaches its TTL: When a deployment reaches its TTL:
- WordPress cron and REST requests detect the expiration. - WordPress cron and REST requests detect the expiration.
- The plugin attempts to stop the VM. - The plugin attempts to stop the VM or container.
- The deployment status becomes `EXPIRED`. - The deployment status becomes `EXPIRED`.
- The VM cannot be started again until the user prolongs its TTL. - The deployment cannot be started again until the user prolongs its TTL.
- The user can either prolong the TTL or delete the deployment. - The user can either prolong the TTL or delete the deployment.
- Deleted deployments are hidden from the active deployment list, but audit rows remain. - Deleted deployments are hidden from the active deployment list, but audit rows remain.
@@ -125,9 +125,9 @@ Per-user overrides are available in the **User Rights** section on the **Support
## IP Addresses ## IP Addresses
Deployments include an `ipAddresses` field in REST responses and show those addresses in the UI. In mock mode, deterministic documentation-range IPs are assigned. In HTTP mode, the plugin reads IPs from the Proxmox guest-agent `network-get-interfaces` endpoint when available. Deployments include an `ipAddresses` field in REST responses and show those addresses in the UI. In mock mode, deterministic documentation-range IPs are assigned. In HTTP mode, the plugin reads VM IPs from the Proxmox guest-agent `network-get-interfaces` endpoint when available and reads LXC IPs from the container interfaces endpoint.
If the guest agent reports IPs only after boot, use **Refresh IPs** in the deployment detail view. If IPs are reported only after boot, use **Refresh IPs** in the deployment detail view.
## Proxmox Settings ## Proxmox Settings
@@ -138,6 +138,8 @@ The plugin defaults to mock mode. Configure live Proxmox access from the **Suppo
- Token ID, for example `user@realm!token-name` - Token ID, for example `user@realm!token-name`
- Token Secret. The saved secret is not rendered back into the settings form; leave the field blank to keep it unchanged. - Token Secret. The saved secret is not rendered back into the settings form; leave the field blank to keep it unchanged.
- Node, for example `pve-01` - Node, for example `pve-01`
- LXC rootfs storage, for example `local-lvm`. This is required before live LXC deployments can be created.
- LXC network bridge, for example `vmbr0`. Leave empty if containers should be created without an automatic DHCP `net0`.
No Proxmox secrets are committed to the repository. No Proxmox secrets are committed to the repository.
@@ -147,16 +149,17 @@ Use this section when moving from mock mode to a real Proxmox VE node.
### 1. Prepare Proxmox Templates ### 1. Prepare Proxmox Templates
The plugin only provisions from approved templates. Proxmox remains the source for actual VM templates, while the plugin stores an approved template policy row with display name, OS type, CPU, RAM, disk, default TTL, and Proxmox template VMID. The plugin only provisions from approved templates. Proxmox remains the source for actual QEMU VM templates and LXC OS templates, while the plugin stores an approved template policy row with display name, OS type, CPU, RAM, disk, default TTL, template type, and either a Proxmox template VMID or an LXC `ostemplate` reference.
Open the **Templates** section on the **Support Provisioning** admin page to: Open the **Templates** section on the **Support Provisioning** admin page to:
- see approved plugin templates - see approved plugin templates
- import QEMU templates from the configured Proxmox node - import QEMU VM templates from the configured Proxmox node
- import LXC templates from the configured node's storage `vztmpl` content
- edit the policy values used during provisioning - edit the policy values used during provisioning
- remove templates from new provisioning without breaking historical deployment records - remove templates from new provisioning without breaking historical deployment records
The plugin lists QEMU templates from the configured node via the Proxmox API. It does not provision from LXC templates. The plugin lists QEMU templates from `/nodes/{node}/qemu` and LXC templates from storage content with `content=vztmpl`.
Seeded example template IDs: Seeded example template IDs:
@@ -166,9 +169,9 @@ Seeded example template IDs:
| Windows Support Client | `9002` | | Windows Support Client | `9002` |
| Linux Utility VM | `9003` | | Linux Utility VM | `9003` |
The fastest first test is to create one real Proxmox QEMU template, then import it from **Support Provisioning > Templates**. The fastest first test is to create one real Proxmox QEMU template or download one LXC OS template, then import it from **Support Provisioning > Templates**.
Template requirements: QEMU template requirements:
- The VM must be converted to a Proxmox template. - The VM must be converted to a Proxmox template.
- The template must exist on the Proxmox node configured in the plugin. - The template must exist on the Proxmox node configured in the plugin.
@@ -176,6 +179,13 @@ Template requirements:
- CPU and memory are set by the plugin after clone based on the approved template policy row. - CPU and memory are set by the plugin after clone based on the approved template policy row.
- For IP address display, install and enable `qemu-guest-agent` inside the guest and enable the guest agent option on the VM/template. - For IP address display, install and enable `qemu-guest-agent` inside the guest and enable the guest agent option on the VM/template.
LXC template requirements:
- The OS template must be visible as `vztmpl` storage content on the configured Proxmox node.
- Configure **LXC rootfs storage** in the plugin before deploying live containers.
- Configure **LXC network bridge** if containers should receive a DHCP `eth0` during creation.
- The rootfs storage must have enough free capacity for the approved template disk size.
### 2. Create A Dedicated Proxmox API User ### 2. Create A Dedicated Proxmox API User
In Proxmox, create a dedicated user instead of using `root@pam` for the plugin. In Proxmox, create a dedicated user instead of using `root@pam` for the plugin.
@@ -214,12 +224,16 @@ The plugin needs permissions for:
- getting the next VMID - getting the next VMID
- listing QEMU templates on the configured node - listing QEMU templates on the configured node
- listing LXC template content on the node's storages
- cloning a template VM - cloning a template VM
- creating LXC containers from an OS template
- changing CPU and memory after clone - changing CPU and memory after clone
- starting and stopping VMs - setting Proxmox tags on created VMs and containers
- deleting VMs - starting and stopping VMs and containers
- reading VM status - deleting VMs and containers
- reading VM and container status
- reading guest-agent network interfaces for IP display - reading guest-agent network interfaces for IP display
- reading LXC interfaces for IP display
Practical first-test option: Practical first-test option:
@@ -236,7 +250,7 @@ pveum aclmod /storage/local-lvm -user wp-support@pve -role PVEDatastoreAdmin
For a tighter production setup, create a custom role with only the required privileges and assign it to the API token or user: For a tighter production setup, create a custom role with only the required privileges and assign it to the API token or user:
```bash ```bash
pveum role add SupportProvisioner -privs "VM.Allocate VM.Audit VM.Clone VM.Config.CPU VM.Config.Memory VM.PowerMgmt Datastore.AllocateSpace Datastore.Audit Sys.Audit" pveum role add SupportProvisioner -privs "VM.Allocate VM.Audit VM.Clone VM.Config.CPU VM.Config.Disk VM.Config.Memory VM.Config.Network VM.Config.Options VM.PowerMgmt Datastore.AllocateSpace Datastore.Audit Sys.Audit"
pveum aclmod / -user wp-support@pve -role SupportProvisioner pveum aclmod / -user wp-support@pve -role SupportProvisioner
``` ```
@@ -268,6 +282,8 @@ In WordPress admin, open **Support Provisioning** and use the **Proxmox Settings
| Token ID | `wp-support@pve!support-portal` | | Token ID | `wp-support@pve!support-portal` |
| Token Secret | token secret from Proxmox | | Token Secret | token secret from Proxmox |
| Node | Proxmox node name, for example `pve-01` | | Node | Proxmox node name, for example `pve-01` |
| LXC rootfs storage | target container rootfs storage, for example `local-lvm` |
| LXC network bridge | bridge for DHCP `net0`, for example `vmbr0` |
The **Node** value must match the Proxmox node name exactly as shown in the Proxmox UI under **Datacenter**. The **Node** value must match the Proxmox node name exactly as shown in the Proxmox UI under **Datacenter**.
@@ -278,9 +294,10 @@ Before creating a deployment:
- Confirm WordPress can reach Proxmox on TCP `8006`. - Confirm WordPress can reach Proxmox on TCP `8006`.
- Confirm the Proxmox base URL opens from the WordPress server. - Confirm the Proxmox base URL opens from the WordPress server.
- Confirm the configured node name is exact. - Confirm the configured node name is exact.
- Confirm the selected plugin template points to a real Proxmox template VMID. - Confirm the selected plugin template points to a real Proxmox template VMID or visible LXC `vztmpl` item.
- Confirm the token has clone, VM config, power, delete, audit, and datastore permissions. - For LXC tests, confirm **LXC rootfs storage** is configured.
- Confirm target storage has enough capacity for a full clone. - Confirm the token has clone/create, VM config, power, delete, audit, and datastore permissions.
- Confirm target storage has enough capacity for the VM clone or LXC rootfs.
- Confirm RAM contingents in WordPress are not blocking the selected template. - Confirm RAM contingents in WordPress are not blocking the selected template.
Then test in this order: Then test in this order:
@@ -302,16 +319,17 @@ Then test in this order:
| `Missing Proxmox HTTP configuration` | One of Base URL, Token ID, Token Secret, or Node is empty. | | `Missing Proxmox HTTP configuration` | One of Base URL, Token ID, Token Secret, or Node is empty. |
| `Proxmox request failed with HTTP 401` | Token ID/secret is wrong, token was deleted, or the secret was copied incorrectly. | | `Proxmox request failed with HTTP 401` | Token ID/secret is wrong, token was deleted, or the secret was copied incorrectly. |
| `Proxmox request failed with HTTP 403` | Token exists but lacks permissions for clone/config/power/delete/storage. | | `Proxmox request failed with HTTP 403` | Token exists but lacks permissions for clone/config/power/delete/storage. |
| `Proxmox request failed with HTTP 404` | Node name or template VMID is wrong, or the template is on a different node. | | `Proxmox request failed with HTTP 404` | Node name, template VMID, or LXC template reference is wrong, or the template is on a different node/storage. |
| `Missing LXC rootfs storage configuration` | An LXC template was selected but no target rootfs storage is configured in plugin settings. |
| SSL/cURL certificate error | WordPress/PHP does not trust the Proxmox certificate. | | SSL/cURL certificate error | WordPress/PHP does not trust the Proxmox certificate. |
| Deployment created but no IPs | Guest agent is missing, disabled, not running, or the VM has not finished booting. | | Deployment created but no IPs | Guest agent is missing/disabled for a VM, the VM/container has not finished booting, or the LXC bridge/DHCP path is not configured. |
| Start is blocked | Deployment is `EXPIRED`; prolong the TTL first. | | Start is blocked | Deployment is `EXPIRED`; prolong the TTL first. |
## Design Notes ## Design Notes
- Templates are the only provisioning path. - Templates are the only provisioning path.
- Resource limits come from approved templates, not user input. - Resource limits come from approved templates, not user input.
- Deployment lifecycle operations are routed through a dedicated Proxmox client interface. - Deployment lifecycle operations are routed through a dedicated Proxmox client interface and include the stored resource type so VM and LXC actions use the correct Proxmox API path.
- Live Proxmox access can be replaced or expanded without changing REST or UI code. - Live Proxmox access can be replaced or expanded without changing REST or UI code.
- WordPress users are used as actors for audit logging. Portal permissions are assigned per user inside the plugin so users created by SSO or external identity providers do not need WordPress author/editor roles. - WordPress users are used as actors for audit logging. Portal permissions are assigned per user inside the plugin so users created by SSO or external identity providers do not need WordPress author/editor roles.

View File

@@ -148,6 +148,8 @@
return '<span class="spp-badge OWNER">OWNER</span>'; return '<span class="spp-badge OWNER">OWNER</span>';
}; };
const resourceTypeLabel = (type) => type === "lxc" ? "LXC" : "VM";
const actionButton = (permission, action, label, id, className = "", disabled = false) => { const actionButton = (permission, action, label, id, className = "", disabled = false) => {
if (!can(permission)) { if (!can(permission)) {
return ""; return "";
@@ -195,7 +197,7 @@
<div class="spp-header"> <div class="spp-header">
<div> <div>
<h2 class="spp-title">Support Provisioning Portal</h2> <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()} ${quotaLine()}
</div> </div>
<div class="spp-tabs"> <div class="spp-tabs">
@@ -215,7 +217,7 @@
<div class="spp-panel"> <div class="spp-panel">
<table class="spp-table"> <table class="spp-table">
<thead> <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> </thead>
<tbody> <tbody>
${state.deployments.map((deployment) => ` ${state.deployments.map((deployment) => `
@@ -223,6 +225,7 @@
<td><strong>${escapeHtml(deployment.name)}</strong></td> <td><strong>${escapeHtml(deployment.name)}</strong></td>
<td>${statusBadge(deployment.status)}</td> <td>${statusBadge(deployment.status)}</td>
<td>${accessLabel(deployment)}</td> <td>${accessLabel(deployment)}</td>
<td>${resourceTypeLabel(deployment.provisioningType)}</td>
<td>${escapeHtml(deployment.templateName)}</td> <td>${escapeHtml(deployment.templateName)}</td>
<td>${ipList(deployment.ipAddresses)}</td> <td>${ipList(deployment.ipAddresses)}</td>
<td>${dateTime(deployment.expiresAt)}</td> <td>${dateTime(deployment.expiresAt)}</td>
@@ -243,6 +246,7 @@
<p>${escapeHtml(template.description)}</p> <p>${escapeHtml(template.description)}</p>
<div class="spp-meta"> <div class="spp-meta">
<span>OS<strong>${template.osType}</strong></span> <span>OS<strong>${template.osType}</strong></span>
<span>Type<strong>${resourceTypeLabel(template.provisioningType)}</strong></span>
<span>CPU<strong>${template.cpuCores} cores</strong></span> <span>CPU<strong>${template.cpuCores} cores</strong></span>
<span>Memory<strong>${template.memoryMb} MB</strong></span> <span>Memory<strong>${template.memoryMb} MB</strong></span>
<span>Disk<strong>${template.diskGb} GB</strong></span> <span>Disk<strong>${template.diskGb} GB</strong></span>
@@ -263,7 +267,7 @@
<form class="spp-form" id="spp-create-form"> <form class="spp-form" id="spp-create-form">
<label>Template <label>Template
<select class="spp-select" name="templateId" required> <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> </select>
</label> </label>
<label>Deployment name <label>Deployment name
@@ -313,9 +317,10 @@
</div> </div>
<div class="spp-meta"> <div class="spp-meta">
<span>Template<strong>${escapeHtml(deployment.templateName)}</strong></span> <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>Requested by<strong>${escapeHtml(deployment.requestedByName)}</strong></span>
<span>Access<strong>${deployment.accessType ? escapeHtml(deployment.accessType) : "Owner"}</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>IP addresses<strong>${ipList(deployment.ipAddresses)}</strong></span>
<span>CPU<strong>${deployment.cpuCores} cores</strong></span> <span>CPU<strong>${deployment.cpuCores} cores</strong></span>
<span>Memory<strong>${deployment.memoryMb} MB</strong></span> <span>Memory<strong>${deployment.memoryMb} MB</strong></span>

View File

@@ -6,7 +6,7 @@ if (!defined('ABSPATH')) {
final class SPP_Activator final class SPP_Activator
{ {
private const DB_VERSION = '0.6.0'; private const DB_VERSION = '0.7.0';
public static function activate(): void public static function activate(): void
{ {
@@ -15,6 +15,8 @@ final class SPP_Activator
add_option('spp_proxmox_mode', 'mock'); add_option('spp_proxmox_mode', 'mock');
add_option('spp_proxmox_node', 'pve-01'); 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_mock_next_vm_id', 10000);
add_option('spp_quota_user_memory_mb', 0); add_option('spp_quota_user_memory_mb', 0);
add_option('spp_quota_global_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, memory_mb int unsigned NOT NULL,
disk_gb int unsigned NOT NULL, disk_gb int unsigned NOT NULL,
default_ttl_hours 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_id int unsigned NOT NULL,
proxmox_template_ref varchar(255) NULL,
is_active tinyint(1) NOT NULL DEFAULT 1, is_active tinyint(1) NOT NULL DEFAULT 1,
created_at datetime NOT NULL, created_at datetime NOT NULL,
updated_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, id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
name varchar(160) NOT NULL, name varchar(160) NOT NULL,
status varchar(32) NOT NULL, status varchar(32) NOT NULL,
provisioning_type varchar(16) NOT NULL DEFAULT 'qemu',
proxmox_vm_id int unsigned DEFAULT NULL, proxmox_vm_id int unsigned DEFAULT NULL,
ip_addresses longtext NULL, ip_addresses longtext NULL,
error_message text NULL, error_message text NULL,
@@ -92,6 +97,10 @@ final class SPP_Activator
$wpdb->query("ALTER TABLE {$deployments} MODIFY expires_at datetime NULL"); $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')); $ip_column = $wpdb->get_var($wpdb->prepare("SHOW COLUMNS FROM {$deployments} LIKE %s", 'ip_addresses'));
if ($ip_column === null) { if ($ip_column === null) {
$wpdb->query("ALTER TABLE {$deployments} ADD COLUMN ip_addresses longtext NULL AFTER proxmox_vm_id"); $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; 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 private static function seed_templates(): void
{ {
global $wpdb; global $wpdb;
@@ -154,7 +173,9 @@ final class SPP_Activator
'memory_mb' => 2048, 'memory_mb' => 2048,
'disk_gb' => 24, 'disk_gb' => 24,
'default_ttl_hours' => 72, 'default_ttl_hours' => 72,
'provisioning_type' => 'qemu',
'proxmox_template_id' => 9001, 'proxmox_template_id' => 9001,
'proxmox_template_ref' => null,
], ],
[ [
'template_key' => 'windows-support-client', 'template_key' => 'windows-support-client',
@@ -165,7 +186,9 @@ final class SPP_Activator
'memory_mb' => 8192, 'memory_mb' => 8192,
'disk_gb' => 80, 'disk_gb' => 80,
'default_ttl_hours' => 48, 'default_ttl_hours' => 48,
'provisioning_type' => 'qemu',
'proxmox_template_id' => 9002, 'proxmox_template_id' => 9002,
'proxmox_template_ref' => null,
], ],
[ [
'template_key' => 'linux-utility-vm', 'template_key' => 'linux-utility-vm',
@@ -176,7 +199,9 @@ final class SPP_Activator
'memory_mb' => 2048, 'memory_mb' => 2048,
'disk_gb' => 32, 'disk_gb' => 32,
'default_ttl_hours' => 168, 'default_ttl_hours' => 168,
'provisioning_type' => 'qemu',
'proxmox_template_id' => 9003, 'proxmox_template_id' => 9003,
'proxmox_template_ref' => null,
], ],
]; ];

View File

@@ -100,7 +100,7 @@ final class SPP_Admin_Page
'settings_saved' => __('Settings saved.', 'support-provisioning-portal'), 'settings_saved' => __('Settings saved.', 'support-provisioning-portal'),
'template_saved' => __('Template saved.', 'support-provisioning-portal'), 'template_saved' => __('Template saved.', 'support-provisioning-portal'),
'template_removed' => __('Template removed from new provisioning.', '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'), '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'), '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> <span>Node</span>
<input name="spp_proxmox_node" type="text" value="<?php echo esc_attr(get_option('spp_proxmox_node', 'pve-01')); ?>"> <input name="spp_proxmox_node" type="text" value="<?php echo esc_attr(get_option('spp_proxmox_node', 'pve-01')); ?>">
</label> </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> <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> <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> <label>
@@ -187,7 +195,7 @@ final class SPP_Admin_Page
private function render_template_management(): void private function render_template_management(): void
{ {
$approved_templates = $this->repository->admin_templates(); $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; $proxmox_error = null;
try { try {
@@ -220,7 +228,7 @@ final class SPP_Admin_Page
<div class="spp-template-row-head"> <div class="spp-template-row-head">
<div> <div>
<strong><?php echo esc_html((string) $template['name']); ?></strong> <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> </div>
<?php if (!empty($template['isActive'])) : ?> <?php if (!empty($template['isActive'])) : ?>
<span class="spp-badge RUNNING"><?php echo esc_html__('Active', 'support-provisioning-portal'); ?></span> <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; ?> <?php endif; ?>
</div> </div>
<div class="spp-template-fields"> <div class="spp-template-fields">
<input type="hidden" name="spp_provisioning_type" value="<?php echo esc_attr((string) $template['provisioningType']); ?>">
<label>Name <label>Name
<input name="spp_template_name" type="text" required maxlength="160" value="<?php echo esc_attr((string) $template['name']); ?>"> <input name="spp_template_name" type="text" required maxlength="160" value="<?php echo esc_attr((string) $template['name']); ?>">
</label> </label>
<label>PVE template VMID <label>Type
<input name="spp_proxmox_template_id" type="number" min="1" required value="<?php echo esc_attr((string) $template['proxmoxTemplateId']); ?>"> <input type="text" readonly value="<?php echo esc_attr($this->template_type_label((string) $template['provisioningType'])); ?>">
</label> </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 <label>OS type
<?php $this->render_os_type_select((string) $template['osType']); ?> <?php $this->render_os_type_select((string) $template['osType']); ?>
</label> </label>
@@ -271,17 +291,27 @@ final class SPP_Admin_Page
<p class="spp-error"><?php echo esc_html($proxmox_error); ?></p> <p class="spp-error"><?php echo esc_html($proxmox_error); ?></p>
<?php elseif (empty($proxmox_templates)) : ?> <?php elseif (empty($proxmox_templates)) : ?>
<div class="spp-panel spp-admin-notice"> <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> </div>
<?php else : ?> <?php else : ?>
<div class="spp-pve-template-grid"> <div class="spp-pve-template-grid">
<?php foreach ($proxmox_templates as $template) : ?> <?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')); ?>"> <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="action" value="spp_save_template">
<input type="hidden" name="spp_template_action" value="import"> <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_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_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_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']); ?>"> <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 class="spp-template-row-head">
<div> <div>
<strong><?php echo esc_html((string) $template['name']); ?></strong> <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> </div>
<?php if ($is_imported) : ?> <?php if ($is_imported) : ?>
<span class="spp-badge RUNNING"><?php echo esc_html__('Imported', 'support-provisioning-portal'); ?></span> <span class="spp-badge RUNNING"><?php echo esc_html__('Imported', 'support-provisioning-portal'); ?></span>
<?php endif; ?> <?php endif; ?>
</div> </div>
<div class="spp-meta"> <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>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>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> <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> <input name="spp_default_ttl_hours" type="number" min="1" max="720" value="168" required>
</label> </label>
<label>Description <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> </label>
<button class="button button-primary" type="submit"><?php echo esc_html__('Import Template', 'support-provisioning-portal'); ?></button> <button class="button button-primary" type="submit"><?php echo esc_html__('Import Template', 'support-provisioning-portal'); ?></button>
<?php endif; ?> <?php endif; ?>
@@ -318,10 +353,12 @@ final class SPP_Admin_Page
</div> </div>
<?php endif; ?> <?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')); ?>"> <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="action" value="spp_save_template">
<input type="hidden" name="spp_template_action" value="import"> <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'); ?> <?php wp_nonce_field('spp_save_template'); ?>
<div class="spp-template-fields"> <div class="spp-template-fields">
<label>Name <label>Name
@@ -464,6 +501,8 @@ final class SPP_Admin_Page
update_option('spp_proxmox_token_secret', $token_secret); 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_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_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')))); 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'); $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'); $this->redirect_to_admin_page('template_error');
} }
@@ -579,7 +622,13 @@ final class SPP_Admin_Page
private function posted_string(string $key): string 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 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 : ''; 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 { try {
foreach ($this->proxmox->list_templates() as $template) { 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; return true;
} }
} }
@@ -614,15 +682,25 @@ final class SPP_Admin_Page
private function posted_template_data(): ?array private function posted_template_data(): ?array
{ {
$name = sanitize_text_field($this->posted_string('spp_template_name')); $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_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')); $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 null;
} }
return [ 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, 'name' => $name,
'description' => $description, 'description' => $description,
'os_type' => $this->posted_os_type(), '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'))), 'memory_mb' => max(128, absint($this->posted_string('spp_memory_mb'))),
'disk_gb' => max(1, absint($this->posted_string('spp_disk_gb'))), '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')))), '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_id' => $proxmox_template_id,
'proxmox_template_ref' => $proxmox_template_ref,
'is_active' => $this->posted_string('spp_is_active') === '1', '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'; 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> * @return array<int, int>
*/ */

View File

@@ -19,7 +19,7 @@ final class SPP_Expiration_Service
if (!empty($deployment['proxmox_vm_id']) && $deployment['status'] !== 'STOPPED') { if (!empty($deployment['proxmox_vm_id']) && $deployment['status'] !== 'STOPPED') {
try { 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) { } catch (Throwable $error) {
$stop_error = $error->getMessage(); $stop_error = $error->getMessage();
} }

View File

@@ -6,18 +6,81 @@ if (!defined('ABSPATH')) {
final class SPP_Http_Proxmox_Client implements SPP_Proxmox_Client 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; 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) public function __construct(array $options)
{ {
$this->options = $options; $this->options = array_merge([
'lxc_rootfs_storage' => '',
'lxc_bridge' => 'vmbr0',
], $options);
} }
public function list_templates(): array 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'); $data = $this->request("/nodes/{$this->options['node']}/qemu", 'GET');
$vms = is_array($data) ? $data : []; $vms = is_array($data) ? $data : [];
@@ -29,7 +92,10 @@ final class SPP_Http_Proxmox_Client implements SPP_Proxmox_Client
} }
$templates[] = [ $templates[] = [
'provisioningType' => 'qemu',
'vmId' => (int) $vm['vmid'], 'vmId' => (int) $vm['vmid'],
'templateRef' => '',
'storage' => '',
'name' => isset($vm['name']) && $vm['name'] !== '' ? (string) $vm['name'] : 'template-' . (int) $vm['vmid'], 'name' => isset($vm['name']) && $vm['name'] !== '' ? (string) $vm['name'] : 'template-' . (int) $vm['vmid'],
'cpuCores' => max(1, (int) ($vm['cpus'] ?? 1)), 'cpuCores' => max(1, (int) ($vm['cpus'] ?? 1)),
'memoryMb' => $this->bytes_to_mb((int) ($vm['maxmem'] ?? 0)), 'memoryMb' => $this->bytes_to_mb((int) ($vm['maxmem'] ?? 0)),
@@ -38,15 +104,83 @@ final class SPP_Http_Proxmox_Client implements SPP_Proxmox_Client
]; ];
} }
usort( return $templates;
$templates, }
static fn(array $left, array $right): int => strcasecmp((string) $left['name'], (string) $right['name'])
); /**
* @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; 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'); $vm_id = (int) $this->request('/cluster/nextid', 'GET');
$template_id = (int) $input['template_vm_id']; $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', [ $this->request("/nodes/{$this->options['node']}/qemu/{$vm_id}/config", 'PUT', [
'cores' => (int) $input['cpu_cores'], 'cores' => (int) $input['cpu_cores'],
'memory' => (int) $input['memory_mb'], 'memory' => (int) $input['memory_mb'],
'tags' => $this->tags_string($input['tags'] ?? []),
]); ]);
return ['vm_id' => $vm_id]; 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 private function get_qemu_ip_addresses(int $vm_id): array
{
$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
{ {
$data = $this->request("/nodes/{$this->options['node']}/qemu/{$vm_id}/agent/network-get-interfaces", 'GET'); $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'] : []; $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)); 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 private function bytes_to_mb(int $bytes): int
{ {
if ($bytes < 1) { if ($bytes < 1) {

View File

@@ -10,7 +10,10 @@ final class SPP_Mock_Proxmox_Client implements SPP_Proxmox_Client
{ {
return [ return [
[ [
'provisioningType' => 'qemu',
'vmId' => 9001, 'vmId' => 9001,
'templateRef' => '',
'storage' => '',
'name' => 'Turnkey PBX Test Appliance', 'name' => 'Turnkey PBX Test Appliance',
'cpuCores' => 2, 'cpuCores' => 2,
'memoryMb' => 2048, 'memoryMb' => 2048,
@@ -18,7 +21,10 @@ final class SPP_Mock_Proxmox_Client implements SPP_Proxmox_Client
'status' => 'stopped', 'status' => 'stopped',
], ],
[ [
'provisioningType' => 'qemu',
'vmId' => 9002, 'vmId' => 9002,
'templateRef' => '',
'storage' => '',
'name' => 'Windows Support Client', 'name' => 'Windows Support Client',
'cpuCores' => 4, 'cpuCores' => 4,
'memoryMb' => 8192, 'memoryMb' => 8192,
@@ -26,51 +32,80 @@ final class SPP_Mock_Proxmox_Client implements SPP_Proxmox_Client
'status' => 'stopped', 'status' => 'stopped',
], ],
[ [
'provisioningType' => 'qemu',
'vmId' => 9003, 'vmId' => 9003,
'templateRef' => '',
'storage' => '',
'name' => 'Linux Utility VM', 'name' => 'Linux Utility VM',
'cpuCores' => 2, 'cpuCores' => 2,
'memoryMb' => 2048, 'memoryMb' => 2048,
'diskGb' => 32, 'diskGb' => 32,
'status' => 'stopped', '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); $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_next_vm_id', $next_id + 1, false);
update_option('spp_mock_vm_status_' . $next_id, 'stopped', 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); update_option('spp_mock_vm_ips_' . $next_id, [sprintf('192.0.2.%d', (($next_id - 10000) % 200) + 10)], false);
return ['vm_id' => $next_id]; 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); $this->ensure_vm($vm_id);
update_option('spp_mock_vm_status_' . $vm_id, 'running', false); 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); $this->ensure_vm($vm_id);
update_option('spp_mock_vm_status_' . $vm_id, 'stopped', false); 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); $this->ensure_vm($vm_id);
delete_option('spp_mock_vm_status_' . $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); 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'); 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, []); $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 private function ensure_vm(int $vm_id): void
{ {
if (get_option('spp_mock_vm_status_' . $vm_id, null) === null) { 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';
}
} }

View File

@@ -43,6 +43,8 @@ final class SPP_Plugin
'token_id' => (string) get_option('spp_proxmox_token_id', ''), 'token_id' => (string) get_option('spp_proxmox_token_id', ''),
'token_secret' => (string) get_option('spp_proxmox_token_secret', ''), 'token_secret' => (string) get_option('spp_proxmox_token_secret', ''),
'node' => (string) get_option('spp_proxmox_node', ''), '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'),
]); ]);
} }

View File

@@ -157,6 +157,7 @@ final class SPP_Repository
$wpdb->insert($table, [ $wpdb->insert($table, [
'name' => $name, 'name' => $name,
'status' => 'STOPPED', 'status' => 'STOPPED',
'provisioning_type' => $this->normalise_template_type((string) ($template['provisioning_type'] ?? 'qemu')),
'proxmox_vm_id' => $vm_id, 'proxmox_vm_id' => $vm_id,
'ip_addresses' => wp_json_encode(array_values($ip_addresses)), 'ip_addresses' => wp_json_encode(array_values($ip_addresses)),
'expires_at' => $expires_at, 'expires_at' => $expires_at,
@@ -169,6 +170,7 @@ final class SPP_Repository
$deployment_id = (int) $wpdb->insert_id; $deployment_id = (int) $wpdb->insert_id;
$this->audit('DEPLOYMENT_CREATED', 'deployment', $deployment_id, $actor_id, [ $this->audit('DEPLOYMENT_CREATED', 'deployment', $deployment_id, $actor_id, [
'template_id' => (int) $template['id'], 'template_id' => (int) $template['id'],
'provisioning_type' => $this->normalise_template_type((string) ($template['provisioning_type'] ?? 'qemu')),
'proxmox_vm_id' => $vm_id, 'proxmox_vm_id' => $vm_id,
'ip_addresses' => $ip_addresses, 'ip_addresses' => $ip_addresses,
'ttl_hours' => $ttl_hours, 'ttl_hours' => $ttl_hours,
@@ -439,15 +441,11 @@ final class SPP_Repository
$table = SPP_Activator::table('templates'); $table = SPP_Activator::table('templates');
$now = current_time('mysql'); $now = current_time('mysql');
$template_key = $this->unique_template_key((string) $data['template_key'], (int) $data['proxmox_template_id']); $provisioning_type = $this->normalise_template_type((string) ($data['provisioning_type'] ?? 'qemu'));
$existing_id = (int) $wpdb->get_var( $proxmox_template_id = $provisioning_type === 'qemu' ? absint($data['proxmox_template_id']) : 0;
$wpdb->prepare( $proxmox_template_ref = $provisioning_type === 'lxc' ? sanitize_text_field((string) ($data['proxmox_template_ref'] ?? '')) : null;
"SELECT id FROM {$table} WHERE proxmox_template_id = %d OR template_key = %s ORDER BY proxmox_template_id = %d DESC LIMIT 1", $template_key = $this->unique_template_key((string) $data['template_key'], $provisioning_type, $proxmox_template_id, (string) $proxmox_template_ref);
(int) $data['proxmox_template_id'], $existing_id = $this->template_existing_id($provisioning_type, $proxmox_template_id, (string) $proxmox_template_ref, $template_key);
$template_key,
(int) $data['proxmox_template_id']
)
);
$row = [ $row = [
'template_key' => $template_key, 'template_key' => $template_key,
@@ -458,7 +456,9 @@ final class SPP_Repository
'memory_mb' => max(128, absint($data['memory_mb'])), 'memory_mb' => max(128, absint($data['memory_mb'])),
'disk_gb' => max(1, absint($data['disk_gb'])), 'disk_gb' => max(1, absint($data['disk_gb'])),
'default_ttl_hours' => max(1, min(720, absint($data['default_ttl_hours']))), '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, 'is_active' => 1,
'updated_at' => $now, 'updated_at' => $now,
]; ];
@@ -474,7 +474,9 @@ final class SPP_Repository
} }
$this->audit($action, 'template', $template_id, $actor_id, [ $this->audit($action, 'template', $template_id, $actor_id, [
'provisioning_type' => (string) $row['provisioning_type'],
'proxmox_template_id' => (int) $row['proxmox_template_id'], 'proxmox_template_id' => (int) $row['proxmox_template_id'],
'proxmox_template_ref' => (string) ($row['proxmox_template_ref'] ?? ''),
'name' => (string) $row['name'], 'name' => (string) $row['name'],
]); ]);
@@ -489,6 +491,9 @@ final class SPP_Repository
global $wpdb; global $wpdb;
$table = SPP_Activator::table('templates'); $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 = [ $row = [
'name' => sanitize_text_field((string) $data['name']), 'name' => sanitize_text_field((string) $data['name']),
'description' => sanitize_textarea_field((string) $data['description']), 'description' => sanitize_textarea_field((string) $data['description']),
@@ -497,7 +502,9 @@ final class SPP_Repository
'memory_mb' => max(128, absint($data['memory_mb'])), 'memory_mb' => max(128, absint($data['memory_mb'])),
'disk_gb' => max(1, absint($data['disk_gb'])), 'disk_gb' => max(1, absint($data['disk_gb'])),
'default_ttl_hours' => max(1, min(720, absint($data['default_ttl_hours']))), '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, 'is_active' => empty($data['is_active']) ? 0 : 1,
'updated_at' => current_time('mysql'), 'updated_at' => current_time('mysql'),
]; ];
@@ -505,7 +512,9 @@ final class SPP_Repository
$wpdb->update($table, $row, ['id' => $id]); $wpdb->update($table, $row, ['id' => $id]);
$this->audit('TEMPLATE_UPDATED', 'template', $id, $actor_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_id' => (int) $row['proxmox_template_id'],
'proxmox_template_ref' => (string) ($row['proxmox_template_ref'] ?? ''),
'name' => (string) $row['name'], 'name' => (string) $row['name'],
'is_active' => (int) $row['is_active'], '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; global $wpdb;
$table = SPP_Activator::table('templates'); $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; 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); $key = sanitize_title($raw_key);
if ($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); 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 * @param array<string, mixed> $metadata
*/ */
@@ -661,7 +723,9 @@ final class SPP_Repository
'memoryMb' => (int) $row['memory_mb'], 'memoryMb' => (int) $row['memory_mb'],
'diskGb' => (int) $row['disk_gb'], 'diskGb' => (int) $row['disk_gb'],
'defaultTtlHours' => (int) $row['default_ttl_hours'], 'defaultTtlHours' => (int) $row['default_ttl_hours'],
'provisioningType' => $this->normalise_template_type((string) ($row['provisioning_type'] ?? 'qemu')),
'proxmoxTemplateId' => (int) $row['proxmox_template_id'], 'proxmoxTemplateId' => (int) $row['proxmox_template_id'],
'proxmoxTemplateRef' => (string) ($row['proxmox_template_ref'] ?? ''),
'isActive' => (int) $row['is_active'] === 1, 'isActive' => (int) $row['is_active'] === 1,
]; ];
} }
@@ -676,6 +740,7 @@ final class SPP_Repository
'id' => (int) $row['id'], 'id' => (int) $row['id'],
'name' => (string) $row['name'], 'name' => (string) $row['name'],
'status' => (string) $row['status'], 'status' => (string) $row['status'],
'provisioningType' => $this->normalise_template_type((string) ($row['provisioning_type'] ?? 'qemu')),
'templateName' => (string) $row['template_name'], 'templateName' => (string) $row['template_name'],
'requestedById' => (int) $row['requested_by'], 'requestedById' => (int) $row['requested_by'],
'requestedByName' => (string) $row['requested_by_name'], 'requestedByName' => (string) $row['requested_by_name'],
@@ -695,6 +760,8 @@ final class SPP_Repository
return array_merge($this->deployment_summary_dto($row), [ return array_merge($this->deployment_summary_dto($row), [
'templateId' => (int) $row['template_id'], 'templateId' => (int) $row['template_id'],
'proxmoxVmId' => isset($row['proxmox_vm_id']) ? (int) $row['proxmox_vm_id'] : null, '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'], 'cpuCores' => (int) $row['cpu_cores'],
'memoryMb' => (int) $row['memory_mb'], 'memoryMb' => (int) $row['memory_mb'],
'diskGb' => (int) $row['disk_gb'], 'diskGb' => (int) $row['disk_gb'],

View File

@@ -222,22 +222,27 @@ final class SPP_REST_Controller
return $quota_error; return $quota_error;
} }
$provisioning_type = $this->normalise_template_type((string) ($template['provisioning_type'] ?? 'qemu'));
try { try {
$clone = $this->proxmox->clone_vm([ $instance = $this->proxmox->provision_instance([
'provisioning_type' => $provisioning_type,
'template_vm_id' => (int) $template['proxmox_template_id'], 'template_vm_id' => (int) $template['proxmox_template_id'],
'lxc_template_ref' => (string) ($template['proxmox_template_ref'] ?? ''),
'name' => $name, 'name' => $name,
'cpu_cores' => (int) $template['cpu_cores'], 'cpu_cores' => (int) $template['cpu_cores'],
'memory_mb' => (int) $template['memory_mb'], 'memory_mb' => (int) $template['memory_mb'],
'disk_gb' => (int) $template['disk_gb'], '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( $deployment = $this->repository->create_deployment(
$template, $template,
$name, $name,
$ttl_hours, $ttl_hours,
$vm_id, $vm_id,
$this->safe_ip_addresses($vm_id), $this->safe_ip_addresses($provisioning_type, $vm_id),
get_current_user_id() get_current_user_id()
); );
} catch (Throwable $error) { } 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 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 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 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 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'])) { 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 { try {
$this->repository->update_deployment_ips( $this->repository->update_deployment_ips(
$id, $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() get_current_user_id()
); );
} catch (Throwable $error) { } catch (Throwable $error) {
@@ -442,11 +450,11 @@ final class SPP_REST_Controller
return new WP_Error('spp_not_found', 'Deployment not found.', ['status' => 404]); 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]); 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( return new WP_Error(
'spp_expired_deployment', 'spp_expired_deployment',
'This deployment is expired. Prolong its TTL before starting it again.', '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'])) { 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 { try {
$this->proxmox->{$method}((int) $record['proxmox_vm_id']); if ($method === 'start') {
if ($method === 'start_vm') { $this->proxmox->start_instance($provisioning_type, (int) $record['proxmox_vm_id']);
$this->repository->update_deployment_status_and_ips( $this->repository->update_deployment_status_and_ips(
$id, $id,
$status, $status,
$this->safe_ip_addresses((int) $record['proxmox_vm_id']), $this->safe_ip_addresses($provisioning_type, (int) $record['proxmox_vm_id']),
$audit_action, $audit_action,
get_current_user_id() 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 { } 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()); $this->repository->update_deployment_status($id, $status, $audit_action, get_current_user_id());
} }
} catch (Throwable $error) { } catch (Throwable $error) {
return new WP_Error('spp_proxmox_error', $error->getMessage(), ['status' => 502]); return new WP_Error('spp_proxmox_error', $error->getMessage(), ['status' => 502]);
} }
if ($method === 'delete_vm') { if ($method === 'delete') {
return rest_ensure_response(['deleted' => true]); return rest_ensure_response(['deleted' => true]);
} }
@@ -553,12 +567,32 @@ final class SPP_REST_Controller
/** /**
* @return array<int, string> * @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 { try {
return $this->proxmox->get_ip_addresses($vm_id); return $this->proxmox->get_ip_addresses($provisioning_type, $vm_id);
} catch (Throwable) { } catch (Throwable) {
return []; 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';
}
} }

View File

@@ -7,26 +7,26 @@ if (!defined('ABSPATH')) {
interface SPP_Proxmox_Client 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; public function list_templates(): array;
/** /**
* @param array<string, int|string> $input * @param array<string, mixed> $input
* @return array{vm_id:int} * @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> * @return array<int, string>
*/ */
public function get_ip_addresses(int $vm_id): array; public function get_ip_addresses(string $type, int $vm_id): array;
} }

View File

@@ -1,8 +1,8 @@
<?php <?php
/** /**
* Plugin Name: Support Provisioning Portal * Plugin Name: Support Provisioning Portal
* Description: Internal self-service portal for provisioning standardized Proxmox VE VMs. * Description: Internal self-service portal for provisioning standardized Proxmox VE VMs and LXC containers.
* Version: 0.6.0 * Version: 0.7.0
* Author: Internal Support * Author: Internal Support
* Requires PHP: 8.0 * Requires PHP: 8.0
* Requires at least: 6.2 * Requires at least: 6.2
@@ -13,7 +13,7 @@ if (!defined('ABSPATH')) {
exit; exit;
} }
define('SPP_VERSION', '0.6.0'); define('SPP_VERSION', '0.7.0');
define('SPP_PLUGIN_FILE', __FILE__); define('SPP_PLUGIN_FILE', __FILE__);
define('SPP_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('SPP_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('SPP_PLUGIN_URL', plugin_dir_url(__FILE__)); define('SPP_PLUGIN_URL', plugin_dir_url(__FILE__));