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}