boatramp_node/default_scope.rs
1//! Substrate-complete default-scope readiness (v0.31.0 Finding 8).
2//!
3//! The KV re-home + the serve version-guard that `boatramp_core::migrate` owns are KV-only. A
4//! default project's data also lives in BLOB (`hblob/`, `mqp/`, `mqgp/`) and LIBSQL (`{dir}/{site}.db`),
5//! which the KV version cannot observe — so a blob-only or SQL-only default project could pass the KV
6//! guard as `Ready` while its data strands (HIGH-1/2/3 of the security review). These node-level
7//! detectors make the re-home observable across those substrates so the serve guard and
8//! `boatramp migrate --finalize` can refuse/warn/sequence until ALL substrates are re-homed.
9//!
10//! The critical asymmetry: BLOB has a runtime read-fallback during the KV dual soak (a cold object is
11//! bridged), so an outstanding blob is a WARN while the soak is live. LIBSQL has **no** runtime
12//! fallback — `database("default", site)` would open the non-existent `{dir}/default/{site}.db` and
13//! auto-create an EMPTY database — so an un-re-homed default libsql database is a hard **REFUSE**.
14
15use std::collections::HashSet;
16use std::path::Path;
17use std::sync::Arc;
18
19use boatramp_core::project::DEFAULT_PROJECT;
20use boatramp_core::{Storage, StorageError};
21
22/// The guest-blob data families the default-scope re-home covers (mirrors
23/// [`crate::blobs::DEFAULT_SCOPE_BLOB_FAMILIES`]).
24const BLOB_FAMILIES: &[&str] = &["hblob/", "mqp/", "mqgp/"];
25
26/// Control-plane KV marker set by `boatramp blob rehome --finalize` once the blob substrate is
27/// verified retired (no bare default-scoped object remains), and REQUIRED by `boatramp migrate
28/// --finalize` before it clears the KV dual soak (which disengages the runtime blob read-fallback).
29/// The explicit "I retired the blob substrate" signal — set by the command that holds the blob
30/// backend (and resolves its credentials), read by the KV finalize that does not. A node with no
31/// blob data still sets it by running `blob rehome --finalize` once (a 0-object no-op).
32pub const BLOB_RETIRED_MARKER: &str = "schema/default-scope-blob-retired";
33
34/// Un-re-homed default-project **libsql** identities still at the bare (pre-`default/`) on-disk
35/// layout under `libsql_dir`. A default site's data moves from `{dir}/{site}.db` (and named DBs from
36/// `{dir}/{site}/{name}.db`, functions from `{dir}/fn/{name}.db`) to under `{dir}/default/…`; so:
37///
38/// - a top-level `*.db` FILE is a default site's default DB, un-re-homed;
39/// - a top-level SUBDIR whose name is neither the reserved `default` (already re-homed) nor a KNOWN
40/// project (`projects`, that project's own DBs) holds a default site/function's named/preview DBs,
41/// un-re-homed.
42///
43/// Empty ⇒ nothing bare (re-homed, or no libsql dir). Returned identities are human strings for the
44/// refusal message. This is why the serve guard must REFUSE (not just warn) on a non-empty result:
45/// libsql has no runtime fallback, so serving would boot a default-project SQL workload onto an
46/// empty database.
47pub fn libsql_bare_default_idents(libsql_dir: &Path, projects: &HashSet<String>) -> Vec<String> {
48 let mut out = Vec::new();
49 let Ok(entries) = std::fs::read_dir(libsql_dir) else {
50 return out; // no libsql dir ⇒ nothing bare
51 };
52 for entry in entries.flatten() {
53 let name = entry.file_name().to_string_lossy().to_string();
54 let Ok(ft) = entry.file_type() else { continue };
55 if ft.is_file() {
56 // `{site}.db` (the `-wal`/`-shm` sidecars end in `.db-wal`/`.db-shm`, not `.db`).
57 if let Some(site) = name.strip_suffix(".db")
58 && !site.is_empty()
59 {
60 out.push(format!("site `{site}` ({})", entry.path().display()));
61 }
62 } else if ft.is_dir() && name != DEFAULT_PROJECT && !projects.contains(&name) {
63 // A default site/function subdir (named/preview DBs). A known project's subdir and the
64 // re-homed `default/` subdir are left alone.
65 out.push(format!(
66 "`{name}/` named/preview databases ({})",
67 entry.path().display()
68 ));
69 }
70 }
71 out.sort();
72 out
73}
74
75/// Whether any BARE default-scoped blob object remains under `hblob/`/`mqp/`/`mqgp/` on `storage` —
76/// i.e. a key whose scope segment is neither the reserved `default` (already re-homed) nor a KNOWN
77/// project (`projects`, that project's own object). Short-circuits on the first such key. Used by
78/// `boatramp migrate --finalize` to REFUSE clearing the KV dual soak (which disengages the runtime
79/// blob read-fallback) until the blob substrate is drained — otherwise a cold default object would
80/// 404 after finalize.
81pub async fn blob_default_outstanding(
82 storage: &Arc<dyn Storage>,
83 projects: &HashSet<String>,
84) -> Result<bool, StorageError> {
85 for family in BLOB_FAMILIES {
86 for meta in storage.list(family).await? {
87 let Some(rest) = meta.key.strip_prefix(family) else {
88 continue;
89 };
90 let seg = rest.split('/').next().unwrap_or("");
91 if !seg.is_empty() && seg != DEFAULT_PROJECT && !projects.contains(seg) {
92 return Ok(true); // a bare default-scoped object remains
93 }
94 }
95 }
96 Ok(false)
97}
98
99/// Read the `projectmeta/` project-name set (including the reserved `default`) from the control-plane
100/// KV — the set both detectors use to tell a bare default scope from a named project.
101pub async fn known_projects(
102 kv: &dyn boatramp_core::kv::KvStore,
103) -> Result<HashSet<String>, boatramp_core::error::KvError> {
104 let mut set = HashSet::new();
105 for key in kv
106 .list_prefix(boatramp_core::project::POINTER_PREFIX)
107 .await?
108 {
109 if let Some(name) = key.strip_prefix(boatramp_core::project::POINTER_PREFIX)
110 && !name.is_empty()
111 && !name.contains('/')
112 {
113 set.insert(name.to_string());
114 }
115 }
116 Ok(set)
117}
118
119/// The libsql data directory for a node config's `handlers.bindings.sql` (single-node), mirroring
120/// `handlers::build_sql_backends`: `handlers.bindings.sql.dir`, else `{data_dir}/handlers-sql`. Only
121/// meaningful for a LOCAL (non-`url`) libsql binding — a remote sqld has no local directory.
122pub fn libsql_dir_for(
123 config: &crate::config::ServerConfig,
124 data_dir: &Path,
125) -> Option<std::path::PathBuf> {
126 let sql = config
127 .handlers
128 .as_ref()
129 .and_then(|h| h.bindings.sql.as_ref());
130 // A remote sqld binding has no local directory to scan.
131 if sql.and_then(|c| c.url.as_ref()).is_some() {
132 return None;
133 }
134 Some(
135 sql.and_then(|c| c.dir.clone())
136 .unwrap_or_else(|| data_dir.join("handlers-sql")),
137 )
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143
144 #[test]
145 fn libsql_detector_flags_bare_default_and_skips_rehomed_and_projects() {
146 let tmp = tempfile::tempdir().unwrap();
147 let dir = tmp.path();
148 // Bare default site default DB (+ sidecar, which must be ignored).
149 std::fs::write(dir.join("blog.db"), b"x").unwrap();
150 std::fs::write(dir.join("blog.db-wal"), b"x").unwrap();
151 // A default function's named-DB subdir (fn/ is neither default nor a project).
152 std::fs::create_dir_all(dir.join("fn")).unwrap();
153 std::fs::write(dir.join("fn").join("resize.db"), b"x").unwrap();
154 // Already re-homed (default/) — must be skipped.
155 std::fs::create_dir_all(dir.join("default")).unwrap();
156 std::fs::write(dir.join("default").join("shop.db"), b"x").unwrap();
157 // A named project's subdir — must be skipped.
158 std::fs::create_dir_all(dir.join("acme")).unwrap();
159 std::fs::write(dir.join("acme").join("blog.db"), b"x").unwrap();
160
161 let projects: HashSet<String> = ["default", "acme"].iter().map(|s| s.to_string()).collect();
162 let bare = libsql_bare_default_idents(dir, &projects);
163 // Flags the bare `blog.db` + the `fn/` subdir; skips `default/` and `acme/`.
164 assert_eq!(bare.len(), 2, "got {bare:?}");
165 assert!(bare.iter().any(|s| s.contains("site `blog`")));
166 assert!(bare.iter().any(|s| s.contains("`fn/`")));
167 assert!(!bare.iter().any(|s| s.contains("acme")));
168 assert!(!bare.iter().any(|s| s.contains("shop")));
169 }
170
171 #[test]
172 fn libsql_detector_empty_when_all_rehomed_or_absent() {
173 // Absent dir ⇒ empty.
174 assert!(
175 libsql_bare_default_idents(Path::new("/nonexistent/xyz"), &HashSet::new()).is_empty()
176 );
177 // Only a `default/` subdir ⇒ empty (fully re-homed).
178 let tmp = tempfile::tempdir().unwrap();
179 std::fs::create_dir_all(tmp.path().join("default")).unwrap();
180 std::fs::write(tmp.path().join("default").join("blog.db"), b"x").unwrap();
181 assert!(libsql_bare_default_idents(tmp.path(), &HashSet::new()).is_empty());
182 }
183
184 /// A list-only in-memory `Storage` double (only `list` is exercised by the detector).
185 #[derive(Default)]
186 struct KeyStore {
187 keys: Vec<String>,
188 }
189 #[async_trait::async_trait]
190 impl Storage for KeyStore {
191 async fn get(&self, key: &str) -> Result<boatramp_core::GetObject, StorageError> {
192 Err(StorageError::NotFound(key.into()))
193 }
194 async fn get_range(
195 &self,
196 key: &str,
197 _: u64,
198 _: Option<u64>,
199 ) -> Result<boatramp_core::GetObject, StorageError> {
200 Err(StorageError::NotFound(key.into()))
201 }
202 async fn put(
203 &self,
204 key: &str,
205 _: boatramp_core::ByteStream,
206 _: boatramp_core::PutMeta,
207 ) -> Result<boatramp_core::ObjectMeta, StorageError> {
208 Err(StorageError::unsupported(key))
209 }
210 async fn head(&self, key: &str) -> Result<boatramp_core::ObjectMeta, StorageError> {
211 Err(StorageError::NotFound(key.into()))
212 }
213 async fn delete(&self, _: &str) -> Result<(), StorageError> {
214 Ok(())
215 }
216 async fn list(&self, prefix: &str) -> Result<Vec<boatramp_core::ObjectMeta>, StorageError> {
217 Ok(self
218 .keys
219 .iter()
220 .filter(|k| k.starts_with(prefix))
221 .map(|k| boatramp_core::ObjectMeta {
222 key: k.clone(),
223 size: Some(1),
224 content_type: None,
225 etag: None,
226 })
227 .collect())
228 }
229 }
230
231 #[tokio::test]
232 async fn blob_detector_true_for_bare_default_false_when_rehomed_or_named() {
233 let projects: HashSet<String> = ["default", "acme"].iter().map(|s| s.to_string()).collect();
234
235 // A bare default-scoped object (`hblob/{site}/…`, site not a project) ⇒ outstanding.
236 let bare: Arc<dyn Storage> = Arc::new(KeyStore {
237 keys: vec![
238 "hblob/default/shop/c/ok".into(), // already re-homed
239 "hblob/acme/site/c/ok".into(), // named project
240 "hblob/blog/c/needs".into(), // BARE default scope — the one that matters
241 ],
242 });
243 assert!(
244 blob_default_outstanding(&bare, &projects).await.unwrap(),
245 "a bare default-scoped blob object must read as outstanding"
246 );
247
248 // Only re-homed + named-project objects (across all three families) ⇒ NOT outstanding.
249 let clean: Arc<dyn Storage> = Arc::new(KeyStore {
250 keys: vec![
251 "hblob/default/blog/c/ok".into(),
252 "mqp/default/blog/orders/17".into(),
253 "mqgp/default/blog/orders/18".into(),
254 "hblob/acme/site/c/ok".into(),
255 ],
256 });
257 assert!(
258 !blob_default_outstanding(&clean, &projects).await.unwrap(),
259 "re-homed + named-project objects only must NOT read as outstanding"
260 );
261 }
262}