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 deploy: &DeployStore,
59 secrets_envelope: Option<Arc<dyn KeyEnvelope>>,
60) -> Result<boatramp_server::HandlerRuntime> {
61 let defaults = boatramp_handlers::Limits::default();
71 let sync_limits = boatramp_handlers::Limits {
72 timeout_ms: handlers_cfg
73 .and_then(|h| h.sync_max_timeout_ms)
74 .unwrap_or(defaults.timeout_ms),
75 ..defaults
76 };
77 let async_limits = boatramp_handlers::Limits {
78 timeout_ms: handlers_cfg
79 .and_then(|h| h.async_max_timeout_ms)
80 .unwrap_or(DEFAULT_ASYNC_TIMEOUT_MS),
81 max_concurrency: handlers_cfg
82 .and_then(|h| h.async_max_concurrency)
83 .unwrap_or(DEFAULT_ASYNC_CONCURRENCY),
84 fuel: handlers_cfg.and_then(|h| h.async_max_fuel),
85 ..sync_limits
86 };
87 let streaming_limits = boatramp_handlers::Limits {
92 timeout_ms: handlers_cfg
93 .and_then(|h| h.streaming_max_timeout_ms)
94 .unwrap_or(DEFAULT_STREAMING_TIMEOUT_MS),
95 max_concurrency: handlers_cfg
96 .and_then(|h| h.streaming_max_concurrency)
97 .unwrap_or(DEFAULT_STREAMING_CONCURRENCY),
98 fuel: handlers_cfg.and_then(|h| h.streaming_max_fuel),
99 ..sync_limits
100 };
101 let outbound_timeout = handlers_cfg
102 .and_then(|h| h.outbound_timeout_ms)
103 .map(std::time::Duration::from_millis);
104 let engine = if handlers_cfg.is_some_and(|h| h.pooling) {
107 boatramp_handlers::HandlerEngine::with_pooling(sync_limits, 64)?
108 } else {
109 boatramp_handlers::HandlerEngine::new(sync_limits, 64)?
110 }
111 .with_async_limits(async_limits)
112 .with_streaming_limits(streaming_limits)
113 .with_outbound_timeout(outbound_timeout);
114 let sql = build_sql_backends(
115 handlers_cfg.and_then(|h| h.bindings.sql.as_ref()),
116 data_dir,
117 deploy,
118 &kv,
119 secrets_envelope.as_ref(),
120 )
121 .await?;
122 let messaging: Arc<dyn boatramp_core::messaging::Messaging> = messaging_override
125 .unwrap_or_else(|| {
126 Arc::new(boatramp_core::messaging::LogMessaging::new(
127 storage.clone(),
128 kv.clone(),
129 ))
130 });
131 let runtime =
132 boatramp_server::HandlerRuntime::new(engine, kv, storage, Some(sql), Some(messaging));
133 runtime.set_max_blob_bytes(max_blob_bytes);
135 runtime.set_max_component_bytes(max_component_bytes);
136 Ok(runtime)
137}
138
139#[cfg(feature = "handlers")]
144async fn build_sql_backends(
145 cfg: Option<&crate::config::SqlBindingConfig>,
146 data_dir: &Path,
147 deploy: &DeployStore,
148 kv: &Arc<dyn KvStore>,
149 secrets_envelope: Option<&Arc<dyn KeyEnvelope>>,
150) -> Result<Arc<dyn boatramp_core::sql::SqlBackends>> {
151 let resolve_env = |var: &Option<String>| -> Result<Option<String>> {
152 match var {
153 Some(var) => Ok(Some(
154 std::env::var(var).map_err(|_| Error::SqlEnvUnset(var.clone()))?,
155 )),
156 None => Ok(None),
157 }
158 };
159
160 let backend = match cfg.and_then(|c| c.url.as_ref()) {
161 Some(url) => {
164 let cfg = cfg.expect("url implies cfg");
165 let admin_url = cfg.admin_url.as_ref().ok_or(Error::SqlAdminUrlRequired)?;
166 let token = resolve_env(&cfg.token_env)?.unwrap_or_default();
167 let admin_token = resolve_env(&cfg.admin_token_env)?;
168 let backends = boatramp_storage::LibsqlSqlBackends::remote(
169 url.clone(),
170 admin_url.clone(),
171 token,
172 admin_token,
173 );
174 match &cfg.replica_url {
176 Some(replica_url) => backends.with_read_replica(replica_url.clone()),
177 None => backends,
178 }
179 }
180 None => {
182 let dir = cfg
183 .and_then(|c| c.dir.clone())
184 .unwrap_or_else(|| data_dir.join("handlers-sql"));
185 boatramp_storage::LibsqlSqlBackends::local(dir)
186 }
187 };
188 let preview_mode = match cfg.and_then(|c| c.preview_mode.as_deref()) {
190 None | Some("empty") => boatramp_core::sql::PreviewSqlMode::Empty,
191 Some("branch") => boatramp_core::sql::PreviewSqlMode::Branch,
192 Some("shared") => boatramp_core::sql::PreviewSqlMode::Shared,
193 Some(other) => return Err(Error::UnknownPreviewMode(other.to_string())),
194 };
195 let preview_init = match cfg.and_then(|c| c.preview_init.as_ref()) {
196 Some(path) => {
197 Some(
198 std::fs::read_to_string(path).map_err(|err| Error::PreviewInitRead {
199 path: path.clone(),
200 source: err,
201 })?,
202 )
203 }
204 None => None,
205 };
206 let default: Arc<dyn boatramp_core::sql::SqlBackends> =
207 Arc::new(backend.with_preview_policy(preview_mode, preview_init));
208
209 let databases = cfg.map(|c| &c.databases);
213 if databases.is_none_or(std::collections::BTreeMap::is_empty) {
214 return Ok(default);
215 }
216 let databases = databases.expect("checked non-empty above");
217
218 #[cfg(any(feature = "sql-postgres", feature = "sql-mysql"))]
219 {
220 use boatramp_core::project::DEFAULT_PROJECT;
221 use boatramp_core::sql::SqlBackend;
222 use boatramp_storage::sql_compute::ComputeResolvedSqlBackend;
223 use boatramp_storage::sql_sqlx::{
224 connect, CompositeSqlBackends, ExternalSqlKind, ExternalSqlOptions,
225 };
226 let timeout = |db: &crate::config::ExternalDatabaseConfig| {
227 db.connect_timeout_secs.map(std::time::Duration::from_secs)
228 };
229 let mut composite = CompositeSqlBackends::new(default);
230 for (name, db) in databases {
231 let kind = ExternalSqlKind::parse(&db.kind).ok_or_else(|| Error::SqlExternalKind {
232 name: name.clone(),
233 kind: db.kind.clone(),
234 })?;
235 let external: Arc<dyn SqlBackend> = if let Some(workload) =
236 db.compute.as_deref().filter(|c| !c.is_empty())
237 {
238 let password = match db.password_env.as_deref().filter(|v| !v.is_empty()) {
242 Some(var) => std::env::var(var).map_err(|_| Error::SqlEnvUnset(var.into()))?,
243 None => {
244 let envelope = secrets_envelope
247 .cloned()
248 .ok_or_else(|| Error::SqlManagedNeedsSecrets(name.clone()))?;
249 crate::managed_sql::ManagedSqlCredentials::new(kv.clone(), envelope)
250 .password(DEFAULT_PROJECT, workload)
251 .await
252 .map_err(|reason| Error::SqlManagedCredential {
253 name: name.clone(),
254 reason,
255 })?
256 }
257 };
258 let resolver = Arc::new(crate::managed_sql::DeployEndpointResolver::new(
259 deploy.clone(),
260 DEFAULT_PROJECT,
261 ));
262 Arc::new(ComputeResolvedSqlBackend::new(
263 resolver,
264 workload,
265 kind,
266 db.database.clone().unwrap_or_default(),
267 db.user.clone().unwrap_or_default(),
268 password,
269 db.pool_max,
270 db.read_only,
271 timeout(db),
272 ))
273 } else {
274 if db.url_env.trim().is_empty() {
277 return Err(Error::SqlExternalUrlEnvMissing(name.clone()));
278 }
279 let url = std::env::var(&db.url_env)
280 .map_err(|_| Error::SqlEnvUnset(db.url_env.clone()))?;
281 let read_url = match &db.read_url_env {
282 Some(var) => {
283 Some(std::env::var(var).map_err(|_| Error::SqlEnvUnset(var.clone()))?)
284 }
285 None => None,
286 };
287 let opts = ExternalSqlOptions::new(url)
288 .with_read_url(read_url)
289 .with_max_connections(db.pool_max)
290 .read_only(db.read_only)
291 .with_connect_timeout(timeout(db));
292 connect(kind, &opts).map_err(|source| Error::SqlExternalConnect {
293 name: name.clone(),
294 source,
295 })?
296 };
297 composite = composite.with_external(name.clone(), external, db.allow_preview);
298 }
299 Ok(Arc::new(composite))
300 }
301 #[cfg(not(any(feature = "sql-postgres", feature = "sql-mysql")))]
302 {
303 let _ = (deploy, kv, secrets_envelope);
306 let name = databases.keys().next().cloned().unwrap_or_default();
307 Err(Error::SqlExternalUnavailable(name))
308 }
309}
310
311#[cfg(not(feature = "handlers"))]
312#[allow(clippy::too_many_arguments)]
313pub async fn build_handler_runtime(
314 _kv: Arc<dyn KvStore>,
315 _storage: Arc<dyn boatramp_core::Storage>,
316 _data_dir: &Path,
317 _handlers_cfg: Option<&crate::config::HandlersConfig>,
318 _messaging_override: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
319 _max_blob_bytes: u64,
320 _max_component_bytes: u64,
321 _deploy: &DeployStore,
322 _secrets_envelope: Option<Arc<dyn KeyEnvelope>>,
323) -> Result<boatramp_server::HandlerRuntime> {
324 Ok(boatramp_server::HandlerRuntime::disabled())
325}
326
327#[cfg(all(test, any(feature = "sql-postgres", feature = "sql-mysql")))]
328mod tests {
329 use super::*;
330 use std::result::Result;
333
334 use async_trait::async_trait;
335 use boatramp_core::envelope::EnvelopeError;
336 use boatramp_core::kv::MemoryKv;
337 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, Storage, StorageError};
338
339 struct TestEnvelope;
341 #[async_trait]
342 impl KeyEnvelope for TestEnvelope {
343 async fn wrap(&self, p: &[u8]) -> Result<Vec<u8>, EnvelopeError> {
344 Ok(p.iter().rev().copied().collect())
345 }
346 async fn unwrap(&self, w: &[u8]) -> Result<Vec<u8>, EnvelopeError> {
347 Ok(w.iter().rev().copied().collect())
348 }
349 }
350
351 struct NullStorage;
354 #[async_trait]
355 impl Storage for NullStorage {
356 async fn get(&self, _: &str) -> Result<GetObject, StorageError> {
357 Err(StorageError::NotFound(String::new()))
358 }
359 async fn get_range(
360 &self,
361 _: &str,
362 _: u64,
363 _: Option<u64>,
364 ) -> Result<GetObject, StorageError> {
365 Err(StorageError::NotFound(String::new()))
366 }
367 async fn put(
368 &self,
369 _: &str,
370 _: ByteStream,
371 _: PutMeta,
372 ) -> Result<ObjectMeta, StorageError> {
373 Err(StorageError::unsupported("null"))
374 }
375 async fn head(&self, _: &str) -> Result<ObjectMeta, StorageError> {
376 Err(StorageError::NotFound(String::new()))
377 }
378 async fn delete(&self, _: &str) -> Result<(), StorageError> {
379 Ok(())
380 }
381 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
382 Ok(Vec::new())
383 }
384 }
385
386 fn managed_sql_cfg() -> crate::config::SqlBindingConfig {
388 let mut databases = std::collections::BTreeMap::new();
389 databases.insert(
390 "analytics".to_string(),
391 crate::config::ExternalDatabaseConfig {
392 kind: "postgres".into(),
393 compute: Some("pg".into()),
394 database: Some("analytics".into()),
395 user: Some("app".into()),
396 ..Default::default()
397 },
398 );
399 crate::config::SqlBindingConfig {
400 databases,
401 ..Default::default()
402 }
403 }
404
405 #[tokio::test]
406 async fn managed_sql_fails_closed_without_secrets() {
407 let tmp = tempfile::tempdir().unwrap();
408 let deploy = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
409 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
410 let cfg = managed_sql_cfg();
411 match build_sql_backends(Some(&cfg), tmp.path(), &deploy, &kv, None).await {
413 Err(Error::SqlManagedNeedsSecrets(name)) => assert_eq!(name, "analytics"),
414 Ok(_) => panic!("a managed DB without [secrets] must fail closed, got Ok"),
415 Err(other) => panic!("expected SqlManagedNeedsSecrets, got: {other}"),
416 }
417 }
418
419 #[tokio::test]
420 async fn managed_sql_builds_lazily_and_seals_the_credential() {
421 let tmp = tempfile::tempdir().unwrap();
422 let deploy = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
423 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
424 let envelope: Arc<dyn KeyEnvelope> = Arc::new(TestEnvelope);
425 let cfg = managed_sql_cfg();
426 let backends = build_sql_backends(Some(&cfg), tmp.path(), &deploy, &kv, Some(&envelope))
429 .await
430 .expect("managed sql builds without a live DB (lazy connect)");
431 let sealed = kv
432 .get("managed-sql-cred/default/pg")
433 .await
434 .unwrap()
435 .expect("managed credential sealed at build under the default project");
436 assert_ne!(sealed.len(), 0);
437 let _: Arc<dyn boatramp_core::sql::SqlBackends> = backends;
439 }
440}