Skip to main content

boatramp_node/
handlers.rs

1//! WebAssembly handler-runtime assembly (moved from the binary — node-library N2b).
2//!
3//! Builds `boatramp_server::HandlerRuntime` from `[handlers]` config: the wasmtime
4//! engine plus the libsql `sql` binding (single-node file per site, a cluster sqld
5//! namespace, or an external Postgres/MySQL). Handlers-gated; a lean node gets a
6//! disabled runtime. Lives here (not the backend-agnostic `boatramp-server`)
7//! because it drives the concrete `boatramp-storage` SQL backends.
8
9#[cfg(feature = "handlers")]
10use crate::error::Error;
11use crate::error::Result;
12use boatramp_core::deploy::DeployStore;
13use boatramp_core::envelope::KeyEnvelope;
14use boatramp_core::kv::KvStore;
15use std::path::Path;
16use std::sync::Arc;
17
18/// Default async-lane wall-clock ceiling: 15 minutes. Large enough for a
19/// genuinely long background job (an LLM generation, a batch transform) while
20/// staying bounded — work that needs longer belongs in a workflow, one bounded
21/// invocation per step. The lease that guards a crashed-node reclaim is sized
22/// from this, so a bounded value also bounds the orphan-recovery window.
23#[cfg(feature = "handlers")]
24const DEFAULT_ASYNC_TIMEOUT_MS: u64 = 15 * 60 * 1000;
25
26/// Default async-lane concurrency: a small, isolated pool. The point of the
27/// separate budget is that a burst of long background jobs cannot exhaust the
28/// (much larger) request pool live site traffic draws from.
29#[cfg(feature = "handlers")]
30const DEFAULT_ASYNC_CONCURRENCY: usize = 8;
31
32/// Default streaming-lane wall-clock: like the async lane, a long-lived streaming
33/// response (SSE, agent token streaming) can run for minutes.
34#[cfg(feature = "handlers")]
35const DEFAULT_STREAMING_TIMEOUT_MS: u64 = 15 * 60 * 1000;
36
37/// Default streaming-lane concurrency: larger than the async drain because
38/// concurrent connected SSE clients are the expected shape, but still an isolated
39/// budget so a burst can't touch the fast request pool.
40#[cfg(feature = "handlers")]
41const DEFAULT_STREAMING_CONCURRENCY: usize = 64;
42
43/// Build the WebAssembly handler runtime. With the `handlers` feature it wraps a
44/// wasmtime engine serving the kv/blob bindings from the server's own backends;
45/// otherwise it is an empty placeholder (handler routes fall through to static).
46#[cfg(feature = "handlers")]
47#[allow(clippy::too_many_arguments)]
48pub async fn build_handler_runtime(
49    kv: Arc<dyn KvStore>,
50    storage: Arc<dyn boatramp_core::Storage>,
51    data_dir: &Path,
52    handlers_cfg: Option<&crate::config::HandlersConfig>,
53    messaging_override: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
54    max_blob_bytes: u64,
55    max_component_bytes: u64,
56    // Posture: whether a guest's outbound `wasi:http` may reach private/loopback hosts.
57    allow_guest_private_egress: bool,
58    // Posture: the instance's own serve socket(s) a guest self-call may reach (empty ⇒ off).
59    self_egress_addrs: Vec<std::net::SocketAddr>,
60    // The deploy store (for a managed compute-backed `sql` database's endpoint
61    // resolution) and the `[secrets]` envelope (to seal a managed credential).
62    deploy: &DeployStore,
63    secrets_envelope: Option<Arc<dyn KeyEnvelope>>,
64) -> Result<boatramp_server::HandlerRuntime> {
65    // Two engine ceilings by lane. The **sync** ceiling bounds connection-bearing
66    // requests (site handlers, synchronous invokes) — kept tight so a slow
67    // handler can't pin a client, a proxy, and the shared request pool; default
68    // 10s. The **async** ceiling bounds the durable drain / workflow / trigger /
69    // messaging path — no client is connected and the work is retried +
70    // dead-lettered, so it can run far longer (default 15 min) on its own
71    // concurrency budget, which is how a legitimately long background job (e.g.
72    // an LLM generation) can declare and actually get minutes of runtime without
73    // ever starving live site traffic.
74    let defaults = boatramp_handlers::Limits::default();
75    let sync_limits = boatramp_handlers::Limits {
76        timeout_ms: handlers_cfg
77            .and_then(|h| h.sync_max_timeout_ms)
78            .unwrap_or(defaults.timeout_ms),
79        ..defaults
80    };
81    let async_limits = boatramp_handlers::Limits {
82        timeout_ms: handlers_cfg
83            .and_then(|h| h.async_max_timeout_ms)
84            .unwrap_or(DEFAULT_ASYNC_TIMEOUT_MS),
85        max_concurrency: handlers_cfg
86            .and_then(|h| h.async_max_concurrency)
87            .unwrap_or(DEFAULT_ASYNC_CONCURRENCY),
88        fuel: handlers_cfg.and_then(|h| h.async_max_fuel),
89        ..sync_limits
90    };
91    // The **streaming** ceiling bounds a `#[handler(stream)]` response (SSE, chunked, agent
92    // token streaming): connection-bearing but long-lived, so — like the async lane — a large
93    // wall-clock on its own concurrency budget, kept apart from both the fast request pool and
94    // the durable drain.
95    let streaming_limits = boatramp_handlers::Limits {
96        timeout_ms: handlers_cfg
97            .and_then(|h| h.streaming_max_timeout_ms)
98            .unwrap_or(DEFAULT_STREAMING_TIMEOUT_MS),
99        max_concurrency: handlers_cfg
100            .and_then(|h| h.streaming_max_concurrency)
101            .unwrap_or(DEFAULT_STREAMING_CONCURRENCY),
102        fuel: handlers_cfg.and_then(|h| h.streaming_max_fuel),
103        ..sync_limits
104    };
105    let outbound_timeout = handlers_cfg
106        .and_then(|h| h.outbound_timeout_ms)
107        .map(std::time::Duration::from_millis);
108    // Opt-in pooling allocator: faster instantiation, large up-front virtual
109    // reservation — benchmark before enabling.
110    let engine = if handlers_cfg.is_some_and(|h| h.pooling) {
111        boatramp_handlers::HandlerEngine::with_pooling(sync_limits, 64)?
112    } else {
113        boatramp_handlers::HandlerEngine::new(sync_limits, 64)?
114    }
115    .with_async_limits(async_limits)
116    .with_streaming_limits(streaming_limits)
117    .with_outbound_timeout(outbound_timeout)
118    .with_private_egress(allow_guest_private_egress)
119    .with_self_egress(self_egress_addrs);
120    let sql = build_sql_backends(
121        handlers_cfg.and_then(|h| h.bindings.sql.as_ref()),
122        data_dir,
123        deploy,
124        &kv,
125        secrets_envelope.as_ref(),
126    )
127    .await?;
128    // The `wasi:messaging` substrate: single-node `LogMessaging` over the same
129    // blob/KV backends by default, or the cluster coordinator when one is given.
130    let messaging: Arc<dyn boatramp_core::messaging::Messaging> = messaging_override
131        .unwrap_or_else(|| {
132            Arc::new(boatramp_core::messaging::LogMessaging::new(
133                storage.clone(),
134                kv.clone(),
135            ))
136        });
137    let runtime =
138        boatramp_server::HandlerRuntime::new(engine, kv, storage, Some(sql), Some(messaging));
139    // Apply the posture's host-side blob cap + component-size cap.
140    runtime.set_max_blob_bytes(max_blob_bytes);
141    runtime.set_max_component_bytes(max_component_bytes);
142    Ok(runtime)
143}
144
145/// Resolve the `[handlers.bindings.sql]` config to the libsql SQL backend.
146/// Single-node by default (an embedded file per site under `<data-dir>`); set
147/// `url` to bind a shared sqld cluster (a namespace per site). Either way sites
148/// get a real database boundary — see `boatramp_core::sql`.
149#[cfg(feature = "handlers")]
150async fn build_sql_backends(
151    cfg: Option<&crate::config::SqlBindingConfig>,
152    data_dir: &Path,
153    deploy: &DeployStore,
154    kv: &Arc<dyn KvStore>,
155    secrets_envelope: Option<&Arc<dyn KeyEnvelope>>,
156) -> Result<Arc<dyn boatramp_core::sql::SqlBackends>> {
157    let resolve_env = |var: &Option<String>| -> Result<Option<String>> {
158        match var {
159            Some(var) => Ok(Some(
160                std::env::var(var).map_err(|_| Error::SqlEnvUnset(var.clone()))?,
161            )),
162            None => Ok(None),
163        }
164    };
165
166    let backend = match cfg.and_then(|c| c.url.as_ref()) {
167        // Cluster: a sqld namespace per site. Auth tokens come from the
168        // environment, never the config file.
169        Some(url) => {
170            let cfg = cfg.expect("url implies cfg");
171            let admin_url = cfg.admin_url.as_ref().ok_or(Error::SqlAdminUrlRequired)?;
172            let token = resolve_env(&cfg.token_env)?.unwrap_or_default();
173            let admin_token = resolve_env(&cfg.admin_token_env)?;
174            let backends = boatramp_storage::LibsqlSqlBackends::remote(
175                url.clone(),
176                admin_url.clone(),
177                token,
178                admin_token,
179            );
180            // Optional read-replica routing: reads → replica, writes → primary.
181            match &cfg.replica_url {
182                Some(replica_url) => backends.with_read_replica(replica_url.clone()),
183                None => backends,
184            }
185        }
186        // Single-node: an embedded file per site.
187        None => {
188            let dir = cfg
189                .and_then(|c| c.dir.clone())
190                .unwrap_or_else(|| data_dir.join("handlers-sql"));
191            boatramp_storage::LibsqlSqlBackends::local(dir)
192        }
193    };
194    // Preview SQL policy (how preview deployments relate to live data).
195    let preview_mode = match cfg.and_then(|c| c.preview_mode.as_deref()) {
196        None | Some("empty") => boatramp_core::sql::PreviewSqlMode::Empty,
197        Some("branch") => boatramp_core::sql::PreviewSqlMode::Branch,
198        Some("shared") => boatramp_core::sql::PreviewSqlMode::Shared,
199        Some(other) => return Err(Error::UnknownPreviewMode(other.to_string())),
200    };
201    let preview_init = match cfg.and_then(|c| c.preview_init.as_ref()) {
202        Some(path) => {
203            Some(
204                std::fs::read_to_string(path).map_err(|err| Error::PreviewInitRead {
205                    path: path.clone(),
206                    source: err,
207                })?,
208            )
209        }
210        None => None,
211    };
212    let default: Arc<dyn boatramp_core::sql::SqlBackends> =
213        Arc::new(backend.with_preview_policy(preview_mode, preview_init));
214
215    // Overlay any external (bring-your-own) databases on the managed default.
216    // With none configured the default is returned unchanged (and a build
217    // without an external SQL engine never has to link the sqlx path).
218    let databases = cfg.map(|c| &c.databases);
219    if databases.is_none_or(std::collections::BTreeMap::is_empty) {
220        return Ok(default);
221    }
222    let databases = databases.expect("checked non-empty above");
223
224    #[cfg(any(feature = "sql-postgres", feature = "sql-mysql"))]
225    {
226        use boatramp_core::project::DEFAULT_PROJECT;
227        use boatramp_core::sql::SqlBackend;
228        use boatramp_storage::sql_compute::ComputeResolvedSqlBackend;
229        use boatramp_storage::sql_sqlx::{
230            connect, CompositeSqlBackends, ExternalSqlKind, ExternalSqlOptions,
231        };
232        let timeout = |db: &crate::config::ExternalDatabaseConfig| {
233            db.connect_timeout_secs.map(std::time::Duration::from_secs)
234        };
235        let mut composite = CompositeSqlBackends::new(default);
236        for (name, db) in databases {
237            let kind = ExternalSqlKind::parse(&db.kind).ok_or_else(|| Error::SqlExternalKind {
238                name: name.clone(),
239                kind: db.kind.clone(),
240            })?;
241            let external: Arc<dyn SqlBackend> = if let Some(workload) =
242                db.compute.as_deref().filter(|c| !c.is_empty())
243            {
244                // Compute-backed: resolve the workload's live endpoint on demand and
245                // build the connection. The credential is either brought
246                // (`password_env`) or **boatramp-managed** (generated + sealed).
247                let password = match db.password_env.as_deref().filter(|v| !v.is_empty()) {
248                    Some(var) => std::env::var(var).map_err(|_| Error::SqlEnvUnset(var.into()))?,
249                    None => {
250                        // Managed credential: fail closed without a secrets envelope
251                        // (we will not persist a DB password in cleartext).
252                        let envelope = secrets_envelope
253                            .cloned()
254                            .ok_or_else(|| Error::SqlManagedNeedsSecrets(name.clone()))?;
255                        crate::managed_sql::ManagedSqlCredentials::new(kv.clone(), envelope)
256                            .password(DEFAULT_PROJECT, workload)
257                            .await
258                            .map_err(|reason| Error::SqlManagedCredential {
259                                name: name.clone(),
260                                reason,
261                            })?
262                    }
263                };
264                let resolver = Arc::new(crate::managed_sql::DeployEndpointResolver::new(
265                    deploy.clone(),
266                    DEFAULT_PROJECT,
267                ));
268                Arc::new(ComputeResolvedSqlBackend::new(
269                    resolver,
270                    workload,
271                    kind,
272                    db.database.clone().unwrap_or_default(),
273                    db.user.clone().unwrap_or_default(),
274                    password,
275                    db.pool_max,
276                    db.read_only,
277                    timeout(db),
278                ))
279            } else {
280                // Bring-your-own URL: the connection URL(s) are secrets, resolved
281                // from the environment.
282                if db.url_env.trim().is_empty() {
283                    return Err(Error::SqlExternalUrlEnvMissing(name.clone()));
284                }
285                let url = std::env::var(&db.url_env)
286                    .map_err(|_| Error::SqlEnvUnset(db.url_env.clone()))?;
287                let read_url = match &db.read_url_env {
288                    Some(var) => {
289                        Some(std::env::var(var).map_err(|_| Error::SqlEnvUnset(var.clone()))?)
290                    }
291                    None => None,
292                };
293                let opts = ExternalSqlOptions::new(url)
294                    .with_read_url(read_url)
295                    .with_max_connections(db.pool_max)
296                    .read_only(db.read_only)
297                    .with_connect_timeout(timeout(db));
298                connect(kind, &opts).map_err(|source| Error::SqlExternalConnect {
299                    name: name.clone(),
300                    source,
301                })?
302            };
303            composite = composite.with_external(name.clone(), external, db.allow_preview);
304        }
305        Ok(Arc::new(composite))
306    }
307    #[cfg(not(any(feature = "sql-postgres", feature = "sql-mysql")))]
308    {
309        // The compute-backed arm (the only consumer of these) is compiled out
310        // without a SQL engine; a `databases` entry then can't be served at all.
311        let _ = (deploy, kv, secrets_envelope);
312        let name = databases.keys().next().cloned().unwrap_or_default();
313        Err(Error::SqlExternalUnavailable(name))
314    }
315}
316
317#[cfg(not(feature = "handlers"))]
318#[allow(clippy::too_many_arguments)]
319pub async fn build_handler_runtime(
320    _kv: Arc<dyn KvStore>,
321    _storage: Arc<dyn boatramp_core::Storage>,
322    _data_dir: &Path,
323    _handlers_cfg: Option<&crate::config::HandlersConfig>,
324    _messaging_override: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
325    _max_blob_bytes: u64,
326    _max_component_bytes: u64,
327    _allow_guest_private_egress: bool,
328    _self_egress_addrs: Vec<std::net::SocketAddr>,
329    _deploy: &DeployStore,
330    _secrets_envelope: Option<Arc<dyn KeyEnvelope>>,
331) -> Result<boatramp_server::HandlerRuntime> {
332    Ok(boatramp_server::HandlerRuntime::disabled())
333}
334
335#[cfg(all(test, any(feature = "sql-postgres", feature = "sql-mysql")))]
336mod tests {
337    use super::*;
338    // `super::*` brings the crate's 1-arg `Result` alias into scope; the trait impls
339    // below need the std 2-arg `Result`, so shadow it back (explicit beats glob).
340    use std::result::Result;
341
342    use async_trait::async_trait;
343    use boatramp_core::envelope::EnvelopeError;
344    use boatramp_core::kv::MemoryKv;
345    use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, Storage, StorageError};
346
347    /// A reversible test envelope (NOT encryption) — proves sealing round-trips.
348    struct TestEnvelope;
349    #[async_trait]
350    impl KeyEnvelope for TestEnvelope {
351        async fn wrap(&self, p: &[u8]) -> Result<Vec<u8>, EnvelopeError> {
352            Ok(p.iter().rev().copied().collect())
353        }
354        async fn unwrap(&self, w: &[u8]) -> Result<Vec<u8>, EnvelopeError> {
355            Ok(w.iter().rev().copied().collect())
356        }
357    }
358
359    /// A no-op object store, so a `DeployStore` can be built (the endpoint resolver
360    /// only reads KV replica state, which is empty here — the backend is lazy).
361    struct NullStorage;
362    #[async_trait]
363    impl Storage for NullStorage {
364        async fn get(&self, _: &str) -> Result<GetObject, StorageError> {
365            Err(StorageError::NotFound(String::new()))
366        }
367        async fn get_range(
368            &self,
369            _: &str,
370            _: u64,
371            _: Option<u64>,
372        ) -> Result<GetObject, StorageError> {
373            Err(StorageError::NotFound(String::new()))
374        }
375        async fn put(
376            &self,
377            _: &str,
378            _: ByteStream,
379            _: PutMeta,
380        ) -> Result<ObjectMeta, StorageError> {
381            Err(StorageError::unsupported("null"))
382        }
383        async fn head(&self, _: &str) -> Result<ObjectMeta, StorageError> {
384            Err(StorageError::NotFound(String::new()))
385        }
386        async fn delete(&self, _: &str) -> Result<(), StorageError> {
387            Ok(())
388        }
389        async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
390            Ok(Vec::new())
391        }
392    }
393
394    /// A `sql` binding with one managed (compute-backed, no `password_env`) database.
395    fn managed_sql_cfg() -> crate::config::SqlBindingConfig {
396        let mut databases = std::collections::BTreeMap::new();
397        databases.insert(
398            "analytics".to_string(),
399            crate::config::ExternalDatabaseConfig {
400                kind: "postgres".into(),
401                compute: Some("pg".into()),
402                database: Some("analytics".into()),
403                user: Some("app".into()),
404                ..Default::default()
405            },
406        );
407        crate::config::SqlBindingConfig {
408            databases,
409            ..Default::default()
410        }
411    }
412
413    #[tokio::test]
414    async fn managed_sql_fails_closed_without_secrets() {
415        let tmp = tempfile::tempdir().unwrap();
416        let deploy = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
417        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
418        let cfg = managed_sql_cfg();
419        // `Arc<dyn SqlBackends>` isn't `Debug`, so match rather than `unwrap_err`.
420        match build_sql_backends(Some(&cfg), tmp.path(), &deploy, &kv, None).await {
421            Err(Error::SqlManagedNeedsSecrets(name)) => assert_eq!(name, "analytics"),
422            Ok(_) => panic!("a managed DB without [secrets] must fail closed, got Ok"),
423            Err(other) => panic!("expected SqlManagedNeedsSecrets, got: {other}"),
424        }
425    }
426
427    #[tokio::test]
428    async fn managed_sql_builds_lazily_and_seals_the_credential() {
429        let tmp = tempfile::tempdir().unwrap();
430        let deploy = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
431        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
432        let envelope: Arc<dyn KeyEnvelope> = Arc::new(TestEnvelope);
433        let cfg = managed_sql_cfg();
434        // No DB is running: the backend resolves the endpoint on first use, so
435        // assembly succeeds without a connection and the credential is sealed now.
436        let backends = build_sql_backends(Some(&cfg), tmp.path(), &deploy, &kv, Some(&envelope))
437            .await
438            .expect("managed sql builds without a live DB (lazy connect)");
439        let sealed = kv
440            .get("managed-sql-cred/default/pg")
441            .await
442            .unwrap()
443            .expect("managed credential sealed at build under the default project");
444        assert_ne!(sealed.len(), 0);
445        // Sanity: the composite is usable as a provider (no connection yet).
446        let _: Arc<dyn boatramp_core::sql::SqlBackends> = backends;
447    }
448}