photoncloud-monorepo/fiberlb/crates/fiberlb-server/src/services/loadbalancer.rs
centra ce979d8f26
Some checks failed
Nix CI / filter (push) Successful in 6s
Nix CI / gate () (push) Failing after 1s
Nix CI / gate (shared crates) (push) Has been skipped
Nix CI / build () (push) Has been skipped
Nix CI / ci-status (push) Failing after 1s
fiberlb: add BGP interop, drain, and policy validation
2026-03-30 20:06:08 +09:00

368 lines
12 KiB
Rust

//! LoadBalancer service implementation
use std::net::IpAddr;
use std::sync::Arc;
use base64::Engine as _;
use crate::metadata::LbMetadataStore;
use fiberlb_api::{
load_balancer_service_server::LoadBalancerService,
CreateLoadBalancerRequest, CreateLoadBalancerResponse,
DeleteLoadBalancerRequest, DeleteLoadBalancerResponse,
GetLoadBalancerRequest, GetLoadBalancerResponse,
ListLoadBalancersRequest, ListLoadBalancersResponse,
UpdateLoadBalancerRequest, UpdateLoadBalancerResponse,
LoadBalancer as ProtoLoadBalancer, LoadBalancerStatus as ProtoLoadBalancerStatus,
};
use fiberlb_types::{LoadBalancer, LoadBalancerId, LoadBalancerStatus};
use iam_service_auth::{get_tenant_context, resolve_tenant_ids_from_context, resource_for_tenant, AuthService};
use tonic::{Request, Response, Status};
use uuid::Uuid;
/// LoadBalancer service implementation
pub struct LoadBalancerServiceImpl {
metadata: Arc<LbMetadataStore>,
auth: Arc<AuthService>,
}
impl LoadBalancerServiceImpl {
/// Create a new LoadBalancerServiceImpl
pub fn new(metadata: Arc<LbMetadataStore>, auth: Arc<AuthService>) -> Self {
Self { metadata, auth }
}
}
fn normalize_requested_vip(vip_address: &str) -> Result<Option<String>, Status> {
let trimmed = vip_address.trim();
if trimmed.is_empty() {
return Ok(None);
}
let vip: IpAddr = trimmed
.parse()
.map_err(|_| Status::invalid_argument("vip_address must be a valid IP address"))?;
if vip.is_unspecified() || vip.is_multicast() {
return Err(Status::invalid_argument(
"vip_address must be a usable unicast address",
));
}
Ok(Some(vip.to_string()))
}
async fn ensure_vip_available(metadata: &LbMetadataStore, vip: &str) -> Result<(), Status> {
let lbs = metadata
.list_all_lbs()
.await
.map_err(|e| Status::internal(format!("metadata error: {}", e)))?;
if lbs
.iter()
.any(|lb| lb.vip_address.as_deref() == Some(vip))
{
return Err(Status::already_exists(format!(
"vip_address {} is already in use",
vip
)));
}
Ok(())
}
const ACTION_LB_CREATE: &str = "network:loadbalancers:create";
const ACTION_LB_READ: &str = "network:loadbalancers:read";
const ACTION_LB_LIST: &str = "network:loadbalancers:list";
const ACTION_LB_UPDATE: &str = "network:loadbalancers:update";
const ACTION_LB_DELETE: &str = "network:loadbalancers:delete";
/// Convert domain LoadBalancer to proto
fn lb_to_proto(lb: &LoadBalancer) -> ProtoLoadBalancer {
ProtoLoadBalancer {
id: lb.id.to_string(),
name: lb.name.clone(),
org_id: lb.org_id.clone(),
project_id: lb.project_id.clone(),
description: lb.description.clone().unwrap_or_default(),
status: match lb.status {
LoadBalancerStatus::Provisioning => ProtoLoadBalancerStatus::Provisioning.into(),
LoadBalancerStatus::Active => ProtoLoadBalancerStatus::Active.into(),
LoadBalancerStatus::Updating => ProtoLoadBalancerStatus::Updating.into(),
LoadBalancerStatus::Error => ProtoLoadBalancerStatus::Error.into(),
LoadBalancerStatus::Deleting => ProtoLoadBalancerStatus::Deleting.into(),
},
vip_address: lb.vip_address.clone().unwrap_or_default(),
created_at: lb.created_at,
updated_at: lb.updated_at,
}
}
/// Parse LoadBalancerId from string
fn parse_lb_id(id: &str) -> Result<LoadBalancerId, Status> {
let uuid: Uuid = id
.parse()
.map_err(|_| Status::invalid_argument("invalid load balancer ID"))?;
Ok(LoadBalancerId::from_uuid(uuid))
}
#[tonic::async_trait]
impl LoadBalancerService for LoadBalancerServiceImpl {
async fn create_load_balancer(
&self,
request: Request<CreateLoadBalancerRequest>,
) -> Result<Response<CreateLoadBalancerResponse>, Status> {
let tenant = get_tenant_context(&request)?;
let req = request.into_inner();
let (org_id, project_id) =
resolve_tenant_ids_from_context(&tenant, &req.org_id, &req.project_id)?;
self.auth
.authorize(
&tenant,
ACTION_LB_CREATE,
&resource_for_tenant("loadbalancer", "*", &org_id, &project_id),
)
.await?;
// Validate required fields
if req.name.is_empty() {
return Err(Status::invalid_argument("name is required"));
}
// Create new load balancer
let mut lb = LoadBalancer::new(&req.name, &org_id, &project_id);
// Apply optional description
if !req.description.is_empty() {
lb.description = Some(req.description);
}
let requested_vip = normalize_requested_vip(&req.vip_address)?;
let vip = if let Some(vip) = requested_vip {
ensure_vip_available(&self.metadata, &vip).await?;
vip
} else {
self.metadata
.allocate_vip()
.await
.map_err(|e| Status::resource_exhausted(format!("failed to allocate VIP: {}", e)))?
};
lb.vip_address = Some(vip);
// Save load balancer
self.metadata
.save_lb(&lb)
.await
.map_err(|e| Status::internal(format!("failed to save load balancer: {}", e)))?;
Ok(Response::new(CreateLoadBalancerResponse {
loadbalancer: Some(lb_to_proto(&lb)),
}))
}
async fn get_load_balancer(
&self,
request: Request<GetLoadBalancerRequest>,
) -> Result<Response<GetLoadBalancerResponse>, Status> {
let tenant = get_tenant_context(&request)?;
let req = request.into_inner();
if req.id.is_empty() {
return Err(Status::invalid_argument("id is required"));
}
let lb_id = parse_lb_id(&req.id)?;
let lb = self
.metadata
.load_lb_by_id(&lb_id)
.await
.map_err(|e| Status::internal(format!("metadata error: {}", e)))?
.ok_or_else(|| Status::not_found("load balancer not found"))?;
if lb.org_id != tenant.org_id || lb.project_id != tenant.project_id {
return Err(Status::permission_denied("load balancer not in tenant scope"));
}
self.auth
.authorize(
&tenant,
ACTION_LB_READ,
&resource_for_tenant("loadbalancer", &lb.id.to_string(), &lb.org_id, &lb.project_id),
)
.await?;
Ok(Response::new(GetLoadBalancerResponse {
loadbalancer: Some(lb_to_proto(&lb)),
}))
}
async fn list_load_balancers(
&self,
request: Request<ListLoadBalancersRequest>,
) -> Result<Response<ListLoadBalancersResponse>, Status> {
let tenant = get_tenant_context(&request)?;
let req = request.into_inner();
let (org_id, project_id) =
resolve_tenant_ids_from_context(&tenant, &req.org_id, &req.project_id)?;
self.auth
.authorize(
&tenant,
ACTION_LB_LIST,
&resource_for_tenant("loadbalancer", "*", &org_id, &project_id),
)
.await?;
let lbs = self
.metadata
.list_lbs(&org_id, Some(project_id.as_str()))
.await
.map_err(|e| Status::internal(format!("metadata error: {}", e)))?;
let page_size = if req.page_size == 0 {
50
} else {
req.page_size as usize
};
let offset = if req.page_token.is_empty() {
0
} else {
let decoded = base64::engine::general_purpose::STANDARD
.decode(&req.page_token)
.map_err(|_| Status::invalid_argument("invalid page_token"))?;
let offset_str = String::from_utf8(decoded)
.map_err(|_| Status::invalid_argument("invalid page_token encoding"))?;
offset_str
.parse::<usize>()
.map_err(|_| Status::invalid_argument("invalid page_token format"))?
};
let total = lbs.len();
let end = std::cmp::min(offset + page_size, total);
let paginated = lbs.iter().skip(offset).take(page_size);
let loadbalancers: Vec<ProtoLoadBalancer> = paginated.map(lb_to_proto).collect();
let next_page_token = if end < total {
base64::engine::general_purpose::STANDARD.encode(end.to_string().as_bytes())
} else {
String::new()
};
Ok(Response::new(ListLoadBalancersResponse {
loadbalancers,
next_page_token,
}))
}
async fn update_load_balancer(
&self,
request: Request<UpdateLoadBalancerRequest>,
) -> Result<Response<UpdateLoadBalancerResponse>, Status> {
let tenant = get_tenant_context(&request)?;
let req = request.into_inner();
if req.id.is_empty() {
return Err(Status::invalid_argument("id is required"));
}
let lb_id = parse_lb_id(&req.id)?;
let mut lb = self
.metadata
.load_lb_by_id(&lb_id)
.await
.map_err(|e| Status::internal(format!("metadata error: {}", e)))?
.ok_or_else(|| Status::not_found("load balancer not found"))?;
if lb.org_id != tenant.org_id || lb.project_id != tenant.project_id {
return Err(Status::permission_denied("load balancer not in tenant scope"));
}
self.auth
.authorize(
&tenant,
ACTION_LB_UPDATE,
&resource_for_tenant("loadbalancer", &lb.id.to_string(), &lb.org_id, &lb.project_id),
)
.await?;
// Apply updates
if !req.name.is_empty() {
lb.name = req.name;
}
if !req.description.is_empty() {
lb.description = Some(req.description);
}
// Update timestamp
lb.updated_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
// Save updated load balancer
self.metadata
.save_lb(&lb)
.await
.map_err(|e| Status::internal(format!("failed to save load balancer: {}", e)))?;
Ok(Response::new(UpdateLoadBalancerResponse {
loadbalancer: Some(lb_to_proto(&lb)),
}))
}
async fn delete_load_balancer(
&self,
request: Request<DeleteLoadBalancerRequest>,
) -> Result<Response<DeleteLoadBalancerResponse>, Status> {
let tenant = get_tenant_context(&request)?;
let req = request.into_inner();
if req.id.is_empty() {
return Err(Status::invalid_argument("id is required"));
}
let lb_id = parse_lb_id(&req.id)?;
let lb = self
.metadata
.load_lb_by_id(&lb_id)
.await
.map_err(|e| Status::internal(format!("metadata error: {}", e)))?
.ok_or_else(|| Status::not_found("load balancer not found"))?;
if lb.org_id != tenant.org_id || lb.project_id != tenant.project_id {
return Err(Status::permission_denied("load balancer not in tenant scope"));
}
self.auth
.authorize(
&tenant,
ACTION_LB_DELETE,
&resource_for_tenant("loadbalancer", &lb.id.to_string(), &lb.org_id, &lb.project_id),
)
.await?;
// Delete all associated resources (cascade delete)
self.metadata
.delete_lb_listeners(&lb.id)
.await
.map_err(|e| Status::internal(format!("failed to delete listeners: {}", e)))?;
self.metadata
.delete_lb_pools(&lb.id)
.await
.map_err(|e| Status::internal(format!("failed to delete pools: {}", e)))?;
// Delete load balancer
self.metadata
.delete_lb(&lb)
.await
.map_err(|e| Status::internal(format!("failed to delete load balancer: {}", e)))?;
Ok(Response::new(DeleteLoadBalancerResponse {}))
}
}