Skip to main content

notedthat_server/
provision.rs

1//! Startup provisioning: validate configs, ensure buckets, write manifests.
2//!
3//! See `SPECIFICATIONS.md` §6.12 provisioning steps and D39 (fail-fast startup).
4
5use notedthat_core::{
6    AccessPolicy, Error, KbDetails, KbManifest, KbSlug, Storage, TenantSlug, derive_bucket_name,
7    validate_bucket_name,
8};
9use notedthat_indexer::{ProvisionError, QdrantProvisioner};
10use std::collections::BTreeMap;
11use std::sync::Arc;
12use std::time::{SystemTime, UNIX_EPOCH};
13use tracing::{info, warn};
14
15/// The startup snapshot of every declared knowledge base's manifest: what the
16/// surfaces may rely on until the next restart (D51).
17#[derive(Debug, Default)]
18pub struct ProvisionedKbs {
19    /// Each knowledge base's validated access rules, keyed by slug.
20    pub access_policies: BTreeMap<String, Arc<AccessPolicy>>,
21    /// Each knowledge base's display name and description, keyed by slug.
22    pub details: BTreeMap<String, KbDetails>,
23}
24
25/// Provision all declared knowledge bases against the storage backend.
26///
27/// For each KB:
28/// 1. Validate the derived bucket name (fail-fast on >63 char names).
29/// 2. Ensure the bucket exists (idempotent).
30/// 3. Read the manifest; if absent, write a fresh v1 manifest.
31///    If present but KB slug mismatches, overwrite with a fresh manifest.
32/// 4. Ensure the Qdrant collection exists, and record the embedding
33///    configuration in the manifest the first time.
34///
35/// Any failure, including an unreachable Qdrant, returns immediately as
36/// `Err(Error)` (D39).
37pub async fn provision_kbs(
38    storage: &dyn Storage,
39    tenant: &TenantSlug,
40    kbs: &[KbSlug],
41    provisioner: &QdrantProvisioner,
42    embedder_model: &str,
43    embedder_dim: u32,
44    embedder_endpoint_hint: Option<&str>,
45) -> Result<ProvisionedKbs, Error> {
46    let mut snapshot = ProvisionedKbs::default();
47    for kb in kbs {
48        validate_bucket_name(tenant, kb)?;
49
50        let bucket = derive_bucket_name(tenant, kb);
51        info!(kb = %kb.as_str(), bucket = %bucket, "provisioning KB");
52
53        storage.ensure_bucket(kb).await?;
54
55        let mut manifest = match storage.read_manifest(kb).await {
56            Ok(manifest) => {
57                if manifest.kb_slug.as_str() == kb.as_str() {
58                    info!(kb = %kb.as_str(), "manifest OK");
59                    manifest
60                } else {
61                    warn!(
62                        kb = %kb.as_str(),
63                        manifest_kb = %manifest.kb_slug.as_str(),
64                        "manifest kb_slug mismatch — overwriting with fresh manifest"
65                    );
66                    let fresh = KbManifest::new_v1(tenant, kb, kb.as_str(), current_unix_ts());
67                    storage.write_manifest(kb, &fresh).await?;
68                    fresh
69                }
70            }
71            Err(e) if e.is_not_found() => {
72                info!(kb = %kb.as_str(), "writing initial manifest");
73                let fresh = KbManifest::new_v1(tenant, kb, kb.as_str(), current_unix_ts());
74                storage.write_manifest(kb, &fresh).await?;
75                fresh
76            }
77            Err(e) => return Err(Error::Storage(e)),
78        };
79
80        // Storage implementations need not validate manifests on read. Reject
81        // unsupported schemas before publishing their public-read policies.
82        manifest.validate()?;
83
84        // Every step is fatal (D39, §6.12 step 6): a server that starts without
85        // its collection indexes nothing and cannot tell anyone until a write
86        // fails, so the deployment finds out here, with the setting named.
87        let recorded =
88            QdrantProvisioner::cross_check_manifest(&manifest, embedder_model, embedder_dim)
89                .map_err(|err| provision_error(&err))?;
90        provisioner
91            .ensure_collection(kb, u64::from(embedder_dim))
92            .await
93            .map_err(|err| qdrant_provision_error(kb, &err))?;
94        if recorded.is_none() {
95            manifest.embedding = Some(QdrantProvisioner::manifest_embedding_from_env(
96                embedder_model.to_string(),
97                embedder_dim,
98                embedder_endpoint_hint.map(str::to_string),
99            ));
100            storage.write_manifest(kb, &manifest).await?;
101            info!(kb = %kb.as_str(), "qdrant collection provisioned and manifest embedding recorded");
102        }
103        if manifest.access.is_empty() {
104            // Allow-only rules mean an empty array grants nothing to anyone. The
105            // knowledge base is inert but not unrecoverable — the credential
106            // holder still reaches `.notedthat` — and silently inert is worse
107            // than loud.
108            warn!(kb = %kb.as_str(), "ACCESS_RULES_EMPTY");
109        }
110        info!(
111            kb = %kb.as_str(),
112            rules = manifest.access.rules().len(),
113            anonymous = manifest.access.visible_in_listing(&notedthat_core::Principal::Anyone),
114            "access policy loaded"
115        );
116        snapshot
117            .access_policies
118            .insert(kb.as_str().to_string(), Arc::new(manifest.access.clone()));
119        snapshot
120            .details
121            .insert(kb.as_str().to_string(), manifest.details());
122    }
123    Ok(snapshot)
124}
125
126fn provision_error(err: &ProvisionError) -> Error {
127    Error::Config {
128        message: err.to_string(),
129    }
130}
131
132/// A collection that could not be ensured, with the knowledge base and the
133/// setting the operator will reach for — the same naming rule every other
134/// startup diagnostic follows.
135fn qdrant_provision_error(kb: &KbSlug, err: &ProvisionError) -> Error {
136    Error::Config {
137        message: format!(
138            "failed to provision the qdrant collection for knowledge base '{}' via {}: {err}",
139            kb.as_str(),
140            notedthat_core::setting("NOTEDTHAT_QDRANT_URL"),
141        ),
142    }
143}
144
145/// Current Unix timestamp in seconds. Used for `created_at` in manifests.
146/// No chrono dependency — [`SystemTime`] from std is sufficient for M2.
147fn current_unix_ts() -> i64 {
148    let seconds = SystemTime::now()
149        .duration_since(UNIX_EPOCH)
150        .unwrap_or_default()
151        .as_secs();
152    i64::try_from(seconds).unwrap_or(i64::MAX)
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use notedthat_api_http::testing::InMemoryStorage;
159    use notedthat_indexer::testing::InMemoryVectorStore;
160
161    fn provisioner() -> QdrantProvisioner {
162        QdrantProvisioner::new(std::sync::Arc::new(InMemoryVectorStore::new()))
163    }
164
165    #[tokio::test]
166    async fn test_provision_kbs_happy_path() {
167        let storage = InMemoryStorage::default();
168        let tenant = TenantSlug::default();
169        let kbs = vec![
170            KbSlug::try_new("notes").unwrap(),
171            KbSlug::try_new("docs").unwrap(),
172        ];
173
174        provision_kbs(
175            &storage,
176            &tenant,
177            &kbs,
178            &provisioner(),
179            "test-model",
180            3,
181            Some("http://embedder.example"),
182        )
183        .await
184        .unwrap();
185
186        assert_eq!(
187            storage
188                .read_manifest(&kbs[0])
189                .await
190                .unwrap()
191                .kb_slug
192                .as_str(),
193            "notes"
194        );
195        assert_eq!(
196            storage
197                .read_manifest(&kbs[1])
198                .await
199                .unwrap()
200                .kb_slug
201                .as_str(),
202            "docs"
203        );
204    }
205
206    #[tokio::test]
207    async fn test_provision_kbs_idempotent() {
208        let storage = InMemoryStorage::default();
209        let tenant = TenantSlug::default();
210        let kbs = vec![KbSlug::try_new("notes").unwrap()];
211
212        provision_kbs(
213            &storage,
214            &tenant,
215            &kbs,
216            &provisioner(),
217            "test-model",
218            3,
219            Some("http://embedder.example"),
220        )
221        .await
222        .unwrap();
223        provision_kbs(
224            &storage,
225            &tenant,
226            &kbs,
227            &provisioner(),
228            "test-model",
229            3,
230            Some("http://embedder.example"),
231        )
232        .await
233        .unwrap();
234
235        let manifest = storage.read_manifest(&kbs[0]).await.unwrap();
236        assert_eq!(manifest.kb_slug.as_str(), "notes");
237        assert_eq!(manifest.manifest_version, KbManifest::CURRENT_VERSION);
238    }
239
240    #[tokio::test]
241    async fn test_provision_kbs_writes_initial_manifest() {
242        let storage = InMemoryStorage::default();
243        let tenant = TenantSlug::default();
244        let kb = KbSlug::try_new("mykb").unwrap();
245        let kbs = vec![kb.clone()];
246
247        provision_kbs(
248            &storage,
249            &tenant,
250            &kbs,
251            &provisioner(),
252            "test-model",
253            3,
254            Some("http://embedder.example"),
255        )
256        .await
257        .unwrap();
258
259        let manifest = storage.read_manifest(&kb).await.unwrap();
260        assert_eq!(manifest.kb_slug.as_str(), "mykb");
261        assert_eq!(manifest.tenant_slug.as_str(), "default");
262        assert_eq!(manifest.manifest_version, KbManifest::CURRENT_VERSION);
263        assert!(manifest.created_at > 0);
264    }
265
266    #[tokio::test]
267    async fn an_unreachable_vector_store_refuses_startup_and_names_the_setting() {
268        let storage = InMemoryStorage::default();
269        let tenant = TenantSlug::default();
270        let kb = KbSlug::try_new("notes").unwrap();
271        let store = InMemoryVectorStore::new();
272        store.set_reachable(false);
273
274        let error = provision_kbs(
275            &storage,
276            &tenant,
277            std::slice::from_ref(&kb),
278            &QdrantProvisioner::new(std::sync::Arc::new(store)),
279            "test-model",
280            3,
281            Some("http://embedder.example"),
282        )
283        .await
284        .expect_err("an unreachable vector store is fatal (D39)");
285
286        let message = error.to_string();
287        for needle in ["'notes'", "NOTEDTHAT_QDRANT_URL", "--qdrant-url"] {
288            assert!(message.contains(needle), "{message:?} should name {needle}");
289        }
290
291        // The manifest written in step 3 must not claim an embedding the
292        // collection never got, so the next start provisions from scratch.
293        let manifest = storage.read_manifest(&kb).await.unwrap();
294        assert!(manifest.embedding.is_none());
295    }
296}