#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum TenantStatus {
Active,
Provisioning,
Degraded,
Offboarding,
Archived,
}
impl TenantStatus {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Active => "active",
Self::Provisioning => "provisioning",
Self::Degraded => "degraded",
Self::Offboarding => "offboarding",
Self::Archived => "archived",
}
}
#[must_use]
pub fn is_reconciled(self) -> bool {
match self {
Self::Active | Self::Provisioning => true,
Self::Degraded | Self::Offboarding | Self::Archived => false,
}
}
#[must_use]
pub fn is_terminal(self) -> bool {
match self {
Self::Archived => true,
Self::Active | Self::Provisioning | Self::Degraded | Self::Offboarding => false,
}
}
#[must_use]
pub const fn admits(self) -> &'static [Self] {
match self {
Self::Provisioning => &[Self::Provisioning],
Self::Active => &[Self::Provisioning, Self::Degraded, Self::Active],
Self::Degraded => &[Self::Provisioning, Self::Active, Self::Degraded],
Self::Offboarding => &[
Self::Provisioning,
Self::Active,
Self::Degraded,
Self::Offboarding,
],
Self::Archived => &[Self::Offboarding],
}
}
#[must_use]
pub fn can_become(self, next: Self) -> bool {
next.admits().contains(&self)
}
#[must_use]
pub fn parse(status: &str) -> Self {
match status {
"active" => Self::Active,
"provisioning" => Self::Provisioning,
"offboarding" => Self::Offboarding,
"archived" => Self::Archived,
_ => Self::Degraded,
}
}
}
impl std::fmt::Display for TenantStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize)]
pub struct TenantId(String);
impl TenantId {
pub(crate) fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for TenantId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct Tenant {
id: TenantId,
status: TenantStatus,
}
impl Tenant {
pub(crate) fn new(id: impl Into<String>, status: TenantStatus) -> Self {
Self {
id: TenantId::new(id),
status,
}
}
#[must_use]
pub fn id(&self) -> &TenantId {
&self.id
}
#[must_use]
pub fn status(&self) -> TenantStatus {
self.status
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Resolution {
Found {
id: String,
status: TenantStatus,
},
Unknown,
}
impl Resolution {
pub(crate) fn admit(self) -> Result<Tenant, &'static crate::problems::ProblemDef> {
match self {
Self::Found { id, status } if status == TenantStatus::Active => {
Ok(Tenant::new(id, status))
}
Self::Found { status, .. }
if status == TenantStatus::Offboarding || status == TenantStatus::Archived =>
{
Err(&crate::problems::SLUGS.unknown_tenant)
}
Self::Found { .. } => Err(&crate::problems::SLUGS.tenant_degraded),
Self::Unknown => Err(&crate::problems::SLUGS.unknown_tenant),
}
}
}
pub trait ResolveTenant: Send + Sync {
fn resolve(&self, host: &str) -> Resolution;
}
#[async_trait::async_trait]
pub trait TenantDatabases: Send + Sync {
async fn database(
&self,
tenant: &Tenant,
) -> Result<std::sync::Arc<dyn crate::ports::Database>, TenantDbError>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TenantDbError {
Unreachable {
tenant: String,
},
}
impl std::fmt::Display for TenantDbError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unreachable { tenant } => {
write!(f, "tenant `{tenant}`'s database is unreachable")
}
}
}
}
impl std::error::Error for TenantDbError {}
pub trait TenantRouting: ResolveTenant + TenantDatabases {}
impl<T: ResolveTenant + TenantDatabases> TenantRouting for T {}
pub const IMPLICIT_TENANT: &str = "default";
#[derive(Debug, Clone, Copy, Default)]
pub struct ImplicitTenant;
impl ResolveTenant for ImplicitTenant {
fn resolve(&self, _host: &str) -> Resolution {
Resolution::Found {
id: IMPLICIT_TENANT.to_owned(),
status: TenantStatus::Active,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tenancy {
Sole,
FromRegistry,
}
#[cfg(test)]
mod tests {
use super::{Resolution, TenantStatus};
fn found(status: TenantStatus) -> Resolution {
Resolution::Found {
id: "acme".to_owned(),
status,
}
}
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);
#[test]
fn the_implicit_tenant_answers_for_every_host() {
use super::{IMPLICIT_TENANT, ImplicitTenant, ResolveTenant};
for host in ["acme.example", "localhost:8787", ""] {
let tenant = ImplicitTenant
.resolve(host)
.admit()
.expect("the implicit tenant always serves");
assert_eq!(tenant.id().as_str(), IMPLICIT_TENANT);
assert_eq!(tenant.status(), TenantStatus::Active);
}
}
#[test]
fn only_an_active_tenant_is_admitted() {
let tenant = found(TenantStatus::Active)
.admit()
.expect("an active tenant serves");
assert_eq!(tenant.id().as_str(), "acme");
assert_eq!(tenant.status(), TenantStatus::Active);
}
#[test]
fn provisioning_is_refused_like_degraded_and_not_like_unknown() {
for status in [TenantStatus::Provisioning, TenantStatus::Degraded] {
let problem = found(status).admit().expect_err("must not serve");
assert_eq!(problem.slug, "tenant-degraded", "for {status}");
assert_eq!(problem.status.as_u16(), 503);
}
}
#[test]
fn a_retired_tenant_is_indistinguishable_from_one_that_never_existed() {
for status in [TenantStatus::Offboarding, TenantStatus::Archived] {
let retired = found(status).admit().expect_err("must not serve");
let never_existed = Resolution::Unknown.admit().expect_err("must not serve");
assert_eq!(
retired.slug, never_existed.slug,
"{status} must answer exactly as an unknown host does"
);
}
}
#[test]
fn every_status_round_trips_through_the_registry_text() {
for &status in EVERY {
assert_eq!(
TenantStatus::parse(status.as_str()),
status,
"{status} does not survive a trip through the registry"
);
}
}
#[test]
fn only_active_serves() {
for &status in EVERY
.iter()
.filter(|status| **status != TenantStatus::Active)
{
assert!(
found(status).admit().is_err(),
"{status} must not serve requests"
);
}
assert!(found(TenantStatus::Active).admit().is_ok());
}
#[test]
fn a_retired_tenant_is_never_flown_by_the_fleet() {
assert!(TenantStatus::Active.is_reconciled());
assert!(TenantStatus::Provisioning.is_reconciled());
for retired in [
TenantStatus::Degraded,
TenantStatus::Offboarding,
TenantStatus::Archived,
] {
assert!(
!retired.is_reconciled(),
"{retired} must not be flown by boot reconciliation"
);
}
}
#[test]
fn archived_is_the_only_terminal_status() {
assert!(TenantStatus::Archived.is_terminal());
for live in [
TenantStatus::Active,
TenantStatus::Provisioning,
TenantStatus::Degraded,
TenantStatus::Offboarding,
] {
assert!(!live.is_terminal(), "{live} is a state, not an ending");
}
}
#[test]
fn a_terminal_status_never_serves_and_is_never_reconciled() {
for &status in EVERY {
if status.is_terminal() {
assert!(found(status).admit().is_err(), "{status} served");
assert!(!status.is_reconciled(), "{status} was flown");
}
}
}
#[test]
fn an_unknown_host_is_a_404_not_a_503() {
let problem = Resolution::Unknown.admit().expect_err("must not serve");
assert_eq!(problem.slug, "unknown-tenant");
assert_eq!(problem.status.as_u16(), 404);
}
#[test]
fn a_status_this_binary_does_not_know_is_refused_not_guessed() {
let unknown = TenantStatus::parse("quiesced");
assert_eq!(unknown, TenantStatus::Degraded);
assert!(found(unknown).admit().is_err());
}
#[test]
fn serializes_as_the_registry_text_not_the_variant_name() {
for &status in EVERY {
let json = serde_json::to_string(&status).expect("a unit variant serializes");
assert_eq!(
json,
format!("\"{}\"", status.as_str()),
"the wire form is the registry's word, so a log field and a row agree"
);
}
}
#[test]
fn an_unknown_status_reads_as_degraded() {
assert_eq!(TenantStatus::parse("busy"), TenantStatus::Degraded);
assert_eq!(TenantStatus::parse(""), TenantStatus::Degraded);
}
#[test]
fn a_tenant_being_shredded_is_not_revived_by_a_reconciliation() {
assert!(!TenantStatus::Offboarding.can_become(TenantStatus::Active));
assert!(!TenantStatus::Offboarding.can_become(TenantStatus::Degraded));
assert!(!TenantStatus::Offboarding.can_become(TenantStatus::Provisioning));
assert!(TenantStatus::Offboarding.can_become(TenantStatus::Archived));
}
#[test]
fn archiving_records_a_shred_that_happened() {
assert_eq!(
TenantStatus::Archived.admits(),
&[TenantStatus::Offboarding]
);
for &from in EVERY {
assert_eq!(
from.can_become(TenantStatus::Archived),
from == TenantStatus::Offboarding,
"{from} -> archived"
);
}
}
#[test]
fn nothing_leaves_archived_including_a_write_of_archived() {
for &to in EVERY {
assert!(
!TenantStatus::Archived.can_become(to),
"archived -> {to} is not a move that exists"
);
}
}
#[test]
fn a_status_written_twice_is_not_a_failure() {
for &status in EVERY {
assert_eq!(
status.can_become(status),
status != TenantStatus::Archived,
"{status} -> {status}"
);
}
}
#[test]
fn a_tenant_abandoned_before_it_served_still_has_keys_to_shred() {
assert!(TenantStatus::Provisioning.can_become(TenantStatus::Offboarding));
assert!(!TenantStatus::Provisioning.can_become(TenantStatus::Archived));
}
#[test]
fn a_tenant_never_goes_back_to_provisioning() {
assert_eq!(
TenantStatus::Provisioning.admits(),
&[TenantStatus::Provisioning]
);
}
#[test]
fn the_rule_reads_the_same_from_both_ends() {
for &to in EVERY {
for &from in EVERY {
assert_eq!(
from.can_become(to),
to.admits().contains(&from),
"{from} -> {to}"
);
}
}
}
#[test]
fn a_status_the_fleet_flies_is_one_a_reconciliation_can_write() {
for &status in EVERY.iter().filter(|status| status.is_reconciled()) {
assert!(
status.can_become(TenantStatus::Active)
&& status.can_become(TenantStatus::Degraded),
"{status} is flown, and its own outcome would be refused"
);
}
}
}