From 407fc0861bb4b6f8858161c90ac58685082c24cd Mon Sep 17 00:00:00 2001 From: Dev Jadeja Date: Thu, 20 Nov 2025 17:15:16 +0530 Subject: [PATCH 1/2] feat: gateway api support --- CLAUDE.md | 210 +++++++++++++++ Cargo.lock | 651 ++++++++++++++++++++++++++++------------------ Cargo.toml | 5 +- Dockerfile | 2 +- README.md | 115 +++++++- src/consts.rs | 8 +- src/error.rs | 16 ++ src/finalizers.rs | 64 +++++ src/gateway.rs | 148 +++++++++++ src/lb.rs | 2 +- src/main.rs | 297 ++++++++++++++++++++- src/routes.rs | 212 +++++++++++++++ tutorial.md | 113 ++++++++ 13 files changed, 1564 insertions(+), 279 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/gateway.rs create mode 100644 src/routes.rs diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a90194a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,210 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +RobotLB is a Kubernetes operator written in Rust that integrates Hetzner Robot bare-metal clusters with Hetzner Cloud load balancers. It supports two APIs: +1. **LoadBalancer Services**: Traditional Kubernetes Service resources of type `LoadBalancer` +2. **Gateway API**: Modern Kubernetes Gateway API (v1) for more advanced routing capabilities + +Both APIs provision and manage Hetzner Cloud load balancers automatically. + +## Architecture + +### Controller Pattern +The operator runs **two concurrent controllers** using the Kubernetes controller pattern via `kube-rs`: +1. **Service Controller**: Watches LoadBalancer Service resources + - Main reconciliation in `src/main.rs:reconcile_service()` + - Triggered on Service resource changes +2. **Gateway Controller**: Watches Gateway API resources + - Main reconciliation in `src/main.rs:reconcile_gateway()` + - Triggered on Gateway, HTTPRoute, and TCPRoute changes + +Both controllers: +- Use finalizers to ensure clean resource deletion +- Requeue every 30 seconds for periodic reconciliation +- Run concurrently using `tokio::select!` + +### Core Components + +**main.rs**: Controller setup and service reconciliation +- `reconcile_service()`: Entry point for reconciliation, validates service type and load balancer class +- `reconcile_load_balancer()`: Main reconciliation logic that: + - Determines target nodes (dynamically via pod locations or via node selector) + - Collects node IPs (InternalIP for private networks, ExternalIP for public) + - Extracts service ports and creates load balancer services + - Updates Service status with load balancer ingress IPs +- `get_nodes_dynamically()`: Finds nodes where target pods are deployed using service selectors +- `get_nodes_by_selector()`: Finds nodes using label filters from annotations + +**lb.rs**: Load balancer management +- `LoadBalancer` struct holds configuration parsed from service annotations and operator config +- `try_from_svc()`: Constructs LoadBalancer from Service annotations with fallback to operator defaults +- `reconcile()`: Orchestrates reconciliation of all LB aspects (algorithm, type, network, services, targets) +- `reconcile_services()`: Ensures LB services match desired configuration (add/update/delete) +- `reconcile_targets()`: Ensures LB targets match current node IPs (add/remove) +- `reconcile_network()`: Manages LB network attachment/detachment with optional private IP +- `reconcile_algorithm()` and `reconcile_lb_type()`: Update LB settings when changed +- `cleanup()`: Removes all services and targets before deleting the load balancer + +**finalizers.rs**: Prevents accidental service deletion +- Adds `robotlb/finalizer` to services to ensure cleanup happens before deletion +- Cleanup triggered when `deletion_timestamp` is set on the service + +**label_filter.rs**: Node label filtering +- Implements custom label selector syntax for the `robotlb/node-selector` annotation +- Supports: `key=value`, `key!=value`, `key` (exists), `!key` (does not exist) + +**config.rs**: Operator configuration via CLI args and environment variables +- All configs prefixed with `ROBOTLB_` +- Contains defaults for LB settings (location, type, algorithm, healthcheck params) + +**consts.rs**: Annotation and constant definitions +- All service annotations use `robotlb/` prefix +- Gateway class name: `robotlb` + +**gateway.rs**: Gateway API load balancer management +- `GatewayLoadBalancer` wraps `LoadBalancer` for Gateway resources +- `try_from_gateway()`: Constructs from Gateway spec and annotations +- Reuses core Hetzner LB provisioning logic from `lb.rs` + +**routes.rs**: HTTPRoute and TCPRoute handling +- `extract_http_route_backends()`: Extracts backend Services from HTTPRoute +- `extract_tcp_route_backends()`: Extracts backend Services from TCPRoute +- `get_backend_services()`: Fetches actual Service resources from K8s +- `determine_port_mappings()`: Maps Gateway listener ports to backend ports + +### Key Behaviors + +1. **Node Selection**: Two modes controlled by `--dynamic-node-selector`: + - Dynamic (default): Finds nodes by looking up where pods matching service selector are running + - Static: Uses `robotlb/node-selector` annotation to filter nodes + +2. **Network Handling**: + - If `robotlb/lb-network` annotation is set, uses private network (InternalIP) + - Otherwise uses public IPs (ExternalIP) + - Supports optional `robotlb/lb-private-ip` for specific IP allocation + +3. **Service Filtering**: Only processes services with: + - `type: LoadBalancer` + - `loadBalancerClass: robotlb` (or no class specified, defaults to `robotlb`) + +4. **Protocol Support**: Only TCP protocol is currently supported; UDP ports are ignored with warnings + +5. **Gateway API Support**: + - Supports Gateway resources with `gatewayClassName: robotlb` + - Extracts listeners from `Gateway.spec.listeners` for port configuration + - Supports both HTTPRoute and TCPRoute resources + - HTTPRoute is in standard API (`gateway_api::apis::standard::httproutes`) + - TCPRoute is in experimental API (`gateway_api::apis::experimental::tcproutes`) + - Routes must reference the Gateway via `spec.parentRefs` + - Backend Services are discovered from route `backendRefs` + - Node discovery uses same logic as Service controller (dynamic or selector-based) + - Updates `Gateway.status.addresses` with provisioned load balancer IPs + +## Building and Development + +### Build Commands +```bash +# Build debug version +cargo build + +# Build optimized release version +cargo build --release + +# Run locally (requires .env file with ROBOTLB_HCLOUD_TOKEN) +cargo run -- --hcloud-token +``` + +### Testing +There are no unit tests in the repository currently. Manual testing requires: +- A Kubernetes cluster with access to the API +- Hetzner Cloud API token +- Hetzner Robot bare-metal servers configured with vSwitch + +### Code Style +- Uses strict Clippy lints (all, pedantic, nursery) +- Allows `module_name_repetitions` and `missing_errors_doc` +- Formatted with rustfmt (config in `.rustfmt.toml`) + +### Dependencies +- `kube`: Kubernetes client library (v2.0.1) +- `k8s-openapi`: Kubernetes API types v0.26.0 (v1.31 feature) +- `gateway-api`: Gateway API CRD types (v0.19.0, supports Gateway API v1.4.0) +- `hcloud`: Hetzner Cloud API client +- `tokio`: Async runtime +- `clap`: CLI argument parsing with environment variable support +- `tracing`: Structured logging + +### Performance +- Uses jemalloc allocator for better memory performance (non-MSVC targets) +- Release profile optimized with LTO and single codegen unit + +## Gateway API Architecture + +### Resource Flow +``` +Gateway (with gatewayClassName: robotlb) + └─ spec.listeners[] → defines ports and protocols + └─ Annotations (robotlb/*) → LB configuration + +HTTPRoute / TCPRoute + └─ spec.parentRefs[] → references Gateway + └─ spec.rules[].backendRefs[] → references Services + +Services (backends) + └─ Pods → determine target nodes + └─ Nodes → IP addresses added as LB targets +``` + +### Reconciliation Flow (Gateway) +1. Gateway controller triggered on Gateway/Route changes +2. Validate `gatewayClassName == "robotlb"` +3. Parse Gateway annotations for LB configuration (same as Service annotations) +4. Extract listener ports from `spec.listeners` +5. Find all HTTPRoute/TCPRoute resources referencing this Gateway +6. Extract backend Services from routes +7. Determine target nodes using pod discovery or node selectors +8. Create/update Hetzner load balancer +9. Update `Gateway.status.addresses` with LB IPs + +### Important Type Details +- Gateway types from `gateway_api::apis::standard::gateways` +- HTTPRoute from `gateway_api::apis::standard::httproutes::HTTPRoute` +- TCPRoute from `gateway_api::apis::experimental::tcproutes::TCPRoute` +- Spec fields are **not** `Option` (direct access: `gateway.spec.listeners`) +- HTTPRoute `backend_refs` is `Option>` +- TCPRoute `backend_refs` is `Vec<...>` (not Option) +- HTTPRoute `rules` is `Option>` +- TCPRoute `rules` is `Vec<...>` (not Option) + +## Common Patterns + +### Adding New Service Annotations +1. Add constant to `src/consts.rs` +2. Parse annotation in `LoadBalancer::try_from_svc()` in `src/lb.rs` +3. Add field to `LoadBalancer` struct +4. Use field in relevant reconciliation method + +### Adding New Operator Config Options +1. Add field to `OperatorConfig` in `src/config.rs` with `#[arg]` attribute +2. Set env variable name with `ROBOTLB_` prefix +3. Use in reconciliation logic via `context.config` + +### Adding Gateway-Related Features +1. Gateway configuration parsing happens in `gateway::GatewayLoadBalancer::try_from_gateway()` +2. Annotations are reused from Service controller (same constants from `consts.rs`) +3. Route backend extraction in `routes.rs` +4. Gateway finalizer functions in `finalizers.rs` (separate from Service finalizers) + +### Error Handling +- Custom error type `RobotLBError` in `src/error.rs` +- `RobotLBError::SkipService` is special: silently skips service without error log +- `RobotLBError::SkipGateway` is special: silently skips gateway without error log +- All other errors trigger requeue with 30s delay +- Use `?` operator for error propagation; controller handles logging + +## Deployment + +Deployed via Helm chart at `oci://ghcr.io/intreecom/charts/robotlb`. See README.md for deployment instructions. diff --git a/Cargo.lock b/Cargo.lock index 4582e6b..383f9ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -143,17 +143,6 @@ dependencies = [ "syn", ] -[[package]] -name = "async-trait" -version = "0.1.83" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "atomic-waker" version = "1.1.2" @@ -167,14 +156,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] -name = "backoff" -version = "0.4.0" +name = "backon" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" dependencies = [ - "getrandom", - "instant", - "rand", + "fastrand", + "gloo-timers", + "tokio", ] [[package]] @@ -189,27 +178,15 @@ dependencies = [ "miniz_oxide", "object", "rustc-demangle", - "windows-targets", + "windows-targets 0.52.6", ] -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.6.0" @@ -231,12 +208,6 @@ version = "3.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "bytes" version = "1.8.0" @@ -268,7 +239,7 @@ dependencies = [ "iana-time-zone", "num-traits", "serde", - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -373,8 +344,18 @@ version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.10", + "darling_macro 0.20.10", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", ] [[package]] @@ -391,13 +372,49 @@ dependencies = [ "syn", ] +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + [[package]] name = "darling_macro" version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ - "darling_core", + "darling_core 0.20.10", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn", +] + +[[package]] +name = "delegate" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" +dependencies = [ + "proc-macro2", "quote", "syn", ] @@ -412,6 +429,26 @@ dependencies = [ "serde", ] +[[package]] +name = "derive_more" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "digest" version = "0.10.7" @@ -439,6 +476,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "educe" version = "0.6.0" @@ -529,21 +572,18 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "486f806e73c5707928240ddc295403b1b93c96a02038563881c4a2fd84b81ac4" -[[package]] -name = "fluent-uri" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d" -dependencies = [ - "bitflags 1.3.2", -] - [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foreign-types" version = "0.3.2" @@ -657,6 +697,23 @@ dependencies = [ "slab", ] +[[package]] +name = "gateway-api" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22945b64cd520921b037a237f2ad62c4491993e5d1ab01156269922430486d63" +dependencies = [ + "delegate", + "k8s-openapi", + "kube", + "once_cell", + "regex-lite", + "schemars", + "serde", + "serde_json", + "serde_yaml", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -684,6 +741,18 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "h2" version = "0.4.6" @@ -711,20 +780,15 @@ checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" [[package]] name = "hashbrown" -version = "0.14.5" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +checksum = "3a9bfc1af68b1726ea47d3d5109de126281def866b33970e10fbab11b5dafab3" dependencies = [ - "ahash", "allocator-api2", + "equivalent", + "foldhash", ] -[[package]] -name = "hashbrown" -version = "0.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a9bfc1af68b1726ea47d3d5109de126281def866b33970e10fbab11b5dafab3" - [[package]] name = "hcloud" version = "0.21.0" @@ -739,30 +803,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "headers" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "322106e6bd0cba2d5ead589ddb8150a13d7c4217cf80d7c4f682ca994ccc6aa9" -dependencies = [ - "base64 0.21.7", - "bytes", - "headers-core", - "http", - "httpdate", - "mime", - "sha1", -] - -[[package]] -name = "headers-core" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" -dependencies = [ - "http", -] - [[package]] name = "heck" version = "0.5.0" @@ -790,6 +830,17 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "hostname" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a56f203cd1c76362b69e3863fd987520ac36cf70a8c92627449b2f64a8cf7d65" +dependencies = [ + "cfg-if", + "libc", + "windows-link 0.1.3", +] + [[package]] name = "http" version = "1.1.0" @@ -830,52 +881,28 @@ version = "1.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d71d3574edd2771538b901e6549113b4006ece66150fb69c0fb6d9a2adae946" -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - [[package]] name = "hyper" -version = "1.5.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbbff0a806a4728c99295b254c8838933b5b082d75e3cb70c8dab21fdfbcfa9a" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" dependencies = [ + "atomic-waker", "bytes", "futures-channel", - "futures-util", + "futures-core", "h2", "http", "http-body", "httparse", "itoa", "pin-project-lite", + "pin-utils", "smallvec", "tokio", "want", ] -[[package]] -name = "hyper-http-proxy" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d06dbdfbacf34d996c6fb540a71a684a7aae9056c71951163af8a8a4c07b9a4" -dependencies = [ - "bytes", - "futures-util", - "headers", - "http", - "hyper", - "hyper-rustls", - "hyper-util", - "pin-project-lite", - "rustls-native-certs 0.7.3", - "tokio", - "tokio-rustls", - "tower-service", -] - [[package]] name = "hyper-rustls" version = "0.27.3" @@ -888,7 +915,7 @@ dependencies = [ "hyper-util", "log", "rustls", - "rustls-native-certs 0.8.0", + "rustls-native-certs", "rustls-pki-types", "tokio", "tokio-rustls", @@ -926,18 +953,20 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.10" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4" +checksum = "52e9a2a24dc5c6821e71a7030e1e14b7b632acac55c40e9d2e082c621261bb56" dependencies = [ "bytes", "futures-channel", + "futures-core", "futures-util", "http", "http-body", "hyper", + "libc", "pin-project-lite", - "socket2", + "socket2 0.6.1", "tokio", "tower-service", "tracing", @@ -1133,15 +1162,6 @@ dependencies = [ "serde", ] -[[package]] -name = "instant" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" -dependencies = [ - "cfg-if", -] - [[package]] name = "ipnet" version = "2.10.1" @@ -1171,9 +1191,9 @@ dependencies = [ [[package]] name = "json-patch" -version = "2.0.0" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b1fb8864823fad91877e6caea0baca82e49e8db50f8e5c9f9a453e27d3330fc" +checksum = "f300e415e2134745ef75f04562dd0145405c2f7fd92065db029ac4b16b57fe90" dependencies = [ "jsonptr", "serde", @@ -1183,62 +1203,60 @@ dependencies = [ [[package]] name = "jsonpath-rust" -version = "0.5.1" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d8fe85bd70ff715f31ce8c739194b423d79811a19602115d611a3ec85d6200" +checksum = "0c00ae348f9f8fd2d09f82a98ca381c60df9e0820d8d79fce43e649b4dc3128b" dependencies = [ - "lazy_static", - "once_cell", "pest", "pest_derive", "regex", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.17", ] [[package]] name = "jsonptr" -version = "0.4.7" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c6e529149475ca0b2820835d3dce8fcc41c6b943ca608d32f35b449255e4627" +checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe" dependencies = [ - "fluent-uri", "serde", "serde_json", ] [[package]] name = "k8s-openapi" -version = "0.23.0" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8847402328d8301354c94d605481f25a6bdc1ed65471fd96af8eca71141b13" +checksum = "d13f06d5326a915becaffabdfab75051b8cdc260c2a5c06c0e90226ede89a692" dependencies = [ - "base64 0.22.1", + "base64", "chrono", + "schemars", "serde", - "serde-value", "serde_json", ] [[package]] name = "kube" -version = "0.96.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efffeb3df0bd4ef3e5d65044573499c0e4889b988070b08c50b25b1329289a1f" +checksum = "48e7bb0b6a46502cc20e4575b6ff401af45cfea150b34ba272a3410b78aa014e" dependencies = [ "k8s-openapi", "kube-client", "kube-core", + "kube-derive", "kube-runtime", ] [[package]] name = "kube-client" -version = "0.96.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bf471ece8ff8d24735ce78dac4d091e9fcb8d74811aeb6b75de4d1c3f5de0f1" +checksum = "4987d57a184d2b5294fdad3d7fc7f278899469d21a4da39a8f6ca16426567a36" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "chrono", "either", @@ -1248,7 +1266,6 @@ dependencies = [ "http-body", "http-body-util", "hyper", - "hyper-http-proxy", "hyper-rustls", "hyper-timeout", "hyper-util", @@ -1257,12 +1274,11 @@ dependencies = [ "kube-core", "pem", "rustls", - "rustls-pemfile", "secrecy", "serde", "serde_json", "serde_yaml", - "thiserror 1.0.69", + "thiserror 2.0.17", "tokio", "tokio-util", "tower", @@ -1272,44 +1288,59 @@ dependencies = [ [[package]] name = "kube-core" -version = "0.96.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f42346d30bb34d1d7adc5c549b691bce7aa3a1e60254e68fab7e2d7b26fe3d77" +checksum = "914bbb770e7bb721a06e3538c0edd2babed46447d128f7c21caa68747060ee73" dependencies = [ "chrono", + "derive_more", "form_urlencoded", "http", "json-patch", "k8s-openapi", + "schemars", "serde", "serde-value", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.17", +] + +[[package]] +name = "kube-derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03dee8252be137772a6ab3508b81cd797dee62ee771112a2453bc85cbbe150d2" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn", ] [[package]] name = "kube-runtime" -version = "0.96.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fbf1f6ffa98e65f1d2a9a69338bb60605d46be7edf00237784b89e62c9bd44" +checksum = "6aea4de4b562c5cc89ab10300bb63474ae1fa57ff5a19275f2e26401a323e3fd" dependencies = [ "ahash", "async-broadcast", "async-stream", - "async-trait", - "backoff", + "backon", "educe", "futures", - "hashbrown 0.14.5", + "hashbrown 0.15.1", + "hostname", "json-patch", - "jsonptr", "k8s-openapi", "kube-client", "parking_lot", "pin-project", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.17", "tokio", "tokio-util", "tracing", @@ -1323,9 +1354,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.162" +version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18d287de67fe55fd7e1581fe933d965a5a9477b38e949cfa9f8574ef01506398" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" [[package]] name = "linux-raw-sys" @@ -1451,9 +1482,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.20.2" +version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "openssl" @@ -1461,7 +1492,7 @@ version = "0.10.70" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61cfb4e166a8bb8c9b55c500bc2308550148ece889be90f609377e58140f42c6" dependencies = [ - "bitflags 2.6.0", + "bitflags", "cfg-if", "foreign-types", "libc", @@ -1540,7 +1571,7 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -1549,7 +1580,7 @@ version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e459365e590736a54c3fa561947c84837534b8e9af6fc5bf781307e82658fae" dependencies = [ - "base64 0.22.1", + "base64", "serde", ] @@ -1648,15 +1679,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" -[[package]] -name = "ppv-lite86" -version = "0.2.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" -dependencies = [ - "zerocopy", -] - [[package]] name = "proc-macro2" version = "1.0.89" @@ -1676,42 +1698,32 @@ dependencies = [ ] [[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" +name = "redox_syscall" +version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f" dependencies = [ - "ppv-lite86", - "rand_core", + "bitflags", ] [[package]] -name = "rand_core" -version = "0.6.4" +name = "ref-cast" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" dependencies = [ - "getrandom", + "ref-cast-impl", ] [[package]] -name = "redox_syscall" -version = "0.5.7" +name = "ref-cast-impl" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ - "bitflags 2.6.0", + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1737,6 +1749,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d942b98df5e658f56f20d592c7f868833fe38115e65c33003d8cd224b0155da" + [[package]] name = "regex-syntax" version = "0.8.5" @@ -1749,7 +1767,7 @@ version = "0.12.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a77c62af46e79de0a562e1a9849205ffcb7fc1238876e9bd743357570e04046f" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "encoding_rs", "futures-core", @@ -1809,10 +1827,11 @@ dependencies = [ "clap", "dotenvy", "futures", + "gateway-api", "hcloud", "k8s-openapi", "kube", - "thiserror 2.0.3", + "thiserror 2.0.17", "tikv-jemallocator", "tokio", "tracing", @@ -1831,7 +1850,7 @@ version = "0.38.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99e4ea3e1cdc4b559b8e5650f9c8e5998e3e5c1343b4eaf034565f32318d63c0" dependencies = [ - "bitflags 2.6.0", + "bitflags", "errno", "libc", "linux-raw-sys", @@ -1853,19 +1872,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-native-certs" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" -dependencies = [ - "openssl-probe", - "rustls-pemfile", - "rustls-pki-types", - "schannel", - "security-framework", -] - [[package]] name = "rustls-native-certs" version = "0.8.0" @@ -1920,6 +1926,31 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "schemars" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9558e172d4e8533736ba97870c4b2cd63f84b382a3d6eb063da41b91cce17289" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301858a4023d78debd2353c7426dc486001bddc91ae31a76fb1f55132f7e2633" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1941,7 +1972,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.6.0", + "bitflags", "core-foundation", "core-foundation-sys", "libc", @@ -1960,10 +1991,11 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.215" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ + "serde_core", "serde_derive", ] @@ -1977,11 +2009,31 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + [[package]] name = "serde_derive" -version = "1.0.215" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", @@ -1990,14 +2042,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.132" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d726bfaff4b320266d395898905d0eba0345aae23b54aee3a737e260fd46db03" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ "itoa", "memchr", "ryu", "serde", + "serde_core", ] [[package]] @@ -2018,7 +2071,7 @@ version = "3.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e28bdad6db2b8340e449f7108f020b3b092e8583a9e3fb82713e1d4e71fe817" dependencies = [ - "base64 0.22.1", + "base64", "chrono", "hex", "indexmap 1.9.3", @@ -2036,7 +2089,7 @@ version = "3.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d846214a9854ef724f3da161b426242d8de7c1fc7de2f89bb1efcb154dca79d" dependencies = [ - "darling", + "darling 0.20.10", "proc-macro2", "quote", "syn", @@ -2055,17 +2108,6 @@ dependencies = [ "unsafe-libyaml", ] -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "sha2" version = "0.10.8" @@ -2126,6 +2168,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + [[package]] name = "spin" version = "0.9.8" @@ -2193,7 +2245,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags 2.6.0", + "bitflags", "core-foundation", "system-configuration-sys", ] @@ -2232,11 +2284,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.3" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c006c85c7651b3cf2ada4584faa36773bd07bac24acfb39f3c431b36d7e667aa" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl 2.0.3", + "thiserror-impl 2.0.17", ] [[package]] @@ -2252,9 +2304,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.3" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f077553d607adc1caf65430528a576c757a71ed73944b66ebb58ef2bbd243568" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", @@ -2344,7 +2396,7 @@ dependencies = [ "mio", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.5.7", "tokio-macros", "windows-sys 0.52.0", ] @@ -2418,8 +2470,8 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8437150ab6bbc8c5f0f519e3d5ed4aa883a83dd4cdd3d1b21f9482936046cb97" dependencies = [ - "base64 0.22.1", - "bitflags 2.6.0", + "base64", + "bitflags", "bytes", "http", "http-body", @@ -2719,9 +2771,21 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-registry" version = "0.2.0" @@ -2730,7 +2794,7 @@ checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0" dependencies = [ "windows-result", "windows-strings", - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -2739,7 +2803,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -2749,7 +2813,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" dependencies = [ "windows-result", - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -2758,7 +2822,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -2767,7 +2831,16 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", ] [[package]] @@ -2776,14 +2849,31 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -2792,48 +2882,96 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "write16" version = "1.0.0" @@ -2876,7 +3014,6 @@ version = "0.7.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" dependencies = [ - "byteorder", "zerocopy-derive", ] diff --git a/Cargo.toml b/Cargo.toml index 69407ef..d077219 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,9 +8,10 @@ readme = "README.md" clap = { version = "4.5.21", features = ["derive", "env"] } dotenvy = "0.15.7" futures = "0.3.31" +gateway-api = "0.19.0" hcloud = "0.21.0" -k8s-openapi = { version = "0.23.0", features = ["v1_31"] } -kube = { version = "0.96.0", features = ["runtime"] } +k8s-openapi = { version = "0.26.0", features = ["v1_31"] } +kube = { version = "2.0.1", features = ["runtime"] } thiserror = "2.0.3" tokio = { version = "1.41.1", features = ["macros", "rt-multi-thread"] } tracing = "0.1.40" diff --git a/Dockerfile b/Dockerfile index 69f65ae..62fd07d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM rust:1.82-bookworm AS builder +FROM rust:1.87-bookworm AS builder RUN apt update && apt-get install -y pkg-config libjemalloc-dev libssl-dev && apt-get clean diff --git a/README.md b/README.md index b3b0585..bb29c00 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ This project is useful when you've deployed a bare-metal Kubernetes cluster on Hetzner Robot and want to use Hetzner's cloud load balancer. -This small operator integrates them together, allowing you to use the `LoadBalancer` service type. +This operator integrates them together, supporting two APIs: +1. **LoadBalancer Services**: Traditional Kubernetes Service resources of type `LoadBalancer` +2. **Gateway API**: Modern Kubernetes Gateway API (v1) for advanced routing capabilities You can follow the [TUTORIAL.md](./tutorial.md) to see how to set up a cluster using RobotLB from scratch. @@ -29,18 +31,20 @@ helm show values oci://ghcr.io/intreecom/charts/robotlb > values.yaml helm install robotlb oci://ghcr.io/intreecom/charts/robotlb -f values.yaml ``` -After the chart is installed, you should be able to create `LoadBalancer` services. +After the chart is installed, you should be able to create `LoadBalancer` services and Gateway API resources. ## How it works -The operator listens to the Kubernetes API for services of type `LoadBalancer` and creates Hetzner load balancers that point to nodes based on `node-ip`. +The operator runs two concurrent controllers: +1. **Service Controller**: Listens for services of type `LoadBalancer` and creates Hetzner load balancers +2. **Gateway Controller**: Listens for Gateway API resources (Gateway, HTTPRoute, TCPRoute) and provisions load balancers -Nodes are selected based on where the service's target pods are deployed, which is determined by searching for pods with the service's selector. This behavior can be configured. +Nodes are selected based on where the target pods are deployed, which is determined by searching for pods with the service's selector. This behavior can be configured. ## Configuration -This project has two places for configuration: environment variables and service annotations. +This project has two places for configuration: environment variables and resource annotations (Service or Gateway). ### Envs @@ -140,6 +144,107 @@ spec: targetPort: 80 ``` +## Gateway API Support + +RobotLB supports the Kubernetes Gateway API as a modern alternative to LoadBalancer Services. The Gateway API provides more advanced routing capabilities and a role-oriented design. + +### Prerequisites + +Install the Gateway API CRDs in your cluster: + +```bash +kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.0/standard-install.yaml +``` + +For TCPRoute support, install the experimental channel: + +```bash +kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.0/experimental-install.yaml +``` + +### Gateway Configuration + +Gateway resources use the same `robotlb/*` annotations as Services: + +```yaml +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: example-gateway + annotations: + # All the same annotations as Services are supported + robotlb/lb-network: "my-net" + robotlb/lb-location: "hel1" + robotlb/balancer-type: "lb11" + robotlb/lb-algorithm: "least-connections" + robotlb/lb-check-interval: "5" + robotlb/lb-timeout: "3" + robotlb/lb-retries: "3" +spec: + gatewayClassName: robotlb + listeners: + - name: http + protocol: HTTP + port: 80 + - name: https + protocol: HTTPS + port: 443 +``` + +### HTTPRoute Example + +```yaml +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: example-route +spec: + parentRefs: + - name: example-gateway + rules: + - matches: + - path: + type: PathPrefix + value: /app + backendRefs: + - name: my-service + port: 8080 +``` + +### TCPRoute Example + +```yaml +apiVersion: gateway.networking.k8s.io/v1alpha2 +kind: TCPRoute +metadata: + name: example-tcp-route +spec: + parentRefs: + - name: example-gateway + rules: + - backendRefs: + - name: my-tcp-service + port: 3306 +``` + +### How Gateway API Works with RobotLB + +1. Create a Gateway resource with `gatewayClassName: robotlb` +2. The Gateway controller provisions a Hetzner Cloud load balancer +3. Create HTTPRoute or TCPRoute resources that reference the Gateway +4. RobotLB discovers backend Services from the routes +5. Target nodes are determined by finding where backend Service pods run +6. The load balancer is configured with the appropriate ports and targets +7. Gateway status is updated with the load balancer's IP addresses + +### Benefits of Gateway API + +- **Rich Routing**: Advanced HTTP routing with header matching, path rewrites, etc. +- **Role-Oriented**: Separation between infrastructure (Gateway) and routing (Routes) +- **Protocol Support**: Native support for HTTP, HTTPS, TCP, and more +- **Portable**: Works the same across different implementations +- **Type-Safe**: Strongly typed API without relying on annotations for core functionality + ## Star History [![Star History Chart](https://api.star-history.com/svg?repos=Intreecom/robotlb&type=Date)](https://star-history.com/#Intreecom/robotlb&Date) diff --git a/src/consts.rs b/src/consts.rs index 820fa02..bd312cc 100644 --- a/src/consts.rs +++ b/src/consts.rs @@ -1,8 +1,9 @@ +// Service annotations pub const LB_NAME_LABEL_NAME: &str = "robotlb/balancer"; pub const LB_NODE_SELECTOR: &str = "robotlb/node-selector"; pub const LB_NODE_IP_LABEL_NAME: &str = "robotlb/node-ip"; -// LB config +// LB config (used by both Service and Gateway) pub const LB_CHECK_INTERVAL_ANN_NAME: &str = "robotlb/lb-check-interval"; pub const LB_TIMEOUT_ANN_NAME: &str = "robotlb/lb-timeout"; pub const LB_RETRIES_ANN_NAME: &str = "robotlb/lb-retries"; @@ -22,5 +23,10 @@ pub const DEFAULT_LB_LOCATION: &str = "hel1"; pub const DEFAULT_LB_ALGORITHM: &str = "least-connections"; pub const DEFAULT_LB_BALANCER_TYPE: &str = "lb11"; +// Finalizers pub const FINALIZER_NAME: &str = "robotlb/finalizer"; +pub const GATEWAY_FINALIZER_NAME: &str = "robotlb/gateway-finalizer"; + +// LoadBalancer class and GatewayClass pub const ROBOTLB_LB_CLASS: &str = "robotlb"; +pub const GATEWAY_CLASS_NAME: &str = "robotlb"; diff --git a/src/error.rs b/src/error.rs index dae2e3f..aae279c 100644 --- a/src/error.rs +++ b/src/error.rs @@ -23,6 +23,22 @@ pub enum RobotLBError { #[error("Cannot get target nodes, because the service has no selector")] ServiceWithoutSelector, + // Gateway API errors + #[error("Gateway resource skipped")] + SkipGateway, + #[error("Gateway has no listeners configured")] + GatewayWithoutListeners, + #[error("Unsupported protocol in Gateway listener: {0}")] + UnsupportedGatewayProtocol(String), + #[error("Route has no parent references")] + RouteWithoutParentRefs, + #[error("Route has no backend references")] + RouteWithoutBackendRefs, + #[error("Cannot find Gateway for Route")] + GatewayNotFound, + #[error("Unsupported route kind: {0}")] + UnsupportedRouteKind(String), + // HCloud API errors #[error("Cannot attach load balancer to a network. Reason: {0}")] HCloudLBAttachToNetworkError( diff --git a/src/finalizers.rs b/src/finalizers.rs index f45d4fc..9ab0eae 100644 --- a/src/finalizers.rs +++ b/src/finalizers.rs @@ -1,3 +1,4 @@ +use gateway_api::apis::standard::gateways::Gateway; use k8s_openapi::{api::core::v1::Service, serde_json::json}; use kube::{ api::{Patch, PatchParams}, @@ -69,3 +70,66 @@ pub async fn remove(client: Client, svc: &Service) -> RobotLBResult<()> { .await?; Ok(()) } + +// Gateway-specific finalizer functions + +/// Add finalizer to the Gateway. +/// This will prevent the Gateway from being deleted. +pub async fn add_gateway(client: Client, gateway: &Gateway) -> RobotLBResult<()> { + let api = Api::::namespaced( + client, + gateway.namespace().ok_or(RobotLBError::SkipGateway)?.as_str(), + ); + let patch = json!({ + "metadata": { + "finalizers": [consts::GATEWAY_FINALIZER_NAME] + } + }); + api.patch( + gateway.name_any().as_str(), + &PatchParams::default(), + &Patch::Merge(patch), + ) + .await?; + Ok(()) +} + +/// Check if Gateway has the finalizer. +#[must_use] +pub fn check_gateway(gateway: &Gateway) -> bool { + gateway + .metadata + .finalizers + .as_ref() + .map_or(false, |finalizers| { + finalizers.contains(&consts::GATEWAY_FINALIZER_NAME.to_string()) + }) +} + +/// Remove finalizer from the Gateway. +/// This will allow the Gateway to be deleted. +/// +/// if Gateway does not have the finalizer, this function will do nothing. +pub async fn remove_gateway(client: Client, gateway: &Gateway) -> RobotLBResult<()> { + let api = Api::::namespaced( + client, + gateway.namespace().ok_or(RobotLBError::SkipGateway)?.as_str(), + ); + let finalizers = gateway + .finalizers() + .iter() + .filter(|item| item.as_str() != consts::GATEWAY_FINALIZER_NAME) + .collect::>(); + let patch = json!({ + "metadata": { + "finalizers": finalizers + } + }); + api.patch( + gateway.name_any().as_str(), + &PatchParams::default(), + &Patch::Merge(patch), + ) + .await?; + Ok(()) +} diff --git a/src/gateway.rs b/src/gateway.rs new file mode 100644 index 0000000..326b94d --- /dev/null +++ b/src/gateway.rs @@ -0,0 +1,148 @@ +use gateway_api::apis::standard::gateways::Gateway; +use kube::ResourceExt; +use std::{collections::HashMap, str::FromStr}; + +use crate::{ + consts, + error::{RobotLBError, RobotLBResult}, + lb::{LBAlgorithm, LoadBalancer}, + CurrentContext, +}; + +/// Struct representing a Gateway-based load balancer. +/// This wraps the existing LoadBalancer functionality but +/// is constructed from Gateway API resources instead of Services. +#[derive(Debug)] +pub struct GatewayLoadBalancer { + pub gateway_name: String, + pub gateway_namespace: String, + pub lb: LoadBalancer, +} + +impl GatewayLoadBalancer { + /// Create a new `GatewayLoadBalancer` instance from a Gateway resource + /// and the current context. + /// This method extracts configuration from Gateway annotations and spec. + pub fn try_from_gateway( + gateway: &Gateway, + context: &CurrentContext, + ) -> RobotLBResult { + let annotations = gateway.metadata.annotations.as_ref(); + + // Parse health check configuration + let retries = annotations + .and_then(|a| a.get(consts::LB_RETRIES_ANN_NAME)) + .map(String::as_str) + .map(i32::from_str) + .transpose()? + .unwrap_or(context.config.default_lb_retries); + + let timeout = annotations + .and_then(|a| a.get(consts::LB_TIMEOUT_ANN_NAME)) + .map(String::as_str) + .map(i32::from_str) + .transpose()? + .unwrap_or(context.config.default_lb_timeout); + + let check_interval = annotations + .and_then(|a| a.get(consts::LB_CHECK_INTERVAL_ANN_NAME)) + .map(String::as_str) + .map(i32::from_str) + .transpose()? + .unwrap_or(context.config.default_lb_interval); + + let proxy_mode = annotations + .and_then(|a| a.get(consts::LB_PROXY_MODE_LABEL_NAME)) + .map(String::as_str) + .map(bool::from_str) + .transpose()? + .unwrap_or(context.config.default_lb_proxy_mode_enabled); + + // Parse load balancer configuration + let location = annotations + .and_then(|a| a.get(consts::LB_LOCATION_LABEL_NAME)) + .cloned() + .unwrap_or_else(|| context.config.default_lb_location.clone()); + + let balancer_type = annotations + .and_then(|a| a.get(consts::LB_BALANCER_TYPE_LABEL_NAME)) + .cloned() + .unwrap_or_else(|| context.config.default_balancer_type.clone()); + + let algorithm = annotations + .and_then(|a| a.get(consts::LB_ALGORITHM_LABEL_NAME)) + .map(String::as_str) + .or(Some(&context.config.default_lb_algorithm)) + .map(LBAlgorithm::from_str) + .transpose()? + .unwrap_or(LBAlgorithm::LeastConnections); + + // Network configuration + let network_name = annotations + .and_then(|a| a.get(consts::LB_NETWORK_LABEL_NAME)) + .or(context.config.default_network.as_ref()) + .cloned(); + + let private_ip = annotations + .and_then(|a| a.get(consts::LB_PRIVATE_IP_LABEL_NAME)) + .cloned(); + + // Gateway name becomes the load balancer name + let name = annotations + .and_then(|a| a.get(consts::LB_NAME_LABEL_NAME)) + .cloned() + .unwrap_or_else(|| gateway.name_any()); + + let gateway_name = gateway.name_any(); + let gateway_namespace = gateway + .namespace() + .ok_or(RobotLBError::SkipGateway)?; + + Ok(Self { + gateway_name: gateway_name.clone(), + gateway_namespace, + lb: LoadBalancer { + name, + private_ip, + balancer_type, + check_interval, + timeout, + retries, + location, + proxy_mode, + network_name, + algorithm: algorithm.into(), + services: HashMap::default(), + targets: Vec::default(), + hcloud_config: context.hcloud_config.clone(), + }, + }) + } + + /// Add a service (listener) to the load balancer. + /// The service will listen on the `listen_port` and forward traffic + /// to the `target_port` on all targets. + pub fn add_service(&mut self, listen_port: i32, target_port: i32) { + self.lb.add_service(listen_port, target_port); + } + + /// Add a target to the load balancer. + /// The target will receive traffic from the services. + /// The target is identified by its IP address. + pub fn add_target(&mut self, ip: &str) { + self.lb.add_target(ip); + } + + /// Reconcile the load balancer to match the desired configuration. + /// This delegates to the underlying LoadBalancer reconciliation logic. + #[tracing::instrument(skip(self), fields(gateway_name=self.gateway_name, gateway_namespace=self.gateway_namespace))] + pub async fn reconcile(&self) -> RobotLBResult { + self.lb.reconcile().await + } + + /// Cleanup the load balancer. + /// This removes all services and targets before deleting the load balancer. + pub async fn cleanup(&self) -> RobotLBResult<()> { + self.lb.cleanup().await + } +} diff --git a/src/lb.rs b/src/lb.rs index bac19f1..ee35990 100644 --- a/src/lb.rs +++ b/src/lb.rs @@ -32,7 +32,7 @@ pub struct LBService { pub target_port: i32, } -enum LBAlgorithm { +pub enum LBAlgorithm { RoundRobin, LeastConnections, } diff --git a/src/main.rs b/src/main.rs index 395fea0..96b87da 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,6 +20,11 @@ use clap::Parser; use config::OperatorConfig; use error::{RobotLBError, RobotLBResult}; use futures::StreamExt; +use gateway::GatewayLoadBalancer; +use gateway_api::apis::{ + experimental::tcproutes::TCPRoute, + standard::{gateways::Gateway, httproutes::HTTPRoute}, +}; use hcloud::apis::configuration::Configuration as HCloudConfig; use k8s_openapi::{ api::core::v1::{Node, Pod, Service}, @@ -38,8 +43,10 @@ pub mod config; pub mod consts; pub mod error; pub mod finalizers; +pub mod gateway; pub mod label_filter; pub mod lb; +pub mod routes; #[cfg(not(target_env = "msvc"))] #[global_allocator] @@ -59,26 +66,25 @@ async fn main() -> RobotLBResult<()> { tracing::info!("Starting robotlb operator v{}", env!("CARGO_PKG_VERSION")); let kube_client = kube::Client::try_default().await?; tracing::info!("Kube client is connected"); - watcher::Config::default(); + let context = Arc::new(CurrentContext::new( kube_client.clone(), operator_config.clone(), hcloud_conf, )); - tracing::info!("Starting the controller"); - Controller::new( - kube::Api::::all(kube_client), + + // Create Service controller + let service_controller = Controller::new( + kube::Api::::all(kube_client.clone()), watcher::Config::default(), ) - .run(reconcile_service, on_error, context) + .run(reconcile_service, on_service_error, context.clone()) .for_each(|reconcilation_result| async move { match reconcilation_result { Ok((service, _action)) => { - tracing::info!("Reconcilation of a service {} was successful", service.name); + tracing::info!("Reconcilation of service {} was successful", service.name); } Err(err) => match err { - // During reconcilation process, - // the controller has decided to skip the service. kube::runtime::controller::Error::ReconcilerFailed( RobotLBError::SkipService, _, @@ -88,8 +94,43 @@ async fn main() -> RobotLBResult<()> { } }, } - }) - .await; + }); + + // Create Gateway controller + let gateway_controller = Controller::new( + kube::Api::::all(kube_client.clone()), + watcher::Config::default(), + ) + .run(reconcile_gateway, on_gateway_error, context.clone()) + .for_each(|reconcilation_result| async move { + match reconcilation_result { + Ok((gateway, _action)) => { + tracing::info!("Reconcilation of gateway {} was successful", gateway.name); + } + Err(err) => match err { + kube::runtime::controller::Error::ReconcilerFailed( + RobotLBError::SkipGateway, + _, + ) => {} + _ => { + tracing::error!("Error reconciling gateway: {:#?}", err); + } + }, + } + }); + + tracing::info!("Starting Service and Gateway controllers"); + + // Run both controllers concurrently + tokio::select! { + _ = service_controller => { + tracing::error!("Service controller stopped unexpectedly"); + } + _ = gateway_controller => { + tracing::error!("Gateway controller stopped unexpectedly"); + } + } + Ok(()) } @@ -344,11 +385,243 @@ pub async fn reconcile_load_balancer( Ok(Action::requeue(Duration::from_secs(30))) } -/// Handle the error during reconcilation. +/// Reconcile the Gateway resource. +/// This function is called by the controller for each Gateway. +/// It will create or update the load balancer based on the Gateway and its Routes. +/// If the Gateway is being deleted, it will clean up the resources. +#[tracing::instrument(skip(gateway,context), fields(gateway=gateway.name_any()))] +pub async fn reconcile_gateway( + gateway: Arc, + context: Arc, +) -> RobotLBResult { + // Check if this Gateway uses our GatewayClass + let gateway_class = gateway + .spec + .gateway_class_name + .as_str(); + + if gateway_class != consts::GATEWAY_CLASS_NAME { + tracing::debug!( + "Gateway class is not {}. Skipping...", + consts::GATEWAY_CLASS_NAME + ); + return Err(RobotLBError::SkipGateway); + } + + tracing::info!("Starting gateway reconciliation"); + + let mut lb = GatewayLoadBalancer::try_from_gateway(&gateway, &context)?; + + // If the Gateway is being deleted, we need to clean up the resources. + if gateway.metadata.deletion_timestamp.is_some() { + tracing::info!("Gateway deletion detected. Cleaning up resources."); + lb.cleanup().await?; + finalizers::remove_gateway(context.client.clone(), &gateway).await?; + return Ok(Action::await_change()); + } + + // Add finalizer if it's not there yet. + if !finalizers::check_gateway(&gateway) { + finalizers::add_gateway(context.client.clone(), &gateway).await?; + } + + // Extract listeners from Gateway spec + let listeners = &gateway.spec.listeners; + + let mut listener_ports = Vec::new(); + for listener in listeners { + let port = listener.port as i32; + let protocol = listener.protocol.as_str(); + + // For now, we only support TCP and HTTP (HTTP uses TCP) + if protocol != "TCP" && protocol != "HTTP" && protocol != "HTTPS" { + tracing::warn!("Protocol {} is not supported. Skipping listener...", protocol); + continue; + } + + listener_ports.push(port); + } + + if listener_ports.is_empty() { + tracing::warn!("No supported listeners found in Gateway"); + return Err(RobotLBError::GatewayWithoutListeners); + } + + // Find all HTTPRoute and TCPRoute resources that reference this Gateway + let gateway_name = gateway.name_any(); + let gateway_namespace = gateway + .namespace() + .ok_or(RobotLBError::SkipGateway)?; + + let mut all_backend_services = Vec::new(); + + // Check HTTPRoute resources + let http_routes_api = kube::Api::::all(context.client.clone()); + let http_routes = http_routes_api.list(&ListParams::default()).await?; + + for http_route in http_routes { + match routes::extract_http_route_backends(&http_route, &gateway_name, &gateway_namespace) + { + Ok(backends) => all_backend_services.extend(backends), + Err(e) => { + tracing::warn!("Failed to extract backends from HTTPRoute: {}", e); + } + } + } + + // Check TCPRoute resources + let tcp_routes_api = kube::Api::::all(context.client.clone()); + let tcp_routes = tcp_routes_api.list(&ListParams::default()).await?; + + for tcp_route in tcp_routes { + match routes::extract_tcp_route_backends(&tcp_route, &gateway_name, &gateway_namespace) { + Ok(backends) => all_backend_services.extend(backends), + Err(e) => { + tracing::warn!("Failed to extract backends from TCPRoute: {}", e); + } + } + } + + // If we have routes with backends, use them to determine nodes + // Otherwise, we'll just provision the LB without targets (unusual but valid) + if !all_backend_services.is_empty() { + let backend_services = routes::get_backend_services(&all_backend_services, &context).await?; + + // Determine node IP type based on network configuration + let mut node_ip_type = "InternalIP"; + if lb.lb.network_name.is_none() { + node_ip_type = "ExternalIP"; + } + + // For each backend service, find the nodes where its pods are running + let mut target_nodes = Vec::new(); + + for service in backend_services { + let nodes = if context.config.dynamic_node_selector { + get_nodes_for_service(&service, Arc::clone(&context)).await? + } else { + get_nodes_by_selector_for_service(&service, Arc::clone(&context)).await? + }; + + target_nodes.extend(nodes); + } + + // Deduplicate nodes by name + target_nodes.sort_by(|a, b| a.name_any().cmp(&b.name_any())); + target_nodes.dedup_by(|a, b| a.name_any() == b.name_any()); + + // Extract IPs from nodes and add as targets + for node in target_nodes { + let Some(status) = node.status else { + continue; + }; + let Some(addresses) = status.addresses else { + continue; + }; + for addr in addresses { + if addr.type_ == node_ip_type { + lb.add_target(&addr.address); + } + } + } + + // Determine port mappings from routes and listeners + let port_mappings = routes::determine_port_mappings(&listener_ports, &all_backend_services); + + for (listen_port, target_port) in port_mappings { + lb.add_service(listen_port, target_port); + } + } else { + // No routes found, provision LB with listeners but no backends + tracing::info!("No routes found for Gateway, provisioning LB with listeners only"); + for listener_port in listener_ports { + // Use the listener port as both listen and target port + lb.add_service(listener_port, listener_port); + } + } + + // Reconcile the load balancer + let hcloud_lb = lb.reconcile().await?; + + // Update Gateway status with load balancer addresses + let gateway_api = kube::Api::::namespaced( + context.client.clone(), + gateway + .namespace() + .unwrap_or_else(|| context.client.default_namespace().to_string()) + .as_str(), + ); + + let mut addresses = vec![]; + + let ipv4 = hcloud_lb.public_net.ipv4.ip.flatten(); + let ipv6 = hcloud_lb.public_net.ipv6.ip.flatten(); + + if let Some(ipv4) = &ipv4 { + addresses.push(json!({ + "type": "IPAddress", + "value": ipv4 + })); + } + + if context.config.ipv6_ingress { + if let Some(ipv6) = &ipv6 { + addresses.push(json!({ + "type": "IPAddress", + "value": ipv6 + })); + } + } + + if !addresses.is_empty() { + gateway_api + .patch_status( + gateway.name_any().as_str(), + &PatchParams::default(), + &kube::api::Patch::Merge(json!({ + "status": { + "addresses": addresses + } + })), + ) + .await?; + } + + Ok(Action::requeue(Duration::from_secs(30))) +} + +/// Get nodes where a specific service's pods are running (dynamic mode). +async fn get_nodes_for_service( + service: &Service, + context: Arc, +) -> RobotLBResult> { + let svc_arc = Arc::new(service.clone()); + get_nodes_dynamically(&svc_arc, &context).await +} + +/// Get nodes based on service's node selector annotation (static mode). +async fn get_nodes_by_selector_for_service( + service: &Service, + context: Arc, +) -> RobotLBResult> { + let svc_arc = Arc::new(service.clone()); + get_nodes_by_selector(&svc_arc, &context).await +} + +/// Handle errors during service reconciliation. #[allow(clippy::needless_pass_by_value)] -fn on_error(_: Arc, error: &RobotLBError, _context: Arc) -> Action { +fn on_service_error(_: Arc, error: &RobotLBError, _context: Arc) -> Action { match error { RobotLBError::SkipService => Action::await_change(), _ => Action::requeue(Duration::from_secs(30)), } } + +/// Handle errors during gateway reconciliation. +#[allow(clippy::needless_pass_by_value)] +fn on_gateway_error(_: Arc, error: &RobotLBError, _context: Arc) -> Action { + match error { + RobotLBError::SkipGateway => Action::await_change(), + _ => Action::requeue(Duration::from_secs(30)), + } +} diff --git a/src/routes.rs b/src/routes.rs new file mode 100644 index 0000000..f2bf5bd --- /dev/null +++ b/src/routes.rs @@ -0,0 +1,212 @@ +use gateway_api::apis::{ + experimental::tcproutes::TCPRoute, + standard::httproutes::HTTPRoute, +}; +use k8s_openapi::api::core::v1::Service; +use kube::{Api, ResourceExt}; +use std::collections::HashMap; + +use crate::{ + error::{RobotLBError, RobotLBResult}, + CurrentContext, +}; + +/// Information about a backend service extracted from a route. +#[derive(Debug, Clone)] +pub struct BackendInfo { + pub service_name: String, + pub service_namespace: String, + pub port: Option, +} + +/// Information extracted from routes for a specific Gateway. +#[derive(Debug)] +pub struct RouteInfo { + pub backend_services: Vec, + pub port_mappings: HashMap, // listener port -> backend port +} + +/// Extract backend services from an HTTPRoute resource. +/// Returns a list of BackendInfo containing service names, namespaces, and ports. +pub fn extract_http_route_backends( + route: &HTTPRoute, + gateway_name: &str, + gateway_namespace: &str, +) -> RobotLBResult> { + let route_namespace = route + .namespace() + .unwrap_or_else(|| gateway_namespace.to_string()); + + // Check if this route references our Gateway + let parent_refs = route + .spec + .parent_refs + .as_ref() + .ok_or(RobotLBError::RouteWithoutParentRefs)?; + + let references_our_gateway = parent_refs.iter().any(|parent| { + let parent_name = parent.name.as_str(); + let parent_namespace = parent + .namespace + .as_ref() + .map(String::as_str) + .unwrap_or(&route_namespace); + + parent_name == gateway_name && parent_namespace == gateway_namespace + }); + + if !references_our_gateway { + return Ok(vec![]); + } + + let mut backends = Vec::new(); + + // Extract backends from all rules + if let Some(rules) = &route.spec.rules { + for rule in rules { + if let Some(backend_refs) = &rule.backend_refs { + for backend_ref in backend_refs { + let backend_name = backend_ref.name.clone(); + let backend_namespace = backend_ref + .namespace + .as_ref() + .map(String::as_str) + .unwrap_or(&route_namespace) + .to_string(); + + let port = backend_ref.port.map(|p| p as i32); + + backends.push(BackendInfo { + service_name: backend_name, + service_namespace: backend_namespace, + port, + }); + } + } + } + } + + Ok(backends) +} + +/// Extract backend services from a TCPRoute resource. +/// Returns a list of BackendInfo containing service names, namespaces, and ports. +pub fn extract_tcp_route_backends( + route: &TCPRoute, + gateway_name: &str, + gateway_namespace: &str, +) -> RobotLBResult> { + let route_namespace = route + .namespace() + .unwrap_or_else(|| gateway_namespace.to_string()); + + // Check if this route references our Gateway + let parent_refs = route + .spec + .parent_refs + .as_ref() + .ok_or(RobotLBError::RouteWithoutParentRefs)?; + + let references_our_gateway = parent_refs.iter().any(|parent| { + let parent_name = parent.name.as_str(); + let parent_namespace = parent + .namespace + .as_ref() + .map(String::as_str) + .unwrap_or(&route_namespace); + + parent_name == gateway_name && parent_namespace == gateway_namespace + }); + + if !references_our_gateway { + return Ok(vec![]); + } + + let mut backends = Vec::new(); + + // Extract backends from all rules + for rule in &route.spec.rules { + for backend_ref in &rule.backend_refs { + let backend_name = backend_ref.name.clone(); + let backend_namespace = backend_ref + .namespace + .as_ref() + .map(String::as_str) + .unwrap_or(&route_namespace) + .to_string(); + + let port = backend_ref.port.map(|p| p as i32); + + backends.push(BackendInfo { + service_name: backend_name, + service_namespace: backend_namespace, + port, + }); + } + } + + Ok(backends) +} + +/// Get all Services referenced by route backends. +/// This fetches the actual Service resources from Kubernetes. +pub async fn get_backend_services( + backends: &[BackendInfo], + context: &CurrentContext, +) -> RobotLBResult> { + let mut services = Vec::new(); + let mut seen_services = Vec::new(); + + for backend in backends { + let service_key = format!("{}/{}", backend.service_namespace, backend.service_name); + + // Skip if we've already fetched this service + if seen_services.contains(&service_key) { + continue; + } + seen_services.push(service_key); + + let service_api = Api::::namespaced( + context.client.clone(), + &backend.service_namespace, + ); + + match service_api.get(&backend.service_name).await { + Ok(service) => { + services.push(service); + } + Err(e) => { + tracing::warn!( + "Failed to get backend service {}/{}: {}", + backend.service_namespace, + backend.service_name, + e + ); + } + } + } + + Ok(services) +} + +/// Determine port mappings for the load balancer based on Gateway listeners and routes. +/// Returns a HashMap of listener_port -> target_port mappings. +pub fn determine_port_mappings( + listener_ports: &[i32], + backends: &[BackendInfo], +) -> HashMap { + let mut port_mappings = HashMap::new(); + + // For each listener port, try to find a matching backend port + for listener_port in listener_ports { + // Try to find a backend that specifies a port + let backend_port = backends + .iter() + .find_map(|b| b.port) + .unwrap_or(*listener_port); + + port_mappings.insert(*listener_port, backend_port); + } + + port_mappings +} diff --git a/tutorial.md b/tutorial.md index 8ddc550..32a670a 100644 --- a/tutorial.md +++ b/tutorial.md @@ -488,3 +488,116 @@ helm install ingress-nginx \ ``` Once the nginx is deployed, verify that the load-balancer is created on Hetzner cloud console. And check that external-ip for the service of type LoadBalancer is an actual public IP of a cloud loadbalancer. + +### Alternative: Using Gateway API + +As an alternative to the traditional Ingress controller approach, you can use the Kubernetes Gateway API which provides more advanced routing capabilities and a cleaner separation of concerns. + +#### Install Gateway API CRDs + +First, install the Gateway API CRDs: + +```bash +# For standard features (Gateway, HTTPRoute) +kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.0/standard-install.yaml + +# For experimental features (TCPRoute, UDPRoute) +kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.0/experimental-install.yaml +``` + +#### Create a Gateway + +Create a Gateway resource that will provision the Hetzner load balancer: + +```yaml +# gateway.yaml +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: robotlb-gateway + namespace: default + annotations: + robotlb/lb-network: "" + robotlb/balancer-type: "lb11" + robotlb/lb-algorithm: "least-connections" +spec: + gatewayClassName: robotlb + listeners: + - name: http + protocol: HTTP + port: 80 + - name: https + protocol: HTTPS + port: 443 +``` + +Apply the Gateway: + +```bash +kubectl apply -f gateway.yaml +``` + +RobotLB will provision a Hetzner Cloud load balancer and update the Gateway status with the load balancer's IP address. + +#### Create HTTPRoutes for your applications + +Now you can create HTTPRoute resources to route traffic to your services: + +```yaml +# app-route.yaml +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: my-app-route + namespace: default +spec: + parentRefs: + - name: robotlb-gateway + hostnames: + - "myapp.example.com" + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: my-app-service + port: 8080 +``` + +Apply the route: + +```bash +kubectl apply -f app-route.yaml +``` + +#### Benefits of Gateway API over Ingress + +- **Advanced Routing**: Header-based routing, traffic splitting, redirects, and more +- **Multiple Protocols**: Support for HTTP, HTTPS, TCP, gRPC, and UDP +- **Role-Oriented**: Infrastructure team manages Gateways, app teams manage Routes +- **Type-Safe API**: Rich, strongly-typed API without annotation sprawl +- **Future-Proof**: The future of Kubernetes traffic management + +#### Verify the setup + +Check that the Gateway has been provisioned: + +```bash +kubectl get gateway robotlb-gateway +``` + +You should see the Gateway with an IP address in the status: + +``` +NAME CLASS ADDRESS READY AGE +robotlb-gateway robotlb 1.2.3.4 True 2m +``` + +Check that your HTTPRoute is accepted: + +```bash +kubectl get httproute my-app-route +``` + +The load balancer will automatically discover the backend pods and configure the appropriate targets based on where your application pods are running. From b71e004f5f25ce4fce598505d10776c2070f418b Mon Sep 17 00:00:00 2001 From: Dev Jadeja Date: Thu, 20 Nov 2025 17:21:00 +0530 Subject: [PATCH 2/2] fix: formatting --- src/finalizers.rs | 10 ++++++++-- src/gateway.rs | 9 ++------- src/main.rs | 32 +++++++++++++++++++------------- src/routes.rs | 11 +++-------- 4 files changed, 32 insertions(+), 30 deletions(-) diff --git a/src/finalizers.rs b/src/finalizers.rs index 9ab0eae..0f911f9 100644 --- a/src/finalizers.rs +++ b/src/finalizers.rs @@ -78,7 +78,10 @@ pub async fn remove(client: Client, svc: &Service) -> RobotLBResult<()> { pub async fn add_gateway(client: Client, gateway: &Gateway) -> RobotLBResult<()> { let api = Api::::namespaced( client, - gateway.namespace().ok_or(RobotLBError::SkipGateway)?.as_str(), + gateway + .namespace() + .ok_or(RobotLBError::SkipGateway)? + .as_str(), ); let patch = json!({ "metadata": { @@ -113,7 +116,10 @@ pub fn check_gateway(gateway: &Gateway) -> bool { pub async fn remove_gateway(client: Client, gateway: &Gateway) -> RobotLBResult<()> { let api = Api::::namespaced( client, - gateway.namespace().ok_or(RobotLBError::SkipGateway)?.as_str(), + gateway + .namespace() + .ok_or(RobotLBError::SkipGateway)? + .as_str(), ); let finalizers = gateway .finalizers() diff --git a/src/gateway.rs b/src/gateway.rs index 326b94d..ee4b096 100644 --- a/src/gateway.rs +++ b/src/gateway.rs @@ -23,10 +23,7 @@ impl GatewayLoadBalancer { /// Create a new `GatewayLoadBalancer` instance from a Gateway resource /// and the current context. /// This method extracts configuration from Gateway annotations and spec. - pub fn try_from_gateway( - gateway: &Gateway, - context: &CurrentContext, - ) -> RobotLBResult { + pub fn try_from_gateway(gateway: &Gateway, context: &CurrentContext) -> RobotLBResult { let annotations = gateway.metadata.annotations.as_ref(); // Parse health check configuration @@ -94,9 +91,7 @@ impl GatewayLoadBalancer { .unwrap_or_else(|| gateway.name_any()); let gateway_name = gateway.name_any(); - let gateway_namespace = gateway - .namespace() - .ok_or(RobotLBError::SkipGateway)?; + let gateway_namespace = gateway.namespace().ok_or(RobotLBError::SkipGateway)?; Ok(Self { gateway_name: gateway_name.clone(), diff --git a/src/main.rs b/src/main.rs index 96b87da..9dfe050 100644 --- a/src/main.rs +++ b/src/main.rs @@ -395,10 +395,7 @@ pub async fn reconcile_gateway( context: Arc, ) -> RobotLBResult { // Check if this Gateway uses our GatewayClass - let gateway_class = gateway - .spec - .gateway_class_name - .as_str(); + let gateway_class = gateway.spec.gateway_class_name.as_str(); if gateway_class != consts::GATEWAY_CLASS_NAME { tracing::debug!( @@ -435,7 +432,10 @@ pub async fn reconcile_gateway( // For now, we only support TCP and HTTP (HTTP uses TCP) if protocol != "TCP" && protocol != "HTTP" && protocol != "HTTPS" { - tracing::warn!("Protocol {} is not supported. Skipping listener...", protocol); + tracing::warn!( + "Protocol {} is not supported. Skipping listener...", + protocol + ); continue; } @@ -449,9 +449,7 @@ pub async fn reconcile_gateway( // Find all HTTPRoute and TCPRoute resources that reference this Gateway let gateway_name = gateway.name_any(); - let gateway_namespace = gateway - .namespace() - .ok_or(RobotLBError::SkipGateway)?; + let gateway_namespace = gateway.namespace().ok_or(RobotLBError::SkipGateway)?; let mut all_backend_services = Vec::new(); @@ -460,8 +458,7 @@ pub async fn reconcile_gateway( let http_routes = http_routes_api.list(&ListParams::default()).await?; for http_route in http_routes { - match routes::extract_http_route_backends(&http_route, &gateway_name, &gateway_namespace) - { + match routes::extract_http_route_backends(&http_route, &gateway_name, &gateway_namespace) { Ok(backends) => all_backend_services.extend(backends), Err(e) => { tracing::warn!("Failed to extract backends from HTTPRoute: {}", e); @@ -485,7 +482,8 @@ pub async fn reconcile_gateway( // If we have routes with backends, use them to determine nodes // Otherwise, we'll just provision the LB without targets (unusual but valid) if !all_backend_services.is_empty() { - let backend_services = routes::get_backend_services(&all_backend_services, &context).await?; + let backend_services = + routes::get_backend_services(&all_backend_services, &context).await?; // Determine node IP type based on network configuration let mut node_ip_type = "InternalIP"; @@ -610,7 +608,11 @@ async fn get_nodes_by_selector_for_service( /// Handle errors during service reconciliation. #[allow(clippy::needless_pass_by_value)] -fn on_service_error(_: Arc, error: &RobotLBError, _context: Arc) -> Action { +fn on_service_error( + _: Arc, + error: &RobotLBError, + _context: Arc, +) -> Action { match error { RobotLBError::SkipService => Action::await_change(), _ => Action::requeue(Duration::from_secs(30)), @@ -619,7 +621,11 @@ fn on_service_error(_: Arc, error: &RobotLBError, _context: Arc, error: &RobotLBError, _context: Arc) -> Action { +fn on_gateway_error( + _: Arc, + error: &RobotLBError, + _context: Arc, +) -> Action { match error { RobotLBError::SkipGateway => Action::await_change(), _ => Action::requeue(Duration::from_secs(30)), diff --git a/src/routes.rs b/src/routes.rs index f2bf5bd..86da91f 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -1,7 +1,4 @@ -use gateway_api::apis::{ - experimental::tcproutes::TCPRoute, - standard::httproutes::HTTPRoute, -}; +use gateway_api::apis::{experimental::tcproutes::TCPRoute, standard::httproutes::HTTPRoute}; use k8s_openapi::api::core::v1::Service; use kube::{Api, ResourceExt}; use std::collections::HashMap; @@ -166,10 +163,8 @@ pub async fn get_backend_services( } seen_services.push(service_key); - let service_api = Api::::namespaced( - context.client.clone(), - &backend.service_namespace, - ); + let service_api = + Api::::namespaced(context.client.clone(), &backend.service_namespace); match service_api.get(&backend.service_name).await { Ok(service) => {