//! Integration tests for VM-to-VM Cross-Communication via NovaNET (T029.S3) //! //! These tests verify that: //! 1. VMs on the same NovaNET subnet can communicate (logical L2 connectivity) //! 2. Tenant isolation is enforced (different VPCs cannot communicate) //! 3. Full lifecycle works correctly (create → attach → verify → delete) //! //! This test uses real service implementations with in-memory/mock backends: //! - NovaNET: NetworkMetadataStore (in-memory) + OvnClient (mock) //! - PlasmaVMC: VmServiceImpl with in-memory storage use std::sync::Arc; use std::time::Duration; use tokio::time::sleep; use tonic::transport::{Channel, Server}; use tonic::Request; // NovaNET imports use novanet_api::proto::{ port_service_client::PortServiceClient, subnet_service_client::SubnetServiceClient, vpc_service_client::VpcServiceClient, CreatePortRequest, CreateSubnetRequest, CreateVpcRequest, GetPortRequest, }; use novanet_server::{ metadata::NetworkMetadataStore, ovn::OvnClient, services::{ port::PortServiceImpl, security_group::SecurityGroupServiceImpl, subnet::SubnetServiceImpl, vpc::VpcServiceImpl, }, }; // PlasmaVMC imports use plasmavmc_api::proto::{ vm_service_client::VmServiceClient, CreateVmRequest, DeleteVmRequest, HypervisorType as ProtoHypervisorType, NetworkSpec as ProtoNetworkSpec, VmSpec, }; use plasmavmc_hypervisor::HypervisorRegistry; use plasmavmc_kvm::KvmBackend; use plasmavmc_server::VmServiceImpl; // ============================================================================ // Service Startup Helpers // ============================================================================ /// Start NovaNET server with in-memory metadata store and mock OVN client async fn start_novanet_server(addr: &str) -> tokio::task::JoinHandle<()> { let metadata_store = Arc::new(NetworkMetadataStore::new_in_memory()); let ovn_client = Arc::new(OvnClient::new_mock()); let vpc_svc = VpcServiceImpl::new(metadata_store.clone(), ovn_client.clone()); let subnet_svc = SubnetServiceImpl::new(metadata_store.clone()); let port_svc = PortServiceImpl::new(metadata_store.clone(), ovn_client.clone()); let sg_svc = SecurityGroupServiceImpl::new(metadata_store, ovn_client); let addr_parsed = addr.parse().unwrap(); tokio::spawn(async move { Server::builder() .add_service(novanet_api::proto::vpc_service_server::VpcServiceServer::new(vpc_svc)) .add_service(novanet_api::proto::subnet_service_server::SubnetServiceServer::new(subnet_svc)) .add_service(novanet_api::proto::port_service_server::PortServiceServer::new(port_svc)) .add_service(novanet_api::proto::security_group_service_server::SecurityGroupServiceServer::new(sg_svc)) .serve(addr_parsed) .await .unwrap(); }) } /// Start PlasmaVMC server with NovaNET integration async fn start_plasmavmc_server(addr: &str, novanet_endpoint: String) -> tokio::task::JoinHandle<()> { std::env::set_var("NOVANET_ENDPOINT", novanet_endpoint); std::env::set_var("PLASMAVMC_STORAGE_BACKEND", "file"); let registry = Arc::new(HypervisorRegistry::new()); registry.register(Arc::new(KvmBackend::with_defaults())); let svc = VmServiceImpl::new(registry).await.unwrap(); let addr_parsed = addr.parse().unwrap(); tokio::spawn(async move { Server::builder() .add_service(plasmavmc_api::proto::vm_service_server::VmServiceServer::new(svc)) .serve(addr_parsed) .await .unwrap(); }) } // ============================================================================ // Test Case 1: Two VMs in Same Subnet Connectivity // ============================================================================ #[tokio::test] #[ignore] // Requires mock hypervisor mode async fn test_vm_same_subnet_connectivity() { // === Step 1: Start all services === // Start NovaNET server let novanet_addr = "127.0.0.1:50091"; let novanet_handle = start_novanet_server(novanet_addr).await; sleep(Duration::from_millis(300)).await; // Start PlasmaVMC server with NovaNET integration let plasmavmc_addr = "127.0.0.1:50092"; let novanet_endpoint = format!("http://{}", novanet_addr); let plasmavmc_handle = start_plasmavmc_server(plasmavmc_addr, novanet_endpoint).await; sleep(Duration::from_millis(300)).await; // === Step 2: Create NovaNET clients === let novanet_channel = Channel::from_shared(format!("http://{}", novanet_addr)) .unwrap() .connect() .await .unwrap(); let mut vpc_client = VpcServiceClient::new(novanet_channel.clone()); let mut subnet_client = SubnetServiceClient::new(novanet_channel.clone()); let mut port_client = PortServiceClient::new(novanet_channel); // Create PlasmaVMC client let plasmavmc_channel = Channel::from_shared(format!("http://{}", plasmavmc_addr)) .unwrap() .connect() .await .unwrap(); let mut vm_client = VmServiceClient::new(plasmavmc_channel); let org_id = "test-org"; let project_id = "test-project"; // === Step 3: Create NovaNET VPC and Subnet === // Create VPC (10.0.0.0/16) let vpc_resp = vpc_client .create_vpc(Request::new(CreateVpcRequest { org_id: org_id.to_string(), project_id: project_id.to_string(), name: "test-vpc".to_string(), description: "Test VPC for VM-to-VM connectivity".to_string(), cidr_block: "10.0.0.0/16".to_string(), })) .await .unwrap() .into_inner(); let vpc_id = vpc_resp.vpc.unwrap().id; // Create Subnet (10.0.1.0/24) let subnet_resp = subnet_client .create_subnet(Request::new(CreateSubnetRequest { vpc_id: vpc_id.clone(), name: "test-subnet".to_string(), description: "Test subnet for VM-to-VM connectivity".to_string(), cidr_block: "10.0.1.0/24".to_string(), gateway_ip: "10.0.1.1".to_string(), dhcp_enabled: true, })) .await .unwrap() .into_inner(); let subnet_id = subnet_resp.subnet.unwrap().id; // === Step 4: Create Port-1 for VM-1 (10.0.1.10) === let port1_resp = port_client .create_port(Request::new(CreatePortRequest { org_id: org_id.to_string(), project_id: project_id.to_string(), subnet_id: subnet_id.clone(), name: "vm1-port".to_string(), description: "Port for VM-1".to_string(), ip_address: "10.0.1.10".to_string(), security_group_ids: vec![], })) .await .unwrap() .into_inner(); let port1 = port1_resp.port.unwrap(); let port1_id = port1.id.clone(); // Verify port is initially unattached assert!(port1.device_id.is_empty(), "Port-1 should not have device_id initially"); assert_eq!(port1.ip_address, "10.0.1.10", "Port-1 should have correct IP"); // === Step 5: Create Port-2 for VM-2 (10.0.1.20) === let port2_resp = port_client .create_port(Request::new(CreatePortRequest { org_id: org_id.to_string(), project_id: project_id.to_string(), subnet_id: subnet_id.clone(), name: "vm2-port".to_string(), description: "Port for VM-2".to_string(), ip_address: "10.0.1.20".to_string(), security_group_ids: vec![], })) .await .unwrap() .into_inner(); let port2 = port2_resp.port.unwrap(); let port2_id = port2.id.clone(); assert!(port2.device_id.is_empty(), "Port-2 should not have device_id initially"); assert_eq!(port2.ip_address, "10.0.1.20", "Port-2 should have correct IP"); // === Step 6: Create VM-1 with Port-1 === let vm1_spec = VmSpec { cpu: None, memory: None, disks: vec![], network: vec![ProtoNetworkSpec { id: "eth0".to_string(), network_id: vpc_id.clone(), subnet_id: subnet_id.clone(), port_id: port1_id.clone(), mac_address: String::new(), ip_address: String::new(), model: 1, // VirtioNet security_groups: vec![], }], boot: None, security: None, }; let create_vm1_resp = vm_client .create_vm(Request::new(CreateVmRequest { name: "test-vm-1".to_string(), org_id: org_id.to_string(), project_id: project_id.to_string(), spec: Some(vm1_spec), hypervisor: ProtoHypervisorType::Kvm as i32, metadata: Default::default(), labels: Default::default(), })) .await .unwrap() .into_inner(); let vm1_id = create_vm1_resp.id.clone(); assert_eq!(create_vm1_resp.name, "test-vm-1", "VM-1 should have correct name"); sleep(Duration::from_millis(200)).await; // === Step 7: Create VM-2 with Port-2 === let vm2_spec = VmSpec { cpu: None, memory: None, disks: vec![], network: vec![ProtoNetworkSpec { id: "eth0".to_string(), network_id: vpc_id.clone(), subnet_id: subnet_id.clone(), port_id: port2_id.clone(), mac_address: String::new(), ip_address: String::new(), model: 1, // VirtioNet security_groups: vec![], }], boot: None, security: None, }; let create_vm2_resp = vm_client .create_vm(Request::new(CreateVmRequest { name: "test-vm-2".to_string(), org_id: org_id.to_string(), project_id: project_id.to_string(), spec: Some(vm2_spec), hypervisor: ProtoHypervisorType::Kvm as i32, metadata: Default::default(), labels: Default::default(), })) .await .unwrap() .into_inner(); let vm2_id = create_vm2_resp.id.clone(); assert_eq!(create_vm2_resp.name, "test-vm-2", "VM-2 should have correct name"); sleep(Duration::from_millis(200)).await; // === Step 8: Verify ports are attached to VMs === let port1_after_vm = port_client .get_port(Request::new(GetPortRequest { org_id: org_id.to_string(), project_id: project_id.to_string(), subnet_id: subnet_id.clone(), id: port1_id.clone(), })) .await .unwrap() .into_inner() .port .unwrap(); assert_eq!( port1_after_vm.device_id, vm1_id, "Port-1 should be attached to VM-1" ); assert_eq!( port1_after_vm.device_type, 2, // DeviceType::Vm = 2 (DEVICE_TYPE_VM from proto) "Port-1 device_type should be Vm" ); let port2_after_vm = port_client .get_port(Request::new(GetPortRequest { org_id: org_id.to_string(), project_id: project_id.to_string(), subnet_id: subnet_id.clone(), id: port2_id.clone(), })) .await .unwrap() .into_inner() .port .unwrap(); assert_eq!( port2_after_vm.device_id, vm2_id, "Port-2 should be attached to VM-2" ); assert_eq!( port2_after_vm.device_type, 2, // DeviceType::Vm = 2 "Port-2 device_type should be Vm" ); // === Step 9: Verify connectivity (mock mode - logical L2 connectivity) === // Both ports are in the same VPC and same subnet // In a real deployment, this would allow L2 connectivity via OVN overlay // Verify both ports are in the same subnet (logical L2 connectivity) assert_eq!( port1_after_vm.subnet_id, port2_after_vm.subnet_id, "VM-1 and VM-2 ports should be in the same subnet for L2 connectivity" ); // Verify both IPs are in the same /24 subnet assert!( port1_after_vm.ip_address.starts_with("10.0.1.") && port2_after_vm.ip_address.starts_with("10.0.1."), "VM-1 IP ({}) and VM-2 IP ({}) should be in same subnet for connectivity", port1_after_vm.ip_address, port2_after_vm.ip_address ); // Mock connectivity check: Verify both ports are attached to devices // In real OVN, this configuration would allow ping between VMs println!( "VM-1 at {} and VM-2 at {} are logically connected via NovaNET overlay (VPC: {}, Subnet: {})", port1_after_vm.ip_address, port2_after_vm.ip_address, vpc_id, subnet_id ); // === Step 10: Cleanup === // Delete VM-1 vm_client .delete_vm(Request::new(DeleteVmRequest { org_id: org_id.to_string(), project_id: project_id.to_string(), vm_id: vm1_id.clone(), force: true, })) .await .unwrap(); // Delete VM-2 vm_client .delete_vm(Request::new(DeleteVmRequest { org_id: org_id.to_string(), project_id: project_id.to_string(), vm_id: vm2_id.clone(), force: true, })) .await .unwrap(); sleep(Duration::from_millis(200)).await; // Verify ports are detached after deletion let port1_after_delete = port_client .get_port(Request::new(GetPortRequest { org_id: org_id.to_string(), project_id: project_id.to_string(), subnet_id: subnet_id.clone(), id: port1_id.clone(), })) .await .unwrap() .into_inner() .port .unwrap(); assert!( port1_after_delete.device_id.is_empty(), "Port-1 should be detached after VM-1 deletion" ); let port2_after_delete = port_client .get_port(Request::new(GetPortRequest { org_id: org_id.to_string(), project_id: project_id.to_string(), subnet_id: subnet_id.clone(), id: port2_id.clone(), })) .await .unwrap() .into_inner() .port .unwrap(); assert!( port2_after_delete.device_id.is_empty(), "Port-2 should be detached after VM-2 deletion" ); // Cleanup server handles novanet_handle.abort(); plasmavmc_handle.abort(); } // ============================================================================ // Test Case 2: Tenant Isolation - Different VPCs // ============================================================================ #[tokio::test] #[ignore] // Requires mock hypervisor mode async fn test_tenant_isolation_different_vpc() { // === Step 1: Start all services === let novanet_addr = "127.0.0.1:50095"; let novanet_handle = start_novanet_server(novanet_addr).await; sleep(Duration::from_millis(300)).await; let plasmavmc_addr = "127.0.0.1:50096"; let novanet_endpoint = format!("http://{}", novanet_addr); let plasmavmc_handle = start_plasmavmc_server(plasmavmc_addr, novanet_endpoint).await; sleep(Duration::from_millis(300)).await; // === Step 2: Create clients === let novanet_channel = Channel::from_shared(format!("http://{}", novanet_addr)) .unwrap() .connect() .await .unwrap(); let mut vpc_client = VpcServiceClient::new(novanet_channel.clone()); let mut subnet_client = SubnetServiceClient::new(novanet_channel.clone()); let mut port_client = PortServiceClient::new(novanet_channel); let plasmavmc_channel = Channel::from_shared(format!("http://{}", plasmavmc_addr)) .unwrap() .connect() .await .unwrap(); let mut vm_client = VmServiceClient::new(plasmavmc_channel); // === TENANT A: org-a, project-a === let org_a = "org-a"; let project_a = "project-a"; // Create VPC-A (10.0.0.0/16) let vpc_a_resp = vpc_client .create_vpc(Request::new(CreateVpcRequest { org_id: org_a.to_string(), project_id: project_a.to_string(), name: "vpc-a".to_string(), description: "Tenant A VPC".to_string(), cidr_block: "10.0.0.0/16".to_string(), })) .await .unwrap() .into_inner(); let vpc_a_id = vpc_a_resp.vpc.unwrap().id; // Create Subnet-A (10.0.1.0/24) let subnet_a_resp = subnet_client .create_subnet(Request::new(CreateSubnetRequest { vpc_id: vpc_a_id.clone(), name: "subnet-a".to_string(), description: "Tenant A Subnet".to_string(), cidr_block: "10.0.1.0/24".to_string(), gateway_ip: "10.0.1.1".to_string(), dhcp_enabled: true, })) .await .unwrap() .into_inner(); let subnet_a_id = subnet_a_resp.subnet.unwrap().id; // Create Port-A for VM (10.0.1.20) let port_a_vm_resp = port_client .create_port(Request::new(CreatePortRequest { org_id: org_a.to_string(), project_id: project_a.to_string(), subnet_id: subnet_a_id.clone(), name: "vm-a-port".to_string(), description: "Port for Tenant A VM".to_string(), ip_address: "10.0.1.20".to_string(), security_group_ids: vec![], })) .await .unwrap() .into_inner(); let port_a_vm_id = port_a_vm_resp.port.unwrap().id; // Create VM-A let vm_a_spec = VmSpec { cpu: None, memory: None, disks: vec![], network: vec![ProtoNetworkSpec { id: "eth0".to_string(), network_id: vpc_a_id.clone(), subnet_id: subnet_a_id.clone(), port_id: port_a_vm_id.clone(), mac_address: String::new(), ip_address: String::new(), model: 1, security_groups: vec![], }], boot: None, security: None, }; let vm_a_resp = vm_client .create_vm(Request::new(CreateVmRequest { name: "vm-a".to_string(), org_id: org_a.to_string(), project_id: project_a.to_string(), spec: Some(vm_a_spec), hypervisor: ProtoHypervisorType::Kvm as i32, metadata: Default::default(), labels: Default::default(), })) .await .unwrap() .into_inner(); let vm_a_id = vm_a_resp.id; sleep(Duration::from_millis(200)).await; // === TENANT B: org-b, project-b === let org_b = "org-b"; let project_b = "project-b"; // Create VPC-B (10.1.0.0/16) - DIFFERENT CIDR, DIFFERENT ORG let vpc_b_resp = vpc_client .create_vpc(Request::new(CreateVpcRequest { org_id: org_b.to_string(), project_id: project_b.to_string(), name: "vpc-b".to_string(), description: "Tenant B VPC".to_string(), cidr_block: "10.1.0.0/16".to_string(), })) .await .unwrap() .into_inner(); let vpc_b_id = vpc_b_resp.vpc.unwrap().id; // Create Subnet-B (10.1.1.0/24) let subnet_b_resp = subnet_client .create_subnet(Request::new(CreateSubnetRequest { vpc_id: vpc_b_id.clone(), name: "subnet-b".to_string(), description: "Tenant B Subnet".to_string(), cidr_block: "10.1.1.0/24".to_string(), gateway_ip: "10.1.1.1".to_string(), dhcp_enabled: true, })) .await .unwrap() .into_inner(); let subnet_b_id = subnet_b_resp.subnet.unwrap().id; // Create Port-B for VM (10.1.1.20) let port_b_vm_resp = port_client .create_port(Request::new(CreatePortRequest { org_id: org_b.to_string(), project_id: project_b.to_string(), subnet_id: subnet_b_id.clone(), name: "vm-b-port".to_string(), description: "Port for Tenant B VM".to_string(), ip_address: "10.1.1.20".to_string(), security_group_ids: vec![], })) .await .unwrap() .into_inner(); let port_b_vm_id = port_b_vm_resp.port.unwrap().id; // Create VM-B let vm_b_spec = VmSpec { cpu: None, memory: None, disks: vec![], network: vec![ProtoNetworkSpec { id: "eth0".to_string(), network_id: vpc_b_id.clone(), subnet_id: subnet_b_id.clone(), port_id: port_b_vm_id.clone(), mac_address: String::new(), ip_address: String::new(), model: 1, security_groups: vec![], }], boot: None, security: None, }; let vm_b_resp = vm_client .create_vm(Request::new(CreateVmRequest { name: "vm-b".to_string(), org_id: org_b.to_string(), project_id: project_b.to_string(), spec: Some(vm_b_spec), hypervisor: ProtoHypervisorType::Kvm as i32, metadata: Default::default(), labels: Default::default(), })) .await .unwrap() .into_inner(); let vm_b_id = vm_b_resp.id; sleep(Duration::from_millis(200)).await; // === VERIFICATION: Tenant Isolation === // Verify VPCs are different logical switches assert_ne!( vpc_a_id, vpc_b_id, "Tenant A and Tenant B must have different VPC IDs" ); // Verify subnet isolation assert_ne!( subnet_a_id, subnet_b_id, "Tenant A and Tenant B must have different Subnet IDs" ); // Verify port isolation - different org_id/project_id let port_a_vm_final = port_client .get_port(Request::new(GetPortRequest { org_id: org_a.to_string(), project_id: project_a.to_string(), subnet_id: subnet_a_id.clone(), id: port_a_vm_id.clone(), })) .await .unwrap() .into_inner() .port .unwrap(); let port_b_vm_final = port_client .get_port(Request::new(GetPortRequest { org_id: org_b.to_string(), project_id: project_b.to_string(), subnet_id: subnet_b_id.clone(), id: port_b_vm_id.clone(), })) .await .unwrap() .into_inner() .port .unwrap(); // Verify VM-A is attached to Subnet-A assert_eq!(port_a_vm_final.device_id, vm_a_id); assert_eq!(port_a_vm_final.ip_address, "10.0.1.20"); assert_eq!(port_a_vm_final.device_type, 2, "Port-A device_type should be Vm"); // Verify VM-B is attached to Subnet-B assert_eq!(port_b_vm_final.device_id, vm_b_id); assert_eq!(port_b_vm_final.ip_address, "10.1.1.20"); assert_eq!(port_b_vm_final.device_type, 2, "Port-B device_type should be Vm"); // Verify isolation: different subnets mean no L2 connectivity // In OVN, different VPCs use different logical switches, enforced via subnet isolation assert_ne!( port_a_vm_final.subnet_id, port_b_vm_final.subnet_id, "Tenant A and B must use different subnets for isolation" ); // Additional verification: Different subnets belong to different VPCs assert_ne!( subnet_a_id, subnet_b_id, "Tenant A and B must have different subnet IDs" ); println!( "Tenant isolation verified: VPC-A ({}) and VPC-B ({}) are isolated via different subnets", vpc_a_id, vpc_b_id ); // === Cleanup === novanet_handle.abort(); plasmavmc_handle.abort(); } // ============================================================================ // Test Case 3: VM E2E Lifecycle // ============================================================================ #[tokio::test] #[ignore] // Requires mock hypervisor mode async fn test_vm_e2e_lifecycle() { // === Step 1: Start all services === let novanet_addr = "127.0.0.1:50099"; let novanet_handle = start_novanet_server(novanet_addr).await; sleep(Duration::from_millis(300)).await; let plasmavmc_addr = "127.0.0.1:50100"; let novanet_endpoint = format!("http://{}", novanet_addr); let plasmavmc_handle = start_plasmavmc_server(plasmavmc_addr, novanet_endpoint).await; sleep(Duration::from_millis(300)).await; // === Step 2: Create clients === let novanet_channel = Channel::from_shared(format!("http://{}", novanet_addr)) .unwrap() .connect() .await .unwrap(); let mut vpc_client = VpcServiceClient::new(novanet_channel.clone()); let mut subnet_client = SubnetServiceClient::new(novanet_channel.clone()); let mut port_client = PortServiceClient::new(novanet_channel); let plasmavmc_channel = Channel::from_shared(format!("http://{}", plasmavmc_addr)) .unwrap() .connect() .await .unwrap(); let mut vm_client = VmServiceClient::new(plasmavmc_channel); let org_id = "lifecycle-org"; let project_id = "lifecycle-project"; // === Step 3: Create VPC and Subnet === let vpc_resp = vpc_client .create_vpc(Request::new(CreateVpcRequest { org_id: org_id.to_string(), project_id: project_id.to_string(), name: "lifecycle-vpc".to_string(), description: "VPC for VM lifecycle test".to_string(), cidr_block: "10.2.0.0/16".to_string(), })) .await .unwrap() .into_inner(); let vpc_id = vpc_resp.vpc.unwrap().id; let subnet_resp = subnet_client .create_subnet(Request::new(CreateSubnetRequest { vpc_id: vpc_id.clone(), name: "lifecycle-subnet".to_string(), description: "Subnet for VM lifecycle test".to_string(), cidr_block: "10.2.1.0/24".to_string(), gateway_ip: "10.2.1.1".to_string(), dhcp_enabled: true, })) .await .unwrap() .into_inner(); let subnet_id = subnet_resp.subnet.unwrap().id; // === Step 4: Create VM port === let vm_port_resp = port_client .create_port(Request::new(CreatePortRequest { org_id: org_id.to_string(), project_id: project_id.to_string(), subnet_id: subnet_id.clone(), name: "lifecycle-vm-port".to_string(), description: "Port for lifecycle test VM".to_string(), ip_address: "10.2.1.20".to_string(), security_group_ids: vec![], })) .await .unwrap() .into_inner(); let vm_port = vm_port_resp.port.unwrap(); let vm_port_id = vm_port.id.clone(); assert!(vm_port.device_id.is_empty(), "VM port should be unattached initially"); assert_eq!(vm_port.ip_address, "10.2.1.20", "VM port should have correct IP"); // === Step 5: Create VM and attach to port === let vm_spec = VmSpec { cpu: None, memory: None, disks: vec![], network: vec![ProtoNetworkSpec { id: "eth0".to_string(), network_id: vpc_id.clone(), subnet_id: subnet_id.clone(), port_id: vm_port_id.clone(), mac_address: String::new(), ip_address: String::new(), model: 1, security_groups: vec![], }], boot: None, security: None, }; let create_vm_resp = vm_client .create_vm(Request::new(CreateVmRequest { name: "lifecycle-vm".to_string(), org_id: org_id.to_string(), project_id: project_id.to_string(), spec: Some(vm_spec), hypervisor: ProtoHypervisorType::Kvm as i32, metadata: Default::default(), labels: Default::default(), })) .await .unwrap() .into_inner(); let vm_id = create_vm_resp.id.clone(); assert_eq!(create_vm_resp.name, "lifecycle-vm"); sleep(Duration::from_millis(200)).await; // === Step 6: Verify VM port state transition: unattached → attached === let vm_port_attached = port_client .get_port(Request::new(GetPortRequest { org_id: org_id.to_string(), project_id: project_id.to_string(), subnet_id: subnet_id.clone(), id: vm_port_id.clone(), })) .await .unwrap() .into_inner() .port .unwrap(); assert_eq!( vm_port_attached.device_id, vm_id, "VM port should be attached to VM" ); assert_eq!(vm_port_attached.device_type, 2, "VM port device_type should be Vm (DEVICE_TYPE_VM = 2)"); assert_eq!(vm_port_attached.subnet_id, subnet_id, "VM port should be in the correct subnet"); assert_eq!(vm_port_attached.ip_address, "10.2.1.20", "VM port should maintain its IP address"); println!( "VM lifecycle: VM (IP: {}) attached to VPC {} and Subnet {}", vm_port_attached.ip_address, vpc_id, subnet_id ); // === Step 7: Delete VM === vm_client .delete_vm(Request::new(DeleteVmRequest { org_id: org_id.to_string(), project_id: project_id.to_string(), vm_id: vm_id.clone(), force: true, })) .await .unwrap(); sleep(Duration::from_millis(200)).await; // === Step 8: Verify VM port state transition: attached → unattached === let vm_port_after_delete = port_client .get_port(Request::new(GetPortRequest { org_id: org_id.to_string(), project_id: project_id.to_string(), subnet_id: subnet_id.clone(), id: vm_port_id.clone(), })) .await .unwrap() .into_inner() .port .unwrap(); assert!( vm_port_after_delete.device_id.is_empty(), "VM port should be detached after VM deletion" ); assert_eq!( vm_port_after_delete.device_type, 0, "VM port device_type should be None after deletion" ); println!("VM lifecycle test completed: All resources cleaned up successfully"); // === Cleanup === novanet_handle.abort(); plasmavmc_handle.abort(); }