feat/tenants-table-polish #86
2 changed files with 190 additions and 0 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue