use crate::tenant::TenantStatus;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum TenantLifecycleError {
Unknown {
tenant: String,
},
Refused {
tenant: String,
from: TenantStatus,
to: TenantStatus,
},
Backend {
tenant: String,
message: String,
},
}
impl std::fmt::Display for TenantLifecycleError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unknown { tenant } => {
write!(f, "no registry row for tenant `{tenant}`")
}
Self::Refused { tenant, from, to } => {
write!(f, "tenant `{tenant}` cannot move from {from} to {to}")
}
Self::Backend { tenant, message } => {
let message = crate::logging::scrub_text(message);
write!(f, "tenant `{tenant}`'s registry write failed: {message}")
}
}
}
}
impl std::error::Error for TenantLifecycleError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TenantSummary {
pub tenant: String,
pub status: TenantStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ErasureStep {
StopServing,
ExportData,
ShredDataKeys,
HoldRetention,
DropDatabase,
Archive,
}
impl ErasureStep {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::StopServing => "stop-serving",
Self::ExportData => "export-data",
Self::ShredDataKeys => "shred-data-keys",
Self::HoldRetention => "hold-retention",
Self::DropDatabase => "drop-database",
Self::Archive => "archive",
}
}
#[must_use]
pub const fn is_operator(self) -> bool {
match self {
Self::StopServing | Self::Archive => false,
Self::ExportData | Self::ShredDataKeys | Self::HoldRetention | Self::DropDatabase => {
true
}
}
}
}
impl std::fmt::Display for ErasureStep {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[must_use]
pub fn remaining_erasure(status: TenantStatus) -> &'static [ErasureStep] {
match status {
TenantStatus::Active | TenantStatus::Provisioning | TenantStatus::Degraded => &[
ErasureStep::StopServing,
ErasureStep::ExportData,
ErasureStep::ShredDataKeys,
ErasureStep::HoldRetention,
ErasureStep::DropDatabase,
ErasureStep::Archive,
],
TenantStatus::Offboarding => &[
ErasureStep::ExportData,
ErasureStep::ShredDataKeys,
ErasureStep::HoldRetention,
ErasureStep::DropDatabase,
ErasureStep::Archive,
],
TenantStatus::Archived => &[],
}
}
#[async_trait::async_trait]
pub trait TenantLifecycle: Send + Sync {
async fn create(&self, tenant: &str, dsn: &str) -> Result<(), TenantLifecycleError>;
async fn status(&self, tenant: &str) -> Result<TenantStatus, TenantLifecycleError>;
async fn tenants(&self) -> Result<Vec<TenantSummary>, TenantLifecycleError>;
async fn set_status(
&self,
tenant: &str,
next: TenantStatus,
) -> Result<(), TenantLifecycleError>;
async fn begin_erasure(&self, tenant: &str) -> Result<Vec<ErasureStep>, TenantLifecycleError> {
self.set_status(tenant, TenantStatus::Offboarding).await?;
Ok(remaining_erasure(TenantStatus::Offboarding).to_vec())
}
async fn complete_erasure(&self, tenant: &str) -> Result<(), TenantLifecycleError> {
self.set_status(tenant, TenantStatus::Archived).await
}
}
#[cfg(test)]
mod tests {
use super::{
ErasureStep, TenantLifecycle, TenantLifecycleError, TenantSummary, remaining_erasure,
};
use crate::tenant::TenantStatus;
macro_rules! statuses {
($($variant:ident),+ $(,)?) => {
const EVERY: &[TenantStatus] = &[$(TenantStatus::$variant),+];
#[expect(dead_code, reason = "its only job is to be exhaustive")]
fn exhaustive(status: TenantStatus) {
match status {
$(TenantStatus::$variant => {}),+
}
}
};
}
statuses!(Provisioning, Active, Degraded, Offboarding, Archived);
struct FakeRow {
tenant: String,
status: TenantStatus,
dsn: String,
}
struct FakeLifecycle {
#[expect(
clippy::disallowed_types,
reason = "a test double needs interior mutability behind the trait's \
`Sync` bound, and lock poisoning is irrelevant here: a \
poisoned lock means a test has already failed"
)]
rows: std::sync::Mutex<Vec<FakeRow>>,
}
impl FakeLifecycle {
#[expect(
clippy::disallowed_types,
reason = "the same double's constructor names the same lock the \
field above already carries the expect for: interior \
mutability behind the trait's `Sync` bound, with lock \
poisoning irrelevant to a test that has already failed"
)]
fn new() -> Self {
Self {
rows: std::sync::Mutex::new(Vec::new()),
}
}
}
const DSN: &str = "postgres://acme:s3cret@db.internal:5432/acme";
#[async_trait::async_trait]
impl TenantLifecycle for FakeLifecycle {
async fn create(&self, tenant: &str, dsn: &str) -> Result<(), TenantLifecycleError> {
let mut rows = self
.rows
.lock()
.expect("a poisoned lock means a test has already failed");
match rows.iter_mut().find(|row| row.tenant == tenant) {
Some(row) if row.status != TenantStatus::Archived => {
row.status = TenantStatus::Provisioning;
row.dsn = dsn.to_owned();
Ok(())
}
Some(row) => {
let from = row.status;
Err(TenantLifecycleError::Refused {
tenant: row.tenant.clone(),
from,
to: TenantStatus::Provisioning,
})
}
None => {
rows.push(FakeRow {
tenant: tenant.to_owned(),
status: TenantStatus::Provisioning,
dsn: dsn.to_owned(),
});
Ok(())
}
}
}
async fn status(&self, tenant: &str) -> Result<TenantStatus, TenantLifecycleError> {
let rows = self
.rows
.lock()
.expect("a poisoned lock means a test has already failed");
match rows.iter().find(|row| row.tenant == tenant) {
Some(row) => Ok(row.status),
None => Err(TenantLifecycleError::Unknown {
tenant: tenant.to_owned(),
}),
}
}
async fn tenants(&self) -> Result<Vec<TenantSummary>, TenantLifecycleError> {
let rows = self
.rows
.lock()
.expect("a poisoned lock means a test has already failed");
let mut summaries: Vec<TenantSummary> = rows
.iter()
.map(|row| TenantSummary {
tenant: row.tenant.clone(),
status: row.status,
})
.collect();
summaries.sort_by(|a, b| a.tenant.cmp(&b.tenant));
Ok(summaries)
}
async fn set_status(
&self,
tenant: &str,
next: TenantStatus,
) -> Result<(), TenantLifecycleError> {
let mut rows = self
.rows
.lock()
.expect("a poisoned lock means a test has already failed");
match rows.iter_mut().find(|row| row.tenant == tenant) {
Some(row) if row.status.can_become(next) => {
row.status = next;
Ok(())
}
Some(row) => {
let from = row.status;
Err(TenantLifecycleError::Refused {
tenant: row.tenant.clone(),
from,
to: next,
})
}
None => Err(TenantLifecycleError::Unknown {
tenant: tenant.to_owned(),
}),
}
}
}
#[pollster::test]
async fn a_tenant_walks_the_whole_lifecycle_through_the_port() {
let lifecycle = FakeLifecycle::new();
lifecycle
.create("acme", DSN)
.await
.expect("a fresh id registers");
assert_eq!(
lifecycle.status("acme").await.expect("registered"),
TenantStatus::Provisioning,
"a tenant registers as provisioning"
);
lifecycle
.set_status("acme", TenantStatus::Active)
.await
.expect("provisioning becomes active once reconciliation succeeds");
let steps = lifecycle
.begin_erasure("acme")
.await
.expect("a serving tenant can be retired");
assert_eq!(
lifecycle.status("acme").await.expect("registered"),
TenantStatus::Offboarding,
"begin_erasure moved the row to offboarding"
);
assert_eq!(
steps,
remaining_erasure(TenantStatus::Offboarding).to_vec(),
"the returned steps are the ones §2 still has outstanding"
);
assert_eq!(
steps.first(),
Some(&ErasureStep::ExportData),
"the stop already happened, so the export is the first step left"
);
lifecycle
.complete_erasure("acme")
.await
.expect("archived is the only way out of offboarding");
assert_eq!(
lifecycle.status("acme").await.expect("registered"),
TenantStatus::Archived,
);
}
#[pollster::test]
async fn every_revival_out_of_offboarding_is_a_typed_refusal() {
let lifecycle = FakeLifecycle::new();
lifecycle.create("acme", DSN).await.expect("registers");
lifecycle
.begin_erasure("acme")
.await
.expect("retirement starts");
for &revival in EVERY {
let outcome = lifecycle.set_status("acme", revival).await;
if matches!(revival, TenantStatus::Offboarding | TenantStatus::Archived) {
assert!(
outcome.is_ok(),
"{revival} is a legal way out of offboarding"
);
} else {
assert_eq!(
outcome,
Err(TenantLifecycleError::Refused {
tenant: "acme".to_owned(),
from: TenantStatus::Offboarding,
to: revival,
}),
"{revival} must be refused with the move that was asked for"
);
}
}
}
#[pollster::test]
async fn nothing_leaves_archived_through_the_port() {
let lifecycle = FakeLifecycle::new();
lifecycle.create("acme", DSN).await.expect("registers");
lifecycle
.begin_erasure("acme")
.await
.expect("retirement starts");
lifecycle.complete_erasure("acme").await.expect("archives");
for &to in EVERY {
assert_eq!(
lifecycle.set_status("acme", to).await,
Err(TenantLifecycleError::Refused {
tenant: "acme".to_owned(),
from: TenantStatus::Archived,
to,
}),
"archived -> {to} is not a move that exists, not even a repeat"
);
}
assert_eq!(
lifecycle.create("acme", DSN).await,
Err(TenantLifecycleError::Refused {
tenant: "acme".to_owned(),
from: TenantStatus::Archived,
to: TenantStatus::Provisioning,
}),
"an archived id must not re-register"
);
assert_eq!(
lifecycle.status("acme").await.expect("registered"),
TenantStatus::Archived,
"the row survived every attempt unmoved"
);
}
#[pollster::test]
async fn an_absent_tenant_is_unknown_not_backend() {
let lifecycle = FakeLifecycle::new();
assert_eq!(
lifecycle.status("ghost").await,
Err(TenantLifecycleError::Unknown {
tenant: "ghost".to_owned(),
}),
"a tenant with no row is unknown"
);
assert_eq!(
lifecycle.set_status("ghost", TenantStatus::Active).await,
Err(TenantLifecycleError::Unknown {
tenant: "ghost".to_owned(),
}),
"moving a tenant with no row is unknown, not a refusal"
);
assert!(
matches!(
lifecycle.begin_erasure("ghost").await,
Err(TenantLifecycleError::Unknown { .. })
),
"begin_erasure on an absent tenant is unknown"
);
assert!(
matches!(
lifecycle.complete_erasure("ghost").await,
Err(TenantLifecycleError::Unknown { .. })
),
"complete_erasure on an absent tenant is unknown"
);
}
#[pollster::test]
async fn a_listing_through_the_port_never_carries_a_dsn() {
let lifecycle = FakeLifecycle::new();
lifecycle.create("acme", DSN).await.expect("registers");
lifecycle.create("zephyr", DSN).await.expect("registers");
let summaries: Vec<TenantSummary> = lifecycle.tenants().await.expect("readable");
assert_eq!(summaries.len(), 2, "every row is listed");
assert_eq!(
summaries
.iter()
.map(|summary| (summary.tenant.as_str(), summary.status))
.collect::<Vec<_>>(),
vec![
("acme", TenantStatus::Provisioning),
("zephyr", TenantStatus::Provisioning),
],
"the listing carries the tenant and its status"
);
let rendered = format!("{summaries:?}");
assert!(
!rendered.contains("postgres://"),
"a listing leaked a connect string: {rendered}"
);
assert!(
!rendered.contains("s3cret"),
"a listing leaked the DSN's secret: {rendered}"
);
}
#[test]
fn the_remaining_erasure_of_a_status_is_its_runbook_position() {
assert!(
remaining_erasure(TenantStatus::Archived).is_empty(),
"an archived tenant has no erasure left"
);
assert_eq!(
remaining_erasure(TenantStatus::Active).first(),
Some(&ErasureStep::StopServing),
"a serving tenant has not even been stopped"
);
assert_eq!(
remaining_erasure(TenantStatus::Active),
remaining_erasure(TenantStatus::Provisioning),
"every status that still serves has the whole runbook ahead of it"
);
assert_eq!(
remaining_erasure(TenantStatus::Offboarding).first(),
Some(&ErasureStep::ExportData),
"offboarding means the stop was performed for it"
);
assert_eq!(
remaining_erasure(TenantStatus::Offboarding),
&[
ErasureStep::ExportData,
ErasureStep::ShredDataKeys,
ErasureStep::HoldRetention,
ErasureStep::DropDatabase,
ErasureStep::Archive,
]
);
}
#[test]
fn a_backend_message_is_scrubbed_on_its_way_out() {
let error = TenantLifecycleError::Backend {
tenant: "acme".to_owned(),
message: "error connecting to postgres://venture:sup3r-s3cret@db.internal:5432/app?\
sslmode=require"
.to_owned(),
};
let rendered = error.to_string();
assert!(
!rendered.contains("sup3r-s3cret"),
"the password reached the rendered error: {rendered}"
);
assert!(
!rendered.contains("sslmode"),
"the query reached the rendered error: {rendered}"
);
assert!(
rendered.contains("postgres://[redacted]@db.internal:5432/app?[redacted]"),
"what survives is the scrubbed host and path: {rendered}"
);
assert!(
rendered.contains("acme"),
"the error still names its tenant: {rendered}"
);
}
}