Skip to main content

boatramp_server/
sql_shim.rs

1//! The compute **sql-shim** (PLAN-compute-bindings, Phase 0).
2//!
3//! An opaque compute workload (container / micro-VM) cannot import the WASI `sql`
4//! host interface a handler uses. This shim gives it the *same* per-tenant-scoped
5//! [`SqlBackend`] over a wire protocol: a single per-node HTTP endpoint speaking
6//! libsql's **hrana-over-HTTP `/v2/pipeline`** JSON, so an off-the-shelf libsql
7//! client in the guest connects with zero boatramp-specific code.
8//!
9//! ## Isolation
10//!
11//! Requests authenticate with `Authorization: Bearer <token>`; the token maps to an
12//! `Arc<dyn SqlBackend>` **already resolved for exactly one `project/site`** (via the
13//! same `SqlBackends::database` call a handler makes). The wire protocol has no
14//! "open database" verb — a request can only run statements against the backend its
15//! token was registered with — so a workload is structurally incapable of naming
16//! another tenant's data, exactly like the WASI handler. The token is boatramp-minted
17//! and instance-scoped; the operator's DB credentials never enter the guest.
18
19use 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/// The shim's token → resolved backend registry, shared with the HTTP handler.
36#[derive(Clone, Default)]
37pub struct SqlShim {
38    registry: Arc<RwLock<HashMap<String, Arc<dyn SqlBackend>>>>,
39}
40
41impl SqlShim {
42    /// A fresh, empty shim.
43    pub fn new() -> Self {
44        Self::default()
45    }
46
47    /// Register a bearer `token` for an already-resolved, tenant-scoped `backend`.
48    /// Idempotent: re-registering the same token replaces the mapping.
49    pub async fn register(&self, token: String, backend: Arc<dyn SqlBackend>) {
50        self.registry.write().await.insert(token, backend);
51    }
52
53    /// Drop a token when its workload replica is torn down.
54    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    /// The axum router: `POST /v2/pipeline` (+ `/v3/pipeline`), the hrana-over-HTTP
63    /// endpoint. Mount it on a listener bound to the guest-reachable gateway.
64    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
72/// Resolves a workload's `sql` [`ComputeBinding`]s to a shim endpoint + credential
73/// injected into the guest env. Holds the same `SqlBackends` provider a handler uses,
74/// the shim registry, the guest-reachable shim base URL, and a per-node secret used
75/// to derive a token that is **deterministic** (recomputable at release / re-register
76/// without persisting it) yet **unguessable** (keyed by the secret).
77pub struct SqlShimResolver {
78    provider: Arc<dyn SqlBackends>,
79    shim: SqlShim,
80    base_url: String,
81    secret: [u8; 32],
82}
83
84impl SqlShimResolver {
85    /// `base_url` is the shim URL reachable from the guest (e.g. the compute bridge
86    /// gateway); `secret` is a per-node random used only to key the token derivation.
87    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    /// A deterministic, unguessable bearer token for one `(project, workload, replica,
102    /// binding)` — `HMAC-SHA256(secret, project ∥ workload ∥ replica ∥ kind ∥ name)`.
103    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            // Phase 0 implements the `sql` kind; the others are reserved.
138            if binding.kind != BindingKind::Sql {
139                continue;
140            }
141            // The workload name is its site identity; resolve the SAME tenant-scoped
142            // backend a handler for that site would get.
143            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
180/// Activate the compute sql-shim: bind its listener and build the resolver to hand
181/// the compute reconcile. `shim_url` is the guest-reachable base URL (e.g.
182/// `http://10.0.0.1:8081`, or the docker bridge gateway); the shim binds
183/// `0.0.0.0:<port-from-url>`. Returns `None` — the feature stays off — when there is
184/// no sql provider, no `shim_url`, or the bind fails.
185pub 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            // TCP_NODELAY: the hrana-over-HTTP shim serves small JSON responses to
209            // the guest's `sql` binding over keep-alive; Nagle would add a ~40 ms
210            // delayed-ACK stall to each query. See `disable_nagle`.
211            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
228/// Extract the `Authorization: Bearer <token>` value.
229fn 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// ---- hrana wire types (the `/v2/pipeline` subset) ---------------------------
239
240#[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    /// Any other hrana request type (`batch`, `store_sql`, …) — unsupported in the
254    /// stateless subset; answered with an error result, not a hard failure.
255    #[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/// A hrana value. Integers are strings (JSON can't hold a full i64 exactly);
309/// booleans are folded to `0`/`1` (SQLite-family), matching the WASI binding.
310#[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
357/// `POST /v{2,3}/pipeline`: authenticate the bearer token to a resolved backend,
358/// then run each request against it. A statement error is a per-result error, not a
359/// transport failure (matching hrana). An unknown/missing token is `401`.
360async 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
403/// Run one statement in its own implicit transaction. `want_rows` routes to the
404/// backend's `query` (return rows) or `execute` (return the affected count) — the
405/// two `SqlTransaction` methods.
406async 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, &params).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, &params).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    /// The statements (sql + decoded params) a fake backend observed.
465    type Seen = Arc<Mutex<Vec<(String, Vec<SqlValue>)>>>;
466
467    /// An in-memory backend: `execute` records the (sql, params) it saw and returns a
468    /// fixed affected count; `query` returns a fixed 1x1 row. Enough to prove the wire
469    /// mapping without a real database.
470    #[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    /// A provider that hands out a fresh fake backend for any (project, site, name).
515    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        // want_rows: true → query → the fake's 1x1 row comes back hrana-encoded.
571        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"); // close
587
588        // The bound backend saw the statement + the decoded arg.
589        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        // The shim now authorizes that token …
636        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        // … resolving again is idempotent (same deterministic token) …
642        assert_eq!(
643            resolver.resolve("acme", "api", 0, &bindings).await,
644            env,
645            "token derivation is deterministic"
646        );
647        // … and release revokes it.
648        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}