1use std::collections::HashMap;
20use std::sync::Arc;
21
22use axum::extract::State;
23use axum::http::{HeaderMap, StatusCode};
24use axum::response::{IntoResponse, Response};
25use axum::routing::post;
26use axum::{Json, Router};
27use base64::Engine as _;
28use boatramp_core::compute::{BindingKind, ComputeBinding, ComputeBindingResolver};
29use boatramp_core::sql::{SqlBackend, SqlBackends, SqlValue};
30use hmac::{Hmac, Mac};
31use serde::{Deserialize, Serialize};
32use sha2::Sha256;
33use tokio::sync::RwLock;
34
35#[derive(Clone, Default)]
37pub struct SqlShim {
38 registry: Arc<RwLock<HashMap<String, Arc<dyn SqlBackend>>>>,
39}
40
41impl SqlShim {
42 pub fn new() -> Self {
44 Self::default()
45 }
46
47 pub async fn register(&self, token: String, backend: Arc<dyn SqlBackend>) {
50 self.registry.write().await.insert(token, backend);
51 }
52
53 pub async fn deregister(&self, token: &str) {
55 self.registry.write().await.remove(token);
56 }
57
58 async fn lookup(&self, token: &str) -> Option<Arc<dyn SqlBackend>> {
59 self.registry.read().await.get(token).cloned()
60 }
61
62 pub fn router(&self) -> Router {
65 Router::new()
66 .route("/v2/pipeline", post(pipeline))
67 .route("/v3/pipeline", post(pipeline))
68 .with_state(self.clone())
69 }
70}
71
72pub struct SqlShimResolver {
78 provider: Arc<dyn SqlBackends>,
79 shim: SqlShim,
80 base_url: String,
81 secret: [u8; 32],
82}
83
84impl SqlShimResolver {
85 pub fn new(
88 provider: Arc<dyn SqlBackends>,
89 shim: SqlShim,
90 base_url: String,
91 secret: [u8; 32],
92 ) -> Self {
93 Self {
94 provider,
95 shim,
96 base_url,
97 secret,
98 }
99 }
100
101 fn token(
104 &self,
105 project: &str,
106 workload: &str,
107 replica: u32,
108 binding: &ComputeBinding,
109 ) -> String {
110 let mut mac =
111 Hmac::<Sha256>::new_from_slice(&self.secret).expect("hmac accepts any key len");
112 for part in [
113 project.as_bytes(),
114 workload.as_bytes(),
115 binding.name.as_bytes(),
116 ] {
117 mac.update(part);
118 mac.update(&[0]);
119 }
120 mac.update(&replica.to_le_bytes());
121 mac.update(format!("{:?}", binding.kind).as_bytes());
122 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
123 }
124}
125
126#[async_trait::async_trait]
127impl ComputeBindingResolver for SqlShimResolver {
128 async fn resolve(
129 &self,
130 project: &str,
131 workload: &str,
132 replica: u32,
133 bindings: &[ComputeBinding],
134 ) -> Vec<(String, String)> {
135 let mut env = Vec::new();
136 for binding in bindings {
137 if binding.kind != BindingKind::Sql {
139 continue;
140 }
141 let backend = match self
144 .provider
145 .database(project, workload, &binding.name)
146 .await
147 {
148 Ok(backend) => backend,
149 Err(err) => {
150 tracing::warn!(%project, %workload, error = %err, "sql binding: resolve failed");
151 continue;
152 }
153 };
154 let token = self.token(project, workload, replica, binding);
155 self.shim.register(token.clone(), backend).await;
156 let url_env = binding.url_env();
157 env.push((url_env.clone(), self.base_url.clone()));
158 env.push((format!("{url_env}_AUTH_TOKEN"), token));
159 }
160 env
161 }
162
163 async fn release(
164 &self,
165 project: &str,
166 workload: &str,
167 replica: u32,
168 bindings: &[ComputeBinding],
169 ) {
170 for binding in bindings {
171 if binding.kind == BindingKind::Sql {
172 self.shim
173 .deregister(&self.token(project, workload, replica, binding))
174 .await;
175 }
176 }
177 }
178}
179
180pub async fn spawn_sql_shim(
186 sql: Option<Arc<dyn SqlBackends>>,
187 shim_url: Option<String>,
188) -> Option<Arc<dyn ComputeBindingResolver>> {
189 let sql = sql?;
190 let base_url = shim_url?;
191 let Some(port) = base_url
192 .rsplit_once(':')
193 .and_then(|(_, p)| p.trim_end_matches('/').parse::<u16>().ok())
194 else {
195 tracing::warn!(%base_url, "compute.sql_shim_url has no :port; sql bindings disabled");
196 return None;
197 };
198 let mut secret = [0u8; 32];
199 if getrandom::getrandom(&mut secret).is_err() {
200 tracing::error!("getrandom failed; sql bindings disabled");
201 return None;
202 }
203 let shim = SqlShim::new();
204 let router = shim.router();
205 let bind = std::net::SocketAddr::from(([0, 0, 0, 0], port));
206 match tokio::net::TcpListener::bind(bind).await {
207 Ok(listener) => {
208 use axum::serve::ListenerExt;
212 let listener = listener.tap_io(crate::disable_nagle);
213 tokio::spawn(async move {
214 if let Err(err) = axum::serve(listener, router).await {
215 tracing::error!(error = %err, "compute sql-shim listener exited");
216 }
217 });
218 tracing::info!(%bind, %base_url, "compute sql-shim listening");
219 }
220 Err(err) => {
221 tracing::warn!(%bind, error = %err, "compute sql-shim bind failed; sql bindings disabled");
222 return None;
223 }
224 }
225 Some(Arc::new(SqlShimResolver::new(sql, shim, base_url, secret)))
226}
227
228fn bearer(headers: &HeaderMap) -> Option<String> {
230 headers
231 .get(axum::http::header::AUTHORIZATION)?
232 .to_str()
233 .ok()?
234 .strip_prefix("Bearer ")
235 .map(|s| s.trim().to_string())
236}
237
238#[derive(Deserialize)]
241struct PipelineReq {
242 #[serde(default)]
243 requests: Vec<StreamRequest>,
244}
245
246#[derive(Deserialize)]
247#[serde(tag = "type", rename_all = "snake_case")]
248enum StreamRequest {
249 Execute {
250 stmt: Stmt,
251 },
252 Close,
253 #[serde(other)]
256 Unsupported,
257}
258
259#[derive(Deserialize)]
260struct Stmt {
261 sql: Option<String>,
262 #[serde(default)]
263 args: Vec<Value>,
264 #[serde(default)]
265 want_rows: bool,
266}
267
268#[derive(Serialize)]
269struct PipelineResp {
270 baton: Option<String>,
271 base_url: Option<String>,
272 results: Vec<StreamResult>,
273}
274
275#[derive(Serialize)]
276#[serde(tag = "type", rename_all = "snake_case")]
277enum StreamResult {
278 Ok { response: HranaResponse },
279 Error { error: HranaError },
280}
281
282#[derive(Serialize)]
283#[serde(tag = "type", rename_all = "snake_case")]
284enum HranaResponse {
285 Execute { result: StmtResult },
286 Close,
287}
288
289#[derive(Serialize)]
290struct StmtResult {
291 cols: Vec<Col>,
292 rows: Vec<Vec<Value>>,
293 affected_row_count: u64,
294 last_insert_rowid: Option<String>,
295}
296
297#[derive(Serialize)]
298struct Col {
299 name: Option<String>,
300 decltype: Option<String>,
301}
302
303#[derive(Serialize)]
304struct HranaError {
305 message: String,
306}
307
308#[derive(Serialize, Deserialize)]
311#[serde(tag = "type", rename_all = "snake_case")]
312enum Value {
313 Null,
314 Integer { value: String },
315 Float { value: f64 },
316 Text { value: String },
317 Blob { base64: String },
318}
319
320impl Value {
321 fn from_sql(v: &SqlValue) -> Self {
322 match v {
323 SqlValue::Null => Self::Null,
324 SqlValue::Boolean(b) => Self::Integer {
325 value: (i64::from(*b)).to_string(),
326 },
327 SqlValue::Integer(i) => Self::Integer {
328 value: i.to_string(),
329 },
330 SqlValue::Real(f) => Self::Float { value: *f },
331 SqlValue::Text(s) => Self::Text { value: s.clone() },
332 SqlValue::Blob(b) => Self::Blob {
333 base64: base64::engine::general_purpose::STANDARD.encode(b),
334 },
335 }
336 }
337
338 fn to_sql(&self) -> Result<SqlValue, String> {
339 Ok(match self {
340 Self::Null => SqlValue::Null,
341 Self::Integer { value } => SqlValue::Integer(
342 value
343 .parse()
344 .map_err(|_| "invalid integer arg".to_string())?,
345 ),
346 Self::Float { value } => SqlValue::Real(*value),
347 Self::Text { value } => SqlValue::Text(value.clone()),
348 Self::Blob { base64 } => SqlValue::Blob(
349 base64::engine::general_purpose::STANDARD
350 .decode(base64)
351 .map_err(|_| "invalid base64 blob arg".to_string())?,
352 ),
353 })
354 }
355}
356
357async fn pipeline(
361 State(shim): State<SqlShim>,
362 headers: HeaderMap,
363 Json(req): Json<PipelineReq>,
364) -> Response {
365 let Some(token) = bearer(&headers) else {
366 return (StatusCode::UNAUTHORIZED, "missing bearer token\n").into_response();
367 };
368 let Some(backend) = shim.lookup(&token).await else {
369 return (StatusCode::UNAUTHORIZED, "unknown token\n").into_response();
370 };
371
372 let mut results = Vec::with_capacity(req.requests.len());
373 for request in req.requests {
374 let result = match request {
375 StreamRequest::Close => StreamResult::Ok {
376 response: HranaResponse::Close,
377 },
378 StreamRequest::Unsupported => StreamResult::Error {
379 error: HranaError {
380 message: "unsupported request type in the stateless pipeline".to_string(),
381 },
382 },
383 StreamRequest::Execute { stmt } => match run_stmt(backend.as_ref(), stmt).await {
384 Ok(result) => StreamResult::Ok {
385 response: HranaResponse::Execute { result },
386 },
387 Err(message) => StreamResult::Error {
388 error: HranaError { message },
389 },
390 },
391 };
392 results.push(result);
393 }
394
395 Json(PipelineResp {
396 baton: None,
397 base_url: None,
398 results,
399 })
400 .into_response()
401}
402
403async fn run_stmt(backend: &dyn SqlBackend, stmt: Stmt) -> Result<StmtResult, String> {
407 let sql = stmt.sql.ok_or_else(|| "statement has no sql".to_string())?;
408 let params: Vec<SqlValue> = stmt
409 .args
410 .iter()
411 .map(Value::to_sql)
412 .collect::<Result<_, _>>()?;
413
414 let mut tx = backend.begin().await.map_err(|e| e.to_string())?;
415 if stmt.want_rows {
416 let rows = tx.query(&sql, ¶ms).await.map_err(|e| e.to_string());
417 let rows = match rows {
418 Ok(rows) => rows,
419 Err(e) => return Err(e),
420 };
421 tx.commit().await.map_err(|e| e.to_string())?;
422 Ok(StmtResult {
423 cols: rows
424 .columns
425 .iter()
426 .map(|c| Col {
427 name: Some(c.clone()),
428 decltype: None,
429 })
430 .collect(),
431 rows: rows
432 .rows
433 .iter()
434 .map(|row| row.iter().map(Value::from_sql).collect())
435 .collect(),
436 affected_row_count: 0,
437 last_insert_rowid: None,
438 })
439 } else {
440 let affected = match tx.execute(&sql, ¶ms).await.map_err(|e| e.to_string()) {
441 Ok(n) => n,
442 Err(e) => return Err(e),
443 };
444 tx.commit().await.map_err(|e| e.to_string())?;
445 Ok(StmtResult {
446 cols: vec![],
447 rows: vec![],
448 affected_row_count: affected,
449 last_insert_rowid: None,
450 })
451 }
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457 use async_trait::async_trait;
458 use axum::body::Body;
459 use axum::http::Request;
460 use boatramp_core::sql::{SqlError, SqlRows, SqlTransaction};
461 use std::sync::Mutex;
462 use tower::ServiceExt as _;
463
464 type Seen = Arc<Mutex<Vec<(String, Vec<SqlValue>)>>>;
466
467 #[derive(Default)]
471 struct FakeBackend {
472 seen: Seen,
473 }
474 struct FakeTx {
475 seen: Seen,
476 }
477
478 #[async_trait]
479 impl SqlBackend for FakeBackend {
480 async fn begin(&self) -> Result<Box<dyn SqlTransaction>, SqlError> {
481 Ok(Box::new(FakeTx {
482 seen: self.seen.clone(),
483 }))
484 }
485 }
486
487 #[async_trait]
488 impl SqlTransaction for FakeTx {
489 async fn query(&mut self, sql: &str, params: &[SqlValue]) -> Result<SqlRows, SqlError> {
490 self.seen
491 .lock()
492 .unwrap()
493 .push((sql.to_string(), params.to_vec()));
494 Ok(SqlRows {
495 columns: vec!["n".to_string()],
496 rows: vec![vec![SqlValue::Integer(42)]],
497 })
498 }
499 async fn execute(&mut self, sql: &str, params: &[SqlValue]) -> Result<u64, SqlError> {
500 self.seen
501 .lock()
502 .unwrap()
503 .push((sql.to_string(), params.to_vec()));
504 Ok(7)
505 }
506 async fn commit(self: Box<Self>) -> Result<(), SqlError> {
507 Ok(())
508 }
509 async fn rollback(self: Box<Self>) -> Result<(), SqlError> {
510 Ok(())
511 }
512 }
513
514 struct FakeProvider;
516 #[async_trait]
517 impl SqlBackends for FakeProvider {
518 async fn database(
519 &self,
520 _project: &str,
521 _site: &str,
522 _name: &str,
523 ) -> Result<Arc<dyn SqlBackend>, SqlError> {
524 Ok(Arc::new(FakeBackend::default()))
525 }
526 }
527
528 async fn post(
529 shim: &SqlShim,
530 token: Option<&str>,
531 body: serde_json::Value,
532 ) -> (StatusCode, serde_json::Value) {
533 let mut builder = Request::builder()
534 .method("POST")
535 .uri("/v2/pipeline")
536 .header("content-type", "application/json");
537 if let Some(t) = token {
538 builder = builder.header("authorization", format!("Bearer {t}"));
539 }
540 let req = builder.body(Body::from(body.to_string())).unwrap();
541 let resp = shim.router().oneshot(req).await.unwrap();
542 let status = resp.status();
543 let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20)
544 .await
545 .unwrap();
546 let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null);
547 (status, json)
548 }
549
550 #[tokio::test]
551 async fn unknown_or_missing_token_is_unauthorized() {
552 let shim = SqlShim::new();
553 let body = serde_json::json!({ "requests": [{ "type": "close" }] });
554 assert_eq!(
555 post(&shim, None, body.clone()).await.0,
556 StatusCode::UNAUTHORIZED
557 );
558 assert_eq!(
559 post(&shim, Some("nope"), body).await.0,
560 StatusCode::UNAUTHORIZED
561 );
562 }
563
564 #[tokio::test]
565 async fn a_registered_token_runs_a_query_and_maps_values() {
566 let shim = SqlShim::new();
567 let backend = Arc::new(FakeBackend::default());
568 shim.register("tok".to_string(), backend.clone()).await;
569
570 let body = serde_json::json!({
572 "requests": [
573 { "type": "execute", "stmt": {
574 "sql": "SELECT n WHERE x = ?",
575 "args": [{ "type": "text", "value": "hi" }],
576 "want_rows": true }},
577 { "type": "close" }
578 ]
579 });
580 let (status, json) = post(&shim, Some("tok"), body).await;
581 assert_eq!(status, StatusCode::OK);
582 let result = &json["results"][0]["response"]["result"];
583 assert_eq!(result["cols"][0]["name"], "n");
584 assert_eq!(result["rows"][0][0]["type"], "integer");
585 assert_eq!(result["rows"][0][0]["value"], "42");
586 assert_eq!(json["results"][1]["type"], "ok"); let seen = backend.seen.lock().unwrap();
590 assert_eq!(seen[0].0, "SELECT n WHERE x = ?");
591 assert_eq!(seen[0].1, vec![SqlValue::Text("hi".to_string())]);
592 }
593
594 #[tokio::test]
595 async fn want_rows_false_routes_to_execute_and_returns_affected() {
596 let shim = SqlShim::new();
597 shim.register("tok".to_string(), Arc::new(FakeBackend::default()))
598 .await;
599 let body = serde_json::json!({
600 "requests": [
601 { "type": "execute", "stmt": { "sql": "INSERT INTO t VALUES (1)", "want_rows": false }}
602 ]
603 });
604 let (status, json) = post(&shim, Some("tok"), body).await;
605 assert_eq!(status, StatusCode::OK);
606 assert_eq!(
607 json["results"][0]["response"]["result"]["affected_row_count"],
608 7
609 );
610 }
611
612 #[tokio::test]
613 async fn resolver_registers_a_working_token_and_release_revokes() {
614 let shim = SqlShim::new();
615 let resolver = SqlShimResolver::new(
616 Arc::new(FakeProvider),
617 shim.clone(),
618 "http://10.0.0.1:9999".to_string(),
619 [7u8; 32],
620 );
621 let bindings = vec![ComputeBinding {
622 kind: BindingKind::Sql,
623 name: String::new(),
624 url_env: None,
625 }];
626
627 let env = resolver.resolve("acme", "api", 0, &bindings).await;
628 let get = |k: &str| env.iter().find(|(key, _)| key == k).map(|(_, v)| v.clone());
629 assert_eq!(
630 get("BOATRAMP_SQL_URL").as_deref(),
631 Some("http://10.0.0.1:9999")
632 );
633 let token = get("BOATRAMP_SQL_URL_AUTH_TOKEN").expect("token env is injected");
634
635 let body = serde_json::json!({ "requests": [{ "type": "close" }] });
637 assert_eq!(
638 post(&shim, Some(&token), body.clone()).await.0,
639 StatusCode::OK
640 );
641 assert_eq!(
643 resolver.resolve("acme", "api", 0, &bindings).await,
644 env,
645 "token derivation is deterministic"
646 );
647 resolver.release("acme", "api", 0, &bindings).await;
649 assert_eq!(
650 post(&shim, Some(&token), body).await.0,
651 StatusCode::UNAUTHORIZED
652 );
653 }
654
655 #[tokio::test]
656 async fn deregister_revokes_access() {
657 let shim = SqlShim::new();
658 shim.register("tok".to_string(), Arc::new(FakeBackend::default()))
659 .await;
660 shim.deregister("tok").await;
661 let body = serde_json::json!({ "requests": [{ "type": "close" }] });
662 assert_eq!(
663 post(&shim, Some("tok"), body).await.0,
664 StatusCode::UNAUTHORIZED
665 );
666 }
667}