diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..03777d4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.log +*.zip +.DS_Store +Thumbs.db diff --git a/README.md b/README.md index a9f75e1..2ec8bb9 100644 --- a/README.md +++ b/README.md @@ -1,123 +1,300 @@ -You are helping implement an internal web application called **Support Provisioning Portal**. +# Support Provisioning Portal -Your task is to bootstrap the project based on the local `README.md` in this repository and then implement the first vertical slice with strong engineering discipline. +Internal WordPress plugin for support staff to provision standardized Proxmox VE VMs without direct Proxmox access. -## Product context +The application runs as a WordPress plugin and exposes both: -The application is an internal self-service portal for support staff. It provisions standardized VMs on **Proxmox VE** through a backend service. Users must never interact with Proxmox directly. +- an admin page at **Support Provisioning** +- a frontend shortcode: `[support_provisioning_portal]` + +## Implemented First Slice + +- WordPress plugin bootstrap with activation hook +- Custom database tables: + - `wp_spp_templates` + - `wp_spp_deployments` + - `wp_spp_audit_logs` +- Seed data for 3 approved templates +- WordPress REST API endpoints: + - `GET /wp-json/support-provisioning/v1/templates` + - `GET /wp-json/support-provisioning/v1/quota` + - `GET /wp-json/support-provisioning/v1/deployments` + - `GET /wp-json/support-provisioning/v1/deployments/:id` + - `POST /wp-json/support-provisioning/v1/deployments` + - `POST /wp-json/support-provisioning/v1/deployments/:id/start` + - `POST /wp-json/support-provisioning/v1/deployments/:id/stop` + - `POST /wp-json/support-provisioning/v1/deployments/:id/prolong` + - `POST /wp-json/support-provisioning/v1/deployments/:id/refresh-ips` + - `DELETE /wp-json/support-provisioning/v1/deployments/:id` +- Mock Proxmox adapter for local/no-cluster use +- HTTP token-auth Proxmox adapter behind the same interface +- Audit log rows for every mutating action +- Optional `Never expire` deployments +- Per-user and global RAM contingents +- IP address display for deployments when available from the mock adapter or Proxmox guest agent +- Manual IP refresh action for deployments +- Non-destructive expiration: expired VMs are stopped and locked, not deleted +- Minimal admin/frontend UI for: + - deployment dashboard + - templates + - create deployment form + - deployment detail view + - start/stop/delete actions + - TTL prolongation for expired or active deployments + - theme-inherited form controls and colors for shortcode rendering + +## Install + +1. Copy the `support-provisioning-portal` folder into: + + ```text + wp-content/plugins/support-provisioning-portal + ``` + +2. Activate **Support Provisioning Portal** in WordPress admin. + +3. Open **Support Provisioning** in the WordPress admin menu. + +4. Optional: add this shortcode to an internal page: + + ```text + [support_provisioning_portal] + ``` + +## Permissions + +- Logged-in users with `read` can view templates and deployments. +- Logged-in users with `edit_posts` can create, start, stop, and delete deployments. +- WordPress REST nonces are required for UI mutations. + +## Expiration And Contingents + +Deployment creation supports either a TTL in hours or **Never expire**. + +When a deployment reaches its TTL: + +- WordPress cron and REST requests detect the expiration. +- The plugin attempts to stop the VM. +- The deployment status becomes `EXPIRED`. +- The VM cannot be started again until the user prolongs its TTL. +- The user can either prolong the TTL or delete the deployment. +- Deleted deployments are hidden from the active deployment list, but audit rows remain. + +RAM contingents are configured from **Support Provisioning > Proxmox Settings**: + +- Default per-user RAM limit (MB) +- Global RAM limit (MB) + +Set either value to `0` for unlimited. Active allocations include deployments in `PROVISIONING`, `STOPPED`, `RUNNING`, and `DELETING` states. + +Per-user overrides are available on each WordPress user profile under **Support Provisioning Contingent**. Leave the override empty to use the default per-user limit. + +## 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. + +If the guest agent reports IPs only after boot, use **Refresh IPs** in the deployment detail view. + +## Proxmox Settings + +The plugin defaults to mock mode. Configure live Proxmox access from the **Support Provisioning** admin page: + +- Mode: `Mock` or `HTTP token auth` +- Base URL, for example `https://proxmox.example.internal:8006` +- Token ID, for example `user@realm!token-name` +- Token Secret +- Node, for example `pve-01` + +No Proxmox secrets are committed to the repository. + +## Configure A Live Proxmox Link + +Use this section when moving from mock mode to a real Proxmox VE node. + +### 1. Prepare Proxmox Templates + +The plugin only provisions from approved templates. For a first real test, either create Proxmox VM templates with the seeded IDs or update the plugin database rows to match your real template IDs. + +Seeded template IDs: + +| Plugin template | Proxmox template VMID | +| --- | ---: | +| Turnkey PBX Test Appliance | `9001` | +| Windows Support Client | `9002` | +| Linux Utility VM | `9003` | + +The fastest first test is to create one real Proxmox template with VMID `9003`, then use the **Linux Utility VM** option in the plugin. + +Template requirements: + +- The VM must be converted to a Proxmox template. +- The template must exist on the Proxmox node configured in the plugin. +- The plugin currently performs a full clone (`full=1`), so the target storage must have enough free capacity. +- CPU and memory are set by the plugin after clone based on the 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. + +If you do not want to use VMIDs `9001`, `9002`, or `9003`, update the template rows after activation: + +```sql +UPDATE wp_spp_templates +SET proxmox_template_id = 1234 +WHERE template_key = 'linux-utility-vm'; +``` + +Replace `wp_` with your WordPress table prefix. + +### 2. Create A Dedicated Proxmox API User + +In Proxmox, create a dedicated user instead of using `root@pam` for the plugin. + +Example via Proxmox shell: + +```bash +pveum user add wp-support@pve --comment "WordPress Support Provisioning Portal" +``` + +You can also create the user in the Proxmox UI under **Datacenter > Permissions > Users**. + +### 3. Create An API Token + +In the Proxmox UI: + +1. Go to **Datacenter > Permissions > API Tokens**. +2. Click **Add**. +3. User: `wp-support@pve` +4. Token ID: `support-portal` +5. Enable **Privilege Separation**. +6. Save the token secret immediately. Proxmox only shows it once. + +The plugin stores these fields separately: + +- **Token ID**: `wp-support@pve!support-portal` +- **Token Secret**: the secret value shown by Proxmox + +Do not include the `=` between token ID and secret in the WordPress settings. The plugin builds the required `PVEAPIToken=tokenid=secret` header internally. + +### 4. Grant Proxmox Permissions + +Proxmox permissions are path based. For a first controlled test, grant the token access only to the node/storage/template area you intend to use. + +The plugin needs permissions for: + +- getting the next VMID +- cloning a template VM +- changing CPU and memory after clone +- starting and stopping VMs +- deleting VMs +- reading VM status +- reading guest-agent network interfaces for IP display + +Practical first-test option: + +```bash +pveum aclmod / -user wp-support@pve -role PVEVMAdmin +``` + +If the clone fails because storage permissions are missing, also grant datastore access on the storage path used by your templates/clones: + +```bash +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: + +```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 aclmod / -user wp-support@pve -role SupportProvisioner +``` + +Depending on your Proxmox version and storage layout, you may need to scope storage permissions separately, for example: + +```bash +pveum aclmod /storage/local-lvm -user wp-support@pve -role SupportProvisioner +``` + +### 5. Check Proxmox TLS + +The plugin uses WordPress HTTP requests to call Proxmox. That means PHP/WordPress must trust the Proxmox HTTPS certificate. + +Recommended: + +- Use a DNS name for Proxmox, not a raw IP address. +- Install a valid certificate on Proxmox, or install your internal CA certificate so the WordPress/PHP host trusts it. + +If Proxmox still uses its default self-signed certificate, WordPress may reject the request with an SSL/cURL certificate error. Fix the trust chain before debugging plugin credentials. + +### 6. Configure WordPress Plugin Settings + +In WordPress admin, open **Support Provisioning > Proxmox Settings**: + +| Setting | Value | +| --- | --- | +| Mode | `HTTP token auth` | +| Base URL | `https://proxmox.example.internal:8006` | +| Token ID | `wp-support@pve!support-portal` | +| Token Secret | token secret from Proxmox | +| Node | Proxmox node name, for example `pve-01` | + +The **Node** value must match the Proxmox node name exactly as shown in the Proxmox UI under **Datacenter**. + +### 7. First Live Test Checklist + +Before creating a deployment: + +- Confirm WordPress can reach Proxmox on TCP `8006`. +- Confirm the Proxmox base URL opens from the WordPress server. +- Confirm the configured node name is exact. +- Confirm the selected plugin template points to a real Proxmox template VMID. +- Confirm the token has clone, VM config, power, delete, audit, and datastore permissions. +- Confirm target storage has enough capacity for a full clone. +- Confirm RAM contingents in WordPress are not blocking the selected template. + +Then test in this order: + +1. Switch plugin mode to `HTTP token auth`. +2. Create a deployment from **Linux Utility VM** or whichever template VMID you mapped. +3. Open the deployment detail view. +4. Click **Start**. +5. Wait for the guest to boot. +6. Click **Refresh IPs**. +7. Confirm IP addresses appear. +8. Click **Stop**. +9. Click **Delete** when done. + +### 8. Common Failure Points + +| Symptom | Likely cause | +| --- | --- | +| `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 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. | +| 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. | +| Start is blocked | Deployment is `EXPIRED`; prolong the TTL first. | + +## Design Notes + +- Templates are the only provisioning path. +- Resource limits come from approved templates, not user input. +- Deployment lifecycle operations are routed through a dedicated Proxmox client interface. +- Live Proxmox access can be replaced or expanded without changing REST or UI code. +- WordPress users are used as actors for audit logging. This keeps the first slice simple and leaves room for later RBAC/SSO mapping. + +## Original Bootstrap Brief + +The application is an internal self-service portal for support staff. It provisions standardized VMs on **Proxmox VE** through a backend service. Users must never interact with Proxmox directly. The application MUST run as a WordPress plugin. Typical resources: + - turnkey PBX/test appliances - Windows support clients - Linux utility VMs The system must enforce: + - template-based provisioning only - policy-controlled resource limits - audit logging - lifecycle actions - TTL / expiration support - -## Technical direction - -Use this stack unless the repository already contains an equivalent decision: -- Monorepo with pnpm -- Next.js + TypeScript for `apps/web` -- Node.js + TypeScript for `apps/api` -- Fastify preferred for the API -- PostgreSQL + Prisma -- Tailwind CSS for the web UI -- Zod for request/response validation - -## Constraints - -- Do not overengineer. -- Prefer a clean vertical slice over broad scaffolding. -- Keep Proxmox integration behind a dedicated adapter/module. -- Use clear domain names and explicit types. -- Avoid magic strings. -- Add TODOs only when truly necessary. -- Document all non-obvious decisions in code comments or small docs. -- Make the codebase easy to extend toward RBAC, SSO, and multi-cluster support later. - -## Initial implementation goals - -Implement a usable first slice with the following: - -1. Monorepo structure - - `apps/web` - - `apps/api` - - `packages/types` - - `packages/proxmox-client` - - `prisma` - -2. Database schema with at least: - - users - - templates - - deployments - - audit_logs - -3. Backend API endpoints - - `GET /api/templates` - - `GET /api/deployments` - - `GET /api/deployments/:id` - - `POST /api/deployments` - - `POST /api/deployments/:id/start` - - `POST /api/deployments/:id/stop` - - `DELETE /api/deployments/:id` - -4. Proxmox adapter - - token auth support - - stubbed or mockable implementation if no live environment is available - - methods for clone/start/stop/delete/getStatus - - clear interface separation between domain service and transport - -5. Web UI - - dashboard page listing deployments - - templates page - - create deployment form - - deployment detail view - - simple status badges and action buttons - -6. Seed data - - at least 3 sample templates - -7. Audit logging - - every mutating action must write an audit log row - -8. Developer experience - - `.env.example` - - scripts for dev/build/lint - - basic README sections updated if needed - -## Working style - -Proceed in small, reviewable steps: -1. inspect repository -2. propose concrete file plan -3. implement foundation -4. implement backend slice -5. implement frontend slice -6. verify types/build consistency -7. summarize what was created and what remains - -## Output requirements - -- Start by reading the local `README.md` and align implementation with it. -- Show the file/folder plan before making broad changes. -- Then create the project incrementally. -- When assumptions are required, state them briefly and choose the most pragmatic path. -- Favor production-style code structure over tutorial-style code. -- Where live Proxmox access is unavailable, create a mock adapter that can later be replaced without touching domain logic. - -## Quality bar - -- TypeScript strict mode -- input validation at API boundaries -- no secrets committed -- minimal but coherent UI -- compile-ready code where possible -- no dead code -- no fake features presented as complete - -Begin by inspecting the repository and proposing the exact bootstrap structure to create. \ No newline at end of file diff --git a/support-provisioning-portal/assets/portal.css b/support-provisioning-portal/assets/portal.css new file mode 100644 index 0000000..66599a3 --- /dev/null +++ b/support-provisioning-portal/assets/portal.css @@ -0,0 +1,272 @@ +.spp-portal, +.spp-admin-wrap { + --spp-ink: var(--wp--preset--color--contrast, currentColor); + --spp-muted: color-mix(in srgb, currentColor 68%, transparent); + --spp-line: color-mix(in srgb, currentColor 22%, transparent); + --spp-field: color-mix(in srgb, currentColor 5%, transparent); + --spp-surface: var(--wp--preset--color--base, Canvas); + --spp-accent: var(--wp--preset--color--primary, #2271b1); + color: var(--spp-ink); +} + +.spp-admin-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 340px; + gap: 20px; + align-items: start; +} + +.spp-settings, +.spp-panel, +.spp-card { + background: var(--spp-surface); + border: 1px solid var(--spp-line); + border-radius: 6px; +} + +.spp-settings { + padding: 18px; +} + +.spp-settings label { + display: grid; + gap: 6px; + margin: 0 0 14px; + font-weight: 600; +} + +.spp-settings input, +.spp-settings select, +.spp-input, +.spp-select { + min-height: 38px; + border: 1px solid var(--spp-line); + border-radius: 6px; + background: var(--spp-surface); + color: inherit; + font: inherit; + padding: 7px 10px; +} + +.spp-header { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.spp-title { + margin: 0; + font-size: 24px; + line-height: 1.25; +} + +.spp-subtitle { + margin: 4px 0 0; + color: var(--spp-muted); +} + +.spp-tabs, +.spp-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.spp-button { + display: inline-flex; + min-height: 36px; + align-items: center; + justify-content: center; + gap: 8px; + border: 1px solid var(--spp-line); + border-radius: 6px; + background: var(--spp-surface); + color: inherit; + cursor: pointer; + font: inherit; + font-weight: 700; + padding: 7px 12px; +} + +.spp-button-primary { + background: var(--spp-accent); + border-color: var(--spp-accent); + color: #fff; +} + +.spp-button-danger { + border-color: #fecaca; + color: #b91c1c; +} + +.spp-button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.spp-panel { + overflow: hidden; +} + +.spp-table { + width: 100%; + border-collapse: collapse; +} + +.spp-table th { + background: var(--spp-field); + color: var(--spp-muted); + font-size: 12px; + letter-spacing: 0; + text-align: left; + text-transform: uppercase; +} + +.spp-table th, +.spp-table td { + border-bottom: 1px solid var(--spp-line); + padding: 12px; + vertical-align: middle; +} + +.spp-table tr:last-child td { + border-bottom: 0; +} + +.spp-badge { + display: inline-flex; + border-radius: 999px; + font-size: 12px; + font-weight: 800; + padding: 4px 9px; +} + +.spp-badge.RUNNING { + background: #d1fae5; + color: #065f46; +} + +.spp-badge.STOPPED, +.spp-badge.DELETED { + background: #f1f5f9; + color: #475569; +} + +.spp-badge.EXPIRED { + background: #ffedd5; + color: #9a3412; +} + +.spp-badge.PROVISIONING, +.spp-badge.DELETING { + background: #fef3c7; + color: #92400e; +} + +.spp-badge.FAILED, +.spp-error { + background: #fee2e2; + color: #991b1b; +} + +.spp-warning { + background: #fff7ed; + border: 1px solid #fed7aa; + border-radius: 6px; + color: #9a3412; + margin-bottom: 16px; + padding: 10px 12px; +} + +.spp-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.spp-card { + padding: 16px; +} + +.spp-card h3 { + margin: 0 0 8px; +} + +.spp-card p { + color: var(--spp-muted); + margin: 0 0 12px; +} + +.spp-quota { + display: flex; + flex-wrap: wrap; + gap: 8px 16px; + margin-top: 8px; + color: var(--spp-muted); + font-size: 13px; +} + +.spp-meta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + color: var(--spp-muted); + font-size: 13px; +} + +.spp-meta strong { + color: var(--spp-ink); + display: block; + font-size: 14px; +} + +.spp-form { + display: grid; + max-width: 640px; + gap: 14px; +} + +.spp-prolong-form { + border-top: 1px solid var(--spp-line); + display: grid; + gap: 12px; + margin-top: 18px; + max-width: 520px; + padding-top: 16px; +} + +.spp-prolong-form h3 { + margin: 0; +} + +.spp-form label, +.spp-prolong-form label { + display: grid; + gap: 6px; + font-weight: 700; +} + +.spp-check { + align-items: center; + display: flex !important; + gap: 8px; +} + +.spp-check input { + margin: 0; +} + +.spp-error { + border-radius: 6px; + padding: 10px 12px; +} + +@media (max-width: 960px) { + .spp-admin-grid, + .spp-grid { + grid-template-columns: 1fr; + } +} diff --git a/support-provisioning-portal/assets/portal.js b/support-provisioning-portal/assets/portal.js new file mode 100644 index 0000000..d433ce9 --- /dev/null +++ b/support-provisioning-portal/assets/portal.js @@ -0,0 +1,353 @@ +(function () { + const roots = document.querySelectorAll(".spp-portal[data-rest-url]"); + + roots.forEach((root) => { + const api = root.dataset.restUrl; + const nonce = root.dataset.nonce; + let state = { + view: "deployments", + deployments: [], + templates: [], + quota: null, + selectedDeployment: null, + error: null, + loading: true + }; + + const request = async (path, options = {}) => { + const response = await fetch(`${api}${path}`, { + ...options, + headers: { + "Content-Type": "application/json", + "X-WP-Nonce": nonce, + ...(options.headers || {}) + } + }); + + const payload = await response.json().catch(() => null); + if (!response.ok) { + throw new Error(payload && payload.message ? payload.message : `Request failed with ${response.status}`); + } + return payload; + }; + + const load = async () => { + state = { ...state, loading: true, error: null }; + render(); + try { + const [deployments, templates, quota] = await Promise.all([ + request("/deployments"), + request("/templates"), + request("/quota") + ]); + state = { ...state, deployments, templates, quota, loading: false }; + } catch (error) { + state = { ...state, error: error.message, loading: false }; + } + render(); + }; + + const lifecycle = async (id, action) => { + try { + const path = action === "delete" ? `/deployments/${id}` : `/deployments/${id}/${action}`; + await request(path, { method: action === "delete" ? "DELETE" : "POST" }); + await load(); + state.selectedDeployment = action === "delete" ? null : await request(`/deployments/${id}`); + state.view = action === "delete" ? "deployments" : "detail"; + } catch (error) { + state = { ...state, error: error.message }; + } + render(); + }; + + const prolongDeployment = async (event, id) => { + event.preventDefault(); + const form = new FormData(event.currentTarget); + + try { + const deployment = await request(`/deployments/${id}/prolong`, { + method: "POST", + body: JSON.stringify({ + ttlHours: form.get("ttlHours") ? Number(form.get("ttlHours")) : undefined, + neverExpire: form.get("neverExpire") === "1" + }) + }); + await load(); + state = { ...state, view: "detail", selectedDeployment: deployment }; + } catch (error) { + state = { ...state, error: error.message }; + } + render(); + }; + + const createDeployment = async (event) => { + event.preventDefault(); + const form = new FormData(event.currentTarget); + + try { + const deployment = await request("/deployments", { + method: "POST", + body: JSON.stringify({ + templateId: Number(form.get("templateId")), + name: String(form.get("name")), + ttlHours: form.get("ttlHours") ? Number(form.get("ttlHours")) : undefined, + neverExpire: form.get("neverExpire") === "1" + }) + }); + await load(); + state = { ...state, view: "detail", selectedDeployment: deployment }; + } catch (error) { + state = { ...state, error: error.message }; + } + render(); + }; + + const statusBadge = (status) => `${status.replace("_", " ")}`; + + const ipList = (ips) => { + if (!Array.isArray(ips) || ips.length === 0) { + return "Pending"; + } + + return ips.map(escapeHtml).join(", "); + }; + + const dateTime = (value) => { + if (!value) { + return "Never"; + } + + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short" + }).format(new Date(value.replace(" ", "T"))); + }; + + const quotaLine = () => { + if (!state.quota) { + return ""; + } + + const userLimit = state.quota.userLimitMb > 0 ? `${state.quota.userLimitMb} MB` : "Unlimited"; + const globalLimit = state.quota.globalLimitMb > 0 ? `${state.quota.globalLimitMb} MB` : "Unlimited"; + + return ` +
+ Your RAM: ${state.quota.userUsedMb} MB / ${userLimit} + Global RAM: ${state.quota.globalUsedMb} MB / ${globalLimit} +
+ `; + }; + + const header = () => ` +
+
+

Support Provisioning Portal

+

Template-based VM provisioning for support work.

+ ${quotaLine()} +
+
+ + + +
+
+ `; + + const deploymentsView = () => { + if (state.deployments.length === 0) { + return '

No deployments have been created yet.

'; + } + + return ` +
+ + + + + + ${state.deployments.map((deployment) => ` + + + + + + + + + `).join("")} + +
NameStatusTemplateIP addressesExpires
${escapeHtml(deployment.name)}${statusBadge(deployment.status)}${escapeHtml(deployment.templateName)}${ipList(deployment.ipAddresses)}${dateTime(deployment.expiresAt)}
+
+ `; + }; + + const templatesView = () => ` +
+ ${state.templates.map((template) => ` +
+

${escapeHtml(template.name)}

+

${escapeHtml(template.description)}

+
+ OS${template.osType} + CPU${template.cpuCores} cores + Memory${template.memoryMb} MB + Disk${template.diskGb} GB + Default TTL${template.defaultTtlHours}h +
+
+ `).join("")} +
+ `; + + const createView = () => ` +
+
+ + + + + +
+
+ `; + + const detailView = () => { + const deployment = state.selectedDeployment; + if (!deployment) { + return deploymentsView(); + } + + return ` +
+ ${deployment.status === "EXPIRED" ? ` +
+ This deployment is expired${deployment.errorMessage ? "" : " and has been stopped"}. Prolong its TTL to unlock start actions, or delete it when the data is no longer needed. + ${deployment.errorMessage ? `
Stop warning: ${escapeHtml(deployment.errorMessage)}` : ""} +
+ ` : ""} +
+
+ +

${escapeHtml(deployment.name)}

+ ${statusBadge(deployment.status)} +
+
+ + + + +
+
+
+ Template${escapeHtml(deployment.templateName)} + Requested by${escapeHtml(deployment.requestedByName)} + Proxmox VM ID${deployment.proxmoxVmId || "Pending"} + IP addresses${ipList(deployment.ipAddresses)} + CPU${deployment.cpuCores} cores + Memory${deployment.memoryMb} MB + Disk${deployment.diskGb} GB + Created${dateTime(deployment.createdAt)} + Expires${dateTime(deployment.expiresAt)} +
+
+

Prolong TTL

+ + + +
+
+ `; + }; + + const render = () => { + root.innerHTML = ` + ${header()} + ${state.error ? `

${escapeHtml(state.error)}

` : ""} + ${state.loading ? '

Loading...

' : ""} + ${!state.loading && state.view === "deployments" ? deploymentsView() : ""} + ${!state.loading && state.view === "templates" ? templatesView() : ""} + ${!state.loading && state.view === "create" ? createView() : ""} + ${!state.loading && state.view === "detail" ? detailView() : ""} + `; + + root.querySelectorAll("[data-view]").forEach((button) => { + button.addEventListener("click", () => { + state = { ...state, view: button.dataset.view, selectedDeployment: null }; + render(); + }); + }); + + root.querySelectorAll("[data-detail]").forEach((button) => { + button.addEventListener("click", async () => { + try { + state.selectedDeployment = await request(`/deployments/${button.dataset.detail}`); + state.view = "detail"; + } catch (error) { + state.error = error.message; + } + render(); + }); + }); + + root.querySelectorAll("[data-action]").forEach((button) => { + button.addEventListener("click", () => lifecycle(Number(button.dataset.id), button.dataset.action)); + }); + + const form = root.querySelector("#spp-create-form"); + if (form) { + form.addEventListener("submit", createDeployment); + const neverExpire = form.querySelector('input[name="neverExpire"]'); + const ttlHours = form.querySelector('input[name="ttlHours"]'); + if (neverExpire && ttlHours) { + neverExpire.addEventListener("change", () => { + ttlHours.disabled = neverExpire.checked; + if (neverExpire.checked) { + ttlHours.value = ""; + } + }); + } + } + + root.querySelectorAll("[data-prolong-form]").forEach((form) => { + form.addEventListener("submit", (event) => prolongDeployment(event, Number(form.dataset.prolongForm))); + const neverExpire = form.querySelector('input[name="neverExpire"]'); + const ttlHours = form.querySelector('input[name="ttlHours"]'); + if (neverExpire && ttlHours) { + neverExpire.addEventListener("change", () => { + ttlHours.disabled = neverExpire.checked; + if (neverExpire.checked) { + ttlHours.value = ""; + } + }); + } + }); + }; + + const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (character) => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'" + }[character])); + + load(); + }); +})(); diff --git a/support-provisioning-portal/includes/class-spp-activator.php b/support-provisioning-portal/includes/class-spp-activator.php new file mode 100644 index 0000000..c9b699f --- /dev/null +++ b/support-provisioning-portal/includes/class-spp-activator.php @@ -0,0 +1,188 @@ +get_charset_collate(); + $templates = self::table('templates'); + $deployments = self::table('deployments'); + $audit_logs = self::table('audit_logs'); + + dbDelta("CREATE TABLE {$templates} ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + template_key varchar(80) NOT NULL, + name varchar(160) NOT NULL, + description text NOT NULL, + os_type varchar(24) NOT NULL, + cpu_cores int unsigned NOT NULL, + memory_mb int unsigned NOT NULL, + disk_gb int unsigned NOT NULL, + default_ttl_hours int unsigned NOT NULL, + proxmox_template_id int unsigned NOT NULL, + is_active tinyint(1) NOT NULL DEFAULT 1, + created_at datetime NOT NULL, + updated_at datetime NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY template_key (template_key) + ) {$charset_collate};"); + + dbDelta("CREATE TABLE {$deployments} ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + name varchar(160) NOT NULL, + status varchar(32) NOT NULL, + proxmox_vm_id int unsigned DEFAULT NULL, + ip_addresses longtext NULL, + error_message text NULL, + expires_at datetime NULL, + created_at datetime NOT NULL, + updated_at datetime NOT NULL, + template_id bigint(20) unsigned NOT NULL, + requested_by bigint(20) unsigned NOT NULL, + PRIMARY KEY (id), + KEY status (status), + KEY expires_at (expires_at), + KEY template_id (template_id), + KEY requested_by (requested_by) + ) {$charset_collate};"); + + $wpdb->query("ALTER TABLE {$deployments} MODIFY expires_at datetime NULL"); + + $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"); + } + + dbDelta("CREATE TABLE {$audit_logs} ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + action varchar(80) NOT NULL, + entity_type varchar(80) NOT NULL, + entity_id bigint(20) unsigned NOT NULL, + actor_id bigint(20) unsigned NOT NULL, + metadata longtext NULL, + created_at datetime NOT NULL, + PRIMARY KEY (id), + KEY entity_lookup (entity_type, entity_id), + KEY created_at (created_at) + ) {$charset_collate};"); + + } + + private static function schedule_expiration_check(): void + { + if (!wp_next_scheduled('spp_expire_deployments')) { + wp_schedule_event(time() + 5 * MINUTE_IN_SECONDS, 'hourly', 'spp_expire_deployments'); + } + } + + public static function table(string $name): string + { + global $wpdb; + + return $wpdb->prefix . 'spp_' . $name; + } + + private static function seed_templates(): void + { + global $wpdb; + + $table = self::table('templates'); + $now = current_time('mysql'); + $templates = [ + [ + 'template_key' => 'turnkey-pbx-test', + 'name' => 'Turnkey PBX Test Appliance', + 'description' => 'Small PBX appliance for call-flow reproduction and support testing.', + 'os_type' => 'APPLIANCE', + 'cpu_cores' => 2, + 'memory_mb' => 2048, + 'disk_gb' => 24, + 'default_ttl_hours' => 72, + 'proxmox_template_id' => 9001, + ], + [ + 'template_key' => 'windows-support-client', + 'name' => 'Windows Support Client', + 'description' => 'Standard Windows client VM with support tooling pre-installed.', + 'os_type' => 'WINDOWS', + 'cpu_cores' => 4, + 'memory_mb' => 8192, + 'disk_gb' => 80, + 'default_ttl_hours' => 48, + 'proxmox_template_id' => 9002, + ], + [ + 'template_key' => 'linux-utility-vm', + 'name' => 'Linux Utility VM', + 'description' => 'Lightweight Linux host for network checks, packet capture, and diagnostics.', + 'os_type' => 'LINUX', + 'cpu_cores' => 2, + 'memory_mb' => 2048, + 'disk_gb' => 32, + 'default_ttl_hours' => 168, + 'proxmox_template_id' => 9003, + ], + ]; + + foreach ($templates as $template) { + $exists = (int) $wpdb->get_var( + $wpdb->prepare("SELECT id FROM {$table} WHERE template_key = %s", $template['template_key']) + ); + + $data = array_merge($template, [ + 'is_active' => 1, + 'updated_at' => $now, + ]); + + if ($exists > 0) { + $wpdb->update($table, $data, ['id' => $exists]); + continue; + } + + $wpdb->insert($table, array_merge($data, ['created_at' => $now])); + } + } +} diff --git a/support-provisioning-portal/includes/class-spp-admin-page.php b/support-provisioning-portal/includes/class-spp-admin-page.php new file mode 100644 index 0000000..4e85843 --- /dev/null +++ b/support-provisioning-portal/includes/class-spp-admin-page.php @@ -0,0 +1,186 @@ + 'string', + 'sanitize_callback' => static fn($value) => $value === 'http' ? 'http' : 'mock', + 'default' => 'mock', + ]); + register_setting('spp_settings', 'spp_proxmox_base_url', ['type' => 'string', 'sanitize_callback' => 'esc_url_raw']); + register_setting('spp_settings', 'spp_proxmox_token_id', ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field']); + register_setting('spp_settings', 'spp_proxmox_token_secret', ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field']); + register_setting('spp_settings', 'spp_proxmox_node', ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => 'pve-01']); + register_setting('spp_settings', 'spp_quota_user_memory_mb', [ + 'type' => 'integer', + 'sanitize_callback' => static fn($value) => max(0, absint($value)), + 'default' => 0, + ]); + register_setting('spp_settings', 'spp_quota_global_memory_mb', [ + 'type' => 'integer', + 'sanitize_callback' => static fn($value) => max(0, absint($value)), + 'default' => 0, + ]); + } + + public function enqueue_assets(string $hook): void + { + if ($hook !== 'toplevel_page_support-provisioning-portal') { + return; + } + + wp_enqueue_style('spp-portal', SPP_PLUGIN_URL . 'assets/portal.css', [], SPP_VERSION); + wp_enqueue_script('spp-portal', SPP_PLUGIN_URL . 'assets/portal.js', [], SPP_VERSION, true); + } + + public function render(): void + { + if (!current_user_can('edit_posts')) { + wp_die(esc_html__('You do not have permission to access this page.', 'support-provisioning-portal')); + } + + ?> +
+

+
+ render_app_root(); ?> + render_settings(); ?> +
+
+ +
+ +
+

+ + + + + + +

+

+ + + +
+ ID, 'spp_memory_quota_mb', true); + ?> +

+ + + + + + + repository->due_for_expiration() as $deployment) { + $stop_error = null; + + if (!empty($deployment['proxmox_vm_id']) && $deployment['status'] !== 'STOPPED') { + try { + $this->proxmox->stop_vm((int) $deployment['proxmox_vm_id']); + } catch (Throwable $error) { + $stop_error = $error->getMessage(); + } + } + + $this->repository->mark_deployment_expired((int) $deployment['id'], $stop_error); + } + } +} diff --git a/support-provisioning-portal/includes/class-spp-http-proxmox-client.php b/support-provisioning-portal/includes/class-spp-http-proxmox-client.php new file mode 100644 index 0000000..bde6a36 --- /dev/null +++ b/support-provisioning-portal/includes/class-spp-http-proxmox-client.php @@ -0,0 +1,124 @@ +options = $options; + } + + public function clone_vm(array $input): array + { + $vm_id = (int) $this->request('/cluster/nextid', 'GET'); + $template_id = (int) $input['template_vm_id']; + + $this->request("/nodes/{$this->options['node']}/qemu/{$template_id}/clone", 'POST', [ + 'newid' => $vm_id, + 'name' => (string) $input['name'], + 'full' => 1, + ]); + + $this->request("/nodes/{$this->options['node']}/qemu/{$vm_id}/config", 'PUT', [ + 'cores' => (int) $input['cpu_cores'], + 'memory' => (int) $input['memory_mb'], + ]); + + return ['vm_id' => $vm_id]; + } + + public function start_vm(int $vm_id): void + { + $this->request("/nodes/{$this->options['node']}/qemu/{$vm_id}/status/start", 'POST'); + } + + 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 + { + $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'] : []; + $ips = []; + + foreach ($interfaces as $interface) { + if (!is_array($interface) || empty($interface['ip-addresses']) || !is_array($interface['ip-addresses'])) { + continue; + } + + foreach ($interface['ip-addresses'] as $address) { + if (!is_array($address) || empty($address['ip-address'])) { + continue; + } + + $ip = (string) $address['ip-address']; + if ($ip === '127.0.0.1' || $ip === '::1' || str_starts_with($ip, 'fe80:')) { + continue; + } + + $ips[] = $ip; + } + } + + return array_values(array_unique($ips)); + } + + /** + * @param array $body + * @return mixed + */ + private function request(string $path, string $method, array $body = []) + { + foreach (['base_url', 'token_id', 'token_secret', 'node'] as $key) { + if ($this->options[$key] === '') { + throw new RuntimeException('Missing Proxmox HTTP configuration.'); + } + } + + $url = trailingslashit($this->options['base_url']) . 'api2/json' . $path; + $response = wp_remote_request($url, [ + 'method' => $method, + 'timeout' => 30, + 'headers' => [ + 'Authorization' => 'PVEAPIToken=' . $this->options['token_id'] . '=' . $this->options['token_secret'], + ], + 'body' => $body, + ]); + + if (is_wp_error($response)) { + throw new RuntimeException($response->get_error_message()); + } + + $status = (int) wp_remote_retrieve_response_code($response); + if ($status < 200 || $status >= 300) { + throw new RuntimeException('Proxmox request failed with HTTP ' . $status); + } + + $payload = json_decode(wp_remote_retrieve_body($response), true); + + return is_array($payload) && array_key_exists('data', $payload) ? $payload['data'] : null; + } +} diff --git a/support-provisioning-portal/includes/class-spp-mock-proxmox-client.php b/support-provisioning-portal/includes/class-spp-mock-proxmox-client.php new file mode 100644 index 0000000..11a6f97 --- /dev/null +++ b/support-provisioning-portal/includes/class-spp-mock-proxmox-client.php @@ -0,0 +1,56 @@ + $next_id]; + } + + public function start_vm(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 + { + $this->ensure_vm($vm_id); + update_option('spp_mock_vm_status_' . $vm_id, 'stopped', false); + } + + public function delete_vm(int $vm_id): void + { + $this->ensure_vm($vm_id); + delete_option('spp_mock_vm_status_' . $vm_id); + delete_option('spp_mock_vm_ips_' . $vm_id); + } + + public function get_status(int $vm_id): string + { + return (string) get_option('spp_mock_vm_status_' . $vm_id, 'unknown'); + } + + public function get_ip_addresses(int $vm_id): array + { + $ips = get_option('spp_mock_vm_ips_' . $vm_id, []); + + return is_array($ips) ? array_values(array_map('strval', $ips)) : []; + } + + 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.'); + } + } +} diff --git a/support-provisioning-portal/includes/class-spp-plugin.php b/support-provisioning-portal/includes/class-spp-plugin.php new file mode 100644 index 0000000..282d4ef --- /dev/null +++ b/support-provisioning-portal/includes/class-spp-plugin.php @@ -0,0 +1,50 @@ +make_proxmox_client(); + $expiration_service = new SPP_Expiration_Service($repository, $proxmox); + + add_action('spp_expire_deployments', [$expiration_service, 'expire_due_deployments']); + + (new SPP_REST_Controller($repository, $proxmox, $expiration_service))->register_hooks(); + (new SPP_Admin_Page())->register_hooks(); + (new SPP_Shortcode())->register_hooks(); + } + + private function make_proxmox_client(): SPP_Proxmox_Client + { + $mode = get_option('spp_proxmox_mode', 'mock'); + + if ($mode === 'http') { + return new SPP_Http_Proxmox_Client([ + 'base_url' => (string) get_option('spp_proxmox_base_url', ''), + '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', ''), + ]); + } + + return new SPP_Mock_Proxmox_Client(); + } +} diff --git a/support-provisioning-portal/includes/class-spp-repository.php b/support-provisioning-portal/includes/class-spp-repository.php new file mode 100644 index 0000000..d11b2a9 --- /dev/null +++ b/support-provisioning-portal/includes/class-spp-repository.php @@ -0,0 +1,378 @@ +> + */ + public function templates(): array + { + global $wpdb; + + $table = SPP_Activator::table('templates'); + $rows = $wpdb->get_results("SELECT * FROM {$table} WHERE is_active = 1 ORDER BY name ASC", ARRAY_A); + + return array_map([$this, 'template_dto'], is_array($rows) ? $rows : []); + } + + /** + * @return array|null + */ + public function template(int $id): ?array + { + global $wpdb; + + $table = SPP_Activator::table('templates'); + $row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE id = %d AND is_active = 1", $id), ARRAY_A); + + return is_array($row) ? $row : null; + } + + /** + * @return array> + */ + public function deployments(): array + { + global $wpdb; + + $deployments = SPP_Activator::table('deployments'); + $templates = SPP_Activator::table('templates'); + $users = $wpdb->users; + + $rows = $wpdb->get_results( + "SELECT d.*, t.name AS template_name, t.cpu_cores, t.memory_mb, t.disk_gb, u.display_name AS requested_by_name + FROM {$deployments} d + INNER JOIN {$templates} t ON t.id = d.template_id + INNER JOIN {$users} u ON u.ID = d.requested_by + WHERE d.status <> 'DELETED' + ORDER BY d.created_at DESC", + ARRAY_A + ); + + return array_map([$this, 'deployment_summary_dto'], is_array($rows) ? $rows : []); + } + + /** + * @return array|null + */ + public function deployment(int $id): ?array + { + global $wpdb; + + $deployments = SPP_Activator::table('deployments'); + $templates = SPP_Activator::table('templates'); + $users = $wpdb->users; + + $row = $wpdb->get_row( + $wpdb->prepare( + "SELECT d.*, t.name AS template_name, t.cpu_cores, t.memory_mb, t.disk_gb, u.display_name AS requested_by_name + FROM {$deployments} d + INNER JOIN {$templates} t ON t.id = d.template_id + INNER JOIN {$users} u ON u.ID = d.requested_by + WHERE d.id = %d", + $id + ), + ARRAY_A + ); + + return is_array($row) ? $this->deployment_detail_dto($row) : null; + } + + /** + * @param array $template + * @return array + */ + public function create_deployment(array $template, string $name, ?int $ttl_hours, int $vm_id, array $ip_addresses, int $actor_id): array + { + global $wpdb; + + $now = current_time('mysql'); + $expires_at = $ttl_hours === null ? null : date('Y-m-d H:i:s', current_time('timestamp') + ($ttl_hours * HOUR_IN_SECONDS)); + $table = SPP_Activator::table('deployments'); + + $wpdb->insert($table, [ + 'name' => $name, + 'status' => 'STOPPED', + 'proxmox_vm_id' => $vm_id, + 'ip_addresses' => wp_json_encode(array_values($ip_addresses)), + 'expires_at' => $expires_at, + 'created_at' => $now, + 'updated_at' => $now, + 'template_id' => (int) $template['id'], + 'requested_by' => $actor_id, + ]); + + $deployment_id = (int) $wpdb->insert_id; + $this->audit('DEPLOYMENT_CREATED', 'deployment', $deployment_id, $actor_id, [ + 'template_id' => (int) $template['id'], + 'proxmox_vm_id' => $vm_id, + 'ip_addresses' => $ip_addresses, + 'ttl_hours' => $ttl_hours, + 'never_expire' => $ttl_hours === null, + ]); + + return $this->deployment($deployment_id); + } + + public function update_deployment_status(int $id, string $status, string $action, int $actor_id): ?array + { + global $wpdb; + + $table = SPP_Activator::table('deployments'); + $wpdb->update($table, [ + 'status' => $status, + 'updated_at' => current_time('mysql'), + ], ['id' => $id]); + + $this->audit($action, 'deployment', $id, $actor_id, ['status' => $status]); + + return $this->deployment($id); + } + + public function update_deployment_status_and_ips(int $id, string $status, array $ip_addresses, string $action, int $actor_id): ?array + { + global $wpdb; + + $table = SPP_Activator::table('deployments'); + $wpdb->update($table, [ + 'status' => $status, + 'ip_addresses' => wp_json_encode(array_values($ip_addresses)), + 'updated_at' => current_time('mysql'), + ], ['id' => $id]); + + $this->audit($action, 'deployment', $id, $actor_id, [ + 'status' => $status, + 'ip_addresses' => $ip_addresses, + ]); + + return $this->deployment($id); + } + + public function update_deployment_ips(int $id, array $ip_addresses, int $actor_id): ?array + { + global $wpdb; + + $table = SPP_Activator::table('deployments'); + $wpdb->update($table, [ + 'ip_addresses' => wp_json_encode(array_values($ip_addresses)), + 'updated_at' => current_time('mysql'), + ], ['id' => $id]); + + $this->audit('DEPLOYMENT_IPS_REFRESHED', 'deployment', $id, $actor_id, [ + 'ip_addresses' => $ip_addresses, + ]); + + return $this->deployment($id); + } + + public function prolong_deployment(int $id, ?int $ttl_hours, int $actor_id): ?array + { + global $wpdb; + + $expires_at = $ttl_hours === null ? null : date('Y-m-d H:i:s', current_time('timestamp') + ($ttl_hours * HOUR_IN_SECONDS)); + $table = SPP_Activator::table('deployments'); + + $wpdb->update($table, [ + 'status' => 'STOPPED', + 'expires_at' => $expires_at, + 'error_message' => null, + 'updated_at' => current_time('mysql'), + ], ['id' => $id]); + + $this->audit('DEPLOYMENT_PROLONGED', 'deployment', $id, $actor_id, [ + 'ttl_hours' => $ttl_hours, + 'never_expire' => $ttl_hours === null, + ]); + + return $this->deployment($id); + } + + /** + * @return array|null + */ + public function deployment_record(int $id): ?array + { + global $wpdb; + + $table = SPP_Activator::table('deployments'); + $row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE id = %d", $id), ARRAY_A); + + return is_array($row) ? $row : null; + } + + /** + * @return array> + */ + public function due_for_expiration(): array + { + global $wpdb; + + $table = SPP_Activator::table('deployments'); + $now = current_time('mysql'); + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM {$table} + WHERE expires_at IS NOT NULL + AND expires_at <= %s + AND status IN ('PROVISIONING', 'STOPPED', 'RUNNING')", + $now + ), + ARRAY_A + ); + + return is_array($rows) ? $rows : []; + } + + public function mark_deployment_expired(int $id, ?string $stop_error): void + { + global $wpdb; + + $table = SPP_Activator::table('deployments'); + $wpdb->update($table, [ + 'status' => 'EXPIRED', + 'error_message' => $stop_error, + 'updated_at' => current_time('mysql'), + ], ['id' => $id]); + + $this->audit('DEPLOYMENT_EXPIRED', 'deployment', $id, 0, [ + 'stopped' => $stop_error === null, + 'stop_error' => $stop_error, + ]); + } + + /** + * @return array{userUsedMb:int,userLimitMb:int,globalUsedMb:int,globalLimitMb:int} + */ + public function quota(int $actor_id): array + { + return [ + 'userUsedMb' => $this->allocated_memory_mb($actor_id), + 'userLimitMb' => $this->user_memory_limit_mb($actor_id), + 'globalUsedMb' => $this->allocated_memory_mb(null), + 'globalLimitMb' => max(0, (int) get_option('spp_quota_global_memory_mb', 0)), + ]; + } + + private function user_memory_limit_mb(int $actor_id): int + { + $user_limit = get_user_meta($actor_id, 'spp_memory_quota_mb', true); + + if ($user_limit !== '') { + return max(0, (int) $user_limit); + } + + return max(0, (int) get_option('spp_quota_user_memory_mb', 0)); + } + + private function allocated_memory_mb(?int $actor_id): int + { + global $wpdb; + + $deployments = SPP_Activator::table('deployments'); + $templates = SPP_Activator::table('templates'); + $active_statuses = ['PROVISIONING', 'STOPPED', 'RUNNING', 'DELETING']; + $placeholders = implode(',', array_fill(0, count($active_statuses), '%s')); + + $sql = "SELECT COALESCE(SUM(t.memory_mb), 0) + FROM {$deployments} d + INNER JOIN {$templates} t ON t.id = d.template_id + WHERE d.status IN ({$placeholders})"; + $params = $active_statuses; + + if ($actor_id !== null) { + $sql .= ' AND d.requested_by = %d'; + $params[] = $actor_id; + } + + return (int) $wpdb->get_var($wpdb->prepare($sql, $params)); + } + + /** + * @param array $metadata + */ + private function audit(string $action, string $entity_type, int $entity_id, int $actor_id, array $metadata): void + { + global $wpdb; + + $wpdb->insert(SPP_Activator::table('audit_logs'), [ + 'action' => $action, + 'entity_type' => $entity_type, + 'entity_id' => $entity_id, + 'actor_id' => $actor_id, + 'metadata' => wp_json_encode($metadata), + 'created_at' => current_time('mysql'), + ]); + } + + /** + * @param array $row + * @return array + */ + private function template_dto(array $row): array + { + return [ + 'id' => (int) $row['id'], + 'key' => (string) $row['template_key'], + 'name' => (string) $row['name'], + 'description' => (string) $row['description'], + 'osType' => (string) $row['os_type'], + 'cpuCores' => (int) $row['cpu_cores'], + 'memoryMb' => (int) $row['memory_mb'], + 'diskGb' => (int) $row['disk_gb'], + 'defaultTtlHours' => (int) $row['default_ttl_hours'], + ]; + } + + /** + * @param array $row + * @return array + */ + private function deployment_summary_dto(array $row): array + { + return [ + 'id' => (int) $row['id'], + 'name' => (string) $row['name'], + 'status' => (string) $row['status'], + 'templateName' => (string) $row['template_name'], + 'requestedByName' => (string) $row['requested_by_name'], + 'ipAddresses' => $this->ip_addresses_from_row($row), + 'expiresAt' => $row['expires_at'] === null ? null : (string) $row['expires_at'], + 'createdAt' => (string) $row['created_at'], + ]; + } + + /** + * @param array $row + * @return array + */ + private function deployment_detail_dto(array $row): array + { + 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, + 'cpuCores' => (int) $row['cpu_cores'], + 'memoryMb' => (int) $row['memory_mb'], + 'diskGb' => (int) $row['disk_gb'], + 'errorMessage' => $row['error_message'] === null ? null : (string) $row['error_message'], + ]); + } + + /** + * @param array $row + * @return array + */ + private function ip_addresses_from_row(array $row): array + { + if (empty($row['ip_addresses'])) { + return []; + } + + $decoded = json_decode((string) $row['ip_addresses'], true); + + return is_array($decoded) ? array_values(array_map('strval', $decoded)) : []; + } +} diff --git a/support-provisioning-portal/includes/class-spp-rest-controller.php b/support-provisioning-portal/includes/class-spp-rest-controller.php new file mode 100644 index 0000000..354621b --- /dev/null +++ b/support-provisioning-portal/includes/class-spp-rest-controller.php @@ -0,0 +1,348 @@ + WP_REST_Server::READABLE, + 'callback' => [$this, 'list_templates'], + 'permission_callback' => [$this, 'can_read'], + ]); + + register_rest_route(self::NAMESPACE, '/quota', [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [$this, 'get_quota'], + 'permission_callback' => [$this, 'can_read'], + ]); + + register_rest_route(self::NAMESPACE, '/deployments', [ + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [$this, 'list_deployments'], + 'permission_callback' => [$this, 'can_read'], + ], + [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [$this, 'create_deployment'], + 'permission_callback' => [$this, 'can_mutate'], + ], + ]); + + register_rest_route(self::NAMESPACE, '/deployments/(?P\d+)', [ + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [$this, 'get_deployment'], + 'permission_callback' => [$this, 'can_read'], + ], + [ + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => [$this, 'delete_deployment'], + 'permission_callback' => [$this, 'can_mutate'], + ], + ]); + + register_rest_route(self::NAMESPACE, '/deployments/(?P\d+)/start', [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [$this, 'start_deployment'], + 'permission_callback' => [$this, 'can_mutate'], + ]); + + register_rest_route(self::NAMESPACE, '/deployments/(?P\d+)/stop', [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [$this, 'stop_deployment'], + 'permission_callback' => [$this, 'can_mutate'], + ]); + + register_rest_route(self::NAMESPACE, '/deployments/(?P\d+)/prolong', [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [$this, 'prolong_deployment'], + 'permission_callback' => [$this, 'can_mutate'], + ]); + + register_rest_route(self::NAMESPACE, '/deployments/(?P\d+)/refresh-ips', [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [$this, 'refresh_deployment_ips'], + 'permission_callback' => [$this, 'can_mutate'], + ]); + } + + public function can_read(): bool + { + return is_user_logged_in() && current_user_can('read'); + } + + public function can_mutate(): bool + { + return is_user_logged_in() && current_user_can('edit_posts'); + } + + public function list_templates(): WP_REST_Response + { + $this->sync_expirations(); + + return rest_ensure_response($this->repository->templates()); + } + + public function get_quota(): WP_REST_Response + { + $this->sync_expirations(); + + return rest_ensure_response($this->repository->quota(get_current_user_id())); + } + + public function list_deployments(): WP_REST_Response + { + $this->sync_expirations(); + + return rest_ensure_response($this->repository->deployments()); + } + + public function get_deployment(WP_REST_Request $request): WP_REST_Response|WP_Error + { + $this->sync_expirations(); + $deployment = $this->repository->deployment((int) $request['id']); + + if ($deployment === null) { + return new WP_Error('spp_not_found', 'Deployment not found.', ['status' => 404]); + } + + return rest_ensure_response($deployment); + } + + public function create_deployment(WP_REST_Request $request): WP_REST_Response|WP_Error + { + $this->sync_expirations(); + $template_id = (int) $request->get_param('templateId'); + $name = sanitize_text_field((string) $request->get_param('name')); + $ttl_hours = (int) $request->get_param('ttlHours'); + $never_expire = (bool) $request->get_param('neverExpire'); + + if ($template_id < 1 || strlen($name) < 3 || strlen($name) > 160) { + return new WP_Error('spp_invalid_request', 'Template and a deployment name of 3-160 characters are required.', ['status' => 400]); + } + + $template = $this->repository->template($template_id); + if ($template === null) { + return new WP_Error('spp_template_not_found', 'Template not found.', ['status' => 404]); + } + + if ($never_expire) { + $ttl_hours = null; + } elseif ($ttl_hours < 1) { + $ttl_hours = (int) $template['default_ttl_hours']; + } + + if ($ttl_hours !== null && $ttl_hours > 720) { + return new WP_Error('spp_ttl_too_long', 'TTL cannot exceed 720 hours.', ['status' => 400]); + } + + $quota_error = $this->validate_memory_quota((int) $template['memory_mb'], get_current_user_id()); + if ($quota_error instanceof WP_Error) { + return $quota_error; + } + + try { + $clone = $this->proxmox->clone_vm([ + 'template_vm_id' => (int) $template['proxmox_template_id'], + 'name' => $name, + 'cpu_cores' => (int) $template['cpu_cores'], + 'memory_mb' => (int) $template['memory_mb'], + 'disk_gb' => (int) $template['disk_gb'], + ]); + + $vm_id = (int) $clone['vm_id']; + $deployment = $this->repository->create_deployment( + $template, + $name, + $ttl_hours, + $vm_id, + $this->safe_ip_addresses($vm_id), + get_current_user_id() + ); + } catch (Throwable $error) { + return new WP_Error('spp_proxmox_error', $error->getMessage(), ['status' => 502]); + } + + return new WP_REST_Response($deployment, 201); + } + + private function validate_memory_quota(int $requested_memory_mb, int $actor_id): ?WP_Error + { + $quota = $this->repository->quota($actor_id); + + if ($quota['userLimitMb'] > 0 && ($quota['userUsedMb'] + $requested_memory_mb) > $quota['userLimitMb']) { + return new WP_Error( + 'spp_user_quota_exceeded', + 'This deployment would exceed your RAM contingent.', + ['status' => 403, 'quota' => $quota] + ); + } + + if ($quota['globalLimitMb'] > 0 && ($quota['globalUsedMb'] + $requested_memory_mb) > $quota['globalLimitMb']) { + return new WP_Error( + 'spp_global_quota_exceeded', + 'This deployment would exceed the global RAM contingent.', + ['status' => 403, 'quota' => $quota] + ); + } + + return null; + } + + 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'); + } + + 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'); + } + + 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'); + } + + public function prolong_deployment(WP_REST_Request $request): WP_REST_Response|WP_Error + { + $this->sync_expirations(); + $id = (int) $request['id']; + $record = $this->repository->deployment_record($id); + + if ($record === null || $record['status'] === 'DELETED') { + return new WP_Error('spp_not_found', 'Deployment not found.', ['status' => 404]); + } + + $ttl_hours = (int) $request->get_param('ttlHours'); + $never_expire = (bool) $request->get_param('neverExpire'); + $deployment = $this->repository->deployment($id); + + if ($deployment === null) { + return new WP_Error('spp_not_found', 'Deployment not found.', ['status' => 404]); + } + + if ($never_expire) { + $ttl_hours = null; + } elseif ($ttl_hours < 1) { + return new WP_Error('spp_invalid_ttl', 'Choose a TTL in hours or select never expire.', ['status' => 400]); + } + + if ($ttl_hours !== null && $ttl_hours > 720) { + return new WP_Error('spp_ttl_too_long', 'TTL cannot exceed 720 hours.', ['status' => 400]); + } + + if ($record['status'] === 'EXPIRED') { + $quota_error = $this->validate_memory_quota((int) $deployment['memoryMb'], get_current_user_id()); + if ($quota_error instanceof WP_Error) { + return $quota_error; + } + } + + return rest_ensure_response($this->repository->prolong_deployment($id, $ttl_hours, get_current_user_id())); + } + + public function refresh_deployment_ips(WP_REST_Request $request): WP_REST_Response|WP_Error + { + $this->sync_expirations(); + $id = (int) $request['id']; + $record = $this->repository->deployment_record($id); + + if ($record === null || $record['status'] === 'DELETED') { + return new WP_Error('spp_not_found', 'Deployment not found.', ['status' => 404]); + } + + if (empty($record['proxmox_vm_id'])) { + return new WP_Error('spp_missing_vm_id', 'Deployment is missing a Proxmox VM id.', ['status' => 409]); + } + + try { + $deployment = $this->repository->update_deployment_ips( + $id, + $this->proxmox->get_ip_addresses((int) $record['proxmox_vm_id']), + get_current_user_id() + ); + } catch (Throwable $error) { + return new WP_Error('spp_proxmox_error', $error->getMessage(), ['status' => 502]); + } + + return rest_ensure_response($deployment); + } + + private function apply_lifecycle_action(int $id, string $status, string $audit_action, string $method): WP_REST_Response|WP_Error + { + $this->sync_expirations(); + $record = $this->repository->deployment_record($id); + + if ($record === null || $record['status'] === 'DELETED') { + return new WP_Error('spp_not_found', 'Deployment not found.', ['status' => 404]); + } + + if ($method === 'start_vm' && $record['status'] === 'EXPIRED') { + return new WP_Error( + 'spp_expired_deployment', + 'This deployment is expired. Prolong its TTL before starting it again.', + ['status' => 409] + ); + } + + if (empty($record['proxmox_vm_id'])) { + return new WP_Error('spp_missing_vm_id', 'Deployment is missing a Proxmox VM id.', ['status' => 409]); + } + + try { + $this->proxmox->{$method}((int) $record['proxmox_vm_id']); + if ($method === 'start_vm') { + $deployment = $this->repository->update_deployment_status_and_ips( + $id, + $status, + $this->safe_ip_addresses((int) $record['proxmox_vm_id']), + $audit_action, + get_current_user_id() + ); + } else { + $deployment = $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]); + } + + return rest_ensure_response($deployment); + } + + private function sync_expirations(): void + { + $this->expiration_service->expire_due_deployments(); + } + + /** + * @return array + */ + private function safe_ip_addresses(int $vm_id): array + { + try { + return $this->proxmox->get_ip_addresses($vm_id); + } catch (Throwable) { + return []; + } + } +} diff --git a/support-provisioning-portal/includes/class-spp-shortcode.php b/support-provisioning-portal/includes/class-spp-shortcode.php new file mode 100644 index 0000000..ea6294f --- /dev/null +++ b/support-provisioning-portal/includes/class-spp-shortcode.php @@ -0,0 +1,32 @@ + $atts + */ + public function render(array $atts = []): string + { + if (!is_user_logged_in()) { + return ''; + } + + wp_enqueue_style('spp-portal', SPP_PLUGIN_URL . 'assets/portal.css', [], SPP_VERSION); + wp_enqueue_script('spp-portal', SPP_PLUGIN_URL . 'assets/portal.js', [], SPP_VERSION, true); + + return sprintf( + '
', + esc_url_raw(rest_url('support-provisioning/v1')), + esc_attr(wp_create_nonce('wp_rest')) + ); + } +} diff --git a/support-provisioning-portal/includes/interface-spp-proxmox-client.php b/support-provisioning-portal/includes/interface-spp-proxmox-client.php new file mode 100644 index 0000000..6a88fab --- /dev/null +++ b/support-provisioning-portal/includes/interface-spp-proxmox-client.php @@ -0,0 +1,27 @@ + $input + * @return array{vm_id:int} + */ + public function clone_vm(array $input): array; + + public function start_vm(int $vm_id): void; + + public function stop_vm(int $vm_id): void; + + public function delete_vm(int $vm_id): void; + + public function get_status(int $vm_id): string; + + /** + * @return array + */ + public function get_ip_addresses(int $vm_id): array; +} diff --git a/support-provisioning-portal/support-provisioning-portal.php b/support-provisioning-portal/support-provisioning-portal.php new file mode 100644 index 0000000..38c52cc --- /dev/null +++ b/support-provisioning-portal/support-provisioning-portal.php @@ -0,0 +1,37 @@ +boot(); +});