//! REST HTTP API handlers for PlasmaVMC //! //! Implements REST endpoints as specified in T050.S5: //! - GET /api/v1/vms - List VMs //! - POST /api/v1/vms - Create VM //! - GET /api/v1/vms/{id} - Get VM details //! - DELETE /api/v1/vms/{id} - Delete VM //! - POST /api/v1/vms/{id}/start - Start VM //! - POST /api/v1/vms/{id}/stop - Stop VM //! - GET /health - Health check use axum::{ extract::{Path, State}, http::HeaderMap, http::StatusCode, routing::{get, post}, Json, Router, }; use plasmavmc_api::proto::{ vm_service_server::VmService, CreateVmRequest, DeleteVmRequest, GetVmRequest, ListVmsRequest, MigrateVmRequest, StartVmRequest, StopVmRequest, VirtualMachine as ProtoVm, }; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tonic::Code; use tonic::Request; use crate::VmServiceImpl; use iam_service_auth::{resolve_tenant_ids_from_context, AuthService, TenantContext}; /// REST API state #[derive(Clone)] pub struct RestApiState { pub vm_service: Arc, pub auth_service: Arc, } /// Standard REST error response #[derive(Debug, Serialize)] pub struct ErrorResponse { pub error: ErrorDetail, pub meta: ResponseMeta, } #[derive(Debug, Serialize)] pub struct ErrorDetail { pub code: String, pub message: String, #[serde(skip_serializing_if = "Option::is_none")] pub details: Option, } #[derive(Debug, Serialize)] pub struct ResponseMeta { pub request_id: String, pub timestamp: String, } impl ResponseMeta { fn new() -> Self { Self { request_id: uuid::Uuid::new_v4().to_string(), timestamp: chrono::Utc::now().to_rfc3339(), } } } /// Standard REST success response #[derive(Debug, Serialize)] pub struct SuccessResponse { pub data: T, pub meta: ResponseMeta, } impl SuccessResponse { fn new(data: T) -> Self { Self { data, meta: ResponseMeta::new(), } } } /// VM creation request #[derive(Debug, Deserialize)] pub struct CreateVmRequestRest { pub name: String, pub org_id: Option, pub project_id: Option, pub vcpus: Option, pub memory_mib: Option, pub hypervisor: Option, #[serde(default)] pub disks: Vec, #[serde(default)] pub network: Vec, } #[derive(Debug, Deserialize)] pub struct DiskSpecRest { pub id: String, pub source: DiskSourceRest, pub size_gib: Option, pub bus: Option, pub cache: Option, pub boot_index: Option, } #[derive(Debug, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum DiskSourceRest { Image { image_id: String }, Volume { volume_id: String }, Blank, } #[derive(Debug, Deserialize)] pub struct NetworkSpecRest { pub id: Option, pub network_id: Option, pub subnet_id: Option, pub port_id: Option, pub mac_address: Option, pub ip_address: Option, pub cidr_block: Option, pub gateway_ip: Option, pub dhcp_enabled: Option, pub model: Option, #[serde(default)] pub security_groups: Vec, } /// VM migration request #[derive(Debug, Deserialize)] pub struct MigrateVmRequestRest { pub destination_node_id: String, pub timeout_seconds: Option, pub wait: Option, } /// VM response #[derive(Debug, Serialize)] pub struct VmResponse { pub id: String, pub name: String, pub org_id: String, pub project_id: String, #[serde(skip_serializing_if = "Option::is_none")] pub node_id: Option, pub state: String, pub hypervisor: String, pub cpus: u32, pub memory_mb: u64, pub network: Vec, } #[derive(Debug, Serialize)] pub struct VmNetworkResponse { pub id: String, pub network_id: String, #[serde(skip_serializing_if = "Option::is_none")] pub subnet_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub port_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub mac_address: Option, #[serde(skip_serializing_if = "Option::is_none")] pub ip_address: Option, #[serde(skip_serializing_if = "Option::is_none")] pub cidr_block: Option, #[serde(skip_serializing_if = "Option::is_none")] pub gateway_ip: Option, pub dhcp_enabled: bool, pub model: String, pub security_groups: Vec, } const PUBLIC_KVM_ONLY_MESSAGE: &str = "PlasmaVMC public VM APIs support only the KVM backend"; fn nic_model_to_string(model: i32) -> String { match plasmavmc_api::proto::NicModel::try_from(model) .unwrap_or(plasmavmc_api::proto::NicModel::Unspecified) { plasmavmc_api::proto::NicModel::VirtioNet => "virtio-net".to_string(), plasmavmc_api::proto::NicModel::E1000 => "e1000".to_string(), plasmavmc_api::proto::NicModel::Unspecified => "unspecified".to_string(), } } fn hypervisor_to_string(hypervisor: i32) -> String { match plasmavmc_api::proto::HypervisorType::try_from(hypervisor) .unwrap_or(plasmavmc_api::proto::HypervisorType::Unspecified) { plasmavmc_api::proto::HypervisorType::Kvm => "kvm".to_string(), plasmavmc_api::proto::HypervisorType::Firecracker => { "legacy-unsupported-firecracker".to_string() } plasmavmc_api::proto::HypervisorType::Mvisor => "legacy-unsupported-mvisor".to_string(), plasmavmc_api::proto::HypervisorType::Unspecified => "kvm".to_string(), } } fn parse_supported_public_hypervisor( hypervisor: Option<&str>, ) -> Result { match hypervisor.map(str::trim).filter(|value| !value.is_empty()) { None | Some("kvm") => Ok(plasmavmc_api::proto::HypervisorType::Kvm), Some("firecracker") => Err(format!( "{PUBLIC_KVM_ONLY_MESSAGE}; firecracker remains outside the supported surface" )), Some("mvisor") => Err(format!( "{PUBLIC_KVM_ONLY_MESSAGE}; mvisor remains outside the supported surface" )), Some(other) => Err(format!( "{PUBLIC_KVM_ONLY_MESSAGE}; unsupported value `{other}`" )), } } impl From for VmNetworkResponse { fn from(network: plasmavmc_api::proto::NetworkSpec) -> Self { Self { id: network.id, network_id: network.network_id, subnet_id: (!network.subnet_id.is_empty()).then_some(network.subnet_id), port_id: (!network.port_id.is_empty()).then_some(network.port_id), mac_address: (!network.mac_address.is_empty()).then_some(network.mac_address), ip_address: (!network.ip_address.is_empty()).then_some(network.ip_address), cidr_block: (!network.cidr_block.is_empty()).then_some(network.cidr_block), gateway_ip: (!network.gateway_ip.is_empty()).then_some(network.gateway_ip), dhcp_enabled: network.dhcp_enabled, model: nic_model_to_string(network.model), security_groups: network.security_groups, } } } impl From for VmResponse { fn from(vm: ProtoVm) -> Self { let cpus = vm .spec .as_ref() .and_then(|s| s.cpu.as_ref()) .map(|c| c.vcpus) .unwrap_or(1); let memory_mb = vm .spec .as_ref() .and_then(|s| s.memory.as_ref()) .map(|m| m.size_mib) .unwrap_or(512); let state = format!("{:?}", vm.state()); let network = vm .spec .as_ref() .map(|spec| { spec.network .clone() .into_iter() .map(VmNetworkResponse::from) .collect() }) .unwrap_or_default(); Self { id: vm.id, name: vm.name, org_id: vm.org_id, project_id: vm.project_id, node_id: (!vm.node_id.is_empty()).then_some(vm.node_id), state, hypervisor: hypervisor_to_string(vm.hypervisor), cpus, memory_mb, network, } } } /// VMs list response #[derive(Debug, Serialize)] pub struct VmsResponse { pub vms: Vec, } /// Build the REST API router pub fn build_router(state: RestApiState) -> Router { Router::new() .route("/api/v1/vms", get(list_vms).post(create_vm)) .route("/api/v1/vms/{id}", get(get_vm).delete(delete_vm)) .route("/api/v1/vms/{id}/start", post(start_vm)) .route("/api/v1/vms/{id}/stop", post(stop_vm)) .route("/api/v1/vms/{id}/migrate", post(migrate_vm)) .route("/health", get(health_check)) .with_state(state) } /// Health check endpoint async fn health_check() -> (StatusCode, Json>) { ( StatusCode::OK, Json(SuccessResponse::new( serde_json::json!({ "status": "healthy" }), )), ) } /// GET /api/v1/vms - List VMs async fn list_vms( State(state): State, headers: HeaderMap, ) -> Result>, (StatusCode, Json)> { let tenant = resolve_rest_tenant(&state, &headers, None, None).await?; let mut req = Request::new(ListVmsRequest { org_id: tenant.org_id.clone(), project_id: tenant.project_id.clone(), page_size: 100, page_token: String::new(), filter: String::new(), }); req.extensions_mut().insert(tenant); let response = state .vm_service .list_vms(req) .await .map_err(map_tonic_status)?; let vms: Vec = response .into_inner() .vms .into_iter() .map(VmResponse::from) .collect(); Ok(Json(SuccessResponse::new(VmsResponse { vms }))) } /// POST /api/v1/vms - Create VM async fn create_vm( State(state): State, headers: HeaderMap, Json(req): Json, ) -> Result<(StatusCode, Json>), (StatusCode, Json)> { use plasmavmc_api::proto::{ disk_source, CpuSpec, DiskBus, DiskCache, DiskSource, DiskSpec, MemorySpec, NicModel as ProtoNicModel, }; let CreateVmRequestRest { name, org_id, project_id, vcpus, memory_mib, hypervisor, disks, network, } = req; let hypervisor_type = parse_supported_public_hypervisor(hypervisor.as_deref()) .map_err(|message| error_response(StatusCode::BAD_REQUEST, "INVALID_ARGUMENT", &message))?; let disks = disks .into_iter() .map(|disk| DiskSpec { id: disk.id, source: Some(DiskSource { source: Some(match disk.source { DiskSourceRest::Image { image_id } => disk_source::Source::ImageId(image_id), DiskSourceRest::Volume { volume_id } => { disk_source::Source::VolumeId(volume_id) } DiskSourceRest::Blank => disk_source::Source::Blank(true), }), }), size_gib: disk.size_gib.unwrap_or(10), bus: match disk.bus.as_deref() { Some("scsi") => DiskBus::Scsi as i32, Some("ide") => DiskBus::Ide as i32, Some("sata") => DiskBus::Sata as i32, _ => DiskBus::Virtio as i32, }, cache: match disk.cache.as_deref() { Some("writeback") => DiskCache::Writeback as i32, Some("writethrough") => DiskCache::Writethrough as i32, _ => DiskCache::Writeback as i32, }, boot_index: disk.boot_index.unwrap_or_default(), }) .collect(); let network = network .into_iter() .enumerate() .map(|(index, nic)| plasmavmc_api::proto::NetworkSpec { id: nic.id.unwrap_or_else(|| format!("nic{}", index)), network_id: nic.network_id.unwrap_or_else(|| "default".to_string()), subnet_id: nic.subnet_id.unwrap_or_default(), port_id: nic.port_id.unwrap_or_default(), mac_address: nic.mac_address.unwrap_or_default(), ip_address: nic.ip_address.unwrap_or_default(), cidr_block: nic.cidr_block.unwrap_or_default(), gateway_ip: nic.gateway_ip.unwrap_or_default(), dhcp_enabled: nic.dhcp_enabled.unwrap_or(false), model: match nic.model.as_deref() { Some("e1000") => ProtoNicModel::E1000 as i32, _ => ProtoNicModel::VirtioNet as i32, }, security_groups: nic.security_groups, }) .collect(); let tenant = resolve_rest_tenant(&state, &headers, org_id.as_deref(), project_id.as_deref()).await?; let mut grpc_req = Request::new(CreateVmRequest { name, org_id: tenant.org_id.clone(), project_id: tenant.project_id.clone(), spec: Some(plasmavmc_api::proto::VmSpec { cpu: Some(CpuSpec { vcpus: vcpus.unwrap_or(1), cores_per_socket: 1, sockets: 1, cpu_model: String::new(), }), memory: Some(MemorySpec { size_mib: memory_mib.unwrap_or(512), hugepages: false, }), disks, network, boot: None, security: None, }), hypervisor: hypervisor_type as i32, metadata: Default::default(), labels: Default::default(), }); grpc_req.extensions_mut().insert(tenant); let response = state .vm_service .create_vm(grpc_req) .await .map_err(map_tonic_status)?; Ok(( StatusCode::CREATED, Json(SuccessResponse::new(VmResponse::from( response.into_inner(), ))), )) } /// GET /api/v1/vms/{id} - Get VM details async fn get_vm( State(state): State, Path(id): Path, headers: HeaderMap, ) -> Result>, (StatusCode, Json)> { let tenant = resolve_rest_tenant(&state, &headers, None, None).await?; let mut req = Request::new(GetVmRequest { org_id: tenant.org_id.clone(), project_id: tenant.project_id.clone(), vm_id: id, }); req.extensions_mut().insert(tenant); let response = state .vm_service .get_vm(req) .await .map_err(map_tonic_status)?; Ok(Json(SuccessResponse::new(VmResponse::from( response.into_inner(), )))) } /// DELETE /api/v1/vms/{id} - Delete VM async fn delete_vm( State(state): State, Path(id): Path, headers: HeaderMap, ) -> Result<(StatusCode, Json>), (StatusCode, Json)> { let tenant = resolve_rest_tenant(&state, &headers, None, None).await?; let mut req = Request::new(DeleteVmRequest { org_id: tenant.org_id.clone(), project_id: tenant.project_id.clone(), vm_id: id.clone(), force: false, }); req.extensions_mut().insert(tenant); state .vm_service .delete_vm(req) .await .map_err(map_tonic_status)?; Ok(( StatusCode::OK, Json(SuccessResponse::new( serde_json::json!({ "id": id, "deleted": true }), )), )) } /// POST /api/v1/vms/{id}/start - Start VM async fn start_vm( State(state): State, Path(id): Path, headers: HeaderMap, ) -> Result>, (StatusCode, Json)> { let tenant = resolve_rest_tenant(&state, &headers, None, None).await?; let mut req = Request::new(StartVmRequest { org_id: tenant.org_id.clone(), project_id: tenant.project_id.clone(), vm_id: id.clone(), }); req.extensions_mut().insert(tenant); state .vm_service .start_vm(req) .await .map_err(map_tonic_status)?; Ok(Json(SuccessResponse::new( serde_json::json!({ "id": id, "action": "started" }), ))) } /// POST /api/v1/vms/{id}/stop - Stop VM async fn stop_vm( State(state): State, Path(id): Path, headers: HeaderMap, ) -> Result>, (StatusCode, Json)> { let tenant = resolve_rest_tenant(&state, &headers, None, None).await?; let mut req = Request::new(StopVmRequest { org_id: tenant.org_id.clone(), project_id: tenant.project_id.clone(), vm_id: id.clone(), force: false, timeout_seconds: 30, }); req.extensions_mut().insert(tenant); state .vm_service .stop_vm(req) .await .map_err(map_tonic_status)?; Ok(Json(SuccessResponse::new( serde_json::json!({ "id": id, "action": "stopped" }), ))) } /// POST /api/v1/vms/{id}/migrate - Migrate VM async fn migrate_vm( State(state): State, headers: HeaderMap, Path(id): Path, Json(req): Json, ) -> Result>, (StatusCode, Json)> { let tenant = resolve_rest_tenant(&state, &headers, None, None).await?; let mut grpc_req = Request::new(MigrateVmRequest { org_id: tenant.org_id.clone(), project_id: tenant.project_id.clone(), vm_id: id, destination_node_id: req.destination_node_id, timeout_seconds: req.timeout_seconds.unwrap_or(0), wait: req.wait.unwrap_or(false), }); grpc_req.extensions_mut().insert(tenant); let response = state .vm_service .migrate_vm(grpc_req) .await .map_err(map_tonic_status)?; Ok(Json(SuccessResponse::new(VmResponse::from( response.into_inner(), )))) } /// Helper to create error response fn error_response( status: StatusCode, code: &str, message: &str, ) -> (StatusCode, Json) { ( status, Json(ErrorResponse { error: ErrorDetail { code: code.to_string(), message: message.to_string(), details: None, }, meta: ResponseMeta::new(), }), ) } async fn resolve_rest_tenant( state: &RestApiState, headers: &HeaderMap, req_org_id: Option<&str>, req_project_id: Option<&str>, ) -> Result)> { let tenant = state .auth_service .authenticate_headers(headers) .await .map_err(map_auth_status)?; resolve_tenant_ids_from_context( &tenant, req_org_id.unwrap_or(""), req_project_id.unwrap_or(""), ) .map_err(map_auth_status)?; Ok(tenant) } fn map_auth_status(status: tonic::Status) -> (StatusCode, Json) { map_tonic_status(status) } fn map_tonic_status(status: tonic::Status) -> (StatusCode, Json) { let status_code = match status.code() { Code::Unauthenticated => StatusCode::UNAUTHORIZED, Code::PermissionDenied => StatusCode::FORBIDDEN, Code::InvalidArgument => StatusCode::BAD_REQUEST, Code::NotFound => StatusCode::NOT_FOUND, Code::AlreadyExists => StatusCode::CONFLICT, Code::ResourceExhausted => StatusCode::TOO_MANY_REQUESTS, Code::FailedPrecondition => StatusCode::UNPROCESSABLE_ENTITY, _ => StatusCode::INTERNAL_SERVER_ERROR, }; let code = match status.code() { Code::Unauthenticated => "UNAUTHENTICATED", Code::PermissionDenied => "FORBIDDEN", Code::InvalidArgument => "INVALID_ARGUMENT", Code::NotFound => "NOT_FOUND", Code::AlreadyExists => "ALREADY_EXISTS", Code::ResourceExhausted => "RESOURCE_EXHAUSTED", Code::FailedPrecondition => "FAILED_PRECONDITION", _ => "INTERNAL", }; error_response(status_code, code, status.message()) } #[cfg(test)] mod tests { use super::*; use plasmavmc_api::proto::{ CpuSpec, HypervisorType, MemorySpec, NetworkSpec, NicModel, VirtualMachine as ProtoVm, VmSpec, }; #[test] fn map_tonic_status_preserves_client_error_categories() { let (status, Json(body)) = map_tonic_status(tonic::Status::not_found("missing vm")); assert_eq!(status, StatusCode::NOT_FOUND); assert_eq!(body.error.code, "NOT_FOUND"); let (status, Json(body)) = map_tonic_status(tonic::Status::invalid_argument("bad nic")); assert_eq!(status, StatusCode::BAD_REQUEST); assert_eq!(body.error.code, "INVALID_ARGUMENT"); let (status, Json(body)) = map_tonic_status(tonic::Status::failed_precondition("network attach failed")); assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); assert_eq!(body.error.code, "FAILED_PRECONDITION"); } #[test] fn vm_response_exposes_network_details() { let response = VmResponse::from(ProtoVm { id: "vm-1".to_string(), name: "vm-1".to_string(), org_id: "org-1".to_string(), project_id: "proj-1".to_string(), state: plasmavmc_api::proto::VmState::Running as i32, spec: Some(VmSpec { cpu: Some(CpuSpec { vcpus: 2, cores_per_socket: 1, sockets: 1, cpu_model: String::new(), }), memory: Some(MemorySpec { size_mib: 2048, hugepages: false, }), disks: vec![], network: vec![NetworkSpec { id: "nic0".to_string(), network_id: "default".to_string(), mac_address: "02:00:00:00:00:01".to_string(), ip_address: "10.62.10.15".to_string(), cidr_block: "10.62.10.0/24".to_string(), gateway_ip: "10.62.10.1".to_string(), dhcp_enabled: true, model: NicModel::VirtioNet as i32, security_groups: vec!["sg-1".to_string()], port_id: "port-1".to_string(), subnet_id: "subnet-1".to_string(), }], boot: None, security: None, }), status: None, node_id: "node04".to_string(), hypervisor: HypervisorType::Kvm as i32, created_at: 0, updated_at: 0, created_by: String::new(), metadata: Default::default(), labels: Default::default(), }); assert_eq!(response.hypervisor, "kvm"); assert_eq!(response.node_id.as_deref(), Some("node04")); assert_eq!(response.network.len(), 1); assert_eq!(response.network[0].port_id.as_deref(), Some("port-1")); assert_eq!(response.network[0].subnet_id.as_deref(), Some("subnet-1")); assert_eq!( response.network[0].ip_address.as_deref(), Some("10.62.10.15") ); assert_eq!( response.network[0].cidr_block.as_deref(), Some("10.62.10.0/24") ); assert_eq!( response.network[0].gateway_ip.as_deref(), Some("10.62.10.1") ); assert!(response.network[0].dhcp_enabled); } #[test] fn parse_supported_public_hypervisor_defaults_to_kvm() { assert_eq!( parse_supported_public_hypervisor(None).unwrap(), HypervisorType::Kvm ); assert_eq!( parse_supported_public_hypervisor(Some("kvm")).unwrap(), HypervisorType::Kvm ); } #[test] fn parse_supported_public_hypervisor_rejects_non_kvm_backends() { assert!(parse_supported_public_hypervisor(Some("firecracker")) .unwrap_err() .contains(PUBLIC_KVM_ONLY_MESSAGE)); assert!(parse_supported_public_hypervisor(Some("mvisor")) .unwrap_err() .contains(PUBLIC_KVM_ONLY_MESSAGE)); } #[test] fn hypervisor_to_string_marks_legacy_backends_as_unsupported() { assert_eq!( hypervisor_to_string(HypervisorType::Firecracker as i32), "legacy-unsupported-firecracker" ); assert_eq!( hypervisor_to_string(HypervisorType::Mvisor as i32), "legacy-unsupported-mvisor" ); } }