use sqlx::Database;
use super::error::TenancyError;
use super::org::{Org, StorageMode};
use super::org_host::OrgHost;
use super::pools::TenantPools;
use crate::core::Column as _;
use crate::sql::{FetcherPool as _, UpdaterPool as _};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
Deactivate,
Purge {
purge_database: bool,
},
}
#[derive(Debug, Clone, Default)]
pub struct Report {
pub slug: String,
pub no_change: bool,
pub deactivated: bool,
pub schema_dropped: Option<String>,
pub database_dropped: Option<String>,
pub row_deleted: bool,
pub notes: Vec<String>,
}
pub async fn decommission<DB: Database>(
pools: &TenantPools<DB>,
slug: &str,
action: Action,
) -> Result<Report, TenancyError>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
let registry = pools.registry_pool();
let rows: Vec<Org> = Org::objects()
.where_(Org::slug.eq(slug.to_owned()))
.fetch(®istry)
.await?;
let org = rows
.into_iter()
.next()
.ok_or_else(|| TenancyError::Validation(format!("no tenant with slug `{slug}`")))?;
let mut report = Report {
slug: slug.to_owned(),
..Report::default()
};
match action {
Action::Deactivate => {
if !org.active {
report.no_change = true;
return Ok(report);
}
let id = org
.id
.get()
.copied()
.ok_or_else(|| TenancyError::Validation("Org row has no PK".into()))?;
let updated = Org::objects()
.where_(Org::id.eq(id))
.update()
.set("active", false)
.execute_pool(®istry)
.await?;
if updated == 0 {
return Err(TenancyError::Validation(format!(
"no row updated for id {id} — race condition?"
)));
}
super::invalidate_org_cache();
report.deactivated = true;
}
Action::Purge { purge_database } => {
purge(pools, ®istry, &org, purge_database, &mut report).await?;
}
}
Ok(report)
}
async fn purge<DB: Database>(
pools: &TenantPools<DB>,
registry: &crate::sql::Pool,
org: &Org,
purge_database: bool,
report: &mut Report,
) -> Result<(), TenancyError>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
let slug = &org.slug;
let mode = StorageMode::parse(&org.storage_mode)
.map_err(|e| TenancyError::Validation(e.to_owned()))?;
if mode == StorageMode::Database && !purge_database {
return Err(TenancyError::Validation(format!(
"tenant `{slug}` is database-mode — dropping its database is \
unrecoverable. Ask for the database to be purged explicitly, or \
deactivate instead."
)));
}
let id = org
.id
.get()
.copied()
.ok_or_else(|| TenancyError::Validation("Org row has no PK".into()))?;
Org::objects()
.where_(Org::id.eq(id))
.update()
.set("active", false)
.execute_pool(registry)
.await?;
super::invalidate_org_cache();
pools.invalidate(slug).await;
match mode {
StorageMode::Schema => {
let schema = org.schema_name.clone().unwrap_or_else(|| slug.clone());
let sql = format!(
"DROP SCHEMA IF EXISTS {} CASCADE",
registry.dialect().quote_ident(&schema)
);
crate::sql::raw_execute_pool(registry, &sql, Vec::new()).await?;
report.schema_dropped = Some(schema);
}
StorageMode::Database => {
let url = pools.resolved_database_url(org).await?;
if org.backend_kind == "postgres" {
#[cfg(feature = "postgres")]
{
drop_database_at(&url).await?;
report.database_dropped = Some(crate::sql::connect_diagnosis::redact(&url));
}
#[cfg(not(feature = "postgres"))]
report.notes.push(format!(
"the tenant database at {} was left in place — this build has no \
`postgres` feature",
crate::sql::connect_diagnosis::redact(&url)
));
} else {
report.notes.push(format!(
"delete the tenant database at {} by hand — dropping a `{}` database \
is not wired up",
crate::sql::connect_diagnosis::redact(&url),
org.backend_kind
));
}
}
}
let hosts = OrgHost::objects()
.where_(OrgHost::org_id.eq(id))
.compile_delete()
.map_err(crate::sql::ExecError::from)?;
crate::sql::delete_pool(registry, &hosts).await?;
super::invalidate_host_cache();
super::provision_store::ProvisioningRun::objects()
.where_(super::provision_store::ProvisioningRun::org_id.eq(Some(id)))
.update()
.set("org_id", None::<i64>)
.execute_pool(registry)
.await?;
let deleted = org.clone().delete_pool(registry).await?;
if deleted == 0 {
return Err(TenancyError::Validation(format!(
"no Org row deleted for id {id} — race condition?"
)));
}
super::invalidate_org_cache();
report.row_deleted = true;
Ok(())
}
#[cfg(feature = "postgres")]
async fn drop_database_at(tenant_url: &str) -> Result<(), TenancyError> {
use crate::sql::sqlx::postgres::PgConnectOptions;
use crate::sql::sqlx::ConnectOptions;
use std::str::FromStr;
let opts = PgConnectOptions::from_str(tenant_url).map_err(|e| {
TenancyError::Validation(format!(
"cannot parse the tenant's database URL: {e} ({})",
crate::sql::connect_diagnosis::redact(tenant_url)
))
})?;
let dbname = opts.get_database().ok_or_else(|| {
TenancyError::Validation(
"the tenant's database URL names no database — nothing to drop".into(),
)
})?;
if matches!(
dbname.to_ascii_lowercase().as_str(),
"postgres" | "template0" | "template1"
) {
return Err(TenancyError::Validation(format!(
"refusing to drop `{dbname}` — that is a Postgres system database"
)));
}
let dbname = dbname.to_owned();
let mut admin = opts.clone().database("postgres").connect().await?;
let sql = format!(
"DROP DATABASE IF EXISTS \"{}\"",
dbname.replace('"', "\"\"")
);
crate::sql::sqlx::query(&sql).execute(&mut admin).await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn purging_a_database_is_a_distinct_action() {
assert_ne!(
Action::Purge {
purge_database: false
},
Action::Purge {
purge_database: true
}
);
assert_ne!(
Action::Deactivate,
Action::Purge {
purge_database: false
}
);
}
}