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/// Build the WebAssembly handler runtime. With the `handlers` feature it wraps a
19/// wasmtime engine serving the kv/blob bindings from the server's own backends;
20/// otherwise it is an empty placeholder (handler routes fall through to static).
21#[cfg(feature = "handlers")]
22#[allow(clippy::too_many_arguments)]
23pub async fn build_handler_runtime(
24    kv: Arc<dyn KvStore>,
25    storage: Arc<dyn boatramp_core::Storage>,
26    data_dir: &Path,
27    handlers_cfg: Option<&crate::config::HandlersConfig>,
28    messaging_override: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
29    max_blob_bytes: u64,
30    max_component_bytes: u64,
31    // The deploy store (for a managed compute-backed `sql` database's endpoint
32    // resolution) and the `[secrets]` envelope (to seal a managed credential).
33    deploy: &DeployStore,
34    secrets_envelope: Option<Arc<dyn KeyEnvelope>>,
35) -> Result<boatramp_server::HandlerRuntime> {
36    // Opt-in pooling allocator: faster instantiation, large
37    // up-front virtual reservation — benchmark before enabling.
38    let limits = boatramp_handlers::Limits::default();
39    let engine = if handlers_cfg.is_some_and(|h| h.pooling) {
40        boatramp_handlers::HandlerEngine::with_pooling(limits, 64)?
41    } else {
42        boatramp_handlers::HandlerEngine::new(limits, 64)?
43    };
44    let sql = build_sql_backends(
45        handlers_cfg.and_then(|h| h.bindings.sql.as_ref()),
46        data_dir,
47        deploy,
48        &kv,
49        secrets_envelope.as_ref(),
50    )
51    .await?;
52    // The `wasi:messaging` substrate: single-node `LogMessaging` over the same
53    // blob/KV backends by default, or the cluster coordinator when one is given.
54    let messaging: Arc<dyn boatramp_core::messaging::Messaging> = messaging_override
55        .unwrap_or_else(|| {
56            Arc::new(boatramp_core::messaging::LogMessaging::new(
57                storage.clone(),
58                kv.clone(),
59            ))
60        });
61    let runtime =
62        boatramp_server::HandlerRuntime::new(engine, kv, storage, Some(sql), Some(messaging));
63    // Apply the posture's host-side blob cap + component-size cap.
64    runtime.set_max_blob_bytes(max_blob_bytes);
65    runtime.set_max_component_bytes(max_component_bytes);
66    Ok(runtime)
67}
68
69/// Resolve the `[handlers.bindings.sql]` config to the libsql SQL backend.
70/// Single-node by default (an embedded file per site under `<data-dir>`); set
71/// `url` to bind a shared sqld cluster (a namespace per site). Either way sites
72/// get a real database boundary — see `boatramp_core::sql`.
73#[cfg(feature = "handlers")]
74async fn build_sql_backends(
75    cfg: Option<&crate::config::SqlBindingConfig>,
76    data_dir: &Path,
77    deploy: &DeployStore,
78    kv: &Arc<dyn KvStore>,
79    secrets_envelope: Option<&Arc<dyn KeyEnvelope>>,
80) -> Result<Arc<dyn boatramp_core::sql::SqlBackends>> {
81    let resolve_env = |var: &Option<String>| -> Result<Option<String>> {
82        match var {
83            Some(var) => Ok(Some(
84                std::env::var(var).map_err(|_| Error::SqlEnvUnset(var.clone()))?,
85            )),
86            None => Ok(None),
87        }
88    };
89
90    let backend = match cfg.and_then(|c| c.url.as_ref()) {
91        // Cluster: a sqld namespace per site. Auth tokens come from the
92        // environment, never the config file.
93        Some(url) => {
94            let cfg = cfg.expect("url implies cfg");
95            let admin_url = cfg.admin_url.as_ref().ok_or(Error::SqlAdminUrlRequired)?;
96            let token = resolve_env(&cfg.token_env)?.unwrap_or_default();
97            let admin_token = resolve_env(&cfg.admin_token_env)?;
98            let backends = boatramp_storage::LibsqlSqlBackends::remote(
99                url.clone(),
100                admin_url.clone(),
101                token,
102                admin_token,
103            );
104            // Optional read-replica routing: reads → replica, writes → primary.
105            match &cfg.replica_url {
106                Some(replica_url) => backends.with_read_replica(replica_url.clone()),
107                None => backends,
108            }
109        }
110        // Single-node: an embedded file per site.
111        None => {
112            let dir = cfg
113                .and_then(|c| c.dir.clone())
114                .unwrap_or_else(|| data_dir.join("handlers-sql"));
115            boatramp_storage::LibsqlSqlBackends::local(dir)
116        }
117    };
118    // Preview SQL policy (how preview deployments relate to live data).
119    let preview_mode = match cfg.and_then(|c| c.preview_mode.as_deref()) {
120        None | Some("empty") => boatramp_core::sql::PreviewSqlMode::Empty,
121        Some("branch") => boatramp_core::sql::PreviewSqlMode::Branch,
122        Some("shared") => boatramp_core::sql::PreviewSqlMode::Shared,
123        Some(other) => return Err(Error::UnknownPreviewMode(other.to_string())),
124    };
125    let preview_init = match cfg.and_then(|c| c.preview_init.as_ref()) {
126        Some(path) => {
127            Some(
128                std::fs::read_to_string(path).map_err(|err| Error::PreviewInitRead {
129                    path: path.clone(),
130                    source: err,
131                })?,
132            )
133        }
134        None => None,
135    };
136    let default: Arc<dyn boatramp_core::sql::SqlBackends> =
137        Arc::new(backend.with_preview_policy(preview_mode, preview_init));
138
139    // Overlay any external (bring-your-own) databases on the managed default.
140    // With none configured the default is returned unchanged (and a build
141    // without an external SQL engine never has to link the sqlx path).
142    let databases = cfg.map(|c| &c.databases);
143    if databases.is_none_or(std::collections::BTreeMap::is_empty) {
144        return Ok(default);
145    }
146    let databases = databases.expect("checked non-empty above");
147
148    #[cfg(any(feature = "sql-postgres", feature = "sql-mysql"))]
149    {
150        use boatramp_core::project::DEFAULT_PROJECT;
151        use boatramp_core::sql::SqlBackend;
152        use boatramp_storage::sql_compute::ComputeResolvedSqlBackend;
153        use boatramp_storage::sql_sqlx::{
154            connect, CompositeSqlBackends, ExternalSqlKind, ExternalSqlOptions,
155        };
156        let timeout = |db: &crate::config::ExternalDatabaseConfig| {
157            db.connect_timeout_secs.map(std::time::Duration::from_secs)
158        };
159        let mut composite = CompositeSqlBackends::new(default);
160        for (name, db) in databases {
161            let kind = ExternalSqlKind::parse(&db.kind).ok_or_else(|| Error::SqlExternalKind {
162                name: name.clone(),
163                kind: db.kind.clone(),
164            })?;
165            let external: Arc<dyn SqlBackend> = if let Some(workload) =
166                db.compute.as_deref().filter(|c| !c.is_empty())
167            {
168                // Compute-backed: resolve the workload's live endpoint on demand and
169                // build the connection. The credential is either brought
170                // (`password_env`) or **boatramp-managed** (generated + sealed).
171                let password = match db.password_env.as_deref().filter(|v| !v.is_empty()) {
172                    Some(var) => std::env::var(var).map_err(|_| Error::SqlEnvUnset(var.into()))?,
173                    None => {
174                        // Managed credential: fail closed without a secrets envelope
175                        // (we will not persist a DB password in cleartext).
176                        let envelope = secrets_envelope
177                            .cloned()
178                            .ok_or_else(|| Error::SqlManagedNeedsSecrets(name.clone()))?;
179                        crate::managed_sql::ManagedSqlCredentials::new(kv.clone(), envelope)
180                            .password(DEFAULT_PROJECT, workload)
181                            .await
182                            .map_err(|reason| Error::SqlManagedCredential {
183                                name: name.clone(),
184                                reason,
185                            })?
186                    }
187                };
188                let resolver = Arc::new(crate::managed_sql::DeployEndpointResolver::new(
189                    deploy.clone(),
190                    DEFAULT_PROJECT,
191                ));
192                Arc::new(ComputeResolvedSqlBackend::new(
193                    resolver,
194                    workload,
195                    kind,
196                    db.database.clone().unwrap_or_default(),
197                    db.user.clone().unwrap_or_default(),
198                    password,
199                    db.pool_max,
200                    db.read_only,
201                    timeout(db),
202                ))
203            } else {
204                // Bring-your-own URL: the connection URL(s) are secrets, resolved
205                // from the environment.
206                if db.url_env.trim().is_empty() {
207                    return Err(Error::SqlExternalUrlEnvMissing(name.clone()));
208                }
209                let url = std::env::var(&db.url_env)
210                    .map_err(|_| Error::SqlEnvUnset(db.url_env.clone()))?;
211                let read_url = match &db.read_url_env {
212                    Some(var) => {
213                        Some(std::env::var(var).map_err(|_| Error::SqlEnvUnset(var.clone()))?)
214                    }
215                    None => None,
216                };
217                let opts = ExternalSqlOptions::new(url)
218                    .with_read_url(read_url)
219                    .with_max_connections(db.pool_max)
220                    .read_only(db.read_only)
221                    .with_connect_timeout(timeout(db));
222                connect(kind, &opts).map_err(|source| Error::SqlExternalConnect {
223                    name: name.clone(),
224                    source,
225                })?
226            };
227            composite = composite.with_external(name.clone(), external, db.allow_preview);
228        }
229        Ok(Arc::new(composite))
230    }
231    #[cfg(not(any(feature = "sql-postgres", feature = "sql-mysql")))]
232    {
233        // The compute-backed arm (the only consumer of these) is compiled out
234        // without a SQL engine; a `databases` entry then can't be served at all.
235        let _ = (deploy, kv, secrets_envelope);
236        let name = databases.keys().next().cloned().unwrap_or_default();
237        Err(Error::SqlExternalUnavailable(name))
238    }
239}
240
241#[cfg(not(feature = "handlers"))]
242#[allow(clippy::too_many_arguments)]
243pub async fn build_handler_runtime(
244    _kv: Arc<dyn KvStore>,
245    _storage: Arc<dyn boatramp_core::Storage>,
246    _data_dir: &Path,
247    _handlers_cfg: Option<&crate::config::HandlersConfig>,
248    _messaging_override: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
249    _max_blob_bytes: u64,
250    _max_component_bytes: u64,
251    _deploy: &DeployStore,
252    _secrets_envelope: Option<Arc<dyn KeyEnvelope>>,
253) -> Result<boatramp_server::HandlerRuntime> {
254    Ok(boatramp_server::HandlerRuntime::disabled())
255}
256
257#[cfg(all(test, any(feature = "sql-postgres", feature = "sql-mysql")))]
258mod tests {
259    use super::*;
260    // `super::*` brings the crate's 1-arg `Result` alias into scope; the trait impls
261    // below need the std 2-arg `Result`, so shadow it back (explicit beats glob).
262    use std::result::Result;
263
264    use async_trait::async_trait;
265    use boatramp_core::envelope::EnvelopeError;
266    use boatramp_core::kv::MemoryKv;
267    use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, Storage, StorageError};
268
269    /// A reversible test envelope (NOT encryption) — proves sealing round-trips.
270    struct TestEnvelope;
271    #[async_trait]
272    impl KeyEnvelope for TestEnvelope {
273        async fn wrap(&self, p: &[u8]) -> Result<Vec<u8>, EnvelopeError> {
274            Ok(p.iter().rev().copied().collect())
275        }
276        async fn unwrap(&self, w: &[u8]) -> Result<Vec<u8>, EnvelopeError> {
277            Ok(w.iter().rev().copied().collect())
278        }
279    }
280
281    /// A no-op object store, so a `DeployStore` can be built (the endpoint resolver
282    /// only reads KV replica state, which is empty here — the backend is lazy).
283    struct NullStorage;
284    #[async_trait]
285    impl Storage for NullStorage {
286        async fn get(&self, _: &str) -> Result<GetObject, StorageError> {
287            Err(StorageError::NotFound(String::new()))
288        }
289        async fn get_range(
290            &self,
291            _: &str,
292            _: u64,
293            _: Option<u64>,
294        ) -> Result<GetObject, StorageError> {
295            Err(StorageError::NotFound(String::new()))
296        }
297        async fn put(
298            &self,
299            _: &str,
300            _: ByteStream,
301            _: PutMeta,
302        ) -> Result<ObjectMeta, StorageError> {
303            Err(StorageError::unsupported("null"))
304        }
305        async fn head(&self, _: &str) -> Result<ObjectMeta, StorageError> {
306            Err(StorageError::NotFound(String::new()))
307        }
308        async fn delete(&self, _: &str) -> Result<(), StorageError> {
309            Ok(())
310        }
311        async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
312            Ok(Vec::new())
313        }
314    }
315
316    /// A `sql` binding with one managed (compute-backed, no `password_env`) database.
317    fn managed_sql_cfg() -> crate::config::SqlBindingConfig {
318        let mut databases = std::collections::BTreeMap::new();
319        databases.insert(
320            "analytics".to_string(),
321            crate::config::ExternalDatabaseConfig {
322                kind: "postgres".into(),
323                compute: Some("pg".into()),
324                database: Some("analytics".into()),
325                user: Some("app".into()),
326                ..Default::default()
327            },
328        );
329        crate::config::SqlBindingConfig {
330            databases,
331            ..Default::default()
332        }
333    }
334
335    #[tokio::test]
336    async fn managed_sql_fails_closed_without_secrets() {
337        let tmp = tempfile::tempdir().unwrap();
338        let deploy = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
339        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
340        let cfg = managed_sql_cfg();
341        // `Arc<dyn SqlBackends>` isn't `Debug`, so match rather than `unwrap_err`.
342        match build_sql_backends(Some(&cfg), tmp.path(), &deploy, &kv, None).await {
343            Err(Error::SqlManagedNeedsSecrets(name)) => assert_eq!(name, "analytics"),
344            Ok(_) => panic!("a managed DB without [secrets] must fail closed, got Ok"),
345            Err(other) => panic!("expected SqlManagedNeedsSecrets, got: {other}"),
346        }
347    }
348
349    #[tokio::test]
350    async fn managed_sql_builds_lazily_and_seals_the_credential() {
351        let tmp = tempfile::tempdir().unwrap();
352        let deploy = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
353        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
354        let envelope: Arc<dyn KeyEnvelope> = Arc::new(TestEnvelope);
355        let cfg = managed_sql_cfg();
356        // No DB is running: the backend resolves the endpoint on first use, so
357        // assembly succeeds without a connection and the credential is sealed now.
358        let backends = build_sql_backends(Some(&cfg), tmp.path(), &deploy, &kv, Some(&envelope))
359            .await
360            .expect("managed sql builds without a live DB (lazy connect)");
361        let sealed = kv
362            .get("managed-sql-cred/default/pg")
363            .await
364            .unwrap()
365            .expect("managed credential sealed at build under the default project");
366        assert_ne!(sealed.len(), 0);
367        // Sanity: the composite is usable as a provider (no connection yet).
368        let _: Arc<dyn boatramp_core::sql::SqlBackends> = backends;
369    }
370}