Skip to main content

rust_web_server/service_discovery/
mod.rs

1//! Dynamic backend pool with pluggable discovery sources.
2//!
3//! [`BackendPool`] maintains a thread-safe list of `"host:port"` addresses that
4//! can be refreshed on a background thread.  Discovery is delegated to a
5//! [`DiscoverySource`]:
6//!
7//! | Variant      | Description                                               |
8//! |------------- |---------------------------------------------------------- |
9//! | `Static`     | Fixed list — no polling required.                         |
10//! | `EnvPrefix`  | Scan `PREFIX_0`, `PREFIX_1`, … environment variables.     |
11//! | `File`       | Read one `host:port` per line from a file.                |
12//! | `Dns`        | A-record lookup — resolve hostname to all IPs.            |
13//! | `DnsSrv`     | SRV record lookup — weight-expanded `host:port` list.     |
14//! | `Consul`     | Consul HTTP API `/v1/health/service/:name`.                |
15//! | `Docker`     | Docker Engine API — containers carrying a given label.    |
16//! | `EtcdWatch`  | etcd v3 watch stream — incremental, low-latency updates.   |
17//!
18//! # Example
19//!
20//! ```rust,no_run
21//! use rust_web_server::service_discovery::BackendPool;
22//!
23//! // Fixed list — no background thread needed.
24//! let pool = BackendPool::r#static(vec!["10.0.0.1:8080".into(), "10.0.0.2:8080".into()]);
25//! println!("{:?}", pool.backends());
26//!
27//! // Env-var discovery, refreshed every 60 seconds.
28//! let pool = BackendPool::env_prefix("MY_SVC_BACKEND")
29//!     .poll_interval_secs(60);
30//! pool.start();
31//! println!("{:?}", pool.backends());
32//! ```
33
34#[cfg(all(test, not(target_arch = "wasm32")))]
35mod tests;
36// `pub(crate)` (not just `mod`) so `secrets`'s Vault/AWS-SM/Key-Vault backends
37// can reuse this parser for their own small JSON responses, instead of a
38// third hand-rolled JSON parser alongside this one and `mcp::json_rpc`.
39// Pure parsing, no sockets — stays available on every target, unlike the
40// rest of this module (DNS/etcd/Consul/Docker all need real network access
41// no wasi:http guest has). See spec/WASM_SHIM.md.
42pub(crate) mod json_lite;
43
44#[cfg(not(target_arch = "wasm32"))]
45mod consul;
46#[cfg(not(target_arch = "wasm32"))]
47pub(crate) mod dns_srv;
48#[cfg(not(target_arch = "wasm32"))]
49mod docker;
50#[cfg(not(target_arch = "wasm32"))]
51mod etcd;
52
53#[cfg(not(target_arch = "wasm32"))]
54use std::net::ToSocketAddrs;
55#[cfg(not(target_arch = "wasm32"))]
56use std::sync::{Arc, RwLock};
57#[cfg(not(target_arch = "wasm32"))]
58use std::time::Duration;
59
60// ── DiscoverySource ───────────────────────────────────────────────────────────
61
62/// Controls how [`BackendPool`] discovers backend addresses.
63#[cfg(not(target_arch = "wasm32"))]
64pub enum DiscoverySource {
65    /// Fixed list of `"host:port"` addresses — never refreshed.
66    Static(Vec<String>),
67    /// Scan environment variables `PREFIX_0`, `PREFIX_1`, … until one is absent.
68    EnvPrefix(String),
69    /// Read one `host:port` per line from a file.  Blank lines and lines starting
70    /// with `#` are ignored.
71    File(String),
72    /// Resolve `hostname` via A-record DNS lookup; format each IP as `ip:port`.
73    Dns { hostname: String, port: u16 },
74    /// Resolve `record` (e.g. `_http._tcp.example.com`) via SRV lookup. Only the
75    /// lowest-priority tier of records is used; within that tier each `target:port`
76    /// is repeated `weight.clamp(1, 20)` times in the returned list so a plain
77    /// round-robin consumer still sees proportional selection frequency — see
78    /// [`dns_srv`] module docs for the exact algorithm and its bound.
79    DnsSrv { record: String },
80    /// Query a Consul agent's `/v1/health/service/:name` endpoint. `addr` is
81    /// `host:port` of the agent (e.g. `127.0.0.1:8500`); only instances passing
82    /// all health checks are returned.
83    Consul { addr: String, service: String },
84    /// Query the Docker Engine API (over a Unix domain socket) for running
85    /// containers carrying `label`, using each container's *value* for that
86    /// label as the backend address directly (e.g. `rws.backend=10.0.0.5:8080`)
87    /// — see [`docker`] module docs for why the label value, not a published
88    /// port guess, is the address. Unix-only; a no-op elsewhere.
89    Docker { label: String, socket_path: String },
90    /// Watch an etcd v3 key prefix via its gRPC-gateway JSON/HTTP `/v3/watch`
91    /// endpoint. Unlike every other source, this is *not* driven by the
92    /// generic poll loop — [`BackendPool::start`] spawns a dedicated
93    /// long-lived connection instead, applying `PUT`/`DELETE` events
94    /// incrementally as they arrive rather than re-listing on each one.
95    /// `resolve()` (and therefore plain `refresh()`) still performs a one-shot
96    /// `/v3/kv/range` listing, so a pool built with this source is usable
97    /// without ever calling `start()`, just without live updates. Plain HTTP
98    /// only — no TLS support yet.
99    EtcdWatch { endpoints: Vec<String>, prefix: String },
100}
101
102#[cfg(not(target_arch = "wasm32"))]
103impl DiscoverySource {
104    /// Perform a single discovery cycle and return the current backend list.
105    fn resolve(&self) -> Vec<String> {
106        match self {
107            DiscoverySource::Static(v) => v.clone(),
108
109            DiscoverySource::EnvPrefix(prefix) => {
110                let mut backends = Vec::new();
111                let mut i = 0usize;
112                loop {
113                    let key = format!("{}_{}", prefix, i);
114                    match std::env::var(&key) {
115                        Ok(val) => { backends.push(val); i += 1; }
116                        Err(_) => break,
117                    }
118                }
119                backends
120            }
121
122            DiscoverySource::File(path) => {
123                match std::fs::read_to_string(path) {
124                    Ok(contents) => contents
125                        .lines()
126                        .map(str::trim)
127                        .filter(|line| !line.is_empty() && !line.starts_with('#'))
128                        .map(str::to_string)
129                        .collect(),
130                    Err(e) => {
131                        eprintln!("service_discovery: cannot read backend file {:?}: {}", path, e);
132                        Vec::new()
133                    }
134                }
135            }
136
137            DiscoverySource::Dns { hostname, port } => {
138                let addr_str = format!("{}:{}", hostname, port);
139                match addr_str.to_socket_addrs() {
140                    Ok(addrs) => addrs
141                        .map(|sa| format!("{}:{}", sa.ip(), sa.port()))
142                        .collect(),
143                    Err(e) => {
144                        eprintln!("service_discovery: DNS lookup for {} failed: {}", addr_str, e);
145                        Vec::new()
146                    }
147                }
148            }
149
150            DiscoverySource::DnsSrv { record } => dns_srv::resolve(record),
151
152            DiscoverySource::Consul { addr, service } => consul::discover(addr, service),
153
154            DiscoverySource::Docker { label, socket_path } => docker::discover(socket_path, label),
155
156            DiscoverySource::EtcdWatch { endpoints, prefix } => {
157                let Some(endpoint) = endpoints.first() else { return Vec::new() };
158                match etcd::kv_range(endpoint, prefix) {
159                    Ok(map) => map.into_values().collect(),
160                    Err(e) => {
161                        eprintln!("service_discovery: etcd range query failed: {}", e);
162                        Vec::new()
163                    }
164                }
165            }
166        }
167    }
168}
169
170// ── BackendPool ───────────────────────────────────────────────────────────────
171
172/// Thread-safe pool of backend addresses, optionally refreshed in the background.
173///
174/// Clone this type freely — all clones share the same underlying `RwLock<Vec>`.
175#[cfg(not(target_arch = "wasm32"))]
176#[derive(Clone)]
177pub struct BackendPool {
178    backends: Arc<RwLock<Vec<String>>>,
179    source: Arc<DiscoverySource>,
180    poll_interval_secs: u64,
181}
182
183#[cfg(not(target_arch = "wasm32"))]
184impl BackendPool {
185    fn new(source: DiscoverySource) -> Self {
186        Self {
187            backends: Arc::new(RwLock::new(Vec::new())),
188            source: Arc::new(source),
189            poll_interval_secs: 30,
190        }
191    }
192
193    /// Create a pool from a fixed list of backends.
194    ///
195    /// The list is available immediately; `start()` is a no-op.
196    pub fn r#static(backends: Vec<String>) -> Self {
197        let initial = backends.clone();
198        let pool = Self::new(DiscoverySource::Static(backends));
199        *pool.backends.write().unwrap() = initial;
200        pool
201    }
202
203    /// Create a pool whose backends are read from environment variables
204    /// `PREFIX_0`, `PREFIX_1`, … at startup and every `poll_interval_secs`.
205    pub fn env_prefix(prefix: impl Into<String>) -> Self {
206        Self::new(DiscoverySource::EnvPrefix(prefix.into()))
207    }
208
209    /// Create a pool whose backends are read from a file (one `host:port` per line).
210    pub fn file(path: impl Into<String>) -> Self {
211        Self::new(DiscoverySource::File(path.into()))
212    }
213
214    /// Create a pool whose backends are discovered via DNS A-record lookup.
215    pub fn dns(hostname: impl Into<String>, port: u16) -> Self {
216        Self::new(DiscoverySource::Dns { hostname: hostname.into(), port })
217    }
218
219    /// Create a pool whose backends are discovered via DNS SRV lookup (e.g.
220    /// `_http._tcp.my-service.default.svc.cluster.local` for a Kubernetes
221    /// headless Service). See [`DiscoverySource::DnsSrv`] for the
222    /// priority/weight handling.
223    pub fn dns_srv(record: impl Into<String>) -> Self {
224        Self::new(DiscoverySource::DnsSrv { record: record.into() })
225    }
226
227    /// Create a pool whose backends are discovered via a Consul agent's
228    /// `/v1/health/service/:name` endpoint. `addr` is `host:port` of the
229    /// agent, e.g. `"127.0.0.1:8500"`. Only instances passing all health
230    /// checks are returned.
231    pub fn consul(addr: impl Into<String>, service: impl Into<String>) -> Self {
232        Self::new(DiscoverySource::Consul { addr: addr.into(), service: service.into() })
233    }
234
235    /// Create a pool whose backends are discovered from running Docker
236    /// containers carrying `label`, using the default socket path
237    /// (`/var/run/docker.sock`). See [`DiscoverySource::Docker`] for the
238    /// label-value-is-the-address convention. Unix-only; a no-op elsewhere.
239    pub fn docker(label: impl Into<String>) -> Self {
240        Self::new(DiscoverySource::Docker {
241            label: label.into(),
242            socket_path: "/var/run/docker.sock".to_string(),
243        })
244    }
245
246    /// Same as [`BackendPool::docker`] but against a non-default Docker
247    /// socket path.
248    pub fn docker_with_socket(label: impl Into<String>, socket_path: impl Into<String>) -> Self {
249        Self::new(DiscoverySource::Docker { label: label.into(), socket_path: socket_path.into() })
250    }
251
252    /// Create a pool whose backends are discovered from an etcd v3 key
253    /// `prefix`, kept live via a watch stream once [`BackendPool::start`] is
254    /// called. `endpoints` is a list of `host:port` etcd cluster members —
255    /// only the first is used today (no client-side failover yet). Plain
256    /// HTTP only. See [`DiscoverySource::EtcdWatch`].
257    pub fn etcd(endpoints: Vec<String>, prefix: impl Into<String>) -> Self {
258        Self::new(DiscoverySource::EtcdWatch { endpoints, prefix: prefix.into() })
259    }
260
261    /// Override the background refresh interval (default: 30 seconds).
262    ///
263    /// Only meaningful for `File` and `Dns` sources.
264    pub fn poll_interval_secs(mut self, secs: u64) -> Self {
265        self.poll_interval_secs = secs;
266        self
267    }
268
269    /// Start the background refresh thread.
270    ///
271    /// For `Static` sources this is a no-op. For all others, an immediate
272    /// `refresh()` is performed before spawning the background thread so that
273    /// the first `backends()` call returns a populated list.
274    ///
275    /// `EtcdWatch` is special-cased: instead of the generic sleep-and-poll
276    /// loop, a dedicated thread holds a long-lived connection to etcd's watch
277    /// stream and applies `PUT`/`DELETE` events to the backend list as they
278    /// arrive — no polling interval involved after the initial `refresh()`.
279    pub fn start(&self) {
280        if matches!(self.source.as_ref(), DiscoverySource::Static(_)) {
281            return;
282        }
283        self.refresh();
284
285        if let DiscoverySource::EtcdWatch { endpoints, prefix } = self.source.as_ref() {
286            let endpoints = endpoints.clone();
287            let prefix = prefix.clone();
288            let pool = self.clone();
289            std::thread::spawn(move || etcd::watch_forever(&endpoints, &prefix, &pool));
290            return;
291        }
292
293        let pool = self.clone();
294        let interval = Duration::from_secs(self.poll_interval_secs);
295        std::thread::spawn(move || loop {
296            std::thread::sleep(interval);
297            pool.refresh();
298        });
299    }
300
301    /// Return a snapshot of the current backend list.
302    pub fn backends(&self) -> Vec<String> {
303        self.backends.read().unwrap().clone()
304    }
305
306    /// Replace the current backend list with `backends`.
307    ///
308    /// Useful for testing or for external control planes that push updates.
309    pub fn update(&self, backends: Vec<String>) {
310        *self.backends.write().unwrap() = backends;
311    }
312
313    /// Perform one synchronous refresh cycle.
314    ///
315    /// Called automatically by `start()` and by the background thread.
316    pub fn refresh(&self) {
317        let resolved = self.source.resolve();
318        *self.backends.write().unwrap() = resolved;
319    }
320}