modified: README.md

This commit is contained in:
2026-04-23 12:39:36 +02:00
parent cbaf3319ce
commit aee79ddbfa
15 changed files with 2371 additions and 108 deletions

View File

@@ -0,0 +1,348 @@
<?php
if (!defined('ABSPATH')) {
exit;
}
final class SPP_REST_Controller
{
private const NAMESPACE = 'support-provisioning/v1';
public function __construct(
private SPP_Repository $repository,
private SPP_Proxmox_Client $proxmox,
private SPP_Expiration_Service $expiration_service
) {
}
public function register_hooks(): void
{
add_action('rest_api_init', [$this, 'register_routes']);
}
public function register_routes(): void
{
register_rest_route(self::NAMESPACE, '/templates', [
'methods' => 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<id>\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<id>\d+)/start', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'start_deployment'],
'permission_callback' => [$this, 'can_mutate'],
]);
register_rest_route(self::NAMESPACE, '/deployments/(?P<id>\d+)/stop', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'stop_deployment'],
'permission_callback' => [$this, 'can_mutate'],
]);
register_rest_route(self::NAMESPACE, '/deployments/(?P<id>\d+)/prolong', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'prolong_deployment'],
'permission_callback' => [$this, 'can_mutate'],
]);
register_rest_route(self::NAMESPACE, '/deployments/(?P<id>\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<int, string>
*/
private function safe_ip_addresses(int $vm_id): array
{
try {
return $this->proxmox->get_ip_addresses($vm_id);
} catch (Throwable) {
return [];
}
}
}