feat/tenants-table-polish #86

Merged
clawdie merged 2 commits from feat/tenants-table-polish into main 2026-06-19 21:41:45 +02:00
2 changed files with 190 additions and 0 deletions

View file

@ -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/<name>/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/<name>/root on FreeBSD.
pub fn register_tenant(
&self,
tenant_id: &str,
jail_root_path: &str,
collection_id: &str,
) -> Result<Tenant> {
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<Option<Tenant>> {
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<Vec<Tenant>> {
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.

View file

@ -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/<name>/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);
";