diff --git a/crates/colibri-store/src/lib.rs b/crates/colibri-store/src/lib.rs index 1ecace9..d6180c3 100644 --- a/crates/colibri-store/src/lib.rs +++ b/crates/colibri-store/src/lib.rs @@ -97,6 +97,21 @@ pub struct Skill { pub created_at: String, } +/// A tenant record — 1:1:1 map between a jail/bastille name, its root path, +/// and a Vaultwarden collection. Used by colibri-vault to provision .env files. +/// +/// FreeBSD/Bastille: jail_root_path = /usr/local/bastille/jails//root +/// Linux/Docker: jail_root_path = container volume path (portable column) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Tenant { + pub tenant_id: String, + pub jail_root_path: String, + pub collection_id: String, + pub status: String, + pub created_at: String, + pub updated_at: String, +} + // --------------------------------------------------------------------------- // Errors // --------------------------------------------------------------------------- @@ -428,6 +443,93 @@ impl Store { Ok(skills) } + // ------------------------------------------------------------------ + // Tenants — 1:1:1 map for colibri-vault provisioning + // ------------------------------------------------------------------ + + /// Register a tenant. tenant_id = jail name = bastille name. + /// jail_root_path is OS-specific: /usr/local/bastille/jails//root on FreeBSD. + pub fn register_tenant( + &self, + tenant_id: &str, + jail_root_path: &str, + collection_id: &str, + ) -> Result { + let now = Utc::now().to_rfc3339(); + self.conn.execute( + "INSERT INTO tenants (tenant_id, jail_root_path, collection_id, status, created_at, updated_at) + VALUES (?1, ?2, ?3, 'provisioned', ?4, ?4)", + params![tenant_id, jail_root_path, collection_id, now], + )?; + Ok(Tenant { + tenant_id: tenant_id.to_string(), + jail_root_path: jail_root_path.to_string(), + collection_id: collection_id.to_string(), + status: "provisioned".to_string(), + created_at: now.clone(), + updated_at: now, + }) + } + + /// Get a tenant by ID (jail name). + pub fn get_tenant(&self, tenant_id: &str) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT tenant_id, jail_root_path, collection_id, status, created_at, updated_at + FROM tenants WHERE tenant_id = ?1", + )?; + let mut rows = stmt.query_map(params![tenant_id], |row| { + Ok(Tenant { + tenant_id: row.get(0)?, + jail_root_path: row.get(1)?, + collection_id: row.get(2)?, + status: row.get(3)?, + created_at: row.get(4)?, + updated_at: row.get(5)?, + }) + })?; + match rows.next() { + Some(Ok(t)) => Ok(Some(t)), + Some(Err(e)) => Err(StoreError::Database(e)), + None => Ok(None), + } + } + + /// List all tenants. + pub fn list_tenants(&self) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT tenant_id, jail_root_path, collection_id, status, created_at, updated_at + FROM tenants ORDER BY tenant_id", + )?; + let rows = stmt.query_map([], |row| { + Ok(Tenant { + tenant_id: row.get(0)?, + jail_root_path: row.get(1)?, + collection_id: row.get(2)?, + status: row.get(3)?, + created_at: row.get(4)?, + updated_at: row.get(5)?, + }) + })?; + let mut tenants = Vec::new(); + for row in rows { + tenants.push(row?); + } + Ok(tenants) + } + + /// Update tenant status (provisioned → active → stopped → destroyed). + pub fn set_tenant_status(&self, tenant_id: &str, status: &str) -> Result<()> { + let now = Utc::now().to_rfc3339(); + let rows = self.conn.execute( + "UPDATE tenants SET status = ?1, updated_at = ?2 WHERE tenant_id = ?3", + params![status, now, tenant_id], + )?; + if rows == 0 { + return Err(StoreError::NotFound(tenant_id.to_string())); + } + Ok(()) + } + // ------------------------------------------------------------------ // Backup / export // ------------------------------------------------------------------ @@ -447,6 +549,7 @@ impl Store { "tasks": self.list_tasks(None)?, "agents": self.list_agents()?, "skills": self.list_skills()?, + "tenants": self.list_tenants()?, "exported_at": Utc::now().to_rfc3339(), })) } @@ -625,14 +728,82 @@ mod tests { store .register_skill("test-skill", None, Some("test")) .unwrap(); + store + .register_tenant( + "test-jail", + "/usr/local/bastille/jails/test-jail/root", + "col-deadbeef", + ) + .unwrap(); let json = store.export_json().unwrap(); assert!(json["tasks"].as_array().unwrap().len() == 1); assert!(json["agents"].as_array().unwrap().len() == 1); assert!(json["skills"].as_array().unwrap().len() == 1); + assert!(json["tenants"].as_array().unwrap().len() == 1); assert!(json["exported_at"].is_string()); } + #[test] + fn test_tenant_lifecycle() { + let store = Store::open_memory().unwrap(); + + // Register + let tenant = store + .register_tenant( + "worker-1", + "/usr/local/bastille/jails/worker-1/root", + "col-abc123", + ) + .unwrap(); + assert_eq!(tenant.tenant_id, "worker-1"); + assert_eq!(tenant.status, "provisioned"); + + // Get + let got = store.get_tenant("worker-1").unwrap().unwrap(); + assert_eq!(got.collection_id, "col-abc123"); + assert_eq!( + got.jail_root_path, + "/usr/local/bastille/jails/worker-1/root" + ); + + // List + let list = store.list_tenants().unwrap(); + assert_eq!(list.len(), 1); + + // Status transitions + store.set_tenant_status("worker-1", "active").unwrap(); + let active = store.get_tenant("worker-1").unwrap().unwrap(); + assert_eq!(active.status, "active"); + assert!(active.updated_at > active.created_at); + + store.set_tenant_status("worker-1", "stopped").unwrap(); + let stopped = store.get_tenant("worker-1").unwrap().unwrap(); + assert_eq!(stopped.status, "stopped"); + + store.set_tenant_status("worker-1", "destroyed").unwrap(); + assert_eq!( + store.get_tenant("worker-1").unwrap().unwrap().status, + "destroyed" + ); + + // NotFound + assert!(store.get_tenant("nonexistent").unwrap().is_none()); + assert!(store.set_tenant_status("nonexistent", "active").is_err()); + } + + #[test] + fn test_tenant_uniqueness() { + let store = Store::open_memory().unwrap(); + store.register_tenant("a", "/path/a", "col-a").unwrap(); + + // Duplicate jail_root_path + assert!(store.register_tenant("b", "/path/a", "col-b").is_err()); + + // Duplicate collection_id + assert!(store.register_tenant("c", "/path/c", "col-a").is_err()); + } + #[test] fn test_default_db_path_freebsd() { // On Linux, this returns the XDG path. On FreeBSD, /var/db/colibri. diff --git a/crates/colibri-store/src/schema.rs b/crates/colibri-store/src/schema.rs index f4269f8..ee8d619 100644 --- a/crates/colibri-store/src/schema.rs +++ b/crates/colibri-store/src/schema.rs @@ -39,4 +39,23 @@ CREATE TABLE IF NOT EXISTS skills ( ); CREATE INDEX IF NOT EXISTS idx_skills_category ON skills(category); + +-- Tenants: 1:1:1 map — tenant_id = jail name = Vaultwarden collection +-- Used by colibri-vault to know where to write .env after jail creation. +-- FreeBSD/Bastille: jail_root_path = /usr/local/bastille/jails//root +-- Linux/Docker: jail_root_path = container volume path (portable column) +CREATE TABLE IF NOT EXISTS tenants ( + tenant_id TEXT PRIMARY KEY NOT NULL, + jail_root_path TEXT NOT NULL UNIQUE, + collection_id TEXT NOT NULL UNIQUE, + status TEXT NOT NULL DEFAULT 'provisioned' + CHECK(status IN ('provisioned', + 'active', + 'stopped', + 'destroyed')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_tenants_status ON tenants(status); ";