use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use anyhow::{Context, Result};
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use tracing::{info, warn};
use crate::repository::{
SqliteAssetRepository, SqliteAuditRepository, SqliteCollectionRepository,
SqliteRelationRepository,
};
use crate::service::Services;
const INSTANCE_POOL_MAX_CONN: u32 = 3;
fn validate_uuid(id: &str) -> Result<()> {
uuid::Uuid::parse_str(id)
.map(|_| ())
.map_err(|e| anyhow::anyhow!("invalid instance id (not a UUID): {} ({})", id, e))
}
#[derive(Debug, Clone, Default)]
pub struct TenantContext {
pub instance_id: Option<String>,
}
impl TenantContext {
pub fn instance_id(&self) -> Option<&str> {
self.instance_id.as_deref()
}
pub fn is_tenant(&self) -> bool {
self.instance_id.is_some()
}
}
impl<S: Send + Sync> FromRequestParts<S> for TenantContext {
type Rejection = std::convert::Infallible;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let instance_id = parts
.headers
.get("X-Instance-Id")
.and_then(|h| h.to_str().ok())
.map(|s| s.to_string());
Ok(TenantContext { instance_id })
}
}
#[derive(Clone)]
pub struct TenantPoolManager {
global_services: Services,
instances: Arc<RwLock<HashMap<String, Services>>>,
instances_dir: PathBuf,
}
impl TenantPoolManager {
pub fn new(global_services: Services, instances_dir: PathBuf) -> Self {
Self {
global_services,
instances: Arc::new(RwLock::new(HashMap::new())),
instances_dir,
}
}
pub async fn get_services(&self, instance_id: Option<&str>) -> Result<Services> {
let Some(instance_id) = instance_id else {
return Ok(self.global_services.clone());
};
{
let cache = self.instances.read().unwrap();
if let Some(services) = cache.get(instance_id) {
return Ok(services.clone());
}
}
validate_uuid(instance_id)?;
let db_path = self.locate_db(instance_id).await?;
info!("Creating SQLite pool for instance: {}", instance_id);
let services = self.open_instance_services(instance_id, &db_path).await?;
{
let mut cache = self.instances.write().unwrap();
cache.insert(instance_id.to_string(), services.clone());
}
Ok(services)
}
async fn locate_db(&self, instance_id: &str) -> Result<PathBuf> {
let top = self.instances_dir.join(instance_id).join("pdt.db");
if top.exists() {
return Ok(top);
}
if let Ok(entries) = std::fs::read_dir(&self.instances_dir) {
for entry in entries.flatten() {
if !entry.path().is_dir() {
continue;
}
let candidate = entry.path().join(instance_id).join("pdt.db");
if candidate.exists() {
return Ok(candidate);
}
}
}
Ok(top)
}
async fn open_instance_services(&self, instance_id: &str, db_path: &PathBuf) -> Result<Services> {
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!("Failed to create instance directory: {:?}", parent)
})?;
}
info!("Instance SQLite path: {:?}", db_path);
let pool = SqlitePoolOptions::new()
.max_connections(INSTANCE_POOL_MAX_CONN)
.connect_with(
SqliteConnectOptions::new()
.filename(db_path)
.create_if_missing(true)
.foreign_keys(true)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal),
)
.await
.context("Failed to connect to instance SQLite")?;
let migration_sql = include_str!("../migrations/sqlite/001_initial.sql");
sqlx::raw_sql(migration_sql)
.execute(&pool)
.await
.context("Failed to run instance SQLite migrations")?;
sqlx::raw_sql("INSERT INTO assets_fts(assets_fts) VALUES('rebuild');")
.execute(&pool)
.await
.map_err(|e| warn!("Instance FTS rebuild skipped (non-fatal): {}", e))
.ok();
info!("Instance {} migrations applied", instance_id);
let asset_repo = SqliteAssetRepository::new(pool.clone());
let relation_repo = SqliteRelationRepository::new(pool.clone());
let collection_repo = SqliteCollectionRepository::new(pool.clone());
let audit_repo = SqliteAuditRepository::new(pool);
Ok(Services::from_repositories(
asset_repo,
relation_repo,
collection_repo,
audit_repo,
))
}
pub async fn provision_instance(&self, instance_id: &str, parent_id: Option<&str>) -> Result<()> {
validate_uuid(instance_id)?;
if let Some(parent) = parent_id {
validate_uuid(parent)?;
}
let db_path = match parent_id {
None => self.instances_dir.join(instance_id).join("pdt.db"),
Some(parent) => {
let parent_db = self.instances_dir.join(parent).join("pdt.db");
if !parent_db.exists() {
anyhow::bail!(
"Parent workspace {} is not provisioned (missing {:?})",
parent,
parent_db
);
}
self.instances_dir.join(parent).join(instance_id).join("pdt.db")
}
};
if db_path.exists() {
info!("Instance {} already provisioned at {:?}", instance_id, db_path);
return Ok(());
}
self.open_instance_services(instance_id, &db_path).await?;
info!("Instance {} provisioned under parent {:?} at {:?}",
instance_id, parent_id, db_path);
Ok(())
}
pub async fn provision_workspace_tree(
&self,
workspace_id: &str,
child_ids: &[String],
) -> Result<()> {
self.provision_instance(workspace_id, None).await?;
for child in child_ids {
self.provision_instance(child, Some(workspace_id)).await?;
}
Ok(())
}
pub async fn delete_instance(&self, instance_id: &str) -> Result<()> {
validate_uuid(instance_id)?;
let top_dir = self.instances_dir.join(instance_id);
let target_dir = if top_dir.is_dir() {
top_dir
} else {
let mut found: Option<PathBuf> = None;
if let Ok(entries) = std::fs::read_dir(&self.instances_dir) {
for entry in entries.flatten() {
let candidate = entry.path().join(instance_id);
if candidate.is_dir() {
found = Some(candidate);
break;
}
}
}
match found {
Some(dir) => dir,
None => anyhow::bail!("Instance {} not found on disk", instance_id),
}
};
{
let mut cache = self.instances.write().unwrap();
cache.remove(instance_id);
if let Ok(entries) = std::fs::read_dir(&target_dir) {
for entry in entries.flatten() {
if let Some(name) = entry.file_name().to_str() {
cache.remove(name);
}
}
}
}
std::fs::remove_dir_all(&target_dir).with_context(|| {
format!("Failed to delete instance directory {:?}", target_dir)
})?;
info!("Instance {} deleted (recursive): {:?}", instance_id, target_dir);
Ok(())
}
pub fn list_instances(&self) -> Vec<String> {
let mut ids: HashSet<String> = self.instances.read().unwrap().keys().cloned().collect();
for (dir, _) in self.scan_workspace_dirs() {
ids.insert(dir);
}
ids.into_iter().collect()
}
pub fn list_instance_tree(&self) -> Vec<(String, Option<String>)> {
let mut out = Vec::new();
if let Ok(entries) = std::fs::read_dir(&self.instances_dir) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let ws_name = match entry.file_name().into_string() {
Ok(n) => n,
Err(_) => continue,
};
if path.join("pdt.db").exists() {
out.push((ws_name.to_string(), None));
}
if let Ok(children) = std::fs::read_dir(&path) {
for child in children.flatten() {
let cpath = child.path();
if cpath.is_dir() && cpath.join("pdt.db").exists() {
if let Some(cname) = child.file_name().to_str() {
out.push((ws_name.to_string(), Some(cname.to_string())));
}
}
}
}
}
}
out
}
fn scan_workspace_dirs(&self) -> Vec<(String, PathBuf)> {
let mut out = Vec::new();
if let Ok(entries) = std::fs::read_dir(&self.instances_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() && path.join("pdt.db").exists() {
if let Some(name) = entry.file_name().to_str() {
out.push((name.to_string(), path));
}
}
}
}
out
}
pub fn global(&self) -> &Services {
&self.global_services
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_uuid_rejects_traversal() {
assert!(validate_uuid("../../etc/passwd").is_err());
assert!(validate_uuid("foo/bar").is_err());
assert!(validate_uuid("..").is_err());
assert!(validate_uuid("not-a-uuid").is_err());
assert!(validate_uuid("").is_err());
assert!(validate_uuid("550e8400-e29b-41d4-a716-446655440000").is_ok());
assert!(validate_uuid("550E8400E29B41D4A716446655440000").is_ok());
}
#[cfg(feature = "sqlite-backend")]
mod integration {
use super::super::*;
async fn manager_with_dir() -> TenantPoolManager {
let dir = std::env::temp_dir()
.join(format!("pdt_tenant_test_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect_with(
sqlx::sqlite::SqliteConnectOptions::new()
.filename(":memory:")
.create_if_missing(true),
)
.await
.unwrap();
let migration_sql = include_str!("../migrations/sqlite/001_initial.sql");
sqlx::raw_sql(migration_sql).execute(&pool).await.unwrap();
let services = Services::from_repositories(
SqliteAssetRepository::new(pool.clone()),
SqliteRelationRepository::new(pool.clone()),
SqliteCollectionRepository::new(pool.clone()),
SqliteAuditRepository::new(pool),
);
TenantPoolManager::new(services, dir)
}
#[tokio::test]
async fn workspace_provision_creates_top_level_db() {
let mgr = manager_with_dir().await;
let ws = uuid::Uuid::new_v4().to_string();
mgr.provision_instance(&ws, None).await.unwrap();
assert!(mgr.instances_dir.join(&ws).join("pdt.db").exists());
}
#[tokio::test]
async fn child_provision_creates_nested_db() {
let mgr = manager_with_dir().await;
let ws = uuid::Uuid::new_v4().to_string();
let child = uuid::Uuid::new_v4().to_string();
mgr.provision_instance(&ws, None).await.unwrap();
mgr.provision_instance(&child, Some(&ws)).await.unwrap();
assert!(mgr.instances_dir.join(&ws).join(&child).join("pdt.db").exists());
}
#[tokio::test]
async fn child_requires_existing_parent() {
let mgr = manager_with_dir().await;
let missing_parent = uuid::Uuid::new_v4().to_string();
let child = uuid::Uuid::new_v4().to_string();
let err = mgr.provision_instance(&child, Some(&missing_parent)).await;
assert!(err.is_err());
}
#[tokio::test]
async fn provisioning_is_idempotent() {
let mgr = manager_with_dir().await;
let ws = uuid::Uuid::new_v4().to_string();
mgr.provision_instance(&ws, None).await.unwrap();
mgr.provision_instance(&ws, None).await.unwrap();
}
#[tokio::test]
async fn provisioning_rejects_traversal() {
let mgr = manager_with_dir().await;
assert!(mgr.provision_instance("../evil", None).await.is_err());
assert!(mgr.provision_instance("evil", Some("../parent")).await.is_err());
}
#[tokio::test]
async fn routing_finds_child_db() {
let mgr = manager_with_dir().await;
let ws = uuid::Uuid::new_v4().to_string();
let child = uuid::Uuid::new_v4().to_string();
mgr.provision_instance(&ws, None).await.unwrap();
mgr.provision_instance(&child, Some(&ws)).await.unwrap();
let services = mgr.get_services(Some(&child)).await.unwrap();
let _created = services
.assets()
.create(
crate::models::CreateAssetRequest {
title: "child-marker".into(),
content: None,
tags: vec![],
metadata: Default::default(),
auth_context: None,
},
"test-user",
)
.await
.unwrap();
let direct = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect_with(
sqlx::sqlite::SqliteConnectOptions::new()
.filename(mgr.instances_dir.join(&ws).join(&child).join("pdt.db")),
)
.await
.unwrap();
let row: (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM assets WHERE title = 'child-marker'")
.fetch_one(&direct)
.await
.unwrap();
assert_eq!(row.0, 1, "marker must be in the nested child DB");
}
#[tokio::test]
async fn routing_rejects_non_uuid_header() {
let mgr = manager_with_dir().await;
assert!(mgr.get_services(Some("../etc")).await.is_err());
assert!(mgr.get_services(Some("nope")).await.is_err());
}
#[tokio::test]
async fn delete_workspace_removes_children() {
let mgr = manager_with_dir().await;
let ws = uuid::Uuid::new_v4().to_string();
let a = uuid::Uuid::new_v4().to_string();
let b = uuid::Uuid::new_v4().to_string();
mgr.provision_instance(&ws, None).await.unwrap();
mgr.provision_instance(&a, Some(&ws)).await.unwrap();
mgr.provision_instance(&b, Some(&ws)).await.unwrap();
mgr.delete_instance(&ws).await.unwrap();
assert!(!mgr.instances_dir.join(&ws).exists());
}
#[tokio::test]
async fn delete_child_keeps_workspace() {
let mgr = manager_with_dir().await;
let ws = uuid::Uuid::new_v4().to_string();
let child = uuid::Uuid::new_v4().to_string();
mgr.provision_instance(&ws, None).await.unwrap();
mgr.provision_instance(&child, Some(&ws)).await.unwrap();
mgr.delete_instance(&child).await.unwrap();
assert!(mgr.instances_dir.join(&ws).join("pdt.db").exists());
assert!(!mgr.instances_dir.join(&ws).join(&child).exists());
}
#[tokio::test]
async fn list_instance_tree_reports_hierarchy() {
let mgr = manager_with_dir().await;
let ws = uuid::Uuid::new_v4().to_string();
let child = uuid::Uuid::new_v4().to_string();
mgr.provision_instance(&ws, None).await.unwrap();
mgr.provision_instance(&child, Some(&ws)).await.unwrap();
let tree = mgr.list_instance_tree();
assert!(tree.contains(&(ws.clone(), None)));
assert!(tree.contains(&(ws, Some(child))));
}
}
}