1#[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#[cfg(feature = "handlers")]
24const DEFAULT_ASYNC_TIMEOUT_MS: u64 = 15 * 60 * 1000;
25
26#[cfg(feature = "handlers")]
30const DEFAULT_ASYNC_CONCURRENCY: usize = 8;
31
32#[cfg(feature = "handlers")]
35const DEFAULT_STREAMING_TIMEOUT_MS: u64 = 15 * 60 * 1000;
36
37#[cfg(feature = "handlers")]
41const DEFAULT_STREAMING_CONCURRENCY: usize = 64;
42
43#[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 allow_guest_private_egress: bool,
58 self_egress_addrs: Vec<std::net::SocketAddr>,
60 allow_env_secret_refs: bool,
64 allow_guest_email: bool,
68 require_tenancy_declaration: bool,
72 allow_cross_tenant_db: bool,
73 deploy: &DeployStore,
76 secrets_envelope: Option<Arc<dyn KeyEnvelope>>,
77) -> Result<boatramp_server::HandlerRuntime> {
78 #[cfg(not(feature = "email"))]
81 let _ = allow_guest_email;
82 let defaults = boatramp_handlers::Limits::default();
92 let sync_limits = boatramp_handlers::Limits {
93 timeout_ms: handlers_cfg
94 .and_then(|h| h.sync_max_timeout_ms)
95 .unwrap_or(defaults.timeout_ms),
96 ..defaults
97 };
98 let async_limits = boatramp_handlers::Limits {
99 timeout_ms: handlers_cfg
100 .and_then(|h| h.async_max_timeout_ms)
101 .unwrap_or(DEFAULT_ASYNC_TIMEOUT_MS),
102 max_concurrency: handlers_cfg
103 .and_then(|h| h.async_max_concurrency)
104 .unwrap_or(DEFAULT_ASYNC_CONCURRENCY),
105 fuel: handlers_cfg.and_then(|h| h.async_max_fuel),
106 ..sync_limits
107 };
108 let streaming_limits = boatramp_handlers::Limits {
113 timeout_ms: handlers_cfg
114 .and_then(|h| h.streaming_max_timeout_ms)
115 .unwrap_or(DEFAULT_STREAMING_TIMEOUT_MS),
116 max_concurrency: handlers_cfg
117 .and_then(|h| h.streaming_max_concurrency)
118 .unwrap_or(DEFAULT_STREAMING_CONCURRENCY),
119 fuel: handlers_cfg.and_then(|h| h.streaming_max_fuel),
120 ..sync_limits
121 };
122 let outbound_timeout = handlers_cfg
123 .and_then(|h| h.outbound_timeout_ms)
124 .map(std::time::Duration::from_millis);
125 let engine = if handlers_cfg.is_some_and(|h| h.pooling) {
128 boatramp_handlers::HandlerEngine::with_pooling(sync_limits, 64)?
129 } else {
130 boatramp_handlers::HandlerEngine::new(sync_limits, 64)?
131 }
132 .with_async_limits(async_limits)
133 .with_streaming_limits(streaming_limits)
134 .with_outbound_timeout(outbound_timeout)
135 .with_private_egress(allow_guest_private_egress)
136 .with_self_egress(self_egress_addrs);
137 let sql = build_sql_backends(
138 handlers_cfg.and_then(|h| h.bindings.sql.as_ref()),
139 data_dir,
140 deploy,
141 &kv,
142 secrets_envelope.as_ref(),
143 )
144 .await?;
145 let messaging: Arc<dyn boatramp_core::messaging::Messaging> = messaging_override
148 .unwrap_or_else(|| {
149 Arc::new(boatramp_core::messaging::LogMessaging::new(
150 storage.clone(),
151 kv.clone(),
152 ))
153 });
154 let kv_for_secrets = kv.clone();
157 #[cfg(feature = "email")]
161 let kv_for_email = kv.clone();
162 #[cfg(feature = "email")]
163 let messaging_for_email = messaging.clone();
164 #[cfg(feature = "email")]
165 let email_envelope = secrets_envelope.clone();
166 let runtime =
167 boatramp_server::HandlerRuntime::new(engine, kv, storage, Some(sql), Some(messaging));
168 runtime.set_max_blob_bytes(max_blob_bytes);
170 runtime.set_max_component_bytes(max_component_bytes);
171 runtime.set_allow_env_secret_refs(allow_env_secret_refs);
173 runtime.set_tenancy_posture(require_tenancy_declaration, allow_cross_tenant_db);
175 if let Some(envelope) = secrets_envelope {
179 runtime.set_secret_store(Arc::new(boatramp_core::secret_store::SecretStore::new(
180 kv_for_secrets,
181 envelope,
182 )));
183 }
184 #[cfg(feature = "email")]
192 if allow_guest_email {
193 if let Some(envelope) = email_envelope {
194 let store = Arc::new(boatramp_core::email_config::EmailProfileStore::new(
195 kv_for_email,
196 envelope,
197 ));
198 let backend = Arc::new(boatramp_handlers::LettreBackend::new(
199 allow_guest_private_egress,
200 ));
201 let spool = boatramp_server::NodeEmailSpool::spawn(
202 backend,
203 Some(messaging_for_email),
204 store.clone(),
205 );
206 runtime.set_email_profile_store(store);
207 runtime.set_email_spool(spool);
208 }
209 }
210 Ok(runtime)
211}
212
213#[cfg(feature = "handlers")]
218async fn build_sql_backends(
219 cfg: Option<&crate::config::SqlBindingConfig>,
220 data_dir: &Path,
221 deploy: &DeployStore,
222 kv: &Arc<dyn KvStore>,
223 secrets_envelope: Option<&Arc<dyn KeyEnvelope>>,
224) -> Result<Arc<dyn boatramp_core::sql::SqlBackends>> {
225 let resolve_env = |var: &Option<String>| -> Result<Option<String>> {
226 match var {
227 Some(var) => Ok(Some(
228 std::env::var(var).map_err(|_| Error::SqlEnvUnset(var.clone()))?,
229 )),
230 None => Ok(None),
231 }
232 };
233
234 let backend = match cfg.and_then(|c| c.url.as_ref()) {
235 Some(url) => {
238 let cfg = cfg.expect("url implies cfg");
239 let admin_url = cfg.admin_url.as_ref().ok_or(Error::SqlAdminUrlRequired)?;
240 let token = resolve_env(&cfg.token_env)?.unwrap_or_default();
241 let admin_token = resolve_env(&cfg.admin_token_env)?;
242 let backends = boatramp_storage::LibsqlSqlBackends::remote(
243 url.clone(),
244 admin_url.clone(),
245 token,
246 admin_token,
247 );
248 match &cfg.replica_url {
250 Some(replica_url) => backends.with_read_replica(replica_url.clone()),
251 None => backends,
252 }
253 }
254 None => {
256 let dir = cfg
257 .and_then(|c| c.dir.clone())
258 .unwrap_or_else(|| data_dir.join("handlers-sql"));
259 boatramp_storage::LibsqlSqlBackends::local(dir)
260 }
261 };
262 let preview_mode = match cfg.and_then(|c| c.preview_mode.as_deref()) {
264 None | Some("empty") => boatramp_core::sql::PreviewSqlMode::Empty,
265 Some("branch") => boatramp_core::sql::PreviewSqlMode::Branch,
266 Some("shared") => boatramp_core::sql::PreviewSqlMode::Shared,
267 Some(other) => return Err(Error::UnknownPreviewMode(other.to_string())),
268 };
269 let preview_init = match cfg.and_then(|c| c.preview_init.as_ref()) {
270 Some(path) => {
271 Some(
272 std::fs::read_to_string(path).map_err(|err| Error::PreviewInitRead {
273 path: path.clone(),
274 source: err,
275 })?,
276 )
277 }
278 None => None,
279 };
280 let default: Arc<dyn boatramp_core::sql::SqlBackends> =
281 Arc::new(backend.with_preview_policy(preview_mode, preview_init));
282
283 let databases = cfg.map(|c| &c.databases);
287 if databases.is_none_or(std::collections::BTreeMap::is_empty) {
288 return Ok(default);
289 }
290 let databases = databases.expect("checked non-empty above");
291
292 #[cfg(any(feature = "sql-postgres", feature = "sql-mysql"))]
293 {
294 use boatramp_core::sql::SqlBackend;
295 use boatramp_storage::sql_sqlx::{
296 connect, CompositeSqlBackends, ExternalSqlKind, ExternalSqlOptions,
297 };
298 let timeout = |db: &crate::config::ExternalDatabaseConfig| {
299 db.connect_timeout_secs.map(std::time::Duration::from_secs)
300 };
301 let mut composite = CompositeSqlBackends::new(default);
302 for (name, db) in databases {
303 let kind = ExternalSqlKind::parse(&db.kind).ok_or_else(|| Error::SqlExternalKind {
304 name: name.clone(),
305 kind: db.kind.clone(),
306 })?;
307 if db.compute.as_deref().is_some_and(|c| !c.is_empty()) {
308 if let Some(var) = db.password_env.as_deref().filter(|v| !v.is_empty()) {
316 let workload = db.compute.as_deref().expect("compute checked above");
317 let password =
318 std::env::var(var).map_err(|_| Error::SqlEnvUnset(var.into()))?;
319 let resolver = Arc::new(crate::managed_sql::DeployEndpointResolver::new(
320 deploy.clone(),
321 boatramp_core::project::DEFAULT_PROJECT,
322 ));
323 let external: Arc<dyn SqlBackend> = Arc::new(
324 boatramp_storage::sql_compute::ComputeResolvedSqlBackend::new(
325 resolver,
326 workload,
327 kind,
328 db.database.clone().unwrap_or_default(),
329 db.user.clone().unwrap_or_default(),
330 password,
331 db.pool_max,
332 db.read_only,
333 timeout(db),
334 ),
335 );
336 composite = composite.with_external(name.clone(), external, db.allow_preview);
337 continue;
338 }
339 let envelope = secrets_envelope
342 .cloned()
343 .ok_or_else(|| Error::SqlManagedNeedsSecrets(name.clone()))?;
344 let resolver = crate::tenant_sql::NodeTenantSqlResolver::new(
345 deploy.clone(),
346 kv.clone(),
347 envelope,
348 db,
349 )
350 .expect("a compute-backed managed binding builds a per-tenant resolver");
351 let site_scoped = resolver.site_scoped();
352 composite = composite.with_per_tenant(
353 name.clone(),
354 Arc::new(resolver),
355 site_scoped,
356 db.allow_preview,
357 );
358 } else {
359 if db.url_env.trim().is_empty() {
362 return Err(Error::SqlExternalUrlEnvMissing(name.clone()));
363 }
364 let url = std::env::var(&db.url_env)
365 .map_err(|_| Error::SqlEnvUnset(db.url_env.clone()))?;
366 let read_url = match &db.read_url_env {
367 Some(var) => {
368 Some(std::env::var(var).map_err(|_| Error::SqlEnvUnset(var.clone()))?)
369 }
370 None => None,
371 };
372 let opts = ExternalSqlOptions::new(url)
373 .with_read_url(read_url)
374 .with_max_connections(db.pool_max)
375 .read_only(db.read_only)
376 .with_connect_timeout(timeout(db));
377 let external: Arc<dyn SqlBackend> =
378 connect(kind, &opts).map_err(|source| Error::SqlExternalConnect {
379 name: name.clone(),
380 source,
381 })?;
382 composite = composite.with_external(name.clone(), external, db.allow_preview);
383 }
384 }
385 Ok(Arc::new(composite))
386 }
387 #[cfg(not(any(feature = "sql-postgres", feature = "sql-mysql")))]
388 {
389 let _ = (deploy, kv, secrets_envelope);
392 let name = databases.keys().next().cloned().unwrap_or_default();
393 Err(Error::SqlExternalUnavailable(name))
394 }
395}
396
397#[cfg(not(feature = "handlers"))]
398#[allow(clippy::too_many_arguments)]
399pub async fn build_handler_runtime(
400 _kv: Arc<dyn KvStore>,
401 _storage: Arc<dyn boatramp_core::Storage>,
402 _data_dir: &Path,
403 _handlers_cfg: Option<&crate::config::HandlersConfig>,
404 _messaging_override: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
405 _max_blob_bytes: u64,
406 _max_component_bytes: u64,
407 _allow_guest_private_egress: bool,
408 _self_egress_addrs: Vec<std::net::SocketAddr>,
409 _allow_env_secret_refs: bool,
413 _allow_guest_email: bool,
414 _require_tenancy_declaration: bool,
415 _allow_cross_tenant_db: bool,
416 _deploy: &DeployStore,
417 _secrets_envelope: Option<Arc<dyn KeyEnvelope>>,
418) -> Result<boatramp_server::HandlerRuntime> {
419 Ok(boatramp_server::HandlerRuntime::disabled())
420}
421
422#[cfg(all(test, any(feature = "sql-postgres", feature = "sql-mysql")))]
423mod tests {
424 use super::*;
425 use std::result::Result;
428
429 use async_trait::async_trait;
430 use boatramp_core::envelope::EnvelopeError;
431 use boatramp_core::kv::MemoryKv;
432 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, Storage, StorageError};
433
434 struct TestEnvelope;
436 #[async_trait]
437 impl KeyEnvelope for TestEnvelope {
438 async fn wrap(&self, p: &[u8]) -> Result<Vec<u8>, EnvelopeError> {
439 Ok(p.iter().rev().copied().collect())
440 }
441 async fn unwrap(&self, w: &[u8]) -> Result<Vec<u8>, EnvelopeError> {
442 Ok(w.iter().rev().copied().collect())
443 }
444 }
445
446 struct NullStorage;
449 #[async_trait]
450 impl Storage for NullStorage {
451 async fn get(&self, _: &str) -> Result<GetObject, StorageError> {
452 Err(StorageError::NotFound(String::new()))
453 }
454 async fn get_range(
455 &self,
456 _: &str,
457 _: u64,
458 _: Option<u64>,
459 ) -> Result<GetObject, StorageError> {
460 Err(StorageError::NotFound(String::new()))
461 }
462 async fn put(
463 &self,
464 _: &str,
465 _: ByteStream,
466 _: PutMeta,
467 ) -> Result<ObjectMeta, StorageError> {
468 Err(StorageError::unsupported("null"))
469 }
470 async fn head(&self, _: &str) -> Result<ObjectMeta, StorageError> {
471 Err(StorageError::NotFound(String::new()))
472 }
473 async fn delete(&self, _: &str) -> Result<(), StorageError> {
474 Ok(())
475 }
476 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
477 Ok(Vec::new())
478 }
479 }
480
481 fn managed_sql_cfg() -> crate::config::SqlBindingConfig {
483 let mut databases = std::collections::BTreeMap::new();
484 databases.insert(
485 "analytics".to_string(),
486 crate::config::ExternalDatabaseConfig {
487 kind: "postgres".into(),
488 compute: Some("pg".into()),
489 database: Some("analytics".into()),
490 user: Some("app".into()),
491 ..Default::default()
492 },
493 );
494 crate::config::SqlBindingConfig {
495 databases,
496 ..Default::default()
497 }
498 }
499
500 #[tokio::test]
501 async fn managed_sql_fails_closed_without_secrets() {
502 let tmp = tempfile::tempdir().unwrap();
503 let deploy = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
504 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
505 let cfg = managed_sql_cfg();
506 match build_sql_backends(Some(&cfg), tmp.path(), &deploy, &kv, None).await {
508 Err(Error::SqlManagedNeedsSecrets(name)) => assert_eq!(name, "analytics"),
509 Ok(_) => panic!("a managed DB without [secrets] must fail closed, got Ok"),
510 Err(other) => panic!("expected SqlManagedNeedsSecrets, got: {other}"),
511 }
512 }
513
514 #[tokio::test]
515 async fn managed_sql_builds_lazily_and_seals_the_credential() {
516 let tmp = tempfile::tempdir().unwrap();
517 let deploy = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
518 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
519 let envelope: Arc<dyn KeyEnvelope> = Arc::new(TestEnvelope);
520 let cfg = managed_sql_cfg();
521 let backends = build_sql_backends(Some(&cfg), tmp.path(), &deploy, &kv, Some(&envelope))
526 .await
527 .expect("managed sql builds without a live DB (lazy connect)");
528 assert!(
529 kv.get("managed-sql-cred/default/pg")
530 .await
531 .unwrap()
532 .is_none(),
533 "nothing sealed at build — per-tenant credentials are minted on first open"
534 );
535
536 let _ = backends
540 .database("default", "blog", "analytics")
541 .await
542 .unwrap();
543 let sealed = kv
544 .get("managed-sql-cred/default/pg")
545 .await
546 .unwrap()
547 .expect("credential sealed on first resolve under the default project");
548 assert_ne!(sealed.len(), 0);
549 }
550}