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 []; } } }