Skip to main content

brain_edge/
lib.rs

1//! brain-edge — the HTTP/JSON edge for the Brain memory database, as a library.
2//!
3//! Brain speaks a binary wire protocol (CBOR over TCP). This crate translates
4//! the hosted `/v1/*` HTTP/JSON API to that wire protocol via the Brain SDK. It
5//! is both:
6//!
7//! - a **binary** ([`run`]) — the self-hostable front door; point any HTTP
8//!   client at it and the self-host experience matches the cloud, only the base
9//!   URL differs; and
10//! - a **library** — the Arc cloud gateway embeds [`app::router`] and injects
11//!   its own [`CredentialResolver`] + [`MeteringSink`] (via
12//!   [`EdgeState::with_ports`]) to add API-key auth and usage metering without
13//!   forking the wire↔JSON translation layer.
14//!
15//! The two deployments share one engine: the same DTOs, the same mapping, the
16//! same per-credential connection pool. They differ only in the two injected
17//! ports.
18
19pub mod app;
20pub mod config;
21pub mod dto;
22pub mod error;
23pub mod pool;
24pub mod port;
25pub mod state;
26pub mod wire_proxy;
27
28pub use config::EdgeConfig;
29pub use error::ApiError;
30pub use pool::{BrainPool, BrainPoolConfig};
31pub use port::{
32    BearerResolver, CredentialResolver, MeterEvent, MeteringSink, NoopMeter, Outcome,
33    ResolvedCredential, bearer_token,
34};
35pub use state::EdgeState;
36pub use wire_proxy::{RateLimitConfig, WireProxyConfig};
37
38/// The per-request effective-identity selector the gateway sets when running a
39/// [`BrainPool::shared`] service pool: build one with `ActAs { namespace,
40/// space_id }` (or the SDK request builders' `.act_as(..)`) to run each op as a
41/// resolved tenant on behalf of the trusted service principal. Re-exported so a
42/// gateway consuming `brain_edge` need not also name `brain_db_sdk` directly.
43pub use brain_db_sdk::wire::types::ActAs;
44
45use std::time::Duration;
46
47use axum::Router;
48use axum::extract::State;
49use axum::http::{HeaderName, StatusCode, header};
50use axum::response::IntoResponse;
51use axum::routing::get;
52use tower_http::catch_panic::CatchPanicLayer;
53use tower_http::limit::RequestBodyLimitLayer;
54use tower_http::request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer};
55use tower_http::sensitive_headers::SetSensitiveRequestHeadersLayer;
56use tower_http::timeout::TimeoutLayer;
57use tower_http::trace::TraceLayer;
58use tracing::{error, info};
59
60/// The header carrying the correlation id, in and out.
61const REQUEST_ID_HEADER: HeaderName = HeaderName::from_static("x-request-id");
62
63/// Turn a panicking handler into a `500` instead of a dropped connection.
64///
65/// Without this a panic aborts the task and hyper closes the socket with no
66/// response: the caller sees a connection reset with no status, no body, and
67/// nothing to quote in a bug report. The panic is logged, and the caller gets
68/// the same JSON envelope as every other failure — carrying the request id, so
69/// the two ends can be joined up afterwards.
70fn panic_to_response(err: &Box<dyn std::any::Any + Send + 'static>) -> axum::response::Response {
71    // `panic!` and `unwrap` produce a `&str` or a `String`; anything else is
72    // opaque and there is nothing useful to extract.
73    let detail: &str = err.downcast_ref::<&str>().copied().unwrap_or_else(|| {
74        err.downcast_ref::<String>()
75            .map_or("non-string panic payload", String::as_str)
76    });
77    ApiError::internal(format!("handler panicked: {detail}")).into_response()
78}
79
80/// Apply the transport-level protections every deployment needs.
81///
82/// Panic recovery, correlation ids, header redaction, request tracing, a
83/// request timeout, and a request-body cap. [`run`] applies these;
84/// [`app::router`] deliberately does not, because a host application merges
85/// that router under its own middleware stack.
86///
87/// This exists so an embedder does not have to rediscover the list. The body
88/// cap is the one most easily missed: [`EdgeConfig::max_body_bytes`] is a
89/// visible, validated knob, so a gateway author can reasonably assume the
90/// router already honours it. It does not — only this function does.
91///
92/// # Arguments
93///
94/// * `request_timeout` — a stalled Brain must not hang a request forever; past
95///   this the request is cut with an explicit `408`.
96/// * `max_body_bytes` — an oversized body must not be buffered into memory.
97pub fn harden(router: Router, request_timeout: Duration, max_body_bytes: usize) -> Router {
98    router
99        // A panic must not take the connection down silently. Innermost, so it
100        // wraps the handler rather than the middleware around it.
101        .layer(CatchPanicLayer::custom(|e| panic_to_response(&e)))
102        // Redact credential headers so they never reach the log sink, then trace
103        // every request. Order matters: mark-sensitive is outermost so it wraps
104        // the trace layer's view of the headers.
105        .layer(TraceLayer::new_for_http())
106        .layer(SetSensitiveRequestHeadersLayer::new([
107            header::AUTHORIZATION,
108            HeaderName::from_static("x-api-key"),
109        ]))
110        .layer(TimeoutLayer::with_status_code(
111            StatusCode::REQUEST_TIMEOUT,
112            request_timeout,
113        ))
114        .layer(RequestBodyLimitLayer::new(max_body_bytes))
115        // Correlation, outermost so every response carries it — including the
116        // ones the layers above generate without reaching a handler (413 from
117        // the body cap, 408 from the timeout). A caller-supplied
118        // `x-request-id` is kept, so an id assigned upstream survives the hop.
119        .layer(PropagateRequestIdLayer::new(REQUEST_ID_HEADER))
120        .layer(SetRequestIdLayer::new(REQUEST_ID_HEADER, MakeRequestUuid))
121}
122
123/// Run the self-host edge: build default state (bearer passthrough, no
124/// metering), bind the listener, and serve until shutdown.
125///
126/// The hosted gateway does not call this — it builds its own [`EdgeState`] with
127/// [`EdgeState::with_ports`], merges [`app::router`] under its middleware, and
128/// serves itself.
129///
130/// # Errors
131/// Fails if the listen address can't be bound or the server exits with an error.
132pub async fn run(config: EdgeConfig) -> Result<(), Box<dyn std::error::Error>> {
133    let listen_addr = config.listen_addr;
134    let request_timeout = Duration::from_secs(config.request_timeout_secs);
135    let max_body_bytes = config.max_body_bytes;
136    // Wire-proxy settings, captured before `config` is moved into the state.
137    let wire_listen_addr = config.wire_listen_addr;
138    let brain_addr = config.brain_addr;
139    let wire_rate = RateLimitConfig {
140        capacity: config.wire_rate_capacity,
141        refill_per_sec: config.wire_rate_refill_per_sec,
142    };
143    let state = EdgeState::new(config);
144
145    // The transparent wire-protocol proxy is a second listener alongside HTTP.
146    // Opt-in (only when an address is configured); it reuses the state's metering
147    // sink so wire-path ops meter through the same sink as the HTTP data plane.
148    if let Some(wire_addr) = wire_listen_addr {
149        let meter = state.meter();
150        let wcfg = WireProxyConfig {
151            wire_listen_addr: wire_addr,
152            brain_addr,
153            rate: wire_rate,
154        };
155        tokio::spawn(async move {
156            if let Err(e) = wire_proxy::serve(wcfg, meter).await {
157                error!(error = %e, "wire proxy listener exited");
158            }
159        });
160    }
161
162    // Drop credential pools that have gone idle past their TTL. Runs off the
163    // request path so a long-lived self-host process serving many credentials
164    // doesn't hold live sockets forever between bursts.
165    {
166        let sweeper_state = state.clone();
167        tokio::spawn(async move {
168            let mut ticker = tokio::time::interval(Duration::from_secs(60));
169            ticker.tick().await; // consume the immediate first tick
170            loop {
171                ticker.tick().await;
172                sweeper_state.pool().sweep_idle();
173            }
174        });
175    }
176
177    // The library router is the data plane (`/v1/*`) only, so a host app can
178    // merge it without colliding on health routes; the binary owns its health.
179    // `/health/live` is a static liveness signal (the process is up);
180    // `/health/ready` actually probes Brain so an orchestrator doesn't route
181    // traffic here before the database it proxies to is reachable.
182    let health = axum::Router::new()
183        .route("/health/live", get(|| async { StatusCode::OK }))
184        .route("/health/ready", get(readiness))
185        .with_state(state.clone());
186
187    let app = harden(
188        app::router(state).merge(health),
189        request_timeout,
190        max_body_bytes,
191    );
192
193    let listener = tokio::net::TcpListener::bind(listen_addr).await?;
194    info!(addr = %listen_addr, "listening");
195    axum::serve(listener, app)
196        .with_graceful_shutdown(shutdown_signal())
197        .await?;
198    info!("shutdown complete");
199    Ok(())
200}
201
202/// Readiness: 200 only when a TCP socket to Brain opens within the probe budget,
203/// else 503. Lets an orchestrator gate traffic on the downstream being live.
204async fn readiness(State(state): State<EdgeState>) -> StatusCode {
205    if state.pool().probe(Duration::from_secs(2)).await {
206        StatusCode::OK
207    } else {
208        StatusCode::SERVICE_UNAVAILABLE
209    }
210}
211
212/// Resolve when the process receives SIGINT (Ctrl-C) or SIGTERM (the signal an
213/// orchestrator sends on rollout/scale-down), so in-flight requests drain
214/// instead of being severed mid-response.
215async fn shutdown_signal() {
216    let ctrl_c = async {
217        let _ = tokio::signal::ctrl_c().await;
218    };
219
220    #[cfg(unix)]
221    let terminate = async {
222        match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
223            Ok(mut sig) => {
224                sig.recv().await;
225            }
226            Err(_) => std::future::pending::<()>().await,
227        }
228    };
229
230    #[cfg(not(unix))]
231    let terminate = std::future::pending::<()>();
232
233    tokio::select! {
234        () = ctrl_c => info!("SIGINT received, shutting down"),
235        () = terminate => info!("SIGTERM received, shutting down"),
236    }
237}