Skip to main content

brain_edge/config/
mod.rs

1//! Deploy-time configuration, read from the environment.
2
3use std::net::{SocketAddr, ToSocketAddrs};
4
5/// brain-edge configuration.
6#[derive(Clone, Debug)]
7pub struct EdgeConfig {
8    /// Address to serve the HTTP API on.
9    pub listen_addr: SocketAddr,
10    /// Address of the Brain database to proxy to.
11    pub brain_addr: SocketAddr,
12    /// Per-credential connection-pool width to Brain.
13    pub pool_size: usize,
14    /// Hard cap on distinct cached credentials (LRU-evicted over this).
15    pub max_credentials: usize,
16    /// Seconds of idle before a cached credential's pool is swept.
17    pub idle_ttl_secs: u64,
18    /// Per-request wall-clock timeout, in seconds. A request that outlives this
19    /// (e.g. a stalled Brain connection) is cut with `408 Request Timeout`
20    /// rather than hanging a worker indefinitely.
21    pub request_timeout_secs: u64,
22    /// Hard cap on request body size, in bytes. Bodies over this are rejected
23    /// with `413 Payload Too Large` before the handler runs.
24    pub max_body_bytes: usize,
25    /// Optional address for the transparent wire-protocol proxy listener. When
26    /// `Some`, the edge serves the Brain binary wire protocol here (in addition
27    /// to the HTTP data plane) — a customer SDK points at this address with its
28    /// own Brain key and the edge splices frames to Brain untouched. `None`
29    /// (the default) leaves the proxy off; the edge is HTTP-only.
30    pub wire_listen_addr: Option<SocketAddr>,
31    /// Wire-proxy per-credential rate-limit burst ceiling. `0` (the default)
32    /// disables rate limiting on the wire path.
33    pub wire_rate_capacity: u32,
34    /// Wire-proxy per-credential sustained rate, in ops per second, refilled into
35    /// the burst bucket. Ignored when `wire_rate_capacity` is `0`.
36    pub wire_rate_refill_per_sec: u32,
37}
38
39/// Parse an env var as `T`, or fail loudly. Unlike a silent `unwrap_or(default)`,
40/// a typo'd value (`BRAIN_EDGE_POOL_SIZE=four`) is a hard startup error rather
41/// than a silent fall-back to the default — a value that was set was meant.
42fn parse_env<T>(key: &str, default: T) -> Result<T, String>
43where
44    T: std::str::FromStr,
45    T::Err: std::fmt::Display,
46{
47    match std::env::var(key) {
48        Err(_) => Ok(default),
49        Ok(raw) => raw
50            .trim()
51            .parse::<T>()
52            .map_err(|e| format!("{key} `{raw}`: {e}")),
53    }
54}
55
56/// Resolve a `host:port` string to a `SocketAddr`, accepting both a literal
57/// `ip:port` and a DNS name (`brain:9090`) so the edge works unchanged under
58/// docker-compose / Kubernetes service DNS, not only with hard-coded IPs.
59/// Resolution happens once at startup; a Service name resolves to its stable
60/// cluster IP, so a single boot-time lookup is sufficient.
61fn resolve_addr(key: &str, raw: &str) -> Result<SocketAddr, String> {
62    if let Ok(addr) = raw.parse::<SocketAddr>() {
63        return Ok(addr);
64    }
65    raw.to_socket_addrs()
66        .map_err(|e| format!("{key} `{raw}`: {e}"))?
67        .next()
68        .ok_or_else(|| format!("{key} `{raw}`: resolved to no addresses"))
69}
70
71impl EdgeConfig {
72    /// Load from the environment with sane self-host defaults.
73    ///
74    /// - `BRAIN_EDGE_LISTEN`          — HTTP listen addr (default `0.0.0.0:8080`).
75    /// - `BRAIN_ADDR`                 — Brain address, `ip:port` or `host:port`
76    ///   (default `127.0.0.1:7878`).
77    /// - `BRAIN_EDGE_POOL_SIZE`       — pool width per credential (default `4`).
78    /// - `BRAIN_EDGE_MAX_CREDENTIALS` — cap on cached credentials (default `256`).
79    /// - `BRAIN_EDGE_IDLE_TTL_SECS`   — idle-sweep TTL in seconds (default `900`).
80    /// - `BRAIN_EDGE_REQUEST_TIMEOUT_SECS` — per-request timeout (default `30`).
81    /// - `BRAIN_EDGE_MAX_BODY_BYTES`  — request body cap (default `1048576` = 1 MiB).
82    /// - `BRAIN_EDGE_WIRE_LISTEN`     — wire-proxy listen addr, `ip:port` or
83    ///   `host:port` (unset = wire proxy off).
84    /// - `BRAIN_EDGE_WIRE_RATE_CAPACITY`      — per-credential burst (default `0` = off).
85    /// - `BRAIN_EDGE_WIRE_RATE_REFILL_PER_SEC`— per-credential ops/sec refill (default `0`).
86    ///
87    /// # Errors
88    /// Returns a message if an address fails to parse/resolve, a numeric var is
89    /// malformed, or a required-positive value is zero.
90    pub fn from_env() -> Result<Self, String> {
91        let listen = std::env::var("BRAIN_EDGE_LISTEN").unwrap_or_else(|_| "0.0.0.0:8080".into());
92        let brain = std::env::var("BRAIN_ADDR").unwrap_or_else(|_| "127.0.0.1:7878".into());
93
94        let pool_size = parse_env::<usize>("BRAIN_EDGE_POOL_SIZE", 4)?;
95        let max_credentials = parse_env::<usize>("BRAIN_EDGE_MAX_CREDENTIALS", 256)?;
96        let idle_ttl_secs = parse_env::<u64>("BRAIN_EDGE_IDLE_TTL_SECS", 900)?;
97        let request_timeout_secs = parse_env::<u64>("BRAIN_EDGE_REQUEST_TIMEOUT_SECS", 30)?;
98        let max_body_bytes = parse_env::<usize>("BRAIN_EDGE_MAX_BODY_BYTES", 1024 * 1024)?;
99
100        // The wire proxy is opt-in: absent env var → HTTP-only, unchanged.
101        let wire_listen_addr = match std::env::var("BRAIN_EDGE_WIRE_LISTEN") {
102            Err(_) => None,
103            Ok(raw) => Some(resolve_addr("BRAIN_EDGE_WIRE_LISTEN", &raw)?),
104        };
105        let wire_rate_capacity = parse_env::<u32>("BRAIN_EDGE_WIRE_RATE_CAPACITY", 0)?;
106        let wire_rate_refill_per_sec = parse_env::<u32>("BRAIN_EDGE_WIRE_RATE_REFILL_PER_SEC", 0)?;
107
108        if pool_size == 0 {
109            return Err("BRAIN_EDGE_POOL_SIZE must be >= 1".into());
110        }
111        if max_credentials == 0 {
112            return Err("BRAIN_EDGE_MAX_CREDENTIALS must be >= 1".into());
113        }
114        if request_timeout_secs == 0 {
115            return Err("BRAIN_EDGE_REQUEST_TIMEOUT_SECS must be >= 1".into());
116        }
117        if max_body_bytes == 0 {
118            return Err("BRAIN_EDGE_MAX_BODY_BYTES must be >= 1".into());
119        }
120
121        Ok(Self {
122            listen_addr: listen
123                .parse()
124                .map_err(|e| format!("BRAIN_EDGE_LISTEN `{listen}`: {e}"))?,
125            brain_addr: resolve_addr("BRAIN_ADDR", &brain)?,
126            pool_size,
127            max_credentials,
128            idle_ttl_secs,
129            request_timeout_secs,
130            max_body_bytes,
131            wire_listen_addr,
132            wire_rate_capacity,
133            wire_rate_refill_per_sec,
134        })
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn resolve_addr_accepts_literal_ip_port() {
144        let addr = resolve_addr("BRAIN_ADDR", "127.0.0.1:7878").expect("literal ip:port");
145        assert_eq!(addr.port(), 7878);
146        assert!(addr.ip().is_loopback());
147    }
148
149    #[test]
150    fn resolve_addr_resolves_a_hostname() {
151        // `localhost` resolves offline to a loopback address; this proves a
152        // DNS name (not just a literal IP) is accepted — the docker/k8s case.
153        let addr = resolve_addr("BRAIN_ADDR", "localhost:9090").expect("hostname:port");
154        assert_eq!(addr.port(), 9090);
155        assert!(addr.ip().is_loopback());
156    }
157
158    #[test]
159    fn resolve_addr_rejects_garbage() {
160        assert!(resolve_addr("BRAIN_ADDR", "not-an-address").is_err());
161        assert!(resolve_addr("BRAIN_ADDR", "").is_err());
162    }
163
164    #[test]
165    fn parse_env_uses_default_when_unset() {
166        // A key we never set falls back to the default rather than erroring.
167        let v = parse_env::<usize>("BRAIN_EDGE_TEST_DEFINITELY_UNSET_KEY", 42).expect("default");
168        assert_eq!(v, 42);
169    }
170}