349 lines
12 KiB
PHP
349 lines
12 KiB
PHP
<?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 [];
|
|
}
|
|
}
|
|
}
|