use serde::Serialize;
#[derive(crate::Model, Debug, Clone, Serialize)]
#[rustango(
table = "rustango_org_hosts",
scope = "registry",
display = "hostname",
admin(
list_display = "hostname, org_id, enabled, created_at",
ordering = "hostname",
)
)]
pub struct OrgHost {
#[rustango(primary_key)]
pub id: crate::sql::Auto<i64>,
#[rustango(fk = "rustango_orgs", on = "id", index)]
pub org_id: i64,
#[rustango(max_length = 255, unique, index)]
pub hostname: String,
pub enabled: bool,
#[rustango(auto_now_add)]
pub created_at: crate::sql::Auto<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Clone, Serialize)]
pub struct TenantHost {
pub hostname: String,
pub is_base: bool,
pub enabled: bool,
pub id: Option<i64>,
}
#[derive(Debug)]
pub enum HostError {
Invalid(String),
Taken(String),
IsBaseHost(String),
NoSuchOrg(String),
NotFound,
Driver(crate::sql::ExecError),
}
impl std::fmt::Display for HostError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Invalid(h) => write!(f, "`{h}` is not a bare hostname"),
Self::Taken(h) => write!(f, "`{h}` is already registered"),
Self::IsBaseHost(h) => {
write!(f, "`{h}` is this tenant's base host and cannot be removed")
}
Self::NoSuchOrg(s) => write!(f, "no tenant named `{s}`"),
Self::NotFound => write!(f, "no such host"),
Self::Driver(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for HostError {}
impl From<crate::sql::ExecError> for HostError {
fn from(e: crate::sql::ExecError) -> Self {
Self::Driver(e)
}
}
pub fn normalize_hostname(raw: &str) -> Result<String, HostError> {
let h = raw.trim().to_ascii_lowercase();
let bad = h.is_empty()
|| h.len() > 255
|| h.contains("://")
|| h.contains('/')
|| h.contains(':')
|| h.contains(char::is_whitespace)
|| h.starts_with('.')
|| h.ends_with('.')
|| !h
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-');
if bad {
return Err(HostError::Invalid(raw.to_owned()));
}
Ok(h)
}
use crate::core::Column as _;
use crate::sql::{FetcherPool as _, Pool};
pub async fn list_for_org(registry: &Pool, org_slug: &str) -> Result<Vec<TenantHost>, HostError> {
let Some(org) = super::Org::objects()
.where_(super::Org::slug.eq(org_slug.to_owned()))
.first(registry)
.await?
else {
return Err(HostError::NoSuchOrg(org_slug.to_owned()));
};
let org_id = org.id.get().copied().unwrap_or_default();
let mut out = Vec::new();
if let Some(base) = org.host_pattern.as_deref().filter(|h| !h.trim().is_empty()) {
out.push(TenantHost {
hostname: base.to_owned(),
is_base: true,
enabled: true,
id: None,
});
}
let extras: Vec<OrgHost> = OrgHost::objects()
.where_(OrgHost::org_id.eq(org_id))
.order_by(&[("hostname", false)])
.fetch(registry)
.await?;
out.extend(extras.into_iter().map(|h| TenantHost {
hostname: h.hostname,
is_base: false,
enabled: h.enabled,
id: h.id.get().copied(),
}));
Ok(out)
}
pub async fn add_host(
registry: &Pool,
org_slug: &str,
hostname: &str,
) -> Result<OrgHost, HostError> {
let host = normalize_hostname(hostname)?;
let Some(org) = super::Org::objects()
.where_(super::Org::slug.eq(org_slug.to_owned()))
.first(registry)
.await?
else {
return Err(HostError::NoSuchOrg(org_slug.to_owned()));
};
let org_id = org.id.get().copied().unwrap_or_default();
let Claimed { tx, row } = claim_extra_host(registry, &host, org_id).await?;
tx.commit().await.map_err(crate::sql::ExecError::from)?;
super::invalidate_host_cache();
Ok(row)
}
pub(crate) async fn host_claimed(
registry: &Pool,
host: &str,
except_org: Option<i64>,
) -> Result<bool, crate::sql::ExecError> {
let base: Vec<super::Org> = super::Org::objects()
.where_(super::Org::host_pattern.iexact(host))
.fetch(registry)
.await?;
if other_org(&base, except_org) {
return Ok(true);
}
let extra: Vec<OrgHost> = OrgHost::objects()
.where_(OrgHost::hostname.iexact(host))
.fetch(registry)
.await?;
Ok(extra
.iter()
.any(|h| except_org.is_none() || Some(h.org_id) != except_org))
}
pub(crate) enum Claimant<'a> {
Base(i64),
NewOrg(&'a mut super::Org),
}
struct Claimed {
tx: crate::sql::PoolTx<'static>,
row: OrgHost,
}
async fn claim_extra_host(registry: &Pool, host: &str, org_id: i64) -> Result<Claimed, HostError> {
match claim_extra_host_once(registry, host, org_id).await {
Err(HostError::Driver(e)) if e.is_deadlock() => {
claim_extra_host_once(registry, host, org_id).await
}
r => r,
}
}
async fn claim_extra_host_once(
registry: &Pool,
host: &str,
org_id: i64,
) -> Result<Claimed, HostError> {
let key = ClaimKey::Host(host.to_owned());
let mut tx = crate::sql::write_transaction(registry).await?;
let (row, _) = lock_key(&mut tx, &key, org_id, false)
.await
.map_err(ClaimError::into_host)?;
if held_elsewhere(&mut tx, &key, row.id.get().copied(), None).await? {
return Err(HostError::Taken(host.to_owned()));
}
Ok(Claimed { tx, row })
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum ClaimKey {
Host(String),
PathPrefix(String),
Port(i32),
}
impl ClaimKey {
fn lock_name(&self) -> String {
match self {
Self::Host(h) => h.clone(),
Self::PathPrefix(p) => p.to_ascii_lowercase(),
Self::Port(n) => format!(":{n}"),
}
}
}
impl std::fmt::Display for ClaimKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Host(h) => write!(f, "host `{h}`"),
Self::PathPrefix(p) => write!(f, "path prefix `{p}`"),
Self::Port(n) => write!(f, "port {n}"),
}
}
}
#[derive(Debug)]
pub(crate) enum ClaimError {
Taken(ClaimKey),
Driver(crate::sql::ExecError),
}
impl From<crate::sql::ExecError> for ClaimError {
fn from(e: crate::sql::ExecError) -> Self {
Self::Driver(e)
}
}
impl ClaimError {
pub(crate) fn is_deadlock(&self) -> bool {
matches!(self, Self::Driver(e) if e.is_deadlock())
}
fn into_host(self) -> HostError {
match self {
Self::Taken(k) => HostError::Taken(k.lock_name()),
Self::Driver(e) => HostError::Driver(e),
}
}
pub(crate) fn into_tenancy(self) -> super::TenancyError {
match self {
Self::Taken(k) => {
super::TenancyError::Validation(format!("{k} is already used by another tenant"))
}
Self::Driver(e) => super::TenancyError::Exec(e),
}
}
}
impl Claimant<'_> {
async fn begin(
&mut self,
tx: &mut crate::sql::PoolTx<'_>,
) -> Result<i64, crate::sql::ExecError> {
Ok(match self {
Self::Base(id) => *id,
Self::NewOrg(org) => {
org.id = crate::sql::Auto::Unset;
org.insert_tx(tx).await?;
org.id.get().copied().unwrap_or_default()
}
})
}
}
pub(crate) async fn claim_routing(
registry: &Pool,
keys: &[ClaimKey],
mut claimant: Claimant<'_>,
) -> Result<crate::sql::PoolTx<'static>, ClaimError> {
match claim_routing_once(registry, keys, &mut claimant).await {
Err(ClaimError::Driver(e)) if e.is_deadlock() => {
claim_routing_once(registry, keys, &mut claimant).await
}
r => r,
}
}
async fn claim_routing_once(
registry: &Pool,
keys: &[ClaimKey],
claimant: &mut Claimant<'_>,
) -> Result<crate::sql::PoolTx<'static>, ClaimError> {
let mut tx = crate::sql::write_transaction(registry).await?;
let org_id = claimant.begin(&mut tx).await?;
claim_routing_tx(&mut tx, keys, org_id).await?;
Ok(tx)
}
pub(crate) async fn claim_routing_tx(
tx: &mut crate::sql::PoolTx<'_>,
keys: &[ClaimKey],
org_id: i64,
) -> Result<(), ClaimError> {
let mut keys = keys.to_vec();
keys.sort();
keys.dedup();
let mut locked = Vec::with_capacity(keys.len());
for key in &keys {
locked.push(lock_key(tx, key, org_id, true).await?);
}
for (key, (row, _)) in keys.iter().zip(&locked) {
if held_elsewhere(tx, key, row.id.get().copied(), Some(org_id)).await? {
return Err(ClaimError::Taken(key.clone()));
}
}
for (row, inserted) in locked {
if inserted {
row.delete_tx(tx).await?;
}
}
Ok(())
}
async fn lock_key(
tx: &mut crate::sql::PoolTx<'_>,
key: &ClaimKey,
org_id: i64,
base: bool,
) -> Result<(OrgHost, bool), ClaimError> {
use crate::sql::FetcherTx as _;
let name = key.lock_name();
let held: Vec<OrgHost> = OrgHost::objects()
.where_(OrgHost::hostname.eq(name.clone()))
.select_for_update()
.silent_on_sqlite()
.fetch_tx(tx)
.await?;
match held.into_iter().next() {
Some(own) if base && own.org_id == org_id && matches!(key, ClaimKey::Host(_)) => {
Ok((own, false))
}
Some(_) => Err(ClaimError::Taken(key.clone())),
None => {
let mut row = OrgHost {
id: crate::sql::Auto::Unset,
org_id,
hostname: name,
enabled: true,
created_at: crate::sql::Auto::Unset,
};
match row.insert_tx(tx).await {
Err(e) if e.is_unique_violation() => return Err(ClaimError::Taken(key.clone())),
r => r?,
}
Ok((row, true))
}
}
}
async fn held_elsewhere(
tx: &mut crate::sql::PoolTx<'_>,
key: &ClaimKey,
own_row: Option<i64>,
except: Option<i64>,
) -> Result<bool, crate::sql::ExecError> {
use crate::sql::FetcherTx as _;
let filter = match key {
ClaimKey::Host(h) => super::Org::host_pattern.iexact(h.clone()),
ClaimKey::PathPrefix(p) => super::Org::path_prefix.iexact(p.clone()),
ClaimKey::Port(n) => super::Org::port.eq(Some(*n)),
};
let orgs: Vec<super::Org> = super::Org::objects().where_(filter).fetch_tx(tx).await?;
if other_org(&orgs, except) {
return Ok(true);
}
let ClaimKey::Host(h) = key else {
return Ok(false);
};
let extra: Vec<OrgHost> = OrgHost::objects()
.where_(OrgHost::hostname.iexact(h.clone()))
.fetch_tx(tx)
.await?;
Ok(extra
.iter()
.any(|r| r.id.get().copied() != own_row && except != Some(r.org_id)))
}
pub(crate) async fn insert_org(
registry: &Pool,
org: &mut super::Org,
) -> Result<(), super::TenancyError> {
let keys = routing_keys(org);
if keys.is_empty() {
org.insert_pool(registry).await?;
return Ok(());
}
let tx = claim_routing(registry, &keys, Claimant::NewOrg(org))
.await
.map_err(ClaimError::into_tenancy)?;
tx.commit().await?;
Ok(())
}
pub(crate) fn routing_keys(org: &super::Org) -> Vec<ClaimKey> {
let host = org.host_pattern.clone().map(ClaimKey::Host);
let prefix = org.path_prefix.clone().map(ClaimKey::PathPrefix);
let port = org.port.map(ClaimKey::Port);
[host, prefix, port].into_iter().flatten().collect()
}
pub(crate) async fn prefix_claimed(
registry: &Pool,
prefix: &str,
except_org: Option<i64>,
) -> Result<bool, crate::sql::ExecError> {
let rows: Vec<super::Org> = super::Org::objects()
.where_(super::Org::path_prefix.iexact(prefix))
.fetch(registry)
.await?;
Ok(other_org(&rows, except_org))
}
pub(crate) async fn port_claimed(
registry: &Pool,
port: i32,
except_org: Option<i64>,
) -> Result<bool, crate::sql::ExecError> {
let rows: Vec<super::Org> = super::Org::objects()
.where_(super::Org::port.eq(Some(port)))
.fetch(registry)
.await?;
Ok(other_org(&rows, except_org))
}
pub(crate) async fn schema_claimed(
registry: &Pool,
schema: &str,
except_org: Option<i64>,
) -> Result<bool, crate::sql::ExecError> {
let mut rows: Vec<super::Org> = super::Org::objects()
.where_(
super::Org::schema_name
.eq(Some(schema.to_owned()))
.or(super::Org::slug.eq(schema.to_owned())),
)
.fetch(registry)
.await?;
rows.retain(|o| {
o.storage_mode == super::StorageMode::Schema.as_str() && o.effective_schema() == schema
});
Ok(other_org(&rows, except_org))
}
fn other_org(rows: &[super::Org], except_org: Option<i64>) -> bool {
rows.iter()
.any(|o| except_org.is_none() || o.id.get().copied() != except_org)
}
pub async fn remove_host(registry: &Pool, org_slug: &str, hostname: &str) -> Result<(), HostError> {
let host = normalize_hostname(hostname)?;
let Some(org) = super::Org::objects()
.where_(super::Org::slug.eq(org_slug.to_owned()))
.first(registry)
.await?
else {
return Err(HostError::NoSuchOrg(org_slug.to_owned()));
};
if org.host_pattern.as_deref() == Some(host.as_str()) {
return Err(HostError::IsBaseHost(host));
}
let org_id = org.id.get().copied().unwrap_or_default();
let Some(row) = OrgHost::objects()
.where_(OrgHost::hostname.eq(host))
.where_(OrgHost::org_id.eq(org_id))
.first(registry)
.await?
else {
return Err(HostError::NotFound);
};
row.delete_pool(registry).await?;
super::invalidate_host_cache();
Ok(())
}
pub async fn set_host_enabled(
registry: &Pool,
org_slug: &str,
hostname: &str,
enabled: bool,
) -> Result<(), HostError> {
let host = normalize_hostname(hostname)?;
let Some(org) = super::Org::objects()
.where_(super::Org::slug.eq(org_slug.to_owned()))
.first(registry)
.await?
else {
return Err(HostError::NoSuchOrg(org_slug.to_owned()));
};
if org.host_pattern.as_deref() == Some(host.as_str()) {
return Err(HostError::IsBaseHost(host));
}
let org_id = org.id.get().copied().unwrap_or_default();
let Some(mut row) = OrgHost::objects()
.where_(OrgHost::hostname.eq(host))
.where_(OrgHost::org_id.eq(org_id))
.first(registry)
.await?
else {
return Err(HostError::NotFound);
};
row.enabled = enabled;
row.save_pool(registry).await?;
super::invalidate_host_cache();
Ok(())
}
pub async fn generation(registry: &Pool) -> Result<Generation, HostError> {
use crate::core::aggregates::{count_all, max, sum};
use crate::core::{AggregateQuery, Model as _, WhereExpr};
use crate::sql::fetch_aggregate_pool;
let q = AggregateQuery {
model: OrgHost::SCHEMA,
joins: Vec::new(),
where_clause: WhereExpr::And(Vec::new()),
group_by: Vec::new(),
aggregates: vec![
("n".into(), count_all().into()),
(
"n_on".into(),
count_all().filter(OrgHost::enabled.eq(true)).into(),
),
("max_id".into(), max("id").into()),
(
"on_id_sum".into(),
sum("id").filter(OrgHost::enabled.eq(true)).into(),
),
],
aliases: Vec::new(),
having: None,
order_by: Vec::new(),
limit: None,
offset: None,
source: None,
};
let rows: Vec<(Option<i64>, Option<i64>, Option<i64>, Option<i64>)> =
fetch_aggregate_pool(registry, &q).await?;
let (n, n_on, max_id, on_id_sum) = rows.into_iter().next().unwrap_or((None, None, None, None));
Ok(Generation {
count: n.unwrap_or(0),
enabled: n_on.unwrap_or(0),
max_id: max_id.unwrap_or(0),
enabled_id_sum: on_id_sum.unwrap_or(0),
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Generation {
pub count: i64,
pub enabled: i64,
pub max_id: i64,
pub enabled_id_sum: i64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_a_bare_hostname_and_lowercases_it() {
assert_eq!(normalize_hostname("Example.COM").unwrap(), "example.com");
assert_eq!(
normalize_hostname(" a.b.example.com ").unwrap(),
"a.b.example.com"
);
assert_eq!(
normalize_hostname("my-site.co.uk").unwrap(),
"my-site.co.uk"
);
assert_eq!(normalize_hostname("localhost").unwrap(), "localhost");
}
#[test]
fn rejects_anything_that_is_not_a_bare_host() {
for bad in [
"",
" ",
"https://example.com", "example.com/", "example.com/blog", "example.com:8080", "exa mple.com", ".example.com", "example.com.", "exam_ple.com", "exam?ple.com",
] {
assert!(
normalize_hostname(bad).is_err(),
"{bad:?} should be rejected"
);
}
}
#[test]
fn rejects_an_over_long_hostname() {
assert!(normalize_hostname(&"a".repeat(256)).is_err());
assert!(normalize_hostname(&"a".repeat(255)).is_ok());
}
#[test]
fn base_host_is_flagged_so_callers_can_refuse_a_remove() {
let base = TenantHost {
hostname: "acme.example.com".into(),
is_base: true,
enabled: true,
id: None,
};
assert!(base.is_base);
assert!(base.id.is_none());
}
}
pub(crate) fn routing_written(table: &str) {
use crate::core::Model as _;
if table == super::Org::SCHEMA.table {
super::invalidate_org_cache();
} else if table == OrgHost::SCHEMA.table {
super::invalidate_host_cache();
}
}
#[cfg(feature = "admin")]
pub(crate) enum AdminClaims {
Nothing,
OrgBefore(Vec<ClaimKey>),
OrgAfter(Vec<ClaimKey>),
HostAfter(String),
}
#[cfg(feature = "admin")]
impl AdminClaims {
pub(crate) fn of(
table: &str,
values: &mut [(&'static str, crate::core::SqlValue)],
before: Option<&serde_json::Value>,
) -> Result<Self, String> {
use crate::core::SqlValue;
use crate::tenancy::provision::{
validate_host_pattern, validate_new_path_prefix, validate_port,
};
let changed = |column: &str, now: &serde_json::Value| {
before.is_none_or(|row| row.get(column).unwrap_or(&serde_json::Value::Null) != now)
};
let text = |v: &SqlValue| match v {
SqlValue::String(s) if !s.trim().is_empty() => Some(s.clone()),
_ => None,
};
if table == <super::Org as crate::core::Model>::SCHEMA.table {
let mut keys = Vec::new();
for (column, value) in values.iter_mut() {
let key = match *column {
"host_pattern" => text(value)
.map(|h| validate_host_pattern(&h).map(ClaimKey::Host))
.transpose()?,
"path_prefix" => text(value)
.map(|p| {
let p = p.trim().to_owned();
validate_new_path_prefix(&p).map(|()| ClaimKey::PathPrefix(p))
})
.transpose()?,
"port" => match value {
SqlValue::I32(n) => {
validate_port(*n)?;
Some(ClaimKey::Port(*n))
}
_ => None,
},
_ => continue,
};
*value = match &key {
Some(ClaimKey::Host(h) | ClaimKey::PathPrefix(h)) => {
SqlValue::String(h.clone())
}
Some(ClaimKey::Port(n)) => SqlValue::I32(*n),
None => SqlValue::Null,
};
let now = match &key {
Some(ClaimKey::Host(h) | ClaimKey::PathPrefix(h)) => serde_json::json!(h),
Some(ClaimKey::Port(n)) => serde_json::json!(n),
None => serde_json::Value::Null,
};
if let Some(key) = key.filter(|_| changed(column, &now)) {
keys.push(key);
}
}
return Ok(match (keys.is_empty(), before.is_some()) {
(true, _) => Self::Nothing,
(false, true) => Self::OrgBefore(keys),
(false, false) => Self::OrgAfter(keys),
});
}
if table == <OrgHost as crate::core::Model>::SCHEMA.table {
let mut host = None;
let mut moved = false;
for (column, value) in values.iter_mut() {
match (*column, &*value) {
("hostname", SqlValue::String(h)) => {
let h = normalize_hostname(h).map_err(|e| e.to_string())?;
moved |= changed(column, &serde_json::json!(h));
*value = SqlValue::String(h.clone());
host = Some(h);
}
("org_id", SqlValue::I64(id)) => {
moved |= changed(column, &serde_json::json!(id))
}
_ => {}
}
}
return Ok(match host {
Some(h) if moved => Self::HostAfter(h),
_ => Self::Nothing,
});
}
Ok(Self::Nothing)
}
pub(crate) fn of_edit(
table: &str,
values: &mut [(&'static str, crate::core::SqlValue)],
) -> Result<Self, String> {
Ok(match Self::of(table, values, None)? {
Self::OrgAfter(keys) => Self::OrgBefore(keys),
other => other,
})
}
pub(crate) async fn claim_in(
&self,
tx: &mut crate::sql::PoolTx<'_>,
pk: &crate::core::SqlValue,
) -> Result<(), ClaimError> {
match (self, pk_id(pk)) {
(Self::OrgBefore(keys), Some(id)) => claim_routing_tx(tx, keys, id).await,
_ => self.after_write(tx, pk).await,
}
}
pub(crate) fn retries(&self) -> bool {
!matches!(self, Self::Nothing)
}
pub(crate) async fn begin(
&self,
registry: &Pool,
pk: &crate::core::SqlValue,
) -> Result<crate::sql::PoolTx<'static>, ClaimError> {
match (self, pk_id(pk)) {
(Self::OrgBefore(keys), Some(id)) => {
claim_routing(registry, keys, Claimant::Base(id)).await
}
_ => Ok(crate::sql::write_transaction(registry).await?),
}
}
pub(crate) async fn after_write(
&self,
tx: &mut crate::sql::PoolTx<'_>,
pk: &crate::core::SqlValue,
) -> Result<(), ClaimError> {
let Some(id) = pk_id(pk) else {
return Ok(());
};
match self {
Self::OrgAfter(keys) => claim_routing_tx(tx, keys, id).await,
Self::HostAfter(h) => {
let key = ClaimKey::Host(h.clone());
if held_elsewhere(tx, &key, Some(id), None).await? {
return Err(ClaimError::Taken(key));
}
Ok(())
}
Self::Nothing | Self::OrgBefore(_) => Ok(()),
}
}
}
#[cfg(feature = "admin")]
fn pk_id(pk: &crate::core::SqlValue) -> Option<i64> {
match pk {
crate::core::SqlValue::I64(n) => Some(*n),
crate::core::SqlValue::I32(n) => Some(i64::from(*n)),
_ => None,
}
}