Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ futures = "0.3.31"
hcloud = "0.21.0"
k8s-openapi = { version = "0.23.0", features = ["v1_31"] }
kube = { version = "0.96.0", features = ["runtime"] }
serde = { version = "1.0.229", features = ["derive"] }
thiserror = "2.0.3"
tokio = { version = "1.43.1", features = ["macros", "rt-multi-thread"] }
tracing = "0.1.40"
Expand Down
42 changes: 38 additions & 4 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::ip_type::IPMode;
use clap::Parser;
use tracing::level_filters::LevelFilter;

Expand Down Expand Up @@ -30,21 +31,22 @@ pub struct OperatorConfig {
pub default_lb_interval: i32,

/// Default location of a load balancer.
/// https://docs.hetzner.com/cloud/general/locations/
/// <https://docs.hetzner.com/cloud/general/locations>/
#[arg(long, env = "ROBOTLB_DEFAULT_LB_LOCATION", default_value = "hel1")]
pub default_lb_location: String,

/// Type of a load balancer. It differs in price, number of connections,
/// target servers, etc. The default value is the smallest balancer.
/// https://docs.hetzner.com/cloud/load-balancers/overview#pricing
/// <https://docs.hetzner.com/cloud/load-balancers/overview#pricing>
#[arg(long, env = "ROBOTLB_DEFAULT_LB_TYPE", default_value = "lb11")]
pub default_balancer_type: String,

/// Default load balancer algorithm.
/// Possible values:
/// * `least-connections`
/// * `round-robin`
/// https://docs.hetzner.com/cloud/load-balancers/overview#load-balancers
///
/// <https://docs.hetzner.com/cloud/load-balancers/overview#load-balancers>
#[arg(
long,
env = "ROBOTLB_DEFAULT_LB_ALGORITHM",
Expand All @@ -54,14 +56,17 @@ pub struct OperatorConfig {

/// Default load balancer proxy mode. If enabled, the load balancer will
/// act as a proxy for the target servers. The default value is `false`.
/// https://docs.hetzner.com/cloud/load-balancers/faq/#what-does-proxy-protocol-mean-and-should-i-enable-it
/// <https://docs.hetzner.com/cloud/load-balancers/faq/#what-does-proxy-protocol-mean-and-should-i-enable-it>
#[arg(
long,
env = "ROBOTLB_DEFAULT_LB_PROXY_MODE_ENABLED",
default_value = "false"
)]
pub default_lb_proxy_mode_enabled: bool,

#[arg(long, env = "ROBOTLB_DEFAULT_IP_MODE", default_value = "VIP")]
pub default_ip_mode: IPMode,

/// Whether to enable IPv6 ingress for the load balancer.
/// If enabled, the load balancer's IPv6 will be attached to the service as an external IP along with IPv4.
#[arg(long, env = "ROBOTLB_IPV6_INGRESS", default_value = "false")]
Expand All @@ -87,6 +92,7 @@ pub struct OperatorConfig {
#[cfg(test)]
mod tests {
use super::OperatorConfig;
use crate::ip_type::IPMode;
use clap::Parser;

fn parse(resync: &str) -> Result<OperatorConfig, clap::Error> {
Expand All @@ -99,6 +105,16 @@ mod tests {
])
}

fn parse_with_ip_mode(ip_mode: &str) -> Result<OperatorConfig, clap::Error> {
OperatorConfig::try_parse_from([
"robotlb",
"--hcloud-token",
"t",
"--default-ip-mode",
ip_mode,
])
}

// Zero would requeue every successful reconcile right away, spending Hetzner
// API requests on every service all the time. The controller's delay queue
// panics on delays past about two years.
Expand All @@ -109,4 +125,22 @@ mod tests {
assert_eq!(parse("31536000").unwrap().resync_interval, 31_536_000);
assert_eq!(parse("1").unwrap().resync_interval, 1);
}

#[test]
fn the_ip_mode_defaults_to_vip() {
assert!(matches!(parse("300").unwrap().default_ip_mode, IPMode::VIP));
}

#[test]
fn the_ip_mode_can_be_set_to_proxy() {
assert!(matches!(
parse_with_ip_mode("Proxy").unwrap().default_ip_mode,
IPMode::Proxy
));
}

#[test]
fn an_unknown_ip_mode_is_rejected() {
assert!(parse_with_ip_mode("vip").is_err());
}
}
1 change: 1 addition & 0 deletions src/consts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ pub const LB_PRIVATE_IP_LABEL_NAME: &str = "robotlb/lb-private-ip";
pub const LB_LOCATION_LABEL_NAME: &str = "robotlb/lb-location";
pub const LB_ALGORITHM_LABEL_NAME: &str = "robotlb/lb-algorithm";
pub const LB_BALANCER_TYPE_LABEL_NAME: &str = "robotlb/balancer-type";
pub const LB_IP_TYPE_LABEL_NAME: &str = "robotlb/ip-type";

pub const DEFAULT_LB_RETRIES: i32 = 3;
pub const DEFAULT_LB_TIMEOUT: i32 = 10;
Expand Down
18 changes: 18 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ pub enum RobotLBError {
HcloudListLoadBalancersError(
#[from] hcloud::apis::Error<hcloud::apis::load_balancers_api::ListLoadBalancersError>,
),
#[error("Unknown IP Type. Only VIP or Proxy are allowed.")]
UnknownIPType,
}

impl RobotLBError {
Expand Down Expand Up @@ -115,6 +117,7 @@ impl RobotLBError {
| Self::HCloudError(_)
| Self::KubeError(_)
| Self::UnknownLBAlgorithm
| Self::UnknownIPType
| Self::ServiceWithoutSelector
| Self::RateLimited(_) => false,
}
Expand Down Expand Up @@ -282,6 +285,21 @@ mod tests {
assert!(!RobotLBError::SkipService.is_rate_limited());
}

// A misconfigured annotation is the user's problem, not Hetzner's, so it
// must not close the rate limit gate.
#[test]
fn an_unknown_ip_type_is_not_a_rate_limit() {
assert!(!RobotLBError::UnknownIPType.is_rate_limited());
}

#[test]
fn the_unknown_ip_type_error_names_the_allowed_values() {
assert_eq!(
RobotLBError::UnknownIPType.to_string(),
"Unknown IP Type. Only VIP or Proxy are allowed."
);
}

#[test]
fn a_locked_balancer_is_temporary() {
let body = r#"{"error": {"code": "locked", "message": "item is locked"}}"#;
Expand Down
4 changes: 1 addition & 3 deletions src/finalizers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,7 @@ pub fn check(service: &Service) -> bool {
.metadata
.finalizers
.as_ref()
.map_or(false, |finalizers| {
finalizers.contains(&consts::FINALIZER_NAME.to_string())
})
.is_some_and(|finalizers| finalizers.contains(&consts::FINALIZER_NAME.to_string()))
}

/// Remove finalizer from the service.
Expand Down
64 changes: 64 additions & 0 deletions src/ip_type.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
use std::str::FromStr;

use crate::error::RobotLBError;

/// IP Type set by the loadbancer in the status field.
#[derive(Clone, Debug, Copy, serde::Serialize)]
pub enum IPMode {
/// Type of an IP when the traffic is routed
/// with the loadbalancer IP and port.
VIP,
/// Type of an IP when the traffic is routed
/// with the node IP.
Proxy,
}

impl FromStr for IPMode {
type Err = RobotLBError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"VIP" => Ok(Self::VIP),
"Proxy" => Ok(Self::Proxy),
_ => Err(RobotLBError::UnknownIPType),
}
}
}

#[cfg(test)]
mod tests {
use super::IPMode;
use crate::error::RobotLBError;
use std::str::FromStr;

#[test]
fn vip_and_proxy_are_parsed() {
assert!(matches!(IPMode::from_str("VIP"), Ok(IPMode::VIP)));
assert!(matches!(IPMode::from_str("Proxy"), Ok(IPMode::Proxy)));
}

// The values are case-sensitive, since the annotation is passed to the
// status verbatim and anything else would end up in the service.
#[test]
fn an_unknown_ip_mode_is_rejected() {
for input in ["vip", "proxy", "Vip", "EXTERNAL", ""] {
assert!(
matches!(IPMode::from_str(input), Err(RobotLBError::UnknownIPType)),
"{input:?} must be rejected"
);
}
}

// The mode is written into the service status `ipMode` field as-is, so the
// serialized form must match the accepted annotation values exactly.
#[test]
fn the_serialized_form_matches_the_accepted_values() {
assert_eq!(
k8s_openapi::serde_json::to_string(&IPMode::VIP).unwrap(),
"\"VIP\""
);
assert_eq!(
k8s_openapi::serde_json::to_string(&IPMode::Proxy).unwrap(),
"\"Proxy\""
);
}
}
Loading
Loading