fix(opnsense-config): ensure load balancer service configuration is idempotent #129

Merged
letian merged 8 commits from idempotent-load-balancer into master 2025-10-20 19:18:50 +00:00
2 changed files with 88 additions and 81 deletions
Showing only changes of commit fc4c18ccea - Show all commits

View File

@ -29,12 +29,8 @@ impl LoadBalancer for OPNSenseFirewall {
let (frontend, backend, servers, healthcheck) = let (frontend, backend, servers, healthcheck) =
harmony_load_balancer_service_to_haproxy_xml(service); harmony_load_balancer_service_to_haproxy_xml(service);
load_balancer.add_backend(backend);
load_balancer.add_frontend(frontend); load_balancer.configure_service(frontend, backend, servers, healthcheck);
load_balancer.add_servers(servers);
if let Some(healthcheck) = healthcheck {
load_balancer.add_healthcheck(healthcheck);
}
Ok(()) Ok(())
} }

View File

@ -1,4 +1,4 @@
use std::sync::Arc; use std::{collections::HashSet, sync::Arc};
use log::warn; use log::warn;
use opnsense_config_xml::{ use opnsense_config_xml::{
@ -40,86 +40,51 @@ impl<'a> LoadBalancerConfig<'a> {
self.with_haproxy(|haproxy| haproxy.general.enabled = enabled as i32); self.with_haproxy(|haproxy| haproxy.general.enabled = enabled as i32);
} }
/// Adds or updates a backend pool. /// Configures a service by removing any existing service on the same port
/// If a backend with the same name exists, it is updated. Otherwise, it is added. /// and then adding the new definition. This ensures idempotency.
pub fn add_backend(&mut self, mut backend: HAProxyBackend) { pub fn configure_service(
warn!("TODO make sure this new backend does not refer non-existing entities like servers or health checks"); &mut self,
self.with_haproxy(|haproxy| { frontend: Frontend,
let existing_backend = haproxy backend: HAProxyBackend,
.backends servers: Vec<HAProxyServer>,
.backends healthcheck: Option<HAProxyHealthCheck>,
.iter_mut() ) {
.find(|b| b.name == backend.name); self.remove_service_by_bind_address(&frontend.bind);
self.add_new_service(frontend, backend, servers, healthcheck);
}
if let Some(existing_backend) = existing_backend { /// Removes a service and its dependent components based on the frontend's bind address.
letian marked this conversation as resolved Outdated

As of now, the first time you add a load balancer service, everything will work as expected.

Though if you run it again, as we update in place the existing backend and we reuse its uuid, the frontend will be referring to a non-existent uuid for the default_backend (see harmony/src/infra/opnsense/load_balancer.rs on line 323). So we need to fix this before merging.

As of now, the first time you add a load balancer service, everything will work as expected. Though if you run it again, as we update in place the existing backend and we reuse its uuid, the frontend will be referring to a non-existent uuid for the `default_backend` (see `harmony/src/infra/opnsense/load_balancer.rs` on line 323). So we need to fix this before merging.
backend.uuid = existing_backend.uuid.clone(); // This breaks the `frontend` config /// This performs a cascading delete of the frontend, backend, servers, and health check.
// as it is now relying on a stale uuid fn remove_service_by_bind_address(&mut self, bind_address: &str) {
backend.id = existing_backend.id.clone(); self.with_haproxy(|haproxy| {
*existing_backend = backend; let Some(old_frontend) = remove_frontend_by_bind_address(haproxy, bind_address) else {
} else { return;
haproxy.backends.backends.push(backend); };
}
let Some(old_backend) = remove_backend(haproxy, old_frontend) else {
return;
};
remove_healthcheck(haproxy, &old_backend);
remove_servers(haproxy, &old_backend);
}); });
} }
/// Adds or updates a frontend. /// Adds the components of a new service to the HAProxy configuration.
/// If a frontend with the same name exists, it is updated. Otherwise, it is added. fn add_new_service(
pub fn add_frontend(&mut self, mut frontend: Frontend) { &mut self,
frontend: Frontend,
backend: HAProxyBackend,
servers: Vec<HAProxyServer>,
healthcheck: Option<HAProxyHealthCheck>,
) {
self.with_haproxy(|haproxy| { self.with_haproxy(|haproxy| {
let existing_frontend = haproxy if let Some(check) = healthcheck {
.frontends haproxy.healthchecks.healthchecks.push(check);
.frontend
.iter_mut()
.find(|f| f.name == frontend.name);
if let Some(existing_frontend) = existing_frontend {
frontend.uuid = existing_frontend.uuid.clone();
frontend.id = existing_frontend.id.clone();
*existing_frontend = frontend;
} else {
haproxy.frontends.frontend.push(frontend);
}
});
}
/// Adds or updates a health check.
/// If a health check with the same name exists, it is updated. Otherwise, it is added.
pub fn add_healthcheck(&mut self, mut healthcheck: HAProxyHealthCheck) {
self.with_haproxy(|haproxy| {
let existing_healthcheck = haproxy
.healthchecks
.healthchecks
.iter_mut()
.find(|h| h.name == healthcheck.name);
if let Some(existing_check) = existing_healthcheck {
healthcheck.uuid = existing_check.uuid.clone();
*existing_check = healthcheck;
} else {
haproxy.healthchecks.healthchecks.push(healthcheck);
}
});
}
/// Adds or updates a list of servers to the HAProxy configuration.
/// If a server with the same name already exists, it is updated. Otherwise, it is added.
pub fn add_servers(&mut self, servers: Vec<HAProxyServer>) {
self.with_haproxy(|haproxy| {
for server in servers {
let existing_server = haproxy
.servers
.servers
.iter_mut()
.find(|s| s.name == server.name);
if let Some(existing_server) = existing_server {
existing_server.address = server.address;
existing_server.port = server.port;
existing_server.enabled = server.enabled;
} else {
haproxy.servers.servers.push(server);
}
} }
haproxy.servers.servers.extend(servers);
haproxy.backends.backends.push(backend);
haproxy.frontends.frontend.push(frontend);
}); });
} }
@ -148,3 +113,49 @@ impl<'a> LoadBalancerConfig<'a> {
Ok(()) Ok(())
} }
} }
fn remove_frontend_by_bind_address(haproxy: &mut HAProxy, bind_address: &str) -> Option<Frontend> {
let pos = haproxy
.frontends
.frontend
.iter()
.position(|f| f.bind == bind_address);
match pos {
Some(pos) => Some(haproxy.frontends.frontend.remove(pos)),
None => None,
}
}
fn remove_backend(haproxy: &mut HAProxy, old_frontend: Frontend) -> Option<HAProxyBackend> {
let pos = haproxy
.backends
.backends
.iter()
.position(|b| b.uuid == old_frontend.default_backend);
match pos {
Some(pos) => Some(haproxy.backends.backends.remove(pos)),
None => None, // orphaned frontend, shouldn't happen
}
}
fn remove_healthcheck(haproxy: &mut HAProxy, backend: &HAProxyBackend) {
if let Some(uuid) = &backend.health_check.content {
haproxy
.healthchecks
.healthchecks
.retain(|h| h.uuid != *uuid);
}
}
/// Remove the backend's servers. This assumes servers are not shared between services.
fn remove_servers(haproxy: &mut HAProxy, backend: &HAProxyBackend) {
if let Some(server_uuids_str) = &backend.linked_servers.content {
let server_uuids_to_remove: HashSet<_> = server_uuids_str.split(',').collect();
haproxy
.servers
.servers
.retain(|s| !server_uuids_to_remove.contains(s.uuid.as_str()));
}
}