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