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")]
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 deploy: &DeployStore,
34 secrets_envelope: Option<Arc<dyn KeyEnvelope>>,
35) -> Result<boatramp_server::HandlerRuntime> {
36 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 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 runtime.set_max_blob_bytes(max_blob_bytes);
65 runtime.set_max_component_bytes(max_component_bytes);
66 Ok(runtime)
67}
68
69#[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 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 match &cfg.replica_url {
106 Some(replica_url) => backends.with_read_replica(replica_url.clone()),
107 None => backends,
108 }
109 }
110 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 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 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 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 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 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 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 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 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 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 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 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 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 let _: Arc<dyn boatramp_core::sql::SqlBackends> = backends;
369 }
370}