use anyhow::{bail, Result};
use async_trait::async_trait;
use crate::config::MachineConfig;
#[async_trait]
pub trait FloatingIpProvider: Send + Sync {
fn id(&self) -> &'static str;
async fn resolve_target(&self, machine: &MachineConfig) -> Result<FloatingIpTarget>;
async fn current_assignment(&self, ip_id: &str) -> Result<FloatingIpState>;
async fn reassign(&self, ip_id: &str, target: &FloatingIpTarget) -> Result<()>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FloatingIpTarget {
pub attach_id: String,
pub zone: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FloatingIpState {
pub zone: String,
pub attached_to: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FloatingIpAssignOutcome {
pub reassigned: bool,
pub attached_to: String,
}
pub async fn reconcile_assignment(
provider: &dyn FloatingIpProvider,
ip_id: &str,
target: &FloatingIpTarget,
) -> Result<FloatingIpAssignOutcome> {
let current = provider.current_assignment(ip_id).await?;
if current.zone != target.zone {
bail!(
"floating_ip.assign: {} ip {ip_id:?} is homed to zone {:?}, cannot move it into zone {:?} (target attach id {:?}) — {} floating/reserved IPs are not mobile across zones (W267 §Tier 1)",
provider.id(),
current.zone,
target.zone,
target.attach_id,
provider.id(),
);
}
if current.attached_to.as_deref() == Some(target.attach_id.as_str()) {
return Ok(FloatingIpAssignOutcome {
reassigned: false,
attached_to: target.attach_id.clone(),
});
}
provider.reassign(ip_id, target).await?;
Ok(FloatingIpAssignOutcome {
reassigned: true,
attached_to: target.attach_id.clone(),
})
}
pub async fn on_ingress_owner_changed(
provider: &dyn FloatingIpProvider,
machine: &MachineConfig,
ip_id: &str,
) -> Result<FloatingIpAssignOutcome> {
let target = provider.resolve_target(machine).await?;
reconcile_assignment(provider, ip_id, &target).await
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Mutex;
struct FakeProvider {
zone: &'static str,
attached_to: Mutex<Option<String>>,
reassign_calls: AtomicU32,
}
#[async_trait]
impl FloatingIpProvider for FakeProvider {
fn id(&self) -> &'static str {
"fake"
}
async fn resolve_target(&self, machine: &MachineConfig) -> Result<FloatingIpTarget> {
Ok(FloatingIpTarget {
attach_id: machine.name.clone(),
zone: self.zone.to_string(),
})
}
async fn current_assignment(&self, _ip_id: &str) -> Result<FloatingIpState> {
Ok(FloatingIpState {
zone: self.zone.to_string(),
attached_to: self.attached_to.lock().unwrap().clone(),
})
}
async fn reassign(&self, _ip_id: &str, target: &FloatingIpTarget) -> Result<()> {
self.reassign_calls.fetch_add(1, Ordering::SeqCst);
*self.attached_to.lock().unwrap() = Some(target.attach_id.clone());
Ok(())
}
}
fn machine(name: &str) -> MachineConfig {
MachineConfig {
name: name.into(),
provider: "fake".into(),
location: None,
server_type: None,
hosts_mirrors: vec![],
mesh_tags: vec![],
region: None,
zone: None,
arch: None,
bucket: None,
vendor: None,
nickname: None,
legacy_hostkey_fingerprint: None,
registration: Default::default(),
ssh_keys: vec![],
cloudflared: None,
hosts_operator_bridge: false,
connect: None,
allocatable: None,
taints: vec![],
}
}
#[tokio::test]
async fn ownership_flip_drives_exactly_one_reassign_call() {
let provider = FakeProvider {
zone: "us-west",
attached_to: Mutex::new(Some("old-node".into())),
reassign_calls: AtomicU32::new(0),
};
let outcome = on_ingress_owner_changed(&provider, &machine("new-node"), "ip-1")
.await
.unwrap();
assert!(outcome.reassigned);
assert_eq!(outcome.attached_to, "new-node");
assert_eq!(provider.reassign_calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn reapplying_the_same_owner_is_a_zero_call_noop() {
let provider = FakeProvider {
zone: "us-west",
attached_to: Mutex::new(Some("new-node".into())),
reassign_calls: AtomicU32::new(0),
};
let outcome = on_ingress_owner_changed(&provider, &machine("new-node"), "ip-1")
.await
.unwrap();
assert!(!outcome.reassigned);
assert_eq!(outcome.attached_to, "new-node");
assert_eq!(
provider.reassign_calls.load(Ordering::SeqCst),
0,
"idempotent re-apply must not call reassign"
);
}
#[tokio::test]
async fn never_assigned_ip_gets_a_first_assign_call() {
let provider = FakeProvider {
zone: "us-west",
attached_to: Mutex::new(None),
reassign_calls: AtomicU32::new(0),
};
let outcome = on_ingress_owner_changed(&provider, &machine("new-node"), "ip-1")
.await
.unwrap();
assert!(outcome.reassigned);
assert_eq!(provider.reassign_calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn cross_zone_target_is_rejected_before_any_reassign_call() {
let provider = FakeProvider {
zone: "eu-central",
attached_to: Mutex::new(None),
reassign_calls: AtomicU32::new(0),
};
let target = FloatingIpTarget {
attach_id: "new-node".into(),
zone: "us-west".into(),
};
let err = reconcile_assignment(&provider, "ip-1", &target)
.await
.unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("zone"),
"expected a zone-mismatch message, got: {msg}"
);
assert_eq!(
provider.reassign_calls.load(Ordering::SeqCst),
0,
"zone mismatch must never call reassign"
);
}
}