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 deploy: &DeployStore,
67 secrets_envelope: Option<Arc<dyn KeyEnvelope>>,
68) -> Result<boatramp_server::HandlerRuntime> {
69 let defaults = boatramp_handlers::Limits::default();
79 let sync_limits = boatramp_handlers::Limits {
80 timeout_ms: handlers_cfg
81 .and_then(|h| h.sync_max_timeout_ms)
82 .unwrap_or(defaults.timeout_ms),
83 ..defaults
84 };
85 let async_limits = boatramp_handlers::Limits {
86 timeout_ms: handlers_cfg
87 .and_then(|h| h.async_max_timeout_ms)
88 .unwrap_or(DEFAULT_ASYNC_TIMEOUT_MS),
89 max_concurrency: handlers_cfg
90 .and_then(|h| h.async_max_concurrency)
91 .unwrap_or(DEFAULT_ASYNC_CONCURRENCY),
92 fuel: handlers_cfg.and_then(|h| h.async_max_fuel),
93 ..sync_limits
94 };
95 let streaming_limits = boatramp_handlers::Limits {
100 timeout_ms: handlers_cfg
101 .and_then(|h| h.streaming_max_timeout_ms)
102 .unwrap_or(DEFAULT_STREAMING_TIMEOUT_MS),
103 max_concurrency: handlers_cfg
104 .and_then(|h| h.streaming_max_concurrency)
105 .unwrap_or(DEFAULT_STREAMING_CONCURRENCY),
106 fuel: handlers_cfg.and_then(|h| h.streaming_max_fuel),
107 ..sync_limits
108 };
109 let outbound_timeout = handlers_cfg
110 .and_then(|h| h.outbound_timeout_ms)
111 .map(std::time::Duration::from_millis);
112 let engine = if handlers_cfg.is_some_and(|h| h.pooling) {
115 boatramp_handlers::HandlerEngine::with_pooling(sync_limits, 64)?
116 } else {
117 boatramp_handlers::HandlerEngine::new(sync_limits, 64)?
118 }
119 .with_async_limits(async_limits)
120 .with_streaming_limits(streaming_limits)
121 .with_outbound_timeout(outbound_timeout)
122 .with_private_egress(allow_guest_private_egress)
123 .with_self_egress(self_egress_addrs);
124 let sql = build_sql_backends(
125 handlers_cfg.and_then(|h| h.bindings.sql.as_ref()),
126 data_dir,
127 deploy,
128 &kv,
129 secrets_envelope.as_ref(),
130 )
131 .await?;
132 let messaging: Arc<dyn boatramp_core::messaging::Messaging> = messaging_override
135 .unwrap_or_else(|| {
136 Arc::new(boatramp_core::messaging::LogMessaging::new(
137 storage.clone(),
138 kv.clone(),
139 ))
140 });
141 let kv_for_secrets = kv.clone();
144 let runtime =
145 boatramp_server::HandlerRuntime::new(engine, kv, storage, Some(sql), Some(messaging));
146 runtime.set_max_blob_bytes(max_blob_bytes);
148 runtime.set_max_component_bytes(max_component_bytes);
149 runtime.set_allow_env_secret_refs(allow_env_secret_refs);
151 if let Some(envelope) = secrets_envelope {
155 runtime.set_secret_store(Arc::new(boatramp_core::secret_store::SecretStore::new(
156 kv_for_secrets,
157 envelope,
158 )));
159 }
160 Ok(runtime)
161}
162
163#[cfg(feature = "handlers")]
168async fn build_sql_backends(
169 cfg: Option<&crate::config::SqlBindingConfig>,
170 data_dir: &Path,
171 deploy: &DeployStore,
172 kv: &Arc<dyn KvStore>,
173 secrets_envelope: Option<&Arc<dyn KeyEnvelope>>,
174) -> Result<Arc<dyn boatramp_core::sql::SqlBackends>> {
175 let resolve_env = |var: &Option<String>| -> Result<Option<String>> {
176 match var {
177 Some(var) => Ok(Some(
178 std::env::var(var).map_err(|_| Error::SqlEnvUnset(var.clone()))?,
179 )),
180 None => Ok(None),
181 }
182 };
183
184 let backend = match cfg.and_then(|c| c.url.as_ref()) {
185 Some(url) => {
188 let cfg = cfg.expect("url implies cfg");
189 let admin_url = cfg.admin_url.as_ref().ok_or(Error::SqlAdminUrlRequired)?;
190 let token = resolve_env(&cfg.token_env)?.unwrap_or_default();
191 let admin_token = resolve_env(&cfg.admin_token_env)?;
192 let backends = boatramp_storage::LibsqlSqlBackends::remote(
193 url.clone(),
194 admin_url.clone(),
195 token,
196 admin_token,
197 );
198 match &cfg.replica_url {
200 Some(replica_url) => backends.with_read_replica(replica_url.clone()),
201 None => backends,
202 }
203 }
204 None => {
206 let dir = cfg
207 .and_then(|c| c.dir.clone())
208 .unwrap_or_else(|| data_dir.join("handlers-sql"));
209 boatramp_storage::LibsqlSqlBackends::local(dir)
210 }
211 };
212 let preview_mode = match cfg.and_then(|c| c.preview_mode.as_deref()) {
214 None | Some("empty") => boatramp_core::sql::PreviewSqlMode::Empty,
215 Some("branch") => boatramp_core::sql::PreviewSqlMode::Branch,
216 Some("shared") => boatramp_core::sql::PreviewSqlMode::Shared,
217 Some(other) => return Err(Error::UnknownPreviewMode(other.to_string())),
218 };
219 let preview_init = match cfg.and_then(|c| c.preview_init.as_ref()) {
220 Some(path) => {
221 Some(
222 std::fs::read_to_string(path).map_err(|err| Error::PreviewInitRead {
223 path: path.clone(),
224 source: err,
225 })?,
226 )
227 }
228 None => None,
229 };
230 let default: Arc<dyn boatramp_core::sql::SqlBackends> =
231 Arc::new(backend.with_preview_policy(preview_mode, preview_init));
232
233 let databases = cfg.map(|c| &c.databases);
237 if databases.is_none_or(std::collections::BTreeMap::is_empty) {
238 return Ok(default);
239 }
240 let databases = databases.expect("checked non-empty above");
241
242 #[cfg(any(feature = "sql-postgres", feature = "sql-mysql"))]
243 {
244 use boatramp_core::sql::SqlBackend;
245 use boatramp_storage::sql_sqlx::{
246 connect, CompositeSqlBackends, ExternalSqlKind, ExternalSqlOptions,
247 };
248 let timeout = |db: &crate::config::ExternalDatabaseConfig| {
249 db.connect_timeout_secs.map(std::time::Duration::from_secs)
250 };
251 let mut composite = CompositeSqlBackends::new(default);
252 for (name, db) in databases {
253 let kind = ExternalSqlKind::parse(&db.kind).ok_or_else(|| Error::SqlExternalKind {
254 name: name.clone(),
255 kind: db.kind.clone(),
256 })?;
257 if db.compute.as_deref().is_some_and(|c| !c.is_empty()) {
258 if let Some(var) = db.password_env.as_deref().filter(|v| !v.is_empty()) {
266 let workload = db.compute.as_deref().expect("compute checked above");
267 let password =
268 std::env::var(var).map_err(|_| Error::SqlEnvUnset(var.into()))?;
269 let resolver = Arc::new(crate::managed_sql::DeployEndpointResolver::new(
270 deploy.clone(),
271 boatramp_core::project::DEFAULT_PROJECT,
272 ));
273 let external: Arc<dyn SqlBackend> = Arc::new(
274 boatramp_storage::sql_compute::ComputeResolvedSqlBackend::new(
275 resolver,
276 workload,
277 kind,
278 db.database.clone().unwrap_or_default(),
279 db.user.clone().unwrap_or_default(),
280 password,
281 db.pool_max,
282 db.read_only,
283 timeout(db),
284 ),
285 );
286 composite = composite.with_external(name.clone(), external, db.allow_preview);
287 continue;
288 }
289 let envelope = secrets_envelope
292 .cloned()
293 .ok_or_else(|| Error::SqlManagedNeedsSecrets(name.clone()))?;
294 let resolver = crate::tenant_sql::NodeTenantSqlResolver::new(
295 deploy.clone(),
296 kv.clone(),
297 envelope,
298 db,
299 )
300 .expect("a compute-backed managed binding builds a per-tenant resolver");
301 let site_scoped = resolver.site_scoped();
302 composite = composite.with_per_tenant(
303 name.clone(),
304 Arc::new(resolver),
305 site_scoped,
306 db.allow_preview,
307 );
308 } else {
309 if db.url_env.trim().is_empty() {
312 return Err(Error::SqlExternalUrlEnvMissing(name.clone()));
313 }
314 let url = std::env::var(&db.url_env)
315 .map_err(|_| Error::SqlEnvUnset(db.url_env.clone()))?;
316 let read_url = match &db.read_url_env {
317 Some(var) => {
318 Some(std::env::var(var).map_err(|_| Error::SqlEnvUnset(var.clone()))?)
319 }
320 None => None,
321 };
322 let opts = ExternalSqlOptions::new(url)
323 .with_read_url(read_url)
324 .with_max_connections(db.pool_max)
325 .read_only(db.read_only)
326 .with_connect_timeout(timeout(db));
327 let external: Arc<dyn SqlBackend> =
328 connect(kind, &opts).map_err(|source| Error::SqlExternalConnect {
329 name: name.clone(),
330 source,
331 })?;
332 composite = composite.with_external(name.clone(), external, db.allow_preview);
333 }
334 }
335 Ok(Arc::new(composite))
336 }
337 #[cfg(not(any(feature = "sql-postgres", feature = "sql-mysql")))]
338 {
339 let _ = (deploy, kv, secrets_envelope);
342 let name = databases.keys().next().cloned().unwrap_or_default();
343 Err(Error::SqlExternalUnavailable(name))
344 }
345}
346
347#[cfg(not(feature = "handlers"))]
348#[allow(clippy::too_many_arguments)]
349pub async fn build_handler_runtime(
350 _kv: Arc<dyn KvStore>,
351 _storage: Arc<dyn boatramp_core::Storage>,
352 _data_dir: &Path,
353 _handlers_cfg: Option<&crate::config::HandlersConfig>,
354 _messaging_override: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
355 _max_blob_bytes: u64,
356 _max_component_bytes: u64,
357 _allow_guest_private_egress: bool,
358 _self_egress_addrs: Vec<std::net::SocketAddr>,
359 _allow_env_secret_refs: bool,
363 _deploy: &DeployStore,
364 _secrets_envelope: Option<Arc<dyn KeyEnvelope>>,
365) -> Result<boatramp_server::HandlerRuntime> {
366 Ok(boatramp_server::HandlerRuntime::disabled())
367}
368
369#[cfg(all(test, any(feature = "sql-postgres", feature = "sql-mysql")))]
370mod tests {
371 use super::*;
372 use std::result::Result;
375
376 use async_trait::async_trait;
377 use boatramp_core::envelope::EnvelopeError;
378 use boatramp_core::kv::MemoryKv;
379 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, Storage, StorageError};
380
381 struct TestEnvelope;
383 #[async_trait]
384 impl KeyEnvelope for TestEnvelope {
385 async fn wrap(&self, p: &[u8]) -> Result<Vec<u8>, EnvelopeError> {
386 Ok(p.iter().rev().copied().collect())
387 }
388 async fn unwrap(&self, w: &[u8]) -> Result<Vec<u8>, EnvelopeError> {
389 Ok(w.iter().rev().copied().collect())
390 }
391 }
392
393 struct NullStorage;
396 #[async_trait]
397 impl Storage for NullStorage {
398 async fn get(&self, _: &str) -> Result<GetObject, StorageError> {
399 Err(StorageError::NotFound(String::new()))
400 }
401 async fn get_range(
402 &self,
403 _: &str,
404 _: u64,
405 _: Option<u64>,
406 ) -> Result<GetObject, StorageError> {
407 Err(StorageError::NotFound(String::new()))
408 }
409 async fn put(
410 &self,
411 _: &str,
412 _: ByteStream,
413 _: PutMeta,
414 ) -> Result<ObjectMeta, StorageError> {
415 Err(StorageError::unsupported("null"))
416 }
417 async fn head(&self, _: &str) -> Result<ObjectMeta, StorageError> {
418 Err(StorageError::NotFound(String::new()))
419 }
420 async fn delete(&self, _: &str) -> Result<(), StorageError> {
421 Ok(())
422 }
423 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
424 Ok(Vec::new())
425 }
426 }
427
428 fn managed_sql_cfg() -> crate::config::SqlBindingConfig {
430 let mut databases = std::collections::BTreeMap::new();
431 databases.insert(
432 "analytics".to_string(),
433 crate::config::ExternalDatabaseConfig {
434 kind: "postgres".into(),
435 compute: Some("pg".into()),
436 database: Some("analytics".into()),
437 user: Some("app".into()),
438 ..Default::default()
439 },
440 );
441 crate::config::SqlBindingConfig {
442 databases,
443 ..Default::default()
444 }
445 }
446
447 #[tokio::test]
448 async fn managed_sql_fails_closed_without_secrets() {
449 let tmp = tempfile::tempdir().unwrap();
450 let deploy = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
451 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
452 let cfg = managed_sql_cfg();
453 match build_sql_backends(Some(&cfg), tmp.path(), &deploy, &kv, None).await {
455 Err(Error::SqlManagedNeedsSecrets(name)) => assert_eq!(name, "analytics"),
456 Ok(_) => panic!("a managed DB without [secrets] must fail closed, got Ok"),
457 Err(other) => panic!("expected SqlManagedNeedsSecrets, got: {other}"),
458 }
459 }
460
461 #[tokio::test]
462 async fn managed_sql_builds_lazily_and_seals_the_credential() {
463 let tmp = tempfile::tempdir().unwrap();
464 let deploy = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
465 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
466 let envelope: Arc<dyn KeyEnvelope> = Arc::new(TestEnvelope);
467 let cfg = managed_sql_cfg();
468 let backends = build_sql_backends(Some(&cfg), tmp.path(), &deploy, &kv, Some(&envelope))
473 .await
474 .expect("managed sql builds without a live DB (lazy connect)");
475 assert!(
476 kv.get("managed-sql-cred/default/pg")
477 .await
478 .unwrap()
479 .is_none(),
480 "nothing sealed at build — per-tenant credentials are minted on first open"
481 );
482
483 let _ = backends
487 .database("default", "blog", "analytics")
488 .await
489 .unwrap();
490 let sealed = kv
491 .get("managed-sql-cred/default/pg")
492 .await
493 .unwrap()
494 .expect("credential sealed on first resolve under the default project");
495 assert_ne!(sealed.len(), 0);
496 }
497}