photoncloud-monorepo/prismnet/crates/prismnet-server/src/ovn/client.rs
centra d2149b6249 fix(lightningstor): Fix SigV4 canonicalization for AWS S3 auth
- Replace form_urlencoded with RFC 3986 compliant URI encoding
- Implement aws_uri_encode() matching AWS SigV4 spec exactly
- Unreserved chars (A-Z,a-z,0-9,-,_,.,~) not encoded
- All other chars percent-encoded with uppercase hex
- Preserve slashes in paths, encode in query params
- Normalize empty paths to '/' per AWS spec
- Fix test expectations (body hash, HMAC values)
- Add comprehensive SigV4 signature determinism test

This fixes the canonicalization mismatch that caused signature
validation failures in T047. Auth can now be enabled for production.

Refs: T058.S1
2025-12-12 06:23:46 +09:00

945 lines
30 KiB
Rust

use std::sync::Arc;
use prismnet_types::{DhcpOptions, Port, PortId, SecurityGroupId, SecurityGroupRule, SecurityGroupRuleId, VpcId};
use tokio::process::Command;
use tokio::sync::Mutex;
use crate::ovn::mock::MockOvnState;
/// OVN client mode
#[derive(Debug, Clone)]
pub enum OvnMode {
Real { nb_addr: String },
Mock(Arc<Mutex<MockOvnState>>),
}
/// OVN client error
#[derive(Debug, thiserror::Error)]
pub enum OvnError {
#[error("OVN command failed: {0}")]
Command(String),
#[error("Invalid argument: {0}")]
InvalidArgument(String),
}
pub type OvnResult<T> = std::result::Result<T, OvnError>;
/// Lightweight OVN client with mock/real modes
#[derive(Clone)]
pub struct OvnClient {
mode: OvnMode,
}
impl OvnClient {
/// Build an OVN client from environment variables (default: mock)
/// - NOVANET_OVN_MODE: "mock" (default) or "real"
/// - NOVANET_OVN_NB_ADDR: ovsdb northbound address (real mode only)
pub fn from_env() -> OvnResult<Self> {
let mode = std::env::var("NOVANET_OVN_MODE").unwrap_or_else(|_| "mock".to_string());
match mode.to_lowercase().as_str() {
"mock" => Ok(Self::new_mock()),
"real" => {
let nb_addr = std::env::var("NOVANET_OVN_NB_ADDR")
.unwrap_or_else(|_| "tcp:127.0.0.1:6641".to_string());
Ok(Self::new_real(nb_addr))
}
other => Err(OvnError::InvalidArgument(format!(
"Unknown OVN mode: {}",
other
))),
}
}
pub fn new_mock() -> Self {
Self {
mode: OvnMode::Mock(Arc::new(Mutex::new(MockOvnState::new()))),
}
}
pub fn new_real(nb_addr: impl Into<String>) -> Self {
Self {
mode: OvnMode::Real {
nb_addr: nb_addr.into(),
},
}
}
/// Expose mock state for tests
pub fn mock_state(&self) -> Option<Arc<Mutex<MockOvnState>>> {
match &self.mode {
OvnMode::Mock(state) => Some(state.clone()),
_ => None,
}
}
fn logical_switch_name(vpc_id: &VpcId) -> String {
format!("vpc-{}", vpc_id)
}
fn logical_port_name(port_id: &PortId) -> String {
format!("port-{}", port_id)
}
fn acl_key(rule_id: &SecurityGroupRuleId) -> String {
format!("acl-{}", rule_id)
}
async fn run_nbctl(&self, args: Vec<String>) -> OvnResult<()> {
let nb_addr = match &self.mode {
OvnMode::Real { nb_addr } => nb_addr,
_ => {
return Err(OvnError::InvalidArgument(
"nbctl invocation only valid in real mode".to_string(),
))
}
};
let output = Command::new("ovn-nbctl")
.args(["--db", nb_addr])
.args(args.iter().map(String::as_str))
.output()
.await
.map_err(|e| OvnError::Command(format!("failed to run ovn-nbctl: {}", e)))?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
Err(OvnError::Command(format!(
"ovn-nbctl exit code {}: {}",
output.status, stderr
)))
}
}
pub async fn create_logical_switch(&self, vpc_id: &VpcId, cidr: &str) -> OvnResult<()> {
match &self.mode {
OvnMode::Mock(state) => {
let mut guard = state.lock().await;
guard.create_logical_switch(*vpc_id, cidr.to_string());
Ok(())
}
OvnMode::Real { .. } => {
let name = Self::logical_switch_name(vpc_id);
self.run_nbctl(vec!["ls-add".into(), name.clone()]).await?;
// Store CIDR for reference (best-effort; ignore errors)
let _ = self
.run_nbctl(vec![
"set".into(),
"Logical_Switch".into(),
name,
format!("other_config:subnet={}", cidr),
])
.await;
Ok(())
}
}
}
pub async fn delete_logical_switch(&self, vpc_id: &VpcId) -> OvnResult<()> {
match &self.mode {
OvnMode::Mock(state) => {
let mut guard = state.lock().await;
guard.delete_logical_switch(vpc_id);
Ok(())
}
OvnMode::Real { .. } => {
let name = Self::logical_switch_name(vpc_id);
self.run_nbctl(vec!["ls-del".into(), name]).await
}
}
}
pub async fn create_logical_switch_port(
&self,
port: &Port,
vpc_id: &VpcId,
ip_address: &str,
) -> OvnResult<()> {
match &self.mode {
OvnMode::Mock(state) => {
let mut guard = state.lock().await;
guard.create_logical_switch_port(
port.id,
*vpc_id,
port.mac_address.clone(),
ip_address.to_string(),
);
Ok(())
}
OvnMode::Real { .. } => {
let ls_name = Self::logical_switch_name(vpc_id);
let lsp_name = Self::logical_port_name(&port.id);
self.run_nbctl(vec!["lsp-add".into(), ls_name.clone(), lsp_name.clone()])
.await?;
let address = format!("{} {}", port.mac_address, ip_address);
self.run_nbctl(vec![
"set".into(),
"Logical_Switch_Port".into(),
lsp_name,
format!("addresses=\"{}\"", address),
])
.await?;
Ok(())
}
}
}
pub async fn delete_logical_switch_port(&self, port_id: &PortId) -> OvnResult<()> {
match &self.mode {
OvnMode::Mock(state) => {
let mut guard = state.lock().await;
guard.delete_logical_switch_port(port_id);
Ok(())
}
OvnMode::Real { .. } => {
let lsp_name = Self::logical_port_name(port_id);
self.run_nbctl(vec!["lsp-del".into(), lsp_name]).await
}
}
}
pub async fn create_acl(
&self,
sg_id: &SecurityGroupId,
rule: &SecurityGroupRule,
logical_switch: &VpcId,
match_expr: &str,
priority: u16,
) -> OvnResult<String> {
let key = Self::acl_key(&rule.id);
match &self.mode {
OvnMode::Mock(state) => {
let mut guard = state.lock().await;
guard.create_acl(
key.clone(),
*sg_id,
rule.clone(),
*logical_switch,
match_expr.to_string(),
);
Ok(key)
}
OvnMode::Real { .. } => {
let ls_name = Self::logical_switch_name(logical_switch);
let direction = direction_to_ovn(rule);
// ovn-nbctl acl-add <switch> <direction> <priority> <match> <action>
self.run_nbctl(vec![
"acl-add".into(),
ls_name,
direction,
priority.to_string(),
match_expr.to_string(),
"allow-related".into(),
])
.await?;
Ok(key)
}
}
}
pub async fn delete_acl(&self, rule_id: &SecurityGroupRuleId) -> OvnResult<()> {
let key = Self::acl_key(rule_id);
match &self.mode {
OvnMode::Mock(state) => {
let mut guard = state.lock().await;
guard.delete_acl(&key);
Ok(())
}
OvnMode::Real { .. } => {
// Best-effort deletion by external-id match (placeholder)
let _ = self
.run_nbctl(vec![
"--".into(),
"find".into(),
"ACL".into(),
format!("name={}", key),
"--delete".into(),
"ACL".into(),
"uuid".into(),
])
.await;
Ok(())
}
}
}
/// Create DHCP options in OVN for a subnet
pub async fn create_dhcp_options(
&self,
cidr: &str,
options: &DhcpOptions,
) -> OvnResult<String> {
match &self.mode {
OvnMode::Mock(state) => {
let mut guard = state.lock().await;
let uuid = guard.create_dhcp_options(cidr.to_string(), options.clone());
Ok(uuid)
}
OvnMode::Real { nb_addr } => {
// Command: ovn-nbctl dhcp-options-create <cidr>
let output = Command::new("ovn-nbctl")
.args(["--db", nb_addr])
.args(["dhcp-options-create", cidr])
.output()
.await
.map_err(|e| OvnError::Command(format!("failed to run ovn-nbctl: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
return Err(OvnError::Command(format!(
"dhcp-options-create failed: {}",
stderr
)));
}
let uuid = String::from_utf8_lossy(&output.stdout).trim().to_string();
// Set DHCP options
let mut opts = Vec::new();
if let Some(router) = &options.router {
opts.push(format!("router={}", router));
}
if !options.dns_servers.is_empty() {
opts.push(format!(
"dns_server={{{}}}",
options.dns_servers.join(",")
));
}
opts.push(format!("lease_time={}", options.lease_time));
if let Some(domain) = &options.domain_name {
opts.push(format!("domain_name={}", domain));
}
// Command: ovn-nbctl dhcp-options-set-options <uuid> <options>
let opts_str = opts.join(" ");
self.run_nbctl(vec![
"dhcp-options-set-options".into(),
uuid.clone(),
opts_str,
])
.await?;
Ok(uuid)
}
}
}
/// Delete DHCP options from OVN
pub async fn delete_dhcp_options(&self, uuid: &str) -> OvnResult<()> {
match &self.mode {
OvnMode::Mock(state) => {
let mut guard = state.lock().await;
guard.delete_dhcp_options(uuid);
Ok(())
}
OvnMode::Real { .. } => {
self.run_nbctl(vec!["dhcp-options-del".into(), uuid.to_string()])
.await
}
}
}
/// Associate DHCP options with a logical switch port
pub async fn set_lsp_dhcp_options(&self, lsp_name: &str, dhcp_uuid: &str) -> OvnResult<()> {
match &self.mode {
OvnMode::Mock(state) => {
let mut guard = state.lock().await;
guard.set_lsp_dhcp_options(lsp_name.to_string(), dhcp_uuid.to_string());
Ok(())
}
OvnMode::Real { .. } => {
self.run_nbctl(vec![
"lsp-set-dhcpv4-options".into(),
lsp_name.to_string(),
dhcp_uuid.to_string(),
])
.await
}
}
}
/// Create a logical router
pub async fn create_logical_router(&self, name: &str) -> OvnResult<String> {
match &self.mode {
OvnMode::Mock(state) => {
let mut guard = state.lock().await;
let id = guard.create_router(name.to_string());
Ok(id)
}
OvnMode::Real { nb_addr } => {
// Command: ovn-nbctl lr-add <router-name>
self.run_nbctl(vec!["lr-add".into(), name.to_string()])
.await?;
// Get the UUID of the created router
let output = Command::new("ovn-nbctl")
.args(["--db", nb_addr])
.args(["--columns=_uuid", "--bare", "find", "Logical_Router"])
.arg(format!("name={}", name))
.output()
.await
.map_err(|e| OvnError::Command(format!("failed to run ovn-nbctl: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
return Err(OvnError::Command(format!("lr-add query failed: {}", stderr)));
}
let uuid = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok(uuid)
}
}
}
/// Delete a logical router
pub async fn delete_logical_router(&self, router_id: &str) -> OvnResult<()> {
match &self.mode {
OvnMode::Mock(state) => {
let mut guard = state.lock().await;
guard.delete_router(router_id);
Ok(())
}
OvnMode::Real { .. } => {
self.run_nbctl(vec!["lr-del".into(), router_id.to_string()])
.await
}
}
}
/// Add a router port connecting a router to a logical switch
/// Returns the router port ID
pub async fn add_router_port(
&self,
router_id: &str,
switch_id: &VpcId,
cidr: &str,
mac: &str,
) -> OvnResult<String> {
match &self.mode {
OvnMode::Mock(state) => {
let mut guard = state.lock().await;
let port_id = guard.add_router_port(
router_id.to_string(),
*switch_id,
cidr.to_string(),
mac.to_string(),
);
Ok(port_id)
}
OvnMode::Real { .. } => {
// Generate unique port names
let router_port_name = format!("rtr-port-{}", uuid::Uuid::new_v4());
let switch_port_name = format!("lsp-rtr-{}", uuid::Uuid::new_v4());
// Extract the IP address from CIDR for the router port
let ip_with_prefix = cidr;
// Create logical router port
// ovn-nbctl lrp-add <router> <port-name> <mac> <network>
self.run_nbctl(vec![
"lrp-add".into(),
router_id.to_string(),
router_port_name.clone(),
mac.to_string(),
ip_with_prefix.to_string(),
])
.await?;
// Create the corresponding switch port
let ls_name = Self::logical_switch_name(switch_id);
// ovn-nbctl lsp-add <switch> <port-name>
self.run_nbctl(vec![
"lsp-add".into(),
ls_name,
switch_port_name.clone(),
])
.await?;
// ovn-nbctl lsp-set-type <port> router
self.run_nbctl(vec![
"lsp-set-type".into(),
switch_port_name.clone(),
"router".into(),
])
.await?;
// ovn-nbctl lsp-set-addresses <port> router
self.run_nbctl(vec![
"lsp-set-addresses".into(),
switch_port_name.clone(),
"router".into(),
])
.await?;
// ovn-nbctl lsp-set-options <port> router-port=<router-port>
self.run_nbctl(vec![
"lsp-set-options".into(),
switch_port_name,
format!("router-port={}", router_port_name),
])
.await?;
Ok(router_port_name)
}
}
}
/// Configure SNAT on a logical router
pub async fn configure_snat(
&self,
router_id: &str,
external_ip: &str,
logical_ip_cidr: &str,
) -> OvnResult<()> {
match &self.mode {
OvnMode::Mock(state) => {
let mut guard = state.lock().await;
guard.configure_snat(
router_id.to_string(),
external_ip.to_string(),
logical_ip_cidr.to_string(),
);
Ok(())
}
OvnMode::Real { .. } => {
// ovn-nbctl lr-nat-add <router> snat <external-ip> <logical-ip-cidr>
self.run_nbctl(vec![
"lr-nat-add".into(),
router_id.to_string(),
"snat".into(),
external_ip.to_string(),
logical_ip_cidr.to_string(),
])
.await
}
}
}
}
fn direction_to_ovn(rule: &SecurityGroupRule) -> String {
match rule.direction {
prismnet_types::RuleDirection::Ingress => "to-lport".to_string(),
prismnet_types::RuleDirection::Egress => "from-lport".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use prismnet_types::{RuleDirection, SecurityGroupRule, Vpc};
#[tokio::test]
async fn mock_logical_switch_and_port_lifecycle() {
let client = OvnClient::new_mock();
let vpc = Vpc::new("test", "org", "proj", "10.0.0.0/16");
client
.create_logical_switch(&vpc.id, &vpc.cidr_block)
.await
.unwrap();
let mut port = Port::new("p1", prismnet_types::SubnetId::new());
port.ip_address = Some("10.0.0.5".to_string());
client
.create_logical_switch_port(&port, &vpc.id, port.ip_address.as_ref().unwrap())
.await
.unwrap();
let state = client.mock_state().unwrap();
let guard = state.lock().await;
assert!(guard.has_logical_switch(&vpc.id));
assert!(guard.port_attached(&port.id));
}
#[tokio::test]
async fn mock_acl_tracks_rule() {
let client = OvnClient::new_mock();
let vpc = Vpc::new("test", "org", "proj", "10.0.0.0/16");
client
.create_logical_switch(&vpc.id, &vpc.cidr_block)
.await
.unwrap();
let mut rule = SecurityGroupRule::new(
SecurityGroupId::new(),
RuleDirection::Ingress,
Default::default(),
);
rule.remote_cidr = Some("0.0.0.0/0".to_string());
let key = client
.create_acl(
&rule.security_group_id,
&rule,
&vpc.id,
"ip4 && ip4.src == 0.0.0.0/0",
1000,
)
.await
.unwrap();
let state = client.mock_state().unwrap();
let guard = state.lock().await;
assert!(guard.acl_exists(&key));
}
#[tokio::test]
async fn mock_deletions_remove_state() {
let client = OvnClient::new_mock();
let vpc = Vpc::new("test", "org", "proj", "10.0.0.0/16");
client
.create_logical_switch(&vpc.id, &vpc.cidr_block)
.await
.unwrap();
client.delete_logical_switch(&vpc.id).await.unwrap();
let state = client.mock_state().unwrap();
let guard = state.lock().await;
assert!(!guard.has_logical_switch(&vpc.id));
}
#[tokio::test]
async fn test_dhcp_options_lifecycle() {
let client = OvnClient::new_mock();
let opts = DhcpOptions {
cidr: "192.168.1.0/24".to_string(),
router: Some("192.168.1.1".to_string()),
dns_servers: vec!["8.8.8.8".to_string(), "8.8.4.4".to_string()],
lease_time: 3600,
domain_name: Some("example.com".to_string()),
};
// Create DHCP options
let uuid = client
.create_dhcp_options("192.168.1.0/24", &opts)
.await
.unwrap();
assert!(!uuid.is_empty());
assert!(uuid.starts_with("dhcp-"));
// Verify it was created
let state = client.mock_state().unwrap();
let guard = state.lock().await;
assert!(guard.dhcp_options_exists(&uuid));
drop(guard);
// Delete DHCP options
client.delete_dhcp_options(&uuid).await.unwrap();
// Verify it was deleted
let guard = state.lock().await;
assert!(!guard.dhcp_options_exists(&uuid));
}
#[tokio::test]
async fn test_dhcp_options_default() {
let opts = DhcpOptions::default();
assert_eq!(opts.lease_time, 86400);
assert_eq!(opts.dns_servers, vec!["8.8.8.8"]);
}
#[tokio::test]
async fn test_lsp_dhcp_binding() {
let client = OvnClient::new_mock();
let vpc = Vpc::new("test", "org", "proj", "10.0.0.0/16");
// Create logical switch
client
.create_logical_switch(&vpc.id, &vpc.cidr_block)
.await
.unwrap();
// Create DHCP options
let opts = DhcpOptions {
cidr: vpc.cidr_block.clone(),
router: Some("10.0.0.1".to_string()),
dns_servers: vec!["8.8.8.8".to_string()],
lease_time: 86400,
domain_name: None,
};
let dhcp_uuid = client
.create_dhcp_options(&vpc.cidr_block, &opts)
.await
.unwrap();
// Create a port
let mut port = Port::new("p1", prismnet_types::SubnetId::new());
port.ip_address = Some("10.0.0.5".to_string());
client
.create_logical_switch_port(&port, &vpc.id, port.ip_address.as_ref().unwrap())
.await
.unwrap();
// Bind DHCP options to port
let lsp_name = format!("port-{}", port.id);
client
.set_lsp_dhcp_options(&lsp_name, &dhcp_uuid)
.await
.unwrap();
// Verify binding
let state = client.mock_state().unwrap();
let guard = state.lock().await;
assert!(guard.port_has_dhcp(&lsp_name));
}
// Router tests
#[tokio::test]
async fn test_router_create_and_delete() {
let client = OvnClient::new_mock();
// Create a router
let router_id = client
.create_logical_router("test-router")
.await
.unwrap();
assert!(!router_id.is_empty());
assert!(router_id.starts_with("router-"));
// Verify it exists
let state = client.mock_state().unwrap();
let guard = state.lock().await;
assert!(guard.router_exists(&router_id));
drop(guard);
// Delete the router
client.delete_logical_router(&router_id).await.unwrap();
// Verify it's gone
let guard = state.lock().await;
assert!(!guard.router_exists(&router_id));
}
#[tokio::test]
async fn test_router_port_attachment() {
let client = OvnClient::new_mock();
let vpc = Vpc::new("test", "org", "proj", "10.0.0.0/16");
// Create logical switch
client
.create_logical_switch(&vpc.id, &vpc.cidr_block)
.await
.unwrap();
// Create router
let router_id = client
.create_logical_router("test-router")
.await
.unwrap();
// Add router port
let mac = "02:00:00:00:00:01";
let cidr = "10.0.0.1/24";
let port_id = client
.add_router_port(&router_id, &vpc.id, cidr, mac)
.await
.unwrap();
assert!(!port_id.is_empty());
assert!(port_id.starts_with("rtr-port-"));
// Verify port exists
let state = client.mock_state().unwrap();
let guard = state.lock().await;
assert!(guard.router_port_exists(&port_id));
assert_eq!(guard.get_router_port_count(&router_id), 1);
}
#[tokio::test]
async fn test_snat_configuration() {
let client = OvnClient::new_mock();
// Create router
let router_id = client
.create_logical_router("test-router")
.await
.unwrap();
// Configure SNAT
let external_ip = "203.0.113.10";
let logical_ip_cidr = "10.0.0.0/24";
client
.configure_snat(&router_id, external_ip, logical_ip_cidr)
.await
.unwrap();
// Verify SNAT rule exists
let state = client.mock_state().unwrap();
let guard = state.lock().await;
assert!(guard.snat_rule_exists(&router_id, external_ip));
}
#[tokio::test]
async fn test_router_deletion_cascades() {
let client = OvnClient::new_mock();
let vpc = Vpc::new("test", "org", "proj", "10.0.0.0/16");
// Create logical switch
client
.create_logical_switch(&vpc.id, &vpc.cidr_block)
.await
.unwrap();
// Create router
let router_id = client
.create_logical_router("test-router")
.await
.unwrap();
// Add router port
let mac = "02:00:00:00:00:01";
let cidr = "10.0.0.1/24";
let port_id = client
.add_router_port(&router_id, &vpc.id, cidr, mac)
.await
.unwrap();
// Configure SNAT
let external_ip = "203.0.113.10";
let logical_ip_cidr = "10.0.0.0/24";
client
.configure_snat(&router_id, external_ip, logical_ip_cidr)
.await
.unwrap();
// Delete router
client.delete_logical_router(&router_id).await.unwrap();
// Verify everything is cleaned up
let state = client.mock_state().unwrap();
let guard = state.lock().await;
assert!(!guard.router_exists(&router_id));
assert!(!guard.router_port_exists(&port_id));
assert!(!guard.snat_rule_exists(&router_id, external_ip));
}
#[tokio::test]
async fn test_multiple_router_ports() {
let client = OvnClient::new_mock();
let vpc1 = Vpc::new("test1", "org", "proj", "10.0.0.0/16");
let vpc2 = Vpc::new("test2", "org", "proj", "10.1.0.0/16");
// Create logical switches
client
.create_logical_switch(&vpc1.id, &vpc1.cidr_block)
.await
.unwrap();
client
.create_logical_switch(&vpc2.id, &vpc2.cidr_block)
.await
.unwrap();
// Create router
let router_id = client
.create_logical_router("test-router")
.await
.unwrap();
// Add router ports to both switches
let port1_id = client
.add_router_port(&router_id, &vpc1.id, "10.0.0.1/24", "02:00:00:00:00:01")
.await
.unwrap();
let port2_id = client
.add_router_port(&router_id, &vpc2.id, "10.1.0.1/24", "02:00:00:00:00:02")
.await
.unwrap();
// Verify both ports exist
let state = client.mock_state().unwrap();
let guard = state.lock().await;
assert!(guard.router_port_exists(&port1_id));
assert!(guard.router_port_exists(&port2_id));
assert_eq!(guard.get_router_port_count(&router_id), 2);
}
#[tokio::test]
async fn test_full_vpc_router_snat_workflow() {
let client = OvnClient::new_mock();
let vpc = Vpc::new("test", "org", "proj", "10.0.0.0/16");
// Step 1: Create VPC (logical switch)
client
.create_logical_switch(&vpc.id, &vpc.cidr_block)
.await
.unwrap();
// Step 2: Create router
let router_id = client
.create_logical_router("vpc-router")
.await
.unwrap();
// Step 3: Attach router to switch
let mac = "02:00:00:00:00:01";
let gateway_cidr = "10.0.0.1/24";
let port_id = client
.add_router_port(&router_id, &vpc.id, gateway_cidr, mac)
.await
.unwrap();
// Step 4: Configure SNAT for outbound traffic
let external_ip = "203.0.113.10";
let internal_cidr = "10.0.0.0/24";
client
.configure_snat(&router_id, external_ip, internal_cidr)
.await
.unwrap();
// Verify the complete setup
let state = client.mock_state().unwrap();
let guard = state.lock().await;
// Check switch exists
assert!(guard.has_logical_switch(&vpc.id));
// Check router exists
assert!(guard.router_exists(&router_id));
// Check router port exists
assert!(guard.router_port_exists(&port_id));
assert_eq!(guard.get_router_port_count(&router_id), 1);
// Check SNAT rule exists
assert!(guard.snat_rule_exists(&router_id, external_ip));
}
#[tokio::test]
async fn test_multiple_snat_rules() {
let client = OvnClient::new_mock();
// Create router
let router_id = client
.create_logical_router("test-router")
.await
.unwrap();
// Configure multiple SNAT rules
client
.configure_snat(&router_id, "203.0.113.10", "10.0.0.0/24")
.await
.unwrap();
client
.configure_snat(&router_id, "203.0.113.11", "10.1.0.0/24")
.await
.unwrap();
// Verify both SNAT rules exist
let state = client.mock_state().unwrap();
let guard = state.lock().await;
assert!(guard.snat_rule_exists(&router_id, "203.0.113.10"));
assert!(guard.snat_rule_exists(&router_id, "203.0.113.11"));
}
}