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