use crate::config::{Config, StorageConfig};
use crate::oidc::OidcVerifier;
use crate::provision::provision_kbs;
use anyhow::Context;
use notedthat_api_http::{
router::{MAX_BODY_BYTES, build_router},
state::AppState,
};
use notedthat_core::{Authenticator, ProtectedResource};
use notedthat_indexer::{
IndexEvent, IndexerWorker, QdrantClient, QdrantConfig, QdrantProvisioner, VectorStore,
embedder::openai::{OpenAiCompatibleConfig, OpenAiCompatibleEmbedder},
};
use notedthat_storage_fs::{FsStorage, RootLock};
use notedthat_storage_s3::S3Storage;
use notedthat_webdav::{router::build_router as build_dav_router, state::WebDavState};
use std::{collections::BTreeMap, sync::Arc, time::Duration};
use tokio::net::TcpListener;
use tokio::signal;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use tracing::info;
mod events;
mod fs_watch;
mod mcp_http;
mod readiness;
mod reconcile;
#[cfg(test)]
#[path = "run/mcp_http_listener.rs"]
mod mcp_http_listener;
mod backends {
use super::VectorStore;
use std::sync::Arc;
pub struct Backends {
pub storage: Arc<dyn notedthat_core::Storage>,
pub store: Arc<dyn VectorStore>,
pub embedder: Arc<dyn notedthat_indexer::embedder::Embedder>,
pub events: Option<Arc<dyn notedthat_core::EventPublisher>>,
}
}
#[cfg(feature = "test-support")]
pub use backends::Backends;
fn backends_from_config(
config: &Config,
root: Option<&RootLock>,
events: Option<Arc<dyn notedthat_core::EventPublisher>>,
) -> anyhow::Result<backends::Backends> {
let storage: Arc<dyn notedthat_core::Storage> = match &config.storage {
StorageConfig::S3(s3) => {
info!(
backend = "s3",
endpoint = ?s3.endpoint_url,
path_style = s3.force_path_style,
"storage backend selected"
);
Arc::new(S3Storage::new(
s3.build_client(),
config.tenant_slug.clone(),
))
}
StorageConfig::Fs(fs) => {
let root = root.context(
"the filesystem backend needs a claimed storage root; call open_storage_root first",
)?;
info!(
backend = "fs",
root = %root.root().display(),
metadata = %fs.metadata,
"storage backend selected"
);
Arc::new(FsStorage::new(
fs,
root.root().to_path_buf(),
config.tenant_slug.clone(),
))
}
};
let qdrant_config = QdrantConfig {
url: config.qdrant.url.clone(),
api_key: config.qdrant.api_key.clone(),
timeout: Duration::from_millis(config.qdrant.timeout_ms),
connect_timeout: Duration::from_millis(config.qdrant.connect_timeout_ms),
};
let store: Arc<dyn VectorStore> =
Arc::new(QdrantClient::new(&qdrant_config).context("failed to build Qdrant client")?);
let embedder_config = OpenAiCompatibleConfig {
endpoint_url: config.embedder.endpoint_url.clone(),
model: config.embedder.model.clone(),
api_key: config.embedder.api_key.clone(),
dim: config.embedder.dimensions as usize,
max_input_tokens: config.embedder.max_input_tokens,
timeout: Duration::from_millis(config.embedder.timeout_ms),
max_retries: config.embedder.max_retries,
};
let embedder: Arc<dyn notedthat_indexer::embedder::Embedder> = Arc::new(
OpenAiCompatibleEmbedder::new(embedder_config).context("failed to build embedder")?,
);
Ok(backends::Backends {
storage,
store,
embedder,
events,
})
}
struct Infrastructure {
state: AppState,
dav_state: WebDavState,
indexer_shutdown: CancellationToken,
worker_handle: tokio::task::JoinHandle<()>,
fs_watch: Option<fs_watch::FsWatch>,
readiness: readiness::ReadinessPoller,
reconciler: Option<Arc<reconcile::Reconciler>>,
}
fn readiness_poller(
config: &Config,
kb_list: &[notedthat_core::KbSlug],
storage: &Arc<dyn notedthat_core::Storage>,
store: &Arc<dyn VectorStore>,
) -> anyhow::Result<(
readiness::ReadinessPoller,
notedthat_api_http::readiness::ReadinessReceiver,
)> {
let witness = kb_list
.first()
.cloned()
.context("at least one knowledge base is declared")?;
Ok(readiness::ReadinessPoller::new(
storage.clone(),
store.clone(),
witness,
config.storage.kind().as_str(),
Duration::from_millis(config.ready_probe_interval_ms),
))
}
fn start_change_detection(
config: &Config,
kb_list: Vec<notedthat_core::KbSlug>,
reconciler: Option<&reconcile::Reconciler>,
store: &Arc<dyn VectorStore>,
indexer_tx: &mpsc::Sender<IndexEvent>,
index_health: Arc<notedthat_indexer::IndexHealth>,
) -> anyhow::Result<Option<fs_watch::FsWatch>> {
match &config.storage {
StorageConfig::S3(s3) => {
match reconciler {
Some(reconciler) if s3.reconcile_on_startup => {
reconciler.spawn_startup_pass(kb_list);
}
_ => info!(
"s3 startup reconciliation is off; objects changed outside NotedThat are \
indexed when the operator asks (POST …/index/reconcile)"
),
}
Ok(None)
}
StorageConfig::Fs(fs) => fs_watch::start(
fs,
config.tenant_slug.clone(),
kb_list,
store.clone(),
indexer_tx.clone(),
index_health,
),
}
}
async fn build_infrastructure(
config: Config,
backends: backends::Backends,
) -> anyhow::Result<Infrastructure> {
let backends::Backends {
storage,
store,
embedder,
events,
} = backends;
let (indexer_tx, indexer_rx) = mpsc::channel::<IndexEvent>(1024);
let indexer_shutdown = CancellationToken::new();
let index_health = Arc::new(notedthat_indexer::IndexHealth::new());
let declared_kbs = Arc::new(config.kbs.clone());
let kb_list: Vec<_> = config.kbs.values().cloned().collect();
let provisioner = QdrantProvisioner::new(store.clone());
let snapshot = provision_kbs(
storage.as_ref(),
&config.tenant_slug,
&kb_list,
&provisioner,
&config.embedder.model,
config.embedder.dimensions,
Some(config.embedder.endpoint_url.as_str()),
)
.await?;
let access_policies = Arc::new(snapshot.access_policies);
let kb_details = Arc::new(snapshot.details);
let authenticator = build_authenticator(&config, &access_policies).await?;
let (readiness, readiness_rx) = readiness_poller(&config, &kb_list, &storage, &store)?;
let dav_state = WebDavState {
authenticator: authenticator.clone(),
storage: storage.clone(),
declared_kbs: declared_kbs.clone(),
access_policies: access_policies.clone(),
indexer_tx: indexer_tx.clone(),
staging_config: config.staging.clone(),
events: events.clone(),
index_health: index_health.clone(),
};
let reconciler = match &config.storage {
StorageConfig::S3(_) => Some(reconcile::Reconciler::new(
storage.clone(),
store.clone(),
indexer_tx.clone(),
index_health.clone(),
&kb_list,
)),
StorageConfig::Fs(_) => None,
};
let searcher: Arc<dyn notedthat_indexer::Searcher> = Arc::new(
notedthat_indexer::searcher::HybridSearcher::new(store.clone(), embedder.clone()),
);
let state = AppState {
storage: storage.clone(),
declared_kbs,
access_policies,
kb_details,
authenticator: authenticator.clone(),
max_body_size: MAX_BODY_BYTES,
max_patchable_size: config.max_patchable_size,
indexer_tx,
searcher,
events: events.clone(),
index_health: index_health.clone(),
readiness: readiness_rx,
reconcile: reconciler
.clone()
.map(|r| r as Arc<dyn notedthat_api_http::state::ReconcileTrigger>),
};
let worker_handle = tokio::spawn(
IndexerWorker::new(
storage.clone(),
embedder.clone(),
store.clone(),
indexer_rx,
indexer_shutdown.clone(),
config.embedder.batch_size,
)
.with_staging_config(config.staging.clone())
.with_event_publisher(events)
.with_health(index_health.clone())
.run(),
);
let watch = start_change_detection(
&config,
kb_list,
reconciler.as_deref(),
&store,
&state.indexer_tx,
index_health,
)?;
Ok(Infrastructure {
state,
dav_state,
indexer_shutdown,
worker_handle,
fs_watch: watch,
readiness,
reconciler,
})
}
async fn build_authenticator(
config: &Config,
access_policies: &BTreeMap<String, Arc<notedthat_core::AccessPolicy>>,
) -> anyhow::Result<Arc<Authenticator>> {
let mut authenticator = Authenticator::new(config.api_token.clone()).with_basic(
config.webdav_username.clone(),
config.webdav_password.clone(),
);
if let Some(oidc) = &config.oidc {
let verifier = OidcVerifier::discover(oidc.clone())
.await
.context("failed to reach NOTEDTHAT_OIDC_ISSUER (--oidc-issuer)")?;
authenticator = authenticator.with_token_verifier(Arc::new(verifier));
if let Some(resource) = &oidc.resource {
authenticator = authenticator.with_protected_resource(ProtectedResource {
resource: resource.clone(),
authorization_servers: vec![oidc.issuer.clone()],
metadata_url: format!("{resource}/.well-known/oauth-protected-resource"),
});
}
} else {
for (slug, policy) in access_policies {
if policy.names_an_identity() {
tracing::warn!(kb = %slug, "ACCESS_RULES_IDENTITY_WITHOUT_OIDC");
}
}
}
Ok(Arc::new(authenticator))
}
pub async fn run(config: Config) -> anyhow::Result<()> {
config
.staging
.validate()
.await
.context("failed to validate NOTEDTHAT_UPLOAD_TMP_DIR")?;
let storage_root = open_storage_root(&config).await?;
let events = events::connect(&config.events).await?;
let backends = backends_from_config(&config, storage_root.as_ref(), events)?;
serve(config, backends).await
}
async fn open_storage_root(config: &Config) -> anyhow::Result<Option<RootLock>> {
match &config.storage {
StorageConfig::S3(_) => Ok(None),
StorageConfig::Fs(fs) => Ok(Some(
notedthat_storage_fs::open_root(fs)
.await
.context("failed to claim NOTEDTHAT_FS_ROOT")?,
)),
}
}
#[cfg(feature = "test-support")]
pub async fn run_with(config: Config, backends: Backends) -> anyhow::Result<()> {
config
.staging
.validate()
.await
.context("failed to validate NOTEDTHAT_UPLOAD_TMP_DIR")?;
serve(config, backends).await
}
async fn serve(config: Config, backends: backends::Backends) -> anyhow::Result<()> {
let Infrastructure {
state,
dav_state,
indexer_shutdown,
worker_handle,
fs_watch,
readiness,
reconciler,
} = build_infrastructure(config.clone(), backends).await?;
let shutdown_token = CancellationToken::new();
let readiness_handle = tokio::spawn(readiness.run(shutdown_token.child_token()));
let serve_result = async {
let listener = TcpListener::bind(config.listen_addr)
.await
.with_context(|| format!("failed to bind HTTP listener on {}", config.listen_addr))?;
let bound_addr = listener.local_addr()?;
let internal_api_url = mcp_http::internal_http_api_url(bound_addr);
info!(http = %bound_addr, "notedthat-server listening");
let app = build_router(state.clone())
.merge(build_dav_router(dav_state))
.merge(mcp_http::build_router(
&config,
state.authenticator.clone(),
&state.access_policies,
&internal_api_url,
shutdown_token.child_token(),
state.events.is_some(),
)?);
let graceful_shutdown = shutdown_token.clone();
let signal_shutdown = shutdown_token.clone();
let shutdown_trigger = tokio::spawn(async move {
shutdown_signal().await;
signal_shutdown.cancel();
});
let result = axum::serve(listener, app)
.with_graceful_shutdown(async move { graceful_shutdown.cancelled().await })
.await
.context("HTTP listener failed");
shutdown_trigger.abort();
result
}
.await;
shutdown_token.cancel();
if let Err(e) = readiness_handle.await {
tracing::error!(error = %e, "readiness poller panicked");
}
if let Some(fs_watch) = fs_watch {
fs_watch.stop().await;
}
if let Some(reconciler) = reconciler {
reconciler.stop().await;
}
complete_shutdown(indexer_shutdown, worker_handle).await;
serve_result
}
async fn complete_shutdown(
indexer_shutdown: CancellationToken,
worker_handle: tokio::task::JoinHandle<()>,
) {
drain_indexer(indexer_shutdown, worker_handle).await;
info!("shutdown complete");
}
async fn drain_indexer(
indexer_shutdown: CancellationToken,
worker_handle: tokio::task::JoinHandle<()>,
) {
tracing::info!("shutdown signal received; draining indexer queue");
indexer_shutdown.cancel();
let join_result = tokio::time::timeout(Duration::from_secs(31), worker_handle).await;
match join_result {
Ok(Ok(())) => tracing::info!("indexer worker drained cleanly"),
Ok(Err(e)) => tracing::error!(error = %e, "indexer worker panicked"),
Err(_) => tracing::warn!("indexer worker did not drain within 31s; abandoning"),
}
}
async fn shutdown_signal() {
let ctrl_c = async {
if let Err(e) = signal::ctrl_c().await {
tracing::warn!(
error = %e,
"SIGINT handler installation failed; only SIGTERM will trigger graceful shutdown"
);
std::future::pending::<()>().await;
}
};
#[cfg(unix)]
let terminate = async {
match signal::unix::signal(signal::unix::SignalKind::terminate()) {
Ok(mut sig) => {
sig.recv().await;
}
Err(e) => {
tracing::warn!(
error = %e,
"SIGTERM handler installation failed; only SIGINT will trigger graceful shutdown"
);
std::future::pending::<()>().await;
}
}
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
() = ctrl_c => tracing::info!("SIGINT received, shutting down"),
() = terminate => tracing::info!("SIGTERM received, shutting down"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::sync::oneshot;
#[tokio::test]
async fn completes_shutdown_by_cancelling_and_draining_indexer_immediately() {
let indexer_shutdown = CancellationToken::new();
let worker_shutdown = indexer_shutdown.clone();
let (cancelled_tx, cancelled_rx) = oneshot::channel();
let worker_handle = tokio::spawn(async move {
worker_shutdown.cancelled().await;
cancelled_tx
.send(())
.expect("test observes indexer cancellation once");
});
tokio::time::timeout(
Duration::from_secs(1),
complete_shutdown(indexer_shutdown, worker_handle),
)
.await
.expect("shutdown completion should not wait after listeners quiesce");
cancelled_rx
.await
.expect("indexer worker should receive cancellation");
}
}